Skip to content

[Perf][ROCm][Kimi-K3] Fuse all-reduce with RMSNorm in latent-MoE - #57920

Open
amd-mghanimi wants to merge 11 commits into
vllm-project:mainfrom
amd-mghanimi:mghanimi/kimik3-aiter-fused-ar-rmsnorm
Open

amd-mghanimi wants to merge 11 commits into
vllm-project:mainfrom
amd-mghanimi:mghanimi/kimik3-aiter-fused-ar-rmsnorm

Conversation

@amd-mghanimi

@amd-mghanimi amd-mghanimi commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

When running KimiK3 with aiter backend, there is an opportunity to fuse all-reduce and RMSnorm using the existing fusion kernels: torch.ops.vllm.rocm_aiter_fused_allreduce_rmsnorm

There is an explicit usage of cross-device all reduce followed by rmsnorm in Latent-MoE tail here:

latent = tensor_model_parallel_all_reduce(fused_output)
if transform.norm is not None:
latent = transform.norm(latent)

To be able to enable this, we needed to check some guards and implement the call interface for rocm_aiter_fused_allreduce_rmsnorm. So, without updating aiter this PR simply defines: vllm/models/common/amd/ops/fused_ar_rms.py, with a unittest to make sure the guards are in place for other configs.

Fusion is gated with can_fuse_allreduce_rms_norm checks:

  • AITER custom AR enabled (VLLM_ROCM_USE_AITER_CUSTOM_AR) (default is on)
  • not gfx1250 (that custom-AR backend has no fused_allreduce_rmsnorm;
    looking at latest AITER v0.1.22 gfx1250 header vs fused op); fused_allreduce_rmsnorm doesn't exist on gfx1250 yet.
  • supports_dynamic_hidden_dim (K3 latent is 3584; pre-0.1.12 aiter
    template-specializes on {512, 1024, 2048, 4096} and silently no-ops)
  • should_custom_ar and one-stage fused AR+RMS (two-stage is slower than
    plain AR + norm)

Additional notes

On TP8 bf16 latent [tokens, 3584], one-stage is < 128 KB (~≤18 tokens).
CudaCommunicator.all_reduce tries QuickReduce before AITER custom AR;
QuickReduce INT4 needs ≳292 tokens. So, this fusion practically only hits Decode and only on concurrency ~≤18 tokens.
Larger capture sizes keep unfused all-reduce.
Confirm against the Using [...] all-reduce backends startup log.

Zero residuals are cached per (shape, dtype, device) and never resized, so a
graph captured at one size cannot replay against a reallocated buffer.

AI-assisted: Cursor was used to draft and iterate on this change. I reviewed
the code and own the submission.

Test Plan

  • tests/models/kimi_k3/test_amd_fused_ar_rms.py on ROCm (2 GPUs): fused
    (4 tokens) and fallback (256 tokens) vs explicit AR + RMSNorm
  • Side-by-side profiler traces, same decode capture, with vs without this
    PR at MC4 for ISL/OSL 8k/1k and 60k/600. Expect the fused
    1-stage kernel to replace one cross_device_reduce_1stage and one
    add_rmsnorm_quant per launch.
  • Serving sweep with vs without this PR across ISL/OSL and max
    concurrency. Expect ITL movement only where one-stage fusion can fire
    (decode ≲18 tokens, e.g. MC4/MC8); higher MC is a control.
  • Accuracy tests.

Serve config

We only ran the tests on MI355X. This was tested by patching this nightly image onto
vllm/vllm-openai-rocm:nightly-rocm100-af1c01499b289be555c475669ba50a88e96d846e
as nightly-af1c014-k3-fused-ar-rms:

