Skip to content

AutoEP: fused Triton kernel for the clamped expert activations - #8652

Open
pengdurice wants to merge 2 commits into
deepspeedai:masterfrom
pengdurice:peng-add-fuse-fn-clamped-form-v1
Open

pengdurice wants to merge 2 commits into
deepspeedai:masterfrom
pengdurice:peng-add-fuse-fn-clamped-form-v1

Conversation

@pengdurice

@pengdurice pengdurice commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

AutoEP: fused Triton kernel for the clamped expert activations

Branch: peng-add-fuse-fn-clamped-form-v1, rebased on master at 5f266e26d (2026-10-15). The expert activation registry it
registers into is merged (#8650), and so is the fused gate+up projection (#8733), which this PR now also covers.
Part of: #8672 (Q4 2026 roadmap), "Mixture-of-Expert (MoE) Training Scaling and Kernel Development": "AutoEP
Performance Optimization & Parallel Folding (AutoEP + AutoTP)" (PoC @tohtana @pengdurice). Follows the merged expert
activation registry (#8650) and MiniMax-M3 preset (#8651).

Summary

The clamped expert activations swiglu_clamped (DeepSeek-V4) and swiglu_oai (GPT-OSS, MiniMax-M3) run as plain
PyTorch expressions that keep about four [rows, ffn] temporaries for backward. Plain swiglu has fused Triton
kernels that keep only gate and up. This PR adds the same kernels for the clamped forms, for both expert layouts:

At the busiest rank of a MiniMax-M3 run that ran out of memory (1,045,081 rows x 3,072, bf16), one swiglu_oai
call goes from 41.86 GiB extra peak memory and 77.8 ms to 17.94 GiB and 11.9 ms, the same as plain swiglu.

Duplicate check

Open PRs and issues searched on 2026-10-12 for "swiglu", "clamp", "expert activation" and "gpt-oss": no other work on
fused clamped activations. #8748 (another author, open) adds a different kernel, swiglu_weighted (routing weight
inside the activation, plain swiglu only), to the same two files (swiglu_triton.py and the registry in
ep_experts.py). The two do not overlap in function; whichever merges second needs a small rebase.

Problem

Each clamped form is two clamps, a sigmoid, three products and an add in plain PyTorch, each a full [rows, ffn]
temporary, most of them saved for backward. On an expert-parallel rank this is where the memory goes, because
AutoEP has no capacity limit and real routers send some ranks several times the mean number of rows.

MiniMax-M3 at 65,536 tokens with expert parallel 32 on 4 nodes of 8 H200 ran out of memory there: the busiest rank
received 1,045,081 rows (3.99 times its own 262,144) and failed inside apply_expert_activation on one 11.96 GiB
temporary, with 129.50 GiB already in use. (Setup: all 60 layers, real checkpoint, minimax_m3 preset, ZeRO-3 with
the optimizer state on the host, activation checkpointing; torch 2.8.0+cu126, transformers 5.15.1, DeepSpeed master
4a5856c25 plus the then-open registry PR.) Over the 57 MoE layers, the busiest of 32 ranks received a median 4.20
times the mean number of rows (maximum 7.84).

The change

File What
deepspeed/ops/triton_ops/swiglu_triton.py _swiglu_clamped_fwd_kernel / _swiglu_clamped_bwd_kernel, modelled on master's swiglu kernels: int64 offsets, float32 math, one rounding on store, OAI: tl.constexpr selects the form, PACKED: tl.constexpr reads gate and up from one [rows, 2 * INTER] tensor with master's packed indexing. The clamp gradient passes at the limit, as torch.clamp's does. Public swiglu_clamped(gate, up, alpha, limit, oai) and swiglu_clamped_packed(gate_up, alpha, limit, oai); plain-PyTorch fallback without Triton or on the CPU. The packed backward raises for create_graph=True, as master's packed swiglu does
deepspeed/moe/ep_experts.py registers the kernels as fused_fn and packed_fused_fn of swiglu_clamped and swiglu_oai. Nothing else changes: the grouped-GEMM expert paths reach them through apply_expert_activation(..., fused=True) and apply_packed_expert_activation; the for-loop reference path keeps fused=False
docs/_pages/config-json.md one sentence: which forms run a fused kernel
tests/unit/v1/ops/triton_ops/test_swiglu_triton.py, tests/unit/v1/moe/test_autoep_expert_activation.py see Tests

Size: 264 added and 2 removed lines under deepspeed/ (232 added in swiglu_triton.py, 32 in ep_experts.py).

Measured

One H200, torch 2.8.0+cu126, Triton 3.4.0, through apply_expert_activation / apply_packed_expert_activation
(fused=True). 1,045,081 rows x 3,072 in bf16 (one [rows, ffn] tensor = 5.98 GiB), alpha 1.702, limit
7.0, inputs at 6 x standard normal so most values are past a clamp. Extra peak = max_memory_allocated during
forward + backward minus the memory allocated before (inputs and upstream gradient). Kept = memory still allocated
after the forward, minus the output. Time = median of 5 forward + backward passes after 3 warm-ups.

Form Layout master 1c862f7df: extra peak / kept / time this PR: extra peak / kept / time
swiglu (existing kernels, for comparison) separate 17.94 GiB / 0.00 GiB / 11.9 ms 17.94 GiB / 0.00 GiB / 11.9 ms
swiglu packed 17.94 / 0.00 / 11.9 ms 17.94 / 0.00 / 11.9 ms
swiglu_clamped separate 35.88 / 17.94 / 51.6 ms 17.94 / 0.00 / 11.9 ms
swiglu_clamped packed 35.88 / 17.94 / 92.1 ms 17.94 / 0.00 / 13.1 ms
swiglu_oai separate 41.86 / 23.92 / 77.8 ms 17.94 / 0.00 / 11.9 ms
swiglu_oai packed 41.86 / 23.92 / 118.3 ms 17.94 / 0.00 / 13.2 ms

Per MoE layer on that rank, swiglu_oai keeps 23.92 GiB less for backward and runs 6.5 times faster (separate) or
9.0 times faster (packed). The packed clamped kernels are 1.2-1.3 ms slower than master's packed swiglu kernel,
which does less arithmetic per element. Bench script: expert_activation_bench.py (TODO(user): attach as a gist or
drop this line).

Tests

Command, from tests/, on 1 node of 8 H200 (143,771 MiB each, driver 580.126.09), torch 2.8.0+cu126, Triton 3.4.0,
transformers 5.15.1, pytest 9.1.1:

DEEPSPEED_RUN_H100_TESTS=1 python -m pytest unit/v1/ops/triton_ops/test_swiglu_triton.py \
    unit/v1/moe/test_autoep_expert_activation.py -v \
    -k "not (skewed_and_empty or accumulated_updates_and_checkpoint)"

Result: 204 passed, 12 deselected, in 31 s. On master 5f266e26d (2026-10-15): 204 passed, 12 deselected, in 34 s on torch 2.8.0; the two empty-expert tests from #8733 on torch 2.9.1: 12 passed on this branch and 12 passed on master.

The two deselected tests are master's (#8733). On torch 2.8.0+cu126 on H200 they do not finish, on master as well
as on this branch: they pass an empty expert group (counts = [37, 0, 291, 72] and [16, 0, 32, 16]) to
torch._grouped_mm, whose backward never returns for some shapes when a group is empty on that torch version (GPU at
100% with no progress; known from MiniMax-M3 work, one-GPU reproduction available). AutoEP itself never passes an
empty group, because generate_permute_indices gives every local expert at least 8 rows. On torch 2.8.0 master's
test_matches_separate_with_skewed_and_empty_experts[swiglu] did not finish in 300 s; on torch 2.9.1+cu128 (Triton
3.5.1) both tests pass, 12 passed on this branch and 12 passed on master.

New and changed tests:

Test What it would catch
test_clamped_forward_matches_reference (2 forms x 3 dtypes x 6 shapes) a wrong output: compared with the expression evaluated in float32 and rounded once; inputs at scale 4 so about 8% are past a clamp; the kernel must also be no farther from float32 than the eager low-precision evaluation
test_clamped_backward_matches_reference (2 x 3 x 5) wrong input gradients, the same way, with a random upstream gradient
test_clamped_alpha_and_limit_are_honoured alpha or limit ignored; a nonzero gradient where an input was clamped
test_clamped_gradient_at_the_limit_is_inclusive a gradient that stops at gate == limit or up == -limit, where torch.clamp's passes
test_clamped_empty_input, test_clamped_non_contiguous_input, test_clamped_shape_and_dtype_mismatch_rejected the edge cases master's swiglu tests cover
test_clamped_packed_matches_the_separate_kernel_exactly (2 forms x 2 dtypes x 4 shapes, including 3-D and empty) a packed path that reads the wrong half or row: output and gradient bit-equal to the separate kernel
test_fused_kernel_matches_plain_pytorch (every form with a fused_fn) apply_expert_activation(fused=True) differing from the plain form evaluated in float32 and rounded once (bf16, 256 x 3,072)
test_packed_kernels_are_registered (was test_only_swiglu_has_a_packed_kernel) the fused gate+up path silently running the clamped forms in plain PyTorch
test_grouped_gemm_paths_match_the_for_loop, test_packed_activation_matches_the_separate_halves (master's, now running the new kernels) the grouped-GEMM paths differing from the for-loop reference path

pre-commit run --files passes on every changed file.

Compatibility

The change is an elementwise kernel behind AutoEP's existing activation registry; it does not depend on the ZeRO
stage, offload or parallel layout.

Validated Not validated
Precision float32, float16, bf16 (kernel tests); bf16 through the expert paths
Expert layouts gate_up_impl "separate" and "fused"; torch._grouped_mm and Triton grouped-GEMM paths (unit tests)
End to end no training job has run with this kernel. The MiniMax-M3 runs on 4 nodes used the plain PyTorch form (the out-of-memory case above was avoided with expert parallel 4)
Accelerators CUDA (H200); CPU fallback (plain PyTorch) ROCm, XPU and other Triton backends. The packed path sets the device with torch.cuda.device (#ignore-cuda), as master's packed swiglu does

AI assistance

This PR was written with AI assistance (Claude).

🤖 Generated with Claude Code

@tohtana tohtana mentioned this pull request Sep 27, 2026
10 of 22 tasks
@pengdurice
pengdurice force-pushed the peng-add-fuse-fn-clamped-form-v1 branch from f0db347 to 90c9660 Compare September 29, 2026 16:18
Signed-off-by: pengdurice <pengduhit@gmail.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T19:09:34.676198Z b5febe5 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b5febe562a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +282 to +283
g = tl.minimum(gate, limit)
u = tl.minimum(tl.maximum(up, -limit), limit)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve NaNs through the clamp

When a preceding grouped GEMM yields a NaN in gate or up, Triton's elementwise minimum/maximum use their default non-NaN-propagating mode, unlike the eager torch.clamp reference, so these lines replace the NaN with a finite clamp bound. The backward kernel repeats that clamp and its comparisons then zero the corresponding gradient, allowing a diverged AutoEP step to produce finite outputs and gradients; request NaN propagation (or use comparisons that preserve NaNs) in both kernels.

Useful? React with 👍 / 👎.

n_elements = gate.numel()
if n_elements > 0:
grid = (triton.cdiv(n_elements, _BLOCK_SIZE), )
_swiglu_clamped_fwd_kernel[grid](gate,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard separate launches with the input device

When gate and up reside on a CUDA device that is not the process's current device (for example, tensors on cuda:1 while the caller remains on cuda:0), this launch and the analogous backward launch run on Triton's current device/stream rather than the tensor device, so the kernel sees inaccessible pointers or launches on the wrong stream. The packed implementation already enters an input-device context around both launches; the separate implementation needs the same protection.

AGENTS.md reference: AGENTS.md:L40-L40

Useful? React with 👍 / 👎.

return out

@staticmethod
def backward(ctx, grad_out: torch.Tensor):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject higher-order gradients in the separate path

When a caller requests create_graph=True from the separate swiglu_clamped path, this backward writes gradients into fresh empty_like buffers via Triton, so the returned first derivatives are detached and a subsequent derivative fails. The packed class explicitly detects and rejects this unsupported mode, but this new separate class neither rejects it nor provides a differentiable fallback; add the same guard or a differentiable backward here.

Useful? React with 👍 / 👎.

@delock
delock self-requested a review October 23, 2026 11:19

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant