Skip to content

perf(predict): GPU channel reduction + pinned patch offload; fix CPU device default - #30

Merged
vboussot merged 3 commits into
mainfrom
perf/gpu-channel-reduction
Jul 6, 2026
Merged

perf(predict): GPU channel reduction + pinned patch offload; fix CPU device default#30
vboussot merged 3 commits into
mainfrom
perf/gpu-channel-reduction

Conversation

@vboussot

@vboussot vboussot commented Jul 5, 2026

Copy link
Copy Markdown
Member

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:total the argmax alone was ~32 s of a ~77 s run (5 models × a strided
argmax 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.assemble
averages 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 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 (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.device was declared but never initialised — any object off the .to() propagation path
    raised AttributeError. It now defaults to torch.device("cpu").
  • The prediction output datasets were never moved to a device. run_process now co-locates them with the
    model, so their reduction (and any device-aware before_reduction transforms) see the GPU. Note the
    NeedDevice convention stores a CUDA ordinal (int) on GPU and a torch.device on CPU, so the reduction
    normalises accordingly.

Effect (measured, TotalSegmentator:total, 5 models, 1.5 mm, RTX PRO 5000)

Test plan


Folded-in follow-ups

fix(predict): default OutputDataset.device to CPU on CPU-only runs.
Dataset.__init__ does not forward super().__init__(), so the NeedDevice mixin was never
initialised through the MRO. On a --cpu PREDICTION the output-writer device propagation is
CUDA-gated and never calls .to(), so self.device was unset and the reduction path raised
AttributeError. Fixed by initialising NeedDevice explicitly; adds a regression test that
builds 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 keep
the 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).

@vboussot
vboussot force-pushed the perf/gpu-channel-reduction branch 2 times, most recently from 26db2e8 to c93289c Compare July 5, 2026 17:04
@vboussot vboussot changed the title perf(predict): GPU channel reduction (argmax) when it fits VRAM — byte-identical perf(predict): GPU channel reduction (argmax) when it fits VRAM (−31s on total) Jul 5, 2026
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 vboussot changed the title perf(predict): GPU channel reduction (argmax) when it fits VRAM (−31s on total) perf(predict): GPU channel reduction (argmax) + device propagation (−32s on total) Jul 5, 2026
@vboussot
vboussot force-pushed the perf/gpu-channel-reduction branch from c93289c to f19ffe1 Compare July 5, 2026 17:21
vboussot added 2 commits July 5, 2026 20:08
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).
@vboussot vboussot changed the title perf(predict): GPU channel reduction (argmax) + device propagation (−32s on total) perf(predict): GPU channel reduction + pinned patch offload; fix CPU device default Jul 5, 2026
@vboussot
vboussot merged commit a2a1356 into main Jul 6, 2026
29 checks passed
@vboussot
vboussot deleted the perf/gpu-channel-reduction branch July 6, 2026 08:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant