Skip to content

[ROCm] Fuse per-token activation quant into RMSNorm for per-channel quantized attention - #34502

Open
Emmanuel0612 wants to merge 51 commits into
sgl-project:mainfrom
Emmanuel0612:opt/kimi-k2-mxfp4-fuse-pertoken-quant
Open

Emmanuel0612 wants to merge 51 commits into
sgl-project:mainfrom
Emmanuel0612:opt/kimi-k2-mxfp4-fuse-pertoken-quant

Conversation

@Emmanuel0612

@Emmanuel0612 Emmanuel0612 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

This PR fuses the per-token FP8 activation quantization into the preceding RMSNorm for per-channel dynamic FP8 attention projections on AMD gfx95 (validated on MI355X with Kimi-K2.7-Code-MXFP4). By emitting a pre-quantized (fp8, scale, orig_dtype) tuple straight from the norm, it removes the standalone per-token quant kernel before each attention projection, improving throughput by up to ~3.5% with no accuracy change.

Motivation

For per-channel fp8 attention projections on gfx95, the per-token activation quant runs as a standalone _per_token_group_quant_8bit launch immediately before each projection (fused_qkv_a_proj_with_mqa, q_b_proj, kv_b_proj). Profiling shows this as a separate kernel that can be folded into the preceding RMSNorm.

Change

Fold the per-token fp8 quant into the preceding RMSNorm, so the projection receives a pre-quantized (fp8, x_scale[m, 1], orig_dtype) tuple consumed directly by gemm_a8w8_bpreshuffle in apply_fp8_linear. The standalone quant kernel is eliminated. The tuple carries the original activation dtype so the GEMM output preserves it (FP16 stays FP16; not promoted to BF16).

Fusion sites, each emitting a per-token whole-row scale [m, 1]:

  • Entry proj (fused_qkv_a_proj_with_mqa): fold into input_layernorm, covering the fused all-reduce + RMSNorm path (custom_fused_ar_rms_quant) and the post-all-reduce path used by large-batch prefill.
  • Decode q_b_proj: fold into the fused q/kv RMSNorm (fused_qk_rmsnorm, quant_type=per_Token).
  • Prefill q_b_proj / kv_b_proj: fold into the single-tensor RMSNorm (rmsnorm2d_fwd_with_dynamicquant, group_size=0).

Testing

  • test/registered/unit/models/test_pertoken_fp8_eligibility.py (CPU)
    — unit tests for the fold-eligibility predicate _is_per_channel_dynamic_fp8. Asserts the layout contract is read from the layer's own state: the explicit _fp8_weight_preshuffled marker (not the global gfx95 flag), input_scale is None (dynamic), and weight_scale.numel() equal to the projection's real output-channel count (output_size_per_partition else output_size), 1-D [N] or 2-D [N,1]. Covers eligible per-channel (1-D/2-D) vs. rejected per-tensor, static-input-scale, block-scale, non-fp8, missing-marker, and a scale sized to the input dim K (must be rejected).
image
  • test/registered/kernels/test_pertoken_fp8_fold_parity.py (MI35x)
    —GEMM-level parity: the pre-quantized (fp8, scale) tuple path vs. the standard apply_fp8_linear path produce identical output, and the carried dtype is preserved (FP16 stays FP16; a 2-tuple falls back to pre_quant_output_dtype / BF16).
image
  • test/registered/kernels/test_pertoken_fp8_quant_method_routing.py (MI35x)
    — drives the folded tuple through the real quant method rather than apply_fp8_linear directly: test_quark_scheme_routes_tuple runs the actual Quark W8A8Fp8 apply_weights for the entry (2112), q_b (12288), and kv_b (16384) widths at M=1 (decode) and M>1 (prefill), BF16/FP16, asserting parity and dtype preservation; test_unwrapped_per_token_scale_does_not_assert guards the native Fp8LinearMethod / compressed-tensors unwrap path (apply_fp8_linear(input=qx, input_scale=scale[M,1])) that previously asserted numel == 1 and failed for M>1.