docker run \
  --rm \
  --name vllm-private \
  -e VLLM_ROCM_USE_AITER="1" \
  -e VLLM_ROCM_USE_AITER_MOE="1" \
  -e VLLM_ROCM_USE_AITER_MLA="1" \
  -e VLLM_ROCM_QUICK_REDUCE_QUANTIZATION="INT4" \
  -e VLLM_ROCM_USE_AITER_MOE_SITUV2="1" \
  -e SAFETENSORS_FAST_GPU="1" \
  -e VLLM_USE_BREAKABLE_CUDAGRAPH="1" \
  -e VLLM_ROCM_USE_AITER_CUSTOM_AR="1" \
  --entrypoint "" \
  --network host \
  --ipc host \
  --device /dev/dri:/dev/dri \
  --device /dev/kfd:/dev/kfd \
  --security-opt seccomp=unconfined \
  --shm-size 128G \
  nightly-af1c014-k3-fused-ar-rms \
  vllm serve moonshotai/Kimi-K3 \
    --served_model_name moonshotai/Kimi-K3 \
    --load_format dummy \
    --tensor_parallel_size 8 \
    --trust_remote_code \
    --mm_encoder_tp_mode data \
    --host 0.0.0.0 \
    --port 8000 \
    --max_model_len 131072 \
    --kv_cache_dtype fp8 \
    --max_num_batched_tokens 16384 \
    --max_num_seqs 256 \
    --max_cudagraph_capture_size 512 \
    --no_enable_prefix_caching \
    --no-enable_log_requests \
    --async_scheduling \
    --compilation_config '{"cudagraph_mode":"FULL_AND_PIECEWISE","custom_ops":["+fused_rms_norm_gated"]}' \
    --mm_encoder_attn_backend ROCM_AITER_FA \
    --reasoning_parser kimi_k3 \
    --tool_call_parser kimi_k3 \
    --enable_auto_tool_choice

The profile traces was captured with these additional configs:

    --profiler-config.profiler torch \
    --profiler-config.torch_profiler_dir /tmp/traces/ \
    --profiler-config.torch_profiler_record_shapes false \
    --profiler-config.torch_profiler_with_stack false \
    --profiler-config.ignore_frontend true \
    --profiler-config.delay_iterations 0 \
    --profiler-config.max_iterations 64

(so the cropped steps was used for the call statisitic.)

Test Result

Unit test: tests/models/kimi_k3/test_amd_fused_ar_rms.py covers fused (4
tokens) and unfused fallback (256 tokens).

Profiler, rank-0 call counts, same decode capture. Each fused launch consumed
one all-reduce and one norm (Δs agree to the unit). The fused kernel is absent
from baseline.

8k/1k MC4

kernel baseline PR Δ
aiter::cross_device_reduce_1stage 17019 11407 −5612
aiter::allreduce_fusion_kernel_1stage — 5612 +5612
aiter::add_rmsnorm_quant_kernel 5888 276 −5612

60k/600 MC4

kernel baseline PR Δ
aiter::cross_device_reduce_1stage 13950 9350 −4600
aiter::allreduce_fusion_kernel_1stage — 4600 +4600
aiter::add_rmsnorm_quant_kernel 5888 1288 −4600

The perfetto screenshot shows how mc4 looks like with/without (from 30us down to 33us):

no fusion:
no_fusion

all reduce fusion:
allreduce_fusion_kernel

When serving, median ITL (ms) and output tok/s recieve the uplift on affected MC.
One-stage fusion only covers decode batches ≲18 tokens (MC4/MC8 here). Higher MC does not hit by this PR.

ISL/OSL MC ITL base ITL PR ITL Δ tok/s base tok/s PR tok/s Δ fusion
8k/1k 4 16.64 16.40 −1.46% 211.26 217.89 +3.14% fires
8k/1k 8 17.96 17.72 −1.33% 370.81 374.38 +0.96% fires
8k/1k 16 18.44 18.42 −0.11% 600.39 614.79 +2.40% no effect
8k/1k 64 23.49 23.52 +0.13% 1324.25 1342.80 +1.40% no effect
8k/1k 256 46.67 46.75 +0.16% 1828.12 1847.59 +1.06% no effect
60k/600 4 17.21 16.97 −1.39% 110.06 109.02 −0.94% fires
60k/600 16 20.56 20.54 −0.12% 169.10 169.66 +0.33% no effect
128k/1k 4 17.85 17.60 −1.37% 92.77 92.94 +0.18% fires
128k/1k 16 22.72 22.70 −0.07% 130.62 130.90 +0.21% no effect

