docs(findings): record the #147 levers that did not pay, and the benchmark that showed it - #149
Conversation
Two questions came out of the lever A/D A/B runs that the phase timers cannot separate. Where the +12% user time comes from. Both levers collapse sys by 70-94% and raise user by 11-16%, at different element widths, so it tracks reusing the allocation rather than its size. A rayon spin-wait explanation is falsified by accounting and recorded as such in the module doc: soil ITS2 R2 has only 838 core-seconds of CPU outside the map's summed busy time (8,056 total vs 7,218 busy) while lever D's user rise alone is 1,211, and 48.7% occupancy says workers sleep through the serial phases. So the rise is inside busy. collect_fresh vs collect_reuse tests the remaining mechanism: freshly-mmapped pages are zeroed by the kernel immediately before the worker writes them and are therefore cache-warm, while a reused buffer's lines are cold and dirty and every store pays a read-for-ownership plus a writeback. What the map loses to load imbalance. Production reports 86-90% map parallel efficiency, which on ITS2 R2 is 1,166 core-seconds lost inside the parallel region -- 4.6 ms per thread per call, far too long to be rayon's spin-then-sleep. join_uniform and join_skewed do the SAME total work per call and differ only in how it is distributed, so the gap is imbalance rather than framework overhead; an arm that simply did less work would have measured work instead, which is how the first draft of this benchmark was wrong. --base defaults to 160 iterations/item so join_skewed lands at ~2,200 core-ms/call against production's 2,086; --base 0 strips the compute for the fork/join/wake floor. Defaults to the ITS2 R2 pool (939,532 raws) so the 48 B vector is 45.1 MB, above glibc's 32 MB mmap cap -- dropping --raws below ~700,000 should converge the fresh/reuse arms and is a positive control on the mechanism. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#147) Makes parallel_overhead usable on a node shared with another job, which is what is actually available. Arms now run round-robin, one round each, rather than as sequential blocks. Contention drifts over the life of a run, so blocked arms sample different node states and the between-arm difference picks up whatever the neighbour was doing during each block. Interleaved, every arm sees the same drift, and since only the matched pairs carry the argument the common component cancels. Per-round times are kept rather than summed, and the estimator is the minimum: the least-disturbed round is the closest to the uncontended truth, while a mean is pulled around by the neighbour. Median and max print alongside, and a med/min line makes contention visible instead of silent -- near 1.00 is a quiet node, a wide spread is the signal to distrust the run rather than to reason about small differences in it. Neither trick rescues an oversubscribed node, and the module doc says so: if the neighbour is using cores this run wants, join_skewed measures the neighbour's stragglers too and the imbalance number is not meaningful. Also adds one untimed warm-up round per arm so first-touch and allocator warm-up do not land on a measured round. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
) The first cluster run reported imbalance at -3.3%, i.e. none. That was an artifact of my own parameters, not a result. The skew marked every 32nd index heavy while the task grain is with_max_len(32), so every task contained exactly one heavy item and mean task cost equalled max task cost by construction. A skew that is uniform at the granularity of a task is not a skew, and the arm could not have found imbalance if there were any. The pattern was also wrong for production regardless of the grain collision: raws are abundance-sorted, so the comparisons that clear the k-mer screen and pay a full alignment are concentrated at the FRONT of the index range rather than sprinkled evenly. The skew is now positional -- the first --heavy-frac of indices are heavy -- which makes whole tasks heavy and gives work-stealing something real to rebalance. Defaults now follow production rather than round numbers: --skew 14 is the measured aligner/screen cost ratio (17,759 vs 1,282 ns/comp on soil 16S R1) and --heavy-frac 0.008 is ITS2's pass rate (16S is 0.045). --base rises 160 -> 430 so the mean stays 474 iters/item and join_uniform still lands near production's 2,086 core-ms/call. The header now prints the mean and the heavy-task ratio so a repeat of this mistake is visible in the output. The allocation arms are unaffected: fresh-vs-reuse is symmetric in the work function, and that pair's result stands -- 8.2% apart at 48 B (45.1 MB, over glibc's 32 MB cap) and 0.1% apart at 16 B (15.0 MB, under it), which is the positive control firing exactly as predicted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng (#147) The min estimator was chosen to survive a noisy neighbour, and it is right for that. It is wrong for collect_fresh, whose spread is intrinsic rather than external: the variance is glibc's allocator state -- whether a round recycled rather than remapped -- so taking the minimum systematically selects the rounds where the thing under test did not happen. That arm runs at med/min 1.06-1.10 in every cluster run so far while every other arm sits at 1.00-1.01. The consequence was a wrong reading, not a subtle one. On min the allocation cost appeared to SHRINK as the vector grew -- 2.5 ms at 45.1 MB and 1.3 ms at 58.8 MB -- and I read that spread as a pool-size effect. On median it is 5.04 and 5.10 ms/call, essentially identical, which is what a page-fault cost should look like. Both estimators now print for every comparison, plus the 48 B gap in absolute ms/call, so the disagreement is visible rather than a matter of which line someone happened to read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes out #147's second half on the findings page. The allocation levers (A: 16 B element; D: reused scratch buffer) are recorded as built, measured, and NOT merged. The mechanism is confirmed -- glibc's 32 MB mmap cap, ~5 ms/call, cross-checked three ways -- and removing it returns nothing: sys falls 94% while user rises 16% and wall gets worse. The section says do not retry either on the strength of the sys number, since that time was never on the critical path. Map load imbalance is recorded as falsified at +-0.1% on both pool shapes, which retires an earlier claim on this page that read the 86-90% map parallel efficiency as 1,166 core-seconds of idle. busy sums timers taken inside the map closure and never measured rayon's task dispatch or the collect's stores, so the gap is mostly unmeasured work. Adds a section on the three instrument errors behind these nulls, because all three produced clean-looking results: a rayon hypothesis killed by accounting rather than node time, a skew model whose period matched the task grain so mean task cost equalled max by construction, and a min-of-rounds estimator applied to the one arm whose variance was intrinsic rather than contention. The rule they share is that a null is evidence only once the instrument is shown capable of returning something else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…residual A verbose B-vs-D pair on soil ITS2 localises the +16% user time: busy rises +16.0% on R2 against a +16.4% user rise measured non-verbose, so the extra CPU is inside the map closure. That falsifies the read-for-ownership account this page carried as the "leading" explanation -- or rather shows the test was built wrong, which amounts to the same thing. busy times the closure body; the write into the destination vector happens in rayon's collect after the closure returns, so a cost on the destination store could never appear in busy. The confirming prediction was mapped onto an instrument that could not observe it. What survives is narrower and more general: reused memory is slower than freshly-faulted memory for this workload, across two independent implementations and two buffer sizes (lever A reuses 15 MB via glibc's arena for +11%, lever D reuses 45 MB explicitly for +16%). Size modulates the penalty but does not cause it. Worth recording beyond this issue, because hoisting an allocation out of a loop is a routine instinct and here it costs more than it saves. The LLC-contention candidate is labelled a hypothesis and the instrument named (hardware counters, not another timer). Also resolves half the map-efficiency residual: removing the allocation raises map parallel efficiency 87-88% -> 93-94% with busy unchanged or higher, so ~6 of the missing 10-14 points was allocation and page-fault work inside map wall but outside the per-item timers. Adds a section on run non-uniformity from the new progress lines (#150). Occupancy climbs 22 -> 42 effective cores of 64 over one run, so the end-of-run mean of ~29 describes no window of it. The ramp is the serial fraction shrinking, not the workload changing -- align falls six-fold and rises again while occupancy climbs straight through. Both "effective cores" and "map parallel efficiency" have been used to rank levers and both are means over a run that spans nearly a factor of two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Updated with the verbose B-vs-D run (soil ITS2, two reps, 180/180 byte-identical). Corrected: the read-for-ownership account is falsified. What replaces it is narrower and more general: reused memory is slower than Half the map-efficiency residual is now attributed. Removing the allocation New section: the run is not uniform. Per-window progress lines (#150) show D's verdict is unchanged: −3.9% on R1, +6.5% on R2, not merged. |
Docs and diagnostic tooling for the second half of #147, now closed. No
production code changes —
examples/parallel_overhead.rsplus the findingswrite-up.
#148 merged the lever that paid (
e_minmaxinto a dense array, −20 to −21% ofrun_dada). This records the two that did not, and one hypothesis that turnedout to be false — plus the benchmark that settled both, since the findings page
cites it.
What the write-up records
The allocation levers are rejected, with the mechanism confirmed. Removing
b_compare_parallel's per-call allocation — either by shrinking the elementunder glibc's 32 MB
mmapcap (lever A) or by reusing one buffer (lever D) —collapses
sysby 70–94% and returns nothing: wall +2.5%,user+16%, totalCPU up.
parallel_overheadisolates the cost at ~5 ms/call above the cap andfinds no consistent difference below it, and the arithmetic closes against
production's
sysfigure. That time simply was never on the critical path.The section says explicitly not to retry either lever on the strength of the
sysnumber, and leaves both branches unmerged for whoever wants to explain theuserrise with hardware counters.Load imbalance in the map is falsified. The 86–90% parallel efficiency
looked like tail stragglers; work-matched uniform-vs-skewed arms measure ±0.1%
on both pool shapes. This retires a claim on the existing page that read that
figure as 1,166 core-seconds of idle —
busysums timers taken inside the mapclosure and never measured rayon's task dispatch or the collect's stores, so the
gap is mostly unmeasured work.
The part worth reviewing
A section on three instrument errors, because all three returned clean-looking
nulls:
the run had 838 core-seconds of CPU outside busy against a 1,211-second rise
to explain.
max by construction. It reported "no imbalance" and would have done so
whatever the truth was.
intrinsic (allocator state) rather than contention, which made the
allocation cost look like it shrank as the vector grew. On medians it is
5.04 vs 5.10 ms — flat, as a page-fault cost should be.
The rule they share, and the reason the section exists: a null is evidence
only once the instrument has been shown capable of returning something else.
The benchmark
examples/parallel_overheadruns six arms as two matched pairs — fresh vsreused destination, and work-matched uniform vs skewed cost. Defaults follow
production rather than round numbers (
--skew 14is the measuredaligner/screen ratio,
--heavy-frac 0.008is ITS2's pass rate,--base 430puts the arms at ~2,100 core-ms/call against production's 2,086).
It is built to survive a shared node: arms interleave round-robin so contention
drift is common-mode, and a
med/minline reports how quiet the node was. Bothmin and median print for every comparison, with a comment explaining which to
read and why they disagree.
mkdocs build --strictclean; clippy clean.