[Perf] Support internal prefill checkpoints for Mamba prefix caching, 9%~25% TTFT improvement (#52789)
Signed-off-by: yewentao256 <zhyanwentao@126.com> Signed-off-by: Ziming Huang <zelda.huanghuang@gmail.com> Signed-off-by: Wentao Ye <44945378+yewentao256@users.noreply.github.com> Co-authored-by: Ziming Huang <zelda.huanghuang@gmail.com> Co-authored-by: OpenAI Codex <codex@openai.com>
Wentao Ye committed
Aug 22, 2026 at 13:32 UTC
9eb9d9d3953959695108600c8ed33d36bc6a1e5f
12 files changed
+562
-48
cmake/external_projects/flashkda.cmake
+1
-1
@@ -13,7 +13,7 @@ else()
13
FetchContent_Declare(
14
flashkda
15
GIT_REPOSITORY https://github.com/vllm-project/FlashKDA.git
16
- GIT_TAG 053de1b716ef3255873e02d2d28f4adf09951978
16
+ GIT_TAG ee0be888cd0e972f9409bf53756f8c38c6652173
17
GIT_PROGRESS TRUE
18
GIT_SUBMODULES cutlass
19
)
csrc/flashkda_registration.cpp
+2
-1
@@ -10,7 +10,8 @@ STABLE_TORCH_LIBRARY(_flashkda_C, m) {
10
"Tensor(a!) out, Tensor(c!) workspace, Tensor A_log, Tensor dt_bias, "
11
"float lower_bound, "
12
"Tensor? initial_state=None, Tensor(b!)? final_state=None, "
13
- "Tensor? cu_seqlens=None) -> ()");
13
+ "Tensor? cu_seqlens=None, Tensor(d!)? checkpoint_state=None, "
14
+ "Tensor? checkpoint_offsets=None) -> ()");
15
}
16
17
STABLE_TORCH_LIBRARY_IMPL(_flashkda_C, CompositeExplicitAutograd, m) {
tests/models/kimi_k3/test_kda.py
+81
@@ -21,6 +21,8 @@ from vllm.models.kimi_k3.amd.ops.third_party.kda import (
21
fused_recurrent_kda_packed_decode as fused_recurrent_kda_packed_decode_amd,
22
)
23
from vllm.models.kimi_k3.nvidia.kda import (
24
+ _flashkda_prefill,
25
+ _store_cache_checkpoints_kernel,
26
is_flashkda_supported,
27
is_fused_kda_decode_supported,
28
)
@@ -40,6 +42,7 @@ from vllm.models.kimi_k3.nvidia.ops.third_party.kda import (
42
)
43
from vllm.platforms import current_platform
44
from vllm.third_party.flash_linear_attention.ops.l2norm import l2norm_fwd
45
+from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
46
47
DEVICE = current_platform.device_type
48
@@ -1088,6 +1091,17 @@ def test_flashkda_correctness():
1091
expected_states.append(final_state)
1092
expected_out = torch.cat(expected_outputs, dim=1)
1093
expected_state = torch.cat(expected_states).transpose(-1, -2).contiguous()
1094
+ _, expected_checkpoint = naive_recurrent_kda(
1095
+ q_norm[:, :16],
1096
+ k_norm[:, :16],
1097
+ v[:, :16],
1098
+ gate[:, :16],
1099
+ beta[:, :16],
1100
+ initial_state=initial_state[0:1].transpose(-1, -2),
1101
+ output_final_state=True,
1102
+ )
1103
+ assert expected_checkpoint is not None
1104
+ expected_checkpoint = expected_checkpoint.transpose(-1, -2).contiguous()
1105
1106
actual_out = torch.empty_like(v)
1107
actual_state = torch.empty_like(initial_state)
@@ -1115,3 +1129,70 @@ def test_flashkda_correctness():
1129
1130
assert_close("o", expected_out, actual_out, 0.01)
1131
assert_close("ht", expected_state, actual_state, 0.01)
1132
+
1133
+ checkpoint_out = torch.empty_like(v)
1134
+ checkpoint_final_state = torch.empty_like(initial_state)
1135
+ checkpoint_state = torch.empty_like(initial_state)
1136
+ checkpoint_offsets = torch.tensor([16, 31], dtype=torch.int32, device=DEVICE)
1137
+ _flashkda_prefill(
1138
+ q=q,
1139
+ k=k,
1140
+ v=v,
1141
+ g=raw_g,
1142
+ beta=beta_logits,
1143
+ A_log=A_log,
1144
+ dt_bias=dt_bias,
1145
+ lower_bound=lower_bound,
1146
+ initial_state=initial_state,
1147
+ cu_seqlens=cu_seqlens,
1148
+ out=checkpoint_out,
1149
+ final_state=checkpoint_final_state,
1150
+ workspace=workspace,
1151
+ checkpoint_state=checkpoint_state,
1152
+ checkpoint_offsets=checkpoint_offsets,
1153
+ )
1154
+
1155
+ assert_close("checkpoint_o", expected_out, checkpoint_out, 0.01)
1156
+ assert_close("checkpoint_ht", expected_state, checkpoint_final_state, 0.01)
1157
+ assert_close("checkpoint", expected_checkpoint, checkpoint_state[:1], 0.01)
1158
+
1159
+ conv_state = torch.zeros(2, H * D, 3, dtype=q.dtype, device=DEVICE)
1160
+ recurrent_storage = torch.zeros(2, H * D * D + 8, device=DEVICE)
1161
+ recurrent_state = recurrent_storage[:, : H * D * D].view(2, H, D, D)
1162
+ conv_input = q[0].flatten(1)
1163
+ checkpoint_state_indices = torch.tensor(
1164
+ [1, NULL_BLOCK_ID], dtype=torch.int32, device=DEVICE
1165
+ )
1166
+ state_len = conv_state.shape[-1]
1167
+ width = H * D
1168
+ recurrent_row_size = checkpoint_state[0].numel()
1169
+ block_size = 256
1170
+ _store_cache_checkpoints_kernel[
1171
+ (
1172
+ checkpoint_state_indices.numel(),
1173
+ (max(width * state_len, recurrent_row_size) + block_size - 1) // block_size,
1174
+ )
1175
+ ](
1176
+ conv_input,
1177
+ conv_state,
1178
+ checkpoint_state,
1179
+ recurrent_state,
1180
+ cu_seqlens,
1181
+ checkpoint_offsets,
1182
+ checkpoint_state_indices,
1183
+ conv_input.stride(0),
1184
+ conv_input.stride(1),
1185
+ conv_state.stride(0),
1186
+ conv_state.stride(1),
1187
+ conv_state.stride(2),
1188
+ checkpoint_state.stride(0),
1189
+ recurrent_state.stride(0),
1190
+ checkpoint_offsets.stride(0),
1191
+ state_len,
1192
+ width,
1193
+ recurrent_row_size,
1194
+ NULL_BLOCK_ID,
1195
+ block_size,
1196
+ )
1197
+ torch.testing.assert_close(conv_state[1], q[0, 13:16].flatten(1).transpose(0, 1))
1198
+ torch.testing.assert_close(recurrent_state[1], checkpoint_state[0])
tests/models/kimi_k3/test_kda_metadata.py
+50
@@ -87,6 +87,7 @@ def _make_builder(
87
device: torch.device = DEVICE,
88
mamba_cache_mode: str = "none",
89
use_recoverssm: bool = False,
90
+ num_prefill_checkpoint_blocks: int = 0,
91
) -> AttentionMetadataBuilder:
92
vllm_config = create_vllm_config(
93
model_name="Qwen/Qwen3.5-0.8B",
@@ -110,6 +111,7 @@ def _make_builder(
111
dtypes=(torch.float16,),
112
mamba_cache_mode=mamba_cache_mode,
113
num_speculative_blocks=(0 if use_recoverssm else num_speculative_tokens),
114
+ num_prefill_checkpoint_blocks=num_prefill_checkpoint_blocks,
115
),
116
layer_names=["layer.0"],
117
vllm_config=vllm_config,
@@ -121,6 +123,54 @@ def _make_builder(
123
return builder
124
125
126
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
127
+def test_internal_checkpoint_metadata_targets_last_aligned_boundary():
128
+ device = torch.device("cuda")
129
+ batch = BatchSpec(seq_lens=[50, 32], query_lens=[50, 16])
130
+ common_attn_metadata = create_common_attn_metadata(
131
+ batch, BLOCK_SIZE, device, arange_block_indices=True
132
+ ).replace(is_prefilling=torch.tensor([True, True]))
133
+ actual = _make_builder(
134
+ KimiK3KDAMetadataBuilder,
135
+ num_speculative_tokens=0,
136
+ full_cuda_graph=False,
137
+ mamba_cache_mode="align",
138
+ num_prefill_checkpoint_blocks=1,
139
+ device=device,
140
+ ).build(0, common_attn_metadata)
141
+
142
+ assert actual.checkpoint is not None
143
+ torch.testing.assert_close(
144
+ actual.checkpoint.state_indices,
145
+ torch.tensor([2, NULL_BLOCK_ID], dtype=torch.int32, device=device),
146
+ )
147
+ torch.testing.assert_close(
148
+ actual.checkpoint.checkpoint_offsets,
149
+ torch.tensor([48, 0], dtype=torch.int32, device=device),
150
+ )
151
+
152
+
153
+@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
154
+def test_internal_checkpoint_metadata_skips_unaligned_offset():
155
+ device = torch.device("cuda")
156
+ # The checkpoint block boundary is 48, but this query starts at token 1,
157
+ # making the real checkpoint offset 47, which is not FlashKDA-aligned.
158
+ batch = BatchSpec(seq_lens=[50], query_lens=[49])
159
+ common_attn_metadata = create_common_attn_metadata(
160
+ batch, BLOCK_SIZE, device, arange_block_indices=True
161
+ ).replace(is_prefilling=torch.tensor([True]))
162
+ actual = _make_builder(
163
+ KimiK3KDAMetadataBuilder,
164
+ num_speculative_tokens=0,
165
+ full_cuda_graph=False,
166
+ mamba_cache_mode="align",
167
+ num_prefill_checkpoint_blocks=1,
168
+ device=device,
169
+ ).build(0, common_attn_metadata)
170
+
171
+ assert actual.checkpoint is None
172
+
173
+
174
@pytest.mark.parametrize(
175
(
176
"batch",
tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py
+85
@@ -77,6 +77,7 @@ def make_full_mamba_manager(
77
mamba_block_size: int = 4,
78
num_blocks: int = 32,
79
use_eagle: bool = False,
80
+ num_prefill_checkpoint_blocks: int = 0,
81
):
82
kv_cache_config = KVCacheConfig(
83
num_blocks=num_blocks,
@@ -98,6 +99,7 @@ def make_full_mamba_manager(
99
shapes=(1, 1),
100
dtypes=(torch.float32,),
101
mamba_cache_mode="align",
102
+ num_prefill_checkpoint_blocks=num_prefill_checkpoint_blocks,
103
),
104
),
105
],
@@ -135,6 +137,7 @@ def test_mamba_align_split_partial_tail_schedule(dcp_world_size: int):
137
dcp_world_size=dcp_world_size,
138
scheduler_block_size=scheduler_block_size,
139
mamba_partial_cache_hit=True,
140
+ mamba_has_prefill_checkpoint_blocks=False,
141
)
142
split = Scheduler._mamba_block_aligned_split
143
@@ -180,6 +183,7 @@ def test_mamba_align_split_when_block_exceeds_scheduling_budget():
183
use_eagle=False,
184
hash_block_size=32,
185
mamba_partial_cache_hit=False,
186
+ mamba_has_prefill_checkpoint_blocks=False,
187
)
188
req = make_request("0", [0] * prompt_length, 32, sha256)
189
split = Scheduler._mamba_block_aligned_split
@@ -218,6 +222,7 @@ def test_mamba_align_split_when_block_exceeds_long_prefill_threshold():
222
use_eagle=False,
223
hash_block_size=32,
224
mamba_partial_cache_hit=False,
225
+ mamba_has_prefill_checkpoint_blocks=False,
226
)
227
req = make_request("0", [0] * prompt_length, 32, sha256)
228
split = Scheduler._mamba_block_aligned_split
@@ -462,6 +467,85 @@ def test_hybrid_mamba_partial_tail_owner_uses_cow_on_continue():
467
assert moved[0].block_hash_num_tokens == 6
468
469
470
+def test_partial_hit_then_internal_checkpoint_uses_distinct_mamba_blocks():
471
+ hash_block_size = 2
472
+ mamba_block_size = 4
473
+ manager = make_full_mamba_manager(
474
+ dcp_world_size=1,
475
+ hash_block_size=hash_block_size,
476
+ full_block_size=hash_block_size,
477
+ mamba_block_size=mamba_block_size,
478
+ num_prefill_checkpoint_blocks=1,
479
+ )
480
+
481
+ owner = make_request("owner", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
482
+ computed_blocks, num_computed, _ = manager.get_computed_blocks(owner)
483
+ assert manager.allocate_slots(owner, 6, num_computed, computed_blocks) is not None
484
+ manager.free(owner)
485
+ manager.new_step_starts()
486
+
487
+ partial_hash = owner.block_hashes[2]
488
+ partial_block = manager.block_pool.get_cached_block(partial_hash, [1])
489
+ assert partial_block is not None
490
+ partial_block_id = partial_block[0].block_id
491
+
492
+ replay = make_request(
493
+ "replay",
494
+ [0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6],
495
+ hash_block_size,
496
+ sha256,
497
+ )
498
+ computed_blocks, num_computed, _ = manager.get_computed_blocks(replay)
499
+ assert num_computed == 6
500
+
501
+ realigned_blocks = manager.allocate_slots(replay, 2, num_computed, computed_blocks)
502
+ assert realigned_blocks is not None
503
+ mamba_cow_block_id = realigned_blocks.get_block_ids()[1][0]
504
+ copies, retained = manager.take_kv_cache_block_copies()
505
+ assert KVCacheBlockCopy(partial_block_id, mamba_cow_block_id) in copies
506
+ manager.block_pool.free_blocks(retained)
507
+
508
+ replay.num_computed_tokens = 8
509
+ manager.new_step_starts()
510
+ final_blocks = manager.allocate_slots(replay, 6)
511
+ assert final_blocks is not None
512
+
513
+ checkpoint_block_id, running_block_id = final_blocks.get_block_ids()[1]
514
+ assert len({mamba_cow_block_id, checkpoint_block_id, running_block_id}) == 3
515
+ mamba_blocks = manager.get_blocks(replay.request_id).blocks[1]
516
+ assert mamba_blocks[2].block_id == checkpoint_block_id
517
+ assert mamba_blocks[3].block_id == running_block_id
518
+
519
+
520
+def test_internal_checkpoint_requires_block_aligned_start():
521
+ hash_block_size = 2
522
+ mamba_block_size = 16
523
+ manager = make_full_mamba_manager(
524
+ dcp_world_size=1,
525
+ hash_block_size=hash_block_size,
526
+ full_block_size=hash_block_size,
527
+ mamba_block_size=mamba_block_size,
528
+ num_prefill_checkpoint_blocks=1,
529
+ )
530
+ request = make_request("producer", list(range(50)), hash_block_size, sha256)
531
+
532
+ # Compute token 0 first, so the next query starts at token 1, which is not
533
+ # aligned to the Mamba block size.
534
+ assert manager.allocate_slots(request, 1) is not None
535
+ request.num_computed_tokens = 1
536
+ manager.new_step_starts()
537
+
538
+ new_blocks = manager.allocate_slots(request, 49)
539
+
540
+ assert new_blocks is not None
541
+ mamba_blocks = manager.get_blocks(request.request_id).blocks[1]
542
+ checkpoint_block_idx = 48 // mamba_block_size - 1
543
+ assert mamba_blocks[checkpoint_block_idx].is_null
544
+ assert not mamba_blocks[-1].is_null
545
+ checkpoint_hash = request.block_hashes[48 // hash_block_size - 1]
546
+ assert manager.block_pool.get_cached_block(checkpoint_hash, [1]) is None
547
+
548
+
549
def test_external_mamba_hit_same_block_uses_running_cow_on_continue():
550
"""An external mid-block hit must become a running request even when its
551
first continuation does not need another Mamba block."""
@@ -560,6 +644,7 @@ def test_take_partial_tail_offloads_returns_cow_target():
644
shapes=(1, 1),
645
dtypes=(torch.float32,),
646
mamba_cache_mode="align",
647
+ num_prefill_checkpoint_blocks=1,
648
),
649
),
650
],
tests/v1/core/test_mamba_align_chunk_split.py
+52
-3
@@ -37,7 +37,9 @@ PROMPT_LEN = 2002
37
MAMBA_GROUP_ID = 1
38
39
40
-def _make_hybrid_kv_cache_manager() -> KVCacheManager:
40
+def _make_hybrid_kv_cache_manager(
41
+ num_prefill_checkpoint_blocks: int = 0,
42
+) -> KVCacheManager:
43
config = KVCacheConfig(
44
num_blocks=10000,
45
kv_cache_tensors=[],
@@ -59,6 +61,7 @@ def _make_hybrid_kv_cache_manager() -> KVCacheManager:
61
dtypes=(torch.float32,),
62
mamba_cache_mode="align",
63
num_speculative_blocks=NUM_SPEC,
64
+ num_prefill_checkpoint_blocks=num_prefill_checkpoint_blocks,
65
),
66
),
67
],
@@ -78,6 +81,7 @@ def _split(
81
num_new_tokens: int,
82
use_eagle: bool = True,
83
partial_hit: bool = False,
84
+ num_prefill_checkpoint_blocks: int = 0,
85
) -> int:
86
"""Call the real `Scheduler._mamba_block_aligned_split` on a stub self."""
87
stub = SimpleNamespace(
@@ -88,10 +92,42 @@ def _split(
92
# `prefix_match_unit` finer than the block size (#46384).
93
mamba_partial_cache_hit=partial_hit,
94
hash_block_size=ATTN_BLOCK_SIZE,
95
+ mamba_has_prefill_checkpoint_blocks=(
96
+ num_prefill_checkpoint_blocks > 0 and not use_eagle
97
+ ),
98
)
99
return Scheduler._mamba_block_aligned_split(stub, request, num_new_tokens)
100
101
102
+@pytest.mark.parametrize(
103
+ ("prompt_len", "num_new_tokens", "use_eagle", "expected"),
104
+ [
105
+ (2002, 2002, False, 2002),
106
+ (3602, 2000, False, 2000),
107
+ (3602, 3602, True, MAMBA_BLOCK_SIZE),
108
+ ],
109
+)
110
+def test_internal_checkpoint_split(
111
+ prompt_len: int, num_new_tokens: int, use_eagle: bool, expected: int
112
+) -> None:
113
+ (request,) = create_requests(1, num_tokens=prompt_len, block_size=ATTN_BLOCK_SIZE)
114
+ assert (
115
+ _split(
116
+ request,
117
+ num_new_tokens,
118
+ use_eagle=use_eagle,
119
+ num_prefill_checkpoint_blocks=1,
120
+ )
121
+ == expected
122
+ )
123
+ if not use_eagle:
124
+ manager = _make_hybrid_kv_cache_manager(num_prefill_checkpoint_blocks=1)
125
+ assert manager.allocate_slots(request, expected) is not None
126
+ mamba_manager = manager.coordinator.single_type_managers[MAMBA_GROUP_ID]
127
+ blocks = mamba_manager.req_to_blocks[request.request_id]
128
+ assert all(not block.is_null for block in blocks) # checkpoint + running state
129
+
130
+
131
def _run_chunked_prefill(
132
manager: KVCacheManager, request: Request, budgets: list[int]
133
) -> dict[int, int]:
@@ -211,8 +247,15 @@ def test_poisoning_is_block_size_independent(
247
248
@pytest.mark.parametrize("partial_hit", [False, True])
249
@pytest.mark.parametrize("resume_at", [331, 1599, 1601, 2531, 3011])
250
+@pytest.mark.parametrize(
251
+ ("num_prefill_checkpoint_blocks", "use_eagle"),
252
+ [(0, True), (1, False)],
253
+)
254
def test_unaligned_resume_never_runs_past_its_block(
215
- partial_hit: bool, resume_at: int
255
+ partial_hit: bool,
256
+ resume_at: int,
257
+ num_prefill_checkpoint_blocks: int,
258
+ use_eagle: bool,
259
) -> None:
260
"""A prefill resuming mid-block must re-align before crossing a boundary.
261
@@ -227,7 +270,13 @@ def test_unaligned_resume_never_runs_past_its_block(
270
pos, ends = resume_at, []
271
while pos < prompt_len:
272
request.num_computed_tokens = pos
230
- num_new = _split(request, prompt_len - pos, partial_hit=partial_hit)
273
+ num_new = _split(
274
+ request,
275
+ prompt_len - pos,
276
+ use_eagle=use_eagle,
277
+ partial_hit=partial_hit,
278
+ num_prefill_checkpoint_blocks=num_prefill_checkpoint_blocks,
279
+ )
280
assert num_new > 0, f"no progress at {pos}"
281
if pos % MAMBA_BLOCK_SIZE != 0:
282
block_end = (pos // MAMBA_BLOCK_SIZE + 1) * MAMBA_BLOCK_SIZE
tests/v1/core/test_prefix_caching.py
+1
@@ -1089,6 +1089,7 @@ def test_hybrid_cache_mamba_align_shared_prefix_detection():
1089
use_eagle=False,
1090
hash_block_size=block_size,
1091
mamba_partial_cache_hit=False,
1092
+ mamba_has_prefill_checkpoint_blocks=False,
1093
)
1094
req_2.shared_prefix_boundary = shared_prefix_boundary
1095
num_new_tokens_adjusted = Scheduler._mamba_block_aligned_split(
vllm/models/kimi_k3/nvidia/kda.py
+161
-19
@@ -2,6 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
from collections.abc import Callable
5
+from dataclasses import replace
6
7
import torch
8
from einops import rearrange
@@ -45,7 +46,10 @@ from vllm.models.kimi_k3.nvidia.kda_metadata import (
46
from vllm.platforms import current_platform
47
from vllm.third_party.flash_linear_attention.ops.kda import FusedRMSNormGated
48
from vllm.transformers_utils.configs.kimi_linear import KimiLinearConfig
49
+from vllm.triton_utils import tl, triton
50
from vllm.v1.attention.backend import AttentionBackend
51
+from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
52
+from vllm.v1.kv_cache_interface import MambaSpec
53
from vllm.v1.worker.workspace import current_workspace_manager
54
55
logger = init_logger(__name__)
@@ -199,6 +203,8 @@ def _flashkda_prefill(
203
out: torch.Tensor,
204
final_state: torch.Tensor,
205
workspace: torch.Tensor,
206
+ checkpoint_state: torch.Tensor | None = None,
207
+ checkpoint_offsets: torch.Tensor | None = None,
208
) -> tuple[torch.Tensor, torch.Tensor]:
209
import vllm._flashkda_C # noqa: F401
210
@@ -221,10 +227,75 @@ def _flashkda_prefill(
227
initial_state.contiguous(),
228
final_state,
229
cu_seqlens.contiguous(),
230
+ checkpoint_state,
231
+ checkpoint_offsets.contiguous() if checkpoint_offsets is not None else None,
232
)
233
return out, final_state
234
235
236
+@triton.jit
237
+def _store_cache_checkpoints_kernel(
238
+ x_ptr,
239
+ conv_state_ptr,
240
+ recurrent_checkpoint_ptr,
241
+ recurrent_state_ptr,
242
+ query_start_loc_ptr,
243
+ checkpoint_offsets_ptr,
244
+ checkpoint_state_indices_ptr,
245
+ x_stride_0: tl.constexpr,
246
+ x_stride_1: tl.constexpr,
247
+ state_stride_0: tl.constexpr,
248
+ state_stride_1: tl.constexpr,
249
+ state_stride_2: tl.constexpr,
250
+ checkpoint_stride_0: tl.constexpr,
251
+ recurrent_state_stride_0: tl.constexpr,
252
+ checkpoint_offset_stride: tl.constexpr,
253
+ STATE_LEN: tl.constexpr,
254
+ WIDTH: tl.constexpr,
255
+ RECURRENT_ROW_SIZE: tl.constexpr,
256
+ NULL_STATE_IDX: tl.constexpr,
257
+ BLOCK_SIZE: tl.constexpr,
258
+):
259
+ # store checkpoints to cache
260
+ seq_idx = tl.program_id(0)
261
+ cols = tl.program_id(1) * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
262
+ state_idx = tl.load(checkpoint_state_indices_ptr + seq_idx)
263
+ checkpoint_offset = tl.load(
264
+ checkpoint_offsets_ptr + seq_idx * checkpoint_offset_stride
265
+ )
266
+ valid_checkpoint = (state_idx != NULL_STATE_IDX) & (checkpoint_offset > 0)
267
+ valid_conv = (
268
+ (cols < WIDTH * STATE_LEN) & valid_checkpoint & (checkpoint_offset >= STATE_LEN)
269
+ )
270
+ width_idx = cols // STATE_LEN
271
+ history_idx = cols % STATE_LEN
272
+ checkpoint_end = tl.load(query_start_loc_ptr + seq_idx) + checkpoint_offset
273
+ token_idx = checkpoint_end - STATE_LEN + history_idx
274
+ values = tl.load(
275
+ x_ptr + token_idx * x_stride_0 + width_idx * x_stride_1,
276
+ mask=valid_conv,
277
+ )
278
+ tl.store(
279
+ conv_state_ptr
280
+ + state_idx * state_stride_0
281
+ + width_idx * state_stride_1
282
+ + history_idx * state_stride_2,
283
+ values,
284
+ mask=valid_conv,
285
+ )
286
+
287
+ valid_recurrent = (cols < RECURRENT_ROW_SIZE) & valid_checkpoint
288
+ recurrent = tl.load(
289
+ recurrent_checkpoint_ptr + seq_idx * checkpoint_stride_0 + cols,
290
+ mask=valid_recurrent,
291
+ )
292
+ tl.store(
293
+ recurrent_state_ptr + state_idx * recurrent_state_stride_0 + cols,
294
+ recurrent,
295
+ mask=valid_recurrent,
296
+ )
297
+
298
+
299
def resolve_kda_prefill_backend(
300
backend: str,
301
head_dim: int,
@@ -471,6 +542,7 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
542
self._flashkda_buffer_specs = (
543
((1, T, H, D), self.model_config.dtype),
544
((N, H, D, D), self.get_state_dtype()[1]),
545
+ ((N, H, D, D), self.get_state_dtype()[1]),
546
((workspace_size,), torch.uint8),
547
)
548
@@ -518,6 +590,14 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
590
raise ValueError(f"Duplicate layer name: {prefix}")
591
compilation_config.static_forward_context[prefix] = self
592
593
+ def get_kv_cache_spec(self, vllm_config: VllmConfig) -> MambaSpec:
594
+ spec = super().get_kv_cache_spec(vllm_config)
595
+ assert isinstance(spec, MambaSpec)
596
+ return replace(
597
+ spec,
598
+ num_prefill_checkpoint_blocks=int(self.kda_prefill_backend == "flashkda"),
599
+ )
600
+
601
def forward(
602
self,
603
hidden_states: torch.Tensor,
@@ -590,6 +670,7 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
670
spec_query_start_loc = m.spec_query_start_loc
671
num_accepted_tokens = m.num_accepted_tokens
672
num_actual_tokens = m.num_actual_tokens
673
+ checkpoint = m.checkpoint
674
has_spec_decode = m.num_spec_decodes > 0
675
mixed_qkv = mixed_qkv[:num_actual_tokens]
676
g1 = g1[:, :num_actual_tokens]
@@ -780,7 +861,7 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
861
if self.kda_prefill_backend == "flashkda":
862
assert self.gate_lower_bound is not None
863
assert self._flashkda_buffer_specs is not None
783
- workspace_out, final_state, workspace = (
864
+ workspace_out, final_state, checkpoint_state, workspace = (
865
current_workspace_manager().get_simultaneous(
866
*self._flashkda_buffer_specs
867
)
@@ -788,24 +869,85 @@ class KimiK3DeltaAttention(GatedDeltaNetAttention):
869
flashkda_out = (
870
workspace_out if has_spec_decode else core_attn_out
871
)[:, : q_ns.shape[1]]
791
- (
792
- core_attn_out_non_spec,
793
- last_recurrent_state,
794
- ) = _flashkda_prefill(
795
- q=q_ns,
796
- k=k_ns,
797
- v=v_ns,
798
- g=g1_ns,
799
- beta=beta_ns,
800
- A_log=self.A_log,
801
- dt_bias=self.dt_bias,
802
- lower_bound=self.gate_lower_bound,
803
- initial_state=initial_state,
804
- cu_seqlens=non_spec_query_start_loc,
805
- out=flashkda_out,
806
- final_state=final_state[: initial_state.shape[0]],
807
- workspace=workspace,
808
- )
872
+ if checkpoint is not None:
873
+ assert non_spec_query_start_loc is not None
874
+ num_sequences = initial_state.shape[0]
875
+ assert checkpoint.checkpoint_offsets.shape == (num_sequences,)
876
+ final_state = final_state[:num_sequences]
877
+ checkpoint_state = checkpoint_state[:num_sequences]
878
+ checkpoint_offsets = checkpoint.checkpoint_offsets
879
+ _flashkda_prefill(
880
+ q=q_ns,
881
+ k=k_ns,
882
+ v=v_ns,
883
+ g=g1_ns,
884
+ beta=beta_ns,
885
+ A_log=self.A_log,
886
+ dt_bias=self.dt_bias,
887
+ lower_bound=self.gate_lower_bound,
888
+ initial_state=initial_state,
889
+ cu_seqlens=non_spec_query_start_loc,
890
+ out=flashkda_out,
891
+ final_state=final_state,
892
+ workspace=workspace,
893
+ checkpoint_state=checkpoint_state,
894
+ checkpoint_offsets=checkpoint_offsets,
895
+ )
896
+ core_attn_out_non_spec = flashkda_out
897
+ last_recurrent_state = final_state
898
+ state_len = conv_state.shape[-1]
899
+ width = mixed_qkv_ns.shape[-1]
900
+ recurrent_row_size = checkpoint_state[0].numel()
901
+ block_size = 256
902
+ _store_cache_checkpoints_kernel[
903
+ (
904
+ checkpoint_offsets.numel(),
905
+ triton.cdiv(
906
+ max(width * state_len, recurrent_row_size),
907
+ block_size,
908
+ ),
909
+ )
910
+ ](
911
+ mixed_qkv_ns,
912
+ conv_state,
913
+ checkpoint_state,
914
+ recurrent_state,
915
+ non_spec_query_start_loc,
916
+ checkpoint_offsets,
917
+ checkpoint.state_indices,
918
+ mixed_qkv_ns.stride(0),
919
+ mixed_qkv_ns.stride(1),
920
+ conv_state.stride(0),
921
+ conv_state.stride(1),
922
+ conv_state.stride(2),
923
+ checkpoint_state.stride(0),
924
+ recurrent_state.stride(0),
925
+ checkpoint_offsets.stride(0),
926
+ state_len,
927
+ width,
928
+ recurrent_row_size,
929
+ NULL_BLOCK_ID,
930
+ block_size,
931
+ )
932
+ else:
933
+ (
934
+ core_attn_out_non_spec,
935
+ last_recurrent_state,
936
+ ) = _flashkda_prefill(
937
+ q=q_ns,
938
+ k=k_ns,
939
+ v=v_ns,
940
+ g=g1_ns,
941
+ beta=beta_ns,
942
+ A_log=self.A_log,
943
+ dt_bias=self.dt_bias,
944
+ lower_bound=self.gate_lower_bound,
945
+ initial_state=initial_state,
946
+ cu_seqlens=non_spec_query_start_loc,
947
+ out=flashkda_out,
948
+ final_state=final_state[: initial_state.shape[0]],
949
+ workspace=workspace,
950
+ )
951
else:
952
(
953
core_attn_out_non_spec,
vllm/models/kimi_k3/nvidia/kda_metadata.py
+67
@@ -44,6 +44,9 @@ if TYPE_CHECKING:
44
)
45
46
47
+FLASHKDA_CHUNK_SIZE = 16
48
+
49
+
50
@cache
51
def _metadata_launch_pdl() -> bool:
52
return current_platform.is_arch_support_pdl()
@@ -257,12 +260,19 @@ class KDARecoverSSMCommitMetadata:
260
align: KDARecoverSSMAlignMetadata | None
261
262
263
+@dataclass
264
+class KDACheckpointMetadata:
265
+ checkpoint_offsets: torch.Tensor
266
+ state_indices: torch.Tensor
267
+
268
+
269
@dataclass
270
class KimiK3KDAMetadata(GDNAttentionMetadata, RecoverSSMMetadata):
271
recoverssm_commit: KDARecoverSSMCommitMetadata | None = None
272
recoverssm_context: "KDARecoverSSMCommitContext | None" = field(
273
default=None, repr=False, compare=False
274
)
275
+ checkpoint: KDACheckpointMetadata | None = None
276
277
def commit_recoverssm_state(
278
self, num_accepted_tokens: torch.Tensor
@@ -557,6 +567,62 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
567
else:
568
has_initial_state = None
569
570
+ checkpoint = None
571
+ if (
572
+ num_prefills > 0
573
+ and self.kv_cache_spec.num_prefill_checkpoint_blocks > 0
574
+ and self.vllm_config.cache_config.mamba_cache_mode == "align"
575
+ ):
576
+ # prepare checkpoint metadata
577
+ assert m.seq_lens_cpu_upper_bound is not None
578
+ request_rows = list(range(m.num_reqs))
579
+ if spec_sequence_masks_cpu is not None:
580
+ # get non spec request rows
581
+ request_rows = active_non_spec_mask_cpu.nonzero().flatten().tolist()
582
+ all_query_lens = query_start_loc_cpu.diff().tolist()
583
+ query_lens = [all_query_lens[row] for row in request_rows]
584
+ seq_lens = m.seq_lens_cpu_upper_bound.tolist()
585
+ block_size = self.kv_cache_spec.block_size
586
+ checkpoint_splits = []
587
+ checkpoint_cols = []
588
+ for row, query_len in zip(request_rows, query_lens):
589
+ seq_len = seq_lens[row]
590
+ offset = seq_len // block_size * block_size - (seq_len - query_len)
591
+ # offset should be less than query_len
592
+ valid = (
593
+ seq_len % block_size != 0
594
+ and 0 < offset < query_len
595
+ and offset % FLASHKDA_CHUNK_SIZE == 0
596
+ )
597
+ offset = offset if valid else 0
598
+ first_len = offset or query_len
599
+ checkpoint_splits.append((first_len, query_len - first_len))
600
+ checkpoint_cols.append(seq_len // block_size - 1 if valid else -1)
601
+ if any(tail for _, tail in checkpoint_splits):
602
+ checkpoint_offsets_tensor = async_tensor_h2d(
603
+ [first if tail else 0 for first, tail in checkpoint_splits],
604
+ dtype=torch.int32,
605
+ device=query_start_loc.device,
606
+ )
607
+ request_rows_tensor = async_tensor_h2d(
608
+ request_rows, dtype=torch.int64, device=query_start_loc.device
609
+ )
610
+ checkpoint_cols_tensor = async_tensor_h2d(
611
+ checkpoint_cols, dtype=torch.int64, device=query_start_loc.device
612
+ )
613
+ checkpoint_state_indices = m.block_table_tensor[
614
+ request_rows_tensor, checkpoint_cols_tensor
615
+ ]
616
+ checkpoint_state_indices = torch.where(
617
+ checkpoint_cols_tensor >= 0,
618
+ checkpoint_state_indices,
619
+ NULL_BLOCK_ID,
620
+ )
621
+ checkpoint = KDACheckpointMetadata(
622
+ checkpoint_offsets_tensor,
623
+ checkpoint_state_indices,
624
+ )
625
+
626
# Prepare per-request tensors for cudagraph replay. num_actual_tokens
627
# may be token-padded, while state/query/acceptance metadata is indexed
628
# by request.
@@ -647,6 +713,7 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
713
nums_dict=nums_dict,
714
batch_ptr=batch_ptr,
715
token_chunk_offset_ptr=token_chunk_offset_ptr,
716
+ checkpoint=checkpoint,
717
)
718
719
vllm/v1/core/sched/scheduler.py
+17
-2
@@ -53,7 +53,7 @@ from vllm.v1.core.sched.request_queue import (
53
)
54
from vllm.v1.core.sched.utils import check_stop, remove_all
55
from vllm.v1.engine import EngineCoreEventType, EngineCoreOutput, EngineCoreOutputs
56
-from vllm.v1.kv_cache_interface import KVCacheConfig
56
+from vllm.v1.kv_cache_interface import KVCacheConfig, MambaSpec
57
from vllm.v1.metrics.perf import ModelMetrics, PerfStats
58
from vllm.v1.metrics.stats import (
59
PrefixCacheStats,
@@ -324,6 +324,16 @@ class Scheduler(SchedulerInterface):
324
self.need_mamba_block_aligned_split = (
325
self.has_mamba_layers and self.cache_config.mamba_cache_mode == "align"
326
)
327
+ self.mamba_has_prefill_checkpoint_blocks = (
328
+ self.has_mamba_layers
329
+ # TODO: support spec decoding
330
+ and not self.use_eagle
331
+ and all(
332
+ not isinstance(group.kv_cache_spec, MambaSpec)
333
+ or group.kv_cache_spec.num_prefill_checkpoint_blocks > 0
334
+ for group in kv_cache_config.kv_cache_groups
335
+ )
336
+ )
337
# A finer prefix_match_unit is configured: a mamba partial tail entry
338
# can only be registered by a step ending exactly at the prompt's last
339
# hash boundary, so the split adds that stop.
@@ -406,12 +416,17 @@ class Scheduler(SchedulerInterface):
416
last_cache_position = max(last_cache_position - block_size, 0)
417
418
end = start + num_new_tokens
419
+ use_internal_checkpoint = (
420
+ self.mamba_has_prefill_checkpoint_blocks and start % block_size == 0
421
+ )
422
+ if use_internal_checkpoint:
423
+ last_cache_position = 0
424
# Invariant: slot p holds the state after exactly (p + 1) * block_size
425
# tokens. State is written at chunk ends, so chunk ends must be block
426
# aligned. Exempt: the prompt's last chunk, whose slot decode advances
427
# to the boundary. A block too wide for one chunk advances sub-block
428
# and re-aligns at the next boundary.
414
- if end < prefill_end:
429
+ if end < prefill_end and not use_internal_checkpoint:
430
max_prefill_tokens = self.max_num_scheduled_tokens
431
long_prefill_threshold = self.scheduler_config.long_prefill_token_threshold
432
if long_prefill_threshold > 0:
vllm/v1/core/single_type_kv_cache_manager.py
+40
-21
@@ -1285,6 +1285,9 @@ class MambaManager(SingleTypeKVCacheManager):
1285
self.last_state_block_idx: dict[str, int] = {}
1286
# The set of the requests that have been allocated blocks
1287
self._allocated_block_reqs: set[str] = set()
1288
+ # Number of internal checkpoint blocks required by each request's
1289
+ # current allocation.
1290
+ self._num_checkpoint_blocks: dict[str, int] = {}
1291
# Requests that registered their own last-prompt-boundary partial
1292
# tail (producers). On the next step's CoW the boundary state moves
1293
# into a private cow_block; we record that block for connector
@@ -1464,6 +1467,23 @@ class MambaManager(SingleTypeKVCacheManager):
1467
"""
1468
return 0
1469
1470
+ def _needs_internal_checkpoint(
1471
+ self,
1472
+ request_id: str,
1473
+ num_tokens: int,
1474
+ num_computed_tokens: int,
1475
+ ) -> bool:
1476
+ assert isinstance(self.kv_cache_spec, MambaSpec)
1477
+ checkpoint_idx = cdiv(num_tokens, self.block_size) - 2
1478
+ blocks = self.req_to_blocks[request_id]
1479
+ return (
1480
+ self.kv_cache_spec.num_prefill_checkpoint_blocks > 0
1481
+ and num_tokens % self.block_size != 0
1482
+ and num_computed_tokens % self.block_size == 0
1483
+ and checkpoint_idx >= 0
1484
+ and (checkpoint_idx >= len(blocks) or blocks[checkpoint_idx].is_null)
1485
+ )
1486
+
1487
def get_num_blocks_to_allocate(
1488
self,
1489
request_id: str,
@@ -1526,18 +1546,17 @@ class MambaManager(SingleTypeKVCacheManager):
1546
)
1547
if has_partial_hit:
1548
num_new_blocks = max(num_new_blocks, 0) + 1
1549
+ checkpoint_block = int(
1550
+ self._needs_internal_checkpoint(
1551
+ request_id, num_tokens, total_computed_tokens
1552
+ )
1553
+ )
1554
+ if not apply_admission_cap:
1555
+ self._num_checkpoint_blocks[request_id] = checkpoint_block
1556
if num_new_blocks > 0:
1530
- if request_id in self._allocated_block_reqs:
1531
- # Old request. Needs at most 1 more blocks as we can reuse the
1532
- # speculative blocks in previous step.
1533
- num_new_blocks = 1 + int(has_partial_hit)
1534
- else:
1535
- # First prefill. Allocate 1 block for running state, the
1536
- # speculative blocks, and one extra block if a partial cache
1537
- # hit must be copy-on-written before the new tokens run.
1538
- num_new_blocks = (
1539
- 1 + self.num_speculative_blocks + int(has_partial_hit)
1540
- )
1557
+ num_new_blocks = 1 + int(has_partial_hit) + checkpoint_block
1558
+ if request_id not in self._allocated_block_reqs:
1559
+ num_new_blocks += self.num_speculative_blocks
1560
1561
num_evictable_computed_blocks = self._get_num_evictable_blocks(
1562
new_computed_blocks
@@ -1569,6 +1588,7 @@ class MambaManager(SingleTypeKVCacheManager):
1588
num_required_blocks = (
1589
cdiv(num_tokens, self.block_size) + self.num_speculative_blocks
1590
)
1591
+ checkpoint_block = self._num_checkpoint_blocks.get(request_id, 0)
1592
partial_hit = self._partial_hit_reqs.get(request_id)
1593
has_partial_hit = partial_hit is not None
1594
# `num_required_blocks` might be less than `len(req_blocks)` if blocks are
@@ -1596,11 +1616,11 @@ class MambaManager(SingleTypeKVCacheManager):
1616
)
1617
# null blocks
1618
if prev_block_len < num_skipped_blocks:
1619
+ # minus the internal checkpoint block
1620
+ # so we don't set null for that block
1621
+ null_end = num_skipped_blocks - checkpoint_block
1622
req_blocks.extend(
1600
- [
1601
- self._null_block
1602
- for _ in range(prev_block_len, num_skipped_blocks)
1603
- ]
1623
+ [self._null_block for _ in range(prev_block_len, null_end)]
1624
)
1625
1626
if blocks_allocated:
@@ -1616,12 +1636,10 @@ class MambaManager(SingleTypeKVCacheManager):
1636
num_new_blocks = num_required_blocks - len(req_blocks)
1637
if has_partial_hit:
1638
num_new_blocks = max(num_new_blocks, 0) + 1
1619
- if blocks_allocated:
1620
- assert num_new_blocks <= 1 + int(has_partial_hit)
1621
- else:
1622
- assert num_new_blocks <= self.num_speculative_blocks + 1 + int(
1623
- has_partial_hit
1624
- )
1639
+ max_new_blocks = 1 + int(has_partial_hit) + checkpoint_block
1640
+ if not blocks_allocated:
1641
+ max_new_blocks += self.num_speculative_blocks
1642
+ assert num_new_blocks <= max_new_blocks
1643
new_blocks = self.block_pool.get_new_blocks(num_new_blocks)
1644
returned_blocks = req_blocks[prev_block_len:]
1645
if partial_hit is not None:
@@ -1670,6 +1688,7 @@ class MambaManager(SingleTypeKVCacheManager):
1688
if self.mamba_cache_mode == "align":
1689
self._allocated_block_reqs.discard(request_id)
1690
self.last_state_block_idx.pop(request_id, None)
1691
+ self._num_checkpoint_blocks.pop(request_id, None)
1692
self._producer_partial_tail_reqs.pop(request_id, None)
1693
# A hand-off whose request died in this same scheduling pass must
1694
# not reach the connector: its unpin hook (free) has already run.
vllm/v1/kv_cache_interface.py
+5
-1
@@ -815,6 +815,7 @@ class MambaSpec(KVCacheSpec):
815
mamba_type: MambaAttentionBackendEnum = MambaAttentionBackendEnum.MAMBA2
816
mamba_cache_mode: str = "none"
817
num_speculative_blocks: int = 0
818
+ num_prefill_checkpoint_blocks: int = 0
819
num_heads: int = 1
820
tokens_per_state: int = -1
821
@@ -843,7 +844,9 @@ class MambaSpec(KVCacheSpec):
844
cdiv(max_model_len, self.block_size) + self.num_speculative_blocks
845
) * self.page_size_bytes
846
elif vllm_config.cache_config.mamba_cache_mode == "align":
846
- return self.page_size_bytes * (2 + self.num_speculative_blocks)
847
+ return self.page_size_bytes * (
848
+ 2 + self.num_speculative_blocks + self.num_prefill_checkpoint_blocks
849
+ )
850
else:
851
return self.page_size_bytes * (1 + self.num_speculative_blocks)
852
@@ -865,6 +868,7 @@ class MambaSpec(KVCacheSpec):
868
return all(
869
isinstance(spec, MambaSpec)
870
and spec.num_speculative_blocks == self.num_speculative_blocks
871
+ and spec.num_prefill_checkpoint_blocks == self.num_prefill_checkpoint_blocks
872
for spec in kv_cache_specs.values()
873
)
874