[Qwen3.8-Flash-Next] Support FP8 indexer cache for QSA (#54890)
Signed-off-by: Thien Tran <gau.nernst@yahoo.com.sg>
Thien Tran committed
Sep 7, 2026 at 20:15 UTC
94e26dd3dd7d6363b524c96521c78feb501bdc61
5 files changed
+142
-24
tests/models/qwen4_exp/test_qsa_pre_indexer.py
+31
-6
@@ -49,8 +49,21 @@ def _make_block_table(block_counts):
49
return table, num_blocks
50
51
52
+def assert_fp8_within_one_ulp(actual: torch.Tensor, expected: torch.Tensor) -> None:
53
+ # e4m3 is sign-magnitude, so within a sign the uint8 code order matches the
54
+ # value order and one ulp is one code step. The two paths' intermediates
55
+ # differ in the pooling accumulation order, which at denormal magnitudes
56
+ # (absolute grid step 2^-9) shows up as up to 2 code steps.
57
+ code_diff = (
58
+ actual.view(torch.uint8).int() - expected.view(torch.uint8).int()
59
+ ).abs()
60
+ abs_diff = (actual.float() - expected.float()).abs()
61
+ assert bool(((code_diff <= 1) | (abs_diff <= 2**-8)).all())
62
+
63
+
64
@requires_qsa_kernels
65
@pytest.mark.usefixtures("default_vllm_config")
66
+@pytest.mark.parametrize("indexer_dtype", [torch.bfloat16, torch.float8_e4m3fn])
67
@pytest.mark.parametrize(
68
"mrope,is_2d_positions,cache_rope_positions,state_size,seq_lens,query_lens,history_lens",
69
[
@@ -74,6 +87,7 @@ def _make_block_table(block_counts):
87
],
88
)
89
def test_qsa_fused_pre_indexer_matches_unfused(
90
+ indexer_dtype,
91
mrope,
92
is_2d_positions,
93
cache_rope_positions,
@@ -215,7 +229,7 @@ def test_qsa_fused_pre_indexer_matches_unfused(
229
fused_compressed_storage = torch.zeros(
230
num_compressed_blocks,
231
compressed_page_elements + 16,
218
- dtype=torch.bfloat16,
232
+ dtype=indexer_dtype,
233
device=device,
234
)
235
fused_compressed = torch.as_strided(
@@ -231,7 +245,7 @@ def test_qsa_fused_pre_indexer_matches_unfused(
245
q_weight = torch.randn(D, dtype=torch.bfloat16, device=device) * 0.2
246
k_weight = torch.randn(D, dtype=torch.bfloat16, device=device) * 0.2
247
234
- fused_query = torch.empty(num_tokens, HQ, D, dtype=torch.bfloat16, device=device)
248
+ fused_query = torch.empty(num_tokens, HQ, D, dtype=indexer_dtype, device=device)
249
qsa_pre_indexer(
250
projected_qk[:, : HQ * D],
251
projected_qk[:, HQ * D :],
@@ -290,8 +304,19 @@ def test_qsa_fused_pre_indexer_matches_unfused(
304
if rope_positions is not None:
305
qsa_store_cache_rows(rope_positions, raw_slots, position_rows)
306
293
- torch.testing.assert_close(fused_query, unfused_query, rtol=RTOL, atol=ATOL)
307
+ if indexer_dtype == torch.float8_e4m3fn:
308
+ # Both paths round the same ~bf16 intermediates to e4m3. Bitwise
309
+ # equality (the dsv4 indexer test's bar) does not hold here: the 1D
310
+ # reference rope (forward_cuda) differs from _norm_rope by up to 1
311
+ # bf16 ulp, and even the MRoPE pair flips a code occasionally.
312
+ unfused_query = unfused_query.to(indexer_dtype)
313
+ assert_fp8_within_one_ulp(fused_query, unfused_query)
314
+ else:
315
+ torch.testing.assert_close(fused_query, unfused_query, rtol=RTOL, atol=ATOL)
316
assert torch.equal(fused_raw.view(torch.int16), unfused_raw.view(torch.int16))
295
- torch.testing.assert_close(
296
- fused_compressed, unfused_compressed, rtol=RTOL, atol=ATOL
297
- )
317
+ if indexer_dtype == torch.float8_e4m3fn:
318
+ assert_fp8_within_one_ulp(fused_compressed, unfused_compressed)
319
+ else:
320
+ torch.testing.assert_close(
321
+ fused_compressed, unfused_compressed, rtol=RTOL, atol=ATOL
322
+ )
tests/models/qwen4_exp/test_qsa_reference.py
+61
-6
@@ -48,6 +48,7 @@ def test_qsa_mtp_index_share_updates_cache_but_skips_selection(
48
index_n_heads=1,
49
index_kv_heads=1,
50
index_head_dim=1,
51
+ indexer_dtype=torch.bfloat16,
52
raw_key_cache=SimpleNamespace(
53
kv_cache=torch.empty(0),
54
rope_position_cache=None,
@@ -484,6 +485,7 @@ def test_qsa_unfused_cache_update_ignores_padded_qk() -> None:
485
use_fused_pre_indexer=False,
486
index_n_heads=1,
487
index_head_dim=64,
488
+ indexer_dtype=torch.bfloat16,
489
q_layernorm=norm,
490
k_layernorm=norm,
491
rotary_emb=rope,
@@ -666,13 +668,16 @@ def test_qsa_fused_metadata_matches_pytorch_for_large_padded_prefill() -> None:
668
(4, 33),
669
],
670
)
671
+@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float8_e4m3fn])
672
def test_qsa_decode_selection_correctness(
670
- decode_query_len: int, num_requests: int
673
+ decode_query_len: int, num_requests: int, dtype: torch.dtype
674
) -> None:
675
torch.manual_seed(1)
676
heads, head_dim = 4, 128
677
rows = num_requests * decode_query_len
675
- q = torch.randn(rows, heads, head_dim, device="cuda", dtype=torch.bfloat16)
678
+ q = torch.randn(rows, heads, head_dim, device="cuda", dtype=torch.bfloat16).to(
679
+ dtype
680
+ )
681
page_size, pages_per_request, max_sequence_length = (
682
(16, 40, 2560) if num_requests > 32 else (4, 20, 320)
683
)
@@ -684,7 +689,7 @@ def test_qsa_decode_selection_correctness(
689
head_dim,
690
device="cuda",
691
dtype=torch.bfloat16,
687
- )
692
+ ).to(dtype)
693
page_table = torch.randperm(num_pages, device="cuda", dtype=torch.int32).reshape(
694
num_requests, pages_per_request
695
)
@@ -736,14 +741,39 @@ def test_qsa_decode_selection_correctness(
741
compress_ratio,
742
)
743
744
+ if dtype == torch.float8_e4m3fn:
745
+ # fp8 logits tie at the top-k boundary more often than bf16, so index
746
+ # identity is not stable; compare the selected value multisets.
747
+ # SM90 wgmma accumulates fp8 in reduced precision (~3e-4 abs
748
+ # observed); SM100 tcgen05 is exact fp32.
749
+ rtol = atol = 1e-3 if current_platform.is_device_capability(90) else None
750
+ logits = _qsa_mqa_paged_reference(
751
+ q, cache, page_table, token_to_req, visible_blocks
752
+ )
753
+ for row in range(rows):
754
+ selected = actual[row][actual[row] >= 0]
755
+ wanted = expected[row][expected[row] >= 0]
756
+ assert selected.numel() == wanted.numel()
757
+ torch.testing.assert_close(
758
+ logits[row, selected.long()].sort().values,
759
+ logits[row, wanted.long()].sort().values,
760
+ rtol=rtol,
761
+ atol=atol,
762
+ )
763
+ return
764
+
765
torch.testing.assert_close(actual.sort().values, expected.sort().values)
766
767
768
@requires_qsa_kernels
769
+@pytest.mark.parametrize("dtype", [torch.bfloat16, torch.float8_e4m3fn])
770
@pytest.mark.parametrize("seq_len_slack", [0, 1792])
771
@pytest.mark.parametrize("force_chunk", [False, True])
772
def test_qsa_prefill_selection_correctness(
746
- monkeypatch: pytest.MonkeyPatch, seq_len_slack: int, force_chunk: bool
773
+ monkeypatch: pytest.MonkeyPatch,
774
+ seq_len_slack: int,
775
+ force_chunk: bool,
776
+ dtype: torch.dtype,
777
) -> None:
778
# page_size=24 (does not divide the 64-aligned clipped width) and an
779
# oversized page table, so the clipped logits width comes from
@@ -755,8 +785,12 @@ def test_qsa_prefill_selection_correctness(
785
torch.manual_seed(2)
786
query_lens = [3, 33]
787
rows, heads, head_dim = sum(query_lens), 4, 128
758
- q = torch.randn(rows, heads, head_dim, device="cuda", dtype=torch.bfloat16)
759
- cache = torch.randn(128, 24, 1, head_dim, device="cuda", dtype=torch.bfloat16)
788
+ q = torch.randn(rows, heads, head_dim, device="cuda", dtype=torch.bfloat16).to(
789
+ dtype
790
+ )
791
+ cache = torch.randn(128, 24, 1, head_dim, device="cuda", dtype=torch.bfloat16).to(
792
+ dtype
793
+ )
794
page_table = torch.randperm(128, device="cuda", dtype=torch.int32).reshape(2, 64)
795
token_to_req = torch.repeat_interleave(
796
torch.arange(2, device="cuda", dtype=torch.int32),
@@ -804,6 +838,27 @@ def test_qsa_prefill_selection_correctness(
838
compress_ratio,
839
)
840
841
+ if dtype == torch.float8_e4m3fn:
842
+ # fp8 logits tie at the top-k boundary more often than bf16, so index
843
+ # identity is not stable; compare the selected value multisets.
844
+ # SM90 wgmma accumulates fp8 in reduced precision (~3e-4 abs
845
+ # observed); SM100 tcgen05 is exact fp32.
846
+ rtol = atol = 1e-3 if current_platform.is_device_capability(90) else None
847
+ logits = _qsa_mqa_paged_reference(
848
+ q, cache, page_table, token_to_req, visible_blocks
849
+ )
850
+ for row in range(rows):
851
+ selected = actual[row][actual[row] >= 0]
852
+ wanted = expected[row][expected[row] >= 0]
853
+ assert selected.numel() == wanted.numel()
854
+ torch.testing.assert_close(
855
+ logits[row, selected.long()].sort().values,
856
+ logits[row, wanted.long()].sort().values,
857
+ rtol=rtol,
858
+ atol=atol,
859
+ )
860
+ return
861
+
862
torch.testing.assert_close(actual.sort().values, expected.sort().values)
863
864
vllm/models/qwen4_exp/common/qsa_cache.py
+15
-5
@@ -725,10 +725,16 @@ class QSAMetadataBuilder(AttentionMetadataBuilder[QSAForwardMetadata]):
725
726
727
class QSAStateBackend(AttentionBackend):
728
- """Key-only dummy backend for out-of-band BF16 QSA side-cache operations."""
728
+ """Key-only dummy backend for out-of-band QSA side-cache operations."""
729
730
supported_dtypes: ClassVar[list[torch.dtype]] = [torch.bfloat16]
731
- supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = ["auto", "bfloat16"]
731
+ # fp8 entries allow the optional e4m3 compressed indexer cache.
732
+ supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [
733
+ "auto",
734
+ "bfloat16",
735
+ "fp8",
736
+ "fp8_e4m3",
737
+ ]
738
739
@staticmethod
740
def get_name() -> str:
@@ -790,8 +796,12 @@ class _QSAStateCache(nn.Module, AttentionLayerBase):
796
"""Adapt the unified [B, H, N, C] view to QSA's [B, N, H, C]."""
797
if kv_cache.ndim != 4 or kv_cache.shape[1] != 1:
798
raise ValueError("QSA state cache must be [blocks, 1, states, width]")
793
- if kv_cache.dtype != torch.bfloat16 or kv_cache.shape[3] != self.head_size:
794
- raise ValueError("QSA state cache does not match its packed BF16 spec")
799
+ if kv_cache.dtype != self.dtype or kv_cache.shape[3] != self.head_size:
800
+ raise ValueError(
801
+ f"QSA state cache does not match its spec "
802
+ f"(dtype {kv_cache.dtype} != {self.dtype} or width "
803
+ f"{kv_cache.shape[3]} != {self.head_size})"
804
+ )
805
super().bind_kv_cache(kv_cache.transpose(1, 2))
806
807
def get_attn_backend(self) -> type[AttentionBackend]:
@@ -850,7 +860,7 @@ class QSAKeyStateCache(_QSAStateCache):
860
861
862
class QSACompressedKeyCache(_QSAStateCache):
853
- """Normalized, group-first-RoPE BF16 key at one row per complete group."""
863
+ """Normed, group-first-RoPE key at one row per complete group."""
864
865
def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
866
del vllm_config
vllm/models/qwen4_exp/nvidia/indexer_qsa.py
+18
-1
@@ -147,6 +147,21 @@ class QSAIndexer(nn.Module):
147
148
cache_config = vllm_config.cache_config
149
cache_prefix = f"{prefix}." if prefix else ""
150
+ # Plain e4m3 without scales: Q and the compressed K are RMSNormed
151
+ # before quantization, and the logits kernels dot fp8 x fp8 directly.
152
+ self.indexer_kv_dtype = vllm_config.attention_config.resolve_indexer_kv_dtype(
153
+ "bf16"
154
+ )
155
+ if self.indexer_kv_dtype == "fp8":
156
+ indexer_dtype = torch.float8_e4m3fn
157
+ elif self.indexer_kv_dtype == "bf16":
158
+ indexer_dtype = torch.bfloat16
159
+ else:
160
+ raise NotImplementedError(
161
+ f"indexer_kv_dtype={self.indexer_kv_dtype!r} is not supported "
162
+ "by the Qwen4Exp QSA indexer (only 'bf16' or 'fp8')."
163
+ )
164
+ self.indexer_dtype = indexer_dtype
165
self.raw_key_cache = QSAKeyStateCache(
166
head_size=self.index_head_dim,
167
dtype=torch.bfloat16,
@@ -158,7 +173,7 @@ class QSAIndexer(nn.Module):
173
)
174
self.compressed_key_cache = QSACompressedKeyCache(
175
head_size=self.index_head_dim,
161
- dtype=torch.bfloat16,
176
+ dtype=indexer_dtype,
177
compress_ratio=self.compress_ratio,
178
prefix=f"{cache_prefix}compressed_key_cache",
179
cache_config=cache_config,
@@ -276,6 +291,7 @@ class QSAIndexer(nn.Module):
291
num_tokens,
292
self.index_n_heads,
293
self.index_head_dim,
294
+ dtype=self.indexer_dtype,
295
)
296
qsa_pre_indexer(
297
projected_q,
@@ -313,6 +329,7 @@ class QSAIndexer(nn.Module):
329
self.q_layernorm.variance_epsilon,
330
).reshape_as(q)
331
q = apply_qsa_rope(self.rotary_emb, positions, q)
332
+ q = q.to(self.indexer_dtype)
333
334
raw_key_cache = raw_key_state_cache.key_cache
335
rope_position_cache = raw_key_state_cache.rope_position_cache
vllm/models/qwen4_exp/nvidia/ops/qsa_indexer.py
+17
-6
@@ -200,6 +200,9 @@ def _qsa_mqa_paged_prefill_kernel(
200
other=0.0,
201
eviction_policy="evict_first",
202
)
203
+ # With an fp8 cache, SM90 lowers this dot to reduced-precision wgmma
204
+ # accumulation; SM100 tcgen05 accumulates in fp32. The SM90 error
205
+ # (~1e-4 relative) is far below the e4m3 quantization noise floor.
206
scores = tl.dot(keys, query, out_dtype=tl.float32)
207
scores = tl.reshape(scores, (BLOCK_N, TILE_R, NUM_HEADS_PADDED))
208
score = tl.sum(tl.maximum(scores, 0.0), axis=2)
@@ -337,7 +340,7 @@ def warmup_qsa_mqa_paged_decode(
340
for decode_query_len, num_requests in profiles:
341
num_rows = decode_query_len * num_requests
342
q_ptr = TritonWarmupTensor(
340
- torch.bfloat16,
343
+ k_cache.dtype,
344
shape=(num_rows, num_heads, head_dim),
345
)
346
logits_ptr = TritonWarmupTensor(
@@ -365,7 +368,8 @@ def warmup_qsa_mqa_paged_decode(
368
BLOCK_N=_DECODE_BLOCK_N,
369
TILES_PER_PROG=tiles_per_program,
370
STAGES=2,
368
- num_warps=2,
371
+ # tuned on GB300
372
+ num_warps=1 if k_cache.dtype == torch.float8_e4m3fn else 2,
373
grid=(
374
num_requests,
375
triton.cdiv(columns, _DECODE_BLOCK_N * tiles_per_program),
@@ -393,7 +397,11 @@ def _prefill_logits(
397
logits = torch.empty(
398
(num_queries, logits_width), dtype=torch.float32, device=q.device
399
)
396
- TILE_R = 64
400
+ # tuned on GB300
401
+ if k_cache.dtype == torch.float8_e4m3fn:
402
+ TILE_R, STAGES, num_warps = 32, 2, 8
403
+ else:
404
+ TILE_R, STAGES, num_warps = 64, 2, 4
405
BLOCK_N = 64
406
K_TILES = 16
407
grid = (
@@ -421,8 +429,8 @@ def _prefill_logits(
429
TILE_R=TILE_R,
430
BLOCK_N=BLOCK_N,
431
K_TILES=K_TILES,
424
- STAGES=2,
425
- num_warps=4,
432
+ STAGES=STAGES,
433
+ num_warps=num_warps,
434
)
435
return logits
436
@@ -521,6 +529,7 @@ def qsa_select_paged_decode(
529
assert token_topk % compress_ratio == 0
530
assert block_indices.shape == (q.shape[0], token_topk // compress_ratio)
531
assert decode_query_len > 0 and q.shape[0] % decode_query_len == 0
532
+ assert q.dtype == k_cache.dtype, "Q and the compressed K cache must match"
533
num_requests = q.shape[0] // decode_query_len
534
assert page_table.shape[0] == num_requests
535
assert visible_blocks.shape == (q.shape[0],)
@@ -550,7 +559,8 @@ def qsa_select_paged_decode(
559
BLOCK_N=_DECODE_BLOCK_N,
560
TILES_PER_PROG=tiles_per_program,
561
STAGES=2,
553
- num_warps=2,
562
+ # tuned on GB300
563
+ num_warps=1 if k_cache.dtype == torch.float8_e4m3fn else 2,
564
)
565
_topk(
566
logits,
@@ -593,6 +603,7 @@ def qsa_select_paged_prefill(
603
604
assert token_topk % compress_ratio == 0
605
assert block_indices.shape == (q.shape[0], token_topk // compress_ratio)
606
+ assert q.dtype == k_cache.dtype, "Q and the compressed K cache must match"
607
rows = q.shape[0]
608
# No row scores beyond cdiv(max_seq_len, compress_ratio) compressed
609
# columns. Round up to 64 to keep the logits row stride