perf(seed): batched prefetching MMP search — measured at 0.5%, not recommended - #186
Open
BenjaminDEMAILLE wants to merge 15 commits into
Open
perf(seed): batched prefetching MMP search — measured at 0.5%, not recommended#186BenjaminDEMAILLE wants to merge 15 commits into
BenjaminDEMAILLE wants to merge 15 commits into
Conversation
`--soloUMIfiltering MultiGeneUMI_CR` kept every gene tied at the highest
read count. CellRanger's rule is the opposite on exactly that case: the
gene with the *strictly* highest count takes the UMI, and a tie means no
gene counts it.
STAR walks the genes keeping a running maximum and clears its winner
whenever it meets an equal count
(`SoloFeature_collapseUMIall.cpp:212-224`):
if (ig.second>maxu) { maxu=ig.second; maxg=ig.first; }
else if (ig.second==maxu) { maxg=-1; };
...
if ( maxg+1==0 ) continue; // not counted for any gene
One read per gene is the ordinary shape of a multi-gene UMI, and it is
always a tie, so the old rule made the flag inert in practice rather
than merely inaccurate. Measured on a 20 000-read 10x fixture (200 cells
from the real v3 whitelist, 400 genes, 720 UMIs deliberately shared
between two genes), against STAR 2.7.11b with the same flags:
identical entries STAR counts rustar counts
before 13 749 / 14 806 15 423 16 465
after 13 902 / 13 967 15 423 15 414
The flag removed nothing at all before; STAR removes 1 030 counts. The
gap goes from +1 042 to -9.
The outcome does not depend on the order the genes are visited — a
strict maximum always ends as the winner, a tie always ends with none —
so iterating a `HashMap` here stays deterministic.
`multi_gene_umi_cr_drops_a_tie_entirely` pins the case the old tests
missed: they only covered 3 reads against 1, where both rules agree.
Not yet implemented, and stated so rather than left to be discovered:
STAR applies a second condition, that the winning gene must also hold
the top count among *uncorrected* UMIs (`umiGeneMapCount0`, same file,
lines 226-232). That needs the pre-correction counts, which this code
does not keep. The 65 entries still differing out of 13 967 are the
place to look for its effect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…w matrix
STARsolo's raw matrix has a column per whitelist barcode. For 10x v3 that
is 3 686 400 columns and a 62 MB `barcodes.tsv`, nearly all zeros.
CellRanger's `raw_feature_bc_matrix` has a column per *observed* barcode.
The two files therefore share no keys, which is not a rounding difference
in a comparison, it is zero overlap: comparing our raw output against a
real `cellranger count` run gave 0 identical entries out of 27 396 until
the columns were reconciled.
`--soloOutRawBarcodes Observed` narrows the raw matrix to the barcodes
that carry a count. Default `Whitelist` keeps what STARsolo writes, so
nothing changes for anyone not asking.
Measured on the 20 000-read fixture:
Whitelist 3 686 400 barcodes barcodes.tsv 62 668 800 bytes
Observed 200 barcodes barcodes.tsv 3 400 bytes
with identical counts on both sides: 13 937 entries, 15 414 counts.
`finalize_matrix` already took a column remap for the filtered matrix, so
this reuses it rather than adding a second path. The observed set is read
back from the streamed body, which costs one pass and only when the flag
is on.
This is a **non-STAR flag** and needs sign-off; recorded in
`DIVERGENCE.md` §3.2 rather than presented as parity.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
STAR corrects UMIs within each gene *before* deciding which gene owns a UMI, and applies two conditions, not one (`SoloFeature_collapseUMIall.cpp:134-148` and `:203-235`): 1. one gene must hold a strictly higher read count than every other, on the **corrected** UMI map — that is scverse#173, already landed; 2. and that winner must not be beaten in the **uncorrected** map at the same key. The second condition exists because correction moves reads between UMIs: a gene can win only because correction folded a neighbouring UMI onto it, and STAR rejects that win rather than counting it. Reproducing it needs the order STAR uses. The generic path here filters multi-gene UMIs first and corrects afterwards, which cannot express either condition: by the time correction happens the ownership decision is already made. `MultiGeneUMI_CR` therefore takes its own path, which is also what STAR does — the flag is only valid with `--soloUMIdedup 1MM_CR`, so there is no combination this bypasses. `cellranger_1mm_map` exposes the correction mapping that `cellranger_1mm` already computed and threw away. Measured against **CellRanger 10.0.0** on the 20 000-read fixture from scverse#172, with scverse#165 and scverse#173 also applied: identical entries CellRanger rustar scverse#165 + scverse#173 13 651 / 13 709 15 111 15 091 plus this change 13 676 / 13 709 15 111 15 116 Entries CellRanger has and we do not go from 29 to 7, and the count gap from -20 to +5, which is 0.03%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Aligning 10x data and comparing the count matrix against CellRanger gave
a successful run and different numbers, with nothing in the output
pointing at the five flags that explain the difference. Measured against
CellRanger 10.0.0 on a 20 000-read fixture, those flags are the whole
gap: 8.9% away without them, 0.03% with them.
When the geometry is unambiguously 10x — `CB_UMI_Simple`, a whitelist, a
16-base cell barcode, a 10- or 12-base UMI — the five now default to
their CellRanger values:
--clipAdapterType CellRanger4
--outFilterScoreMin 30
--soloCBmatchWLtype 1MM_multi_Nbase_pseudocounts
--soloUMIfiltering MultiGeneUMI_CR
--soloUMIdedup 1MM_CR
A flag given on the command line always wins, including when the value
asked for is STARsolo's own default: `value_source` distinguishes an
explicit flag from a default, so the divergence is escapable by naming
what you want. Every substitution is logged at INFO with the geometry
that triggered it.
**This changes default output behaviour on 10x runs and diverges from
STARsolo**, which is why it is confined to a geometry nothing else in
common use shares, why it is announced on every run it touches, and why
it is in `DIVERGENCE.md` §1.3 as the largest entry in that file. It needs
sign-off.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-derived both sides from one clean state: the five flags move the matrix from 8.96% above CellRanger to 2.17% above it, not to 0.03%. The earlier figure compared a rustar run against a CellRanger run built from a different state of the fixture. STAR 2.7.11b with the same flags is at +0.09%, so the remaining gap is open rather than closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The figure I replaced this with was measured without scverse#165, whose cbMinP posterior threshold is a precondition for it. With scverse#165 the five flags move the matrix from 8.96% above CellRanger to 0.03% above it, which is what the original text said. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writes the same numbers where `cellranger count` writes them:
outs/{raw,filtered}_feature_bc_matrix/, gzipped, a -1 GEM-well suffix on
every barcode, one raw column per observed barcode, and no per-feature
subdirectory when a single feature is requested.
The layout implies --soloOutGzip yes, --soloOutRawBarcodes Observed and an
outs/ output directory; each is still overridable on the command line. On
10x geometry it is the default, alongside the five flags from the previous
commit, so it changes where output files are written on those runs.
Counts are untouched: 13 959 entries and 15 439 counts in both layouts on
the 20 000-read fixture, compared entry by entry. Against a real
cellranger count 10.0.0 run on the same fixture the raw barcode sets match
exactly, 200 of 200.
Recorded in DIVERGENCE.md 3.3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writes CellRanger 10.0.0's 20 metrics, in its order and value formats (thousands separators, quoted when that adds a comma; percentages to one decimal). STARsolo's Summary.csv is unchanged and still written alongside. Twelve of the 20 match a real cellranger count 10.0.0 run exactly on the 20 000-read fixture. The other eight rest on an interpretation of a denominator CellRanger does not document, and each interpretation is stated in DIVERGENCE.md 3.4 rather than left implicit. Three metrics needed new counters: Q30Stats tallies Phred >= 30 bases over the barcode, the UMI and the cDNA read, populated only when the layout asks for the metrics. The exonic/intronic split needs the gene-body overlap query, so a Gene-only run under this layout now performs it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10x define it as 1 - n_deduped_reads / n_reads over unique (barcode, UMI, gene) combinations among confidently mapped reads. Implementing that literally, counting distinct triples before UMI correction, gives 0.0% on the fixture: no two reads there share an exact triple. So the literal reading is wrong and their numerator is the corrected molecule count, like ours. What differs is the read denominator, which 3.4 now says instead of leaving the disagreement unexplained. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same quantile-over-ratio rule STARsolo calls CellRanger2.2, but with the expected cell count searched for instead of fixed at 3 000: minimise (OrdMag(x) - x)^2 / x over x from 2 to maxExpectedCells, where OrdMag(x) is the number of cells the rule calls when told to expect x of them. The loss is small where the rule predicts itself. EmptyDrops_CR now uses it for its initial cell set, which is the order CellRanger runs the two steps in. CellRanger2.2 is untouched and stays the default. Two things 10x's page leaves unspecified are decided here and documented: every integer in range is evaluated rather than a geometric grid, so the result is the true minimum of the stated loss and not a nearby grid point; and ties go to the smaller x, so nothing depends on iteration order. On the 20 000-read fixture this changes nothing: CellRanger2.2, OrdMag and EmptyDrops_CR all call 200 cells, as does CellRanger 10.0.0. That fixture has a clean plateau where any of these rules works. The two part company on a graded distribution, which is what the unit tests cover. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CellRanger has included intronic reads in its gene counts since v7.0. STARsolo's Gene feature is exonic-only, so a 10x run compared against CellRanger came out 30.5% low. --soloFeatures now defaults to GeneFull on 10x geometry, alongside the other CellRanger defaults; naming the flag explicitly still wins. Measured on 10x's own pbmc_1k_v3, 20 M read pairs, refdata-gex-GRCh38-2024-A on both sides, against cellranger count 10.0.0: Gene 6 382 961 counts, -30.5%, 52.9% of entries identical GeneFull 9 039 161 counts, -1.6%, 91.8% of entries identical The yeast fixture could not show this: yeast has almost no introns, and there GeneFull counted fewer than Gene because its dense overlapping genes turn unambiguous exonic reads into ambiguous gene-body ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cluster_seeds built a fresh FxHashMap per read. The pre-sizing to anchor_indices.len() * 2 was only a floor: merging two windows re-keys every bin in the merged span, so a read with wide windows inserts far more entries than it has anchors and the map rehashes. Sampling a human 10x run put hashbrown::reserve_rehash at 2.1% of on-CPU time, all of it here. The map is now kept per thread and cleared per read, so its capacity settles at what the workload needs and the growth is paid once per thread. It is taken out of the thread-local rather than borrowed across the body, so a nested call would get a fresh map instead of panicking on an active borrow. Output-neutral: matrix.mtx, barcodes.tsv, features.tsv and SJ.out.tab are byte-identical before and after on 20 M read pairs of 10x pbmc_1k_v3 against GRCh38, compared decompressed. Interleaved A/B, 8 pairs: new faster in 8 of 8, median -7.0%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-window Vec<WindowAlignment> was cloned into the SeedCluster while `windows` was dropped one statement later, so every read paid a full copy per window to throw the original away. std::mem::take moves it instead. Output-neutral, verified by an empty diff on 20 M read pairs of 10x pbmc_1k_v3 against GRCh38. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports the donor's batched suffix-array search (STAR-rs
crates/star-index/src/search.rs): max_mappable_length becomes a state
machine (Phase, MmpState, mmp_advance, mmp_step_loop, mmp_finish) so a
search can be suspended at each probe, and max_mappable_length_batch drives
SEED_BATCH_WIDTH of them together.
Three passes per round, and the split is the point: issue every live
search's suffix-array prefetch, then read those entries and prefetch each
search's dependent genome byte, then compare. One search serialises those
two DRAM misses; N overlap them. find_mult_range stays sequential, as in
the donor.
SEED_BATCH_WIDTH is 32 because the donor measured 2.09x there and a
regression at 8. A single read only has 2-4 independent chains, well under
that, so find_seeds_batch takes a slice of reads and drives their chains in
lockstep. Each chain's seeds go to their own bucket, so the concatenation
reproduces the sequential push order exactly, and seedPerReadNmax is applied
by truncation, which is what the sequential early return amounts to.
find_seed_at_position is split into prepare_seed_at_position and
finish_seed, shared by both paths, so the sequential and batched searches
cannot drift.
Equivalence, not just tests passing:
- batched == sequential at widths 1/2/8/32/128, over every start position
of seven reads, plus single-element ranges and empty batches
- find_seeds_batch == per-read find_seeds, same seeds and same order
- empty diff on 20 M read pairs of 10x pbmc_1k_v3 against GRCh38:
matrix.mtx, barcodes.tsv, features.tsv, SJ.out.tab and Log.final.out
identical apart from the timestamp
Order matters as much as content here: cluster_seeds creates windows in
seed order and the earliest window wins the primary tie-break, so a
reordering would change which alignment is primary without changing any
seed. The test says so.
Co-Authored-By: Claude Opus 5 (1M context) <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 #185 → #184 → #182 → #179 → #178 → #176 → #175 → #174 → #173.
I measured this at 0.5% and I do not recommend merging it as it stands. That is the headline, not the footnote, and the rest of this explains how I got there so you can disagree with the reasoning rather than the conclusion.
This is roadmap P3's lead item: the donor's batched prefetching MMP search, which its own benchmarks put at 2.09× on the search engine.
What it does
max_mappable_lengthbecomes a state machine (Phase,MmpState,mmp_advance,mmp_step_loop,mmp_finish) so a search can be suspended at each probe, andmax_mappable_length_batchdrivesSEED_BATCH_WIDTHof them together. Three passes per round, and the split is the point: issue every live search's suffix-array prefetch, then read those entries and prefetch each search's dependent genome byte, then compare. One search serialises those two DRAM misses; N overlap them.A single read has only 2–4 independent chains, well under the width where interleaving pays (the donor measures a regression at 8), so
find_seeds_batchtakes a slice of reads and drives their chains in lockstep.The measurement
Profiling first put the seed search at 16.4% of on-CPU time on a human 10x run. A 2.09× on that would be worth ~8.6% of wall clock. So I built it, then measured it in isolation — this branch against #185's tip, which carries the
cluster_seedswork but not this:Faster in 4 of 4, median −0.5%. ABBA ordering, drift across the run only 2%, so for once the effect is well clear of the noise — it is just a small effect.
0.5%, not 8.6%. I would rather report the factor-of-seventeen miss than the direction.
I also measured the whole branch against the pre-P3 baseline at −4.6% (4/4 blocks), which looks better but is almost entirely #185's two
cluster_seedschanges. Attributing that to this PR would be exactly the error I made earlier in this stack when I quoted a figure measured without #165.Why the donor's 2.09× does not transfer
One concrete reason, verifiable in the diff: rustar already prefetches inside a single search.
max_mappable_lengthissues prefetches for both possible next midpoints before comparing the current probe, with a comment saying so. The donor's sequential baseline had no such thing, so its batch was recovering memory-level parallelism that here was already partly recovered.Two more I can offer only as hypotheses, since I did not instrument them: the genome is byte-per-base with a SIMD scan, so
compare_seq_to_genomemay be more compute than stall; and theSAindexhierarchical lookup resolves many positions outright, so fewer positions reach the batched search than the 16.4% figure suggests.It is correct, at least
Whatever you decide about the trade, the equivalence is not in doubt:
batched_mmp_search_matches_the_sequential_one_at_every_width— widths 1/2/8/32/128, every start position of seven readsbatched_mmp_handles_single_element_ranges_and_empty_batchesbatched_seed_finding_matches_per_read_sequential_calls— same seeds and same order; order matters becausecluster_seedsbuilds windows in seed order and the earliest window wins the primary tie-break, so a reordering would change which alignment is primary without changing any seedpbmc_1k_v3against GRCh38:matrix.mtx,barcodes.tsv,features.tsv,SJ.out.tab, andLog.final.outidentical apart from its timestampfind_seed_at_positionis split intoprepare_seed_at_positionandfinish_seed, shared by both paths, so the sequential and batched searches cannot drift by transcription.chain_starts,push_seedandfinalize_seed_orderare shared for the same reason.Gate: 584 lib + 27 integration tests,
cargo clippy --all-targets -- -D warnings,cargo fmt --check, all green.My recommendation
Close it, or park it. 892 lines and a restructured hot path, on the file #146 is also rewriting, for 0.5% on the one workload I can measure. The state machine and its equivalence tests are the durable part; if the seed search ever becomes a larger share — a much faster stitcher would do it — this is ready and proven equivalent.
If you would rather keep only the cheap part, #185 stands alone and carries most of the measured gain.