perf(delfi): ~80x faster via blacklist preloading and binary search - #172
perf(delfi): ~80x faster via blacklist preloading and binary search#172DucoG wants to merge 1 commit into
Conversation
jamesli124
left a comment
There was a problem hiding this comment.
Thank you for this PR. The performance improvement is very welcome and clever. I've left a couple of notes for you in my review, the main one being that support for fragment input in tabix-indexed bed.gz was inadvertently dropped. Otherwise, we appreciate your contribution!
| pandas DataFrame | ||
| Results of delfi analysis, with column names corresponding to | ||
| those generated by the original author's scripts. | ||
| See original docstring for full parameter documentation. This |
There was a problem hiding this comment.
The new docstring says "See original docstring for full parameter
documentation", but the original docstring was deleted in this same commit. Users calling help(delfi) or reading rendered
docs will get no parameter descriptions at all. Please either restore the
parameter docs or link to the documentation site.
| def _delfi_pool_initializer(input_file, reference_file, blacklist_by_contig, | ||
| contig_gaps_by_contig): | ||
| global _WORKER_BAM, _WORKER_REF, _WORKER_BLACKLIST, _WORKER_CONTIG_GAPS | ||
| _WORKER_BAM = pysam.AlignmentFile(str(input_file), 'r') |
There was a problem hiding this comment.
This unconditionally opens the input as a BAM/SAM file, but frag_generator
(which the rest of the codebase uses) also supports tabix-indexed .frag.gz/bed.gz
files by opening them as pysam.TabixFile. When given a .frag.gz/bed.gz input,
AlignmentFile raises "file does not contain alignment data" in every worker
process, causing the pool to hang silently with an empty output.
The fix is to mirror the detection already in frag_generator:
s = str(input_file)
if s.endswith('.sam') or s.endswith('.bam') or s.endswith('.cram'):
_WORKER_BAM = pysam.AlignmentFile(s, 'r')
else:
_WORKER_BAM = pysam.TabixFile(s, 'r')
The existing tests pass because they exclusively use .bam input — this code
path is never exercised for .frag.gz.
There was a problem hiding this comment.
@DucoG please consider using the AlignmentWrapper class, which should be compatible with all alignment file / frag.(gz/txt) inputs:
There was a problem hiding this comment.
I also think this can be done without opening a global BAM wrapper (yikes).
- read the BED intervals (for blocklist / passlist) to Dict[str, numpy] structure.
- Do some set algebra to remove the blocklist intervals from the passlist intervals.
- Chunk the remaining intervals into n_threads chunks
- For each chunk, use the original fragmentgenerator codepath.
|
|
||
| results = delfi(frag_file, autosomes, bins_file, twobit, blacklist, gaps) | ||
|
|
||
|
|
There was a problem hiding this comment.
Small note: none of the tests test opening fragment information in .frag.gz/bed.gz files.
- waiting for PR #172 to clear before addressing DELFI
| gap_file: Union[str, GenomeGaps]=None, | ||
| output_file: str=None, | ||
| no_gc_correct: bool=False, | ||
| blacklist_file: str = None, |
There was a problem hiding this comment.
@jamesli124 one thing to consider for these repo would be setting a format convention and integrating pre-commit to run it automatically. That would prevent these little format changes from sneaking in to MRs. Happy to help if it's of interest.
There was a problem hiding this comment.
@edawson Sorry, I was away when you wrote this, but I think it is a great idea. Is this something you are still interested in helping with?
Reworks delfi's worker pool so per-window work no longer reopens files or re-parses the blacklist. On a 56-core chr1+chr2 hg38 benchmark (16 workers) this is ~80x faster (643s -> 8s wall) with identical output. - Parse the blacklist once, index it by contig as sorted arrays, and filter each window with binary search instead of a per-fragment linear scan. - Open one AlignmentWrapper (BAM/CRAM/frag.gz) and one ReferenceWrapper (.2bit/FASTA) per worker via the Pool initializer, reused for every window, instead of reopening them per window. Using AlignmentWrapper restores tabix-indexed fragment input (.frag.gz/.bed.gz), which the previous revision of this PR dropped by opening input unconditionally as pysam.AlignmentFile. - Pre-load per-contig ContigGaps into worker globals instead of pickling them into every task. - Count GC bases with str.count instead of a per-base Python loop. Adds tests: workers=1 vs workers=N output equivalence, and tabix fragment-file input (.frag.gz/.bed.gz) that verifies it reads correctly, is worker-count invariant, and matches between the two tabix layouts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
87321f0 to
6c56293
Compare
|
Thanks for the reviews, @jamesli124 and @edawson. I've reworked the PR to address all the feedback. Since What changed vs. the previous revision1. Restored the full docstring (@jamesli124). The change is now based on 2. Fixed the dropped tabix fragment-file support (@jamesli124). The worker no longer opens input unconditionally as 3. Used 4. Added tests for fragment-file input (@jamesli124).
5. Avoided cosmetic churn (@edawson). The diff is restricted to functional changes — On the "no global wrapper / set-algebra" suggestion (@edawson)I kept the per-worker handle but moved it behind Correctness
One pre-existing observation (not changed here)BAM and tabix input give slightly different per-window counts (~0.02% on the test data): the BAM reader fetches each window by read-alignment position while the tabix reader fetches by fragment span, so a handful of window-boundary fragments land in adjacent windows. This is independent of this PR (it's in |
Incorporate the DELFI worker-pool optimization from Duco Gaillard's upstream PR #172: a Pool initializer that shares the alignment and reference handles per worker, blacklist lookups via binary search (np.searchsorted), and preloaded contig gaps. Output is bit-identical to the previous implementation. Includes Duco's worker-equivalence and fragment-file regression tests. Co-Authored-By: D.H.K. (Duco) Gaillard <gaillard.systems@gmail.com>
Bump version 0.12.0 -> 1.0.0 and update the changelog: Click CLI, regional-mds rename, delfi-gc-correct removal, DELFI speedup (#172), and documentation overhaul.
|
@DucoG Thank you for the help. We have incorporated this into our refactor and have credited you accordingly in changelog and as an Github contributor. |
Summary
delfiis roughly 80x faster on whole-genome inputs while producing bit-identical output. On a chr1+chr2 hg38 benchmark (4912 100kb bins, 16 worker processes), wall-clock time drops from 643s → 8s and total CPU time from 9845s → 59s.Motivation
While running DELFI on 211 WGS samples I profiled the worker function and noticed almost all of the per-window time was spent re-reading the blacklist BED file from disk and doing per-fragment linear scans against it. Per sample the existing implementation does ~26K full re-parses of the blacklist file (one per 100kb window) and reopens the BAM and 2bit reference inside every worker call. With many samples processed in parallel on shared/NFS storage this also produces a heavy and mostly-redundant I/O load.
Changes
src/finaletoolkit/frag/_delfi.py:np.searchsortedinstead of a linear scan over every fragment.multiprocessing.Poolinitializer (one open per worker, reused for every window). The handle is reused by passing the openpysam.AlignmentFiletofrag_generator, which already supports it.ContigGapsinto worker globals instead of pickling one into every task argument.str.count('G') + str.count('C')in place of a Pythonsumover'G' or 'C'comparisons.tests/test_delfi.py:test_workers_equivalencewhich assertsdelfi(workers=1)anddelfi(workers=4)produce bit-identical output on every column. This guards against any future regression that introduces parallel-only state in the worker pool.CHANGELOG.md: documents the speedup and the new test under[Unreleased].The public API (
delfi(...)signature, output schema, output values) is unchanged.Benchmarks
Hardware: 56-core x86_64, NFS-backed BAM, hg38, 16 workers, identical inputs across runs. Wall and CPU times are means over 5 runs each (σ < 1% in every cell).
main)Ablation (which optimisation contributes what)
Cumulative speedup as each optimisation is added on top of the previous:
The two dominant wins are preloading the blacklist (eliminates ~26K file reopens) and switching to a sorted-array blacklist filter (eliminates O(blacklist) work per fragment). The remaining three optimisations together are worth ~1.5s wall / ~25s CPU on this input.
Correctness
tests/test_delfi.py::test_overallpasses unchanged.tests/test_delfi.py::test_workers_equivalenceverifiesworkers=1andworkers=4produce identical output on every column.contig,start,stop,arm,short,long,gc,num_frags) matches exactly.Notes
pysam.fetchper contig instead of per window) hoping to reduce I/O. It was actually slower in every regime (cold and warm cache) because the existing per-window fetches use the BAI index and skip gap regions, while a per-contig fetch reads the full contig. Not pursued._end_motifs,_frag_length,_coverage,_wps, and_multi_wps. Happy to send follow-up PRs that extract a small shared_poolhelper and apply the same pattern there if there's appetite for it.