image
  • test/registered/kernels/test_pertoken_fp8_layer_routing.py (MI35x)
    —genuine layer-path regression test: builds a real ColumnParallelLinear, quantizes and preshuffles its weight via the real Quark scheme (create_weights → load → process_weights_after_loading, which sets the marker), then calls layer.forward() — exercising the full production routing (ColumnParallelLinear.forward → quant_method.apply → scheme.apply_weights → apply_fp8_linear → gemm_a8w8_bpreshuffle). Asserts the preshuffle marker landed, the folded-tuple forward matches the plain-activation forward, and dtype is preserved (q_b/kv_b, M=1 and M>1, BF16/FP16).
image
  • test_pertoken_fp8_ar_capture_guard.py (CPU)
    — guards the P0 in fused_allreduce_rmsnorm_quant. Under AITER's TC-piecewise replay state (_IS_CAPTURING set, stream not capturing, in a piecewise graph), custom_fused_ar_rms_quant returns non-None dummy zeros that would be consumed as real (fp8, residual, scale). Using a fake communicator with the three capture predicates patched, it asserts:
    — the wrapper returns None without calling the kernel in that capture state, and
    — it still calls the kernel in the three non-capture cases (so the fold isn't silently disabled).
    Pure control-flow logic, runs on CPU CI.
image

All four pass on MI355X (gfx950); the CPU eligibility test also runs on CPU CI.

Accuracy test

Accuracy (GSM8K) — neutral, within run-to-run noise:
Results (MI355X / gfx950, Kimi-K2.7-Code-MXFP4)

parallel 100 300 600 1200
original 0.951 0.953 0.942 0.950
fold_quant 0.947 0.946 0.947 0.951

Speed Tests and Profiling

Throughput / GPU (tok/s) — consistent improvement:
Results (MI355X / gfx950, Kimi-K2.7-Code-MXFP4 / 8K-1K)

Conc 4 8 16 32 64 128
original 906.82 1491.25 2128.38 2955.05 3925.4 5048.96
fold_quant 932.15 1511.13 2203.48 2992.39 3983.7 5084.88
Δ +2.79% +1.33% +3.53% +1.26% +1.49% +0.71%

original profiling:
image
fold_quant profiling:
image

Checklist

Review and Merge Process

  1. Ping Merge Oncalls to start the process. See the PR Merge Process.
  2. Get approvals from CODEOWNERS and other reviewers.
  3. Trigger CI tests with comments or contact authorized users to do so.
    • Common commands include /tag-and-rerun-ci, /tag-run-ci-label, /rerun-failed-ci
  4. After green CI and required approvals, ask Merge Oncalls or people with Write permission to merge the PR.

CI States

Latest PR Test (Base): ⏳ Run #34840567183
Latest PR Test (Extra): ❌ Run #34840566707
Latest PR Test (AMD ROCm 10): ❌ Run #34840567029

mqhc2020 and others added 20 commits July 23, 2026 11:05
Conflicts:
	python/sglang/srt/distributed/parallel_state.py
Conflicts:
	python/sglang/srt/layers/layernorm.py
Conflicts:
	python/sglang/srt/layers/layernorm.py
…el fp8 attn

For per-channel fp8 attention projections (q_b_proj / kv_b_proj /
fused_qkv_a_proj_with_mqa) on gfx95, the per-token fp8 activation quant was a
standalone _per_token_group_quant_8bit launch before each projection. Fold it
into the preceding (fused) RMSNorm so the projection receives a
(fp8, x_scale[m,1]) tuple consumed directly by gemm_a8w8_bpreshuffle.

Adds a new quant_format "fp8_per_token" (per-channel fp8) alongside the
existing block-scale "fp8":

- apply_fp8_linear: tuple fast path -> gemm_a8w8_bpreshuffle (fp8_utils.py)
- Fused AR+RMSNorm+per-token-quant plumbing: layernorm.py,
  parallel_state.py (custom_fused_ar_rms_quant), communication_op.py,
  communicator.py
- deepseek_v2.py: _detect_gfx95_quant_format returns "fp8_per_token" for
  per-channel fp8; prepare_qkv_latent / q_b_proj_forward route the tuple
  through the standard proj (fused-a-gemm cannot consume a tuple)
- forward_mla_rocm.py: fused_qk_rmsnorm_q_pertoken_fp8 (decode q_b_proj)
- forward_mha_rocm.py: _fused_rmsnorm_pertoken_fp8 (prefill q_b_proj/kv_b_proj)

All new paths gate on aiter kernel availability and fall back to the existing
bf16 + standalone-quant path when unavailable.

Co-Authored-By: Claude <noreply@anthropic.com>
Emmanuel0612 and others added 3 commits August 18, 2026 12:14
Co-Authored-By: Claude <noreply@anthropic.com>
The predicate now gates on the explicit _fp8_weight_preshuffled marker and an
exact output-channel match (output_size_per_partition | output_size). Update the
mock proj builder to set those attrs (preshuffled marker + output_size), so the
per-channel eligible cases pass, and add coverage for a missing marker and for a
scale sized to the input dim K (which must be rejected).

Co-Authored-By: Claude <noreply@anthropic.com>
…n fp8 fold

Drives the fold's (fp8, scale[M,1], dtype) tuple through a real
ColumnParallelLinear whose weight is quantized and preshuffled by the actual
Quark W8A8Fp8 scheme (create_weights -> load -> process_weights_after_loading),
then calls layer.forward() -- exercising the production routing
(quant_method.apply -> scheme.apply_weights -> apply_fp8_linear ->
gemm_a8w8_bpreshuffle) that the kernel-level parity test bypasses. Asserts the
preshuffle marker landed, the folded-tuple forward matches the plain-activation
forward, and dtype is preserved (q_b/kv_b widths, M=1 and M>1, BF16/FP16).

Validated on MI355X (gfx950).

Co-Authored-By: Claude <noreply@anthropic.com>

@kkHuang-amd kkHuang-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed current head 21d2f5ab. The earlier tuple-routing, exact [M,1] scale, output-dtype, preshuffle-marker, and real ColumnParallelLinear coverage findings are resolved. Three P0 issues remain in the newly added fused collective wrapper, so I cannot approve yet. The current-head CPU and MI35X jobs were also gate-skipped; rerun them after the fixes. One P1 test-hardening item is non-blocking and may be handled in a follow-up PR.

use_1stage_ar = envs.SGLANG_USE_1STAGE_ALLREDUCE.get()
else:
total_bytes = input_.numel() * input_.element_size()
use_1stage_ar = total_bytes <= 128 * 1024

@kkHuang-amd kkHuang-amd Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] The generic default can select AITER's 1-stage kernel beyond its documented 80-token limit, but the advertised Kimi/DeepSeek path is safe. For BF16 hidden size 7168, the 128 KiB threshold permits at most nine tokens, so this is not reachable in the supported checkpoint. A future/custom hidden width such as [128,512] would exceed kMaxBlocks=80 and can silently truncate work. In a follow-up PR, add token_num <= 80 to the default decision and cover the 80/81-token boundary. This does not block approval of the current Kimi path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We can separate the issue into 3 situations and discuss.

  1. the batch_size is small (<= 128KB)
    it will run "1-stage fused AR+RMSNorm+Quant", 9 tokens on Kimi, smaller than 80.
  2. bigger batch_size (>128KB and AR-fusion still on)
    it will run "2-stage fused AR+RMSNorm+Quant", no token cap.
  3. AR-fusion off
    it will run "plain AR + fused RMSNorm+Quant", still does not encounter 80-token risk.
    So, it will not cause any problem.

