[Feature] Triton INT4 per-token-head KV cache quantization (#40835)
Signed-off-by: JartX <sagformas@epdcenter.es> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
JartX committed
Jun 24, 2026 at 12:21 UTC
70749fdcca7da31be7206101e6f6fc77de9e6839
10 files changed
+1463
-112
docs/design/attention_backends.md
+1
-1
@@ -170,7 +170,7 @@ Priority is **1 = highest** (tried first).
170
| `ROCM_AITER_FA` | | fp16, bf16 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | 16, 32 | 64, 128, 256 | ✅ | ✅ | ❌ | ❌ | Decoder | N/A |
171
| `ROCM_AITER_UNIFIED_ATTN` | | fp16, bf16 | `auto` | %16 | Any | ✅ | ❌ | ✅ | ❌ | All | N/A |
172
| `ROCM_ATTN` | | fp16, bf16, fp32 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2` | %16 | 32, 64, 80, 96, 128, 160, 192, 224, 256 | ❌ | ✅ | ✅ | ❌ | Decoder, Encoder, Encoder Only | N/A |
173
-| `TRITON_ATTN` | | fp16, bf16, fp32 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2`, `int8_per_token_head`, `fp8_per_token_head` | %16 | Any | ✅ | ✅ | ✅ | ❌ | All | Any |
173
+| `TRITON_ATTN` | | fp16, bf16, fp32 | `auto`, `float16`, `bfloat16`, `fp8`, `fp8_e4m3`, `fp8_e5m2`, `int4_per_token_head`, `int8_per_token_head`, `fp8_per_token_head` | %16 | Any | ✅ | ✅ | ✅ | ❌ | All | Any |
174
| `TRITON_ATTN_DIFFKV` | | fp16, bf16 | `auto`, `bfloat16` | Any | Any | ❌ | ❌ | ❌ | ❌ | Decoder | Any |
175
| `TURBOQUANT` | | fp16, bf16 | `turboquant_k8v4`, `turboquant_4bit_nc`, `turboquant_k3v4_nc`, `turboquant_3bit_nc` | 16, 32, 64, 128 | Any | ❌ | ❌ | ❌ | ❌ | Decoder | Any |
176
tests/models/quantization/test_per_token_kv_cache.py
+2
-1
@@ -31,7 +31,8 @@ from ..utils import check_logprobs_close
31
],
32
)
33
@pytest.mark.parametrize(
34
- "kv_cache_dtype", ["int8_per_token_head", "fp8_per_token_head"]
34
+ "kv_cache_dtype",
35
+ ["int4_per_token_head", "int8_per_token_head", "fp8_per_token_head"],
36
)
37
@pytest.mark.parametrize("max_tokens", [4])
38
@pytest.mark.parametrize("enforce_eager", [True])
tests/quantization/test_per_token_kv_cache.py
+185
-70
@@ -1,6 +1,6 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""Tests for per-token-head KV cache quantization (INT8 and FP8).
3
+"""Tests for per-token-head KV cache quantization (INT4, INT8 and FP8).
4
5
Covers:
6
- Per-token-head Triton reshape-and-cache kernel
@@ -23,6 +23,7 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
23
)
24
from vllm.platforms import current_platform
25
from vllm.utils.torch_utils import set_random_seed
26
+from vllm.v1.attention.ops.int4_per_token_head import single_rht
27
from vllm.v1.kv_cache_interface import KVQuantMode, is_quantized_kv_cache
28
29
DEVICE_TYPE = current_platform.device_type
@@ -81,11 +82,19 @@ FP8_CONFIG = QuantConfig(
82
kv_quant_mode=KVQuantMode.FP8_PER_TOKEN_HEAD,
83
rounds_before_store=False,
84
)
84
-
85
-QUANT_CONFIGS = [INT8_CONFIG, FP8_CONFIG]
85
+INT4_CONFIG = QuantConfig(
86
+ cache_dtype=torch.uint8,
87
+ kv_cache_dtype_str="int4_per_token_head",
88
+ quant_max=7.0,
89
+ quant_min=-8.0,
90
+ kv_quant_mode=KVQuantMode.INT4_PER_TOKEN_HEAD,
91
+ # Unused for int4 (handled by its own rint path); kept for the dataclass.
92
+ rounds_before_store=False,
93
+)
94
+QUANT_CONFIGS = [INT4_CONFIG, INT8_CONFIG, FP8_CONFIG]
95
96
88
-@pytest.fixture(params=QUANT_CONFIGS, ids=["int8", "fp8"])
97
+@pytest.fixture(params=QUANT_CONFIGS, ids=["int4", "int8", "fp8"])
98
def qcfg(request) -> QuantConfig:
99
return request.param
100
@@ -120,6 +129,9 @@ class TestIsQuantizedKvCache:
129
assert is_quantized_kv_cache("fp8_e4m3")
130
assert is_quantized_kv_cache("fp8_e5m2")
131
132
+ def test_int4_per_token_head(self):
133
+ assert is_quantized_kv_cache("int4_per_token_head")
134
+
135
def test_int8_per_token_head(self):
136
assert is_quantized_kv_cache("int8_per_token_head")
137
@@ -132,6 +144,13 @@ class TestIsQuantizedKvCache:
144
def test_bfloat16(self):
145
assert not is_quantized_kv_cache("bfloat16")
146
147
+ def test_kv_quant_mode_int4(self):
148
+ from vllm.v1.kv_cache_interface import get_kv_quant_mode
149
+
150
+ assert (
151
+ get_kv_quant_mode("int4_per_token_head") == KVQuantMode.INT4_PER_TOKEN_HEAD
152
+ )
153
+
154
def test_kv_quant_mode_int8(self):
155
from vllm.v1.kv_cache_interface import get_kv_quant_mode
156
@@ -171,15 +190,17 @@ def test_reshape_and_cache_per_token_head(
190
torch.set_default_device(DEVICE_TYPE)
191
192
num_blocks = (num_tokens + block_size - 1) // block_size + 4
193
+ is_int4 = qcfg.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD
194
+ cache_head_size = head_size // 2 if is_int4 else head_size
195
196
key = torch.randn(num_tokens, num_heads, head_size, dtype=torch.bfloat16)
197
value = torch.randn(num_tokens, num_heads, head_size, dtype=torch.bfloat16)
198
199
key_cache = torch.zeros(
179
- num_blocks, block_size, num_heads, head_size, dtype=qcfg.cache_dtype
200
+ num_blocks, block_size, num_heads, cache_head_size, dtype=qcfg.cache_dtype
201
)
202
value_cache = torch.zeros(
182
- num_blocks, block_size, num_heads, head_size, dtype=qcfg.cache_dtype
203
+ num_blocks, block_size, num_heads, cache_head_size, dtype=qcfg.cache_dtype
204
)
205
k_scale_cache = torch.ones(num_blocks, block_size, num_heads, dtype=torch.float32)
206
v_scale_cache = torch.ones(num_blocks, block_size, num_heads, dtype=torch.float32)
@@ -197,45 +218,68 @@ def test_reshape_and_cache_per_token_head(
218
k_scale_cache,
219
v_scale_cache,
220
slot_mapping,
221
+ kv_quant_mode=qcfg.kv_quant_mode,
222
)
223
202
- # Reference
203
- ref_k_quant, ref_k_scales = _quantize_per_token_head_ref(key, qcfg)
204
- ref_v_quant, ref_v_scales = _quantize_per_token_head_ref(value, qcfg)
224
+ # INT4 (RHT + asymmetric), INT8/FP8 have different dequant paths. Only
225
+ # INT8/FP8 can be compared to a PyTorch reference.
226
+ if not is_int4:
227
+ ref_k_quant, ref_k_scales = _quantize_per_token_head_ref(key, qcfg)
228
+ ref_v_quant, ref_v_scales = _quantize_per_token_head_ref(value, qcfg)
229
206
- # Compare dequantized values rather than raw quantized values.
207
- # Triton and PyTorch reductions can differ at FP8 rounding boundaries
208
- # (up to 32 in quantized domain for fp8_e4m3), but the dequantized
209
- # error is bounded by the scale.
230
for i, slot in enumerate(slot_mapping.tolist()):
231
blk = slot // block_size
232
off = slot % block_size
233
214
- actual_k_scale = k_scale_cache[blk, off] # [num_heads]
215
- k_deq = key_cache[blk, off].float() * actual_k_scale[:, None]
216
- k_ref_deq = key[i].float()
217
- torch.testing.assert_close(
218
- k_deq,
219
- k_ref_deq,
220
- atol=0.1,
221
- rtol=0.1,
222
- )
223
- actual_v_scale = v_scale_cache[blk, off] # [num_heads]
224
- v_deq = value_cache[blk, off].float() * actual_v_scale[:, None]
225
- v_ref_deq = value[i].float()
226
- torch.testing.assert_close(
227
- v_deq,
228
- v_ref_deq,
229
- atol=0.1,
230
- rtol=0.1,
231
- )
232
- # Per-head scales: [num_heads]
233
- torch.testing.assert_close(
234
- k_scale_cache[blk, off], ref_k_scales[i], atol=1e-4, rtol=1e-3
235
- )
236
- torch.testing.assert_close(
237
- v_scale_cache[blk, off], ref_v_scales[i], atol=1e-4, rtol=1e-3
238
- )
234
+ if is_int4:
235
+ # Coarser quantization → wider tolerance.
236
+ deq_atol = deq_rtol = 0.5
237
+ for label, data, cache, sc in [
238
+ ("key", key, key_cache, k_scale_cache),
239
+ ("val", value, value_cache, v_scale_cache),
240
+ ]:
241
+ packed_scale = sc[blk, off] # [num_heads] float32
242
+ scale_bits = packed_scale.view(torch.int32)
243
+ zp = (scale_bits & 0xF).to(torch.float32)
244
+ clean_scale = (scale_bits & -16).view(torch.float32)
245
+
246
+ packed = cache[blk, off]
247
+ lo = (packed & 0xF).to(torch.float32)
248
+ hi = ((packed >> 4) & 0xF).to(torch.float32)
249
+ full = torch.zeros(num_heads, head_size, dtype=torch.float32)
250
+ full[:, 0::2] = lo
251
+ full[:, 1::2] = hi
252
+ # Asymmetric dequant in RHT domain, then IRHT/d → original
253
+ deq_rht = (full - zp[:, None]) * clean_scale[:, None]
254
+ deq = single_rht(deq_rht, inverse=True) / head_size
255
+ ref_deq = data[i].float()
256
+ torch.testing.assert_close(deq, ref_deq, atol=deq_atol, rtol=deq_rtol)
257
+ else:
258
+ actual_k_scale = k_scale_cache[blk, off] # [num_heads]
259
+ k_deq = key_cache[blk, off].float() * actual_k_scale[:, None]
260
+ k_ref_deq = key[i].float()
261
+ torch.testing.assert_close(
262
+ k_deq,
263
+ k_ref_deq,
264
+ atol=0.1,
265
+ rtol=0.1,
266
+ )
267
+ actual_v_scale = v_scale_cache[blk, off] # [num_heads]
268
+ v_deq = value_cache[blk, off].float() * actual_v_scale[:, None]
269
+ v_ref_deq = value[i].float()
270
+ torch.testing.assert_close(
271
+ v_deq,
272
+ v_ref_deq,
273
+ atol=0.1,
274
+ rtol=0.1,
275
+ )
276
+ # Per-head scales: [num_heads]
277
+ torch.testing.assert_close(
278
+ k_scale_cache[blk, off], ref_k_scales[i], atol=1e-4, rtol=1e-3
279
+ )
280
+ torch.testing.assert_close(
281
+ v_scale_cache[blk, off], ref_v_scales[i], atol=1e-4, rtol=1e-3
282
+ )
283
284
285
# ===========================================================================
@@ -265,16 +309,18 @@ def test_per_token_head_round_trip_accuracy(
309
torch.set_default_device(DEVICE_TYPE)
310
set_random_seed(42)
311
312
+ is_int4 = qcfg.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD
313
num_blocks = (num_tokens + block_size - 1) // block_size + 2
314
+ cache_head_size = head_size // 2 if is_int4 else head_size
315
316
key = torch.randn(num_tokens, num_heads, head_size, dtype=torch.bfloat16) * 0.5
317
value = torch.randn(num_tokens, num_heads, head_size, dtype=torch.bfloat16) * 0.5
318
319
key_cache = torch.zeros(
274
- num_blocks, block_size, num_heads, head_size, dtype=qcfg.cache_dtype
320
+ num_blocks, block_size, num_heads, cache_head_size, dtype=qcfg.cache_dtype
321
)
322
value_cache = torch.zeros(
277
- num_blocks, block_size, num_heads, head_size, dtype=qcfg.cache_dtype
323
+ num_blocks, block_size, num_heads, cache_head_size, dtype=qcfg.cache_dtype
324
)
325
k_scale_cache = torch.ones(num_blocks, block_size, num_heads, dtype=torch.float32)
326
v_scale_cache = torch.ones(num_blocks, block_size, num_heads, dtype=torch.float32)
@@ -289,8 +335,11 @@ def test_per_token_head_round_trip_accuracy(
335
k_scale_cache,
336
v_scale_cache,
337
slot_mapping,
338
+ kv_quant_mode=qcfg.kv_quant_mode,
339
)
340
341
+ rt_atol = 0.5 if is_int4 else 0.1
342
+
343
for i in range(num_tokens):
344
blk = i // block_size
345
off = i % block_size
@@ -300,18 +349,30 @@ def test_per_token_head_round_trip_accuracy(
349
("val", value, value_cache, v_scale_cache),
350
]:
351
for h in range(num_heads):
303
- orig = data[i, h].float() # [head_size]
304
-
305
- actual_q = cache[blk, off, h]
352
+ orig = data[i, h].float()
353
actual_sc = sc[blk, off, h]
307
- actual_deq = actual_q.float() * actual_sc
308
-
309
- # Round-trip: dequantized should be close to original
354
+ if is_int4:
355
+ sc_bits = actual_sc.view(torch.int32)
356
+ zp = (sc_bits & 0xF).to(torch.float32)
357
+ clean_sc = (sc_bits & -16).view(torch.float32)
358
+ packed = cache[blk, off, h]
359
+ lo = (packed & 0xF).to(torch.float32)
360
+ hi = ((packed >> 4) & 0xF).to(torch.float32)
361
+ full = torch.zeros(head_size)
362
+ full[0::2] = lo
363
+ full[1::2] = hi
364
+ deq_rht = (full - zp) * clean_sc
365
+ actual_deq = (
366
+ single_rht(deq_rht.unsqueeze(0), inverse=True).squeeze(0)
367
+ / head_size
368
+ )
369
+ else:
370
+ actual_deq = cache[blk, off, h].float() * actual_sc
371
torch.testing.assert_close(
372
actual_deq,
373
orig,
313
- atol=0.1,
314
- rtol=0.1,
374
+ atol=rt_atol,
375
+ rtol=rt_atol,
376
)
377
378
@@ -347,6 +408,7 @@ def test_int8_per_token_head_raw_cache_matches_round_reference():
408
k_scale_cache,
409
v_scale_cache,
410
slot_mapping,
411
+ kv_quant_mode=INT8_CONFIG.kv_quant_mode,
412
)
413
414
ref_k_quant, ref_k_scales = _quantize_per_token_head_ref(key, INT8_CONFIG)
@@ -377,15 +439,17 @@ def test_per_token_head_negative_slot_skipped(qcfg: QuantConfig):
439
head_size = 64
440
block_size = 16
441
num_blocks = 2
442
+ is_int4 = qcfg.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD
443
+ cache_head_size = head_size // 2 if is_int4 else head_size
444
445
key = torch.randn(num_tokens, num_heads, head_size, dtype=torch.bfloat16)
446
value = torch.randn(num_tokens, num_heads, head_size, dtype=torch.bfloat16)
447
448
key_cache = torch.zeros(
385
- num_blocks, block_size, num_heads, head_size, dtype=qcfg.cache_dtype
449
+ num_blocks, block_size, num_heads, cache_head_size, dtype=qcfg.cache_dtype
450
)
451
value_cache = torch.zeros(
388
- num_blocks, block_size, num_heads, head_size, dtype=qcfg.cache_dtype
452
+ num_blocks, block_size, num_heads, cache_head_size, dtype=qcfg.cache_dtype
453
)
454
k_scale_cache = torch.ones(num_blocks, block_size, num_heads, dtype=torch.float32)
455
v_scale_cache = torch.ones(num_blocks, block_size, num_heads, dtype=torch.float32)
@@ -403,6 +467,7 @@ def test_per_token_head_negative_slot_skipped(qcfg: QuantConfig):
467
k_scale_cache,
468
v_scale_cache,
469
slot_mapping,
470
+ kv_quant_mode=qcfg.kv_quant_mode,
471
)
472
473
# Slots 0 and 1 should have been written (tokens 0 and 2)
@@ -420,7 +485,8 @@ def test_per_token_head_negative_slot_skipped(qcfg: QuantConfig):
485
# 5. process_weights_after_loading -- per-token-head early return
486
# ===========================================================================
487
@pytest.mark.parametrize(
423
- "kv_cache_dtype", ["int8_per_token_head", "fp8_per_token_head"]
488
+ "kv_cache_dtype",
489
+ ["int4_per_token_head", "int8_per_token_head", "fp8_per_token_head"],
490
)
491
def test_process_weights_sets_placeholder_scales(kv_cache_dtype: str):
492
"""Per-token-head should set _k_scale=1.0, _v_scale=1.0
@@ -454,7 +520,7 @@ def test_process_weights_sets_placeholder_scales(kv_cache_dtype: str):
520
521
522
# ===========================================================================
457
-# 6. Triton unified_attention -- per-token-head scale cache (INT8 and FP8)
523
+# 6. Triton unified_attention -- per-token-head scale cache (INT4/INT8/FP8)
524
# ===========================================================================
525
@pytest.mark.parametrize(
526
"seq_lens",
@@ -481,6 +547,8 @@ def test_triton_unified_attention_per_token_head_scale(
547
torch.set_default_device(DEVICE_TYPE)
548
set_random_seed(0)
549
550
+ is_int4 = qcfg.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD
551
+
552
num_seqs = len(seq_lens)
553
query_lens = [s[0] for s in seq_lens]
554
kv_lens = [s[1] for s in seq_lens]
@@ -499,22 +567,68 @@ def test_triton_unified_attention_per_token_head_scale(
567
)
568
value_cache_bf16 = torch.randn_like(key_cache_bf16)
569
502
- # Per-token-head quantization: one scale per (block, slot, head)
503
- k_absmax = key_cache_bf16.float().abs().amax(dim=-1) # [..., num_kv_heads]
504
- v_absmax = value_cache_bf16.float().abs().amax(dim=-1)
505
- k_scale_cache = (k_absmax / qcfg.quant_max).clamp(min=1e-6).to(torch.float32)
506
- v_scale_cache = (v_absmax / qcfg.quant_max).clamp(min=1e-6).to(torch.float32)
507
-
508
- scaled_k = key_cache_bf16.float() / k_scale_cache[:, :, :, None]
509
- scaled_v = value_cache_bf16.float() / v_scale_cache[:, :, :, None]
510
- if qcfg.rounds_before_store:
511
- key_cache_q = (
512
- scaled_k.round().clamp(qcfg.quant_min, qcfg.quant_max).to(qcfg.cache_dtype)
570
+ if is_int4:
571
+ # Asymmetric quantization reference (matches the Triton kernel).
572
+ kf = key_cache_bf16.float()
573
+ vf = value_cache_bf16.float()
574
+ k_min = kf.amin(dim=-1)
575
+ k_max = kf.amax(dim=-1)
576
+ v_min = vf.amin(dim=-1)
577
+ v_max = vf.amax(dim=-1)
578
+ k_scale_cache = ((k_max - k_min) / 15.0).clamp(min=1e-6).to(torch.float32)
579
+ v_scale_cache = ((v_max - v_min) / 15.0).clamp(min=1e-6).to(torch.float32)
580
+ k_zp = (-k_min / k_scale_cache).round().clamp(0, 15)
581
+ v_zp = (-v_min / v_scale_cache).round().clamp(0, 15)
582
+
583
+ key_cache_q_full = (
584
+ (kf / k_scale_cache[..., None] + k_zp[..., None]).round().clamp(0, 15)
585
)
514
- value_cache_q = (
515
- scaled_v.round().clamp(qcfg.quant_min, qcfg.quant_max).to(qcfg.cache_dtype)
586
+ value_cache_q_full = (
587
+ (vf / v_scale_cache[..., None] + v_zp[..., None]).round().clamp(0, 15)
588
)
589
+
590
+ # Dequantized reference: x_hat = (q - zp) * scale
591
+ key_cache_deq = (key_cache_q_full - k_zp[..., None]) * k_scale_cache[..., None]
592
+ value_cache_deq = (value_cache_q_full - v_zp[..., None]) * v_scale_cache[
593
+ ..., None
594
+ ]
595
+
596
+ # Pack two uint4 values into one byte
597
+ def _pack_int4(data_float):
598
+ u = data_float.to(torch.uint8)
599
+ lo = u[..., 0::2]
600
+ hi = u[..., 1::2]
601
+ return (lo & 0xF) | ((hi & 0xF) << 4)
602
+
603
+ key_cache_q = _pack_int4(key_cache_q_full)
604
+ value_cache_q = _pack_int4(value_cache_q_full)
605
+
606
+ # Steganography: pack zp into low 4 bits of scale
607
+ k_zp_int = k_zp.to(torch.int32)
608
+ k_bits = k_scale_cache.view(torch.int32)
609
+ k_scale_cache = ((k_bits & -16) | (k_zp_int & 0xF)).view(torch.float32)
610
+ v_zp_int = v_zp.to(torch.int32)
611
+ v_bits = v_scale_cache.view(torch.int32)
612
+ v_scale_cache = ((v_bits & -16) | (v_zp_int & 0xF)).view(torch.float32)
613
else:
614
+ # Symmetric quantization for int8/fp8.
615
+ k_absmax = key_cache_bf16.float().abs().amax(dim=-1)
616
+ v_absmax = value_cache_bf16.float().abs().amax(dim=-1)
617
+ k_scale_cache = (k_absmax / qcfg.quant_max).clamp(min=1e-6).to(torch.float32)
618
+ v_scale_cache = (v_absmax / qcfg.quant_max).clamp(min=1e-6).to(torch.float32)
619
+ scaled_k = key_cache_bf16.float() / k_scale_cache[:, :, :, None]
620
+ scaled_v = value_cache_bf16.float() / v_scale_cache[:, :, :, None]
621
+
622
+ key_cache_q_full = scaled_k.round().clamp(qcfg.quant_min, qcfg.quant_max)
623
+ value_cache_q_full = scaled_v.round().clamp(qcfg.quant_min, qcfg.quant_max)
624
+
625
+ key_cache_deq = key_cache_q_full * k_scale_cache[:, :, :, None]
626
+ value_cache_deq = value_cache_q_full * v_scale_cache[:, :, :, None]
627
+
628
+ if not is_int4 and qcfg.rounds_before_store:
629
+ key_cache_q = key_cache_q_full.to(qcfg.cache_dtype)
630
+ value_cache_q = value_cache_q_full.to(qcfg.cache_dtype)
631
+ elif not is_int4:
632
key_cache_q = scaled_k.clamp(qcfg.quant_min, qcfg.quant_max).to(
633
qcfg.cache_dtype
634
)
@@ -522,10 +636,6 @@ def test_triton_unified_attention_per_token_head_scale(
636
qcfg.cache_dtype
637
)
638
525
- # Dequantized reference
526
- key_cache_deq = key_cache_q.float() * k_scale_cache[:, :, :, None]
527
- value_cache_deq = value_cache_q.float() * v_scale_cache[:, :, :, None]
528
-
639
cu_query_lens = torch.tensor([0] + query_lens, dtype=torch.int32).cumsum(
640
dim=0, dtype=torch.int32
641
)
@@ -605,4 +715,9 @@ def test_triton_unified_attention_per_token_head_scale(
715
softmax_segm_expsum=softmax_segm_expsum,
716
)
717
608
- torch.testing.assert_close(output_q, output_ref, atol=5e-2, rtol=5e-2)
718
+ # Coarser quantization → wider tolerance.
719
+ if is_int4:
720
+ atol, rtol = 0.5, 0.5
721
+ else:
722
+ atol, rtol = 5e-2, 5e-2
723
+ torch.testing.assert_close(output_q, output_ref, atol=atol, rtol=rtol)
vllm/config/cache.py
+1
@@ -29,6 +29,7 @@ CacheDType = Literal[
29
"turboquant_4bit_nc",
30
"turboquant_k3v4_nc",
31
"turboquant_3bit_nc",
32
+ "int4_per_token_head",
33
"int8_per_token_head",
34
"fp8_per_token_head",
35
"nvfp4",
vllm/utils/torch_utils.py
+1
@@ -39,6 +39,7 @@ STR_DTYPE_TO_TORCH_DTYPE = {
39
"fp8_e4m3": torch.uint8,
40
"fp8_e5m2": torch.uint8,
41
"int8": torch.int8,
42
+ "int4_per_token_head": torch.uint8,
43
"int8_per_token_head": torch.int8,
44
"fp8_per_token_head": torch.uint8,
45
"fp8_inc": torch.float8_e4m3fn,
vllm/v1/attention/backends/triton_attn.py
+33
-16
@@ -258,6 +258,7 @@ class TritonAttentionBackend(AttentionBackend):
258
"fp8",
259
"fp8_e4m3",
260
"fp8_e5m2",
261
+ "int4_per_token_head",
262
"int8_per_token_head",
263
"fp8_per_token_head",
264
]
@@ -301,9 +302,11 @@ class TritonAttentionBackend(AttentionBackend):
302
if block_size % 16 != 0:
303
raise ValueError("Block size must be a multiple of 16.")
304
if kv_cache_uses_per_token_head_scales(cache_dtype_str):
304
- # Pad head_size by sizeof(float32)/sizeof(cache_dtype) so
305
- # the per-head scale fits inline. The backend extracts
306
- # data[:head_size] and scale[head_size:] via typed views.
305
+ # Pad the head dim by sizeof(float32)/sizeof(cache_dtype) so the
306
+ # per-(token, head) scale fits inline after the quantized data;
307
+ # the backend extracts data[:head_size] and scale[head_size:] via
308
+ # typed views (see _ensure_scale_caches). INT4 packs two values
309
+ # per byte, so the data occupies only head_size // 2 bytes.
310
from vllm.utils.torch_utils import (
311
STR_DTYPE_TO_TORCH_DTYPE,
312
get_dtype_size,
@@ -311,7 +314,11 @@ class TritonAttentionBackend(AttentionBackend):
314
315
cache_dtype = STR_DTYPE_TO_TORCH_DTYPE[cache_dtype_str]
316
scale_pad = get_dtype_size(torch.float32) // get_dtype_size(cache_dtype)
314
- return (num_blocks, 2, block_size, num_kv_heads, head_size + scale_pad)
317
+ if get_kv_quant_mode(cache_dtype_str) == KVQuantMode.INT4_PER_TOKEN_HEAD:
318
+ data_head_size = head_size // 2
319
+ else:
320
+ data_head_size = head_size
321
+ return (num_blocks, 2, block_size, num_kv_heads, data_head_size + scale_pad)
322
return (num_blocks, 2, block_size, num_kv_heads, head_size)
323
324
@staticmethod
@@ -587,18 +594,14 @@ class TritonAttentionImpl(AttentionImpl):
594
layer,
595
)
596
590
- # Per-token-head quantized KV cache: use separate scale caches.
597
+ # Per-token-head quantized KV cache: handled by the core unified
598
+ # kernel, which dequantizes per-(token, head) inline via constexpr
599
+ # branches (INT8 / FP8) and dispatches to the packed INT4 kernel.
600
if self._is_per_token_head_quant:
592
- self._ensure_scale_caches(kv_cache)
593
- key_cache, value_cache = kv_cache.unbind(1)
594
- if key_cache.dtype == torch.uint8:
595
- key_cache = key_cache.view(self.fp8_dtype)
596
- value_cache = value_cache.view(self.fp8_dtype)
597
- q_descale = None
598
- k_descale = None
599
- v_descale = None
601
+ key_cache, value_cache = self._pth_key_value_caches(kv_cache)
602
k_scale_cache = self._k_scale_cache
603
v_scale_cache = self._v_scale_cache
604
+ q_descale = k_descale = v_descale = None
605
# FP8 per-tensor / auto path (original flow).
606
else:
607
key_cache, value_cache = kv_cache.unbind(1)
@@ -675,6 +678,17 @@ class TritonAttentionImpl(AttentionImpl):
678
679
return output
680
681
+ def _pth_key_value_caches(
682
+ self, kv_cache: torch.Tensor
683
+ ) -> tuple[torch.Tensor, torch.Tensor]:
684
+ """Per-token-head K/V cache views (ensures scale caches; FP8 retyped)."""
685
+ self._ensure_scale_caches(kv_cache)
686
+ key_cache, value_cache = kv_cache.unbind(1)
687
+ if self._kv_quant_mode == KVQuantMode.FP8_PER_TOKEN_HEAD:
688
+ key_cache = key_cache.view(self.fp8_dtype)
689
+ value_cache = value_cache.view(self.fp8_dtype)
690
+ return key_cache, value_cache
691
+
692
def _forward_encoder_attention(
693
self,
694
query: torch.Tensor,
@@ -737,7 +751,9 @@ class TritonAttentionImpl(AttentionImpl):
751
if self._is_per_token_head_quant:
752
self._ensure_scale_caches(kv_cache)
753
key_cache, value_cache = kv_cache.unbind(1)
740
- if key_cache.dtype == torch.uint8:
754
+ k_scale_cache = self._k_scale_cache
755
+ v_scale_cache = self._v_scale_cache
756
+ if self._kv_quant_mode == KVQuantMode.FP8_PER_TOKEN_HEAD:
757
key_cache = key_cache.view(self.fp8_dtype)
758
value_cache = value_cache.view(self.fp8_dtype)
759
triton_reshape_and_cache_flash_per_token_head_quant(
@@ -745,9 +761,10 @@ class TritonAttentionImpl(AttentionImpl):
761
value,
762
key_cache,
763
value_cache,
748
- self._k_scale_cache,
749
- self._v_scale_cache,
764
+ k_scale_cache,
765
+ v_scale_cache,
766
slot_mapping,
767
+ kv_quant_mode=self._kv_quant_mode,
768
)
769
return
770
# For decoder and cross-attention, use KV cache as before.
vllm/v1/attention/ops/int4_per_token_head.py
new
+1155
@@ -0,0 +1,1155 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Sub-byte packed (INT4) per-token-head KV cache mode.
4
+
5
+INT4 packs two 4-bit values per cache byte, pre-rotates with a single RHT,
6
+and hides a 4-bit zero-point in the scale's low mantissa bits — too
7
+different from the core kernel to share it. Owns the whole mode: nibble
8
+pack/unpack, the reshape (write) kernel, the split-dot attention (read)
9
+kernel, the RHT transform, and the public ``reshape_and_cache_int4`` /
10
+``unified_attention_int4`` entry points.
11
+"""
12
+
13
+from __future__ import annotations
14
+
15
+from typing import Any
16
+
17
+import torch
18
+
19
+from vllm.platforms import current_platform
20
+from vllm.triton_utils import tl, triton
21
+from vllm.v1.attention.ops.triton_attention_helpers import (
22
+ apply_alibi_to_score,
23
+ apply_softcap,
24
+ cdiv_fn,
25
+ compute_kv_seq_mask,
26
+ compute_tile_loop_bounds,
27
+ init_softmax_M,
28
+ load_qq_bias_tile,
29
+ resolve_seq_and_query_len,
30
+ softmax_step,
31
+ store_segm_reduce_scalars,
32
+)
33
+from vllm.v1.attention.ops.triton_unified_attention import reduce_segments
34
+
35
+float8_info = torch.finfo(current_platform.fp8_dtype())
36
+
37
+# 2 x int4 packed per storage byte.
38
+_INT4_PACKING_FACTOR = 2
39
+
40
+
41
+# ----------------------------------------------------------------------
42
+# Nibble pack / unpack (shared write+read format)
43
+# ----------------------------------------------------------------------
44
+
45
+
46
+@triton.jit
47
+def pack_int4_nibbles(lo, hi):
48
+ """Pack two uint8 values (each in [0, 15]) into one byte."""
49
+ return (lo & 0xF) | ((hi & 0xF) << 4)
50
+
51
+
52
+@triton.jit
53
+def unpack_int4_nibbles(packed):
54
+ """Split one packed byte into the (low, high) nibble pair as uint8."""
55
+ return packed & 0xF, (packed >> 4) & 0xF
56
+
57
+
58
+# ----------------------------------------------------------------------
59
+# Write path: RHT + pack + per-(token, head) scale
60
+# ----------------------------------------------------------------------
61
+
62
+
63
+@triton.jit
64
+def _reshape_cache_int4_kernel(
65
+ key_ptr,
66
+ value_ptr,
67
+ key_cache_ptr,
68
+ value_cache_ptr,
69
+ k_scale_cache_ptr,
70
+ v_scale_cache_ptr,
71
+ slot_mapping_ptr,
72
+ stride_key_tok: tl.int64,
73
+ stride_key_head: tl.int64,
74
+ stride_val_tok: tl.int64,
75
+ stride_val_head: tl.int64,
76
+ stride_kc_blk: tl.int64,
77
+ stride_kc_slot: tl.int64,
78
+ stride_kc_head: tl.int64,
79
+ stride_vc_blk: tl.int64,
80
+ stride_vc_slot: tl.int64,
81
+ stride_vc_head: tl.int64,
82
+ stride_ks_blk: tl.int64,
83
+ stride_ks_slot: tl.int64,
84
+ stride_ks_head: tl.int64,
85
+ stride_vs_blk: tl.int64,
86
+ stride_vs_slot: tl.int64,
87
+ stride_vs_head: tl.int64,
88
+ block_size: tl.constexpr,
89
+ head_size: tl.constexpr,
90
+ head_size_v: tl.constexpr,
91
+ PACKED_HEAD_PADDED: tl.constexpr,
92
+):
93
+ """INT4 asymmetric quantization with zero-point steganography."""
94
+ tok = tl.program_id(0)
95
+ head = tl.program_id(1)
96
+
97
+ slot = tl.load(slot_mapping_ptr + tok).to(tl.int64)
98
+ if slot < 0:
99
+ return
100
+
101
+ blk = slot // block_size
102
+ slot_in_blk = slot % block_size
103
+
104
+ half_offs = tl.arange(0, PACKED_HEAD_PADDED)
105
+ even_offs = half_offs * 2
106
+ odd_offs = half_offs * 2 + 1
107
+
108
+ half_k = head_size // 2
109
+ even_k_mask = even_offs < head_size
110
+ odd_k_mask = odd_offs < head_size
111
+ key_base = key_ptr + tok * stride_key_tok + head * stride_key_head
112
+
113
+ k_even = tl.load(key_base + even_offs, mask=even_k_mask, other=0.0).to(tl.float32)
114
+ k_odd = tl.load(key_base + odd_offs, mask=odd_k_mask, other=0.0).to(tl.float32)
115
+
116
+ k_min = tl.minimum(
117
+ tl.min(tl.where(even_k_mask, k_even, float("inf"))),
118
+ tl.min(tl.where(odd_k_mask, k_odd, float("inf"))),
119
+ )
120
+ k_max = tl.maximum(
121
+ tl.max(tl.where(even_k_mask, k_even, float("-inf"))),
122
+ tl.max(tl.where(odd_k_mask, k_odd, float("-inf"))),
123
+ )
124
+ k_scale = tl.maximum((k_max - k_min) / 15.0, 1e-6)
125
+ k_zp_f = tl.clamp(
126
+ tl.where(
127
+ -k_min / k_scale >= 0,
128
+ (-k_min / k_scale + 0.5).to(tl.int32),
129
+ (-k_min / k_scale - 0.5).to(tl.int32),
130
+ ).to(tl.float32),
131
+ 0.0,
132
+ 15.0,
133
+ )
134
+
135
+ inv_k = 1.0 / k_scale
136
+ k_even_s = k_even * inv_k + k_zp_f
137
+ k_odd_s = k_odd * inv_k + k_zp_f
138
+ k_even_q = tl.clamp(
139
+ tl.where(
140
+ k_even_s >= 0,
141
+ (k_even_s + 0.5).to(tl.int32),
142
+ (k_even_s - 0.5).to(tl.int32),
143
+ ).to(tl.float32),
144
+ 0.0,
145
+ 15.0,
146
+ )
147
+ k_odd_q = tl.clamp(
148
+ tl.where(
149
+ k_odd_s >= 0,
150
+ (k_odd_s + 0.5).to(tl.int32),
151
+ (k_odd_s - 0.5).to(tl.int32),
152
+ ).to(tl.float32),
153
+ 0.0,
154
+ 15.0,
155
+ )
156
+
157
+ k_zp_int = k_zp_f.to(tl.int32)
158
+ k_scale_bits = k_scale.to(tl.int32, bitcast=True)
159
+ k_scale_packed = ((k_scale_bits & -16) | (k_zp_int & 0xF)).to(
160
+ tl.float32, bitcast=True
161
+ )
162
+
163
+ tl.store(
164
+ k_scale_cache_ptr
165
+ + blk * stride_ks_blk
166
+ + slot_in_blk * stride_ks_slot
167
+ + head * stride_ks_head,
168
+ k_scale_packed,
169
+ )
170
+
171
+ k_packed = pack_int4_nibbles(k_even_q.to(tl.uint8), k_odd_q.to(tl.uint8))
172
+ tl.store(
173
+ key_cache_ptr
174
+ + blk * stride_kc_blk
175
+ + slot_in_blk * stride_kc_slot
176
+ + head * stride_kc_head
177
+ + half_offs,
178
+ k_packed,
179
+ mask=half_offs < half_k,
180
+ )
181
+
182
+ half_v = head_size_v // 2
183
+ even_v_mask = even_offs < head_size_v
184
+ odd_v_mask = odd_offs < head_size_v
185
+ val_base = value_ptr + tok * stride_val_tok + head * stride_val_head
186
+
187
+ v_even = tl.load(val_base + even_offs, mask=even_v_mask, other=0.0).to(tl.float32)
188
+ v_odd = tl.load(val_base + odd_offs, mask=odd_v_mask, other=0.0).to(tl.float32)
189
+
190
+ v_min = tl.minimum(
191
+ tl.min(tl.where(even_v_mask, v_even, float("inf"))),
192
+ tl.min(tl.where(odd_v_mask, v_odd, float("inf"))),
193
+ )
194
+ v_max = tl.maximum(
195
+ tl.max(tl.where(even_v_mask, v_even, float("-inf"))),
196
+ tl.max(tl.where(odd_v_mask, v_odd, float("-inf"))),
197
+ )
198
+ v_scale = tl.maximum((v_max - v_min) / 15.0, 1e-6)
199
+ v_zp_f = tl.clamp(
200
+ tl.where(
201
+ -v_min / v_scale >= 0,
202
+ (-v_min / v_scale + 0.5).to(tl.int32),
203
+ (-v_min / v_scale - 0.5).to(tl.int32),
204
+ ).to(tl.float32),
205
+ 0.0,
206
+ 15.0,
207
+ )
208
+
209
+ inv_v = 1.0 / v_scale
210
+ v_even_s = v_even * inv_v + v_zp_f
211
+ v_odd_s = v_odd * inv_v + v_zp_f
212
+ v_even_q = tl.clamp(
213
+ tl.where(
214
+ v_even_s >= 0,
215
+ (v_even_s + 0.5).to(tl.int32),
216
+ (v_even_s - 0.5).to(tl.int32),
217
+ ).to(tl.float32),
218
+ 0.0,
219
+ 15.0,
220
+ )
221
+ v_odd_q = tl.clamp(
222
+ tl.where(
223
+ v_odd_s >= 0,
224
+ (v_odd_s + 0.5).to(tl.int32),
225
+ (v_odd_s - 0.5).to(tl.int32),
226
+ ).to(tl.float32),
227
+ 0.0,
228
+ 15.0,
229
+ )
230
+
231
+ v_zp_int = v_zp_f.to(tl.int32)
232
+ v_scale_bits = v_scale.to(tl.int32, bitcast=True)
233
+ v_scale_packed = ((v_scale_bits & -16) | (v_zp_int & 0xF)).to(
234
+ tl.float32, bitcast=True
235
+ )
236
+
237
+ tl.store(
238
+ v_scale_cache_ptr
239
+ + blk * stride_vs_blk
240
+ + slot_in_blk * stride_vs_slot
241
+ + head * stride_vs_head,
242
+ v_scale_packed,
243
+ )
244
+
245
+ v_packed = pack_int4_nibbles(v_even_q.to(tl.uint8), v_odd_q.to(tl.uint8))
246
+ tl.store(
247
+ value_cache_ptr
248
+ + blk * stride_vc_blk
249
+ + slot_in_blk * stride_vc_slot
250
+ + head * stride_vc_head
251
+ + half_offs,
252
+ v_packed,
253
+ mask=half_offs < half_v,
254
+ )
255
+
256
+
257
+def _run_reshape_kernel(
258
+ kernel,
259
+ *,
260
+ key: torch.Tensor,
261
+ value: torch.Tensor,
262
+ key_cache: torch.Tensor,
263
+ value_cache: torch.Tensor,
264
+ k_scale_cache: torch.Tensor,
265
+ v_scale_cache: torch.Tensor,
266
+ slot_mapping: torch.Tensor,
267
+ packing_factor: int,
268
+) -> None:
269
+ """Launch the packed INT4 reshape kernel."""
270
+ num_tokens, num_kv_heads, head_size = key.shape
271
+ head_size_v = value.shape[2]
272
+ assert head_size % packing_factor == 0 and head_size_v % packing_factor == 0
273
+ packed_padded = triton.next_power_of_2(
274
+ max(head_size, head_size_v) // packing_factor
275
+ )
276
+ if current_platform.is_rocm() or current_platform.is_xpu():
277
+ num_warps = 4
278
+ else:
279
+ num_warps = min(16, max(1, packed_padded // 32))
280
+
281
+ kernel[(num_tokens, num_kv_heads)](
282
+ key_ptr=key,
283
+ value_ptr=value,
284
+ key_cache_ptr=key_cache,
285
+ value_cache_ptr=value_cache,
286
+ k_scale_cache_ptr=k_scale_cache,
287
+ v_scale_cache_ptr=v_scale_cache,
288
+ slot_mapping_ptr=slot_mapping,
289
+ stride_key_tok=key.stride(0),
290
+ stride_key_head=key.stride(1),
291
+ stride_val_tok=value.stride(0),
292
+ stride_val_head=value.stride(1),
293
+ stride_kc_blk=key_cache.stride(0),
294
+ stride_kc_slot=key_cache.stride(1),
295
+ stride_kc_head=key_cache.stride(2),
296
+ stride_vc_blk=value_cache.stride(0),
297
+ stride_vc_slot=value_cache.stride(1),
298
+ stride_vc_head=value_cache.stride(2),
299
+ stride_ks_blk=k_scale_cache.stride(0),
300
+ stride_ks_slot=k_scale_cache.stride(1),
301
+ stride_ks_head=k_scale_cache.stride(2),
302
+ stride_vs_blk=v_scale_cache.stride(0),
303
+ stride_vs_slot=v_scale_cache.stride(1),
304
+ stride_vs_head=v_scale_cache.stride(2),
305
+ block_size=key_cache.shape[1],
306
+ head_size=head_size,
307
+ head_size_v=head_size_v,
308
+ PACKED_HEAD_PADDED=packed_padded,
309
+ num_warps=num_warps,
310
+ )
311
+
312
+
313
+# ----------------------------------------------------------------------
314
+# Read path: split-dot attention over the packed cache
315
+# ----------------------------------------------------------------------
316
+
317
+
318
+@triton.jit
319
+def _attn_packed(
320
+ # Output destinations. In 2D mode the final result is written into
321
+ # ``output_ptr``; in 3D mode per-segment partials go into the three
322
+ # ``segm_*`` tensors and ``output_ptr`` is unused.
323
+ output_ptr,
324
+ segm_output_ptr,
325
+ segm_max_ptr,
326
+ segm_expsum_ptr,
327
+ query_ptr,
328
+ key_cache_ptr,
329
+ value_cache_ptr,
330
+ sink_ptr,
331
+ block_tables_ptr,
332
+ seq_lens_ptr,
333
+ alibi_slopes_ptr,
334
+ qq_bias_ptr,
335
+ scale,
336
+ out_scale,
337
+ softcap,
338
+ k_scale_cache_ptr,
339
+ v_scale_cache_ptr,
340
+ num_query_heads: tl.constexpr,
341
+ num_queries_per_kv: tl.constexpr,
342
+ block_table_stride: tl.int64,
343
+ query_stride_0: tl.int64,
344
+ query_stride_1: tl.int64,
345
+ output_stride_0: tl.int64,
346
+ output_stride_1: tl.int64,
347
+ qq_bias_stride_0: tl.int64,
348
+ BLOCK_SIZE: tl.constexpr,
349
+ TILE_SIZE: tl.constexpr,
350
+ HEAD_SIZE: tl.constexpr,
351
+ HEAD_SIZE_PADDED: tl.constexpr,
352
+ PACKED_HEAD_PADDED: tl.constexpr, # HEAD_SIZE / PACKING_FACTOR, rounded up
353
+ USE_ALIBI_SLOPES: tl.constexpr,
354
+ USE_ALIBI_SQRT: tl.constexpr,
355
+ USE_QQ_BIAS: tl.constexpr,
356
+ USE_SOFTCAP: tl.constexpr,
357
+ USE_SINKS: tl.constexpr,
358
+ SLIDING_WINDOW: tl.constexpr,
359
+ USE_MM_PREFIX: tl.constexpr,
360
+ MAX_MM_RANGES: tl.constexpr,
361
+ mm_prefix_range_ptr,
362
+ stride_k_cache_0: tl.int64,
363
+ stride_k_cache_1: tl.int64,
364
+ stride_k_cache_2: tl.int64,
365
+ stride_k_cache_3: tl.constexpr,
366
+ stride_v_cache_0: tl.int64,
367
+ stride_v_cache_1: tl.int64,
368
+ stride_v_cache_2: tl.int64,
369
+ stride_v_cache_3: tl.constexpr,
370
+ stride_ks_blk: tl.int64,
371
+ stride_ks_slot: tl.int64,
372
+ stride_ks_head: tl.int64,
373
+ stride_vs_blk: tl.int64,
374
+ stride_vs_slot: tl.int64,
375
+ stride_vs_head: tl.int64,
376
+ query_start_len_ptr,
377
+ BLOCK_Q: tl.constexpr,
378
+ num_seqs: tl.int32,
379
+ BLOCK_M: tl.constexpr,
380
+ NUM_SEGMENTS_PER_SEQ: tl.constexpr,
381
+ USE_FP8: tl.constexpr,
382
+ IS_3D: tl.constexpr,
383
+ # 2 → INT4 nibble pair (asymmetric + zp). The packed KV cache stores
384
+ # one byte per ``packed_offs``, holding PACKING_FACTOR values.
385
+ PACKING_FACTOR: tl.constexpr,
386
+ FP8_MIN: tl.constexpr = float8_info.min,
387
+ FP8_MAX: tl.constexpr = float8_info.max,
388
+):
389
+ # Shared prologue: sequence lookup, q-block bounds, early returns.
390
+ q_block_global_idx = tl.program_id(0)
391
+ kv_head_idx = tl.program_id(1)
392
+ segm_idx = tl.program_id(2) if IS_3D else 0
393
+
394
+ (
395
+ seq_idx,
396
+ q_block_local_idx,
397
+ cur_batch_in_all_start_index,
398
+ cur_batch_query_len,
399
+ seq_len,
400
+ ) = resolve_seq_and_query_len(
401
+ query_start_len_ptr, seq_lens_ptr, q_block_global_idx, num_seqs, BLOCK_Q
402
+ )
403
+
404
+ if q_block_local_idx * BLOCK_Q >= cur_batch_query_len:
405
+ return
406
+
407
+ if IS_3D:
408
+ tiles_per_segment = cdiv_fn(seq_len, NUM_SEGMENTS_PER_SEQ * TILE_SIZE)
409
+ if segm_idx * tiles_per_segment * TILE_SIZE >= seq_len:
410
+ return
411
+ else:
412
+ tiles_per_segment = 0
413
+
414
+ offs_m = tl.arange(0, BLOCK_M)
415
+ offs_t = tl.arange(0, TILE_SIZE)
416
+ query_pos = q_block_local_idx * BLOCK_Q + offs_m // num_queries_per_kv
417
+
418
+ query_offset_0 = cur_batch_in_all_start_index + query_pos
419
+ query_offset_1 = kv_head_idx * num_queries_per_kv + offs_m % num_queries_per_kv
420
+
421
+ query_mask_0 = tl.where(query_pos < cur_batch_query_len, 1, 0).to(tl.int1)
422
+ query_mask_1 = tl.where(query_offset_1 < num_query_heads, 1, 0).to(tl.int1)
423
+
424
+ # Split-Q prologue: PACKING_FACTOR interleaved streams of Q.
425
+ # INT4 uses 2 streams (even / odd). The packed KV cache stores one
426
+ # byte per ``packed_offs``, which holds PACKING_FACTOR values.
427
+ packed_offs = tl.arange(0, PACKED_HEAD_PADDED)
428
+ offs_s0 = packed_offs * PACKING_FACTOR
429
+ offs_s1 = packed_offs * PACKING_FACTOR + 1
430
+ mask_s0 = tl.where(offs_s0 < HEAD_SIZE, 1, 0).to(tl.int1)
431
+ mask_s1 = tl.where(offs_s1 < HEAD_SIZE, 1, 0).to(tl.int1)
432
+ packed_dim_mask = tl.where(packed_offs < HEAD_SIZE // PACKING_FACTOR, 1, 0).to(
433
+ tl.int1
434
+ )
435
+ q_base = (
436
+ query_offset_0[:, None] * query_stride_0
437
+ + query_offset_1[:, None] * query_stride_1
438
+ )
439
+ q_mask = query_mask_0[:, None] & query_mask_1[:, None]
440
+ Q_s0 = tl.load(
441
+ query_ptr + q_base + offs_s0[None, :],
442
+ mask=mask_s0[None, :] & q_mask,
443
+ other=0.0,
444
+ ).to(tl.float32)
445
+ Q_s1 = tl.load(
446
+ query_ptr + q_base + offs_s1[None, :],
447
+ mask=mask_s1[None, :] & q_mask,
448
+ other=0.0,
449
+ ).to(tl.float32)
450
+
451
+ # INT4 asymmetric correction needs sum(Q) per row.
452
+ Q_sum = tl.sum(Q_s0, axis=1) + tl.sum(Q_s1, axis=1)
453
+
454
+ block_table_offset = seq_idx * block_table_stride
455
+
456
+ # Online-softmax state + optional feature loads.
457
+ M = init_softmax_M(
458
+ sink_ptr, query_offset_1, query_mask_1, segm_idx, BLOCK_M, USE_SINKS, IS_3D
459
+ )
460
+ L = tl.full([BLOCK_M], 1.0, dtype=tl.float32)
461
+ acc_s0 = tl.zeros([BLOCK_M, PACKED_HEAD_PADDED], dtype=tl.float32)
462
+ acc_s1 = tl.zeros([BLOCK_M, PACKED_HEAD_PADDED], dtype=tl.float32)
463
+
464
+ context_len = seq_len - cur_batch_query_len
465
+
466
+ if USE_ALIBI_SLOPES:
467
+ alibi_slope = tl.load(
468
+ alibi_slopes_ptr + query_offset_1, mask=query_mask_1, other=0.0
469
+ )
470
+
471
+ if USE_QQ_BIAS:
472
+ qq_bias_row_ptrs = qq_bias_ptr + query_pos[:, None] * qq_bias_stride_0
473
+
474
+ loop_lo, loop_hi, max_seq_prefix_len = compute_tile_loop_bounds(
475
+ context_len,
476
+ seq_len,
477
+ cur_batch_query_len,
478
+ q_block_local_idx,
479
+ segm_idx,
480
+ tiles_per_segment,
481
+ TILE_SIZE,
482
+ BLOCK_M,
483
+ BLOCK_Q,
484
+ num_queries_per_kv,
485
+ SLIDING_WINDOW,
486
+ USE_MM_PREFIX,
487
+ IS_3D,
488
+ )
489
+
490
+ # Tile loop. Per-tile: load packed KV + scales, dequantize into
491
+ # PACKING_FACTOR streams, compute the split dot, run the shared
492
+ # softmax step, accumulate per stream.
493
+ for j in range(loop_lo, loop_hi):
494
+ seq_offset = j * TILE_SIZE + offs_t
495
+ tile_mask = seq_offset < max_seq_prefix_len
496
+
497
+ physical_block_idx = tl.load(
498
+ block_tables_ptr + block_table_offset + seq_offset // BLOCK_SIZE
499
+ ).to(tl.int64)
500
+
501
+ slot_in_blk = seq_offset % BLOCK_SIZE
502
+ k_off = (
503
+ physical_block_idx[None, :] * stride_k_cache_0
504
+ + kv_head_idx * stride_k_cache_2
505
+ + packed_offs[:, None] * stride_k_cache_3
506
+ + slot_in_blk[None, :] * stride_k_cache_1
507
+ )
508
+ K_packed = tl.load(
509
+ key_cache_ptr + k_off,
510
+ mask=packed_dim_mask[:, None] & tile_mask[None, :],
511
+ other=0,
512
+ )
513
+ v_off = (
514
+ physical_block_idx[:, None] * stride_v_cache_0
515
+ + kv_head_idx * stride_v_cache_2
516
+ + packed_offs[None, :] * stride_v_cache_3
517
+ + slot_in_blk[:, None] * stride_v_cache_1
518
+ )
519
+ V_packed = tl.load(
520
+ value_cache_ptr + v_off,
521
+ mask=packed_dim_mask[None, :] & tile_mask[:, None],
522
+ other=0,
523
+ )
524
+ # Dequantize KV. INT4 unpacks nibbles as plain uint [0..15];
525
+ # the zero-point is applied on the score side.
526
+ K_s0_u, K_s1_u = unpack_int4_nibbles(K_packed)
527
+ K_s0 = K_s0_u.to(tl.float32)
528
+ K_s1 = K_s1_u.to(tl.float32)
529
+ V_s0_u, V_s1_u = unpack_int4_nibbles(V_packed)
530
+ V_s0 = V_s0_u.to(tl.float32)
531
+ V_s1 = V_s1_u.to(tl.float32)
532
+
533
+ ks_idx = (
534
+ physical_block_idx * stride_ks_blk
535
+ + slot_in_blk * stride_ks_slot
536
+ + kv_head_idx * stride_ks_head
537
+ )
538
+ ks_raw = tl.load(k_scale_cache_ptr + ks_idx, mask=tile_mask, other=0)
539
+ vs_idx = (
540
+ physical_block_idx * stride_vs_blk
541
+ + slot_in_blk * stride_vs_slot
542
+ + kv_head_idx * stride_vs_head
543
+ )
544
+ vs_raw = tl.load(v_scale_cache_ptr + vs_idx, mask=tile_mask, other=0)
545
+
546
+ # INT4 steganographs the 4-bit zero-point in the low 4 bits of
547
+ # the float32 scale's mantissa.
548
+ ks_bits = ks_raw.to(tl.int32, bitcast=True)
549
+ k_zp = (ks_bits & 0xF).to(tl.float32)
550
+ k_token_head_scales = (ks_bits & -16).to(tl.float32, bitcast=True)
551
+ vs_bits = vs_raw.to(tl.int32, bitcast=True)
552
+ v_zp = (vs_bits & 0xF).to(tl.float32)
553
+ v_token_head_scales = (vs_bits & -16).to(tl.float32, bitcast=True)
554
+
555
+ query_abs_pos = context_len + query_pos[:, None]
556
+ seq_mask = compute_kv_seq_mask(
557
+ query_abs_pos,
558
+ seq_offset,
559
+ seq_idx,
560
+ seq_len,
561
+ mm_prefix_range_ptr,
562
+ SLIDING_WINDOW,
563
+ USE_MM_PREFIX,
564
+ MAX_MM_RANGES,
565
+ )
566
+
567
+ # Score: split-dot across the 2 INT4 streams; fused
568
+ # softmax_scale * per-(token, head) k_scale in one mul. INT4
569
+ # subtracts the ``zp * sum(Q)`` correction term.
570
+ S = tl.zeros(shape=(BLOCK_M, TILE_SIZE), dtype=tl.float32)
571
+ raw_dot = tl.dot(Q_s0, K_s0) + tl.dot(Q_s1, K_s1)
572
+ S += (raw_dot - Q_sum[:, None] * k_zp[None, :]) * (
573
+ scale * k_token_head_scales[None, :]
574
+ )
575
+
576
+ if USE_SOFTCAP:
577
+ S = apply_softcap(S, softcap)
578
+
579
+ S = tl.where(
580
+ query_mask_1[:, None] & query_mask_0[:, None] & seq_mask, S, float("-inf")
581
+ )
582
+
583
+ if USE_ALIBI_SLOPES:
584
+ S = apply_alibi_to_score(
585
+ S, alibi_slope, seq_offset, context_len, query_pos, USE_ALIBI_SQRT
586
+ )
587
+
588
+ if USE_QQ_BIAS:
589
+ S += load_qq_bias_tile(
590
+ qq_bias_row_ptrs, seq_offset, context_len, qq_bias_stride_0
591
+ )
592
+
593
+ M, L, P, alpha = softmax_step(S, M, L)
594
+ acc_s0 = acc_s0 * alpha[:, None]
595
+ acc_s1 = acc_s1 * alpha[:, None]
596
+
597
+ if SLIDING_WINDOW:
598
+ qpos_lo = q_block_local_idx * BLOCK_Q
599
+ sw_mask = (context_len + qpos_lo - seq_offset) < SLIDING_WINDOW
600
+ V_s0 = tl.where(sw_mask[:, None], V_s0, 0.0)
601
+ V_s1 = tl.where(sw_mask[:, None], V_s1, 0.0)
602
+
603
+ # Fuse v per-(token, head) scale into P. INT4 also subtracts
604
+ # the v-zero-point contribution from each stream once.
605
+ P_v = (P * v_token_head_scales[None, :]).to(tl.float32)
606
+ Pv_zp_sum = tl.sum(P_v * v_zp[None, :], axis=1)
607
+ acc_s0 += tl.dot(P_v, V_s0) - Pv_zp_sum[:, None]
608
+ acc_s1 += tl.dot(P_v, V_s1) - Pv_zp_sum[:, None]
609
+
610
+ # Epilogue. 2D writes the final output with optional FP8 clamp;
611
+ # 3D writes the per-segment partials (output / max / expsum) for
612
+ # ``reduce_segments`` to finalize. Each stream writes its own
613
+ # stripe in the output layout.
614
+ out_mask = query_mask_0[:, None] & query_mask_1[:, None]
615
+ if IS_3D:
616
+ segm_base = (
617
+ query_offset_0[:, None].to(tl.int64)
618
+ * (num_query_heads * NUM_SEGMENTS_PER_SEQ * HEAD_SIZE_PADDED)
619
+ + query_offset_1[:, None] * (NUM_SEGMENTS_PER_SEQ * HEAD_SIZE_PADDED)
620
+ + segm_idx * HEAD_SIZE_PADDED
621
+ )
622
+ tl.store(
623
+ segm_output_ptr + segm_base + offs_s0[None, :],
624
+ acc_s0,
625
+ mask=mask_s0[None, :] & out_mask,
626
+ )
627
+ tl.store(
628
+ segm_output_ptr + segm_base + offs_s1[None, :],
629
+ acc_s1,
630
+ mask=mask_s1[None, :] & out_mask,
631
+ )
632
+ store_segm_reduce_scalars(
633
+ segm_max_ptr,
634
+ segm_expsum_ptr,
635
+ query_offset_0,
636
+ query_offset_1,
637
+ segm_idx,
638
+ M,
639
+ L,
640
+ query_mask_0,
641
+ query_mask_1,
642
+ num_query_heads,
643
+ NUM_SEGMENTS_PER_SEQ,
644
+ )
645
+ else:
646
+ acc_s0 = acc_s0 / L[:, None]
647
+ acc_s1 = acc_s1 / L[:, None]
648
+ if USE_FP8:
649
+ out_s = tl.load(out_scale)
650
+ acc_s0 = tl.clamp(acc_s0 * out_s, FP8_MIN, FP8_MAX)
651
+ acc_s1 = tl.clamp(acc_s1 * out_s, FP8_MIN, FP8_MAX)
652
+ out_base = (
653
+ query_offset_0[:, None] * output_stride_0
654
+ + query_offset_1[:, None] * output_stride_1
655
+ )
656
+ tl.store(
657
+ output_ptr + out_base + offs_s0[None, :],
658
+ acc_s0,
659
+ mask=mask_s0[None, :] & out_mask,
660
+ )
661
+ tl.store(
662
+ output_ptr + out_base + offs_s1[None, :],
663
+ acc_s1,
664
+ mask=mask_s1[None, :] & out_mask,
665
+ )
666
+
667
+
668
+def _launch_packed_attn(
669
+ *,
670
+ q,
671
+ k_cache,
672
+ v_cache,
673
+ out,
674
+ cu_seqlens_q,
675
+ max_seqlen_q,
676
+ seqused_k,
677
+ softmax_scale,
678
+ window_size,
679
+ block_table,
680
+ softcap,
681
+ sinks,
682
+ alibi_slopes,
683
+ use_alibi_sqrt,
684
+ qq_bias,
685
+ output_scale,
686
+ mm_prefix_range,
687
+ k_scale_cache,
688
+ v_scale_cache,
689
+ seq_threshold_3D,
690
+ num_par_softmax_segments,
691
+ softmax_segm_output,
692
+ softmax_segm_max,
693
+ softmax_segm_expsum,
694
+ packing_factor: int,
695
+):
696
+ """Launch ``_attn_packed`` for one of the sub-byte modes.
697
+
698
+ Handles 2D-vs-3D dispatch, placeholder pointers for the unused side
699
+ of that split, and the trailing ``reduce_segments`` pass. Writes
700
+ into ``out`` (directly for 2D; via the segm buffers for 3D).
701
+ """
702
+ import vllm.envs as envs
703
+ from vllm.v1.attention.ops.triton_unified_attention import _get_tile_size
704
+
705
+ is_batch_invariant = envs.VLLM_BATCH_INVARIANT
706
+
707
+ use_mm_prefix = False
708
+ max_mm_ranges = 0
709
+ if mm_prefix_range is not None:
710
+ assert mm_prefix_range.ndim == 3, (
711
+ f"Unsupported mm_prefix_range shape: {mm_prefix_range.shape}"
712
+ )
713
+ use_mm_prefix = True
714
+ max_mm_ranges = mm_prefix_range.shape[1]
715
+
716
+ block_size = v_cache.shape[1]
717
+ num_seqs = len(seqused_k)
718
+ num_query_heads = q.shape[1]
719
+ num_kv_heads = k_cache.shape[2]
720
+ num_queries_per_kv = num_query_heads // num_kv_heads
721
+ head_size = q.shape[2]
722
+
723
+ BLOCK_M = (
724
+ 16 if num_queries_per_kv <= 16 else triton.next_power_of_2(num_queries_per_kv)
725
+ )
726
+ BLOCK_Q = BLOCK_M // num_queries_per_kv
727
+ total_num_q_blocks = q.shape[0] // BLOCK_Q + num_seqs
728
+ sliding_window_val = 1 + window_size[0] if window_size[0] >= 0 else 0
729
+ TILE_SIZE_PREFILL = _get_tile_size(
730
+ head_size, sliding_window_val, q.element_size(), is_prefill=True
731
+ )
732
+ TILE_SIZE_DECODE = _get_tile_size(
733
+ head_size, sliding_window_val, q.element_size(), is_prefill=False
734
+ )
735
+
736
+ use_3d = not (
737
+ seq_threshold_3D is None
738
+ or num_par_softmax_segments is None
739
+ or softmax_segm_output is None
740
+ or softmax_segm_max is None
741
+ or softmax_segm_expsum is None
742
+ or max_seqlen_q > 1
743
+ or num_seqs > seq_threshold_3D
744
+ or is_batch_invariant
745
+ )
746
+
747
+ # 3D never reads ``output_ptr`` and 2D never reads the segm tensors,
748
+ # but Triton needs a non-null pointer everywhere; reuse ``out`` as
749
+ # the placeholder for the unused side.
750
+ segm_output_ptr = softmax_segm_output if use_3d else out
751
+ segm_max_ptr = softmax_segm_max if use_3d else out
752
+ segm_expsum_ptr = softmax_segm_expsum if use_3d else out
753
+ num_segments = num_par_softmax_segments if use_3d else 1
754
+
755
+ grid: tuple[Any, ...]
756
+ if use_3d:
757
+ grid = (total_num_q_blocks, num_kv_heads, num_par_softmax_segments)
758
+ tile_size = TILE_SIZE_DECODE
759
+ else:
760
+ grid = (total_num_q_blocks, num_kv_heads)
761
+ tile_size = TILE_SIZE_PREFILL
762
+
763
+ _attn_packed[grid](
764
+ output_ptr=out,
765
+ segm_output_ptr=segm_output_ptr,
766
+ segm_max_ptr=segm_max_ptr,
767
+ segm_expsum_ptr=segm_expsum_ptr,
768
+ query_ptr=q,
769
+ key_cache_ptr=k_cache,
770
+ value_cache_ptr=v_cache,
771
+ sink_ptr=sinks,
772
+ block_tables_ptr=block_table,
773
+ seq_lens_ptr=seqused_k,
774
+ alibi_slopes_ptr=alibi_slopes,
775
+ qq_bias_ptr=qq_bias,
776
+ scale=softmax_scale,
777
+ out_scale=1 / output_scale if output_scale is not None else 1.0,
778
+ softcap=softcap,
779
+ k_scale_cache_ptr=k_scale_cache,
780
+ v_scale_cache_ptr=v_scale_cache,
781
+ num_query_heads=num_query_heads,
782
+ num_queries_per_kv=num_queries_per_kv,
783
+ block_table_stride=block_table.stride(0),
784
+ query_stride_0=q.stride(0),
785
+ query_stride_1=q.stride(1),
786
+ output_stride_0=out.stride(0),
787
+ output_stride_1=out.stride(1),
788
+ qq_bias_stride_0=qq_bias.stride(0) if qq_bias is not None else 0,
789
+ BLOCK_SIZE=block_size,
790
+ TILE_SIZE=tile_size,
791
+ HEAD_SIZE=head_size,
792
+ HEAD_SIZE_PADDED=triton.next_power_of_2(head_size),
793
+ PACKED_HEAD_PADDED=triton.next_power_of_2(head_size) // packing_factor,
794
+ USE_ALIBI_SLOPES=alibi_slopes is not None,
795
+ USE_ALIBI_SQRT=use_alibi_sqrt,
796
+ USE_QQ_BIAS=qq_bias is not None,
797
+ USE_SOFTCAP=(softcap > 0),
798
+ USE_SINKS=(sinks is not None),
799
+ SLIDING_WINDOW=(1 + window_size[0]),
800
+ USE_MM_PREFIX=use_mm_prefix,
801
+ MAX_MM_RANGES=max_mm_ranges,
802
+ mm_prefix_range_ptr=mm_prefix_range,
803
+ stride_k_cache_0=k_cache.stride(0),
804
+ stride_k_cache_1=k_cache.stride(1),
805
+ stride_k_cache_2=k_cache.stride(2),
806
+ stride_k_cache_3=k_cache.stride(3),
807
+ stride_v_cache_0=v_cache.stride(0),
808
+ stride_v_cache_1=v_cache.stride(1),
809
+ stride_v_cache_2=v_cache.stride(2),
810
+ stride_v_cache_3=v_cache.stride(3),
811
+ stride_ks_blk=k_scale_cache.stride(0),
812
+ stride_ks_slot=k_scale_cache.stride(1),
813
+ stride_ks_head=k_scale_cache.stride(2),
814
+ stride_vs_blk=v_scale_cache.stride(0),
815
+ stride_vs_slot=v_scale_cache.stride(1),
816
+ stride_vs_head=v_scale_cache.stride(2),
817
+ query_start_len_ptr=cu_seqlens_q,
818
+ BLOCK_Q=BLOCK_Q,
819
+ num_seqs=num_seqs,
820
+ BLOCK_M=BLOCK_M,
821
+ NUM_SEGMENTS_PER_SEQ=num_segments,
822
+ USE_FP8=output_scale is not None,
823
+ IS_3D=use_3d,
824
+ PACKING_FACTOR=packing_factor,
825
+ )
826
+
827
+ if use_3d:
828
+ reduce_segments[(q.shape[0], num_query_heads)](
829
+ output_ptr=out,
830
+ segm_output_ptr=softmax_segm_output,
831
+ segm_max_ptr=softmax_segm_max,
832
+ segm_expsum_ptr=softmax_segm_expsum,
833
+ seq_lens_ptr=seqused_k,
834
+ num_seqs=num_seqs,
835
+ num_query_heads=num_query_heads,
836
+ out_scale_inv=1 / output_scale if output_scale is not None else 1.0,
837
+ output_stride_0=out.stride(0),
838
+ output_stride_1=out.stride(1),
839
+ block_table_stride=block_table.stride(0),
840
+ TILE_SIZE=TILE_SIZE_DECODE,
841
+ HEAD_SIZE=head_size,
842
+ HEAD_SIZE_PADDED=triton.next_power_of_2(head_size),
843
+ query_start_len_ptr=cu_seqlens_q,
844
+ BLOCK_Q=BLOCK_Q,
845
+ NUM_SEGMENTS_PER_SEQ=num_par_softmax_segments,
846
+ USE_FP8=output_scale is not None,
847
+ )
848
+
849
+
850
+# ----------------------------------------------------------------------
851
+# Public entry points
852
+# ----------------------------------------------------------------------
853
+
854
+
855
+def reshape_and_cache_int4(
856
+ key: torch.Tensor,
857
+ value: torch.Tensor,
858
+ key_cache: torch.Tensor,
859
+ value_cache: torch.Tensor,
860
+ slot_mapping: torch.Tensor,
861
+ *,
862
+ k_scale_cache: torch.Tensor,
863
+ v_scale_cache: torch.Tensor,
864
+) -> None:
865
+ """Pre-rotate (RHT), pack to INT4 and write into the paged cache."""
866
+ key = single_rht(key.float()).to(key.dtype)
867
+ value = single_rht(value.float()).to(value.dtype)
868
+ _run_reshape_kernel(
869
+ _reshape_cache_int4_kernel,
870
+ key=key,
871
+ value=value,
872
+ key_cache=key_cache,
873
+ value_cache=value_cache,
874
+ k_scale_cache=k_scale_cache,
875
+ v_scale_cache=v_scale_cache,
876
+ slot_mapping=slot_mapping,
877
+ packing_factor=_INT4_PACKING_FACTOR,
878
+ )
879
+
880
+
881
+def unified_attention_int4(
882
+ q: torch.Tensor,
883
+ k_cache: torch.Tensor,
884
+ v_cache: torch.Tensor,
885
+ out: torch.Tensor,
886
+ *,
887
+ cu_seqlens_q: torch.Tensor,
888
+ max_seqlen_q: int,
889
+ seqused_k: torch.Tensor,
890
+ max_seqlen_k: int,
891
+ softmax_scale: float,
892
+ window_size: tuple[int, int],
893
+ block_table: torch.Tensor,
894
+ softcap: float,
895
+ sinks: torch.Tensor | None,
896
+ alibi_slopes: torch.Tensor | None,
897
+ use_alibi_sqrt: bool,
898
+ qq_bias: torch.Tensor | None,
899
+ output_scale: torch.Tensor | None,
900
+ mm_prefix_range: torch.Tensor | None,
901
+ k_scale_cache: torch.Tensor,
902
+ v_scale_cache: torch.Tensor,
903
+ seq_threshold_3D: int | None = None,
904
+ num_par_softmax_segments: int | None = None,
905
+ softmax_segm_output: torch.Tensor | None = None,
906
+ softmax_segm_max: torch.Tensor | None = None,
907
+ softmax_segm_expsum: torch.Tensor | None = None,
908
+) -> None:
909
+ """Paged attention over the INT4 packed cache, writing into *out*.
910
+
911
+ The forward RHT has norm ``sqrt(head_size)``, so ``softmax_scale`` is
912
+ divided by ``head_size`` and the inverse RHT divides the output by
913
+ ``head_size`` as well.
914
+ """
915
+ q_orig_dtype = q.dtype
916
+ q = single_rht(q.float()).to(q_orig_dtype)
917
+ head_size = q.shape[2]
918
+ softmax_scale = softmax_scale / head_size
919
+
920
+ _launch_packed_attn(
921
+ q=q,
922
+ k_cache=k_cache,
923
+ v_cache=v_cache,
924
+ out=out,
925
+ cu_seqlens_q=cu_seqlens_q,
926
+ max_seqlen_q=max_seqlen_q,
927
+ seqused_k=seqused_k,
928
+ softmax_scale=softmax_scale,
929
+ window_size=window_size,
930
+ block_table=block_table,
931
+ softcap=softcap,
932
+ sinks=sinks,
933
+ alibi_slopes=alibi_slopes,
934
+ use_alibi_sqrt=use_alibi_sqrt,
935
+ qq_bias=qq_bias,
936
+ output_scale=output_scale,
937
+ mm_prefix_range=mm_prefix_range,
938
+ k_scale_cache=k_scale_cache,
939
+ v_scale_cache=v_scale_cache,
940
+ seq_threshold_3D=seq_threshold_3D,
941
+ num_par_softmax_segments=num_par_softmax_segments,
942
+ softmax_segm_output=softmax_segm_output,
943
+ softmax_segm_max=softmax_segm_max,
944
+ softmax_segm_expsum=softmax_segm_expsum,
945
+ packing_factor=_INT4_PACKING_FACTOR,
946
+ )
947
+
948
+ out_f = single_rht(out.float(), inverse=True) / head_size
949
+ out.copy_(out_f.to(q_orig_dtype))
950
+
951
+
952
+# ----------------------------------------------------------------------
953
+# Randomized Hadamard Transform (RHT) — gaussianizes K/V before INT4
954
+# quantization; applied on write and to Q before the read kernels.
955
+# ----------------------------------------------------------------------
956
+
957
+# Hadacore (CUDA tensor core kernel) availability check
958
+# Hadacore's CUDA impl is only registered when built for sm_80+, but the
959
+# schema def is unconditional — on ROCm ``hasattr`` is True yet dispatch
960
+# would crash, so we also gate on ``is_cuda()`` and the sm_80 capability.
961
+_HADACORE_AVAILABLE: bool | None = None
962
+
963
+
964
+def _hadacore_available() -> bool:
965
+ global _HADACORE_AVAILABLE
966
+ if _HADACORE_AVAILABLE is None:
967
+ _HADACORE_AVAILABLE = (
968
+ current_platform.is_cuda()
969
+ and current_platform.has_device_capability(80)
970
+ and hasattr(torch.ops._C, "hadacore_transform")
971
+ )
972
+ return _HADACORE_AVAILABLE
973
+
974
+
975
+# Cached Hadamard matrices (one per (size, dtype, device) tuple)
976
+_HADAMARD_MATRIX_CACHE: dict[tuple[int, torch.dtype, str], torch.Tensor] = {}
977
+
978
+
979
+def _get_hadamard_matrix(
980
+ d: int, dtype: torch.dtype, device: torch.device
981
+) -> torch.Tensor:
982
+ key = (d, dtype, str(device))
983
+ cached = _HADAMARD_MATRIX_CACHE.get(key)
984
+ if cached is None:
985
+ H = torch.ones(1, 1, dtype=torch.float32, device=device)
986
+ while H.shape[0] < d:
987
+ H = torch.cat(
988
+ [
989
+ torch.cat([H, H], dim=1),
990
+ torch.cat([H, -H], dim=1),
991
+ ],
992
+ dim=0,
993
+ )
994
+ cached = H.to(dtype).contiguous()
995
+ _HADAMARD_MATRIX_CACHE[key] = cached
996
+ return cached
997
+
998
+
999
+# Triton MMA Hadamard kernel (Tier 2)
1000
+@triton.jit
1001
+def _hadamard_mma_kernel(
1002
+ x_ptr,
1003
+ h_ptr,
1004
+ out_ptr,
1005
+ n_rows,
1006
+ stride_x_row: tl.int64,
1007
+ stride_x_col: tl.int64,
1008
+ stride_o_row: tl.int64,
1009
+ stride_o_col: tl.int64,
1010
+ BLOCK_M: tl.constexpr,
1011
+ D: tl.constexpr,
1012
+):
1013
+ pid = tl.program_id(0)
1014
+ rows = pid * BLOCK_M + tl.arange(0, BLOCK_M)
1015
+ cols = tl.arange(0, D)
1016
+ row_mask = rows < n_rows
1017
+
1018
+ x = tl.load(
1019
+ x_ptr + rows[:, None] * stride_x_row + cols[None, :] * stride_x_col,
1020
+ mask=row_mask[:, None],
1021
+ other=0.0,
1022
+ )
1023
+ H = tl.load(h_ptr + cols[:, None] * D + cols[None, :])
1024
+
1025
+ out = tl.dot(x, H, out_dtype=tl.float32).to(x.dtype)
1026
+
1027
+ tl.store(
1028
+ out_ptr + rows[:, None] * stride_o_row + cols[None, :] * stride_o_col,
1029
+ out,
1030
+ mask=row_mask[:, None],
1031
+ )
1032
+
1033
+
1034
+# H is D×D bf16 = 2·D² bytes of LDS. AMD CDNA has 64 KiB LDS, so D ≤ 128
1035
+# (32 KiB) leaves room for input + accumulator. Larger D falls back.
1036
+_TRITON_HADAMARD_MIN_D = 16
1037
+_TRITON_HADAMARD_MAX_D = 128
1038
+
1039
+
1040
+def _triton_hadamard_transform(x: torch.Tensor) -> torch.Tensor:
1041
+ d = x.shape[-1]
1042
+ orig_shape = x.shape
1043
+ orig_dtype = x.dtype
1044
+
1045
+ work_dtype = torch.bfloat16 if orig_dtype == torch.float32 else orig_dtype
1046
+ x2d = x.contiguous().to(work_dtype).reshape(-1, d)
1047
+ out2d = torch.empty_like(x2d)
1048
+ n_rows = x2d.shape[0]
1049
+ H_mat = _get_hadamard_matrix(d, work_dtype, x.device)
1050
+
1051
+ BLOCK_M = 16
1052
+ grid = (triton.cdiv(n_rows, BLOCK_M),)
1053
+ # num_stages=1: the kernel has no loop, so default 3-stage pipelining
1054
+ # would triple-buffer H and blow the AMD LDS budget.
1055
+ _hadamard_mma_kernel[grid](
1056
+ x2d,
1057
+ H_mat,
1058
+ out2d,
1059
+ n_rows,
1060
+ x2d.stride(0),
1061
+ x2d.stride(1),
1062
+ out2d.stride(0),
1063
+ out2d.stride(1),
1064
+ BLOCK_M=BLOCK_M,
1065
+ D=d,
1066
+ num_stages=1,
1067
+ num_warps=4,
1068
+ )
1069
+ return out2d.reshape(orig_shape).to(orig_dtype)
1070
+
1071
+
1072
+# Public API
1073
+def fast_hadamard_transform(x: torch.Tensor) -> torch.Tensor:
1074
+ """Unnormalized Walsh-Hadamard Transform along the last dimension.
1075
+
1076
+ H_d × x where H_d × H_d = d × I. Last dim must be a power of 2.
1077
+
1078
+ Three-tier dispatch:
1079
+ 1. Hadacore CUDA Tensor Core kernel (sm_80+).
1080
+ 2. Triton MMA matmul kernel (CUDA fallback + ROCm MFMA/WMMA path).
1081
+ 3. PyTorch butterfly (CPU and any GPU/dtype combo Triton can't take).
1082
+ """
1083
+ d = x.shape[-1]
1084
+ assert d & (d - 1) == 0, f"Requires power-of-2 dim, got {d}"
1085
+
1086
+ # Tier 1 — hadacore on CUDA.
1087
+ if _hadacore_available() and 0 < d <= (1 << 15):
1088
+ from vllm import _custom_ops as ops
1089
+
1090
+ # hadacore returns x @ (H/√d); rescale to the unnormalized H × x
1091
+ # convention the INT4 scale math is calibrated to.
1092
+ rescale = d**0.5
1093
+ if x.dtype in (torch.float16, torch.bfloat16):
1094
+ y = ops.hadacore_transform(x.contiguous().clone(), inplace=True)
1095
+ return y * rescale
1096
+ # fp32 → bf16 round-trip; precision loss is irrelevant before
1097
+ # INT4 quantization.
1098
+ orig_dtype = x.dtype
1099
+ x_bf16 = x.contiguous().to(torch.bfloat16)
1100
+ y_bf16 = ops.hadacore_transform(x_bf16, inplace=True)
1101
+ return y_bf16.to(orig_dtype) * rescale
1102
+
1103
+ # Tier 2 — Triton MMA kernel (covers ROCm via MFMA/WMMA codegen, and
1104
+ # also CUDA when hadacore is unavailable).
1105
+ if (
1106
+ x.is_cuda
1107
+ and _TRITON_HADAMARD_MIN_D <= d <= _TRITON_HADAMARD_MAX_D
1108
+ and x.dtype in (torch.float16, torch.bfloat16, torch.float32)
1109
+ ):
1110
+ return _triton_hadamard_transform(x)
1111
+
1112
+ # Tier 3 — PyTorch butterfly (CPU / unsupported dtype / D < 16).
1113
+ h = 1
1114
+ while h < d:
1115
+ xv = x.view(*x.shape[:-1], d // (2 * h), 2, h)
1116
+ a = xv[..., 0, :]
1117
+ b = xv[..., 1, :]
1118
+ x = torch.stack([a + b, a - b], dim=-2).reshape(x.shape)
1119
+ h <<= 1
1120
+ return x
1121
+
1122
+
1123
+# Randomized Hadamard Transform (used by INT4)
1124
+# Deterministic ±1 signs for Randomized Hadamard Transform.
1125
+# RHT = H × D × x (sign flip + Hadamard). Breaks residual structure
1126
+# in KV vectors, improving quantization quality.
1127
+_RHT_SIGNS_CACHE: dict[tuple[int, int, str], torch.Tensor] = {}
1128
+
1129
+
1130
+def _get_rht_signs(d: int, round_idx: int, device: torch.device) -> torch.Tensor:
1131
+ """Return a cached deterministic ±1 sign vector of length *d*."""
1132
+ key = (d, round_idx, str(device))
1133
+ if key not in _RHT_SIGNS_CACHE:
1134
+ gen = torch.Generator(device="cpu")
1135
+ gen.manual_seed(0x9E3779B9 + round_idx * 0x517CC1B7)
1136
+ signs = (
1137
+ 2.0 * torch.bernoulli(torch.full((d,), 0.5, device="cpu"), generator=gen)
1138
+ - 1.0
1139
+ )
1140
+ _RHT_SIGNS_CACHE[key] = signs.to(device)
1141
+ return _RHT_SIGNS_CACHE[key]
1142
+
1143
+
1144
+def single_rht(x: torch.Tensor, inverse: bool = False) -> torch.Tensor:
1145
+ """Single Randomized Hadamard Transform: H × D₁ × x.
1146
+
1147
+ Used by INT4 per-token-head quantization to gaussianize data
1148
+ before asymmetric quantization.
1149
+ """
1150
+ d = x.shape[-1]
1151
+ d1 = _get_rht_signs(d, 0, x.device)
1152
+ if inverse:
1153
+ return fast_hadamard_transform(x) * d1
1154
+ else:
1155
+ return fast_hadamard_transform(x * d1)
vllm/v1/attention/ops/triton_reshape_and_cache_flash.py
+21
-2
@@ -10,6 +10,7 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
10
from vllm.platforms import current_platform
11
from vllm.triton_utils import tl, triton
12
from vllm.utils.torch_utils import is_quantized_kv_cache
13
+from vllm.v1.kv_cache_interface import KVQuantMode
14
15
FP8_MIN, FP8_MAX = get_fp8_min_max()
16
@@ -276,6 +277,7 @@ def triton_reshape_and_cache_flash_per_token_head_quant(
277
k_scale_cache: torch.Tensor, # [num_blocks, block_size, num_kv_heads] float32
278
v_scale_cache: torch.Tensor, # [num_blocks, block_size, num_kv_heads] float32
279
slot_mapping: torch.Tensor, # [num_tokens]
280
+ kv_quant_mode: KVQuantMode,
281
):
282
"""Quantize key/value per (token, head) and write to paged cache.
283
@@ -283,9 +285,26 @@ def triton_reshape_and_cache_flash_per_token_head_quant(
285
quantized data in key_cache/value_cache, and stores the float32
286
scale in k_scale_cache/v_scale_cache.
287
286
- The quantization range (QUANT_MAX, QUANT_MIN) is derived from the
287
- cache tensor dtype so the same code path works for int8 and fp8.
288
+ INT4 needs sub-byte packing + a Hadamard rotation, so it is handled by
289
+ its own kernel; INT8 / FP8 share this kernel, with the quantization
290
+ range (QUANT_MAX, QUANT_MIN) derived from the cache tensor dtype.
291
"""
292
+ if kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD:
293
+ from vllm.v1.attention.ops.int4_per_token_head import (
294
+ reshape_and_cache_int4,
295
+ )
296
+
297
+ reshape_and_cache_int4(
298
+ key,
299
+ value,
300
+ key_cache,
301
+ value_cache,
302
+ slot_mapping,
303
+ k_scale_cache=k_scale_cache,
304
+ v_scale_cache=v_scale_cache,
305
+ )
306
+ return
307
+
308
cache_dtype = key_cache.dtype
309
quant_params = _PER_TOKEN_HEAD_QUANT_PARAMS.get(cache_dtype)
310
if quant_params is None:
vllm/v1/attention/ops/triton_unified_attention.py
+43
-1
@@ -259,7 +259,8 @@ def kernel_unified_attention(
259
stride_vs_head: tl.int64 = None,
260
# KV cache quantization mode handled inside this kernel via constexpr
261
# branches: NONE (0), FP8_PER_TENSOR (1), INT8_PER_TOKEN_HEAD (2),
262
- # FP8_PER_TOKEN_HEAD (3).
262
+ # FP8_PER_TOKEN_HEAD (3). Sub-byte INT4 (4) uses its own
263
+ # int4_per_token_head kernel, not this one.
264
KV_QUANT_MODE: tl.constexpr = 0,
265
FP8_MIN: tl.constexpr = float8_info.min,
266
FP8_MAX: tl.constexpr = float8_info.max,
@@ -824,6 +825,47 @@ def unified_attention(
825
use_causal = bool(causal) if not use_per_seq_causal else True
826
per_seq_causal_ptr = causal if use_per_seq_causal else None
827
828
+ # Sub-byte packed mode (INT4) needs a bespoke kernel (split-dot +
829
+ # sub-byte unpack); everything else goes through the core kernel below.
830
+ if kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD:
831
+ assert use_causal and not use_per_seq_causal, (
832
+ "INT4_PER_TOKEN_HEAD only supports causal attention"
833
+ )
834
+ from vllm.v1.attention.ops.int4_per_token_head import (
835
+ unified_attention_int4,
836
+ )
837
+
838
+ if sinks is not None:
839
+ assert sinks.shape[0] == q.shape[1], "Sinks must be num_query_heads size"
840
+ unified_attention_int4(
841
+ q=q,
842
+ k_cache=k,
843
+ v_cache=v,
844
+ out=out,
845
+ cu_seqlens_q=cu_seqlens_q,
846
+ max_seqlen_q=max_seqlen_q,
847
+ seqused_k=seqused_k,
848
+ max_seqlen_k=max_seqlen_k,
849
+ softmax_scale=softmax_scale,
850
+ window_size=window_size,
851
+ block_table=block_table,
852
+ softcap=softcap,
853
+ sinks=sinks,
854
+ alibi_slopes=alibi_slopes,
855
+ use_alibi_sqrt=use_alibi_sqrt,
856
+ qq_bias=qq_bias,
857
+ output_scale=output_scale,
858
+ mm_prefix_range=mm_prefix_range,
859
+ k_scale_cache=k_scale_cache,
860
+ v_scale_cache=v_scale_cache,
861
+ seq_threshold_3D=seq_threshold_3D,
862
+ num_par_softmax_segments=num_par_softmax_segments,
863
+ softmax_segm_output=softmax_segm_output,
864
+ softmax_segm_max=softmax_segm_max,
865
+ softmax_segm_expsum=softmax_segm_expsum,
866
+ )
867
+ return
868
+
869
if sinks is not None:
870
assert sinks.shape[0] == q.shape[1], "Sinks must be num_query_heads size"
871
vllm/v1/kv_cache_interface.py
+21
-21
@@ -41,7 +41,8 @@ class KVQuantMode(IntEnum):
41
FP8_PER_TENSOR = 1 # per-tensor scales (current fp8 path)
42
INT8_PER_TOKEN_HEAD = 2 # per-token-head dynamic scales for int8
43
FP8_PER_TOKEN_HEAD = 3 # per-token-head dynamic scales for fp8
44
- NVFP4 = 4 # packed fp4 data + fp8 block scales
44
+ INT4_PER_TOKEN_HEAD = 4 # packed 2×int4/byte, RHT + asymmetric zp
45
+ NVFP4 = 5 # packed fp4 data + fp8 block scales
46
47
@property
48
def is_per_token_head(self) -> bool:
@@ -49,6 +50,7 @@ class KVQuantMode(IntEnum):
50
return self in (
51
KVQuantMode.INT8_PER_TOKEN_HEAD,
52
KVQuantMode.FP8_PER_TOKEN_HEAD,
53
+ KVQuantMode.INT4_PER_TOKEN_HEAD,
54
)
55
56
@property
@@ -59,6 +61,8 @@ class KVQuantMode(IntEnum):
61
62
def get_kv_quant_mode(kv_cache_dtype: str) -> KVQuantMode:
63
"""Map a ``kv_cache_dtype`` string to a :class:`KVQuantMode`."""
64
+ if kv_cache_dtype == "int4_per_token_head":
65
+ return KVQuantMode.INT4_PER_TOKEN_HEAD
66
if kv_cache_dtype == "int8_per_token_head":
67
return KVQuantMode.INT8_PER_TOKEN_HEAD
68
if kv_cache_dtype == "fp8_per_token_head":
@@ -184,19 +188,16 @@ class AttentionSpec(KVCacheSpec):
188
def real_page_size_bytes(self) -> int:
189
if self.kv_quant_mode.is_nvfp4:
190
# Packed layout: fp4 data + fp8 block scales per head.
187
- full_dim = nvfp4_kv_cache_full_dim(self.head_size)
188
- return (
189
- 2
190
- * self.block_size
191
- * self.num_kv_heads
192
- * full_dim
193
- * get_dtype_size(self.dtype)
194
- )
191
+ head_dim = nvfp4_kv_cache_full_dim(self.head_size)
192
+ elif self.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD:
193
+ head_dim = self.head_size // 2
194
+ else:
195
+ head_dim = self.head_size
196
return (
197
2
198
* self.block_size
199
* self.num_kv_heads
199
- * self.head_size
200
+ * head_dim
201
* get_dtype_size(self.dtype)
202
)
203
@@ -314,17 +315,12 @@ class FullAttentionSpec(AttentionSpec):
315
last_dim = nvfp4_kv_cache_full_dim(
316
self.head_size
317
) + nvfp4_kv_cache_full_dim(self.head_size_v)
317
- return (
318
- self.block_size
319
- * self.num_kv_heads
320
- * last_dim
321
- * get_dtype_size(self.dtype)
322
- )
318
+ elif self.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD:
319
+ last_dim = self.head_size // 2 + self.head_size_v // 2
320
+ else:
321
+ last_dim = self.head_size + self.head_size_v
322
return (
324
- self.block_size
325
- * self.num_kv_heads
326
- * (self.head_size + self.head_size_v)
327
- * get_dtype_size(self.dtype)
323
+ self.block_size * self.num_kv_heads * last_dim * get_dtype_size(self.dtype)
324
)
325
326
@@ -390,10 +386,14 @@ class MLAAttentionSpec(FullAttentionSpec):
386
# V3.2 main MLA: 656-byte custom layout (kv_lora_rank=512 +
387
# qk_rope_head_dim=64, head_size=576). See flashmla_sparse.py.
388
return self.block_size * 656
389
+ if self.kv_quant_mode == KVQuantMode.INT4_PER_TOKEN_HEAD:
390
+ head_dim = self.head_size // 2
391
+ else:
392
+ head_dim = self.head_size
393
return (
394
self.storage_block_size
395
* self.num_kv_heads
396
- * self.head_size
396
+ * head_dim
397
* get_dtype_size(self.dtype)
398
)
399