Rayon Solver-Parallelism Lab Report¶
ergo epic RAYPAR (7MKIR7). Authors: offline harness + 80/40-path capture corpus.
TL;DR¶
The production drain’s 4.21/8 achieved parallelism is not caused by rayon overhead, per-item diagnostics, or memory bandwidth. It is caused by rayon’s work-stealing scheduler failing to load-balance under extreme path-cost skew. An LPT (longest-processing-time) static partition over the same 8 threads, same solver code, same captures achieves 7.80/8 efficiency (97%) vs rayon’s 4.91/8 (61%) — a 35% wall-time reduction with zero solver changes.
The single highest-impact intervention (RAYPAR-T3) is to replace the per-item
rayon par_iter fan-out with an LPT-pre-balanced scoped-thread partition,
using a resolve-time cost proxy (e.g. total word-boundary-price count per hop)
to bin paths before the solve.
1. Method¶
Harness¶
degenbot-solvers/examples/rayon_scale_probe.rs reconstructs production
ResolvedMixedPath inputs (V3 CL hops carrying the production
build_cl_word_profiles + build_cl_crossing_table precomputes) from the
committed heavy-CL capture fixture (tests/fixtures/heavy_cl_solve_captures.jsonl,
420 paths / 87 MB) and re-runs the production solver entry
mixed::solve_path_with_min_profit under controlled thread counts.
The harness sweeps:
Thread counts: {1, 2, 4, 8} via per-pool
ThreadPoolBuilder::num_threads.Closure variants: bare solve vs production-diagnostics (span enter + 8 relaxed atomics + K-slowest heap + walk/gate stat reset-take) — to isolate per-item closure overhead.
Scheduling variants: rayon
par_iter(work-stealing) vsstd::thread::scopestatic partition (contiguous vs LPT-balanced bins).Control experiment: pure-CPU spin workload through the same rayon pools — maps the machine’s scheduling baseline (no solver, no memory traffic).
Golden-lite gate¶
Each reconstructed path is solved once serially and its profit compared to the
recorded capture golden within PROFIT_EPS = 100,000 wei. On the 40-path
validation subset: 14/14 golden-bearing paths matched, 0 excluded.
2. Results¶
2.1 80-path corpus (bare solve, release build)¶
label |
threads |
wall_s |
sum_item_s |
efficiency |
item_med_ms |
|---|---|---|---|---|---|
serial-median |
1 |
147.5 |
147.5 |
1.00 |
91.6 |
rayon-bare |
4 |
41.0 |
154.8 |
3.77/4 |
95.6 |
rayon-bare |
8 |
32.6 |
159.9 |
4.91/8 |
98.9 |
std-lpt |
4 |
39.1 |
156.0 |
3.99/4 |
100.4 |
std-lpt |
8 |
21.1 |
164.7 |
7.80/8 |
99.3 |
std-contig |
8 |
45.4 |
157.3 |
3.46/8 |
102.8 |
Top-10 per-path medians (us, path_id): four paths at ~18s (all path 501 across different blocks), three at ~7.3s (path 500), three at ~6.6s (path 400). Top-8 share = 60% of total serial work. max_item = 18.1s.
2.2 40-path corpus (full variant sweep)¶
label |
threads |
wall_s |
efficiency |
|---|---|---|---|
rayon-bare |
1 |
54.6 |
1.00 |
rayon-bare |
4 |
17.7 |
3.25/4 |
rayon-bare |
8 |
17.6 |
3.31/8 |
rayon-diag |
8 |
17.7 |
3.32/8 |
std-lpt |
8 |
17.4 |
3.34/8 |
control-spin |
8 |
0.011 |
7.31/8 |
2.3 Production cross-check (KGXFT7 gate-on run, 50 drains)¶
Achieved parallelism: 4.21/8 (solve.cpu_us = 1,380s vs phase_us = 328s).
Per-drain efficiency spread: 2.08–7.46, median 4.74.
Heaviest drain: 71s CPU in 16.3s wall on 2,529 paths.
3. Attribution¶
Hypotheses tested:¶
Hypothesis |
Verdict |
Evidence |
|---|---|---|
Rayon scheduling overhead |
Falsified |
Control spin scales to 7.31/8 (near-linear). Rayon provides no friction for uniform work. |
Per-item diagnostics overhead |
Falsified |
diag vs bare: efficiency 3.32 vs 3.31 (statistical zero). Atomics, span enter, heap = negligible. |
Memory-bandwidth saturation |
Partially true (minor) |
Per-item median rises 91.6ms→98.9ms (1→8 threads) = +8%. Cannot explain 3.3 vs 8.0 gap. |
Work-stealing load imbalance under skew |
CONFIRMED — dominant cause |
LPT static partition at 8 threads: 7.80/8 vs rayon 4.91/8. Same threads, same solver, same bandwidth. 35% wall reduction. |
Root cause¶
The heavy-CL capture workload has extreme cost skew: the top 8 of 80 paths (10%) account for 60% of total CPU. Path 501 alone costs ~18 seconds — longer than the ideal 8-thread makespan for 72 of the 80 paths.
Rayon’s work-stealing splits work in half lazily; when a worker steals, it takes the easy half (shallow on its work queue). The heavy items stay on the original worker’s queue and, being unsplittable (one path = one rayon task), they serialize the tail: 7 threads sit idle while one grinds through an 18s path.
LPT pre-balances: sort paths by descending cost, greedily assign each to the least-loaded bin. With 8 bins, the four 18s paths go to 4 different threads; the remaining 76 paths fill the gaps. No thread is left idle with an unsplittable giant while others wait.
Why production shows 4.21 (not 4.91)¶
Production drains average ~284 paths (vs the 80-path harness), and the closure adds span enter + atomics + heap + capture check + profit-envelope gate. The larger item count provides more stealing opportunities (partially compensating for the scheduling deficiency), but the fundamental skew — a handful of multi-second paths per drain — remains. The production K-slowest heap names these paths every drain; they are the same shapes as path 501 in the harness.
4. Intervention ranking¶
# |
Intervention |
Predicted wall impact |
Complexity |
Risk |
|---|---|---|---|---|
1 |
LPT static partition (replace rayon |
−35% solve wall at 8 threads |
Medium (new dispatch path + cost proxy) |
Low (cutover gate, A/B against rayon) |
2 |
Solver-side CPU reduction on path 501-class paths (the 18s giants) |
Multiplies through all threads at any efficiency |
High (solver math) |
Medium |
3 |
Thread-count tuning vs 8-CPU cgroup quota |
Marginal |
Trivial |
Low |
4 |
Thread-local accumulators replacing per-item atomics |
<1% (diag overhead is noise) |
Low |
Low |
Recommended: T3 implements #1. Cost proxy for LPT binning at resolve time:
∑ word_boundary_prices.len() across the path’s CL hops (available without
solving, correlates with walk combinatorics). The per-drain solve then uses
std::thread::scope with 8 LPT bins instead of par_iter.
5. Environment¶
Host: AMD Ryzen 9 5900X 12-core / 24-thread.
cgroup:
cpu.max = 800000 100000→ 8 CPUs quota.Rayon pool (production): 8 threads (named
degenbot-solve-{i}).std::thread::available_parallelism()= 8 (cgroup-aware).
6. Artifacts¶
Harness:
rust/crates/engine/degenbot-solvers/examples/rayon_scale_probe.rsFixture:
rust/crates/engine/degenbot-solvers/tests/fixtures/heavy_cl_solve_captures.jsonlRaw CSV:
/tmp/raypar80.csv(80-path), smoke run inline (40-path).ergo: epic 7MKIR7, task 5A3C2P (harness), task QXEVGN (this report).