[Feat][Model] Enable KDA prefill checkpoints for GLM-5.3-Flash (#56960)

Signed-off-by: chaunceyjiang <chaunceyjiang@gmail.com>

Chauncey committed Sep 29, 2026 at 17:14 UTC 3e2a7e74a556623ddee3a327299d7bf04055501e
6 files changed +307 -11
tests/models/glm5next/test_kda_recurrent.py
+167
@@ -193,3 +193,170 @@ def test_fused_recurrent_kda_rejects_unaddressable_layouts():
193 broken["cu_seqlens"] = None if q.shape[0] > 1 else inputs["cu_seqlens"]
194 with pytest.raises(AssertionError, match=r"torch.Size"):
195 run_kernel(broken, state)
196 +
197 +
198 +@pytest.mark.parametrize("offset", [16, 144, 256])
199 +@pytest.mark.parametrize("dim_first", [False, True])
200 +@pytest.mark.parametrize("num_spec", [0, 3])
201 +@torch.inference_mode()
202 +def test_prefill_checkpoint_resumes_suffix(monkeypatch, dim_first, num_spec, offset):
203 + """Restoring both cached states must reproduce an uninterrupted prefill."""
204 + from types import SimpleNamespace
205 +
206 + from vllm.model_executor.layers.mamba.checkpoint import (
207 + MambaPrefillCheckpointMetadata,
208 + )
209 + from vllm.model_executor.layers.mamba.kda_checkpoint import (
210 + FlashKDAPrefillCheckpointExporter,
211 + )
212 + from vllm.models.glm5next.common import kda
213 + from vllm.v1.attention.backends.gdn_attn import GDNAttentionMetadata
214 + from vllm.v1.attention.backends.utils import compute_causal_conv1d_metadata
215 + from vllm.v1.worker.workspace import WorkspaceManager
216 +
217 + if kda._resolve_kda_prefill_backend("auto", D, torch.bfloat16, -5) != "flashkda":
218 + pytest.skip("requires FlashKDA")
219 + import vllm._flashkda_C # noqa: F401
220 +
221 + torch.manual_seed(42)
222 + device = torch.device("cuda")
223 + length, heads = 273, 2
224 + width, state_len = 3 * heads * D, 3 + num_spec
225 + layer = object.__new__(kda.Glm5NextLinearAttention)
226 + torch.nn.Module.__init__(layer)
227 + layer.prefix = "layer.0"
228 + layer.local_num_heads = heads
229 + layer.head_dim = D
230 + layer.conv_size = 4
231 + layer.local_projection_size = heads * D
232 + layer.kda_safe_gate = True
233 + layer.kda_lower_bound = -5.0
234 + layer.kda_prefill_backend = "flashkda"
235 + layer._conv_state_dim_first = dim_first
236 + layer._merged_conv_weight = torch.randn(width, 4, device=device) * 0.1
237 + layer.q_conv1d = SimpleNamespace(bias=None)
238 + layer.A_log = torch.randn(1, 1, heads, 1, device=device) * 0.1
239 + layer.dt_bias = torch.randn(heads * D, device=device) * 0.1
240 + state_shape = (2, heads, D, D)
241 + layer._flashkda_buffer_specs = (
242 + (state_shape, torch.float32),
243 + (state_shape, torch.float32),
244 + (
245 + (torch.ops._flashkda_C.get_workspace_size(2 * length, heads, 2),),
246 + torch.uint8,
247 + ),
248 + ((1, 2 * length, heads, D), torch.bfloat16),
249 + )
250 + layer._checkpoint_exporter = FlashKDAPrefillCheckpointExporter(state_len=3)
251 + workspace = WorkspaceManager(device)
252 + monkeypatch.setattr(kda, "current_workspace_manager", lambda: workspace)
253 + conv_shape = (width, state_len) if dim_first else (state_len, width)
254 + num_slots = 5 + (num_spec + 1 if num_spec else 0)
255 + conv_storage = torch.zeros(
256 + num_slots, width * state_len + 8, device=device, dtype=torch.bfloat16
257 + )
258 + conv = conv_storage[:, : width * state_len].view(num_slots, *conv_shape)
259 + recurrent_storage = torch.zeros(
260 + num_slots, heads * D * D + 8, device=device, dtype=torch.float32
261 + )
262 + recurrent = recurrent_storage[:, : heads * D * D].view(num_slots, heads, D, D)
263 + layer.kv_cache = (conv, recurrent)
264 + projected = torch.randn(
265 + 2 * length, width + heads, device=device, dtype=torch.bfloat16
266 + )
267 + raw_qkv, beta = projected[:, :width], projected[:, width:].unsqueeze(0)
268 + raw_before = raw_qkv.clone()
269 + gate = torch.randn(1, 2 * length, heads, D, device=device, dtype=torch.bfloat16)
270 +
271 + def run(qkv, g, b, slots, has_initial, checkpoint=None):
272 + n = qkv.shape[0] // 2
273 + cu_cpu = torch.tensor([0, n, 2 * n], dtype=torch.int32)
274 + nums, batch_ptr, chunk_offsets = compute_causal_conv1d_metadata(
275 + cu_cpu, device=device
276 + )
277 + metadata = GDNAttentionMetadata(
278 + num_prefills=2,
279 + num_prefill_tokens=2 * n,
280 + num_decodes=0,
281 + num_decode_tokens=0,
282 + num_spec_decodes=0,
283 + num_spec_decode_tokens=0,
284 + num_actual_tokens=2 * n,
285 + has_initial_state=torch.tensor(has_initial, device=device),
286 + non_spec_query_start_loc=cu_cpu.to(device),
287 + non_spec_state_indices_tensor=torch.tensor(
288 + slots, dtype=torch.int32, device=device
289 + ),
290 + checkpoint=checkpoint,
291 + nums_dict=nums,
292 + batch_ptr=batch_ptr,
293 + token_chunk_offset_ptr=chunk_offsets,
294 + )
295 + monkeypatch.setattr(
296 + kda,
297 + "get_forward_context",
298 + lambda: SimpleNamespace(attn_metadata={layer.prefix: metadata}),
299 + )
300 + spec_tokens = num_spec + 1 if num_spec else 0
301 + if spec_tokens:
302 + qkv = torch.cat([qkv[:spec_tokens], qkv])
303 + g = torch.cat([g[:, :spec_tokens], g], dim=1)
304 + b = torch.cat([b[:, :spec_tokens], b], dim=1)
305 + metadata.num_spec_decodes = 1
306 + metadata.num_spec_decode_tokens = spec_tokens
307 + metadata.num_actual_tokens += spec_tokens
308 + metadata.spec_sequence_masks = torch.tensor(
309 + [True, False, False], device=device
310 + )
311 + metadata.spec_query_start_loc = torch.tensor(
312 + [0, spec_tokens], dtype=torch.int32, device=device
313 + )
314 + metadata.spec_state_indices_tensor = torch.arange(
315 + 5, num_slots, dtype=torch.int32, device=device
316 + ).unsqueeze(0)
317 + metadata.spec_token_indx = torch.arange(spec_tokens, device=device)
318 + metadata.non_spec_token_indx = torch.arange(
319 + spec_tokens, qkv.shape[0], device=device
320 + )
321 + metadata.num_accepted_tokens = torch.ones(
322 + 1, dtype=torch.int32, device=device
323 + )
324 + out = torch.empty(
325 + 1, qkv.shape[0], heads, D, device=device, dtype=torch.bfloat16
326 + )
327 + layer._forward(qkv, g, b, out)
328 + return out[:, spec_tokens:]
329 +
330 + checkpoint = MambaPrefillCheckpointMetadata(
331 + torch.tensor([offset, 0], dtype=torch.int32, device=device),
332 + torch.tensor([3, 0], dtype=torch.int32, device=device),
333 + )
334 + expected = run(raw_qkv, gate, beta, [1, 2], [False, False], checkpoint)
335 + expected_state = recurrent[1].clone()
336 + saved_conv, saved_recurrent = conv[3].clone(), recurrent[3].clone()
337 + torch.testing.assert_close(raw_qkv, raw_before)
338 + conv_view = conv if dim_first else conv.transpose(-1, -2)
339 + torch.testing.assert_close(conv_view[3, :, :3], raw_qkv[offset - 3 : offset].T)
340 + assert torch.count_nonzero(conv_view[3, :, 3:]) == 0
341 + assert torch.count_nonzero(conv[0]) == torch.count_nonzero(recurrent[0]) == 0
342 +
343 + run(
344 + torch.cat([raw_qkv[:offset], raw_qkv[length : length + offset]]),
345 + torch.cat([gate[:, :offset], gate[:, length : length + offset]], dim=1),
346 + torch.cat([beta[:, :offset], beta[:, length : length + offset]], dim=1),
347 + [1, 2],
348 + [False, False],
349 + )
350 + torch.testing.assert_close(saved_recurrent, recurrent[1], atol=0.003, rtol=0.03)
351 +
352 + suffix = slice(offset, length)
353 + qkv = torch.cat([raw_qkv[suffix], raw_qkv[length + offset :]])
354 + g = torch.cat([gate[:, suffix], gate[:, length + offset :]], dim=1)
355 + b = torch.cat([beta[:, suffix], beta[:, length + offset :]], dim=1)
356 + conv[4].copy_(saved_conv)
357 + recurrent[4].copy_(saved_recurrent)
358 + actual = run(qkv, g, b, [3, 4], [True, True])
359 + torch.testing.assert_close(
360 + actual[:, : length - offset], expected[:, suffix], atol=0.003, rtol=0.03
361 + )
362 + torch.testing.assert_close(recurrent[3], expected_state, atol=0.003, rtol=0.03)
tests/v1/attention/test_gdn_metadata_builder.py
+63
@@ -367,3 +367,66 @@ def test_cudagraph_capture_batch_stays_decode_only():
367 assert staged is not None
368 assert staged.data_ptr() == builder.non_spec_state_indices_tensor.data_ptr()
369 torch.testing.assert_close(staged, common_attn_metadata.block_table_tensor[:, 0])
370 +
371 +
372 +@pytest.mark.parametrize("num_spec", [0, 3])
373 +@pytest.mark.parametrize("prefix_match_unit, expected_offset", [(16, 96), (8, 96)])
374 +def test_checkpoint_metadata_preserves_non_spec_order(
375 + num_spec, prefix_match_unit, expected_offset
376 +):
377 + """Only eligible non-spec rows get a checkpoint in their reserved page."""
378 + from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
379 +
380 + vllm_config = create_vllm_config(
381 + model_name="Qwen/Qwen3.5-0.8B",
382 + block_size=BLOCK_SIZE,
383 + )
384 + if num_spec > 0:
385 + vllm_config.speculative_config = SpeculativeConfig(
386 + method="ngram",
387 + num_speculative_tokens=num_spec,
388 + )
389 + vllm_config.cache_config.mamba_cache_mode = "align"
390 + vllm_config.cache_config.prefix_match_unit = prefix_match_unit
391 + mamba_spec = MambaSpec(
392 + block_size=64,
393 + shapes=((16, 64),),
394 + dtypes=(torch.float16,),
395 + mamba_cache_mode="align",
396 + num_speculative_blocks=num_spec,
397 + num_prefill_checkpoint_blocks=1,
398 + prefill_checkpoint_alignment=16,
399 + )
400 + builder = GDNAttentionMetadataBuilder(
401 + kv_cache_spec=mamba_spec,
402 + layer_names=["layer.0"],
403 + vllm_config=vllm_config,
404 + device=DEVICE,
405 + )
406 + batch = BatchSpec(
407 + seq_lens=[65, 100, 100], query_lens=[3 if num_spec else 1, 100, 99]
408 + )
409 + common = create_common_attn_metadata(batch, 64, DEVICE, arange_block_indices=True)
410 + table = common.block_table_tensor + 1
411 + if num_spec:
412 + table = torch.cat([table, torch.zeros(3, num_spec, dtype=table.dtype)], dim=1)
413 + common = common.replace(block_table_tensor=table)
414 + kwargs = {}
415 + if num_spec:
416 + kwargs = dict(
417 + num_decode_draft_tokens_cpu=torch.tensor([2, -1, -1], dtype=torch.int32),
418 + num_accepted_tokens=torch.ones(3, dtype=torch.int32),
419 + )
420 +
421 + actual = builder.build(0, common, **kwargs)
422 + assert actual.checkpoint is not None
423 + offsets = [expected_offset, 0] if num_spec else [0, expected_offset, 0]
424 + slots = [common.block_table_tensor[1, 0].item(), NULL_BLOCK_ID]
425 + if not num_spec:
426 + slots.insert(0, NULL_BLOCK_ID)
427 + torch.testing.assert_close(
428 + actual.checkpoint.checkpoint_offsets, torch.tensor(offsets, dtype=torch.int32)
429 + )
430 + torch.testing.assert_close(
431 + actual.checkpoint.state_indices, torch.tensor(slots, dtype=torch.int32)
432 + )
vllm/model_executor/layers/mamba/checkpoint.py
+2
@@ -76,6 +76,8 @@ class MambaPrefillCheckpointBuilder:
76 m: CommonAttentionMetadata,
77 request_rows: list[int],
78 ) -> MambaPrefillCheckpointMetadata | None:
79 + if self.vllm_config.cache_config.mamba_cache_mode != "align":
80 + return None
81 if self.kv_cache_spec.num_prefill_checkpoint_blocks == 0:
82 return None
83 assert m.seq_lens_cpu_upper_bound is not None
vllm/models/glm5next/common/kda.py
+59 -3
@@ -2,6 +2,8 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """GLM-5.3-Flash KDA layer with separate convolutions and a bounded safe gate."""
4
5 +from dataclasses import replace
6 +
7 import torch
8 from torch import nn
9
@@ -14,7 +16,14 @@ from vllm.model_executor.layers.linear import (
16 MergedColumnParallelLinear,
17 RowParallelLinear,
18 )
19 +from vllm.model_executor.layers.mamba.checkpoint import (
20 + MambaPrefillCheckpointMetadata,
21 +)
22 from vllm.model_executor.layers.mamba.gdn.base import GatedDeltaNetAttention
23 +from vllm.model_executor.layers.mamba.kda_checkpoint import (
24 + FlashKDAPrefillCheckpointExporter,
25 + kda_prefill_checkpoint_alignment,
26 +)
27 from vllm.model_executor.layers.mamba.mamba_utils import (
28 MambaStateDtypeCalculator,
29 MambaStateShapeCalculator,
@@ -37,6 +46,7 @@ from vllm.platforms import current_platform
46 from vllm.third_party.flash_linear_attention.ops.kda import FusedRMSNormGated
47 from vllm.transformers_utils.configs.glm5_next import Glm5NextConfig
48 from vllm.v1.attention.backends.gdn_attn import GDNAttentionMetadata
49 +from vllm.v1.kv_cache_interface import MambaSpec
50 from vllm.v1.worker.workspace import current_workspace_manager
51
52 if current_platform.is_rocm():
@@ -155,6 +165,16 @@ class Glm5NextLinearAttention(GatedDeltaNetAttention):
165 num_heads: int
166 conv_size: int
167
168 + def get_kv_cache_spec(self, vllm_config: VllmConfig) -> MambaSpec:
169 + spec = super().get_kv_cache_spec(vllm_config)
170 + assert isinstance(spec, MambaSpec)
171 + alignment = kda_prefill_checkpoint_alignment(self.kda_prefill_backend)
172 + return replace(
173 + spec,
174 + num_prefill_checkpoint_blocks=int(alignment is not None),
175 + prefill_checkpoint_alignment=alignment,
176 + )
177 +
178 def get_state_dtype(
179 self,
180 ) -> tuple[torch.dtype, torch.dtype]:
@@ -330,6 +350,7 @@ class Glm5NextLinearAttention(GatedDeltaNetAttention):
350 self._flashkda_buffer_specs: (
351 tuple[tuple[tuple[int, ...], torch.dtype], ...] | None
352 ) = None
353 + self._checkpoint_exporter: FlashKDAPrefillCheckpointExporter | None = None
354 if self.kda_prefill_backend == "flashkda":
355 import vllm._flashkda_C # noqa: F401
356
@@ -339,6 +360,10 @@ class Glm5NextLinearAttention(GatedDeltaNetAttention):
360 max_tokens, self.local_num_heads, max_seqs
361 )
362 self._flashkda_buffer_specs = (
363 + (
364 + (max_seqs, self.local_num_heads, self.head_dim, self.head_dim),
365 + self.get_state_dtype()[1],
366 + ),
367 (
368 (max_seqs, self.local_num_heads, self.head_dim, self.head_dim),
369 self.get_state_dtype()[1],
@@ -351,6 +376,9 @@ class Glm5NextLinearAttention(GatedDeltaNetAttention):
376 vllm_config.model_config.dtype,
377 ),
378 )
379 + self._checkpoint_exporter = FlashKDAPrefillCheckpointExporter(
380 + state_len=self.conv_size - 1
381 + )
382
383 def _flashkda_prefill(
384 self,
@@ -362,6 +390,10 @@ class Glm5NextLinearAttention(GatedDeltaNetAttention):
390 initial_state: torch.Tensor,
391 cu_seqlens: torch.Tensor,
392 out: torch.Tensor | None,
393 + checkpoint: MambaPrefillCheckpointMetadata | None = None,
394 + raw_qkv: torch.Tensor | None = None,
395 + conv_state: torch.Tensor | None = None,
396 + recurrent_state: torch.Tensor | None = None,
397 ) -> tuple[torch.Tensor, torch.Tensor]:
398 """Fused KDA chunked prefill (FlashKDA). Takes the raw gate logits ``g``
399 and raw ``beta`` logits, l2-normalizes q/k in-kernel and applies the
@@ -370,10 +402,15 @@ class Glm5NextLinearAttention(GatedDeltaNetAttention):
402 attention output into ``out`` (a workspace buffer when ``None``) and
403 returns ``(out, final_state)``."""
404 assert self._flashkda_buffer_specs is not None
373 - final_state, workspace, workspace_out = (
405 + final_state, checkpoint_state, workspace, workspace_out = (
406 current_workspace_manager().get_simultaneous(*self._flashkda_buffer_specs)
407 )
408 final_state = final_state[: initial_state.shape[0]]
409 + checkpoint_state = (
410 + checkpoint_state[: initial_state.shape[0]]
411 + if checkpoint is not None
412 + else None
413 + )
414 if out is None:
415 out = workspace_out[:, : q.shape[1]]
416 # FlashKDA hardcodes dense q/k/v/g strides; beta may be row-strided.
@@ -392,9 +429,23 @@ class Glm5NextLinearAttention(GatedDeltaNetAttention):
429 initial_state.contiguous(),
430 final_state,
431 cu_seqlens.contiguous(),
395 - None,
396 - None,
432 + checkpoint_state,
433 + checkpoint.checkpoint_offsets.contiguous()
434 + if checkpoint is not None
435 + else None,
436 )
437 + if checkpoint is not None:
438 + assert self._checkpoint_exporter is not None
439 + assert raw_qkv is not None and conv_state is not None
440 + assert recurrent_state is not None
441 + self._checkpoint_exporter.export(
442 + checkpoint,
443 + raw_qkv=raw_qkv,
444 + conv_state=conv_state,
445 + recurrent_checkpoint=checkpoint_state,
446 + recurrent_state=recurrent_state,
447 + cu_seqlens=cu_seqlens,
448 + )
449 return out, final_state
450
451 def forward(
@@ -565,6 +616,7 @@ class Glm5NextLinearAttention(GatedDeltaNetAttention):
616 q_spec, k_spec, v_spec = qkv_spec.split(self.local_projection_size, dim=-1)
617
618 # --- causal conv1d: non-spec path (prefill or plain decode) ---
619 + raw_qkv_ns = qkv_ns
620 q_ns = k_ns = v_ns = None
621 if attn_metadata_narrowed.num_prefills > 0:
622 assert qkv_ns is not None
@@ -662,6 +714,10 @@ class Glm5NextLinearAttention(GatedDeltaNetAttention):
714 initial_state=initial_state,
715 cu_seqlens=non_spec_query_start_loc,
716 out=ns_out,
717 + checkpoint=attn_metadata_narrowed.checkpoint,
718 + raw_qkv=raw_qkv_ns,
719 + conv_state=conv_state,
720 + recurrent_state=recurrent_state,
721 )
722 else:
723 (
vllm/models/kimi_k3/nvidia/kda_metadata.py
-8
@@ -18,10 +18,6 @@ from typing import TYPE_CHECKING
18 import torch
19
20 from vllm.config import VllmConfig
21 -from vllm.model_executor.layers.mamba.checkpoint import (
22 - MambaPrefillCheckpointBuilder,
23 - MambaPrefillCheckpointMetadata,
24 -)
21 from vllm.platforms import current_platform
22 from vllm.triton_utils import tl, triton
23 from vllm.utils.torch_utils import async_tensor_h2d
@@ -271,7 +267,6 @@ class KimiK3KDAMetadata(GDNAttentionMetadata, RecoverSSMMetadata):
267 recoverssm_context: "KDARecoverSSMCommitContext | None" = field(
268 default=None, repr=False, compare=False
269 )
274 - checkpoint: MambaPrefillCheckpointMetadata | None = None
270
271 def commit_recoverssm_state(
272 self, num_accepted_tokens: torch.Tensor
@@ -319,9 +314,6 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
314 device: torch.device,
315 ) -> None:
316 super().__init__(kv_cache_spec, layer_names, vllm_config, device)
322 - self.checkpoint_builder = MambaPrefillCheckpointBuilder(
323 - vllm_config, kv_cache_spec
324 - )
317 additional_config = vllm_config.additional_config
318 self.use_flashinfer_prefill = (
319 isinstance(additional_config, dict)
vllm/v1/attention/backends/gdn_attn.py
+16
@@ -8,6 +8,10 @@ from typing import Literal
8 import torch
9
10 from vllm.config import VllmConfig
11 +from vllm.model_executor.layers.mamba.checkpoint import (
12 + MambaPrefillCheckpointBuilder,
13 + MambaPrefillCheckpointMetadata,
14 +)
15 from vllm.utils.torch_utils import async_tensor_h2d
16 from vllm.v1.attention.backend import (
17 AttentionBackend,
@@ -48,6 +52,7 @@ class GDNAttentionMetadata:
52 num_spec_decode_tokens: int
53 num_actual_tokens: int
54
55 + checkpoint: MambaPrefillCheckpointMetadata | None = None
56 has_initial_state: torch.Tensor | None = None
57
58 spec_query_start_loc: torch.Tensor | None = None # shape: [num_spec_decodes + 1,]
@@ -97,6 +102,9 @@ class GDNAttentionMetadataBuilder(AttentionMetadataBuilder[GDNAttentionMetadata]
102 super().__init__(kv_cache_spec, layer_names, vllm_config, device)
103 self.compilation_config = vllm_config.compilation_config
104 self.speculative_config = vllm_config.speculative_config
105 + self.checkpoint_builder = MambaPrefillCheckpointBuilder(
106 + vllm_config, kv_cache_spec
107 + )
108 from vllm.model_executor.layers.mamba.gdn.qwen_gdn_linear_attn import (
109 _resolve_gdn_prefill_backend,
110 )
@@ -466,6 +474,13 @@ class GDNAttentionMetadataBuilder(AttentionMetadataBuilder[GDNAttentionMetadata]
474 else:
475 has_initial_state = None
476
477 + checkpoint: MambaPrefillCheckpointMetadata | None = None
478 + if num_prefills > 0:
479 + request_rows = list(range(m.num_reqs))
480 + if spec_sequence_masks_cpu is not None:
481 + request_rows = (~spec_sequence_masks_cpu).nonzero().flatten().tolist()
482 + checkpoint = self.checkpoint_builder.build(m, request_rows)
483 +
484 # Function code counted on either presency non-spec decode or spec decode,
485 # but not both.
486 assert not (num_decodes > 0 and num_spec_decodes > 0), (
@@ -543,6 +558,7 @@ class GDNAttentionMetadataBuilder(AttentionMetadataBuilder[GDNAttentionMetadata]
558 num_spec_decodes=num_spec_decodes,
559 num_spec_decode_tokens=num_spec_decode_tokens,
560 num_actual_tokens=m.num_actual_tokens,
561 + checkpoint=checkpoint,
562 has_initial_state=has_initial_state,
563 chunk_indices=chunk_indices,
564 chunk_offsets=chunk_offsets,