Skip to content

Thread sweep: tooling, symmetric --numa, tuning guide, and findings (#152) - #156

Merged
cjfields merged 10 commits into
mainfrom
dev/thread-sweep-analysis
Aug 29, 2026
Merged

Thread sweep: tooling, symmetric --numa, tuning guide, and findings (#152)#156
cjfields merged 10 commits into
mainfrom
dev/thread-sweep-analysis

Conversation

@cjfields

Copy link
Copy Markdown
Member

Tooling and user-facing docs from the #152 thread sweep. No library code
dev/ scripts, the benchmark harness, and two docs pages.

What the measurements found

Two results, with opposite characters, which is what the docs are built around:

Thread count is strongly data-dependent. Four arms, two replicates each,
across 24/48/64/96 threads:

threads 16S R1 16S R2 ITS2 R1 ITS2 R2
24 1072.5 1076.3 221.1 308.5
48 713.8 767.8 194.0 254.9
64 638.9 711.5 198.2 258.2
96 594.3 734.9 203.8 263.2

The best thread count varies two-fold between reads of the same pool, and it
is predicted by a number the verbose log already prints — the share of map time
in the k-mer screen (memory-bound) versus alignment (compute-bound). 50.8% screen
scales to 96; 67.7% peaks at 64; 83–86% peaks at 48 and degrades past it.

Memory placement is not data-dependent. Binding one job to one NUMA domain:

ITS2 R1 ITS2 R2 16S R1 16S R2
interleaved (default) 197.8 259.7 708.1 785.5
bound to one domain 143.9 185.2 528.1 575.0
gain −27% −29% −25% −27%

A 25–29% band across a 35-point gap in screen share. Every phase gains, including
the single-threaded ones (16S: map −27%, store −22%, shuffle −21%, p-update −31%)
— the signature of memory latency, not bandwidth. Two bound jobs give 2.6–2.9×
the throughput
of one default job. All outputs byte-identical.

Contents

dev/analyze_thread_sweep.py — replaces ad-hoc analysis of sweep logs.
Aligns segments by cluster index, never wall time: at the same t=, a
96-thread run and a 48-thread run sit at different cluster positions in a
workload whose serial fraction collapses over the run. Doing that wrong reported
a trend with the sign inverted before it was caught, so the constraint is encoded
rather than remembered. Prints replicate spread beside every mean and warns when
a single-replicate arm cannot distinguish sub-2% differences.

dev/run_concurrency_test.shsolo / sbind / both / split arms.
The sbind arm exists to separate locality from packing, and settled it: one
bound job captures ~92% of what two bound jobs get, so the win is locality and
packing is incidental.

bench_pooled.py --numa {none,interleave,bind} — see below.

docs/tuning-for-your-data.md — the user-facing guide, keyed to workload
character rather than dataset names so it is usable by someone whose data is
neither of ours. Separates the latency question from the throughput one (every
arm buys wall at ~4× the core-seconds, so a queue wants fewer threads and more
jobs). Ends with an explicit list of what has not been tested.

docs/benchmarking.md §6c--numa for benchmark operators.

The part most worth reviewing: --numa symmetry

bench_pooled.py had no placement handling at all — both stacks ran through a
bare subprocess.Popen. Since placement is worth 25–30%, a harness that pins
one stack and not the other is not a harness.
R DADA2 threads the same
algorithm through multithread=, with the same k-mer screen and banded NW.

So the policy is applied in numa_wrap, which every launch routes through, not
at each call site — wrapping the convenient call sites is exactly how an arm gets
missed and the speedup silently inflates by ~25%.

It degrades rather than fails, and never silently:

situation result
no numactl unpinned, warning, recorded numa=none
single NUMA domain unpinned, warning, recorded numa=none
numactl --hardware fails unpinned, warning, recorded numa=none
threads exceed one domain falls back to interleave with a warning

summary.csv records the effective policy alongside the requested one. A run
that asked for bind on a machine without numactl must not be filed as bound —
that is the #145 failure mode, where a silently-resolved gate cost two full A/B
runs. Default is none, so new runs stay comparable to every archived one.

Verified with no numactl present (all policies degrade, numa_wrap is a no-op)
and against a stub reporting 2 domains × 8 CPUs (threads=4 binds, 8 binds with a
saturation note, 12 falls back).

dev/numa_pin.sh is annotated, not changed

It recommends --interleave=all over binding, from a 64-thread PacBio
measurement where binding cost the map 21% — 64 threads bound to a 64-core domain
saturates it. At 48 threads the result reverses on both pools. The default is
left alone because that helper exists for reproducibility, and whether binding
replicates as tightly is unmeasured. But "interleave beats bind" is no longer
safe to read as general, and it now says so where someone would find it.

One consequence stated plainly in the annotation: benchmark numbers gathered
under it are conservative in absolute terms at sub-domain thread counts. A/B
deltas are unaffected — both arms always shared the policy.

Known limitation

filter and remove_primers fan one subprocess per sample via
run_phase_concurrent; under --numa bind they all pile onto domain 0 while
domain 1 idles. It does not affect the denoising comparison (learn-errors and
dada* go through run_step), but those two phases' bound timings should not be
quoted. Tracked in #155 with two proposed fixes.

Caveat carried into the docs

The thread table was measured entirely under interleaving, so those knees may
shift once memory is pinned. Called out in the guide's "what we have not tested"
section rather than left to be discovered.

Full test suite passes; mkdocs build --strict clean.

cjfields and others added 10 commits August 28, 2026 23:16
Replaces the ad-hoc Python that has been pasted at these logs all session, and
encodes the one thing that must not be got wrong.

Segments are aligned by CLUSTER INDEX, never by wall time. At the same t=, a
96-thread run and a 48-thread run sit at different cluster positions, and the
serial fraction collapses over a run -- so fixed wall-clock windows compare
different phases. Doing exactly that produced a confidently reported conclusion
with the sign of the trend inverted before it was caught.

Reports four things:

- run_dada per thread count, with replicate spread beside every mean, and an
  explicit note when an arm has one replicate that differences under ~2% are not
  distinguishable from run-to-run variation.
- The serial block (store+shuffle+bud+p_update), which is invariant in seconds
  and rising in share -- 16S R1 is 208.5s at 48 threads and 207.3s at 96, so map
  work walks toward the Amdahl wall rather than away from it. That is the figure
  to quote when weighing bandwidth work (#28) against serial work (#154).
- Marginal seconds per cluster segment, showing where in the run the benefit or
  penalty lands.
- The map's screen/align split, which predicts the knee.

On that last point the calibration is printed rather than a threshold, because
four arms is not a rule: 50.8% screen still scales at 96, 67.7% turns over near
64, and 83-86% turns over below 48. It predicts WITHIN a pool as well as across
-- 16S's two reads differ by 17 points of screen share and have different knees
-- so it tracks arithmetic intensity, not the dataset. The thread numbers are
specific to the EPYC 7713; the ordering is what travels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two corrections, both to errors the 24-thread ITS2 arm exposed.

ITS2's knee is AT 48 threads, not below it. From 48/64/96 alone the pool
looked monotonically degrading and was reported as "already past the knee
below 48". Adding 24 shows 48 is the peak, beating 24 by 12-17% on wall.
A sweep that does not bracket the peak cannot locate it, and reading a
trend off three points on one side of a maximum is how that happened.

The screen/align split is itself THREAD-DEPENDENT: ITS2 R1 reads 80.0%
screen at 24 threads and 85.8% at 48, because the bandwidth-bound half
degrades faster under contention. The script took the composition from the
lowest thread count in the sweep, so adding 24 to ITS2 silently made its
number incomparable to 16S's (still measured at 48) -- in the same table
that presents them as a calibration. Now pinned via --ref-threads
(default 48), with a warning when that count is absent from a sweep.

The calibration ordering survives both corrections: 50.8% screen -> best
at 96, 67.7% -> 64, 83.2% and 85.8% -> 48.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The #152 sweep says every arm buys wall time at ~4x the core-seconds --
16S R1 goes 1072.5s at 24 threads to 594.3s at 96, 45% parallel efficiency
at the top -- which implies "pack jobs, don't scale threads". Nobody has
measured two jobs sharing a node, so that is currently an inference and
not a result. The two jobs would contend for exactly the memory bandwidth
the k-mer screen is already limited by.

Three arms. The interesting one is NUMA policy, because it may invert an
existing finding: dev/numa_pin.sh documents that binding to one domain is
the wrong choice for a single job, costing the parallel map 21% by forcing
every thread through one node's controllers. With two concurrent jobs on a
2-domain node that reasoning may reverse -- each job gets private
controllers and neither disturbs the other, where interleaving both has
them share every controller. Not predictable from the single-job result,
so both are measured.

Runs the SAME read twice rather than R1+R2. Pairing 16S R1 (713.8s) with
R2 (767.8s) at 48 threads lets the shorter job finish first, leaving the
longer one's tail uncontended -- which flatters the pair and blurs the
contention being measured.

Times via epoch timestamps rather than /usr/bin/time, which is not
installed on the benchmark node. Drops the split arm automatically on a
single-domain machine rather than reporting a meaningless comparison, and
reminds the operator that outputs must be byte-identical across arms --
concurrency and NUMA policy change timing only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ITS2 R1 came back with the split arm running each job 28% FASTER than a
job running alone (141.0s vs 196.9s run_dada), giving 2.79x throughput and
better per-job latency at the same time. The gain shows up in every phase,
not just the map -- map -29%, store -23%, shuffle -27%, p_update -35% --
which is the signature of memory locality rather than bandwidth: under
--interleave=all roughly half of every access is remote (distance 32 vs 10
on this node), and the single-threaded serial phases have nothing to hide
that latency behind.

But split_a was still sharing the node with split_b, so locality and
packing are confounded. The sbind arm runs ONE job bound to one domain. If
it lands near split, the entire gain is locality and packing is
incidental; if it lands near solo, the gain needs the second job.

This matters beyond the packing question: dev/numa_pin.sh recommends
--interleave=all over binding, on a measurement showing binding cost the
map 21%. That was 64 threads on PacBio, where 64 threads bound to a
64-core domain saturates it and 48 leaves headroom -- so the guidance may
be thread-count-specific rather than wrong. Every ITS2 number published so
far was measured under that policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers "how many threads should I use, and does pinning memory help" from
measurement rather than from "use all your cores" -- which costs 5% of
wall on one benchmark pool and leaves 45% on the table on another.

Keyed to the screen/align split the verbose log already prints, rather
than to dataset names, so it is usable by someone whose data is neither of
ours. The screen streams per-sequence vectors and is memory-bound; the
aligner does dense arithmetic and is core-bound; the ratio predicted the
thread knee across four arms (50.8% screen -> scales to 96, 67.7% -> 64,
83-86% -> 48).

Records what moves that ratio, since it is the actionable part: amplicon
and diversity dominate (16S and ITS2 here came from the SAME run and
samples, so that gap is biology, not instrument), but read position moves
it 17 points within one library and changes the recommended thread count.

Separates the latency question from the throughput one -- every arm buys
wall time at ~4x the core-seconds, so a queue wants fewer threads and more
jobs while a single urgent sample wants the table.

The NUMA section is marked provisional: it is soil ITS2 only, pending the
16S runs, and we expect a smaller effect there because compute-bound work
has arithmetic to hide memory latency behind. Publishing a bind-instead-of-
interleave rule from one pool would repeat the mistake that put the
opposite rule in dev/numa_pin.sh, which came from a single PacBio
measurement at a thread count that saturated one domain.

Ends with an explicit list of what has not been tested, and an invitation
to contradict it -- four arms on one machine is a summary, not a law.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The heading starts with a backticked `--verbose`, which slugifies to a
leading single hyphen. Read the generated id out of the built HTML rather
than guessing the slug a third time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nditional

16S R1 gains 25% from binding one job to one NUMA domain, against ITS2's
27-29%. We expected the compute-bound pool to gain much less, since
arithmetic can hide memory latency; it did not. A 35-point gap in screen
share moves the thread-count recommendation by a factor of two but barely
moves this, so the tuning guide now presents NUMA binding as the one
recommendation that does not depend on the data. 150/150 outputs
byte-identical, and every phase gains including the single-threaded ones
(16S: map -27%, store -22%, shuffle -21%, p-update -31%).

Also annotates dev/numa_pin.sh, which recommends the opposite. Its 21%
penalty for binding was measured at 64 threads, where 64 threads bound to
a 64-core domain saturates it. At 48 threads, which leaves headroom, the
result reverses on both pools. The default is left alone because whether
binding is as REPRODUCIBLE as interleaving is not yet measured, and
reproducibility is what that helper exists for -- but "interleave beats
bind" is no longer safe to read as general, and benchmark numbers gathered
under it are conservative in absolute terms at sub-domain thread counts.
A/B deltas are unaffected: both arms always shared the policy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Memory placement is worth 25-30% of run_dada on this hardware -- larger
than most changes this harness exists to measure -- and the harness had no
handling for it at all: both stacks ran through a bare subprocess.Popen.

Applied in numa_wrap, which every launch routes through (run_step and
run_phase_concurrent), rather than at each call site. That is the whole
design constraint: R DADA2 threads the same algorithm through multithread=,
with the same k-mer screen and banded NW, so pinning dada2-rs and not R
would inflate every speedup number by up to ~25% for free. Wrapping at the
convenient call sites is exactly how that would happen by accident.
Whether R gains as much as we do is the interesting question and is
answerable only with both arms treated alike.

Degrades rather than fails, and never silently:

- no numactl installed -> unpinned, warning, recorded as numa=none
- single NUMA domain    -> unpinned, warning, recorded as numa=none
- numactl --hardware errors -> unpinned, warning, recorded as numa=none
- threads exceed one domain's CPUs -> falls back to interleave with a
  warning, because bind cannot honour it: at or above a domain's core count
  binding saturates that domain's controllers and loses to interleaving,
  which is how dev/numa_pin.sh came to recommend the opposite from a
  64-thread measurement on a 64-core domain.

summary.csv records the EFFECTIVE policy alongside the requested one in a
header comment. A run that asked for bind on a machine without numactl
must not be filed as bound -- that is the #145 failure mode, where a gate
resolved silently and two full A/B runs compared a configuration against
itself.

Default is none, so new runs stay comparable to every archived one. Not a
recommendation; docs/tuning-for-your-data.md carries that.

Verified on this machine (no numactl: all three policies degrade to none
and numa_wrap is a no-op) and against a stub numactl reporting 2 domains x
8 CPUs: threads=4 binds, threads=8 binds with a saturation note,
threads=12 falls back to interleave.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four arms across two pools now, and the one-job binding gain sits in a
25-29% band on all of them: ITS2 -27/-29%, 16S -25/-27%. A 35-point gap in
screen share moves the thread recommendation by a factor of two and barely
touches this, which is what makes it the one recommendation in the guide
that does not depend on the data.

Also sharpens the two-unpinned-jobs observation. The spread across arms is
-8% to +6%, and rather than call the 16S read 1 result unexplained, the
spread IS the finding: that is the arm where placement is left to the
kernel, so an unpinned run's performance depends on where its pages happen
to land. A bandwidth shortage would slow both jobs reliably; it does not.
Distance to memory, not throughput to it.

150/150 outputs byte-identical across all six arms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The lab-notebook counterpart to the tuning guide on this branch: what was
tested, the numbers, and what each result dictates.

Two results with opposite characters. Thread count is strongly
data-dependent -- the best count differs two-fold between two reads of the
SAME pool -- and is predicted by the map's screen/align split, monotonic
across four arms and predictive within a pool as well as across pools.
Memory placement is not data-dependent: binding one job to one NUMA domain
is worth 25-29% on every arm despite a 35-point gap in screen share, which
falsified our prediction that compute-bound work would gain less.

The section worth reading is the reconciliation with
findings/measuring-on-numa.md, which concluded that binding is "the obvious
fix that turns out to be wrong". Both are right, and the earlier page
ALREADY CONTAINED the mechanism: its own phase table shows b_shuffle 23%
and store 37% faster bound, within a point or two of what we just measured
on entirely different pools. The map penalty at 64 threads outweighed them,
the run-level total said binding loses, and the phase table underneath went
unexamined -- by this author among others. What changes at 48 threads is
the map penalty, not the serial gain.

That reconciliation is explicitly flagged as confounded: the old
measurement is PacBio at 64 threads and the new one NovaSeq at 48, so
platform and thread count move together. The clean test is one pool bound
at both 48 and 64, named as a prerequisite before numa_pin.sh's default
changes.

Closes with the four instrument errors from this arc, including comparing
fixed wall-clock windows across thread counts -- which inverted the sign of
a reported trend before it was caught -- and locating a knee from one side
of it. Each produced a plausible, quotable number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cjfields cjfields changed the title Thread-sweep tooling, symmetric --numa, and a user-facing tuning guide (#152) Thread sweep: tooling, symmetric --numa, tuning guide, and findings (#152) Aug 29, 2026
@cjfields

Copy link
Copy Markdown
Member Author

Rolled the #152 findings page into this branch (c8e6664) rather than opening a
second PR — same evidence, one review.

docs/findings/thread-scaling-and-placement.md is the lab-notebook counterpart
to the user-facing guide already here. The section worth a look is the
reconciliation with findings/measuring-on-numa.md, which concluded that
binding is "the obvious fix that turns out to be wrong."

Both are right, and the earlier page already contained the mechanism:

PacBio, 64 threads bound interleaved
parallel map 286.8 s 235.4 s
b_shuffle (serial) 50.4 s 65.3 s
store (serial) 12.8 s 20.2 s
run_dada 391.4 s 372.5 s

Its serial phases were already 23% and 37% faster bound — within a point or two
of what we just measured on entirely different pools. The map penalty at 64
threads outweighed them, the run-level total said "binding loses", and the phase
table underneath went unexamined. What changes at 48 threads is the map penalty,
not the serial gain.

That reconciliation is flagged as confounded, not proven: the old measurement
is PacBio at 64 threads and the new one NovaSeq at 48, so platform and thread
count move together. The clean test is one pool bound at both 48 and 64, named in
the page as a prerequisite before dev/numa_pin.sh's default changes.

Also closes with the four instrument errors from this arc — including comparing
fixed wall-clock windows across thread counts, which inverted the sign of a
reported trend before it was caught.

mkdocs build --strict clean; findings index and nav updated.

@cjfields
cjfields merged commit e66e548 into main Aug 29, 2026
7 checks passed
@cjfields
cjfields deleted the dev/thread-sweep-analysis branch August 29, 2026 20:33
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