[PyTorch] Fix NaN expert weight gradients at num_groups == 1 with SReLU - #3485
[PyTorch] Fix NaN expert weight gradients at num_groups == 1 with SReLU#3485GarlGuo wants to merge 1 commit into
Conversation
…els agree
At num_groups == 1 the fused grouped MLP may quantize and scale-swizzle the token
buffer as ONE dense tensor over `tensor.shape[0]` rows and read it back the same
way. That is coherent only if the cuDNN kernels also write their intermediates
densely over `tensor.shape[0]`, which is what `use_single_group_runtime_offsets`
asks of them -- they then derive M from the runtime tensor shapes rather than from
the offsets.
The two halves of that decision are currently gated differently. The request to
the kernels is already guarded, because it can be refused:
if supports_single_group_runtime_offsets:
fc1_activation_kwargs["use_single_group_runtime_offsets"] = num_groups == 1
but its siblings -- `use_single_group_weight_swizzle`, `use_single_discrete_weight`,
`use_single_group_dense_fc2`, `use_single_group_dense_dgrad`, the dense quantize
and the plain wgrad GEMM -- are gated on `num_groups == 1` alone. So when the
specialization is unavailable, `ScaledSReLU` at any version or any activation on a
cuDNN frontend older than 1.27.0, the kernels pack their output to
`sum(split_sizes)` rows while TE still reads everything densely.
Because the columnwise swizzled MXFP8 scale layout is `[k/128][m/128][32][4][4]`,
with the m-tile as an INNER stride, an m-extent mismatch misindexes every k-tile
past the first: consumer k-tile j reads producer k-tile j*T_c/T_p. The HEAD rows
-- the ones that do belong to the group -- pick up the wrong scale factors, and
high k-tiles read scale memory the producer never wrote. The data is read
correctly; only the scales move, so the error is order 1 and often NaN. Crucially
this is value-independent: zeroing the padded tail does not prevent it, because
the defect is in the indexing rather than in the padding's contents, so a caller
cannot work around it.
Callers may legally pass a token buffer padded past `sum(split_sizes)` -- an MoE
expert-parallel fixed-capacity receive buffer always does, and
`test_grouped_linear_cuda_graph_safe` documents the padded tail as "intentionally
outside every group" and "uninitialized".
Measured on B300 with MXFP8 and a native ScaledSReLU at num_groups == 1, input
padded 512 -> 768 rows, FC1 main_grad against the unpadded result:
before zeroed tail 406528 / 1048576 non-finite
before poisoned tail 406528 / 1048576 non-finite (identical -- not the values)
after either tail 0.0000e+00, bit-identical
control num_groups=2 clean before and after
Every corrected arm also sits at 1.70e-02 against an unfused reference, the MXFP8
fused-vs-unfused noise floor.
Gate the whole shortcut family on the same predicate the kernels use, applying the
guard TE already writes at the sites that were missing it. No new helper: the
binding calls `_cudnn_frontend_supports_single_group_runtime_offsets` directly,
as this file already does at three other sites, and the flag is named
`use_dense_single_group` to match its neighbours. It deliberately does not claim
the group spans the buffer -- that is unknowable here -- only that producer and
consumer will agree on the extent. It is a host-side check
on an activation type and a package version, so it costs nothing and stays CUDA
graph capturable, and it leaves the GLU activations -- which do get the
specialization -- byte-for-byte on their existing fast path. That matters:
removing the shortcuts outright costs a shared expert +6% to +30% of the fused MLP
step (+5-10% at hidden 4096 / ffn 14336, rising to +28-30% at 1024/4096 with few
tokens), measured by CUDA-graph replay against num_groups >= 2 null controls, and
Megatron's FusedSharedExpertMLP builds exactly this path.
All the shortcuts have to move together: quantization layout and kernel indexing
must agree, so enabling a subset pairs a dense consumer with a packed producer and
yields NaN rather than a slowdown. Hence one flag threaded explicitly through the
grouped-quantize helpers and `_compute_grad_params` rather than a predicate per
site.
What this does NOT do: where the specialization is available, producer and
consumer agree and the residual exposure is that the plain wgrad GEMM still
contracts over `logical_shape[0]` rows, summing any padding past
`sum(split_sizes)` into the weight gradient. That part is value-dependent -- a
zeroed tail contributes an all-zero outer product and is bit-exact, verified over
10 launches -- so a caller that zeroes its padding, or passes none, is unaffected.
Closing that gap needs either a device-side masked zeroing of TE's own
intermediate buffers or an explicit per-op dense-single-group contract; neither is
attempted here.
The condition cannot be evaluated at run time: `split_sizes` is device-resident,
so testing `sum(split_sizes) == tensor.shape[0]` forces a device-to-host sync, and
that test exists to keep this flow sync-free and graph-capturable.
tests/pytorch/test_grouped_mlp.py is at baseline: 6 failed, 48 passed, the 6 being
pre-existing nvfp4_rht and cuBLAS-version failures also present on main.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Wentao Guo <wg0420@princeton.edu>
Greptile SummaryThis PR makes the fused grouped-MLP single-group optimization conditional on cuDNN runtime-offset support, keeping quantization layouts and their GEMM consumers consistent.
Confidence Score: 4/5The implementation appears safe to merge, with the non-blocking concern that its corrected padded single-group SReLU behavior is not protected by an automated regression test. The shared predicate is consistently applied across the affected forward and backward layout paths, and no correctness regression was established; the remaining finding concerns future regression detection. Files Needing Attention: transformer_engine/pytorch/ops/fused/grouped_mlp.py Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Single-group fused MLP] --> B{Runtime-offset specialization supported?}
B -->|Yes| C[Dense quantization and scale layout]
C --> D[Dense FC2, dgrad, and wgrad consumers]
B -->|No| E[Grouped quantization using split sizes and offsets]
E --> F[Generic grouped GEMM consumers]
Reviews (1): Last reviewed commit: "[PyTorch] Take the num_groups == 1 dense..." | Re-trigger Greptile |
| use_dense_single_group = num_groups == 1 and ( | ||
| _cudnn_frontend_supports_single_group_runtime_offsets(type(activation_op)) | ||
| ) |
There was a problem hiding this comment.
Regression coverage is missing
This capability gate fixes a specific numerical failure, but no automated test combines a padded single-group buffer, ScaledSReLU, MXFP8 or NVFP4, and backward gradient validation. The existing single-group MXFP8 test uses unpadded ScaledSwiGLU, while padded fused-MLP coverage uses GLU activations and four groups. Without a focused regression test, the dense-versus-packed layout mismatch and resulting NaN gradients could return without failing CI.
Knowledge Base Used:
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Description
At
num_groups == 1the fused grouped MLP may quantize and scale-swizzle the token buffer as one dense tensor overtensor.shape[0]rows and read it back the same way. That is only coherent if the cuDNN kernels also write their intermediates densely overtensor.shape[0], which is whatuse_single_group_runtime_offsetsasks of them — they then derive M from the runtime tensor shapes rather than from the offsets.TE selects the dense consumer without checking whether the producer will be dense. The two disagree for
ScaledSReLUat any version, and for every activation on a cuDNN frontend older than 1.27.0, because_cudnn_frontend_supports_single_group_runtime_offsetsreturns False there and the kernels pack their output tosum(split_sizes)rows instead.The columnwise swizzled MXFP8 scale layout is
[k/128][m/128][32][4][4], with the m-tile as an inner stride, so an m-extent mismatch misindexes every k-tile past the first: consumer k-tilejreads producer k-tilej * T_c / T_p. The head rows — the ones that do belong to the group — pick up the wrong scale factors, and high k-tiles read scale memory the producer never wrote. The data is read correctly; only the scales move, so the error is order 1 and often NaN.Crucially this is value-independent: zeroing the padded tail does not help, because the defect is in the indexing rather than in the padding's contents. A caller cannot work around it.
Callers may legally pass a token buffer padded past
sum(split_sizes); an MoE expert-parallel fixed-capacity receive buffer always does, andtest_grouped_linear_cuda_graph_safedocuments the padded tail as "intentionally outside every group" and "uninitialized".Fix to #3484
Minimal reproduction
Single GPU, no Megatron, no distributed.
NVTE_CUTEDSL_FUSED_GROUPED_MLP=1must be set before the TE import.On
main:With this PR:
Note the two
mainrows are identical: the corruption does not depend on what the padding holds, so a caller cannot avoid it by zeroing.Why the fix is where it is
The two halves of the "treat this as one dense group" decision are currently gated differently. The request to the kernels is already guarded, because it can be refused:
but its siblings —
use_single_group_weight_swizzle,use_single_discrete_weight,use_single_group_dense_fc2,use_single_group_dense_dgrad, the dense quantize and the plain wgrad GEMM — are gated onnum_groups == 1alone. So when the specialization is unavailable, TE still reads everything densely while the kernels pack.This PR applies the guard TE already writes, at the sites that were missing it:
That predicate is called directly rather than wrapped, matching the three other call sites in this file, and the flag is named for the decision it drives rather than for a property of the data. It deliberately does not claim the single group spans the buffer —
split_sizesis device-resident, so that is unknowable here — only that the producer and the consumer will agree on the row extent.They have to move together: quantization layout and kernel indexing must agree, so enabling a subset pairs a dense consumer with a packed producer and yields NaN rather than a slowdown. Hence one flag threaded explicitly through the grouped-quantize helpers and
_compute_grad_params.Type of change
Changes
Please list the changes introduced in this PR:
num_groups == 1dense shortcut family on_cudnn_frontend_supports_single_group_runtime_offsets(type(activation_op))as well as on the group count, so TE only reads operands densely when the cuDNN kernels also write them densely._group_quantize_for_grouped_mlp,_group_quantize_with_amax_for_grouped_mlpand_compute_grad_paramsrather than recomputing it per site, so all of the shortcuts move together.Checklist:
tests/pytorch/test_grouped_mlp.pyis unmodified and at baseline: 6 failed, 34 passed, the 6 being pre-existingnvfp4_rhtand cuBLAS-version failures also present onmain. The fix is verified out-of-tree by the reproducer above; happy to add an in-tree regression test if you would like one in this PR.