Skip to content

perf(qsnp): more efficient data structures used by Accumulator - #413

Open
holmeso wants to merge 2 commits into
masterfrom
qsnp_speedup
Open

holmeso wants to merge 2 commits into
masterfrom
qsnp_speedup

Conversation

@holmeso

@holmeso holmeso commented Nov 4, 2026

Copy link
Copy Markdown
Contributor

Summary

qsnp is 22% faster on a whole-genome tumour/normal pair (about 115x tumour, 40x normal). Both runs below used the same node, JDK 25 and input, and produced identical VCFs:

Run Tumour records/s Wall time
master (G1 GC) 481k 7,180 s
this branch (G1 GC) 529k 6,533 s
this branch + -XX:+UseParallelGC 589k 5,867 s

With Parallel GC, the JVM also used about half the CPU: about 5 cores on average instead of about 11 under G1.

Background

Profiling with JFR (-XX:+DebugNonSafepoints, so the results aren't skewed towards safepoints) showed:

  • The tumour consumer thread is the bottleneck: the producers spend their time waiting on it.
  • Its time goes on adding bases to Accumulators, which is memory-latency bound.
  • G1 spent about 22% of wall time in GC pauses. Accumulators live for a few seconds, long enough to be promoted to the old generation before the cleaner frees them, so G1 keeps copying them.

Changes

Accumulator / AccumulatorUtils (qcommon)

  • Per-position base data is held in a raw long[] with a size counter instead of a TLongArrayList. That saves an object and a pointer dereference per position.
  • The four failed-filter lists are stored the same way. Each is created on first use with the default capacity of 10.
  • New addEncodedBase(readNameHash, encodedBase, initialCapacity) adds a base that is already encoded. encodeReadBits and encodeBase split the encoding so the per-read part (strand and start position) is computed once per read, not once per base.
  • getData() and getFailedFilterXCount() now return lightweight TLongList views over the arrays. Existing callers still work, but the hot paths in AccumulatorUtils read getDataArray()/getDataSize() directly.
  • getUniqueBasesMask and related helpers no longer allocate.

Pipeline (qsnp)

  • Each consumer adjusts the initial data capacity of new Accumulators to the mean depth it observes (1.25 × mean depth, kept between 16 and 1024 longs). This avoids repeated array growth at 100x+. Changes are logged.
  • The Accumulator is looked up once per aligned block, and bases are added with the per-read encoding computed in advance.
  • The CIGAR loops use indices rather than iterators, here and in qbamfilter's CigarFilter. This removes an ArrayList$Itr allocation per record.
  • minStartPosition is written with a release store instead of a full volatile write.
  • The consumer's throttle scan now starts from the position the cleaner has already reached (cleanedUpToPosition). Previously it rescanned up to 10M slots each time.
  • When the consumers haven't advanced, the cleaner sleeps for 1 ms instead of spinning.
  • Compound SNP detection now only looks at the current contig's SNPs. Previously it rescanned the whole SNP list on every call. adjacentAccumulators is cleared at the end of each contig.

New CountingConcurrentQueue (qsnp)

  • A ConcurrentLinkedQueue wrapper whose size() reads a LongAdder counter, so it takes constant time. ConcurrentLinkedQueue.size() walks every node, and the producers' back-pressure check calls it on queues holding up to millions of records.

Launcher and docs

  • adama/bin/qsnp now runs with -XX:+UseParallelGC.
  • docs/qsnp/qsnp_v2_0.md recommends Parallel GC and a large heap (60 GB in testing) for WGS pairs.

GC comparison (10-minute JFR window, 60 GB heap)

G1 Parallel
Total pause time 130 s 45.8 s
Number of pauses 638 114
Longest pause — 1.24 s
Average JVM CPU about 11 cores about 5 cores

Parallel GC's stop-the-world pauses briefly use all available cores. If that matters on shared nodes, add -XX:ParallelGCThreads=N.

Testing

  • New unit tests:
    • AccumulatorTest: array growth beyond the initial capacity, views matching the underlying arrays, setData copying, and failed-filter growth.
    • AccumulatorUtilsTest: the new encoding and the allocation-free helpers.
    • PipelineTest: consumer output matches the old addBase path, failed-filter lists included.
    • CountingConcurrentQueueTest: a concurrent producer/consumer with 1M items, among others.
  • Unit tests pass in qcommon (409), qsnp (156) and qbamfilter (51).
  • End to end: VCFs from master, this branch with G1, and this branch with Parallel GC are identical on a full WGS pair.

Notes

  • The 22% is measured on newer hardware (hpcnode072). Production stdQsnp runs on older nodes such as hpcnode018 took 3 h 42 m for the same sample, so node choice is also a large factor there.
  • adama/bin/qsnp doesn't set -Xmx, so the JVM defaults to 25% of the memory it can see. Make sure the Cromwell task requests enough memory, or set the heap explicitly.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Copilot was unable to complete this review at the selected tier due to model policy or plan restrictions.

🟡 Changes recommended

Changes are still needed because the review found two moderate correctness issues: a Javadoc/behavior mismatch in Accumulator.addEncodedBase() and a race in CountingConcurrentQueue.offer() that can under-count size() and break back-pressure or empty-wait logic.

2 open issues
What changed in this PR

This PR optimizes qsnp’s hottest path (base accumulation and queue back-pressure) to reduce allocations, improve cache locality, and cut GC overhead, with additional pipeline-side changes to better amortize accumulator growth and reduce scanning/spinning.

Changes:

  • Reworks Accumulator/AccumulatorUtils to use primitive arrays and split per-read/per-base encoding to reduce per-base overhead and allocations.
  • Updates qsnp Pipeline consumer/cleaner logic (adaptive initial capacities, fewer iterator allocations, reduced rescans/spins, contig-scoped compound SNP detection).
  • Introduces CountingConcurrentQueue to provide O(1) size() for producer back-pressure, plus adds/updates unit tests and runtime guidance (Parallel GC).
File Description
qsnp/​test/​org/​qcmg/​snp/​PipelineTest.java Adds regression tests for contig-scoped compound SNP handling and new consumer encoding/capacity behavior.
qsnp/​test/​org/​qcmg/​snp/​CountingConcurrentQueueTest.java Adds tests for queue semantics, iterator immutability, and concurrent producer/consumer behavior.
qsnp/​src/​org/​qcmg/​snp/​Pipeline.java Applies performance improvements in consumer/cleaner loops, contig-scoped compound SNP logic, and uses the new queue for back-pressure.
qsnp/​src/​org/​qcmg/​snp/​CountingConcurrentQueue.java Adds a counted wrapper around ConcurrentLinkedQueue to avoid linear-time size().
qcommon/​test/​org/​qcmg/​common/​util/​AccumulatorUtilsTest.java Adds tests ensuring new split-encoding matches legacy encoding and allocation-free helper behavior stays correct.
qcommon/​test/​org/​qcmg/​common/​model/​AccumulatorTest.java Adds tests for raw-array growth, list views, setData copying, and failed-filter storage growth.
qcommon/​src/​org/​qcmg/​common/​util/​AccumulatorUtils.java Optimizes encoding and hot helpers to avoid allocations and use raw-array reads.
qcommon/​src/​org/​qcmg/​common/​model/​Accumulator.java Replaces TLongArrayList storage with raw arrays and adds addEncodedBase + lightweight list views.
qbamfilter/​src/​org/​qcmg/​qbamfilter/​filter/​CigarFilter.java Switches to index-based CIGAR iteration to avoid iterator allocations.
docs/​qsnp/​qsnp_v2_0.md Documents Parallel GC and heap sizing recommendations for WGS tumour/normal pairs.
adama/​bin/​qsnp Defaults launcher to -XX:+UseParallelGC for improved WGS throughput and reduced CPU usage.

Review effort: Lite

Comment on lines +216 to +217
* @param initialCapacity capacity (in longs - each base uses 2) of the data list if this is the first base added to this Accumulator
* @return true if this was the first base added to this Accumulator
Comment on lines +33 to +38
@Override
public boolean offer(E e) {
queue.offer(e); // always returns true (or throws NPE for null)
count.increment();
return true;
}
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