[Kernel][ROCm][Perf] FlyDSL decode-attention kernel for 4-bit TurboQuant KV cache (#47896)

Signed-off-by: root <root@smci355-ccs-aus-m02-09.cs-aus.dcgpu> Co-authored-by: root <root@smci355-ccs-aus-m02-09.cs-aus.dcgpu> Co-authored-by: Andreas Karatzas <akaratza@amd.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

aditi-amd committed Aug 11, 2026 at 08:45 UTC 5426311d9115c597c561967ec3f277ee6e970420
13 files changed +6396 -53
tests/kernels/turboquant/__init__.py
tests/kernels/turboquant/test_flydsl_turboquant_decode.py new
+261
@@ -0,0 +1,261 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +# Copyright (c) 2025 FlyDSL Project Contributors
4 +"""Correctness test for the FlyDSL TurboQuant 4-bit KV decode kernel.
5 +
6 +The kernel is validated against a pure-PyTorch full-precision attention
7 +oracle rather than another kernel: the synthetic KV cache is built from
8 +known centroid indices, K-norms and V-scales/zeros, so we can dequantize
9 +the exact stored values and run fp32 softmax attention as ground truth.
10 +Both sides therefore see identical quantized values; the only difference
11 +is bf16-vs-fp32 accumulation, which is bounded by ``ATOL`` below.
12 +
13 +Skipped unless running on ROCm/gfx950 with FlyDSL importable.
14 +"""
15 +
16 +import pytest
17 +import torch
18 +
19 +from vllm.platforms import current_platform
20 +
21 +# Guard the ROCm-only import: `vllm.platforms.rocm` pulls in amdsmi and other
22 +# ROCm-specific modules that are not importable on non-ROCm platforms, so it
23 +# must not be imported at module load time on CUDA/CPU CI.
24 +if not current_platform.is_rocm():
25 + pytest.skip(
26 + "TurboQuant FlyDSL decode only runs on ROCm gfx950.",
27 + allow_module_level=True,
28 + )
29 +
30 +from vllm.platforms.rocm import on_gfx950 # noqa: E402
31 +
32 +if not on_gfx950():
33 + pytest.skip(
34 + "TurboQuant FlyDSL decode only runs on ROCm gfx950.",
35 + allow_module_level=True,
36 + )
37 +
38 +from vllm.v1.attention.ops.flydsl_turboquant_decode import ( # noqa: E402
39 + flydsl_turboquant_decode_attention,
40 + is_flydsl_available,
41 + is_flydsl_gqa6_available,
42 +)
43 +
44 +if not is_flydsl_available():
45 + pytest.skip("FlyDSL runtime is not available.", allow_module_level=True)
46 +
47 +HEAD_SIZE = 128
48 +N_CENTROIDS = 16
49 +KEY_DATA_BYTES = HEAD_SIZE // 2 # 4-bit packed key nibbles
50 +DATA_BYTES_PER_SLOT = HEAD_SIZE # packed K (64) + packed V (64)
51 +NUM_SOA_FIELDS = 3
52 +SOA_K_NORM, SOA_V_SCALE, SOA_V_ZERO = 0, 1, 2
53 +
54 +# bf16-vs-fp32 accumulation tolerance (FlyDSL test_pa.py default).
55 +ATOL = 5e-3
56 +
57 +
58 +def _build_cache(
59 + num_seqs, num_kv_heads, seq_len, qg, kv_block_size, seed=0xC0FFEE, device="cuda"
60 +):
61 + """Vectorized builder for a SoA-layout 4-bit KV cache + fp32 ground truth.
62 +
63 + Returns ``(centroids, q_bf16, kv_cache_4d, block_table, seq_lens,
64 + K_ref, V_ref)`` where ``K_ref``/``V_ref`` are the dequantized
65 + full-precision keys/values used by the reference attention.
66 + """
67 + g = torch.Generator(device=device).manual_seed(seed)
68 + hk = num_kv_heads
69 + hq = hk * qg
70 +
71 + centroids = (
72 + torch.randn(N_CENTROIDS, generator=g, dtype=torch.float32, device=device) * 0.5
73 + )
74 + q = (
75 + torch.randn(
76 + num_seqs, hq, HEAD_SIZE, generator=g, dtype=torch.float32, device=device
77 + )
78 + * 0.1
79 + )
80 + q_bf16 = q.to(torch.bfloat16)
81 +
82 + blocks_per_seq = (seq_len + kv_block_size - 1) // kv_block_size
83 + max_bps = blocks_per_seq + 4
84 + block_table = torch.zeros(num_seqs, max_bps, dtype=torch.int32, device=device)
85 + block_table[:, :blocks_per_seq] = (
86 + torch.arange(
87 + num_seqs * blocks_per_seq, dtype=torch.int32, device=device
88 + ).reshape(num_seqs, blocks_per_seq)
89 + + 1
90 + )
91 + total_blocks = num_seqs * blocks_per_seq + 8
92 +
93 + slot_size_aligned = (KEY_DATA_BYTES + 2) + (KEY_DATA_BYTES + 4) # 134
94 + if slot_size_aligned % 2:
95 + slot_size_aligned += 1
96 + bytes_per_block = kv_block_size * hk * slot_size_aligned
97 +
98 + k_idx = torch.randint(
99 + 0,
100 + N_CENTROIDS,
101 + (num_seqs, hk, seq_len, HEAD_SIZE),
102 + generator=g,
103 + dtype=torch.uint8,
104 + device=device,
105 + )
106 + v_idx = torch.randint(
107 + 0,
108 + N_CENTROIDS,
109 + (num_seqs, hk, seq_len, HEAD_SIZE),
110 + generator=g,
111 + dtype=torch.uint8,
112 + device=device,
113 + )
114 + knorm = (
115 + torch.rand(num_seqs, hk, seq_len, generator=g, device=device) * 0.5 + 0.5
116 + ).to(torch.float16)
117 + vscale = (
118 + torch.rand(num_seqs, hk, seq_len, generator=g, device=device) * 0.05 + 0.01
119 + ).to(torch.float16)
120 + vzero = (
121 + (torch.rand(num_seqs, hk, seq_len, generator=g, device=device) - 0.5) * 0.1
122 + ).to(torch.float16)
123 +
124 + k_ref = centroids[k_idx.long()].float() * knorm.float().unsqueeze(-1)
125 + v_ref = v_idx.float() * vscale.float().unsqueeze(-1) + vzero.float().unsqueeze(-1)
126 +
127 + k_packed = k_idx[..., 0::2] | (k_idx[..., 1::2] << 4)
128 + v_packed = v_idx[..., 0::2] | (v_idx[..., 1::2] << 4)
129 +
130 + kv_cache = torch.zeros(
131 + total_blocks, bytes_per_block, dtype=torch.uint8, device=device
132 + )
133 +
134 + seq_ax = (
135 + torch.arange(num_seqs, device=device)
136 + .view(num_seqs, 1)
137 + .expand(num_seqs, seq_len)
138 + )
139 + tok_ax = (
140 + torch.arange(seq_len, device=device).view(1, seq_len).expand(num_seqs, seq_len)
141 + )
142 + blk_for_tok = block_table[seq_ax, tok_ax // kv_block_size]
143 + slot_for_tok = tok_ax % kv_block_size
144 +
145 + h_ax = torch.arange(hk, device=device)
146 + base_data = (
147 + slot_for_tok.unsqueeze(1) * hk * DATA_BYTES_PER_SLOT
148 + + h_ax.view(1, hk, 1) * DATA_BYTES_PER_SLOT
149 + )
150 + blk_b = blk_for_tok.unsqueeze(1).expand(num_seqs, hk, seq_len)
151 + dst_base = blk_b * bytes_per_block + base_data
152 + rng_k = torch.arange(KEY_DATA_BYTES, device=device)
153 + rng_v = torch.arange(KEY_DATA_BYTES, device=device) + KEY_DATA_BYTES
154 + k_dst = (dst_base.unsqueeze(-1) + rng_k.view(1, 1, 1, -1)).reshape(-1)
155 + v_dst = (dst_base.unsqueeze(-1) + rng_v.view(1, 1, 1, -1)).reshape(-1)
156 + flat = kv_cache.view(-1)
157 + flat[k_dst] = k_packed.reshape(-1)
158 + flat[v_dst] = v_packed.reshape(-1)
159 +
160 + meta_off = kv_block_size * hk * DATA_BYTES_PER_SLOT
161 + meta_view = kv_cache.view(torch.float16).view(total_blocks, -1)
162 + meta_off_hw = meta_off // 2
163 + common = h_ax.view(
164 + 1, hk, 1
165 + ) * NUM_SOA_FIELDS * kv_block_size + slot_for_tok.unsqueeze(1)
166 + blk_flat = blk_for_tok.unsqueeze(1).expand(num_seqs, hk, seq_len)
167 + knorm_off = meta_off_hw + common + SOA_K_NORM * kv_block_size
168 + vscale_off = meta_off_hw + common + SOA_V_SCALE * kv_block_size
169 + vzero_off = meta_off_hw + common + SOA_V_ZERO * kv_block_size
170 + meta_view[blk_flat.reshape(-1).long(), knorm_off.reshape(-1).long()] = (
171 + knorm.reshape(-1)
172 + )
173 + meta_view[blk_flat.reshape(-1).long(), vscale_off.reshape(-1).long()] = (
174 + vscale.reshape(-1)
175 + )
176 + meta_view[blk_flat.reshape(-1).long(), vzero_off.reshape(-1).long()] = (
177 + vzero.reshape(-1)
178 + )
179 +
180 + seq_lens = torch.full((num_seqs,), seq_len, dtype=torch.int32, device=device)
181 + kv_cache_4d = kv_cache.view(total_blocks, kv_block_size, hk, slot_size_aligned)
182 + return (
183 + centroids,
184 + q_bf16,
185 + kv_cache_4d,
186 + block_table,
187 + seq_lens,
188 + k_ref.cpu(),
189 + v_ref.cpu(),
190 + )
191 +
192 +
193 +def _reference_attention(q_bf16, k_ref, v_ref, seq_lens, scale):
194 + """fp32 softmax attention on dequantized keys/values (ground truth)."""
195 + num_seqs, hq, d = q_bf16.shape
196 + hk = k_ref.shape[1]
197 + qg = hq // hk
198 + q = q_bf16.float().reshape(num_seqs, hk, qg, d)
199 + out = torch.zeros(num_seqs, hq, d, dtype=torch.float32)
200 + for s in range(num_seqs):
201 + for h in range(hk):
202 + ql = int(seq_lens[s].item())
203 + k = k_ref[s, h, :ql]
204 + v = v_ref[s, h, :ql]
205 + scores = (q[s, h] @ k.T) * scale
206 + m = scores.max(dim=-1, keepdim=True).values
207 + e = torch.exp(scores - m)
208 + p = e / e.sum(dim=-1, keepdim=True)
209 + out[s, h * qg : (h + 1) * qg] = p @ v
210 + return out
211 +
212 +
213 +@pytest.mark.parametrize("num_seqs", [1, 4, 16])
214 +@pytest.mark.parametrize("seq_len", [256, 1024, 4096])
215 +@pytest.mark.parametrize(
216 + "num_kv_heads,qg",
217 + [
218 + (8, 8), # Qwen2.5-72B class
219 + (8, 16), # Qwen3-32B class
220 + pytest.param(
221 + 4,
222 + 6, # MiniMax-M2.5 class (GQA-6 sibling kernel)
223 + marks=pytest.mark.skipif(
224 + not is_flydsl_gqa6_available(),
225 + reason="FlyDSL GQA-6 sibling kernel not available",
226 + ),
227 + ),
228 + ],
229 +)
230 +@pytest.mark.parametrize("kv_block_size", [16, 32])
231 +def test_flydsl_matches_reference(num_seqs, seq_len, num_kv_heads, qg, kv_block_size):
232 + """FlyDSL decode must match fp32 attention on dequantized KV."""
233 + centroids, q_bf16, kv_cache, block_table, seq_lens, k_ref, v_ref = _build_cache(
234 + num_seqs, num_kv_heads, seq_len, qg, kv_block_size
235 + )
236 + scale = 1.0 / (HEAD_SIZE**0.5)
237 + identity = torch.eye(HEAD_SIZE, dtype=torch.float32, device="cuda")
238 +
239 + out = flydsl_turboquant_decode_attention(
240 + query=q_bf16,
241 + kv_cache=kv_cache,
242 + block_table=block_table,
243 + seq_lens=seq_lens,
244 + Pi=identity,
245 + centroids=centroids,
246 + scale=scale,
247 + mse_bits=4,
248 + key_packed_size=KEY_DATA_BYTES + 2,
249 + value_quant_bits=4,
250 + value_packed_size=KEY_DATA_BYTES + 4,
251 + key_fp8=False,
252 + norm_correction=False,
253 + PiT=identity.T.contiguous(),
254 + max_seq_len=seq_len,
255 + max_num_kv_splits=32,
256 + sinks=None,
257 + )
258 +
259 + ref = _reference_attention(q_bf16.cpu(), k_ref, v_ref, seq_lens.cpu(), scale)
260 +
261 + torch.testing.assert_close(out.cpu().float(), ref, atol=ATOL, rtol=0.0)
vllm/platforms/rocm.py
+23 -8
@@ -623,23 +623,38 @@ class RocmPlatform(Platform):
623 if selected_backend is not None:
624 try:
625 backend_class = selected_backend.get_class()
626 - invalid_reasons = backend_class.validate_configuration(
626 + sel_invalid_reasons = backend_class.validate_configuration(
627 device_capability=device_capability,
628 **attn_selector_config._asdict(),
629 )
630 except ImportError:
631 - invalid_reasons = ["ImportError"]
632 - if invalid_reasons:
633 - raise ValueError(
634 - f"Selected backend {selected_backend} is not valid for "
635 - f"this configuration. Reason: {invalid_reasons}"
636 - )
637 - else:
631 + sel_invalid_reasons = ["ImportError"]
632 + if not sel_invalid_reasons:
633 logger.info_once(
634 "Using %s backend (selected via --attention-backend).",
635 selected_backend.name,
636 )
637 return selected_backend.get_path()
638 + # Only tolerate the mismatch for turboquant_* KV-cache layers:
639 + # boundary layers keep the native dtype (served by the selected
640 + # backend) while turboquant_* layers need TURBOQUANT, so no single
641 + # --attention-backend can serve every layer. For any other dtype
642 + # the explicit selection is genuinely invalid -> fail loud.
643 + kv_dtype = attn_selector_config.kv_cache_dtype
644 + if not (kv_dtype is not None and str(kv_dtype).startswith("turboquant")):
645 + raise ValueError(
646 + f"Selected backend {selected_backend} is not valid for "
647 + f"this configuration. Reason: {sel_invalid_reasons}"
648 + )
649 + # NOTE: pass a str (not the list) -- info_once hashes its args.
650 + logger.info_once(
651 + "Selected backend %s is incompatible with this turboquant "
652 + "layer (%s); using the auto-selected per-layer backend. "
653 + "Reason: %s",
654 + selected_backend.name,
655 + attn_selector_config.attn_type,
656 + str(sel_invalid_reasons),
657 + )
658
659 # No selected backend or the selected backend is invalid,
660 # so we try finding a valid backend.
vllm/v1/attention/backends/turboquant_attn.py
+349 -45
@@ -16,6 +16,7 @@ Per-head per-position slot layout:
16 For turboquant_k3v4_nc head_dim=256: [100 bytes key | 512 bytes value] = 612
17 """
18
19 +import contextlib
20 import functools
21 import math
22 from dataclasses import dataclass
@@ -26,6 +27,7 @@ import torch.nn.functional as F
27
28 from vllm.config import get_current_vllm_config
29 from vllm.config.cache import CacheDType
30 +from vllm.logger import init_logger
31 from vllm.model_executor.layers.quantization.turboquant.centroids import (
32 get_centroids,
33 )
@@ -47,6 +49,15 @@ from vllm.v1.attention.backends.fa_utils import (
49 is_flash_attn_varlen_func_available,
50 )
51 from vllm.v1.attention.backends.utils import split_decodes_and_prefills
52 +
53 +# FlyDSL TurboQuant decode (AMD gfx950). Auto-selected when FlyDSL is available
54 +# for eligible layers (SoA store + FlyDSL decode + SoA-aware continuation);
55 +# non-gfx950 or ineligible layers use the SoA Triton decode.
56 +from vllm.v1.attention.ops.flydsl_turboquant_decode import (
57 + flydsl_turboquant_decode_attention,
58 + is_flydsl_available,
59 + is_flydsl_gqa6_available,
60 +)
61 from vllm.v1.attention.ops.triton_turboquant_decode import (
62 _tq_full_dequant_kv,
63 _use_fp8_e4b15,
@@ -59,6 +70,8 @@ from vllm.v1.worker.workspace import (
70 is_workspace_manager_initialized,
71 )
72
73 +logger = init_logger(__name__)
74 +
75 _HAS_FLASH_ATTN = is_flash_attn_varlen_func_available()
76 if _HAS_FLASH_ATTN:
77 from vllm.v1.attention.backends.fa_utils import flash_attn_varlen_func
@@ -71,6 +84,24 @@ if _HAS_FLASH_ATTN:
84 _CONTINUATION_DECODE_THRESHOLD = 128
85
86
87 +def _soa_imports():
88 + """Lazy import of the HIP-free SoA Triton subset (store / dequant / decode).
89 +
90 + Kept lazy so the default (FlyDSL-off) path never imports these modules.
91 + """
92 + from vllm.v1.attention.ops.turboquant_soa.triton_turboquant_decode import (
93 + _tq_full_dequant_kv as soa_dequant,
94 + )
95 + from vllm.v1.attention.ops.turboquant_soa.triton_turboquant_store import (
96 + triton_turboquant_store as soa_store,
97 + )
98 + from vllm.v1.attention.ops.turboquant_soa.triton_turboquant_unified_attention import ( # noqa: E501
99 + triton_turboquant_decode_attention_soa as soa_decode,
100 + )
101 +
102 + return soa_store, soa_dequant, soa_decode
103 +
104 +
105 def _build_hadamard(d: int, device_str: str) -> torch.Tensor:
106 """Orthonormal Hadamard matrix (Sylvester construction), cached per (d, device).
107
@@ -290,6 +321,10 @@ class TurboQuantAttentionImpl(AttentionImpl["TurboQuantMetadata"]):
321
322 supports_quant_query_input: bool = False
323
324 + # Lazily populated before cudagraph capture (FlyDSL decode path only).
325 + _arange_cache: torch.Tensor
326 + _cu_2: torch.Tensor
327 +
328 def __init__(
329 self,
330 num_heads: int,
@@ -337,6 +372,17 @@ class TurboQuantAttentionImpl(AttentionImpl["TurboQuantMetadata"]):
372 vllm_config.attention_config.tq_max_kv_splits_for_cuda_graph
373 )
374
375 + # FlyDSL decode state. Auto-enabled on gfx950 when FlyDSL is available.
376 + self.sliding_window = sliding_window
377 + self.sinks = kwargs.get("sinks")
378 + # Cache max_model_len now (config is available at __init__ but NOT
379 + # during CUDA-graph capture when _ensure_on_device is re-entered).
380 + self._max_model_len = vllm_config.model_config.max_model_len
381 + # SoA store is required by the FlyDSL decode/continuation path, so it
382 + # tracks FlyDSL availability (single switch for the whole pipeline).
383 + self._use_flydsl = is_flydsl_available()
384 + self._soa_store = self._use_flydsl
385 +
386 def _flash_attn_varlen(
387 self,
388 q: torch.Tensor,
@@ -382,6 +428,44 @@ class TurboQuantAttentionImpl(AttentionImpl["TurboQuantMetadata"]):
428 quantizer is symmetric around zero (sign-flipping a coordinate
429 maps it to the mirror centroid with identical distortion).
430 """
431 + if self._soa_store:
432 + # CUDA-graph capture safety for the FlyDSL decode path on ROCm.
433 + # (1) Pre-allocate _arange_cache / _cu_2 BEFORE any capture; lazy
434 + # allocation during graph replay lands in the HIP graph memory
435 + # pool and yields stale addresses (GPU fault / garbage).
436 + # (2) Pre-warm the WorkspaceManager to its max size before capture
437 + # so mid-capture growth cannot invalidate pointers baked into
438 + # already-captured batch sizes.
439 + _max_len = self._max_model_len
440 + _already_ok = (
441 + hasattr(self, "_arange_cache")
442 + and self._arange_cache.device.type == str(device).split(":")[0]
443 + and self._arange_cache.shape[0] >= _max_len + 2
444 + )
445 + if not _already_ok:
446 + self._arange_cache = torch.arange(
447 + 0, _max_len + 2, device=device, dtype=torch.int32
448 + )
449 + if not hasattr(self, "_cu_2") or self._cu_2.device != torch.device(device):
450 + self._cu_2 = torch.zeros(2, device=device, dtype=torch.int32)
451 + if (
452 + is_workspace_manager_initialized()
453 + and not current_workspace_manager().is_locked()
454 + ):
455 + B_max = self._max_capture_batch_size()
456 + D = self.head_size
457 + Hq = self.num_heads
458 + S = self.max_num_kv_splits
459 + _pre_warm_bytes = (
460 + B_max * Hq * (S * (D + 1) + D) * 4 # fp32 mid_o + fp32 lse
461 + + B_max * Hq * D * 2 # query-dtype output (bf16 = 2 B)
462 + + 512 # alignment padding
463 + )
464 + with contextlib.suppress(AssertionError):
465 + current_workspace_manager().get_simultaneous(
466 + ((_pre_warm_bytes,), torch.uint8)
467 + )
468 +
469 if not hasattr(layer, "_tq_cached"):
470 D = self.head_size
471
@@ -402,6 +486,29 @@ class TurboQuantAttentionImpl(AttentionImpl["TurboQuantMetadata"]):
486 layer._tq_midpoints = (c_sorted[:-1] + c_sorted[1:]) / 2
487 layer._tq_cached = True
488
489 + def _max_capture_batch_size(self) -> int:
490 + """Largest decode batch we might see at runtime (for workspace pre-warm).
491 +
492 + Take max(cudagraph_capture_sizes, scheduler.max_num_seqs): a forward
493 + pass exceeding the largest captured graph size falls back to eager,
494 + but the workspace is locked and that eager path can still hit batch
495 + sizes up to max_num_seqs. Falls back to 1024 if config is unavailable.
496 + """
497 + try:
498 + cfg = get_current_vllm_config()
499 + candidates: list[int] = []
500 + sizes = cfg.compilation_config.cudagraph_capture_sizes
501 + if sizes:
502 + candidates.append(int(max(sizes)))
503 + sched = getattr(cfg, "scheduler_config", None)
504 + if sched is not None and getattr(sched, "max_num_seqs", None):
505 + candidates.append(int(sched.max_num_seqs))
506 + if candidates:
507 + return max(candidates)
508 + except Exception: # noqa: BLE001
509 + pass
510 + return 1024
511 +
512 def do_kv_cache_update(
513 self,
514 layer: torch.nn.Module,
@@ -590,6 +697,27 @@ class TurboQuantAttentionImpl(AttentionImpl["TurboQuantMetadata"]):
697 layer: Any,
698 ):
699 """Quantize + store via fused Triton kernel."""
700 + if self._soa_store:
701 + # SoA layout (data region + metadata region separated per block),
702 + # required by the FlyDSL decode kernel. Pure-Triton store; the
703 + # cache tensor shape is identical to the default AoS store, only
704 + # the within-block byte convention differs.
705 + soa_store, _, _ = _soa_imports()
706 + soa_store(
707 + key=key,
708 + value=value,
709 + kv_cache=kv_cache,
710 + slot_mapping=slot_mapping,
711 + PiT=layer._tq_PiT,
712 + midpoints=layer._tq_midpoints,
713 + mse_bits=self.tq_config.key_mse_bits,
714 + key_packed_size=self.tq_config.key_packed_size,
715 + value_quant_bits=self.tq_config.effective_value_quant_bits,
716 + key_fp8=self.tq_config.key_fp8,
717 + centroids=layer._tq_centroids,
718 + norm_correction=self.tq_config.norm_correction,
719 + )
720 + return
721 triton_turboquant_store(
722 key,
723 value,
@@ -722,21 +850,55 @@ class TurboQuantAttentionImpl(AttentionImpl["TurboQuantMetadata"]):
850 # Slice from pre-built arange (no kernel launch)
851 synth_seq_lens = _arange_cache[cached_len + 1 : seq_len + 1]
852 synth_bt = attn_metadata.block_table[i : i + 1].expand(q_len, -1)
725 - out = triton_turboquant_decode_attention(
726 - query=q_seq,
727 - kv_cache=kv_cache,
728 - block_table=synth_bt,
729 - seq_lens=synth_seq_lens,
730 - Pi=Pi,
731 - centroids=centroids,
732 - scale=self.scale,
733 - mse_bits=self.tq_config.key_mse_bits,
734 - key_packed_size=self.tq_config.key_packed_size,
735 - value_quant_bits=(self.tq_config.effective_value_quant_bits),
736 - key_fp8=self.tq_config.key_fp8,
737 - norm_correction=self.tq_config.norm_correction,
738 - PiT=PiT,
739 - )
853 + if self._soa_store:
854 + # The cache was written in SoA layout (always, for FlyDSL),
855 + # so it MUST be read with the SoA-aware decode. The
856 + # default AoS decode reads k_norm/v_scale/v_zero from
857 + # the wrong offsets in a SoA cache -> garbage
858 + # cached-prefix output ->
859 + # accuracy collapse on every multi-turn / prefix-cached
860 + # (APC) request. Continuation stays on the Triton SoA
861 + # path even when FlyDSL is the main decode kernel
862 + # (FlyDSL is decode-batch only).
863 + out = self._dispatch_decode_soa(
864 + query=q_seq,
865 + kv_cache=kv_cache,
866 + block_table=synth_bt,
867 + seq_lens=synth_seq_lens,
868 + Pi=Pi,
869 + centroids=centroids,
870 + scale=self.scale,
871 + mse_bits=self.tq_config.key_mse_bits,
872 + key_packed_size=self.tq_config.key_packed_size,
873 + value_quant_bits=(
874 + self.tq_config.effective_value_quant_bits
875 + ),
876 + value_packed_size=self.tq_config.value_packed_size,
877 + max_seq_len=int(seq_len),
878 + key_fp8=self.tq_config.key_fp8,
879 + norm_correction=self.tq_config.norm_correction,
880 + PiT=PiT,
881 + sinks=self.sinks,
882 + sliding_window=self.sliding_window,
883 + )
884 + else:
885 + out = triton_turboquant_decode_attention(
886 + query=q_seq,
887 + kv_cache=kv_cache,
888 + block_table=synth_bt,
889 + seq_lens=synth_seq_lens,
890 + Pi=Pi,
891 + centroids=centroids,
892 + scale=self.scale,
893 + mse_bits=self.tq_config.key_mse_bits,
894 + key_packed_size=self.tq_config.key_packed_size,
895 + value_quant_bits=(
896 + self.tq_config.effective_value_quant_bits
897 + ),
898 + key_fp8=self.tq_config.key_fp8,
899 + norm_correction=self.tq_config.norm_correction,
900 + PiT=PiT,
901 + )
902 else:
903 # Large continuation: dequant cached K/V and use
904 # flash_attn for better throughput.
@@ -801,36 +963,84 @@ class TurboQuantAttentionImpl(AttentionImpl["TurboQuantMetadata"]):
963 v_cached = v_buf[:, :, :alloc_len, :]
964
965 grid = (alloc_len, 1 * Hk)
804 - _tq_full_dequant_kv[grid](
805 - kv_cache,
806 - block_table,
807 - centroids,
808 - k_cached,
809 - v_cached,
810 - k_cached.stride(0),
811 - k_cached.stride(1),
812 - k_cached.stride(2),
813 - v_cached.stride(0),
814 - v_cached.stride(1),
815 - v_cached.stride(2),
816 - kv_cache.stride(0),
817 - kv_cache.stride(1),
818 - kv_cache.stride(2),
819 - block_table.stride(0),
820 - HEAD_DIM=D,
821 - BLOCK_SIZE=block_size,
822 - NUM_KV_HEADS=Hk,
823 - MSE_BYTES=mse_bytes,
824 - KPS=self.tq_config.key_packed_size,
825 - VQB=self.tq_config.effective_value_quant_bits,
826 - VAL_DATA_BYTES=val_data_bytes,
827 - MSE_BITS=self.tq_config.key_mse_bits,
828 - KEY_FP8=1 if self.tq_config.key_fp8 else 0,
829 - BLOCK_D=BLOCK_D,
830 - NORM_CORRECTION=1 if self.tq_config.norm_correction else 0,
831 - FP8_E4B15=_use_fp8_e4b15(device.index or 0),
832 - num_warps=4,
833 - )
966 + if self._soa_store:
967 + # SoA-aware dequant: read the data/metadata-separated SoA cache
968 + # written by the SoA store. Constants must match the store side.
969 + _, soa_dequant, _ = _soa_imports()
970 + key_fp8 = self.tq_config.key_fp8
971 + key_data_bytes = D if key_fp8 else mse_bytes
972 + data_bytes_per_slot = key_data_bytes + val_data_bytes
973 + meta_region_offset = block_size * Hk * data_bytes_per_slot
974 + num_soa_fields = 2 if key_fp8 else 3
975 + soa_k_norm = 0
976 + soa_v_scale = 0 if key_fp8 else 1
977 + soa_v_zero = 1 if key_fp8 else 2
978 + kv_cache_u16 = kv_cache.view(torch.uint16)
979 + soa_dequant[grid](
980 + kv_cache,
981 + kv_cache_u16,
982 + block_table,
983 + centroids,
984 + k_cached,
985 + v_cached,
986 + k_cached.stride(0),
987 + k_cached.stride(1),
988 + k_cached.stride(2),
989 + v_cached.stride(0),
990 + v_cached.stride(1),
991 + v_cached.stride(2),
992 + kv_cache.stride(0),
993 + block_table.stride(0),
994 + HEAD_DIM=D,
995 + BLOCK_SIZE=block_size,
996 + NUM_KV_HEADS=Hk,
997 + MSE_BYTES=mse_bytes,
998 + VQB=self.tq_config.effective_value_quant_bits,
999 + VAL_DATA_BYTES=val_data_bytes,
1000 + MSE_BITS=self.tq_config.key_mse_bits,
1001 + KEY_FP8=1 if key_fp8 else 0,
1002 + KEY_DATA_BYTES=key_data_bytes,
1003 + META_REGION_OFFSET=meta_region_offset,
1004 + NUM_SOA_FIELDS=num_soa_fields,
1005 + SOA_K_NORM=soa_k_norm,
1006 + SOA_V_SCALE=soa_v_scale,
1007 + SOA_V_ZERO=soa_v_zero,
1008 + BLOCK_D=BLOCK_D,
1009 + NORM_CORRECTION=1 if self.tq_config.norm_correction else 0,
1010 + FP8_E4B15=_use_fp8_e4b15(device.index or 0),
1011 + num_warps=4,
1012 + )
1013 + else:
1014 + _tq_full_dequant_kv[grid](
1015 + kv_cache,
1016 + block_table,
1017 + centroids,
1018 + k_cached,
1019 + v_cached,
1020 + k_cached.stride(0),
1021 + k_cached.stride(1),
1022 + k_cached.stride(2),
1023 + v_cached.stride(0),
1024 + v_cached.stride(1),
1025 + v_cached.stride(2),
1026 + kv_cache.stride(0),
1027 + kv_cache.stride(1),
1028 + kv_cache.stride(2),
1029 + block_table.stride(0),
1030 + HEAD_DIM=D,
1031 + BLOCK_SIZE=block_size,
1032 + NUM_KV_HEADS=Hk,
1033 + MSE_BYTES=mse_bytes,
1034 + KPS=self.tq_config.key_packed_size,
1035 + VQB=self.tq_config.effective_value_quant_bits,
1036 + VAL_DATA_BYTES=val_data_bytes,
1037 + MSE_BITS=self.tq_config.key_mse_bits,
1038 + KEY_FP8=1 if self.tq_config.key_fp8 else 0,
1039 + BLOCK_D=BLOCK_D,
1040 + NORM_CORRECTION=1 if self.tq_config.norm_correction else 0,
1041 + FP8_E4B15=_use_fp8_e4b15(device.index or 0),
1042 + num_warps=4,
1043 + )
1044
1045 # Inverse-rotate MSE keys back to original space
1046 if not self.tq_config.key_fp8:
@@ -930,6 +1140,82 @@ class TurboQuantAttentionImpl(AttentionImpl["TurboQuantMetadata"]):
1140 )
1141 )
1142
1143 + if self._use_flydsl:
1144 + # FlyDSL decode (gfx950, MSE-key, HEAD_SIZE=128, GQA in {6, 8, 16}).
1145 + # GQA-6 routes to the MiniMax sibling kernel. Ineligible layers fall
1146 + # back to SoA Triton decode.
1147 + _gqa = self.num_kv_groups
1148 + flydsl_gqa_ok = (_gqa in (8, 16)) or (
1149 + _gqa == 6 and is_flydsl_gqa6_available()
1150 + )
1151 + flydsl_eligible = (
1152 + not self.tq_config.key_fp8
1153 + and self.tq_config.key_mse_bits == 4
1154 + and self.tq_config.effective_value_quant_bits == 4
1155 + and self.head_size == 128
1156 + and flydsl_gqa_ok
1157 + and self.sinks is None
1158 + and not (self.sliding_window and self.sliding_window > 0)
1159 + )
1160 + if flydsl_eligible:
1161 + return flydsl_turboquant_decode_attention(
1162 + query=query,
1163 + kv_cache=kv_cache,
1164 + block_table=attn_metadata.block_table,
1165 + seq_lens=attn_metadata.seq_lens,
1166 + Pi=Pi,
1167 + centroids=centroids,
1168 + scale=self.scale,
1169 + mse_bits=self.tq_config.key_mse_bits,
1170 + key_packed_size=self.tq_config.key_packed_size,
1171 + value_quant_bits=self.tq_config.effective_value_quant_bits,
1172 + value_packed_size=self.tq_config.value_packed_size,
1173 + max_seq_len=attn_metadata.max_seq_len,
1174 + key_fp8=self.tq_config.key_fp8,
1175 + norm_correction=self.tq_config.norm_correction,
1176 + PiT=PiT,
1177 + mid_o_buf=mid_o_buf,
1178 + output_buf=output_buf,
1179 + lse_buf=lse_buf,
1180 + buf_holder=layer,
1181 + max_num_kv_splits=self.max_num_kv_splits,
1182 + sinks=self.sinks,
1183 + )
1184 + logger.warning_once(
1185 + "TurboQuant FlyDSL ineligible (key_fp8=%s mse_bits=%s vqb=%s "
1186 + "head_size=%s num_kv_groups=%s sinks=%s) -> SoA Triton decode",
1187 + self.tq_config.key_fp8,
1188 + self.tq_config.key_mse_bits,
1189 + self.tq_config.effective_value_quant_bits,
1190 + self.head_size,
1191 + self.num_kv_groups,
1192 + self.sinks is not None,
1193 + )
1194 + return self._dispatch_decode_soa(
1195 + query=query,
1196 + kv_cache=kv_cache,
1197 + block_table=attn_metadata.block_table,
1198 + seq_lens=attn_metadata.seq_lens,
1199 + Pi=Pi,
1200 + centroids=centroids,
1201 + scale=self.scale,
1202 + mse_bits=self.tq_config.key_mse_bits,
1203 + key_packed_size=self.tq_config.key_packed_size,
1204 + value_quant_bits=self.tq_config.effective_value_quant_bits,
1205 + value_packed_size=self.tq_config.value_packed_size,
1206 + max_seq_len=attn_metadata.max_seq_len,
1207 + key_fp8=self.tq_config.key_fp8,
1208 + norm_correction=self.tq_config.norm_correction,
1209 + PiT=PiT,
1210 + mid_o_buf=mid_o_buf,
1211 + output_buf=output_buf,
1212 + lse_buf=lse_buf,
1213 + buf_holder=layer,
1214 + max_num_kv_splits=self.max_num_kv_splits,
1215 + sinks=self.sinks,
1216 + sliding_window=self.sliding_window,
1217 + )
1218 +
1219 result = triton_turboquant_decode_attention(
1220 query=query,
1221 kv_cache=kv_cache,
@@ -951,3 +1237,21 @@ class TurboQuantAttentionImpl(AttentionImpl["TurboQuantMetadata"]):
1237 max_num_kv_splits=self.max_num_kv_splits,
1238 )
1239 return result
1240 +
1241 + def _dispatch_decode_soa(self, **kwargs):
1242 + """SoA-aware Triton decode — fallback for FlyDSL-ineligible layers.
1243 +
1244 + The SoA decode launcher accepts a subset of the FlyDSL kwargs; filter to
1245 + the params it accepts and raise if a *meaningful* (non-None) kwarg would
1246 + be silently dropped (so we never mask a real feature gap).
1247 + """
1248 + import inspect
1249 +
1250 + _, _, soa_decode = _soa_imports()
1251 + accepted = set(inspect.signature(soa_decode).parameters)
1252 + dropped = [k for k, v in kwargs.items() if k not in accepted and v is not None]
1253 + if dropped:
1254 + raise NotImplementedError(
1255 + f"SoA decode does not support kwargs {sorted(dropped)}"
1256 + )
1257 + return soa_decode(**{k: v for k, v in kwargs.items() if k in accepted})
vllm/v1/attention/ops/flydsl_kernels/__init__.py new
+2
@@ -0,0 +1,2 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
vllm/v1/attention/ops/flydsl_kernels/tq_decode.py new
+823
@@ -0,0 +1,823 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +# TurboQuant decode (FlyDSL) — MI355X / gfx950 / CDNA4
4 +#
5 +# Decode-only paged attention over a TurboQuant SoA KV cache.
6 +# Targets Qwen-class models: HEAD_SIZE=128, GQA group=8, MSE_BITS=4 K, VQB=4 V,
7 +# N_CENTROIDS=16, BLOCK_SIZE=16, partitioned mode.
8 +#
9 +# === Design ===
10 +# * 1 wave (64 lanes) per CTA — simpler than 4-warp; trades parallelism for
11 +# correctness/debug-ability. A later revision will fan out to 4 warps.
12 +# * Flash-Attention-2 online softmax: rescale running PV/sum per K-tile,
13 +# no QK score storage across tiles, no P-LDS round-trip (qk_acc layout
14 +# matches PV B-operand directly).
15 +# * Centroids loaded once into LDS at kernel start (LDS-resident LUT).
16 +# * K dequant → LDS [token, head_dim] (natural layout)
17 +# * V dequant → LDS [head_dim, token] (TRANSPOSED — required so PV MFMA's
18 +# A=V operand can be loaded as 4-bf16 contiguous via 1 ds_read_b64).
19 +#
20 +# MFMA layouts used:
21 +# QK: mfma(A=K, B=Q, C=qk_acc) → C[m=token, n=query], 4 fp32/lane
22 +# = 4 tokens for SAME query → softmax via
23 +# xor_shuffle(16),(32) reduces per-row
24 +# PV: mfma(A=V_T, B=P, C=acc_pv) → C[m=head_dim, n=query], 4 fp32/lane
25 +# = 4 contiguous head_dim cols for SAME
26 +# query → 1 buffer_store of 8 bytes
27 +# per chunk per lane
28 +
29 +from __future__ import annotations
30 +
31 +import flydsl.compiler as flyc
32 +import flydsl.expr as fx
33 +from flydsl._mlir import ir
34 +from flydsl._mlir.dialects import scf as _scf
35 +from flydsl.expr import (
36 + arith,
37 + buffer_ops,
38 + const_expr, # noqa: F401 kept for kernel-DSL API parity
39 + gpu,
40 + range_constexpr,
41 + rocdl,
42 + vector,
43 +)
44 +from flydsl.expr.typing import Int32, T # noqa: F401 Int32 via fx.Int32
45 +from flydsl.runtime.device import get_rocm_arch as get_hip_arch
46 +from flydsl.utils.smem_allocator import SmemAllocator, SmemPtr
47 +
48 +# === Constants (Qwen-class TQ decode profile) ===============================
49 +HEAD_SIZE = 128
50 +# default; overridable via build_tq_decode_module(kv_block_size=...)
51 +KV_BLOCK_SIZE = 16
52 +# MFMA tile = 16 tokens (do not change without re-deriving MFMA shapes)
53 +TILE_SIZE = 16
54 +N_CENTROIDS = 16
55 +# default for tests; overridable via build_tq_decode_module(query_group_size=...)
56 +QUERY_GROUP_SIZE = 16
57 +WARP_SIZE = 64
58 +NUM_WARPS = 1
59 +BLOCK_THREADS = NUM_WARPS * WARP_SIZE
60 +KV_COMPUTE_BLOCK = 256 # 16 K-tiles × 16 tokens
61 +
62 +# TQ SoA layout
63 +NUM_SOA_FIELDS = 3
64 +SOA_K_NORM = 0
65 +SOA_V_SCALE = 1
66 +SOA_V_ZERO = 2
67 +KEY_DATA_BYTES = HEAD_SIZE // 2
68 +VAL_DATA_BYTES = HEAD_SIZE // 2
69 +DATA_BYTES_PER_SLOT = KEY_DATA_BYTES + VAL_DATA_BYTES
70 +
71 +# MFMA
72 +MFMA_M = MFMA_N = 16
73 +MFMA_K_BF16_QK = 32 # CDNA4 wide-K (mfma_f32_16x16x32_bf16)
74 +MFMA_K_BF16_PV = 16 # PV's K = token tile = 16
75 +QK_K_CHUNKS = HEAD_SIZE // MFMA_K_BF16_QK # 4
76 +PV_N_CHUNKS = HEAD_SIZE // MFMA_N # 8
77 +
78 +# LDS regions
79 +CENTROID_LDS_BYTES = N_CENTROIDS * 4 # 64
80 +Q_LDS_BYTES = QUERY_GROUP_SIZE * HEAD_SIZE * 2 # 4096
81 +KV_TILE_LDS_BYTES = TILE_SIZE * HEAD_SIZE * 2 # 4096
82 +
83 +LOG2E = 1.4426950408889634
84 +NEG_INF_VAL = float("-inf")
85 +
86 +
87 +def _vsplat_mul(vec, scalar):
88 + s = scalar.ir_value() if hasattr(scalar, "ir_value") else scalar
89 + return vec * vector.broadcast(T.f32x4, s)
90 +
91 +
92 +allocator = None
93 +
94 +
95 +def build_tq_decode_module(
96 + num_seqs: int,
97 + num_kv_heads: int,
98 + num_partitions: int,
99 + max_blocks_per_seq: int = 512,
100 + softmax_scale: float | None = None,
101 + query_group_size: int = QUERY_GROUP_SIZE,
102 + kv_block_size: int = KV_BLOCK_SIZE,
103 + use_hw_v_transpose: bool = False,
104 + tile_groups_per_partition: int = 1,
105 +):
106 + """Build a TQ decode kernel module.
107 +
108 + ``query_group_size`` (= num_query_heads // num_kv_heads) selects the
109 + GQA factor. Supported values: 8 and 16. For 8 the MFMA's 16-row
110 + capacity is half-used; lanes 8..15 compute garbage and are gated out
111 + of all global-memory writes via an OOB-offset trick on the buffer
112 + resource. Q load iterates ``QG // 4`` chunks (= 2 for QG=8, 4 for
113 + QG=16) so we never read past the real Q rows in HBM.
114 +
115 + ``kv_block_size`` is the number of tokens per vLLM cache block. Must
116 + be a multiple of TILE_SIZE (=16). Supported values: 16 and 32. With
117 + block_size=32, two MFMA K-tiles fit in each block; the kernel walks
118 + block-table entries every ``kv_block_size // TILE_SIZE`` tiles and
119 + re-uses the entry for sub-tiles within the same block. SoA metadata
120 + region grows linearly with block_size.
121 +
122 + ``use_hw_v_transpose`` (CDNA4 / gfx950 only) replaces the 32-element
123 + scattered ``ds_write_b16`` V-dequant pattern with a row-major
124 + ``V[token][head_dim]`` LDS layout written as 4 wide ``ds_write_b128``
125 + per lane, then read back into the MFMA A operand via the hardware
126 + ``ds_read_tr16_b64`` transpose. This eliminates ~28 ds_write
127 + instructions per lane per K-tile (32 -> 4) and 8 strided ds_read_b64
128 + per PV chunk (replaces them with 1 hw-transpose read each).
129 +
130 + ``tile_groups_per_partition`` (default 1) controls the FA-2 split-K
131 + granularity. With value G each partition processes ``G * 16`` K-tiles
132 + = ``G * KV_COMPUTE_BLOCK`` tokens, with the existing 16-tile loop
133 + body iterated G times. The launcher uses this to bound
134 + ``num_partitions`` (e.g. cap at 32) for long context: at 32K /
135 + block_size=32 / num_partitions=32, G=5 covers 32*5*256 = 40 960
136 + tokens of worst-case context with grid.z=32 instead of 256.
137 + Behavior at G=1 is bit-identical to the single-partition kernel.
138 + """
139 + assert query_group_size in (8, 16), (
140 + f"query_group_size must be 8 or 16; got {query_group_size}"
141 + )
142 + assert kv_block_size in (16, 32), (
143 + f"kv_block_size must be 16 or 32; got {kv_block_size}"
144 + )
145 + assert kv_block_size % TILE_SIZE == 0
146 + assert int(tile_groups_per_partition) >= 1, (
147 + f"tile_groups_per_partition must be >= 1; got {tile_groups_per_partition}"
148 + )
149 + USE_HW_TR = bool(use_hw_v_transpose)
150 + TGPP = int(tile_groups_per_partition)
151 + PARTITION_EXTENT_TOKENS = TGPP * KV_COMPUTE_BLOCK
152 +
153 + QG = int(query_group_size)
154 + QG_LOAD_ITERS = QG // 4 # 2 for QG=8, 4 for QG=16
155 + OOB_OFFSET = 0x7FFFFFF0 # noqa: F841 ~2GB byte offset; > any plausible buffer
156 +
157 + _BS = int(kv_block_size)
158 + _TILES_PER_BLOCK = _BS // TILE_SIZE # 1 for BS=16, 2 for BS=32
159 +
160 + global allocator
161 + arch = get_hip_arch()
162 +
163 + if softmax_scale is None:
164 + softmax_scale = 1.0 / (HEAD_SIZE**0.5)
165 + _qk_scale = float(softmax_scale)
166 +
167 + # --- Strides ---
168 + _Hq = num_kv_heads * QG
169 + _stride_q_seq = _Hq * HEAD_SIZE
170 + _stride_q_head = HEAD_SIZE
171 + _stride_bt_seq = max_blocks_per_seq
172 +
173 + _data_region_bytes = _BS * num_kv_heads * DATA_BYTES_PER_SLOT
174 + _meta_region_bytes = num_kv_heads * NUM_SOA_FIELDS * _BS * 2
175 + _stride_cache_block = _data_region_bytes + _meta_region_bytes
176 + _meta_region_offset = _data_region_bytes
177 +
178 + _stride_out_part = QG * HEAD_SIZE
179 + _stride_out_head = num_partitions * QG * HEAD_SIZE
180 + _stride_out_seq = num_kv_heads * num_partitions * QG * HEAD_SIZE
181 + _stride_es_seq = num_kv_heads * num_partitions * QG
182 + _stride_ml_seq = _stride_es_seq
183 +
184 + # --- LDS layout ---
185 + allocator = SmemAllocator(None, arch=arch, global_sym_name="tq_smem")
186 + centroid_off = 0
187 + allocator.ptr = CENTROID_LDS_BYTES
188 + q_off = allocator.ptr
189 + allocator.ptr += Q_LDS_BYTES
190 + kv_off = allocator.ptr
191 + allocator.ptr += KV_TILE_LDS_BYTES
192 +
193 + @flyc.kernel
194 + def tq_decode_kernel(
195 + out_ptr: fx.Tensor,
196 + exp_sums_ptr: fx.Tensor,
197 + max_logits_ptr: fx.Tensor,
198 + query_ptr: fx.Tensor,
199 + kv_cache_ptr: fx.Tensor,
200 + centroids_ptr: fx.Tensor,
201 + block_tables_ptr: fx.Tensor,
202 + seq_lens_ptr: fx.Tensor,
203 + ):
204 + # ---- IDs ---------------------------------------------------------
205 + tid = gpu.thread_idx.x
206 + seq = gpu.block_idx.x
207 + kv_h = gpu.block_idx.y
208 + part = gpu.block_idx.z
209 + lane = tid # 0..63
210 + mfma_row = lane & fx.Int32(15) # = query_row (B's N) /
211 + # token (A's M for QK)
212 + mfma_col_grp = lane >> fx.Int32(4) # 0..3, K-group dim
213 +
214 + # ---- Buffer resources -------------------------------------------
215 + q_rsrc = buffer_ops.create_buffer_resource(query_ptr, max_size=True)
216 + kv_rsrc = buffer_ops.create_buffer_resource(kv_cache_ptr, max_size=True)
217 + bt_rsrc = buffer_ops.create_buffer_resource(block_tables_ptr, max_size=True)
218 + sl_rsrc = buffer_ops.create_buffer_resource(seq_lens_ptr, max_size=True)
219 + cent_rsrc = buffer_ops.create_buffer_resource(centroids_ptr, max_size=True)
220 + out_rsrc = buffer_ops.create_buffer_resource(out_ptr, max_size=True)
221 + es_rsrc = buffer_ops.create_buffer_resource(exp_sums_ptr, max_size=True)
222 + ml_rsrc = buffer_ops.create_buffer_resource(max_logits_ptr, max_size=True)
223 +
224 + # ---- LDS pointers -----------------------------------------------
225 + base = allocator.get_base()
226 + cent_lds = SmemPtr(base, centroid_off, T.f32, shape=(N_CENTROIDS,))
227 + q_lds_i32 = SmemPtr(base, q_off, T.i32, shape=(Q_LDS_BYTES // 4,)).get()
228 + q_lds_i64 = SmemPtr(base, q_off, T.i64, shape=(Q_LDS_BYTES // 8,)).get()
229 + kv_lds_i32 = SmemPtr(base, kv_off, T.i32, shape=(KV_TILE_LDS_BYTES // 4,)).get() # noqa: F841
230 + kv_lds_i64 = SmemPtr(base, kv_off, T.i64, shape=(KV_TILE_LDS_BYTES // 8,)).get()
231 + kv_lds_i16 = SmemPtr(base, kv_off, T.i16, shape=(KV_TILE_LDS_BYTES // 2,)).get()
232 +
233 + # ---- Constants ---------------------------------------------------
234 + c_sq = fx.Int32(_stride_q_seq)
235 + c_qh = fx.Int32(_stride_q_head)
236 + c_qg = fx.Int32(QG)
237 + c_bt = fx.Int32(_stride_bt_seq)
238 + c_block = fx.Int32(_stride_cache_block)
239 + c_meta_off = fx.Int32(_meta_region_offset)
240 + c_data_per_slot = fx.Int32(DATA_BYTES_PER_SLOT)
241 + c_kv_heads = fx.Int32(num_kv_heads)
242 + c_keydata = fx.Int32(KEY_DATA_BYTES)
243 + c_w = fx.Int32(WARP_SIZE)
244 +
245 + NEG_INF = arith.constant(NEG_INF_VAL, type=T.f32)
246 + ZERO_F = fx.Float32(0.0)
247 + ONE_F = fx.Float32(1.0)
248 + LOG2E_C = arith.constant(LOG2E, type=T.f32)
249 + QK_SCALE = arith.constant(_qk_scale, type=T.f32)
250 +
251 + # Helper: unwrap fx wrapper → raw ir.Value (used in STEP F)
252 + def _ival(v):
253 + return v.ir_value() if hasattr(v, "ir_value") else v
254 +
255 + # ===== STEP A: Load centroids → LDS (cooperative, race-safe) =====
256 + c_idx_safe = lane & fx.Int32(N_CENTROIDS - 1)
257 + c_val = buffer_ops.buffer_load(cent_rsrc, c_idx_safe, vec_width=1, dtype=T.f32)
258 + cent_lds.store(c_val, [arith.index_cast(T.index, c_idx_safe)])
259 + gpu.barrier()
260 +
261 + # ===== STEP B: Load pre-rotated q_rot → row-major LDS ============
262 + # q_rsrc points to the externally-rotated q_rot tensor. Load 8 bf16
263 + # per lane per iter (vec_width=4 i32) in QG_LOAD_ITERS iterations.
264 + for c in range_constexpr(QG_LOAD_ITERS):
265 + row_chunk = lane + fx.Int32(c * WARP_SIZE) # 0..QG*16-1
266 + row = row_chunk >> fx.Int32(4) # 0..QG-1
267 + col_b = row_chunk & fx.Int32(15) # 0..15
268 + col_elem = col_b * fx.Int32(8) # 0..120 in bf16
269 + q_off_elem = seq * c_sq + (kv_h * c_qg + row) * c_qh + col_elem
270 + q_v = buffer_ops.buffer_load(
271 + q_rsrc,
272 + q_off_elem // fx.Int32(2),
273 + vec_width=4,
274 + dtype=T.i32,
275 + )
276 + q_lds_byte = row * fx.Int32(HEAD_SIZE * 2) + col_elem * fx.Int32(2)
277 + vector.store(
278 + q_v,
279 + q_lds_i32,
280 + [arith.index_cast(T.index, q_lds_byte // fx.Int32(4))],
281 + )
282 + gpu.barrier()
283 +
284 + # ===== STEP C: Pre-load Q operands for QK_K_CHUNKS = 4 K-chunks ===
285 + # Wide-K MFMA: K_per_chunk = 32, K_per_lane = 32/4 = 8 bf16 per chunk.
286 + # Lane t holds 8 bf16 at row=mfma_row, K-cols
287 + # chunk*32 + mfma_col_grp*8 + 0..7
288 + # Byte addr = mfma_row * HEAD_SIZE*2 + (chunk*32 + col_grp*8)*2
289 + # = mfma_row * 256 + chunk*64 + col_grp*16
290 + # i64 idx = mfma_row * 32 + chunk*8 + col_grp*2 (each i64 = 4 bf16)
291 + # Load 16 bytes (= 8 bf16 = 2 i64) per chunk via vec(2, i64).
292 + q_chunks = []
293 + for chk in range_constexpr(QK_K_CHUNKS):
294 + q_idx_i64 = (
295 + mfma_row * fx.Int32(HEAD_SIZE * 2 // 8)
296 + + fx.Int32(chk * 8)
297 + + mfma_col_grp * fx.Int32(2)
298 + )
299 + qv = vector.load_op(
300 + T.vec(2, T.i64),
301 + q_lds_i64,
302 + [arith.index_cast(T.index, q_idx_i64)],
303 + )
304 + q_chunks.append(vector.bitcast(T.vec(8, T.bf16), qv))
305 +
306 + # ===== STEP D: Online softmax + PV state =========================
307 + running_max = NEG_INF
308 + running_sum = ZERO_F
309 + zero_v4 = arith.constant_vector(0.0, T.f32x4)
310 + acc_pv = [zero_v4 for _ in range(PV_N_CHUNKS)]
311 +
312 + # ===== STEP E: Sequence-len + partition base ====================
313 + seq_len = buffer_ops.buffer_load(sl_rsrc, seq, vec_width=1, dtype=T.i32)
314 + # ── Bounded num_partitions + internal looping ───────────────────
315 + # With TGPP > 1 this CTA owns a contiguous slice of length
316 + # ``PARTITION_EXTENT_TOKENS`` that is iterated as ``TGPP`` groups
317 + # of 16 K-tiles each. ``partition_start`` is recomputed per
318 + # outer ``tg`` iteration below; the FA-2 online-softmax state
319 + # (running_max, running_sum, acc_pv) accumulates across all
320 + # ``TGPP * 16`` tiles for this CTA — semantically identical to
321 + # processing one large 16*TGPP-tile partition.
322 + partition_base = part * fx.Int32(PARTITION_EXTENT_TOKENS)
323 +
324 + # Per-K-tile dequant lane assignment:
325 + # 16 tokens × 64 packed bytes per token = 1024 bytes
326 + # 64 lanes × 16 bytes/lane = 1024 ✓
327 + # Lane t: token = t / 4, sub-chunk = t % 4 (0..3) → 16 bytes contiguous
328 + tok_in_tile = lane >> fx.Int32(2)
329 + chunk_in_tok = lane & fx.Int32(3)
330 + # ``slot`` (= absolute slot index within the cache block, range
331 + # 0.._BS-1) is recomputed per tile inside the K-tile loop below
332 + # so it reflects ``tile_in_block * TILE_SIZE + tok_in_tile``.
333 +
334 + c_meta_u16_per_kvh = fx.Int32(NUM_SOA_FIELDS * _BS)
335 + c_knorm_off_u16 = fx.Int32(SOA_K_NORM * _BS)
336 + c_vscale_off_u16 = fx.Int32(SOA_V_SCALE * _BS)
337 + c_vzero_off_u16 = fx.Int32(SOA_V_ZERO * _BS)
338 +
339 + # ===== STEP F: K-tile loop =======================================
340 + # With kv_block_size > TILE_SIZE there are _TILES_PER_BLOCK tiles
341 + # per cache block. Tiles within the same block share a block-table
342 + # entry but address different slot ranges within the block.
343 + #
344 + # ── Per-tile block-table OOB redirect ────────────────────────────
345 + # The K-tile loop is unrolled 16 times so every CTA issues 16
346 + # block-table reads regardless of (partition, seq_len). When a
347 + # partition extends past ``seq_len`` (e.g. num_partitions=2 for
348 + # a 256-token sequence — partition 1 covers tokens 256..511, all
349 + # masked out) the per-tile bt offset can land beyond the bt
350 + # allocation. With ``max_size=True`` the descriptor advertises
351 + # 4 GB so the HW returns whatever pre-existing HBM bytes live at
352 + # that offset; the resulting garbage ``phys_block`` is multiplied
353 + # by ``stride_cache_block`` and the subsequent kv_cache read
354 + # jumps into pages that may be unmapped (→ "Memory access fault
355 + # by GPU node") or may decode as NaN bytes (→ NaN segm_out).
356 + # Allocator-pattern dependent, but reproduces deterministically
357 + # at large B (e.g. B=64 seq=256 in the test sweep produces 8 192
358 + # NaN entries even on canonical QG=8).
359 + #
360 + # Fix: when the tile starts at or past ``seq_len`` (so its
361 + # qk_acc is going to be killed by the per-token
362 + # ``kv_tok < seq_len`` mask anyway), redirect the bt read to
363 + # ``bt[seq, 0]`` — always in bounds. The redundant phys_block
364 + # decode + kv_cache read is wasted work, but correctness is
365 + # preserved without changing the kernel's iteration count.
366 + # ===== Runtime-adaptive scf.ForOp w/ iter_args =========
367 + # Runtime-adaptive outer loop: trip count derives from
368 + # actual seq_len, NOT TGPP_max. At cudagraph-capture warmup
369 + # (seq_len=1) only 1 iteration runs; at 32K production
370 + # decode all TGPP iterations run. Kernel binary stays small
371 + # (single body, looped at runtime) — capture time matches
372 + # the legacy 16-tile-only kernel rather than scaling 4x.
373 + #
374 + # FA-2 state (running_max, running_sum, acc_pv[8]) threads
375 + # through scf iter_args; the body reads them at top, runs
376 + # the unchanged 16-tile inner body, and yields the new
377 + # state at bottom. After the loop, results are pulled out
378 + # of for_op.results back into the local Python names so the
379 + # downstream STEP G (output) is unmodified.
380 + c_kcb = fx.Int32(KV_COMPUTE_BLOCK)
381 + c_tgpp = fx.Int32(TGPP)
382 + c_zero_i32 = fx.Int32(0)
383 + c_one_i32 = fx.Int32(1)
384 + # remaining = seq_len - partition_base (signed, may be <=0)
385 + remaining = seq_len - partition_base
386 + in_range = remaining > c_zero_i32
387 + # trip_raw = ceil(remaining / KV_COMPUTE_BLOCK)
388 + # (when in_range is false, the divisor branch produces
389 + # garbage that the select below overrides with zero, so we
390 + # don't pre-clamp)
391 + trip_raw = (remaining + c_kcb - c_one_i32) // c_kcb
392 + trip_clamped = (trip_raw > c_tgpp).select(c_tgpp, trip_raw)
393 + trip_or_zero = in_range.select(trip_clamped, c_zero_i32)
394 + c_zero_idx = arith.constant(0, index=True)
395 + c_one_idx = arith.constant(1, index=True)
396 + trip_idx = arith.index_cast(
397 + T.index,
398 + trip_or_zero.ir_value()
399 + if hasattr(trip_or_zero, "ir_value")
400 + else trip_or_zero,
401 + )
402 + # Initial iter_args list: FA-2 accumulator state.
403 + # Order: running_max, running_sum, acc_pv[0..PV_N_CHUNKS-1].
404 + # NOTE: scf.ForOp requires ir.Value (with .type). The DSL
405 + # constants ZERO_F / ONE_F are fx.Float32 (Numeric) wrappers,
406 + # not raw ir.Value, so we unwrap via .ir_value() before
407 + # passing. _ival() is defined once near the top of the kernel body.
408 + _init_iter = [
409 + _ival(running_max),
410 + _ival(running_sum),
411 + *[_ival(p) for p in acc_pv],
412 + ]
413 + _for_op = _scf.ForOp(
414 + c_zero_idx,
415 + trip_idx,
416 + c_one_idx,
417 + _init_iter,
418 + )
419 + _for_ip = ir.InsertionPoint(_for_op.body)
420 + _for_ip.__enter__()
421 + try:
422 + tg_idx = _for_op.induction_variable
423 + tg_i32 = fx.Int32(arith.index_cast(T.i32, tg_idx))
424 + partition_start = partition_base + tg_i32 * c_kcb
425 + bt_seq_base = seq * c_bt + (partition_start // fx.Int32(_BS))
426 + running_max = _for_op.inner_iter_args[0]
427 + running_sum = _for_op.inner_iter_args[1]
428 + acc_pv = list(_for_op.inner_iter_args[2:])
429 + for n_tile in range_constexpr(16):
430 + block_in_part = n_tile // _TILES_PER_BLOCK
431 + tile_in_block = n_tile % _TILES_PER_BLOCK
432 + tile_start_tok = partition_start + fx.Int32(n_tile * TILE_SIZE)
433 + tile_in_seq = tile_start_tok < seq_len
434 + bt_off = bt_seq_base + fx.Int32(block_in_part)
435 + bt_off_safe = tile_in_seq.select(bt_off, seq * c_bt)
436 + phys_block = buffer_ops.buffer_load(
437 + bt_rsrc,
438 + bt_off_safe,
439 + vec_width=1,
440 + dtype=T.i32,
441 + )
442 + block_base = phys_block * c_block
443 + data_region = block_base
444 + meta_region = block_base + c_meta_off
445 +
446 + # ``slot`` is the absolute slot index within the cache block
447 + # (range 0.._BS-1). For BS=16 it equals tok_in_tile; for BS=32
448 + # it equals tile_in_block * 16 + tok_in_tile. Used both for
449 + # the K/V data region and SoA metadata region addressing.
450 + slot = fx.Int32(tile_in_block * TILE_SIZE) + tok_in_tile
451 + data_bases_byte = (
452 + data_region
453 + + slot * (c_kv_heads * c_data_per_slot)
454 + + kv_h * c_data_per_slot
455 + )
456 + k_byte = data_bases_byte + chunk_in_tok * fx.Int32(16)
457 + k_packed = buffer_ops.buffer_load(
458 + kv_rsrc,
459 + k_byte // fx.Int32(4),
460 + vec_width=4,
461 + dtype=T.i32,
462 + )
463 +
464 + # ---- HOISTED: issue V data + meta HBM loads early ---------
465 + # These are async; their s_waitcnt is pushed by the compiler
466 + # past the K dequant + QK MFMA + softmax block, hiding most
467 + # of the V HBM latency behind compute.
468 + v_byte = data_bases_byte + c_keydata + chunk_in_tok * fx.Int32(16)
469 + v_packed = buffer_ops.buffer_load(
470 + kv_rsrc,
471 + v_byte // fx.Int32(4),
472 + vec_width=4,
473 + dtype=T.i32,
474 + )
475 + vscale_u16 = (
476 + meta_region // fx.Int32(2)
477 + + kv_h * c_meta_u16_per_kvh
478 + + c_vscale_off_u16
479 + + slot
480 + )
481 + vzero_u16 = (
482 + meta_region // fx.Int32(2)
483 + + kv_h * c_meta_u16_per_kvh
484 + + c_vzero_off_u16
485 + + slot
486 + )
487 + vscale_raw = buffer_ops.buffer_load(
488 + kv_rsrc,
489 + vscale_u16,
490 + vec_width=1,
491 + dtype=T.i16,
492 + )
493 + vzero_raw = buffer_ops.buffer_load(
494 + kv_rsrc,
495 + vzero_u16,
496 + vec_width=1,
497 + dtype=T.i16,
498 + )
499 +
500 + knorm_u16 = (
501 + meta_region // fx.Int32(2)
502 + + kv_h * c_meta_u16_per_kvh
503 + + c_knorm_off_u16
504 + + slot
505 + )
506 + knorm_raw = buffer_ops.buffer_load(
507 + kv_rsrc,
508 + knorm_u16,
509 + vec_width=1,
510 + dtype=T.i16,
511 + )
512 + knorm_f16 = arith.bitcast(T.f16, knorm_raw)
513 + knorm_f32 = arith.extf(T.f32, knorm_f16)
514 +
515 + # K dequant → LDS [token, head_dim] (natural)
516 + # Lane writes 32 bf16 (= 4×8) for token=tok_in_tile,
517 + # head_dims chunk_in_tok*32..+31 (4 sub-chunks of 8).
518 + tok_kreg = tok_in_tile * fx.Int32(HEAD_SIZE * 2 // 8)
519 + chunk_kreg = tok_kreg + chunk_in_tok * fx.Int32(8)
520 + for w in range_constexpr(4):
521 + word_i32 = vector.extract(k_packed, static_position=[w])
522 + bf16_elems = []
523 + for n in range_constexpr(8):
524 + nibble = (word_i32 >> fx.Int32(n * 4)) & fx.Int32(0xF)
525 + nibble_idx = arith.index_cast(T.index, nibble)
526 + cent_f32 = cent_lds.load([nibble_idx])
527 + elem_bf16 = arith.trunc_f(T.bf16, cent_f32 * knorm_f32)
528 + bf16_elems.append(elem_bf16)
529 + v_bf16 = vector.from_elements(T.vec(8, T.bf16), bf16_elems)
530 + v_i64 = vector.bitcast(T.vec(2, T.i64), v_bf16)
531 + vector.store(
532 + v_i64,
533 + kv_lds_i64,
534 + [arith.index_cast(T.index, chunk_kreg + fx.Int32(w * 2))],
535 + )
536 + gpu.barrier()
537 +
538 + # QK MFMA (CDNA4 wide-K): A=K[token, head_dim], B=Q (= Q^T).
539 + # K read: lane t = K[token=mfma_row, head_dim=chunk*32+col_grp*8..+7]
540 + # Same i64×2 indexing as Q.
541 + qk_acc = zero_v4
542 + for chk in range_constexpr(QK_K_CHUNKS):
543 + k_idx_i64 = (
544 + mfma_row * fx.Int32(HEAD_SIZE * 2 // 8)
545 + + fx.Int32(chk * 8)
546 + + mfma_col_grp * fx.Int32(2)
547 + )
548 + kv_load = vector.load_op(
549 + T.vec(2, T.i64),
550 + kv_lds_i64,
551 + [arith.index_cast(T.index, k_idx_i64)],
552 + )
553 + k_op = vector.bitcast(T.vec(8, T.bf16), kv_load)
554 + qk_acc = rocdl.mfma_f32_16x16x32_bf16(
555 + T.f32x4, [k_op, q_chunks[chk], qk_acc, 0, 0, 0]
556 + )
557 +
558 + # qk_acc layout: lane t holds C[token=(t/16)*4..+3, query=t%16]
559 + # 4 fp32/lane = 4 different tokens at SAME query_row.
560 +
561 + # Scale + mask out-of-context tokens.
562 + qk_acc = _vsplat_mul(qk_acc, QK_SCALE)
563 + for elem in range_constexpr(4):
564 + kv_tok = (
565 + partition_start
566 + + fx.Int32(n_tile * TILE_SIZE)
567 + + mfma_col_grp * fx.Int32(4)
568 + + fx.Int32(elem)
569 + )
570 + in_b = kv_tok < seq_len
571 + v = vector.extract(qk_acc, static_position=[elem])
572 + qk_acc = vector.insert(
573 + in_b.select(v, NEG_INF),
574 + qk_acc,
575 + static_position=[elem],
576 + dynamic_position=[],
577 + )
578 +
579 + # FA2 online softmax: per-query-row reduce.
580 + # Per-row max: max over 4 fp32 in lane, then xor-shuffle 16, 32
581 + # (across the 4 col_grps that share same mfma_row).
582 + local_max = vector.reduction(T.f32, "maxnumf", qk_acc)
583 + r1 = local_max.shuffle_xor(fx.Int32(16), c_w)
584 + local_max = local_max.maximumf(r1)
585 + r2 = local_max.shuffle_xor(fx.Int32(32), c_w)
586 + tile_max = local_max.maximumf(r2)
587 +
588 + new_max = running_max.maximumf(tile_max)
589 + max_diff = running_max - new_max
590 + safe_diff = (running_max > NEG_INF).select(max_diff, ZERO_F)
591 + scale = (safe_diff * LOG2E_C).exp2(fastmath=arith.FastMathFlags.fast)
592 + running_sum = running_sum * scale
593 + for h in range_constexpr(PV_N_CHUNKS):
594 + acc_pv[h] = _vsplat_mul(acc_pv[h], scale)
595 + running_max = new_max
596 +
597 + # Compute probs: p = exp((qk - new_max) * LOG2E)
598 + tile_sum = ZERO_F
599 + for elem in range_constexpr(4):
600 + s = vector.extract(qk_acc, static_position=[elem])
601 + d = s - new_max
602 + d = (new_max > NEG_INF).select(d, NEG_INF)
603 + p = (d * LOG2E_C).exp2(fastmath=arith.FastMathFlags.fast)
604 + tile_sum = tile_sum + p
605 + qk_acc = vector.insert(
606 + p, qk_acc, static_position=[elem], dynamic_position=[]
607 + )
608 +
609 + ts1 = tile_sum.shuffle_xor(fx.Int32(16), c_w)
610 + tile_sum = tile_sum + ts1
611 + ts2 = tile_sum.shuffle_xor(fx.Int32(32), c_w)
612 + tile_sum = tile_sum + ts2
613 + running_sum = running_sum + tile_sum
614 +
615 + # ---- V dequant → LDS (V/scale/zero raw loads were hoisted) -
616 + # v_packed, vscale_raw, vzero_raw are already in flight from
617 + # the top of this iteration; only the f32 conversion stays
618 + # here so the actual consume site is still close to the use.
619 + vscale_f32 = arith.extf(T.f32, arith.bitcast(T.f16, vscale_raw))
620 + vzero_f32 = arith.extf(T.f32, arith.bitcast(T.f16, vzero_raw))
621 +
622 + if USE_HW_TR:
623 + # V dequant → LDS [token][head_dim] (ROW-MAJOR, no transpose).
624 + # Each lane writes 32 contiguous bf16 (one token, head_dims
625 + # chunk_in_tok*32..+31) as 4× ds_write_b128 = 4× vec(2,i64).
626 + # Replaces 32× ds_write_b16 of the legacy transposed path.
627 + v_lds_elem_base = tok_in_tile * fx.Int32(
628 + HEAD_SIZE
629 + ) + chunk_in_tok * fx.Int32(32)
630 + for w in range_constexpr(4):
631 + word_i32 = vector.extract(v_packed, static_position=[w])
632 + bf16_elems = []
633 + for n in range_constexpr(8):
634 + nibble = (word_i32 >> fx.Int32(n * 4)) & fx.Int32(0xF)
635 + nibble_f32 = arith.sitofp(T.f32, nibble)
636 + elem_f32 = nibble_f32 * vscale_f32 + vzero_f32
637 + elem_bf16 = arith.trunc_f(T.bf16, elem_f32)
638 + bf16_elems.append(elem_bf16)
639 + v_bf16 = vector.from_elements(T.vec(8, T.bf16), bf16_elems)
640 + v_i64 = vector.bitcast(T.vec(2, T.i64), v_bf16)
641 + v_lds_i64_idx = (v_lds_elem_base + fx.Int32(w * 8)) // fx.Int32(
642 + 4
643 + )
644 + vector.store(
645 + v_i64,
646 + kv_lds_i64,
647 + [arith.index_cast(T.index, v_lds_i64_idx)],
648 + )
649 + # ── HW V transpose: cross-lane LDS sync ─────────────────────
650 + # ds_read_tr16_b64 below introduces a cross-lane LDS read:
651 + # consumer lane t reads bytes written by source lanes
652 + # 0,4,8,12 (etc.). The compiler's automatic waitcnt insertion
653 + # is per-lane and has no awareness of the HW transpose's
654 + # cross-lane forwarding, so it can sink the next iteration's
655 + # ds_read ahead of these ds_write_b128 ops in the schedule.
656 + #
657 + # Two-part fence:
658 + # 1. sched_barrier(0): MachineScheduler reorder barrier
659 + # (mask=0 → no instruction class may cross). Pure hint,
660 + # generates no runtime instruction. Use the simple
661 + # sched_barrier (NOT sched_group_barrier, which is an
662 + # IGLP marker that requires partner barriers and
663 + # corrupts the schedule when used in isolation).
664 + # 2. s_waitcnt lgkmcnt=0: runtime drain of the LDS write
665 + # queue. vmcnt/expcnt left at no-wait so we don't
666 + # stall on speculatively-issued HBM loads (which can
667 + # include OOB-but-masked buffer_load reads from the
668 + # next K-tile's hoisted V prefetch).
669 + #
670 + # The SW path below does NOT need this because each lane
671 + # only reads cells it itself wrote (no cross-lane dep), and
672 + # the per-lane same-address waitcnt the compiler emits is
673 + # correct.
674 + #
675 + # Without this fence the reordered ds_read produces
676 + # wrong results on high-partition-count configs (large
677 + # padded num_partitions); low-partition configs are
678 + # unaffected.
679 + rocdl.sched_barrier(0)
680 + # encode_waitcnt(vmcnt=63, expcnt=7, lgkmcnt=0)
681 + # = 0xF | (7<<4) | (0<<8) | (3<<14) = 0xC07F
682 + rocdl.s_waitcnt(0xC07F)
683 + else:
684 + # Legacy transposed V_LDS path: 32 ds_write_b16 per lane.
685 + for w in range_constexpr(4):
686 + word_i32 = vector.extract(v_packed, static_position=[w])
687 + for n in range_constexpr(8):
688 + nibble = (word_i32 >> fx.Int32(n * 4)) & fx.Int32(0xF)
689 + nibble_f32 = arith.sitofp(T.f32, nibble)
690 + elem_f32 = nibble_f32 * vscale_f32 + vzero_f32
691 + elem_bf16 = arith.trunc_f(T.bf16, elem_f32)
692 + elem_i16 = arith.bitcast(T.i16, elem_bf16)
693 + head_dim = chunk_in_tok * fx.Int32(32) + fx.Int32(w * 8 + n)
694 + v_idx_i16 = head_dim * fx.Int32(TILE_SIZE) + tok_in_tile
695 + v_vec = vector.from_elements(T.vec(1, T.i16), [elem_i16])
696 + vector.store(
697 + v_vec,
698 + kv_lds_i16,
699 + [arith.index_cast(T.index, v_idx_i16)],
700 + )
701 + gpu.barrier()
702 +
703 + # ---- PV MFMA: A=V[head_dim, token], B=P (=qk_acc bf16) -----
704 + # P operand B layout matches qk_acc: lane t holds 4 bf16 at
705 + # K=token=(t/16)*4..+3, N=query=t%16. Just trunc_f to bf16.
706 + p_bf16 = arith.trunc_f(T.vec(4, T.bf16), qk_acc)
707 + p_op = vector.bitcast(T.vec(4, T.i16), p_bf16)
708 +
709 + if USE_HW_TR:
710 + # HW-transpose PV path: V_lds is row-major V[token][head_dim].
711 + # MFMA A operand layout: lane t holds 4 bf16 at
712 + # M=head_dim=mfma_row + h*16, K=token=(t/16)*4..+3
713 + # ds_read_tr16_b64 (4-element 16-bit transpose, per-16-lane block):
714 + # result[lane=t, elem=e] = Input[source_lane=e*4 + (t%16)//4,
715 + # col=t%4]
716 + # Per-lane address: token_idx = lane // 4 (covers 0..15 across
717 + # all four 16-lane MFMA blocks), hd_sub = (lane % 4)*4 selects
718 + # the 4-element column window inside the h*16 chunk.
719 + # Total LDS byte offset = kv_off + token_idx*HEAD_SIZE*2
720 + # + (h*16 + hd_sub)*2
721 + token_idx = lane >> fx.Int32(2)
722 + hd_sub = (lane & fx.Int32(3)) * fx.Int32(4)
723 + v_lane_byte = (
724 + fx.Int32(kv_off)
725 + + token_idx * fx.Int32(HEAD_SIZE * 2)
726 + + hd_sub * fx.Int32(2)
727 + )
728 + for h in range_constexpr(PV_N_CHUNKS):
729 + v_byte_off = v_lane_byte + fx.Int32(h * 32)
730 + v_byte_i64 = fx.Int64(v_byte_off)
731 + v_ptr = buffer_ops.create_llvm_ptr(
732 + v_byte_i64,
733 + address_space=3,
734 + )
735 + v_op_raw = rocdl.ds_read_tr16_b64(
736 + T.vec(4, T.i16),
737 + v_ptr,
738 + ).result
739 + acc_pv[h] = rocdl.mfma_f32_16x16x16bf16_1k(
740 + T.f32x4, [v_op_raw, p_op, acc_pv[h], 0, 0, 0]
741 + )
742 + else:
743 + # Legacy transposed-LDS PV: 1 ds_read_b64 per chunk.
744 + # Byte addr = (mfma_row + h*16) * TILE_SIZE * 2 + (col_grp*4) * 2
745 + # = (mfma_row + h*16) * 32 + col_grp*8
746 + # i64 idx = (mfma_row + h*16) * 4 + col_grp
747 + for h in range_constexpr(PV_N_CHUNKS):
748 + v_idx_i64 = (mfma_row + fx.Int32(h * 16)) * fx.Int32(
749 + 4
750 + ) + mfma_col_grp
751 + kv_load = vector.load_op(
752 + T.vec(1, T.i64),
753 + kv_lds_i64,
754 + [arith.index_cast(T.index, v_idx_i64)],
755 + )
756 + v_op = vector.bitcast(T.vec(4, T.i16), kv_load)
757 + acc_pv[h] = rocdl.mfma_f32_16x16x16bf16_1k(
758 + T.f32x4, [v_op, p_op, acc_pv[h], 0, 0, 0]
759 + )
760 +
761 + _scf.YieldOp(
762 + [
763 + _ival(running_max),
764 + _ival(running_sum),
765 + *[_ival(p) for p in acc_pv],
766 + ]
767 + )
768 + finally:
769 + _for_ip.__exit__(None, None, None)
770 + # Pull final accumulator values out of the for_op results.
771 + running_max = _for_op.results[0]
772 + running_sum = _for_op.results[1]
773 + acc_pv = list(_for_op.results[2:])
774 +
775 + # ===== STEP G: Output ===========================================
776 + # acc_pv[h] layout: lane t holds 4 fp32 at M=head_dim=(t/16)*4..+3 + h*16,
777 + # N=query_row=t%16.
778 + # 4 fp32/lane = 4 contiguous head_dim values for ONE query_row.
779 + safe_sum = (running_sum > ZERO_F).select(running_sum, ONE_F)
780 + rcp = ONE_F / safe_sum
781 +
782 + c_os = fx.Int32(_stride_out_seq)
783 + c_oh = fx.Int32(_stride_out_head)
784 + c_op_ = fx.Int32(_stride_out_part)
785 + out_base = seq * c_os + kv_h * c_oh + part * c_op_
786 +
787 + # Gate all global stores by mfma_row < QG. For QG=16 the predicate is
788 + # tautological (mfma_row ∈ [0,15]); for QG=8 lanes 8..15 (whose MFMA
789 + # outputs are computational waste) skip the stores entirely.
790 + valid_row_pred = arith.cmpi(
791 + arith.CmpIPredicate.ult,
792 + mfma_row.ir_value() if hasattr(mfma_row, "ir_value") else mfma_row,
793 + arith.constant(QG, type=T.i32),
794 + )
795 + _if = _scf.IfOp(valid_row_pred)
796 + with ir.InsertionPoint(_if.then_block):
797 + for h in range_constexpr(PV_N_CHUNKS):
798 + pv_norm = _vsplat_mul(acc_pv[h], rcp)
799 + pv_bf16 = arith.trunc_f(T.vec(4, T.bf16), pv_norm)
800 + pv_i32x2 = vector.bitcast(T.vec(2, T.i32), pv_bf16)
801 + head_dim_start = fx.Int32(h * 16) + mfma_col_grp * fx.Int32(4)
802 + out_off_elem = (
803 + out_base + mfma_row * fx.Int32(HEAD_SIZE) + head_dim_start
804 + )
805 + buffer_ops.buffer_store(
806 + pv_i32x2,
807 + out_rsrc,
808 + out_off_elem * fx.Int32(2),
809 + offset_is_bytes=True,
810 + )
811 +
812 + c_npq = fx.Int32(num_partitions * QG)
813 + ml_off = (
814 + seq * fx.Int32(_stride_ml_seq) + kv_h * c_npq + part * c_qg + mfma_row
815 + )
816 + es_off = (
817 + seq * fx.Int32(_stride_es_seq) + kv_h * c_npq + part * c_qg + mfma_row
818 + )
819 + buffer_ops.buffer_store(running_max, ml_rsrc, ml_off)
820 + buffer_ops.buffer_store(running_sum, es_rsrc, es_off)
821 + _scf.YieldOp([])
822 +
823 + return tq_decode_kernel
vllm/v1/attention/ops/flydsl_kernels/tq_decode_gqa6.py new
+875
@@ -0,0 +1,875 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +# TurboQuant decode GQA-6 sibling (FlyDSL) — MI355X / gfx950 / CDNA4
4 +#
5 +# Decode-only paged attention over a TurboQuant SoA KV cache.
6 +# Targets MiniMax-M2.5 class models: HEAD_SIZE=128, GQA group=6, MSE_BITS=4 K,
7 +# VQB=4 V, N_CENTROIDS=16, BLOCK_SIZE=16, partitioned mode.
8 +#
9 +# === Sibling-file rationale ===
10 +# Structurally identical to tq_decode.py except:
11 +# * QUERY_GROUP_SIZE default = 6 (was 8)
12 +# * Build-time assert allows query_group_size = 6 only (Qwen sizes go to
13 +# the canonical tq_decode kernel — NOT this file)
14 +# * QG_LOAD_ITERS = (QG + 3) // 4 (round-up; 2 iters for QG=6 loads
15 +# 8 LDS rows of which only 6 carry real Q; rows 6..7 are zero-padded
16 +# by buffer_load OOB protection — gated out by the same mfma_row < QG
17 +# predicate the canonical kernel already uses for QG=8)
18 +#
19 +# All cross-lane operations (xor_shuffle softmax reductions, ds_read_tr16_b64
20 +# HW V transpose) are mfma_row-isolated and thus already GQA-agnostic. The
21 +# kernel is functionally a direct copy of tq_decode.py with the QG knob
22 +# turned to 6 — kept as a sibling per FlyDSL convention to preserve the
23 +# canonical kernel's invariants for its production callers (Qwen2.5-72B,
24 +# Qwen3-32B).
25 +#
26 +# === Design ===
27 +# * 1 wave (64 lanes) per CTA — simpler than 4-warp; trades parallelism for
28 +# correctness/debug-ability. A later revision will fan out to 4 warps.
29 +# * Flash-Attention-2 online softmax: rescale running PV/sum per K-tile,
30 +# no QK score storage across tiles, no P-LDS round-trip (qk_acc layout
31 +# matches PV B-operand directly).
32 +# * Centroids loaded once into LDS at kernel start (LDS-resident LUT).
33 +# * K dequant → LDS [token, head_dim] (natural layout)
34 +# * V dequant → LDS [head_dim, token] (TRANSPOSED — required so PV MFMA's
35 +# A=V operand can be loaded as 4-bf16 contiguous via 1 ds_read_b64).
36 +#
37 +# MFMA layouts used:
38 +# QK: mfma(A=K, B=Q, C=qk_acc) → C[m=token, n=query], 4 fp32/lane
39 +# = 4 tokens for SAME query → softmax via
40 +# xor_shuffle(16),(32) reduces per-row
41 +# PV: mfma(A=V_T, B=P, C=acc_pv) → C[m=head_dim, n=query], 4 fp32/lane
42 +# = 4 contiguous head_dim cols for SAME
43 +# query → 1 buffer_store of 8 bytes
44 +# per chunk per lane
45 +
46 +from __future__ import annotations
47 +
48 +import flydsl.compiler as flyc
49 +import flydsl.expr as fx
50 +from flydsl._mlir import ir
51 +from flydsl._mlir.dialects import scf as _scf
52 +from flydsl.expr import (
53 + arith,
54 + buffer_ops,
55 + const_expr, # noqa: F401 kept for kernel-DSL API parity
56 + gpu,
57 + range_constexpr,
58 + rocdl,
59 + vector,
60 +)
61 +from flydsl.expr.typing import Int32, T # noqa: F401 Int32 via fx.Int32
62 +from flydsl.runtime.device import get_rocm_arch as get_hip_arch
63 +from flydsl.utils.smem_allocator import SmemAllocator, SmemPtr
64 +
65 +# === Constants (MiniMax-M2.5-class TQ decode profile) =======================
66 +HEAD_SIZE = 128
67 +# default; overridable via build_tq_decode_gqa6_module(kv_block_size=...)
68 +KV_BLOCK_SIZE = 16
69 +# MFMA tile = 16 tokens (do not change without re-deriving MFMA shapes)
70 +TILE_SIZE = 16
71 +N_CENTROIDS = 16
72 +QUERY_GROUP_SIZE = 6 # GQA-6 default for MiniMax-M2.5
73 +WARP_SIZE = 64
74 +NUM_WARPS = 1
75 +BLOCK_THREADS = NUM_WARPS * WARP_SIZE
76 +KV_COMPUTE_BLOCK = 256 # 16 K-tiles × 16 tokens
77 +
78 +# TQ SoA layout
79 +NUM_SOA_FIELDS = 3
80 +SOA_K_NORM = 0
81 +SOA_V_SCALE = 1
82 +SOA_V_ZERO = 2
83 +KEY_DATA_BYTES = HEAD_SIZE // 2
84 +VAL_DATA_BYTES = HEAD_SIZE // 2
85 +DATA_BYTES_PER_SLOT = KEY_DATA_BYTES + VAL_DATA_BYTES
86 +
87 +# MFMA
88 +MFMA_M = MFMA_N = 16
89 +MFMA_K_BF16_QK = 32 # CDNA4 wide-K (mfma_f32_16x16x32_bf16)
90 +MFMA_K_BF16_PV = 16 # PV's K = token tile = 16
91 +QK_K_CHUNKS = HEAD_SIZE // MFMA_K_BF16_QK # 4
92 +PV_N_CHUNKS = HEAD_SIZE // MFMA_N # 8
93 +
94 +# LDS regions
95 +# Q region uses the full 16-row footprint (4096 B) rather than a tight
96 +# QG=6 sizing, keeping the LDS layout identical to the canonical kernel.
97 +CENTROID_LDS_BYTES = N_CENTROIDS * 4 # 64
98 +Q_LDS_BYTES = 16 * HEAD_SIZE * 2 # 4096
99 +KV_TILE_LDS_BYTES = TILE_SIZE * HEAD_SIZE * 2 # 4096
100 +
101 +LOG2E = 1.4426950408889634
102 +NEG_INF_VAL = float("-inf")
103 +
104 +
105 +def _vsplat_mul(vec, scalar):
106 + s = scalar.ir_value() if hasattr(scalar, "ir_value") else scalar
107 + return vec * vector.broadcast(T.f32x4, s)
108 +
109 +
110 +allocator = None
111 +
112 +
113 +def build_tq_decode_gqa6_module(
114 + num_seqs: int,
115 + num_kv_heads: int,
116 + num_partitions: int,
117 + max_blocks_per_seq: int = 512,
118 + softmax_scale: float | None = None,
119 + query_group_size: int = QUERY_GROUP_SIZE,
120 + kv_block_size: int = KV_BLOCK_SIZE,
121 + use_hw_v_transpose: bool = False,
122 + tile_groups_per_partition: int = 1,
123 +):
124 + """Build a TQ decode GQA-6 kernel module (MiniMax-M2.5).
125 +
126 + ``query_group_size`` (= num_query_heads // num_kv_heads) is fixed at 6
127 + for this sibling. The MFMA's 16-row capacity is 6/16 = 37.5% used;
128 + lanes 6..15 compute garbage and are gated out of all global-memory
129 + writes via the same ``mfma_row < QG`` predicate the canonical kernel
130 + uses for QG=8. Q load iterates ``(QG + 3) // 4`` = 2 chunks (loads
131 + 8 LDS rows of which rows 6..7 are zero-padded by the buffer-resource
132 + OOB protection — they're never consumed because the gating lanes
133 + don't write).
134 +
135 + For GQA-8 or GQA-16 (Qwen models), use the canonical
136 + ``build_tq_decode_module`` from ``tq_decode.py`` instead.
137 +
138 + ``kv_block_size`` is the number of tokens per vLLM cache block. Must
139 + be a multiple of TILE_SIZE (=16). Supported values: 16 and 32.
140 +
141 + ``use_hw_v_transpose`` (CDNA4 / gfx950 only) — see canonical kernel
142 + docstring for details. Same fence pattern applies here.
143 + """
144 + assert query_group_size == 6, (
145 + f"build_tq_decode_gqa6_module is GQA-6 only; got "
146 + f"query_group_size={query_group_size}. Use build_tq_decode_module "
147 + f"from tq_decode.py for GQA-8 or GQA-16."
148 + )
149 + assert kv_block_size in (16, 32), (
150 + f"kv_block_size must be 16 or 32; got {kv_block_size}"
151 + )
152 + assert kv_block_size % TILE_SIZE == 0
153 + assert int(tile_groups_per_partition) >= 1, (
154 + f"tile_groups_per_partition must be >= 1; got {tile_groups_per_partition}"
155 + )
156 + USE_HW_TR = bool(use_hw_v_transpose)
157 + TGPP = int(tile_groups_per_partition)
158 + PARTITION_EXTENT_TOKENS = TGPP * KV_COMPUTE_BLOCK
159 +
160 + QG = int(query_group_size)
161 + # Round-up: 2 iters for QG=6 → loads 8 LDS rows (rows 6..7 are
162 + # zero-padded by buffer_load OOB protection; never consumed because
163 + # mfma_row >= QG lanes are gated out of all global stores below).
164 + QG_LOAD_ITERS = (QG + 3) // 4
165 + OOB_OFFSET = 0x7FFFFFF0 # noqa: F841 ~2GB byte offset; > any plausible buffer
166 +
167 + _BS = int(kv_block_size)
168 + _TILES_PER_BLOCK = _BS // TILE_SIZE # 1 for BS=16, 2 for BS=32
169 +
170 + global allocator
171 + arch = get_hip_arch()
172 +
173 + if softmax_scale is None:
174 + softmax_scale = 1.0 / (HEAD_SIZE**0.5)
175 + _qk_scale = float(softmax_scale)
176 +
177 + # --- Strides ---
178 + _Hq = num_kv_heads * QG
179 + _stride_q_seq = _Hq * HEAD_SIZE
180 + _stride_q_head = HEAD_SIZE
181 + _stride_bt_seq = max_blocks_per_seq
182 +
183 + _data_region_bytes = _BS * num_kv_heads * DATA_BYTES_PER_SLOT
184 + _meta_region_bytes = num_kv_heads * NUM_SOA_FIELDS * _BS * 2
185 + _stride_cache_block = _data_region_bytes + _meta_region_bytes
186 + _meta_region_offset = _data_region_bytes
187 +
188 + _stride_out_part = QG * HEAD_SIZE
189 + _stride_out_head = num_partitions * QG * HEAD_SIZE
190 + _stride_out_seq = num_kv_heads * num_partitions * QG * HEAD_SIZE
191 + _stride_es_seq = num_kv_heads * num_partitions * QG
192 + _stride_ml_seq = _stride_es_seq
193 +
194 + # --- LDS layout ---
195 + allocator = SmemAllocator(None, arch=arch, global_sym_name="tq_gqa6_smem")
196 + centroid_off = 0
197 + allocator.ptr = CENTROID_LDS_BYTES
198 + q_off = allocator.ptr
199 + allocator.ptr += Q_LDS_BYTES
200 + kv_off = allocator.ptr
201 + allocator.ptr += KV_TILE_LDS_BYTES
202 +
203 + @flyc.kernel
204 + def tq_decode_gqa6_kernel(
205 + out_ptr: fx.Tensor,
206 + exp_sums_ptr: fx.Tensor,
207 + max_logits_ptr: fx.Tensor,
208 + query_ptr: fx.Tensor,
209 + kv_cache_ptr: fx.Tensor,
210 + centroids_ptr: fx.Tensor,
211 + block_tables_ptr: fx.Tensor,
212 + seq_lens_ptr: fx.Tensor,
213 + ):
214 + # ---- IDs ---------------------------------------------------------
215 + tid = gpu.thread_idx.x
216 + seq = gpu.block_idx.x
217 + kv_h = gpu.block_idx.y
218 + part = gpu.block_idx.z
219 + lane = tid # 0..63
220 + mfma_row = lane & fx.Int32(15) # = query_row (B's N) /
221 + # token (A's M for QK)
222 + mfma_col_grp = lane >> fx.Int32(4) # 0..3, K-group dim
223 +
224 + # ---- Buffer resources -------------------------------------------
225 + q_rsrc = buffer_ops.create_buffer_resource(query_ptr, max_size=True)
226 + kv_rsrc = buffer_ops.create_buffer_resource(kv_cache_ptr, max_size=True)
227 + bt_rsrc = buffer_ops.create_buffer_resource(block_tables_ptr, max_size=True)
228 + sl_rsrc = buffer_ops.create_buffer_resource(seq_lens_ptr, max_size=True)
229 + cent_rsrc = buffer_ops.create_buffer_resource(centroids_ptr, max_size=True)
230 + out_rsrc = buffer_ops.create_buffer_resource(out_ptr, max_size=True)
231 + es_rsrc = buffer_ops.create_buffer_resource(exp_sums_ptr, max_size=True)
232 + ml_rsrc = buffer_ops.create_buffer_resource(max_logits_ptr, max_size=True)
233 +
234 + # ---- LDS pointers -----------------------------------------------
235 + base = allocator.get_base()
236 + cent_lds = SmemPtr(base, centroid_off, T.f32, shape=(N_CENTROIDS,))
237 + q_lds_i32 = SmemPtr(base, q_off, T.i32, shape=(Q_LDS_BYTES // 4,)).get()
238 + q_lds_i64 = SmemPtr(base, q_off, T.i64, shape=(Q_LDS_BYTES // 8,)).get()
239 + kv_lds_i32 = SmemPtr(base, kv_off, T.i32, shape=(KV_TILE_LDS_BYTES // 4,)).get() # noqa: F841
240 + kv_lds_i64 = SmemPtr(base, kv_off, T.i64, shape=(KV_TILE_LDS_BYTES // 8,)).get()
241 + kv_lds_i16 = SmemPtr(base, kv_off, T.i16, shape=(KV_TILE_LDS_BYTES // 2,)).get()
242 +
243 + # ---- Constants ---------------------------------------------------
244 + c_sq = fx.Int32(_stride_q_seq)
245 + c_qh = fx.Int32(_stride_q_head)
246 + c_qg = fx.Int32(QG)
247 + c_bt = fx.Int32(_stride_bt_seq)
248 + c_block = fx.Int32(_stride_cache_block)
249 + c_meta_off = fx.Int32(_meta_region_offset)
250 + c_data_per_slot = fx.Int32(DATA_BYTES_PER_SLOT)
251 + c_kv_heads = fx.Int32(num_kv_heads)
252 + c_keydata = fx.Int32(KEY_DATA_BYTES)
253 + c_w = fx.Int32(WARP_SIZE)
254 +
255 + NEG_INF = arith.constant(NEG_INF_VAL, type=T.f32)
256 + ZERO_F = fx.Float32(0.0)
257 + ONE_F = fx.Float32(1.0)
258 + LOG2E_C = arith.constant(LOG2E, type=T.f32)
259 + QK_SCALE = arith.constant(_qk_scale, type=T.f32)
260 +
261 + # ===== STEP A: Load centroids → LDS (cooperative, race-safe) =====
262 + c_idx_safe = lane & fx.Int32(N_CENTROIDS - 1)
263 + c_val = buffer_ops.buffer_load(cent_rsrc, c_idx_safe, vec_width=1, dtype=T.f32)
264 + cent_lds.store(c_val, [arith.index_cast(T.index, c_idx_safe)])
265 + gpu.barrier()
266 +
267 + # ===== STEP B: Load Q → row-major LDS [query_row, head_dim] ======
268 + # Q layout in HBM: [N, Hq, D] bf16, slice = QG rows × D=128 cols × 2B.
269 + # QG=6 → 1536 B real, 96 chunks; QG_LOAD_ITERS=2 issues 128 chunks
270 + # (8 rows × 16 col_b) so lanes whose row ∈ {6, 7} address heads that
271 + # do NOT exist in the Q tensor (Hq = num_kv_heads * 6).
272 + #
273 + # ── Per-lane Q-load OOB redirect ─────────────────────────────────
274 + # Hq = num_kv_heads * QG, so head index = kv_h * QG + row. For QG=6
275 + # the upper rows alias to (kv_h+1)'s real heads (still in-bounds
276 + # for most kv_h), but for the *last* (seq, kv_h) the read tips
277 + # past the Q tensor entirely. With ``max_size=True`` the buffer
278 + # descriptor advertises 4 GB so the HW does no OOB clamping; the
279 + # padding read then touches unmapped pages and the kernel dies
280 + # with "Memory access fault by GPU node" the moment the
281 + # surrounding allocator state leaves the next page unmapped
282 + # (allocator-pattern-dependent → nondeterministic fault).
283 + #
284 + # Fix: redirect OOB lanes (``row >= QG``) to load Q[0,0,col_elem]
285 + # — a byte offset that is always inside the actual Q tensor.
286 + # The redundant load is wasted but harmless; the resulting LDS
287 + # write to row ∈ {6, 7} is overwritten/ignored downstream because
288 + # every global store is gated by the output-time
289 + # ``mfma_row < QG`` predicate (the same gate canonical QG=8 uses
290 + # for its garbage rows 8..15).
291 + #
292 + # NB: A ``mask=`` parameter on ``buffer_load`` substitutes the
293 + # masked-out offset with 0x7FFFFFFF *bytes*; with ``max_size=True``
294 + # this is still inside the 4 GB descriptor and so the HW happily
295 + # dereferences ``q_base + 2 GB`` → another unmapped page → fault.
296 + # The address-redirect below is the only safe option short of
297 + # threading the true Q size through as a runtime kernel arg.
298 + #
299 + # The canonical Qwen kernel does not need this redirect because
300 + # for QG ∈ {8, 16} the (kv_h, row) iteration covers exactly the
301 + # real heads; QG=6 is the only case where
302 + # ``QG_LOAD_ITERS * 4 != QG``.
303 + # STEP B: load pre-rotated q_rot → Q_LDS. QG=6 over-reads
304 + # (QG_LOAD_ITERS * 4 != QG), so rows >= QG redirect to a safe
305 + # in-bounds offset to avoid faulting past the real Q rows.
306 + for c in range_constexpr(QG_LOAD_ITERS):
307 + row_chunk = lane + fx.Int32(c * WARP_SIZE) # 0..127
308 + row = row_chunk >> fx.Int32(4) # 0..7
309 + col_b = row_chunk & fx.Int32(15) # 0..15
310 + col_elem = col_b * fx.Int32(8) # 0..120 in bf16
311 + q_off_real = seq * c_sq + (kv_h * c_qg + row) * c_qh + col_elem
312 + row_in_qg = row < fx.Int32(QG)
313 + q_off_elem = row_in_qg.select(q_off_real, col_elem)
314 + q_v = buffer_ops.buffer_load(
315 + q_rsrc,
316 + q_off_elem // fx.Int32(2),
317 + vec_width=4,
318 + dtype=T.i32,
319 + )
320 + q_lds_byte = row * fx.Int32(HEAD_SIZE * 2) + col_elem * fx.Int32(2)
321 + vector.store(
322 + q_v,
323 + q_lds_i32,
324 + [arith.index_cast(T.index, q_lds_byte // fx.Int32(4))],
325 + )
326 + gpu.barrier()
327 +
328 + # ===== STEP C: Pre-load Q operands for QK_K_CHUNKS = 4 K-chunks ===
329 + # Wide-K MFMA: K_per_chunk = 32, K_per_lane = 32/4 = 8 bf16 per chunk.
330 + # Lane t holds 8 bf16 at row=mfma_row, K-cols
331 + # chunk*32 + mfma_col_grp*8 + 0..7
332 + # Byte addr = mfma_row * HEAD_SIZE*2 + (chunk*32 + col_grp*8)*2
333 + # = mfma_row * 256 + chunk*64 + col_grp*16
334 + # i64 idx = mfma_row * 32 + chunk*8 + col_grp*2 (each i64 = 4 bf16)
335 + # Load 16 bytes (= 8 bf16 = 2 i64) per chunk via vec(2, i64).
336 + q_chunks = []
337 + for chk in range_constexpr(QK_K_CHUNKS):
338 + q_idx_i64 = (
339 + mfma_row * fx.Int32(HEAD_SIZE * 2 // 8)
340 + + fx.Int32(chk * 8)
341 + + mfma_col_grp * fx.Int32(2)
342 + )
343 + qv = vector.load_op(
344 + T.vec(2, T.i64),
345 + q_lds_i64,
346 + [arith.index_cast(T.index, q_idx_i64)],
347 + )
348 + q_chunks.append(vector.bitcast(T.vec(8, T.bf16), qv))
349 +
350 + # ===== STEP D: Online softmax + PV state =========================
351 + running_max = NEG_INF
352 + running_sum = ZERO_F
353 + zero_v4 = arith.constant_vector(0.0, T.f32x4)
354 + acc_pv = [zero_v4 for _ in range(PV_N_CHUNKS)]
355 +
356 + # ===== STEP E: Sequence-len + partition base ====================
357 + seq_len = buffer_ops.buffer_load(sl_rsrc, seq, vec_width=1, dtype=T.i32)
358 + # ── Bounded num_partitions + internal looping ───────────────────
359 + # Mirrors the canonical tq_decode kernel: each CTA owns
360 + # ``PARTITION_EXTENT_TOKENS = TGPP * KV_COMPUTE_BLOCK`` contiguous
361 + # tokens of the seq, processed as TGPP groups of 16 K-tiles each.
362 + # FA-2 online-softmax state accumulates across all TGPP * 16 tiles.
363 + # With TGPP=1 this is bit-identical to the legacy 16-tile body.
364 + partition_base = part * fx.Int32(PARTITION_EXTENT_TOKENS)
365 +
366 + # Per-K-tile dequant lane assignment:
367 + # 16 tokens × 64 packed bytes per token = 1024 bytes
368 + # 64 lanes × 16 bytes/lane = 1024 ✓
369 + # Lane t: token = t / 4, sub-chunk = t % 4 (0..3) → 16 bytes contiguous
370 + tok_in_tile = lane >> fx.Int32(2)
371 + chunk_in_tok = lane & fx.Int32(3)
372 + # ``slot`` (= absolute slot index within the cache block, range
373 + # 0.._BS-1) is recomputed per tile inside the K-tile loop below
374 + # so it reflects ``tile_in_block * TILE_SIZE + tok_in_tile``.
375 +
376 + c_meta_u16_per_kvh = fx.Int32(NUM_SOA_FIELDS * _BS)
377 + c_knorm_off_u16 = fx.Int32(SOA_K_NORM * _BS)
378 + c_vscale_off_u16 = fx.Int32(SOA_V_SCALE * _BS)
379 + c_vzero_off_u16 = fx.Int32(SOA_V_ZERO * _BS)
380 +
381 + # ===== STEP F: K-tile loop =======================================
382 + # With kv_block_size > TILE_SIZE there are _TILES_PER_BLOCK tiles
383 + # per cache block. Tiles within the same block share a block-table
384 + # entry but address different slot ranges within the block.
385 + #
386 + # ── Per-tile block-table OOB redirect ────────────────────────────
387 + # The K-tile loop is unrolled 16 times so every CTA issues 16
388 + # block-table reads regardless of (partition, seq_len). When a
389 + # partition extends past ``seq_len`` (e.g. num_partitions=2 for a
390 + # 256-token sequence — partition 1 covers tokens 256..511, all
391 + # masked out) the per-tile bt offset can land beyond the bt
392 + # allocation. With ``max_size=True`` the descriptor is 4 GB so
393 + # the HW returns whatever pre-existing HBM bytes live at that
394 + # offset; the resulting garbage ``phys_block`` is multiplied by
395 + # ``stride_cache_block`` and the subsequent kv_cache read jumps
396 + # into unmapped pages, killing the whole launch with a
397 + # "Memory access fault by GPU node" exactly as the OOB Q load
398 + # used to (same allocator-pattern-dependent fault; B=4 makes
399 + # it deterministic by adding more colliding CTAs).
400 + #
401 + # Fix: when the tile starts at or past ``seq_len`` (so its
402 + # qk_acc is going to be killed by the per-token
403 + # ``kv_tok < seq_len`` mask anyway), redirect the bt read to
404 + # ``bt[seq, 0]`` — always in bounds. The redundant phys_block
405 + # decode + kv_cache read is wasted work, but correctness is
406 + # preserved without changing the kernel's iteration count.
407 + #
408 + # The canonical Qwen kernel reads the same tiles and is also
409 + # exposed to this issue in principle; in practice it surfaces on
410 + # GQA-6 first because the surrounding allocator state differs
411 + # (extra Q-tail allocation) and shifts the page maps so the bt
412 + # overflow lands on unmapped pages. Applying the same redirect to
413 + # the canonical kernel is a possible follow-up.
414 + # ===== Runtime-adaptive scf.ForOp w/ iter_args =========
415 + # Runtime-adaptive outer loop: trip count derives from
416 + # actual seq_len, NOT TGPP_max. At cudagraph-capture warmup
417 + # (seq_len=1) only 1 iteration runs; at 32K production
418 + # decode all TGPP iterations run. Kernel binary stays small
419 + # (single body, looped at runtime) — capture time matches
420 + # the legacy 16-tile-only kernel rather than scaling 4x.
421 + #
422 + # FA-2 state (running_max, running_sum, acc_pv[8]) threads
423 + # through scf iter_args; the body reads them at top, runs
424 + # the unchanged 16-tile inner body, and yields the new
425 + # state at bottom. After the loop, results are pulled out
426 + # of for_op.results back into the local Python names so the
427 + # downstream STEP G (output) is unmodified.
428 + c_kcb = fx.Int32(KV_COMPUTE_BLOCK)
429 + c_tgpp = fx.Int32(TGPP)
430 + c_zero_i32 = fx.Int32(0)
431 + c_one_i32 = fx.Int32(1)
432 + # remaining = seq_len - partition_base (signed, may be <=0)
433 + remaining = seq_len - partition_base
434 + in_range = remaining > c_zero_i32
435 + # trip_raw = ceil(remaining / KV_COMPUTE_BLOCK)
436 + # (when in_range is false, the divisor branch produces
437 + # garbage that the select below overrides with zero, so we
438 + # don't pre-clamp)
439 + trip_raw = (remaining + c_kcb - c_one_i32) // c_kcb
440 + trip_clamped = (trip_raw > c_tgpp).select(c_tgpp, trip_raw)
441 + trip_or_zero = in_range.select(trip_clamped, c_zero_i32)
442 + c_zero_idx = arith.constant(0, index=True)
443 + c_one_idx = arith.constant(1, index=True)
444 + trip_idx = arith.index_cast(
445 + T.index,
446 + trip_or_zero.ir_value()
447 + if hasattr(trip_or_zero, "ir_value")
448 + else trip_or_zero,
449 + )
450 +
451 + # Initial iter_args list: FA-2 accumulator state.
452 + # Order: running_max, running_sum, acc_pv[0..PV_N_CHUNKS-1].
453 + # NOTE: scf.ForOp requires ir.Value (with .type). The DSL
454 + # constants ZERO_F / ONE_F are fx.Float32 (Numeric) wrappers,
455 + # not raw ir.Value, so we unwrap via .ir_value() before
456 + # passing.
457 + def _ival(v):
458 + return v.ir_value() if hasattr(v, "ir_value") else v
459 +
460 + _init_iter = [
461 + _ival(running_max),
462 + _ival(running_sum),
463 + *[_ival(p) for p in acc_pv],
464 + ]
465 + _for_op = _scf.ForOp(
466 + c_zero_idx,
467 + trip_idx,
468 + c_one_idx,
469 + _init_iter,
470 + )
471 + _for_ip = ir.InsertionPoint(_for_op.body)
472 + _for_ip.__enter__()
473 + try:
474 + tg_idx = _for_op.induction_variable
475 + tg_i32 = fx.Int32(arith.index_cast(T.i32, tg_idx))
476 + partition_start = partition_base + tg_i32 * c_kcb
477 + bt_seq_base = seq * c_bt + (partition_start // fx.Int32(_BS))
478 + running_max = _for_op.inner_iter_args[0]
479 + running_sum = _for_op.inner_iter_args[1]
480 + acc_pv = list(_for_op.inner_iter_args[2:])
481 + for n_tile in range_constexpr(16):
482 + block_in_part = n_tile // _TILES_PER_BLOCK
483 + tile_in_block = n_tile % _TILES_PER_BLOCK
484 + tile_start_tok = partition_start + fx.Int32(n_tile * TILE_SIZE)
485 + tile_in_seq = tile_start_tok < seq_len
486 + bt_off = bt_seq_base + fx.Int32(block_in_part)
487 + bt_off_safe = tile_in_seq.select(bt_off, seq * c_bt)
488 + phys_block = buffer_ops.buffer_load(
489 + bt_rsrc,
490 + bt_off_safe,
491 + vec_width=1,
492 + dtype=T.i32,
493 + )
494 + block_base = phys_block * c_block
495 + data_region = block_base
496 + meta_region = block_base + c_meta_off
497 +
498 + # ``slot`` is the absolute slot index within the cache block
499 + # (range 0.._BS-1). For BS=16 it equals tok_in_tile; for BS=32
500 + # it equals tile_in_block * 16 + tok_in_tile. Used both for
501 + # the K/V data region and SoA metadata region addressing.
502 + slot = fx.Int32(tile_in_block * TILE_SIZE) + tok_in_tile
503 + data_bases_byte = (
504 + data_region
505 + + slot * (c_kv_heads * c_data_per_slot)
506 + + kv_h * c_data_per_slot
507 + )
508 + k_byte = data_bases_byte + chunk_in_tok * fx.Int32(16)
509 + k_packed = buffer_ops.buffer_load(
510 + kv_rsrc,
511 + k_byte // fx.Int32(4),
512 + vec_width=4,
513 + dtype=T.i32,
514 + )
515 +
516 + # ---- HOISTED: issue V data + meta HBM loads early ---------
517 + # These are async; their s_waitcnt is pushed by the compiler
518 + # past the K dequant + QK MFMA + softmax block, hiding most
519 + # of the V HBM latency behind compute.
520 + v_byte = data_bases_byte + c_keydata + chunk_in_tok * fx.Int32(16)
521 + v_packed = buffer_ops.buffer_load(
522 + kv_rsrc,
523 + v_byte // fx.Int32(4),
524 + vec_width=4,
525 + dtype=T.i32,
526 + )
527 + vscale_u16 = (
528 + meta_region // fx.Int32(2)
529 + + kv_h * c_meta_u16_per_kvh
530 + + c_vscale_off_u16
531 + + slot
532 + )
533 + vzero_u16 = (
534 + meta_region // fx.Int32(2)
535 + + kv_h * c_meta_u16_per_kvh
536 + + c_vzero_off_u16
537 + + slot
538 + )
539 + vscale_raw = buffer_ops.buffer_load(
540 + kv_rsrc,
541 + vscale_u16,
542 + vec_width=1,
543 + dtype=T.i16,
544 + )
545 + vzero_raw = buffer_ops.buffer_load(
546 + kv_rsrc,
547 + vzero_u16,
548 + vec_width=1,
549 + dtype=T.i16,
550 + )
551 +
552 + knorm_u16 = (
553 + meta_region // fx.Int32(2)
554 + + kv_h * c_meta_u16_per_kvh
555 + + c_knorm_off_u16
556 + + slot
557 + )
558 + knorm_raw = buffer_ops.buffer_load(
559 + kv_rsrc,
560 + knorm_u16,
561 + vec_width=1,
562 + dtype=T.i16,
563 + )
564 + knorm_f16 = arith.bitcast(T.f16, knorm_raw)
565 + knorm_f32 = arith.extf(T.f32, knorm_f16)
566 +
567 + # K dequant → LDS [token, head_dim] (natural)
568 + # Lane writes 32 bf16 (= 4×8) for token=tok_in_tile,
569 + # head_dims chunk_in_tok*32..+31 (4 sub-chunks of 8).
570 + tok_kreg = tok_in_tile * fx.Int32(HEAD_SIZE * 2 // 8)
571 + chunk_kreg = tok_kreg + chunk_in_tok * fx.Int32(8)
572 + for w in range_constexpr(4):
573 + word_i32 = vector.extract(k_packed, static_position=[w])
574 + bf16_elems = []
575 + for n in range_constexpr(8):
576 + nibble = (word_i32 >> fx.Int32(n * 4)) & fx.Int32(0xF)
577 + nibble_idx = arith.index_cast(T.index, nibble)
578 + cent_f32 = cent_lds.load([nibble_idx])
579 + elem_bf16 = arith.trunc_f(T.bf16, cent_f32 * knorm_f32)
580 + bf16_elems.append(elem_bf16)
581 + v_bf16 = vector.from_elements(T.vec(8, T.bf16), bf16_elems)
582 + v_i64 = vector.bitcast(T.vec(2, T.i64), v_bf16)
583 + vector.store(
584 + v_i64,
585 + kv_lds_i64,
586 + [arith.index_cast(T.index, chunk_kreg + fx.Int32(w * 2))],
587 + )
588 + gpu.barrier()
589 +
590 + # QK MFMA (CDNA4 wide-K): A=K[token, head_dim], B=Q (= Q^T).
591 + # K read: lane t = K[token=mfma_row, head_dim=chunk*32+col_grp*8..+7]
592 + # Same i64×2 indexing as Q.
593 + qk_acc = zero_v4
594 + for chk in range_constexpr(QK_K_CHUNKS):
595 + k_idx_i64 = (
596 + mfma_row * fx.Int32(HEAD_SIZE * 2 // 8)
597 + + fx.Int32(chk * 8)
598 + + mfma_col_grp * fx.Int32(2)
599 + )
600 + kv_load = vector.load_op(
601 + T.vec(2, T.i64),
602 + kv_lds_i64,
603 + [arith.index_cast(T.index, k_idx_i64)],
604 + )
605 + k_op = vector.bitcast(T.vec(8, T.bf16), kv_load)
606 + qk_acc = rocdl.mfma_f32_16x16x32_bf16(
607 + T.f32x4, [k_op, q_chunks[chk], qk_acc, 0, 0, 0]
608 + )
609 +
610 + # qk_acc layout: lane t holds C[token=(t/16)*4..+3, query=t%16]
611 + # 4 fp32/lane = 4 different tokens at SAME query_row.
612 +
613 + # Scale + mask out-of-context tokens.
614 + qk_acc = _vsplat_mul(qk_acc, QK_SCALE)
615 + for elem in range_constexpr(4):
616 + kv_tok = (
617 + partition_start
618 + + fx.Int32(n_tile * TILE_SIZE)
619 + + mfma_col_grp * fx.Int32(4)
620 + + fx.Int32(elem)
621 + )
622 + in_b = kv_tok < seq_len
623 + v = vector.extract(qk_acc, static_position=[elem])
624 + qk_acc = vector.insert(
625 + in_b.select(v, NEG_INF),
626 + qk_acc,
627 + static_position=[elem],
628 + dynamic_position=[],
629 + )
630 +
631 + # FA2 online softmax: per-query-row reduce.
632 + # Per-row max: max over 4 fp32 in lane, then xor-shuffle 16, 32
633 + # (across the 4 col_grps that share same mfma_row).
634 + local_max = vector.reduction(T.f32, "maxnumf", qk_acc)
635 + r1 = local_max.shuffle_xor(fx.Int32(16), c_w)
636 + local_max = local_max.maximumf(r1)
637 + r2 = local_max.shuffle_xor(fx.Int32(32), c_w)
638 + tile_max = local_max.maximumf(r2)
639 +
640 + new_max = running_max.maximumf(tile_max)
641 + max_diff = running_max - new_max
642 + safe_diff = (running_max > NEG_INF).select(max_diff, ZERO_F)
643 + scale = (safe_diff * LOG2E_C).exp2(fastmath=arith.FastMathFlags.fast)
644 + running_sum = running_sum * scale
645 + for h in range_constexpr(PV_N_CHUNKS):
646 + acc_pv[h] = _vsplat_mul(acc_pv[h], scale)
647 + running_max = new_max
648 +
649 + # Compute probs: p = exp((qk - new_max) * LOG2E)
650 + tile_sum = ZERO_F
651 + for elem in range_constexpr(4):
652 + s = vector.extract(qk_acc, static_position=[elem])
653 + d = s - new_max
654 + d = (new_max > NEG_INF).select(d, NEG_INF)
655 + p = (d * LOG2E_C).exp2(fastmath=arith.FastMathFlags.fast)
656 + tile_sum = tile_sum + p
657 + qk_acc = vector.insert(
658 + p, qk_acc, static_position=[elem], dynamic_position=[]
659 + )
660 +
661 + ts1 = tile_sum.shuffle_xor(fx.Int32(16), c_w)
662 + tile_sum = tile_sum + ts1
663 + ts2 = tile_sum.shuffle_xor(fx.Int32(32), c_w)
664 + tile_sum = tile_sum + ts2
665 + running_sum = running_sum + tile_sum
666 +
667 + # ---- V dequant → LDS (V/scale/zero raw loads were hoisted) -
668 + # v_packed, vscale_raw, vzero_raw are already in flight from
669 + # the top of this iteration; only the f32 conversion stays
670 + # here so the actual consume site is still close to the use.
671 + vscale_f32 = arith.extf(T.f32, arith.bitcast(T.f16, vscale_raw))
672 + vzero_f32 = arith.extf(T.f32, arith.bitcast(T.f16, vzero_raw))
673 +
674 + if USE_HW_TR:
675 + # V dequant → LDS [token][head_dim] (ROW-MAJOR, no transpose).
676 + # Each lane writes 32 contiguous bf16 (one token, head_dims
677 + # chunk_in_tok*32..+31) as 4× ds_write_b128 = 4× vec(2,i64).
678 + # Replaces 32× ds_write_b16 of the legacy transposed path.
679 + v_lds_elem_base = tok_in_tile * fx.Int32(
680 + HEAD_SIZE
681 + ) + chunk_in_tok * fx.Int32(32)
682 + for w in range_constexpr(4):
683 + word_i32 = vector.extract(v_packed, static_position=[w])
684 + bf16_elems = []
685 + for n in range_constexpr(8):
686 + nibble = (word_i32 >> fx.Int32(n * 4)) & fx.Int32(0xF)
687 + nibble_f32 = arith.sitofp(T.f32, nibble)
688 + elem_f32 = nibble_f32 * vscale_f32 + vzero_f32
689 + elem_bf16 = arith.trunc_f(T.bf16, elem_f32)
690 + bf16_elems.append(elem_bf16)
691 + v_bf16 = vector.from_elements(T.vec(8, T.bf16), bf16_elems)
692 + v_i64 = vector.bitcast(T.vec(2, T.i64), v_bf16)
693 + v_lds_i64_idx = (v_lds_elem_base + fx.Int32(w * 8)) // fx.Int32(
694 + 4
695 + )
696 + vector.store(
697 + v_i64,
698 + kv_lds_i64,
699 + [arith.index_cast(T.index, v_lds_i64_idx)],
700 + )
701 + # ── HW V transpose: cross-lane LDS sync ─────────────────────
702 + # ds_read_tr16_b64 below introduces a cross-lane LDS read:
703 + # consumer lane t reads bytes written by source lanes
704 + # 0,4,8,12 (etc.). The compiler's automatic waitcnt insertion
705 + # is per-lane and has no awareness of the HW transpose's
706 + # cross-lane forwarding, so it can sink the next iteration's
707 + # ds_read ahead of these ds_write_b128 ops in the schedule.
708 + #
709 + # Two-part fence:
710 + # 1. sched_barrier(0): MachineScheduler reorder barrier
711 + # (mask=0 → no instruction class may cross). Pure hint,
712 + # generates no runtime instruction. Use the simple
713 + # sched_barrier (NOT sched_group_barrier, which is an
714 + # IGLP marker that requires partner barriers and
715 + # corrupts the schedule when used in isolation).
716 + # 2. s_waitcnt lgkmcnt=0: runtime drain of the LDS write
717 + # queue. vmcnt/expcnt left at no-wait so we don't
718 + # stall on speculatively-issued HBM loads (which can
719 + # include OOB-but-masked buffer_load reads from the
720 + # next K-tile's hoisted V prefetch).
721 + #
722 + # The SW path below does NOT need this because each lane
723 + # only reads cells it itself wrote (no cross-lane dep), and
724 + # the per-lane same-address waitcnt the compiler emits is
725 + # correct.
726 + #
727 + # Without this fence the reordered ds_read produces
728 + # wrong results on high-partition-count configs (large
729 + # padded num_partitions); low-partition configs are
730 + # unaffected.
731 + rocdl.sched_barrier(0)
732 + # encode_waitcnt(vmcnt=63, expcnt=7, lgkmcnt=0)
733 + # = 0xF | (7<<4) | (0<<8) | (3<<14) = 0xC07F
734 + rocdl.s_waitcnt(0xC07F)
735 + else:
736 + # Legacy transposed V_LDS path: 32 ds_write_b16 per lane.
737 + for w in range_constexpr(4):
738 + word_i32 = vector.extract(v_packed, static_position=[w])
739 + for n in range_constexpr(8):
740 + nibble = (word_i32 >> fx.Int32(n * 4)) & fx.Int32(0xF)
741 + nibble_f32 = arith.sitofp(T.f32, nibble)
742 + elem_f32 = nibble_f32 * vscale_f32 + vzero_f32
743 + elem_bf16 = arith.trunc_f(T.bf16, elem_f32)
744 + elem_i16 = arith.bitcast(T.i16, elem_bf16)
745 + head_dim = chunk_in_tok * fx.Int32(32) + fx.Int32(w * 8 + n)
746 + v_idx_i16 = head_dim * fx.Int32(TILE_SIZE) + tok_in_tile
747 + v_vec = vector.from_elements(T.vec(1, T.i16), [elem_i16])
748 + vector.store(
749 + v_vec,
750 + kv_lds_i16,
751 + [arith.index_cast(T.index, v_idx_i16)],
752 + )
753 + gpu.barrier()
754 +
755 + # ---- PV MFMA: A=V[head_dim, token], B=P (=qk_acc bf16) -----
756 + # P operand B layout matches qk_acc: lane t holds 4 bf16 at
757 + # K=token=(t/16)*4..+3, N=query=t%16. Just trunc_f to bf16.
758 + p_bf16 = arith.trunc_f(T.vec(4, T.bf16), qk_acc)
759 + p_op = vector.bitcast(T.vec(4, T.i16), p_bf16)
760 +
761 + if USE_HW_TR:
762 + # HW-transpose PV path: V_lds is row-major V[token][head_dim].
763 + # MFMA A operand layout: lane t holds 4 bf16 at
764 + # M=head_dim=mfma_row + h*16, K=token=(t/16)*4..+3
765 + # ds_read_tr16_b64 (4-element 16-bit transpose, per-16-lane block):
766 + # result[lane=t, elem=e] = Input[source_lane=e*4 + (t%16)//4,
767 + # col=t%4]
768 + # Per-lane address: token_idx = lane // 4 (covers 0..15 across
769 + # all four 16-lane MFMA blocks), hd_sub = (lane % 4)*4 selects
770 + # the 4-element column window inside the h*16 chunk.
771 + # Total LDS byte offset = kv_off + token_idx*HEAD_SIZE*2
772 + # + (h*16 + hd_sub)*2
773 + token_idx = lane >> fx.Int32(2)
774 + hd_sub = (lane & fx.Int32(3)) * fx.Int32(4)
775 + v_lane_byte = (
776 + fx.Int32(kv_off)
777 + + token_idx * fx.Int32(HEAD_SIZE * 2)
778 + + hd_sub * fx.Int32(2)
779 + )
780 + for h in range_constexpr(PV_N_CHUNKS):
781 + v_byte_off = v_lane_byte + fx.Int32(h * 32)
782 + v_byte_i64 = fx.Int64(v_byte_off)
783 + v_ptr = buffer_ops.create_llvm_ptr(
784 + v_byte_i64,
785 + address_space=3,
786 + )
787 + v_op_raw = rocdl.ds_read_tr16_b64(
788 + T.vec(4, T.i16),
789 + v_ptr,
790 + ).result
791 + acc_pv[h] = rocdl.mfma_f32_16x16x16bf16_1k(
792 + T.f32x4, [v_op_raw, p_op, acc_pv[h], 0, 0, 0]
793 + )
794 + else:
795 + # Legacy transposed-LDS PV: 1 ds_read_b64 per chunk.
796 + # Byte addr = (mfma_row + h*16) * TILE_SIZE * 2 + (col_grp*4) * 2
797 + # = (mfma_row + h*16) * 32 + col_grp*8
798 + # i64 idx = (mfma_row + h*16) * 4 + col_grp
799 + for h in range_constexpr(PV_N_CHUNKS):
800 + v_idx_i64 = (mfma_row + fx.Int32(h * 16)) * fx.Int32(
801 + 4
802 + ) + mfma_col_grp
803 + kv_load = vector.load_op(
804 + T.vec(1, T.i64),
805 + kv_lds_i64,
806 + [arith.index_cast(T.index, v_idx_i64)],
807 + )
808 + v_op = vector.bitcast(T.vec(4, T.i16), kv_load)
809 + acc_pv[h] = rocdl.mfma_f32_16x16x16bf16_1k(
810 + T.f32x4, [v_op, p_op, acc_pv[h], 0, 0, 0]
811 + )
812 +
813 + _scf.YieldOp(
814 + [
815 + _ival(running_max),
816 + _ival(running_sum),
817 + *[_ival(p) for p in acc_pv],
818 + ]
819 + )
820 + finally:
821 + _for_ip.__exit__(None, None, None)
822 + # Pull final accumulator values out of the for_op results.
823 + running_max = _for_op.results[0]
824 + running_sum = _for_op.results[1]
825 + acc_pv = list(_for_op.results[2:])
826 +
827 + # ===== STEP G: Output ===========================================
828 + # acc_pv[h] layout: lane t holds 4 fp32 at M=head_dim=(t/16)*4..+3 + h*16,
829 + # N=query_row=t%16.
830 + # 4 fp32/lane = 4 contiguous head_dim values for ONE query_row.
831 + safe_sum = (running_sum > ZERO_F).select(running_sum, ONE_F)
832 + rcp = ONE_F / safe_sum
833 +
834 + c_os = fx.Int32(_stride_out_seq)
835 + c_oh = fx.Int32(_stride_out_head)
836 + c_op_ = fx.Int32(_stride_out_part)
837 + out_base = seq * c_os + kv_h * c_oh + part * c_op_
838 +
839 + # Gate all global stores by mfma_row < QG. For QG=16 the predicate is
840 + # tautological (mfma_row ∈ [0,15]); for QG=8 lanes 8..15 (whose MFMA
841 + # outputs are computational waste) skip the stores entirely.
842 + valid_row_pred = arith.cmpi(
843 + arith.CmpIPredicate.ult,
844 + mfma_row.ir_value() if hasattr(mfma_row, "ir_value") else mfma_row,
845 + arith.constant(QG, type=T.i32),
846 + )
847 + _if = _scf.IfOp(valid_row_pred)
848 + with ir.InsertionPoint(_if.then_block):
849 + for h in range_constexpr(PV_N_CHUNKS):
850 + pv_norm = _vsplat_mul(acc_pv[h], rcp)
851 + pv_bf16 = arith.trunc_f(T.vec(4, T.bf16), pv_norm)
852 + pv_i32x2 = vector.bitcast(T.vec(2, T.i32), pv_bf16)
853 + head_dim_start = fx.Int32(h * 16) + mfma_col_grp * fx.Int32(4)
854 + out_off_elem = (
855 + out_base + mfma_row * fx.Int32(HEAD_SIZE) + head_dim_start
856 + )
857 + buffer_ops.buffer_store(
858 + pv_i32x2,
859 + out_rsrc,
860 + out_off_elem * fx.Int32(2),
861 + offset_is_bytes=True,
862 + )
863 +
864 + c_npq = fx.Int32(num_partitions * QG)
865 + ml_off = (
866 + seq * fx.Int32(_stride_ml_seq) + kv_h * c_npq + part * c_qg + mfma_row
867 + )
868 + es_off = (
869 + seq * fx.Int32(_stride_es_seq) + kv_h * c_npq + part * c_qg + mfma_row
870 + )
871 + buffer_ops.buffer_store(running_max, ml_rsrc, ml_off)
872 + buffer_ops.buffer_store(running_sum, es_rsrc, es_off)
873 + _scf.YieldOp([])
874 +
875 + return tq_decode_gqa6_kernel
vllm/v1/attention/ops/flydsl_turboquant_decode.py new
+874
@@ -0,0 +1,874 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""FlyDSL TurboQuant decode launcher (vLLM-side).
4 +
5 +Drop-in replacement for ``triton_turboquant_decode_attention_soa`` for the
6 +TQ decode profile HEAD_SIZE=128, MSE_BITS=4 K, VQB=4 V, N_CENTROIDS=16,
7 +BLOCK_SIZE in {16, 32}.
8 +
9 +Per-GQA kernel dispatch:
10 + * GQA group ∈ {8, 16} → canonical kernels.tq_decode (Qwen-class)
11 + * GQA group == 6 → kernels.tq_decode_gqa6 sibling (MiniMax-M2.5)
12 +
13 +Auto-selected on gfx950 when FlyDSL is importable; falls back to the SoA Triton
14 +decode otherwise (wrong arch, missing build tree). The GQA-6 sibling is imported
15 +best-effort: missing it does not affect Qwen GQA-{8,16} paths.
16 +
17 +Architecture:
18 + 1. Q rotation: ``q_rot = (query.float() @ PiT).bfloat16()`` — the same
19 + launcher-side rocBLAS GEMM the SoA Triton decode uses.
20 + 2. FlyDSL kernel writes per-partition outputs into
21 + ``[N, Hk, P, QG, D]`` bf16 + ``[N, Hk, P, QG]`` fp32 max/sum buffers.
22 + 3. A small Triton reducer combines partitions in the kernel's native layout
23 + (no permute/cast), writing the final ``[N, Hq, D]`` output.
24 +
25 +Kernel module is built once per ``(num_kv_heads, num_partitions,
26 +max_blocks_per_seq, scale)`` shape and cached.
27 +"""
28 +
29 +from __future__ import annotations
30 +
31 +from typing import Any
32 +
33 +import torch
34 +
35 +from vllm.logger import init_logger
36 +from vllm.triton_utils import tl, triton
37 +
38 +logger = init_logger(__name__)
39 +
40 +# -- FlyDSL import bootstrap --------------------------------------------------
41 +# FlyDSL is imported directly and must be importable (pip-installed, or on
42 +# PYTHONPATH). Import failures are caught below and fall back to the SoA Triton
43 +# decode, so vLLM startup is unaffected on hosts without FlyDSL.
44 +
45 +
46 +_FLYDSL_AVAILABLE: bool | None = None
47 +_TQ_MOD: Any = None # kernels.tq_decode module (Qwen GQA-{8,16})
48 +_TQ_MOD_GQA6: Any = None # kernels.tq_decode_gqa6 module (MiniMax GQA-6, optional)
49 +_FLYC: Any = None # flydsl.compiler
50 +_FX: Any = None # flydsl.expr
51 +_TYPING_T: Any = None
52 +_CC: Any = None # CompilationContext
53 +_IR: Any = None
54 +
55 +
56 +def is_flydsl_available() -> bool:
57 + """Return True iff on gfx950 and FlyDSL imports + kernel module load.
58 +
59 + The GQA-6 sibling kernel (``kernels.tq_decode_gqa6``) is imported
60 + best-effort: if it's missing (older FlyDSL checkout that pre-dates
61 + MiniMax support) the canonical Qwen path stays fully functional and
62 + only GQA-6 dispatches will fail with a clear error at launch time.
63 + """
64 + global _FLYDSL_AVAILABLE, _TQ_MOD, _TQ_MOD_GQA6
65 + global _FLYC, _FX, _TYPING_T, _CC, _IR
66 + if _FLYDSL_AVAILABLE is not None:
67 + return _FLYDSL_AVAILABLE
68 + try:
69 + # gfx950-only kernel (CDNA4 intrinsics); gate before importing FlyDSL.
70 + from vllm.platforms.rocm import on_gfx950
71 +
72 + if not on_gfx950():
73 + _FLYDSL_AVAILABLE = False
74 + return _FLYDSL_AVAILABLE
75 + import flydsl.compiler as flyc # noqa: F401
76 + import flydsl.expr as fx # noqa: F401
77 + from flydsl._mlir import ir
78 + from flydsl.compiler.kernel_function import CompilationContext
79 + from flydsl.expr.typing import T # noqa: F401
80 +
81 + # Kernel is vendored in-tree (ships with vLLM); FlyDSL provides only
82 + # the compiler/runtime framework (imported above).
83 + from vllm.v1.attention.ops.flydsl_kernels import tq_decode as tq_mod
84 +
85 + _FLYC = flyc
86 + _FX = fx
87 + _TYPING_T = T
88 + _CC = CompilationContext
89 + _IR = ir
90 + _TQ_MOD = tq_mod
91 + _FLYDSL_AVAILABLE = True
92 + logger.info_once("FlyDSL TQ decode launcher: available")
93 + except Exception as ex: # noqa: BLE001
94 + _FLYDSL_AVAILABLE = False
95 + logger.warning_once(
96 + "FlyDSL TQ decode launcher: unavailable (%s). "
97 + "Falling back to SoA Triton decode.",
98 + ex,
99 + )
100 + return _FLYDSL_AVAILABLE
101 + # Best-effort GQA-6 sibling import (does NOT gate Qwen availability).
102 + try:
103 + from vllm.v1.attention.ops.flydsl_kernels import (
104 + tq_decode_gqa6 as tq_mod_gqa6,
105 + )
106 +
107 + _TQ_MOD_GQA6 = tq_mod_gqa6
108 + logger.info_once("FlyDSL TQ decode GQA-6 sibling: available (MiniMax-class)")
109 + except Exception as ex: # noqa: BLE001
110 + _TQ_MOD_GQA6 = None
111 + logger.info_once(
112 + "FlyDSL TQ decode GQA-6 sibling: not available (%s); "
113 + "GQA-6 models will fall back to SoA Triton decode.",
114 + ex,
115 + )
116 + return _FLYDSL_AVAILABLE
117 +
118 +
119 +def is_flydsl_gqa6_available() -> bool:
120 + """True iff the optional GQA-6 sibling kernel module loaded.
121 +
122 + Used by the eligibility gate in turboquant_attn.py to decide whether
123 + a layer with num_kv_groups==6 can run on FlyDSL or must fall back
124 + to the SoA Triton decode.
125 + """
126 + if _FLYDSL_AVAILABLE is None:
127 + is_flydsl_available()
128 + return _TQ_MOD_GQA6 is not None
129 +
130 +
131 +# -- Kernel module cache -------------------------------------------------------
132 +# Compiling a FlyDSL kernel is expensive; cache by the constexprs that
133 +# parameterize stride math: num_kv_heads, num_partitions, max_blocks_per_seq,
134 +# scale. ``num_seqs`` is irrelevant to the kernel body so we omit it.
135 +_KERN_CACHE: dict[tuple, Any] = {}
136 +
137 +
138 +# -- One-shot log guards ------------------------------------------------------
139 +# Replaces vllm `logger.{info,warning}_once` (which hashes the format string
140 +# every call to dedup) with a true zero-overhead bool guard.
141 +_LOG_INVOKED_ONCE: bool = False
142 +_LOG_SINKS_WARNED: bool = False
143 +_LOG_NORM_WARNED: bool = False
144 +
145 +
146 +def _detect_max_capture_B() -> int:
147 + """Probe vLLM compilation config for the largest cudagraph capture size.
148 +
149 + Returns max(cudagraph_capture_sizes) if available, else falls back to
150 + 512 (vLLM's default cap). The segm-pool auto-grows if a larger batch is
151 + seen (see ``_SegmBufPool.get``).
152 +
153 + Used to size the segm-pool bucket once, so that all distinct B's
154 + (whether captured in a graph, or hit eagerly in mixed batches) share
155 + a single allocation per shape — eliminating the per-B growth that
156 + previously dominated dense-capture VRAM cost.
157 + """
158 + try:
159 + from vllm.config import get_current_vllm_config
160 +
161 + cfg = get_current_vllm_config()
162 + sizes = cfg.compilation_config.cudagraph_capture_sizes
163 + if sizes:
164 + return int(max(sizes))
165 + except Exception: # noqa: BLE001
166 + pass
167 + return 512
168 +
169 +
170 +class _SegmBufPool:
171 + """Single-bucket buffer pool for segm_out/segm_max/segm_sum/output.
172 +
173 + Each unique shape signature (Hk, Hq, num_partitions, QG, D, device,
174 + dtype) is backed by ONE allocation sized at ``max_B`` (= max captured
175 + cudagraph batch size, or env override). Per-call ``get(B, ...)``
176 + returns ``[:B]`` views — same data_ptr base, narrower N dim. The
177 + kernel grid is ``(B, ...)`` so it only ever writes the first B rows
178 + of segm_out / segm_max / segm_sum; downstream readers (Triton
179 + reducer + caller) only consume the first B rows of output.
180 +
181 + Why a single bucket: cudagraph records launch pointers at capture
182 + time. With per-B allocations, every captured size triggered a fresh
183 + allocation, so total VRAM scaled with the number of captured sizes.
184 + With single-bucket, the pool tops out at one max_B-sized buffer per
185 + shape, shared across ALL captured sizes and ALL eager mixed-batch
186 + calls. The data_ptr is stable from first allocation onward, so
187 + cudagraph capture is happy across all captured B's.
188 +
189 + Eliminates per-decode-step ``cudaMalloc`` (× 4) plus the device-side
190 + memset kernels behind ``torch.full(-inf)`` and ``torch.zeros``.
191 + The kernel always writes the FULL ``[B, Hk, P, QG, D]`` slice and
192 + every (n, kv_h, p, qg) position of segm_max / segm_sum (it stores
193 + ``-inf`` and ``0`` for empty partitions itself), so uninitialized
194 + bytes from the pool are safe to reuse for any B ≤ B_bucket.
195 + """
196 +
197 + __slots__ = ("_bufs", "_max_B")
198 +
199 + def __init__(self) -> None:
200 + self._bufs: dict[tuple, dict[str, torch.Tensor]] = {}
201 + self._max_B: int | None = None # lazy-init on first get()
202 +
203 + def get(
204 + self,
205 + B: int,
206 + Hk: int,
207 + Hq: int,
208 + num_partitions: int,
209 + QG: int,
210 + D: int,
211 + device: torch.device,
212 + q_dtype: torch.dtype,
213 + ) -> dict[str, torch.Tensor]:
214 + if self._max_B is None:
215 + self._max_B = _detect_max_capture_B()
216 + # B may exceed the detected max if user set max-num-seqs higher
217 + # than the largest captured size — still safe, we'll grow once.
218 + # Note: growing AFTER cudagraph capture would invalidate captured
219 + # pointers, so this should only happen during warmup or in
220 + # always-eager configs.
221 + B_bucket = max(self._max_B, int(B))
222 + key = (
223 + int(Hk),
224 + int(Hq),
225 + int(num_partitions),
226 + int(QG),
227 + int(D),
228 + str(device),
229 + q_dtype,
230 + )
231 + bufs = self._bufs.get(key)
232 + if bufs is None or bufs["segm_out"].shape[0] < B_bucket:
233 + if bufs is not None and bufs["segm_out"].shape[0] < B_bucket:
234 + # First-time grow: warn so user knows cudagraphs may be
235 + # invalidated. This should only happen during warmup or in
236 + # always-eager configs (a batch larger than any captured
237 + # cudagraph size).
238 + logger.warning_once(
239 + "FlyDSL _SegmBufPool: growing bucket from %d to %d "
240 + "(B=%d). If you see this AFTER cudagraph warmup, the "
241 + "previously-captured graphs hold stale pointers and "
242 + "will GPU-fault (max cudagraph capture size < B).",
243 + bufs["segm_out"].shape[0],
244 + B_bucket,
245 + B,
246 + )
247 + bufs = {
248 + "segm_out": torch.empty(
249 + (B_bucket, Hk, num_partitions, QG, D),
250 + dtype=torch.bfloat16,
251 + device=device,
252 + ),
253 + "segm_max": torch.empty(
254 + (B_bucket, Hk, num_partitions, QG),
255 + dtype=torch.float32,
256 + device=device,
257 + ),
258 + "segm_sum": torch.empty(
259 + (B_bucket, Hk, num_partitions, QG),
260 + dtype=torch.float32,
261 + device=device,
262 + ),
263 + "output": torch.empty(
264 + (B_bucket, Hq, D),
265 + dtype=q_dtype,
266 + device=device,
267 + ),
268 + # Stable pre-allocated buffer for the rotated query
269 + # tensor. On ROCm, fresh torch.empty / matmul-result allocations
270 + # after HIP graph capture can land in the graph memory pool and
271 + # cause GPU memory faults when passed as kernel arguments in the
272 + # eager mixed-batch path. By pooling q_rot here (same pattern as
273 + # segm_out/output above), the data_ptr is stable from first
274 + # allocation onward — same for all captured B's and eager calls.
275 + "q_rot": torch.empty(
276 + (B_bucket, Hq, D),
277 + dtype=q_dtype,
278 + device=device,
279 + ),
280 + # q_float: fp32 copy of query (bf16→fp32 for mm input).
281 + # Pooled to avoid fresh allocations post-capture.
282 + "q_float": torch.empty(
283 + (B_bucket, Hq, D),
284 + dtype=torch.float32,
285 + device=device,
286 + ),
287 + # q_rot_fp32: fp32 output of the rotation mm (before bf16 cast).
288 + # Using torch.mm(..., out=this) eliminates the fresh mm-result
289 + # allocation that would otherwise land in the HIP graph pool.
290 + "q_rot_fp32": torch.empty(
291 + (B_bucket, Hq, D),
292 + dtype=torch.float32,
293 + device=device,
294 + ),
295 + }
296 + self._bufs[key] = bufs
297 + self._max_B = B_bucket
298 + logger.info_once(
299 + "FlyDSL _SegmBufPool: allocated single-bucket "
300 + "shape=(Hk=%d, Hq=%d, P=%d, QG=%d, D=%d, dtype=%s) "
301 + "B_bucket=%d (covers all captured + eager B's). "
302 + "VRAM = %.1f MiB / shape.",
303 + Hk,
304 + Hq,
305 + num_partitions,
306 + QG,
307 + D,
308 + q_dtype,
309 + B_bucket,
310 + sum(t.numel() * t.element_size() for t in bufs.values()) / (1 << 20),
311 + )
312 + # Return [:B] views — same data_ptr base, narrower N dim.
313 + return {
314 + "segm_out": bufs["segm_out"][:B],
315 + "segm_max": bufs["segm_max"][:B],
316 + "segm_sum": bufs["segm_sum"][:B],
317 + "output": bufs["output"][:B],
318 + "q_rot": bufs["q_rot"][:B],
319 + "q_float": bufs["q_float"][:B],
320 + "q_rot_fp32": bufs["q_rot_fp32"][:B],
321 + }
322 +
323 + def stats(self) -> dict[str, int]:
324 + return {
325 + "shapes": len(self._bufs),
326 + "max_B": int(self._max_B or 0),
327 + "bytes": sum(
328 + sum(t.numel() * t.element_size() for t in d.values())
329 + for d in self._bufs.values()
330 + ),
331 + }
332 +
333 +
334 +_SEGM_POOL = _SegmBufPool()
335 +
336 +
337 +_HW_TR_CACHED: bool | None = None
338 +
339 +
340 +def _hw_tr_enabled() -> bool:
341 + """Resolve the HW V-transpose build flag.
342 +
343 + ON for gfx950+ (ds_read_tr16_b64 is bit-exact vs baseline and faster
344 + on Qwen-class shapes), off elsewhere. Resolved from the GPU arch.
345 + """
346 + global _HW_TR_CACHED
347 + if _HW_TR_CACHED is not None:
348 + return _HW_TR_CACHED
349 + try:
350 + from flydsl.runtime.device import get_rocm_arch as _arch
351 +
352 + a = str(_arch() or "")
353 + _HW_TR_CACHED = a.startswith("gfx950")
354 + except Exception: # noqa: BLE001
355 + _HW_TR_CACHED = False
356 + if _HW_TR_CACHED:
357 + logger.info_once("FlyDSL TQ: HW V transpose ON (default for gfx950+)")
358 + else:
359 + logger.info_once("FlyDSL TQ: HW V transpose OFF")
360 + return _HW_TR_CACHED
361 +
362 +
363 +_GET_KERNEL_STATS = {"hits": 0, "misses": 0, "build_total_s": 0.0}
364 +
365 +
366 +def _get_kernel(
367 + num_kv_heads: int,
368 + num_partitions: int,
369 + max_blocks_per_seq: int,
370 + scale: float,
371 + query_group_size: int,
372 + kv_block_size: int,
373 + use_hw_v_transpose: bool = False,
374 + num_seqs_hint: int = 1,
375 + tile_groups_per_partition: int = 1,
376 +):
377 + # ``num_seqs_hint`` (= runtime B) is forwarded to the build for shape
378 + # awareness; it does NOT participate in the cache key because the
379 + # kernel body uses gpu.block_idx.x (= seq index at runtime) and is
380 + # GQA-symmetric across B at build time.
381 + #
382 + # ``tile_groups_per_partition`` IS in the cache key — each
383 + # value compiles a different unrolled K-tile loop body.
384 + key = (
385 + num_kv_heads,
386 + int(num_partitions),
387 + int(max_blocks_per_seq),
388 + round(float(scale), 8),
389 + int(query_group_size),
390 + int(kv_block_size),
391 + bool(use_hw_v_transpose),
392 + int(tile_groups_per_partition),
393 + )
394 + cached = _KERN_CACHE.get(key)
395 + if cached is not None:
396 + _GET_KERNEL_STATS["hits"] += 1
397 + return cached
398 + assert is_flydsl_available()
399 + # Time the FlyDSL build path so we can quantify cudagraph capture cost.
400 + import time as _t
401 +
402 + _build_t0 = _t.perf_counter()
403 +
404 + # Per-GQA dispatch: GQA-6 (MiniMax-M2.5) lives in the sibling module
405 + # tq_decode_gqa6 to keep the Qwen kernel's invariants untouched.
406 + # GQA-{8,16} (Qwen) keep using the canonical tq_decode kernel.
407 + qg = int(query_group_size)
408 + if qg == 6:
409 + if _TQ_MOD_GQA6 is None:
410 + raise RuntimeError(
411 + "FlyDSL TQ GQA-6 sibling kernel "
412 + "(vllm.v1.attention.ops.flydsl_kernels.tq_decode_gqa6) "
413 + "failed to import; check that FlyDSL is installed/importable."
414 + )
415 + kmod = _TQ_MOD_GQA6
416 + kfn = kmod.build_tq_decode_gqa6_module(
417 + num_seqs=int(num_seqs_hint),
418 + num_kv_heads=num_kv_heads,
419 + num_partitions=num_partitions,
420 + max_blocks_per_seq=max_blocks_per_seq,
421 + softmax_scale=float(scale),
422 + query_group_size=qg,
423 + kv_block_size=int(kv_block_size),
424 + use_hw_v_transpose=bool(use_hw_v_transpose),
425 + tile_groups_per_partition=int(tile_groups_per_partition),
426 + )
427 + else:
428 + kmod = _TQ_MOD
429 + kfn = kmod.build_tq_decode_module(
430 + num_seqs=int(num_seqs_hint),
431 + num_kv_heads=num_kv_heads,
432 + num_partitions=num_partitions,
433 + max_blocks_per_seq=max_blocks_per_seq,
434 + softmax_scale=float(scale),
435 + query_group_size=qg,
436 + kv_block_size=int(kv_block_size),
437 + use_hw_v_transpose=bool(use_hw_v_transpose),
438 + tile_groups_per_partition=int(tile_groups_per_partition),
439 + )
440 + al = kmod.allocator
441 + block_threads = kmod.BLOCK_THREADS
442 +
443 + flyc = _FLYC
444 + fx = _FX
445 + T = _TYPING_T
446 + CompilationContext = _CC
447 + ir_mod = _IR
448 +
449 + @flyc.jit
450 + def _launch(
451 + out,
452 + es,
453 + ml,
454 + q,
455 + kvc,
456 + cents,
457 + bt,
458 + sl,
459 + gx: fx.Int32, # type: ignore[name-defined]
460 + gy: fx.Int32, # type: ignore[name-defined]
461 + gz: fx.Int32, # type: ignore[name-defined]
462 + stream: fx.Stream, # type: ignore[name-defined]
463 + ):
464 + # Re-finalize the LDS allocator for this launch (idempotent per build).
465 + al.finalized = False
466 + ctx = CompilationContext.get_current()
467 + with ir_mod.InsertionPoint(ctx.gpu_module_body):
468 + al.finalize()
469 + from flydsl.expr import arith
470 +
471 + grid_x = arith.index_cast(T.index, gx.ir_value())
472 + grid_y = arith.index_cast(T.index, gy.ir_value())
473 + grid_z = arith.index_cast(T.index, gz.ir_value())
474 + kfn(out, es, ml, q, kvc, cents, bt, sl).launch(
475 + grid=(grid_x, grid_y, grid_z),
476 + block=(block_threads, 1, 1),
477 + stream=stream,
478 + )
479 +
480 + _KERN_CACHE[key] = _launch
481 + _build_dt = _t.perf_counter() - _build_t0
482 + _GET_KERNEL_STATS["misses"] += 1
483 + _GET_KERNEL_STATS["build_total_s"] += _build_dt
484 + logger.info(
485 + "FlyDSL _get_kernel BUILD #%d dt=%.2fs (cumulative=%.1fs) key=%s",
486 + _GET_KERNEL_STATS["misses"],
487 + _build_dt,
488 + _GET_KERNEL_STATS["build_total_s"],
489 + key,
490 + )
491 + return _launch
492 +
493 +
494 +# -- Partition reducer (Triton, native kernel layout) -------------------------
495 +# Reduces the FA2 split-KV partials produced by the FlyDSL kernel.
496 +# segm_out [N, Hk, P, QG, D] bf16
497 +# segm_max [N, Hk, P, QG] fp32
498 +# segm_sum [N, Hk, P, QG] fp32
499 +# → output [N, Hq=Hk*QG, D] of any (bf16/fp16/fp32) dtype.
500 +@triton.jit
501 +def _reduce_partitions(
502 + output_ptr, # [N, Hq, D] in OUT_DTYPE
503 + segm_out_ptr, # [N, Hk, P, QG, D] bf16
504 + segm_max_ptr, # [N, Hk, P, QG] fp32
505 + segm_sum_ptr, # [N, Hk, P, QG] fp32
506 + out_stride_n: tl.int64, # stride on N axis (in elems of OUT_DTYPE)
507 + out_stride_h: tl.int64, # stride on Hq axis
508 + NUM_KV_HEADS: tl.constexpr,
509 + QG: tl.constexpr,
510 + NUM_PARTS: tl.constexpr,
511 + HEAD_SIZE: tl.constexpr,
512 +):
513 + # grid = (N, Hq)
514 + n = tl.program_id(0)
515 + hq = tl.program_id(1)
516 + kv_h = hq // QG
517 + qg = hq % QG
518 +
519 + # Per-partition pointers for this (n, kv_h, qg) row.
520 + # Strides in elems of source dtype.
521 + msum_base = n * (NUM_KV_HEADS * NUM_PARTS * QG) + kv_h * (NUM_PARTS * QG) + qg
522 + so_base = (
523 + n * (NUM_KV_HEADS * NUM_PARTS * QG * HEAD_SIZE)
524 + + kv_h * (NUM_PARTS * QG * HEAD_SIZE)
525 + + qg * HEAD_SIZE
526 + )
527 +
528 + p_off = tl.arange(0, NUM_PARTS)
529 + d_off = tl.arange(0, HEAD_SIZE)
530 +
531 + # Load per-partition max/sum.
532 + m_idx = msum_base + p_off * QG
533 + seg_max = tl.load(segm_max_ptr + m_idx)
534 + seg_sum = tl.load(segm_sum_ptr + m_idx)
535 +
536 + # Mask out empty partitions (max == -inf).
537 + valid = seg_max > float("-inf")
538 + overall_max = tl.max(tl.where(valid, seg_max, float("-inf")))
539 +
540 + # Rescale exp sums.
541 + rescale = tl.where(valid, tl.exp(seg_max - overall_max), 0.0)
542 + seg_sum_rescaled = seg_sum * rescale
543 + overall_sum = tl.sum(seg_sum_rescaled)
544 +
545 + # Load segment outputs and combine.
546 + so_idx = so_base + p_off[:, None] * (QG * HEAD_SIZE) + d_off[None, :]
547 + seg_out = tl.load(segm_out_ptr + so_idx).to(tl.float32)
548 + # acc = sum_p( seg_out_p * (seg_sum_p * rescale_p) ) / overall_sum
549 + weighted = seg_out * seg_sum_rescaled[:, None]
550 + acc_sum = tl.sum(weighted, axis=0)
551 + acc = tl.where(overall_sum > 0.0, acc_sum / overall_sum, 0.0)
552 +
553 + # Store.
554 + out_off = n * out_stride_n + hq * out_stride_h + d_off
555 + tl.store(output_ptr + out_off, acc.to(output_ptr.dtype.element_ty))
556 +
557 +
558 +# -- Public launcher ----------------------------------------------------------
559 +def flydsl_turboquant_decode_attention(
560 + query: torch.Tensor, # [B, Hq, D] bf16/fp16
561 + kv_cache: torch.Tensor, # [num_blocks, BS, Hk, slot_size_aligned]
562 + block_table: torch.Tensor, # [B, max_blocks_per_seq] int32
563 + seq_lens: torch.Tensor, # [B] int32
564 + Pi: torch.Tensor, # [D, D] fp32
565 + centroids: torch.Tensor, # [N_CENTROIDS] fp32
566 + scale: float,
567 + mse_bits: int,
568 + key_packed_size: int,
569 + value_quant_bits: int,
570 + value_packed_size: int,
571 + key_fp8: bool = False,
572 + norm_correction: bool = False,
573 + PiT: torch.Tensor | None = None,
574 + max_seq_len: int = 0,
575 + mid_o_buf: Any = None,
576 + output_buf: torch.Tensor | None = None,
577 + lse_buf: Any = None,
578 + buf_holder: Any = None,
579 + max_num_kv_splits: int = 32,
580 + sinks: torch.Tensor | None = None,
581 +) -> torch.Tensor:
582 + """SoA-decode-compatible launcher backed by the FlyDSL decode kernel.
583 +
584 + Constraints:
585 + * key_fp8 == False
586 + * mse_bits == 4
587 + * value_quant_bits == 4
588 + * centroids.numel() == 16
589 + * D == 128
590 + * block_size in {16, 32}
591 + * Hq // Hk in {6, 8, 16}
592 + - 8/16 → canonical tq_decode kernel (Qwen2.5-72B / Qwen3-32B)
593 + - 6 → tq_decode_gqa6 sibling kernel (MiniMax-M2.5)
594 +
595 + Sinks are NYI and silently ignored if set. norm_correction is honored
596 + implicitly via the pre-folded stored K-norm (see footer comment).
597 + """
598 + del mid_o_buf, lse_buf, key_packed_size, value_packed_size
599 + if not is_flydsl_available():
600 + raise RuntimeError(
601 + "FlyDSL decode requested but FlyDSL is not available (needs gfx950 "
602 + "+ importable FlyDSL); the SoA Triton decode is the fallback."
603 + )
604 + assert not key_fp8, "FlyDSL supports MSE-key path only"
605 + assert mse_bits == 4, f"FlyDSL expects mse_bits=4, got {mse_bits}"
606 + assert value_quant_bits == 4, (
607 + f"FlyDSL expects value_quant_bits=4, got {value_quant_bits}"
608 + )
609 +
610 + B, Hq, D = query.shape
611 + Hk = kv_cache.shape[2]
612 + block_size = kv_cache.shape[1]
613 + QG = Hq // Hk
614 + assert D == _TQ_MOD.HEAD_SIZE
615 + assert block_size in (16, 32), (
616 + f"FlyDSL supports kv_block_size 16 or 32, got {block_size}"
617 + )
618 + assert QG in (6, 8, 16), f"FlyDSL supports GQA factor 6, 8 or 16, got {QG}"
619 + if QG == 6 and _TQ_MOD_GQA6 is None:
620 + raise RuntimeError(
621 + "FlyDSL launcher: GQA-6 requested (MiniMax-class) but the "
622 + "tq_decode_gqa6 sibling module is not available. Update your "
623 + "FlyDSL checkout (must include kernels/tq_decode_gqa6.py); "
624 + "otherwise the SoA Triton decode is the fallback."
625 + )
626 + assert centroids.numel() == _TQ_MOD.N_CENTROIDS, (
627 + f"centroids.numel={centroids.numel()} != {_TQ_MOD.N_CENTROIDS}"
628 + )
629 +
630 + # ---- Per-layer cache for PiT_f32 + contiguous centroids ---
631 + # PiT and centroids are model constants (set once at layer warmup).
632 + # Avoid the per-decode-step transpose+cast and `.contiguous()` no-op
633 + # check by stashing the pre-cooked tensors on ``buf_holder`` (= layer).
634 + PiT_f32: torch.Tensor
635 + centroids_c: torch.Tensor
636 + if buf_holder is not None:
637 + PiT_f32 = getattr(buf_holder, "_tq_PiT_f32", None)
638 + if PiT_f32 is None:
639 + _PiT_src = PiT if PiT is not None else Pi.T.contiguous()
640 + PiT_f32 = (
641 + _PiT_src
642 + if _PiT_src.dtype == torch.float32
643 + else _PiT_src.to(torch.float32)
644 + )
645 + buf_holder._tq_PiT_f32 = PiT_f32
646 + centroids_c = getattr(buf_holder, "_tq_centroids_c", None)
647 + if centroids_c is None:
648 + centroids_c = centroids.contiguous()
649 + buf_holder._tq_centroids_c = centroids_c
650 + else:
651 + # Defensive fallback (unit tests may pass ``buf_holder=None``).
652 + _PiT_src = PiT if PiT is not None else Pi.T.contiguous()
653 + PiT_f32 = (
654 + _PiT_src if _PiT_src.dtype == torch.float32 else _PiT_src.to(torch.float32)
655 + )
656 + centroids_c = centroids.contiguous()
657 +
658 + # ---- Pooled q_rot (stable data_ptr post-capture) ------
659 + # On ROCm, fresh tensor allocations made AFTER HIP graph capture can land
660 + # in the graph memory pool and cause GPU memory faults when passed as kernel
661 + # pointer arguments in the eager mixed-batch path. We pool q_rot and the
662 + # fp32 intermediate the same way segm_out/output are pooled: one max-B
663 + # allocation per shape, reused across all B's.
664 + #
665 + # Note: pool_bufs is computed BELOW after num_partitions is determined;
666 + # q_rot needs pool_bufs["q_float"] and pool_bufs["q_rot"]. We compute the
667 + # rotation inline after the pool is fetched (see the pooled-buffers
668 + # section below).
669 +
670 + # ---- Partition count (FA2 split-KV) ----------------------------------
671 + #
672 + # FlyDSL runs INSIDE FULL cudagraph (TurboQuantMetadataBuilder._cudagraph_
673 + # support = UNIFORM_BATCH). That means the gridDim baked at capture
674 + # time MUST equal the gridDim at replay — the kernel launch parameters
675 + # are recorded into the captured graph. So `num_partitions` (which
676 + # becomes gridDim.z) MUST be derived from a stable source that produces
677 + # the same value at capture and at runtime.
678 + #
679 + # We use the worst-case context length implied by the block table's
680 + # allocation (block_table.shape[1] * block_size) as that source. It's
681 + # bounded by `max_model_len` (vLLM allocates the block table for the
682 + # configured max), and it's identical at capture and at runtime
683 + # because `block_table.shape[1]` is fixed once the model is loaded.
684 + # Per-tile OOB redirects + the masked FA-2 reducer ensure that for
685 + # short sequences the extra partitions contribute zero to the output.
686 + #
687 + # We always size from the worst case (block_table.shape[1] * block_size),
688 + # never from the per-step actual max_seq_len: a per-step size would make
689 + # the captured gridDim mismatch runtime and GPU-fault under cudagraph.
690 + kv_compute_block = _TQ_MOD.KV_COMPUTE_BLOCK
691 + worst_case_max_seq_len = int(block_table.shape[1]) * int(block_size)
692 + if max_seq_len <= 0:
693 + max_seq_len = worst_case_max_seq_len
694 + sizing_max_seq_len = worst_case_max_seq_len
695 +
696 + # ── Bounded num_partitions + internal tile-group looping ──
697 + # The kernel previously hardcoded one partition = 256 tokens (=
698 + # KV_COMPUTE_BLOCK), forcing num_partitions to scale linearly with
699 + # max_model_len, which made the cudagraph capture cost grow
700 + # prohibitively at long context. The new kernel takes
701 + # ``tile_groups_per_partition`` (TGPP); each CTA processes
702 + # ``TGPP * 16`` K-tiles = ``TGPP * KV_COMPUTE_BLOCK`` tokens with the
703 + # FA-2 online-softmax state accumulating across all of them. This
704 + # mirrors what the SoA Triton decode / HIP SoA-fusion already do.
705 + #
706 + # Strategy: cap num_partitions at ``MAX_PARTITIONS`` (default 32),
707 + # then derive TGPP so that ``num_partitions * TGPP * KV_COMPUTE_BLOCK
708 + # >= sizing_max_seq_len``.
709 + #
710 + # Examples (block_size=32 → worst_case max_bps*32):
711 + # max_model_len=8K: required=32, parts=32, TGPP=1 (no waste)
712 + # max_model_len=16K: required=64, parts=32, TGPP=2 (no waste)
713 + # max_model_len=32K: required=128, parts=32, TGPP=4 (no waste)
714 + # max_model_len=64K: required=256, parts=32, TGPP=8 (no waste)
715 + # max_model_len=128K: required=512, parts=32, TGPP=16
716 + MAX_PARTITIONS = 32
717 + required_num_partitions = (
718 + sizing_max_seq_len + kv_compute_block - 1
719 + ) // kv_compute_block
720 + # max_num_kv_splits acts as a parallelism floor (ensure at least this
721 + # many CTAs along grid.z), but is itself capped by MAX_PARTITIONS.
722 + parallelism_floor = min(MAX_PARTITIONS, max(1, max_num_kv_splits))
723 + # Cap num_partitions at MAX_PARTITIONS, but never go below the floor.
724 + num_partitions_actual = max(
725 + parallelism_floor,
726 + min(MAX_PARTITIONS, required_num_partitions),
727 + )
728 + # Round up to next power of 2 for the Triton reducer's tl.arange(0, N)
729 + # constraint (Triton requires power-of-2 ≥ 2).
730 + num_partitions = max(2, triton.next_power_of_2(num_partitions_actual))
731 + # Now derive tile_groups_per_partition (TGPP) so total coverage
732 + # ``num_partitions * TGPP * KV_COMPUTE_BLOCK`` is >= sizing_max_seq_len.
733 + # This guarantees no work is dropped at runtime regardless of seq_len.
734 + # Round TGPP up to the next power-of-2 so the JIT cache key is bounded
735 + # to a small set of values (e.g. {1, 2, 4, 8, 16}) — limits the number
736 + # of distinct kernel binaries that need to be compiled across requests.
737 + _tgpp_required = max(
738 + 1,
739 + (required_num_partitions + num_partitions - 1) // num_partitions,
740 + )
741 + tile_groups_per_partition = int(triton.next_power_of_2(_tgpp_required))
742 +
743 + # ---- Pooled buffers (no per-call cudaMalloc / memset) ----------
744 + # The FlyDSL kernel writes the FULL [B, Hk, P, QG, D] segm_out and the
745 + # FULL [B, Hk, P, QG] segm_max / segm_sum (running_max=-inf, sum=0 for
746 + # empty partitions are stored by the kernel itself), so uninitialized
747 + # buffers from the pool are safe to reuse.
748 + device = query.device
749 + pool_bufs = _SEGM_POOL.get(
750 + B,
751 + Hk,
752 + Hq,
753 + num_partitions,
754 + QG,
755 + D,
756 + device,
757 + query.dtype,
758 + )
759 + segm_out = pool_bufs["segm_out"]
760 + segm_max = pool_bufs["segm_max"]
761 + segm_sum = pool_bufs["segm_sum"]
762 + if output_buf is None:
763 + output = pool_bufs["output"]
764 + else:
765 + output = output_buf[:B] if output_buf.shape[0] != B else output_buf
766 +
767 + # ---- q rotation: GEMM q @ PiT via stable pooled buffers -------------
768 + _q_float = pool_bufs["q_float"] # [B, Hq, D] fp32, stable
769 + _q_rot_f32 = pool_bufs["q_rot_fp32"] # [B, Hq, D] fp32, stable mm output
770 + _q_rot_out = pool_bufs["q_rot"] # [B, Hq, D] query.dtype, stable
771 + _q_float.copy_(query) # bf16 → fp32 in-place, no alloc
772 + # mm into stable fp32 buffer via out= to avoid fresh allocations.
773 + # PiT_f32 is cached on buf_holder (stable ptr since first layer warmup).
774 + torch.mm(
775 + _q_float.view(B * Hq, D),
776 + PiT_f32,
777 + out=_q_rot_f32.view(B * Hq, D), # in-place into pool buffer
778 + )
779 + _q_rot_out.copy_(_q_rot_f32) # fp32 → bf16, into stable buf
780 + q_for_kernel = _q_rot_out # stable ptr for kernel launch
781 +
782 + # ---- FlyDSL kernel launch -------------------------------------------
783 + max_bps = int(block_table.shape[1])
784 + use_hw_tr = _hw_tr_enabled()
785 + launch = _get_kernel(
786 + Hk,
787 + num_partitions,
788 + max_bps,
789 + scale,
790 + QG,
791 + block_size,
792 + use_hw_v_transpose=use_hw_tr,
793 + num_seqs_hint=int(B),
794 + tile_groups_per_partition=int(tile_groups_per_partition),
795 + )
796 + # Zero-overhead one-shot info log (replaces logger.info_once which
797 + # hashes its format string on every call to dedup).
798 + global _LOG_INVOKED_ONCE, _LOG_SINKS_WARNED, _LOG_NORM_WARNED
799 + if not _LOG_INVOKED_ONCE:
800 + _LOG_INVOKED_ONCE = True
801 + logger.info(
802 + "FlyDSL launcher invoked (UNIFORM_BATCH cudagraph): "
803 + "B=%d Hk=%d Hq=%d D=%d QG=%d num_partitions=%d (actual=%d, "
804 + "cap=%d) TGPP=%d max_bps=%d block_size=%d max_seq_len=%d "
805 + "hw_v_transpose=%s (coverage=%d tokens, worst_case=%d tokens)",
806 + B,
807 + Hk,
808 + Hq,
809 + D,
810 + QG,
811 + num_partitions,
812 + num_partitions_actual,
813 + MAX_PARTITIONS,
814 + tile_groups_per_partition,
815 + max_bps,
816 + int(block_size),
817 + int(max_seq_len),
818 + use_hw_tr,
819 + num_partitions * tile_groups_per_partition * kv_compute_block,
820 + worst_case_max_seq_len,
821 + )
822 + launch(
823 + segm_out,
824 + segm_sum,
825 + segm_max,
826 + q_for_kernel,
827 + kv_cache,
828 + centroids_c,
829 + block_table,
830 + seq_lens,
831 + B,
832 + Hk,
833 + num_partitions,
834 + torch.cuda.current_stream(),
835 + )
836 +
837 + # ---- Reduce partitions -> [B, Hq, D] --------------------------------
838 + _reduce_partitions[(B, Hq)](
839 + output_ptr=output,
840 + segm_out_ptr=segm_out,
841 + segm_max_ptr=segm_max,
842 + segm_sum_ptr=segm_sum,
843 + out_stride_n=output.stride(0),
844 + out_stride_h=output.stride(1),
845 + NUM_KV_HEADS=Hk,
846 + QG=QG,
847 + NUM_PARTS=num_partitions,
848 + HEAD_SIZE=D,
849 + )
850 + if sinks is not None and not _LOG_SINKS_WARNED:
851 + _LOG_SINKS_WARNED = True
852 + logger.warning(
853 + "FlyDSL launcher: sinks ignored (NYI). Disable sinks if they "
854 + "are required for correctness."
855 + )
856 + # ── norm_correction is honored IMPLICITLY ───────────────────────────
857 + # When the model was stored with norm_correction=True (the *_nc presets
858 + # turboquant_4bit_nc / k3v4_nc / 3bit_nc), the per-token K-norm scalar
859 + # was pre-folded to ||k_t|| / ||c_t|| at store time by the TurboQuant
860 + # store path. The decode kernel just multiplies `c_vals * stored_knorm`,
861 + # which then equals `(c_vals / ||c_t||) * ||k_t||` — exactly the
862 + # unit-norm-renormalized centroid times the original key norm. The
863 + # Triton decode does the identical multiply, so both decoders honor
864 + # norm_correction equivalently and there is nothing to "do" at decode
865 + # time. The launcher's `norm_correction` arg is only kept for API
866 + # parity; we emit a one-shot INFO log to make the contract explicit.
867 + if norm_correction and not _LOG_NORM_WARNED:
868 + _LOG_NORM_WARNED = True
869 + logger.info(
870 + "FlyDSL launcher: norm_correction honored implicitly via "
871 + "pre-folded stored K-norm (cf. triton_turboquant_store step 3); "
872 + "no decode-time work required, identical to the SoA decode behavior."
873 + )
874 + return output
vllm/v1/attention/ops/turboquant_soa/__init__.py new
+3
@@ -0,0 +1,3 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +# HIP-free SoA Triton subset for the FlyDSL TurboQuant decode path.
vllm/v1/attention/ops/turboquant_soa/triton_turboquant_decode.py new
+643
@@ -0,0 +1,643 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Triton fused TurboQuant decode attention.
4 +
5 +Decode path: Triton stage1 (split-KV tiled attention scoring + value
6 +accumulation) + stage2 (log-sum-exp reduction across splits).
7 +
8 +Supports FP8 (E4M3) keys, 3-bit and 4-bit uniform quantized values.
9 +"""
10 +
11 +import math
12 +from typing import Any
13 +
14 +import torch
15 +
16 +from vllm.platforms import current_platform
17 +from vllm.triton_utils import tl, triton
18 +from vllm.v1.attention.ops.triton_decode_attention import (
19 + _fwd_kernel_stage2,
20 +)
21 +
22 +_FP8_E4B15: dict[int, int] = {}
23 +
24 +
25 +def _use_fp8_e4b15(device: int = 0) -> int:
26 + """Return 1 if device needs fp8e4b15 (Ampere/Ada, SM < 8.9), else 0.
27 + On non-CUDA platforms (e.g. XPU), always returns 0 (use e4nv format).
28 + """
29 + if device not in _FP8_E4B15:
30 + if current_platform.is_cuda_alike():
31 + cap = torch.cuda.get_device_capability(device)
32 + _FP8_E4B15[device] = 1 if cap < (8, 9) else 0
33 + else:
34 + _FP8_E4B15[device] = 0
35 + return _FP8_E4B15[device]
36 +
37 +
38 +# ---------------------------------------------------------------------------
39 +# Stage 1: Fused TQ score + value accumulation (BLOCK_KV tiled)
40 +# ---------------------------------------------------------------------------
41 +
42 +
43 +@triton.jit
44 +def _tq_decode_stage1(
45 + # Precomputed query projection
46 + Q_rot_ptr, # [B, Hq, D] float32
47 + # Compressed KV cache (combined K+V)
48 + KV_cache_ptr, # [num_blocks, block_size, Hk, padded_slot] uint8
49 + KV_cache_u16_ptr, # uint16 view of same storage — Opt#3 SoA loads
50 + # Block table and sequence info
51 + Block_table_ptr, # [B, max_num_blocks] int32
52 + Seq_lens_ptr, # [B] int32
53 + # TQ parameters
54 + Centroids_ptr, # [n_centroids] float32
55 + # Output (intermediate for stage2)
56 + Mid_o_ptr, # [B, Hq, NUM_KV_SPLITS, D+1] float32
57 + # Sink support
58 + Sink_ptr, # [Hq] float32, pre-computed sink attention logits per head
59 + # Strides
60 + stride_qb,
61 + stride_qh, # Q strides: [B, Hq, D]
62 + stride_cache_block: tl.int64, # bytes per block (bs*H*slot_aligned)
63 + stride_bt_b: tl.int64, # block_table stride per batch
64 + stride_mid_b,
65 + stride_mid_h,
66 + stride_mid_s, # mid_o strides
67 + # Constexpr dims
68 + NUM_KV_HEADS: tl.constexpr,
69 + HEAD_DIM: tl.constexpr,
70 + BLOCK_SIZE: tl.constexpr, # KV cache block_size (pages)
71 + NUM_KV_SPLITS: tl.constexpr,
72 + KV_GROUP_SIZE: tl.constexpr, # Hq // Hk
73 + # TQ layout constants
74 + MSE_BITS: tl.constexpr, # 3 or 4
75 + MSE_BYTES: tl.constexpr, # ceil(D * mse_bits / 8)
76 + VQB: tl.constexpr, # value_quant_bits (4 or 8=FP8)
77 + VAL_DATA_BYTES: tl.constexpr, # ceil(D * vqb / 8) or D for FP8
78 + # Opt#3 SoA layout
79 + KEY_DATA_BYTES: tl.constexpr, # MSE_BYTES (MSE) or HEAD_DIM (FP8)
80 + META_REGION_OFFSET: tl.constexpr, # bytes from block start to SoA region
81 + NUM_SOA_FIELDS: tl.constexpr, # 3 (MSE) or 2 (FP8)
82 + SOA_K_NORM: tl.constexpr, # 0 (MSE); unused for FP8
83 + SOA_V_SCALE: tl.constexpr, # 1 (MSE) / 0 (FP8)
84 + SOA_V_ZERO: tl.constexpr, # 2 (MSE) / 1 (FP8)
85 + # Score constants
86 + ATTN_SCALE: tl.constexpr, # 1/sqrt(D)
87 + # Block tile sizes
88 + BLOCK_D: tl.constexpr, # next_power_of_2(HEAD_DIM)
89 + BLOCK_KV: tl.constexpr, # tokens per tile (16)
90 + KEY_FP8: tl.constexpr, # 1 if K is stored as FP8
91 + NORM_CORRECTION: tl.constexpr = 0, # 1 = re-normalize centroids
92 + FP8_E4B15: tl.constexpr = 0, # 1 = use e4b15 (Ampere/Ada), 0 = e4nv (Hopper+)
93 + USE_SINKS: tl.constexpr = 0, # 1 = use sink tokens for attention anchoring
94 +):
95 + bid = tl.program_id(0) # batch index
96 + hid = tl.program_id(1) # q_head index
97 + sid = tl.program_id(2) # kv_split index
98 +
99 + kv_head = hid // KV_GROUP_SIZE
100 +
101 + # Sequence length for this batch
102 + seq_len = tl.load(Seq_lens_ptr + bid)
103 +
104 + # KV split range
105 + split_len = tl.cdiv(seq_len, NUM_KV_SPLITS)
106 + split_start = split_len * sid
107 + split_end = tl.minimum(split_start + split_len, seq_len)
108 +
109 + if split_start >= split_end:
110 + return
111 +
112 + # Dimension offsets
113 + d_offs = tl.arange(0, BLOCK_D)
114 + d_mask = d_offs < HEAD_DIM
115 + kv_range = tl.arange(0, BLOCK_KV)
116 +
117 + # Load query vector: q_rot — [BLOCK_D] float32
118 + q_base = bid * stride_qb + hid * stride_qh
119 + q_rot = tl.load(Q_rot_ptr + q_base + d_offs, mask=d_mask, other=0.0).to(tl.float32)
120 +
121 + # Precompute byte/bit index vectors for MSE gather loads
122 + if not KEY_FP8:
123 + mse_bit_off = d_offs * MSE_BITS
124 + mse_byte_idx = mse_bit_off // 8
125 + mse_bit_shift = mse_bit_off % 8
126 + mse_mask = (1 << MSE_BITS) - 1
127 +
128 + # Precompute value bit/byte index vectors (loop-invariant)
129 + if VQB == 3:
130 + val_bit_off = d_offs * 3
131 + val_byte_idx = val_bit_off // 8
132 + val_bit_shift = val_bit_off % 8
133 +
134 + # Online softmax accumulators
135 + # Sink tokens provide a pre-computed attention bias that should be
136 + # included in the softmax normalization. For the first split (sid==0),
137 + # we initialize m_prev with the sink logit. L is always 1.0 for all
138 + # splits to match unified attention semantics.
139 + if USE_SINKS:
140 + if sid == 0:
141 + m_prev = tl.load(Sink_ptr + hid).to(tl.float32)
142 + l_prev = 1.0
143 + else:
144 + m_prev = -float("inf")
145 + l_prev = 0.0
146 + else:
147 + m_prev = -float("inf")
148 + l_prev = 0.0
149 + acc = tl.zeros([BLOCK_D], dtype=tl.float32)
150 +
151 + bt_base = bid * stride_bt_b
152 +
153 + # ================================================================
154 + # TILED LOOP: process BLOCK_KV tokens per iteration
155 + # ================================================================
156 + for start_n in range(split_start, split_end, BLOCK_KV):
157 + kv_offs = start_n + kv_range
158 + kv_mask = kv_offs < split_end
159 +
160 + page_idx = kv_offs // BLOCK_SIZE
161 + page_off = kv_offs % BLOCK_SIZE
162 + block_nums = tl.load(
163 + Block_table_ptr + bt_base + page_idx,
164 + mask=kv_mask,
165 + other=0,
166 + ).to(tl.int64)
167 +
168 + # Opt#3 SoA addressing: data region then metadata region per block.
169 + slot_within_block = page_off.to(tl.int64)
170 + block_base = block_nums * stride_cache_block
171 + DATA_BYTES_PER_SLOT: tl.constexpr = KEY_DATA_BYTES + VAL_DATA_BYTES
172 + data_bases = (
173 + block_base
174 + + slot_within_block * (NUM_KV_HEADS * DATA_BYTES_PER_SLOT)
175 + + tl.cast(kv_head, tl.int64) * DATA_BYTES_PER_SLOT
176 + )
177 + head_meta_u16_base = (block_base + META_REGION_OFFSET) // 2 + tl.cast(
178 + kv_head, tl.int64
179 + ) * (NUM_SOA_FIELDS * BLOCK_SIZE)
180 + knorm_u16_addrs = (
181 + head_meta_u16_base + SOA_K_NORM * BLOCK_SIZE + slot_within_block
182 + )
183 + vscale_u16_addrs = (
184 + head_meta_u16_base + SOA_V_SCALE * BLOCK_SIZE + slot_within_block
185 + )
186 + vzero_u16_addrs = (
187 + head_meta_u16_base + SOA_V_ZERO * BLOCK_SIZE + slot_within_block
188 + )
189 +
190 + # ============================================================
191 + # COMPUTE ATTENTION SCORES: [BLOCK_KV]
192 + # ============================================================
193 + if KEY_FP8:
194 + k_addrs = data_bases[:, None] + d_offs[None, :]
195 + k_raw = tl.load(
196 + KV_cache_ptr + k_addrs,
197 + mask=kv_mask[:, None] & d_mask[None, :],
198 + other=0,
199 + )
200 + if FP8_E4B15:
201 + k_float = k_raw.to(tl.float8e4b15, bitcast=True).to(tl.float32)
202 + else:
203 + k_float = k_raw.to(tl.float8e4nv, bitcast=True).to(tl.float32)
204 + scores = (
205 + tl.sum(
206 + tl.where(d_mask[None, :], q_rot[None, :] * k_float, 0.0),
207 + axis=1,
208 + )
209 + * ATTN_SCALE
210 + )
211 + scores = tl.where(kv_mask, scores, -float("inf"))
212 + else:
213 + # MSE unpack + norms
214 + mse_addrs0 = data_bases[:, None] + mse_byte_idx[None, :]
215 + mse_raw0 = tl.load(
216 + KV_cache_ptr + mse_addrs0,
217 + mask=kv_mask[:, None] & d_mask[None, :],
218 + other=0,
219 + ).to(tl.int32)
220 + mse_raw1 = tl.load(
221 + KV_cache_ptr + mse_addrs0 + 1,
222 + mask=kv_mask[:, None] & d_mask[None, :],
223 + other=0,
224 + ).to(tl.int32)
225 + raw16 = mse_raw0 | (mse_raw1 << 8)
226 + mse_idx = (raw16 >> mse_bit_shift[None, :]) & mse_mask
227 +
228 + # Centroid gather + dot product
229 + c_vals = tl.load(
230 + Centroids_ptr + mse_idx,
231 + mask=kv_mask[:, None] & d_mask[None, :],
232 + other=0.0,
233 + )
234 +
235 + # Opt#1: norm-correction (1/||c_vec||) is pre-folded into the
236 + # stored per-token scalar by the MSE store kernel when
237 + # NORM_CORRECTION=1, so we skip the per-tile sum+sqrt+divide.
238 + # NORM_CORRECTION is kept in the signature for API parity; it is
239 + # implicit in the stored norm value now.
240 +
241 + term1 = tl.sum(
242 + tl.where(d_mask[None, :], q_rot[None, :] * c_vals, 0.0),
243 + axis=1,
244 + )
245 +
246 + # Opt#3: K-norms live in the per-block SoA region. Single u16
247 + # load per token; contiguous across tokens within a block.
248 + norm_u16 = tl.load(
249 + KV_cache_u16_ptr + knorm_u16_addrs, mask=kv_mask, other=0
250 + )
251 + vec_norms = norm_u16.to(tl.float16, bitcast=True).to(tl.float32)
252 +
253 + scores = vec_norms * term1 * ATTN_SCALE
254 + scores = tl.where(kv_mask, scores, -float("inf"))
255 +
256 + # ============================================================
257 + # ONLINE SOFTMAX UPDATE (block-level)
258 + # ============================================================
259 + n_e_max = tl.maximum(tl.max(scores, 0), m_prev)
260 + re_scale = tl.exp(m_prev - n_e_max)
261 + p = tl.exp(scores - n_e_max)
262 +
263 + # ============================================================
264 + # VALUE LOAD + DEQUANTIZE: [BLOCK_KV, BLOCK_D]
265 + # Opt#3: V data immediately follows K data in the slot (no
266 + # interleaved metadata). Scale/zero come from the SoA metadata
267 + # region as single u16 loads per field per token.
268 + # ============================================================
269 + val_bases = data_bases + KEY_DATA_BYTES
270 +
271 + if VQB == 3:
272 + val_addrs0 = val_bases[:, None] + val_byte_idx[None, :]
273 + val_raw0 = tl.load(
274 + KV_cache_ptr + val_addrs0,
275 + mask=kv_mask[:, None] & d_mask[None, :],
276 + other=0,
277 + ).to(tl.int32)
278 + val_raw1 = tl.load(
279 + KV_cache_ptr + val_addrs0 + 1,
280 + mask=kv_mask[:, None] & d_mask[None, :],
281 + other=0,
282 + ).to(tl.int32)
283 + raw16 = val_raw0 | (val_raw1 << 8)
284 + v_idx = ((raw16 >> val_bit_shift[None, :]) & 0x7).to(tl.float32)
285 + else: # VQB == 4
286 + vb_idx = d_offs // 2
287 + vb_shift = (d_offs % 2) * 4
288 + val_addrs = val_bases[:, None] + vb_idx[None, :]
289 + val_raw = tl.load(
290 + KV_cache_ptr + val_addrs,
291 + mask=kv_mask[:, None] & d_mask[None, :],
292 + other=0,
293 + ).to(tl.int32)
294 + v_idx = ((val_raw >> vb_shift[None, :]) & 0xF).to(tl.float32)
295 +
296 + scale_u16 = tl.load(KV_cache_u16_ptr + vscale_u16_addrs, mask=kv_mask, other=0)
297 + zero_u16 = tl.load(KV_cache_u16_ptr + vzero_u16_addrs, mask=kv_mask, other=0)
298 + v_scales = scale_u16.to(tl.float16, bitcast=True).to(tl.float32)
299 + v_zeros = zero_u16.to(tl.float16, bitcast=True).to(tl.float32)
300 + values = v_idx * v_scales[:, None] + v_zeros[:, None]
301 +
302 + # ============================================================
303 + # WEIGHTED VALUE ACCUMULATION
304 + # ============================================================
305 + acc = acc * re_scale + tl.sum(p[:, None] * values, 0)
306 + l_prev = l_prev * re_scale + tl.sum(p, 0)
307 + m_prev = n_e_max
308 +
309 + # Store partial result
310 + out_base = bid * stride_mid_b + hid * stride_mid_h + sid * stride_mid_s
311 + safe_l = tl.where(l_prev > 0.0, l_prev, 1.0)
312 + tl.store(Mid_o_ptr + out_base + d_offs, acc / safe_l, mask=d_mask)
313 + lse = m_prev + tl.log(safe_l)
314 + tl.store(Mid_o_ptr + out_base + HEAD_DIM, lse)
315 +
316 +
317 +# ---------------------------------------------------------------------------
318 +# Pre-dequant kernel: Bulk dequant K (MSE+norms) and V to fp16
319 +# ---------------------------------------------------------------------------
320 +
321 +
322 +@triton.jit
323 +def _tq_full_dequant_kv(
324 + KV_cache_ptr,
325 + KV_cache_u16_ptr,
326 + Block_table_ptr,
327 + Centroids_ptr,
328 + K_out_ptr, # [B, Hk, max_seq, D] float16
329 + V_out_ptr, # [B, Hk, max_seq, D] float16
330 + stride_ko_b: tl.int64,
331 + stride_ko_h: tl.int64,
332 + stride_ko_s: tl.int64,
333 + stride_vo_b: tl.int64,
334 + stride_vo_h: tl.int64,
335 + stride_vo_s: tl.int64,
336 + stride_cache_block: tl.int64,
337 + stride_bt_b: tl.int64,
338 + HEAD_DIM: tl.constexpr,
339 + BLOCK_SIZE: tl.constexpr,
340 + NUM_KV_HEADS: tl.constexpr,
341 + MSE_BYTES: tl.constexpr,
342 + VQB: tl.constexpr,
343 + VAL_DATA_BYTES: tl.constexpr,
344 + MSE_BITS: tl.constexpr,
345 + KEY_FP8: tl.constexpr,
346 + # Opt#3 SoA layout
347 + KEY_DATA_BYTES: tl.constexpr,
348 + META_REGION_OFFSET: tl.constexpr,
349 + NUM_SOA_FIELDS: tl.constexpr,
350 + SOA_K_NORM: tl.constexpr,
351 + SOA_V_SCALE: tl.constexpr,
352 + SOA_V_ZERO: tl.constexpr,
353 + BLOCK_D: tl.constexpr,
354 + NORM_CORRECTION: tl.constexpr = 0,
355 + FP8_E4B15: tl.constexpr = 0, # 1 = use e4b15 (Ampere/Ada), 0 = e4nv (Hopper+)
356 +):
357 + """Full dequant: reconstruct K (MSE centroids * norm or FP8) and V to fp16.
358 +
359 + Opt#3 SoA layout: data region [bs, H, KD+VD] + per-block SoA metadata.
360 + """
361 + pos = tl.program_id(0)
362 + bh = tl.program_id(1)
363 + bid = bh // NUM_KV_HEADS
364 + hid = bh % NUM_KV_HEADS
365 +
366 + page_idx = pos // BLOCK_SIZE
367 + page_off = pos % BLOCK_SIZE
368 + block_num = tl.load(Block_table_ptr + bid * stride_bt_b + page_idx).to(tl.int64)
369 +
370 + block_base = block_num * stride_cache_block
371 + slot_within_block = tl.cast(page_off, tl.int64)
372 + DATA_BYTES_PER_SLOT: tl.constexpr = KEY_DATA_BYTES + VAL_DATA_BYTES
373 + data_base = (
374 + block_base
375 + + slot_within_block * (NUM_KV_HEADS * DATA_BYTES_PER_SLOT)
376 + + tl.cast(hid, tl.int64) * DATA_BYTES_PER_SLOT
377 + )
378 + head_meta_u16_base = (block_base + META_REGION_OFFSET) // 2 + tl.cast(
379 + hid, tl.int64
380 + ) * (NUM_SOA_FIELDS * BLOCK_SIZE)
381 + knorm_u16_addr = head_meta_u16_base + SOA_K_NORM * BLOCK_SIZE + slot_within_block
382 + vscale_u16_addr = head_meta_u16_base + SOA_V_SCALE * BLOCK_SIZE + slot_within_block
383 + vzero_u16_addr = head_meta_u16_base + SOA_V_ZERO * BLOCK_SIZE + slot_within_block
384 +
385 + d_offs = tl.arange(0, BLOCK_D)
386 + d_mask = d_offs < HEAD_DIM
387 +
388 + # === K dequant ===
389 + ko_base = bid * stride_ko_b + hid * stride_ko_h + pos * stride_ko_s
390 + if KEY_FP8:
391 + k_raw = tl.load(KV_cache_ptr + data_base + d_offs, mask=d_mask, other=0)
392 + if FP8_E4B15:
393 + k_recon = k_raw.to(tl.float8e4b15, bitcast=True).to(tl.float32)
394 + else:
395 + k_recon = k_raw.to(tl.float8e4nv, bitcast=True).to(tl.float32)
396 + tl.store(K_out_ptr + ko_base + d_offs, k_recon.to(tl.float16), mask=d_mask)
397 + else:
398 + mse_bit_off = d_offs * MSE_BITS
399 + mse_byte_idx = mse_bit_off // 8
400 + mse_bit_shift = mse_bit_off % 8
401 + mse_umask = (1 << MSE_BITS) - 1
402 +
403 + mse_raw0 = tl.load(
404 + KV_cache_ptr + data_base + mse_byte_idx, mask=d_mask, other=0
405 + ).to(tl.int32)
406 + mse_raw1 = tl.load(
407 + KV_cache_ptr + data_base + mse_byte_idx + 1, mask=d_mask, other=0
408 + ).to(tl.int32)
409 + raw16_key = mse_raw0 | (mse_raw1 << 8)
410 + mse_idx = (raw16_key >> mse_bit_shift) & mse_umask
411 +
412 + k_mse = tl.load(Centroids_ptr + mse_idx, mask=d_mask, other=0.0)
413 +
414 + # Opt#1: norm-correction is pre-folded into the stored scalar.
415 + # Opt#3: K-norm lives in the SoA region — single u16 load.
416 + norm_u16 = tl.load(KV_cache_u16_ptr + knorm_u16_addr)
417 + vec_norm = norm_u16.to(tl.float16, bitcast=True).to(tl.float32)
418 +
419 + k_recon = vec_norm * k_mse
420 + tl.store(K_out_ptr + ko_base + d_offs, k_recon.to(tl.float16), mask=d_mask)
421 +
422 + # === V dequant ===
423 + val_base = data_base + KEY_DATA_BYTES
424 + if VQB == 4:
425 + vb_idx = d_offs // 2
426 + vb_shift = (d_offs % 2) * 4
427 + val_raw = tl.load(KV_cache_ptr + val_base + vb_idx, mask=d_mask, other=0).to(
428 + tl.int32
429 + )
430 + v_idx = ((val_raw >> vb_shift) & 0xF).to(tl.float32)
431 + elif VQB == 3:
432 + val_bit_off = d_offs * 3
433 + val_byte_idx = val_bit_off // 8
434 + val_bit_shift = val_bit_off % 8
435 + val_raw0 = tl.load(
436 + KV_cache_ptr + val_base + val_byte_idx, mask=d_mask, other=0
437 + ).to(tl.int32)
438 + val_raw1 = tl.load(
439 + KV_cache_ptr + val_base + val_byte_idx + 1, mask=d_mask, other=0
440 + ).to(tl.int32)
441 + raw16_val = val_raw0 | (val_raw1 << 8)
442 + v_idx = ((raw16_val >> val_bit_shift) & 0x7).to(tl.float32)
443 + else:
444 + v_idx = tl.zeros([BLOCK_D], dtype=tl.float32)
445 +
446 + # Opt#3: V scale/zero single-u16 loads from SoA region.
447 + sc_u16 = tl.load(KV_cache_u16_ptr + vscale_u16_addr)
448 + zr_u16 = tl.load(KV_cache_u16_ptr + vzero_u16_addr)
449 + v_scale = sc_u16.to(tl.float16, bitcast=True).to(tl.float32)
450 + v_zero = zr_u16.to(tl.float16, bitcast=True).to(tl.float32)
451 + v_vals = v_idx * v_scale + v_zero
452 +
453 + vo_base = bid * stride_vo_b + hid * stride_vo_h + pos * stride_vo_s
454 + tl.store(V_out_ptr + vo_base + d_offs, v_vals.to(tl.float16), mask=d_mask)
455 +
456 +
457 +# ---------------------------------------------------------------------------
458 +# Stage 2: Reuse from triton_decode_attention.py
459 +# ---------------------------------------------------------------------------
460 +
461 +# ---------------------------------------------------------------------------
462 +# Launcher — cached constants + fused GEMM
463 +# ---------------------------------------------------------------------------
464 +
465 +_layout_cache: dict = {}
466 +
467 +
468 +def _get_layout(D, mse_bits, value_quant_bits, key_packed_size):
469 + """Get cached layout constants."""
470 + key = (D, mse_bits, value_quant_bits, key_packed_size)
471 + cfg = _layout_cache.get(key)
472 + if cfg is None:
473 + val_data_bytes = math.ceil(D * value_quant_bits / 8)
474 + cfg = {
475 + "mse_bytes": math.ceil(D * mse_bits / 8),
476 + "val_data_bytes": val_data_bytes,
477 + "mse_bits": mse_bits,
478 + "n_centroids": 2**mse_bits,
479 + "BLOCK_D": triton.next_power_of_2(D),
480 + }
481 + _layout_cache[key] = cfg
482 + return cfg
483 +
484 +
485 +def triton_turboquant_decode_attention(
486 + query: torch.Tensor, # [B, Hq, D] — original query
487 + kv_cache: torch.Tensor, # [num_blocks, block_size, Hk, padded_slot] uint8
488 + block_table: torch.Tensor, # [B, max_num_blocks] int32
489 + seq_lens: torch.Tensor, # [B] int32
490 + Pi: torch.Tensor, # [D, D] float32
491 + centroids: torch.Tensor, # [n_centroids] float32
492 + scale: float,
493 + mse_bits: int,
494 + key_packed_size: int,
495 + value_quant_bits: int,
496 + key_fp8: bool = False,
497 + norm_correction: bool = False,
498 + PiT: torch.Tensor | None = None, # [D, D] pre-computed Pi.T contiguous
499 + # Pre-allocated buffers (optional, avoids per-call allocation)
500 + mid_o_buf: torch.Tensor | None = None,
501 + output_buf: torch.Tensor | None = None,
502 + lse_buf: torch.Tensor | None = None,
503 + buf_holder: Any = None,
504 + max_num_kv_splits: int = 32, # fixed split count (must be constant for cudagraph)
505 + sinks: torch.Tensor | None = None, # [Hq] float32, pre-computed sink logits
506 +) -> torch.Tensor:
507 + """Launch fused TQ decode attention (Triton stage1 + stage2).
508 +
509 + Returns: output tensor [B, Hq, D] in query's dtype.
510 + """
511 + B, Hq, D = query.shape
512 + Hk = kv_cache.shape[2]
513 + block_size = kv_cache.shape[1]
514 + kv_group_size = Hq // Hk
515 + device = query.device
516 +
517 + cfg = _get_layout(D, mse_bits, value_quant_bits, key_packed_size)
518 +
519 + # Opt#3 SoA layout constants (derived locally; match store-side values).
520 + key_data_bytes = D if key_fp8 else cfg["mse_bytes"]
521 + data_bytes_per_slot = key_data_bytes + cfg["val_data_bytes"]
522 + meta_region_offset = block_size * Hk * data_bytes_per_slot
523 + num_soa_fields = 2 if key_fp8 else 3
524 + soa_k_norm = 0
525 + soa_v_scale = 0 if key_fp8 else 1
526 + soa_v_zero = 1 if key_fp8 else 2
527 + kv_cache_u16 = kv_cache.view(torch.uint16)
528 +
529 + # Compute q_rot = q @ Pi.T (rotated query for MSE key scoring)
530 + # FP8 path: pass query directly (float16); kernel casts inline.
531 + # MSE path: still needs external GEMM (cuBLAS), so q_rot is float32.
532 + if key_fp8:
533 + q_rot = query.contiguous()
534 + else:
535 + q_float = query.float()
536 + if PiT is None:
537 + PiT = Pi.T.contiguous()
538 + q_rot = (q_float @ PiT).contiguous()
539 +
540 + NUM_KV_SPLITS = max_num_kv_splits
541 +
542 + if (
543 + mid_o_buf is not None
544 + and mid_o_buf.shape[0] >= B
545 + and mid_o_buf.shape[2] >= NUM_KV_SPLITS
546 + ):
547 + mid_o = mid_o_buf[:B, :Hq, :NUM_KV_SPLITS, :]
548 + else:
549 + mid_o = torch.empty(
550 + B,
551 + Hq,
552 + NUM_KV_SPLITS,
553 + D + 1,
554 + dtype=torch.float32,
555 + device=device,
556 + )
557 + if buf_holder is not None:
558 + buf_holder._tq_mid_o_buf = mid_o
559 +
560 + # Stage 1: split-KV tiled attention scoring + value accumulation
561 + fp8_e4b15 = _use_fp8_e4b15(device.index or 0)
562 + BLOCK_KV = 4
563 + grid = (B, Hq, NUM_KV_SPLITS)
564 +
565 + # Prepare sink pointer (convert to float32 on device if provided)
566 + use_sinks = sinks is not None
567 +
568 + _tq_decode_stage1[grid](
569 + q_rot,
570 + kv_cache,
571 + kv_cache_u16,
572 + block_table,
573 + seq_lens,
574 + centroids,
575 + mid_o,
576 + sinks,
577 + q_rot.stride(0),
578 + q_rot.stride(1),
579 + kv_cache.stride(0),
580 + block_table.stride(0),
581 + mid_o.stride(0),
582 + mid_o.stride(1),
583 + mid_o.stride(2),
584 + NUM_KV_HEADS=Hk,
585 + HEAD_DIM=D,
586 + BLOCK_SIZE=block_size,
587 + NUM_KV_SPLITS=NUM_KV_SPLITS,
588 + KV_GROUP_SIZE=kv_group_size,
589 + MSE_BITS=mse_bits,
590 + MSE_BYTES=cfg["mse_bytes"],
591 + VQB=value_quant_bits,
592 + VAL_DATA_BYTES=cfg["val_data_bytes"],
593 + KEY_DATA_BYTES=key_data_bytes,
594 + META_REGION_OFFSET=meta_region_offset,
595 + NUM_SOA_FIELDS=num_soa_fields,
596 + SOA_K_NORM=soa_k_norm,
597 + SOA_V_SCALE=soa_v_scale,
598 + SOA_V_ZERO=soa_v_zero,
599 + ATTN_SCALE=scale,
600 + BLOCK_D=cfg["BLOCK_D"],
601 + BLOCK_KV=BLOCK_KV,
602 + KEY_FP8=1 if key_fp8 else 0,
603 + NORM_CORRECTION=1 if norm_correction else 0,
604 + FP8_E4B15=fp8_e4b15,
605 + USE_SINKS=1 if use_sinks else 0,
606 + num_warps=1,
607 + num_stages=1,
608 + )
609 +
610 + # Stage 2: Reduce across KV splits
611 + if output_buf is not None and output_buf.shape[0] >= B:
612 + output = output_buf[:B, :Hq, :D]
613 + else:
614 + output = torch.empty(B, Hq, D, dtype=torch.float32, device=device)
615 + if buf_holder is not None:
616 + buf_holder._tq_output_buf = output
617 + if lse_buf is not None and lse_buf.shape[0] >= B:
618 + lse = lse_buf[:B, :Hq]
619 + else:
620 + lse = torch.empty(B, Hq, dtype=torch.float32, device=device)
621 + if buf_holder is not None:
622 + buf_holder._tq_lse_buf = lse
623 +
624 + grid2 = (B, Hq)
625 + _fwd_kernel_stage2[grid2](
626 + mid_o,
627 + output,
628 + lse,
629 + seq_lens,
630 + mid_o.stride(0),
631 + mid_o.stride(1),
632 + mid_o.stride(2),
633 + output.stride(0),
634 + output.stride(1),
635 + lse.stride(0),
636 + NUM_KV_SPLITS=NUM_KV_SPLITS,
637 + BLOCK_DV=cfg["BLOCK_D"],
638 + Lv=D,
639 + num_warps=4,
640 + num_stages=2,
641 + )
642 +
643 + return output.to(query.dtype)
vllm/v1/attention/ops/turboquant_soa/triton_turboquant_decode_v2.py new
+669
@@ -0,0 +1,669 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Optimized Triton TurboQuant decode attention (v2).
4 +
5 +FLUTE-paper optimizations applied:
6 + 1. Grouped Q heads: grid over (B, Hk, splits) instead of (B, Hq, splits).
7 + Each program loads BLOCK_M Q heads sharing a KV head into a 2D tile,
8 + enabling tl.dot on tensor cores (MFMA/WMMA) for both Q·K and P·V.
9 + 2. Vectorized pair LUT: precompute pair_table[i][j] = (T[i], T[j])
10 + offline. At runtime, extract adjacent index pairs and fetch two
11 + dequantized centroids with a single gather, halving LUT lookups.
12 + 3. exp2 instead of exp: scores pre-scaled by log2(e) so the hardware-
13 + native exp2 instruction replaces the more expensive exp.
14 + 4. Wider index extraction: for 4-bit MSE, two adjacent 4-bit indices share
15 + a byte. One byte load yields both, eliminating redundant loads.
16 + 5. Centroids pre-warmed in L1 at kernel start.
17 + 6. BLOCK_KV = TILE_SIZE raised to 16-32 (from 4), reducing loop iterations
18 + and softmax rescaling overhead.
19 +
20 +Stage 2 is reused unchanged from triton_decode_attention.py.
21 +"""
22 +
23 +import math
24 +from typing import Any
25 +
26 +import torch
27 +
28 +from vllm.platforms import current_platform
29 +from vllm.triton_utils import tl, triton
30 +from vllm.v1.attention.ops.triton_decode_attention import _fwd_kernel_stage2
31 +
32 +from .triton_turboquant_decode import _use_fp8_e4b15
33 +
34 +# ROCm prefers num_stages=1 in attention-like kernels to reduce shared-memory
35 +# pressure (mirrors the pattern used in triton_decode_attention.py and
36 +# triton_turboquant_decode.py).
37 +_is_hip = current_platform.is_rocm()
38 +
39 +# On ROCm, bf16 has the same MFMA throughput as fp16 but wider dynamic range
40 +# (8-bit exponent vs 5-bit), which is safer for attention scores. On CUDA,
41 +# fp16 tensor cores may be faster than bf16 for some shapes.
42 +_DOT_DTYPE = tl.bfloat16 if _is_hip else tl.float16
43 +
44 +
45 +# ---------------------------------------------------------------------------
46 +# Pair LUT construction (FLUTE) — called once at launcher time
47 +# ---------------------------------------------------------------------------
48 +
49 +
50 +def build_pair_lut(centroids: torch.Tensor) -> torch.Tensor:
51 + """Build vectorized pair lookup table.
52 +
53 + For N centroids, returns a [N, N, 2] float32 tensor where
54 + pair_lut[i, j] = (centroids[i], centroids[j]).
55 + Flattened to [N*N, 2] for kernel indexing: pair_lut[i*N + j].
56 +
57 + For 4-bit MSE (N=16): 16*16*2*4 = 2048 bytes — fits in L1/smem.
58 + For 3-bit MSE (N=8): 8*8*2*4 = 512 bytes.
59 + """
60 + N = centroids.shape[0]
61 + # pair_lut[i,j,0] = centroids[i], pair_lut[i,j,1] = centroids[j]
62 + c = centroids.float()
63 + lut = torch.empty(N, N, 2, dtype=torch.float32, device=centroids.device)
64 + lut[:, :, 0] = c[:, None]
65 + lut[:, :, 1] = c[None, :]
66 + return lut.reshape(N * N, 2).contiguous()
67 +
68 +
69 +# ---------------------------------------------------------------------------
70 +# Stage 1 v2: Grouped-Q + pair LUT + tl.dot + exp2
71 +# ---------------------------------------------------------------------------
72 +
73 +
74 +@triton.jit
75 +def _tq_decode_stage1_v2(
76 + Q_rot_ptr,
77 + KV_cache_ptr,
78 + KV_cache_u16_ptr, # uint16 view — Opt#3 SoA loads
79 + Block_table_ptr,
80 + Seq_lens_ptr,
81 + Centroids_ptr,
82 + Pair_lut_ptr,
83 + Mid_o_ptr,
84 + stride_qb,
85 + stride_qh,
86 + stride_cache_block, # bytes per block (bs*H*slot_aligned)
87 + stride_bt_b,
88 + stride_mid_b,
89 + stride_mid_h,
90 + stride_mid_s,
91 + NUM_Q_HEADS: tl.constexpr,
92 + NUM_KV_HEADS: tl.constexpr,
93 + HEAD_DIM: tl.constexpr,
94 + BLOCK_SIZE: tl.constexpr,
95 + PADDED_SLOT: tl.constexpr,
96 + MAX_NUM_BLOCKS: tl.constexpr,
97 + NUM_KV_SPLITS: tl.constexpr,
98 + KV_GROUP_SIZE: tl.constexpr,
99 + MSE_BITS: tl.constexpr,
100 + MSE_BYTES: tl.constexpr,
101 + VQB: tl.constexpr,
102 + VAL_DATA_BYTES: tl.constexpr,
103 + # Opt#3 SoA layout
104 + KEY_DATA_BYTES: tl.constexpr,
105 + META_REGION_OFFSET: tl.constexpr,
106 + NUM_SOA_FIELDS: tl.constexpr,
107 + SOA_K_NORM: tl.constexpr,
108 + SOA_V_SCALE: tl.constexpr,
109 + SOA_V_ZERO: tl.constexpr,
110 + N_CENTROIDS: tl.constexpr,
111 + ATTN_SCALE: tl.constexpr,
112 + BLOCK_D: tl.constexpr,
113 + TILE_SIZE: tl.constexpr,
114 + BLOCK_M: tl.constexpr,
115 + KEY_FP8: tl.constexpr,
116 + NORM_CORRECTION: tl.constexpr = 0,
117 + FP8_E4B15: tl.constexpr = 0,
118 + USE_PAIR_LUT: tl.constexpr = 0,
119 + USE_BF16_DOT: tl.constexpr = 0,
120 +):
121 + # Grid: (B, Hk, NUM_KV_SPLITS) — one program per KV head group
122 + bid = tl.program_id(0)
123 + kv_hid = tl.program_id(1)
124 + sid = tl.program_id(2)
125 +
126 + seq_len = tl.load(Seq_lens_ptr + bid)
127 + split_len = tl.cdiv(seq_len, NUM_KV_SPLITS)
128 + split_start = split_len * sid
129 + split_end = tl.minimum(split_start + split_len, seq_len)
130 +
131 + if split_start >= split_end:
132 + return
133 +
134 + # Dimension and tile offsets
135 + d_offs = tl.arange(0, BLOCK_D)
136 + d_mask = d_offs < HEAD_DIM
137 + m_offs = tl.arange(0, BLOCK_M)
138 + t_offs = tl.arange(0, TILE_SIZE)
139 +
140 + # Q head indices for this KV group: [kv_hid * GS, kv_hid * GS + GS)
141 + q_head_offs = kv_hid * KV_GROUP_SIZE + m_offs
142 + q_mask = m_offs < KV_GROUP_SIZE
143 +
144 + # Load Q tile: [BLOCK_M, BLOCK_D] float32
145 + q_addrs = bid * stride_qb + q_head_offs[:, None] * stride_qh + d_offs[None, :]
146 + Q = tl.load(
147 + Q_rot_ptr + q_addrs,
148 + mask=q_mask[:, None] & d_mask[None, :],
149 + other=0.0,
150 + )
151 +
152 + # Pre-warm centroids in L1
153 + if not KEY_FP8:
154 + _c_warm = tl.load(Centroids_ptr + tl.arange(0, N_CENTROIDS)) # noqa: F841
155 +
156 + # Loop-invariant bit-extraction constants for MSE keys
157 + if not KEY_FP8:
158 + if MSE_BITS == 4:
159 + pass # 4-bit byte extraction is computed inline in the tile loop
160 + else:
161 + # Generic bit extraction
162 + mse_bit_off = d_offs * MSE_BITS
163 + mse_byte_idx = mse_bit_off // 8
164 + mse_bit_shift = mse_bit_off % 8
165 + mse_mask_val = (1 << MSE_BITS) - 1
166 +
167 + # Loop-invariant bit-extraction for 3-bit values
168 + if VQB == 3:
169 + val_bit_off = d_offs * 3
170 + val_byte_idx = val_bit_off // 8
171 + val_bit_shift = val_bit_off % 8
172 +
173 + # exp2 pre-scaling
174 + RCP_LN2: tl.constexpr = 1.4426950408889634
175 + LN2: tl.constexpr = 0.6931471805599453
176 + QK_SCALE = ATTN_SCALE * RCP_LN2
177 +
178 + # Online softmax accumulators: [BLOCK_M] for max, [BLOCK_M] for L
179 + M = tl.full([BLOCK_M], float("-inf"), dtype=tl.float32)
180 + L = tl.zeros([BLOCK_M], dtype=tl.float32)
181 +
182 + acc = tl.zeros([BLOCK_M, BLOCK_D], dtype=tl.float32)
183 +
184 + bt_base = bid * stride_bt_b
185 +
186 + # ================================================================
187 + # TILED LOOP: TILE_SIZE KV tokens per iteration
188 + # ================================================================
189 + for start_n in range(split_start, split_end, TILE_SIZE):
190 + kv_offs = start_n + t_offs
191 + kv_mask_1d = kv_offs < split_end
192 +
193 + page_idx = kv_offs // BLOCK_SIZE
194 + page_off = kv_offs % BLOCK_SIZE
195 + block_nums = tl.load(
196 + Block_table_ptr + bt_base + page_idx,
197 + mask=kv_mask_1d,
198 + other=0,
199 + ).to(tl.int64)
200 +
201 + # Opt#3 SoA addressing: data region then per-block SoA metadata.
202 + slot_within_block = page_off.to(tl.int64)
203 + block_base = block_nums * stride_cache_block
204 + DATA_BYTES_PER_SLOT: tl.constexpr = KEY_DATA_BYTES + VAL_DATA_BYTES
205 + data_bases = (
206 + block_base
207 + + slot_within_block * (NUM_KV_HEADS * DATA_BYTES_PER_SLOT)
208 + + tl.cast(kv_hid, tl.int64) * DATA_BYTES_PER_SLOT
209 + )
210 + head_meta_u16_base = (block_base + META_REGION_OFFSET) // 2 + tl.cast(
211 + kv_hid, tl.int64
212 + ) * (NUM_SOA_FIELDS * BLOCK_SIZE)
213 + knorm_u16_addrs = (
214 + head_meta_u16_base + SOA_K_NORM * BLOCK_SIZE + slot_within_block
215 + )
216 + vscale_u16_addrs = (
217 + head_meta_u16_base + SOA_V_SCALE * BLOCK_SIZE + slot_within_block
218 + )
219 + vzero_u16_addrs = (
220 + head_meta_u16_base + SOA_V_ZERO * BLOCK_SIZE + slot_within_block
221 + )
222 +
223 + # ============================================================
224 + # KEY DEQUANT → K_T: [BLOCK_D, TILE_SIZE] for tl.dot(Q, K_T)
225 + # ============================================================
226 + if KEY_FP8:
227 + k_addrs = data_bases[:, None] + d_offs[None, :]
228 + k_raw = tl.load(
229 + KV_cache_ptr + k_addrs,
230 + mask=kv_mask_1d[:, None] & d_mask[None, :],
231 + other=0,
232 + )
233 + if FP8_E4B15:
234 + K_f = k_raw.to(tl.float8e4b15, bitcast=True).to(tl.float32)
235 + else:
236 + K_f = k_raw.to(tl.float8e4nv, bitcast=True).to(tl.float32)
237 + # K_f: [TILE_SIZE, BLOCK_D] → transpose for tl.dot
238 + K_T = tl.trans(K_f)
239 + # S: [BLOCK_M, TILE_SIZE] (bf16 dot on ROCm for better dynamic range)
240 + if USE_BF16_DOT:
241 + S = QK_SCALE * tl.dot(Q.to(tl.bfloat16), K_T.to(tl.bfloat16))
242 + else:
243 + S = QK_SCALE * tl.dot(Q.to(tl.float16), K_T.to(tl.float16))
244 + else:
245 + # --- MSE dequantization ---
246 + if MSE_BITS == 4 and USE_PAIR_LUT:
247 + # OPT#1 (load-halving): FLUTE §3.2 pair LUT, but with each
248 + # packed byte loaded EXACTLY ONCE instead of twice (the
249 + # previous code built byte_addrs from d_offs // 2 which has
250 + # the pattern [0,0,1,1,2,2,...], duplicating every byte
251 + # load). We load the packed bytes as a [TILE_SIZE, HALF_D]
252 + # tile, decode both nibbles, then issue a single 3-D gather
253 + # against pair_lut which returns (T[lo], T[hi]) in slots
254 + # 0/1. Reshape [TILE_SIZE, HALF_D, 2] -> [TILE_SIZE, BLOCK_D]
255 + # interleaves the pairs into the expected dim order
256 + # (d=2k -> T[lo_k], d=2k+1 -> T[hi_k]).
257 + HALF_D: tl.constexpr = BLOCK_D // 2
258 + half_offs = tl.arange(0, HALF_D)
259 + # Each byte k covers dims (2k, 2k+1); it is in-range iff
260 + # 2k < HEAD_DIM. HEAD_DIM is always even in our layouts,
261 + # so this is equivalent to k < HEAD_DIM // 2.
262 + byte_mask = (half_offs * 2) < HEAD_DIM
263 +
264 + byte_addrs = data_bases[:, None] + half_offs[None, :]
265 + byte_raw = tl.load(
266 + KV_cache_ptr + byte_addrs,
267 + mask=kv_mask_1d[:, None] & byte_mask[None, :],
268 + other=0,
269 + ).to(tl.int32)
270 +
271 + lo_idx = byte_raw & 0xF # [TILE_SIZE, HALF_D]
272 + hi_idx = (byte_raw >> 4) & 0xF # [TILE_SIZE, HALF_D]
273 + pair_key = lo_idx * N_CENTROIDS + hi_idx # [TILE_SIZE, HALF_D]
274 +
275 + # Single 3-D gather: pair_lut[pair_key*2 + {0,1}]
276 + # -> [TILE_SIZE, HALF_D, 2] = (T[lo_k], T[hi_k]) per element.
277 + pair_slot = tl.arange(0, 2)
278 + c_pair = tl.load(
279 + Pair_lut_ptr + pair_key[:, :, None] * 2 + pair_slot[None, None, :],
280 + mask=(kv_mask_1d[:, None, None] & byte_mask[None, :, None]),
281 + other=0.0,
282 + )
283 + # Row-major reshape interleaves pairs:
284 + # out[t, 2k] = c_pair[t, k, 0] = T[lo_k]
285 + # out[t, 2k+1] = c_pair[t, k, 1] = T[hi_k]
286 + c_vals = tl.reshape(c_pair, [TILE_SIZE, BLOCK_D])
287 + elif MSE_BITS == 4:
288 + # 4-bit without pair LUT: exploit byte alignment
289 + half_idx = d_offs // 2
290 + nibble_shift = (d_offs % 2) * 4
291 + mse_addrs = data_bases[:, None] + half_idx[None, :]
292 + mse_raw = tl.load(
293 + KV_cache_ptr + mse_addrs,
294 + mask=kv_mask_1d[:, None] & d_mask[None, :],
295 + other=0,
296 + ).to(tl.int32)
297 + mse_idx = (mse_raw >> nibble_shift[None, :]) & 0xF
298 + c_vals = tl.load(
299 + Centroids_ptr + mse_idx,
300 + mask=kv_mask_1d[:, None] & d_mask[None, :],
301 + other=0.0,
302 + )
303 + else:
304 + # Generic 3-bit path: two byte loads → 16-bit → shift/mask
305 + mse_addrs0 = data_bases[:, None] + mse_byte_idx[None, :]
306 + mse_raw0 = tl.load(
307 + KV_cache_ptr + mse_addrs0,
308 + mask=kv_mask_1d[:, None] & d_mask[None, :],
309 + other=0,
310 + ).to(tl.int32)
311 + mse_raw1 = tl.load(
312 + KV_cache_ptr + mse_addrs0 + 1,
313 + mask=kv_mask_1d[:, None] & d_mask[None, :],
314 + other=0,
315 + ).to(tl.int32)
316 + raw16 = mse_raw0 | (mse_raw1 << 8)
317 + mse_idx = (raw16 >> mse_bit_shift[None, :]) & mse_mask_val
318 + c_vals = tl.load(
319 + Centroids_ptr + mse_idx,
320 + mask=kv_mask_1d[:, None] & d_mask[None, :],
321 + other=0.0,
322 + )
323 +
324 + # Opt#1: norm-correction is pre-folded into the stored per-token
325 + # scalar at store time, so no per-tile sum+sqrt+divide here. The
326 + # stored vec_norm carries ||k||/||c_vec||; multiply by c_vals
327 + # below gives the original c_vec/||c_vec|| * ||k||.
328 +
329 + # Opt#3: K-norms from per-block SoA region. Single u16 load per
330 + # token; contiguous across tokens within a block.
331 + norm_u16 = tl.load(
332 + KV_cache_u16_ptr + knorm_u16_addrs, mask=kv_mask_1d, other=0
333 + )
334 + vec_norms = norm_u16.to(tl.float16, bitcast=True).to(tl.float32)
335 +
336 + # Reconstruct K: K_recon[t, d] = norm[t] * centroid[t, d]
337 + K_recon = c_vals * vec_norms[:, None]
338 + # K_T: [BLOCK_D, TILE_SIZE]
339 + K_T = tl.trans(K_recon)
340 +
341 + # S: [BLOCK_M, TILE_SIZE] via tensor core MMA
342 + if USE_BF16_DOT:
343 + S = QK_SCALE * tl.dot(Q.to(tl.bfloat16), K_T.to(tl.bfloat16))
344 + else:
345 + S = QK_SCALE * tl.dot(Q.to(tl.float16), K_T.to(tl.float16))
346 +
347 + # Mask out-of-range positions
348 + S = tl.where(kv_mask_1d[None, :], S, float("-inf"))
349 +
350 + # ============================================================
351 + # ONLINE SOFTMAX with exp2
352 + # ============================================================
353 + m_j = tl.maximum(M, tl.max(S, axis=1))
354 + m_j = tl.where(m_j > float("-inf"), m_j, 0.0)
355 + P = tl.math.exp2(S - m_j[:, None])
356 + l_j = tl.sum(P, axis=1)
357 + alpha = tl.math.exp2(M - m_j)
358 +
359 + acc = acc * alpha[:, None]
360 + L = L * alpha + l_j
361 + M = m_j
362 +
363 + # ============================================================
364 + # VALUE DEQUANT → V: [TILE_SIZE, BLOCK_D] for tl.dot(P, V)
365 + # Opt#3: V data lives at data_base + KEY_DATA_BYTES; scale/zero
366 + # move to the SoA metadata region (one u16 load each per token).
367 + # ============================================================
368 + val_bases = data_bases + KEY_DATA_BYTES
369 +
370 + if VQB == 3:
371 + val_addrs0 = val_bases[:, None] + val_byte_idx[None, :]
372 + val_raw0 = tl.load(
373 + KV_cache_ptr + val_addrs0,
374 + mask=kv_mask_1d[:, None] & d_mask[None, :],
375 + other=0,
376 + ).to(tl.int32)
377 + val_raw1 = tl.load(
378 + KV_cache_ptr + val_addrs0 + 1,
379 + mask=kv_mask_1d[:, None] & d_mask[None, :],
380 + other=0,
381 + ).to(tl.int32)
382 + raw16 = val_raw0 | (val_raw1 << 8)
383 + v_idx = ((raw16 >> val_bit_shift[None, :]) & 0x7).to(tl.float32)
384 + else: # VQB == 4
385 + # OPT#3 (load-halving, value path): same pattern as OPT#1 for
386 + # keys -- load each byte exactly once as [TILE_SIZE, HALF_D],
387 + # decode both nibbles, then tl.interleave to [TILE_SIZE, BLOCK_D]
388 + # with pattern [v_lo0, v_hi0, v_lo1, v_hi1, ...].
389 + V_HALF_D: tl.constexpr = BLOCK_D // 2
390 + v_half_offs = tl.arange(0, V_HALF_D)
391 + v_byte_mask = (v_half_offs * 2) < HEAD_DIM
392 +
393 + val_addrs = val_bases[:, None] + v_half_offs[None, :]
394 + val_byte = tl.load(
395 + KV_cache_ptr + val_addrs,
396 + mask=kv_mask_1d[:, None] & v_byte_mask[None, :],
397 + other=0,
398 + ).to(tl.int32)
399 + v_lo = (val_byte & 0xF).to(tl.float32) # [T, HALF_D]
400 + v_hi = ((val_byte >> 4) & 0xF).to(tl.float32) # [T, HALF_D]
401 + v_idx = tl.interleave(v_lo, v_hi) # [T, BLOCK_D]
402 +
403 + scale_u16 = tl.load(
404 + KV_cache_u16_ptr + vscale_u16_addrs, mask=kv_mask_1d, other=0
405 + )
406 + zero_u16 = tl.load(KV_cache_u16_ptr + vzero_u16_addrs, mask=kv_mask_1d, other=0)
407 + v_scales = scale_u16.to(tl.float16, bitcast=True).to(tl.float32)
408 + v_zeros = zero_u16.to(tl.float16, bitcast=True).to(tl.float32)
409 + V = v_idx * v_scales[:, None] + v_zeros[:, None]
410 +
411 + # P·V accumulation via tensor core MMA
412 + # P: [BLOCK_M, TILE_SIZE], V: [TILE_SIZE, BLOCK_D]
413 + if USE_BF16_DOT:
414 + acc += tl.dot(P.to(tl.bfloat16), V.to(tl.bfloat16))
415 + else:
416 + acc += tl.dot(P.to(tl.float16), V.to(tl.float16))
417 +
418 + # ================================================================
419 + # EPILOGUE: Store per-Q-head partial results for stage2
420 + # ================================================================
421 + # acc: [BLOCK_M, BLOCK_D], L: [BLOCK_M], M: [BLOCK_M]
422 + # Stage2 expects per-Q-head: mid_o[B, Hq, split, D+1]
423 + safe_L = tl.where(L > 0.0, L, 1.0)
424 + one_over_L = 1.0 / safe_L
425 + acc = acc * one_over_L[:, None]
426 +
427 + # Convert M from log2 space back to natural log for stage2 compatibility
428 + lse = M * LN2 + tl.log(safe_L)
429 +
430 + # Store each Q head's result using 2D scatter
431 + # q_head_offs: [BLOCK_M] = kv_hid * KV_GROUP_SIZE + m_offs
432 + out_addrs = (
433 + bid * stride_mid_b
434 + + q_head_offs[:, None] * stride_mid_h
435 + + sid * stride_mid_s
436 + + d_offs[None, :]
437 + )
438 + tl.store(
439 + Mid_o_ptr + out_addrs,
440 + acc,
441 + mask=q_mask[:, None] & d_mask[None, :],
442 + )
443 + # Store LSE per Q head
444 + lse_addrs = (
445 + bid * stride_mid_b + q_head_offs * stride_mid_h + sid * stride_mid_s + HEAD_DIM
446 + )
447 + tl.store(Mid_o_ptr + lse_addrs, lse, mask=q_mask)
448 +
449 +
450 +# ---------------------------------------------------------------------------
451 +# Launcher v2
452 +# ---------------------------------------------------------------------------
453 +
454 +_layout_cache: dict = {}
455 +
456 +
457 +def _get_layout(D, mse_bits, value_quant_bits, key_packed_size):
458 + key = (D, mse_bits, value_quant_bits, key_packed_size)
459 + cfg = _layout_cache.get(key)
460 + if cfg is None:
461 + val_data_bytes = math.ceil(D * value_quant_bits / 8)
462 + cfg = {
463 + "mse_bytes": math.ceil(D * mse_bits / 8),
464 + "val_data_bytes": val_data_bytes,
465 + "mse_bits": mse_bits,
466 + "n_centroids": 2**mse_bits,
467 + "BLOCK_D": triton.next_power_of_2(D),
468 + }
469 + _layout_cache[key] = cfg
470 + return cfg
471 +
472 +
473 +def _get_pair_lut(centroids: torch.Tensor) -> torch.Tensor:
474 + """Return a fresh pair-LUT for ``centroids`` on each call.
475 +
476 + The LUT is tiny (N*N*2 fp32, e.g. 2KB for 4-bit MSE) so the build cost
477 + is negligible compared to attention. We avoid caching by data_ptr()
478 + because CUDA allocator memory reuse across different centroid tensors
479 + can silently return a stale LUT (subtle correctness bug). If this ever
480 + shows up on a profile, cache by a hash-of-values fingerprint instead.
481 + """
482 + return build_pair_lut(centroids)
483 +
484 +
485 +def triton_turboquant_decode_attention_v2(
486 + query: torch.Tensor,
487 + kv_cache: torch.Tensor,
488 + block_table: torch.Tensor,
489 + seq_lens: torch.Tensor,
490 + Pi: torch.Tensor,
491 + centroids: torch.Tensor,
492 + scale: float,
493 + mse_bits: int,
494 + key_packed_size: int,
495 + value_quant_bits: int,
496 + value_packed_size: int,
497 + max_seq_len: int = 0, # unused; kept for backward compatibility
498 + key_fp8: bool = False,
499 + norm_correction: bool = False,
500 + PiT: torch.Tensor | None = None,
501 + # Pre-allocated buffers (optional, required for CUDA-graph stability).
502 + mid_o_buf: torch.Tensor | None = None,
503 + output_buf: torch.Tensor | None = None,
504 + lse_buf: torch.Tensor | None = None,
505 + buf_holder: Any = None,
506 + # Fixed split count — MUST be a compile-time constant across iterations
507 + # for CUDA-graph capture/replay to work. Mirrors the v1 launcher contract.
508 + max_num_kv_splits: int = 32,
509 +) -> torch.Tensor:
510 + """Launch optimized TQ decode attention (v2 stage1 + shared stage2).
511 +
512 + Follows the same buffer-reuse + fixed-grid contract as the v1 launcher
513 + (triton_turboquant_decode_attention) so the backend can capture a CUDA
514 + graph across both versions.
515 + """
516 + B, Hq, D = query.shape
517 + Hk = kv_cache.shape[2]
518 + block_size = kv_cache.shape[1]
519 + padded_slot = kv_cache.shape[3]
520 + max_num_blocks = block_table.shape[1]
521 + n_centroids = centroids.shape[0]
522 + kv_group_size = Hq // Hk
523 + device = query.device
524 + del max_seq_len # no longer used: splits is fixed via max_num_kv_splits
525 +
526 + cfg = _get_layout(D, mse_bits, value_quant_bits, key_packed_size)
527 +
528 + # Opt#3 SoA layout constants (match store-side computation).
529 + key_data_bytes = D if key_fp8 else cfg["mse_bytes"]
530 + data_bytes_per_slot = key_data_bytes + cfg["val_data_bytes"]
531 + meta_region_offset = block_size * Hk * data_bytes_per_slot
532 + num_soa_fields = 2 if key_fp8 else 3
533 + soa_k_norm = 0
534 + soa_v_scale = 0 if key_fp8 else 1
535 + soa_v_zero = 1 if key_fp8 else 2
536 + kv_cache_u16 = kv_cache.view(torch.uint16)
537 +
538 + # Compute q_rot = q @ Pi.T
539 + if key_fp8:
540 + q_rot = query.float().contiguous()
541 + else:
542 + q_float = query.float()
543 + if PiT is None:
544 + PiT = Pi.T.contiguous()
545 + q_rot = (q_float @ PiT).contiguous()
546 +
547 + # BLOCK_M: pad KV_GROUP_SIZE to power of 2, minimum 16 for tensor cores
548 + BLOCK_M = max(16, triton.next_power_of_2(kv_group_size))
549 +
550 + # TILE_SIZE (BLOCK_KV): tokens per inner-loop iteration
551 + TILE_SIZE = 16
552 +
553 + # Fixed split count — must be constant across calls for cudagraph replay.
554 + NUM_KV_SPLITS = max_num_kv_splits
555 +
556 + # --- mid_o buffer reuse (same pattern as v1 launcher) ---
557 + if (
558 + mid_o_buf is not None
559 + and mid_o_buf.shape[0] >= B
560 + and mid_o_buf.shape[2] >= NUM_KV_SPLITS
561 + ):
562 + mid_o = mid_o_buf[:B, :Hq, :NUM_KV_SPLITS, :]
563 + else:
564 + mid_o = torch.empty(
565 + B,
566 + Hq,
567 + NUM_KV_SPLITS,
568 + D + 1,
569 + dtype=torch.float32,
570 + device=device,
571 + )
572 + if buf_holder is not None:
573 + buf_holder._tq_mid_o_buf = mid_o
574 +
575 + fp8_e4b15 = _use_fp8_e4b15(device.index or 0)
576 +
577 + # Build pair LUT for 4-bit MSE (FLUTE §3.2)
578 + use_pair_lut = mse_bits == 4 and not key_fp8
579 + pair_lut = _get_pair_lut(centroids) if use_pair_lut else centroids
580 +
581 + # Platform-dependent pipelining depth: ROCm prefers num_stages=1.
582 + stage1_num_stages = 1 if _is_hip else 2
583 +
584 + # --- Stage 1: v2 kernel ---
585 + # Grid over KV heads (not Q heads) — each program handles all Q heads
586 + grid = (B, Hk, NUM_KV_SPLITS)
587 + _tq_decode_stage1_v2[grid](
588 + q_rot,
589 + kv_cache,
590 + kv_cache_u16,
591 + block_table,
592 + seq_lens,
593 + centroids,
594 + pair_lut,
595 + mid_o,
596 + q_rot.stride(0),
597 + q_rot.stride(1),
598 + kv_cache.stride(0),
599 + block_table.stride(0),
600 + mid_o.stride(0),
601 + mid_o.stride(1),
602 + mid_o.stride(2),
603 + NUM_Q_HEADS=Hq,
604 + NUM_KV_HEADS=Hk,
605 + HEAD_DIM=D,
606 + BLOCK_SIZE=block_size,
607 + PADDED_SLOT=padded_slot,
608 + MAX_NUM_BLOCKS=max_num_blocks,
609 + NUM_KV_SPLITS=NUM_KV_SPLITS,
610 + KV_GROUP_SIZE=kv_group_size,
611 + MSE_BITS=mse_bits,
612 + MSE_BYTES=cfg["mse_bytes"],
613 + VQB=value_quant_bits,
614 + VAL_DATA_BYTES=cfg["val_data_bytes"],
615 + KEY_DATA_BYTES=key_data_bytes,
616 + META_REGION_OFFSET=meta_region_offset,
617 + NUM_SOA_FIELDS=num_soa_fields,
618 + SOA_K_NORM=soa_k_norm,
619 + SOA_V_SCALE=soa_v_scale,
620 + SOA_V_ZERO=soa_v_zero,
621 + N_CENTROIDS=n_centroids,
622 + ATTN_SCALE=scale,
623 + BLOCK_D=cfg["BLOCK_D"],
624 + TILE_SIZE=TILE_SIZE,
625 + BLOCK_M=BLOCK_M,
626 + KEY_FP8=1 if key_fp8 else 0,
627 + NORM_CORRECTION=1 if norm_correction else 0,
628 + FP8_E4B15=fp8_e4b15,
629 + USE_PAIR_LUT=1 if use_pair_lut else 0,
630 + USE_BF16_DOT=1 if _is_hip else 0,
631 + num_warps=4,
632 + num_stages=stage1_num_stages,
633 + )
634 +
635 + # --- output / lse buffer reuse (same pattern as v1 launcher) ---
636 + if output_buf is not None and output_buf.shape[0] >= B:
637 + output = output_buf[:B, :Hq, :D]
638 + else:
639 + output = torch.empty(B, Hq, D, dtype=torch.float32, device=device)
640 + if buf_holder is not None:
641 + buf_holder._tq_output_buf = output
642 + if lse_buf is not None and lse_buf.shape[0] >= B:
643 + lse = lse_buf[:B, :Hq]
644 + else:
645 + lse = torch.empty(B, Hq, dtype=torch.float32, device=device)
646 + if buf_holder is not None:
647 + buf_holder._tq_lse_buf = lse
648 +
649 + # --- Stage 2: reduce across KV splits (unchanged) ---
650 + grid2 = (B, Hq)
651 + _fwd_kernel_stage2[grid2](
652 + mid_o,
653 + output,
654 + lse,
655 + seq_lens,
656 + mid_o.stride(0),
657 + mid_o.stride(1),
658 + mid_o.stride(2),
659 + output.stride(0),
660 + output.stride(1),
661 + lse.stride(0),
662 + NUM_KV_SPLITS=NUM_KV_SPLITS,
663 + BLOCK_DV=cfg["BLOCK_D"],
664 + Lv=D,
665 + num_warps=4,
666 + num_stages=2,
667 + )
668 +
669 + return output.to(query.dtype)
vllm/v1/attention/ops/turboquant_soa/triton_turboquant_store.py new
+524
@@ -0,0 +1,524 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Fused Triton kernels for TurboQuant KV store.
4 +
5 +Two kernels:
6 +1. _tq_fused_store_fp8: FP8 key scatter + value uniform quantization.
7 +2. _tq_fused_store_mse: Fused binary-search bucketize + MSE index
8 + packing + value quantization.
9 +
10 +The launcher `triton_turboquant_store` selects the appropriate kernel.
11 +"""
12 +
13 +import math
14 +
15 +import torch
16 +
17 +from vllm.triton_utils import tl, triton
18 +
19 +from .triton_turboquant_decode import _use_fp8_e4b15
20 +
21 +# ═══════════════════════════════════════════════════════════════════════
22 +# Shared: value uniform quantization + pack + SoA scale/zero store
23 +# ═══════════════════════════════════════════════════════════════════════
24 +
25 +
26 +@triton.jit
27 +def _store_quantized_value(
28 + Value_ptr,
29 + KV_cache_ptr,
30 + KV_cache_u16_ptr, # Opt#3: u16 view for SoA scale/zero stores
31 + base, # pid * D offset into Value_ptr
32 + data_base, # byte offset into KV_cache_ptr for this slot+head's DATA region
33 + vscale_u16_addr, # u16 element index for V-scale in the SoA region
34 + vzero_u16_addr, # u16 element index for V-zero in the SoA region
35 + d_offs, # tl.arange(0, BLOCK_D)
36 + d_mask, # d_offs < D
37 + D: tl.constexpr,
38 + # Offset (bytes) from data_base to V-data region within the slot's data.
39 + # For MSE keys this equals MSE_BYTES; for FP8 keys it equals D.
40 + V_DATA_OFFSET: tl.constexpr,
41 + VQB: tl.constexpr,
42 + VAL_DATA_BYTES: tl.constexpr,
43 + BLOCK_D: tl.constexpr,
44 + BLOCK_VAL: tl.constexpr,
45 + BLOCK_GRP: tl.constexpr,
46 +):
47 + """Uniform quantization of values to VQB bits, pack, and store with
48 + scale/zero in the per-block SoA metadata region (Opt#3 layout)."""
49 + if VQB == 3:
50 + val_vec = tl.load(Value_ptr + base + d_offs, mask=d_mask, other=0.0).to(
51 + tl.float32
52 + )
53 + val_min = tl.min(tl.where(d_mask, val_vec, float("inf")), axis=0)
54 + val_max = tl.max(tl.where(d_mask, val_vec, -float("inf")), axis=0)
55 + v_scale = (val_max - val_min) / 7.0
56 + v_scale = tl.where(v_scale > 1e-8, v_scale, 1e-8)
57 +
58 + q_vals = tl.minimum(
59 + tl.maximum(((val_vec - val_min) / v_scale + 0.5).to(tl.int32), 0), 7
60 + )
61 +
62 + grp_offs = tl.arange(0, BLOCK_GRP)
63 + grp_mask = grp_offs < (D // 8)
64 + q_grp = tl.reshape(q_vals, [BLOCK_GRP, 8])
65 + shifts_3bit = tl.arange(0, 8) * 3
66 + packed_24 = tl.sum(q_grp << shifts_3bit[None, :], axis=1)
67 + b0 = (packed_24 & 0xFF).to(tl.uint8)
68 + b1 = ((packed_24 >> 8) & 0xFF).to(tl.uint8)
69 + b2 = ((packed_24 >> 16) & 0xFF).to(tl.uint8)
70 + tl.store(
71 + KV_cache_ptr + data_base + V_DATA_OFFSET + grp_offs * 3,
72 + b0,
73 + mask=grp_mask,
74 + )
75 + tl.store(
76 + KV_cache_ptr + data_base + V_DATA_OFFSET + grp_offs * 3 + 1,
77 + b1,
78 + mask=grp_mask,
79 + )
80 + tl.store(
81 + KV_cache_ptr + data_base + V_DATA_OFFSET + grp_offs * 3 + 2,
82 + b2,
83 + mask=grp_mask,
84 + )
85 + else: # VQB == 4
86 + val_vec = tl.load(Value_ptr + base + d_offs, mask=d_mask, other=0.0).to(
87 + tl.float32
88 + )
89 + val_min = tl.min(tl.where(d_mask, val_vec, float("inf")), axis=0)
90 + val_max = tl.max(tl.where(d_mask, val_vec, -float("inf")), axis=0)
91 + v_scale = (val_max - val_min) / 15.0
92 + v_scale = tl.where(v_scale > 1e-8, v_scale, 1e-8)
93 +
94 + # Quantize all D elements from register (no re-load)
95 + q_all = tl.minimum(
96 + tl.maximum(((val_vec - val_min) / v_scale + 0.5).to(tl.int32), 0), 15
97 + )
98 + q_pairs = tl.reshape(q_all, [BLOCK_D // 2, 2])
99 + shifts_4 = tl.arange(0, 2) * 4
100 + packed_val = tl.sum((q_pairs & 0xF) << shifts_4[None, :], axis=1).to(tl.uint8)
101 + val_offs = tl.arange(0, BLOCK_D // 2)
102 + val_mask = val_offs < VAL_DATA_BYTES
103 + tl.store(
104 + KV_cache_ptr + data_base + V_DATA_OFFSET + val_offs,
105 + packed_val,
106 + mask=val_mask,
107 + )
108 +
109 + # ── SoA scale/zero store (Opt#3) ──────────────────────────────────
110 + # Scale and zero each get one fp16 stored at their token's slot index
111 + # within the per-head SoA region. Single u16 store per field vs the
112 + # previous 2×u8 writes per field.
113 + sc_u16 = v_scale.to(tl.float16).to(tl.uint16, bitcast=True)
114 + zr_u16 = val_min.to(tl.float16).to(tl.uint16, bitcast=True)
115 + tl.store(KV_cache_u16_ptr + vscale_u16_addr, sc_u16)
116 + tl.store(KV_cache_u16_ptr + vzero_u16_addr, zr_u16)
117 +
118 +
119 +# ═══════════════════════════════════════════════════════════════════════
120 +# FP8 key store + value uniform quantization
121 +# ═══════════════════════════════════════════════════════════════════════
122 +
123 +
124 +@triton.jit
125 +def _tq_fused_store_fp8(
126 + Key_ptr, # [NH, D] float16/bfloat16 — raw keys
127 + Value_ptr, # [NH, D] float16/bfloat16 — raw values
128 + KV_cache_ptr, # [total_bytes] uint8 (flattened view)
129 + KV_cache_u16_ptr, # uint16-aliased view of the same storage (Opt#3)
130 + Slot_mapping_ptr, # [N] int32 — per-token slot indices
131 + # Cache strides (stride_cache_block = bytes per block = bs*H*slot_aligned)
132 + stride_cache_block: tl.constexpr,
133 + # Dimensions
134 + D: tl.constexpr,
135 + H: tl.constexpr,
136 + BLOCK_SIZE: tl.constexpr,
137 + BLOCK_D: tl.constexpr,
138 + # SoA layout (Opt#3): data region is [bs, H, KEY_DATA_BYTES+VAL_DATA_BYTES],
139 + # followed by metadata region [H, NUM_SOA_FIELDS, bs] of fp16.
140 + KEY_DATA_BYTES: tl.constexpr, # FP8: = D
141 + VAL_DATA_BYTES: tl.constexpr,
142 + META_REGION_OFFSET: tl.constexpr, # bytes from block start to SoA region
143 + NUM_SOA_FIELDS: tl.constexpr, # FP8: 2 (V-scale, V-zero)
144 + SOA_V_SCALE: tl.constexpr, # FP8: 0
145 + SOA_V_ZERO: tl.constexpr, # FP8: 1
146 + # Value quantization
147 + VQB: tl.constexpr,
148 + # Packing block sizes
149 + BLOCK_VAL: tl.constexpr,
150 + BLOCK_GRP: tl.constexpr = 16,
151 + FP8_E4B15: tl.constexpr = 0, # 1 = e4b15 (Ampere/Ada), 0 = e4nv (Hopper+)
152 +):
153 + """FP8 key cast+scatter + value uniform quantization (SoA layout)."""
154 + pid = tl.program_id(0)
155 + token_idx = pid // H
156 + head_idx = pid % H
157 +
158 + slot = tl.load(Slot_mapping_ptr + token_idx)
159 + if slot < 0:
160 + return
161 + blk = (slot // BLOCK_SIZE).to(tl.int64)
162 + off = (slot % BLOCK_SIZE).to(tl.int64)
163 + head_idx_i64 = tl.cast(head_idx, tl.int64)
164 +
165 + # Block start byte address — stride_cache_block is unchanged (same total
166 + # bytes per block as the AoS layout).
167 + block_base = blk * stride_cache_block
168 +
169 + # Data region: per-slot stride = H * DATA_BYTES_PER_SLOT; per-head stride
170 + # within a slot = DATA_BYTES_PER_SLOT.
171 + DATA_BYTES_PER_SLOT: tl.constexpr = KEY_DATA_BYTES + VAL_DATA_BYTES
172 + data_base = (
173 + block_base
174 + + off * (H * DATA_BYTES_PER_SLOT)
175 + + head_idx_i64 * DATA_BYTES_PER_SLOT
176 + )
177 +
178 + # SoA metadata region (u16 element indexing): head strip = NUM_SOA_FIELDS
179 + # consecutive bs-long fp16 arrays.
180 + head_meta_u16_base = (block_base + META_REGION_OFFSET) // 2 + head_idx_i64 * (
181 + NUM_SOA_FIELDS * BLOCK_SIZE
182 + )
183 + vscale_u16_addr = head_meta_u16_base + SOA_V_SCALE * BLOCK_SIZE + off
184 + vzero_u16_addr = head_meta_u16_base + SOA_V_ZERO * BLOCK_SIZE + off
185 +
186 + base = pid * D
187 +
188 + # ── FP8 KEY: cast to FP8 in-kernel and store at data_base ────────
189 + d_offs = tl.arange(0, BLOCK_D)
190 + d_mask = d_offs < D
191 + k_vals = tl.load(Key_ptr + base + d_offs, mask=d_mask, other=0.0)
192 + k_fp8 = k_vals.to(tl.float8e4b15) if FP8_E4B15 else k_vals.to(tl.float8e4nv)
193 + k_bytes = k_fp8.to(tl.uint8, bitcast=True)
194 + tl.store(KV_cache_ptr + data_base + d_offs, k_bytes, mask=d_mask)
195 +
196 + # ── VALUE QUANTIZE + PACK + SoA scale/zero store ─────────────────
197 + _store_quantized_value(
198 + Value_ptr,
199 + KV_cache_ptr,
200 + KV_cache_u16_ptr,
201 + base,
202 + data_base,
203 + vscale_u16_addr,
204 + vzero_u16_addr,
205 + d_offs,
206 + d_mask,
207 + D=D,
208 + V_DATA_OFFSET=KEY_DATA_BYTES, # V follows K in the data region
209 + VQB=VQB,
210 + VAL_DATA_BYTES=VAL_DATA_BYTES,
211 + BLOCK_D=BLOCK_D,
212 + BLOCK_VAL=BLOCK_VAL,
213 + BLOCK_GRP=BLOCK_GRP,
214 + )
215 +
216 +
217 +# ═══════════════════════════════════════════════════════════════════════
218 +# Fused MSE store: bucketize + MSE index pack + norm store + value pack
219 +# (eliminates 4 PyTorch kernel launches per layer vs pack-only kernel)
220 +# ═══════════════════════════════════════════════════════════════════════
221 +
222 +
223 +@triton.jit
224 +def _tq_fused_store_mse(
225 + # Post-rotation inputs
226 + Y_ptr, # [NH, D] float32 — rotated normalized keys (x_hat @ PiT)
227 + Norms_ptr, # [NH] float32 — key vector norms (||k||)
228 + Value_ptr, # [NH, D] float32 — raw values
229 + # Quantization tables
230 + Midpoints_ptr, # [n_centroids-1] float32
231 + Centroids_ptr, # [n_centroids] float32 — used only when NORM_CORRECTION=1
232 + # Cache and indexing
233 + KV_cache_ptr, # [total_bytes] uint8 (flattened view)
234 + KV_cache_u16_ptr, # uint16-aliased view of the same storage (Opt#3)
235 + Slot_mapping_ptr, # [N] int32 — per-token slot indices
236 + # Cache strides (stride_cache_block = bs*H*slot_aligned, unchanged)
237 + stride_cache_block: tl.constexpr,
238 + # Dimensions
239 + D: tl.constexpr,
240 + H: tl.constexpr,
241 + BLOCK_SIZE: tl.constexpr,
242 + BLOCK_D: tl.constexpr,
243 + # SoA layout (Opt#3): data region [bs, H, MSE_BYTES+VAL_DATA_BYTES],
244 + # then metadata region [H, NUM_SOA_FIELDS, bs] of fp16.
245 + MSE_BYTES: tl.constexpr,
246 + VAL_DATA_BYTES: tl.constexpr,
247 + META_REGION_OFFSET: tl.constexpr, # bytes
248 + NUM_SOA_FIELDS: tl.constexpr, # MSE: 3 (k_norm, v_scale, v_zero)
249 + SOA_K_NORM: tl.constexpr, # MSE: 0
250 + SOA_V_SCALE: tl.constexpr, # MSE: 1
251 + SOA_V_ZERO: tl.constexpr, # MSE: 2
252 + # Value quantization
253 + VQB: tl.constexpr,
254 + # Packing block sizes
255 + BLOCK_VAL: tl.constexpr,
256 + # MSE params
257 + MSE_BITS: tl.constexpr,
258 + N_CENTROIDS: tl.constexpr,
259 + # When 1, fold the per-token centroid-vector norm ||c_t|| into the
260 + # stored scalar: store ||k_t|| / ||c_t|| instead of ||k_t||. This
261 + # removes the per-tile sum+sqrt+divide cost from the attention kernel's
262 + # K-load path. Load-side math becomes a plain multiply K = c * stored.
263 + NORM_CORRECTION: tl.constexpr = 0,
264 + BLOCK_GRP: tl.constexpr = 16,
265 +):
266 + """Fused MSE quantize + pack + SoA-metadata store (Opt#3 layout).
267 +
268 + Binary-search bucketize → MSE-index pack → K-norm store (SoA) → value
269 + uniform quantize + pack → V scale/zero store (SoA), in one kernel.
270 + """
271 + pid = tl.program_id(0)
272 + token_idx = pid // H
273 + head_idx = pid % H
274 +
275 + slot = tl.load(Slot_mapping_ptr + token_idx)
276 + if slot < 0:
277 + return
278 + blk = (slot // BLOCK_SIZE).to(tl.int64)
279 + off = (slot % BLOCK_SIZE).to(tl.int64)
280 + head_idx_i64 = tl.cast(head_idx, tl.int64)
281 +
282 + # Block start byte address — stride unchanged from AoS (same total
283 + # bytes per block); layout inside the block differs.
284 + block_base = blk * stride_cache_block
285 +
286 + # Data region offset (packed K indices + packed V data).
287 + DATA_BYTES_PER_SLOT: tl.constexpr = MSE_BYTES + VAL_DATA_BYTES
288 + data_base = (
289 + block_base
290 + + off * (H * DATA_BYTES_PER_SLOT)
291 + + head_idx_i64 * DATA_BYTES_PER_SLOT
292 + )
293 +
294 + # SoA metadata addresses (u16 element indexing).
295 + head_meta_u16_base = (block_base + META_REGION_OFFSET) // 2 + head_idx_i64 * (
296 + NUM_SOA_FIELDS * BLOCK_SIZE
297 + )
298 + knorm_u16_addr = head_meta_u16_base + SOA_K_NORM * BLOCK_SIZE + off
299 + vscale_u16_addr = head_meta_u16_base + SOA_V_SCALE * BLOCK_SIZE + off
300 + vzero_u16_addr = head_meta_u16_base + SOA_V_ZERO * BLOCK_SIZE + off
301 +
302 + base = pid * D
303 + d_offs = tl.arange(0, BLOCK_D)
304 + d_mask = d_offs < D
305 +
306 + # ── 1. BINARY SEARCH BUCKETIZE ────────────────────────────────────
307 + y_vec = tl.load(Y_ptr + base + d_offs, mask=d_mask, other=0.0)
308 + lo = tl.zeros([BLOCK_D], dtype=tl.int32)
309 + hi = tl.full([BLOCK_D], N_CENTROIDS - 1, dtype=tl.int32)
310 + for _ in range(MSE_BITS):
311 + mid = (lo + hi) >> 1
312 + safe_mid = tl.minimum(mid, N_CENTROIDS - 2)
313 + mid_val = tl.load(Midpoints_ptr + safe_mid, mask=d_mask, other=0.0)
314 + lo = tl.where(y_vec >= mid_val, mid + 1, lo)
315 + hi = tl.where(y_vec >= mid_val, hi, mid)
316 + idx = tl.minimum(lo, N_CENTROIDS - 1)
317 +
318 + # ── 2. PACK MSE INDICES into data region at data_base[0:MSE_BYTES] ──
319 + if MSE_BITS == 4:
320 + idx_pairs = tl.reshape(idx, [BLOCK_D // 2, 2])
321 + shifts_4 = tl.arange(0, 2) * 4
322 + packed = tl.sum((idx_pairs & 0xF) << shifts_4[None, :], axis=1).to(tl.uint8)
323 + mse_offs = tl.arange(0, BLOCK_D // 2)
324 + mse_mask = mse_offs < MSE_BYTES
325 + tl.store(KV_cache_ptr + data_base + mse_offs, packed, mask=mse_mask)
326 +
327 + elif MSE_BITS == 3:
328 + grp_offs = tl.arange(0, BLOCK_GRP)
329 + grp_mask = grp_offs < (D // 8)
330 + idx_grp = tl.reshape(idx, [BLOCK_GRP, 8])
331 + shifts_3 = tl.arange(0, 8) * 3
332 + packed_24 = tl.sum((idx_grp & 0x7) << shifts_3[None, :], axis=1)
333 + b0 = (packed_24 & 0xFF).to(tl.uint8)
334 + b1 = ((packed_24 >> 8) & 0xFF).to(tl.uint8)
335 + b2 = ((packed_24 >> 16) & 0xFF).to(tl.uint8)
336 + tl.store(KV_cache_ptr + data_base + grp_offs * 3, b0, mask=grp_mask)
337 + tl.store(KV_cache_ptr + data_base + grp_offs * 3 + 1, b1, mask=grp_mask)
338 + tl.store(KV_cache_ptr + data_base + grp_offs * 3 + 2, b2, mask=grp_mask)
339 +
340 + # ── 3. STORE K-norm in SoA region (single u16 store) ─────────────
341 + # Norm-correction (Opt#1) still folds 1/||c_vec|| into the stored value;
342 + # only the destination moved to the SoA K-norm array.
343 + vn_f32 = tl.load(Norms_ptr + pid) # ||k_t||, fp32
344 +
345 + if NORM_CORRECTION:
346 + safe_idx = tl.minimum(tl.maximum(idx, 0), N_CENTROIDS - 1)
347 + c_vals = tl.load(Centroids_ptr + safe_idx, mask=d_mask, other=0.0)
348 + c_norm_sq = tl.sum(tl.where(d_mask, c_vals * c_vals, 0.0), axis=0)
349 + c_inv_norm = 1.0 / tl.sqrt(c_norm_sq + 1e-16)
350 + vn_f32 = vn_f32 * c_inv_norm
351 +
352 + vn_u16 = vn_f32.to(tl.float16).to(tl.uint16, bitcast=True)
353 + tl.store(KV_cache_u16_ptr + knorm_u16_addr, vn_u16)
354 +
355 + # ── 4. VALUE QUANTIZE + PACK + SoA scale/zero store ──────────────
356 + _store_quantized_value(
357 + Value_ptr,
358 + KV_cache_ptr,
359 + KV_cache_u16_ptr,
360 + base,
361 + data_base,
362 + vscale_u16_addr,
363 + vzero_u16_addr,
364 + d_offs,
365 + d_mask,
366 + D=D,
367 + V_DATA_OFFSET=MSE_BYTES, # V follows K indices in the data region
368 + VQB=VQB,
369 + VAL_DATA_BYTES=VAL_DATA_BYTES,
370 + BLOCK_D=BLOCK_D,
371 + BLOCK_VAL=BLOCK_VAL,
372 + BLOCK_GRP=BLOCK_GRP,
373 + )
374 +
375 +
376 +# ═══════════════════════════════════════════════════════════════════════
377 +# Launcher
378 +# ═══════════════════════════════════════════════════════════════════════
379 +
380 +
381 +def triton_turboquant_store(
382 + key: torch.Tensor, # [N, H, D] — raw keys (post-RoPE)
383 + value: torch.Tensor, # [N, H, D] — raw values
384 + kv_cache: torch.Tensor, # [num_blocks, block_size, Hk, padded_slot] uint8
385 + slot_mapping: torch.Tensor, # [N] int32
386 + PiT: torch.Tensor, # [D, D] float32
387 + midpoints: torch.Tensor, # [n_centroids-1] float32
388 + mse_bits: int,
389 + key_packed_size: int,
390 + value_quant_bits: int,
391 + key_fp8: bool = False,
392 + centroids: torch.Tensor
393 + | None = None, # [n_centroids] float32 — required when norm_correction=True
394 + norm_correction: bool = False,
395 +):
396 + """Launch TQ store kernel (FP8 or MSE path).
397 +
398 + When ``norm_correction=True`` (default off; set by MSE norm-correction
399 + presets), the centroid-vector norm ||c_t|| is folded into the stored
400 + per-token scalar at store time (stored value = ||k_t|| / ||c_t||). The
401 + load-side attention kernel no longer computes per-tile sum+sqrt+divide,
402 + saving ~26% of TQ overhead on gpt-oss decode (ctx=4096). ``centroids``
403 + must be provided in this mode.
404 + """
405 + if norm_correction and not key_fp8:
406 + assert centroids is not None, (
407 + "norm_correction=True requires centroids tensor; got None. "
408 + "Pass the same [n_centroids] fp32 tensor used to derive midpoints."
409 + )
410 + N, H, D = key.shape
411 + NH = N * H
412 + block_size = kv_cache.shape[1]
413 + BLOCK_D = triton.next_power_of_2(D)
414 + mse_bytes = math.ceil(D * mse_bits / 8)
415 + n_centroids = 2**mse_bits
416 +
417 + val_data_bytes = math.ceil(D * value_quant_bits / 8)
418 +
419 + BLOCK_VAL = triton.next_power_of_2(val_data_bytes)
420 +
421 + # Cache strides (element_size=1 for uint8, so stride in bytes = stride()).
422 + # Only stride_cache_block (== bs*H*slot_aligned) is used under Opt#3;
423 + # per-slot and per-head strides are computed from DATA_BYTES_PER_SLOT.
424 + stride_block = kv_cache.stride(0)
425 +
426 + block_grp = triton.next_power_of_2(D // 8) if D >= 8 else 1
427 +
428 + # ── Opt#3 SoA layout constants (derived locally so the launcher API
429 + # stays unchanged). Invariant: key_packed_size + value_packed_size
430 + # == slot_size_aligned == (key_data + val_data) + (k_meta + v_meta).
431 + key_data_bytes = D if key_fp8 else mse_bytes
432 + data_bytes_per_slot = key_data_bytes + val_data_bytes
433 + # Data region occupies [0, block_size * H * data_bytes_per_slot) bytes
434 + # at the start of each block; metadata region starts at that offset.
435 + meta_region_offset = block_size * H * data_bytes_per_slot
436 + num_soa_fields = 2 if key_fp8 else 3
437 + soa_v_scale = 0 if key_fp8 else 1
438 + soa_v_zero = 1 if key_fp8 else 2
439 +
440 + # uint16-aliased view of the same storage — enables single-instruction
441 + # u16 writes for the SoA K-norm / V-scale / V-zero stores. The cache
442 + # bytes are always contiguous and 2-byte aligned under the Opt#3 layout
443 + # (MSE_BYTES, VAL_DATA_BYTES, meta_region_offset are all even).
444 + kv_cache_u16 = kv_cache.view(torch.uint16)
445 +
446 + # ── FP8 PATH: in-kernel FP8 cast + scatter via fp8 kernel ──
447 + if key_fp8:
448 + k_flat = key.reshape(NH, D).contiguous()
449 + v_flat = value.reshape(NH, D).contiguous()
450 +
451 + fp8_e4b15 = _use_fp8_e4b15(key.device.index or 0)
452 +
453 + grid = (NH,)
454 + _tq_fused_store_fp8[grid](
455 + k_flat,
456 + v_flat,
457 + kv_cache.view(-1),
458 + kv_cache_u16.view(-1),
459 + slot_mapping,
460 + stride_cache_block=stride_block,
461 + D=D,
462 + H=H,
463 + BLOCK_SIZE=block_size,
464 + BLOCK_D=BLOCK_D,
465 + KEY_DATA_BYTES=key_data_bytes,
466 + VAL_DATA_BYTES=val_data_bytes,
467 + META_REGION_OFFSET=meta_region_offset,
468 + NUM_SOA_FIELDS=num_soa_fields,
469 + SOA_V_SCALE=soa_v_scale,
470 + SOA_V_ZERO=soa_v_zero,
471 + VQB=value_quant_bits,
472 + BLOCK_VAL=BLOCK_VAL,
473 + BLOCK_GRP=block_grp,
474 + FP8_E4B15=fp8_e4b15,
475 + num_warps=4,
476 + num_stages=1,
477 + )
478 + return
479 +
480 + # ── MSE PATH: external GEMM + fused bucketize/pack kernel ──
481 + # Normalize + rotation GEMM externally (cuBLAS is faster than in-kernel)
482 + k_flat = key.float().reshape(NH, D)
483 + norms = k_flat.norm(dim=1, keepdim=True)
484 + x_hat = k_flat / (norms + 1e-8)
485 + y = x_hat @ PiT
486 +
487 + v_flat = value.float().reshape(NH, D)
488 +
489 + # When norm_correction=True, the kernel folds 1/||c_t|| into the
490 + # stored per-token K-norm so the load kernel can skip per-tile norm
491 + # math (Opt#1). centroids is only dereferenced inside the kernel when
492 + # NORM_CORRECTION=1; pass midpoints as a harmless placeholder otherwise.
493 + centroids_ptr = centroids if centroids is not None else midpoints
494 + grid = (NH,)
495 + _tq_fused_store_mse[grid](
496 + y,
497 + norms.squeeze(1),
498 + v_flat,
499 + midpoints,
500 + centroids_ptr,
501 + kv_cache.view(-1),
502 + kv_cache_u16.view(-1),
503 + slot_mapping,
504 + stride_cache_block=stride_block,
505 + D=D,
506 + H=H,
507 + BLOCK_SIZE=block_size,
508 + BLOCK_D=BLOCK_D,
509 + MSE_BYTES=mse_bytes,
510 + VAL_DATA_BYTES=val_data_bytes,
511 + META_REGION_OFFSET=meta_region_offset,
512 + NUM_SOA_FIELDS=num_soa_fields,
513 + SOA_K_NORM=0,
514 + SOA_V_SCALE=soa_v_scale,
515 + SOA_V_ZERO=soa_v_zero,
516 + VQB=value_quant_bits,
517 + BLOCK_VAL=BLOCK_VAL,
518 + MSE_BITS=mse_bits,
519 + N_CENTROIDS=n_centroids,
520 + NORM_CORRECTION=1 if norm_correction else 0,
521 + BLOCK_GRP=block_grp,
522 + num_warps=4,
523 + num_stages=1,
524 + )
vllm/v1/attention/ops/turboquant_soa/triton_turboquant_unified_attention.py new
+1350
@@ -0,0 +1,1350 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unified (prefill + decode) Triton attention kernel for TurboQuant.
4 +
5 +Structure is ported from ``vllm/v1/attention/ops/triton_unified_attention.py``
6 +(the AITER unified attention kernel already upstreamed into vLLM). Only the K
7 +and V load sites are replaced: instead of reading raw fp16 keys/values from
8 +two contiguous caches, this kernel reads TurboQuant-packed bytes from a single
9 +combined cache and dequantizes on the fly inside the tile loop.
10 +
11 +Benefits over the v1/v2 decode-only kernels:
12 +
13 +1. GQA heads are stacked into ``BLOCK_M`` and the Q·K and P·V ops are proper
14 + ``tl.dot`` tensor-core operations (MFMA on MI300X).
15 +2. The same kernel handles decode (``BLOCK_Q=1``) and prefill
16 + (``BLOCK_Q>1``) -- no more Python per-request for-loop for continuation
17 + chunks.
18 +3. Only the subset of features exercised by the current TQ paths is kept
19 + (causal, GQA). Sinks / softcap / ALiBi / sliding-window / qq-bias /
20 + mm-prefix are deferred to follow-ups.
21 +
22 +This is an opt-in v3 path behind ``VLLM_TQ_DECODE_V3``.
23 +"""
24 +
25 +from __future__ import annotations
26 +
27 +import math
28 +from typing import Any
29 +
30 +import torch
31 +
32 +from vllm.platforms import current_platform
33 +from vllm.triton_utils import tl, triton
34 +
35 +# reduce_segments is KV-format-agnostic: by the time it runs, K/V have been
36 +# consumed and only (max, expsum, partial_output) triples remain. Reuse the
37 +# baseline's implementation verbatim to avoid code duplication.
38 +from vllm.v1.attention.ops.triton_unified_attention import reduce_segments
39 +
40 +from .triton_turboquant_decode import _use_fp8_e4b15
41 +from .triton_turboquant_decode_v2 import build_pair_lut
42 +
43 +_is_hip = current_platform.is_rocm()
44 +
45 +
46 +# ---------------------------------------------------------------------------
47 +# Helper: find which sequence in the batch a global q-block index belongs to.
48 +# Identical to the unified kernel's binary search.
49 +# ---------------------------------------------------------------------------
50 +
51 +
52 +@triton.jit
53 +def _find_seq_idx(
54 + query_start_len_ptr,
55 + target_idx,
56 + num_seqs,
57 + BLOCK_Q: tl.constexpr,
58 + use_q_block_mode: tl.constexpr,
59 +):
60 + left: tl.int32 = 0
61 + right = num_seqs
62 + while left < right:
63 + mid = (left + right) // 2
64 + val = tl.load(query_start_len_ptr + mid)
65 + mid_val = val // BLOCK_Q + mid if use_q_block_mode else val
66 + if mid_val <= target_idx:
67 + left = mid + 1
68 + else:
69 + right = mid
70 + return left - 1
71 +
72 +
73 +# ---------------------------------------------------------------------------
74 +# Fused Q-rotation prologue (Opt: eliminates the launcher-side
75 +# q_rot = (query.float() @ PiT).to(dtype).contiguous()
76 +# chain, which cost ~50-60 us/step at D=64 — all launch-bound, not compute-
77 +# bound. The fused version does one small MFMA inside the attention kernel,
78 +# once per program, before the KV-tile loop. PiT is [D,D] fp32 and fits in
79 +# L1 easily (16KB at D=64, 64KB at D=128).
80 +#
81 +# Algebra is identical to the launcher path: both compute Q_rot = Q @ PiT
82 +# with fp32 accumulate. Differences are rounding-order only (MFMA tiling
83 +# vs rocBLAS tiling), well within the 4-bit KV quant noise floor.
84 +# ---------------------------------------------------------------------------
85 +
86 +
87 +@triton.jit
88 +def _tq_fuse_q_rotation(
89 + Q, # [BLOCK_M, HEAD_SIZE_PADDED] — raw query in Q.dtype
90 + PiT_ptr,
91 + PiT_stride_0: tl.int64,
92 + PiT_stride_1: tl.int64,
93 + dim_mask, # [HEAD_SIZE_PADDED] int1 — valid head dims
94 + HEAD_SIZE_PADDED: tl.constexpr,
95 +):
96 + """Fused Q @ PiT prologue. Called once per program for the MSE-key path
97 + when the launcher has passed the raw (un-rotated) query. Returns the
98 + rotated Q in the original dtype.
99 + """
100 + d_offs = tl.arange(0, HEAD_SIZE_PADDED)
101 + pit_offsets = d_offs[:, None] * PiT_stride_0 + d_offs[None, :] * PiT_stride_1
102 + pit_mask = dim_mask[:, None] & dim_mask[None, :]
103 + PiT_tile = tl.load(PiT_ptr + pit_offsets, mask=pit_mask, other=0.0).to(tl.float32)
104 + # input_precision="ieee" pins both inputs to full fp32 MFMA (no TF32
105 + # truncation). allow_tf32 is intentionally omitted — Triton rejects
106 + # passing both. This matches the launcher's rocBLAS fp32 GEMM in
107 + # algebra; rounding-order differs, diff is <= a few fp16 ulp.
108 + Q_rot = tl.dot(
109 + Q.to(tl.float32),
110 + PiT_tile,
111 + input_precision="ieee",
112 + )
113 + return Q_rot.to(Q.dtype)
114 +
115 +
116 +# ---------------------------------------------------------------------------
117 +# TQ K-tile dequant: returns K_T : [HEAD_SIZE_PADDED, TILE_SIZE] in Q.dtype
118 +# ready for tl.dot(Q, K_T).
119 +#
120 +# Packed slot layout (per (block, pos, kv_head)) is the same as v1/v2:
121 +# MSE path: [ key_idx_bytes (MSE_BYTES) | key_norm_fp16 (2B) | ...V... ]
122 +# FP8 path: [ fp8_key_bytes (D) | ...V... ]
123 +# ---------------------------------------------------------------------------
124 +
125 +
126 +@triton.jit
127 +def _tq_load_k_tile(
128 + KV_cache_ptr,
129 + KV_cache_u16_ptr, # uint16 view of KV_cache (same storage)
130 + data_bases, # [TILE_SIZE] int64 — byte offset to each token's DATA region
131 + knorm_u16_addrs, # [TILE_SIZE] int64 — u16 element index for each token's K-norm
132 + d_offs, # [HEAD_SIZE_PADDED]
133 + d_mask, # [HEAD_SIZE_PADDED] int1
134 + tile_mask, # [TILE_SIZE] int1
135 + Centroids_ptr,
136 + Pair_lut_ptr,
137 + OUT_DTYPE: tl.constexpr, # tl.float16 or tl.bfloat16
138 + HEAD_DIM: tl.constexpr,
139 + BLOCK_D: tl.constexpr, # HEAD_SIZE_PADDED — needed for pair-LUT reshape
140 + MSE_BITS: tl.constexpr,
141 + N_CENTROIDS: tl.constexpr,
142 + KEY_FP8: tl.constexpr,
143 + USE_PAIR_LUT: tl.constexpr,
144 + NORM_CORRECTION: tl.constexpr,
145 + FP8_E4B15: tl.constexpr,
146 + TILE_SIZE: tl.constexpr,
147 +):
148 + """Load + dequantize a TILE_SIZE × HEAD_SIZE block of keys and return
149 + the transposed tile K_T : [HEAD_SIZE_PADDED, TILE_SIZE].
150 +
151 + Opt#3 SoA layout: packed K data is at `data_bases[t] + [0, MSE_BYTES)`
152 + for MSE keys (or `[0, D)` for FP8). The per-token K-norm lives in the
153 + per-block SoA metadata region; `knorm_u16_addrs` already encodes its
154 + u16 element index. For decode tiles aligned with blocks, these addresses
155 + are contiguous → one coalesced wide load replaces TILE_SIZE scattered
156 + loads (the whole point of Opt#3).
157 + """
158 + if KEY_FP8:
159 + k_addrs = data_bases[:, None] + d_offs[None, :]
160 + k_raw = tl.load(
161 + KV_cache_ptr + k_addrs,
162 + mask=tile_mask[:, None] & d_mask[None, :],
163 + other=0,
164 + )
165 + if FP8_E4B15:
166 + k_f32 = k_raw.to(tl.float8e4b15, bitcast=True).to(tl.float32)
167 + else:
168 + k_f32 = k_raw.to(tl.float8e4nv, bitcast=True).to(tl.float32)
169 + K = k_f32 # [TILE_SIZE, HEAD_SIZE_PADDED]
170 + else:
171 + # MSE path: gather packed key indices + centroid LUT.
172 + if MSE_BITS == 4 and USE_PAIR_LUT:
173 + # FLUTE pair-LUT fast path — load each packed byte once, decode
174 + # both nibbles, single 3-D gather returns (T[lo], T[hi]) per byte.
175 + HALF_D: tl.constexpr = BLOCK_D // 2
176 + half_offs = tl.arange(0, HALF_D)
177 + byte_mask = (half_offs * 2) < HEAD_DIM
178 + byte_addrs = data_bases[:, None] + half_offs[None, :]
179 + byte_raw = tl.load(
180 + KV_cache_ptr + byte_addrs,
181 + mask=tile_mask[:, None] & byte_mask[None, :],
182 + other=0,
183 + ).to(tl.int32)
184 + lo_idx = byte_raw & 0xF
185 + hi_idx = (byte_raw >> 4) & 0xF
186 + pair_key = lo_idx * N_CENTROIDS + hi_idx
187 + pair_slot = tl.arange(0, 2)
188 + c_pair = tl.load(
189 + Pair_lut_ptr + pair_key[:, :, None] * 2 + pair_slot[None, None, :],
190 + mask=(tile_mask[:, None, None] & byte_mask[None, :, None]),
191 + other=0.0,
192 + )
193 + c_vals = tl.reshape(c_pair, [TILE_SIZE, BLOCK_D])
194 + elif MSE_BITS == 4:
195 + half_idx = d_offs // 2
196 + nibble_shift = (d_offs % 2) * 4
197 + mse_addrs = data_bases[:, None] + half_idx[None, :]
198 + mse_raw = tl.load(
199 + KV_cache_ptr + mse_addrs,
200 + mask=tile_mask[:, None] & d_mask[None, :],
201 + other=0,
202 + ).to(tl.int32)
203 + mse_idx = (mse_raw >> nibble_shift[None, :]) & 0xF
204 + c_vals = tl.load(
205 + Centroids_ptr + mse_idx,
206 + mask=tile_mask[:, None] & d_mask[None, :],
207 + other=0.0,
208 + )
209 + else:
210 + # Generic bit extraction (3-bit, etc.)
211 + mse_bit_off = d_offs * MSE_BITS
212 + mse_byte_idx = mse_bit_off // 8
213 + mse_bit_shift = mse_bit_off % 8
214 + mse_mask_val = (1 << MSE_BITS) - 1
215 + mse_addrs0 = data_bases[:, None] + mse_byte_idx[None, :]
216 + mse_raw0 = tl.load(
217 + KV_cache_ptr + mse_addrs0,
218 + mask=tile_mask[:, None] & d_mask[None, :],
219 + other=0,
220 + ).to(tl.int32)
221 + mse_raw1 = tl.load(
222 + KV_cache_ptr + mse_addrs0 + 1,
223 + mask=tile_mask[:, None] & d_mask[None, :],
224 + other=0,
225 + ).to(tl.int32)
226 + raw16 = mse_raw0 | (mse_raw1 << 8)
227 + mse_idx = (raw16 >> mse_bit_shift[None, :]) & mse_mask_val
228 + c_vals = tl.load(
229 + Centroids_ptr + mse_idx,
230 + mask=tile_mask[:, None] & d_mask[None, :],
231 + other=0.0,
232 + )
233 +
234 + # Opt#1: 1/||c_vec|| is pre-folded into the stored K-norm at store
235 + # time, so the kernel doesn't recompute norm-correction here.
236 + # Opt#3: K-norms for a tile are contiguous in the per-block SoA
237 + # region when the tile lies within one block (always true for
238 + # aligned decode tiles with TILE_SIZE == BLOCK_SIZE) — one coalesced
239 + # u16 load replaces TILE_SIZE scattered 2-byte loads.
240 + norm_u16 = tl.load(KV_cache_u16_ptr + knorm_u16_addrs, mask=tile_mask, other=0)
241 + vec_norms = norm_u16.to(tl.float16, bitcast=True).to(tl.float32)
242 + K = c_vals * vec_norms[:, None] # [TILE_SIZE, HEAD_SIZE_PADDED]
243 +
244 + K_T = tl.trans(K.to(OUT_DTYPE)) # [HEAD_SIZE_PADDED, TILE_SIZE]
245 + _ = HEAD_DIM
246 + return K_T
247 +
248 +
249 +# ---------------------------------------------------------------------------
250 +# TQ V-tile dequant: returns V : [TILE_SIZE, HEAD_SIZE_PADDED] in Q.dtype
251 +# ready for tl.dot(P, V).
252 +# ---------------------------------------------------------------------------
253 +
254 +
255 +@triton.jit
256 +def _tq_load_v_tile(
257 + KV_cache_ptr,
258 + KV_cache_u16_ptr, # uint16 view of KV_cache (same storage)
259 + val_bases, # [TILE_SIZE] int64 — byte offset to each token's V-data
260 + vscale_u16_addrs, # [TILE_SIZE] int64 — u16 element index for V-scale
261 + vzero_u16_addrs, # [TILE_SIZE] int64 — u16 element index for V-zero
262 + d_offs,
263 + d_mask,
264 + tile_mask,
265 + OUT_DTYPE: tl.constexpr,
266 + HEAD_DIM: tl.constexpr,
267 + VQB: tl.constexpr,
268 +):
269 + """Load + dequantize a TILE_SIZE × HEAD_SIZE block of values.
270 +
271 + Opt#3 SoA layout: packed V data is at `val_bases[t] + [0, VAL_DATA_BYTES)`
272 + (V-data immediately follows K-data within the slot's data region, and
273 + `val_bases` is precomputed by the caller as `data_base + KEY_DATA_BYTES`).
274 + V-scale / V-zero live in the per-block SoA metadata region at indices
275 + `vscale_u16_addrs` / `vzero_u16_addrs`. For tiles aligned to block
276 + boundaries, those addresses are contiguous → one coalesced wide load
277 + per field instead of TILE_SIZE scattered 2-byte loads.
278 + """
279 + if VQB == 4:
280 + vb_idx = d_offs // 2
281 + vb_shift = (d_offs % 2) * 4
282 + val_addrs = val_bases[:, None] + vb_idx[None, :]
283 + val_raw = tl.load(
284 + KV_cache_ptr + val_addrs,
285 + mask=tile_mask[:, None] & d_mask[None, :],
286 + other=0,
287 + ).to(tl.int32)
288 + v_idx = ((val_raw >> vb_shift[None, :]) & 0xF).to(tl.float32)
289 + else: # VQB == 3
290 + val_bit_off = d_offs * 3
291 + val_byte_idx = val_bit_off // 8
292 + val_bit_shift = val_bit_off % 8
293 + val_addrs0 = val_bases[:, None] + val_byte_idx[None, :]
294 + val_raw0 = tl.load(
295 + KV_cache_ptr + val_addrs0,
296 + mask=tile_mask[:, None] & d_mask[None, :],
297 + other=0,
298 + ).to(tl.int32)
299 + val_raw1 = tl.load(
300 + KV_cache_ptr + val_addrs0 + 1,
301 + mask=tile_mask[:, None] & d_mask[None, :],
302 + other=0,
303 + ).to(tl.int32)
304 + raw16 = val_raw0 | (val_raw1 << 8)
305 + v_idx = ((raw16 >> val_bit_shift[None, :]) & 0x7).to(tl.float32)
306 +
307 + # SoA scale / zero loads — coalesced on aligned decode tiles.
308 + scale_u16 = tl.load(KV_cache_u16_ptr + vscale_u16_addrs, mask=tile_mask, other=0)
309 + zero_u16 = tl.load(KV_cache_u16_ptr + vzero_u16_addrs, mask=tile_mask, other=0)
310 + v_scales = scale_u16.to(tl.float16, bitcast=True).to(tl.float32)
311 + v_zeros = zero_u16.to(tl.float16, bitcast=True).to(tl.float32)
312 +
313 + V = v_idx * v_scales[:, None] + v_zeros[:, None] # [TILE_SIZE, HEAD_SIZE_PADDED]
314 + _ = HEAD_DIM
315 + return V.to(OUT_DTYPE)
316 +
317 +
318 +# ---------------------------------------------------------------------------
319 +# Unified 2D attention kernel over a TurboQuant KV cache (causal only, GQA).
320 +# Structure follows ``kernel_unified_attention_2d`` from
321 +# triton_unified_attention.py with:
322 +# * Q-head GQA stacking into BLOCK_M
323 +# * tensor-core tl.dot(Q, K) and tl.dot(P, V)
324 +# * causal + query-padding masking
325 +# * online softmax
326 +# and K/V loads replaced by the TQ helpers above.
327 +# ---------------------------------------------------------------------------
328 +
329 +
330 +@triton.jit
331 +def kernel_tq_unified_attention_2d(
332 + output_ptr, # [num_tokens, Hq, D]
333 + query_ptr, # [num_tokens, Hq, D] — raw if FUSE_Q_ROT else rotated
334 + KV_cache_ptr, # [num_blocks, block_size, Hk, padded_slot] uint8 - TQ packed
335 + KV_cache_u16_ptr, # uint16 view of same storage — Opt#2 wide metadata loads
336 + Centroids_ptr, # [n_centroids] fp32
337 + Pair_lut_ptr, # [N*N, 2] fp32 (pair-LUT for 4-bit MSE) or dummy
338 + PiT_ptr, # [D, D] fp32 — only dereferenced when FUSE_Q_ROT == 1
339 + block_tables_ptr, # [num_seqs, max_num_blocks_per_seq]
340 + seq_lens_ptr, # [num_seqs]
341 + query_start_len_ptr, # [num_seqs+1]
342 + sinks_ptr, # [Hq] fp32 — per-head sink logits; dereferenced only when USE_SINKS
343 + scale, # float32
344 + num_query_heads: tl.constexpr,
345 + num_queries_per_kv: tl.constexpr,
346 + block_table_stride: tl.int64,
347 + query_stride_0: tl.int64,
348 + query_stride_1: tl.int64,
349 + output_stride_0: tl.int64,
350 + output_stride_1: tl.int64,
351 + stride_cache_block: tl.int64,
352 + pit_stride_0: tl.int64,
353 + pit_stride_1: tl.int64,
354 + BLOCK_SIZE: tl.constexpr,
355 + TILE_SIZE: tl.constexpr,
356 + HEAD_SIZE: tl.constexpr,
357 + HEAD_SIZE_PADDED: tl.constexpr,
358 + BLOCK_Q: tl.constexpr,
359 + BLOCK_M: tl.constexpr,
360 + num_seqs: tl.int32,
361 + # TQ layout constants
362 + MSE_BITS: tl.constexpr,
363 + MSE_BYTES: tl.constexpr,
364 + VQB: tl.constexpr,
365 + VAL_DATA_BYTES: tl.constexpr,
366 + N_CENTROIDS: tl.constexpr,
367 + KEY_FP8: tl.constexpr,
368 + USE_PAIR_LUT: tl.constexpr,
369 + # Opt#3 SoA layout constants
370 + NUM_KV_HEADS: tl.constexpr,
371 + KEY_DATA_BYTES: tl.constexpr, # MSE_BYTES for MSE, HEAD_SIZE for FP8
372 + META_REGION_OFFSET: tl.constexpr, # bytes: bs * H * (KD+VD)
373 + NUM_SOA_FIELDS: tl.constexpr, # 3 for MSE, 2 for FP8
374 + SOA_K_NORM: tl.constexpr, # 0 for MSE, unused for FP8
375 + SOA_V_SCALE: tl.constexpr, # 1 (MSE) / 0 (FP8)
376 + SOA_V_ZERO: tl.constexpr, # 2 (MSE) / 1 (FP8)
377 + NORM_CORRECTION: tl.constexpr = 0,
378 + FP8_E4B15: tl.constexpr = 0,
379 + FUSE_Q_ROT: tl.constexpr = 0, # Fused Q @ PiT prologue when 1 (MSE path)
380 + USE_SINKS: tl.constexpr = 0, # Per-head sink logit folded into softmax denom
381 +):
382 + q_block_global_idx = tl.program_id(0)
383 + kv_head_idx = tl.program_id(1)
384 +
385 + seq_idx = _find_seq_idx(
386 + query_start_len_ptr, q_block_global_idx, num_seqs, BLOCK_Q, True
387 + )
388 + q_block_start_idx = tl.load(query_start_len_ptr + seq_idx) // BLOCK_Q + seq_idx
389 + q_block_local_idx = q_block_global_idx - q_block_start_idx
390 +
391 + cur_batch_in_all_start_index = tl.load(query_start_len_ptr + seq_idx)
392 + cur_batch_in_all_stop_index = tl.load(query_start_len_ptr + seq_idx + 1)
393 + cur_batch_query_len = cur_batch_in_all_stop_index - cur_batch_in_all_start_index
394 +
395 + if q_block_local_idx * BLOCK_Q >= cur_batch_query_len:
396 + return
397 +
398 + offs_m = tl.arange(0, BLOCK_M)
399 + offs_d = tl.arange(0, HEAD_SIZE_PADDED)
400 + offs_t = tl.arange(0, TILE_SIZE)
401 + query_pos = q_block_local_idx * BLOCK_Q + offs_m // num_queries_per_kv
402 +
403 + query_offset_0 = cur_batch_in_all_start_index + query_pos
404 + query_offset_1 = kv_head_idx * num_queries_per_kv + offs_m % num_queries_per_kv
405 + query_offset = (
406 + query_offset_0[:, None] * query_stride_0
407 + + query_offset_1[:, None] * query_stride_1
408 + + offs_d[None, :]
409 + )
410 +
411 + dim_mask = tl.where(offs_d < HEAD_SIZE, 1, 0).to(tl.int1)
412 + query_mask_0 = tl.where(query_pos < cur_batch_query_len, 1, 0).to(tl.int1)
413 + query_mask_1 = tl.where(query_offset_1 < num_query_heads, 1, 0).to(tl.int1)
414 +
415 + # Q : [BLOCK_M, HEAD_SIZE_PADDED]
416 + Q = tl.load(
417 + query_ptr + query_offset,
418 + mask=dim_mask[None, :] & query_mask_0[:, None] & query_mask_1[:, None],
419 + other=0.0,
420 + )
421 +
422 + if FUSE_Q_ROT:
423 + Q = _tq_fuse_q_rotation(
424 + Q,
425 + PiT_ptr,
426 + pit_stride_0,
427 + pit_stride_1,
428 + dim_mask,
429 + HEAD_SIZE_PADDED,
430 + )
431 +
432 + block_table_offset = seq_idx * block_table_stride
433 +
434 + # Online softmax state.
435 + # When USE_SINKS=1 we fold a per-head sink logit s_h into the softmax
436 + # denominator by initializing M = s_h, L = 1.0 (== exp(s_h - s_h)).
437 + # The standard online-softmax update then correctly rescales the sink
438 + # contribution as real tokens update M, and the final acc / L gives
439 + # out_h = sum_i exp(q.k_i)*V_i / (exp(s_h) + sum_i exp(q.k_i)).
440 + # Masked rows (query_mask_1 == 0) get -inf so the sink cannot pollute
441 + # invalid (q_token, q_head) positions outside the real query range.
442 + if USE_SINKS:
443 + M = tl.load(
444 + sinks_ptr + query_offset_1, mask=query_mask_1, other=float("-inf")
445 + ).to(tl.float32)
446 + else:
447 + M = tl.full([BLOCK_M], float("-inf"), dtype=tl.float32)
448 + L = tl.full([BLOCK_M], 1.0, dtype=tl.float32)
449 + acc = tl.zeros([BLOCK_M, HEAD_SIZE_PADDED], dtype=tl.float32)
450 +
451 + seq_len = tl.load(seq_lens_ptr + seq_idx)
452 + context_len = seq_len - cur_batch_query_len
453 +
454 + # Longest key prefix any query row in this q-block can attend to.
455 + max_seq_prefix_len = (
456 + context_len
457 + + q_block_local_idx * BLOCK_Q
458 + + (BLOCK_M - 1) // num_queries_per_kv
459 + + 1
460 + )
461 + max_seq_prefix_len = tl.minimum(max_seq_prefix_len, seq_len)
462 + num_tiles = tl.cdiv(max_seq_prefix_len, TILE_SIZE)
463 +
464 + for j in range(0, num_tiles):
465 + seq_offset = j * TILE_SIZE + offs_t
466 + tile_mask = seq_offset < max_seq_prefix_len
467 +
468 + physical_block_idx = tl.load(
469 + block_tables_ptr + block_table_offset + seq_offset // BLOCK_SIZE
470 + ).to(tl.int64)
471 +
472 + # Opt#3 SoA addressing: compute data + SoA metadata addresses once
473 + # per tile; the load helpers below issue coalesced wide loads from
474 + # these addresses for decode tiles that align with block boundaries.
475 + slot_within_block = (seq_offset % BLOCK_SIZE).to(tl.int64)
476 + block_base = physical_block_idx * stride_cache_block
477 + DATA_BYTES_PER_SLOT: tl.constexpr = KEY_DATA_BYTES + VAL_DATA_BYTES
478 + data_bases = (
479 + block_base
480 + + slot_within_block * (NUM_KV_HEADS * DATA_BYTES_PER_SLOT)
481 + + tl.cast(kv_head_idx, tl.int64) * DATA_BYTES_PER_SLOT
482 + )
483 + val_bases = data_bases + KEY_DATA_BYTES
484 +
485 + head_meta_u16_base = (block_base + META_REGION_OFFSET) // 2 + tl.cast(
486 + kv_head_idx, tl.int64
487 + ) * (NUM_SOA_FIELDS * BLOCK_SIZE)
488 + knorm_u16_addrs = (
489 + head_meta_u16_base + SOA_K_NORM * BLOCK_SIZE + slot_within_block
490 + )
491 + vscale_u16_addrs = (
492 + head_meta_u16_base + SOA_V_SCALE * BLOCK_SIZE + slot_within_block
493 + )
494 + vzero_u16_addrs = (
495 + head_meta_u16_base + SOA_V_ZERO * BLOCK_SIZE + slot_within_block
496 + )
497 +
498 + # ---- K : [HEAD_SIZE_PADDED, TILE_SIZE] in Q.dtype ----
499 + K_T = _tq_load_k_tile(
500 + KV_cache_ptr,
501 + KV_cache_u16_ptr,
502 + data_bases,
503 + knorm_u16_addrs,
504 + offs_d,
505 + dim_mask,
506 + tile_mask,
507 + Centroids_ptr,
508 + Pair_lut_ptr,
509 + OUT_DTYPE=Q.dtype,
510 + HEAD_DIM=HEAD_SIZE,
511 + BLOCK_D=HEAD_SIZE_PADDED,
512 + MSE_BITS=MSE_BITS,
513 + N_CENTROIDS=N_CENTROIDS,
514 + KEY_FP8=KEY_FP8,
515 + USE_PAIR_LUT=USE_PAIR_LUT,
516 + NORM_CORRECTION=NORM_CORRECTION,
517 + FP8_E4B15=FP8_E4B15,
518 + TILE_SIZE=TILE_SIZE,
519 + )
520 +
521 + # ---- V : [TILE_SIZE, HEAD_SIZE_PADDED] in Q.dtype ----
522 + V = _tq_load_v_tile(
523 + KV_cache_ptr,
524 + KV_cache_u16_ptr,
525 + val_bases,
526 + vscale_u16_addrs,
527 + vzero_u16_addrs,
528 + offs_d,
529 + dim_mask,
530 + tile_mask,
531 + OUT_DTYPE=Q.dtype,
532 + HEAD_DIM=HEAD_SIZE,
533 + VQB=VQB,
534 + )
535 +
536 + # S : [BLOCK_M, TILE_SIZE]
537 + S = scale * tl.dot(Q, K_T)
538 +
539 + # Causal + query-padding mask
540 + query_abs_pos = context_len + query_pos[:, None]
541 + seq_mask = seq_offset[None, :] <= query_abs_pos
542 + S = tl.where(
543 + query_mask_1[:, None] & query_mask_0[:, None] & seq_mask,
544 + S,
545 + float("-inf"),
546 + )
547 +
548 + # Online softmax
549 + m_j = tl.maximum(M, tl.max(S, axis=1))
550 + m_j = tl.where(m_j > float("-inf"), m_j, 0.0)
551 + P = tl.exp(S - m_j[:, None])
552 + l_j = tl.sum(P, axis=1)
553 + alpha = tl.exp(M - m_j)
554 + acc = acc * alpha[:, None]
555 + L = L * alpha + l_j
556 + M = m_j
557 +
558 + # acc += P · V
559 + acc += tl.dot(P.to(V.dtype), V)
560 +
561 + # Epilogue: normalize and store
562 + acc = acc / L[:, None]
563 +
564 + output_offset = (
565 + query_offset_0[:, None] * output_stride_0
566 + + query_offset_1[:, None] * output_stride_1
567 + + offs_d[None, :]
568 + )
569 + tl.store(
570 + output_ptr + output_offset,
571 + acc,
572 + mask=dim_mask[None, :] & query_mask_0[:, None] & query_mask_1[:, None],
573 + )
574 +
575 +
576 +# ---------------------------------------------------------------------------
577 +# Unified 3D (split-KV) attention kernel over a TurboQuant KV cache.
578 +#
579 +# Same loop body as the 2D kernel above, but with an extra ``segm_idx`` grid
580 +# axis: each program walks only ``cdiv(seq_len, NUM_SEGMENTS_PER_SEQ * TILE)``
581 +# tiles of the KV history and writes a *partial* (max, expsum, acc) triple to
582 +# a scratch buffer. A subsequent ``reduce_segments`` kernel merges the
583 +# ``NUM_SEGMENTS_PER_SEQ`` partials per (q_token, q_head) via the standard
584 +# online-softmax merge.
585 +#
586 +# This gives the decode path ``num_seqs × Hk × NUM_SEGMENTS_PER_SEQ`` CTAs
587 +# instead of just ``num_seqs × Hk``, which is what v2 already uses (and what
588 +# lets v2 run flat at ~0.15 ms across KV lengths on MI300X).
589 +# ---------------------------------------------------------------------------
590 +
591 +
592 +@triton.jit
593 +def kernel_tq_unified_attention_3d(
594 + segm_output_ptr, # [num_tokens, Hq, NUM_SEGMENTS_PER_SEQ, HEAD_SIZE_PADDED] fp32
595 + segm_max_ptr, # [num_tokens, Hq, NUM_SEGMENTS_PER_SEQ] fp32
596 + segm_expsum_ptr, # [num_tokens, Hq, NUM_SEGMENTS_PER_SEQ] fp32
597 + query_ptr, # [num_tokens, Hq, D] — raw if FUSE_Q_ROT else rotated
598 + KV_cache_ptr, # [num_blocks, block_size, Hk, padded_slot] uint8
599 + KV_cache_u16_ptr, # uint16 view of same storage — Opt#2 wide metadata loads
600 + Centroids_ptr,
601 + Pair_lut_ptr,
602 + PiT_ptr, # [D, D] fp32 — only dereferenced when FUSE_Q_ROT == 1
603 + block_tables_ptr,
604 + seq_lens_ptr,
605 + query_start_len_ptr,
606 + sinks_ptr, # [Hq] fp32 — per-head sink logits; dereferenced only when USE_SINKS
607 + scale,
608 + num_query_heads: tl.constexpr,
609 + num_queries_per_kv: tl.constexpr,
610 + block_table_stride: tl.int64,
611 + query_stride_0: tl.int64,
612 + query_stride_1: tl.int64,
613 + stride_cache_block: tl.int64,
614 + pit_stride_0: tl.int64,
615 + pit_stride_1: tl.int64,
616 + BLOCK_SIZE: tl.constexpr,
617 + TILE_SIZE: tl.constexpr,
618 + HEAD_SIZE: tl.constexpr,
619 + HEAD_SIZE_PADDED: tl.constexpr,
620 + BLOCK_Q: tl.constexpr,
621 + BLOCK_M: tl.constexpr,
622 + num_seqs: tl.int32,
623 + NUM_SEGMENTS_PER_SEQ: tl.constexpr,
624 + # TQ layout constants (same as 2D)
625 + MSE_BITS: tl.constexpr,
626 + MSE_BYTES: tl.constexpr,
627 + VQB: tl.constexpr,
628 + VAL_DATA_BYTES: tl.constexpr,
629 + N_CENTROIDS: tl.constexpr,
630 + KEY_FP8: tl.constexpr,
631 + USE_PAIR_LUT: tl.constexpr,
632 + # Opt#3 SoA layout constants
633 + NUM_KV_HEADS: tl.constexpr,
634 + KEY_DATA_BYTES: tl.constexpr,
635 + META_REGION_OFFSET: tl.constexpr,
636 + NUM_SOA_FIELDS: tl.constexpr,
637 + SOA_K_NORM: tl.constexpr,
638 + SOA_V_SCALE: tl.constexpr,
639 + SOA_V_ZERO: tl.constexpr,
640 + NORM_CORRECTION: tl.constexpr = 0,
641 + FP8_E4B15: tl.constexpr = 0,
642 + FUSE_Q_ROT: tl.constexpr = 0, # Fused Q @ PiT prologue when 1 (MSE path)
643 + USE_SINKS: tl.constexpr = 0, # Per-head sink logit folded into softmax denom
644 +):
645 + q_block_global_idx = tl.program_id(0)
646 + kv_head_idx = tl.program_id(1)
647 + segm_idx = tl.program_id(2)
648 +
649 + seq_idx = _find_seq_idx(
650 + query_start_len_ptr, q_block_global_idx, num_seqs, BLOCK_Q, True
651 + )
652 + q_block_start_idx = tl.load(query_start_len_ptr + seq_idx) // BLOCK_Q + seq_idx
653 + q_block_local_idx = q_block_global_idx - q_block_start_idx
654 +
655 + cur_batch_in_all_start_index = tl.load(query_start_len_ptr + seq_idx)
656 + cur_batch_in_all_stop_index = tl.load(query_start_len_ptr + seq_idx + 1)
657 + cur_batch_query_len = cur_batch_in_all_stop_index - cur_batch_in_all_start_index
658 +
659 + if q_block_local_idx * BLOCK_Q >= cur_batch_query_len:
660 + return
661 +
662 + seq_len = tl.load(seq_lens_ptr + seq_idx)
663 + tiles_per_segment = tl.cdiv(seq_len, NUM_SEGMENTS_PER_SEQ * TILE_SIZE)
664 +
665 + # Segment empty for this sequence — skip.
666 + if segm_idx * tiles_per_segment * TILE_SIZE >= seq_len:
667 + return
668 +
669 + offs_m = tl.arange(0, BLOCK_M)
670 + offs_d = tl.arange(0, HEAD_SIZE_PADDED)
671 + offs_t = tl.arange(0, TILE_SIZE)
672 + query_pos = q_block_local_idx * BLOCK_Q + offs_m // num_queries_per_kv
673 +
674 + query_offset_0 = cur_batch_in_all_start_index + query_pos
675 + query_offset_1 = kv_head_idx * num_queries_per_kv + offs_m % num_queries_per_kv
676 + query_offset = (
677 + query_offset_0[:, None] * query_stride_0
678 + + query_offset_1[:, None] * query_stride_1
679 + + offs_d[None, :]
680 + )
681 +
682 + dim_mask = tl.where(offs_d < HEAD_SIZE, 1, 0).to(tl.int1)
683 + query_mask_0 = tl.where(query_pos < cur_batch_query_len, 1, 0).to(tl.int1)
684 + query_mask_1 = tl.where(query_offset_1 < num_query_heads, 1, 0).to(tl.int1)
685 +
686 + Q = tl.load(
687 + query_ptr + query_offset,
688 + mask=dim_mask[None, :] & query_mask_0[:, None] & query_mask_1[:, None],
689 + other=0.0,
690 + )
691 +
692 + if FUSE_Q_ROT:
693 + Q = _tq_fuse_q_rotation(
694 + Q,
695 + PiT_ptr,
696 + pit_stride_0,
697 + pit_stride_1,
698 + dim_mask,
699 + HEAD_SIZE_PADDED,
700 + )
701 +
702 + block_table_offset = seq_idx * block_table_stride
703 +
704 + # Online softmax state. For the split-KV (3D) kernel we must fold the
705 + # sink logit into *only* segment 0's (M, L) so that the stage-2 reducer
706 + # — which is a standard online-softmax merge — counts the sink exactly
707 + # once. Segments with segm_idx > 0 keep the plain -inf / 1.0 init and
708 + # therefore carry no sink contribution; stage-2 then combines them.
709 + # This mirrors the v1 sink pattern from PR #40663 (sid==0 init trick).
710 + if USE_SINKS and segm_idx == 0:
711 + M = tl.load(
712 + sinks_ptr + query_offset_1, mask=query_mask_1, other=float("-inf")
713 + ).to(tl.float32)
714 + else:
715 + M = tl.full([BLOCK_M], float("-inf"), dtype=tl.float32)
716 + L = tl.full([BLOCK_M], 1.0, dtype=tl.float32)
717 + acc = tl.zeros([BLOCK_M, HEAD_SIZE_PADDED], dtype=tl.float32)
718 +
719 + context_len = seq_len - cur_batch_query_len
720 + max_seq_prefix_len = (
721 + context_len
722 + + q_block_local_idx * BLOCK_Q
723 + + (BLOCK_M - 1) // num_queries_per_kv
724 + + 1
725 + )
726 + max_seq_prefix_len = tl.minimum(max_seq_prefix_len, seq_len)
727 + num_tiles = tl.cdiv(max_seq_prefix_len, TILE_SIZE)
728 +
729 + # Segment bounds: clip to the causal prefix range.
730 + tile_lo = segm_idx * tiles_per_segment
731 + tile_hi = tl.minimum((segm_idx + 1) * tiles_per_segment, num_tiles)
732 +
733 + for j in range(tile_lo, tile_hi):
734 + seq_offset = j * TILE_SIZE + offs_t
735 + tile_mask = seq_offset < max_seq_prefix_len
736 +
737 + physical_block_idx = tl.load(
738 + block_tables_ptr + block_table_offset + seq_offset // BLOCK_SIZE
739 + ).to(tl.int64)
740 +
741 + # Opt#3 SoA addressing (see 2D kernel for the layout invariants).
742 + slot_within_block = (seq_offset % BLOCK_SIZE).to(tl.int64)
743 + block_base = physical_block_idx * stride_cache_block
744 + DATA_BYTES_PER_SLOT: tl.constexpr = KEY_DATA_BYTES + VAL_DATA_BYTES
745 + data_bases = (
746 + block_base
747 + + slot_within_block * (NUM_KV_HEADS * DATA_BYTES_PER_SLOT)
748 + + tl.cast(kv_head_idx, tl.int64) * DATA_BYTES_PER_SLOT
749 + )
750 + val_bases = data_bases + KEY_DATA_BYTES
751 +
752 + head_meta_u16_base = (block_base + META_REGION_OFFSET) // 2 + tl.cast(
753 + kv_head_idx, tl.int64
754 + ) * (NUM_SOA_FIELDS * BLOCK_SIZE)
755 + knorm_u16_addrs = (
756 + head_meta_u16_base + SOA_K_NORM * BLOCK_SIZE + slot_within_block
757 + )
758 + vscale_u16_addrs = (
759 + head_meta_u16_base + SOA_V_SCALE * BLOCK_SIZE + slot_within_block
760 + )
761 + vzero_u16_addrs = (
762 + head_meta_u16_base + SOA_V_ZERO * BLOCK_SIZE + slot_within_block
763 + )
764 +
765 + K_T = _tq_load_k_tile(
766 + KV_cache_ptr,
767 + KV_cache_u16_ptr,
768 + data_bases,
769 + knorm_u16_addrs,
770 + offs_d,
771 + dim_mask,
772 + tile_mask,
773 + Centroids_ptr,
774 + Pair_lut_ptr,
775 + OUT_DTYPE=Q.dtype,
776 + HEAD_DIM=HEAD_SIZE,
777 + BLOCK_D=HEAD_SIZE_PADDED,
778 + MSE_BITS=MSE_BITS,
779 + N_CENTROIDS=N_CENTROIDS,
780 + KEY_FP8=KEY_FP8,
781 + USE_PAIR_LUT=USE_PAIR_LUT,
782 + NORM_CORRECTION=NORM_CORRECTION,
783 + FP8_E4B15=FP8_E4B15,
784 + TILE_SIZE=TILE_SIZE,
785 + )
786 +
787 + V = _tq_load_v_tile(
788 + KV_cache_ptr,
789 + KV_cache_u16_ptr,
790 + val_bases,
791 + vscale_u16_addrs,
792 + vzero_u16_addrs,
793 + offs_d,
794 + dim_mask,
795 + tile_mask,
796 + OUT_DTYPE=Q.dtype,
797 + HEAD_DIM=HEAD_SIZE,
798 + VQB=VQB,
799 + )
800 +
801 + S = scale * tl.dot(Q, K_T)
802 +
803 + query_abs_pos = context_len + query_pos[:, None]
804 + seq_mask = seq_offset[None, :] <= query_abs_pos
805 + S = tl.where(
806 + query_mask_1[:, None] & query_mask_0[:, None] & seq_mask,
807 + S,
808 + float("-inf"),
809 + )
810 +
811 + m_j = tl.maximum(M, tl.max(S, axis=1))
812 + m_j = tl.where(m_j > float("-inf"), m_j, 0.0)
813 + P = tl.exp(S - m_j[:, None])
814 + l_j = tl.sum(P, axis=1)
815 + alpha = tl.exp(M - m_j)
816 + acc = acc * alpha[:, None]
817 + L = L * alpha + l_j
818 + M = m_j
819 +
820 + acc += tl.dot(P.to(V.dtype), V)
821 +
822 + # -------- Write partial (acc, M, L) to scratch; no normalization --------
823 + # Layout matches baseline reduce_segments so we can reuse it.
824 + segm_output_offset = (
825 + query_offset_0[:, None].to(tl.int64)
826 + * (num_query_heads * NUM_SEGMENTS_PER_SEQ * HEAD_SIZE_PADDED)
827 + + query_offset_1[:, None] * (NUM_SEGMENTS_PER_SEQ * HEAD_SIZE_PADDED)
828 + + segm_idx * HEAD_SIZE_PADDED
829 + + tl.arange(0, HEAD_SIZE_PADDED)[None, :]
830 + )
831 + tl.store(
832 + segm_output_ptr + segm_output_offset,
833 + acc,
834 + mask=dim_mask[None, :] & query_mask_0[:, None] & query_mask_1[:, None],
835 + )
836 + segm_offset = (
837 + query_offset_0.to(tl.int64) * (num_query_heads * NUM_SEGMENTS_PER_SEQ)
838 + + query_offset_1 * NUM_SEGMENTS_PER_SEQ
839 + + segm_idx
840 + )
841 + tl.store(segm_max_ptr + segm_offset, M, mask=query_mask_0 & query_mask_1)
842 + tl.store(segm_expsum_ptr + segm_offset, L, mask=query_mask_0 & query_mask_1)
843 +
844 +
845 +# ---------------------------------------------------------------------------
846 +# Launcher
847 +# ---------------------------------------------------------------------------
848 +
849 +
850 +_layout_cache: dict[tuple, dict[str, int]] = {}
851 +
852 +
853 +def _get_layout(D: int, mse_bits: int, value_quant_bits: int) -> dict[str, int]:
854 + key = (D, mse_bits, value_quant_bits)
855 + cfg = _layout_cache.get(key)
856 + if cfg is None:
857 + cfg = {
858 + "mse_bytes": math.ceil(D * mse_bits / 8),
859 + "val_data_bytes": math.ceil(D * value_quant_bits / 8),
860 + "BLOCK_D": triton.next_power_of_2(D),
861 + }
862 + _layout_cache[key] = cfg
863 + return cfg
864 +
865 +
866 +def _get_pair_lut(centroids: torch.Tensor) -> torch.Tensor:
867 + """Return a fresh pair-LUT for ``centroids`` on each call.
868 +
869 + Previously cached by ``(data_ptr, device)`` — that key is unsafe
870 + because the CUDA allocator can reuse freed addresses for different
871 + centroid tensors, yielding a stale (wrong-values) LUT. The LUT is tiny
872 + (N*N*2 fp32, ~2KB at N=16) so unconditional rebuild is essentially
873 + free compared to attention work. If this shows up on a profile,
874 + replace with a hash-of-values fingerprint, not data_ptr.
875 + """
876 + return build_pair_lut(centroids)
877 +
878 +
879 +# PiT in Q's dtype cached per (PiT-id, dtype) to avoid a host cast on every
880 +# launcher call. Keyed by the fp32 PiT's data_ptr so ownership/lifetime is
881 +# tied to the caller's tensor.
882 +_pit_qdtype_cache: dict = {}
883 +
884 +
885 +def _get_pit_in_query_dtype(PiT: torch.Tensor, qdtype: torch.dtype) -> torch.Tensor:
886 + key = (PiT.data_ptr(), qdtype)
887 + cached = _pit_qdtype_cache.get(key)
888 + if cached is None:
889 + cached = PiT.to(qdtype).contiguous()
890 + _pit_qdtype_cache[key] = cached
891 + return cached
892 +
893 +
894 +def triton_turboquant_unified_attention(
895 + query: torch.Tensor, # [num_tokens, Hq, D] - fp16/bf16
896 + kv_cache: torch.Tensor, # [num_blocks, block_size, Hk, padded_slot] uint8
897 + block_table: torch.Tensor, # [num_seqs, max_num_blocks] int32
898 + seq_lens: torch.Tensor, # [num_seqs] int32
899 + query_start_loc: torch.Tensor, # [num_seqs+1] int32
900 + Pi: torch.Tensor, # [D, D] fp32
901 + centroids: torch.Tensor, # [n_centroids] fp32
902 + scale: float,
903 + mse_bits: int,
904 + key_packed_size: int,
905 + value_quant_bits: int,
906 + value_packed_size: int, # unused; kept for signature parity with v2
907 + key_fp8: bool = False,
908 + norm_correction: bool = False,
909 + PiT: torch.Tensor | None = None,
910 + output: torch.Tensor | None = None,
911 + tile_size: int | None = None,
912 + max_query_len: int | None = None,
913 + max_seq_len: int | None = None,
914 + num_kv_splits: int | None = None,
915 + force_2d: bool = False,
916 + fuse_q_rot: bool = True,
917 + sinks: torch.Tensor | None = None, # [Hq] float — per-head sink logits
918 +) -> torch.Tensor:
919 + """Launch unified TQ attention (v3).
920 +
921 + ``query`` carries *raw* query vectors. For the MSE-key path the query
922 + has to be rotated by ``PiT`` before it can be multiplied against the
923 + (already-rotated) stored K. By default (``fuse_q_rot=True``) that
924 + rotation is done inside the attention kernel prologue as a single
925 + small MFMA — no extra dispatch, no HBM round-trip. Setting
926 + ``fuse_q_rot=False`` restores the original launcher path (fp32 rocBLAS
927 + GEMM + casts + .contiguous()), which is kept as an A/B toggle for
928 + bench harnesses; numerical results match the fused path to within a
929 + few ulp of fp32 round-off. The FP8-key path never rotates Q regardless
930 + of this flag.
931 +
932 + ``tile_size`` defaults to ``32`` for prefill (``max_query_len > 1``) and
933 + ``16`` for pure decode (``max_query_len == 1``). Callers may override.
934 +
935 + Dispatch rule:
936 + * Prefill / chunked-prefill (any query block with ``BLOCK_Q > 1``) and
937 + ``num_tokens > num_seqs``: 2D kernel (plenty of CTAs already).
938 + * Pure decode with long KV: 3D split-KV kernel + ``reduce_segments``
939 + for extra parallelism; this is the same pattern v2 uses.
940 + * ``force_2d=True``: force the 2D path regardless (used by the
941 + apples-to-apples bench to isolate pure TQ dequant overhead).
942 +
943 + ``num_kv_splits`` controls the 3D-split segment count (default ``16``).
944 +
945 + Returns ``output`` of shape ``[num_tokens, Hq, D]`` in ``query.dtype``.
946 + """
947 + assert query.dim() == 3, f"query must be [N, Hq, D], got {query.shape}"
948 + num_tokens, Hq, D = query.shape
949 + Hk = kv_cache.shape[2]
950 + block_size = kv_cache.shape[1]
951 + kv_group_size = Hq // Hk
952 + num_seqs = int(query_start_loc.shape[0] - 1)
953 + device = query.device
954 +
955 + cfg = _get_layout(D, mse_bits, value_quant_bits)
956 + _ = value_packed_size # unused
957 +
958 + # Q-rotation strategy:
959 + # * FP8-key path: no rotation at all (keys stored as fp8, no codebook).
960 + # * MSE-key path + fuse_q_rot=True (default): pass the raw query to the
961 + # kernel together with PiT and let the kernel's prologue do one
962 + # fp32 tl.dot(Q, PiT). Eliminates the launcher-side
963 + # (cast, rocBLAS GEMM, cast, contiguous) chain (~50-60 us/step at
964 + # D=64, all launch-bound — see bottleneck report §10).
965 + # * MSE-key path + fuse_q_rot=False: legacy launcher rotation. Kept
966 + # as an A/B toggle for bench harnesses.
967 + #
968 + # PiT is always materialized as fp32 D x D contiguous; the kernel loads
969 + # it as fp32 (cheap, 16KB at D=64) and computes Q @ PiT with IEEE fp32
970 + # accumulate via tl.dot.
971 + if key_fp8:
972 + q_rot = query.contiguous()
973 + apply_fuse_q_rot = False
974 + else:
975 + if PiT is None:
976 + PiT = Pi.T.contiguous()
977 + apply_fuse_q_rot = bool(fuse_q_rot)
978 + if apply_fuse_q_rot:
979 + q_rot = query.contiguous()
980 + else:
981 + q_rot = (query.float() @ PiT).to(query.dtype).contiguous()
982 +
983 + # PiT in fp32, contiguous. For the fused path this is what the kernel
984 + # loads; for the legacy path it's passed through as a harmless tensor
985 + # (never dereferenced under the FUSE_Q_ROT constexpr guard). On FP8
986 + # path Pi may be unused at the call site, so we fall back to an
987 + # arbitrary non-null tensor (reuse centroids) to satisfy Triton's
988 + # non-null pointer requirement.
989 + if (not key_fp8) and PiT is not None:
990 + PiT_f32 = PiT if PiT.dtype == torch.float32 else PiT.to(torch.float32)
991 + if not PiT_f32.is_contiguous():
992 + PiT_f32 = PiT_f32.contiguous()
993 + pit_stride_0 = PiT_f32.stride(0)
994 + pit_stride_1 = PiT_f32.stride(1)
995 + else:
996 + PiT_f32 = centroids # harmless dummy; not dereferenced when FUSE_Q_ROT=0
997 + pit_stride_0 = 0
998 + pit_stride_1 = 0
999 +
1000 + # Sinks: per-head fp32 logits, contiguous. The kernel only dereferences
1001 + # this pointer when USE_SINKS=1, so when the caller passes None we bind
1002 + # a harmless non-null tensor (centroids) to satisfy Triton's non-null
1003 + # pointer requirement. See the sink design notes in the 2D/3D kernel
1004 + # bodies and the v1 precedent from PR #40663 (sid==0 init trick).
1005 + if sinks is not None:
1006 + sinks_f32 = sinks if sinks.dtype == torch.float32 else sinks.to(torch.float32)
1007 + if not sinks_f32.is_contiguous():
1008 + sinks_f32 = sinks_f32.contiguous()
1009 + assert sinks_f32.numel() == Hq, (
1010 + f"sinks must have shape [Hq={Hq}], got numel={sinks_f32.numel()}"
1011 + )
1012 + use_sinks = True
1013 + else:
1014 + sinks_f32 = centroids # harmless dummy; not dereferenced when USE_SINKS=0
1015 + use_sinks = False
1016 +
1017 + if output is None:
1018 + output = torch.empty_like(query)
1019 +
1020 + # BLOCK_M heuristic (TQ-specific; diverges from stock unified_attention).
1021 + #
1022 + # Stock fp16 picks BLOCK_M=16 for small GQA because fp16 is ALU-saturated
1023 + # at any tile size and smaller BLOCK_M = better occupancy. TQ is a
1024 + # DIFFERENT story: the dequant chain (packed-byte loads + centroid gather
1025 + # + pair-LUT + norm/scale bitcasts) has big per-block fixed overhead, and
1026 + # the pair-LUT gather pattern is latency-bound (NOT HBM-bandwidth-bound,
1027 + # confirmed via rocprof: MemUnitStalled=0.01%, VALUBusy=56% at BM=16).
1028 + #
1029 + # For prefill/chunked (max_query_len > 1) we want BLOCK_M as large as
1030 + # possible to (a) amortize the fixed dequant overhead over more query
1031 + # tokens per CTA and (b) let MFMA saturate with a 128x32 tile shape.
1032 + # Empirical sweep on MI300X across gpt-oss (D=64) and llama (D=128):
1033 + # prefill B=1 Q=4k : BM=128 is 3.4x faster than BM=16
1034 + # chunked B=64 Q=1k C=8: BM=128 is 5.6x faster than BM=16
1035 + # With BM=128 v3 matches or BEATS the fp16 baseline on prefill/chunked.
1036 + #
1037 + # For pure decode (max_query_len == 1) BLOCK_M stays small because
1038 + # BLOCK_Q > 1 just pads rows — each sequence contributes at most 1 query
1039 + # token and larger BLOCK_M wastes lanes on masked-out rows.
1040 + if max_query_len is not None:
1041 + is_prefill_like = max_query_len > 1
1042 + else:
1043 + is_prefill_like = num_tokens > num_seqs
1044 +
1045 + if is_prefill_like:
1046 + # Prefill / chunked: use BLOCK_M=128 (rounded up to a multiple of
1047 + # kv_group_size). Each CTA does very large work (BLOCK_M x TILE_SIZE
1048 + # x KV_len MMA + dequant), so even small grids (~17 CTAs) saturate
1049 + # MI300X just fine. We verified empirically: BM=128 wins across all
1050 + # prefill/chunked sweep points, including small (B=1 Q=256 C=8k) and
1051 + # large (B=64 Q=1k C=8k; 5.6x speedup vs BM=16). BLOCK_M must be a
1052 + # multiple of kv_group_size so BLOCK_Q is an integer.
1053 + BLOCK_M = max(128, triton.next_power_of_2(kv_group_size))
1054 + else:
1055 + # Decode: BLOCK_Q > 1 in decode just pads rows (at most 1 query
1056 + # token per sequence), so there's little to amortize. Inherit
1057 + # stock's small-BLOCK_M heuristic.
1058 + BLOCK_M = 16 if kv_group_size <= 16 else triton.next_power_of_2(kv_group_size)
1059 + BLOCK_Q = BLOCK_M // kv_group_size
1060 +
1061 + # Grid: at most ceil(N / BLOCK_Q) + num_seqs q-blocks total, like unified.
1062 + total_num_q_blocks = num_tokens // BLOCK_Q + num_seqs
1063 +
1064 + # TILE_SIZE heuristic (matches stock unified_attention's _get_tile_size):
1065 + # prefill (max_query_len > 1): 32
1066 + # decode (max_query_len == 1): 16
1067 + if tile_size is None:
1068 + tile_size = 32 if is_prefill_like else 16
1069 +
1070 + # Pair-LUT fast path for 4-bit MSE keys. Skipped for FP8 and non-4-bit.
1071 + # When USE_PAIR_LUT==0 the kernel never dereferences pair_lut, but Triton
1072 + # still requires a tensor pointer, so we fall back to reusing `centroids`.
1073 + use_pair_lut = (not key_fp8) and (mse_bits == 4)
1074 + pair_lut = _get_pair_lut(centroids) if use_pair_lut else centroids
1075 +
1076 + fp8_e4b15 = _use_fp8_e4b15(device.index or 0)
1077 + num_stages = 1 if _is_hip else 2
1078 +
1079 + # uint16-aliased view of the cache. Under the Opt#3 SoA layout, K-norm /
1080 + # V-scale / V-zero live in a contiguous per-block metadata region and are
1081 + # always 2-byte aligned, so single-instruction u16 loads replace the
1082 + # original 2× uint8 + OR sequence for every per-token metadata fetch.
1083 + kv_cache_u16 = kv_cache.view(torch.uint16)
1084 +
1085 + # Opt#3 SoA layout constants (derived locally; matches the store-side
1086 + # computation so the launcher signature stays unchanged). Invariant:
1087 + # data_bytes_per_slot + meta_bytes_per_slot == slot_size_aligned.
1088 + mse_bytes = cfg["mse_bytes"]
1089 + val_data_bytes = cfg["val_data_bytes"]
1090 + key_data_bytes = D if key_fp8 else mse_bytes
1091 + data_bytes_per_slot = key_data_bytes + val_data_bytes
1092 + meta_region_offset = block_size * Hk * data_bytes_per_slot
1093 + num_soa_fields = 2 if key_fp8 else 3
1094 + soa_k_norm = 0 # unused for FP8; harmless constant
1095 + soa_v_scale = 0 if key_fp8 else 1
1096 + soa_v_zero = 1 if key_fp8 else 2
1097 +
1098 + # ------------------------------------------------------------------
1099 + # Dispatch: 2D for prefill / chunked; 3D split-KV for pure decode with
1100 + # long KV. Falls back to 2D if the sequences are short or if the caller
1101 + # forces it. ``is_prefill_like`` was already computed above for the
1102 + # BLOCK_M / tile_size heuristic; reuse it here.
1103 + # ------------------------------------------------------------------
1104 +
1105 + # Use 3D only for pure decode where the KV history is long enough that
1106 + # splitting meaningfully increases SM occupancy. Threshold is empirically
1107 + # tuned on MI300X:
1108 + # seq < 1024 -> 2D path (short KV, reduce overhead > parallelism gain)
1109 + # seq >= 1024 -> 3D path (v3/v2 ratio 1.04-1.27x across shapes)
1110 + # Below 1024 KV tokens the reduce_segments + scratch-alloc overhead
1111 + # exceeds the parallelism gain from splitting. Above it, the single-CTA
1112 + # 2D path is serial over the KV history and starves MI300X's 304 CUs.
1113 + #
1114 + # IMPORTANT: max_seq_len is expected to come from the caller (backends
1115 + # track it from the block table). We avoid calling seq_lens.max().item()
1116 + # here because that forces a GPU->CPU sync on every launcher call, which
1117 + # adds ~150us of overhead to pure-decode steps.
1118 + if max_seq_len is None:
1119 + # Fallback: infer from block_table (host-side, no sync). This is an
1120 + # upper bound, sufficient for the dispatch decision.
1121 + max_seq_len_hint = int(block_table.shape[1]) * int(block_size)
1122 + else:
1123 + max_seq_len_hint = int(max_seq_len)
1124 + use_3d = (not force_2d) and (not is_prefill_like) and max_seq_len_hint >= 1024
1125 +
1126 + if not use_3d:
1127 + kernel_tq_unified_attention_2d[(total_num_q_blocks, Hk)](
1128 + output_ptr=output,
1129 + query_ptr=q_rot,
1130 + KV_cache_ptr=kv_cache,
1131 + KV_cache_u16_ptr=kv_cache_u16,
1132 + Centroids_ptr=centroids,
1133 + Pair_lut_ptr=pair_lut,
1134 + PiT_ptr=PiT_f32,
1135 + block_tables_ptr=block_table,
1136 + seq_lens_ptr=seq_lens,
1137 + query_start_len_ptr=query_start_loc,
1138 + sinks_ptr=sinks_f32,
1139 + scale=scale,
1140 + num_query_heads=Hq,
1141 + num_queries_per_kv=kv_group_size,
1142 + block_table_stride=block_table.stride(0),
1143 + query_stride_0=q_rot.stride(0),
1144 + query_stride_1=q_rot.stride(1),
1145 + output_stride_0=output.stride(0),
1146 + output_stride_1=output.stride(1),
1147 + stride_cache_block=kv_cache.stride(0),
1148 + pit_stride_0=pit_stride_0,
1149 + pit_stride_1=pit_stride_1,
1150 + BLOCK_SIZE=block_size,
1151 + TILE_SIZE=tile_size,
1152 + HEAD_SIZE=D,
1153 + HEAD_SIZE_PADDED=cfg["BLOCK_D"],
1154 + BLOCK_Q=BLOCK_Q,
1155 + BLOCK_M=BLOCK_M,
1156 + num_seqs=num_seqs,
1157 + MSE_BITS=mse_bits,
1158 + MSE_BYTES=mse_bytes,
1159 + VQB=value_quant_bits,
1160 + VAL_DATA_BYTES=val_data_bytes,
1161 + N_CENTROIDS=int(centroids.numel()),
1162 + KEY_FP8=1 if key_fp8 else 0,
1163 + USE_PAIR_LUT=1 if use_pair_lut else 0,
1164 + NUM_KV_HEADS=Hk,
1165 + KEY_DATA_BYTES=key_data_bytes,
1166 + META_REGION_OFFSET=meta_region_offset,
1167 + NUM_SOA_FIELDS=num_soa_fields,
1168 + SOA_K_NORM=soa_k_norm,
1169 + SOA_V_SCALE=soa_v_scale,
1170 + SOA_V_ZERO=soa_v_zero,
1171 + NORM_CORRECTION=1 if norm_correction else 0,
1172 + FP8_E4B15=fp8_e4b15,
1173 + FUSE_Q_ROT=1 if apply_fuse_q_rot else 0,
1174 + USE_SINKS=1 if use_sinks else 0,
1175 + num_warps=4,
1176 + num_stages=num_stages,
1177 + )
1178 + return output
1179 +
1180 + # ---------------- 3D split-KV path ----------------
1181 + # Pick segment count. 16 is a good default for MI300X (304 CUs): at
1182 + # Hk=8, num_seqs=1, total CTAs = 1 * 8 * 16 = 128, which saturates when
1183 + # combined with warp-level parallelism. Capped at ceil(max_seq_len /
1184 + # TILE_SIZE) so we don't launch empty segments.
1185 + if num_kv_splits is None:
1186 + num_kv_splits = 16
1187 + max_possible_splits = max(1, (max_seq_len_hint + tile_size - 1) // tile_size)
1188 + num_segments = max(1, min(num_kv_splits, max_possible_splits))
1189 +
1190 + HEAD_SIZE_PADDED = cfg["BLOCK_D"]
1191 + # Scratch buffers for segment partials. fp32 to match the kernel's
1192 + # accumulator dtype. Allocated per-call for now; a production backend
1193 + # would hoist these to persistent buffers reused across layers.
1194 + segm_output = torch.empty(
1195 + (num_tokens, Hq, num_segments, HEAD_SIZE_PADDED),
1196 + dtype=torch.float32,
1197 + device=device,
1198 + )
1199 + segm_max = torch.empty(
1200 + (num_tokens, Hq, num_segments), dtype=torch.float32, device=device
1201 + )
1202 + segm_expsum = torch.empty(
1203 + (num_tokens, Hq, num_segments), dtype=torch.float32, device=device
1204 + )
1205 +
1206 + kernel_tq_unified_attention_3d[(total_num_q_blocks, Hk, num_segments)](
1207 + segm_output_ptr=segm_output,
1208 + segm_max_ptr=segm_max,
1209 + segm_expsum_ptr=segm_expsum,
1210 + query_ptr=q_rot,
1211 + KV_cache_ptr=kv_cache,
1212 + KV_cache_u16_ptr=kv_cache_u16,
1213 + Centroids_ptr=centroids,
1214 + Pair_lut_ptr=pair_lut,
1215 + PiT_ptr=PiT_f32,
1216 + block_tables_ptr=block_table,
1217 + seq_lens_ptr=seq_lens,
1218 + query_start_len_ptr=query_start_loc,
1219 + sinks_ptr=sinks_f32,
1220 + scale=scale,
1221 + num_query_heads=Hq,
1222 + num_queries_per_kv=kv_group_size,
1223 + block_table_stride=block_table.stride(0),
1224 + query_stride_0=q_rot.stride(0),
1225 + query_stride_1=q_rot.stride(1),
1226 + stride_cache_block=kv_cache.stride(0),
1227 + pit_stride_0=pit_stride_0,
1228 + pit_stride_1=pit_stride_1,
1229 + BLOCK_SIZE=block_size,
1230 + TILE_SIZE=tile_size,
1231 + HEAD_SIZE=D,
1232 + HEAD_SIZE_PADDED=HEAD_SIZE_PADDED,
1233 + BLOCK_Q=BLOCK_Q,
1234 + BLOCK_M=BLOCK_M,
1235 + num_seqs=num_seqs,
1236 + NUM_SEGMENTS_PER_SEQ=num_segments,
1237 + MSE_BITS=mse_bits,
1238 + MSE_BYTES=mse_bytes,
1239 + VQB=value_quant_bits,
1240 + VAL_DATA_BYTES=val_data_bytes,
1241 + N_CENTROIDS=int(centroids.numel()),
1242 + KEY_FP8=1 if key_fp8 else 0,
1243 + USE_PAIR_LUT=1 if use_pair_lut else 0,
1244 + NUM_KV_HEADS=Hk,
1245 + KEY_DATA_BYTES=key_data_bytes,
1246 + META_REGION_OFFSET=meta_region_offset,
1247 + NUM_SOA_FIELDS=num_soa_fields,
1248 + SOA_K_NORM=soa_k_norm,
1249 + SOA_V_SCALE=soa_v_scale,
1250 + SOA_V_ZERO=soa_v_zero,
1251 + NORM_CORRECTION=1 if norm_correction else 0,
1252 + FP8_E4B15=fp8_e4b15,
1253 + FUSE_Q_ROT=1 if apply_fuse_q_rot else 0,
1254 + USE_SINKS=1 if use_sinks else 0,
1255 + num_warps=4,
1256 + num_stages=num_stages,
1257 + )
1258 +
1259 + # Reduce: merge num_segments partials per (q_token, q_head) into the
1260 + # final output via online-softmax merge. Reusing the baseline kernel
1261 + # (KV-format-agnostic).
1262 + reduce_segments[(num_tokens, Hq)](
1263 + output_ptr=output,
1264 + segm_output_ptr=segm_output,
1265 + segm_max_ptr=segm_max,
1266 + segm_expsum_ptr=segm_expsum,
1267 + seq_lens_ptr=seq_lens,
1268 + num_seqs=num_seqs,
1269 + num_query_heads=Hq,
1270 + out_scale_inv=1.0,
1271 + output_stride_0=output.stride(0),
1272 + output_stride_1=output.stride(1),
1273 + block_table_stride=block_table.stride(0),
1274 + TILE_SIZE=tile_size,
1275 + HEAD_SIZE=D,
1276 + HEAD_SIZE_PADDED=HEAD_SIZE_PADDED,
1277 + query_start_len_ptr=query_start_loc,
1278 + BLOCK_Q=BLOCK_Q,
1279 + NUM_SEGMENTS_PER_SEQ=num_segments,
1280 + USE_FP8=False,
1281 + )
1282 + return output
1283 +
1284 +
1285 +# ---------------------------------------------------------------------------
1286 +# Thin decode adapter: lets callers who use the AoS decode launcher signature
1287 +# switch to the SoA decode without rebuilding query_start_loc themselves.
1288 +# ---------------------------------------------------------------------------
1289 +
1290 +
1291 +def triton_turboquant_decode_attention_soa(
1292 + query: torch.Tensor, # [B, Hq, D]
1293 + kv_cache: torch.Tensor,
1294 + block_table: torch.Tensor,
1295 + seq_lens: torch.Tensor,
1296 + Pi: torch.Tensor,
1297 + centroids: torch.Tensor,
1298 + scale: float,
1299 + mse_bits: int,
1300 + key_packed_size: int,
1301 + value_quant_bits: int,
1302 + value_packed_size: int,
1303 + key_fp8: bool = False,
1304 + norm_correction: bool = False,
1305 + PiT: torch.Tensor | None = None,
1306 + # kept for signature parity with v1/v2 decode launchers; unused here.
1307 + max_seq_len: int = 0,
1308 + mid_o_buf: Any = None,
1309 + output_buf: torch.Tensor | None = None,
1310 + lse_buf: Any = None,
1311 + buf_holder: Any = None,
1312 + max_num_kv_splits: int = 32,
1313 + sinks: torch.Tensor | None = None,
1314 +) -> torch.Tensor:
1315 + """Decode-only convenience wrapper around ``triton_turboquant_unified_attention``.
1316 +
1317 + Treats a rank-3 ``[B, Hq, D]`` query as one token per request (query_len = 1)
1318 + and synthesizes a ``query_start_loc`` of ``[0, 1, 2, ..., B]``.
1319 + ``max_num_kv_splits`` is forwarded to the unified launcher as the 3D
1320 + split-KV segment count (capped per-call against ``max_seq_len``).
1321 + ``sinks`` (optional ``[Hq]`` fp32) are forwarded to the kernel which
1322 + folds them into the softmax denominator via the init-time trick.
1323 + """
1324 + del mid_o_buf, lse_buf, buf_holder
1325 + B = query.shape[0]
1326 + cu_seqlens_q = torch.arange(B + 1, device=query.device, dtype=seq_lens.dtype)
1327 +
1328 + out = triton_turboquant_unified_attention(
1329 + query=query.contiguous(),
1330 + kv_cache=kv_cache,
1331 + block_table=block_table,
1332 + seq_lens=seq_lens,
1333 + query_start_loc=cu_seqlens_q,
1334 + Pi=Pi,
1335 + centroids=centroids,
1336 + scale=scale,
1337 + mse_bits=mse_bits,
1338 + key_packed_size=key_packed_size,
1339 + value_quant_bits=value_quant_bits,
1340 + value_packed_size=value_packed_size,
1341 + key_fp8=key_fp8,
1342 + norm_correction=norm_correction,
1343 + PiT=PiT,
1344 + output=output_buf[:B] if output_buf is not None else None,
1345 + max_query_len=1,
1346 + max_seq_len=max_seq_len if max_seq_len > 0 else None,
1347 + num_kv_splits=max_num_kv_splits,
1348 + sinks=sinks,
1349 + )
1350 + return out