Skip to content

Make loss calculation deterministic and sync-free - #103

Open
ndryden wants to merge 3 commits into
triton-kernelsfrom
loss-determinism
Open

Make loss calculation deterministic and sync-free#103
ndryden wants to merge 3 commits into
triton-kernelsfrom
loss-determinism

Conversation

@ndryden

@ndryden ndryden commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator
  • It turns out reduction=sum is nondeterministic. Switching to no reduction and following it with a .sum() is deterministic and costs little.
  • Our use of bincount required a host/device synchronization. This has been replaced.
  • Small unrelated comment updates.

Code by Claude.

ndryden added 3 commits August 6, 2026 16:52
reduction="sum" is not just a convenience on CUDA: nll_loss and cross_entropy
fuse the reduction into the loss kernel and accumulate with atomicAdd, so the
summation order is whatever order the blocks retire in and the value changes
between identical calls. Compute per-voxel losses with reduction="none" and
.sum() them, which is an ordinary tree reduction over a fixed shape.

This was the last thing in the model that was not bitwise reproducible. The
Triton kernels closed everything else (0 of 64 gradient tensors vary against
15 with them off), which left the loss *value* alone drifting -- 3-4 distinct
values per 100 calls at 128**3, rel 2.2e-7. That is small, but it is not
confined to a log line: it lands in train_stats.csv, and is_best is keyed on
val_loss_avg, so it can decide a near-tie between two epochs.

more_determinism did not catch it and could not: worker.py passes
warn_only=True, so torch printed "nll_loss2d_forward_out_cuda_template does
not have a deterministic implementation" and carried on. The config that
exists to make runs reproducible was reporting the defect rather than fixing
it.

Measured, this tree, 4 runs at scale 7 / 128**3, plus 3 more with
more_determinism=1: parameters, per-batch losses, per-batch dice, first-batch
forward activations, CE class weights and every train_stats.csv column
(excluding wall-clock) are now identical bit for bit, and the default config
and more_determinism=1 produce the *same bits* -- more_determinism is now
redundant for reproducibility rather than partial. Same result at 2 ranks and
at 4 ranks with spatial sharding, DDP bucket fingerprints identical on both
sides of the all-reduce. The op probe at 128**3 goes from 1 of 66 tensors
varying to 0 of 66, while an in-process control calling the old
F.nll_loss(reduction="sum") still gives 3-4 distinct values -- so the node is
colliding today and the result is not a vacuous pass.

Not a cost at the scale that matters. fwd+bwd, 7 classes, paired alternating
arms: 241 -> 147 us at 128**3 (0.61x -- the split form wins outright, the
atomics were serializing) and 654 -> 715 us at 256**3 (1.09x, +61 us), against
74 ms and 458 ms steps. Peak memory is unchanged at both sizes: the per-voxel
fp32 tensor is freed before the backward allocates a gradient the size of
log_probs, which is 7x larger and sets the peak.

The two tests are pinned to 128**3 deliberately. Sweeping the pre-fix code,
the atomics do not collide at all below ~96**3 -- 48**3 and 64**3 give one
distinct value no matter which reduction is used -- so a smaller, faster test
would pass with the bug present. The strict-mode test asserts against
use_deterministic_algorithms(True) rather than more_determinism's warn_only
form, for the reason above: under warn_only a regression here is a log line,
not a failure.
The loss head did a blocking device-to-host read every training step.
torch.bincount sizes its output from the largest label, so it copies that
value back even though minlength already fixes the width; the unweighted
branch's new_tensor() staged a Python float through a pageable H2D copy. Both
are invisible in the result, which is why neither was noticed.

The cost is not the copy, it is the drain. The host blocks until the read
returns, which means it stops submitting, and the queue empties out behind it
-- a stall in the middle of a step whose kernels are otherwise long enough to
keep the device saturated.

Replace the histogram with the definition it was computing: the weighted
denominator is sum(weight[target_i]) over voxels, so gather the per-voxel
weights and sum them. That is one pass instead of a bincount plus a dot, and
it is also cheaper in its own right: 83.6 -> 26.1 us at 128**3 and 373.6 ->
90.8 us at 256**3. The unweighted branch uses new_full, which fills on the
device with the value as a kernel argument.

Measured with minibatch_bench, arms alternating within each rep, 22 steady
steps a run, 3 reps, 6 classes:

  config A (scale 7, 1 GPU)   73.27 -> 66.67 ms/step   -6.60 ms   9.0%
  config B (scale 8, 1 GPU)  456.72 -> 450.06 ms/step  -6.66 ms   1.5%

Per-rep ranges are disjoint at both (A: 73.13-74.82 vs 66.09-67.55; B:
455.07-456.75 vs 449.68-450.15). The saving is the same *absolute* size at
both scales while bincount's own kernel cost differs between them by 290 us,
which is the evidence that what was removed is a fixed-latency pipeline drain
and not a cheaper kernel. Under torch.cuda.set_sync_debug_mode("error") the
whole compute step -- forward, CE, dice, backward, Adam -- now completes with
no synchronizing op; before, the CE call alone raised.

This changes the numbers. Summing gathered weights is a different summation
order from counts-dot-weights, so the normalizer moves by up to ~1.3e-7
relative (7 of 20 seeds at 128**3). End to end that is one fp32 ULP at step 0
(6.1e-8) and then training amplifies it: by step 8, 1.2e-5. Runs before and
after this commit are therefore not bitwise comparable to each other. Each is
still bitwise reproducible with itself, which is the property that matters and
which was re-verified after this change -- 4 default runs and 3 with
more_determinism=1 at scale 7, all identical including every train_stats.csv
column, and default still produces the same bits as more_determinism=1.

The test asserts on set_sync_debug_mode, which the docs call a prototype that
does not catch every synchronizing op. It is a floor rather than a proof, but
it does catch both of the calls removed here -- checked by running it against
the previous code.
Both said the segmentation head is kept on MIOpen. _policy_declines has
returned False unconditionally since 2026-08-04, so the head runs on the
Triton rung like every other convolution -- the rung census confirms it
(FastConv3d.triton 228 over a 2-epoch scale-7 run: 19 convolutions a forward,
including outc, times 12 forwards).

The consequence is not cosmetic in the first case. _TritonConv3dFn.backward's
grad_bias line was documented as unreachable in ScaFFold, and outc is the only
biased convolution in the model, so it is now on the hot path -- a full-volume
reduction once a step that nothing was accounting for. The second is in
_TritonConvTranspose3dFn's docstring, where the claim was the contrast that
made grad_bias a difference between the two ladders; there is no difference
now.
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