Repository navigation
fix: pin the region-moment matmul to fp32, which autocast overflows - #136
Merged
Merged
Conversation
`_region_moments` computes per-region voxel count, mean and variance as `region_mask @ design`, where the design matrix's first column is all ones -- so the first output column is a plain voxel count. `autocast` demotes that matmul to fp16, which maxes at 65504, and the count saturates to `inf` for any region above about 40^3 voxels. At the 128^3 training patch size the background region always does. The failure is silent and total. `counts=inf` with `sums=inf` gives `means=NaN`, every per-region Gaussian contribution in `RandomRedistributeSegGPU` becomes NaN, the `tmax - tmin > 1e-8` guard is False on NaN, and `torch.where` returns the input untouched. `retain_stats` then maps the untouched min-max normalisation back to the original mean and std, so the transform is an exact identity. The `torch.isfinite(x).all()` check cannot see it, because the NaN only ever lived in `to_add`, and `record_applications` still reports `changed=True` off the 2.4e-7 of fp32 round-trip noise. The matmul form arrived in 20360d8; before it the same quantity was `mask_flat.sum(dim=1)`, a reduction, which autocast keeps in fp32. So redistribution has been dead in every run trained since, measured on a real preprocessed Dataset804 patch: max abs change 2.4e-7 on data of range 1.7, for every seed and at `in_seg` 0.25 and 1.0. Pinning the matmul to fp32 restores it -- 100% of voxels moved by up to 1.83, bit-identical to the same call with autocast off. The counts are represented exactly (128^3 is far below 2^24) and TF32 keeps the matmul as fast as the fp16 one: 0.33 -> 0.40 ms per call, about 0.04 s on a 53.7 s epoch. The moments are returned in fp32 deliberately, since casting back to `design.dtype` would reintroduce the overflow. Retraining the paper's "Ours" configuration with this patch and nothing else changed recovers the out-of-domain transfer the four-sequence retrains had lost: out-of-domain mean Dice on Dataset804 0.639 -> 0.777 against 0.760 before the regression, SpineGAN Dixon water 0.301 -> 0.769, its spinal-canal class 0.110 -> 0.863, with in-domain unchanged to within 0.006. The new tests pin the count against overflow and the transform against being an identity. They fail before this change and pass after, and they need no GPU because fp16 CPU autocast demotes the same operation. They do need a 64^3 patch: the identity requires two overflows, since with only the count at `inf` the mean comes out 0 rather than NaN, which is still wrong but still moves the data. 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.
The bug
_region_momentscomputes per-region voxel count, mean and variance in one matmul,region_mask @ design, where the first column ofdesignis all ones. So the first output column is a plain voxel count.autocastdemotes that matmul to fp16, which maxes at 65504, and the count saturates toinffor any region above about 40³ voxels. At the 128³ training patch size the background region always does.The failure is silent and total:
counts = infwithsums = infgivesmeans = NaNRandomRedistributeSegGPUbecomesNaNtmax - tmin > 1e-8guard isFalseonNaN, sotorch.wherereturns the inputretain_statsmaps that untouched min-max normalisation back to the original mean and stdThe transform is then an exact identity. Nothing raises: the
torch.isfinite(x).all()guard cannot see it, because theNaNonly ever lived into_add, andrecord_applicationsstill reportschanged=True, because the fp32 round trip leaves 2.4e-7 of rounding noise and the flag is an exacttorch.equal.The matmul form arrived in
20360d8("perf: remove the per-sample loops and host stalls from the augmentations"). Before it, the same quantity wasmask_flat.sum(dim=1), a reduction, which autocast keeps in fp32. So redistribution has been dead in every run trained since.Reproduced
On a real preprocessed Dataset804 128³ patch under
autocast(float16), five seeds,in_seg0.25 and 1.0, data of range 1.74:maininf1540096.0The patched numbers are bit-identical to the same calls with autocast switched off, which is the result the fix is supposed to produce.
What it cost
Four single-variable retrains of the paper's
Oursconfiguration, this patch and nothing else changed, 1000 epochs each, evaluated per subject against both the pre-refactor runs and their20360d8-era siblings. Out-of-domain mean Dice, test split:20360d8Worst single cell, Dataset804 evaluated on SpineGAN Dixon water: 0.713 → 0.301 → 0.769 Dice, and its spinal-canal class 0.824 → 0.110 → 0.863. ASSD agrees, 2.47 → 10.67 → 1.64 mm. In-domain cells move by at most 0.006 in either direction, so this is transfer, not fit, and every difference quoted is Wilcoxon-significant at p<0.001 per subject.
Two caveats worth stating. Out-of-domain cohorts that never regressed also improve by +0.004 to +0.051 Dice, largest on CT seen from an MRI-trained model, so the patch is not a pure restoration of the old behaviour; it also lets
58d8783("read segmentation regions from label values, not the channel axis") take effect for the first time, since before that the whole foreground collapsed to one region. And each cell is a single fold-0 run, so none of this separates a seed effect within a cell.The fix
fp32 represents these counts exactly (128³ is far below 2²⁴) and TF32 keeps the matmul as fast as the fp16 one:
_region_momentsgoes 0.329 → 0.403 ms per call on an A40 at the training shape, the whole transform 9.24 → 9.63 ms, about 0.04 s on a 53.7 s epoch atp=0.4. Median epoch time over the four 1000-epoch retrains came out lower than their siblings' on three of four, so the cost is below card-load noise at this scale.The moments are returned in fp32 deliberately. Casting back to
design.dtypewould reintroduce the overflow at the caller.Tests
unit_tests/test_redistribute_autocast.pypins both halves: that the count survives autocast and agrees exactly with the same call outside it, and that the transform is not an identity under autocast. The four tests fail onmainand pass here. They need no GPU, becausetorch.autocast("cpu", torch.float16)demotes the same op.They do need a 64³ patch. The identity requires two overflows: with only the count at
infthe mean comes out0rather thanNaN, which is still wrong but still moves the data. TheNaNneedssum(x)to overflow too, which in the transform's [0, 1] working space takes roughly 131k voxels, so 48³ does not reproduce it and 64³ does. That is in a comment next to the constant.🤖 Generated with Claude Code