Repository navigation
perf: stop paying kornia's per-module container cost on every batch - #128
Merged
Merged
Conversation
PR #126 made the augmentations faster and left the container they run in untouched; with every `p` set to 0 so nothing fires, a 26-module pipeline on a [2, 1, 128, 128, 128] batch still took 13-14 ms. That is the cost of `AugmentationSequential` itself, and it is paid once per training batch whether or not any augmentation does anything. It is now 3.6-4.3 ms. Four things, in descending order of what they cost: * **201 MB of segmentation copied per call.** kornia deep-copies the parameter dict on the way into every leaf -- `_process_kwargs_to_params_and_flags` -> `override_parameters` -> `deepcopy_dict`, which clones every value that is a `Tensor`, three times per module. SmaugLab puts the segmentation in that dict, so the label map was duplicated 78 times a batch. `SegmentationRef` carries it behind an object instead, which `deepcopy_dict` passes through by reference, and resolves kornia's row selection on first read rather than eagerly -- most modules never look at it. Measured on `transform_params_gpu.json`: 279 clones and 201.3 MB become 22 clones and 0.0 MB. Read it with `segmentation_from`, which still accepts a bare tensor from a direct `apply_transform` call. * **The leaf `forward` chain.** `_apply_leaf` inlines `forward` -> `apply_func` -> `transform_inputs` for this package's 3-D leaves: two parameter deep-copies, three `transform_tensor` round trips and four calls to kornia's `is_autocast_enabled` -- which re-parses `torch.__version__` with `packaging.version.Version`, 7.7 us a time against 0.08 us for the builtin it wraps. `_can_apply_directly` states the conditions under which each skipped step is an identity, and declines otherwise. * **The mask pass for transforms that cannot touch a mask.** Most of a pipeline is image-only, and for those `transform_masks` deep-copies, reshapes, validates and resolves the row selection in order to hand back the tensor it was given. `_leaves_masks_untouched` tests that both mask hooks are `ImageOnlyTransform`'s own, so the skip is exact rather than assumed. * **Constants rebuilt per module.** The 4x4 identity (`torch.eye` plus a `repeat`, 26 times a call) and `forward_input_shape` (`torch.tensor` of a five-element shape, 8.5 us, 27 times a call) are now cached on the patch shape and expanded rather than allocated. `_leaf_parameters` also skips the one-element allocation, sum and broadcast multiply that `__batch_prob_generator__` performs to decide something `p_batch == 1` already fixed, and defers to kornia whenever `p_batch` is not 1. Nothing here changes what the pipeline computes: both shipped configs, in all three pipeline modes, produce bit-identical images and masks against #126 over twelve seeds on the CPU. (On CUDA the pipeline already varies by ~1e-5 between two runs of the *same* checkout, `origin/main` included -- float atomics -- so that is the resolution the comparison has there.) The short-circuits duplicate kornia control flow, which is a moving dependency, so `unit_tests/test_container_fast_path.py` runs every registered transform down both routes -- all-apply, no-apply and the partial-batch case -- and asserts the outputs, the stored transform matrices and the drawn parameter dictionaries all match. It checks the claim against whichever kornia the compat matrix installs rather than stating it. The two private kornia helpers the parameter draw needs are imported behind a try/except; without them the override hands the whole loop back to kornia. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_leaf_parameters` took an `nn.Module` and called `forward_parameters` on it. That method is not declared on `Module`, so the attribute resolves through `Module.__getattr__`, which is typed as returning `Tensor | Module` -- and a `Tensor` is not callable. mypy 2.4.0, the version pinned in pyproject and run by CI, rejects it; 2.1.0 does not, which is why it got through locally. Fixed the way `_RegionSelecting` in gpu/contrast.py already handles the same problem: a Protocol naming the one method the helper needs, which both type-checks and states the requirement. `isinstance(module, _AugmentationBase)` still narrows inside, so the fast path keeps resolving `p`, `p_batch`, `same_on_batch`, `_p_gen` and `generate_parameters` against kornia's class. 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.
Follows #126, which has landed; this branch is off
mainand the diff is onlythe container work. The tables below carry a
#126column so the two steps canbe told apart — it is the commit now at the tip of
main.#126 made the augmentations faster and left the container they run in alone. Set
every
pto 0 so that nothing fires, and a 26-module pipeline on a[2, 1, 128, 128, 128]batch still took 13.9 ms onorigin/mainand13.0 ms after #126. That is
AugmentationSequentialitself, paid once pertraining batch whether or not a single augmentation does anything. It is now
3.9 ms.
What it was spending it on
201 MB of segmentation, copied per call. kornia deep-copies the parameter
dict on the way into every leaf —
_process_kwargs_to_params_and_flags→override_parameters→deepcopy_dict, which clones every value that is aTensor, once per data key per module. SmaugLab puts the segmentation in thatdict, so the label map was duplicated 78 times a batch:
SegmentationRefcarries it behind an object, whichdeepcopy_dictpassesthrough by reference, and resolves kornia's row selection on first read instead
of eagerly — most modules never look at it. Consumers read it with
segmentation_from(params), which still accepts a bare tensor from a directapply_transformcall.The leaf
forwardchain._apply_leafinlinesforward→apply_func→transform_inputsfor this package's 3-D leaves, skipping two parameterdeep-copies, three
transform_tensorround trips and four calls to kornia'sis_autocast_enabled— which re-parsestorch.__version__withpackaging.version.Versionon every call: 7.7 µs, against 0.08 µs forthe builtin it wraps, roughly a hundred times a batch.
The mask pass for transforms that cannot touch a mask. Most of a pipeline is
image-only, and for those
transform_masksdeep-copies, reshapes, validates andresolves the row selection in order to return the tensor it was handed.
Constants rebuilt per module. The 4×4 identity (
torch.eyeplus arepeat,26× a call) and
forward_input_shape(torch.tensorof a five-element shape,8.5 µs, 27× a call) are cached on the patch shape and expanded rather than
allocated.
_leaf_parametersalso drops the one-element allocation, sum andbroadcast multiply that
__batch_prob_generator__performs to decide somethingp_batch == 1already fixed.Net effect on the Python side: cProfile over 20 zero-probability calls goes from
0.276 s to 0.098 s.
Measurements
Idle NVIDIA L40S, torch 2.6.0+cu124, kornia 0.8.2,
[2, 1, 128, 128, 128]with a3-label segmentation. Median of 4 interleaved rounds of 50 calls — the three
checkouts take turns within each round, so GPU clock ramp hits all of them
equally, and each process warms up 20 calls before timing.
gpu.jsongpu.jsongpu.jsonone-sequence-to-segment.jsonone-sequence-to-segment.jsonone-sequence-to-segment.jsongpu.json[all p=0]one-sequence…[all p=0]The
[all p=0]rows are the container on its own.A note on comparing with #126's table: that one timed each checkout in a single
long block, which lets the GPU settle at a high clock; this one restarts a
process every ~20 s so the clock spends more time ramping. Absolute numbers are
accordingly higher here (
main/sequential reads 24.66 against 18.16 there). Theratios are what transfer between the two protocols, and within this table the
conditions are matched.
Correctness
Bit-identical output. Both shipped configs, in all three pipeline modes,
image and mask, over twelve seeds on CPU:
max|diff| = 0against #126 in everyone of the twelve series. This is a pure-overhead change and that is what a pure
overhead change should produce.
On CUDA the comparison bottoms out at ~1e-5, because the pipeline is already
non-deterministic between two runs of the same checkout —
origin/mainincluded (1.2e-5 … 3.8e-5 between two runs of it). Float atomics; not introduced
here, and worth knowing independently.
The short-circuits are checked against kornia, not assumed. They duplicate
kornia control flow, and kornia is a moving dependency —
tests.ymlruns acompat matrix over several versions. So
unit_tests/test_container_fast_path.py(22 tests, 281 subtests) runs every registered transform down both routes, for
all-apply, no-apply and the partial-batch case, and asserts that
_leaf_parametersand kornia'sforward_parametersdraw match tensor-for-tensor, across a grid ofp/p_batch/same_on_batchand three seeds,transform_masksreturns,plus the cases where the fast path must decline: a 4-D volume, non-empty
extra_args, a foreign module, an open autocast region, and a subclass thatoverrides a mask hook.
The two private kornia helpers the parameter draw needs (
_get_new_batch_shape,ImageSequentialBase) are imported behind atry/except; a kornia that movedthem gets its own loop back, and a test exercises that fallback path.
455 passed, 3 skipped, 1596 subtests;ruff check,ruff format --checkandmypyclean.What is left
forward_parametersis still the largest single block of the remaining ~3.9 ms:the per-module probability draw and the geometric generators, which genuinely
have to sample. Cutting further would mean skipping parameter generation for
modules that will not fire, which changes the RNG stream under
same_on_batchand is not worth it for the ~0.4 ms on offer.
🤖 Generated with Claude Code