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.)
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.
developat579e2e7, 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:Each call builds a
SolverContext, uses it once and destroys it. The batch path does theopposite -
library/src/solve_board.cpp:86-89uses the persistentdds::internal::worker_solver_context(), with a comment saying exactly why.SolveBoardPBNdelegates to
SolveBoard, so it behaves the same way.Effect 1 - a fixed cost on every call. Constructing and destroying a
SolverContextwith 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 plusinit_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. Sameanswers 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:
SolveBoard(fresh context per call)SolverContextIdentical trick counts, 51% fewer nodes. Every call after the first sees
diffDeal == 0,so the similar-deal gate in
solve_board_internalwould keep the table - but the context itlives 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
SolverContextAPI 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
SolveBoardagainst one reused context, on this machine: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 toSolveBoard, so it can be reused from call to call."
docs/api_migration.md, "Example 2: Multiple Boards with TT Reuse" - shows a legacySolveBoardloop 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_tablein 27030ba, foranalyse_playin cdd13cf, and for thebatch workers in #261?
If it is the latter, I have a patch that routes
SolveBoardthroughdds::internal::worker_solver_context(), the waysolve_all_boards_nalready does. Itkeeps
thrIdunused, changes no signature, and preserves the existing per-call decisionabout 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
SolverContextAPI onour 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.)