use_1stage_ar = total_bytes <= 128 * 1024

try:
return ca_comm.custom_fused_ar_rms_quant(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P0] The quantized wrapper omits the TC-piecewise CUDA-graph capture-state handling used by fused_allreduce_rmsnorm. When AITER's global _IS_CAPTURING state is active but the current stream is not capturing, the custom wrapper can return dummy zero outputs; this non-None tuple is then consumed as real activations/residuals/scales. Mirror the existing piecewise guard (or use the communicator's real registered path) before this call, and add capture/replay correctness coverage for the per-token format.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This has been solved.

eps,
use_1stage_ar,
)
except Exception:

@kkHuang-amd kkHuang-amd Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] The broad catch exposes a real collective-safety robustness gap, but no normal supported Kimi trigger is demonstrated. Shape/width preflight is rank-symmetric and most HIP kernel failures surface asynchronously, so the current supported path is not known to diverge here. A rank-local synchronous launch fault could still make one rank return None and enter a second all-reduce while peers proceed in the fused collective. In a follow-up PR, return None only from deterministic checks before backend entry and propagate exceptions after launch. This does not block approval once the TC-piecewise P0 is fixed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If some errors go to the exception.
Then it still can't run since the asynchronous GPU error and shape error.
That means the launching will stop without sending any incorrect result.

