bam: optional htslib writer behind the htslib-bam feature - #153
bam: optional htslib writer behind the htslib-bam feature#153BenjaminDEMAILLE wants to merge 4 commits into
Conversation
BGZF compression parallelises perfectly, but the obvious way to exploit that
with the existing stack does not work here: swapping in
`noodles_bgzf::MultithreadedWriter` measured ~37% *slower* at every size up to
a 44 MB BAM. `noodles-bgzf` is already built with its `libdeflate` feature and
both its writers go through the same `deflate::encode`, so the multithreaded
one only adds per-block channel and ordering overhead. That attempt was dropped
rather than shipped.
htslib's `hts_set_threads` is a different mechanism: a genuinely parallel
deflate rather than a layer over one compressor. Measured on 400k reads,
`--outSAMtype BAM Unsorted --outBAMcompression 6`, five interleaved A/B pairs
at `--runThreadN 16`:
noodles 2.55 2.61 2.51 2.30 2.39 median 2.51 s
htslib 2.08 2.22 2.00 2.16 2.02 median 2.08 s
about 17% faster, and the gap widens with thread count (9% at 4, 7% at 8) as
compression takes a larger share.
Off by default, and never built on Windows. `rust-htslib` ships pre-built
bindings for Mac and Linux only, its README calls Windows `bindgen` untested,
and its own CI has no Windows job — while this project's matrix requires
`windows-x86_64`. So the dependency is declared under `cfg(not(windows))`,
`default-features = false` drops bzip2-sys and lzma-sys (CRAM only), and the
default build and published crate are untouched by its existence.
Only the unsorted-BAM-to-file path is taken over, which is where BAM writing
can dominate a run. Stdout and the sorted path keep the default writer; they
have their own constraints and nothing to gain here.
Both backends are handed the same rendered header and the same records go
through the same SAM rendering, so they cannot drift in field encoding.
`backends_agree_on_the_decoded_record_stream` checks the contract: BGZF block
boundaries may differ, since that is framing and htslib chooses differently,
but the decoded record stream may not. On the 400k-read run the decoded output
is identical:
noodles b2051d153f9510ec57260e6450434507
htslib b2051d153f9510ec57260e6450434507
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1dd19d9 to
681fedf
Compare
The PR quoted "five interleaved A/B pairs at 16 threads" and a median wall clock per backend, but gave no command, no input and no platform, so the number could not be checked. CONTRIBUTING asks for a benchmark anyone can rerun from the branch. The script builds both binaries, alternates them pair by pair, and flips the order on even pairs so a machine that drifts cannot favour whichever runs first. It discards one warm-up run so the first pair is not paying for a cold page cache. Running it here does not reproduce the claim; the measurement is in the PR description rather than hidden in a commit message.
I ran the A/B script without checking the load and published the result against the author's number. The machine runs bwa-mem2 jobs at 15 cores. The tail of my series drifted from 2.7 s to 5.1 s on both backends, which I wrote off as the machine warming up; that is what an unrelated job arriving mid-series looks like, and contention of that size can invert a result rather than merely add noise to it. The script now reads the load average before building, refuses above 2.0 unless BENCH_IGNORE_LOAD=1, and prints the load beside every pair so a contaminated series is visible in its own output rather than having to be inferred afterwards. The numbers I posted are withdrawn in the PR description. The headline is unverified in both directions until there is a clean run.
Load average is an exponential average over minutes, so it stays high long after the offending job is gone and refuses to measure on a machine that is now quiet. It read 2.24 here with every core free. Idle percentage answers the question actually being asked: are the cores free right now. The script samples it before building and prints it beside every pair.
|
I don't think it's worth pulling in htslib and it's C for such a small performance change. Thoughts? James |
|
Agreed, and my own measurement since opening this says the same thing more strongly than your objection does. I tried to verify the 17% headline and could not, because on yeast BAM writing is 1-4% of the run at any reachable scale: That removes the case for a C dependency, since there is no demonstrated win to weigh against it. Your alternative is the right one and it turned out not to need noodles-side work at all: Closing this in favour of #164. |
Optional htslib BAM writer behind the
htslib-bamfeature.What changed
src/io/bam_htslib.rsadds a second unsorted-BAM-to-file backend usingrust-htslib, selected by thehtslib-bamfeature. htslib'shts_set_threadsgives a genuinely parallel deflate; worker count is--runThreadN - 1, so a single-threaded run stays serial.Only the unsorted-BAM-to-file path is taken over, the one where BAM writing can dominate a run. Stdout and the sorted path keep the default writer.
Why
BAM writing was measured as the dominant cost at high thread counts, and
noodles_bgzf::MultithreadedWriterwas tried first and rejected (~37% slower; numbers on #143 and #151). htslib is a different mechanism, not a layer over the same compressor. Whether it is actually faster is the open question below.The measurement, and why yeast cannot settle it
The earlier revision reported five interleaved A/B pairs at 16 threads, medians 2.51 s for noodles against 2.08 s for htslib, "~17% faster", with no command, input or platform.
test/bench_bam_backends.shis now in the branch and does that comparison reproducibly.First I ran it badly and posted the result anyway. I did not check whether the machine was idle, and it runs unrelated 15-core jobs. The tail of my series drifted from 2.7 s to 5.1 s on both backends, which I attributed to the machine warming up; that is what an unrelated job arriving mid-series looks like. Those numbers are withdrawn. The script now samples CPU idle before building and refuses below 80%, printing the idle figure beside every pair, so a contaminated series is visible in its own output.
Re-run on an idle machine (90-93% idle throughout), yeast index, 200k reads of ERR12389696,
--outSAMtype BAM Unsorted --runThreadN 16, eight pairs with the order flipped on even ones:But that comparison cannot resolve the claim, and neither could the original. On this data BAM writing is a rounding error in the run:
--outSAMtype NoneBAM UnsortedA 17% improvement in BAM writing would move total wall clock by about half a percent at 200k, well under the run-to-run spread. The 0.04 s between the two medians above is inside that noise, so it is not evidence either way.
So the honest position is that the headline is unverified, not refuted, and it is out of the title until it is measured on a workload where BAM writing is a meaningful share of the run. On yeast it never is, at any scale reachable here. The premise in the section above, that BAM writing dominates at high thread counts, comes from human-scale data with far larger records and output; that is the workload this benchmark needs, and the revision that quotes a number should say which one it used.
Reproduce:
How the backends are kept honest
Both are handed the same rendered header, and every record goes through the same SAM rendering before htslib parses it, so they cannot drift in field encoding.
backends_agree_on_the_decoded_record_streamstates the contract: BGZF block boundaries may differ, that is framing, and htslib chooses them differently, but the decoded record stream may not.Verification
cargo test --release(default features): 561 lib + integration, greencargo test --release --features htslib-bam: greencargo clippy --all-targets -- -D warnings, with and without the feature: cleancargo fmt --check: cleanTrade-off on record
This pulls htslib (C) into a project that is otherwise pure Rust apart from libdeflater, on a crate published to crates.io. Optional and platform-gated limits the blast radius but does not remove the dependency-story change. If the dependency is unwanted, the measurement still stands on its own: it locates where the BAM write cost is.