[Bugfix] GLM-5.3-Flash: fp8 plan dtype on SM90 sparse MLA, and right-size the indexer prefill workspace (#55222)
Signed-off-by: Mikhail Kostryukov <mike@triptrack.net> Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai> Co-authored-by: Mikhail Kostryukov <drakosha81@proton.me> Co-authored-by: NickLucche <nicolo.lucchesi@mistral.ai>
drakosha committed
Sep 30, 2026 at 01:36 UTC
c37f86e5721f089a27c8e4358c30e435805fcaab
5 files changed
+84
-19
tests/v1/attention/test_flashinfer_mla_sparse_sm90.py
+23
-8
@@ -208,12 +208,14 @@ def test_builder_plans_only_rows_dispatched_to_mqa(monkeypatch, use_mha, num_dec
208
assert builder.state.plan_calls[0][1].tolist() == expected_lens
209
210
211
-def test_plan_uses_state_params(monkeypatch):
211
+@pytest.mark.parametrize("kv_dtype", [torch.bfloat16, torch.float8_e4m3fn])
212
+def test_plan_uses_state_params(monkeypatch, kv_dtype):
213
"""The NoPE/rope dims and scale live on the builder state, not the layer.
214
215
Without a device batch layout plan() takes exact per-row KV lengths and
216
rebuilds the schedule on every call; the indptrs are always full-size
216
- with zero-query padding rows past num_tokens.
217
+ with zero-query padding rows past num_tokens. An fp8 cache plans as
218
+ float8_e4m3fn.
219
"""
220
wrapper = FakeWrapper()
221
plan_info = [0] * sm90_mod._PLAN_INFO_LEN
@@ -226,7 +228,7 @@ def test_plan_uses_state_params(monkeypatch):
228
state.device = torch.device("cpu")
229
state.wrapper = wrapper
230
state.num_heads = 4
229
- state.kv_dtype = torch.bfloat16
231
+ state.kv_dtype = kv_dtype
232
state.kv_lora_rank = HEAD
233
state.qk_rope_head_dim = 64
234
state.sm_scale = 576**-0.5
@@ -249,7 +251,7 @@ def test_plan_uses_state_params(monkeypatch):
251
assert (heads, ckv, kpe, page, causal) == (4, HEAD, 64, 1, False)
252
assert scale == 576**-0.5
253
assert kwargs["q_data_type"] == torch.bfloat16
252
- assert kwargs["kv_data_type"] == torch.bfloat16
254
+ assert kwargs["kv_data_type"] == kv_dtype
255
256
# Replanning a smaller batch must clear the previous rows' lengths.
257
state.plan(1, torch.tensor([TOPK], dtype=torch.int32), None, None)
@@ -339,6 +341,14 @@ def test_kv_lens_host_empty():
341
_NO_KPOOL = object()
342
343
344
+@pytest.mark.parametrize(
345
+ "spec_dtype,expected_kv_dtype",
346
+ [
347
+ (torch.bfloat16, torch.bfloat16),
348
+ (torch.uint8, torch.float8_e4m3fn),
349
+ ],
350
+ ids=["bf16", "fp8"],
351
+)
352
@pytest.mark.parametrize(
353
"index_kpool,prefill_lens",
354
[
@@ -348,9 +358,12 @@ _NO_KPOOL = object()
358
],
359
ids=["kpool4", "kpool_none", "no_kpool_attr"],
360
)
351
-def test_builder_kpool_from_model_config(monkeypatch, index_kpool, prefill_lens):
361
+def test_builder_kpool_from_model_config(
362
+ monkeypatch, index_kpool, prefill_lens, spec_dtype, expected_kv_dtype
363
+):
364
"""The builder took kpool from the KV cache spec, whose tokens_per_state is
353
- 1, so the tail pool was never read."""
365
+ 1, so the tail pool was never read. The state plans the cache's logical
366
+ dtype: uint8 fp8 storage becomes float8_e4m3fn."""
367
monkeypatch.setattr(
368
sm90_mod.FlashInferMLASparseMetadataBuilder,
369
"__init__",
@@ -358,14 +371,15 @@ def test_builder_kpool_from_model_config(monkeypatch, index_kpool, prefill_lens)
371
)
372
373
class _RecordingState:
361
- def __init__(self, *_args, **kwargs):
374
+ def __init__(self, *args, **kwargs):
375
+ self.kv_dtype = args[2]
376
self.index_topk = kwargs["index_topk"]
377
self.index_kpool = kwargs["index_kpool"]
378
379
monkeypatch.setattr(sm90_mod, "_SM90State", _RecordingState)
380
impl, _ = make_impl(64)
381
spec = MLAAttentionSpec(
368
- block_size=BLOCK_SIZE, num_kv_heads=1, head_size=576, dtype=torch.bfloat16
382
+ block_size=BLOCK_SIZE, num_kv_heads=1, head_size=576, dtype=spec_dtype
383
)
384
assert spec.tokens_per_state == 1
385
hf_config = SimpleNamespace(index_topk=2048)
@@ -384,6 +398,7 @@ def test_builder_kpool_from_model_config(monkeypatch, index_kpool, prefill_lens)
398
builder = FlashInferMLASparseSM90Builder(
399
spec, ["attn"], vllm_config, torch.device("cpu")
400
)
401
+ assert builder.state.kv_dtype == expected_kv_dtype
402
# req0: prefill chunk ending at context 42295; req1: context <= topk.
403
cam = SimpleNamespace(
404
num_reqs=2,
tests/v1/attention/test_indexer_deepseek_v4_slot_mapping.py
+41
@@ -24,6 +24,7 @@ from vllm.v1.attention.backends.mla.indexer import (
24
DeepseekV4IndexerBackend,
25
DeepseekV32IndexerMetadataBuilder,
26
DeepseekV41IndexerBackend,
27
+ get_max_prefill_buffer_size,
28
)
29
from vllm.v1.attention.backends.mla.sparse_utils import (
30
ConvertReqIndexToGlobalIndexKernel,
@@ -377,3 +378,43 @@ def test_indexer_builder_deepseek_v4_compressed_slot_mapping_uses_num_states():
378
device=device,
379
)
380
torch.testing.assert_close(valid_slots, expected)
381
+
382
+
383
+@pytest.mark.parametrize("compress_ratio", [1, 4])
384
+def test_indexer_prefill_budget_matches_compressed_workspace(compress_ratio):
385
+ """The chunker budget is in compressed rows, like the K-gather workspace."""
386
+ max_model_len = 1024
387
+ kv_cache_spec = MLAAttentionSpec(
388
+ block_size=256,
389
+ num_kv_heads=1,
390
+ head_size=128,
391
+ dtype=torch.bfloat16,
392
+ tokens_per_state=compress_ratio,
393
+ )
394
+ vllm_config = create_vllm_config(max_model_len=max_model_len)
395
+ max_num_blocks = kv_cache_spec.max_num_blocks_per_req(vllm_config, max_model_len)
396
+ block_table_width = get_block_table_width(max_num_blocks, kv_cache_spec.block_size)
397
+ builder = DeepseekV32IndexerMetadataBuilder(
398
+ kv_cache_spec=kv_cache_spec,
399
+ layer_names=["dummy"],
400
+ vllm_config=vllm_config,
401
+ device=torch.device("cpu"),
402
+ block_table_width=block_table_width,
403
+ )
404
+
405
+ workspace_rows = get_max_prefill_buffer_size(vllm_config) // compress_ratio
406
+ assert builder.max_prefill_buffer_size == workspace_rows
407
+
408
+ # Overflows the workspace unless the chunker splits.
409
+ num_reqs = 3 * (workspace_rows // (max_model_len // compress_ratio)) + 1
410
+ compressed_seq_lens = torch.full((num_reqs,), max_model_len // compress_ratio)
411
+ query_lens = torch.ones(num_reqs, dtype=torch.int64)
412
+ chunks = builder._split_indexer_prefill_chunks(
413
+ compressed_seq_lens,
414
+ query_lens,
415
+ builder.max_prefill_buffer_size,
416
+ max_logits_bytes=10**15,
417
+ )
418
+ assert len(chunks) > 1
419
+ for req_slice, _ in chunks:
420
+ assert int(compressed_seq_lens[req_slice].sum()) <= workspace_rows
vllm/models/glm5next/common/attention.py
+3
-1
@@ -312,7 +312,9 @@ class Indexer(nn.Module):
312
self.prefix = prefix
313
from vllm.v1.attention.backends.mla.indexer import get_max_prefill_buffer_size
314
315
- self.max_total_seq_len = get_max_prefill_buffer_size(vllm_config)
315
+ self.max_total_seq_len = (
316
+ get_max_prefill_buffer_size(vllm_config) // self.index_kpool
317
+ )
318
self.indexer_op = SparseAttnIndexerKpool(
319
self.k_cache,
320
self.quant_block_size,
vllm/v1/attention/backends/mla/flashinfer_mla_sparse_sm90.py
+7
-1
@@ -443,10 +443,16 @@ class FlashInferMLASparseSM90Builder(FlashInferMLASparseMetadataBuilder):
443
assert topk_indices_buffer is not None
444
hf_config = vllm_config.model_config.hf_text_config
445
assert hf_config.index_topk is not None
446
+ # fp8 KV cache is stored as uint8; plan() needs the logical dtype.
447
+ kv_plan_dtype = (
448
+ torch.float8_e4m3fn
449
+ if kv_cache_spec.dtype == torch.uint8
450
+ else kv_cache_spec.dtype
451
+ )
452
self.state = _SM90State(
453
device,
454
impl.num_heads,
449
- kv_cache_spec.dtype,
455
+ kv_plan_dtype,
456
vllm_config.scheduler_config.max_num_batched_tokens,
457
topk_indices_buffer.shape[1],
458
kv_lora_rank=impl.kv_lora_rank,
vllm/v1/attention/backends/mla/indexer.py
+10
-9
@@ -901,8 +901,17 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
901
self.use_pcp = self.pcp_world_size > 1
902
self.pcp_rank = get_pcp_group().rank_in_group if self.use_pcp else 0
903
self.cp_kv_cache_interleave_size = parallel_config.cp_kv_cache_interleave_size
904
+ # KV compression (DeepseekV4). Default to 1 for no compression.
905
+ self.compress_ratio = 1
906
+ if isinstance(self.kv_cache_spec, MLAAttentionSpec):
907
+ assert isinstance(self.kv_cache_spec.tokens_per_state, int)
908
+ self.compress_ratio = self.kv_cache_spec.tokens_per_state
909
# NOTE(Chen):an estimated max size of flattened_kv. Need to double check.
905
- self.max_prefill_buffer_size = get_max_prefill_buffer_size(self.vllm_config)
910
+ # Counted in compressed rows, like the chunker's seq_lens and the
911
+ # workspace.
912
+ self.max_prefill_buffer_size = (
913
+ get_max_prefill_buffer_size(self.vllm_config) // self.compress_ratio
914
+ )
915
self.num_speculative_tokens = (
916
self.vllm_config.speculative_config.num_speculative_tokens
917
if self.vllm_config.speculative_config
@@ -984,14 +993,6 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
993
(self.num_sms + 1, 2), dtype=torch.int32, device=self.device
994
)
995
987
- # KV compression. Default to 1 for no compression.
988
- self.compress_ratio = 1
989
- # Get compress_ratio for DeepseekV4 support
990
- if isinstance(self.kv_cache_spec, MLAAttentionSpec):
991
- # MLA compression is a whole number of tokens per state (fractions
992
- # are whisper block pooling and never reach MLA).
993
- assert isinstance(self.kv_cache_spec.tokens_per_state, int)
994
- self.compress_ratio = self.kv_cache_spec.tokens_per_state
996
if self.dcp_world_size > 1 and self.compress_ratio > 1:
997
raise NotImplementedError(
998
"DCP is not supported with sparse indexer KV compression "