_HAS_PATH = (
_use_aiter and _use_aiter_bpreshuffle_gfx95 and torch.cuda.is_available()
)
except Exception:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1] This broad import catch can silently turn the required MI35X layer-routing regression into a green skip when AITER/Quark initialization is broken. In a follow-up PR, keep normal skips for unsupported platforms, but let unexpected import or initialization errors fail on the gfx95 runner so coverage cannot disappear silently. This item does not block approval once the P0s and current MI35X run are resolved.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  1. The Quark/linear imports now run unguarded when _HAS_PATH=True. For gfx95 runners, if AITER/Quark initialization is broken, the import fails (the test errors red) instead of degrading into a green skip — that's the coverage this guards.
  2. The try/except wraps only the lightweight, aiter-independent hardware probe. Its sole purpose is to make sure the test skips cleanly on non-ROCm/non-gfx95 environments — i.e. "keep normal skips for unsupported platforms."

@kkHuang-amd kkHuang-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Severity correction after checking the supported Kimi/DeepSeek shapes and AITER contract at head 21d2f5ab:

  • P0 (blocking): missing TC-piecewise CUDA-graph handling is directly reachable under the default ROCm prefill graph path. AITER returns non-None dummy zero activations/residuals/scales when _IS_CAPTURING is set but the current stream is outside capture; this wrapper accepts them as real output. Mirror the existing unquantized guard and add capture/replay parity coverage.
  • P1 (follow-up): the generic one-stage rule lacks the 80-token cap, but Kimi's BF16 hidden size 7168 limits the byte-threshold path to at most nine tokens, so this is not reachable in the supported checkpoint.
  • P1 (follow-up): broad exception-to-None fallback is a collective-safety robustness gap, but no normal supported Kimi trigger for rank-local synchronous divergence is demonstrated.
  • P1 (follow-up): the MI35X layer-routing test can silently skip on unexpected AITER/Quark initialization errors.

The request-changes state remains because one reachable P0 is still open. Once it is fixed and current-head CPU/MI35X CI executes successfully, the remaining P1s do not block approval.

Emmanuel0612 and others added 2 commits August 19, 2026 11:54
fused_allreduce_rmsnorm_quant called custom_fused_ar_rms_quant without the
TC-piecewise CUDA-graph capture-state handling its unquantized sibling
fused_allreduce_rmsnorm already has. Under the default ROCm prefill graph
path, when AITER's _IS_CAPTURING is set but the current stream is outside
capture, that kernel returns non-None dummy zero outputs which were then
consumed as real (fp8, residual, per-token scale) activations.

