[Kimi-K3] Add option to shard the shared expert instead of replicating (#50656)
Signed-off-by: Tyler Michael Smith <tlrmchlsmth@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jee Jee Li <pandaleefree@gmail.com>
Tyler Michael Smith committed
Aug 3, 2026 at 11:41 UTC
5df9999fcfaa72d9eb61348a789058fff805f142
3 files changed
+154
-3
tests/models/kimi_k3/test_sequence_parallel.py
+79
@@ -10,6 +10,7 @@ import torch
10
from torch import nn
11
12
from vllm.config import ParallelConfig
13
+from vllm.model_executor.layers.activation import SiluAndMul
14
from vllm.models.common.ops import sequence_parallel as sp_ops
15
from vllm.models.kimi_k3.nvidia import model as kimi_model
16
from vllm.models.kimi_k3.nvidia import mtp as kimi_mtp
@@ -265,6 +266,84 @@ def test_kimi_mtp_restores_sequence_parallel_output(monkeypatch):
266
torch.testing.assert_close(final_norm.call_args.args[0], expected_hidden_states)
267
268
269
+@pytest.mark.parametrize(
270
+ ("enabled", "use_sequence_parallel", "eligible", "tp_size", "expected"),
271
+ [
272
+ (True, True, True, 8, True),
273
+ (False, True, True, 8, False), # opt-in only
274
+ (True, False, True, 8, False), # replication only exists under SP
275
+ (True, True, False, 8, False), # FusedMoE path owns the reduction
276
+ (True, True, True, 1, False), # nothing to shard
277
+ (True, True, True, 5, False), # 6144 % 5 -- would fail divide()
278
+ ],
279
+)
280
+def test_shard_sequence_parallel_mlp_gating(
281
+ monkeypatch,
282
+ enabled: bool,
283
+ use_sequence_parallel: bool,
284
+ eligible: bool,
285
+ tp_size: int,
286
+ expected: bool,
287
+):
288
+ monkeypatch.setattr(kimi_model.envs, "VLLM_KIMI_K3_SHARD_SP_SHARED_EXPERT", enabled)
289
+ monkeypatch.setattr(
290
+ kimi_model, "get_tensor_model_parallel_world_size", lambda: tp_size
291
+ )
292
+
293
+ assert (
294
+ kimi_model.shard_sequence_parallel_mlp(
295
+ hidden_size=7168,
296
+ intermediate_size=6144,
297
+ use_sequence_parallel=use_sequence_parallel,
298
+ eligible=eligible,
299
+ )
300
+ is expected
301
+ )
302
+
303
+
304
+def test_sharded_sequence_parallel_mlp_matches_replicated(default_vllm_config):
305
+ """Sharded SP MLP must reproduce the replicated result for every token.
306
+
307
+ Each rank owns a *disjoint* token shard, so a weight shard alone cannot
308
+ finish a rank's own tokens: the ranks must gather the full token set,
309
+ compute partial sums over their intermediate shard, and reduce-scatter.
310
+ Splicing per-rank feature slices together instead silently mixes different
311
+ tokens and produces plausible-looking garbage.
312
+ """
313
+ tp_size, hidden, intermediate, tokens_per_rank = 4, 16, 12, 3
314
+ torch.manual_seed(0)
315
+ num_tokens = tp_size * tokens_per_rank
316
+ x = torch.randn(num_tokens, hidden)
317
+ gate_weight = torch.randn(intermediate, hidden)
318
+ up_weight = torch.randn(intermediate, hidden)
319
+ down_weight = torch.randn(hidden, intermediate)
320
+ act_fn = SiluAndMul()
321
+
322
+ replicated = act_fn(x @ torch.cat([gate_weight, up_weight]).T) @ down_weight.T
323
+
324
+ shard = intermediate // tp_size
325
+ # Every rank all-gathers the full token set, then computes its partial.
326
+ partials = [
327
+ act_fn(
328
+ x
329
+ @ torch.cat(
330
+ [
331
+ gate_weight[r * shard : (r + 1) * shard],
332
+ up_weight[r * shard : (r + 1) * shard],
333
+ ]
334
+ ).T
335
+ )
336
+ @ down_weight[:, r * shard : (r + 1) * shard].T
337
+ for r in range(tp_size)
338
+ ]
339
+ reduced = torch.stack(partials).sum(0)
340
+ # Reduce-scatter: rank r keeps only its own token shard.
341
+ for r in range(tp_size):
342
+ mine = reduced[r * tokens_per_rank : (r + 1) * tokens_per_rank]
343
+ expected = replicated[r * tokens_per_rank : (r + 1) * tokens_per_rank]
344
+ torch.testing.assert_close(mine, expected, atol=1e-5, rtol=1e-5)
345
+
346
+
347
def test_sp_all_gather_uses_custom_kernel(monkeypatch):
348
hidden_states = torch.arange(4, dtype=torch.float32).view(2, 2)
349
expected = torch.cat([hidden_states, hidden_states])
vllm/envs.py
+13
@@ -195,6 +195,7 @@ if TYPE_CHECKING:
195
] = "relax"
196
VLLM_USE_FUSED_MOE_GROUPED_TOPK: bool = True
197
VLLM_MOE_SKIP_PADDING: bool = True
198
+ VLLM_KIMI_K3_SHARD_SP_SHARED_EXPERT: bool = False
199
VLLM_BLOCKSCALE_FP8_GEMM_FLASHINFER: bool = True
200
VLLM_USE_FLASHINFER_MOE_INT4: bool = False
201
VLLM_FLASHINFER_AUTOTUNE_CACHE_DIR: str | None = None
@@ -1524,6 +1525,18 @@ environment_variables: dict[str, Callable[[], Any]] = {
1525
# ids to -1 so the dispatch and experts drop them. Requires a MoE kernel that
1526
# treats topk_id == -1 as a skip sentinel
1527
"VLLM_MOE_SKIP_PADDING": lambda: bool(int(os.getenv("VLLM_MOE_SKIP_PADDING", "1"))),
1528
+ # Kimi-K3 only. Under sequence-parallel MoE the dense and shared-expert MLPs
1529
+ # are replicated on every rank, so each rank streams the whole weight to
1530
+ # serve its own token shard. Shard them across TP instead: the MLP then
1531
+ # all-gathers the full token set, computes this rank's partial, and
1532
+ # reduce-scatters (which both sums across TP and restores the sequence
1533
+ # sharding). Trades weight bandwidth and resident memory for two collectives
1534
+ # per layer, so it only wins at low token counts: intended for decode
1535
+ # instances in a P/D disaggregated deployment, not for prefill or unified
1536
+ # serving.
1537
+ "VLLM_KIMI_K3_SHARD_SP_SHARED_EXPERT": lambda: bool(
1538
+ int(os.getenv("VLLM_KIMI_K3_SHARD_SP_SHARED_EXPERT", "0"))
1539
+ ),
1540
# Allow use of FlashInfer FP8 block-scale GEMM for linear layers.
1541
# This uses TensorRT-LLM kernels and requires SM90+ (Hopper).
1542
"VLLM_BLOCKSCALE_FP8_GEMM_FLASHINFER": lambda: bool(
vllm/models/kimi_k3/nvidia/model.py
+62
-3
@@ -129,7 +129,42 @@ logger = init_logger(__name__)
129
_ROUTED_DOWN_PROJ_STREAM_TOKEN_THRESHOLD = 256
130
131
132
+def shard_sequence_parallel_mlp(
133
+ hidden_size: int,
134
+ intermediate_size: int,
135
+ use_sequence_parallel: bool,
136
+ eligible: bool,
137
+) -> bool:
138
+ """Whether to TP-shard a sequence-parallel MLP instead of replicating it.
139
+
140
+ Opt-in via ``VLLM_KIMI_K3_SHARD_SP_SHARED_EXPERT``; see :class:`KimiMLP` for
141
+ the trade-off and :mod:`vllm.envs` for when it is worth enabling.
142
+ """
143
+ enabled = envs.VLLM_KIMI_K3_SHARD_SP_SHARED_EXPERT
144
+ if not (use_sequence_parallel and eligible and enabled):
145
+ return False
146
+ tp_size = get_tensor_model_parallel_world_size()
147
+ return (
148
+ tp_size > 1 and intermediate_size % tp_size == 0 and hidden_size % tp_size == 0
149
+ )
150
+
151
+
152
class KimiMLP(nn.Module):
153
+ """Dense / shared-expert MLP, optionally TP-sharded under sequence parallel.
154
+
155
+ Under sequence parallelism each rank owns a distinct slice of the tokens, so
156
+ by default both projections are replicated (``disable_tp``) and the block
157
+ needs no collective. That makes every rank stream the entire weight to serve
158
+ its own token shard.
159
+
160
+ With ``VLLM_KIMI_K3_SHARD_SP_SHARED_EXPERT`` the weights are TP-sharded
161
+ instead. A rank then holds only a slice of the intermediate dim, so it
162
+ cannot finish its own tokens alone: ``forward`` all-gathers the full token
163
+ set, computes this rank's partial, and reduce-scatters. The reduce-scatter
164
+ sums across TP and restores the sequence sharding in one collective, so the
165
+ block still ends with one collective per direction.
166
+ """
167
+
168
def __init__(
169
self,
170
hidden_size: int,
@@ -138,18 +173,27 @@ class KimiMLP(nn.Module):
173
quant_config: QuantizationConfig | None = None,
174
reduce_results: bool = True,
175
use_sequence_parallel: bool = False,
176
+ can_shard_sequence_parallel: bool = False,
177
prefix: str = "",
178
activation_situ_beta: float | None = None,
179
activation_situ_linear_beta: float | None = None,
180
) -> None:
181
super().__init__()
182
183
+ self.shard_sequence_parallel = shard_sequence_parallel_mlp(
184
+ hidden_size,
185
+ intermediate_size,
186
+ use_sequence_parallel,
187
+ can_shard_sequence_parallel,
188
+ )
189
+ replicate = use_sequence_parallel and not self.shard_sequence_parallel
190
+
191
self.gate_up_proj = MergedColumnParallelLinear(
192
hidden_size,
193
[intermediate_size] * 2,
194
bias=False,
195
quant_config=quant_config,
152
- disable_tp=use_sequence_parallel,
196
+ disable_tp=replicate,
197
prefix=f"{prefix}.gate_up_proj",
198
)
199
self.down_proj = RowParallelLinear(
@@ -157,8 +201,10 @@ class KimiMLP(nn.Module):
201
hidden_size,
202
bias=False,
203
quant_config=quant_config,
160
- reduce_results=reduce_results,
161
- disable_tp=use_sequence_parallel,
204
+ # Sharded sequence parallel reduces via the reduce-scatter in
205
+ # forward(), which also restores the sequence sharding.
206
+ reduce_results=False if self.shard_sequence_parallel else reduce_results,
207
+ disable_tp=replicate,
208
prefix=f"{prefix}.down_proj",
209
)
210
if hidden_act == "silu":
@@ -175,9 +221,17 @@ class KimiMLP(nn.Module):
221
)
222
223
def forward(self, x):
224
+ if self.shard_sequence_parallel:
225
+ # Each rank holds a weight shard but only its own tokens, so it
226
+ # cannot finish those tokens alone: gather the full token set,
227
+ # compute this rank's partial for all of them, then reduce-scatter,
228
+ # which sums across TP and restores the sequence sharding.
229
+ x = sp_all_gather(x)
230
gate_up, _ = self.gate_up_proj(x)
231
x = self.act_fn(gate_up)
232
x, _ = self.down_proj(x)
233
+ if self.shard_sequence_parallel:
234
+ x = sp_reduce_scatter(x)
235
return x
236
237
@@ -478,6 +532,10 @@ class KimiMoE(nn.Module):
532
quant_config=quant_config,
533
reduce_results=False,
534
use_sequence_parallel=use_sequence_parallel,
535
+ # Only the MegaMoE path calls the shared experts directly; the
536
+ # FusedMoE path below hands them to the runner, which fuses
537
+ # their reduction and assumes the replicated layout.
538
+ can_shard_sequence_parallel=self.use_mega_moe,
539
prefix=f"{prefix}.shared_experts",
540
activation_situ_beta=activation_situ_beta,
541
activation_situ_linear_beta=activation_situ_linear_beta,
@@ -788,6 +846,7 @@ class KimiDecoderLayer(nn.Module):
846
quant_config=quant_config,
847
prefix=f"{prefix}.mlp",
848
use_sequence_parallel=self.use_sequence_parallel,
849
+ can_shard_sequence_parallel=True,
850
activation_situ_beta=config.activation_situ_beta,
851
activation_situ_linear_beta=config.activation_situ_linear_beta,
852
)