Skip to content

[Triton] Clean up MoE elementwise kernels - #5259

Draft
vgokhale wants to merge 3 commits into
mainfrom
vinayak/moe_cleanup
Draft

[Triton] Clean up MoE elementwise kernels#5259
vgokhale wants to merge 3 commits into
mainfrom
vinayak/moe_cleanup

Conversation

@vgokhale

@vgokhale vgokhale commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

No description provided.

…uant

The MoE quant path had four kernels that reimplemented quant kernels living
in aiter/ops/triton/quant. Fold them together.

_mxfp4_quant_kernel: deleted. It already shared _mxfp4_quant_op with
_dynamic_mxfp4_quant_kernel and was otherwise a strict subset of it (no int64
stride guard, no persistent loop, no cache modifier), so mxfp4_quant() is now a
wrapper over dynamic_mxfp4_quant. dynamic_mxfp4_quant gains optional out
params, mirroring dynamic_mxfp8_quant's `scale`, because the a4w4 GEMM needs
row-major scales while dynamic_mxfp4_quant allocates a transposed view -- and
its stride(0)==1 would flip the X_SCALES_TDM heuristic in moe_gemm_a4w4. Also
drops an eager x.to(float32) that materialised the whole activation in fp32
before the launch; the kernel casts on load.

_downcast_to_mxfp: keeps both scale schemes behind a new POW2_SCALE constexpr.
False (default, unchanged) is amax/dtype_max with the exponent rounded per
DEQUANT_SCALE_ROUNDING_MODE; True is the even_round scheme shared with
_mxfp4_quant_op/_mxfp8_quant_op. They are not interchangeable -- they disagree
on ~12% of fp4 block scales and ~0.3% of fp8 ones -- so the flag stays. With
POW2_SCALE=True the output is now bit-identical to dynamic_mxfp4_quant (fp4)
and dynamic_mxfp8_quant (e4m3). No perf difference between the two (0.98-1.09x
across four shapes): the kernel is memory bound, so the log2/exp2 pair costs
nothing and the name describes the policy, not a speed tier. _mxfp8_quant_op
gains a defaulted LOG2_DTYPE_MAX so the fast path also covers e5m2, whose 15
its hardcoded 8 got wrong.

_downcast_to_static_fp8: deleted, merged into
_static_per_tensor_quant_fp8_i8_kernel behind FAST_CONVERT (reciprocal-multiply
vs exact division). The two forms are not equivalent: at scale=448.0 a dense
bf16 sweep puts them 1 fp8 ulp apart on 64 elements, so both survive. Here the
name is literal -- the reciprocal form is 15-20% faster. Two side effects:

  - The merged kernel takes its output dtype from the pointer rather than
    hardcoding tl.float8e4nv, so gfx942 no longer converts fn->fnuz on store
    (the buffer is allocated fnuz there). This changes gfx942 output; it was
    double-rounding through the wrong exponent bias before.
  - The old MoE tile indexed with offs_m[None, :] / offs_n[:, None], putting
    the stride-1 axis first where Triton cannot prove stride_x_n == 1.
    Correcting that makes the MoE path 1.07-1.62x faster. Against the old
    row-per-program kernel: 0.94x worst case, 1.05-2.38x elsewhere, and it can
    now serve wide rows that did not fit in registers at all.

The shared launcher views both tensors as 2D instead of trusting qx.stride(0),
because per_tensor_quant_triton in aiter/ops/quant.py passes an N-D output
alongside a 2-D input -- the old kernel got away with using x's row stride for
the output. No test covered that.

upcast_from_mxfp: moved to op_tests/triton_tests/utils/mxfp_ref.py. It is test
scaffolding -- it builds bf16 references so the torch comparison sees the same
values the kernel reads -- and had no callers under aiter/.

