Repository navigation
[PyTorch] Reduce NVFP4 quantization work for the fused GroupedMLP fc2 input with activation recompute - #3837
xrennvidia wants to merge 16 commits into
Conversation
…s recomputed in backward With activation recompute in the fused NVFP4 GroupedMLP, the backward rebuilds fc2_x from the dSReLU kernel and re-quantizes it column-wise for wgrad. The forward still quantized fc2_x row- and column-wise and then dropped the column-wise copy at save time. Decide recompute_srelu_fc2_x before the fc2 input quantization, request only row-wise data when the backward will rebuild the tensor, and mark for paged stashing only the tensor that is actually saved for backward. Rowwise FP4 data and the fc2 GEMM result are unchanged; the group quantize kernel for the fc2 input drops from ~570us to ~330us per fused call on GB300 for a 130B hybrid MoE proxy. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Xiaowei Ren <xren@nvidia.com>
…uted in backward With activation recompute in the fused NVFP4 GroupedMLP, the backward regenerates fc2_x with the dSReLU kernel and re-quantizes it column-wise, which first runs the group Hadamard-amax kernel over the whole tensor and then the cast. The regenerated tensor is bit-identical to the forward one (the plain SReLU kernel rounds the accumulator through C dtype before the activation; the Hadamard-fused kernel does so with the matching cuDNN frontend fix), so its per-group amax is the forward amax. Keep the forward (pre-RHT, post-RHT) amax of fc2_x on the fc2 context and quantize the regenerated tensor with nvfp4_group_quantize_with_amax, which drops the backward amax pass. The Hadamard-fused fc1 kernel already returns both amax values; for the plain SReLU kernel add nvfp4_group_hadamard_amax, a binding that runs only the group Hadamard-amax kernel, so the forward computes both values in the single pass it needs for the row-wise cast. Without the binding the previous behavior is kept. NVTE_FUSED_GROUPED_MLP_REUSE_FC2_X_AMAX=0 disables the reuse. Verified on GB300 with a fused grouped MLP (8 experts, NVFP4 + RHT): with recompute on and off, output, dgrad, dprob and both weight gradients match bitwise for tanh-SReLU and plain SReLU, and the backward launches one fewer Hadamard-amax kernel per fused call where the forward amax is available. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> The recompute test now asserts a bitwise-equal FC2 weight gradient for tanh-SReLU with NVFP4; plain SReLU keeps its tolerance until the cuDNN frontend fix for the Hadamard-fused kernel is required. Signed-off-by: Xiaowei Ren <xren@nvidia.com>
…uDNN frontend supports it ScaledTanhSReLU was routed to the plain grouped SReLU kernel because the Hadamard-fused kernel had no soft-clamp support. With NVFP4 RHT that costs a separate Hadamard-amax pass over the fc2 input in every forward. The cuDNN frontend now accepts tanh_clamp_scale on grouped_gemm_glu_hadamard_wrapper_sm100; detect that by signature and let the clamped op take the same fused path as the unclamped one. The kernel then returns both NVFP4 amax values, so the forward amax pass disappears and the backward re-quantization of the recomputed fc2 input reuses them. Verified on GB300 with a fused grouped MLP (8 experts, NVFP4 + RHT): the tanh-SReLU forward launches one Hadamard-amax kernel fewer per fused call, the backward one fewer with recompute, and recompute on/off give bitwise- equal output and gradients. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Xiaowei Ren <xren@nvidia.com>
…GEMM kernel The fused fc1 GEMM + activation + Hadamard kernel returns the post-RHT amax that sets the global scale of the column-wise NVFP4 cast of fc2 input. The NVFP4 quantizer applies a randomized Hadamard transform with a per-quantizer sign mask, but the kernel computed the amax for the plain transform, so the scale was off by the sign pattern: up to ~17% too large for SReLU-shaped activations and possibly too small for GLUs. cuDNN frontend now accepts rht_sign_mask on grouped_gemm_glu_hadamard_wrapper_sm100 and also rounds its amax values like the quantizer does; pass the quantizer mask when the wrapper takes it. On frontends without it, use the fused kernel only when the mask is zero, so a wrong amax is never fed to the cast. Verified on GB300 (8 experts, NVFP4 + RHT): with the matching cuDNN frontend the fused amax values are bit-identical to the standalone group Hadamard-amax kernel for SReLU, tanh-SReLU and SwiGLU, and tanh-SReLU gives bitwise-equal output and gradients on the fused and the plain kernel path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Xiaowei Ren <xren@nvidia.com>
for more information, see https://pre-commit.ci
[Medium impact] The PR appears safe to merge; no actionable new issue was established. SummaryThis PR reduces repeated NVFP4 work when the fused grouped MLP rebuilds its activation during backward.
No actionable new issues were established. No previous review threads were supplied. This review used source inspection; GPU tests were not run. Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[FC1 and activation] --> B[FC2 input and amax]
B --> C[Row-wise NVFP4 quantization]
C --> D[FC2 forward]
B --> E[Save amax and pre-activation]
E --> F[Backward rebuilds FC2 input]
F --> G[Column-wise quantization with saved amax]
G --> H[FC2 weight gradient]
Reviews (11) · Last reviewed commit: "Merge branch 'main' into xren/fused_grou..." · Reviewed by Greptile |
| def _fc2_x_amax_reuse_enabled() -> bool: | ||
| """Whether the backward reuses the forward NVFP4 amax of a recomputed fc2 input. | ||
|
|
||
| The grouped dSReLU kernel regenerates fc2_x bit-exactly from the saved pre-activation, | ||
| so the per-group amax computed in the forward is also the amax of the regenerated | ||
| tensor. Reusing it turns the backward re-quantization into a single cast kernel. | ||
| ``NVTE_FUSED_GROUPED_MLP_REUSE_FC2_X_AMAX=0`` disables the reuse. | ||
| """ | ||
| return int(os.environ.get("NVTE_FUSED_GROUPED_MLP_REUSE_FC2_X_AMAX", "1")) > 0 | ||
|
|
||
|
|
There was a problem hiding this comment.
Secret envvar configs are an antipattern. This optimization is a strict improvement, so we should just do it when suported.
| def _fc2_x_amax_reuse_enabled() -> bool: | |
| """Whether the backward reuses the forward NVFP4 amax of a recomputed fc2 input. | |
| The grouped dSReLU kernel regenerates fc2_x bit-exactly from the saved pre-activation, | |
| so the per-group amax computed in the forward is also the amax of the regenerated | |
| tensor. Reusing it turns the backward re-quantization into a single cast kernel. | |
| ``NVTE_FUSED_GROUPED_MLP_REUSE_FC2_X_AMAX=0`` disables the reuse. | |
| """ | |
| return int(os.environ.get("NVTE_FUSED_GROUPED_MLP_REUSE_FC2_X_AMAX", "1")) > 0 |
| amax_fn = getattr(tex, "nvfp4_group_hadamard_amax", None) | ||
| if ( | ||
| amax_fn is None | ||
| or not isinstance(quantizer, NVFP4Quantizer) | ||
| or split_sizes is None | ||
| or tensor_offsets is None | ||
| ): | ||
| return None | ||
| return amax_fn(tensor, quantizer, num_groups, split_sizes, tensor_offsets=tensor_offsets) |
There was a problem hiding this comment.
This is overly defensive. transformer_engine_torch is internal implementation, and it's expected to match the Python code the same way Python files are expected to match each other.
| amax_fn = getattr(tex, "nvfp4_group_hadamard_amax", None) | |
| if ( | |
| amax_fn is None | |
| or not isinstance(quantizer, NVFP4Quantizer) | |
| or split_sizes is None | |
| or tensor_offsets is None | |
| ): | |
| return None | |
| return amax_fn(tensor, quantizer, num_groups, split_sizes, tensor_offsets=tensor_offsets) | |
| if ( | |
| not isinstance(quantizer, NVFP4Quantizer) | |
| or split_sizes is None | |
| or tensor_offsets is None | |
| ): | |
| return None | |
| return tex.nvfp4_group_hadamard_amax(tensor, quantizer, num_groups, split_sizes, tensor_offsets=tensor_offsets) |
| # The hadamard kernel is the GLU one; it applies the SReLU soft clamp only in | ||
| # cuDNN frontends whose wrapper accepts tanh_clamp_scale. Without that, | ||
| # ScaledTanhSReLU keeps the plain SReLU kernel and a separate RHT amax pass -- | ||
| # a performance limitation, not a correctness one. | ||
| activation_is_srelu = isinstance(activation_op, ScaledSReLU) or ( | ||
| isinstance(activation_op, ScaledTanhSReLU) | ||
| and _cudnn_frontend_supports_grouped_gemm_srelu_tanh_hadamard() | ||
| ) |
There was a problem hiding this comment.
This variable is no longer telling you whether "the activation is SReLU", but now is doing something convoluted depending on the cuDNN FE version. Claude likes doing this kind of thing because it narrowly accomplishes its task with a single line change, and technical debt is a problem for someone else. If this variable no longer has a logically coherent meaning, it would be better to get rid of it and make the logic more explicit.
Where we choose the cuDNN kernel:
TransformerEngine/transformer_engine/pytorch/ops/fused/grouped_mlp.py
Lines 1761 to 1762 in 5eace13
elif isinstance(activation_op, ScaledSReLU) and _cudnn_frontend_supports_grouped_gemm_srelu_hadamard():
kernel_impl = "gemm_act_rht_amax"
elif isinstance(activation_op, ScaledTanhSReLU) and _cudnn_frontend_supports_grouped_gemm_srelu_tanh_hadamard():
kernel_impl = "gemm_act_rht_amax"Where we configure args to pass to the cuDNN kernel:
TransformerEngine/transformer_engine/pytorch/ops/fused/grouped_mlp.py
Lines 1895 to 1897 in 5eace13
if isinstance(activation_op, (ScaledSReLU, ScaledTanhSReLU)):
if kernel_impl in ("gemm_act_rht_amax", "gemm_act_rht_quant"):
fc1_activation_kwargs["act_func"] = "srelu"| # Decide up front whether the backward rebuilds fc2_x from the dSReLU kernel | ||
| # (which applies the tanh clamp when regenerating fc2_x). When it does, the | ||
| # forward only needs row-wise fc2_x for the fc2 GEMM; the column-wise copy | ||
| # for wgrad is produced in the backward from the recomputed tensor. | ||
| recompute_srelu_fc2_x = ( |
There was a problem hiding this comment.
Nit: Logically this isn't specific to SReLU. If the GLU dgrad kernel supported recompute we could use the same logic.
| # Decide up front whether the backward rebuilds fc2_x from the dSReLU kernel | |
| # (which applies the tanh clamp when regenerating fc2_x). When it does, the | |
| # forward only needs row-wise fc2_x for the fc2 GEMM; the column-wise copy | |
| # for wgrad is produced in the backward from the recomputed tensor. | |
| recompute_srelu_fc2_x = ( | |
| # Decide up front whether the backward rebuilds fc2_x from the activation | |
| # backward kernel. If so, no need to compute column-wise data in forward. | |
| recompute_fc2_x = ( |
| fc2_ctx.input_requires_grad = input_requires_grad | ||
| fc2_ctx.weight_requires_grad = weight_requires_grad | ||
| fc2_ctx.recompute_input_from_dsrelu = recompute_srelu_fc2_x | ||
| fc2_ctx.fc2_x_amaxes_for_recompute = fc2_x_amaxes_for_recompute |
There was a problem hiding this comment.
Tensors should be saved with save_for_backward, not attached directly to the autograd context. Otherwise PyTorch's autograd can do weird things (see PyTorch autograd context save_for_backward).
Remove the NVTE_FUSED_GROUPED_MLP_REUSE_FC2_X_AMAX toggle. The dSReLU kernel regenerates fc2_x bit-exactly, so reusing the forward amax in the backward re-quantization is a strict improvement and needs no opt-out. The reuse now applies whenever the backward recomputes fc2_x; the fallback for builds without the nvfp4_group_hadamard_amax binding is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Xiaowei Ren <xren@nvidia.com>
Drop the getattr probe for tex.nvfp4_group_hadamard_amax. The private extension is built together with the Python code, so the binding is always present and needs no fallback. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Xiaowei Ren <xren@nvidia.com>
Co-authored-by: Tim Moon <4406448+timmoon10@users.noreply.github.com> Signed-off-by: Xiaowei Ren <103958965+xrennvidia@users.noreply.github.com>
Co-authored-by: Tim Moon <4406448+timmoon10@users.noreply.github.com> Signed-off-by: Xiaowei Ren <103958965+xrennvidia@users.noreply.github.com>
…oupedMLP Rebuilding fc2_x in the backward is not logically specific to SReLU; the same logic would apply to any activation backward kernel that supports recompute. Shorten the accompanying comment accordingly. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Xiaowei Ren <xren@nvidia.com>
…ward The amax tensors reused by the backward re-quantization of a recomputed fc2 input were attached to the fc2 operation context as an attribute. Tensors needed in the backward must go through save_for_backward so autograd tracks them. Append them to the fused op's saved tensors and unpack them in the backward. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Xiaowei Ren <xren@nvidia.com>
…on checks Remove activation_is_srelu from the fused GroupedMLP forward. It had come to mean "SReLU, or tanh-SReLU when the cuDNN frontend's Hadamard wrapper accepts the clamp" rather than what its name says. Check the activation type and the matching cuDNN frontend capability explicitly where the kernel is selected, and check the activation type alone where the kernel arguments are set. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Xiaowei Ren <xren@nvidia.com>
Resolve conflicts in transformer_engine/pytorch/ops/fused/grouped_mlp.py with the removal of the legacy cuDNN frontend guards (NVIDIA#3846): - Drop the version and signature guards that main removed, including the ones the fc2_x recompute decision and the Hadamard-fused kernel selection for ScaledSReLU relied on. - Keep the signature checks for tanh_clamp_scale and rht_sign_mask on grouped_gemm_glu_hadamard_wrapper_sm100, since the new minimum cuDNN frontend (1.31.0) does not accept them, and share one helper between them. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Xiaowei Ren <xren@nvidia.com>
…support Two NVFP4 grouped MLP tests assumed cuDNN frontend features that released frontends (1.31.0, 1.32.3) do not have: - The SiTU-GLU Hadamard fusion test expected the fused GLU-Hadamard kernel, but it is only selected when the frontend applies the RHT sign mask. Expect the plain GLU kernel otherwise. - The activation recompute test required FC2's weight gradient to match bitwise for the clamped SReLU. That needs the forward kernel to round the accumulator through C's dtype so the dSReLU kernel regenerates fc2_x bit-exactly. Keep the bitwise check for frontends that support the tanh clamp in the Hadamard-fused kernel and use the RMS tolerance otherwise. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Xiaowei Ren <xren@nvidia.com>
Description
With activation recompute in the fused NVFP4
GroupedMLP, the backward rebuilds the fc2 input (fc2_x) with the dSReLU kernel and re-quantizes it column-wise for wgrad. The forward nevertheless quantizedfc2_xboth row- and column-wise and discarded the column-wise copy, and the backward re-quantization ran a full group Hadamard-amax pass even though the regenerated tensor is bit-identical to the forward one. This PR removes that redundant work and fixes an amax mismatch in the Hadamard-fused fc1 kernel path with randomized Hadamard transform (RHT).Measured on GB300 with a fused grouped MLP (8 experts, NVFP4 + RHT): the group quantize kernel for the fc2 input drops from ~570us to ~330us per fused call on a 130B hybrid MoE proxy, and the forward and backward each launch one fewer Hadamard-amax kernel per fused call where the Hadamard-fused fc1 kernel is used. Recompute on and off give bitwise-equal output and gradients for SReLU, tanh-SReLU and SwiGLU.
Type of change
Changes
recompute_srelu_fc2_xbefore quantizing the fc2 input and request only row-wise NVFP4 data when the backward will rebuild the tensor; mark only the tensor actually saved for backward for paged stashing.fc2_xon the fc2 context and quantize the recomputed tensor withnvfp4_group_quantize_with_amax, dropping the backward amax pass. Add anvfp4_group_hadamard_amaxbinding so the plain SReLU kernel path computes both amax values in the single forward pass.NVTE_FUSED_GROUPED_MLP_REUSE_FC2_X_AMAX=0disables the reuse.ScaledTanhSReLUthrough the Hadamard-fused fc1 kernel when the cuDNN frontendgrouped_gemm_glu_hadamard_wrapper_sm100acceptstanh_clamp_scale(detected by signature), so the forward amax pass disappears for tanh-SReLU as well.rht_sign_mask) to the Hadamard-fused kernel when the cuDNN frontend accepts it. Previously the kernel computed the post-RHT amax for the plain transform, so the NVFP4 global scale was off by the sign pattern (up to ~17% too large for SReLU-shaped activations). On frontends without the argument, the fused kernel is used only when the mask is zero.tests/pytorch/test_grouped_mlp.py: the recompute test asserts a bitwise-equal fc2 weight gradient for tanh-SReLU with NVFP4; plain SReLU keeps its tolerance until the cuDNN frontend fix for the Hadamard-fused kernel is required.Checklist: