[Model] Qwen4Exp: fp8_e4m3 main KV cache on the QSA path (#55557)
Signed-off-by: Andrea Semeraro <andrea.semeraro@sezione1.it> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Jiangyun Zhu <riverclouds.zhu@qq.com>
semerandre committed
Sep 16, 2026 at 17:07 UTC
dff1bde84dd6e34a49c150116d0f212507280910
3 files changed
+287
-45
tests/models/qwen4_exp/test_qsa_reference.py
+57
-18
@@ -203,7 +203,14 @@ def _qsa_sparse_paged_attention_reference(
203
block_table: torch.Tensor,
204
token_to_req: torch.Tensor,
205
softmax_scale: float,
206
+ k_scale: float = 1.0,
207
+ v_scale: float = 1.0,
208
) -> torch.Tensor:
209
+ """Dense reference for QSA sparse paged attention.
210
+
211
+ Mirrors the kernel's dequant: fp8-e4m3 K/V caches are dequantized with the
212
+ per-tensor k_scale/v_scale host floats; bf16 caches use unit scales.
213
+ """
214
output = torch.zeros_like(q)
215
repeats = q.shape[1] // k_cache.shape[2]
216
page_size = k_cache.shape[1]
@@ -215,13 +222,15 @@ def _qsa_sparse_paged_attention_reference(
222
request = token_to_req[row].long()
223
pages = block_table[request, logical // page_size].long()
224
offsets = logical % page_size
218
- keys = k_cache[pages, offsets].repeat_interleave(repeats, dim=1)
219
- values = v_cache[pages, offsets].repeat_interleave(repeats, dim=1)
220
- scores = torch.einsum("hd,khd->hk", q[row].float(), keys.float())
221
- probabilities = torch.softmax(scores * softmax_scale, dim=-1)
222
- output[row] = torch.einsum("hk,khd->hd", probabilities, values.float()).to(
223
- q.dtype
225
+ keys = (k_cache[pages, offsets].float() * k_scale).repeat_interleave(
226
+ repeats, dim=1
227
)
228
+ values = (v_cache[pages, offsets].float() * v_scale).repeat_interleave(
229
+ repeats, dim=1
230
+ )
231
+ scores = torch.einsum("hd,khd->hk", q[row].float(), keys)
232
+ probabilities = torch.softmax(scores * softmax_scale, dim=-1)
233
+ output[row] = torch.einsum("hk,khd->hd", probabilities, values).to(q.dtype)
234
return output
235
236
@@ -906,22 +915,28 @@ def test_qsa_block_expansion_correctness() -> None:
915
"page_size",
916
"use_prefill_config",
917
"num_requests",
918
+ "fp8",
919
),
920
[
921
# Production page sizes from hybrid-cache block alignment: 784/800
922
# at TP4 and 1568/1600 at TP1/TP2 (no-MTP / MTP num_spec=3). Head
923
# splits are per-rank TP1/TP2/TP4; the largest batch runs both
924
# use_prefill_config variants.
915
- pytest.param(1, 24, 2, 1600, True, 2, id="tp1_r1"),
916
- pytest.param(16, 12, 1, 1600, True, 3, id="tp2_r16"),
917
- pytest.param(32, 6, 1, 800, True, 5, id="tp4_r32"),
918
- pytest.param(128, 24, 2, 1568, True, 7, id="tp1_r128"),
919
- pytest.param(257, 6, 1, 800, True, 13, id="tp4_r257"),
920
- pytest.param(513, 6, 1, 784, True, 17, id="tp4_r513"),
921
- pytest.param(700, 6, 1, 800, True, 23, id="tp4_r700"),
922
- pytest.param(1024, 24, 2, 1600, True, 33, id="tp1_r1024"),
923
- pytest.param(2048, 24, 2, 1600, True, 63, id="tp1_r2048_prefill"),
924
- pytest.param(2048, 24, 2, 1600, False, 63, id="tp1_r2048_uniform"),
925
+ pytest.param(1, 24, 2, 1600, True, 2, False, id="tp1_r1"),
926
+ pytest.param(16, 12, 1, 1600, True, 3, False, id="tp2_r16"),
927
+ pytest.param(32, 6, 1, 800, True, 5, False, id="tp4_r32"),
928
+ pytest.param(128, 24, 2, 1568, True, 7, False, id="tp1_r128"),
929
+ pytest.param(257, 6, 1, 800, True, 13, False, id="tp4_r257"),
930
+ pytest.param(513, 6, 1, 784, True, 17, False, id="tp4_r513"),
931
+ pytest.param(700, 6, 1, 800, True, 23, False, id="tp4_r700"),
932
+ pytest.param(1024, 24, 2, 1600, True, 33, False, id="tp1_r1024"),
933
+ pytest.param(2048, 24, 2, 1600, True, 63, False, id="tp1_r2048_prefill"),
934
+ pytest.param(2048, 24, 2, 1600, False, 63, False, id="tp1_r2048_uniform"),
935
+ # fp8_e4m3 K/V caches on the TP1 head split.
936
+ pytest.param(1, 24, 2, 1600, True, 2, True, id="tp1_r1_fp8"),
937
+ pytest.param(128, 24, 2, 1568, True, 7, True, id="tp1_r128_fp8"),
938
+ pytest.param(2048, 24, 2, 1600, True, 63, True, id="tp1_r2048_prefill_fp8"),
939
+ pytest.param(2048, 24, 2, 1600, False, 63, True, id="tp1_r2048_uniform_fp8"),
940
],
941
)
942
def test_qsa_sparse_paged_attention_correctness(
@@ -931,8 +946,18 @@ def test_qsa_sparse_paged_attention_correctness(
946
page_size: int,
947
use_prefill_config: bool,
948
num_requests: int,
949
+ fp8: bool,
950
) -> None:
951
+ """QSA sparse paged attention matches the dense reference.
952
+
953
+ fp8 only changes the K/V cache dtype (e4m3 with a per-tensor scale pair) and
954
+ the scales; the reference dequantizes the same cache with those scales, so
955
+ both paths compare the production kernel against the reference on identical
956
+ inputs. fp8=True additionally covers the host-side scale folding.
957
+ """
958
torch.manual_seed(2)
959
+ # One QSA attention problem: bf16 Q and paged K/V, a packed selection with
960
+ # the trailing count column, block table and row-to-request map.
961
head_dim = 256
962
num_selected_pages = 64
963
# Keep the newest page outside the synthetic top-k as causal headroom.
@@ -1013,8 +1038,18 @@ def test_qsa_sparse_paged_attention_correctness(
1038
indexer_budget,
1039
logical_indices,
1040
)
1016
- assert logical_indices.shape == (num_rows, selection_width + 1)
1017
- scale = q.shape[-1] ** -0.5
1041
+
1042
+ scale = head_dim**-0.5
1043
+
1044
+ if fp8:
1045
+ # A fixed non-unit pair (k != v) exercises the host-side scale folding
1046
+ # and catches a k/v swap; scales are host floats, as the layer exposes
1047
+ # them. Stored values are the scaled ones, as reshape_and_cache does.
1048
+ k_scale, v_scale = 0.5, 2.0
1049
+ k_cache = (k_cache / k_scale).to(torch.float8_e4m3fn)
1050
+ v_cache = (v_cache / v_scale).to(torch.float8_e4m3fn)
1051
+ else:
1052
+ k_scale, v_scale = 1.0, 1.0
1053
1054
actual = qsa_ops.qsa_sparse_paged_attention(
1055
q,
@@ -1024,6 +1059,8 @@ def test_qsa_sparse_paged_attention_correctness(
1059
block_table,
1060
token_to_req,
1061
use_prefill_config=use_prefill_config,
1062
+ k_scale=k_scale,
1063
+ v_scale=v_scale,
1064
output_gate=output_gate,
1065
)
1066
expected = _qsa_sparse_paged_attention_reference(
@@ -1034,6 +1071,8 @@ def test_qsa_sparse_paged_attention_correctness(
1071
block_table,
1072
token_to_req,
1073
scale,
1074
+ k_scale=k_scale,
1075
+ v_scale=v_scale,
1076
)
1077
expected = expected * torch.sigmoid(output_gate)
1078
vllm/models/qwen4_exp/nvidia/ops/qsa.py
+124
-16
@@ -4,15 +4,24 @@
4
5
from __future__ import annotations
6
7
+from functools import lru_cache
8
+
9
import torch
10
11
from vllm.model_executor.warmup.jit_warmup_triton_helper import (
12
TritonWarmupTensor,
13
triton_scalar_specialization_rep,
14
)
15
+from vllm.platforms import current_platform
16
from vllm.triton_utils import HAS_TRITON, tl, triton
17
18
19
+@lru_cache(maxsize=1)
20
+def _is_sm120() -> bool:
21
+ """True on sm_120 (RTX PRO 6000 Blackwell): selects the sm_120 tuning table."""
22
+ return current_platform.get_device_capability() == (12, 0)
23
+
24
+
25
@triton.jit(do_not_specialize=["num_rows", "num_requests"])
26
def _qsa_sparse_paged_gqa_splitk_kernel(
27
q_ptr,
@@ -24,6 +33,8 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
33
partial_output_ptr,
34
partial_lse_ptr,
35
output_ptr,
36
+ softmax_scale,
37
+ output_scale,
38
output_gate_ptr,
39
stride_q_row,
40
stride_q_head,
@@ -52,6 +63,7 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
63
NUM_TILES: tl.constexpr,
64
BLOCK_M: tl.constexpr,
65
BLOCK_N: tl.constexpr,
66
+ IS_FP8: tl.constexpr,
67
) -> None:
68
row = tl.program_id(0)
69
kv_head = tl.program_id(1)
@@ -81,7 +93,10 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
93
max_value = tl.full((BLOCK_M,), -1.0e20, dtype=tl.float32)
94
normalizer = tl.zeros((BLOCK_M,), dtype=tl.float32)
95
accumulator = tl.zeros((BLOCK_M, HEAD_DIM), dtype=tl.float32)
84
- softmax_scale_log2: tl.constexpr = (HEAD_DIM**-0.5) * 1.4426950408889634
96
+ # softmax_scale is the host-side attention scale (1/sqrt(head_dim), with the
97
+ # fp8 K dequant scale already pre-multiplied in); convert to log2 units once
98
+ # here for the exp2-based online softmax.
99
+ score_scale = softmax_scale * 1.4426950408889634
100
101
tile_end = tl.minimum(NUM_TILES, tl.cdiv(tl.minimum(valid_count, TOPK), BLOCK_N))
102
@@ -129,15 +144,26 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
144
mask=valid[:, None],
145
other=0.0,
146
)
147
+ if IS_FP8:
148
+ # e4m3 -> Q dtype is exact; keep the QK dot in Q's dtype (fp8 QK
149
+ # measured slower here and less accurate).
150
+ keys = keys.to(query.dtype)
151
scores = tl.dot(query, keys)
133
- # Scaling scores avoids re-quantizing a scaled query to BF16.
134
- scores *= softmax_scale_log2
152
+ # Scaling scores avoids re-quantizing a scaled query to BF16; for fp8
153
+ # caches the K dequant scale is already folded into softmax_scale on the
154
+ # host.
155
+ scores *= score_scale
156
scores = tl.where(valid[None, :], scores, -1.0e20)
157
next_max = tl.maximum(max_value, tl.max(scores, axis=1))
158
alpha = tl.math.exp2(max_value - next_max)
159
probabilities = tl.where(
160
valid[None, :], tl.math.exp2(scores - next_max[:, None]), 0.0
161
)
162
+ if IS_FP8:
163
+ # Dequant V to fp16 (not bf16) for the PV dot: P <= 1 (online
164
+ # softmax) so fp16 has the range, its wider mantissa is more
165
+ # accurate, and the fp8->fp16 upcast with an fp16 PV dot is faster.
166
+ values = values.to(tl.float16)
167
accumulator = tl.dot(
168
probabilities.to(values.dtype),
169
values,
@@ -147,9 +173,14 @@ def _qsa_sparse_paged_gqa_splitk_kernel(
173
max_value = next_max
174
175
has_values = normalizer > 0
176
+ # Fold the fp8 V dequant scale (output_scale, 1.0 for bf16) into a per-row
177
+ # reciprocal normalizer, so the output is a per-row multiply rather than a
178
+ # HEAD_DIM-wide scale. The split-K LSE below keeps the unscaled normalizer,
179
+ # so the merge stays correct.
180
+ inv_normalizer = output_scale / tl.maximum(normalizer, 1.0e-20)
181
normalized_output = tl.where(
182
has_values[:, None],
152
- accumulator / tl.maximum(normalizer[:, None], 1.0e-20),
183
+ accumulator * inv_normalizer[:, None],
184
0.0,
185
)
186
output_mask = head_offsets[:, None] < GROUP_SIZE
@@ -439,18 +470,65 @@ def _compress_qsa_groups_kernel(
470
)
471
472
473
+def _select_sm120_config(
474
+ base_programs: int, use_prefill_config: bool, is_fp8: bool
475
+) -> tuple[int, int, int]:
476
+ """(block_n, target_splits, num_warps) retuned on sm_120 (RTX PRO 6000
477
+ Blackwell), split by cache dtype: bf16 and fp8 favour different configs on
478
+ sm_120, most visibly on the large-prefill region. Each entry is the fastest
479
+ config that stays correct on its own path; the main win is more warps."""
480
+ if is_fp8:
481
+ if base_programs > 2048:
482
+ return (32, 2, 1) if use_prefill_config else (64, 1, 2)
483
+ if base_programs <= 24:
484
+ return 64, 64, 8
485
+ if base_programs <= 32:
486
+ return 128, 8, 4
487
+ if base_programs <= 64:
488
+ return 64, 8, 4
489
+ if base_programs <= 128:
490
+ return 32, 4, 4
491
+ if base_programs <= 256:
492
+ return 128, 4, 4
493
+ if base_programs <= 512:
494
+ return 64, 4, 4
495
+ return 32, 2, 1
496
+ # bf16 K/V cache.
497
+ if base_programs > 2048:
498
+ return 64, 2, 2
499
+ if base_programs <= 24:
500
+ return 64, 64, 8
501
+ if base_programs <= 64:
502
+ return 64, 8, 8
503
+ if base_programs <= 128:
504
+ return 32, 8, 8
505
+ if base_programs <= 256:
506
+ return 32, 8, 1
507
+ if base_programs <= 512:
508
+ return 64, 4, 2
509
+ return 32, 4, 1
510
+
511
+
512
def _select_config(
443
- num_rows: int, num_kv_heads: int, use_prefill_config: bool, num_columns: int
513
+ num_rows: int,
514
+ num_kv_heads: int,
515
+ use_prefill_config: bool,
516
+ num_columns: int,
517
+ is_fp8: bool = False,
518
) -> tuple[int, int, int, int]:
519
"""Select (block_n, num_warps, num_tiles, num_splits) for the kernel.
520
447
- Tuned on GB300 for the Qwen3.8-Flash-Next TP1/TP2/TP4 shapes, keyed on
448
- base_programs = num_rows * num_kv_heads. The bp > 2048 region splits on
449
- use_prefill_config (capture-stable: at FULL-graph capture max_query_len is the
450
- uniform decode/verify length).
521
+ Keyed on base_programs = num_rows * num_kv_heads. The bp > 2048 region splits
522
+ on use_prefill_config (capture-stable: at FULL-graph capture max_query_len is
523
+ the uniform decode/verify length). This default table was tuned on GB300;
524
+ sm_120 (RTX PRO 6000 Blackwell) dispatches to _select_sm120_config instead.
525
"""
526
base_programs = num_rows * num_kv_heads
453
- if base_programs > 2048:
527
+ if _is_sm120():
528
+ BLOCK_N, target_splits, num_warps = _select_sm120_config(
529
+ base_programs, use_prefill_config, is_fp8
530
+ )
531
+ elif base_programs > 2048:
532
BLOCK_N, target_splits, num_warps = (
533
(32, 1, 1) if use_prefill_config else (64, 1, 2)
534
)
@@ -483,10 +561,17 @@ def qsa_sparse_paged_attention(
561
token_to_req: torch.Tensor,
562
use_prefill_config: bool,
563
out: torch.Tensor | None = None,
564
+ k_scale: float | None = None,
565
+ v_scale: float | None = None,
566
*,
567
output_gate: torch.Tensor,
568
) -> torch.Tensor:
489
- """Run sparse GQA directly over paged BF16 K/V caches.
569
+ """Run sparse GQA directly over paged BF16 or FP8-e4m3 K/V caches.
570
+
571
+ With fp8 caches, k_scale/v_scale are the layer's per-tensor dequant scales
572
+ as host floats (e.g. layer._k_scale_float/_v_scale_float): k_scale is
573
+ pre-multiplied into the softmax scale and v_scale becomes the kernel's
574
+ output scale, so the kernel needs no device scale buffers.
575
576
logical_indices is the PACKED selection buffer: [rows, selection_width + 1]
577
with the trailing column holding each row's valid-entry count (written by
@@ -510,7 +595,19 @@ def qsa_sparse_paged_attention(
595
raise ValueError("QSA sparse attention requires valid grouped-query heads")
596
head_dim = q.shape[2]
597
assert head_dim >= 16 and (head_dim & (head_dim - 1)) == 0
513
- assert q.dtype == k_cache.dtype == v_cache.dtype == torch.bfloat16
598
+ assert q.dtype == torch.bfloat16
599
+ assert k_cache.dtype == v_cache.dtype
600
+ is_fp8 = k_cache.dtype == torch.float8_e4m3fn
601
+ if is_fp8:
602
+ assert k_scale is not None and v_scale is not None
603
+ # Host pre-multiply: fold the K dequant scale into the attention scale
604
+ # and pass V's dequant scale as the kernel's output scale.
605
+ softmax_scale = (head_dim**-0.5) * float(k_scale)
606
+ output_scale = float(v_scale)
607
+ else:
608
+ assert k_cache.dtype == torch.bfloat16
609
+ softmax_scale = head_dim**-0.5
610
+ output_scale = 1.0
611
assert logical_indices.dtype == block_table.dtype == torch.int32
612
assert token_to_req.dtype == torch.int32
613
assert q.device == k_cache.device == v_cache.device
@@ -535,7 +632,7 @@ def qsa_sparse_paged_attention(
632
block_m = triton.next_power_of_2(group_size)
633
selection_width = logical_indices.shape[1] - 1 # trailing column is the count
634
block_n, partial_warps, num_tiles, num_splits = _select_config(
538
- q.shape[0], k_cache.shape[2], use_prefill_config, selection_width
635
+ q.shape[0], k_cache.shape[2], use_prefill_config, selection_width, is_fp8
636
)
637
638
# Split=1 writes output directly and compiles out all workspace accesses.
@@ -565,6 +662,8 @@ def qsa_sparse_paged_attention(
662
partial_output,
663
partial_lse,
664
out,
665
+ softmax_scale,
666
+ output_scale,
667
output_gate_view,
668
q.stride(0),
669
q.stride(1),
@@ -593,6 +692,7 @@ def qsa_sparse_paged_attention(
692
NUM_TILES=num_tiles,
693
BLOCK_M=block_m,
694
BLOCK_N=block_n,
695
+ IS_FP8=is_fp8,
696
num_warps=partial_warps,
697
num_stages=2,
698
)
@@ -630,13 +730,18 @@ def warmup_qsa_sparse_paged_attention(
730
731
head_dim = kv_cache.shape[-1] // 2
732
key_cache, value_cache = kv_cache.transpose(1, 2).split(head_dim, dim=-1)
733
+ # An fp8 cache is allocated as uint8 and viewed as e4m3 at attention time.
734
+ is_fp8 = kv_cache.dtype == torch.uint8
735
+ cache_dtype = torch.float8_e4m3fn if is_fp8 else key_cache.dtype
736
num_kv_heads = key_cache.shape[2]
737
group_size = num_query_heads // num_kv_heads
738
block_m = triton.next_power_of_2(group_size)
739
740
# Every config the dispatch can pick for this group size.
741
profiles = {
639
- _select_config(num_rows, num_kv_heads, use_prefill_config, selection_width)
742
+ _select_config(
743
+ num_rows, num_kv_heads, use_prefill_config, selection_width, is_fp8
744
+ )
745
for num_rows in range(1, 8193)
746
for use_prefill_config in (False, True)
747
}
@@ -650,12 +755,12 @@ def warmup_qsa_sparse_paged_attention(
755
torch.bfloat16, shape=(num_rows, num_query_heads, head_dim)
756
)
757
k_cache_ptr = TritonWarmupTensor(
653
- key_cache.dtype,
758
+ cache_dtype,
759
shape=tuple(key_cache.shape),
760
strides=tuple(key_cache.stride()),
761
)
762
v_cache_ptr = TritonWarmupTensor(
658
- value_cache.dtype,
763
+ cache_dtype,
764
shape=tuple(value_cache.shape),
765
strides=tuple(value_cache.stride()),
766
)
@@ -701,6 +806,8 @@ def warmup_qsa_sparse_paged_attention(
806
partial_output_ptr,
807
partial_lse_ptr,
808
output_ptr,
809
+ 1.0,
810
+ 1.0,
811
output_gate_ptr,
812
row_stride,
813
head_stride,
@@ -729,6 +836,7 @@ def warmup_qsa_sparse_paged_attention(
836
NUM_TILES=num_tiles,
837
BLOCK_M=block_m,
838
BLOCK_N=block_n,
839
+ IS_FP8=is_fp8,
840
num_warps=warps,
841
num_stages=2,
842
grid=(num_rows, num_kv_heads, num_splits),
vllm/models/qwen4_exp/nvidia/qsa.py
+106
-11
@@ -24,6 +24,7 @@ from vllm.model_executor.layers.quantization import QuantizationConfig
24
from vllm.model_executor.layers.rotary_embedding import MRotaryEmbedding, get_rope
25
from vllm.model_executor.models.qwen3_next import Qwen3NextAttention
26
from vllm.platforms import current_platform
27
+from vllm.platforms.interface import DeviceCapability
28
from vllm.transformers_utils.configs.qwen4_exp import (
29
Qwen4ExpTextConfig,
30
)
@@ -64,7 +65,38 @@ class Qwen4ExpQSAFlashAttentionBackend(FlashAttentionBackend):
65
"""FullAttentionSpec backend used by the merged QSA owner."""
66
67
supported_dtypes: ClassVar[list[torch.dtype]] = [torch.bfloat16]
67
- supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = ["auto", "bfloat16"]
68
+ # fp8/fp8_e4m3: e4m3 bytes in a uint8 cache, written by reshape_and_cache
69
+ # with the layer's per-tensor scales and dequantized on load inside the QSA
70
+ # Triton kernel. flash-attn never runs over this cache, so its fp8 probe
71
+ # does not apply (see supports_kv_cache_dtype and the impl constructor).
72
+ supported_kv_cache_dtypes: ClassVar[list[CacheDType]] = [
73
+ "auto",
74
+ "bfloat16",
75
+ "fp8",
76
+ "fp8_e4m3",
77
+ ]
78
+
79
+ @classmethod
80
+ def supports_kv_cache_dtype(cls, kv_cache_dtype: CacheDType | None) -> bool:
81
+ return kv_cache_dtype is None or kv_cache_dtype in cls.supported_kv_cache_dtypes
82
+
83
+ @classmethod
84
+ def supports_combination(
85
+ cls,
86
+ head_size: int,
87
+ dtype: torch.dtype,
88
+ kv_cache_dtype: CacheDType | None,
89
+ block_size: int | None,
90
+ use_mla: bool,
91
+ has_sink: bool,
92
+ use_sparse: bool,
93
+ use_mm_prefix: bool,
94
+ device_capability: DeviceCapability,
95
+ ) -> str | None:
96
+ # QSA dequantizes the fp8 KV in its own Triton kernel and never runs
97
+ # flash-attn over the quantized cache, so the parent's fp8-KV rejection
98
+ # does not apply and every combination it is handed is accepted here.
99
+ return None
100
101
@staticmethod
102
def get_name() -> str:
@@ -98,16 +130,53 @@ class Qwen4ExpQSAFlashAttentionImpl(FlashAttentionImpl):
130
supports_dcp: bool = False
131
supports_pcp: bool = False
132
101
- def __init__(self, *args, **kwargs) -> None:
102
- super().__init__(*args, **kwargs)
133
+ def __init__(
134
+ self,
135
+ num_heads: int,
136
+ head_size: int,
137
+ scale: float,
138
+ num_kv_heads: int,
139
+ alibi_slopes: list[float] | None,
140
+ sliding_window: int | None,
141
+ kv_cache_dtype: str,
142
+ logits_soft_cap: float | None = None,
143
+ attn_type: AttentionType = AttentionType.DECODER,
144
+ kv_sharing_target_layer_name: str | None = None,
145
+ sinks: torch.Tensor | None = None,
146
+ ) -> None:
147
+ # The parent constructor probes flash-attn for quantized-KV support and
148
+ # raises where it is unavailable (sm120), but QSA dequantizes fp8 inside
149
+ # its own Triton kernel and never runs flash-attn over the cache. Hand
150
+ # the parent "auto" for that probe and restore the real dtype afterwards:
151
+ # the parent only uses it there, and do_kv_cache_update reads the
152
+ # attribute at call time.
153
+ real_kv_cache_dtype = kv_cache_dtype
154
+ if kv_cache_dtype in ("fp8", "fp8_e4m3"):
155
+ kv_cache_dtype = "auto"
156
+ super().__init__(
157
+ num_heads,
158
+ head_size,
159
+ scale,
160
+ num_kv_heads,
161
+ alibi_slopes,
162
+ sliding_window,
163
+ kv_cache_dtype,
164
+ logits_soft_cap,
165
+ attn_type,
166
+ kv_sharing_target_layer_name,
167
+ sinks,
168
+ )
169
+ self.kv_cache_dtype = real_kv_cache_dtype
170
if not is_flash_attn_varlen_func_available():
171
raise NotImplementedError("Qwen4Exp QSA requires FlashAttention")
172
if self.dcp_world_size != 1:
173
raise NotImplementedError(
174
"Qwen4Exp QSA does not support decode context parallelism"
175
)
109
- if self.kv_cache_dtype not in ("auto", "bfloat16"):
110
- raise NotImplementedError("Qwen4Exp QSA requires a BF16 main KV cache")
176
+ if self.kv_cache_dtype not in ("auto", "bfloat16", "fp8", "fp8_e4m3"):
177
+ raise NotImplementedError(
178
+ "Qwen4Exp QSA requires a BF16 or FP8-e4m3 main KV cache"
179
+ )
180
self.supports_quant_query_input = False
181
182
def forward_qsa(
@@ -144,8 +213,23 @@ class Qwen4ExpQSAFlashAttentionImpl(FlashAttentionImpl):
213
logical_indices = topk_buffer[:num_tokens]
214
token_to_req = token_to_req[:num_tokens]
215
key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
147
- if key_cache.dtype != torch.bfloat16 or query.dtype != torch.bfloat16:
148
- raise NotImplementedError("Qwen4Exp QSA requires BF16 Q/K/V")
216
+ k_scale = v_scale = None
217
+ if self.kv_cache_dtype in ("fp8", "fp8_e4m3"):
218
+ # The cache is allocated as uint8; reinterpret the e4m3 bytes
219
+ # (same itemsize, so shape and strides are preserved).
220
+ key_cache = key_cache.view(torch.float8_e4m3fn)
221
+ value_cache = value_cache.view(torch.float8_e4m3fn)
222
+ # Host-side per-tensor dequant scales (Python floats), as used by
223
+ # other host-scale backends; folded into the kernel's scales.
224
+ k_scale = layer._k_scale_float
225
+ v_scale = layer._v_scale_float
226
+ if query.dtype != torch.bfloat16 or key_cache.dtype not in (
227
+ torch.bfloat16,
228
+ torch.float8_e4m3fn,
229
+ ):
230
+ raise NotImplementedError(
231
+ "Qwen4Exp QSA requires BF16 Q and BF16 or FP8-e4m3 K/V"
232
+ )
233
234
from .ops.qsa import qsa_sparse_paged_attention
235
@@ -158,6 +242,8 @@ class Qwen4ExpQSAFlashAttentionImpl(FlashAttentionImpl):
242
token_to_req,
243
use_prefill_config,
244
output[:num_tokens],
245
+ k_scale=k_scale,
246
+ v_scale=v_scale,
247
output_gate=output_gate[:num_tokens],
248
)
249
return output
@@ -185,8 +271,15 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
271
raise ValueError("Qwen4Exp QSA requires a paged KV cache")
272
if model_config.dtype != torch.bfloat16:
273
raise NotImplementedError("Qwen4Exp QSA currently requires BF16")
188
- if cache_config.cache_dtype not in ("auto", "bfloat16"):
189
- raise NotImplementedError("Qwen4Exp QSA requires a BF16 main KV cache")
274
+ if cache_config.cache_dtype not in (
275
+ "auto",
276
+ "bfloat16",
277
+ "fp8",
278
+ "fp8_e4m3",
279
+ ):
280
+ raise NotImplementedError(
281
+ "Qwen4Exp QSA requires a BF16 or FP8-e4m3 main KV cache"
282
+ )
283
if getattr(quant_config, "kv_cache_scheme", None) is not None:
284
raise NotImplementedError("Qwen4Exp QSA does not support KV quantization")
285
parallel_config = vllm_config.parallel_config
@@ -285,8 +378,10 @@ class Qwen4ExpQSAAttention(Qwen3NextAttention, AttentionLayerBase):
378
self.kv_cache_torch_dtype = kv_cache_dtype_str_to_dtype(
379
self.kv_cache_dtype, model_config
380
)
288
- if self.kv_cache_torch_dtype != torch.bfloat16:
289
- raise NotImplementedError("Qwen4Exp QSA requires BF16 cache storage")
381
+ if self.kv_cache_torch_dtype not in (torch.bfloat16, torch.uint8):
382
+ raise NotImplementedError(
383
+ "Qwen4Exp QSA requires BF16 or FP8-e4m3 (uint8) cache storage"
384
+ )
385
self.kv_sharing_target_layer_name = None
386
self.kv_cache = torch.tensor([])
387
set_default_quant_scales(self, register_buffer=True)