Skip to content

Improve parallelism by solving most difficult deals first#216

Open
tameware wants to merge 11 commits into
dds-bridge:developfrom
tameware:opus-improve-parallelism
Open

Improve parallelism by solving most difficult deals first#216
tameware wants to merge 11 commits into
dds-bridge:developfrom
tameware:opus-improve-parallelism

Conversation

@tameware

Copy link
Copy Markdown
Collaborator
$ time ./benchmark.sh --branch opus-two-percent --branch opus-improve-parallelism --repeats 5 --max-deals 10000
Building dtest from 'opus-two-percent'...
Building dtest from 'opus-improve-parallelism'...
Restoring 'benchmark'...
DDS dtest benchmark
===================
branch:      branch 'opus-two-percent' (/var/folders/12/xtx6dlwd0mdcxkspvmsszsrc0000gn/T//dds-dtest-branch.Cf0Cly)
compare:     branch 'opus-improve-parallelism' (/var/folders/12/xtx6dlwd0mdcxkspvmsszsrc0000gn/T//dds-dtest-compare.gwQNLJ)
details:     off (summary only)
run order:   interleaved branch, compare
epsilon:     0.5%
hands dir:   /Users/adamw/src/dds/hands
max_deals:   10000
files:       list10000.txt list1000.txt list100.txt list10.txt list1.txt
git branch:  benchmark
repeats:     5


Summary (avg user ms)
==============================================================================
solver file          opus-improve opus-two-per cmp/branch note
------ ------------- ------------ ------------ ---------- ---------------
solve  list10000.txt         2.67         2.63      1.01x opus-two-percent faster
solve  list1000.txt          2.12         2.09      1.01x opus-two-percent faster
solve  list100.txt           2.06         2.05      1.00x equal
solve  list10.txt            4.20         4.30      0.98x opus-improve-parallelism faster
solve  list1.txt            12.00        12.00      1.00x equal
calc   list10000.txt         9.68        10.25      0.94x opus-improve-parallelism faster
calc   list1000.txt          9.63        10.64      0.91x opus-improve-parallelism faster
calc   list100.txt           8.22         8.70      0.95x opus-improve-parallelism faster
calc   list10.txt           14.44        14.38      1.00x equal
calc   list1.txt            38.20        38.00      1.01x opus-two-percent faster
------ ------------- ------------ ------------ ---------- ---------------
TOTAL  elapsed (s)         684.60       716.16      0.96x opus-improve-parallelism faster

Completed 100 runs (100 expected).

tameware and others added 5 commits June 27, 2026 18:51
The heuristic extraction refactor changed weight_alloc_trump_void1's
first branch from `lead_suit == trump` to `suit == trump`. Since that
is exhaustive with the following `else if (suit != trump)`, the three
ruffing branches (using the `24 - rank + ...` formula) became dead
code, and trump ruffs were scored with side-suit discard weights
instead. This mis-ordered ruffs, costing alpha-beta cutoffs.

The effect is small for solve but compounds heavily in calc's warm-TT
iterative deepening: calc explored ~34% more nodes than v2.9. Restoring
the original `lead_suit == trump` pitch branch makes the ruffing
branches reachable again and cuts calc time ~25% (gap to v2.9: 1.37x ->
1.02x). Ordering-only change; double-dummy results are unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
Per Copilot.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
The heuristic/quick-tricks refactor introduced static_cast<unsigned char>
wrappers on values that v2.9 used as signed, changing search behavior:

- make_3 / make_3_ctx: winner[]/second_best[] .hand and .rank were cast
  to unsigned char, turning the -1 "no card" sentinel into 255. This broke
  winner[trump].hand == -1 style checks in QuickTricks, losing cutoffs.
- weight_alloc_trump_void2 / _void3: rel_rank[aggr[suit]][...] indexed
  through static_cast<unsigned char>(aggr[suit]), truncating the 13-bit
  aggregate holding to 8 bits and reading the wrong rel_rank row.
- QuickTricksPartnerHand{Trump,NT}: bit_map_rank index cast the signed
  rank through unsigned char.

With these reverted to v2.9's signed handling, the per-move-generation
ordering trace now matches v2.9 exactly (0 divergences on list1), closing
the residual calc gap to parity. Ordering/pruning-only change; double-dummy
results are unchanged and all library tests pass.

Co-authored-by: Cursor <cursoragent@cursor.com>
The parallel board loop handed boards out in index order via an atomic
counter, so a hard board picked near the end left one worker running long
while the others sat idle. Hand out the hardest boards first (longest-
processing-time-first) so the tail consists of cheap boards.

parallel_all_boards_n gains an optional dispatch-order permutation: workers
still pull from the same atomic counter, but the slot is mapped through the
order before becoming a board number, so only the dispatch sequence changes
and result placement is unaffected. The solve path passes no order and is
unchanged.

calc estimates per-deal difficulty with a cheap, trump-independent
structural proxy (deal_fanout, mirroring Scheduler::Fanout) and sorts board
indices by descending difficulty before dispatch.

calc list1000 -n18: ~11.0s -> ~9.6s wall (~13%), user CPU unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
CalcDDtableN builds one board per strain for a single deal. deal_fanout is
trump-independent, so all boards share one fanout and the difficulty sort is a
pure no-op there. Gate the sort behind a difficulty_sort flag (default on for
batch CalcAllTablesN) and disable it for the single-deal path.

Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to improve overall throughput for batch calculations by reducing “tail latency” in parallel workloads: it estimates deal difficulty cheaply, sorts boards hardest-first, and adds an optional dispatch-order mechanism to the parallel board runner.

Changes:

  • Extend parallel_all_boards_n() to optionally dispatch boards in a caller-provided order.
  • Add a cheap per-deal “fanout” estimate and use it to stable-sort batch calc boards hardest-first before parallel execution.
  • Simplify/remove several legacy static_cast<unsigned char>(...) conversions in solver/heuristic code paths.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
library/src/system/parallel_boards.hpp Adds optional order parameter to control dispatch order (hardest-first, etc.).
library/src/system/parallel_boards.cpp Implements ordered dispatch via slot→board mapping.
library/src/calc_tables.cpp Computes per-deal difficulty estimate and dispatches hardest boards first for batch calc.
library/src/heuristic_sorting/heuristic_sorting.cpp Cleans up heuristic code (including rel-rank indexing casts) and adjusts some void/trump logic.
library/src/quick_tricks.cpp Removes redundant casts when indexing with abs_rank[..].rank.
library/src/ab_search.cpp Removes redundant casts when copying abs_rank winner/second-best into Pos.

Comment on lines +45 to +51
// Map a dispatch slot to the board number to process. With an order, hand out
// boards in that sequence (e.g. hardest first); otherwise in index order.
const bool use_order =
(order != nullptr && static_cast<int>(order->size()) == count);
auto board_of = [&](const int slot) -> int {
return use_order ? (*order)[static_cast<unsigned>(slot)] : slot;
};
Only honor the optional dispatch order when it is a valid permutation
of [0, count: each element in range and unique. A malformed order
(duplicates or out-of-range values) now falls back to index order,
preventing invalid board indices from reaching process_board.
EOF
)

Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment on lines +45 to +51
// Map a dispatch slot to the board number to process. With an order, hand out
// boards in that sequence (e.g. hardest first); otherwise in index order.
const bool use_order =
(order != nullptr && static_cast<int>(order->size()) == count);
auto board_of = [&](const int slot) -> int {
return use_order ? (*order)[static_cast<unsigned>(slot)] : slot;
};

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

@tameware tameware requested a review from zzcgumn June 29, 2026 12:56
@tameware tameware marked this pull request as ready for review June 29, 2026 12:56
static auto is_permutation_of_range(
const std::vector<int>& order,
const int count) -> bool
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have std::set_intersection and std::set_difference available in . You can use std::ranges::views::iota to generate a range from [0..count].

Standard library functions are at least expected to be faster and reduce the amount of code we need to write.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice find! Fixed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or not…

Copilot reports that the change is less efficient:

is_permutation_of_range() currently re-sorts the order vector and builds an intermediate common vector via set_intersection. This is O(n log n) plus extra allocations and will run on every parallel_all_boards_n() invocation that supplies an order (e.g. batch calc), adding overhead in a performance-sensitive path. You can validate a permutation of [0, count) in O(n) without sorting by tracking seen indices and checking bounds/duplicates.

Thoughts, @zzcgumn?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've had another look. I agree with Copilot - I prefer the original implementation:

static auto is_permutation_of_range(
  const std::vector<int>& order,
  const int count) -> bool
{
  std::vector<char> seen(static_cast<unsigned>(count), 0);
  for (const int v : order)
  {
    if (v < 0 || v >= count || seen[static_cast<unsigned>(v)])
      return false;
    seen[static_cast<unsigned>(v)] = 1;
  }
  return true;
}

seen could equally well be std::vector<unsigned char>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set_difference makes sense if you need to know what the difference is. The use case here doesn't need to prove as hard a problem.

Use std::ranges::set_intersection and std::views::iota instead of a
hand-rolled seen bitmap in is_permutation_of_range.

Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment on lines +36 to +51
static auto is_permutation_of_range(
const std::vector<int>& order,
const int count) -> bool
{
std::vector<int> sorted(order);
std::ranges::sort(sorted);

const auto expected = std::views::iota(0, count);

std::vector<int> common;
common.reserve(static_cast<unsigned>(count));
std::ranges::set_intersection(
sorted, expected, std::back_inserter(common));

return static_cast<int>(common.size()) == count;
}
Emscripten's libc++ lacks C++20 ranges; use iterator-based sort, iota, and set_intersection instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
@tameware tameware marked this pull request as draft July 3, 2026 13:54
@tameware tameware requested a review from Copilot July 3, 2026 13:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment on lines +32 to 37
* @param order Optional dispatch order: a permutation of [0, count) giving the
* sequence in which board numbers are handed out (e.g. hardest first to
* shorten the tail). When null/empty, boards are dispatched in index
* order. Only the dispatch order changes; @p process_board still receives
* the real board number, so result placement is unaffected.
* @return First non-success code from @p process_board, or RETURN_NO_FAULT.
Comment thread library/src/calc_tables.cpp Outdated
tameware and others added 2 commits July 3, 2026 15:04
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Scheduler: :Fanout is private; move the fanout logic to a shared deal_fanout() helper so calc_tables can use it without accessing the scheduler instance.
Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment thread library/src/calc_tables.cpp
Comment on lines +36 to +54
static auto is_permutation_of_range(
const std::vector<int>& order,
const int count) -> bool
{
std::vector<int> sorted(order);
std::sort(sorted.begin(), sorted.end());

std::vector<int> expected(static_cast<unsigned>(count));
std::iota(expected.begin(), expected.end(), 0);

std::vector<int> common;
common.reserve(static_cast<unsigned>(count));
std::set_intersection(
sorted.begin(), sorted.end(),
expected.begin(), expected.end(),
std::back_inserter(common));

return static_cast<int>(common.size()) == count;
}
@tameware tameware marked this pull request as ready for review July 3, 2026 14:41
#include <solve_board.hpp>
#include <api/solve_board.hpp>
#include <solver_if.hpp>
#include <lookup_tables/lookup_tables.hpp>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should probably pull and compile, but I don't see what's being used from this header. fanout comes from scheduler.hpp and all the other called functions appear to be in std:: namespace. What did I miss?


int res = calc_all_boards_n(&bo, &solved, maxThreads);
// Single deal: all boards share one deal, so hardest-first sorting is a no-op.
int res = calc_all_boards_n(&bo, &solved, maxThreads, /*difficulty_sort=*/false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

trivial

Inline variable-name comment isn't necessary.

Comment on lines +761 to +766
{
return deal_fanout(dl);
}


auto deal_fanout(const Deal& dl) -> int

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have any skin in this, but why not move Fanout from private to public instead?

What are the advantages of exposing the function this way? (Ideological purity being a perfectly fine answer.)

Comment thread library/src/ab_search.cpp
Comment on lines -881 to 886
posPoint->winner[st].rank = static_cast<unsigned char>(thrp->rel[aggr].abs_rank[1][st].rank);
posPoint->winner[st].hand = static_cast<unsigned char>(thrp->rel[aggr].abs_rank[1][st].hand);
posPoint->second_best[st].rank = static_cast<unsigned char>(thrp->rel[aggr].abs_rank[2][st].rank);
posPoint->second_best[st].hand = static_cast<unsigned char>(thrp->rel[aggr].abs_rank[2][st].hand);
posPoint->winner[st].rank = thrp->rel[aggr].abs_rank[1][st].rank;
posPoint->winner[st].hand = thrp->rel[aggr].abs_rank[1][st].hand;
posPoint->second_best[st].rank = thrp->rel[aggr].abs_rank[2][st].rank;
posPoint->second_best[st].hand = thrp->rel[aggr].abs_rank[2][st].hand;

}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might as well fix the indenting problem at the same time.

@tameware

tameware commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

When reading doc/2014-11 Performance and Benchmarking.docx I learned that the Scheduler should be doing this already. I asked Cursor about it. It replied:

The 2014 doc is right about what the Scheduler was designed to do, but at the time of b08925f that design was not actually driving parallel dispatch on the calc path. The commit worked because it reintroduced hardest-first ordering in the only place that mattered for batch calc.

What the commit changed

Before b08925f, parallel calc used parallel_all_boards_n, which hands out work with an atomic counter in board index order (0, 1, 2, …):

      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);

b08925f added an optional permutation so slot 0, 1, 2… map to board numbers sorted by descending deal_fanout (same structural proxy as Scheduler::Fanout).

Why that improved wall time (~13%) without changing CPU

This is classic longest-processing-time-first (LPT) scheduling for a thread pool.

The 2014 benchmarking doc describes exactly this failure mode: solve times are highly skewed (roughly Weibull), so with work-stealing in index order one thread can draw a very hard board near the end and sit alone while the others idle. Wall time is dominated by that tail, not by total CPU.

The commit’s benchmark confirms that story:

  • Wall time: ~11.0s → ~9.6s (~13%)
  • User CPU: unchanged

So no less work was done — boards were just started in a better order so threads finish closer together.

“But the Scheduler already sorts hardest first…”

Yes — in the v2.8 design, documented in the 2014 paper. The Scheduler:

  1. Groups boards (strain + hash, with repeat detection)
  2. Predicts difficulty per group via fanout (SortCalc / SortSolve)
  3. Dispatches through GetNumber(), which hands out hardest groups first

That old path looked like:

st = scheduler.GetNumber(thr_id);
index = st.number;

But on the main line at b08925f:

Path Scheduler sort computed? Used for parallel dispatch?
calc (CalcAllTables) No — calc_tables.cpp never called RegisterRun or GetNumber No — index order only
solve (SolveAllBoards) Yes — RegisterRun still runs No — GetNumber was removed when parallelization moved to parallel_all_boards_n (May 2026)

GetNumber() still exists in scheduler.cpp but is not called anywhere on the main branch. The Scheduler’s sort is effectively dead code for dispatch today.

There was a side branch (a71b01a, “Restore scheduler-based calc threading…”) that wired calc back through GetNumber, but it was not merged into the line that contains b08925f.

So the doc describes the intended v2.8 behavior; b08925f fixes a regression gap where parallel calc had been running in naive index order.

How b08925f differs from the full Scheduler

The commit is a lighter-weight version of the same idea:

  • Per-board fanout sort, not per-group scheduling with strain/hash buckets
  • No repeat-hand dedup via scheduler groups (calc comment says repeats are always a direct copy anyway)
  • Trump-independent fanout only — reasonable for batch calc where many distinct deals dominate
  • Explicitly skipped for single-deal CalcDDtable (all boards share one deal → same fanout → sort is a no-op)

The full Scheduler’s SortCalc uses the same fanout concept but with NT/suit-specific scaling curves; the commit uses the raw fanout score, which is enough to fix the tail problem.

Bottom line

b08925f helped because parallel calc had lost hardest-first dispatch somewhere between the v2.8 Scheduler design and the newer parallel_all_boards_n helper. The 2014 doc is describing behavior that used to be wired up via GetNumber; b08925f restores the essential part of that benefit (hard boards first) for calc without reintegrating the full Scheduler machinery.

@tameware

tameware commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

As far as I can tell, a71b01a, “Restore scheduler-based calc threading…”, was never merged and is not part of a current PR. We should merge either this PR or one containing a71b01a, not both.

@tameware

tameware commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator Author

I see now that a71b01a was mine! I think I abandoned it because I mistakenly thought it degraded performance. That was before I'd vibe-coded benchmark.sh.

I cherrypicked a71b01a on top of the develop head and ran benchmark.sh, averaging over three runs. It shows timing close between the two approaches:

Summary (avg user ms)
==============================================================================
solver file               develop calc-run-thr opus-improve
------ ------------- ------------ ------------ ------------
solve  list1000.txt          1.54         1.56         1.55
solve  list100.txt           1.99         1.94         1.93
solve  list10.txt            4.27         4.17         4.30
solve  list1.txt            12.67        13.33        12.00
calc   list1000.txt         11.21         9.68        10.07
calc   list100.txt           9.24         8.23         8.67
calc   list10.txt           16.10        14.97        14.90
calc   list1.txt            41.67        41.00        39.33
------ ------------- ------------ ------------ ------------
TOTAL  elapsed (s)          42.89        38.14        39.29

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants