Skip to content

Legacy SolveBoard constructs a new SolverContext on every call - is that intended? #276

Description

@aBacIdEe

While benchmarking DDS 3 as a drop-in for a Monte-Carlo bridge analyser, I found that our
workload got slower rather than faster, and traced it to the legacy single-board entry
point. I may well be missing context on why it is written this way, so this is a question
with measurements attached rather than a bug report.

Version and platform. develop at 579e2e7, built with the project's own toolchain
(bazelisk build -c opt, Bazel 9.1.0, LLVM 21.1.8). Apple M4 Pro, 12 cores, macOS 26.5.1.

What I see. library/src/solver_if.cpp:69-78:

int STDCALL SolveBoard(Deal dl, int target, int solutions, int mode,
                       FutureTricks * futp, [[maybe_unused]] int thrId)
{
  SolverContext outer_ctx;
  return solve_board(outer_ctx, dl, target, solutions, mode, futp);
}

Each call builds a SolverContext, uses it once and destroys it. The batch path does the
opposite - library/src/solve_board.cpp:86-89 uses the persistent
dds::internal::worker_solver_context(), with a comment saying exactly why. SolveBoardPBN
delegates to SolveBoard, so it behaves the same way.

Effect 1 - a fixed cost on every call. Constructing and destroying a SolverContext
with its transposition table, doing no search at all, takes ~180-270 µs here (2000
repetitions, several runs). sizeof(ThreadData) is ~1 MB, but that part is only ~5 µs;
almost all of it is TransTableL::make_tt() / release_tt() - 56 allocations plus
init_tt() zeroing 14x4x256 entries, then freeing them again.

That is invisible on a full 13-card deal. It is most of the wall time on the short solves a
Monte-Carlo engine spends its life in. Solving 1200 five-card endings through SolveBoard:
387 µs per solve. The same 1200 through one reused SolverContext: 96 µs per solve. Same
answers on every deal.

Effect 2 - repeated calls for the same hand no longer share a table. Solving one set of
four hands at every strain and from every leader, 60 solves at 13 cards per hand:

wall search nodes
SolveBoard (fresh context per call) 26.1 ms/solve 7,974,223
one reused SolverContext 11.5 ms/solve 3,905,015

Identical trick counts, 51% fewer nodes. Every call after the first sees diffDeal == 0,
so the similar-deal gate in solve_board_internal would keep the table - but the context it
lives in has already been destroyed.

I mention this one carefully, because it is the case the README names as the reason
version 3 exists: "Preserving the table is required when making repeated calls for the same
hand while training a declarer model against double-dummy perfect defenders."
The modern
SolverContext API does exactly that. It is only the legacy entry point that cannot.

How much this matters depends entirely on the workload, and I do not want to imply a
single number. Comparing SolveBoard against one reused context, on this machine:

workload solves ratio
13 cards/hand, distinct deals 120 1.14x
8 cards/hand, distinct deals 300 2.3x
5 cards/hand, distinct deals 1200 4.0x
13 cards/hand, same hand repeated 60 2.5x
5 cards/hand, same hand repeated 800 34x

The pattern is just that the fixed cost is amortised away by long searches and dominates
short ones.

On documentation. Whatever the intent, two documents currently describe the old
behaviour:

  • doc/dll-description.md:799 - "The table is not deleted automatically after each call to
    SolveBoard, so it can be reused from call to call."
  • docs/api_migration.md, "Example 2: Multiple Boards with TT Reuse" - shows a legacy
    SolveBoard loop as the "Before" case for TT reuse.

My question. Is the per-call context on this path a deliberate choice - thread safety,
memory footprint, or keeping the legacy API's behaviour predictable - or is it the same gap
that was closed for calc_dd_table in 27030ba, for analyse_play in cdd13cf, and for the
batch workers in #261?

If it is the latter, I have a patch that routes SolveBoard through
dds::internal::worker_solver_context(), the way solve_all_boards_n already does. It
keeps thrId unused, changes no signature, and preserves the existing per-call decision
about whether the table is worth keeping. All 64 test targets pass with it, and I added a
regression test that fails without it. Happy to open it as a PR if that would be useful, or
to leave it if the current behaviour is intended - in which case I think the two doc
snippets above are worth a correction, and we will migrate to the SolverContext API on
our side.

Related: #260, #261, #141.

(Benchmark source available if useful - it is self-contained, seeded, and asserts that all
four variants agree on every deal.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions