Repository navigation
Conversation
Run MarkDuplicates through its command line on small hand-built SAM inputs and check the duplicate flags, tags, output records and metrics: coordinate-sorted input (pairs, fragments, clipped reads, orientations, mates on other contigs, unmapped and secondary records, libraries and read groups, scoring strategies), queryname-sorted and query-grouped input (duplicate-set tags, barcodes, scoring strategies, input errors), optical duplicates, output options, argument validation, read groups with the same ID in two inputs, reads clipped at a contig start, and read ends spilled to disk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover the read-end classes and their codecs and maps, LibraryIdGenerator, MarkQueue, the mate-cigar read ends and location sets, SamRecordWithOrdinalAndSetDuplicateReadFlag, PhysicalLocation, and the methods AbstractMarkDuplicatesCommandLineProgram shares with the programs built on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover the pixel-distance, tile and read-group rules of OpticalDuplicateFinder, read name parsing with the default and custom regular expressions in ReadNameParser, and DuplicationMetrics, DuplicationMetricsFactory and FlowBasedDuplicationMetrics, with the library size estimate checked against an independent solution of the Lander-Waterman equation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover MarkDuplicatesWithMateCigar, SimpleMarkDuplicatesWithMateCigar and UmiAwareMarkDuplicatesWithMateCigar end to end, with their iterators, UmiGraph, UmiUtil and UmiMetrics, and CollectDuplicateMetrics and CheckDuplicateMarking. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cover EstimateLibraryComplexity end to end and below its command line, the agreement of its two duplicate finders on groups whose duplicate sets are known by construction, and MarkDuplicates with FLOW_MODE=true and the flow helper and argument collection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MarkDuplicates is 2.0x to 9.4x faster on the 56 public BAM files timed at 8 cores, and its output BAM and metrics are byte for byte those of master. No argument is added and no default changes. The code is plain Java 17 with no native calls. Output (ParallelBAMFileWriter, ParallelBlockCompressedOutputStream, RawBlockBAMRecord): - A BAM output that SAMFileWriterFactory would write without an index or MD5 file has its BGZF blocks deflated on several threads, cut where BlockCompressedOutputStream cuts them and written in order. While the writing thread waits for the oldest block, it deflates queued blocks itself. - Records reach the writer thread in batches of 1,024. The writer copies a record's read name, cigar, bases and qualities from the bytes it was decoded from, and writes its tags from those bytes with pass 2's edits, without decoding them. - Every other output goes through htsjdk's asynchronous writer. Input (ParallelBAMRecordIterator, ReadAheadIterator, openInputs in AbstractMarkDuplicatesCommandLineProgram): - One local BAM input on 4 or more processors is inflated on one thread with htsjdk's BlockCompressedInputStream, and its records are decoded, validated and eagerly decoded on several threads. They come out in file order, with BAMFileReader's exceptions at the same records and LENIENT warnings in file order. An empty BGZF block ends the records where it ends BAMFileReader's. - On that path pass 2 does not validate again the records that pass 1 validated. - In pass 1 every other input is read ahead on a background thread. Pass 1 and duplicate finding (MarkDuplicates, PairedReadEndKeys, PairedReadEndRows, ReadNameParser): - Read ends, the pair map's key, the read group and the PG tag are looked up on the decoding threads. The main thread assigns library ids and file indices in order, and repeats every lookup itself for a record whose lookup threw. - Paired ends no longer go into fragSort: their fragment keys are kept as packed longs, sorted on a helper thread. Completed pairs are kept as rows of primitive fields rather than as objects. - For one coordinate-sorted input, the pair pass runs on a helper thread during pass 1. When OUTPUT does not exist yet, the output is a BAM file the parallel writer takes and the heap is at least 1 GiB, pass 2 also runs during pass 1, on a thread of its own, and writes a new file that replaces OUTPUT at the end. Otherwise, or when either cannot finish, the passes run in master's order. - The duplicate-index collections are sized to the records pass 1 read. Before master's order allocates them, the sorted key arrays are split into small ones and the rows are released, so that G1 can place the allocation. - The progress loggers are single-threaded copies of htsjdk's. New unit tests: PairedReadEndKeysTest and PairedReadEndRowsTest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Hello! The above message was written by Claude, but this comment is written by me (though based on my other auto-optimization PRs). I have been developing a tool, called [auto-optimize], which basically applies the auto-research loop to code to improve speed and memory usage of code, but with extensive robustness checks to try to ensure nothing subtly breaks. I have been applying [auto-optimize] across the genomics ecosystem, starting with my own tools but now extending to other important ecosystem tools, with the hope of making things broadly better. Most recently, I've applied it to MACS3, Bowtie2, chromap, and bedtools. In an effort to be more robust, the process first augments the unit tests to make sure that nothing breaks during the search. See #2062 (feel free to merge that independently of any choice you make here). Any iteration in our loop that broke any unit test was automatically discarded, and we used a set of external validations that were not directly optimized against each iteration to make sure we got identical results. At the end, we ran a bunch of example cases across assays and data type, covering every command-line flag, combinations, etc. Importantly, we also created a set of malformed data to make sure that not only did you get identical outputs on good data, but that it failed the same way on bad data so that we didn't just get rid of safeties that were necessary but slow. If you look at the results below, you'll see that there were several big improvements, but also a collection of small improvements that all collectively led to the improved speed.
In an effort to be consistent with https://rewrites.bio/, I'd like this to be merged into this repository so the original developers and maintainers can continue to get credit for their work (though I don't think that's really in question...). However, as a maintainer myself, I understand being skeptical of merging a PR of this size, particularly when the submitter can't explain everything in it (a consequence of so many changes being made at a low level). There are several paths forward: (1) you can merge the PR as-is, (2) you can push your own commits on top of it to revert any changes you dislike or built upon them, (3) you can cherry-pick out any individual or set of improvements you like and merge them as separate PRs (as Ben is doing with bowtie2 right now), (4) you can ignore this entirely. We're happy to share any logs or information that would help you! |
|
Dear @jmschrei, could you also try to speedup |
|
Thanks for this work @jmschrei. I just wanted to respond here and say that this is both impressive, and unfortunately unlikely to go anywhere in the near future due to i) the size of the PR, and ii) the lack of any real resourcing to maintain Picard. That said, this is quite impressive, and I'm tempted to mine this PR for certain things that could go upstream into HTSJDK which Picard relies upon and I've been trying my best to resuscitate development on lately. E.g. a lot of the parallelization work for reading/writing BAM files could probably flow upstream and be made more broadly available to both Picard tools and other tools that use HTSJDK. Is that something you would welcome and/or want to participate in? |
|
Hi @tfenne. Totally understand. Regardless, this is here for anyone to use if they'd like and for developers to pick apart. Feel free to take any of the optimizations found here and merge/use them wherever you'd like. Credit pointing to this PR would be appreciated if you do so but tbh it's not required. Alternatively, if you have specific packages or tooling you think would welcome a PR of this type (even if just to cherry-pick out individual ideas), I'm happy to run auto-optimize on those. |

