benchmarks: add a benchmarks/ directory with the warm-TT and recorded-workload benchmarks#262
Conversation
The C ABI hands out an opaque solver-context handle that owns the
transposition table, and documents that a context may be reused across
solves -- but nothing measured what that reuse is actually worth. This adds a
benchmark over the workload where it matters most: opening-lead evaluation.
Per deal it compares two ways of valuing the leads:
A one solve with solutions=3 (scores all 13 leads at once)
B_cold top-K leads, played out, solutions=1, a FRESH context per lead
B_warm top-K leads, played out, solutions=1, ONE reused context
The K post-lead positions differ by only a couple of cards, so they pass the
similar-deal gate and the TT survives between them -- but only if the caller
keeps the same context.
Measured over 100 deterministic deals x {3N, 4S}, -c opt:
K=4 B_cold 0.93x A B_warm 0.54x A (warm/cold 0.58)
K=6 B_cold 1.34x A B_warm 0.71x A (warm/cold 0.53)
So context reuse roughly halves the post-lead solve cost, and at K=6 it flips
the top-K approach from a 1.34x loss into a 0.71x win over solutions=3.
Hard assertions are correctness-only -- every solve succeeds, and warm and cold
agree on every lead's score, so TT reuse provably never changes the answer. The
timings are printed rather than asserted so the test does not flake under CI
load; the one timing check is a loose sanity bound (warm must not be materially
slower than cold).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| constexpr int kWest = 3; // opening leader vs a South declarer | ||
| constexpr int kStrainNT = 4, kStrainSpades = 0; // DDS trump encoding | ||
|
|
||
| // Deal 52 cards into four hands using a deterministic generator. |
There was a problem hiding this comment.
This generator is not necessarily deterministic. What makes the test deterministic is the fixed seed inside the actual test case. I would prefer to have a comment explaining why the seed is fixed inside the test case.
zzcgumn
left a comment
There was a problem hiding this comment.
This makes sense to me. Would prefer to comment about how deals are generated deterministically to be moved.
Review feedback: make_deal() was described as "a deterministic generator", but it is only as reproducible as the generator the caller passes in -- the determinism actually comes from the fixed seed in the test case. Move the guarantee to where it is established, and say why it matters: a fixed std::mt19937 seed measures the same 100 deals on every run and platform, so timings stay comparable across runs and any warm/cold disagreement is reproducible from the failure output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the review — and good catch, you're right.
Rebuilt and re-ran after the change: still passes. Happy to also address the sizing point from the description if you'd like — it runs ~45s, so if that's too slow for the default cycle I can cut the deal count, drop to a single contract, or tag it |
|
I agree that 45 seconds is too long for the default test cycle. It's not exactly a test, in that it doesn't pass or fail. We could add a benchmark directory and move it there, along with benchmark.py and its wrapper, benchmark.sh. |
There was a problem hiding this comment.
Pull request overview
Adds a new GoogleTest benchmark/regression test to quantify (and guard) the performance benefit of reusing a DDS_C_SOLVER_CTX so the transposition table stays warm across successive similar solves, specifically on an opening-lead evaluation workload via the public C ABI.
Changes:
- Introduces a new benchmark-style test that compares
solutions=3vs “top-K then solve after lead” in cold-context vs warm-context modes, while asserting warm/cold correctness equivalence. - Registers the new test as a Bazel
cc_testtarget (sizedmedium) underlibrary/tests/solve_board.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
library/tests/solve_board/context_warm_tt_benchmark.cpp |
New benchmark/regression test measuring warm-TT context reuse benefit and asserting warm/cold result equality. |
library/tests/solve_board/BUILD.bazel |
Adds a cc_test target for the new benchmark. |
Comments suppressed due to low confidence (1)
library/tests/solve_board/context_warm_tt_benchmark.cpp:153
- B_cold reads fut.score[0] without checking that the solver returned at least one card. If FutureTricks::cards ever ends up 0 (e.g., due to an unexpected solver condition), this becomes an out-of-bounds read. Assert cards>0 (and value-initialize FutureTricks) before indexing.
FutureTricks fut;
const int rc = dds_c_solve_board(ctx, &pos, -1, 1, 0, &fut);
dds_c_destroy_solvercontext(ctx);
ASSERT_EQ(RETURN_NO_FAULT, rc) << label << " deal " << d << ": B_cold solve failed";
cold_scores.push_back(fut.score[0]);
| std::vector<int> warm_scores; | ||
| const auto tWarm = Clock::now(); | ||
| DDS_C_SOLVER_CTX ctx_w = dds_c_create_solvercontext_default(); | ||
| ASSERT_NE(nullptr, ctx_w) << label << " deal " << d << ": context alloc failed"; | ||
| for (const auto& pos : positions) { | ||
| FutureTricks fut; | ||
| const int rc = dds_c_solve_board(ctx_w, &pos, -1, 1, 0, &fut); | ||
| ASSERT_EQ(RETURN_NO_FAULT, rc) << label << " deal " << d << ": B_warm solve failed"; | ||
| warm_scores.push_back(fut.score[0]); | ||
| } | ||
| dds_c_destroy_solvercontext(ctx_w); | ||
| t.b_warm += ms_since(tWarm); |
| const Deal full = make_position(hands, trump, leader); | ||
| FutureTricks futA; | ||
| const auto tA = Clock::now(); | ||
| DDS_C_SOLVER_CTX ctx_a = dds_c_create_solvercontext_default(); | ||
| ASSERT_NE(nullptr, ctx_a) << label << " deal " << d << ": context alloc failed"; | ||
| const int rcA = dds_c_solve_board(ctx_a, &full, -1, 3, 1, &futA); | ||
| dds_c_destroy_solvercontext(ctx_a); | ||
| t.a += ms_since(tA); | ||
| ASSERT_EQ(RETURN_NO_FAULT, rcA) << label << " deal " << d << ": A solve failed"; | ||
| if (futA.cards <= 0) continue; |
| deps = [ | ||
| "//library/src:dds", | ||
| "//library/src/api:dds_c_api", | ||
| "@googletest//:gtest_main", | ||
| ], |
|
I am creating a benchmark test for robot bidding and play, so I can move this to a new benchmark folder |
|
How does this technique relate to the internal context reuse in PR #262? |
|
My new benchmark is a full capture of all DDS-calls from BEN from a 32 boards play. It will use the principles from PR #262 |
Most solver benchmarks pick a set of deals and solve them, which measures the
deals someone chose rather than the work a client actually asks for. A bridge
engine's DDS traffic is dominated by things a hand-picked deal list does not
reproduce: batches of sampled hands rather than single deals, a distribution of
trick depths (most calls happen mid-play, not at trick 1), a mix of solutions=1
and solutions=3, and repeated calls on nearly identical positions that hit a
warm transposition table.
//benchmarks:dds_replay re-issues every DDS call a real client made, in the
order it made them, times them, and -- because the recording stores the result
of every call -- checks the answers still match. That makes it a regression test
as well as a benchmark: any change that alters a trick count shows up as a
mismatch over 154,370 solved deals.
bazel run -c opt //benchmarks:dds_replay
bazel run -c opt //benchmarks:dds_replay -- --threads 8 --threads 16
bazel run -c opt //benchmarks:dds_replay -- --purpose play --tricks
Reports calls, boards, ms/call and ms/board per purpose, against the time each
call took when recorded. On this machine the committed workload replays in
140.5 s against 178.4 s recorded, with all 2,740 calls matching.
The workload is every DDS call BEN (a neural-network bridge engine) made playing
the 32 boards of Camrose 2024; the deals are committed alongside it. It is
checked in rather than generated because it cannot be reproduced from this
repository -- that needs BEN and its trained models. A 6-call excerpt backs a
fast smoke test so CI covers the replay path without running the full workload.
The recording format is plain JSON Lines and is documented in benchmarks/README.md;
any recorder emitting it can be replayed. Parsing is done by a small
hand-written reader rather than a third-party JSON library, to avoid adding a
dependency for one benchmark.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
It sat in library/tests/solve_board/, which is where solver regression
tests live; this is a benchmark that happens to assert. benchmarks/ now
holds both it and dds_replay, so the two are found together and the
README covers what each measures.
It stays a cc_test rather than becoming a manual target: pass and fail
are meaningful here. The timings are printed, not asserted -- CI load
would make that flaky -- but warm and cold contexts must agree on every
lead's score, so a change that makes TT reuse return a different answer
fails the build on every platform, on the same 100 deals.
Review fixes:
- Wrap the solver context in a ScopedContext RAII guard. A failing
ASSERT_* expands to a bare return, which jumped over the explicit
destroy and leaked the handle. The guard keeps an explicit destroy()
so the cost of destruction still lands inside the timed region.
- Treat a zero-card result as a failure instead of skipping the deal.
Skipping silently dropped it from the warm/cold comparison and
skewed the reported deal count. Every FutureTricks is now
value-initialised, and cards > 0 is asserted before reading score[0].
- Depend only on //library/src/api:dds_c_api. It already pulls in
//library/src:dds, so naming both widened the dependency surface for
nothing. Same redundancy dropped from replay_lib.
The page grew a second benchmark without its headings being resurveyed: "Running it" was written when there was only one thing to run, and the workload and format sections sat at the same level as the benchmarks they belong to, with the warm-TT section wedged between them. They are now nested under the replay benchmark, which moves the warm-TT section to the end where it reads as a peer. Also gives replay_test a command of its own; it was mentioned but never shown. The deals themselves were identified only as "Camrose 2024", which means nothing to a reader who does not play the home internationals -- and the PBN headers are stripped, so the file cannot say. Names the event, and separates what came from it (the 32 boards) from what did not (the play, which is BEN's across all four seats). Notes what that implies for the replay: BEN uses DDS as a helper and then chooses for itself, so a call's answer neither predicts the card played nor feeds the next call. Every solve record stands alone, which is what entitles the benchmark to reorder, thread, and filter the calls and still verify each answer.
clang rejects it under -Werror,-Wunused-private-field; MSVC does not warn, so it survived local verification and broke the Linux and macOS builds. The field was dead rather than merely unused. The mode a solve runs with travels per call, as the `mode` argument to solve_batch(), so the pool never needed a copy of its own -- ReplayEngine keeps the one that is actually read and passes it down per batch. Removed the field and the constructor parameter that fed it, and said in a comment why the pool does not hold a mode, since the obvious next edit is to put it back.
To partially answer my own question, I created a branch with both PRs and ran |
|
For one way to keep a test in the CI but out of |
What
Creates
benchmarks/and puts two benchmarks in it. No library or API changes.//benchmarks:warm_tt_benchmark//benchmarks:replay_test//benchmarks:dds_replaymanual)This started as the warm-TT test alone, in
library/tests/solve_board/. Per @tameware's review it moved to a benchmark directory, and since I was building a recorded-workload benchmark anyway, both now live here. The two measure the same effect from opposite ends: one isolates TT reuse synthetically, the other shows what it is worth inside a real engine's traffic.1. Warm-transposition-table benchmark
api/dds_c_api.hhands out an opaque solver-context handle that owns the transposition table, and the docs note a context can be reused across solves — but nothing in the tree quantified the benefit. This puts a number on it, on the workload where it matters most: opening-lead evaluation.Why this workload. Two ways to value the opening leads of a deal:
solutions=3, which scores all 13 leads at once.solutions=1.B only competes if the TT survives between the K post-lead positions. Those differ by only a couple of cards, so they pass the solver's similar-deal gate and the TT is not reset — but that only helps if the caller keeps the same context. So B is measured both ways: B_cold (a fresh context per lead) and B_warm (one context for all K).
Results — 100 deterministic deals × {3N, 4S},
-c opt, declarer South / leader West:Context reuse roughly halves the post-lead solve cost. At K=6 it flips the top-K approach from a 1.34× loss against
solutions=3into a 0.71× win.What is asserted. Correctness only: every solve returns
RETURN_NO_FAULTand at least one card, and warm and cold agree on every lead's score across all deals — so TT reuse provably never changes the answer. That check is arguably the more valuable half: it is a regression guard on context reuse. Timings are printed, not asserted, so it cannot flake under CI load; the one timing check is a loose sanity bound, not a performance gate.Deals come from a fixed-seed
mt19937, so every run on every machine measures the same 100 deals. Binds only to the exported C ABI (//library/src/api:dds_c_api), nottestable_dds.2. Recorded-workload benchmark
dds_replayre-issues every DDS call a real client made, in the order it made them, times them, and checks the answers still match what was recorded.Most solver benchmarks pick a set of deals and solve them — which measures the deals someone chose, not the work a client asks for. A bridge engine's traffic is dominated by things a hand-picked deal list does not reproduce: batches of sampled hands rather than single deals, a distribution of trick depths (most calls happen mid-play, not at trick 1), a mix of
solutions=1andsolutions=3, and repeated calls on nearly identical positions that hit a warm TT. Replaying a recording keeps all of it.Because the recording stores the result of every call, the replay also verifies them — so this is a regression test as well as a benchmark, over 154,370 solved deals.
The committed workload — please read this bit.
testdata/dds-camrose-1-32.jsonlis 11.7 MB, the largest thing in this PR by far. It is every DDS call made while BEN bid and played the 32 boards of the Camrose Trophy 2024, all four seats. The deals are from the tournament; the play is the engine's.It is committed rather than generated because it cannot be regenerated from this repository — producing it needs BEN and its trained neural models. Committing it is what makes the benchmark runnable and comparable across machines and over time, but I appreciate 11.7 MB is a real cost and I am happy to drop it and have
dds_replayrequire a recording the user supplies, if you would rather keep it out of the tree.testdata/sample-recording.jsonlis a 6-call excerpt backing//benchmarks:replay_test, so CI exercises the replay path (JSON parsing, PBN decoding, batching, canonicalisation, verification) without spending two minutes on the full workload.Review fixes in this PR
make_deal()is only as deterministic as the generator handed to it; the explanation of why the seed is fixed moved to the seeding site.ScopedContextRAII guard, so a failingASSERT_*(which expands to a barereturn) cannot leak the handle. The guard keeps an explicitdestroy()so the cost of destruction still lands inside the timed region.FutureTricksis value-initialised andcards > 0is asserted before readingscore[0].//library/src/api:dds_c_api, which already pulls in//library/src:dds. Same redundancy dropped fromreplay_lib.On @tameware's questions
They are, so it stays a
cc_testrather than becoming amanualtarget.warm_tt_benchmarkfails if warm and cold contexts ever disagree on a lead's score;replay_testfails if the replay path mis-parses or mis-canonicalises a result. Linux and Windows CI both runbazel test //..., so both are picked up automatically. Onlydds_replayis taggedmanual— it is a measuring instrument, not a test.On the ~45s runtime:
warm_tt_benchmarkis sizedmedium, andreplay_testissmalland takes ~3s. Still happy to cut the deal count or drop to a single contract if 45s is too much for the default cycle — say the word.Verification
bazel test //benchmarks:all //library/tests/solve_board:allpasses on Windows/MSVC (4/4).