Mirror the existing guard and return None in that state so the caller falls
back to the plain fused AR+RMSNorm registered path plus a separate per-token
quant, which is correct under capture/replay. Add a deterministic CPU test
that reproduces the exact capture state and pins each leg of the three-way
predicate.

Co-Authored-By: Claude <noreply@anthropic.com>
…port

The broad import catch turned any AITER/Quark initialization failure into
_HAS_PATH=False, silently skipping the required MI35x layer-routing
regression on the exact runner where it must run. Split it: detect the
target hardware defensively (non-gfx95 still skips), but leave the Quark /
linear imports unguarded on gfx95 so an API move or a broken pinned build
fails loudly instead of losing coverage.

Co-Authored-By: Claude <noreply@anthropic.com>

@kkHuang-amd kkHuang-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed head d31e005a79c702368756ff3a326968b0ba1b6d3c against latest main dad6fd0f04556a9a2c09fc08388ecee45ed5a33f.

The previous blocking findings are resolved: tuple eligibility and exact [M,1] routing are tied to the real preshuffled per-channel layout, output dtype is preserved, production ColumnParallelLinear coverage is present, and the fused per-token collective now avoids AITER dummy outputs in the TC-piecewise replay state. The MI35X import guard also fails loudly on the target platform. No current PR hunk is redundant with main.

The remaining 80-token one-stage cap and post-collective broad exception fallback are non-blocking P1 follow-ups already noted in review comments.

Approving from a code-review perspective. Before merge, integrate current main and rerun the required CPU and MI35X checks; the relevant MI35X targeted job passed, but aggregate CI is currently red.

@Emmanuel0612

Copy link
Copy Markdown
Contributor Author

@amd-bot ci-status

@Emmanuel0612 Emmanuel0612 changed the title [ROCm] Fuse per-token fp8 activation quant into RMSNorm for per-chann… [ROCm] Fuse per-token activation quant into RMSNorm for per-channel quantized attention Aug 27, 2026
@Emmanuel0612

Copy link
Copy Markdown
Contributor Author

@amd-bot ci-status

@Emmanuel0612
Emmanuel0612 force-pushed the opt/kimi-k2-mxfp4-fuse-pertoken-quant branch from a59df27 to 4709d9a Compare August 31, 2026 09:46
@yichiche

Copy link
Copy Markdown
Collaborator

@Emmanuel0612 Please fix lint test.
Lint / lint (pull_request)
Lint / lint (pull_request)Failing after 2m

…i35x

The three test_pertoken_fp8_* tests lived under test/registered/kernels/,
which fails the taxonomy check (kind must be one of unit/kernel/e2e/accuracy/
perf/stress under <kind>/<subsystem>/). They register the AMD mi35x suite, so
place them in the vendor-exempt test/registered/amd/kernel/mi35x/ tree,
matching the existing amd/<kind>/<arch> layout; suite registration unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
@Emmanuel0612

Copy link
Copy Markdown
Contributor Author

@Emmanuel0612 Please fix lint test. Lint / lint (pull_request) Lint / lint (pull_request)Failing after 2m

Thank you. It is done.

…used RMSNorm+quant

The fully-fused AR+RMSNorm+MXFP4 kernel has only a 1-stage tier; past its
envelope (>56 tokens at n=7168) the 2-stage kernel rejects the shape and raises,
crashing test_aiter_allreduce_fusion_amd at (64,7168). Gate
fused_allreduce_rmsnorm_mxfp4_quant to return None past the 1-stage envelope, and
route the corner case in LayerCommunicator to plain all-reduce +
fused_rms_mxfp4_quant so RMSNorm+quant stay fused and the consumer still receives
the (fp4, scale) tuple. Applies to both Kimi-K2.7 and Qwen3.5 (both set
fuse_quant=True, quant_format=mxfp4).

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants