fix(predict): make torch.compile take effect, and unblock the GPU pipeline - #253
Open
jayhesselberth wants to merge 1 commit into
Open
jayhesselberth wants to merge 1 commit into
jayhesselberth wants to merge 1 commit into
Conversation
…eline The compiled module was assigned to `model_wrapper.model` while `forward_batch` calls `forward_module`, so compiling was a measured 1.00x. Compilation was also skipped whenever a CL-regression repr hook was present, though only CUDA graphs are incompatible with it, and every mega-batch's short tail forced a recompile. Separately the BAM writer sat on the consumer's critical path behind a one-deep future, which is when the GPU went idle. Claude-Session: https://claude.ai/code/session_01V6XtaYNMsjxDLoD9aW1KJt
This was referenced Sep 13, 2026
jayhesselberth
added a commit
that referenced
this pull request
Sep 13, 2026
…gle and bundle predict run_bundle_inference's serial Python extraction path carried its own copy of the per-chunk array-building loop (signal channels, sequence encoding, feature-window narrowing) that run_inference's sequential path also had, and the two had drifted: only the bundle copy applied dwell templates, and only the bundle copy skipped prepare_signal_channels's signal_len padding/cropping on the raw signal itself. chunks_for_read in inference/helpers.py is now the one place that loop runs, used by both. GpuBatchRunner extracts the double-buffered single-worker async GPU submission pattern PR #253 introduced for run_inference's sequential path into a reusable primitive, now used by all three of run_bundle_inference's extraction paths (Rust, num_workers>0, serial) via a new BundleScorer class wrapping the vmap/sequential-wrappers/Platt-scaling logic that used to be a closure. Fixed a real bug surfaced while wiring GpuBatchRunner into run_inference's own sequential path: its score closure bound `pending` by value via functools.partial at construction, so when _finalize_mega_batch rebound the name to a fresh dict for the next mega-batch (letting the async BAM-write thread keep draining the old one safely), every mega-batch after the first silently lost its predictions instead of writing them. Fixed by using a plain closure (which looks pending up again on every call) instead of a partial; caught by the new TestExtractionPathParity::test_worker_path_matches_sequential_path regression, not by inspection. Also found and left out of scope: chaining a real torch CPU-inference run_bundle_inference(num_workers=0) call with a real run_bundle_inference(num_workers=2) call in the same process hangs, forking mp.Pool after libtorch's native CPU thread pool has started -- a pre-existing fork-safety hazard unrelated to this change (GpuBatchRunner's own thread is cleanly joined by the time the second call starts, and a second real-inference call that does not fork does not hang). Noted in tests/test_inference.py::TestBundleExtractionPathParity for a future issue. Non-goals held: aggregation semantics (leech.inference.aggregation) untouched; InferenceSpec (issue #269) untouched by this commit. Closes #268
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.
Investigating persistently patchy GPU utilization in
leech predict(the classify stage of2026-aa-trna-models). Every number below is from one real workload — 176,210 reads / 137,137 chunks, 1.8 GB POD5, the 20-classTCNDwellResidualLNproduction bundle with a CL-regression head — on an A30 with 16 logical CPUs, which is 8 physical cores (CR_CORE, both hyperthread siblings per core), the shape this runs in: four such jobs share one 64-core/4-GPU node.The headline is a negative result
At that size the process runs at 904% of a ~9.4-effective-core ceiling, i.e. 87-96% CPU-saturated, and ~84% of extraction CPU is escapepod-signal's resquiggle banded DP (
perf:DpContext::step37.9%,dwell_block_kernel_avx51224.3%,dp_step_buffered18.0%,refine_signal_map4.2%). Extraction is ~70% of the job's CPU.So the GPU is not underfed by a scheduling defect — it is simply five times faster than the CPU can supply it, and no amount of pipeline restructuring changes that on a saturated box. Overlap fixes here buy ~2% wall; the real lever is upstream in the DP, which is filed separately.
What this PR does is fix the things that were outright broken or actively costing, and stop the pipeline stalling the GPU in long blocks.
torch.compilehas never taken effectrun_inferenceassigned the compiled module tomodel_wrapper.model, butforward_batchcallsself.forward_module— separate attributes. Measured, batch 1024:torch.compileassigned to.model(what shipped).forward_module, hook presentreduce-overhead, no hookTwo more problems behind it. Compilation was skipped outright whenever a CL-regression repr hook existed — every production multiclass bundle — but only CUDA graphs cannot replay the hook's Python side effect; plain inductor graph-breaks there and still fuses around it, recovering 1.81x of the 1.83x ceiling. And every mega-batch ends in a short batch, so an unpadded run presents ~19 distinct shapes and recompiles for each: enabling compile naively measured 45.8s → 101.5s.
_stack_to_devicenow takespad_toand the batch runners slice the padding off; the model is per-sample throughout (convolutions, GroupNorm/LayerNorm, BatchNorm in eval) so padding cannot change a real row.The auto-enable threshold moves 5,000 → 2,000,000 reads. Compiling costs ~60s wall and buys ~10% at this deployment size, so it only pays past roughly ten minutes of inference; the old threshold turned it on for essentially every run, at a large net loss.
Extraction sub-batching was dead code
_SUB_BATCH_SIZEwas 50,000 reads against a defaultread_batch_sizeof 10,000, so the loop written to feed the GPU continuously always ran exactly once — the failure escapepod-rs#361 documents on its own GPU pipeline ("the GPU consumer sits fully idle for the entire duration of that one giant prep call"). It is now real and configurable, defaulting to the whole mega-batch: splitting it only pays where there are idle cores, and on a saturated 8-core box it cost ~4s in added GIL contention.The BAM writer was on the consumer's critical path
The consumer blocked on the previous write before it could submit the next. Writing a mega-batch is slower than producing one (pysam tagging ~7k reads/s), so that wait was 22.9s of a 46s run — and that is precisely when the GPU had nothing queued. Now a dedicated writer thread behind a bounded queue: still exactly one thread touching pysam, still in mega-batch order, but the consumer no longer waits. BGZF compression also moves into an htslib thread pool to get deflate off the GIL. The put is timeout-guarded so a dead writer surfaces as an error rather than a hang.
GPU batches likewise queue several deep instead of a one-deep handshake. The executor stays
max_workers=1— load-bearing, since it keeps GPU calls sequential, keeps_stack_to_device's thread-local pinned buffer single-owner, and keepspending[read_id]in extraction order.Rayon threads reserved proportionally
avail - 6is a flat reservation that leaves 1 extraction thread at--cpus-per-task 4and 2 at 8, on the stage that is ~70% of the job's CPU. Now a quarter, capped at 4. Extraction scales cleanly to the allocation's physical core count and only ~12% further across the hyperthread siblings — on 8 physical cores: 149.1s at 2 threads, 76.5s at 4, 40.1s at 8, 35.8s at 14, 34.3s at 16.Measured effect
Interleaved A/B (alternating arms, warm page cache) at the 16-logical-CPU deployment size.
Out of the box, letting leech pick its own rayon thread count — 10 threads before, 12 after — 3 repeats:
3.8% faster, +15% relative GPU duty, and notably tighter run-to-run spread.
With
RAYON_NUM_THREADS=14pinned in both arms, isolating the pipeline changes alone, 4 repeats:~2% wall, ~+18% relative duty cycle, and the long per-mega-batch stall is gone. On a box already at ~90% CPU saturation, ~2% is about all overlap can be worth — which is the point of the negative result above.
Output is byte-identical: 176,210 records compared on
aa/ac/am/pn/pp/pc, 0 differing, same file size. This is a scheduling change, not a grouping change.Testing
uv run pytest: 1131 passed, 18 skipped. One failure,test_profiling.py::test_gpu_util_sampler_no_cuda_is_noop, reproduces identically on unmodifiedmainon a CUDA-present machine and is unrelated.ruff check,ruff format --checkandty checkall clean.tests/test_inference.pygains a guard that the new writer thread is joined at teardown, alongside the existing one for executor shutdown — same fork-safety hazard, new shape.Not done here, deliberately
(shift, scale, drift)it computes each iteration. Windowing it to the ~30 bases actually consumed is not safely doable: both band endpoints are hard-anchored (bands.rsassertsstart[0]==0/end[last]==seq_len) and six of the statistics feeding each pass are global over the read. Buffer reuse inbanded_dplooked promising — ~400 KB of freshVecs per pass, 2 passes/read, past glibc's 128 KB mmap threshold — but testing the hypothesis for free by raisingMALLOC_MMAP_THRESHOLD_to 8 MB moved extraction 20.8s → 20.7s, i.e. nothing, so that change was not worth building. What is left upstream either changes numerics (dropping a rescale iteration) or is ~2%.moves.tolist()→Vec<Vec<u8>>). Measured before building:.tolist()over 255M elements is 1.0s and total FFI marshalling is 3.3s of 35.7s. Worth ~8%, real, but not the lever.🤖 Generated with Claude Code
https://claude.ai/code/session_01V6XtaYNMsjxDLoD9aW1KJt