Accuracy Test Results

I ran lm_eval's needle in a haystack nia_single2 with two different batch sizes num_concurrent {8 and 16}:

lm_eval \
  --model local-chat-completions \
  --tasks niah_single_2 \
  --metadata '{"max_seq_lengths":[4096,65536,131072]}' \
  --batch_size auto \
  --apply_chat_template \
  --seed 1234 \
  --output_path /tmp/lm_eval/ruler_niah_57920_pr_mc8 \
  --model_args '{"model":"moonshotai/Kimi-K3","tokenizer":"moonshotai/Kimi-K3","base_url":"http://localhost:8000/v1/chat/completions","num_concurrent":8,"tokenized_requests":false,"timeout":3600}'


lm_eval \
  --model local-chat-completions \
  --tasks niah_single_2 \
  --metadata '{"max_seq_lengths":[4096,65536,131072]}' \
  --batch_size auto \
  --apply_chat_template \
  --seed 1234 \
  --output_path /tmp/lm_eval/ruler_niah_57920_pr_mc16 \
  --model_args '{"model":"moonshotai/Kimi-K3","tokenizer":"moonshotai/Kimi-K3","base_url":"http://localhost:8000/v1/chat/completions","num_concurrent":16,"tokenized_requests":false,"timeout":3600}'

Two numbers per cell for C=8/C=16

Length Baseline PR Δ (pp)
4k 0.974 / 0.968 0.980 / 0.966 +0.6 / −0.2
64k 0.908 / 0.930 0.910 / 0.924 +0.2 / −0.6
128k 0.878 / 0.888 0.876 / 0.870 −0.2 / −1.8

Between running the experiments and submitting this PR, there was an aiter bump. So, for accuracy tests I switched to the latest image: vllm-openai-rocm/nightly-rocm100-3df4ae153eb385e27b52f26c81f8edb9e20b9984
However, there was an OOM errors in the base image, so I patched both runs with #57923 as a temporary solution to examile accuracy here.


Essential Elements of an Effective PR Description Checklist
  • The purpose of the PR, such as "Fix some issue (link existing issues this PR will resolve)".
  • The test plan, such as providing test command.
  • The test results, such as pasting the results comparison before and after, or e2e results
  • (Optional) The necessary documentation update, such as updating supported_models.md and examples for a new model.

amd-mghanimi and others added 6 commits September 21, 2026 09:54
Call AITER's 1-stage custom AR+RMSNorm from the latent MoE runner instead of a plain all-reduce plus a separate RMSNorm, matching the NVIDIA FlashInfer site. Prefill-sized tensors still fall back because they miss the 1-stage gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Mehdi Ghanimifard <mehdi.ghanimifard@amd.com>
Kimi-K3 runs under breakable CUDA graphs, not a torch.compile eager/compile split, so the comments and logs should name that execution path.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Mehdi Ghanimifard <mehdi.ghanimifard@amd.com>
ROCm uplift PRs must not edit vllm/models/kimi_k3/nvidia/. The common
fused_allreduce_rms_norm_out helper remains for the AMD path.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Mehdi Ghanimifard <mehdi.ghanimifard@amd.com>
T1 fusion targets CompilationMode.NONE, not torch.compile: no compile
decorator, no eager_break_during_capture, zeros_like instead of a
process-global residual cache so capture can record the launch.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Mehdi Ghanimifard <mehdi.ghanimifard@amd.com>
Revert vllm/models/common/ops/fused_allreduce_rms_norm.py: it is the
flashinfer helper consumed by the NVIDIA K3/DSV32 models and has no ROCm
path to add.

