Skip to content

Commit fca8910

Browse files
authored
perf(moe): fused clamped-SwiGLU activations for shared and routed experts (NVIDIA-NeMo#4286)
* perf(moe): compile the shared-expert clamped SwiGLU into one kernel The shared-expert MLP of DeepSeek-V4 models runs its activation eagerly: two fp32 up-casts, two clamps, silu, a multiply and a down-cast — seven launches and two fp32 temporaries per call, issued on the shared-expert side stream while the main stream waits for them (the 16-node trace charges 9 ms/step of copies to this forward). A fullgraph-compiled core over the separate gate / up projections gives one fused kernel per direction with the same arithmetic; the eager chain stays for CPU inputs. The test pins outputs bitwise and gradients, including the clamp masks. Signed-off-by: Yisong Li <yisongbetter@gmail.com> * perf(moe): Triton kernels for the clamped SwiGLU expert activation The routed experts of DeepSeek-V4 models apply silu(clamp(gate)) * clamp(up) * probs to the grouped-GEMM output through a fullgraph-compiled function whose backward AOT autograd derives; that backward concatenates d_gate and d_up and ran at about a quarter of HBM bandwidth on GB200 (0.7 ms per layer for a 1.1 GB pass at the 16-node shape, 24 ms per step). swiglu_triton adds one-pass kernels: a 2-D tiled forward and a backward that walks the columns of a block of rows, writes d_gate / d_up into the two halves of the input gradient and accumulates the per-row probability gradient in registers. fp32 math, clamp gradients as torch.clamp (inclusive masks), results rounded to the input dtype. swiglu_clamped_deepep dispatches to the kernels for contiguous CUDA inputs when Triton imports and otherwise keeps the compiled function. Tests compare against the eager fp32 reference on the GPU and pin the dispatcher and the layout guard. Signed-off-by: Yisong Li <yisongbetter@gmail.com> * fix(moe): fp32 clamped-SwiGLU kernels under inductor; dynamic shared island Under torch.compile inductor hands the user Triton kernels the Python-float `limit` as fp64, so the math ran in fp64 and the backward kernel's loop-carried fp32 accumulator changed type ("Loop-carried variable acc has initial type fp32 but is re-assigned to fp64"): fullgraph compilation of a module around SwigluClampedTriton failed. Both kernels now cast `limit` to fp32 on entry and the row accumulation is cast explicitly. `_clamped_swiglu_act` was `@torch.compile(fullgraph=True, dynamic=False)`: with fullgraph, the recompile limit is a hard failure (FailOnRecompileLimitHit) after `recompile_limit` distinct token counts, which variable-length batches reach. The island now uses Dynamo's default shape policy (one static graph, then one symbolic graph every later shape reuses). Tests: autocast with FP32 parameters (BF16 and FP32 inputs) against the eager reference through a projection, compiled module on CUDA (explain: one graph, no breaks; fullgraph forward + backward through inductor), the dispatcher captured without breaks on CPU, and recompile_limit + 3 distinct row counts through the island without a failure. Signed-off-by: Yisong Li <yisongbetter@gmail.com> * test(moe): keep the new SwiGLU contract tests under CI's 30 s runtime budget CI's conftest refuses a changed test that requests a runtime_budget above 30 s (the recompile sweep asked for 240 s, the compiled-module and dispatcher tests for 120 / 60 s), which failed every unit shard of the previous head. The recompile test now re-wraps the island's own function with the eager backend and a patched recompile limit, so it checks Dynamo's shape policy without inductor's CPU build (the one-GPU contract probe sweeps the production island itself); the two compile tests keep their inductor / Dynamo work and run well inside 30 s. Signed-off-by: Yisong Li <yisongbetter@gmail.com> --------- Signed-off-by: Yisong Li <yisongbetter@gmail.com>
1 parent 4eb27cf commit fca8910

5 files changed

Lines changed: 516 additions & 3 deletions

File tree

‎nemo_automodel/components/moe/experts.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from torch.distributed.device_mesh import DeviceMesh
2626
from torch.distributed.tensor import DTensor
2727

28+
from nemo_automodel.components.moe import swiglu_triton
2829
from nemo_automodel.components.moe.config import MoEConfig
2930
from nemo_automodel.components.moe.megatron.moe_utils import (
3031
weighted_bias_geglu_impl,
@@ -1210,7 +1211,7 @@ def relu2_deepep(x, permuted_probs):
12101211

12111212

12121213
@torch.compile(fullgraph=True, options={"max_autotune": True})
1213-
def swiglu_clamped_deepep(x, permuted_probs, limit: float):
1214+
def _swiglu_clamped_deepep_compiled(x, permuted_probs, limit: float):
12141215
"""Clamped SwiGLU (DeepSeek V4 style) for DeepEP.
12151216
12161217
Gate is clamped at ``max=limit`` and up at ``(-limit, +limit)`` in FP32
@@ -1234,6 +1235,16 @@ def swiglu_clamped_deepep(x, permuted_probs, limit: float):
12341235
return (inter * permuted_probs).to(x.dtype)
12351236

12361237

1238+
def swiglu_clamped_deepep(x, permuted_probs, limit: float):
1239+
"""Clamped SwiGLU (DeepSeek V4 style) for DeepEP: Triton kernels on CUDA, the compiled function elsewhere.
1240+
1241+
See :mod:`nemo_automodel.components.moe.swiglu_triton` for the kernels and the layout guard.
1242+
"""
1243+
if swiglu_triton.uses_triton_kernel(x, permuted_probs):
1244+
return swiglu_triton.swiglu_clamped_triton(x, permuted_probs, limit)
1245+
return _swiglu_clamped_deepep_compiled(x, permuted_probs, limit)
1246+
1247+
12371248
def get_expert_activation_for_deepep(config: MoEConfig):
12381249
"""Return the DeepEP expert activation function selected by the MoE config."""
12391250

‎nemo_automodel/components/moe/layers.py‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,9 +112,16 @@ def forward(self, x: torch.Tensor) -> torch.Tensor:
112112
if self.swiglu_limit > 0.0:
113113
# Mirror DSV4 reference Expert.forward: fp32 SwiGLU with one-sided
114114
# gate clamp and symmetric up clamp; cast back before down_proj.
115+
gate = self.gate_proj(x)
116+
up = self.up_proj(x)
117+
if x.is_cuda:
118+
# One fused kernel instead of two fp32 up-casts, two clamps, silu, mul and a down-cast
119+
# (seven launches and two fp32 temporaries per call; the shared-expert branch is issued on
120+
# a side stream while the main stream waits for its launches).
121+
return self.down_proj(_clamped_swiglu_act(gate, up, self.swiglu_limit))
115122
dtype = x.dtype
116-
gate = self.gate_proj(x).float().clamp(max=self.swiglu_limit)
117-
up = self.up_proj(x).float().clamp(min=-self.swiglu_limit, max=self.swiglu_limit)
123+
gate = gate.float().clamp(max=self.swiglu_limit)
124+
up = up.float().clamp(min=-self.swiglu_limit, max=self.swiglu_limit)
118125
return self.down_proj((F.silu(gate) * up).to(dtype))
119126
return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
120127

@@ -123,6 +130,21 @@ def init_weights(self, buffer_device: torch.device, init_std: float = 0.02) -> N
123130
self.apply(init_weights_fn)
124131

125132

133+
@torch.compile(fullgraph=True)
134+
def _clamped_swiglu_act(gate: torch.Tensor, up: torch.Tensor, limit: float) -> torch.Tensor:
135+
"""fp32 clamped SwiGLU on separate gate / up projections, cast back to the input dtype.
136+
137+
Same arithmetic as the eager branch of ``MLP.forward`` (the DeepSeek-V4 reference Expert.forward); inductor
138+
fuses it into one kernel per direction. Dynamo's default shape policy: the first token count compiles a static
139+
graph, the second recompiles once with a symbolic row count and every later shape reuses it — ``dynamic=False``
140+
with ``fullgraph=True`` would instead raise ``FailOnRecompileLimitHit`` after ``recompile_limit`` distinct
141+
token counts (variable-length batches). CPU inputs keep the eager chain.
142+
"""
143+
gate_f = gate.float().clamp(max=limit)
144+
up_f = up.float().clamp(min=-limit, max=limit)
145+
return (F.silu(gate_f) * up_f).to(gate.dtype)
146+
147+
126148
class FakeBalancedGate(nn.Module):
127149
"""
128150
Load balanced gate implementation, spreads tokens uniformly across all experts.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Triton kernels for the clamped SwiGLU expert activation (DeepSeek-V4 style, ``swiglu_limit > 0``).
15+
16+
The routed experts apply ``silu(clamp(gate, max=L)) * clamp(up, -L, L) * probs`` to the grouped-GEMM output
17+
``x = [gate | up]`` (bf16, ``[rows, 2 * inter]``) with fp32 routing probabilities ``probs`` (``[rows, 1]``). The
18+
compiled PyTorch version (``experts._swiglu_clamped_deepep_compiled``) is one inductor kernel forward, but its
19+
autograd-derived backward concatenates ``d_gate`` and ``d_up`` and ran at about a quarter of HBM bandwidth on
20+
GB200 (0.7 ms for a 1.1 GB pass at the 16-node shape). These kernels do one pass each:
21+
22+
* forward: a 2-D tile per program, fp32 math, result rounded to ``x.dtype``;
23+
* backward: one program per block of rows looping over the columns, writing ``d_gate`` / ``d_up`` straight into
24+
the two halves of ``grad_x`` and accumulating the per-row probability gradient in registers.
25+
26+
Clamp gradients follow ``torch.clamp`` (inclusive range masks on the input values). The dispatcher in
27+
``experts.swiglu_clamped_deepep`` uses these kernels on contiguous CUDA inputs when Triton is importable and
28+
falls back to the compiled version otherwise.
29+
"""
30+
31+
from __future__ import annotations
32+
33+
import torch
34+
35+
from nemo_automodel.shared.import_utils import safe_import
36+
37+
_HAVE_TRITON, triton = safe_import("triton")
38+
_HAVE_TRITON_LANGUAGE, tl = safe_import("triton.language")
39+
HAVE_TRITON: bool = bool(_HAVE_TRITON and _HAVE_TRITON_LANGUAGE)
40+
41+
_SUPPORTED_DTYPES = (torch.bfloat16, torch.float16, torch.float32)
42+
_FWD_BLOCK_M = 8
43+
_FWD_BLOCK_N = 256
44+
_BWD_BLOCK_M = 4
45+
_BWD_BLOCK_N = 512
46+
47+
48+
if HAVE_TRITON:
49+
50+
@triton.jit
51+
def _swiglu_clamped_fwd_kernel(
52+
x_ptr,
53+
probs_ptr,
54+
y_ptr,
55+
rows,
56+
inter,
57+
stride_x,
58+
stride_y,
59+
limit,
60+
BLOCK_M: tl.constexpr,
61+
BLOCK_N: tl.constexpr,
62+
): # pragma: no cover - Triton JIT executes on the GPU, outside Python tracing.
63+
pid_m = tl.program_id(0)
64+
pid_n = tl.program_id(1)
65+
row = pid_m * BLOCK_M + tl.arange(0, BLOCK_M)
66+
col = pid_n * BLOCK_N + tl.arange(0, BLOCK_N)
67+
row_ok = row < rows
68+
mask = row_ok[:, None] & (col[None, :] < inter)
69+
x_off = row[:, None] * stride_x + col[None, :]
70+
# `limit` arrives as a Python float: fp32 under eager Triton, fp64 when inductor compiles the kernel
71+
# (torch.compile of the enclosing module) — cast once so the math and the stores stay fp32
72+
limit = limit.to(tl.float32)
73+
gate = tl.load(x_ptr + x_off, mask=mask, other=0.0).to(tl.float32)
74+
up = tl.load(x_ptr + x_off + inter, mask=mask, other=0.0).to(tl.float32)
75+
probs = tl.load(probs_ptr + row, mask=row_ok, other=0.0).to(tl.float32)
76+
gate = tl.minimum(gate, limit)
77+
up = tl.minimum(tl.maximum(up, -limit), limit)
78+
sig = tl.sigmoid(gate)
79+
y = gate * sig * up * probs[:, None]
80+
tl.store(y_ptr + row[:, None] * stride_y + col[None, :], y.to(y_ptr.dtype.element_ty), mask=mask)
81+
82+
@triton.jit
83+
def _swiglu_clamped_bwd_kernel(
84+
x_ptr,
85+
probs_ptr,
86+
grad_y_ptr,
87+
grad_x_ptr,
88+
grad_probs_ptr,
89+
rows,
90+
inter,
91+
stride_x,
92+
stride_gy,
93+
stride_gx,
94+
limit,
95+
BLOCK_M: tl.constexpr,
96+
BLOCK_N: tl.constexpr,
97+
): # pragma: no cover - Triton JIT executes on the GPU, outside Python tracing.
98+
pid = tl.program_id(0)
99+
row = pid * BLOCK_M + tl.arange(0, BLOCK_M)
100+
row_ok = row < rows
101+
limit = limit.to(tl.float32) # see the forward kernel: fp64 under inductor otherwise, and the loop-carried
102+
probs = tl.load(probs_ptr + row, mask=row_ok, other=0.0).to(tl.float32) # `acc` would change type
103+
acc = tl.zeros([BLOCK_M], dtype=tl.float32)
104+
for start in range(0, inter, BLOCK_N):
105+
col = start + tl.arange(0, BLOCK_N)
106+
mask = row_ok[:, None] & (col[None, :] < inter)
107+
x_off = row[:, None] * stride_x + col[None, :]
108+
gate_raw = tl.load(x_ptr + x_off, mask=mask, other=0.0).to(tl.float32)
109+
up_raw = tl.load(x_ptr + x_off + inter, mask=mask, other=0.0).to(tl.float32)
110+
grad = tl.load(grad_y_ptr + row[:, None] * stride_gy + col[None, :], mask=mask, other=0.0).to(tl.float32)
111+
gate = tl.minimum(gate_raw, limit)
112+
up = tl.minimum(tl.maximum(up_raw, -limit), limit)
113+
sig = tl.sigmoid(gate)
114+
silu = gate * sig
115+
acc += tl.sum(grad * silu * up, axis=1).to(tl.float32)
116+
weighted = grad * probs[:, None]
117+
d_gate = weighted * up * (sig * (1.0 + gate * (1.0 - sig)))
118+
d_up = weighted * silu
119+
d_gate = tl.where(gate_raw <= limit, d_gate, 0.0)
120+
d_up = tl.where((up_raw >= -limit) & (up_raw <= limit), d_up, 0.0)
121+
gx_off = row[:, None] * stride_gx + col[None, :]
122+
tl.store(grad_x_ptr + gx_off, d_gate.to(grad_x_ptr.dtype.element_ty), mask=mask)
123+
tl.store(grad_x_ptr + gx_off + inter, d_up.to(grad_x_ptr.dtype.element_ty), mask=mask)
124+
tl.store(grad_probs_ptr + row, acc, mask=row_ok)
125+
126+
127+
def uses_triton_kernel(x: torch.Tensor, probs: torch.Tensor) -> bool:
128+
"""Whether the Triton kernels can take these inputs (contiguous 2-D CUDA ``x``, one probability per row)."""
129+
return (
130+
HAVE_TRITON
131+
and x.is_cuda
132+
and x.dim() == 2
133+
and x.is_contiguous()
134+
and x.dtype in _SUPPORTED_DTYPES
135+
and x.shape[1] % 2 == 0
136+
and probs.is_cuda
137+
and probs.numel() == x.shape[0]
138+
and probs.dtype == torch.float32
139+
and probs.is_contiguous()
140+
)
141+
142+
143+
def _forward(x: torch.Tensor, probs: torch.Tensor, limit: float) -> torch.Tensor:
144+
rows, width = x.shape
145+
inter = width // 2
146+
y = torch.empty((rows, inter), dtype=x.dtype, device=x.device)
147+
if rows == 0 or inter == 0:
148+
return y
149+
grid = (triton.cdiv(rows, _FWD_BLOCK_M), triton.cdiv(inter, _FWD_BLOCK_N))
150+
_swiglu_clamped_fwd_kernel[grid](
151+
x,
152+
probs,
153+
y,
154+
rows,
155+
inter,
156+
x.stride(0),
157+
y.stride(0),
158+
float(limit),
159+
BLOCK_M=_FWD_BLOCK_M,
160+
BLOCK_N=_FWD_BLOCK_N,
161+
num_warps=4,
162+
)
163+
return y
164+
165+
166+
def _backward(
167+
x: torch.Tensor, probs: torch.Tensor, grad_y: torch.Tensor, limit: float
168+
) -> tuple[torch.Tensor, torch.Tensor]:
169+
rows, width = x.shape
170+
inter = width // 2
171+
grad_x = torch.empty_like(x)
172+
grad_probs = torch.empty((rows,), dtype=torch.float32, device=x.device)
173+
if rows == 0 or inter == 0:
174+
grad_probs.zero_()
175+
return grad_x, grad_probs.view_as(probs)
176+
grad_y = grad_y.contiguous()
177+
grid = (triton.cdiv(rows, _BWD_BLOCK_M),)
178+
_swiglu_clamped_bwd_kernel[grid](
179+
x,
180+
probs,
181+
grad_y,
182+
grad_x,
183+
grad_probs,
184+
rows,
185+
inter,
186+
x.stride(0),
187+
grad_y.stride(0),
188+
grad_x.stride(0),
189+
float(limit),
190+
BLOCK_M=_BWD_BLOCK_M,
191+
BLOCK_N=_BWD_BLOCK_N,
192+
num_warps=8,
193+
)
194+
return grad_x, grad_probs.view_as(probs)
195+
196+
197+
class SwigluClampedTriton(torch.autograd.Function):
198+
"""``silu(clamp(gate)) * clamp(up) * probs`` with the Triton forward and backward kernels."""
199+
200+
@staticmethod
201+
def forward(ctx, x: torch.Tensor, probs: torch.Tensor, limit: float) -> torch.Tensor:
202+
ctx.save_for_backward(x, probs)
203+
ctx.limit = limit
204+
return _forward(x, probs, limit)
205+
206+
@staticmethod
207+
def backward(ctx, grad_y: torch.Tensor):
208+
x, probs = ctx.saved_tensors
209+
grad_x, grad_probs = _backward(x, probs, grad_y, ctx.limit)
210+
return grad_x, (grad_probs if ctx.needs_input_grad[1] else None), None
211+
212+
213+
def swiglu_clamped_triton(x: torch.Tensor, probs: torch.Tensor, limit: float) -> torch.Tensor:
214+
"""Apply the clamped SwiGLU with the Triton kernels; callers check :func:`uses_triton_kernel` first."""
215+
return SwigluClampedTriton.apply(x, probs, limit)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import pytest
16+
import torch
17+
import torch.nn.functional as F
18+
19+
from nemo_automodel.components.moe.layers import _clamped_swiglu_act
20+
21+
pytestmark = pytest.mark.timeout(600) # one inductor graph compiles on the CPU
22+
23+
24+
def _eager(gate, up, limit):
25+
g = gate.float().clamp(max=limit)
26+
u = up.float().clamp(min=-limit, max=limit)
27+
return (F.silu(g) * u).to(gate.dtype)
28+
29+
30+
@pytest.mark.parametrize("limit", [2.0, 7.0])
31+
def test_compiled_clamped_swiglu_matches_the_eager_chain(limit):
32+
torch.manual_seed(3)
33+
gate = (torch.randn(6, 8) * limit * 1.5).to(torch.bfloat16).requires_grad_(True)
34+
up = (torch.randn(6, 8) * limit * 1.5).to(torch.bfloat16).requires_grad_(True)
35+
grad = torch.randn(6, 8).to(torch.bfloat16)
36+
37+
out = _clamped_swiglu_act(gate, up, limit)
38+
out.backward(grad)
39+
40+
gate_e = gate.detach().clone().requires_grad_(True)
41+
up_e = up.detach().clone().requires_grad_(True)
42+
ref = _eager(gate_e, up_e, limit)
43+
ref.backward(grad)
44+
45+
assert out.dtype == torch.bfloat16
46+
torch.testing.assert_close(out, ref, rtol=0, atol=0)
47+
torch.testing.assert_close(gate.grad.float(), gate_e.grad.float(), rtol=1e-2, atol=1e-2)
48+
torch.testing.assert_close(up.grad.float(), up_e.grad.float(), rtol=1e-2, atol=1e-2)
49+
# the clamp masks: no gradient through saturated entries, some gradient elsewhere
50+
saturated_gate = gate.detach().float() > limit
51+
saturated_up = up.detach().float().abs() > limit
52+
assert saturated_gate.any() and saturated_up.any()
53+
assert torch.all(gate.grad.float()[saturated_gate] == 0)
54+
assert torch.all(up.grad.float()[saturated_up] == 0)
55+
assert torch.any(gate.grad.float()[~saturated_gate] != 0)
56+
57+
58+
@pytest.mark.runtime_budget(30, hard_timeout=90, reason="Dynamo traces the island twice (static, then symbolic rows)")
59+
def test_distinct_row_counts_recompile_once_and_never_hit_the_recompile_limit():
60+
"""Variable-length batches give the shared expert a new token count each step. Dynamo's default shape policy
61+
compiles a static graph, then one symbolic graph that every later shape reuses; `dynamic=False` with
62+
`fullgraph=True` raised `FailOnRecompileLimitHit` after `recompile_limit` distinct shapes instead. The island's
63+
own function is re-wrapped with the eager backend so the test measures the shape policy, not inductor's CPU
64+
build (the one-GPU contract probe sweeps the production island itself)."""
65+
torch._dynamo.reset()
66+
torch.manual_seed(5)
67+
island = torch.compile(_clamped_swiglu_act._torchdynamo_orig_callable, fullgraph=True, backend="eager")
68+
limit_name = "recompile_limit" if hasattr(torch._dynamo.config, "recompile_limit") else "cache_size_limit"
69+
with torch._dynamo.config.patch(**{limit_name: 3}):
70+
for rows in range(4, 4 + 8): # eight distinct shapes against a limit of three recompiles
71+
gate = (torch.randn(rows, 8) * 3).to(torch.bfloat16)
72+
up = (torch.randn(rows, 8) * 3).to(torch.bfloat16)
73+
out = island(gate, up, 2.0)
74+
torch.testing.assert_close(out, _eager(gate, up, 2.0), rtol=0, atol=0)

0 commit comments

Comments
 (0)