[Attention] Re-enable cross-layer KV cache layout for MLA via stride-aware kernels (#45111)

Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai>

Yifan Qiao committed Jun 22, 2026 at 06:57 UTC aa4990a9a2024b3f93f1f26f828931f7301daa15
13 files changed +847 -21
csrc/libtorch_stable/attention/mla/sm100_cutlass_mla_kernel.cu
+5 -1
@@ -136,8 +136,12 @@ typename T::Fmha::Arguments args_from_options(
136 StrideQ stride_Q_pe = cute::make_tuple(
137 static_cast<int64_t>(q_pe.stride(1)), _1{}, static_cast<int64_t>(q_pe.stride(0)));
138
139 + // Read the token and page strides from the cache tensor instead of assuming
140 + // packed pages, so strided views (e.g. per-layer views into a cross-layer
141 + // block-major cache) are addressed correctly.
142 StrideK stride_C = cute::make_tuple(
140 - static_cast<int64_t>(0 + D_latent + D_rope), _1{}, static_cast<int64_t>(page_size * (D_latent + D_rope)));
143 + static_cast<int64_t>(kv_c_and_k_pe_cache.stride(1)), _1{},
144 + static_cast<int64_t>(kv_c_and_k_pe_cache.stride(0)));
145 StrideLSE stride_PT = cute::make_stride(_1{}, page_count_per_seq);
146 StrideLSE stride_LSE = cute::make_tuple(_1{}, 0 + H);
147 StrideO stride_O = cute::make_tuple(static_cast<int64_t>(0 + D_latent), _1{}, static_cast<int64_t>(0 + H * D_latent));
csrc/libtorch_stable/cache_kernels.cu
+6 -7
@@ -549,7 +549,7 @@ __global__ void indexer_k_quant_and_cache_kernel(
549 const int head_dim, // dimension of each head
550 const int quant_block_size, // quantization block size
551 const int cache_block_size, // cache block size
552 - const int cache_stride, // stride for each token in kv_cache
552 + const int64_t cache_block_stride, // stride for each block in kv_cache
553
554 const bool use_ue8m0 // use ue8m0 scale format
555 ) {
@@ -590,16 +590,15 @@ __global__ void indexer_k_quant_and_cache_kernel(
590 scale = exp2f(ceilf(log2f(scale)));
591 }
592
593 - const int64_t dst_offset = block_idx * cache_block_size * cache_stride +
594 - block_offset * head_dim + head_dim_idx;
593 + const int64_t dst_offset =
594 + block_idx * cache_block_stride + block_offset * head_dim + head_dim_idx;
595 for (int i = 0; i < VEC_SIZE; i++) {
596 kv_cache[dst_offset + i] =
597 fp8::scaled_convert<cache_t, scalar_t, kv_dt>(k_val_ptr[i], scale);
598 }
599 if (threadIdx.x == 0) {
600 const int64_t dst_scale_idx =
601 - block_idx * cache_block_size * cache_stride +
602 - cache_block_size * head_dim +
601 + block_idx * cache_block_stride + cache_block_size * head_dim +
602 (block_offset * head_dim + head_dim_idx) * 4 / quant_block_size;
603 reinterpret_cast<float*>(kv_cache)[dst_scale_idx / 4] = scale;
604 }
@@ -1452,7 +1451,7 @@ void cp_gather_and_upconvert_fp8_kv_cache(
1451 reinterpret_cast<KV_T*>(k.data_ptr()), \
1452 reinterpret_cast<CACHE_T*>(kv_cache.data_ptr()), \
1453 slot_mapping.const_data_ptr<int64_t>(), head_dim, quant_block_size, \
1455 - cache_block_size, cache_stride, use_ue8m0);
1454 + cache_block_size, cache_block_stride, use_ue8m0);
1455
1456 void indexer_k_quant_and_cache(
1457 torch::stable::Tensor& k, // [num_tokens, head_dim]
@@ -1463,7 +1462,7 @@ void indexer_k_quant_and_cache(
1462 int num_tokens = k.size(0);
1463 int head_dim = k.size(1);
1464 int cache_block_size = kv_cache.size(1);
1466 - int cache_stride = kv_cache.size(2);
1465 + int64_t cache_block_stride = kv_cache.stride(0);
1466 bool use_ue8m0 = scale_fmt == "ue8m0";
1467
1468 STD_TORCH_CHECK(k.device() == kv_cache.device(),
tests/kernels/attention/test_cutlass_mla_decode.py
+66
@@ -212,3 +212,69 @@ def test_cutlass_mla_decode(
212 print(
213 f"{t:.3f} ms, {FLOPS / 10**9 / t:.0f} TFLOPS,", f"{bytes / 10**6 / t:.0f} GB/s"
214 )
215 +
216 +
217 +@pytest.mark.skipif(
218 + not current_platform.has_device_capability(100),
219 + reason=CUTLASS_MLA_UNSUPPORTED_REASON,
220 +)
221 +@torch.inference_mode()
222 +def test_cutlass_mla_decode_cross_layer_view():
223 + """The kernel must read the cache's page-dim stride instead of assuming
224 + pages are packed back-to-back. A per-layer view into a cross-layer
225 + (block-major) cache has stride(0) inflated by num_layers; outputs must
226 + match a contiguous cache holding the same data exactly."""
227 + device = torch.device("cuda:0")
228 + torch.set_default_dtype(torch.bfloat16)
229 + torch.set_default_device(device)
230 + torch.manual_seed(42)
231 +
232 + b, mean_sk, d, dv, block_size = 4, 512, 576, 512, 64
233 + num_layers, layer_idx = 3, 1
234 + scale = math.sqrt(d) ** (-1)
235 +
236 + num_pages = b * (mean_sk // block_size)
237 + cache_seqlens = torch.full((b,), mean_sk, dtype=torch.int32)
238 + block_table = torch.arange(num_pages, dtype=torch.int32).view(
239 + b, mean_sk // block_size
240 + )
241 +
242 + kv_contig = torch.randn(num_pages, block_size, d)
243 + # Neighbor layers hold random data so packed-pages addressing reads
244 + # garbage rather than zeros.
245 + kv_cross_layer = torch.randn(num_pages, num_layers, block_size, d)
246 + kv_view = kv_cross_layer[:, layer_idx]
247 + kv_view.copy_(kv_contig)
248 + assert kv_view.stride(0) == num_layers * block_size * d
249 +
250 + q_nope = torch.randn(b, 128, dv)
251 + q_pe = torch.randn(b, 128, d - dv)
252 + sm_count = num_compute_units(device.index)
253 + workspace_size = ops.sm100_cutlass_mla_get_workspace_size(
254 + mean_sk, b, sm_count, num_kv_splits=1
255 + )
256 + workspace = torch.empty(workspace_size, dtype=torch.uint8)
257 +
258 + def run(cache):
259 + out = torch.empty(b, 128, dv)
260 + lse = torch.empty(b, 128, dtype=torch.float32)
261 + ops.sm100_cutlass_mla_decode(
262 + out,
263 + lse,
264 + q_nope,
265 + q_pe,
266 + cache,
267 + cache_seqlens,
268 + block_table,
269 + workspace,
270 + scale,
271 + 1,
272 + )
273 + return out, lse
274 +
275 + out_contig, lse_contig = run(kv_contig)
276 + out_view, lse_view = run(kv_view)
277 +
278 + # Same data and same compute order; only addressing differs.
279 + assert torch.equal(out_contig, out_view)
280 + assert torch.equal(lse_contig, lse_view)
tests/kernels/attention/test_mla_cross_layer_kernel_equivalence.py new
+566
@@ -0,0 +1,566 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Bit-exact kernel equivalence for MLA decode/write kernels on the
4 +cross-layer (block-major) KV cache layout.
5 +
6 +The cross-layer layout carves each layer's per-block page out of a single
7 +unified slot, so the per-layer view has an inflated ``stride(0)`` (the full
8 +unified slot) and a non-zero storage offset. These tests confirm the MLA
9 +kernels behind the backends that opt in to the layout (FlashMLA dense,
10 +FlashInfer MLA dense, FlashMLA fp8 sparse, plus the ``concat_and_cache_mla``
11 +write) honor that strided view bit-identically to a contiguous per-layer
12 +cache, and that writes do not bleed into neighbouring layers' segments.
13 +"""
14 +
15 +import pytest
16 +import torch
17 +
18 +pytestmark = pytest.mark.skipif(
19 + not torch.cuda.is_available(), reason="MLA cache kernels require CUDA"
20 +)
21 +
22 +
23 +def test_concat_and_cache_mla_into_unified_slot_view():
24 + """concat_and_cache_mla must write correctly into a per-layer view whose
25 + block stride is the full unified slot (block-major), with zero bleed into
26 + the other layers' segments of the same slot."""
27 + from vllm import _custom_ops as ops
28 +
29 + torch.manual_seed(0)
30 + dev = "cuda"
31 + kv_lora_rank = 512
32 + pe = 64
33 + entry = kv_lora_rank + pe
34 + page = 64
35 + num_blocks = 32
36 + ntok = 200
37 +
38 + kv_c = torch.randn(ntok, kv_lora_rank, device=dev, dtype=torch.bfloat16)
39 + k_pe = torch.randn(ntok, pe, device=dev, dtype=torch.bfloat16)
40 + slot = torch.randperm(num_blocks * page, device=dev, dtype=torch.int64)[:ntok]
41 + scale = torch.tensor(1.0, device=dev)
42 +
43 + def write(cache):
44 + ops.concat_and_cache_mla(kv_c, k_pe, cache, slot, "auto", scale)
45 +
46 + # Contiguous per-layer reference: (num_blocks, page, entry).
47 + ref = torch.zeros(num_blocks, page, entry, device=dev, dtype=torch.bfloat16)
48 + write(ref)
49 +
50 + # Unified slot holding three layer pages per block. Carve the middle
51 + # layer's view (non-zero offset, block stride == full unified slot).
52 + layer_page_elems = page * entry
53 + n_layers = 3
54 + unified_slot_elems = n_layers * layer_page_elems
55 + big = torch.zeros(num_blocks, unified_slot_elems, device=dev, dtype=torch.bfloat16)
56 + flat = big.view(-1)
57 + offset = layer_page_elems # middle layer
58 + view = torch.as_strided(
59 + flat,
60 + size=(num_blocks, page, entry),
61 + stride=(unified_slot_elems, entry, 1),
62 + storage_offset=offset,
63 + )
64 + assert not view.is_contiguous()
65 + assert view.stride(0) == unified_slot_elems
66 + write(view)
67 +
68 + # Bit-exact equivalence and zero bleed into the neighbour segments.
69 + max_diff = (ref.float() - view.float()).abs().max().item()
70 + assert max_diff == 0.0, f"max|Δ| = {max_diff}"
71 +
72 + neighbour_lo = torch.as_strided(
73 + flat, (num_blocks, layer_page_elems), (unified_slot_elems, 1), 0
74 + )
75 + neighbour_hi = torch.as_strided(
76 + flat,
77 + (num_blocks, layer_page_elems),
78 + (unified_slot_elems, 1),
79 + 2 * layer_page_elems,
80 + )
81 + assert neighbour_lo.abs().max().item() == 0.0
82 + assert neighbour_hi.abs().max().item() == 0.0
83 +
84 +
85 +def test_flashmla_dense_decode_unified_slot_view():
86 + """FlashMLA dense decode (FLASHMLA backend, e.g. Kimi-K2-style dense MLA
87 + on Hopper) must read a unified-slot block-major view bit-identically to a
88 + contiguous per-layer cache."""
89 + import vllm.v1.attention.ops.flashmla as fm
90 +
91 + ok, reason = fm.is_flashmla_dense_supported()
92 + if not ok:
93 + pytest.skip(reason)
94 +
95 + torch.manual_seed(0)
96 + dev = "cuda"
97 + dt = torch.bfloat16
98 + head_dim = 576
99 + hdv = 512
100 + h_q = 128
101 + page = 64
102 + num_blocks = 64
103 + bs = 4
104 + n_layers = 3
105 + layer = 1
106 +
107 + q = torch.randn(bs, 1, h_q, head_dim, device=dev, dtype=dt) * 0.1
108 + kv_data = torch.randn(num_blocks, page, 1, head_dim, device=dev, dtype=dt) * 0.1
109 +
110 + # (A) contiguous per-layer reference.
111 + cache_contiguous = kv_data.clone().contiguous()
112 +
113 + # (B) unified slot: view one layer -> inflated stride(0), non-zero offset.
114 + unified = (
115 + torch.randn(num_blocks, n_layers, page, 1, head_dim, device=dev, dtype=dt) * 0.1
116 + )
117 + unified[:, layer].copy_(kv_data)
118 + cache_view = unified[:, layer]
119 + assert not cache_view.is_contiguous()
120 + assert cache_view.stride(0) == n_layers * page * 1 * head_dim
121 +
122 + max_blk = num_blocks // bs
123 + block_table = torch.arange(num_blocks, device=dev, dtype=torch.int32).view(
124 + bs, max_blk
125 + )
126 + cache_seqlens = torch.full((bs,), max_blk * page, device=dev, dtype=torch.int32)
127 +
128 + def run(kc):
129 + meta, num_splits = fm.get_mla_metadata()
130 + out, _ = fm.flash_mla_with_kvcache(
131 + q=q,
132 + k_cache=kc,
133 + block_table=block_table,
134 + cache_seqlens=cache_seqlens,
135 + head_dim_v=hdv,
136 + tile_scheduler_metadata=meta,
137 + num_splits=num_splits,
138 + softmax_scale=head_dim**-0.5,
139 + causal=True,
140 + )
141 + return out.clone().float()
142 +
143 + out_ref = run(cache_contiguous)
144 + out_view = run(cache_view)
145 + assert torch.isfinite(out_ref).all()
146 + assert out_ref.abs().max().item() > 0.0
147 + assert (out_ref - out_view).abs().max().item() == 0.0
148 +
149 +
150 +def test_flashinfer_mla_dense_decode_unified_slot_view():
151 + """FlashInfer MLA dense decode must read a unified-slot block-major view
152 + (inflated stride(0), non-zero storage offset) bit-identically to a
153 + contiguous per-layer cache."""
154 + try:
155 + from flashinfer.decode import trtllm_batch_decode_with_kv_cache_mla
156 + except ImportError:
157 + pytest.skip("flashinfer is not available")
158 + from vllm.platforms import current_platform
159 +
160 + if not current_platform.is_device_capability_family(100):
161 + pytest.skip("FlashInfer trtllm-gen MLA requires sm100")
162 +
163 + torch.manual_seed(0)
164 + dev = "cuda"
165 + dt = torch.bfloat16
166 + kv_lora_rank = 512
167 + qk_rope_head_dim = 64
168 + qk_nope_head_dim = 128
169 + head_dim = kv_lora_rank + qk_rope_head_dim # 576
170 + num_qo_heads = 128
171 + page = 64
172 + num_blocks = 64
173 + bs = 4
174 + n_layers = 3 # >1 so the per-layer view's block stride is inflated.
175 + layer = 1
176 +
177 + q = torch.randn(bs, 1, num_qo_heads, head_dim, device=dev, dtype=dt)
178 + kv_data = torch.randn(num_blocks, 1, page, head_dim, device=dev, dtype=dt)
179 +
180 + # (A) contiguous per-layer reference.
181 + kv_contiguous = kv_data.clone().contiguous()
182 +
183 + # (B) unified slot: block b of every layer packed together; view one layer
184 + # -> stride(0) is n_layers x larger and storage offset is non-zero.
185 + unified = torch.randn(num_blocks, n_layers, 1, page, head_dim, device=dev, dtype=dt)
186 + unified[:, layer].copy_(kv_data)
187 + kv_view = unified[:, layer]
188 + assert not kv_view.is_contiguous()
189 + assert kv_view.stride(0) == n_layers * 1 * page * head_dim
190 +
191 + max_blk = num_blocks // bs
192 + block_tables = torch.arange(num_blocks, device=dev, dtype=torch.int32).view(
193 + bs, max_blk
194 + )
195 + seq_lens = torch.full((bs,), max_blk * page, device=dev, dtype=torch.int32)
196 + ws = torch.empty(128 * 1024 * 1024, dtype=torch.int8, device=dev)
197 + scale = head_dim**-0.5
198 +
199 + def run(kv):
200 + return trtllm_batch_decode_with_kv_cache_mla(
201 + query=q,
202 + kv_cache=kv,
203 + workspace_buffer=ws,
204 + qk_nope_head_dim=qk_nope_head_dim,
205 + kv_lora_rank=kv_lora_rank,
206 + qk_rope_head_dim=qk_rope_head_dim,
207 + block_tables=block_tables,
208 + seq_lens=seq_lens,
209 + max_seq_len=int(seq_lens.max().item()),
210 + bmm1_scale=scale,
211 + bmm2_scale=1.0,
212 + ).clone()
213 +
214 + out_ref = run(kv_contiguous).float()
215 + out_view = run(kv_view).float()
216 + assert torch.isfinite(out_ref).all()
217 + assert (out_ref - out_view).abs().max().item() == 0.0
218 +
219 +
220 +def test_flashmla_fp8_sparse_decode_unified_slot_view():
221 + """FlashMLA fp8 sparse decode (DeepSeek V3.2/V4 DSA path) must read a
222 + unified-slot block-major view bit-identically to a contiguous fp8_ds_mla
223 + cache, with finite nonzero output."""
224 + import vllm.v1.attention.ops.flashmla as fm
225 +
226 + ok, reason = fm.is_flashmla_sparse_supported()
227 + if not ok:
228 + pytest.skip(reason)
229 +
230 + torch.manual_seed(0)
231 + dev = "cuda"
232 + entry = 656 # fp8_ds_mla bytes per token
233 + page = 64
234 + num_blocks = 32
235 + h_q = 128
236 + head_dim = 576
237 + hdv = 512
238 + batch = 2
239 + topk = 128
240 + n_layers = 3
241 + layer = 1
242 +
243 + q = torch.randn(batch, 1, h_q, head_dim, device=dev, dtype=torch.bfloat16) * 0.1
244 +
245 + # Structurally valid fp8 ds_mla payload: 512B fp8 + 16B f32 scales + 128B
246 + # bf16 rope (random bytes corrupt the scale region and yield NaNs).
247 + nope = (torch.randn(num_blocks, page, 1, 512, device=dev) * 0.1).to(
248 + torch.float8_e4m3fn
249 + )
250 + scales = torch.ones(num_blocks, page, 1, 4, device=dev, dtype=torch.float32)
251 + rope = (torch.randn(num_blocks, page, 1, 64, device=dev) * 0.1).to(torch.bfloat16)
252 + payload = torch.cat(
253 + [
254 + nope.view(torch.uint8).view(num_blocks, page, 1, 512),
255 + scales.view(torch.uint8).view(num_blocks, page, 1, 16),
256 + rope.view(torch.uint8).view(num_blocks, page, 1, 128),
257 + ],
258 + dim=-1,
259 + ).contiguous()
260 + assert payload.shape[-1] == entry and payload.dtype == torch.uint8
261 +
262 + # (A) contiguous reference.
263 + cache_contiguous = payload.clone().contiguous()
264 +
265 + # (B) unified slot: view one layer -> inflated stride(0), non-zero offset.
266 + unified = torch.randint(
267 + 0, 256, (num_blocks, n_layers, page, 1, entry), device=dev, dtype=torch.uint8
268 + )
269 + unified[:, layer].copy_(payload)
270 + cache_view = unified[:, layer]
271 + assert not cache_view.is_contiguous()
272 + assert cache_view.stride(0) == n_layers * page * 1 * entry
273 +
274 + # Sparse indices: each batch uses its own disjoint blocks.
275 + blocks_per_batch = num_blocks // batch
276 + idx = torch.full((batch, 1, topk), -1, device=dev, dtype=torch.int32)
277 + for b in range(batch):
278 + slots: list[int] = []
279 + for blk in range(b * blocks_per_batch, (b + 1) * blocks_per_batch):
280 + slots.extend(blk * page + off for off in range(page))
281 + slots_t = torch.tensor(slots[:topk], device=dev, dtype=torch.int32)
282 + idx[b, 0, : slots_t.numel()] = slots_t
283 +
284 + def run(kc):
285 + meta, num_splits = fm.get_mla_metadata()
286 + out, _ = fm.flash_mla_with_kvcache(
287 + q=q,
288 + k_cache=kc,
289 + block_table=None,
290 + cache_seqlens=None,
291 + head_dim_v=hdv,
292 + tile_scheduler_metadata=meta,
293 + is_fp8_kvcache=True,
294 + indices=idx,
295 + softmax_scale=head_dim**-0.5,
296 + )
297 + return out.clone().float()
298 +
299 + out_ref = run(cache_contiguous)
300 + out_view = run(cache_view)
301 + assert torch.isfinite(out_ref).all()
302 + assert out_ref.abs().max().item() > 0.0
303 + assert (out_ref - out_view).abs().max().item() == 0.0
304 +
305 +
306 +def test_indexer_k_quant_and_cache_into_unified_slot_view():
307 + """indexer_k_quant_and_cache (DeepSeek V3.2/V4 DSA indexer K write) must
308 + write correctly into a per-layer view whose block stride is the full
309 + unified slot, with zero bleed into the other layers' segments."""
310 + from vllm import _custom_ops as ops
311 +
312 + torch.manual_seed(0)
313 + dev = "cuda"
314 + head_dim = 128
315 + quant_block_size = 128
316 + block_size = 64
317 + num_blocks = 16
318 + ntok = 100
319 + # Indexer cache layout per token: head_dim fp8 bytes followed by
320 + # head_dim * 4 / quant_block_size scale bytes.
321 + cache_stride = head_dim + head_dim * 4 // quant_block_size
322 +
323 + k = torch.randn(ntok, head_dim, device=dev, dtype=torch.bfloat16)
324 + slot = torch.randperm(num_blocks * block_size, device=dev, dtype=torch.int64)[:ntok]
325 +
326 + def write(cache):
327 + ops.indexer_k_quant_and_cache(k, cache, slot, quant_block_size, "ue8m0")
328 +
329 + # Contiguous per-layer reference.
330 + ref = torch.zeros(
331 + num_blocks, block_size, cache_stride, device=dev, dtype=torch.uint8
332 + )
333 + write(ref)
334 +
335 + # Unified slot holding three layer pages per block; carve the middle one.
336 + n_layers = 3
337 + layer = 1
338 + unified = torch.zeros(
339 + num_blocks, n_layers, block_size, cache_stride, device=dev, dtype=torch.uint8
340 + )
341 + view = unified[:, layer]
342 + assert not view.is_contiguous()
343 + assert view.stride(0) == n_layers * block_size * cache_stride
344 + write(view)
345 +
346 + assert torch.equal(ref, view.contiguous())
347 + # Zero bleed into the neighbour layers' segments.
348 + assert unified[:, 0].abs().max().item() == 0
349 + assert unified[:, 2].abs().max().item() == 0
350 +
351 +
352 +def test_flashattn_mla_dense_decode_unified_slot_view():
353 + """FA3 decode (FLASH_ATTN_MLA backend) must read a unified-slot
354 + block-major view bit-identically to a contiguous per-layer cache."""
355 + try:
356 + from vllm.vllm_flash_attn import flash_attn_varlen_func
357 + except ImportError:
358 + pytest.skip("vllm_flash_attn is not available")
359 + from vllm.v1.attention.backends.fa_utils import flash_attn_supports_mla
360 +
361 + if not flash_attn_supports_mla():
362 + pytest.skip("FA3 MLA requires a Hopper device")
363 +
364 + torch.manual_seed(0)
365 + dev = "cuda"
366 + dt = torch.bfloat16
367 + kv_lora_rank = 512
368 + rope_dim = 64
369 + entry = kv_lora_rank + rope_dim # 576
370 + h_q = 16
371 + page = 64
372 + num_blocks = 64
373 + bs = 4
374 + n_layers = 3
375 + layer = 1
376 +
377 + q_pe = torch.randn(bs, h_q, rope_dim, device=dev, dtype=dt) * 0.1
378 + q_nope = torch.randn(bs, h_q, kv_lora_rank, device=dev, dtype=dt) * 0.1
379 + kv_data = torch.randn(num_blocks, page, entry, device=dev, dtype=dt) * 0.1
380 +
381 + # (A) contiguous per-layer reference.
382 + cache_contiguous = kv_data.clone().contiguous()
383 +
384 + # (B) unified slot: view one layer -> inflated stride(0), non-zero offset.
385 + unified = torch.randn(num_blocks, n_layers, page, entry, device=dev, dtype=dt) * 0.1
386 + unified[:, layer].copy_(kv_data)
387 + cache_view = unified[:, layer]
388 + assert not cache_view.is_contiguous()
389 + assert cache_view.stride(0) == n_layers * page * entry
390 +
391 + max_blk = num_blocks // bs
392 + block_table = torch.arange(num_blocks, device=dev, dtype=torch.int32).view(
393 + bs, max_blk
394 + )
395 + seq_lens = torch.full((bs,), max_blk * page, device=dev, dtype=torch.int32)
396 + cu_seqlens_q = torch.arange(bs + 1, device=dev, dtype=torch.int32)
397 +
398 + def run(cache):
399 + kv_c_cache = cache[..., :kv_lora_rank]
400 + k_pe_cache = cache[..., kv_lora_rank:]
401 + out = flash_attn_varlen_func(
402 + q=q_pe,
403 + k=k_pe_cache.unsqueeze(-2), # Add head dim of 1
404 + v=kv_c_cache.unsqueeze(-2), # Add head dim of 1
405 + q_v=q_nope,
406 + max_seqlen_q=1,
407 + cu_seqlens_q=cu_seqlens_q,
408 + max_seqlen_k=int(seq_lens.max().item()),
409 + seqused_k=seq_lens,
410 + block_table=block_table,
411 + softmax_scale=entry**-0.5,
412 + causal=True,
413 + fa_version=3,
414 + )
415 + return out.clone().float()
416 +
417 + out_ref = run(cache_contiguous)
418 + out_view = run(cache_view)
419 + assert torch.isfinite(out_ref).all()
420 + assert out_ref.abs().max().item() > 0.0
421 + assert (out_ref - out_view).abs().max().item() == 0.0
422 +
423 +
424 +def test_flashmla_dense_fp8_decode_unified_slot_view():
425 + """FlashMLA dense fp8 decode (FLASHMLA backend with quantized KV cache)
426 + must read a unified-slot block-major view bit-identically to a contiguous
427 + per-layer fp8 cache."""
428 + import vllm.v1.attention.ops.flashmla as fm
429 +
430 + ok, reason = fm.is_flashmla_dense_supported()
431 + if not ok:
432 + pytest.skip(reason)
433 +
434 + torch.manual_seed(0)
435 + dev = "cuda"
436 + head_dim = 576
437 + hdv = 512
438 + h_q = 128
439 + page = 64
440 + num_blocks = 64
441 + bs = 4
442 + n_layers = 3
443 + layer = 1
444 +
445 + q = torch.randn(bs, 1, h_q, head_dim, device=dev, dtype=torch.bfloat16) * 0.1
446 + kv_data = (torch.randn(num_blocks, page, head_dim, device=dev) * 0.1).to(
447 + torch.float8_e4m3fn
448 + )
449 +
450 + # (A) contiguous per-layer reference.
451 + cache_contiguous = kv_data.clone().contiguous()
452 +
453 + # (B) unified slot: view one layer -> inflated stride(0), non-zero offset.
454 + unified = (torch.randn(num_blocks, n_layers, page, head_dim, device=dev) * 0.1).to(
455 + torch.float8_e4m3fn
456 + )
457 + unified[:, layer].copy_(kv_data)
458 + cache_view = unified[:, layer]
459 + assert not cache_view.is_contiguous()
460 + assert cache_view.stride(0) == n_layers * page * head_dim
461 +
462 + max_blk = num_blocks // bs
463 + block_table = torch.arange(num_blocks, device=dev, dtype=torch.int32).view(
464 + bs, max_blk
465 + )
466 + cache_seqlens = torch.full((bs,), max_blk * page, device=dev, dtype=torch.int32)
467 + descale = torch.ones(1, device=dev, dtype=torch.float32)
468 +
469 + def run(kc):
470 + tile_md, num_splits = fm.get_mla_metadata_dense_fp8(cache_seqlens, h_q, 1)
471 + out, _ = fm.flash_mla_with_kvcache_fp8(
472 + q=q,
473 + k_cache=kc.unsqueeze(-2), # Add head dim of 1
474 + block_table=block_table,
475 + cache_seqlens=cache_seqlens,
476 + head_dim_v=hdv,
477 + tile_scheduler_metadata=tile_md,
478 + num_splits=num_splits,
479 + softmax_scale=head_dim**-0.5,
480 + causal=True,
481 + descale_q=descale,
482 + descale_k=descale,
483 + )
484 + return out.clone().float()
485 +
486 + out_ref = run(cache_contiguous)
487 + out_view = run(cache_view)
488 + assert torch.isfinite(out_ref).all()
489 + assert out_ref.abs().max().item() > 0.0
490 + assert (out_ref - out_view).abs().max().item() == 0.0
491 +
492 +
493 +def test_flashinfer_mla_dense_fp8_decode_unified_slot_view():
494 + """FlashInfer MLA dense decode with an fp8 KV cache must read a
495 + unified-slot block-major view bit-identically to a contiguous per-layer
496 + cache."""
497 + try:
498 + from flashinfer.decode import trtllm_batch_decode_with_kv_cache_mla
499 + except ImportError:
500 + pytest.skip("flashinfer is not available")
501 + from vllm.platforms import current_platform
502 +
503 + if not current_platform.is_device_capability_family(100):
504 + pytest.skip("FlashInfer trtllm-gen MLA requires sm100")
505 +
506 + torch.manual_seed(0)
507 + dev = "cuda"
508 + kv_lora_rank = 512
509 + qk_rope_head_dim = 64
510 + qk_nope_head_dim = 128
511 + head_dim = kv_lora_rank + qk_rope_head_dim # 576
512 + num_qo_heads = 128
513 + page = 64
514 + num_blocks = 64
515 + bs = 4
516 + n_layers = 3
517 + layer = 1
518 +
519 + # With a quantized KV cache the decode query is quantized to fp8 as well
520 + # (trtllm-gen has no bf16-query x fp8-cache decode kernel).
521 + q = (torch.randn(bs, 1, num_qo_heads, head_dim, device=dev) * 0.1).to(
522 + torch.float8_e4m3fn
523 + )
524 + kv_data = (torch.randn(num_blocks, 1, page, head_dim, device=dev) * 0.1).to(
525 + torch.float8_e4m3fn
526 + )
527 +
528 + # (A) contiguous per-layer reference.
529 + kv_contiguous = kv_data.clone().contiguous()
530 +
531 + # (B) unified slot: view one layer -> inflated stride(0), non-zero offset.
532 + unified = (
533 + torch.randn(num_blocks, n_layers, 1, page, head_dim, device=dev) * 0.1
534 + ).to(torch.float8_e4m3fn)
535 + unified[:, layer].copy_(kv_data)
536 + kv_view = unified[:, layer]
537 + assert not kv_view.is_contiguous()
538 + assert kv_view.stride(0) == n_layers * 1 * page * head_dim
539 +
540 + max_blk = num_blocks // bs
541 + block_tables = torch.arange(num_blocks, device=dev, dtype=torch.int32).view(
542 + bs, max_blk
543 + )
544 + seq_lens = torch.full((bs,), max_blk * page, device=dev, dtype=torch.int32)
545 + ws = torch.empty(128 * 1024 * 1024, dtype=torch.int8, device=dev)
546 + scale = head_dim**-0.5
547 +
548 + def run(kv):
549 + return trtllm_batch_decode_with_kv_cache_mla(
550 + query=q,
551 + kv_cache=kv,
552 + workspace_buffer=ws,
553 + qk_nope_head_dim=qk_nope_head_dim,
554 + kv_lora_rank=kv_lora_rank,
555 + qk_rope_head_dim=qk_rope_head_dim,
556 + block_tables=block_tables,
557 + seq_lens=seq_lens,
558 + max_seq_len=int(seq_lens.max().item()),
559 + bmm1_scale=scale,
560 + bmm2_scale=1.0,
561 + ).clone()
562 +
563 + out_ref = run(kv_contiguous).float()
564 + out_view = run(kv_view).float()
565 + assert torch.isfinite(out_ref).all()
566 + assert (out_ref - out_view).abs().max().item() == 0.0
tests/kernels/attention/test_triton_decode_attention.py
+92
@@ -231,3 +231,95 @@ def test_decode_attention_fp8(B, L, H_Q, H_KV, D_QK, D_V, CACHE_SIZE, PAGE_SIZE)
231
232 # FP8 tolerances match test_mla_backends.py test_backend_correctness.
233 torch.testing.assert_close(o_ref, o_fp8, atol=5e-1, rtol=1e-2)
234 +
235 +
236 +@pytest.mark.parametrize(
237 + "H_Q,H_KV,D_QK,D_V,is_mla",
238 + [
239 + (16, 1, 576, 512, True), # MLA path (grouped kernel, v = trans(k))
240 + (32, 8, 128, 128, False), # GQA path (grouped kernel)
241 + (32, 32, 128, 128, False), # MHA path (normal kernel)
242 + ],
243 +)
244 +@pytest.mark.parametrize("PAGE_SIZE", [16])
245 +def test_decode_attention_cross_layer_view(H_Q, H_KV, D_QK, D_V, is_mla, PAGE_SIZE):
246 + """The kernel must honor the cache's page-dim stride, not assume pages are
247 + packed back-to-back. A per-layer view into a cross-layer (block-major)
248 + cache has stride(0) inflated by num_layers; outputs must match a
249 + contiguous cache holding the same data exactly."""
250 + B = 3
251 + seq_len = 1027
252 + CACHE_SIZE = 16384
253 + NUM_LAYERS = 3
254 + LAYER_IDX = 1
255 + dtype = torch.bfloat16
256 + sm_scale = 1.0 / (D_QK**0.5)
257 + num_kv_splits = 8
258 + num_pages = CACHE_SIZE // PAGE_SIZE
259 +
260 + num_pages_per_batch = cdiv(seq_len, PAGE_SIZE)
261 + req_to_page = torch.randint(
262 + 0, num_pages, (B, num_pages_per_batch), device=DEVICE_TYPE
263 + )
264 +
265 + q = torch.randn(B, H_Q, D_QK, dtype=dtype, device=DEVICE_TYPE)
266 + b_seq_len = torch.full((B,), seq_len, device=DEVICE_TYPE)
267 +
268 + # Reference: contiguous paged cache.
269 + k_ref = torch.randn(
270 + num_pages, PAGE_SIZE, H_KV, D_QK, dtype=dtype, device=DEVICE_TYPE
271 + )
272 + if is_mla:
273 + v_ref = k_ref[..., :D_V]
274 + else:
275 + v_ref = torch.randn(
276 + num_pages, PAGE_SIZE, H_KV, D_V, dtype=dtype, device=DEVICE_TYPE
277 + )
278 +
279 + # Cross-layer cache: all layers' pages for a block are adjacent. The
280 + # per-layer view has the same shape as the contiguous cache but
281 + # stride(0) is NUM_LAYERS x larger. Neighbor layers hold random data so
282 + # any packed-pages addressing reads garbage rather than zeros.
283 + k_xl = torch.randn(
284 + num_pages, NUM_LAYERS, PAGE_SIZE, H_KV, D_QK, dtype=dtype, device=DEVICE_TYPE
285 + )
286 + k_view = k_xl[:, LAYER_IDX]
287 + k_view.copy_(k_ref)
288 + assert k_view.stride(0) == NUM_LAYERS * PAGE_SIZE * H_KV * D_QK
289 + if is_mla:
290 + v_view = k_view[..., :D_V]
291 + else:
292 + v_xl = torch.randn(
293 + num_pages, NUM_LAYERS, PAGE_SIZE, H_KV, D_V, dtype=dtype, device=DEVICE_TYPE
294 + )
295 + v_view = v_xl[:, LAYER_IDX]
296 + v_view.copy_(v_ref)
297 +
298 + def run(k_buffer, v_buffer):
299 + o = torch.zeros(B, H_Q, D_V, dtype=dtype, device=DEVICE_TYPE)
300 + lse = torch.zeros(B, H_Q, dtype=dtype, device=DEVICE_TYPE)
301 + attn_logits = torch.empty(
302 + (B, H_Q, num_kv_splits, D_V + 1), dtype=torch.float32, device=DEVICE_TYPE
303 + )
304 + decode_attention_fwd(
305 + q,
306 + k_buffer,
307 + v_buffer,
308 + o,
309 + lse,
310 + req_to_page,
311 + b_seq_len,
312 + attn_logits,
313 + num_kv_splits,
314 + sm_scale,
315 + PAGE_SIZE,
316 + is_mla=is_mla,
317 + )
318 + return o, lse
319 +
320 + o_ref, lse_ref = run(k_ref, v_ref)
321 + o_xl, lse_xl = run(k_view, v_view)
322 +
323 + # Same data and same compute order; only addressing differs.
324 + assert torch.equal(o_ref, o_xl)
325 + assert torch.equal(lse_ref, lse_xl)
tests/v1/kv_connector/unit/test_kv_cache_layout.py
+35 -3
@@ -1,10 +1,13 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +import pytest
5
5 -def test_mla_backend_rejects_cross_layer_kv_cache():
6 - """MLA backends return identity permutation (layers dim first)
7 - to signal cross-layer KV cache is unsupported."""
6 +
7 +def test_mla_common_backend_rejects_cross_layer_kv_cache():
8 + """MLACommonBackend defaults to the identity permutation (layers dim
9 + first) so MLA backends whose decode kernels are not verified to honor
10 + the cache's block-dim stride stay opted out of cross-layer KV cache."""
11 from vllm.model_executor.layers.attention.mla_attention import (
12 MLACommonBackend,
13 )
@@ -19,6 +22,35 @@ def test_mla_backend_rejects_cross_layer_kv_cache():
22 ) == (0, 1, 2)
23
24
25 +@pytest.mark.parametrize(
26 + "backend_path",
27 + [
28 + "vllm.v1.attention.backends.mla.triton_mla.TritonMLABackend",
29 + "vllm.v1.attention.backends.mla.cutlass_mla.CutlassMLABackend",
30 + "vllm.v1.attention.backends.mla.flashattn_mla.FlashAttnMLABackend",
31 + "vllm.v1.attention.backends.mla.flashmla.FlashMLABackend",
32 + "vllm.v1.attention.backends.mla.flashinfer_mla.FlashInferMLABackend",
33 + ],
34 +)
35 +def test_verified_mla_backends_support_cross_layer_kv_cache(backend_path):
36 + """Backends whose decode kernels honor the cache's block-dim stride opt
37 + in to the cross-layer layout with a non-identity permutation placing
38 + num_blocks first in physical layout."""
39 + module_path, name = backend_path.rsplit(".", 1)
40 + backend = getattr(
41 + pytest.importorskip(module_path, reason="backend deps unavailable"), name
42 + )
43 +
44 + stride_order = backend.get_kv_cache_stride_order(include_num_layers_dimension=True)
45 + assert stride_order == (1, 0, 2, 3)
46 + assert stride_order[0] != 0 # num_blocks first => cross-layer supported
47 + assert backend.get_kv_cache_stride_order(include_num_layers_dimension=False) == (
48 + 0,
49 + 1,
50 + 2,
51 + )
52 +
53 +
54 def test_deepseek_v32_indexer_rejects_cross_layer_kv_cache():
55 """DeepseekV32Indexer returns identity permutation (layers dim first)
56 to signal cross-layer KV cache is unsupported."""
vllm/model_executor/layers/attention/mla_attention.py
+3 -3
@@ -1212,9 +1212,9 @@ class MLACommonBackend(AttentionBackend):
1212 include_num_layers_dimension: bool = False,
1213 ) -> tuple[int, ...]:
1214 if include_num_layers_dimension:
1215 - # MLA kernels require contiguous per-layer KV cache views.
1216 - # Identity permutation keeps num_layers first in physical
1217 - # layout, signaling cross-layer allocation is unsupported.
1215 + # Default to identity permutation to signal cross-layer allocation
1216 + # is unsupported. Each MLA backend must opt in to support cross-layer
1217 + # allocation by overriding this method.
1218 return (0, 1, 2, 3)
1219 return (0, 1, 2)
1220
vllm/v1/attention/backends/mla/cutlass_mla.py
+8
@@ -49,6 +49,14 @@ class CutlassMLABackend(MLACommonBackend):
49 def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
50 return [128]
51
52 + @staticmethod
53 + def get_kv_cache_stride_order(
54 + include_num_layers_dimension: bool = False,
55 + ) -> tuple[int, ...]:
56 + if include_num_layers_dimension:
57 + return (1, 0, 2, 3)
58 + return (0, 1, 2)
59 +
60 @staticmethod
61 def get_name() -> str:
62 return "CUTLASS_MLA"
vllm/v1/attention/backends/mla/flashattn_mla.py
+8
@@ -52,6 +52,14 @@ class FlashAttnMLABackend(MLACommonBackend):
52 def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
53 return [MultipleOf(16)]
54
55 + @staticmethod
56 + def get_kv_cache_stride_order(
57 + include_num_layers_dimension: bool = False,
58 + ) -> tuple[int, ...]:
59 + if include_num_layers_dimension:
60 + return (1, 0, 2, 3)
61 + return (0, 1, 2)
62 +
63 @staticmethod
64 def get_name() -> str:
65 return "FLASH_ATTN_MLA"
vllm/v1/attention/backends/mla/flashinfer_mla.py
+8
@@ -49,6 +49,14 @@ class FlashInferMLABackend(MLACommonBackend):
49 def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
50 return [32, 64]
51
52 + @staticmethod
53 + def get_kv_cache_stride_order(
54 + include_num_layers_dimension: bool = False,
55 + ) -> tuple[int, ...]:
56 + if include_num_layers_dimension:
57 + return (1, 0, 2, 3)
58 + return (0, 1, 2)
59 +
60 @staticmethod
61 def get_name() -> str:
62 return "FLASHINFER_MLA"
vllm/v1/attention/backends/mla/flashmla.py
+8
@@ -58,6 +58,14 @@ class FlashMLABackend(MLACommonBackend):
58 def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
59 return [64]
60
61 + @staticmethod
62 + def get_kv_cache_stride_order(
63 + include_num_layers_dimension: bool = False,
64 + ) -> tuple[int, ...]:
65 + if include_num_layers_dimension:
66 + return (1, 0, 2, 3)
67 + return (0, 1, 2)
68 +
69 @staticmethod
70 def get_name() -> str:
71 return "FLASHMLA"
vllm/v1/attention/backends/mla/triton_mla.py
+8
@@ -57,6 +57,14 @@ class TritonMLABackend(MLACommonBackend):
57 return True
58 return block_size % 16 == 0
59
60 + @staticmethod
61 + def get_kv_cache_stride_order(
62 + include_num_layers_dimension: bool = False,
63 + ) -> tuple[int, ...]:
64 + if include_num_layers_dimension:
65 + return (1, 0, 2, 3)
66 + return (0, 1, 2)
67 +
68 @staticmethod
69 def get_name() -> str:
70 return "TRITON_MLA"
vllm/v1/attention/ops/triton_decode_attention.py
+34 -7
@@ -56,6 +56,15 @@ def tanh(x):
56 return 2 * tl.sigmoid(2 * x) - 1
57
58
59 +def _page_stride(buf, page_size):
60 + # Stride between pages. 4D buffers have a page dim; 3D buffers pack pages
61 + # along the token dim, so split it out first. Read the real stride (a
62 + # cross-layer view has gaps), don't assume PAGE_SIZE * token stride.
63 + if buf.ndim == 3:
64 + buf = buf.unflatten(-3, (-1, page_size))
65 + return buf.stride(-4)
66 +
67 +
68 @triton.jit
69 def _fwd_kernel_stage1(
70 Q,
@@ -68,8 +77,10 @@ def _fwd_kernel_stage1(
77 stride_req_to_tokens_b,
78 stride_qbs,
79 stride_qh,
80 + stride_buf_kpbs,
81 stride_buf_kbs,
82 stride_buf_kh,
83 + stride_buf_vpbs,
84 stride_buf_vbs,
85 stride_buf_vh,
86 stride_mid_ob,
@@ -123,9 +134,11 @@ def _fwd_kernel_stage1(
134 mask=offs_n < split_kv_end,
135 other=0,
136 )
126 - kv_loc = kv_page_number * PAGE_SIZE + offs_n % PAGE_SIZE
137 + kv_in_page = offs_n % PAGE_SIZE
138 offs_buf_k = (
128 - kv_loc[:, None] * stride_buf_kbs
139 + (kv_page_number * stride_buf_kpbs + kv_in_page * stride_buf_kbs)[
140 + :, None
141 + ]
142 + cur_kv_head * stride_buf_kh
143 + offs_d[None, :]
144 )
@@ -145,7 +158,9 @@ def _fwd_kernel_stage1(
158 qk = tl.where(offs_n < split_kv_end, qk, float("-inf"))
159
160 offs_buf_v = (
148 - kv_loc[:, None] * stride_buf_vbs
161 + (kv_page_number * stride_buf_vpbs + kv_in_page * stride_buf_vbs)[
162 + :, None
163 + ]
164 + cur_kv_head * stride_buf_vh
165 + offs_dv[None, :]
166 )
@@ -235,8 +250,10 @@ def _decode_att_m_fwd(
250 Req_to_tokens.stride(0),
251 q.stride(0),
252 q.stride(1),
253 + _page_stride(k_buffer, page_size),
254 k_buffer.stride(-3), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM)
255 k_buffer.stride(-2), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM)
256 + _page_stride(v_buffer, page_size),
257 v_buffer.stride(-3), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM)
258 v_buffer.stride(-2), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM)
259 att_out.stride(0),
@@ -270,8 +287,10 @@ def _fwd_grouped_kernel_stage1(
287 stride_req_to_tokens_b,
288 stride_qbs,
289 stride_qh,
290 + stride_buf_kpbs,
291 stride_buf_kbs,
292 stride_buf_kh,
293 + stride_buf_vpbs,
294 stride_buf_vbs,
295 stride_buf_vh,
296 stride_mid_ob,
@@ -357,10 +376,12 @@ def _fwd_grouped_kernel_stage1(
376 other=0,
377 cache_modifier=".ca",
378 )
360 - kv_loc = kv_page_number * PAGE_SIZE + offs_n % PAGE_SIZE
379 + kv_off_k = (
380 + kv_page_number * stride_buf_kpbs + (offs_n % PAGE_SIZE) * stride_buf_kbs
381 + )
382
383 # explicitly facilitate overlapping load/compute
363 - offs_buf_k = kv_loc[None, :] * stride_buf_kbs + base_offs_k
384 + offs_buf_k = kv_off_k[None, :] + base_offs_k
385 k = tl.load(
386 K_Buffer + offs_buf_k,
387 mask=(offs_n[None, :] < split_kv_end) & (mask_d[:, None]),
@@ -372,7 +393,7 @@ def _fwd_grouped_kernel_stage1(
393 k = (k.to(tl.float32) * ks).to(q.dtype)
394 qk = tl.dot(q, k.to(q.dtype))
395 if BLOCK_DPE > 0:
375 - offs_buf_kpe = kv_loc[None, :] * stride_buf_kbs + base_offs_kpe
396 + offs_buf_kpe = kv_off_k[None, :] + base_offs_kpe
397 kpe = tl.load(
398 K_Buffer + offs_buf_kpe,
399 mask=(offs_n[None, :] < split_kv_end) & (mask_dpe[:, None]),
@@ -392,7 +413,11 @@ def _fwd_grouped_kernel_stage1(
413 )
414
415 if not IS_MLA:
395 - offs_buf_v = kv_loc[:, None] * stride_buf_vbs + base_offs_v
416 + kv_off_v = (
417 + kv_page_number * stride_buf_vpbs
418 + + (offs_n % PAGE_SIZE) * stride_buf_vbs
419 + )
420 + offs_buf_v = kv_off_v[:, None] + base_offs_v
421 v = tl.load(
422 V_Buffer + offs_buf_v,
423 mask=(offs_n[:, None] < split_kv_end) & (mask_dv[None, :]),
@@ -517,8 +542,10 @@ def _decode_grouped_att_m_fwd(
542 Req_to_tokens.stride(0),
543 q.stride(0),
544 q.stride(1),
545 + _page_stride(k_buffer, page_size),
546 k_buffer.stride(-3), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM)
547 k_buffer.stride(-2), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM)
548 + _page_stride(v_buffer, page_size),
549 v_buffer.stride(-3), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM)
550 v_buffer.stride(-2), # Assume (..., PAGE_SIZE, NUM_HEADS, HEAD_DIM)
551 att_out.stride(0),