Conversation
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>
1 of 5 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,48in 8 modes: PE gz, PE plain, SE, interleaved input, merge, failed/unpaired outputs,--stdout, and-ssplit (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.-wto the core count, so on a 4-core runner-w 48would 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 gatestep unless the PR has theperf-regression-oklabel, which is the explicit sign-off. Adding or removing that label reruns the benchmark. The threshold can be set with theBENCH_THRESHOLD_PCTrepo 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.ymlposts it from aworkflow_runin 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.jsonholds per-cell base, head, delta and verdict. The step outputsregressionsandimprovementsand 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
Rangerequests (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)
-w 1…48with 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).-w 33and-w 48in all 7 non-split modes (split doesn't hang) and passes everywhere else. The output digests at low-wmatch 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.sleepuses none. The gate failed; after the label was added it passed, and the same comment was updated to "accepted".dev/bench/data.jstogh-pagesand the chart renders.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