Skip to content

[PyTorch] Reduce NVFP4 quantization work for the fused GroupedMLP fc2 input with activation recompute - #3837

Open
xrennvidia wants to merge 16 commits into
NVIDIA:mainfrom
xrennvidia:xren/fused_grouped_mlp_rowwise_fc2x_recompute
Open

xrennvidia wants to merge 16 commits into
NVIDIA:mainfrom
xrennvidia:xren/fused_grouped_mlp_rowwise_fc2x_recompute

Conversation

@xrennvidia

Copy link
Copy Markdown
Collaborator

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 quantized fc2_x both 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

  • Documentation change (change only to the documentation, either a fix or a new content)
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Infra/Build change
  • Code refactoring

Changes

  • Decide recompute_srelu_fc2_x before 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.
  • Keep the forward (pre-RHT, post-RHT) amax of fc2_x on the fc2 context and quantize the recomputed tensor with nvfp4_group_quantize_with_amax, dropping the backward amax pass. Add a nvfp4_group_hadamard_amax binding so the plain SReLU kernel path computes both amax values in the single forward pass. NVTE_FUSED_GROUPED_MLP_REUSE_FC2_X_AMAX=0 disables the reuse.
  • Route ScaledTanhSReLU through the Hadamard-fused fc1 kernel when the cuDNN frontend grouped_gemm_glu_hadamard_wrapper_sm100 accepts tanh_clamp_scale (detected by signature), so the forward amax pass disappears for tanh-SReLU as well.
  • Pass the quantizer's RHT sign mask (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:

  • I have read and followed the contributing guidelines
  • The functionality is complete
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

xrennvidia and others added 4 commits November 9, 2026 01:56
…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>
@github-actions github-actions Bot added the community-contribution PRs from external contributor outside the core maintainers, representing community-driven work. label Nov 9, 2026
@greptile-apps

greptile-apps Bot commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; no actionable new issue was established.

Summary

This PR reduces repeated NVFP4 work when the fused grouped MLP rebuilds its activation during backward.

  • Recomputed FC2 inputs reuse forward amax values for quantization.
  • The fused FC1 path matches its amax calculation to the randomized transform.

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]
Loading

Reviews (11) · Last reviewed commit: "Merge branch 'main' into xren/fused_grou..." · Reviewed by Greptile

Comment on lines +436 to +446
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


Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Secret envvar configs are an antipattern. This optimization is a strict improvement, so we should just do it when suported.

Suggested change
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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

Comment on lines +460 to +468
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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Comment on lines +1524 to +1531
# 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()
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

elif activation_is_srelu and _cudnn_frontend_supports_grouped_gemm_srelu_hadamard():
kernel_impl = "gemm_act_rht_amax"

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:

if activation_is_srelu:
if kernel_impl in ("gemm_act_rht_amax", "gemm_act_rht_quant"):
fc1_activation_kwargs["act_func"] = "srelu"

if isinstance(activation_op, (ScaledSReLU, ScaledTanhSReLU)):
    if kernel_impl in ("gemm_act_rht_amax", "gemm_act_rht_quant"):
        fc1_activation_kwargs["act_func"] = "srelu"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Comment on lines +2041 to +2045
# 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 = (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Logically this isn't specific to SReLU. If the GLU dgrad kernel supported recompute we could use the same logic.

Suggested change
# 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 = (

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

xrennvidia and others added 11 commits November 9, 2026 18:52
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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution PRs from external contributor outside the core maintainers, representing community-driven work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants