Skip to content

perf(bam): parallel BGZF and record encoding (draft: measured slower, investigating) - #164

Closed
BenjaminDEMAILLE wants to merge 1 commit into
scverse:mainfrom
BenjaminDEMAILLE:bd/perf-parallel-bam
Closed

perf(bam): parallel BGZF and record encoding (draft: measured slower, investigating)#164
BenjaminDEMAILLE wants to merge 1 commit into
scverse:mainfrom
BenjaminDEMAILLE:bd/perf-parallel-bam

Conversation

@BenjaminDEMAILLE

@BenjaminDEMAILLE BenjaminDEMAILLE commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

BAM compression and record encoding move onto the rayon pool. Output-neutral, and verified byte-identical rather than assumed.

What changed

make_bgzf_writer returned bgzf::io::Writer, which deflates one block at a time on whichever thread is writing. It now returns bgzf::io::MultithreadedWriter, which hands each block to the rayon pool and writes finished frames in order from a dedicated thread. run() already sizes the global pool from --runThreadN, and noodles-bgzf with the libdeflate feature plus libdeflater were already dependencies, so no new dependency and no new configuration.

encode_batch splits a batch into sub-ranges encoded across the pool. Record encoding was serial on the writing thread; bam::io::Writer::write_alignment_record runs the noodles encoder per record, and that is real CPU work in a BAM run.

The sorted writers encode in bounded slices of 65 536 records rather than in one pass. Their whole record set is already resident as RecordBufs, and encoding it in one go would have held the entire encoded stream alongside them.

Why the output cannot move

Not an assertion about intent. The two writers are the same format machine:

  • both stage into a buffer of MAX_BUF_SIZE and emit a block only when it fills,
  • both emit through the same write_frame,
  • both append the same 28-byte BGZF_EOF,
  • libdeflate is deterministic at a fixed level.

And bam::io::Writer keeps no state between records other than a scratch buffer it clears at the top of every write_alignment_record. A sub-range encoded against a fresh in-memory writer therefore contributes exactly the bytes it would have contributed to the serial stream, so concatenating sub-ranges in input order is the serial stream. par_chunks is an indexed parallel iterator, so collect restores input order whatever the completion order.

Measured on 200 000 real reads (yeast, ERR12389696), old binary from origin/main against this branch:

mode file result
BAM Unsorted Aligned.out.bam identical, 15 130 304 bytes
BAM SortedByCoordinate Aligned.sortedByCoord.out.bam identical, 9 386 500 bytes

Worth saying how that check went wrong first, because it is an easy trap: the files initially differed by one byte. The cause was the harness, not the code. @PG records CL: verbatim, so running ./old with prefix out_old/ against ./new with prefix out_new/ makes the header differ and the compressed size with it. Both sides now run as ./rustar-aligner with prefix ./ from inside their own directory.

One hazard, found by a test that hung

The multithreaded writer hands blocks to the pool via rayon::spawn and blocks on a channel bounded by the worker count while a dedicated thread drains it. A caller that is itself occupying a rayon worker can fill that channel and then wait on a compression task that has no free worker to run on. That is a deadlock, not a slowdown, and it is reachable at any pool size, including one worker.

I found it by writing a thread-count sweep that wrapped the whole writer in ThreadPool::install. It hung; a stack sample showed the worker parked in LockLatch::wait_and_reset.

rustar is safe as written: run_batch_pipeline calls consume on its own dispatcher thread, never on the pool, and the transcriptome writer is reached through a &mut that cannot cross par_iter. To keep it that way, write_batch carries

debug_assert!(rayon::current_thread_index().is_none(), ...)

so a future call site that writes from inside the pool fails loudly in tests rather than hanging in production. The constraint is also documented on make_bgzf_writer, where someone changing the writer will read it.

Verification

New unit tests in src/io/bam.rs, each comparing against bytes produced by the pre-change path (a serial bgzf::io::Writer fed one record at a time), not merely against the other new half:

  • multithreaded_bgzf_is_byte_identical_to_the_serial_writer — 4 000 records, several BGZF blocks, so block boundaries are actually crossed
  • parallel_encoding_is_byte_identical_at_every_worker_count — pools of 1, 3, 4, 7 and 16 workers, including sizes that do not divide the batch evenly
  • a_batch_below_the_fanout_threshold_encodes_the_same_bytes — the serial branch must agree with the parallel one
  • chunked_encoding_matches_a_single_pass — the sorted writers' slice boundary must not show in the output
  • bam_file_is_byte_identical_to_the_serial_path — end to end through BamWriter
  • finishing_twice_is_not_an_errorMultithreadedWriter::finish shuts the workers down and panics if called twice, and Drop calls it too

Gate: 566 lib + 26 integration tests, cargo clippy --all-targets -- -D warnings, cargo fmt --check, all green.

Timings: measured, and they say this regresses

Draft, because the measurement contradicts the premise. At --runThreadN 16 on 16 cores, 2M yeast reads, machine at 87-92% CPU idle, interleaved with the order flipped on even pairs:

configuration old (origin/main) this branch
--outSAMtype None 22.45 / 22.42 / 22.72 / 23.81 s 22.55 / 22.55 / 23.33 / 22.83 s
BAM Unsorted, --outBAMcompression 6 25.00 / 25.05 / 25.58 / 25.60 s 25.94 / 26.50 / 27.80 / 28.01 s

All four compression-6 pairs go the same way: +1.8 s median on 25.3 s, about +7% slower. None is neutral, which is what localises the difference to the BAM write path.

Likely cause, being checked rather than announced: alignment already occupies every core at 16 threads, MultithreadedWriter hands its blocks to that same rayon pool and blocks on a channel bounded by the worker count, so there is no spare capacity for compression to move to and the handoff is pure added cost.

test/bench_bam_write.sh reproduces the above. It times --outSAMtype None alongside the BAM modes deliberately: only the difference is the work this change touches, and a total that is mostly alignment hides it.

See the comment thread for the isolation run in progress (parallel BGZF with serial encoding, plus a --runThreadN 8 leg where spare capacity exists). This PR should not merge on a performance claim until that resolves; the output-neutrality work above stands either way.

BGZF deflate ran one block at a time on the thread doing the writing,
while `noodles-bgzf` with the `libdeflate` feature and `libdeflater`
were already dependencies and rayon's pool was already sized from
`--runThreadN`. Record encoding was serial on that same thread.

`make_bgzf_writer` now returns `bgzf::io::MultithreadedWriter`, and
`encode_batch` splits a batch into sub-ranges encoded across the pool.

Output is unchanged, and provably so rather than by inspection. The
multithreaded writer stages into the same `MAX_BUF_SIZE` buffer, emits
through the same `write_frame` and appends the same EOF block as the
serial writer, and libdeflate is deterministic at a fixed level.
`bam::io::Writer` keeps no state between records beyond a scratch buffer
it clears per record, so a sub-range encoded against a fresh in-memory
writer contributes exactly the bytes it would have contributed to the
serial stream.

One hazard, found by a test that hung: the multithreaded writer hands
blocks to the pool and blocks on a channel bounded by the worker count,
so writing to it from a rayon worker can deadlock at any pool size.
rustar writes from the pipeline's dispatcher thread, never from a
worker. A `debug_assert` now fails loudly instead of hanging if a future
call site breaks that.

The sorted writers encode in bounded slices rather than in one pass, so
peak RSS does not gain a copy of the whole encoded stream.
@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor Author

Measured it. It is slower, not faster. Moving to draft.

Machine at 87-92% CPU idle, 2M yeast reads (ERR12389696), --runThreadN 16 on 16 cores, interleaved with the order flipped on even pairs.

configuration old (origin/main) this branch
--outSAMtype None 22.45 / 22.42 / 22.72 / 23.81 s 22.55 / 22.55 / 23.33 / 22.83 s
BAM Unsorted, --outBAMcompression 6 25.00 / 25.05 / 25.58 / 25.60 s 25.94 / 26.50 / 27.80 / 28.01 s

All four compression-6 pairs go the same way: +1.8 s median on a 25.3 s run, about +7%. None is neutral, which is what says the difference is the BAM write path and not something else in the run. One compression-1 pair is discarded because CPU idle had fallen to 20% during it; the remaining compression-1 pairs are mixed and I am not reading a direction into three numbers.

Why I think it regressed

To be checked rather than announced: at --runThreadN 16 on a 16-core machine, alignment already occupies every core. MultithreadedWriter hands each block to that same rayon pool via rayon::spawn and blocks on a channel bounded by the worker count. There is no spare capacity for the compression to move to, so the handoff cost is added without anything being taken away. Inline serial deflate did not pay that cost.

If that is right, the premise of this PR was wrong in the same way #153's was, just in the other direction: I assumed a serialised bottleneck parallelises, without checking there were free cores to absorb it.

What is running now

A third binary, mtonly — parallel BGZF writer with serial record encoding — to separate the two halves of the change, plus a --runThreadN 8 leg on the same 16 cores, where spare capacity does exist and where the design should pay if it pays anywhere.