This optimization was found with auto-optimize, a tool developed by the Programmable Genomics Laboratory at UMass Chan Medical School: it cuts the median wall time of
MarkDuplicateson a 22-million-pair DNase-seq BAM at 8 cores from 234.2 s to 25.05 s, and speeds it up 2.0x to 9.4x on 56 public BAM files, with output byte-identical tomasteron every input both complete. The output was checked againstmasterin 28,294 comparisons, 28,225 of them identical, on 2,253 inputs from 50 assays (1,899 BAM, 198 SAM and 156 CRAM), among them 62 BAMs made for this check with empty BGZF blocks inserted, 14 BAMs made for this check that are cut short or have a damaged BGZF block, 10 BAMs with one BGZF defect or oddity, 14 BAMs with one record or header defect, 2 SAMs with one defect, 2 CRAMs with one defect and 428 other edge-case files from the test collection that are mostly unusual but valid; see How the output was checked.The 22-million-pair input is ENCODE's T-cell DNase-seq BAM ENCFF377MQA (21,992,283 pairs, PE76, coordinate-sorted, one
@RGadded), run with-Xmx8gon 8 cores. On it,masterspends 55 s reading the input and building read ends, 5 s finding duplicates, and 174 s in the second pass, where one thread decodes, marks, encodes and compresses every record. Most of the gain comes from four things: compressing BGZF blocks on several threads (items 1 and 2 take the second pass from 174 s to 41 s), decoding and validating records on several threads in both passes, writing each record from the bytes it was read from, and finding duplicates and writing the output while the first pass is still reading. Peak memory falls from 8,051 MB to 3,694 MB on that input and is lower on 54 of the 56 inputs timed (see Memory). No command-line argument is added, no default changes, and the code is plain Java 17 with no native or OS-specific calls.It builds on #2062, which adds 54 test files for
MarkDuplicatesand the classes it uses and changes no source. The first five commits here are #2062's. This pull request's own change is the sixth commit: 12 files, +6,005 / -73, two of them new test classes. Once #2062 is merged, only the sixth commit remains. The bugs that those tests and the checks below found inmasterare reported in #2061.Changes
In the order they were made. The time after each item is the median of three runs on the input above with every change up to that item;
mastertakes 234.2 s.MarkDuplicates.doWork): the output writer is made withSAMFileWriterFactory.setUseAsyncIo(true), so htsjdk encodes and compresses records on its writer thread while the main thread reads and marks. Outputs that item 2's writer does not take still use it. 211.6 s.ParallelBlockCompressedOutputStreamandParallelBAMFileWriter): a BGZF stream that cuts blocks exactly whereBlockCompressedOutputStreamcuts them (when its 65,498-byte buffer is full, and onflush()), deflates them on a pool with deflaters from the same factory at the same level, keeps the level-0 fallback for a block whose output does not fit, writes them in order and ends with the same EOF block.ParallelBAMFileWriterwrites the header and records through it asBAMFileWriterdoes. It is used whencanWrite(OUTPUT)holds: the name ends in.bamand neither an index nor an MD5 file is requested. The second pass fell from 151.8 s to 41.2 s. 101.4 s.AbstractMarkDuplicatesCommandLineProgram.openInputs(boolean, boolean)): an overload that turns on htsjdk's asynchronous BGZF read.openInputs(boolean)passes false, which keeps htsjdk's default. The first pass used it until item 12 turned it off, because htsjdk 5.0.0's asynchronous stream never returns from a read after a corrupt deflate block. No caller passes true now. 95.84 s.ReadAheadIterator): the input iterator runs on a background thread that hands records over in batches of 1,024, so decoding and validation overlap with building read ends. htsjdk'sAsyncBufferedIteratorwas not used because itsclose()can hang: it interrupts the reading thread, htsjdk turns the interrupt into an exception and clears it, and the thread then blocks on its full queue (3 of 1,000 closes hung in a test).ReadAheadIterator.close()never interrupts. Since item 6 it is used only for inputs the parallel reader does not take. 84.58 s.fragSort(MarkDuplicates.buildSortedReadEndListsandgenerateDuplicateIndexes, newPairedReadEndKeys): the fragment pass marks only unpaired ends, and a paired end there only tells its chunk that a pair shares the chunk's key. YetfragSortheld one object per mapped primary read (44.0 million on the input above, over its 31.1 million in-memory limit, so it spilled to disk). A paired end now adds only its key (library, reference, coordinate, orientation) packed into a long, and a chunk has pairs when its key is among them. A key that cannot be packed leaves its end infragSort, and keys that outgrow the same memory budget go tofragSortas stand-in paired ends. Peak memory fell from 8,032 to 6,289 MB. 64.76 s.ParallelBAMRecordIterator; anopenInputsoverload with a decoding-thread count): for one local BAM input on 4 or more processors, one thread inflates with htsjdk's synchronousBlockCompressedInputStreamand cuts the stream into 64 KB batches of whole records, and decoding threads runBAMRecordCodec.decodeand then whatBAMFileReader's iterator does to each record: set the stringency,isValidwithSAMUtils.processValidationErrorsand the 1-based record number, and eager decoding. Records come out in file order. LikeBAMFileReader, the iterator reads one record ahead, so a failure in record k is thrown by the samenext()with the same exception, and LENIENT warnings print when the consumer reaches their record. The first pass fell from 20.09 to 16.17 s. 61.34 s.RawBlockBAMRecord;ParallelBAMFileWriter.writeFromBlock; anopenInputsoverload with aSAMRecordFactory): the second pass changes tags on every record (CLEAR_DTand thePGtag), soBAMRecordCodecre-encoded every field of every record. The second pass now readsRawBlockBAMRecords, which keep the variable-length block they were decoded from until the read name, cigar, sequence or qualities are set, and the writer assembles each record in one buffer: the fixed fields by direct stores, the name, cigar, sequence and qualities copied from the block, the bin computed asBAMRecordCodeccomputes it, and the tags throughBinaryTagCodec.writeTag. Any record it cannot copy exactly (a cigar from aCGtag, out-of-range fields, a reference too long for BAI bins, a position past 2^29) goes throughBAMRecordCodec. 55.39 s.ParallelBAMFileWriter.AsyncWriter): in place of htsjdk'sAbstractAsyncWriter, which costs the main thread a queue put and two atomic swaps per record, records go to the writer thread in arrays of 1,024 through a two-slot queue. Failures surface as they do fromAbstractAsyncWriter. The second pass fell from 37.35 to 27.33 s. 43.31 s.MarkDuplicates.doWork,ParallelBAMRecordIteratorwith a record factory): the second pass reads one BAM input through item 6's reader with 2 decoding threads, which makeRawBlockBAMRecords. The second pass fell from 27.33 to 21.3 s. 38.19 s.MarkDuplicates.sortIndicesForDuplicates): htsjdk'sSortingLongCollectionallocates and zeroes its whole in-memory array up front, 268,435,456 longs (2 GiB) at-Xmx8g. At most one index per record of the first pass is ever added, so the size is capped at that record count. Peak memory fell from 7,715 to 5,918 MB, and the time did not change measurably. 38.39 s.MarkDuplicates.decodeReadanddecodeReadEnds; an annotator inParallelBAMRecordIterator;ReadNameParser.getReadNameRegex; anopenInputsoverload): for each mapped primary read, the decoding threads build theReadEndsForMarkDuplicatesthatbuildReadEndswould (unclipped 5' coordinate, orientation, score, mate reference, and tile, x and y from aReadNameParserof their own), build the pair map's key (read group ID and read name, with its hash computed), and read thePGtag. The main thread sets the library id and file index in file order. If any lookup throws, the main thread repeats all of them withmaster's code, so the same exception surfaces at the same record. The first pass uses min(4, processors - 2) decoding threads. It fell from 15.5 to 10.04 s. 33.36 s.BAMFileReaderreads them (ParallelBAMRecordIterator,MarkDuplicates.buildSortedReadEndLists): an empty BGZF block, or one whose ISIZE is 0, ends the records only where it endsBAMFileReader's (cleanly at a record's start, with "Premature EOF" whereBAMRecordCodec.decodestarts one of its other reads) and is read past elsewhere. AnIOExceptionfrom the seek to the first record is wrapped asBAMFileReaderwraps it, and the first pass no longer uses htsjdk's asynchronous BGZF read (item 3). On 78 BAMs with empty, zero-length or corrupt blocks, the reader matches htsjdk's on every one, andMarkDuplicatesexits withmaster's code and first exception line in 16 of 16 cases. 33.09 s.ParallelBAMRecordIterator.openand anopenInputsoverload with a count of validated records;MarkDuplicates.doWork): the first pass has already validated each record it read, at the same stringency, so the second pass's reader skipsisValidfor those and decodes the tags and then the cigar at SILENT, which is what validation would have decoded. Later records are validated as before. See Behaviour changes for the LENIENT warnings. 31.95 s.ParallelBAMRecordIterator.createRecord): in a batch that is not the last, whose records the splitter has already checked, records are made from little-endian loads with the same conversions and factory call asBAMRecordCodec.decode, rather than through 13 stream reads each. The last batch, which can end in a partial record or a stored failure, still goes throughBAMRecordCodec. 31.13 s.RawBlockBAMRecord.deferTagsandwriteTags;ParallelBAMRecordIterator.decodeValidated;ParallelBAMFileWriter.writeFromBlock): a validated record whose tags and cigar cannot fail to decode (every cigar operator known, the tags filling the block exactly, noCGtag, no repeated tag, noHvalue, noBsubtype outside c, C, s, S, i, I and f) keeps them as bytes.getAttributereads from the bytes,setAttributewith null or a String is recorded as an edit, and anything else decodes everything at SILENT and replays the edits. The writer merges the original tags with the edits asBinaryTagCodecwould write them. 30.57 s.ParallelBAMFileWriter.defaultThreads): max(1, min(p - p/4, 8)) for p available processors, 6 on 8 processors, leaving a quarter to the threads that read, decode and encode. With 4 or 5 deflaters on 8 processors, deflation bounds the second pass. 29.87 s.PairedReadEndRows;MarkDuplicates.buildSortedReadEndListsandgenerateDuplicateIndexes): each completed pair is a row of 7 longs in a 64-byte slot, in chunks of up to 2^20 rows, rather than an object inpairSort, so young collections no longer copy 22 million objects.sort()orders the rows asReadEndsMDComparatororders the ends (packed prefixes sorted withArrays.parallelSort, ties ordered by the full comparison) and marks where duplicate sets start, and a set of one pair is only counted. An end the rows cannot hold exactly (an int the comparator would subtract outside [-2^30, 2^30), or more pairs than the in-memory limit) moves every row intomaster'spairSort. G1 pauses in the first pass fell from 642 ms to 122 ms in one logged run, and peak memory from 5,643 to 5,147 MB. 29.18 s.MarkDuplicates.PairPassDuringRead,markDuplicatePairs): for one coordinate-sorted input, the main thread also hands completed pairs to a helper thread in batches of 4,096, each with a frontier 1,000 bp below the alignment start of the last read. Once a duplicate set's read2 end lies below a frontier, every pair of the set has been read, and the helper handles the set ashandleChunkdoes, into its own indices and histograms, which are added after the first pass. A read below the frontier, a pair the rows refuse, too many waiting pairs, or a failure abandons the helper's result, and the pair pass runs on the rows as in item 17. The time between the passes fell from 1.13 to 0.10 s, and peak memory from 5,147 to 4,345 MB. 28.36 s.ParallelBlockCompressedOutputStream.writeOldestanddeflateQueuedBlock): while it waits for the oldest block, the thread that writes runs deflate tasks no worker has started. Their futures are already queued in order, so blocks are still written in order. 27.99 s.PairedReadEndKeys): keys are added into chunks of 2^20 that a sorter thread sorts, de-duplicates and places into ordered runs, sodoneAddingsorts only the last chunk.MarkDuplicates.decodeReadandReadGroupSlots): the decoding threads look up each mapped primary read's read group once, and the main thread takes its library id from an array indexed by the read group's place in the header.MarkDuplicates.SingleThreadProgressLogger): logs the same lines at the same records as htsjdk'sProgressLogger, without its lock, and reads the read name only for a line that prints it. Items 20 to 22 were measured together: peak memory fell from 4,347 to 3,764 MB. 27.18 s.MarkDuplicates.SecondPassDuringRead,startSecondPassandwrittenDuringRead;ParallelBAMRecordIterator.ValidatedRecords; a create-new option and a blocks-in-flight setter inParallelBAMFileWriter;ParallelBlockCompressedOutputStream.setBlocksInFlightLimit): the second pass runs on a thread of its own while the first pass reads, and writes record i only once every duplicate flag below record i+1 is decided. Item 18's helper now decides every flag: those of pairs as before, and those of unpaired ends a chunk at a time, once the chunk's 5' position lies below a frontier, as the fragment pass would; the main thread hands it the unpaired ends and the keys of paired ends for this. A read still waiting for its mate when the frontier passes the mate's alignment start is taken to have no mate, and so not to be a duplicate. The second pass writes a new hidden file beside OUTPUT and moves it onto OUTPUT at the end. It holds back its log lines and releases them after the first pass, inmaster's order. While the first pass reads, 96 BGZF blocks are kept in flight instead of 24. When any condition in Behaviour changes fails, the thread stops, its file is deleted, and the second pass runs after the first as inmaster. 25.70 s.PairedReadEndKeys.splitRunsand its overflow path;MarkDuplicates.generateDuplicateIndexes; newPairedReadEndKeysTestandPairedReadEndRowsTest): beforemaster's order allocates the duplicate indices, the sorted key arrays are split into arrays of at most 32,768 keys (256 KiB), and the rows are released once the helper has handled every pair. The key overflow path splits the same way before it hands keys over. See Memory. The input above does not take these paths. 25.05 s.Tests and CI
The tests from #2062 and the two new test classes pass on this branch. The repository's
Build and Testworkflow was run locally, step by step, on this commit with OpenJDK 17:./gradlew compileJava,shadowJarlegacyTestandbarclayTestfiltered topicard.sam.markduplicates.*,picard.sam.util.*,picard.sam.DuplicationMetrics*andpicard.sam.FlowBasedDuplicationMetricsTestlegacyTest(whichjacocoTestReportruns), every testbarclayTest, every test./gradlew currentJar, thenjava -jar build/libs/picard.jar MarkDuplicates -I testdata/picard/sam/aligned_queryname_sorted.bam -O out.bam --METRICS_FILE out.metrics@PGversion pinned,out.bamand the metrics are identical tomaster'sEvery test passed on this commit, as on
master(5,466 and 5,465 tests there). In earlier runs launched another way on the same machine, five tests that pipe data through standard input or output (FastqToSamTest.testStreamInput,PipedDataTest.testPipedInput[0..2]andUpdateVcfSequenceDictionaryTest.testCaptureStdout) failed onmasterand this branch alike; launched as above, they pass on both. TheCloud Testsworkflow needs Google Cloud credentials and was not run, and neither was the Trivy scan.Behaviour changes
There are four.
MarkDuplicatesfails while writing, both builds exit 1 with the same exception, but the partial file left behind differs. In the comparisons below this happened on inputs whose records are not in the order their header states ("Alignments added out of order") and once on a BAM index failure.masterwrites on the main thread and leaves a flushed partial file. This branch writes on a writer thread; its failure surfaces on a lateraddAlignment, after whichclose()does not close the underlying writer, as in htsjdk'sAbstractAsyncWriter. The partial file is then often empty, or a shorter SAM, and which one is left varies from run to run: on one such input withREMOVE_SEQUENCING_DUPLICATES=true TAGGING_POLICY=All, this branch left an empty file in 5 of 8 runs andmaster's 116,043-byte file in 3, whilemasterleft that file in 4 of 4. 69 of the 28,294 comparisons below differ in this way and in no other.mastervalidates every record in both passes, so atVALIDATION_STRINGENCY=LENIENTit prints each warning twice for an invalid record that both passes read. Where the second pass uses the parallel reader, this branch does not validate those records again (item 13) and prints the warning once. In the comparisons below, the number of warnings differed in 373 jobs: 360 printed fewer (245 of them exactly half as many) and 13 printed more, 12 of them on three SAM inputs that both builds then reject with the same exception (up to 62 warnings against 2) and one on a BAM (3 against 2). In a separate check, a SAM input and a run with two inputs printed one record's warning 4 times againstmaster's 2. We did not establish why these cases print more. Exit codes, exceptions and outputs are the same in all of them.MarkDuplicatesitself rather than a subclass, without barcode tags or flow mode; one INPUT, a local coordinate-sorted BAM file;TAG_DUPLICATE_SET_MEMBERS=false;READ_NAME_REGEXnull or the standard optical duplicate finder; at least 4 available processors and a heap of at least 1 GiB; and an OUTPUT that does not exist yet, whose name ends in.bam, withoutCREATE_INDEXorCREATE_MD5_FILE. It is also abandoned during the run on a read or unpaired end below the frontier, a pair or key that cannot be held, too many pairs, ends, keys or reads waiting, a read with a mapped mate whose RNEXT is*, a read taken to have no mate that then finds it, aPGID the output header lacks, or a failure in the first pass. Otherwise the passes run inmaster's order with every other change; on the input above with an existing OUTPUT, that took 33.32 to 34.47 s in three runs againstmaster's 238.6 to 243.3 s. While the passes overlap, the output is written to a hidden file.<OUTPUT name>.<random hex>.tmpin OUTPUT's directory and moved onto OUTPUT at the end (an atomic move where the file system allows one), so OUTPUT appears only when the run finishes, and a process that is killed can leave the hidden file behind. No command-line argument chooses the path, because Picard writes every argument into the output's@PG CL, so a new one would change the output bytes.Usage benchmarks
Machine: AMD EPYC 9575F, 2 sockets x 64 cores (256 hardware threads, 2 NUMA nodes), 1 TB RAM, Ubuntu 24.04.5, Linux 7.0, OpenJDK 17.0.18, htsjdk 5.0.0 with Picard's default Intel deflater and inflater. Both builds are
./gradlew shadowJarjars:master(with #2062's test files, which change no source) and this branch.Inputs: no synthetic data. Every input is public sequencing data from a test collection: one large subsample per assay (46) and 10 full-depth experiments, two of them limited to chr22. Paired-end files without a read group got one
@RGwithsamtools addreplacerg, becausemasterthrows a NullPointerException on paired reads without one (#2061). One whole-genome subsample is left out of the table for its license.How it was timed:
tasksetto the 8 physical cores of one L3 group (CPUs 24-31), to which no other process was pinned. Other jobs ran on the rest of the machine; the load average at the run starts was 61.5 to 740.8 (median 151.3).-Xmx8gandVALIDATION_STRINGENCY=LENIENT, so that a file with a few invalid records runs to the end; every record is still validated. The input and the jar were in the page cache, and the output went to local disk./usr/bin/time %M).out.bamwas byte-identical and the metrics identical apart from their timestamp and command-line lines, with--PROGRAM_GROUP_VERSIONset to the same value for both builds so that the@PGline matches.master(s)masterpeak (MB)The output was byte-identical in every configuration. On the 22-million-pair input, a fresh clone of
masterwith this change merged, both built with./gradlew shadowJar, gave 238.6 to 243.3 s formasterand 25.11 to 25.95 s for this branch in three runs each, and a paired comparison ofmasterwith an earlier version of this change (before item 24) found the change faster in 9 of 9 pairs (median difference -213.4 s; exact two-sided sign test, p = 0.0039). Over the 56 rows, the speedup has a geometric mean of 4.49x and a median of 4.21x, and the summed time falls from 2,678 s to 395 s. Over the 10 full-depth rows the geometric mean is 7.84x (6.32x to 9.44x), and over the 46 subsamples 3.98x (1.98x to 6.95x).How the output was checked
The usage benchmarks above check speed and memory. Separately, we ran
masterand this branch side by side on every alignment file in the same test collection:@RGadded, 49 queryname-sorted copies, 30 copies with MC tags added bysamtools fixmate -m, and 78 BAMs with empty, zero-length or corrupt BGZF blocks made for this check. Two@RGcopies could not be made, so 26 jobs were not compared.out.bamorout.sam,.bai,.md5) by SHA-256, and the metrics without their timestamp and command-line lines; after a failure the first exception (class and message) and the partial output file. Every path was relative and--PROGRAM_GROUP_VERSIONwas the same for both builds, so@PGlines are compared too.-Xmx8g(-Xmx32gfor both wheremasterran out of memory at 8g) on 8 cores, except where noted.master's side of each comparison was recorded in an earlier run of these comparisons and run again wherever the two builds differed.masterrun stops at validation was run again withVALIDATION_STRINGENCY=LENIENT.What ran
MarkDuplicates, default optionsMarkDuplicateswith each ofREMOVE_DUPLICATES;REMOVE_SEQUENCING_DUPLICATESwithTAGGING_POLICY=All;DUPLICATE_SCORING_STRATEGYTOTAL_MAPPED_REFERENCE_LENGTHandRANDOM;READ_NAME_REGEX=nullwithOPTICAL_DUPLICATE_PIXEL_DISTANCE=2500; forced spilling (SORTING_COLLECTION_SIZE_RATIO=0.00001,MAX_FILE_HANDLES_FOR_READ_ENDS_MAP=2);CREATE_INDEXwithCREATE_MD5_FILE; an existing output file;.samoutput;TAG_DUPLICATE_SET_MEMBERS(queryname inputs);BARCODE_TAG=CBwithMOLECULAR_IDENTIFIER_TAG=MI(single-cell inputs); two INPUTs (16 pairs of files)MarkDuplicatesWithMateCigar, default optionsSimpleMarkDuplicatesWithMateCigar, default optionsUmiAwareMarkDuplicatesWithMateCigar,UMI_METRICS_FILEaddedMarkDuplicatesat LENIENT on 1, 2, 4, 8 and 16 cores, againstmasteron 1 core"Identical failure" means both builds failed with the same exit code, the same first exception and the same partial output. The commonest is the NullPointerException that
masterthrows on paired reads without a read group (4,746 comparisons).Causes of every difference
master's partial fileCREATE_INDEXat LENIENT, hg19 records under an hg38 header)Apart from these, every exit code, exception and output file is the same.
Controls
masteragainst itself: 334 comparisons on 92 inputs, all identical.>changed to>=inmarkDuplicatePairs, so that the last of two tied pairs is kept: 23 of 194 comparisons differ, on 10 inputs.Threads
The new code uses more threads, and
Runtime.availableProcessors()sets how many, sotasksetand container CPU limits are honoured:The output does not depend on the thread count: the 275 comparisons at 1 to 16 cores above are all identical. CPU time falls with the wall time: on the input above, from 250.8 to 184.3 CPU-seconds (median of three runs at 8 cores). On 1 core, a paired comparison on a 400,333-pair subsample of the same input found an earlier version of this change faster in 9 of 9 pairs (median difference -0.657 s on about 7.9 s; exact two-sided sign test, p = 0.0039).
Memory
Peak resident memory is lower on 54 of the 56 inputs timed, with a median of 0.29 times
master's. On the 22-million-pair input, the steps that lowered it were items 5, 10, 17, 18 and 20 to 22 (numbers in Changes). It is higher on two single-end full-depth inputs: SLAM-seq (42,031,760 reads), 3,748 MB against 6,299 MB (1.68x), and PRO-seq (191,353,062 reads), 4,462 MB against 6,203 MB (1.39x). We did not establish which change raises it there.Above about
maxMemoryxSORTING_COLLECTION_SIZE_RATIO(0.25 by default) / 69 read pairs (31 million at-Xmx8g), the rows refuse a pair and the code falls back tomaster's order (item 17). An earlier version of this change ran out of memory there once: on a STARR-seq DNA input (ENCODE ENCFF807BAQ with@RGadded, 113.6 million pairs, 18.2 GB) at-Xmx8g, it threwOutOfMemoryErrorinsortIndicesForDuplicates, wheremasterfinished.-Xlog:gc+humongousshowed why. On the fallback, the duplicate-indexSortingLongCollectionneeds one contiguous 1.8 GBlong[](434 regions of 4 MiB), while the sorted key arrays ofPairedReadEndKeys, about 8 MiB each, were still live: G1 allocates such arrays as humongous objects and never moves them, and 210 of them lay spread through the heap. Item 24 copies the keys into arrays of at most 32,768 keys (256 KiB, under half of G1's smallest region) and releases the rows before that allocation; the full collection after it left 465 humongous regions against 884 before the fix. The failure did not recur in two later runs of the earlier version (at-Xmx8gand-Xmx7g), so the fix rests on the heap layout above and on a check that forces each fallback path at a reduced heap and compares the output withmaster's at the same heap:masterfragSort@RGadded, 1 GB"Identical" covers
out.bam, the metrics without their header lines, and the files left behind. Large inputs that take the fallback still run about 5x faster thanmaster.What this testing does not cover
-Xmx8g, apart from the 1-core comparison and the reduced heaps above. The output was compared at 1, 2, 4, 8 and 16 cores, but no other core count was timed, so item 16's deflater count is untimed on 4 to 7 and 9 or more processors.COMPRESSION_LEVELandUSE_JDK_DEFLATER. Flow mode is covered by [auto-optimize]: Add unit tests for MarkDuplicates and related tools #2062's tests, and the compression options (levels 0, 1 and 9, and the JDK deflater) were compared withmasteron one small input only. With the Intel deflater atCOMPRESSION_LEVEL=1,masteritself is not reproducible from run to run, so neither build can be held byte-identical there.Bugs in
masterfound along the wayThe bugs that #2062's tests and these checks found in
masterare in #2061, and those that live in htsjdk will be reported to samtools/htsjdk and linked here. This pull request fixes none of them. Among them:MarkDuplicates(4,746 of the comparisons above fail this way in both builds).MAX_OPTICAL_DUPLICATE_SET_SIZE=-1, which the documentation says disables the check, turns optical duplicate detection off.READ_NAME_REGEX=null, the duplicate set size histograms miss every set of two or more pairs.MarkDuplicatesexits 0 having written 2,797 of 5,594 records; both builds write the same file. Read past an empty BGZF block that is not the last block samtools/htsjdk#1813 fixed this in htsjdk 6.0.0, so it is not filed again.MarkDuplicatesdoes not check BGZF CRC32s, which the htsjdk report will cover.AsyncBlockCompressedInputStreamnever returns from a read after a corrupt deflate block (item 3).🤖 Generated with Claude Code