Repository navigation
perf: remove the per-sample loops and host stalls from the augmentations - #126
Merged
Merged
Conversation
Every augmentation was read for work that scales with the batch rather than with the volume: Python loops over samples or regions, `.item()` reads inside those loops, and a few operations whose cost came from how they were spelled rather than from what they compute. On a [2, 1, 128, 128, 128] patch the 29 GPU transforms together drop from 86.8 ms to 56.2 ms, and the two heaviest -- the PALETTE pair -- from 43.6/18.0 ms to 28.3/9.5 ms. The three recurring causes: * **A one-channel `F.conv3d` is cuDNN's slowest path.** Folding the batch into the channel axis so `groups == B*C` selects ATen's depthwise kernel instead: 4.25 ms -> 0.12 ms for a 3x3x3 kernel over a 128^3 patch on an A40, and closer to the float64 reference as well, because the implicit-GEMM path it leaves runs in TF32. `kernels.depthwise_conv3d` is that reshape, and the convolution-based transforms now hand it the whole batch (with per-sample kernels zero-padded to a common width where RandConv draws different sizes). * **Reading a device scalar back to Python blocks the host.** `float(scales[b])`, `int(mask.sum())`, `bool(flips[b, axis])` and `torch.rand(1).item()` inside a per-sample loop each drain the queue; they are now single `.tolist()` transfers taken before the loop. * **`scatter_add_` into a handful of bins is an atomics contention test.** The region sums in PALETTE, the k-means cluster sums and the equalisation histogram all went through `bincount`, which accumulates in shared memory, or through a prefix sum. Beyond that: the bias-field polynomial is evaluated as a separable contraction rather than twenty full-volume terms; the EM/GMM E and M steps are two matrix multiplies instead of a dozen `(n, k)` passes; Voronoi sub-parcellation computes distances only for the cluster it is subdividing; the Scharr directions run as one convolution; `torch.quantile` per sample becomes one batched sort; and the constant kernels, the voxel-coordinate grid, the polynomial basis and the identity warp grid are cached on the patch shape instead of rebuilt per call. On the CPU backend the Laplacian uses its `3**d * delta - box` identity, the Scharr directions share one convolution, scipy's `norm.pdf` and its NumPy round trip are gone, and the redistribute dilation applies its structuring element three times rather than once pre-iterated: 110 -> 29 ms, 83 -> 33 ms, 26 -> 11 ms. Most transforms are bit-identical to before; `unit_tests/test_fast_paths.py` pins each reformulated primitive against the spelling it replaced. The exceptions are stated rather than hidden: * The convolution, bias-field and Scharr paths differ by float32 round-off (<= 2.7e-4 absolute, and the convolutions are *more* accurate than before). * PALETTE, the composed PALETTE, redistribute, the flip-axis guarantee, the bucketed random-choose pipelines and SynthSeg's background randomisation draw the same uniforms in a different order, so a seeded run no longer reproduces the previous release's output. Each was checked against a control that compares the old code with itself over a disjoint block of seeds: in every case the old-vs-new difference in mean, standard deviation and quantiles is smaller than that control. * `RandomHistogramEqualizationGPU` no longer raises out of `torch.histc` on a non-finite input; its own guard catches it and returns the channel untouched, which is what the guard was added for. The test that documented the old behaviour now pins the new one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Every augmentation in
smauglab/transformswas read for work that scales with thebatch rather than with the volume. The three patterns that kept turning up:
F.conv3dis cuDNN's slowest path. Folding the batch intothe channel axis so
groups == B*Cpicks ATen's depthwise kernel instead. On anA40, a 3x3x3 kernel over a 128³ patch: 4.25 ms → 0.12 ms. It is also the more
accurate path — the implicit-GEMM path it leaves runs in TF32 and lands 6e-3 from
a float64 reference where the depthwise kernel lands 4e-6 away.
float(scales[b]),int(mask.sum()),bool(flips[b, axis]),torch.rand(1).item()— each inside aper-sample loop. Now one
.tolist()before the loop.scatter_add_into a handful of bins is an atomics-contention test. Regionsums, k-means cluster sums and the equalisation histogram now go through
bincount(shared-memory accumulation) or a prefix sum.Measurements
Single idle NVIDIA L40S, torch 2.6.0+cu124, kornia 0.8.2. Input
[2, 1, 128, 128, 128]with a 3-label segmentation.
apply_transformcalled directly so the probabilitygate does not decide what gets timed. Each cell is the median of three runs of
25 calls each,
base=origin/mainin a separate worktree.Harness:
bench.py/bench_pipeline.py/bench_cpu.py, reproduced in the"Reproducing" section below.
GPU transforms
PaletteSynthesisGPU(EM partitioner)RandomPaletteGPURandomRedistributeSegGPURandomDomainTransferGPURandomRandConvGPURandomUnsharpMaskGPURandomGaussianBlurGPURandomAffineGPURandomBiasFieldGPURandomClampGPURandomHistogramEqualizationGPURandomScharrGPURandomInverseGPURandomContrastGPURandomInvGammaGPURandomGammaGPUZscoreNormalizationGPURandomLaplaceGPURandomSigmoidGPURandomGaussianNoiseGPURandomBrightnessGPURandomLog1pGPURandomSqrtGPURandomExpGPURandomSinGPURandomCropTransformGPURandomAcqTransformGPURandomLowResTransformGPURandomFlipTransformGPURandomAffineGPUis kornia'swarp_affine3dand was not touched; it is in thetable as a control.
SynthSeg
Same patch, median of three runs of 10 calls.
RandomSynthSegGPUSynthSegGenerator(full pipeline)SynthSegGenerator(EM label completion)CPU backend
[1, 96, 96, 96], 8 threads, median of three runs of 7 calls.ScharrConvTransformRedistributeTransformLaplaceConvTransformHistogramEqualTransformGaussianBlurTransformThe rest of the CPU backend is within noise of before;
GaussianBlurTransformisthird-party and in the table only to show it was not made worse.
End to end
Full pipeline
forward(image, mask),[2, 1, 128, 128, 128], median of five runsof 40 calls. Which transforms fire is itself random, hence the repeated runs.
transform_params_gpu.jsontransform_params_gpu.jsontransform_params_gpu.json…one-sequence-to-segment-them-all.json…one-sequence-to-segment-them-all.json…one-sequence-to-segment-them-all.jsonWhy
sequentialbarely moves, and what the next bottleneck is. Run the samepipelines with every
pset to 0 — nothing fires, so what is left is kornia'sAugmentationSequentialmachinery — andtransform_params_gpu.jsonstill takes10.4 ms before this PR and 10.5 ms after (measured in isolation, three
runs of 60; the two are within noise of each other). A profile of one sequential
call shows ~920 kernel launches for ~3.6 ms of GPU work: the container is
CPU-bound on per-module parameter sampling, not on the augmentations. The
random_ordermodes gain more because the bucket actually applies the transformsthis PR made faster. Cutting the container overhead is a separate change and is
not attempted here.
Correctness
unit_tests/test_fast_paths.py(new, 28 tests) pins every reformulated primitiveagainst the straightforward spelling it replaced — the grouped convolution against
a per-plane loop,
segment_sumagainstscatter_add_,_batched_quantilesagainst
torch.quantile,laplacian_responseagainst the dense kernel,_region_momentsagainst the explicit masked statistics, the bias-fieldcontraction against the monomial loop, and the EM quadratic form against the
Gaussian log density written out.
Beyond that, every registered GPU transform was run under 24 seeds on CPU and on
CUDA, at defaults and in 30 explicitly-configured variants (
same_on_batch,in_seg/out_seg/mix_in_out, one-hot and label-map segmentations, non-defaultorders and kernel sizes), and compared against
origin/main:gamma, brightness, contrast, noise, clamp, inverse, histogram equalisation,
z-score, the elementwise functions, and domain transfer in all four of its
modes.
(≤ 2.7e-4 at order 5, from reordering a 56-term sum), signed Scharr (2.4e-4 on
responses of magnitude ~700).
redistribute, the flip-axis "at least one" guarantee, the bucketed
RandomChooseXTransformsGPU, and SynthSeg's background randomisation now drawthe same independent uniforms in a different order, so a seeded run does not
reproduce the previous release's output. Each was checked against a control that
compares
origin/mainwith itself over a disjoint block of seeds; in everycase the old-vs-new difference in mean, standard deviation and quantiles is
smaller than that control, i.e. indistinguishable from re-seeding. Example
(
RandomPaletteGPU, 120 seeds): quantile distance control 0.254, old-vs-new0.122.
One deliberate behaviour change, with its test updated rather than deleted:
RandomHistogramEqualizationGPUused to raiseRuntimeErrorout oftorch.histcwhen the caller passed a non-finite voxel, in the middle of a training step. The
batched version derives its bin edges itself, so the NaN now reaches the
transform's own non-finite guard, which returns the channel untouched and prints a
warning — which is what that guard was added for.
Also fixed along the way:
RandomCropTransformGPUnow moves its sampledparameters to the input's device. kornia builds the generator's samplers on
whatever device
set_rng_device_and_dtypelast saw, andRandomChooseXTransformsGPUcallsforward_parameterswithout going throughforward— the scalar version read the draw out withfloat(...)and nevernoticed.
437 passed, 3 skipped, 1320 subtests,ruff check,ruff format --checkandmypyall clean.Reproducing
The benchmark scripts are not in the repository (they are throwaway harnesses, not
project code). Each one builds every registered transform with
p=1.0, callsapply_transformdirectly on a fixed input, and reports the median wall time withtorch.cuda.synchronize()around each call. To repeat the comparison:Numbers above were taken with
CUDA_VISIBLE_DEVICESpinned to one idle L40S.The convolution speed-up is strongly architecture-dependent: the same
groups == 1→groups == B*Cchange is 36x on an A40 (8.63 ms → 0.24 ms for aper-sample batch of two) and 4.7x on an L40S, because the two pick different
cuDNN heuristics. The table above uses the conservative (L40S) figures.
🤖 Generated with Claude Code