[Kimi K3 Perf] Avoid KDA mixed-batch gather/scatter, 5.2%~7.7% E2E Throughput Improvement (#56159)
Signed-off-by: yewentao256 <zhyanwentao@126.com>
Wentao Ye committed
Sep 10, 2026 at 05:57 UTC
86aca6619161b308d675c753f228201e70b59ed7
6 files changed
+66
-5
tests/models/kimi_k3/test_kda.py
+7
@@ -330,6 +330,7 @@ def test_chunk_kda_fused_gate_cumsum_matches_unfused(
330
cu_seqlens=cu_seqlens_t,
331
use_qk_l2norm_in_kernel=True,
332
)
333
+ output = torch.empty_like(v)
334
new_o, new_ht = chunk_kda_with_fused_gate(
335
q=q.clone(),
336
k=k.clone(),
@@ -343,8 +344,10 @@ def test_chunk_kda_fused_gate_cumsum_matches_unfused(
344
output_final_state=True,
345
cu_seqlens=cu_seqlens_t,
346
use_qk_l2norm_in_kernel=True,
347
+ out=output,
348
)
349
350
+ assert new_o.data_ptr() == output.data_ptr()
351
assert_close("o", old_o, new_o, 1e-3, err_atol=1e-3)
352
assert_close("ht", old_ht, new_ht, 1e-3, err_atol=1e-3)
353
@@ -442,6 +445,7 @@ def test_packed_kda_decode_correctness(
445
use_qk_l2norm_in_kernel=True,
446
)
447
packed_state = state
448
+ packed_output = torch.empty_like(dense_out)
449
packed_out, _ = PACKED_DECODE_IMPLS[impl](
450
mixed_qkv=mixed_qkv,
451
raw_g=raw_g,
@@ -451,8 +455,11 @@ def test_packed_kda_decode_correctness(
455
lower_bound=lower_bound,
456
initial_state=packed_state,
457
state_indices=state_indices,
458
+ **({"out": packed_output} if impl == "nvidia" else {}),
459
)
460
461
+ if impl == "nvidia":
462
+ assert packed_out.data_ptr() == packed_output.data_ptr()
463
assert_close("o", dense_out, packed_out, 1e-3, err_atol=1e-3)
464
assert_close("ht", dense_state, packed_state, 1e-3, err_atol=1e-3)
465
tests/models/kimi_k3/test_kda_metadata.py
+4
@@ -512,6 +512,8 @@ def test_mixed_regular_and_spec_decode_uses_packed_decode_metadata():
512
assert actual.non_spec_query_start_loc is None
513
torch.testing.assert_close(actual.non_spec_token_indx, torch.tensor([0, 1]))
514
torch.testing.assert_close(actual.spec_token_indx, torch.tensor([2, 3, 4]))
515
+ assert actual.non_spec_token_start == 0
516
+ assert actual.spec_token_start == 2
517
torch.testing.assert_close(
518
actual.spec_query_start_loc,
519
torch.tensor([0, 3], dtype=torch.int32),
@@ -539,6 +541,8 @@ def test_mixed_regular_and_spec_decode_excludes_request_padding():
541
assert actual.non_spec_state_indices_tensor.shape == (1,)
542
torch.testing.assert_close(actual.non_spec_token_indx, torch.tensor([0]))
543
torch.testing.assert_close(actual.spec_token_indx, torch.tensor([1, 2, 3]))
544
+ assert actual.non_spec_token_start == 0
545
+ assert actual.spec_token_start == 1
546
547
548
@pytest.mark.parametrize("mamba_cache_mode", ["none", "align"])
vllm/models/kimi_k3/nvidia/kda.py
+33
-3
@@ -903,6 +903,8 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
903
non_spec_state_indices_tensor = m.non_spec_state_indices_tensor
904
spec_token_indx = m.spec_token_indx
905
non_spec_token_indx = m.non_spec_token_indx
906
+ spec_token_start = m.spec_token_start
907
+ non_spec_token_start = m.non_spec_token_start
908
spec_state_indices_tensor = m.spec_state_indices_tensor
909
spec_query_start_loc = m.spec_query_start_loc
910
num_accepted_tokens = m.num_accepted_tokens
@@ -981,6 +983,18 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
983
mixed_qkv_spec = mixed_qkv
984
g1_spec, beta_spec = g1, beta
985
mixed_qkv_ns = g1_ns = beta_ns = None
986
+ elif spec_token_start is not None:
987
+ assert non_spec_token_start is not None
988
+ spec_end = spec_token_start + m.num_spec_decode_tokens
989
+ non_spec_end = (
990
+ non_spec_token_start + m.num_prefill_tokens + m.num_decode_tokens
991
+ )
992
+ spec_slice = slice(spec_token_start, spec_end)
993
+ non_spec_slice = slice(non_spec_token_start, non_spec_end)
994
+ mixed_qkv_spec = mixed_qkv[spec_slice]
995
+ g1_spec, beta_spec = g1[:, spec_slice], beta[:, spec_slice]
996
+ mixed_qkv_ns = mixed_qkv[non_spec_slice]
997
+ g1_ns, beta_ns = g1[:, non_spec_slice], beta[:, non_spec_slice]
998
else:
999
assert spec_token_indx is not None
1000
assert non_spec_token_indx is not None
@@ -1029,6 +1043,8 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
1043
if m.num_prefills == 0 and m.num_decodes == 0
1044
else None
1045
)
1046
+ if spec_token_start is not None:
1047
+ spec_out = core_attn_out[:, spec_slice]
1048
if self.use_recoverssm:
1049
from vllm.models.kimi_k3.nvidia.ops.recoverssm import (
1050
kda_recoverssm_verify,
@@ -1076,6 +1092,9 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
1092
core_attn_out_non_spec = None
1093
if mixed_qkv_ns is not None:
1094
assert g1_ns is not None and beta_ns is not None
1095
+ non_spec_out = None
1096
+ if non_spec_token_start is not None:
1097
+ non_spec_out = core_attn_out[:, non_spec_slice]
1098
if m.num_prefills > 0:
1099
q_ns, k_ns, v_ns = mixed_qkv_ns.split(
1100
self.local_projection_size, dim=-1
@@ -1126,6 +1145,8 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
1145
flashkda_out = (
1146
workspace_out if has_spec_decode else core_attn_out
1147
)[:, : q_ns.shape[1]]
1148
+ if non_spec_out is not None:
1149
+ flashkda_out = non_spec_out
1150
if checkpoint is not None:
1151
assert non_spec_query_start_loc is not None
1152
num_sequences = initial_state.shape[0]
@@ -1211,7 +1232,9 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
1232
if q_ns.shape[1] > initial_state.shape[0]:
1233
assert m.flashinfer_prefill_seq_order is not None
1234
flashinfer_out = core_attn_out[:, : q_ns.shape[1]]
1214
- if has_spec_decode:
1235
+ if non_spec_out is not None:
1236
+ flashinfer_out = non_spec_out
1237
+ elif has_spec_decode:
1238
assert self._flashinfer_kda_output_spec is not None
1239
(workspace_out,) = current_workspace_manager().get_simultaneous(
1240
self._flashinfer_kda_output_spec
@@ -1251,6 +1274,7 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
1274
output_final_state=True,
1275
use_qk_l2norm_in_kernel=True,
1276
cu_seqlens=non_spec_query_start_loc,
1277
+ out=non_spec_out,
1278
)
1279
recurrent_state[non_spec_state_indices_tensor] = (
1280
last_recurrent_state.to(recurrent_state.dtype)
@@ -1284,12 +1308,18 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
1308
lower_bound=self.gate_lower_bound,
1309
initial_state=recurrent_state,
1310
state_indices=decode_conv_indices,
1311
+ out=non_spec_out,
1312
)
1313
1314
# Restore the scheduler's original token order for mixed batches.
1315
if core_attn_out_spec is not None and core_attn_out_non_spec is not None:
1291
- core_attn_out.index_copy_(1, spec_token_indx, core_attn_out_spec)
1292
- core_attn_out.index_copy_(1, non_spec_token_indx, core_attn_out_non_spec)
1316
+ if spec_token_start is None:
1317
+ assert spec_token_indx is not None
1318
+ assert non_spec_token_indx is not None
1319
+ core_attn_out.index_copy_(1, spec_token_indx, core_attn_out_spec)
1320
+ core_attn_out.index_copy_(
1321
+ 1, non_spec_token_indx, core_attn_out_non_spec
1322
+ )
1323
elif core_attn_out_non_spec is not None:
1324
if (
1325
self.kda_prefill_backend not in ("flashkda", "flashinfer")
vllm/models/kimi_k3/nvidia/kda_metadata.py
+13
@@ -270,6 +270,8 @@ class KDACheckpointMetadata:
270
271
@dataclass
272
class KimiK3KDAMetadata(GDNAttentionMetadata, RecoverSSMMetadata):
273
+ spec_token_start: int | None = None
274
+ non_spec_token_start: int | None = None
275
flashinfer_prefill_query_start_loc: torch.Tensor | None = None
276
flashinfer_prefill_seq_order: torch.Tensor | None = None
277
recoverssm_commit: KDARecoverSSMCommitMetadata | None = None
@@ -422,6 +424,8 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
424
spec_sequence_masks_cpu = None
425
426
spec_request_indices = None
427
+ spec_token_start = None
428
+ non_spec_token_start = None
429
if num_spec_decodes == 0:
430
# The runner orders ordinary decodes before prefills.
431
num_decodes, num_prefills, num_decode_tokens, num_prefill_tokens = (
@@ -518,6 +522,13 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
522
non_spec_token_indx = index[:num_non_spec_tokens]
523
spec_token_indx = index[num_non_spec_tokens:]
524
525
+ active_spec_mask = spec_sequence_masks_cpu[query_lens_cpu > 0]
526
+ # check if spec / non spec tokens are continuous
527
+ if (active_spec_mask[1:] != active_spec_mask[:-1]).sum().item() == 1:
528
+ spec_first = active_spec_mask[0].item()
529
+ spec_token_start = 0 if spec_first else num_non_spec_tokens
530
+ non_spec_token_start = num_spec_decode_tokens if spec_first else 0
531
+
532
# Native spec uses one state slot per step. RecoverSSM keeps
533
# only the current checkpoint slot.
534
spec_state_indices_tensor = block_table_tensor[
@@ -759,6 +770,8 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
770
spec_token_indx=spec_token_indx,
771
non_spec_token_indx=non_spec_token_indx,
772
num_accepted_tokens=num_accepted_tokens,
773
+ spec_token_start=spec_token_start,
774
+ non_spec_token_start=non_spec_token_start,
775
flashinfer_prefill_query_start_loc=flashinfer_prefill_query_start_loc,
776
flashinfer_prefill_seq_order=flashinfer_prefill_seq_order,
777
recoverssm_commit=recoverssm_commit,
vllm/models/kimi_k3/nvidia/ops/third_party/kda/chunk.py
+6
-1
@@ -598,6 +598,7 @@ def _chunk_kda_fwd_with_cumulative_g(
598
chunk_indices: torch.Tensor | None = None,
599
chunk_size: int = FLA_CHUNK_SIZE,
600
safe_gate: bool = False,
601
+ out: torch.Tensor | None = None,
602
):
603
# `g` must already be chunk-local cumulatively-summed AND scaled by
604
# RCP_LN2 (so the downstream exp2-based kernels reproduce exp(g)).
@@ -642,7 +643,7 @@ def _chunk_kda_fwd_with_cumulative_g(
643
g=g,
644
A=Aqk,
645
h=h,
645
- o=v,
646
+ o=v if out is None else out,
647
scale=scale,
648
cu_seqlens=cu_seqlens,
649
chunk_indices=chunk_indices,
@@ -706,6 +707,7 @@ def chunk_kda_with_fused_gate_fwd(
707
output_final_state: bool,
708
lower_bound: float | None = None,
709
cu_seqlens: torch.Tensor | None = None,
710
+ out: torch.Tensor | None = None,
711
):
712
chunk_size = FLA_CHUNK_SIZE
713
chunk_indices = (
@@ -736,6 +738,7 @@ def chunk_kda_with_fused_gate_fwd(
738
chunk_indices=chunk_indices,
739
chunk_size=chunk_size,
740
safe_gate=lower_bound is not None,
741
+ out=out,
742
)
743
744
@@ -787,6 +790,7 @@ def chunk_kda_with_fused_gate(
790
lower_bound: float | None = None,
791
use_qk_l2norm_in_kernel: bool = False,
792
cu_seqlens: torch.Tensor | None = None,
793
+ out: torch.Tensor | None = None,
794
**kwargs,
795
):
796
"""Run chunk KDA from raw gate and beta projections."""
@@ -810,6 +814,7 @@ def chunk_kda_with_fused_gate(
814
output_final_state=output_final_state,
815
lower_bound=lower_bound,
816
cu_seqlens=cu_seqlens,
817
+ out=out,
818
)
819
return o, final_state
820
vllm/models/kimi_k3/nvidia/ops/third_party/kda/fused_recurrent.py
+3
-1
@@ -591,6 +591,7 @@ def fused_recurrent_kda_packed_decode(
591
initial_state: torch.Tensor,
592
state_indices: torch.Tensor,
593
scale: float | None = None,
594
+ out: torch.Tensor | None = None,
595
) -> tuple[torch.Tensor, torch.Tensor]:
596
"""Run one-token KDA decode directly from packed post-conv QKV."""
597
if mixed_qkv.ndim != 2 or mixed_qkv.stride(-1) != 1:
@@ -639,7 +640,8 @@ def fused_recurrent_kda_packed_decode(
640
if scale is None:
641
scale = K**-0.5
642
642
- out = torch.empty((1, B, H, V), dtype=mixed_qkv.dtype, device=device)
643
+ if out is None:
644
+ out = torch.empty((1, B, H, V), dtype=mixed_qkv.dtype, device=device)
645
grid = (cdiv(V, BV), B * H)
646
fused_recurrent_kda_packed_decode_kernel[grid](
647
mixed_qkv=mixed_qkv,