Verified on gfx950: the exact-division and reciprocal paths are bit-identical
to the kernels they replace across 9,431,040 elements x 307 scales, int8
included; mxfp4_quant is bit-identical across 6 shapes x 3 dtypes; the N-D
output path matches for 3-D and 4-D, fp8 and int8, static and dynamic.
op_tests/triton_tests/{moe,quant} plus test_activation.py: 6897 passed,
6396 skipped.
Comment-only follow-up to a8968e2. The two constexpr flags each carried a
long docstring explaining both of their branches; move each down to a short
comment at the branch it actually governs (POW2_SCALE in
_compute_mx_quant_and_scale, FAST_CONVERT in
_static_per_tensor_quant_fp8_i8_kernel) and drop the rest. Same for the
wrapper-level prose on mxfp4_quant, downcast_to_static_fp8, downcast_to_mxfp,
_static_per_tensor_quant_launch and _mxfp8_quant_op's LOG2_DTYPE_MAX note.

No functional change. Reverified on gfx950: both static-fp8 paths still
bit-identical to the kernels they replaced over 9,431,040 elements x 307
scales, mxfp4_quant bit-identical, the N-D output path still correct for 3-D
and 4-D across fp8/int8 and static/dynamic, and
op_tests/triton_tests/{moe,quant} plus test_activation.py unchanged at
6897 passed / 6396 skipped.
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every PR:

  • ✅ Pre-checks (submodule verification, code formatting)
  • ✅ Aiter op tests (gfx942 + gfx950)
  • ✅ Triton tests on MI35X (only when aiter/ops/triton/** or related paths are changed)

Extended tests (opt-in via labels):

Label Tests
ci:gfx1250-ffm-triton Run the five-shard gfx1250 FFM Triton test suite
ci:triton-300x Run an additional Triton test job on MI300X in PRs; main branch always runs both MI35X and MI300X
multigpu Aiter multi-GPU tests on the 8-GPU runner
ci:sglang SGLang integration tests: DeepSeek-R1-MXFP4 accuracy, Qwen 3.5 accuracy
ci:atom ATOM benchmark: DeepSeek-R1-0528, GPT-OSS-120B
ci:atom_full ATOM accuracy suite for PR and main models from ATOM models_accuracy.json
ci:vllm vLLM benchmark: GPT-OSS-120B, DeepSeek-R1-0528, Kimi-K2.5
ci:all All standard extended tests (excludes ci:atom_full)

Only add ci:atom_full for FlyDSL or Triton upgrades.
Add labels via the sidebar or gh pr edit 5259 --add-label <label>

PR title tags & labels:
Component tags ([Triton/Gluon], [HIP], [CK], [ASM], ...) are added to the PR title and as PR labels automatically from the changed files and re-synced on every push — change-type tags like [fix]/[Perf], op tags like [MLA], and human labels (ci:*) are left untouched. Add the no-auto-title label to opt this PR out.

The dedup left three names one underscore apart: the kernel
(_static_per_tensor_quant_fp8_i8_kernel), the public wrapper
(static_per_tensor_quant_fp8_i8) and a private launcher between them holding
the tiling heuristic and the FAST_CONVERT choice. That middle tier is not a
layer this tree has anywhere else -- kernels live in _triton_kernels/, host
wrappers in the op module, and nothing sits in between -- so remove it.

fast_convert becomes a keyword argument on static_per_tensor_quant_fp8_i8,
defaulting to True so the existing positional callers (aiter/ops/quant.py) are
unaffected, and the tiling and launch move into the wrapper body alongside
every other op in that file. dynamic_per_tensor_quant_fp8_i8 and the MoE
downcast_to_static_fp8 now both go through the public wrapper. It reads better
as a public knob anyway: reciprocal-multiply vs exact division is a real
accuracy/speed choice for a caller to make.

Side effect: the dynamic path emits a second INFO log line, since it now calls
the public wrapper rather than the kernel. That is gated behind
AITER_TRITON_LOG_LEVEL (default WARNING) and is arguably accurate -- the
dynamic op does perform a static quant internally.

No behavior change otherwise. Reverified on gfx950: both scale-application
paths still bit-identical to the kernels they replaced over 9,431,040 elements
x 307 scales, int8 included; the N-D output path still correct for 3-D and 4-D
across fp8/int8 and static/dynamic; op_tests/triton_tests/{moe,quant} plus
test_activation.py at 6897 passed / 6396 skipped.
@vgokhale vgokhale changed the title [Gluon] Clean up MoE elementwise kernels [Triton] Clean up MoE elementwise kernels Sep 3, 2026
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