Skip to content

ci: thread-count correctness checks (would catch #721) and base-vs-head benchmarks - #727

Open
dougnukem wants to merge 11 commits into
OpenGene:masterfrom
dougnukem:stack/ci-checks
Open

dougnukem wants to merge 11 commits into
OpenGene:masterfrom
dougnukem:stack/ci-checks

Conversation

@dougnukem

@dougnukem dougnukem commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #723 (the correctness check fails on master without it; see "Negative control" below).

Adds two checks to CI that would have caught #721, plus a benchmark job so performance changes come with numbers.

Correctness (ci.yml, both OSes)

  • ./fastp test: the existing unit tests, which CI didn't run.
  • .github/scripts/check_correctness.py: generates deterministic synthetic PE reads (TruSeq read-through, poly-G, Ns, degrading quality), then requires byte-identical output (decompressed) at -w 1,2,4,9,17,33,48 in 8 modes: PE gz, PE plain, SE, interleaved input, merge, failed/unpaired outputs, --stdout, and -s split (compared by sorted records). A run over 180s counts as a hang. It also checks that adapter auto-detection (SE, and PE with --detect_adapter_for_pe) finds the planted adapters.
  • fastp caps -w to the core count, so on a 4-core runner -w 48 would silently run 4 workers. On Linux, fake_nprocs.c (LD_PRELOAD) reports 64 CPUs so high worker counts really run, and any run that still prints "Reduce worker threads" fails. The Linux build is fully static, so the check relinks the same objects against dynamic glibc for that step. macOS runs only the counts it can really run.

Runtime: 50s on ubuntu-latest, 18s on macOS.

Benchmark (bench.yml, ubuntu-latest)

  • On PRs: builds base and head on the same runner and runs them interleaved (3 reps, alternating order) at -w 4. Each build runs on the first 0.3M and 1.2M pairs of the same input (--reads_to_process, no recompression), and a line through the two points gives a fixed cost (intercept) and a cost per pair (slope). Inputs are 1.2M synthetic pairs (used as both PE and SE) and the first 1.2M pairs of a public ATAC-seq run (SRR891268, from ENA).

  • Projected to full size: subsets overstate changes to fixed-cost stages (adapter detection samples at most 256K reads), so the projection to a 50M-read run is fixed cost + cost per pair × 50M. On 3 complete public runs (4 and 16 cores) a line through two subset sizes predicted full-run CPU within 1–2% and wall within 2–4% (median error), against 3% and 11% for scaling a single subset. The fixed cost and the measured subset numbers are in collapsed sections.

  • Regression gate: a cell regresses when its projected median is more than 10% worse than base and every head run is slower than every base run, so one noisy run can't trip it. Only projected wall, CPU per pair (µs) and peak RSS are gated. Any regression fails the regression gate step unless the PR has the perf-regression-ok label, which is the explicit sign-off. Adding or removing that label reruns the benchmark. The threshold can be set with the BENCH_THRESHOLD_PCT repo variable. Improvements over 5% (with no overlap) are marked 🟢 but never gated.

  • PR comment: the report is posted as a single comment that's updated in place on each push. Fork PRs get a read-only token, so bench-comment.yml posts it from a workflow_run in the base repo. It never runs PR code, uses the artifact only as comment text, and finds the PR by head SHA.

  • Structured output: bench-compare.json holds per-cell base, head, delta and verdict. The step outputs regressions and improvements and the job summary are also available.

  • On pushes to master: charts head-only numbers over time with github-action-benchmark on gh-pages. It doesn't comment or fail on alerts; shared runners are too noisy to gate on.

  • Noise: on the fork, upstream vs fix: scale reader backpressure with worker thread count (#721) #723 plus the adapter-detection index shows CPU per pair +4–5% (the backpressure fix costs 2–4% on 4-core runners) and nothing is flagged at the 10% threshold.

  • Input robustness: ENA sometimes drops the connection mid-stream, so the download resumes with Range requests (and restarts from the top if the server answers 416). If an input still can't be fetched, that dataset is skipped with a warning, listed in the report, and the cache is saved only for a complete set.

Dependency builds move into a composite action (.github/actions/build-deps) shared by both workflows. The steps are unchanged.

Validation (on my fork)

  • This branch: all green. Linux ran -w 1…48 with real workers, and every mode was identical across all thread counts. The benchmark job takes about 6 min with cached inputs, 9 min on a first run (it generates the synthetic data and downloads the public subset).
  • Negative control: the same CI commits on unfixed master: Linux fails with HUNG at -w 33 and -w 48 in all 7 non-split modes (split doesn't hang) and passes everywhere else. The output digests at low -w match this branch's, so fix: scale reader backpressure with worker thread count (#721) #723 doesn't change output. That run takes about 45 min because every hang waits out the 180s timeout; with the fix in, the step takes 50s.
  • Gate, on the earlier single-size version: a fork PR that adds a deliberate 2s startup sleep flagged 6 wall-time regressions (+42% to +131%) and left CPU unflagged, which is correct since sleep uses none. The gate failed; after the label was added it passed, and the same comment was updated to "accepted".
  • Master tracking: with these workflows on the fork's master, the push run committed dev/bench/data.js to gh-pages and the chart renders.
  • Two-size version, on fork PRs: the gap-search fix alone (Adapter trimming: one-gap search only compares the read start, so indel adapters are missed and reads that start like the adapter get trimmed #728) is flagged on all 3 datasets (CPU per pair +11% to +22%); the same fix with the early-exit search is within ±1% on the synthetic sets and −9% on ATAC; the adapter-detection index shows fixed cost −20% to −31% and nothing flagged.
  • Not exercised yet: the fork-PR comment path (bench-comment.yml), which needs a PR from a different repository, and the download restart against the real server when several jobs fetch the same file at once (tested against a local server that drops connections and answers 416).

🤖 Generated with Claude Code

dougnukem and others added 7 commits September 22, 2026 22:18
Each worker owns one input list, and SingleProducerSingleConsumerList only
lets a consumer take an item once another item has been produced behind it
(or the producer has finished). Reader backpressure used a fixed
PACK_IN_MEM_LIMIT (32) that is smaller than the worker count on machines
with more than 32 cores. With 33+ workers the readers stop after 33 packs,
before any worker list holds two items, so no pack is ever consumed and all
reader and worker threads wait on the backpressure condition variable
forever. The writer-backlog check has the same shape: the writer drains
worker lists round-robin and waits on a list until its worker produces a
second output, so a fixed limit below the worker count can also block the
readers permanently.

Allow at least two in-flight packs per worker in both the reader/processor
and reader/writer backpressure checks (max(PACK_IN_MEM_LIMIT, 2 * threads)),
for PE, interleaved PE and SE readers. The lock-free list semantics are
unchanged, so this does not reintroduce OpenGene#695.
The reader/writer backlog check still gated on the fixed
PACK_IN_MEM_LIMIT * PACK_SIZE period instead of the new
mPackInMemLimit, so it fired far more often than the (now larger)
buffer actually needs at high thread counts. Not a correctness bug —
the comparisons inside already used mPackInMemLimit — just an
inconsistency worth cleaning up alongside it.

Also removed a stray extra blank line left in common.h.
- ci.yml: run the built-in unit tests and check_correctness.py, which
  requires byte-identical output at 1..48 workers in every output mode
  (catches OpenGene#721-style hangs; an LD_PRELOAD shim stops fastp capping -w to
  the runner's cores) and checks adapter auto-detection.
- bench.yml: on PRs, build base and head on the same runner and time them
  interleaved (wall, adapter detection, processing, CPU, peak RSS) on
  synthetic reads and a public ATAC-seq subset; on master, chart history
  with github-action-benchmark.
- Dependency builds move to a composite action shared by both workflows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Linux build is fully static, so the LD_PRELOAD nprocs shim had no
effect and every -w above the runner's core count was capped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- bench.py marks each cell as a regression (worse than base by more than
  --threshold, default 10%, with no overlap between base and head runs),
  an improvement (better by more than 5%, no overlap), or noise. Wall time,
  CPU and peak RSS are gated; stage timings are informational.
- The PR fails the "regression gate" step on any regression unless it has
  the perf-regression-ok label; adding or removing that label re-runs the
  benchmark. The threshold can be set with the BENCH_THRESHOLD_PCT variable.
- The report is posted as one PR comment that is updated in place. Fork PRs
  get a read-only token, so bench-comment.yml posts it from a workflow_run
  in the base repo, using the artifact only as comment text and finding
  the PR by head SHA.
- bench-compare.json (per-cell base, head, delta, verdict) and the
  regressions/improvements step outputs give machine-readable results.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- github-action-benchmark runs git in the workspace root, so check the PR
  out there (base goes to _base/) and keep results in $RUNNER_TEMP, out of
  reach of its gh-pages switch.
- Alternate base/head order each rep. With base always first, every PR
  showed processing 1-4% slower than base, including ones that don't touch
  that path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Subsets overstate changes to fixed-cost stages: pre-processing, adapter
detection (capped at 256K reads / 39.6M bases per mate) and the report cost
the same on a subset as on a full run. Each run is now also projected to a
full-size run (--project-reads, default 50M reads or pairs): fixed stages as
measured plus processing scaled by read count. The report and the gate use
projected wall and CPU; measured subset numbers move to a collapsed section.

Checked against 30 full-size public runs (6 datasets, 2 builds, -w 8/16/48):
projected wall within 8% of measured (median); base-vs-head deltas within
3 percentage points, against 10 points for raw subset deltas.

The synthetic set grows to 300K pairs so detection reaches its cap, as it
does on a full file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude added 4 commits October 2, 2026 00:00
Projecting one subset run needed stage timestamps and still overpredicted
some datasets. Each build is now run on the first 0.3M and 1.2M pairs of the
same input (--reads_to_process, no recompression; both above the 256K-read
detection cap), and a line through the two points gives a fixed cost
(intercept) and a cost per pair (slope). Projected wall, CPU per pair and
peak RSS are gated; the fixed cost and the measured subset numbers are shown
in collapsed sections.

On 3 complete public runs at 4 and 16 cores, a line through two subset sizes
predicted full-run CPU within 1-2% and wall within 2-4% (median error),
against 3% and 11% for scaling a single subset.

- Only -w 4 is run: CPU per pair doesn't depend on the thread count, and
  -w 1 was the slowest cell.
- gen_reads.py draws qualities from a pool built once instead of 150
  Gaussians per read: 4x faster, so a 1.2M-pair file takes about 2 minutes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Two of three concurrent jobs failed in `prepare` when ENA closed the stream
mid-transfer (EOFError: compressed file ended before the end-of-stream
marker). Retry up to five times with backoff, and treat a short stream as a
failed attempt instead of writing a truncated file.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Restarting from the top never finished for the 1.2M-record subset: ENA
closes the stream partway through every time. Reconnect with a Range request
at the byte where the stream stopped and keep feeding the same gzip
decompressor, including across concatenated gzip members.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…complete sets

The ENA stream for the public input can be left half-populated when two jobs
start pulling the same file at once: the first connection returns a few KB and
every ranged request then gets 416 Range Not Satisfiable. A PR's CI shouldn't
fail on that.

- On 416, restart the download from byte 0 instead of retrying the same offset.
- If an input still can't be fetched, skip that dataset with a warning, say so
  in the report, and run the rest.
- Cache the datasets only when the set is complete (separate restore and save
  steps), so a skipped input is retried on the next run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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.

2 participants