Skip to content

docs(findings): record the #147 levers that did not pay, and the benchmark that showed it - #149

Merged
cjfields merged 6 commits into
mainfrom
docs/store-scan-followup
Aug 18, 2026
Merged

docs(findings): record the #147 levers that did not pay, and the benchmark that showed it#149
cjfields merged 6 commits into
mainfrom
docs/store-scan-followup

Conversation

@cjfields

Copy link
Copy Markdown
Member

Docs and diagnostic tooling for the second half of #147, now closed. No
production code changes
examples/parallel_overhead.rs plus the findings
write-up.

#148 merged the lever that paid (e_minmax into a dense array, −20 to −21% of
run_dada). This records the two that did not, and one hypothesis that turned
out 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 element
under glibc's 32 MB mmap cap (lever A) or by reusing one buffer (lever D) —
collapses sys by 70–94% and returns nothing: wall +2.5%, user +16%, total
CPU up. parallel_overhead isolates the cost at ~5 ms/call above the cap and
finds no consistent difference below it, and the arithmetic closes against
production's sys figure. That time simply was never on the critical path.

The section says explicitly not to retry either lever on the strength of the
sys number, and leaves both branches unmerged for whoever wants to explain the
user rise 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 — 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.

The part worth reviewing

A section on three instrument errors, because all three returned clean-looking
nulls:

  1. A rayon spin-wait hypothesis killed by accounting rather than node time
    the run had 838 core-seconds of CPU outside busy against a 1,211-second rise
    to explain.
  2. A skew model whose period matched the task grain, so mean task cost equalled
    max by construction. It reported "no imbalance" and would have done so
    whatever the truth was.
  3. A per-round-minimum estimator applied to the one arm whose variance is
    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_overhead runs six arms as two matched pairs — fresh vs
reused destination, and work-matched uniform vs skewed cost. Defaults follow
production rather than round numbers (--skew 14 is the measured
aligner/screen ratio, --heavy-frac 0.008 is ITS2's pass rate, --base 430
puts 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/min line reports how quiet the node was. Both
min and median print for every comparison, with a comment explaining which to
read and why they disagree.

mkdocs build --strict clean; clippy clean.

cjfields and others added 6 commits August 18, 2026 09:56
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>
@cjfields

Copy link
Copy Markdown
Member Author

Updated with the verbose B-vs-D run (soil ITS2, two reps, 180/180 byte-identical).

Corrected: the read-for-ownership account is falsified. busy rises +16.0% on
R2, matching the +16.4% user rise measured non-verbose — so the cost is inside
the map closure. But busy times the closure body, and the write into the
destination vector happens in rayon's collect after the closure returns, so a cost
on the destination store could never have appeared there. I wrote the confirming
prediction against an instrument that could not observe the thing it predicted.

What replaces it 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, it doesn't cause it.
Worth recording beyond this issue, since 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 with the instrument named (hardware counters, not another
timer).

Half the map-efficiency residual is now attributed. 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,
outside the per-item timers. The remaining ~6 points is still open.

New section: the run is not uniform. Per-window progress lines (#150) show
occupancy climbing 22 → 42 effective cores of 64 over a single 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. This matters for how the project measures:
"effective cores" and "map parallel efficiency" have both been used to rank levers,
and both are means over a run that spans nearly a factor of two.

D's verdict is unchanged: −3.9% on R1, +6.5% on R2, not merged.

@cjfields
cjfields merged commit de587ac into main Aug 18, 2026
7 checks passed
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.

1 participant