perf(training): fused multi-tensor gradient-norm reduction - #3685
Draft
akoumpa wants to merge 1 commit into
Draft
Conversation
Gradient clipping reduces every parameter's gradient separately, at roughly seven kernel launches each. On DiffusionGemma-26B-A4B (8xH100, ep=8) that is 5,434 launches and 74 ms per step for _clip_grad_norm_impl alone -- more kernel launches than the entire MoE GEMM path -- so the reduction is launch-bound rather than bandwidth-bound. This adds a Triton multi-tensor reduction that handles every gradient in one launch per dtype, used for the 2-norm and inf-norm (the orders the kernel implements). Measured in isolation over 740 tensors: per-tensor loop 16.77 ms fused multi-tensor 1.13 ms (14.8x) Two properties the kernel is deliberate about, because they are easy to get wrong in a norm reduction: Precision. Values are widened in registers on load, so a BF16 gradient is never squared in BF16 (|g| > 256 overflows once squared, and the mantissa degrades well before that). The in-tile tl.sum tree reduction runs in fp64 as well: reducing a tile in fp32 and widening only the tile total leaves fp32-level error (~1e-7 relative), which measured 8.9e-8 on a 2000-tensor case before this was fixed. Against an fp64 reference the kernel is now exact for BF16 and within 2.5e-16 (fp32) and 6.9e-15 (2000 tensors). Widening in registers matters as much as widening at all: upcasting whole gradients would allocate 2-8x the gradient memory across the model. Measured peak allocation overhead is 0.12% of gradient bytes. Determinism. The chunk decomposition is computed host-side from tensor sizes only and no atomics are used, so the norm is bit-identical run to run. An atomic accumulate would be simpler but would make the gradient norm -- and therefore every subsequent optimizer step -- non-reproducible. Non-finite handling is explicit. NaN and Inf propagate naturally through the fp64 sum-of-squares, but tl.maximum follows IEEE maxNum and *ignores* NaN, so an inf-norm over NaN gradients would have come back finite and silently defeated error_if_nonfinite; the kernel counts NaNs and poisons the partial. Inputs the kernel cannot read are routed to a slice-bounded reference path rather than converted: non-CUDA tensors, non-contiguous gradients (where .contiguous() would allocate a full-size copy), and dtypes outside the supported set -- notably FP8, whose reduction belongs in a scaled path rather than a raw square. Tensors are bucketed by dtype so a mixed batch can never reinterpret BF16 storage as FP32. DTensor gradients are unwrapped by the caller, which also owns the cross-rank reduction of the resulting scalar. Set NEMO_AUTOMODEL_FUSED_GRAD_NORM=0 to force the per-tensor path. Signed-off-by: Alexandros Koumparoulis <akoumparouli@nvidia.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.
Draft. The kernel is correct and fast in isolation, but the end-to-end gain is small — see Is this worth it? below. Opening for review of the approach rather than as a merge-ready change.
What
Gradient clipping reduces every parameter's gradient separately, ~7 kernel launches each. On DiffusionGemma-26B-A4B (8×H100, ep=8) that is 5,434 launches and 74 ms/step in
_clip_grad_norm_implalone — more launches than the entire MoE GEMM path. This replaces it with a Triton multi-tensor reduction: one launch per dtype, for the 2-norm and inf-norm.Measured
In isolation, 740 tensors:
End-to-end, 100 steps, DiffusionGemma-26B-A4B SFT with
dispatcher=deepep:NEMO_AUTOMODEL_FUSED_GRAD_NORM=0)wandb: before · after
Is this worth it?
Honestly: the end-to-end win is marginal. I originally sized this change off a profile that attributed 87 ms/step to gradient clipping, but that profile was captured under tracing, which inflates launch-heavy CPU-side work. The isolated benchmark puts the true cost at ~16.8 ms, so removing it is ~0.15% of a 10.4 s step; the measured 0.35% is consistent with that.
What the change does buy, beyond the 0.35%:
Reviewers may reasonably decide 300 lines and a Triton kernel is not worth 0.35%. That call is easier to make with the number stated plainly than buried.
Numerics
Steps 0–11 are bit-identical to the unpatched run (loss,
dllm_loss,grad_norm). They diverge from step 12 (loss 1.0903 vs 1.0890,grad_norm4.9544 vs 4.9792) and drift after that — the expected signature of a more accurate reduction in a system with discrete MoE routing: a last-bit change in the norm changes the clip scale, which flips routing, which amplifies. The 12 identical steps are what establish the implementation is right.Against an fp64 reference: exact for BF16, 2.5e-16 (fp32), 6.9e-15 (2000-tensor accumulation). The first version of this kernel reduced tiles in fp32 and was quietly ~1e-7 — the tolerance in the test is set tight enough to catch that.
Correctness properties
tl.sumruns in fp64 too. Widening in registers matters as much as widening at all: upcasting whole gradients would allocate 2–8× gradient memory. Measured peak overhead: 0.12% of gradient bytes.tl.maximumfollows IEEEmaxNumand ignores NaN, so an inf-norm over NaN gradients would have returned finite and silently defeatederror_if_nonfinite. The kernel counts NaNs and poisons the partial. Both paths tested for NaN/±Inf..contiguous()would allocate a full-size copy), and dtypes outside the supported set — notably FP8, whose reduction belongs in a scaled path rather than a raw square.NEMO_AUTOMODEL_FUSED_GRAD_NORM=0forces the per-tensor path.Not included
Unit tests. The validation above ran as a standalone script on 8×H100; it should be folded into
tests/unit_tests/before this leaves draft.