[ROCm][Perf] Kimi-K3 Shard Latent MoE up-projection for ROCm path (#51253)

Signed-off-by: kliuae <kuanfu.liu@embeddedllm.com>

kliuae committed Aug 7, 2026 at 14:11 UTC 43d691ec6b1d26d3ef3d8725a7c7e4d8556eb984
4 files changed +453
tests/models/kimi_k3/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
tests/models/kimi_k3/test_amd_latent_moe_runner.py new
+297
@@ -0,0 +1,297 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""The ROCm latent-MoE tail must equal the replicated up-projection.
4 +
5 +A wrong shard offset or a dropped accumulation still runs and still produces
6 +plausible text, so these pin the arithmetic rather than the behaviour.
7 +"""
8 +
9 +from types import SimpleNamespace
10 +
11 +import pytest
12 +import torch
13 +import torch.distributed as dist
14 +import torch.nn.functional as F
15 +from torch.multiprocessing import spawn
16 +
17 +from tests.utils import (
18 + ensure_current_vllm_config,
19 + init_test_distributed_environment,
20 + multi_gpu_test,
21 +)
22 +from vllm.distributed import get_tp_group
23 +from vllm.model_executor.layers.fused_moe.runner import moe_runner
24 +from vllm.model_executor.layers.layernorm import RMSNorm
25 +from vllm.model_executor.layers.linear import ReplicatedLinear
26 +from vllm.models.kimi_k3.amd.latent_moe_runner import ROCmLatentMoERunner
27 +from vllm.models.kimi_k3.amd.linear import KimiRoutedOutputTransform
28 +from vllm.platforms import current_platform
29 +from vllm.utils.network_utils import get_open_port
30 +
31 +pytestmark = pytest.mark.skipif(
32 + not current_platform.is_rocm(),
33 + reason="ROCmLatentMoERunner is only wired up on ROCm",
34 +)
35 +
36 +HIDDEN_SIZE = 7168
37 +LATENT_SIZE = 3584
38 +EPS = 1e-5
39 +DTYPE = torch.bfloat16
40 +
41 +
42 +def _build_transform(device: torch.device) -> KimiRoutedOutputTransform:
43 + norm = RMSNorm(LATENT_SIZE, eps=EPS).to(device=device, dtype=DTYPE)
44 + up_proj = ReplicatedLinear(
45 + LATENT_SIZE,
46 + HIDDEN_SIZE,
47 + bias=False,
48 + params_dtype=DTYPE,
49 + prefix="routed_expert_up_proj",
50 + ).to(device=device)
51 +
52 + torch.manual_seed(0)
53 + norm.weight.data.copy_(1 + 0.1 * torch.randn_like(norm.weight))
54 + up_proj.weight.data.copy_(torch.randn_like(up_proj.weight) / LATENT_SIZE**0.5)
55 + return KimiRoutedOutputTransform(norm, up_proj)
56 +
57 +
58 +def _tail_runner(
59 + transform: KimiRoutedOutputTransform, tp_size: int
60 +) -> ROCmLatentMoERunner:
61 + """A runner carrying only what the tail reads, so no engine is needed.
62 +
63 + ``_maybe_reduce_final_output`` is left as the real base-class method, so the
64 + all-reduce that stitches the shards is the real collective.
65 + """
66 + runner = object.__new__(ROCmLatentMoERunner)
67 + attrs = {
68 + "routed_output_transform": transform,
69 + "_up_proj_shard_size": HIDDEN_SIZE // tp_size,
70 + "_logged_sharded_tail": False,
71 + "moe_config": SimpleNamespace(
72 + tp_size=tp_size,
73 + ep_size=1,
74 + is_sequence_parallel=False,
75 + skip_final_all_reduce=False,
76 + ),
77 + }
78 + for name, value in attrs.items():
79 + object.__setattr__(runner, name, value)
80 + return runner
81 +
82 +
83 +def _all_reduced(tensor: torch.Tensor, group) -> torch.Tensor:
84 + reduced = tensor.clone()
85 + dist.all_reduce(reduced, group=group)
86 + return reduced
87 +
88 +
89 +def _rank_partials(
90 + num_tokens: int, device: torch.device
91 +) -> tuple[torch.Tensor, torch.Tensor]:
92 + routed = torch.randn(num_tokens, LATENT_SIZE, device=device, dtype=DTYPE)
93 + shared = torch.randn(num_tokens, HIDDEN_SIZE, device=device, dtype=DTYPE)
94 + return routed.mul_(0.01), shared
95 +
96 +
97 +def _check_matches_replicated(device: torch.device, tp_size: int, rank: int) -> None:
98 + transform = _build_transform(device)
99 + runner = _tail_runner(transform, tp_size)
100 + group = get_tp_group().device_group
101 +
102 + for iteration, num_tokens in enumerate((1, 5, 8, 16, 5)):
103 + torch.manual_seed(100 * iteration + rank + 1)
104 + routed_output, shared_output = _rank_partials(num_tokens, device)
105 +
106 + expected = F.linear(
107 + F.rms_norm(
108 + _all_reduced(routed_output, group),
109 + (LATENT_SIZE,),
110 + transform.norm.weight,
111 + EPS,
112 + ),
113 + transform.up_proj.weight,
114 + )
115 + expected.add_(_all_reduced(shared_output, group))
116 +
117 + actual = runner._shard_up_proj_tail(routed_output, shared_output, None)
118 +
119 + torch.testing.assert_close(actual, expected, atol=8e-2, rtol=3e-2)
120 +
121 +
122 +def _check_writes_only_its_own_shard(
123 + device: torch.device, tp_size: int, rank: int
124 +) -> None:
125 + """Two ranks that swapped both weight rows and write offsets still sum to
126 + the right total, so inspect each slice before the final collective."""
127 + transform = _build_transform(device)
128 + runner = _tail_runner(transform, tp_size)
129 + group = get_tp_group().device_group
130 +
131 + torch.manual_seed(rank + 1)
132 + routed_output, shared_output = _rank_partials(8, device)
133 + before = shared_output.clone()
134 + latent = transform.norm(_all_reduced(routed_output, group))
135 +
136 + captured: dict = {}
137 + real_reduce = runner._maybe_reduce_final_output
138 +
139 + def _capture(states, trunc_size, output_is_reduced=None):
140 + captured["states"] = states.clone()
141 + captured["output_is_reduced"] = output_is_reduced
142 + return real_reduce(states, trunc_size, output_is_reduced)
143 +
144 + object.__setattr__(runner, "_maybe_reduce_final_output", _capture)
145 + runner._shard_up_proj_tail(routed_output, shared_output, None)
146 + # Restoring the method breaks the runner -> _capture -> runner cycle, so the
147 + # captured device tensor is freed before the process group is torn down.
148 + object.__setattr__(runner, "_maybe_reduce_final_output", real_reduce)
149 +
150 + assert captured["output_is_reduced"] is False
151 +
152 + shard = HIDDEN_SIZE // tp_size
153 + start, end = rank * shard, (rank + 1) * shard
154 + local = captured["states"]
155 + projected = F.linear(latent, transform.up_proj.weight[start:end])
156 +
157 + torch.testing.assert_close(
158 + local[:, start:end], before[:, start:end] + projected, atol=8e-2, rtol=3e-2
159 + )
160 + torch.testing.assert_close(local[:, :start], before[:, :start], atol=0, rtol=0)
161 + torch.testing.assert_close(local[:, end:], before[:, end:], atol=0, rtol=0)
162 +
163 +
164 +_CHECKS = {
165 + "matches_replicated": _check_matches_replicated,
166 + "own_shard_only": _check_writes_only_its_own_shard,
167 +}
168 +
169 +
170 +def _worker(local_rank: int, world_size: int, port: str, check: str) -> None:
171 + device = torch.device(f"cuda:{local_rank}")
172 + torch.accelerator.set_device_index(device)
173 + with ensure_current_vllm_config():
174 + init_test_distributed_environment(
175 + world_size, 1, local_rank, port, local_rank=local_rank
176 + )
177 + _CHECKS[check](device, world_size, local_rank)
178 +
179 +
180 +def _run_ranks(check: str, tp_size: int) -> None:
181 + spawn(
182 + _worker,
183 + args=(tp_size, str(get_open_port()), check),
184 + nprocs=tp_size,
185 + join=True,
186 + )
187 +
188 +
189 +@multi_gpu_test(num_gpus=4)
190 +def test_sharded_tail_tp4_matches_replicated_projection() -> None:
191 + _run_ranks("matches_replicated", 4)
192 +
193 +
194 +@multi_gpu_test(num_gpus=8)
195 +def test_sharded_tail_tp8_matches_replicated_projection() -> None:
196 + _run_ranks("matches_replicated", 8)
197 +
198 +
199 +@multi_gpu_test(num_gpus=4)
200 +def test_sharded_tail_tp4_writes_only_its_own_shard() -> None:
201 + _run_ranks("own_shard_only", 4)
202 +
203 +
204 +def _runner(**attrs) -> ROCmLatentMoERunner:
205 + runner = object.__new__(ROCmLatentMoERunner)
206 + for name, value in attrs.items():
207 + object.__setattr__(runner, name, value)
208 + return runner
209 +
210 +
211 +@pytest.mark.parametrize(
212 + "shardable,pre_reduced,expect_sharded",
213 + [
214 + (True, False, True),
215 + (True, True, False),
216 + (False, False, False),
217 + (False, True, False),
218 + ],
219 +)
220 +def test_forward_shards_only_when_the_tail_is_valid(
221 + shardable: bool,
222 + pre_reduced: bool,
223 + expect_sharded: bool,
224 + monkeypatch: pytest.MonkeyPatch,
225 +) -> None:
226 + """The shard needs an un-reduced shared partial to accumulate into."""
227 + base_output, sharded_output = torch.zeros(1), torch.ones(1)
228 + monkeypatch.setattr(moe_runner.MoERunner, "forward", lambda *a, **k: base_output)
229 + monkeypatch.setattr(
230 + ROCmLatentMoERunner, "_fused_forward", lambda *a, **k: sharded_output
231 + )
232 + monkeypatch.setattr(
233 + ROCmLatentMoERunner, "_fused_output_is_reduced", property(lambda _: pre_reduced)
234 + )
235 + runner = _runner(_tail_shardable=shardable)
236 +
237 + result = runner.forward(torch.zeros(1), torch.zeros(1))
238 +
239 + assert result is (sharded_output if expect_sharded else base_output)
240 +
241 +
242 +@pytest.fixture
243 +def build_runner(monkeypatch: pytest.MonkeyPatch):
244 + """Construct through the real subclass ``__init__``, stubbing only the base."""
245 +
246 + def _base_init(
247 + self,
248 + *,
249 + tp_size: int = 8,
250 + hidden: int = HIDDEN_SIZE,
251 + is_sequence_parallel: bool = False,
252 + has_up_proj: bool = True,
253 + has_shared_experts: bool = True,
254 + routed_scaling_factor: float = 1.0,
255 + ) -> None:
256 + self.moe_config = SimpleNamespace(
257 + tp_size=tp_size, is_sequence_parallel=is_sequence_parallel
258 + )
259 + self.routed_output_transform = SimpleNamespace(
260 + norm=None,
261 + up_proj=SimpleNamespace(weight=torch.zeros(hidden, LATENT_SIZE))
262 + if has_up_proj
263 + else None,
264 + )
265 + self.routed_scaling_factor = routed_scaling_factor
266 + self._shared_experts = object() if has_shared_experts else None
267 +
268 + monkeypatch.setattr(moe_runner.MoERunner, "__init__", _base_init)
269 + return ROCmLatentMoERunner
270 +
271 +
272 +def test_shards_under_the_kimi_k3_serving_config(build_runner) -> None:
273 + runner = build_runner()
274 +
275 + assert runner._tail_shardable
276 + assert runner._up_proj_shard_size == HIDDEN_SIZE // 8
277 +
278 +
279 +@pytest.mark.parametrize(
280 + "override",
281 + [
282 + pytest.param({"tp_size": 1}, id="no-tp"),
283 + pytest.param({"hidden": HIDDEN_SIZE + 2}, id="hidden-not-divisible-by-tp"),
284 + pytest.param({"has_up_proj": False}, id="no-up-proj"),
285 + pytest.param({"has_shared_experts": False}, id="no-shared-partial"),
286 + pytest.param({"is_sequence_parallel": True}, id="sequence-parallel"),
287 + pytest.param({"routed_scaling_factor": 2.0}, id="routed-scaling-factor"),
288 + ],
289 +)
290 +def test_falls_back_when_the_config_breaks_the_shard(
291 + build_runner, override: dict
292 +) -> None:
293 + """Each of these makes the sharded tail wrong, not merely slower."""
294 + runner = build_runner(**override)
295 +
296 + assert not runner._tail_shardable
297 + assert runner._up_proj_shard_size == 0
vllm/models/kimi_k3/amd/latent_moe_runner.py new
+152
@@ -0,0 +1,152 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import torch
4 +
5 +from vllm.distributed import (
6 + get_tensor_model_parallel_rank,
7 + tensor_model_parallel_all_reduce,
8 +)
9 +from vllm.logger import init_logger
10 +from vllm.model_executor.layers.fused_moe.runner.moe_runner import MoERunner, _unpack
11 +
12 +logger = init_logger(__name__)
13 +
14 +
15 +class ROCmLatentMoERunner(MoERunner):
16 + """MoE runner for latent MoE with a replicated routed up-projection.
17 +
18 + Mirrors CUDA's LatentMoERunner, but currently only the up projection
19 + -sharded path is implemented. (Tier 2)
20 +
21 + Native path: the replicated up-proj produces the full hidden dim on every
22 + rank, so the base runner combines routed + shared correctly at any TP size.
23 + """
24 +
25 + def __init__(
26 + self,
27 + *args,
28 + **kwargs,
29 + ) -> None:
30 + super().__init__(*args, **kwargs)
31 +
32 + transform = self.routed_output_transform
33 + up_proj = getattr(transform, "up_proj", None)
34 + tp_size = self.moe_config.tp_size
35 +
36 + self._up_proj_shard_size = 0
37 + self._tail_shardable = (
38 + up_proj is not None
39 + and tp_size > 1
40 + and up_proj.weight.shape[0] % tp_size == 0
41 + and self._shared_experts is not None
42 + and not self.moe_config.is_sequence_parallel
43 + and self.routed_scaling_factor == 1.0
44 + )
45 + if self._tail_shardable:
46 + assert up_proj is not None
47 + self._up_proj_shard_size = up_proj.weight.shape[0] // tp_size
48 + else:
49 + logger.warning_once(
50 + "K3 latent-MoE tail is not shardable under this config, "
51 + "falling back to the replicated up-projection.",
52 + scope="global",
53 + )
54 + self._logged_sharded_tail = False
55 +
56 + def _shard_up_proj_tail(
57 + self,
58 + fused_output: torch.Tensor,
59 + shared_output: torch.Tensor,
60 + trunc_size: int | None,
61 + ) -> torch.Tensor:
62 + """
63 + Tier 2: column-parallel up-projection folded into the final reduce.
64 + """
65 + if not self._logged_sharded_tail:
66 + self._logged_sharded_tail = True
67 + logger.info_once(
68 + "Kimi-K3 latent-MoE tail: up-projecting only this rank's "
69 + "hidden shard into the shared output.",
70 + scope="global",
71 + )
72 +
73 + transform = self.routed_output_transform
74 + assert transform is not None
75 +
76 + latent = tensor_model_parallel_all_reduce(fused_output)
77 + if transform.norm is not None:
78 + latent = transform.norm(latent)
79 +
80 + shard_size = self._up_proj_shard_size
81 + shard_start = get_tensor_model_parallel_rank() * shard_size
82 + up_proj_shard = transform.up_proj.weight.narrow(0, shard_start, shard_size)
83 + hidden_shard = shared_output.narrow(-1, shard_start, shard_size)
84 +
85 + # hidden_shard += latent @ up_proj_shard.T, accumulated in the GEMM's
86 + # beta-add epilogue so folding in the shared partial costs no kernel.
87 + hidden_shard.addmm_(latent, up_proj_shard.t())
88 +
89 + return self._maybe_reduce_final_output(
90 + shared_output, trunc_size, output_is_reduced=False
91 + )
92 +
93 + def forward(
94 + self,
95 + hidden_states: torch.Tensor,
96 + router_logits: torch.Tensor,
97 + input_ids: torch.Tensor | None = None,
98 + shared_experts_input: torch.Tensor | None = None,
99 + ) -> torch.Tensor:
100 + if self._tail_shardable and not self._fused_output_is_reduced:
101 + return self._fused_forward(
102 + hidden_states, router_logits, input_ids, shared_experts_input
103 + )
104 + return super().forward(
105 + hidden_states, router_logits, input_ids, shared_experts_input
106 + )
107 +
108 + def _fused_forward(
109 + self,
110 + hidden_states: torch.Tensor,
111 + router_logits: torch.Tensor,
112 + input_ids: torch.Tensor | None,
113 + shared_experts_input: torch.Tensor | None,
114 + ) -> torch.Tensor:
115 + # When the caller pre-applies the routed input transform outside the
116 + # runner (e.g. to overlap it on a separate stream), it passes the
117 + # already-transformed routed input as ``hidden_states`` and the original
118 + # hidden states as ``shared_experts_input``; skip the transform then.
119 + if shared_experts_input is None:
120 + hidden_states, shared_experts_input = self.apply_routed_input_transform(
121 + hidden_states
122 + )
123 +
124 + hidden_states, og_hidden_dim_pre_xform, og_hidden_dim_post_xform = (
125 + self._maybe_pad_hidden_states(
126 + shared_experts_input,
127 + hidden_states,
128 + )
129 + )
130 +
131 + result = self._forward_entry(
132 + hidden_states,
133 + router_logits,
134 + shared_experts_input,
135 + input_ids,
136 + self._encode_layer_name(),
137 + self.moe_config.hidden_dim_unpadded
138 + if self._quant_method.has_unpadded_output
139 + else 0,
140 + )
141 +
142 + shared_output, fused_output = _unpack(result)
143 + assert shared_output is not None
144 +
145 + if og_hidden_dim_pre_xform is not None:
146 + fused_output = fused_output[..., :og_hidden_dim_pre_xform]
147 +
148 + result = self._shard_up_proj_tail(
149 + fused_output, shared_output, og_hidden_dim_post_xform
150 + )
151 +
152 + return self._maybe_add_zero_expert_output(result)
vllm/models/kimi_k3/amd/linear.py
+2
@@ -62,6 +62,7 @@ from vllm.model_executor.models.utils import (
62 maybe_prefix,
63 )
64 from vllm.models.kimi_k3.amd.kda import KimiK3DeltaAttention
65 +from vllm.models.kimi_k3.amd.latent_moe_runner import ROCmLatentMoERunner
66 from vllm.models.kimi_k3.amd.ops.attn_res import attn_res
67 from vllm.sequence import IntermediateTensors
68 from vllm.transformers_utils.configs.kimi_linear import KimiLinearConfig
@@ -289,6 +290,7 @@ class KimiMoE(nn.Module):
290 routed_scaling_factor=self.routed_scaling_factor,
291 routed_input_transform=self.routed_expert_down_proj,
292 routed_output_transform=self.routed_output_transform,
293 + runner_cls=ROCmLatentMoERunner if self.use_latent_moe else None,
294 )
295 if self.padded_moe_intermediate_size != moe_intermediate_size:
296 w13_weight = getattr(self.experts, "w13_weight", None)