Skip to content

perf: speed up per-read validation hot loops (~25% less CPU) - #2

Draft
corneliusroemer-agent wants to merge 1 commit into
loculus-project:masterfrom
corneliusroemer-agent:perf/readtools-hot-loop
Draft

perf: speed up per-read validation hot loops (~25% less CPU)#2
corneliusroemer-agent wants to merge 1 commit into
loculus-project:masterfrom
corneliusroemer-agent:perf/readtools-hot-loop

Conversation

@corneliusroemer-agent

Copy link
Copy Markdown

Not for merging. This is a record of an experiment, opened so the measurements and the patch don't get lost. It shows that a small, behaviour-preserving patch makes readtools' FASTQ validation meaningfully faster — about 25% less CPU — in case that's useful later, either here or upstream in enasequence/readtools.

Context: Loculus runs java -jar readtools.jar <mate1> <mate2> --format FASTQ once per submitted entry in its raw-reads processing service, and that step is one of the larger per-entry costs. I profiled it to find out where the time actually goes, and two things stood out as cheap to fix.

What's slow

Profiled with JFR on a warm JVM, 100k-read paired FASTQ (quick mode's cap), 540 samples, 99.8% attributed:

share where
28.0% InsdcReadsValidator.validate — the per-base IUPAC check
16.5% htsjdk FastqReader line reading
15.9% FastqReadsValidator.validateQualityScores
~22.7% Guava hashing for the pairing Bloom filter
11.9% PairedFastqReadsValidator read-name regexes

What this changes

InsdcReadsValidator — the per-base check was:

for (char base : effectiveBases.toUpperCase().toCharArray()) {
  if (iupacSet.contains(base)) { ... }

with iupacSet a HashSet<Character>. That's two allocations per read (an uppercased String and a char[]), plus a boxed lookup per base. At 64bp × 100k reads × 2 mates that's ~12.8M boxed HashMap lookups — HashMap.getNode was the single hottest leaf frame in the profile.

Replaced with two case-folded boolean[128] tables indexed by the raw char.

PairedFastqReadsValidatorgetNonCasavaReadNameWithoutIndex() and getNonCasavaReadIndex() were called back to back on the same read name, and each ran both patterns. So every read name was matched twice over. Now matched once, with both capture groups read from the one match. The four helpers this made dead are removed.

Behaviour

No intended behaviour change, including on the rejection path.

One case needed care. String.toUpperCase() means U+017F (long s, ſ) folds to S, which is a valid IUPAC code — so the original accepts it. A naive lookup table rejects it, which would fail submissions ENA's own webin-cli accepts, since it runs this same code. So reads containing any non-ASCII character fall back to the original toUpperCase() path. Pure-ASCII reads — i.e. all real data — take the fast path.

Verified byte-identical CLI output against the released v1.0.0 jar across 20 comparisons: 1-read / 5MB / 50MB / 500MB inputs, gzipped and plain, four distinct 100k-read pairs, single-end, and edge cases covering lowercase bases, mixed case, the full IUPAC alphabet, an invalid base (rejected identically), and the non-ASCII case above. The 227 tests in this repo pass.

Numbers

Measured as steady-state CPU time rather than wall clock — the test box had other JVMs on it and wall clock was too noisy to trust. Four distinct 100k-read pairs rotated through one JVM, pinned to a single core, JDK 21:

rep v1.0.0 patched
1 0.867s 0.779s 10% (outlier, cold cache)
2 0.869s 0.640s 26%
3 0.836s 0.629s 25%
4 0.855s 0.649s 24%

~25% less CPU in steady state, ~21% on a cold JVM.

Two caveats on those numbers: they're from an aarch64 container, not the deployment target, so treat the ratio as the transferable part; and they're a validation-call-level measurement, not end-to-end submission latency.

Things found along the way that aren't in this PR

  • Validation cost is flat above 100k reads, because quick mode stops there. A 500MB file costs the same as a 50MB one. Quick mode is also ENA webin-cli's own default (quick = true, with --no-quick marked hidden), so this isn't a Loculus-specific shortcut.
  • Most of a cold run's remaining cost is JIT warmup, not JVM startup — boot to main is only ~0.036s, but a cold call costs ~0.7-0.85s more than a warm one. Keeping a JVM warm is worth roughly 50% per call on top of this patch, which is a much bigger change than this one and isn't attempted here.
  • The next hot spots after this patch are the pairing Bloom filter and htsjdk's line reading. Neither looked as cheap to fix.

Two hot-loop changes in the FASTQ validation path, no behaviour change.

InsdcReadsValidator: the per-base IUPAC check ran
effectiveBases.toUpperCase().toCharArray() and then looked each base up in a
HashSet<Character>. That is two allocations per read plus a boxed HashMap
lookup per base. Replaced with two case-folded boolean[128] tables indexed by
the raw char. Bases containing non-ASCII fall back to the original
String.toUpperCase() path, so exotic case folding (U+017F uppercases to 'S',
a valid IUPAC code) still behaves exactly as before.

PairedFastqReadsValidator: the read name and the pair index were derived from
the same regex match but computed by two separate methods, so every read name
was matched twice by both patterns. Match once and read both groups.

Measured ~25% less CPU in steady state (~21% on a cold JVM) on 100k-read
paired FASTQ.

Co-Authored-By: Claude Opus 5 (1M context) <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.

1 participant