The AMD tail now calls torch.ops.vllm.rocm_aiter_fused_allreduce_rmsnorm
directly, matching how amd/mla.py fires fused_mla_dual_rms_norm. Gate on
AITER custom AR being the active backend, on aiter exposing a runtime
hidden_dim (K3's latent is 3584, outside the old template set), and on the
one-stage kernel bound; everything else keeps the unfused all-reduce.

Zero residuals are cached per shape and never resized, so a graph captured
against one size cannot replay against a reallocated buffer.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Mehdi Ghanimifard <mehdi.ghanimifard@amd.com>
AITER's gfx1250 custom-AR backend has no fused_allreduce_rmsnorm, so the
K3 tail falls back to plain AR+RMS there. The test is one TP2 spawn.

Co-authored-by: Cursor <cursoragent@cursor.com>
Signed-off-by: Mehdi Ghanimifard <mehdi.ghanimifard@amd.com>
@mergify mergify Bot added kimi k3 rocm Related to AMD ROCm labels Sep 21, 2026
@github-project-automation github-project-automation Bot moved this to Todo in AMD Sep 21, 2026
@amd-mghanimi amd-mghanimi changed the title [Perf][ROCm][Kimi-K3][perf] Fuse all-reduce with RMSNorm in latent-MoE [Perf][ROCm][Kimi-K3] Fuse all-reduce with RMSNorm in latent-MoE Sep 21, 2026

@claude claude 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.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@amd-mghanimi

Copy link
Copy Markdown
Contributor Author

@claude review

@simondanielsson simondanielsson left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice work!

Comment thread vllm/models/common/amd/ops/fused_allreduce_rms_norm.py
return buf


def can_fuse_allreduce_rms_norm(hidden_states: torch.Tensor) -> bool:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion: Can we re-use _can_use_aiter_fused_ar_rms instead (and add the on_gfx1250 gate there👍)

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 is also a valid point, if fused_ar_rms needs to live in vllm/model_executor/layers it makes sense to reuse as much as possible between different variations of ar+norm such as vllm/model_executor/layers/fused_allreduce_gemma_rms_norm.py
If there is a clear consensus on this I will update the code accordingly.

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.

I believe we are talking about this:
fused_allreduce_gemma_rms_norm.py

If I understand correctly, the issue is that in the current design we want to keep the code cleaner by keeping flashinfer/trtllm and aiter separate and independent from each other. but that implementation still keeps them closely related. Perhaps we need a separate PR for this.

from vllm.model_executor.layers.layernorm import RMSNorm

# Zero residual, cached per shape so CUDA-graph captures keep a stable pointer.
_zero_residuals: dict[tuple[torch.Size, torch.dtype, torch.device], torch.Tensor] = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question: Would the WorkspaceManager be safer here instead of manual allocation and caching?

@amd-mghanimi amd-mghanimi Sep 21, 2026 •

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.

I think the term zero_residual is related to difference between RMSNorm and AddRMSNorm, so when there is nothing to add, we still need a dummy zero tensor. it takes a few kilo bytes of memory.

The old implementation has this pattern:

residual = torch.zeros_like(input)
allreduce = self.FUSED_AR_RMSNORM_OP(
input_=input,
residual=residual,
weight=weight.to(input.dtype),
epsilon=self.epsilon,
)
return allreduce[0], allreduce[1]

My understanding is that this way we make sure that the dummy cache is allocated once for all other calls since they are stored like key/value dict. I guess we could also use get_persistent/get_persistent_resource then we have to make dependency on v1 workers? since we don't want to change this tensor maybe it is an overkill?

amd-mghanimi and others added 3 commits September 21, 2026 16:09
The helper is not Kimi-K3-specific, so keep it next to other reusable AMD ops.

Signed-off-by: Mehdi Ghanimifard <mehdi.ghanimifard@amd.com>
Co-authored-by: Cursor <cursoragent@cursor.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

k3 kimi rocm Related to AMD ROCm

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

2 participants