Repository navigation
STARsolo UMI dedup: look up 1-mismatch neighbours instead of comparing all pairs (200k UMIs: 44-109 s to 62-208 ms) - #362
Open
BenjaminDEMAILLE wants to merge 2 commits into
Open
BenjaminDEMAILLE wants to merge 2 commits into
BenjaminDEMAILLE wants to merge 2 commits into
Conversation
connected_components, cellranger_1mm_map, graph_correction_map and directional_correction_map compared all pairs of a (cell, gene) UMI set. They now share OneMmFinder, which compares pairs for small sets and looks up the 3 * umi_len single-base variants of a UMI for large ones. The two union-finds are replaced by one component walk, for_each_1mm_component. The old functions stay in the test module as the reference for a randomized comparison on every method. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A UMI longer than --soloUMIlen can share its half-swapped value with another
UMI. The pick then depended on the order the component was walked in: with
{3019, 4042, 976} and --soloUMIlen 5, 3019 was corrected to 4042, where the
smaller-raw rule gives 4042 -> 3019. The smaller raw UMI now wins, so the
UB tag no longer depends on walk order. Molecule counts are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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.
Part of #354 (item 24).
Not stacked. #345 and #346 both touch
src/solo/count.rs, but neither changes the dedup functions (cell_feature_countsanddistribute_multiare what they change), so this is based onmain.git merge-treereports no conflict between this branch and each of #345 and #346.Problem
Four functions compared every pair of a
(cell, gene)UMI set:connected_components,graph_correction_map,cellranger_1mm_mapanddirectional_correction_map. Measured below, 200,000 UMIs in one(cell, gene)take 44 to 109 s per method, and becausebuild_matrix_bodycalls this inside a parallel per-cell map, one deep gene holds a worker for that long. WithCB/UBtags the correction map is computed a second time for the same set.Change
OneMmFinderfinds the UMIs one mismatch from a given UMI. Above 224 UMIs it looks up the3 * umi_lensingle-base variants in the set's ownHashMap; up to 224 it compares pair by pair, since most sets are small and a comparison is cheaper than 36 hash misses. There is no extra index: theumi -> countmap the caller already built is enough.cellranger_1mm_mapno longer sorts. "The last 1MM neighbour with a later position in(count, umi)order" is the largest(count, umi)key above the UMI's own, whichever order the neighbours arrive in.directional_correction_mapstill walks the UMIs in(count descending, umi ascending)order, because a UMI takes its hub's corrected value and the hub has to be done first. "The first earlier hub passing the count test" becomes the smallest such key among the neighbours.for_each_1mm_componentwalks each component with a work list and a seen-set, and bothconnected_componentsandgraph_correction_mapuse it, which removes more code than a shared union-find would. The representative of a component (highest count, then smallest half-swapped value) depends on the component only, and the smaller raw UMI breaks what is left, so nothing depends on walk order.swap_halvesalone is not enough: a UMI longer than--soloUMIlencan share its half-swapped value with another. With{3019, 4042, 976}and--soloUMIlen 5, the first version of this PR corrected 3019 to 4042 by walk order;umis_longer_than_umi_len_match_referencenow checks that 4042 goes to 3019. Theall_pairsreference applies the same rule. Onmainsuch a tie went to the first UMI in hash order, soUBcan differ frommainin that case only; molecule counts cannot.hamming1is now constant-time bit arithmetic instead of a per-base loop. This is what keeps small sets from getting slower: the scan looks at all pairs, not half of them, and no longer stops early.cellranger_1mmanddirectionalcount distinct values in anFxHashSet, not a SipHashHashSet. Only.len()is read.One case stays quadratic on purpose.
--soloUMIpositiondoes not have to agree with--soloUMIlen, so a packed UMI can be longer thanumi_len.hamming1only compares the lastumi_lenbases, so two such UMIs can be neighbours with different high bases, and no lookup of single-base variants would find them.OneMmFinder::newdetects this and stays pairwise, to keep results as they were. I did not check whether comparing only part of the UMI is what STAR does in that configuration; it is the existing behaviour, and it is not changed here.No STAR behaviour is claimed by this PR: the point is to be identical to the code it replaces. The comments that cite STAR's functions were already there.
Measured
cargo test --release --lib umi_dedup_timing -- --ignored --nocapture, on this branch. It runs the old code (kept in the test module) and the new code in one binary, on the same sets: random 12-base UMIs, 1 to 3 reads each. macOS arm64, 16 cores. The machine was busy, with a load average of 65 to 120, so absolute numbers are pessimistic. Up to 1,000 UMIs each number is the best of five rounds; 10,000 and 200,000 are a single round, so the spread between methods at 200,000 on the new code (62 to 208 ms) rests on one round each.Large sets
1MM_Allbefore1MM_Directionalbefore1MM_Directional_UMItoolsbefore1MM_CRbeforeSmall sets (ns per call)
1MM_Allbefore1MM_Directionalbefore1MM_Directional_UMItoolsbefore1MM_CRbeforeThe one cell where "after" is above "before" is
1MM_Directionalat 10 UMIs, by 2 %. So "not slower" holds everywhere measured but that one, and that one is small enough that I would not claim either way.Choosing 224
Measured by building with the limit set to 0 and to 1,000,000 and running the same test.
1MM_All1MM_AllLookup is not monotonic. On random 12-base UMIs almost every lookup is a miss, and the rows line up with how full the map is: 100 and 200 keys fill 78 % of 128 and 256 buckets, and 128 and 500 keys about 50 % of 256 and 1,024.
hashbrowngrows at 7/8, so 224 keys are the most that 256 buckets hold and 225 move to 512 buckets. That is why the limit is 224 and not, say, 200. I did not run 224 or 225 themselves; 200 and 500 are the nearest rows.This is not optimal everywhere: at 128 UMIs lookup alone would take 11,219 ns where this takes 18,022 ns, against 40,696 ns before.
End to end,
mainagainst this branch300,000 reads on one barcode and one gene of a small synthetic genome, 150,000 random 12-base parent UMIs, 10 % of reads with one changed base: 148,070 distinct UMIs.
--runThreadN 4,--outSAMtype BAM SortedByCoordinate --outSAMattributes NH HI CB UB. "Same records" issamtools view | sortcompared in full, so it includesUBon all 300,000 records. The genome is not in the repository; the timing table above is what reproduces from the branch alone.--soloUMIdedup--soloUMIfilteringmatrix.mtxmainExact-NoDedup-1MM_All-1MM_Directional-1MM_Directional_UMItools-1MM_CR-1MM_CRMultiGeneUMI_CR1MM_AllMultiGeneUMIWith a single gene, the two filtering rows run the code paths but no UMI is actually multi-gene. Multi-gene behaviour on the changed functions is covered by the existing
multi_gene_umi_cr_*tests only.test/solo_diff_docker.shRun from a copy of this branch:
PASSon the 3' run (rustar-aligner matrix matches real STARsolo exactly) and on both 5' PE matrices (GeneandGeneFull, byte-identical). Two limits on what that shows: the container's STAR is Debian's 2.7.10b, not 2.7.11b, and the fixtures have 3 entries each, so they do not reach the lookup. It only says small sets still agree with a real STARsolo.Tests
In
src/solo/count.rs:umi_dedup_matches_all_pairs_reference: 3,000 random sets, against the old code kept inmod all_pairs, for all six methods, comparingdedup_count,umi_correction_mapand the fullcellranger_1mm_map. UMI lengths 1 to 12, so anywhere from nearly full to sparse; counts capped at 1, 2, 3, 10 or 1000 to force ties and chains. One set in twenty is over the limit. It asserts that more than 500 sets had corrections and more than 100 reached the lookup.one_mm_finder_paths_agree: the two paths ofOneMmFinderreturn the same neighbours on the same set, including sets under the limit.hamming1_matches_the_base_by_base_reference: 100,000 pairs against the old loop, lengths 1 to 32, including bits pastlen.umis_longer_than_umi_len_match_reference: the longer-than-umi_lencase above, on 896 draws of 8-base UMIs atumi_len5.umi_dedup_timing:#[ignore]d, the table above.I checked that these tests can fail by changing the code by hand and running them, then putting it back:
umi_dedup_matches_all_pairs_reference,umis_longer_than_umi_len_match_referencecellranger_1mm_collapses_neighbor,exact_dedup_corrects_nothingumi_dedup_matches_all_pairs_reference,umis_longer_than_umi_len_match_referenceumi_dedup_matches_all_pairs_referenceumi_dedup_matches_all_pairs_referenceumi_dedup_matches_all_pairs_reference,umis_longer_than_umi_len_match_referenceone_mm_finder_paths_agree,umi_dedup_matches_all_pairs_referenceumis_longer_than_umi_len_match_referenceThe reference adds about 190 lines to the test module. The whole
solo::counttest module took 6 to 16 s in a debug build, depending on the load, and 0.5 s in release.cargo test --release(817 passed, 1 ignored),cargo clippy --all-targetsandcargo fmt --checkare clean on macOS.Not checked
test/parity_suite.shwas not run.--soloUMIlen 33or more) are not compared with the old code. A packedu64cannot hold them, and the oldhamming1wrapped its shifts in that case, so there is no meaningful result to keep. Such sets stay on the pairwise path.CB/UBpath computes the correction twice, as before. I left that alone.🤖 Generated with Claude Code