Three outcomes and what each means:

  • mtonly also regresses — the multithreaded BGZF writer is the problem on a saturated pool, and it should not be wired to the same pool as alignment. A separate small pool, or a dedicated thread, would be the fix, not this diff.
  • mtonly is neutral or better, new regresses — the encoding fan-out is the cost, and the PR should keep only the writer change.
  • both regress at 16 threads but win at 8 — the change is right but must be gated on the pool having headroom, which is a design question worth a maintainer's opinion before more code.

What stands regardless

The output-neutrality work is independent of the performance question and holds either way: byte-identical BAM against origin/main on 200k real reads in both Unsorted and SortedByCoordinate, plus the six unit tests comparing against the pre-change serial path.

So does the deadlock hazard documented on make_bgzf_writer. That one is worth keeping in mind whatever happens to this diff: noodles' MultithreadedWriter cannot safely be written to from a rayon worker, because it can block waiting on a compression task that has no free worker to run on.

I will post the isolation numbers here and either narrow this PR to whatever actually wins or close it with the negative result recorded. It should not merge on the description it currently has.

@BenjaminDEMAILLE
BenjaminDEMAILLE marked this pull request as draft July 30, 2026 12:50
@BenjaminDEMAILLE BenjaminDEMAILLE changed the title perf(bam): compress and encode BAM on the rayon pool (output-neutral) perf(bam): parallel BGZF and record encoding (draft: measured slower, investigating) Jul 30, 2026
@BenjaminDEMAILLE

Copy link
Copy Markdown
Contributor Author

Isolation run done. The result picks outcome 1, and it also refutes the cause I proposed above.

Three binaries, 2M yeast reads, --outBAMcompression 6, rotating which side runs first each round:

  • old — serial BGZF, serial encoding (origin/main)
  • mtonly — parallel BGZF, serial encoding
  • new — parallel BGZF, parallel encoding (this branch)

--runThreadN 16 on 16 cores (idle 85-94%):

round 1 round 2 round 3 median
old 21.92 24.15 23.14 23.14
mtonly 27.11 25.36 25.11 25.36
new 25.24 25.09 25.58 25.24

--runThreadN 8 on 16 cores (idle 96%, and the spread is tight enough to read directly):

round 1 round 2 round 3 median
old 35.39 35.28 35.36 35.34
mtonly 38.55 38.65 38.48 38.55
new 38.56 38.59 38.70 38.59

Two things fall out.

The multithreaded BGZF writer is the whole regression. mtonly and new are indistinguishable at both thread counts (25.36 against 25.24; 38.55 against 38.59). Parallel record encoding contributes nothing measurable in either direction, so it is added complexity with no payoff, not a second cause.

My "no spare cores" explanation was wrong. I said the pool is saturated at 16 threads so compression has nowhere to go. At 8 threads on 16 cores there are eight idle cores and the regression is larger, not smaller: +3.2 s, about +9%, with a 0.17 s spread across three rounds. Spare capacity does not rescue it, so capacity was not the constraint.

What fits both thread counts is backpressure rather than capacity. MultithreadedWriter::write blocks once its channel (bounded by the worker count) is full, and in this pipeline the thread that calls it is the dispatcher in run_batch_pipeline — the same thread that feeds batches to the pool. So a stalled write does not just delay the write, it stops new batches being dispatched, and alignment drains. Inline serial deflate cost CPU on that thread but never suspended it. I am stating that as the reading that survives the data, not as a verified mechanism; the data only establishes that adding cores does not help, which rules out my first answer.

Recommendation

This diff should not merge, in whole or in part. Both halves fail on their own terms: the writer half regresses at every thread count measured, and the encoding half is neutral, which under CONTRIBUTING.md's no-dead-weight rule is not worth carrying either.

Three things from the work are worth keeping regardless of what happens to the diff, and none of them need this branch:

  1. noodles' MultithreadedWriter cannot safely be written to from a rayon worker. It hands blocks to the pool via rayon::spawn and blocks on a channel bounded by the worker count, so a caller occupying a worker can wait on a compression task with no worker free to run it. Deadlock at any pool size, including one worker. I hit it from a test and confirmed it with a stack sample parked in LockLatch::wait_and_reset.

  2. Benchmarking a write path against the total run hides it. BAM writing is 1-4% of a yeast run, so a total dominated by alignment cannot resolve a change to the writer. Timing --outSAMtype None alongside the BAM modes is what makes the difference legible, and it is what turned "roughly flat" into a clean, repeatable +9%.

  3. If BGZF is ever parallelised here, it should not share the pool that alignment runs on, and the measurement should include a leg where the pool has headroom, because that leg is what falsified my first hypothesis.

Happy to close this. If any of the three points above is worth landing on its own — the bench script with the None leg is the obvious candidate — say which and I will open it as its own PR rather than leaving it attached to a failed one.

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