STAR BAM sort memory: --outBAMsortingBinsN is not the lever, read order is #203
Replies: 1 comment
Resolution — what we decided, and what we did NOT doClosing this out. The investigation above is correct and worth keeping, but the decision went a Decided:
NOT adopted, despite being where the analysis pointed:
One correction to the round-2/round-3 analysisThe report attributes the per-read if (featureYes[Velocyto] || featureYes[VelocytoSimple]) readInfoYes[Gene] = true;
if (samAttrYes) readInfoYes[samAttrFeature] = true;Our For comparison: what scRecounter doesSame problem, different constraints ( withLabel:process_high { cpus = 8; memory = { 72.GB * task.attempt }; maxRetries = 3 }
errorStrategy { task.attempt <= maxRetries ? 'retry' : 'ignore' }They sidestep the sort completely with Two things worth taking: 8 CPUs to 72 GB is the right shape for this workload (memory-heavy, |
Uh oh!
There was an error while loading. Please reload this page.
STAR BAM coordinate-sort memory: what the source actually proves
Sources. All code citations are against
alexdobin/STARat commitb1edc1208d91a53bf40ebae8669f71d50b994851, which is bothmasterHEAD (checked 2026-08-03) and the2.7.11brelease tag — i.e. exactly the version you measured, and still the newest STAR that exists.Manual citations are
doc/STARmanual.pdffrom that tree. Issue citations are GitHub issues on thatrepo, restricted to comments authored by
alexdobin.Permalink prefix:
https://github.com/alexdobin/STAR/blob/b1edc1208d91a53bf40ebae8669f71d50b994851/Answer up front
1. Your hypothesis is wrong about the mechanism. Bin boundaries are NOT equal genomic length.
They are equal-count quantiles of observed alignment start positions — STAR sorts a sample of
start positions and cuts at equally-spaced ranks. Confidence: certain (
BAMoutput.cpp:138-142,quoted below). A coverage pileup spread over ~10 kb is therefore not, by itself, able to defeat
binning: the quantile cuts will subdivide the pileup itself.
2.
--outBAMsortingBinsNgenuinely does reduce peak sort memory — it is the primary knob.--limitBAMsortRAMis compared against the largest single bin, not the total. So the sortrequirement is
total / (nBins-1)when binning works. Dobin states the same formula himself(issue #457): "the max RAM required for sorting can be approximated as the size of unzipped fastq
files ... divided by the
--outBAMsortingBinsNvalue." Confidence: certain for the code,high for the practical formula.
3. Memory is therefore NOT linear in total records — it is linear in the largest bin. It is
bounded, and the bound is under your control, provided the quantile sample is representative.
4. The reason binning did nothing for you is almost certainly NOT the rDNA pileup. It is almost
certainly that your FASTQ has non-random read order. The quantile boundaries are estimated from
the first ~50 MB of BAM output — i.e. the first few hundred thousand reads of the file and
nothing else. If those reads are in genome-coordinate order (the universal signature of a FASTQ
regenerated from a CellRanger/coordinate-sorted BAM via
bamtofastq), every boundary lands insideone tiny genomic window, and all the rest of the genome falls past the last boundary into a single
bin. Then max-bin ≈ total, and no value of
--outBAMsortingBinsNcan help — which is exactly whatyou measured. Dobin has diagnosed this same failure four separate times (#289, #1136, #1762, #2070),
and in #2070 the user's requirement dropped from 400 GB to 60 GB purely by shuffling the FASTQ.
Confidence: high (see the arithmetic in "Does it explain our measurement?" — your numbers are
~74x above what working bins would produce, which is the signature of total collapse, not of a 44%
pileup).
5. There is no spill path. None. At all. The sort is strictly in-RAM per bin: one
new char[binS+1]for the whole bin plus one
new uint[binN*3],qsort, write, free. Refusal-or-bad_allocare theonly outcomes. Dobin, 2018: "robust sorting is high on my TODO list" — still open in 2025.
6. For your 2.44-billion-record sample: yes, it is sortable in sane RAM — but the BAM sort is not
your binding constraint. With working bins at
--outBAMsortingBinsN 200the sort needs ~2 GB perbin. The real floor is STARsolo's
readInfoarray, 16 bytes × 2.23e9 input reads = ~36 GB, whichis invisible to
--limitBAMsortRAMand is held live during the sort. PlusrGeneUMIat ~24 GB.Budget ~70-90 GB for that sample, dominated by Solo, not by sorting. There is also a hard architectural
ceiling at 2^32 = 4.295 billion reads (read index is packed into the top 32 bits of a 64-bit word);
you are at 52% of it.
Evidence
1. How bin boundaries are computed — quantiles of a sample, not genomic length
source/BAMoutput.cpp:118-144(BAMoutput::coordBins) — the decisive function.permalink
Read
startPos[binTotalN[0]/(nBins-1)*ib]:startPosis the sorted array of alignment startpositions, and the index is an equally spaced rank. That is a quantile cut. Nothing here
references
chrStart,nGenome, or chromosome lengths. Your "equal genomic length" hypothesis isrefuted by this line. The design intent is explicitly equal counts per bin.
The author's own inline comment
//how to deal with equal boundaries???is an acknowledgedunhandled case — see §"tie floor" below.
Note also the encoding:
alignG = (chr_index << 32) | position, a single 64-bit genome-globalcoordinate, so ordering across chromosomes is well defined.
The sample that defines the boundaries is a fixed ~50 MB and does not grow with the dataset.
source/BAMoutput.cpp:9-33:nBins=P.outBAMcoordNbins; binSize=P.chunkOutBAMsizeBytes/nBins; bamArraySize=binSize*nBins; ... binSize1=binStart[nBins-1]-binStart[0]; nBins=1;//start with one bin to estimate genomic bin sizesnBinsstarts at 1, so every alignment goes to bin 0 until that buffer (binSize1, ≈chunkOutBAMsizeBytes*(nBins-1)/nBins) fills; thencoordBins()runs(
BAMoutput.cpp:97-105) and re-bins retroactively.chunkOutBAMsizeBytes = limitIObufferSize[1](
Parameters.cpp:1163), default 50000000 (parametersDefault:232). At ~160 B/record that is~300,000 alignment records — for a 2 M-record run or a 2.44 G-record run, identically.
P.outBAMsortingBinStartis global and computed once, by whichever thread fills first, under amutex, guarded by
outBAMsortingBinStart[0]!=0(Parameters.cpp:655initialises it to 1). So theboundaries come from one thread's first ~50 MB, i.e. effectively the head of the FASTQ.
Dobin confirms this verbatim in issue #289 (2017-07-07):
Bin assignment —
source/BAMoutput.cpp:86-93:bamIn32=(uint32*) bamIn; alignG=( ((uint) bamIn32[1]) << 32 ) | ( (uint)bamIn32[2] ); if (bamIn32[1] == ((uint32) -1) ) {//unmapped iBin=P.outBAMcoordNbins-1; } else if (nBins>1) {//bin starts have already been determined iBin=binarySearch1a <uint64> (alignG, P.outBAMsortingBinStart, (int32) (nBins-1)); };Mapped reads occupy bins
0 .. nBins-2(that isnBins-1bins); the last bin is reserved forunmapped reads. This is why every memory formula below divides by
nBins-1, notnBins.The tie floor.
source/serviceFuns.cpp:239-263,binarySearch1a"returns the last X elementthat is <= x", and explicitly walks forward over duplicates:
Consequence, proven by code: if a single genomic start coordinate holds more than
1/(nBins-1)of the sample, several consecutive quantile boundaries take the same value; the bins between them
are empty (skipped by
binS==0 continue,bamSortByCoordinate.cpp:59) and every record at thatcoordinate lands in one bin. So:
That is a hard floor no
--outBAMsortingBinsNcan go below. It is the mechanism behindTyberiusPrime's report in issue #413 (2025-06-16): a targeted-panel scRNA-seq where "STAR wants142 gigs of RAM for sorting, no matter that I set --outBAMSortingBinsN to 6400."
2. What
--limitBAMsortRAMis compared against — the LARGEST BINsource/bamSortByCoordinate.cpp:14-33.permalink
if (binS>maxMem) maxMem=binS;— this is a max over bins, summed across mapping threads withineach bin. It is not the total and not a running allocation.
ibin<nBins-1excludes theunmapped bin.
Per-record cost:
binTotalBytesaccumulatesbamSize + sizeof(uint)(BAMoutput.cpp:113), i.e. theBAM record plus an 8-byte read index; the check adds 24 more bytes/record for the sort key triple.
So memory/record = BAM_record_bytes + 32, and this matches the actual allocation exactly
(
BAMbinSortByCoordinate.cpp:11-12:new char[binS+1]+new uint[binN*3]).3. What "Max memory needed for sorting" means
It is
maxMem= the largest single bin, as printed atbamSortByCoordinate.cpp:28. Not thetotal. The number in the error message is that same value + 1 GB of slack
(
bamSortByCoordinate.cpp:32).4. Is there any spill / adaptive / second-pass path? — No.
source/BAMbinSortByCoordinate.cpp:7-52is the entire sort:Whole bin into RAM, single
qsort, stream out via bgzf,delete[]. No chunked merge, no tempspill, no re-binning, no retry with more bins. The only "adaptivity" anywhere is the one-shot
coordBins()re-bin of the first buffer.--limitBAMsortRAMhas a second, distinct role as a concurrency budget(
bamSortByCoordinate.cpp:64-77):So actual peak RSS during sorting ≈ min(limitBAMsortRAM, Σ concurrently-sorted bins), whereas
the refusal threshold is max-bin. Raising
--limitBAMsortRAMpast what is needed therefore letsSTAR consume more. (Two incidental defects:
sleep(0.1)truncates tosleep(0)— a busy spin at100% CPU; and if
maxMem == limitBAMsortRAMexactly, the>check passes but the strict<gatenever opens — an infinite hang. Neither should bite you if you leave headroom.)
The unmapped bin is not a memory risk:
BAMbinSortUnmapped.cpp:19-78is a streaming k-waymerge over
2*nThreadsfile handles with oneBAMoutput_oneAlignMaxBytesbuffer each — O(1) inrecord count.
--outSAMunmapped Withincosts nothing here.5. What the manual says
doc/STARmanual.pdf§17 (Output: SAM/BAM), option list — verbatim and complete:The manual states no memory relationship at all. (It also says "genome bins", which is the
misleading phrase that probably seeded your equal-genomic-length hypothesis — the code does quantiles.)
The only other manual text is under
--outSAMtype ... SortedByCoordinate:and
--limitBAMsortRAM:The memory claim lives in
CHANGES.md:371(STAR 2.6.0a, 2018/04/23), where the option wasintroduced:
6. Known issues and what Dobin recommends
The most quantitative statement, issue #457, alexdobin, 2020-03-21:
That formula is exactly
total / nBinsand is the author confirming binning divides the requirement.Issue #2070 ("STARsolo BAM sorting required too much RAM", user needed 450 GB). alexdobin,
2024-02-23:
alexdobin, 2024-03-01 — the decisive diagnosis, and a precise description of the code path above:
Outcome, reported by the user 2024-03-04: FASTQ had been converted from a BAM; after
fastq-shuffle.pl, RAM dropped from 400 GB+ to 60 GB. Note this user was already running--outBAMsortingBinsN 400and it had not helped — same symptom as yours.Issue #289, alexdobin, 2017-07-10 — the diagnostic recipe:
Issue #1762, alexdobin, 2023-02-21 (STARsolo, FASTQ from a CellRanger BAM):
Issue #1136, alexdobin, 2021-02-11: same diagnosis ("FASTQs recreated from sorted BAM files"),
same two remedies.
Issue #870, alexdobin, 2020-04-10 — the pileup variant:
Issue #1792, alexdobin, 2023-03-17 — directly on your exact situation (STARsolo CB/UB forcing the
internal sort):
Issue #413, alexdobin, 2018-05-04 — no spill path, and none planned then:
That issue is still open as of the 2025-06-16 comment. Nothing shipped.
Issue #942, alexdobin, 2020-06-16 and #1923, 2023-10-13: the general fallback is
--outSAMtype BAM Unsorted+samtools sort— which for you is blocked by CB/UB.7. Interaction with
--outBAMsortingThreadNand thread countsource/Parameters.cpp:648-653:Three real interactions:
nBins >= 3 * sortingThreads.--outBAMsortingThreadN 20silently forces(thread-count independent), but actual concurrent allocation is up to
outBAMsortingThreadNactualbins at once, gated only bylimitBAMsortRAM(
bamSortByCoordinate.cpp:64-77). More sorting threads = higher real RSS, same refusalthreshold. If you are RSS-constrained rather than refusal-constrained, lower
--outBAMsortingThreadN.BAMoutput.cpp:27opens oneofstreamper bin per mapping thread in the constructor. The mapping-stage buffer(
bamArraySize) is fixed atchunkOutBAMsizeBytesregardless ofnBins— bins cost zero extramapping RAM. Dobin, issue STAR invents introns longer than any that exist: no map module sets alignIntronMax, and the default is mammal-calibrated #457, 2018-08-03:
Does it explain our measurement?
Your pileup hypothesis: refuted as stated, and the arithmetic says it is not the cause either
Two independent reasons.
(a) The mechanism is wrong. Boundaries are count-quantiles (
BAMoutput.cpp:139), not genomiclength. A 44%-of-reads pileup spread over ~10 kb attracts ~44% of the boundaries too. With 50 bins,
~21 of the 48 cuts land inside the rDNA array, spaced roughly
10 kb / 21 ≈ 480 bpapart. The pileup gets subdivided, exactly as designed.(b) The magnitude is wrong. Only an exact-coordinate tie collapses bins (§1, tie floor), and
the floor is the multiplicity of the single most common start position. At your 2 M-record test with
44% rDNA, that is ~880 k records spread over ~10 kb ≈ ~90 records per coordinate, i.e. a floor of
~14 KB. Even 100x concentration gives ~1.4 MB. That is three orders of magnitude below your measured
394 MB.
What your numbers actually say
Take your data at face value, assuming you read "Max memory needed for sorting" from
Log.out(orsubtracted the 1 GB slack from the error message) — either way, that number is max-bin:
Your measured max-bin is ~49x larger than the prediction — i.e. it equals the TOTAL. Max-bin ≈
total is the arithmetic signature of one bin holding essentially every mapped record. And
bytes/record = 161-197is precisely the expectedBAM_record + 32for a single un-split pool — itis a total-bytes rate, not a max-bin rate.
Total collapse plus insensitivity to
nBins(50 ≈ 200 ≈ 1000) is the exact fingerprint Dobindescribes in #2070: boundaries all crammed into one small window seen at the head of the file,
everything else past the last boundary in one terminal bin. Adding boundaries just subdivides the
already-empty head window more finely — which is why 1000 bins was marginally worse, not better
(the sample buffer
binSize1 = bamArraySize*(nBins-1)/nBinsgrows slightly withnBins, and theterminal bin still absorbs everything).
Most likely root cause: your FASTQs are in genome-coordinate order. For 10x data this is
overwhelmingly common — public 10x submissions are frequently deposited as CellRanger BAMs and
regenerated with
bamtofastq/cellranger bamtofastq, which preserves the coordinate-sorted order.This is exactly issues #289, #1136, #1762 and #2070.
This is good news for you: it is a property of the file, not of the biology, and it is fixable by
shuffling — the #2070 user went 400 GB -> 60 GB.
Caveat I must flag. I am inferring root cause from the ratio of your numbers to what the code
predicts. I have not seen your
Log.out. There is a free, zero-compute check that settles itoutright — see "cheapest decisive experiment" below.
What it means for a 2.44-billion-record sample
Using your own 160 B/record (which is
BAM_record + 32, consistent with the code):If binning works (random read order): max-bin ≈
2.44e9 * 160 / (nBins-1)--outBAMsortingBinsNYour 390 GB naive extrapolation is not the linear-in-records law — it is precisely the
bins-fully-collapsed number. With working bins the sort is a non-issue at any sample size.
But the BAM sort is not your binding constraint. STARsolo allocates, and holds live across the
sort:
SoloFeature_countCBgeneUMI.cpp:16—readInfo.resize(nReadsInput, ...)wherenReadsInput = g_statsAll.readN+1(SoloFeature_sumThreads.cpp:11), i.e. total input reads.readInfoStructis{uint64 cb; uint32 umi;}(SoloCommon.h:7-10) = 16 B after padding.→ 2.23e9 x 16 B ≈ 36 GB.
SoloFeature_countCBgeneUMI.cpp:21—rGeneUMI = new uint32[rguStride*nReadsMapped], withrguStride=3wheneverreadInfois on (ParametersSolo.cpp:436setsreadIndexYes = readInfoYes)→ ~24 GB at ~2e9 mapped reads.
Neither is counted by
--limitBAMsortRAM, and both must be resident during sorting, becauseSoloFeature::addBAMtagsreadsreadInfo[iread]per record while writing each bin(
SoloFeature_addBAMtags.cpp:13-17; called fromBAMbinSortByCoordinate.cpp:70-71). Ordering isfixed in
STAR.cpp: genome freed at :247,soloMain.processAndOutput()at :256, thenbamSortByCoordinate(...)at :272.Practical recipe for the big sample:
plus, in the same shell before STAR:
Total node RAM to request: ~90-110 GB (≈36 GB
readInfo+ ~24 GBrGeneUMI+ ~20 GB sort budgetSTAR.cpp:247) so it does notstack with the sort, but it does stack with mapping.
Two hard ceilings to be aware of at this scale:
outBAMcoord->coordOneAlign(..., (iReadAll<<32) | (iTr<<8) | ...)(
ReadAlign_outputAlignments.cpp:200, also :228, :247) and recovered asiread >> 32(
SoloFeature_addBAMtags.cpp:8-9). Above 4,294,967,295 input reads the CB/UB tags silentlyattach to the wrong reads. At 2.23e9 you have ~1.9x headroom — fine, but do not concatenate two
such samples into one STAR run.
ParametersSolo.cpp:405-411hard-refuses:--soloAddTagsToUnsortedin any released STAR (I greppedthe whole tree; 2.7.11b is both the latest release and
masterHEAD). The one partial exemption is--soloType CB_samTagOut, which emitsCR/CBinto unsorted BAM but does no UMI counting andexplicitly forbids
UB(ParametersSolo.cpp:413-415) — so it is not a substitute if you needUBor the count matrices.What remains uncertain, and the cheapest decisive experiment
Uncertain:
Log.out'sMax memory needed for sorting(max-bin) orsomething else (peak RSS,
/usr/bin/time). My whole root-cause inference rests on them beingmax-bin. If they are peak RSS they mean something quite different and the analysis needs redoing.
observed by me.
code proves such a floor exists; only your data can say how big it is.
Cheapest decisive experiment — free, no new run, settles #1 and #2 immediately:
greptheLog.outyou already have from the 9.8 M-record run:STAR prints every bin boundary as
index<TAB>chr_index<TAB>position(BAMoutput.cpp:140). Readthe list:
confirmed (Dobin #2070). Fix by shuffling; binning will then work and the monster sample is
routine. This is my prediction.
chr+position-> exact-coordinate tie collapse. Binningcannot help below that floor; use
samtools sortexternally, or accept the floor.Max memory needed for sortingshould have been ≈ total/49. If it was not, my model of the code iswrong and I would want to see the file.
Also worth grepping while you are there:
grep "Allocated and initialized readInfo array" Log.out— confirmsnReadsInputand lets youverify the 16 B/read figure against real RSS.
Second-cheapest — Dobin's own test, ~2 minutes (issue #289), if
Log.outis gone:Dominance by one chromosome (or, better,
cut -f3,4showing monotonically increasing positions)= reads are coordinate-ordered.
If you want the controlled bin-count discrimination anyway — the experiment that separates
"pileup" from "read order" without touching the monster sample: take one small real sample and run it
three ways at
--outBAMsortingBinsN 50and500: (a) as-is, (b) aftershuf-pairing /samtools collatethe reads, (c) after removing the rDNA locus from the reference. CompareMax memory needed for sorting.safe with
--outBAMsortingBinsN 200.That is a ~10-minute experiment on a 2 M-record sample and it answers the operational question
completely.
Round 2: what the real
Log.outproves, and what it revisesNew observation. STAR 2.7.11b, ce11, GSE208154 / SAMN29720279 lane L001, 12,993,522 reads,
--runThreadN 24, default--outBAMsortingBinsN 50.Max memory needed for sorting = 2801566182(2.80 GB). All 48 logged boundaries on chrIdx 0(chr I, 15,072,434 bp in ce11), in two clusters:
The read-order theory is CONFIRMED, not refuted — the read-name argument was a false negative
The counter-argument was: read names are Illumina tile coordinates
(
K00125:218:HCL2KBBXY:1:2106:15818:42829), therefore the file is in instrument order, therefore notbamtofastq-derived.That inference does not hold. Read names carry no information about record order.
bamtofastq,samtools fastqand SRA's own BAM→FASTQ conversion all copy the original read-name string verbatim.A FASTQ regenerated from a coordinate-sorted BAM has original Illumina tile names and
genome-coordinate record order. The two are independent.
Direct external confirmation. ENA Portal API,
result=read_runforSAMN29720279:The submitter uploaded
possorted_genome_bam...bam— CellRanger's position-sorted BAM, namedas such — and
fastq_ftpis empty, i.e. no FASTQ was ever submitted. Every FASTQ for this run isa conversion of a coordinate-sorted BAM. This is exactly Dobin's #289 / #1136 / #1762 / #2070
scenario, confirmed from the archive rather than inferred.
(Incidentally
1:2106:...is a surface-2 tile; a genuinely instrument-ordered file starts at tile1101. Weak evidence on its own, but it points the same way.)
Q1. What exactly is the boundary sample? — one thread's first ~50 MB, as 2-3 disjoint read windows
REVISES round 1, which said "the first ~50 MB of BAM output ... effectively the head of the
FASTQ". More precisely, proven from code:
BAMoutputper mapping thread —ReadAlignChunk.cpp:51:chunkOutBAMcoord = new BAMoutput (iChunk, P.outBAMsortTmpDir, P);BAMoutput.cpp:33:nBins=1;//start with one bin to estimate genomic bin sizes. All its alignments accumulate in bin 0's buffer.binSize1, independent ofnBins—BAMoutput.cpp:11-13, 32:binSize=P.chunkOutBAMsizeBytes/nBins; bamArraySize=binSize*nBins; ... binSize1=binStart[nBins-1]-binStart[0];which is
(chunkOutBAMsizeBytes/nBins)*(nBins-1)≈chunkOutBAMsizeBytes=limitIObufferSize[1]= 50,000,000 for every
nBins >= 2. At ~140 B/record in-buffer that is ~350,000 records.BAMoutput.cpp:122-137:Parameters.cpp:655initialisingoutBAMsortingBinStart[0]=1. The winner computes, sets[0]=0, and the other 23 threads contribute nothing — they only re-bin their own buffersagainst the winner's boundaries.
coordFlush()is per-thread-per-run, not per-chunk —ReadAlignChunk_processChunks.cpp:255-259,outside the
while (!noReadsLeft)chunk loop:chunks in file order (
ReadAlignChunk_processChunks.cpp:18,22):chunkInSizeBytes ≈ limitIObufferSize[0]/readNends = 30 MB/2 = 15 MBper mate(
Parameters.cpp:1161-1162) ≈ ~60,000 reads per chunk. Filling 50 MB of BAM takes ~2-3 chunks,and with 24 threads round-robining, those chunks sit at scattered offsets within roughly the first
~72 chunks (~4 M reads) of the file.
Net: the sample is 2-3 disjoint windows of ~60,000 consecutive reads, taken from the head third of
the file, by one thread. For an instrument-ordered file each window is a genome-wide random sample
and everything works. For a coordinate-ordered file each window is one contiguous genomic
interval — and you get exactly as many genomic clusters as the winning thread consumed chunks.
Observed: 2 clusters. Predicted: 2-3. That is the fingerprint. Round 1 predicted "one tiny
window", which was wrong in detail — the round-robin chunk hand-off produces several windows, not one.
Q2. Why only chr I? — the sample is not representative, and the cuts are working correctly
The cuts are doing exactly what we think. The decisive property is that a boundary value is
literally an element of the sample (
BAMoutput.cpp:139):startPosholds only sampledalignGvalues. So the boundary set can never contain a coordinatethat no sampled alignment occupies.
Read the observed boundaries as sample quantiles — boundary
ibsits at rankib/49:Boundary 48 is at sample rank 48/49 = 97.96% and sits at chr I:15,069,919, i.e. 2.5 kb from the
end of chr I. So ≥98% of the sampled alignments are on chr I, while chr I is 44% of the library.
The sample over-represents chr I by ~2.2x and, critically, has ≤2% mass on the 85 Mb / 56% of records
that live beyond it.
That is exactly the two-contiguous-windows signature: 73.5% of the sample in one 615 kb interval and
20.4% in the 7 kb rDNA — proportions that are impossible for a genome-wide sample and natural for two
contiguous slices of a coordinate-sorted file.
Answer: the sample is not representative of the file. The equal-rank cuts are not at fault.
Q3. The 11 boundaries inside 7 kb are a SYMPTOM, and are in fact correct behaviour
Two things follow from the code.
binarySearch1ato dump everything into one bin (serviceFuns.cpp:261,while (i1<N-1 && x==X[i1+1]) ++i1;). The 11 rDNA boundaries are at 11 distinct positionsspanning 15,062,683-15,069,919. No duplicates ⇒ no collapse ⇒ those 11 bins are real and each
takes a genuine slice of the rDNA. That failure mode is not what is happening here.
records deserves many bins — that is the whole point of equal-count quantiles. If the sample were
representative, 11 bins on the rDNA would be correct and desirable.
The damage is not the cluster; it is the absence of any boundary beyond chr I:15,069,919. That
single gap forces 56% of the library into one bin. 0.56 × total ≈ 2.80 GB observed ⇒ implied total
sort footprint ≈ 5.0 GB, i.e. ~160 B/record over ~31 M records — internally consistent.
Q4. Remedy — and a REVISION to the
total/(nBins-1)formulaWhy more bins gave no benefit on the earlier runs — now provable
Every boundary is an element of the sample, and
binarySearch1areturns "the lastXelement that is<= x". Therefore, ifmax(startPos) = chrI:15,069,919, then for anynBins:More bins can only subdivide the coordinate range the sample already covers. They cannot create a
cut where the sample has no mass. Raising
nBinsalso cannot enlarge the sample, becausebinSize1 ≈ chunkOutBAMsizeBytesregardless ofnBins(Q1). That is the mechanical proof behind"200 bins identical, 1000 slightly worse".
REVISION to §"Answer up front" item 2 and the 2.44 G table
Round 1 stated max-bin ≈
total/(nBins-1). That formula silently assumes a representative sample.The correct statement, with
K = nBins-1mapped bins,s= share of the sample lying in a genomicregion,
L= share of the library in that same region:When the sample is representative,
s = Land this collapses tototal/K— round 1's formula, andDobin's #457 formula. When it is not, the worst region dominates. Here, for the tail beyond chr I,
s ≈ 0.02andL = 0.56:--outBAMsortingBinsNs·K)So more bins probably WOULD help this particular run — roughly 4x at 200 and up to ~20x at 1000 —
but the ceiling is set by
s, which is a property of read order, not ofnBins. Note the earlier"200/1000 bins don't help" measurements were taken on the other, smaller runs (~2 M and ~9.8 M
records), not on this one; if those had
s ≈ 0the flat response is expected. This is a testableprediction and the cheapest next experiment (below).
The actual lever, in order
possorted_genome_bam;un-sort it and
sbecomesL, the formula collapses tototal/K, and default 50 bins is ample.samtools collate -u -O possorted_genome_bam.bam | <bamtofastq>— operate on the BAM youalready have, no re-download. Dobin's own recommendation, #1762: "Shuffle the original BAM file
before generating BAMs with
samtools collate."fastq-shuffle.pl; 400 GB → 60 GB).samtools collateis name-grouping, which is not a uniform shuffle, but it fully destroys thecoordinate correlation that causes this — which is all that is required.
--outBAMsortingBinsNto 200-1000 as a cheap partial mitigation if shuffling isimpossible. Bounded benefit as tabulated; requires
ulimit -n >= runThreadN * nBins(STAR invents introns longer than any that exist: no map module sets alignIntronMax, and the default is mammal-calibrated #457).--limitIObufferSize(that ischunkOutBAMsizeBytes,Parameters.cpp:1163), e.g.--limitIObufferSize 30000000 500000000.A 10x sample reads 10x further into the file. Costs
runThreadN × that value— at 24 threadsthat is 12 GB of buffers, and on a coordinate-sorted file it still only reaches ~10x deeper rather
than genome-wide. Poor value; listed for completeness.
samtools sortexternally — still blocked by CB/UB (ParametersSolo.cpp:405-411).Is this unavoidable for any skewed-expression RNA-seq library? — No, and that matters
No. Expression skew alone does not trigger this. The failure requires the boundary sample to
miss part of the genome. With instrument-ordered reads, ~350,000 records sampled from anywhere in
the file is a genome-wide sample; equal-rank cuts then track the expression skew correctly, and a
pileup simply receives proportionally many bins (precisely what the 11 correct rDNA boundaries in Q3
demonstrate). Deep sequencing makes the sample more reliable, not less, because the sample size is
constant while the estimate is a quantile.
The only skew-driven failure that survives a random read order is the exact-coordinate tie floor
(round 1 §1):
max-bin >= records sharing the single most common start coordinate. That is a real butmuch smaller effect, and it is what
TyberiusPrimehit in #413 with a targeted panel.However, there is a systemic finding worth carrying into seqforge. The trigger — a dataset whose
reads were re-derived from a coordinate-sorted BAM — is common in public 10x data, because
submitters routinely upload
possorted_genome_bam.bamrather than FASTQ. It is also detectablewithout downloading a byte: ENA's
filereportexposessubmitted_formatandsubmitted_ftpdirectly, as demonstrated above. For a compiler processing ~10⁴ public datasets, a check of the form
"submitted_format contains BAM, or submitted_ftp basename matches
*sorted*bam*" flags the affecteddatasets up front and lets the recipe insert a
samtools collatestep. That is a far more actionableresult than a per-sample RAM tuning exercise.
Revised uncertainty, and the cheapest next experiment
Now settled: boundaries come from one thread's fixed ~50 MB buffer as 2-3 contiguous read windows
(Q1, code); the cuts are correct and the sample is not (Q2, code + log arithmetic); the rDNA cluster
is a symptom and no tie-collapse is occurring (Q3, distinct boundary values); more bins cannot create
cuts outside the sampled range (Q4, code); the input derives from a position-sorted BAM (ENA
submitted_format).Still open: the exact value of
s(sample mass beyond chr I). The boundary list only bounds it at≤2%; it cannot distinguish "2% on chr II+" from "2% still on chr I".
sis what decides whetherremedy 2 buys 4-20x or nothing.
Cheapest decisive experiment — one number from the run you already have:
That is printed at
BAMoutput.cpp:134, immediately above the boundary list, and gives the sample sizedirectly. If it is ~350,000, the model above is confirmed end to end. Please capture this — it costs
nothing and it is the one number missing.
Cheapest experiment that discriminates the remaining question (~15 min, this same 13 M-read lane):
Re-run at
--outBAMsortingBinsN 1000(withulimit -n 32768), change nothing else, and readMax memory needed for sorting:s ≈ 2%, binning helps ~20x, remedy 2 is viable as a stopgap.s ≈ 0: the sample contains zero alignments beyond chr I, binning isstructurally incapable of helping, and shuffling is the only fix.
Either outcome is decisive, and neither requires the 2.23 G-read sample.
Confirmatory run (~20 min), worth doing once to validate the recipe for the whole corpus:
samtools collatethe source BAM → regenerate FASTQ → re-run the same lane at default 50 bins.Prediction: boundaries spread across all six chromosomes plus MtDNA, and
Max memory needed for sorting≈ 5.0 GB / 49 ≈ 0.10 GB (a ~27x reduction). If that holds, the2.44 G-record sample needs ≈
2.44e9 × 160 / 49 ≈ 8 GBfor sorting at default settings — and thebinding constraint reverts to STARsolo's
readInfo/rGeneUMIarrays (~60 GB), exactly as §"What itmeans for a 2.44-billion-record sample" concluded.
Round 3 — the prediction, measured
Round 2 predicted more bins would help this run substantially (~20x at 1000 bins). Measured on the
same lane, that is wrong. Same input, same 24 threads, only
--outBAMsortingBinsNchanged:A 20x increase in bins bought 1.4x, not 20x. And the reason is visible in the log: 998 of the
1000 boundaries are still on chrIdx 0 (chromosome I) — the same confinement as at 50 bins. Nothing
past the last chr-I boundary gets a cut, so the terminal bin still swallows chromosomes II/III/IV/V/X/
MtDNA, i.e. 56% of the library.
The small gain is fully explained by the sample growing (142,760 -> 205,513 reads), because buffer
capacity
binSize1 = (chunkOutBAMsizeBytes/nBins)*(nBins-1)rises slightly asnBinsgrows. A biggersample subdivides chr I a little more finely. It does not, and cannot, place a boundary where the
sample has no mass.
Conclusion, measured rather than modelled:
--outBAMsortingBinsNis not the lever for this data.It is a real and correctly-implemented knob — it bounds max-bin when the boundary sample is
representative — but here the sample is confined to one chromosome by read order, and no bin count
repairs that. The fix has to act on the input: shuffle (
samtools collate), or avoid the condition bydetecting it at resolve time.
What this means for seqforge
The condition is detectable for free, before any download. ENA's
filereportexposes what thesubmitter actually uploaded; verified independently for this run:
submitted_formatcontainsBAM,fastq_ftpis empty, and the file is CellRanger'sposition-sorted BAM — so every FASTQ for this run is a conversion of a coordinate-sorted file.
seqforge io resolvealready queries ENA, so this is one extra field on a call that already happens.Sizing
resources.mem_gbper sample treats the symptom; flagging genome-ordered input at resolve timeand collating treats the cause, and is cheaper for a corpus of ~10^4 datasets.
All reactions