From e9ade8e32ba5e05476ec538fc895fde199426985 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 09:14:38 +0200 Subject: [PATCH 01/10] Reuse a persistent worker pool for parallel board dispatch. Avoid create/join on every multi-worker parallel_all_boards_n call so large chunked batches do not pay thread startup cost repeatedly. Co-authored-by: Cursor --- library/src/system/parallel_boards.cpp | 230 ++++++++++++++---- library/src/system/parallel_boards.hpp | 13 + library/tests/system/parallel_boards_test.cpp | 68 ++++++ specs/system-concurrency.md | 4 +- 4 files changed, 261 insertions(+), 54 deletions(-) diff --git a/library/src/system/parallel_boards.cpp b/library/src/system/parallel_boards.cpp index c330a14c..d922636a 100644 --- a/library/src/system/parallel_boards.cpp +++ b/library/src/system/parallel_boards.cpp @@ -11,12 +11,181 @@ #include #include +#include +#include +#include #include #include #include +namespace +{ + +std::atomic g_threads_created{0}; + + +class BoardWorkerPool +{ +public: + BoardWorkerPool() = default; + + ~BoardWorkerPool() + { + { + std::lock_guard lock(mu_); + stopping_ = true; + ++generation_; + job_ = nullptr; + workers_for_job_ = 0; + } + cv_start_.notify_all(); + for (auto& th : threads_) + { + if (th.joinable()) + th.join(); + } + } + + BoardWorkerPool(const BoardWorkerPool&) = delete; + auto operator=(const BoardWorkerPool&) -> BoardWorkerPool& = delete; + + auto run( + const int workers, + const int count, + const std::function& process_board, + const std::function& board_of) -> int + { + ensure_workers(workers); + + std::atomic next{0}; + std::atomic first_error{RETURN_NO_FAULT}; + std::atomic finished{0}; + + Job job{ + &process_board, + &board_of, + &next, + &first_error, + &finished, + count, + workers}; + + { + std::lock_guard lock(mu_); + job_ = &job; + workers_for_job_ = workers; + ++generation_; + } + cv_start_.notify_all(); + + { + std::unique_lock lock(mu_); + cv_done_.wait(lock, [&] { + return finished.load(std::memory_order_acquire) >= workers; + }); + job_ = nullptr; + } + + const int err = first_error.load(std::memory_order_relaxed); + return err != RETURN_NO_FAULT ? err : RETURN_NO_FAULT; + } + +private: + struct Job + { + const std::function* process_board = nullptr; + const std::function* board_of = nullptr; + std::atomic* next = nullptr; + std::atomic* first_error = nullptr; + std::atomic* finished = nullptr; + int count = 0; + int workers = 0; + }; + + void ensure_workers(const int workers) + { + std::lock_guard lock(mu_); + while (static_cast(threads_.size()) < workers) + { + const int worker_id = static_cast(threads_.size()); + threads_.emplace_back([this, worker_id] { worker_main(worker_id); }); + g_threads_created.fetch_add(1, std::memory_order_relaxed); + } + } + + void worker_main(const int worker_id) + { + std::uint64_t seen_generation = 0; + for (;;) + { + Job local{}; + { + std::unique_lock lock(mu_); + cv_start_.wait(lock, [&] { + return stopping_ || + (job_ != nullptr && + generation_ != seen_generation && + worker_id < workers_for_job_); + }); + if (stopping_) + return; + seen_generation = generation_; + local = *job_; + } + + // Workers with id >= local.workers for this job still wake on notify_all; + // only the selected prefix participates and signals completion. + if (worker_id < local.workers) + { + for (;;) + { + const int slot = local.next->fetch_add(1, std::memory_order_relaxed); + if (slot >= local.count || + local.first_error->load(std::memory_order_relaxed) != RETURN_NO_FAULT) + { + break; + } + const int bno = (*local.board_of)(slot); + const int rc = (*local.process_board)(worker_id, bno); + if (rc != RETURN_NO_FAULT) + { + int expected = RETURN_NO_FAULT; + local.first_error->compare_exchange_strong( + expected, rc, std::memory_order_relaxed); + break; + } + } + if (local.finished->fetch_add(1, std::memory_order_acq_rel) + 1 >= + local.workers) + { + cv_done_.notify_one(); + } + } + } + } + + std::mutex mu_; + std::condition_variable cv_start_; + std::condition_variable cv_done_; + std::vector threads_; + bool stopping_ = false; + std::uint64_t generation_ = 0; + Job* job_ = nullptr; + int workers_for_job_ = 0; +}; + + +auto default_pool() -> BoardWorkerPool& +{ + static BoardWorkerPool pool; + return pool; +} + +} // namespace + + auto resolve_worker_count( const int max_threads, const int count) -> int @@ -46,6 +215,12 @@ static auto is_permutation_of_range( } +auto parallel_boards_worker_threads_created() -> std::uint64_t +{ + return g_threads_created.load(std::memory_order_relaxed); +} + + auto parallel_all_boards_n( const int count, const int worker_cap, @@ -65,7 +240,7 @@ auto parallel_all_boards_n( (order != nullptr && order->size() == static_cast(count) && is_permutation_of_range(*order, count)); - auto board_of = [&](const int slot) -> int { + const std::function board_of = [use_order, order](const int slot) { return use_order ? (*order)[static_cast(slot)] : slot; }; @@ -84,56 +259,5 @@ auto parallel_all_boards_n( return RETURN_NO_FAULT; } - std::atomic next{0}; - std::atomic first_error{RETURN_NO_FAULT}; - - auto worker = [&](const int worker_id) { - for (;;) - { - const int slot = next.fetch_add(1, std::memory_order_relaxed); - if (slot >= count || first_error.load(std::memory_order_relaxed) != RETURN_NO_FAULT) - { - break; - } - const int bno = board_of(slot); - - const int rc = process_board(worker_id, bno); - if (rc != RETURN_NO_FAULT) - { - int expected = RETURN_NO_FAULT; - first_error.compare_exchange_strong( - expected, rc, std::memory_order_relaxed); - break; - } - } - }; - - std::vector threads; - threads.reserve(static_cast(workers)); - try - { - for (int t = 0; t < workers; ++t) - { - threads.emplace_back(worker, t); - } - } - catch (...) - { - for (auto & th : threads) - { - if (th.joinable()) - { - th.join(); - } - } - throw; - } - - for (auto & th : threads) - { - th.join(); - } - - const int err = first_error.load(std::memory_order_relaxed); - return err != RETURN_NO_FAULT ? err : RETURN_NO_FAULT; + return default_pool().run(workers, count, process_board, board_of); } diff --git a/library/src/system/parallel_boards.hpp b/library/src/system/parallel_boards.hpp index 29ae953b..68de99ce 100644 --- a/library/src/system/parallel_boards.hpp +++ b/library/src/system/parallel_boards.hpp @@ -9,6 +9,7 @@ #pragma once +#include #include #include @@ -37,9 +38,21 @@ auto resolve_worker_count(int max_threads, int count) -> int; * non-null, the vector must remain valid and must not be mutated until * this function returns because worker threads read it concurrently. * @return First non-success code from @p process_board, or RETURN_NO_FAULT. + * + * Multi-worker runs use a process-local persistent thread pool so consecutive + * calls reuse OS threads instead of create/join each time. Single-worker runs + * stay on the calling thread. */ auto parallel_all_boards_n( int count, int worker_cap, const std::function& process_board, const std::vector* order = nullptr) -> int; + +/** + * @brief Cumulative number of OS worker threads created by the board pool. + * + * Used by tests to verify that consecutive multi-worker runs reuse threads + * rather than spawning a fresh set each call. + */ +auto parallel_boards_worker_threads_created() -> std::uint64_t; diff --git a/library/tests/system/parallel_boards_test.cpp b/library/tests/system/parallel_boards_test.cpp index ba5546dc..c1ba4899 100644 --- a/library/tests/system/parallel_boards_test.cpp +++ b/library/tests/system/parallel_boards_test.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include @@ -144,3 +145,70 @@ TEST(ParallelAllBoards, ZeroSizeNewReturnsNonNull) EXPECT_NE(memory, nullptr); ::operator delete(memory); } + +TEST(ParallelAllBoards, MultiWorkerProcessesEachBoardOnce) +{ + // Arrange + constexpr int count = 32; + constexpr int workers = 4; + std::vector> hits(static_cast(count)); + for (auto& h : hits) + h.store(0, std::memory_order_relaxed); + + // Act + const int result = parallel_all_boards_n( + count, + workers, + [&](const int worker_id, const int bno) { + EXPECT_GE(worker_id, 0); + EXPECT_LT(worker_id, workers); + EXPECT_GE(bno, 0); + EXPECT_LT(bno, count); + hits[static_cast(bno)].fetch_add(1, std::memory_order_relaxed); + return RETURN_NO_FAULT; + }); + + // Assert + EXPECT_EQ(result, RETURN_NO_FAULT); + for (int i = 0; i < count; ++i) + EXPECT_EQ(hits[static_cast(i)].load(), 1) << "board " << i; +} + +TEST(ParallelAllBoards, FailFastReturnsFirstError) +{ + // Arrange / Act + const int result = parallel_all_boards_n( + 16, + 4, + [](const int, const int bno) { + return bno == 7 ? RETURN_TOO_MANY_BOARDS : RETURN_NO_FAULT; + }); + + // Assert + EXPECT_EQ(result, RETURN_TOO_MANY_BOARDS); +} + +TEST(ParallelAllBoards, ReusesWorkerThreadsAcrossConsecutiveCalls) +{ + // Persistent pool should create workers once and reuse them. Spawn-per-call + // creates a fresh set of threads on every multi-worker invocation. + if (std::thread::hardware_concurrency() < 2) + GTEST_SKIP() << "Need at least 2 hardware threads"; + + constexpr int count = 64; + constexpr int workers = 4; + const auto noop = [](const int, const int) { return RETURN_NO_FAULT; }; + + // Arrange: grow/warm the pool to the requested size. + ASSERT_EQ(parallel_all_boards_n(count, workers, noop), RETURN_NO_FAULT); + const auto created_after_warm = parallel_boards_worker_threads_created(); + ASSERT_GE(created_after_warm, static_cast(workers)); + + // Act: two more multi-worker runs at the same width. + ASSERT_EQ(parallel_all_boards_n(count, workers, noop), RETURN_NO_FAULT); + ASSERT_EQ(parallel_all_boards_n(count, workers, noop), RETURN_NO_FAULT); + const auto created_after_reuse = parallel_boards_worker_threads_created(); + + // Assert: reuse must not create additional OS threads. + EXPECT_EQ(created_after_reuse, created_after_warm); +} diff --git a/specs/system-concurrency.md b/specs/system-concurrency.md index c474b9da..b2e18771 100644 --- a/specs/system-concurrency.md +++ b/specs/system-concurrency.md @@ -38,7 +38,9 @@ place so the search and API layers stay portable. It is internal — not part of duplicates) to control dispatch priority; a malformed `order` **falls back to index order** rather than rejecting. Each `process_board(worker_id, bno)` must return `RETURN_NO_FAULT` (1) on success. The function returns the **first - non-success code** encountered (or `RETURN_NO_FAULT`). Guarded by + non-success code** encountered (or `RETURN_NO_FAULT`). Multi-worker runs use a + process-local persistent thread pool (grow-only) so consecutive calls reuse OS + threads; `workers == 1` stays on the calling thread. Guarded by `concurrency_validation_test` and `parallel_boards_test`. - **Result equivalence across thread counts is an invariant.** Solving the same boards single-threaded and multi-threaded must produce identical results; From 8f2792808e76362e6feb4b64fa1a612b046d6552 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 09:58:50 +0200 Subject: [PATCH 02/10] Reuse persistent per-thread SolverContexts in batch calc and solve paths. Batch workers previously created a fresh SolverContext (and transposition table) per chunk in calc and per board in solve. With the persistent worker pool, a thread_local context now survives across boards, chunks and consecutive batch calls, removing TT allocation churn. Co-authored-by: Cursor --- library/src/calc_tables.cpp | 9 +- library/src/solve_board.cpp | 13 +- library/src/solver_context/solver_context.cpp | 33 ++++ library/src/solver_context/solver_context.hpp | 17 ++ library/tests/system/BUILD.bazel | 12 ++ .../system/worker_context_reuse_test.cpp | 154 ++++++++++++++++++ 6 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 library/tests/system/worker_context_reuse_test.cpp diff --git a/library/src/calc_tables.cpp b/library/src/calc_tables.cpp index 86585724..72c6cf78 100644 --- a/library/src/calc_tables.cpp +++ b/library/src/calc_tables.cpp @@ -130,7 +130,7 @@ auto calc_all_boards_n( int err = RETURN_NO_FAULT; if (nthreads <= 1) { - SolverContext ctx; + SolverContext& ctx = dds::internal::worker_solver_context(); for (int bno = 0; bno < n; ++bno) { err = calc_single_common_internal(ctx, *bop, *solvedp, bno); @@ -140,8 +140,6 @@ auto calc_all_boards_n( } else { - std::vector contexts(static_cast(nthreads)); - // Dispatch hardest boards first to shorten the parallel tail. This only // helps across distinct deals (batch calc); for a single deal every board // shares one fanout, so the sort is skipped (it would be a no-op anyway). @@ -162,8 +160,11 @@ auto calc_all_boards_n( err = parallel_all_boards_n(n, nthreads, [&](const int worker_id, const int bno) -> int { + (void)worker_id; + // Persistent per-thread context: pool worker threads survive across + // batches, so the TT allocated here is reused for the whole run. return calc_single_common_internal( - contexts[static_cast(worker_id)], *bop, *solvedp, bno); + dds::internal::worker_solver_context(), *bop, *solvedp, bno); }, order.empty() ? nullptr : &order); } diff --git a/library/src/solve_board.cpp b/library/src/solve_board.cpp index 49fc7ef2..24e9c10c 100644 --- a/library/src/solve_board.cpp +++ b/library/src/solve_board.cpp @@ -10,6 +10,7 @@ #include #include "solve_board.hpp" +#include #include #include #include @@ -80,9 +81,12 @@ auto solve_all_boards_n( FutureTricks fut; const auto t0 = std::chrono::steady_clock::now(); - const int res = SolveBoard( + // Persistent per-thread context: reuses the worker's TT across boards + // and consecutive batch calls instead of allocating one per board. + const int res = solve_board( + dds::internal::worker_solver_context(), bds.deals[bno], bds.target[bno], bds.solutions[bno], - bds.mode[bno], &fut, 0); + bds.mode[bno], &fut); auto dur = std::chrono::duration_cast( std::chrono::steady_clock::now() - t0).count(); if (dur < 0) dur = 0; @@ -272,9 +276,10 @@ auto solve_all_boards_n_seq( for (int bno = 0; bno < n && error == 0; bno++) { FutureTricks fut; const auto t0 = std::chrono::steady_clock::now(); - const int res = SolveBoard( + const int res = solve_board( + dds::internal::worker_solver_context(), bds.deals[bno], bds.target[bno], bds.solutions[bno], - bds.mode[bno], &fut, 0); + bds.mode[bno], &fut); auto dur = std::chrono::duration_cast( std::chrono::steady_clock::now() - t0).count(); if (dur < 0) dur = 0; diff --git a/library/src/solver_context/solver_context.cpp b/library/src/solver_context/solver_context.cpp index 8b27ef6d..6b2ee5af 100644 --- a/library/src/solver_context/solver_context.cpp +++ b/library/src/solver_context/solver_context.cpp @@ -274,6 +274,39 @@ auto SolverContext::reset_best_moves_lite() const -> void #endif } +namespace dds::internal +{ + +namespace +{ + +std::atomic g_worker_contexts_created{0}; + +struct CountedWorkerContext +{ + CountedWorkerContext() + { + g_worker_contexts_created.fetch_add(1, std::memory_order_relaxed); + } + + SolverContext ctx; +}; + +} // namespace + +auto worker_solver_context() -> SolverContext& +{ + thread_local CountedWorkerContext holder; + return holder.ctx; +} + +auto worker_solver_contexts_created() -> std::uint64_t +{ + return g_worker_contexts_created.load(std::memory_order_relaxed); +} + +} // namespace dds::internal + auto ThreadMemoryUsed() -> double { // Fixed per-thread lookup-table memory (RelRanksType) included in memUsed diff --git a/library/src/solver_context/solver_context.hpp b/library/src/solver_context/solver_context.hpp index 7ba130e6..ea4f5011 100644 --- a/library/src/solver_context/solver_context.hpp +++ b/library/src/solver_context/solver_context.hpp @@ -9,6 +9,7 @@ #pragma once #include +#include #include #include #include @@ -451,3 +452,19 @@ class SolverContext }; auto ThreadMemoryUsed() -> double; + +namespace dds::internal +{ + +// Persistent per-thread SolverContext for batch worker threads. Created +// lazily on first use and kept alive for the thread's lifetime, so the +// transposition table survives across boards, chunks and consecutive batch +// calls instead of being reallocated each time. Combined with the persistent +// worker pool this removes per-batch TT churn (the unported half of the ddss +// fork's batching optimization). +auto worker_solver_context() -> SolverContext&; + +// Cumulative count of worker contexts ever created (test seam). +auto worker_solver_contexts_created() -> std::uint64_t; + +} // namespace dds::internal diff --git a/library/tests/system/BUILD.bazel b/library/tests/system/BUILD.bazel index d8a31969..aa1f52a3 100644 --- a/library/tests/system/BUILD.bazel +++ b/library/tests/system/BUILD.bazel @@ -85,6 +85,18 @@ cc_test( ], ) +cc_test( + name = "worker_context_reuse_test", + size = "small", + srcs = ["worker_context_reuse_test.cpp"], + deps = [ + "//library/src:testable_dds", + "//library/src/api:api_definitions", + "//library/src/solver_context", + "@googletest//:gtest_main", + ], +) + cc_test( name = "parallel_boards_test", size = "small", diff --git a/library/tests/system/worker_context_reuse_test.cpp b/library/tests/system/worker_context_reuse_test.cpp new file mode 100644 index 00000000..7f83c834 --- /dev/null +++ b/library/tests/system/worker_context_reuse_test.cpp @@ -0,0 +1,154 @@ +/// @file worker_context_reuse_test.cpp +/// @brief Tests for the persistent per-worker SolverContext used by batch +/// paths. Contexts (and their transposition tables) must be created +/// once per worker thread and reused across consecutive batch calls, +/// without changing results. + +#include +#include +#include + +#include +#include +#include + +namespace +{ + +// Known deal from examples/hands.cpp (hand 0), as used by context_equivalence_test. +// PBN: N:QJ6.K652.J85.T98 873.J97.AT764.Q4 K5.T83.KQ9.A7652 AT942.AQ4.32.KJ3 +DdTableDeal make_known_deal() +{ + DdTableDeal deal{}; + deal.cards[0][0] = 0x1800 | 0x0040; + deal.cards[0][1] = 0x2000 | 0x0060 | 0x0004; + deal.cards[0][2] = 0x0800 | 0x0100 | 0x0020; + deal.cards[0][3] = 0x0400 | 0x0200 | 0x0100; + deal.cards[1][0] = 0x0100 | 0x0080 | 0x0008; + deal.cards[1][1] = 0x0800 | 0x0200 | 0x0080; + deal.cards[1][2] = 0x4000 | 0x0400 | 0x0080 | 0x0040 | 0x0010; + deal.cards[1][3] = 0x1000 | 0x0010; + deal.cards[2][0] = 0x2000 | 0x0020; + deal.cards[2][1] = 0x0400 | 0x0100 | 0x0008; + deal.cards[2][2] = 0x2000 | 0x1000 | 0x0200; + deal.cards[2][3] = 0x4000 | 0x0080 | 0x0040 | 0x0020 | 0x0004; + deal.cards[3][0] = 0x4000 | 0x0400 | 0x0200 | 0x0010 | 0x0004; + deal.cards[3][1] = 0x4000 | 0x1000 | 0x0010; + deal.cards[3][2] = 0x0008 | 0x0004; + deal.cards[3][3] = 0x2000 | 0x0800 | 0x0008; + return deal; +} + +void expect_tables_equal(const DdTableResults& a, const DdTableResults& b) +{ + for (int strain = 0; strain < DDS_STRAINS; strain++) + for (int hand = 0; hand < DDS_HANDS; hand++) + EXPECT_EQ(a.res_table[strain][hand], b.res_table[strain][hand]) + << "Mismatch at strain=" << strain << " hand=" << hand; +} + +} // namespace + +// The helper hands out one persistent context per calling thread. +TEST(WorkerSolverContext, SameThreadReturnsSameInstance) +{ + SolverContext& first = dds::internal::worker_solver_context(); + SolverContext& second = dds::internal::worker_solver_context(); + EXPECT_EQ(&first, &second); +} + +// Distinct threads must not share a context (SolverContext is not thread-safe). +TEST(WorkerSolverContext, DistinctThreadsGetDistinctInstances) +{ + SolverContext* main_ctx = &dds::internal::worker_solver_context(); + SolverContext* other_ctx = nullptr; + std::thread t([&] { other_ctx = &dds::internal::worker_solver_context(); }); + t.join(); + ASSERT_NE(other_ctx, nullptr); + EXPECT_NE(main_ctx, other_ctx); +} + +// Repeated access on one thread creates exactly one context (counter seam). +TEST(WorkerSolverContext, RepeatedCallsOnSameThreadCreateOneContext) +{ + (void)dds::internal::worker_solver_context(); + const std::uint64_t before = dds::internal::worker_solver_contexts_created(); + (void)dds::internal::worker_solver_context(); + (void)dds::internal::worker_solver_context(); + const std::uint64_t after = dds::internal::worker_solver_contexts_created(); + EXPECT_EQ(before, after); +} + +// Consecutive batch calc calls must reuse worker contexts: since pool worker +// threads persist across calls, the total number of contexts ever created is +// bounded by the worker count, no matter how many batches run. Results must +// stay identical across calls (no stale transposition-table pollution). +TEST(WorkerContextReuse, RepeatedCalcCallsAreBoundedByWorkerCountAndStayCorrect) +{ + InitializeStaticMemory(); + const DdTableDeal deal = make_known_deal(); + constexpr int kWorkers = 2; + constexpr int kCalls = 3; + + DdTableResults reference{}; + ASSERT_EQ(CalcDDtableN(deal, &reference, /*maxThreads=*/1), RETURN_NO_FAULT); + + const std::uint64_t before = dds::internal::worker_solver_contexts_created(); + + for (int call = 0; call < kCalls; call++) + { + DdTableResults table{}; + ASSERT_EQ(CalcDDtableN(deal, &table, kWorkers), RETURN_NO_FAULT) + << "call=" << call; + expect_tables_equal(reference, table); + } + + const std::uint64_t created = + dds::internal::worker_solver_contexts_created() - before; + EXPECT_LE(created, static_cast(kWorkers)) + << "worker contexts must be reused across consecutive batch calls"; +} + +// The batch solve path must also stay correct when worker contexts (and their +// transposition tables) are reused across consecutive calls. +TEST(WorkerContextReuse, RepeatedSolveCallsStayCorrect) +{ + InitializeStaticMemory(); + const DdTableDeal table_deal = make_known_deal(); + + Boards bo{}; + bo.no_of_boards = DDS_STRAINS; + for (int tr = 0; tr < DDS_STRAINS; tr++) + { + Deal dl{}; + for (int h = 0; h < DDS_HANDS; h++) + for (int s = 0; s < DDS_SUITS; s++) + dl.remainCards[h][s] = table_deal.cards[h][s]; + dl.trump = tr; + dl.first = 0; + bo.deals[tr] = dl; + bo.target[tr] = -1; + bo.solutions[tr] = 1; + bo.mode[tr] = 1; + } + + SolvedBoards first{}; + ASSERT_EQ(SolveAllBoardsBinN(&bo, &first, /*maxThreads=*/2), RETURN_NO_FAULT); + + SolvedBoards second{}; + ASSERT_EQ(SolveAllBoardsBinN(&bo, &second, /*maxThreads=*/2), RETURN_NO_FAULT); + + ASSERT_EQ(first.no_of_boards, second.no_of_boards); + for (int b = 0; b < first.no_of_boards; b++) + { + const FutureTricks& fa = first.solved_board[b]; + const FutureTricks& fb = second.solved_board[b]; + ASSERT_EQ(fa.cards, fb.cards) << "card count differs at board=" << b; + for (int c = 0; c < fa.cards; c++) + { + EXPECT_EQ(fa.suit[c], fb.suit[c]) << "suit at board=" << b << " c=" << c; + EXPECT_EQ(fa.rank[c], fb.rank[c]) << "rank at board=" << b << " c=" << c; + EXPECT_EQ(fa.score[c], fb.score[c]) << "score at board=" << b << " c=" << c; + } + } +} From 8250c7d21d7ab6d43dc4f06462d85ace112be644 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 12:21:47 +0200 Subject: [PATCH 03/10] Serialize concurrent runs on the shared board worker pool. The pool tracks a single outstanding job, so two threads dispatching batches at the same time could overwrite each other's job and leave one caller waiting forever. run() now holds a run-scoped mutex; concurrent callers queue instead of deadlocking. Co-authored-by: Cursor --- library/src/system/parallel_boards.cpp | 8 ++ library/src/system/parallel_boards.hpp | 4 +- library/tests/system/parallel_boards_test.cpp | 79 +++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/library/src/system/parallel_boards.cpp b/library/src/system/parallel_boards.cpp index d922636a..c7c41978 100644 --- a/library/src/system/parallel_boards.cpp +++ b/library/src/system/parallel_boards.cpp @@ -57,6 +57,12 @@ class BoardWorkerPool const std::function& process_board, const std::function& board_of) -> int { + // The pool tracks exactly one outstanding job (job_, workers_for_job_, + // generation_). Serialize whole runs so a second concurrent caller queues + // up instead of overwriting the first caller's job, which would leave the + // first run waiting forever for workers that never saw its job. + std::lock_guard run_lock(run_mu_); + ensure_workers(workers); std::atomic next{0}; @@ -166,6 +172,8 @@ class BoardWorkerPool } } + // Held for the duration of run(); see the comment there. + std::mutex run_mu_; std::mutex mu_; std::condition_variable cv_start_; std::condition_variable cv_done_; diff --git a/library/src/system/parallel_boards.hpp b/library/src/system/parallel_boards.hpp index 68de99ce..50cd9b6b 100644 --- a/library/src/system/parallel_boards.hpp +++ b/library/src/system/parallel_boards.hpp @@ -41,7 +41,9 @@ auto resolve_worker_count(int max_threads, int count) -> int; * * Multi-worker runs use a process-local persistent thread pool so consecutive * calls reuse OS threads instead of create/join each time. Single-worker runs - * stay on the calling thread. + * stay on the calling thread. The pool handles one job at a time; concurrent + * multi-worker calls from different threads are safe but serialize against + * each other. */ auto parallel_all_boards_n( int count, diff --git a/library/tests/system/parallel_boards_test.cpp b/library/tests/system/parallel_boards_test.cpp index c1ba4899..081444ae 100644 --- a/library/tests/system/parallel_boards_test.cpp +++ b/library/tests/system/parallel_boards_test.cpp @@ -1,6 +1,9 @@ +#include #include +#include #include #include +#include #include #include #include @@ -188,6 +191,82 @@ TEST(ParallelAllBoards, FailFastReturnsFirstError) EXPECT_EQ(result, RETURN_TOO_MANY_BOARDS); } +TEST(ParallelAllBoards, ConcurrentCallersBothCompleteAndProcessAllBoards) +{ + // The pool is process-global; two threads dispatching batches at the same + // time must not clobber each other's job (which would drop boards or hang + // one caller forever waiting for workers that never picked its job up). + constexpr int callers = 2; + constexpr int count = 64; + constexpr int workers = 2; + constexpr auto deadline = std::chrono::seconds(20); + + // Arrange: per-caller hit counters and a start barrier so both callers + // enter the dispatcher at the same moment. + std::array>, callers> hits; + for (auto& caller_hits : hits) + caller_hits = std::vector>(count); + std::atomic ready{0}; + std::array, callers> results; + std::array, callers> futures; + for (int c = 0; c < callers; ++c) + futures[static_cast(c)] = + results[static_cast(c)].get_future(); + + // Act + std::vector threads; + threads.reserve(callers); + for (int c = 0; c < callers; ++c) + { + threads.emplace_back([&, c] { + ready.fetch_add(1, std::memory_order_relaxed); + while (ready.load(std::memory_order_relaxed) < callers) + std::this_thread::yield(); + + const int rc = parallel_all_boards_n( + count, + workers, + [&, c](const int, const int bno) { + hits[static_cast(c)][static_cast(bno)] + .fetch_add(1, std::memory_order_relaxed); + std::this_thread::sleep_for(std::chrono::microseconds(200)); + return RETURN_NO_FAULT; + }); + results[static_cast(c)].set_value(rc); + }); + } + + bool timed_out = false; + for (auto& fut : futures) + { + if (fut.wait_for(deadline) != std::future_status::ready) + timed_out = true; + } + + // A hung caller can never be joined; detach so the failure is reportable. + for (auto& th : threads) + { + if (timed_out) + th.detach(); + else + th.join(); + } + ASSERT_FALSE(timed_out) + << "a concurrent caller hung: its job was lost by the shared pool"; + + // Assert: both callers succeeded and every board was processed exactly once + // per caller. + for (int c = 0; c < callers; ++c) + { + EXPECT_EQ(futures[static_cast(c)].get(), RETURN_NO_FAULT) + << "caller " << c; + for (int i = 0; i < count; ++i) + EXPECT_EQ( + hits[static_cast(c)][static_cast(i)].load(), 1) + << "caller " << c << " board " << i; + } +} + TEST(ParallelAllBoards, ReusesWorkerThreadsAcrossConsecutiveCalls) { // Persistent pool should create workers once and reuse them. Spawn-per-call From e8d8cfcc59b2f31ece4329d56f3b31e760aa7504 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 12:24:45 +0200 Subject: [PATCH 04/10] Fail fast on invalid board indices in MultiWorkerProcessesEachBoardOnce. EXPECT does not abort, so an out-of-range bno could crash with OOB access before the test reported a clean failure. Guard the bounds check and return an error instead of indexing. Co-authored-by: Cursor --- library/tests/system/parallel_boards_test.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/library/tests/system/parallel_boards_test.cpp b/library/tests/system/parallel_boards_test.cpp index 081444ae..ac52dba6 100644 --- a/library/tests/system/parallel_boards_test.cpp +++ b/library/tests/system/parallel_boards_test.cpp @@ -162,11 +162,15 @@ TEST(ParallelAllBoards, MultiWorkerProcessesEachBoardOnce) const int result = parallel_all_boards_n( count, workers, - [&](const int worker_id, const int bno) { - EXPECT_GE(worker_id, 0); - EXPECT_LT(worker_id, workers); - EXPECT_GE(bno, 0); - EXPECT_LT(bno, count); + [&](const int worker_id, const int bno) -> int { + // EXPECT does not abort; guard before indexing so a bad bno fails the + // test cleanly instead of crashing with an out-of-range access. + if (worker_id < 0 || worker_id >= workers || bno < 0 || bno >= count) + { + ADD_FAILURE() << "Invalid dispatch: worker_id=" << worker_id + << " bno=" << bno; + return RETURN_UNKNOWN_FAULT; + } hits[static_cast(bno)].fetch_add(1, std::memory_order_relaxed); return RETURN_NO_FAULT; }); From 8010b930b5a9b9732a752bba59c5d20a9c6f3153 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 17:34:17 +0200 Subject: [PATCH 05/10] Serialize board-pool completion under the job mutex. Workers were signaling done via an atomic counter without holding mu_, which left process_board writes unsynchronized with the caller and risked a lost cv_done_ wakeup. Accounting finished under the mutex restores happens-before for result buffers. Co-authored-by: Cursor --- library/src/system/parallel_boards.cpp | 23 +++++++++++------- library/tests/system/parallel_boards_test.cpp | 24 +++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/library/src/system/parallel_boards.cpp b/library/src/system/parallel_boards.cpp index c7c41978..fec33fcb 100644 --- a/library/src/system/parallel_boards.cpp +++ b/library/src/system/parallel_boards.cpp @@ -67,7 +67,10 @@ class BoardWorkerPool std::atomic next{0}; std::atomic first_error{RETURN_NO_FAULT}; - std::atomic finished{0}; + // finished is protected by mu_: every worker increments it under that lock + // so the unlock→lock handoff through cv_done_ happens-before the caller's + // continued use of process_board side effects (e.g. result buffers). + int finished{0}; Job job{ &process_board, @@ -88,9 +91,7 @@ class BoardWorkerPool { std::unique_lock lock(mu_); - cv_done_.wait(lock, [&] { - return finished.load(std::memory_order_acquire) >= workers; - }); + cv_done_.wait(lock, [&] { return finished >= workers; }); job_ = nullptr; } @@ -105,7 +106,7 @@ class BoardWorkerPool const std::function* board_of = nullptr; std::atomic* next = nullptr; std::atomic* first_error = nullptr; - std::atomic* finished = nullptr; + int* finished = nullptr; int count = 0; int workers = 0; }; @@ -163,11 +164,17 @@ class BoardWorkerPool break; } } - if (local.finished->fetch_add(1, std::memory_order_acq_rel) + 1 >= - local.workers) + // Account completion under mu_ so (1) the predicate change cannot race + // with cv_done_.wait's check-and-sleep (lost wakeup) and (2) unlocking + // mu_ after process_board writes publishes those writes to the caller + // when wait reacquires the mutex. + bool notify = false; { - cv_done_.notify_one(); + std::lock_guard lock(mu_); + notify = ++(*local.finished) >= local.workers; } + if (notify) + cv_done_.notify_one(); } } } diff --git a/library/tests/system/parallel_boards_test.cpp b/library/tests/system/parallel_boards_test.cpp index ac52dba6..6c574f01 100644 --- a/library/tests/system/parallel_boards_test.cpp +++ b/library/tests/system/parallel_boards_test.cpp @@ -181,6 +181,30 @@ TEST(ParallelAllBoards, MultiWorkerProcessesEachBoardOnce) EXPECT_EQ(hits[static_cast(i)].load(), 1) << "board " << i; } +TEST(ParallelAllBoards, CallerSeesNonAtomicWorkerWritesAfterReturn) +{ + // Completion must establish happens-before from each worker's process_board + // stores into the caller's buffers before parallel_all_boards_n returns. + // Plain (non-atomic) writes make missing mutex/condvar sync visible to TSan. + constexpr int count = 64; + constexpr int workers = 4; + std::vector results(static_cast(count), -1); + + // Act + const int result = parallel_all_boards_n( + count, + workers, + [&](const int, const int bno) { + results[static_cast(bno)] = bno * 10; + return RETURN_NO_FAULT; + }); + + // Assert: every slot is visible without further synchronization. + EXPECT_EQ(result, RETURN_NO_FAULT); + for (int i = 0; i < count; ++i) + EXPECT_EQ(results[static_cast(i)], i * 10) << "board " << i; +} + TEST(ParallelAllBoards, FailFastReturnsFirstError) { // Arrange / Act From 853f2e8f9ad8f1e8961ca3a4c53426fb81c6548b Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 17:49:56 +0200 Subject: [PATCH 06/10] Initialize atomic hit counters before fetch_add in concurrent callers test. Default-constructed std::atomic leaves the value uninitialized, so counting board hits was undefined behavior. Co-authored-by: Cursor --- library/tests/system/parallel_boards_test.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/library/tests/system/parallel_boards_test.cpp b/library/tests/system/parallel_boards_test.cpp index 6c574f01..a81cbe81 100644 --- a/library/tests/system/parallel_boards_test.cpp +++ b/library/tests/system/parallel_boards_test.cpp @@ -233,7 +233,11 @@ TEST(ParallelAllBoards, ConcurrentCallersBothCompleteAndProcessAllBoards) // enter the dispatcher at the same moment. std::array>, callers> hits; for (auto& caller_hits : hits) + { caller_hits = std::vector>(count); + for (auto& h : caller_hits) + h.store(0, std::memory_order_relaxed); + } std::atomic ready{0}; std::array, callers> results; std::array, callers> futures; From 90ea8e6f85f6f3ab3ce7e772a1954a909fc59389 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 18:55:10 +0200 Subject: [PATCH 07/10] Avoid std::function for board-slot mapping in the parallel dispatcher. Keep the hot path allocation-free by passing use_order + order as plain data so permutation-validation allocation bounds are not SBO-dependent. Co-authored-by: Cursor --- library/src/system/parallel_boards.cpp | 25 +++++++++++-------- library/tests/system/parallel_boards_test.cpp | 2 ++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/library/src/system/parallel_boards.cpp b/library/src/system/parallel_boards.cpp index fec33fcb..63d333bd 100644 --- a/library/src/system/parallel_boards.cpp +++ b/library/src/system/parallel_boards.cpp @@ -55,7 +55,8 @@ class BoardWorkerPool const int workers, const int count, const std::function& process_board, - const std::function& board_of) -> int + const bool use_order, + const std::vector* order) -> int { // The pool tracks exactly one outstanding job (job_, workers_for_job_, // generation_). Serialize whole runs so a second concurrent caller queues @@ -74,12 +75,13 @@ class BoardWorkerPool Job job{ &process_board, - &board_of, + order, &next, &first_error, &finished, count, - workers}; + workers, + use_order}; { std::lock_guard lock(mu_); @@ -103,12 +105,13 @@ class BoardWorkerPool struct Job { const std::function* process_board = nullptr; - const std::function* board_of = nullptr; + const std::vector* order = nullptr; std::atomic* next = nullptr; std::atomic* first_error = nullptr; int* finished = nullptr; int count = 0; int workers = 0; + bool use_order = false; }; void ensure_workers(const int workers) @@ -154,7 +157,10 @@ class BoardWorkerPool { break; } - const int bno = (*local.board_of)(slot); + const int bno = + local.use_order + ? (*local.order)[static_cast(slot)] + : slot; const int rc = (*local.process_board)(worker_id, bno); if (rc != RETURN_NO_FAULT) { @@ -255,9 +261,6 @@ auto parallel_all_boards_n( (order != nullptr && order->size() == static_cast(count) && is_permutation_of_range(*order, count)); - const std::function board_of = [use_order, order](const int slot) { - return use_order ? (*order)[static_cast(slot)] : slot; - }; const int workers = resolve_worker_count(worker_cap, count); @@ -265,7 +268,9 @@ auto parallel_all_boards_n( { for (int slot = 0; slot < count; ++slot) { - const int rc = process_board(0, board_of(slot)); + const int bno = + use_order ? (*order)[static_cast(slot)] : slot; + const int rc = process_board(0, bno); if (rc != RETURN_NO_FAULT) { return rc; @@ -274,5 +279,5 @@ auto parallel_all_boards_n( return RETURN_NO_FAULT; } - return default_pool().run(workers, count, process_board, board_of); + return default_pool().run(workers, count, process_board, use_order, order); } diff --git a/library/tests/system/parallel_boards_test.cpp b/library/tests/system/parallel_boards_test.cpp index a81cbe81..80c5f4f1 100644 --- a/library/tests/system/parallel_boards_test.cpp +++ b/library/tests/system/parallel_boards_test.cpp @@ -117,6 +117,8 @@ TEST(ParallelAllBoards, WrongSizedOrderFallsBackToIndexOrder) TEST(ParallelAllBoards, PermutationValidationUsesAtMostOneAllocation) { + // Permutation checks may allocate a temporary "seen" bitmap; the board-slot + // mapping itself must stay allocation-free (plain data, not std::function). // Arrange const std::vector order{3, 1, 0, 2}; std::vector boards; From 2622ab35496de6416e2ce2652cea7c36cf2f8e18 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 19:06:37 +0200 Subject: [PATCH 08/10] Move board-pool thread counter into dds::internal. Keep the reuse test seam out of the top-level header API so production consumers do not treat it as a stable surface. Co-authored-by: Cursor --- library/src/system/parallel_boards.cpp | 5 +++++ library/src/system/parallel_boards.hpp | 12 ++++++------ library/tests/system/parallel_boards_test.cpp | 6 ++++-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/library/src/system/parallel_boards.cpp b/library/src/system/parallel_boards.cpp index 63d333bd..74ec49ab 100644 --- a/library/src/system/parallel_boards.cpp +++ b/library/src/system/parallel_boards.cpp @@ -236,11 +236,16 @@ static auto is_permutation_of_range( } +namespace dds::internal +{ + auto parallel_boards_worker_threads_created() -> std::uint64_t { return g_threads_created.load(std::memory_order_relaxed); } +} // namespace dds::internal + auto parallel_all_boards_n( const int count, diff --git a/library/src/system/parallel_boards.hpp b/library/src/system/parallel_boards.hpp index 50cd9b6b..6a25472f 100644 --- a/library/src/system/parallel_boards.hpp +++ b/library/src/system/parallel_boards.hpp @@ -51,10 +51,10 @@ auto parallel_all_boards_n( const std::function& process_board, const std::vector* order = nullptr) -> int; -/** - * @brief Cumulative number of OS worker threads created by the board pool. - * - * Used by tests to verify that consecutive multi-worker runs reuse threads - * rather than spawning a fresh set each call. - */ +namespace dds::internal +{ + +// Cumulative number of OS worker threads created by the board pool (test seam). auto parallel_boards_worker_threads_created() -> std::uint64_t; + +} // namespace dds::internal diff --git a/library/tests/system/parallel_boards_test.cpp b/library/tests/system/parallel_boards_test.cpp index 80c5f4f1..c3d37d1d 100644 --- a/library/tests/system/parallel_boards_test.cpp +++ b/library/tests/system/parallel_boards_test.cpp @@ -314,13 +314,15 @@ TEST(ParallelAllBoards, ReusesWorkerThreadsAcrossConsecutiveCalls) // Arrange: grow/warm the pool to the requested size. ASSERT_EQ(parallel_all_boards_n(count, workers, noop), RETURN_NO_FAULT); - const auto created_after_warm = parallel_boards_worker_threads_created(); + const auto created_after_warm = + dds::internal::parallel_boards_worker_threads_created(); ASSERT_GE(created_after_warm, static_cast(workers)); // Act: two more multi-worker runs at the same width. ASSERT_EQ(parallel_all_boards_n(count, workers, noop), RETURN_NO_FAULT); ASSERT_EQ(parallel_all_boards_n(count, workers, noop), RETURN_NO_FAULT); - const auto created_after_reuse = parallel_boards_worker_threads_created(); + const auto created_after_reuse = + dds::internal::parallel_boards_worker_threads_created(); // Assert: reuse must not create additional OS threads. EXPECT_EQ(created_after_reuse, created_after_warm); From 56ecdbfc5bedeab2873a2556318925a959a29ee3 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 19:10:21 +0200 Subject: [PATCH 09/10] Clarify worker_solver_context lifetime covers calling-thread runs. The previous wording implied pool workers only; single-worker and sequential batch paths reuse the same per-thread context. Co-authored-by: Cursor --- library/src/solver_context/solver_context.hpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/library/src/solver_context/solver_context.hpp b/library/src/solver_context/solver_context.hpp index ea4f5011..3c7124de 100644 --- a/library/src/solver_context/solver_context.hpp +++ b/library/src/solver_context/solver_context.hpp @@ -456,12 +456,13 @@ auto ThreadMemoryUsed() -> double; namespace dds::internal { -// Persistent per-thread SolverContext for batch worker threads. Created -// lazily on first use and kept alive for the thread's lifetime, so the -// transposition table survives across boards, chunks and consecutive batch -// calls instead of being reallocated each time. Combined with the persistent -// worker pool this removes per-batch TT churn (the unported half of the ddss -// fork's batching optimization). +// Persistent per-thread SolverContext for batch solve/calc paths. Created +// lazily on first use on whichever thread calls it — pool workers, or the +// calling thread for single-worker / sequential runs — and kept alive for that +// thread's lifetime, so the transposition table survives across boards, chunks +// and consecutive batch calls instead of being reallocated each time. Combined +// with the persistent worker pool this removes per-batch TT churn (the unported +// half of the ddss fork's batching optimization). auto worker_solver_context() -> SolverContext&; // Cumulative count of worker contexts ever created (test seam). From d70a98ed0a4932b0607a29d7f6988ba330eee833 Mon Sep 17 00:00:00 2001 From: Adam Wildavsky Date: Thu, 23 Jul 2026 21:01:40 +0200 Subject: [PATCH 10/10] Document that multi-worker parallel_all_boards_n is not re-entrant. Nested multi-worker calls from process_board deadlock on the pool mutex; spell that out in the header and concurrency spec. Co-authored-by: Cursor --- library/src/system/parallel_boards.hpp | 4 +++- specs/system-concurrency.md | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/library/src/system/parallel_boards.hpp b/library/src/system/parallel_boards.hpp index 6a25472f..4fcf2ea0 100644 --- a/library/src/system/parallel_boards.hpp +++ b/library/src/system/parallel_boards.hpp @@ -43,7 +43,9 @@ auto resolve_worker_count(int max_threads, int count) -> int; * calls reuse OS threads instead of create/join each time. Single-worker runs * stay on the calling thread. The pool handles one job at a time; concurrent * multi-worker calls from different threads are safe but serialize against - * each other. + * each other. Not re-entrant: calling this again with worker_cap > 1 from + * inside @p process_board of another multi-worker run deadlocks (the inner + * call blocks on the pool mutex while the outer run waits for that worker). */ auto parallel_all_boards_n( int count, diff --git a/specs/system-concurrency.md b/specs/system-concurrency.md index b2e18771..a42fb7d9 100644 --- a/specs/system-concurrency.md +++ b/specs/system-concurrency.md @@ -1,7 +1,7 @@ --- capability: system-concurrency owners: [system] -last-updated: 2026-07-18 +last-updated: 2026-07-23 --- # System & Concurrency @@ -40,7 +40,10 @@ place so the search and API layers stay portable. It is internal — not part of return `RETURN_NO_FAULT` (1) on success. The function returns the **first non-success code** encountered (or `RETURN_NO_FAULT`). Multi-worker runs use a process-local persistent thread pool (grow-only) so consecutive calls reuse OS - threads; `workers == 1` stays on the calling thread. Guarded by + threads; `workers == 1` stays on the calling thread. Concurrent multi-worker + callers serialize on the pool, but the API is **not re-entrant** from inside + `process_board` of another multi-worker run (that nests a second + `run_mu_` acquisition on a pool worker and deadlocks). Guarded by `concurrency_validation_test` and `parallel_boards_test`. - **Result equivalence across thread counts is an invariant.** Solving the same boards single-threaded and multi-threaded must produce identical results;