perf(predict): GPU channel reduction + pinned patch offload; fix CPU device default - #30
Merged
Merged
Conversation
vboussot
force-pushed
the
perf/gpu-channel-reduction
branch
2 times, most recently
from
July 5, 2026 17:04
26db2e8 to
c93289c
Compare
Run the per-model softmax/argmax over the class dimension on the GPU when the assembled chunk fits free VRAM. On multi-class whole-body segmentation this is the dominant reassembly cost (TotalSegmentator:total: argmax ~32s of a ~77s run — a strided argmax over a 122-class whole-body volume, slow on CPU). The blend stays on CPU and runs first over the full patch-combined volume; only the assembled chunk is moved to the device for the reduction, then the small single-channel result comes back. Order is unchanged: blend, then argmax, at the end. empty_cache() is called first so the forward's reserved cache is not mistaken for a full GPU; falls back to CPU when there is no CUDA device or the chunk does not fit. Also fixes the underlying device plumbing this relies on: NeedDevice.device now defaults to CPU (it was declared but never initialised, so any object off the .to() propagation path raised AttributeError), and the prediction output datasets are now co-located with the model in run_process (they were never moved to a device), so their reduction and any device-aware before_reduction transforms see the GPU.
vboussot
force-pushed
the
perf/gpu-channel-reduction
branch
from
July 5, 2026 17:21
c93289c to
f19ffe1
Compare
Dataset.__init__ does not forward super().__init__(), so the NeedDevice mixin was never initialised through OutputDataset's MRO. On a CPU-only PREDICTION run the output-writer device propagation is CUDA-gated and never calls .to(), leaving self.device unset; the channel-reduction path then raised 'OutSameAsGroupDataset has no attribute device'. Initialise NeedDevice explicitly so self.device always defaults to CPU. The existing reduction-device tests built the writer via __new__ and so never exercised __init__; add a regression test that constructs it normally and asserts the CPU default.
The prediction accumulator holds every patch of a case until assembly, so each GPU patch is copied to CPU individually. On a large multi-class ensemble (5-model TotalSegmentator, 610 channels/patch, ~2.4 GB) that pageable .cpu() is a slow, fully synchronous PCIe transfer. Stage the copy through one reusable page-locked buffer (a single patch), then copy into a fresh pageable tensor so the pinned buffer is reused and host page-locked RAM is capped at one patch. Bit-identical to .cpu(); small or non-CUDA patches keep the plain path; falls back to .cpu() when the host cannot lock the memory. TotalSegmentator:total on a whole-body CT: 29.7s -> 26.0s, output byte-identical (0 / 6.4M voxels differ); the isolated 610-ch D2H is 3.4x faster (7.5s -> 2.2s for 9 patches).
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.
Summary
Run the per-model channel reduction (softmax/argmax over the class dimension) on the GPU when the
assembled chunk fits free VRAM. On multi-class whole-body segmentation this is the dominant reassembly
cost — on
TotalSegmentator:totalthe argmax alone was ~32 s of a ~77 s run (5 models × a stridedargmax over a 122-class whole-body volume, pathologically slow on CPU).
What does not change
The overlap blend stays on CPU and runs first over the full patch-combined volume (
Accumulator.assembleaverages over the class channels across overlapping patches; patches are never all held in VRAM). Only the
already-assembled chunk is moved to the device for the reduction, then the small single-channel argmax result
comes back. Order is unchanged: blend, then argmax, at the end.
empty_cache()is called first so theforward's reserved cache is not mistaken for a full GPU; falls back to CPU when there is no CUDA device or the
chunk (plus headroom) does not fit.
Device plumbing this depends on (also fixed here)
The reduction needs to know the GPU, which exposed two latent gaps:
NeedDevice.devicewas declared but never initialised — any object off the.to()propagation pathraised
AttributeError. It now defaults totorch.device("cpu").run_processnow co-locates them with themodel, so their reduction (and any device-aware
before_reductiontransforms) see the GPU. Note theNeedDeviceconvention stores a CUDA ordinal (int) on GPU and atorch.deviceon CPU, so the reductionnormalises accordingly.
Effect (measured,
TotalSegmentator:total, 5 models, 1.5 mm, RTX PRO 5000)CPU, the same negligible category as fix(patching): keep overlap-blend reassembly in the patch dtype (halve peak memory) #27/perf(patching): separable blend windows (drop distance-map Cosinus, add Gaussian) #29.
Test plan
test_predictor_reduction_device: GPU when the chunk fits, CPU fallback when oversized / no CUDA / a CPUdataset.
test_predictor_memoryandtest_runtime_guardsstill pass.TotalSegmentator:totalfrom crashing to running in ~47 s without OOM.Folded-in follow-ups
fix(predict): defaultOutputDataset.deviceto CPU on CPU-only runs.Dataset.__init__does not forwardsuper().__init__(), so theNeedDevicemixin was neverinitialised through the MRO. On a
--cpuPREDICTION the output-writer device propagation isCUDA-gated and never calls
.to(), soself.devicewas unset and the reduction path raisedAttributeError. Fixed by initialisingNeedDeviceexplicitly; adds a regression test thatbuilds the writer via its real constructor (the existing tests used
__new__and missed it).This is what turned CI red.
perf(predict): stage the per-patch GPU→CPU offload through pinned memory.The accumulator keeps every patch of a case until assembly, so each large multi-class patch
(5-model TotalSegmentator: 610 ch, ~2.4 GB) was copied to CPU via a slow pageable
.cpu().Now staged through one reusable page-locked buffer (pinned RAM capped at a single patch), then
copied into a fresh pageable tensor. Bit-identical to
.cpu(); small/non-CUDA patches keepthe plain path; falls back to
.cpu()if the host can't lock memory.Measured on
TotalSegmentator:total, whole-body CT: 29.7 s → 26.0 s, output byte-identical(0 / 6.4M voxels differ), +0.5 GB peak RAM; isolated 610-ch D2H 3.4× faster (7.5 s → 2.2 s / 9 patches).