[2/N][KV-Cache Layout Refactor] Pack K/V into the content dim across attention backends (#44455)
Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Signed-off-by: Matthew Bonanni <mbonanni@redhat.com> Signed-off-by: Lucas Wilkinson <LucasWilkinson@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Matthew Bonanni <mbonanni@redhat.com> Co-authored-by: Nicolò Lucchesi <nlucches@redhat.com>
Lucas Wilkinson committed
Jul 11, 2026 at 11:11 UTC
51878e5b6e4cf27352c427b8508b4f338ba39c9c
31 files changed
+664
-604
csrc/libtorch_stable/fused_minimax_m3_qknorm_rope_kv_insert_kernel.cu
+28
-29
@@ -288,16 +288,15 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
288
int64_t const* __restrict__ positions, // [N] i64
289
int64_t const* __restrict__ slot_mapping, // main K/V slots or nullptr
290
int64_t const* __restrict__ index_slot_mapping, // index K slots/nullptr
291
- cache_t* __restrict__ kv_cache, // [nb,2,bs,nkv,128] or nullptr
291
+ cache_t* __restrict__ kv_cache, // [nb,nkv,bs,2*128] or nullptr
292
out_idx_t* __restrict__ index_cache, // [nb*bs, 128]; scalar_t or e4m3 byte
293
float const eps, int const rotary_dim, int const num_tokens, int const nq,
294
int const nkv, int const niq, int const block_size,
295
- // kv_cache strides (in elements) for logical shape [nb, 2, bs, nkv, 128].
296
- // The head_dim (last) dim is always innermost-contiguous (stride 1), so the
297
- // NHD/HND layout choice is fully captured by these four strides: NHD keeps
298
- // s_token < s_head, HND swaps them. dim_base addresses head_dim directly.
299
- int64_t const kv_s_block, int64_t const kv_s_kv, int64_t const kv_s_token,
300
- int64_t const kv_s_head) {
295
+ // kv_cache strides (in elements) for logical shape [nb, nkv, bs, 2*128].
296
+ // The content (last) dim is always innermost-contiguous (stride 1), so the
297
+ // NHD/HND layout choice is captured by the head/token strides.
298
+ int64_t const kv_s_block, int64_t const kv_s_head, int64_t const kv_s_token,
299
+ int64_t const kv_s_dim) {
300
#if (!defined(__CUDA_ARCH__) || __CUDA_ARCH__ < 800) && !defined(USE_ROCM)
301
// _typeConvert<BFloat16> is unavailable on pre-Ampere; the M3 kernel only
302
// runs with bf16/fp16 inputs in practice. Discard the bf16 body there.
@@ -438,16 +437,16 @@ __global__ void fusedMiniMaxM3QNormRopeKVInsertKernel(
437
storeElems<scalar_t>(index_cache + sm * kHeadDim + dim_base, elems);
438
}
439
} else if (isK || isV) {
441
- // kv_cache logical shape [num_blocks, 2, block_size, nkv, head_dim].
440
+ // kv_cache logical shape [num_blocks, nkv, block_size, 2*head_dim].
441
// Paging is logical (block = sm/block_size, token = sm%block_size);
442
// the physical NHD/HND layout is honoured via the passed strides.
443
int64_t const b = sm / block_size;
444
int64_t const t = sm % block_size;
445
int const kv = isK ? 0 : 1;
447
- int64_t const off =
448
- b * kv_s_block + kv * kv_s_kv + t * kv_s_token + head * kv_s_head;
449
- storeCacheElems<scalar_t, cache_t, kv_dt>(kv_cache + off + dim_base,
450
- elems);
446
+ int64_t const off = b * kv_s_block + head * kv_s_head +
447
+ t * kv_s_token +
448
+ (kv * kHeadDim + dim_base) * kv_s_dim;
449
+ storeCacheElems<scalar_t, cache_t, kv_dt>(kv_cache + off, elems);
450
}
451
}
452
}
@@ -474,8 +473,8 @@ void launchFusedMiniMaxM3(
473
int64_t const* index_slot_mapping, cache_t* kv_cache, void* index_cache,
474
float const eps, int const rotary_dim, int const num_tokens, int const nq,
475
int const nkv, int const niq, int const block_size,
477
- int64_t const kv_s_block, int64_t const kv_s_kv, int64_t const kv_s_token,
478
- int64_t const kv_s_head, bool const has_index, bool const insert_kv,
476
+ int64_t const kv_s_block, int64_t const kv_s_head, int64_t const kv_s_token,
477
+ int64_t const kv_s_dim, bool const has_index, bool const insert_kv,
478
bool const fp8_idx, cudaStream_t stream) {
479
// Index outputs are scalar_t (bf16) or e4m3 bytes (uint8_t); reinterpret the
480
// void* pointers per instantiation in the LAUNCH macro.
@@ -517,20 +516,20 @@ void launchFusedMiniMaxM3(
516
iq_norm_w, ik_norm_w, cos_sin_cache, positions, slot_mapping, \
517
index_slot_mapping, kv_cache, reinterpret_cast<OUT_T*>(index_cache), \
518
eps, rotary_dim, num_tokens, nq, nkv, niq, block_size, kv_s_block, \
520
- kv_s_kv, kv_s_token, kv_s_head)
519
+ kv_s_head, kv_s_token, kv_s_dim)
520
#else
521
// ROCm: standard kernel launch syntax (no PDL/stream serialization).
522
// clang-format off
523
#define LAUNCH(IS_SPARSE, INSERT, FP8, OUT_T) \
525
- fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, cache_t, kv_dt, OUT_T, \
524
+ fusedMiniMaxM3QNormRopeKVInsertKernel<scalar_t, cache_t, kv_dt, OUT_T, \
525
IS_SPARSE, INSERT, FP8> \
526
<<<grid, kBlockSize, 0, stream>>>( \
527
qkv, q_out, reinterpret_cast<OUT_T*>(index_q_out), q_norm_w, \
528
k_norm_w, iq_norm_w, ik_norm_w, cos_sin_cache, positions, \
529
slot_mapping, index_slot_mapping, kv_cache, \
530
reinterpret_cast<OUT_T*>(index_cache), eps, rotary_dim, \
532
- num_tokens, nq, nkv, niq, block_size, kv_s_block, kv_s_kv, \
533
- kv_s_token, kv_s_head)
531
+ num_tokens, nq, nkv, niq, block_size, kv_s_block, kv_s_head, \
532
+ kv_s_token, kv_s_dim)
533
// clang-format on
534
#endif
535
@@ -584,8 +583,8 @@ void launchFusedMiniMaxM3(
583
? reinterpret_cast<void*>(index_cache->data_ptr()) \
584
: nullptr, \
585
static_cast<float>(eps), static_cast<int>(rotary_dim), num_tokens, nq, \
587
- nkv, niq, static_cast<int>(block_size), kv_s_block, kv_s_kv, kv_s_token, \
588
- kv_s_head, has_index, insert_kv, fp8_idx, stream)
586
+ nkv, niq, static_cast<int>(block_size), kv_s_block, kv_s_head, \
587
+ kv_s_token, kv_s_dim, has_index, insert_kv, fp8_idx, stream)
588
589
// ────────────────────────────────────────────────────────────────────────────
590
// Torch op wrapper
@@ -602,7 +601,7 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
601
int64_t num_index_heads, // niq; 0 => dense
602
std::optional<torch::stable::Tensor> slot_mapping, // [N] i64
603
std::optional<torch::stable::Tensor> index_slot_mapping, // [N] i64
605
- std::optional<torch::stable::Tensor> kv_cache, // [nb,2,bs,nkv,128]
604
+ std::optional<torch::stable::Tensor> kv_cache, // [nb,nkv,bs,2*128]
605
std::optional<torch::stable::Tensor> index_cache, // [nb,bs,128]
606
int64_t block_size,
607
std::optional<torch::stable::Tensor> q_out, // [N, nq*128] contiguous
@@ -673,11 +672,11 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
672
index_k_norm_weight->numel() == kHeadDim,
673
"index norm weights must have 128 elements");
674
}
676
- // kv_cache strides (logical shape [nb, 2, bs, nkv, head_dim]). Read straight
675
+ // kv_cache strides (logical shape [nb, nkv, bs, 2*head_dim]). Read straight
676
// off the tensor so the kernel honours whatever physical layout the attention
678
- // backend allocated (NHD: stride order (0,1,2,3,4); HND: (0,1,3,2,4)). No new
677
+ // backend allocated (NHD: stride order (0,2,1,3); HND: (0,1,2,3)). No new
678
// op argument is needed -- the strides ride along with the tensor itself.
680
- int64_t kv_s_block = 0, kv_s_kv = 0, kv_s_token = 0, kv_s_head = 0;
679
+ int64_t kv_s_block = 0, kv_s_head = 0, kv_s_token = 0, kv_s_dim = 0;
680
torch::stable::Tensor const* effective_index_slot_mapping = nullptr;
681
if (insert_kv) {
682
STD_TORCH_CHECK(
@@ -707,13 +706,13 @@ void fused_minimax_m3_qknorm_rope_kv_insert(
706
index_cache->scalar_type() ==
707
torch::headeronly::ScalarType::Float8_e4m3fn),
708
"insert mode requires index_cache matching qkv dtype or fp8 e4m3");
710
- STD_TORCH_CHECK(kv_cache->dim() == 5 && kv_cache->stride(4) == 1,
711
- "kv_cache must be [nb,2,bs,nkv,head_dim] with contiguous "
712
- "head_dim (stride(4)==1)");
709
+ STD_TORCH_CHECK(kv_cache->dim() == 4 && kv_cache->stride(3) == 1,
710
+ "kv_cache must be [nb,nkv,bs,2*head_dim] with contiguous "
711
+ "content dim (stride(3)==1)");
712
kv_s_block = kv_cache->stride(0);
714
- kv_s_kv = kv_cache->stride(1);
713
+ kv_s_head = kv_cache->stride(1);
714
kv_s_token = kv_cache->stride(2);
716
- kv_s_head = kv_cache->stride(3);
715
+ kv_s_dim = kv_cache->stride(3);
716
effective_index_slot_mapping = index_slot_mapping.has_value()
717
? &index_slot_mapping.value()
718
: &slot_mapping.value();
tests/compile/passes/test_fusion_attn.py
+6
-3
@@ -111,7 +111,11 @@ class AttentionQuantPatternModel(torch.nn.Module):
111
# Fetch the attention backend and kv cache shape and stride order
112
attn_backend = self.attn.attn_backend
113
kv_cache_shape = attn_backend.get_kv_cache_shape(
114
- num_blocks, self.block_size, self.num_kv_heads, self.head_size
114
+ num_blocks,
115
+ self.block_size,
116
+ self.num_kv_heads,
117
+ self.head_size,
118
+ cache_dtype_str=self.attn.kv_cache_dtype,
119
)
120
try:
121
kv_cache_stride_order = attn_backend.get_kv_cache_stride_order()
@@ -125,11 +129,10 @@ class AttentionQuantPatternModel(torch.nn.Module):
129
130
# Create dummy KV cache
131
raw_tensor = torch.zeros(
128
- 2 * num_blocks * self.block_size * self.num_kv_heads * self.head_size,
132
+ kv_cache_shape,
133
dtype=self.attn.kv_cache_torch_dtype,
134
device=self.device,
135
)
132
- raw_tensor = raw_tensor.view(kv_cache_shape)
136
kv_cache = raw_tensor.permute(*inv_order)
137
138
self.attn.kv_cache = kv_cache
tests/kernels/attention/test_cache.py
+3
-5
@@ -10,7 +10,7 @@ from tests.kernels.utils import DEFAULT_OPCHECK_TEST_UTILS, opcheck
10
from vllm import _custom_ops as ops
11
from vllm.model_executor.layers.quantization.utils.quant_utils import scaled_dequantize
12
from vllm.platforms import current_platform
13
-from vllm.utils.torch_utils import nvfp4_kv_cache_split_views, set_random_seed
13
+from vllm.utils.torch_utils import nvfp4_split_data_scale, set_random_seed
14
15
COPYING_DIRECTION = [("cuda", "cpu"), ("cuda", "cuda"), ("cpu", "cuda")]
16
DTYPES = [torch.bfloat16, torch.float]
@@ -255,10 +255,8 @@ def test_reshape_and_cache_flash(
255
nvfp4_key_data = None
256
nvfp4_value_data = None
257
if kv_cache_dtype == "nvfp4":
258
- (nvfp4_key_data,), (key_scale_cache,) = nvfp4_kv_cache_split_views(key_cache)
259
- (nvfp4_value_data,), (value_scale_cache,) = nvfp4_kv_cache_split_views(
260
- value_cache
261
- )
258
+ nvfp4_key_data, key_scale_cache = nvfp4_split_data_scale(key_cache)
259
+ nvfp4_value_data, value_scale_cache = nvfp4_split_data_scale(value_cache)
260
261
if kv_cache_dtype == "nvfp4":
262
# Global scale = amax / 448 (per-tensor)
tests/kernels/attention/test_flashinfer_trtllm_attention.py
+15
-15
@@ -13,7 +13,7 @@ from vllm.platforms import current_platform
13
from vllm.utils.math_utils import round_up
14
from vllm.utils.torch_utils import (
15
nvfp4_kv_cache_full_dim,
16
- nvfp4_kv_cache_split_views,
16
+ nvfp4_split_data_scale,
17
set_random_seed,
18
)
19
@@ -74,17 +74,18 @@ def make_nvfp4_kv_cache(
74
kv_scale_val, dtype=torch.float32, device=kv_bf16_hnd.device
75
)
76
77
- # Allocate in HND physical order, permute to NHD logical order.
78
- # hnd_order swaps dims 2↔3; it is its own inverse.
77
+ # layout: (B, 2*H, N, full_dim)
78
+ # where K heads occupy the first H heads and V heads occupy the second H heads.
79
full_dim = nvfp4_kv_cache_full_dim(head_size)
80
- hnd_order = (0, 1, 3, 2, 4)
81
- kv_cache = torch.zeros(
82
- (num_blocks, 2, num_kv_heads, block_size, full_dim),
80
+ kv_cache_hnd = torch.zeros(
81
+ (num_blocks, 2 * num_kv_heads, block_size, full_dim),
82
dtype=torch.uint8,
83
device=kv_bf16_hnd.device,
85
- ).permute(*hnd_order)
84
+ )
85
+ kv_cache_nhd = kv_cache_hnd.permute(0, 2, 1, 3)
86
+ k_view_nhd, v_view_nhd = kv_cache_nhd.split(num_kv_heads, dim=-2)
87
87
- # Flatten NHD [N, T, H, D] → token tensors [N*T, H, D] for the kernel.
88
+ # Flatten input KV → token tensors [B*N, H, head_size] for the kernel.
89
num_tokens = num_blocks * block_size
90
k_tokens = (
91
kv_bf16_hnd[:, 0]
@@ -98,22 +99,21 @@ def make_nvfp4_kv_cache(
99
)
100
slot_mapping = torch.arange(num_tokens, dtype=torch.long, device=kv_bf16_hnd.device)
101
101
- # reshape_and_cache_flash: kernel receives kv_cache[:, 0] and [:, 1]
102
- # (full K/V buffers containing both data and scale).
102
torch.ops._C_cache_ops.reshape_and_cache_flash(
103
k_tokens,
104
v_tokens,
106
- kv_cache[:, 0],
107
- kv_cache[:, 1],
105
+ k_view_nhd,
106
+ v_view_nhd,
107
slot_mapping,
108
"nvfp4",
109
kv_scale_tensor,
110
kv_scale_tensor,
111
)
112
114
- # Split in HND order for trtllm kernel (expects HND numTokensPerPage).
115
- kv_cache_hnd = kv_cache.permute(*hnd_order)
116
- (k_data, v_data), (k_scales, v_scales) = nvfp4_kv_cache_split_views(kv_cache_hnd)
113
+ # Split into data/scale views in HNC order for trtllm kernel.
114
+ k_cache_hnc, v_cache_hnc = kv_cache_hnd.split(num_kv_heads, dim=1)
115
+ k_data, k_scales = nvfp4_split_data_scale(k_cache_hnc)
116
+ v_data, v_scales = nvfp4_split_data_scale(v_cache_hnc)
117
118
# Dequantize for the FA2 reference baseline.
119
ref_k = dequant_nvfp4_kv_cache(
tests/kernels/attention/test_minimax_m3.py
+40
-37
@@ -662,8 +662,9 @@ def _reference_sparse_attn(
662
positions = torch.arange(seq_len, device="cuda")
663
pages = block_table[req_id, positions // BLOCK_SIZE]
664
rows = positions % BLOCK_SIZE
665
- k_req = kv_cache[pages, 0, rows]
666
- v_req = kv_cache[pages, 1, rows].float()
665
+ kv_req = kv_cache[pages, :, rows]
666
+ k_req = kv_req[..., :HEAD_DIM]
667
+ v_req = kv_req[..., HEAD_DIM:].float()
668
669
q_pos = prefix_len + torch.arange(q_len, device="cuda")
670
key_blocks = positions // BLOCK_SIZE
@@ -791,15 +792,15 @@ def test_main_backend_layout_contract():
792
flash_attn-style stride order for each layout."""
793
nb, bs, h, d = 7, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
794
logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
794
- assert logical == (nb, 2, bs, h, d)
795
- # The old HND-ordered shape is no longer the logical shape.
796
- assert logical != (nb, 2, h, bs, d)
795
+ assert logical == (nb, h, bs, 2 * d)
796
+ # The old separate K/V-axis shape is no longer the logical shape.
797
+ assert logical != (nb, 2, bs, h, d)
798
799
try:
800
set_kv_cache_layout("HND")
800
- assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 1, 3, 2, 4)
801
+ assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 1, 2, 3)
802
set_kv_cache_layout("NHD")
802
- assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 1, 2, 3, 4)
803
+ assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 2, 1, 3)
804
finally:
805
set_kv_cache_layout(None)
806
@@ -829,7 +830,7 @@ def test_main_backend_unknown_layout_raises(monkeypatch):
830
831
832
def test_indexer_backend_stride_order_is_identity():
832
- """The 3-dim indexer cache must not inherit the parent's 5-element stride
833
+ """The 3-dim indexer cache must not inherit the parent's 4-element stride
834
order; it overrides to the 3-element identity so the allocator keeps the
835
contiguous layout."""
836
assert MiniMaxM3IndexerBackend.get_kv_cache_stride_order() == (0, 1, 2)
@@ -848,9 +849,9 @@ def test_indexer_backend_stride_order_is_identity():
849
assert _stride_order_for(MiniMaxM3IndexerBackend, len(indexer_shape)) == (0, 1, 2)
850
851
851
-def test_hnd_allocation_is_byte_identical_to_transpose():
852
- """Under HND the backend-visible logical view is byte-identical to the
853
- pre-change allocate-HND-then-transpose(2, 3) workaround."""
852
+def test_hnd_allocation_is_packed_head_major():
853
+ """Under HND the backend-visible logical view is the packed head-major
854
+ physical allocation."""
855
nb, bs, h, d = 4, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
856
logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
857
try:
@@ -860,21 +861,22 @@ def test_hnd_allocation_is_byte_identical_to_transpose():
861
set_kv_cache_layout(None)
862
863
physical_shape = tuple(logical[i] for i in stride_order)
863
- # The physical (permuted) shape equals the old hardcoded HND shape.
864
- assert physical_shape == (nb, 2, h, bs, d)
864
+ assert physical_shape == (nb, h, bs, 2 * d)
865
866
inv_order = [stride_order.index(i) for i in range(len(stride_order))]
867
raw = torch.empty(physical_shape, device="cuda", dtype=DTYPE)
868
view = raw.permute(*inv_order)
869
- expected = raw.view((nb, 2, h, bs, d)).transpose(2, 3)
869
+ expected = raw.view((nb, h, bs, 2 * d))
870
871
assert view.shape == expected.shape
872
assert view.stride() == expected.stride()
873
assert view.storage_offset() == expected.storage_offset()
874
875
- # Negative: the identity (wrong) stride order under HND does not reproduce
876
- # the transpose view.
877
- wrong_view = raw.view(logical)
875
+ # Negative: the NHD stride order under HND does not reproduce the
876
+ # head-major view.
877
+ wrong_order = (0, 2, 1, 3)
878
+ wrong_inv = [wrong_order.index(i) for i in range(len(wrong_order))]
879
+ wrong_view = raw.view(tuple(logical[i] for i in wrong_order)).permute(*wrong_inv)
880
assert wrong_view.stride() != expected.stride()
881
882
@@ -1037,14 +1039,18 @@ def test_decode_wrong_layout_breaks_parity():
1039
seq_lens_list = (130, 257)
1040
q, block_table, seq_lens, topk_idx, num_pages = _build_decode_inputs(seq_lens_list)
1041
1040
- # Physical HND storage [blocks, 2, heads, block, dim].
1042
+ # Physical HND storage [blocks, heads, block, packed_kv_dim].
1043
phys = torch.randn(
1042
- (num_pages, 2, NUM_KV_HEADS, BLOCK_SIZE, HEAD_DIM), device="cuda", dtype=DTYPE
1044
+ (num_pages, NUM_KV_HEADS, BLOCK_SIZE, 2 * HEAD_DIM),
1045
+ device="cuda",
1046
+ dtype=DTYPE,
1047
+ )
1048
+ # Correct logical packed-HND view vs. the same bytes mislabeled as NHD
1049
+ # physical storage and then exposed as a logical cache.
1050
+ correct = phys
1051
+ wrong = phys.reshape(num_pages, BLOCK_SIZE, NUM_KV_HEADS, 2 * HEAD_DIM).permute(
1052
+ 0, 2, 1, 3
1053
)
1044
- # Correct logical-NHD view (strided) vs. the same bytes mislabeled as a
1045
- # contiguous-NHD cache — same shape, different content mapping.
1046
- correct = phys.permute(0, 1, 3, 2, 4)
1047
- wrong = phys.reshape(num_pages, 2, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM)
1054
1055
q_lens_t = torch.ones(len(seq_lens_list), device="cuda", dtype=torch.int32)
1056
prefix_lens = seq_lens - q_lens_t
@@ -1072,9 +1078,9 @@ def _make_attn_group(backend, spec):
1078
def test_main_cache_byte_identical_through_production_allocator():
1079
"""AC-2: drive the real allocator (`_reshape_kv_cache`) for the M3 main
1080
`FullAttentionSpec` under HND and assert the backend-visible view has the
1075
- same shape, stride, and storage offset as the pre-change
1076
- allocate-HND-then-transpose path; the indexer `MLAAttentionSpec` allocates
1077
- through the same path to its 3-dim shape."""
1081
+ same shape, stride, and storage offset as the packed-HND allocation; the
1082
+ indexer `MLAAttentionSpec` allocates through the same path to its 3-dim
1083
+ shape."""
1084
nb = 4
1085
spec = FullAttentionSpec(
1086
block_size=BLOCK_SIZE,
@@ -1092,8 +1098,7 @@ def test_main_cache_byte_identical_through_production_allocator():
1098
set_kv_cache_layout(None)
1099
view = kv_caches["main"]
1100
1095
- oracle = raw.view(DTYPE).view((nb, 2, NUM_KV_HEADS, BLOCK_SIZE, HEAD_DIM))
1096
- oracle = oracle.transpose(2, 3)
1101
+ oracle = raw.view(DTYPE).view((nb, NUM_KV_HEADS, BLOCK_SIZE, 2 * HEAD_DIM))
1102
assert tuple(view.shape) == tuple(oracle.shape)
1103
assert view.stride() == oracle.stride()
1104
assert view.storage_offset() == oracle.storage_offset()
@@ -1119,13 +1124,13 @@ def test_main_cache_byte_identical_through_production_allocator():
1124
1125
1126
def test_indexer_inherited_stride_order_trips_allocator_assert():
1122
- """AC-4 negative: without the indexer override, the inherited 5-element
1127
+ """AC-4 negative: without the indexer override, the inherited 4-element
1128
stride order trips the allocator's `len(stride_order) == len(shape)` assert
1129
for the 3-dim indexer shape; the `AssertionError` is NOT swallowed by the
1130
allocator's `(AttributeError, NotImplementedError)` fallback."""
1131
1132
class _BrokenIndexerBackend(MiniMaxM3IndexerBackend):
1128
- # Simulate inheriting the parent's 5-element stride order.
1133
+ # Simulate inheriting the parent's 4-element stride order.
1134
get_kv_cache_stride_order = staticmethod(
1135
MiniMaxM3SparseBackend.get_kv_cache_stride_order
1136
)
@@ -1192,10 +1197,8 @@ def test_padded_main_cache_is_flagged():
1197
@pytest.mark.parametrize("kv_layout", ["NHD", "HND"], indirect=True)
1198
def test_reshape_and_cache_flash_write_persists(kv_layout: str):
1199
"""AC-5 write path: the `reshape_and_cache_flash` write site now consumes
1195
- `self.kv_cache.unbind(1)` directly. Writing through those views must persist
1196
- into the bound storage (read back through an independent logical view) under
1197
- both layouts — a `.contiguous()` copy of the unbind slice would leave the
1198
- bound storage unchanged."""
1200
+ packed-content K/V split views. Writing through those views must persist
1201
+ into the bound storage under both layouts."""
1202
torch.manual_seed(0)
1203
num_pages = 4
1204
kv_cache = _allocate_main_kv_via_contract(num_pages)
@@ -1203,7 +1206,7 @@ def test_reshape_and_cache_flash_write_persists(kv_layout: str):
1206
kv_cache.zero_()
1207
1208
# Exactly the production write-site code under test.
1206
- key_cache, value_cache = kv_cache.unbind(1)
1209
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(HEAD_DIM, dim=-1)
1210
1211
num_tokens = 12
1212
slot_mapping = torch.randperm(num_pages * BLOCK_SIZE, device="cuda")[
@@ -1222,5 +1225,5 @@ def test_reshape_and_cache_flash_write_persists(kv_layout: str):
1225
for t in range(num_tokens):
1226
slot = int(slot_mapping[t].item())
1227
blk, intra = divmod(slot, BLOCK_SIZE)
1225
- torch.testing.assert_close(kv_cache[blk, 0, intra], key[t])
1226
- torch.testing.assert_close(kv_cache[blk, 1, intra], value[t])
1228
+ torch.testing.assert_close(kv_cache[blk, :, intra, :HEAD_DIM], key[t])
1229
+ torch.testing.assert_close(kv_cache[blk, :, intra, HEAD_DIM:], value[t])
tests/v1/attention/test_attention_backends.py
+9
-13
@@ -140,12 +140,10 @@ def create_and_prepopulate_kv_cache(
140
block_table = common_attn_metadata.block_table_tensor
141
slot_mapping = common_attn_metadata.slot_mapping
142
143
- # Create KV cache and populate in (2, num_blocks, ...) layout for easy
144
- # flat indexing, then transpose to (num_blocks, 2, ...) layout.
143
kv_cache = torch.zeros(
146
- 2, num_blocks, block_size, num_kv_heads, head_size, dtype=dtype, device=device
144
+ num_blocks, block_size, num_kv_heads, 2 * head_size, dtype=dtype, device=device
145
)
148
- kv_cache_flat = kv_cache.view(2, -1, num_kv_heads, head_size)
146
+ kv_cache_flat = kv_cache.view(-1, num_kv_heads, 2 * head_size)
147
148
# Populate the cache with the context tokens
149
# Start from block_id=1 since block_id=0 is considered the null block
@@ -154,15 +152,12 @@ def create_and_prepopulate_kv_cache(
152
k_context, v_context = k_contexts[i], v_contexts[i]
153
start = start_block_idx * block_size
154
end = start + k_context.shape[0]
157
- kv_cache_flat[0, start:end, ...] = k_context
158
- kv_cache_flat[1, start:end, ...] = v_context
155
+ kv_cache_flat[start:end, :, :head_size] = k_context
156
+ kv_cache_flat[start:end, :, head_size:] = v_context
157
158
# Stay block aligned and allocate enough blocks for the new tokens
159
start_block_idx += cdiv(int(seq_lens[i]), block_size)
160
163
- # Transpose to (num_blocks, 2, ...) layout
164
- kv_cache = kv_cache.transpose(0, 1).contiguous()
165
-
161
blocks_end = start_block_idx
162
163
# Permute the context blocks (excluding block 0 which is null)
@@ -199,7 +194,8 @@ def create_and_prepopulate_kv_cache(
194
i, block_indices
195
] * block_size + token_inter_block_offsets.to(device)
196
202
- return kv_cache
197
+ # Transpose to logical (num_blocks, num_kv_heads, block_size, 2*hs)
198
+ return kv_cache.transpose(1, 2).contiguous()
199
200
201
class MockAttentionLayer:
@@ -496,9 +492,6 @@ def _test_backend_correctness(
492
set_kv_cache_layout("HND")
493
reset_kv_cache_layout = True
494
499
- # Apply stride order like runtime does in
500
- # _reshape_kv_cache (attn_utils.py:182-210): permute to physical
501
- # layout, make contiguous, then permute to logical layout.
495
kv_cache_for_backend = kv_cache
496
if backend_cls is not None:
497
try:
@@ -506,6 +499,9 @@ def _test_backend_correctness(
499
except (AttributeError, NotImplementedError):
500
stride_order = tuple(range(kv_cache.ndim))
501
if stride_order != tuple(range(kv_cache.ndim)):
502
+ # Apply stride order like runtime does in
503
+ # _reshape_kv_cache (attn_utils.py:182-210): permute to physical
504
+ # layout, make contiguous, then permute to logical layout.
505
inv_order = [stride_order.index(i) for i in range(len(stride_order))]
506
kv_cache_for_backend = (
507
kv_cache.permute(*stride_order).contiguous().permute(*inv_order)
tests/v1/attention/test_trtllm_attention_integration.py
+43
-39
@@ -97,13 +97,13 @@ def _create_hnd_kv_cache(
97
device,
98
num_blocks,
99
common_attn_metadata,
100
+ kv_in_head_dim=False,
101
):
101
- """Create and populate a KV cache with HND-compatible strides.
102
+ """Create and populate a packed KV cache with HND-compatible strides.
103
103
- The returned tensor has logical shape
104
- (num_blocks, 2, block_size, num_kv_heads, head_size) but is physically
105
- laid out as (num_blocks, 2, num_kv_heads, block_size, head_size) so that
106
- ``kv_cache.permute(0, 1, 3, 2, 4)`` yields a contiguous HND view.
104
+ When kv_in_head_dim=False (default), returns (B, H, N, 2*hs) with K/V
105
+ packed in the content dim. When kv_in_head_dim=True, returns
106
+ (B, 2*H, N, hs) with K/V as separate head groups.
107
"""
108
seq_lens = common_attn_metadata.seq_lens.cpu()
109
query_lens = (
@@ -114,26 +114,34 @@ def _create_hnd_kv_cache(
114
slot_mapping = common_attn_metadata.slot_mapping
115
batch_size = len(k_contexts)
116
117
- # Build cache in (2, num_blocks, block_size, num_kv_heads, head_size)
118
- # then convert to HND format (same approach as test_attention_backends.py).
119
- kv_cache_raw = torch.zeros(
120
- 2,
117
+ # kv_in_head_dim: (B, N, 2*H, hs) — K/V as separate head groups
118
+ # else: (B, N, H, 2*hs) — K/V packed in content dim
119
+ n_heads, content = (
120
+ (2 * num_kv_heads, head_size)
121
+ if kv_in_head_dim
122
+ else (num_kv_heads, 2 * head_size)
123
+ )
124
+ kv_cache = torch.zeros(
125
num_blocks,
126
block_size,
123
- num_kv_heads,
124
- head_size,
127
+ n_heads,
128
+ content,
129
dtype=dtype,
130
device=device,
131
)
128
- kv_cache_flat = kv_cache_raw.view(2, -1, num_kv_heads, head_size)
132
+ kv_cache_flat = kv_cache.view(-1, n_heads, content)
133
134
start_block_idx = 1
135
for i in range(batch_size):
136
k_ctx, v_ctx = k_contexts[i], v_contexts[i]
137
start = start_block_idx * block_size
138
end = start + k_ctx.shape[0]
135
- kv_cache_flat[0, start:end] = k_ctx
136
- kv_cache_flat[1, start:end] = v_ctx
139
+ if kv_in_head_dim:
140
+ kv_cache_flat[start:end, :num_kv_heads] = k_ctx
141
+ kv_cache_flat[start:end, num_kv_heads:] = v_ctx
142
+ else:
143
+ kv_cache_flat[start:end, :, :head_size] = k_ctx
144
+ kv_cache_flat[start:end, :, head_size:] = v_ctx
145
start_block_idx += cdiv(int(seq_lens[i]), block_size)
146
147
blocks_end = start_block_idx
@@ -142,7 +150,7 @@ def _create_hnd_kv_cache(
150
perm = torch.randperm(blocks_end - 1) + 1
151
inv_perm = torch.zeros(blocks_end, dtype=torch.long, device=device)
152
inv_perm[1:] = torch.argsort(perm) + 1
145
- kv_cache_raw[:, 1:blocks_end] = kv_cache_raw[:, perm]
153
+ kv_cache[1:blocks_end] = kv_cache[perm]
154
155
# Build block table.
156
start_block_idx = 1
@@ -165,10 +173,8 @@ def _create_hnd_kv_cache(
173
i, block_indices
174
] * block_size + intra_block_offsets.to(device)
175
168
- # Transpose to FlashInfer logical shape then make HND-strided.
169
- kv_cache = kv_cache_raw.transpose(0, 1)
170
- kv_cache = kv_cache.transpose(2, 3).contiguous().transpose(2, 3)
171
- return kv_cache
176
+ # Transpose to canonical: (B, H, N, 2*hs) or (B, 2*H, N, hs)
177
+ return kv_cache.transpose(1, 2).contiguous()
178
179
180
def _create_nvfp4_hnd_kv_cache(
@@ -187,20 +193,15 @@ def _create_nvfp4_hnd_kv_cache(
193
reshape_and_cache_flash, using the same block-table layout as
194
_create_hnd_kv_cache.
195
190
- The returned tensor is dtype ``uint8`` with shape
191
- ``(num_blocks, 2, block_size, num_kv_heads, full_dim)`` in logical
192
- (NHD) order, but physically permuted to HND layout via stride order
193
- ``(0, 1, 3, 2, 4)`` (i.e. ``num_kv_heads`` before ``block_size``).
194
-
195
- The last dimension ``full_dim = head_size // 2 + head_size // 16``
196
- packs two regions contiguously:
196
+ The returned tensor is dtype ``uint8`` with head-group layout
197
+ ``(num_blocks, 2 * num_kv_heads, block_size, full_dim)``
198
+ where K heads occupy the first ``num_kv_heads`` heads and V heads the second.
199
+ Each ``full_dim = head_size // 2 + head_size // 16`` block packs two regions:
200
- **FP4 data** (``head_size // 2`` bytes): pairs of E2M1 values,
201
two per byte.
202
- **FP8 block scales** (``head_size // 16`` bytes): one E4M3
203
scale per 16-element block.
204
202
- Dimension 1 indexes K (``[:, 0]``) and V (``[:, 1]``).
203
-
205
Args:
206
k_contexts: List of key context tensors, one per sequence.
207
v_contexts: List of value context tensors, one per sequence.
@@ -219,6 +220,7 @@ def _create_nvfp4_hnd_kv_cache(
220
``torch.Tensor``: The nvfp4 kv_cache tensor (uint8, HND-strided).
221
"""
222
# First create a bf16 HND cache so block tables are populated.
223
+ # Use kv_in_head_dim=True so K/V are separate head groups (B, 2*H, N, hs).
224
bf16_cache = _create_hnd_kv_cache(
225
k_contexts,
226
v_contexts,
@@ -229,20 +231,20 @@ def _create_nvfp4_hnd_kv_cache(
231
device,
232
num_blocks,
233
common_attn_metadata,
234
+ kv_in_head_dim=True,
235
)
236
234
- # Allocate nvfp4 cache: same shape but with full_dim (data + scale).
237
+ # (num_blocks, 2 * num_kv_heads, block_size, full_dim) — K heads first, then V heads
238
full_dim = nvfp4_kv_cache_full_dim(head_size)
236
- hnd_order = (0, 1, 3, 2, 4)
239
nvfp4_cache = torch.zeros(
238
- (num_blocks, 2, num_kv_heads, block_size, full_dim),
240
+ (num_blocks, 2 * num_kv_heads, block_size, full_dim),
241
dtype=torch.uint8,
242
device=device,
241
- ).permute(*hnd_order)
243
+ )
244
+ k_cache, v_cache = nvfp4_cache.split(num_kv_heads, dim=1)
245
246
# Flatten bf16 context into tokens and quantize via reshape_and_cache_flash.
244
- # bf16_cache is (num_blocks, 2, block_size, num_kv_heads, head_size) logical
245
- # with HND physical strides.
247
+ # bf16_cache is (B, 2*H, N, hs); split K/V on head dim.
248
block_table = common_attn_metadata.block_table_tensor
249
seq_lens = common_attn_metadata.seq_lens.cpu()
250
query_lens = (
@@ -258,19 +260,21 @@ def _create_nvfp4_hnd_kv_cache(
260
# Gather context tokens from the bf16 cache using block table.
261
n_ctx_blocks = (ctx_len + block_size - 1) // block_size
262
blocks = block_table[i, :n_ctx_blocks]
261
- # bf16_cache[:, kv_idx] is (num_blocks, block_size, num_kv_heads, head_size)
262
- k_ctx = bf16_cache[blocks, 0].reshape(-1, num_kv_heads, head_size)[:ctx_len]
263
- v_ctx = bf16_cache[blocks, 1].reshape(-1, num_kv_heads, head_size)[:ctx_len]
263
+ # bf16_cache is (B, 2*H, N, hs); split K and V head groups.
264
+ k_bf16, v_bf16 = bf16_cache[blocks].split(num_kv_heads, dim=1)
265
+ k_ctx = k_bf16.transpose(1, 2).reshape(-1, num_kv_heads, head_size)[:ctx_len]
266
+ v_ctx = v_bf16.transpose(1, 2).reshape(-1, num_kv_heads, head_size)[:ctx_len]
267
# Build slot mapping for these context tokens.
268
token_offsets = torch.arange(ctx_len, device=device)
269
block_indices = token_offsets // block_size
270
intra_offsets = token_offsets % block_size
271
slots = block_table[i, block_indices] * block_size + intra_offsets
272
+ # reshape_and_cache_flash expects (B, N, H, D) cache views.
273
torch.ops._C_cache_ops.reshape_and_cache_flash(
274
k_ctx,
275
v_ctx,
272
- nvfp4_cache[:, 0],
273
- nvfp4_cache[:, 1],
276
+ k_cache.transpose(1, 2),
277
+ v_cache.transpose(1, 2),
278
slots,
279
"nvfp4",
280
kv_scale_t,
tests/v1/kv_connector/unit/test_mooncake_connector.py
+37
-67
@@ -289,10 +289,9 @@ async def test_send_kv_to_decode_aligns_consumer_regions_by_layer_metadata(
289
prefill_worker = prefill_connector.connector_worker
290
291
block_len = 4096
292
- kv_half = block_len // 2
292
prefill_worker.kv_caches_base_addr = [0x1000]
293
prefill_worker.block_len_per_layer = [block_len]
295
- prefill_worker.kv_block_len_per_layer = [kv_half]
294
+ prefill_worker.kv_block_len_per_layer = [block_len]
295
prefill_worker.registered_layer_names = ["model.layers.1.self_attn"]
296
prefill_worker.registered_layer_indices = [1]
297
@@ -321,7 +320,7 @@ async def test_send_kv_to_decode_aligns_consumer_regions_by_layer_metadata(
320
req_blocks={"d-req-layer-align": (transfer_id, [[20]])},
321
kv_caches_base_addr=[0xA000, 0xB000],
322
block_lens=[block_len, block_len],
324
- kv_block_lens=[kv_half, kv_half],
323
+ kv_block_lens=[block_len, block_len],
324
registered_layer_names=[
325
"model.layers.0.self_attn",
326
"model.layers.1.self_attn",
@@ -338,15 +337,9 @@ async def test_send_kv_to_decode_aligns_consumer_regions_by_layer_metadata(
337
await prefill_worker.send_kv_to_decode(identity, mock_socket, xfer_meta)
338
339
src_ptrs, dst_ptrs, lengths = mock_send_blocks.call_args[0][1:]
341
- assert src_ptrs == [
342
- 0x1000 + 10 * block_len,
343
- 0x1000 + 10 * block_len + kv_half,
344
- ]
345
- assert dst_ptrs == [
346
- 0xB000 + 20 * block_len,
347
- 0xB000 + 20 * block_len + kv_half,
348
- ]
349
- assert lengths == [kv_half, kv_half]
340
+ assert src_ptrs == [0x1000 + 10 * block_len]
341
+ assert dst_ptrs == [0xB000 + 20 * block_len]
342
+ assert lengths == [block_len]
343
344
sent_identity, sent_payload = mock_socket.send_multipart.call_args[0][0]
345
assert sent_identity == identity
@@ -832,9 +825,8 @@ async def test_kv_producer(monkeypatch):
825
prefill_worker = prefill_connector.connector_worker
826
prefill_worker.kv_caches_base_addr = [0x1000]
827
block_len = 4096
835
- kv_half = block_len // 2
828
prefill_worker.block_len_per_layer = [block_len]
837
- prefill_worker.kv_block_len_per_layer = [kv_half]
829
+ prefill_worker.kv_block_len_per_layer = [block_len]
830
prefill_worker.registered_layer_names = ["model.layers.0.self_attn"]
831
prefill_worker.registered_layer_indices = [0]
832
@@ -862,7 +854,7 @@ async def test_kv_producer(monkeypatch):
854
req_blocks={"d-req-1": (transfer_id, [[20, 21]])},
855
kv_caches_base_addr=[0x2000],
856
block_lens=[block_len],
865
- kv_block_lens=[kv_half],
857
+ kv_block_lens=[block_len],
858
registered_layer_names=["model.layers.0.self_attn"],
859
registered_layer_indices=[0],
860
)
@@ -874,24 +866,18 @@ async def test_kv_producer(monkeypatch):
866
with patch.object(
867
prefill_worker, "_send_blocks", return_value=0
868
) as mock_send_blocks:
877
- # With blocks-first layout, each block is virtually split
878
- # into K and V halves, producing non-coalesced transfers.
879
- def expected_split_transfers(src_base, dst_base, src_blocks, dst_blocks):
880
- """Build expected (src_ptrs, dst_ptrs, lengths) for
881
- virtual-split K/V transfers."""
882
- src_ptrs, dst_ptrs, lengths = [], [], []
883
- for kv_offset in (0, kv_half):
884
- for sb, db in zip(src_blocks, dst_blocks):
885
- src_ptrs.append(src_base + sb * block_len + kv_offset)
886
- dst_ptrs.append(dst_base + db * block_len + kv_offset)
887
- lengths.append(kv_half)
888
- return src_ptrs, dst_ptrs, lengths
869
+
870
+ def expected_transfers(src_base, dst_base, src_blocks, dst_blocks):
871
+ n = len(src_blocks)
872
+ return (
873
+ [src_base + src_blocks[0] * block_len],
874
+ [dst_base + dst_blocks[0] * block_len],
875
+ [n * block_len],
876
+ )
877
878
# Normal case: 2 blocks to 2 blocks
879
await prefill_worker.send_kv_to_decode(identity, mock_socket, xfer_meta)
892
- src, dst, lens = expected_split_transfers(
893
- 0x1000, 0x2000, [10, 11], [20, 21]
894
- )
880
+ src, dst, lens = expected_transfers(0x1000, 0x2000, [10, 11], [20, 21])
881
mock_send_blocks.assert_called_once_with(
882
"consumer-host:54321",
883
src,
@@ -923,7 +909,7 @@ async def test_kv_producer(monkeypatch):
909
# Worker processes the consumer's request
910
await prefill_worker.send_kv_to_decode(identity, mock_socket, xfer_meta)
911
# Verify transfer parameters are correct: 11 to 20
926
- src, dst, lens = expected_split_transfers(0x1000, 0x2000, [11], [20])
912
+ src, dst, lens = expected_transfers(0x1000, 0x2000, [11], [20])
913
mock_send_blocks.assert_called_once_with(
914
"consumer-host:54321",
915
src,
@@ -1266,7 +1252,7 @@ async def test_kv_producer_heterogeneous_tp(monkeypatch, d_tp_size):
1252
1253
prefill_worker.kv_caches_base_addr = [0x1000]
1254
prefill_worker.block_len_per_layer = [local_block_len]
1269
- prefill_worker.kv_block_len_per_layer = [local_block_len // 2]
1255
+ prefill_worker.kv_block_len_per_layer = [local_block_len]
1256
prefill_worker.registered_layer_names = ["model.layers.0.self_attn"]
1257
prefill_worker.registered_layer_indices = [0]
1258
@@ -1314,7 +1300,7 @@ async def test_kv_producer_heterogeneous_tp(monkeypatch, d_tp_size):
1300
},
1301
kv_caches_base_addr=[0x2000],
1302
block_lens=[remote_block_len],
1317
- kv_block_lens=[remote_block_len // 2],
1303
+ kv_block_lens=[remote_block_len],
1304
registered_layer_names=["model.layers.0.self_attn"],
1305
registered_layer_indices=[0],
1306
)
@@ -1336,47 +1322,31 @@ async def test_kv_producer_heterogeneous_tp(monkeypatch, d_tp_size):
1322
flat_remote = [b for g in remote_block_ids for b in g]
1323
num_blocks = len(flat_local)
1324
1339
- # With blocks-first layout, virtual split halves block
1340
- # lengths and doubles transfer regions (K + V).
1341
- local_kv_block_len = local_block_len // 2
1342
- remote_kv_block_len = remote_block_len // 2
1343
-
1344
- assert len(src_ptrs) == 2 * num_blocks
1345
- assert len(dst_ptrs) == 2 * num_blocks
1346
- assert len(lengths) == 2 * num_blocks
1325
+ assert len(src_ptrs) == num_blocks
1326
+ assert len(dst_ptrs) == num_blocks
1327
+ assert len(lengths) == num_blocks
1328
1348
- # Compute expected offsets using kv_block_len
1329
if d_tp_size <= P_TP_SIZE:
1330
tp_ratio = P_TP_SIZE // d_tp_size
1331
expected_src_off = 0
1352
- expected_dst_off = (P_TP_RANK % tp_ratio) * local_kv_block_len
1353
- expected_xfer_len = local_kv_block_len
1332
+ expected_dst_off = (P_TP_RANK % tp_ratio) * local_block_len
1333
+ expected_xfer_len = local_block_len
1334
else:
1335
ratio_abs = d_tp_size // P_TP_SIZE
1356
- expected_src_off = (d_rank % ratio_abs) * remote_kv_block_len
1336
+ expected_src_off = (d_rank % ratio_abs) * remote_block_len
1337
expected_dst_off = 0
1358
- expected_xfer_len = remote_kv_block_len
1359
-
1360
- # First num_blocks entries are K region,
1361
- # next num_blocks are V region.
1362
- for region_idx in range(2):
1363
- local_region_base = 0x1000 + region_idx * local_kv_block_len
1364
- remote_region_base = 0x2000 + region_idx * remote_kv_block_len
1365
- for blk_idx, (lblk, rblk) in enumerate(
1366
- zip(flat_local, flat_remote)
1367
- ):
1368
- idx = region_idx * num_blocks + blk_idx
1369
- assert src_ptrs[idx] == (
1370
- local_region_base
1371
- + lblk * local_block_len
1372
- + expected_src_off
1373
- )
1374
- assert dst_ptrs[idx] == (
1375
- remote_region_base
1376
- + rblk * remote_block_len
1377
- + expected_dst_off
1378
- )
1379
- assert lengths[idx] == expected_xfer_len
1338
+ expected_xfer_len = remote_block_len
1339
+
1340
+ local_region_base = 0x1000
1341
+ remote_region_base = 0x2000
1342
+ for blk_idx, (lblk, rblk) in enumerate(zip(flat_local, flat_remote)):
1343
+ assert src_ptrs[blk_idx] == (
1344
+ local_region_base + lblk * local_block_len + expected_src_off
1345
+ )
1346
+ assert dst_ptrs[blk_idx] == (
1347
+ remote_region_base + rblk * remote_block_len + expected_dst_off
1348
+ )
1349
+ assert lengths[blk_idx] == expected_xfer_len
1350
1351
# Verify successful response sent back to consumer
1352
mock_socket.send_multipart.assert_called_once()
tests/v1/kv_connector/unit/test_nixl_connector.py
+64
-15
@@ -1068,6 +1068,65 @@ class TestNixlHandshake:
1068
# whole block is moved.
1069
worker.add_remote_agent(meta, remote_tp_size=1)
1070
1071
+ @patch(
1072
+ "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
1073
+ FakeNixlWrapper,
1074
+ )
1075
+ def test_hybrid_mamba_attention_remote_descs_use_packed_head_slices(
1076
+ self, default_vllm_config, dist_init
1077
+ ):
1078
+ worker = FakeNixlConnectorWorker(
1079
+ create_vllm_config(), "engine", hand_shake_latency=0
1080
+ )
1081
+
1082
+ remote_block_len = 2048
1083
+ local_block_len = remote_block_len // 2
1084
+ worker.block_len_per_layer = [local_block_len]
1085
+ worker._region_is_mla = [False]
1086
+ worker.num_blocks = 1
1087
+ worker.num_regions = 1
1088
+ worker._has_mamba = True
1089
+ worker._mamba_ssm_size = (128, 256)
1090
+ worker.transfer_topo = TransferTopology(
1091
+ tp_rank=1,
1092
+ tp_size=2,
1093
+ block_size=worker.block_size,
1094
+ engine_id=worker.engine_id,
1095
+ is_mla=False,
1096
+ is_mamba=True,
1097
+ total_num_kv_heads=2,
1098
+ attn_backends=worker.attn_backends,
1099
+ tensor_shape=None,
1100
+ )
1101
+ assert worker.transfer_topo.virtually_split_kv_in_blocks
1102
+
1103
+ plan = MagicMock(
1104
+ source_ranks_per_group=((0,), (0,)),
1105
+ rank_offset_factor=1,
1106
+ )
1107
+ meta = MagicMock(
1108
+ kv_caches_base_addr=[0x1000],
1109
+ device_id=0,
1110
+ num_blocks=1,
1111
+ block_lens=[remote_block_len],
1112
+ )
1113
+
1114
+ assert worker.get_backend_aware_kv_block_len(0, mamba_view=False) == (
1115
+ local_block_len
1116
+ )
1117
+ assert (
1118
+ worker.get_backend_aware_kv_block_len(0, first_split=True, mamba_view=True)
1119
+ == worker._mamba_ssm_size[0]
1120
+ )
1121
+ assert (
1122
+ worker.get_backend_aware_kv_block_len(0, first_split=False, mamba_view=True)
1123
+ == worker._mamba_ssm_size[1]
1124
+ )
1125
+
1126
+ assert worker._build_fa_remote(plan, meta, block_size_ratio=1) == [
1127
+ (0x1000 + local_block_len, local_block_len, 0)
1128
+ ]
1129
+
1130
@patch(
1131
"vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
1132
FakeNixlWrapper,
@@ -1691,13 +1750,6 @@ def _run_abort_timeout_test(llm: LLM, timeout: int):
1750
reason="Attention backend FLASH_ATTN is not supported on ROCm",
1751
),
1752
),
1694
- pytest.param(
1695
- "ROCM_ATTN",
1696
- marks=pytest.mark.skipif(
1697
- not current_platform.is_rocm(),
1698
- reason="Attention backend ROCM_ATTN is only supported on ROCm",
1699
- ),
1700
- ),
1753
"TRITON_ATTN",
1754
],
1755
)
@@ -1824,8 +1876,7 @@ def test_register_kv_caches(
1876
test_shape = backend_cls.get_kv_cache_shape(
1877
num_blocks=1, block_size=16, num_kv_heads=1, head_size=1
1878
)
1827
- is_blocks_first = len(test_shape) == 5 and test_shape[0] == 1
1828
- virtually_split = is_blocks_first and not connector.prefer_cross_layer_blocks
1879
+ is_blocks_first = len(test_shape) == 4 and test_shape[0] == 1
1880
1881
if connector.prefer_cross_layer_blocks:
1882
with set_current_vllm_config(vllm_config):
@@ -1856,7 +1907,7 @@ def test_register_kv_caches(
1907
]
1908
expected_num_entries = 1
1909
1859
- expected_blocks_count = num_blocks * (2 if virtually_split else 1)
1910
+ expected_blocks_count = num_blocks
1911
1912
kv_caches = {"all-layers": cross_layers_kv_cache}
1913
else:
@@ -1885,6 +1936,7 @@ def test_register_kv_caches(
1936
unique_tensor.data_ptr(),
1937
]
1938
expected_num_entries = 2
1939
+ expected_blocks_count = kv_cache_config.num_blocks * 2
1940
else:
1941
expected_tensor_size = (
1942
shared_tensor[0].element_size() * shared_tensor[0].numel()
@@ -1896,7 +1948,7 @@ def test_register_kv_caches(
1948
unique_tensor[1].data_ptr(),
1949
]
1950
expected_num_entries = 4
1899
- expected_blocks_count = kv_cache_config.num_blocks * 4
1951
+ expected_blocks_count = kv_cache_config.num_blocks * 4
1952
1953
# Execute register_kv_caches
1954
connector.register_kv_caches(kv_caches)
@@ -1930,10 +1982,7 @@ def test_register_kv_caches(
1982
else:
1983
num_blocks = kv_cache_config.num_blocks
1984
1933
- if virtually_split:
1934
- expected_block_len = expected_tensor_size // num_blocks // 2
1935
- else:
1936
- expected_block_len = expected_tensor_size // num_blocks
1985
+ expected_block_len = expected_tensor_size // num_blocks
1986
1987
for i, block_entry in enumerate(blocks_data):
1988
block_start_addr, block_len, tp_rank = block_entry
tests/v1/kv_connector/unit/test_transfer_topology_sharded.py
+2
-2
@@ -18,8 +18,8 @@ class _FakeAttentionBackend:
18
block_size: int,
19
num_kv_heads: int,
20
head_size: int,
21
- ) -> tuple[int, int, int, int, int]:
22
- return (2, num_blocks, num_kv_heads, block_size, head_size)
21
+ ) -> tuple[int, int, int, int]:
22
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
23
24
25
def _make_topology(
tests/v1/worker/test_gpu_model_runner.py
+7
-5
@@ -795,9 +795,10 @@ def test_kv_cache_stride_order(monkeypatch, model_runner):
795
)
796
797
# TODO mla test
798
- default_stride = tuple(range(5))
798
+ default_stride = tuple(range(len(expected_kv_cache_shape)))
799
+ non_default_stride = (*default_stride[1:], default_stride[0])
800
# Permutation that gets you back to expected kv shape
800
- for test_stride in ((1, 4, 0, 2, 3), (0, 1, 2, 3, 4)):
801
+ for test_stride in (non_default_stride, default_stride):
802
803
def rnd_stride_order(
804
include_num_layers_dimension: bool = False, test_stride=test_stride
@@ -1300,9 +1301,10 @@ def test_hybrid_attention_mamba_tensor_shapes():
1301
actual_kv = vllm_ctx[layer].kv_cache[kernel_block, :]
1302
expected = attn_blocks_constant[i]
1303
1303
- # Check K and V separately
1304
- assert torch.equal(actual_kv[0], expected)
1305
- assert torch.equal(actual_kv[1], expected)
1304
+ # Packed layout: (num_kv_heads, block_size, 2*head_size). Every
1305
+ # head in the block was filled with the same constant.
1306
+ for head_idx in range(actual_kv.shape[0]):
1307
+ assert torch.equal(actual_kv[head_idx], expected)
1308
1309
for layer in [layer_2, layer_3, layer_4, layer_5]:
1310
for i, kv_block in enumerate(kv_blocks_for_mamba):
vllm/distributed/kv_transfer/kv_connector/utils.py
+35
-34
@@ -10,7 +10,12 @@ from typing import TYPE_CHECKING, Any, Literal, cast
10
11
import torch
12
13
-from vllm.config import VllmConfig, get_current_vllm_config, get_layers_from_vllm_config
13
+from vllm.config import (
14
+ VllmConfig,
15
+ get_current_vllm_config,
16
+ get_layers_from_vllm_config,
17
+ set_current_vllm_config,
18
+)
19
from vllm.distributed.kv_transfer.kv_connector.factory import KVConnectorFactory
20
from vllm.logger import init_logger
21
from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
@@ -344,14 +349,15 @@ def get_current_attn_backends(
349
)
350
from vllm.v1.attention.selector import get_attn_backend
351
347
- return [
348
- get_attn_backend(
349
- head_size=vllm_config.model_config.get_head_size(),
350
- dtype=vllm_config.model_config.dtype,
351
- kv_cache_dtype=vllm_config.cache_config.cache_dtype,
352
- use_mla=vllm_config.model_config.use_mla,
353
- )
354
- ]
352
+ with set_current_vllm_config(vllm_config):
353
+ return [
354
+ get_attn_backend(
355
+ head_size=vllm_config.model_config.get_head_size(),
356
+ dtype=vllm_config.model_config.dtype,
357
+ kv_cache_dtype=vllm_config.cache_config.cache_dtype,
358
+ use_mla=vllm_config.model_config.use_mla,
359
+ )
360
+ ]
361
362
363
def get_current_attn_backend(
@@ -426,12 +432,16 @@ class TransferTopology:
432
head_size=1,
433
)
434
logger.debug("Test kv_cache_shape: %s", kv_cache_shape)
429
- # Non-MLA backends caches have 5 dims [num_blocks, 2, H,N,D],
430
- # we just mock num_blocks to 1 for the dimension check below.
431
- # Hybrid SSM models assume a single blocks_first layout
432
- self._is_kv_layout_blocks_first = self.is_mamba or (
433
- len(kv_cache_shape) == 5 and kv_cache_shape[0] == 1
434
- )
435
+ assert kv_cache_shape[0] == 1, (
436
+ "KV cache layout must be blocks-first; expected mocked "
437
+ f"num_blocks=1 in leading dim, got shape {kv_cache_shape}."
438
+ )
439
+ if not self.is_mla:
440
+ assert len(kv_cache_shape) == 4, (
441
+ "Attention KV cache layout must be standardized as "
442
+ "[num_blocks, num_kv_heads, block_size, content_size], "
443
+ f"got shape {kv_cache_shape}."
444
+ )
445
446
self._cross_layers_blocks = False
447
if self.tensor_shape is not None:
@@ -490,29 +500,19 @@ class TransferTopology:
500
# Layout properties
501
# ============================================================
502
493
- @property
494
- def is_kv_layout_blocks_first(self) -> bool:
495
- return self._is_kv_layout_blocks_first
496
-
503
@property
504
def cross_layers_blocks(self) -> bool:
505
return self._cross_layers_blocks
506
507
@property
508
def virtually_split_kv_in_blocks(self) -> bool:
503
- # Whether to logically split each block into K and V halves.
504
- # Applies when K/V are interleaved within each block (blocks-first),
505
- # but NOT when cross-layer blocks are used — cross-layer blocks have
506
- # per-layer K/V interleaving (L0_K, L0_V, L1_K, L1_V, ...) so a
507
- # simple half-split does not separate K from V.
508
- return self._is_kv_layout_blocks_first and not self._cross_layers_blocks
509
-
510
- @property
511
- def split_k_and_v(self) -> bool:
512
- # Whether to register regions for K and V separately (when present).
513
- return not (
514
- self._cross_layers_blocks or self.is_mla or self.is_kv_layout_blocks_first
515
- )
509
+ # Whether to logically split each block into two separately-indexable
510
+ # sub-regions. With K and V packed into the content dim, an attention
511
+ # block transfers as a single unit — no K/V sub-split is needed. Only
512
+ # Mamba still needs this, to index its two state regions (conv/ssm)
513
+ # separately. Not applicable to cross-layer blocks (per-layer
514
+ # interleaving means a simple half-split does not separate the parts).
515
+ return self.is_mamba and not self._cross_layers_blocks
516
517
# ============================================================
518
# Common methods
@@ -616,8 +616,9 @@ class TransferTopology:
616
# Swap [2<>num_blocks] dims for hybrid SSM layout.
617
cache = cache.transpose(0, 1)
618
619
- # Regular case: backends like FA register K/V in separate regions
620
- return cache if self.split_k_and_v else [cache]
619
+ # K and V are packed into one tensor (content dim), so each layer
620
+ # registers as a single region.
621
+ return [cache]
622
623
def describe(self, remote_engine_id: EngineId, remote_pp_rank: int = 0) -> str:
624
"""One-line summary of transfer config for logging."""
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_layout.py
+30
@@ -7,6 +7,7 @@ from typing import NamedTuple
7
import torch
8
9
from vllm.v1.kv_cache_interface import (
10
+ AttentionSpec,
11
KVCacheConfig,
12
KVCacheSpec,
13
MLAAttentionSpec,
@@ -56,6 +57,11 @@ def is_mla_cache_layer(
57
return isinstance(spec, (MLAAttentionSpec, SlidingWindowMLASpec))
58
59
60
+def _content_packed_dim(spec: AttentionSpec) -> int:
61
+ head_size_v = getattr(spec, "head_size_v", spec.head_size)
62
+ return spec.head_size + head_size_v
63
+
64
+
65
def _spec_dim_matches(value: int, expected: int | None) -> bool:
66
return expected is None or value == expected
67
@@ -223,6 +229,30 @@ def get_layer_transfer_geometry(
229
split_kv_regions=False,
230
)
231
232
+ if (
233
+ not is_mla_cache
234
+ and isinstance(spec, AttentionSpec)
235
+ and len(shape) == 4
236
+ and shape[1] == spec.num_kv_heads
237
+ and shape[2] == spec.block_size
238
+ and shape[3] == _content_packed_dim(spec)
239
+ ):
240
+ num_blocks, num_kv_heads, block_size, packed_dim = shape
241
+ slot_size_bytes = num_kv_heads * packed_dim * element_size
242
+ block_len = block_size * slot_size_bytes
243
+ return LayerTransferGeometry(
244
+ num_blocks=num_blocks,
245
+ block_size=block_size,
246
+ block_len=block_len,
247
+ slot_size_bytes=slot_size_bytes,
248
+ block_stride=stride[0],
249
+ local_kv_stride=None,
250
+ remote_kv_stride=None,
251
+ transfers_per_block=1,
252
+ regions_per_block=1,
253
+ split_kv_regions=False,
254
+ )
255
+
256
cache_kind = "MLA" if is_mla_cache else "K/V"
257
raise ValueError(
258
f"Unsupported MoRIIO {cache_kind} cache shape for layer "
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+33
-91
@@ -216,12 +216,10 @@ class NixlBaseConnectorWorker:
216
if n_regions == 0 or self.num_regions == 0:
217
return [False] * num_fa_descs
218
nblk = num_fa_descs // self.num_regions
219
- virtually_split = self.transfer_topo.virtually_split_kv_in_blocks
219
flags: list[bool] = []
220
for i in range(n_regions):
221
replicated = self._is_region_replicated(i)
223
- num_streams = 1 if replicated or not virtually_split else 2
224
- flags.extend([replicated] * (num_streams * nblk))
222
+ flags.extend([replicated] * nblk)
223
assert len(flags) == num_fa_descs, (
224
f"FA desc flags {len(flags)} != num_fa_descs {num_fa_descs}"
225
)
@@ -712,31 +710,31 @@ class NixlBaseConnectorWorker:
710
NOT directly supported by NIXL (e.g., tpu)
711
"""
712
xfer_buffers: dict[str, torch.Tensor] = {}
715
- inv_order = [0, 1, 3, 2, 4]
713
try:
714
for layer_name, kv_cache in kv_caches.items():
715
kv_shape = kv_cache.shape
716
kv_dtype = kv_cache.dtype
717
permute_shape = False
721
- if (
722
- self.kv_cache_layout == "NHD"
723
- and self.vllm_config.kv_transfer_config is not None
724
- and self.vllm_config.kv_transfer_config.enable_permute_local_kv
725
- ):
726
- logger.info_once(
727
- "'enable_permute_local_kv' flag is enabled while "
728
- "device KV Layout is NHD. Init host buffer with"
729
- " HND to better support Decode/Prefill TP_ratio > 1."
730
- )
731
- # Since NHD will not support Decode/Prefill TP_ratio > 1,
732
- # we can leverage host_buffer for permute
733
- self.host_buffer_kv_cache_layout = "HND"
734
- kv_shape = (
735
- tuple(kv_shape[i] for i in inv_order)
736
- if not self.use_mla
737
- else kv_shape
738
- )
739
- permute_shape = not self.use_mla
718
+ inv_order = (0, 2, 1, 3)
719
+ if not self.use_mla:
720
+ assert kv_cache.ndim == 4
721
+
722
+ if self.kv_cache_layout == "NHD":
723
+ if self.kv_transfer_config.enable_permute_local_kv:
724
+ logger.info_once(
725
+ "'enable_permute_local_kv' flag is enabled while "
726
+ "device KV Layout is NHD. Init host buffer with"
727
+ " HND to better support Decode/Prefill TP_ratio > 1."
728
+ )
729
+ # Since NHD will not support Decode/Prefill TP_ratio > 1,
730
+ # we can leverage host_buffer for permute.
731
+ self.host_buffer_kv_cache_layout = "HND"
732
+ else:
733
+ # Packed KV layout is logical (B, H, N, 2*D). Allocate
734
+ # (B, N, H, 2*D) and view it as logical (B, H, N, 2*D)
735
+ # so raw NIXL transfers see NHD physical strides.
736
+ kv_shape = tuple(kv_shape[i] for i in inv_order)
737
+ permute_shape = True
738
739
xfer_buffers[layer_name] = torch.empty(
740
kv_shape, dtype=kv_dtype, device="cpu"
@@ -1151,9 +1149,7 @@ class NixlBaseConnectorWorker:
1149
f"backend={self.backend_name}, "
1150
"all_backends="
1151
f"{[backend.get_name() for backend in self.attn_backends]}, "
1154
- f"kv_cache_layout={self.kv_cache_layout}, "
1155
- "blocks_first="
1156
- f"{self.transfer_topo.is_kv_layout_blocks_first}"
1152
+ f"kv_cache_layout={self.kv_cache_layout}"
1153
)
1154
1155
# Need to make sure the device ID is non-negative for NIXL,
@@ -1184,22 +1180,6 @@ class NixlBaseConnectorWorker:
1180
regions_per_layer = self.num_regions // num_local_layers
1181
self._remote_region_offset = regions_per_layer * start_layer
1182
1187
- if self.transfer_topo.virtually_split_kv_in_blocks:
1188
- # NOTE (NickLucche) When FlashInfer is used, memory is registered
1189
- # with joint KV for each block. This minimizes the overhead in
1190
- # registerMem allowing faster descs queries. In order to be able to
1191
- # split on kv_heads dim as required by heterogeneous TP, one must
1192
- # be able to index K/V separately. Hence we double the number
1193
- # of 'virtual' regions here and halve `block_len` below.
1194
- # Similarly for Mamba layers, we register SSM+Conv as a single region and
1195
- # then duplicate it logically to be able to index SSM/Conv separately.
1196
- # Exception: key-only REPLICATE regions (MLA) have no V half, so
1197
- # they contribute a single desc stream and are not doubled.
1198
- self.num_regions = sum(
1199
- 1 if self._is_region_replicated(i) else 2
1200
- for i in range(len(self._region_is_mla))
1201
- )
1202
-
1183
# Total local FA descriptors (boundary between FA and mamba descs).
1184
self.num_descs = self.num_regions * self.num_blocks
1185
@@ -1363,22 +1343,6 @@ class NixlBaseConnectorWorker:
1343
block_offset = block_id * page_stride
1344
addr = base_addr + block_offset
1345
result.append((addr, kv_block_len, self.device_id))
1366
-
1367
- if (
1368
- self.transfer_topo.virtually_split_kv_in_blocks
1369
- and not self._is_region_replicated(i)
1370
- ):
1371
- # Separate and interleave K/V regions to maintain the same
1372
- # descs ordering. This is needed for selecting contiguous heads
1373
- # when split across TP ranks. (Skipped for key-only REPLICATE.)
1374
- second_split = self.get_backend_aware_kv_block_len(
1375
- layer_idx=i, first_split=False, mamba_view=False
1376
- )
1377
- for block_id in range(num_blocks):
1378
- block_offset = block_id * page_stride
1379
- addr = base_addr + block_offset
1380
- v_addr = addr + kv_block_len
1381
- result.append((v_addr, second_split, self.device_id))
1346
return result
1347
1348
def _build_fa_remote(
@@ -1423,20 +1387,6 @@ class NixlBaseConnectorWorker:
1387
# tp rank of size local_block_len.
1388
addr = base_addr + block_offset + rank_offset
1389
result.append((addr, local_block_len, nixl_agent_meta.device_id))
1426
-
1427
- emits_v = self.transfer_topo.virtually_split_kv_in_blocks and not replicated
1428
- if emits_v:
1429
- # With FlashInfer index V separately to allow head splitting.
1430
- second_split = self.get_backend_aware_kv_block_len(
1431
- layer_idx=i, first_split=False, mamba_view=False
1432
- )
1433
- second_split = second_split // num_reads
1434
- for block_id in range(num_blocks):
1435
- block_offset = block_id * page_size
1436
- addr = base_addr + block_offset + rank_offset
1437
- # Hop over the first split of remote page, K, to read V.
1438
- v_addr = addr + nixl_agent_meta.block_lens[i] // 2
1439
- result.append((v_addr, second_split, nixl_agent_meta.device_id))
1390
return result
1391
1392
def register_local_xfer_handler(
@@ -1927,24 +1877,18 @@ class NixlBaseConnectorWorker:
1877
block_size_ratio,
1878
)
1879
1930
- split_k_and_v = self.transfer_topo.split_k_and_v
1931
-
1880
for block_ids in block_ids_list:
1881
indices = torch.tensor(block_ids, device=self.device_type, dtype=torch.long)
1882
1935
- for _, cache_or_caches in self.device_kv_caches.items():
1936
- cache_list = cache_or_caches if split_k_and_v else [cache_or_caches]
1937
- for cache in cache_list:
1938
- if self.enable_permute_local_kv and block_size_ratio > 1:
1939
- kv_postprocess_blksize_and_layout_on_receive(
1940
- cache, indices, block_size_ratio
1941
- )
1942
- elif self.enable_permute_local_kv:
1943
- kv_postprocess_layout_on_receive(cache, indices)
1944
- else:
1945
- kv_postprocess_blksize_on_receive(
1946
- cache, indices, block_size_ratio
1947
- )
1883
+ for cache in self.device_kv_caches.values():
1884
+ if self.enable_permute_local_kv and block_size_ratio > 1:
1885
+ kv_postprocess_blksize_and_layout_on_receive(
1886
+ cache, indices, block_size_ratio
1887
+ )
1888
+ elif self.enable_permute_local_kv:
1889
+ kv_postprocess_layout_on_receive(cache, indices)
1890
+ else:
1891
+ kv_postprocess_blksize_on_receive(cache, indices, block_size_ratio)
1892
1893
def post_process_device_kv_on_receive_heterogeneous_attn(
1894
self, block_ids: list[int]
@@ -2398,12 +2342,10 @@ class NixlBaseConnectorWorker:
2342
|1st_split-2nd_split| |1st_split-2nd_split |
2343
"""
2344
assert self.transfer_topo is not None
2401
- virtually_split = self.transfer_topo.virtually_split_kv_in_blocks
2402
- if virtually_split and mamba_view:
2345
+ if self.transfer_topo.virtually_split_kv_in_blocks and mamba_view:
2346
block_len = self._mamba_ssm_size[not first_split]
2347
else:
2405
- half_block = virtually_split and not self._is_region_replicated(layer_idx)
2406
- block_len = self.block_len_per_layer[layer_idx] // (2 if half_block else 1)
2348
+ block_len = self.block_len_per_layer[layer_idx]
2349
return block_len
2350
2351
def get_kv_connector_stats(self) -> KVConnectorStats | None:
vllm/models/minimax_m3/common/ops/sparse_attn.py
+14
-21
@@ -8,7 +8,8 @@ equal the sparse block size (128), so one selected block maps to exactly one
8
page.
9
10
Main K/V cache layout (vLLM):
11
- ``(num_blocks, 2, 128, num_kv_heads, head_dim)`` K=[:,0] V=[:,1]
11
+ ``(num_blocks, num_kv_heads, 128, 2 * head_dim)``
12
+ K=[..., :head_dim] V=[..., head_dim:]
13
14
Only the paths MiniMax M3 uses are implemented: no attention sink, base-2
15
(exp2/log2) softmax. The decode kernels use split-K (flash-decoding) over the
@@ -50,7 +51,7 @@ _FP8_DTYPES = (
51
@triton.jit(do_not_specialize_on_alignment=["seq_lens", "prefix_lens"])
52
def _gqa_sparse_fwd_kernel(
53
q_ptr, # [total_q, num_heads, head_dim]
53
- kv_cache_ptr, # main cache: [num_blocks, 2, 128, num_kv_heads, head_dim]
54
+ kv_cache_ptr, # main cache: [num_blocks, num_kv_heads, 128, 2*head_dim]
55
t_ptr, # topk_idx: [num_kv_heads, total_q, topk]
56
o_ptr, # [total_q, num_heads, head_dim]
57
block_table_ptr, # [num_reqs, max_blocks]
@@ -68,9 +69,8 @@ def _gqa_sparse_fwd_kernel(
69
stride_qh,
70
stride_qd,
71
stride_kv_blk,
71
- stride_kv_kv,
72
- stride_kv_pos,
72
stride_kv_h,
73
+ stride_kv_pos,
74
stride_kv_d,
75
stride_th,
76
stride_tn,
@@ -140,9 +140,8 @@ def _gqa_sparse_fwd_kernel(
140
k = tl.load(
141
kv_cache_ptr
142
+ page * stride_kv_blk
143
- + 0 * stride_kv_kv
144
- + off_n[None, :] * stride_kv_pos
143
+ pid_kh * stride_kv_h
144
+ + off_n[None, :] * stride_kv_pos
145
+ off_d[:, None] * stride_kv_d,
146
mask=d_mask[:, None] & pos_mask[None, :],
147
other=0.0,
@@ -162,10 +161,9 @@ def _gqa_sparse_fwd_kernel(
161
v = tl.load(
162
kv_cache_ptr
163
+ page * stride_kv_blk
165
- + 1 * stride_kv_kv
166
- + off_n[:, None] * stride_kv_pos
164
+ pid_kh * stride_kv_h
168
- + off_d[None, :] * stride_kv_d,
165
+ + off_n[:, None] * stride_kv_pos
166
+ + (head_dim + off_d[None, :]) * stride_kv_d,
167
mask=pos_mask[:, None] & d_mask[None, :],
168
other=0.0,
169
)
@@ -206,7 +204,7 @@ def _gqa_sparse_fwd_kernel(
204
@triton.jit(do_not_specialize=["decode_query_len"])
205
def _gqa_sparse_decode_kernel(
206
q_ptr, # [total_q, num_heads, head_dim]
209
- kv_cache_ptr, # main cache: [num_blocks, 2, 128, num_kv_heads, head_dim]
207
+ kv_cache_ptr, # main cache: [num_blocks, num_kv_heads, 128, 2*head_dim]
208
t_ptr, # topk_idx: [num_kv_heads, total_q, topk]
209
o_ptr, # partial out: [NUM_TOPK_CHUNKS, total_q, num_heads, head_dim]
210
lse_ptr, # partial lse (log2): [NUM_TOPK_CHUNKS, total_q, num_heads]
@@ -222,9 +220,8 @@ def _gqa_sparse_decode_kernel(
220
stride_qh,
221
stride_qd,
222
stride_kv_blk,
225
- stride_kv_kv,
226
- stride_kv_pos,
223
stride_kv_h,
224
+ stride_kv_pos,
225
stride_kv_d,
226
stride_th,
227
stride_tn,
@@ -300,9 +297,8 @@ def _gqa_sparse_decode_kernel(
297
k = tl.load(
298
kv_cache_ptr
299
+ page * stride_kv_blk
303
- + 0 * stride_kv_kv
304
- + off_n[None, :] * stride_kv_pos
300
+ pid_kh * stride_kv_h
301
+ + off_n[None, :] * stride_kv_pos
302
+ off_d[:, None] * stride_kv_d,
303
mask=d_mask[:, None] & pos_mask[None, :],
304
other=0.0,
@@ -319,10 +315,9 @@ def _gqa_sparse_decode_kernel(
315
v = tl.load(
316
kv_cache_ptr
317
+ page * stride_kv_blk
322
- + 1 * stride_kv_kv
323
- + off_n[:, None] * stride_kv_pos
318
+ pid_kh * stride_kv_h
325
- + off_d[None, :] * stride_kv_d,
319
+ + off_n[:, None] * stride_kv_pos
320
+ + (head_dim + off_d[None, :]) * stride_kv_d,
321
mask=pos_mask[:, None] & d_mask[None, :],
322
other=0.0,
323
)
@@ -418,7 +413,7 @@ def _merge_topk_attn_out_kernel(
413
@torch.no_grad()
414
def minimax_m3_sparse_attn(
415
q: torch.Tensor, # [total_q, num_heads, head_dim]
421
- kv_cache: torch.Tensor, # [num_blocks, 2, 128, num_kv_heads, head_dim]
416
+ kv_cache: torch.Tensor, # [num_blocks, num_kv_heads, 128, 2*head_dim]
417
topk_idx: torch.Tensor, # [num_kv_heads, total_q, topk]
418
block_table: torch.Tensor, # [batch, max_blocks]
419
cu_seqlens_q: torch.Tensor, # [batch+1] int32
@@ -459,7 +454,6 @@ def minimax_m3_sparse_attn(
454
kv_cache.stride(1),
455
kv_cache.stride(2),
456
kv_cache.stride(3),
462
- kv_cache.stride(4),
457
topk_idx.stride(0),
458
topk_idx.stride(1),
459
topk_idx.stride(2),
@@ -476,7 +470,7 @@ def minimax_m3_sparse_attn(
470
@torch.no_grad()
471
def minimax_m3_sparse_attn_decode(
472
q: torch.Tensor, # [total_q, num_heads, head_dim]
479
- kv_cache: torch.Tensor, # [num_blocks, 2, 128, num_kv_heads, head_dim]
473
+ kv_cache: torch.Tensor, # [num_blocks, num_kv_heads, 128, 2*head_dim]
474
topk_idx: torch.Tensor, # [num_kv_heads, total_q, topk]
475
block_table: torch.Tensor, # [num_reqs, max_blocks]
476
seq_lens: torch.Tensor, # [num_reqs] int32
@@ -529,7 +523,6 @@ def minimax_m3_sparse_attn_decode(
523
kv_cache.stride(1),
524
kv_cache.stride(2),
525
kv_cache.stride(3),
532
- kv_cache.stride(4),
526
topk_idx.stride(0),
527
topk_idx.stride(1),
528
topk_idx.stride(2),
vllm/models/minimax_m3/common/sparse_attention.py
+9
-4
@@ -104,20 +104,25 @@ class MiniMaxM3SparseBackend(AttentionBackend):
104
head_size: int,
105
cache_dtype_str: str = "auto",
106
) -> tuple[int, ...]:
107
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
107
+ # K and V are packed into the content dim: logical (B, H, N, 2*hs).
108
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
109
110
@staticmethod
111
def get_kv_cache_stride_order(
112
include_num_layers_dimension: bool = False,
113
) -> tuple[int, ...]:
113
- # Permutation from get_kv_cache_shape to the actual memory layout.
114
+ # `stride_order` indicates the permutation that gets us from
115
+ # `get_kv_cache_shape` (logical (B, H, N, 2*hs)) to the actual memory
116
+ # layout we want.
117
if include_num_layers_dimension:
118
raise NotImplementedError # no cross-layer KV blocks in M3
119
cache_layout = get_kv_cache_layout()
120
if cache_layout == "NHD":
118
- stride_order = (0, 1, 2, 3, 4)
121
+ # (num_blocks, block_size, num_kv_heads, 2*head_size)
122
+ stride_order = (0, 2, 1, 3)
123
elif cache_layout == "HND":
120
- stride_order = (0, 1, 3, 2, 4)
124
+ # (num_blocks, num_kv_heads, block_size, 2*head_size)
125
+ stride_order = (0, 1, 2, 3)
126
else:
127
raise ValueError(f"Unknown cache layout format {cache_layout}.")
128
return stride_order
vllm/models/minimax_m3/nvidia/sparse_attention_msa.py
+1
-2
@@ -79,8 +79,7 @@ class MiniMaxM3SparseMSAImpl(MiniMaxM3SparseImpl):
79
# strided view directly (topK stays innermost-contiguous).
80
prefill_topk = topk[nd:num_tokens].transpose(0, 1)
81
qp = q[nd:]
82
- k_cache = kv_cache[:, 0].transpose(1, 2)
83
- v_cache = kv_cache[:, 1].transpose(1, 2)
82
+ k_cache, v_cache = kv_cache.split(self.head_size, dim=-1)
83
k2q_row_ptr, k2q_q_indices, schedule = build_k2q_csr(
84
prefill_topk,
85
p.cu_seqlens_q,
vllm/platforms/rocm.py
+16
-2
@@ -427,8 +427,8 @@ def _get_backend_priorities(
427
]
428
429
backends = []
430
- # ROCM_ATTN uses (2, num_blocks, ...) KV cache layout which is
431
- # incompatible with KV connectors that require blocks-first layout.
430
+ # Keep ROCM_ATTN disabled for KV connectors until connector transfer
431
+ # semantics are validated for its asymmetric native K/V cache views.
432
if not use_kv_connector:
433
backends.append(AttentionBackendEnum.ROCM_ATTN)
434
if rocm_aiter_ops.is_mha_enabled():
@@ -511,6 +511,20 @@ class RocmPlatform(Platform):
511
attn_selector_config.use_sparse,
512
attn_selector_config.use_kv_connector,
513
)
514
+ from vllm.config import get_current_vllm_config_or_none
515
+
516
+ vllm_config = get_current_vllm_config_or_none()
517
+ is_encoder_decoder = (
518
+ getattr(getattr(vllm_config, "model_config", None), "attn_type", None)
519
+ == "encoder_decoder"
520
+ )
521
+ # ROCM_ATTN still uses a legacy attention layout (KV is the outer
522
+ # dimension) that is incompatible with the encoder backend layouts. The
523
+ # encoder and decoder need the layouts to match. This is currently
524
+ # enforced implicitly.
525
+ # TODO: Make this explicit in the selector in a future PR.
526
+ if is_encoder_decoder and AttentionBackendEnum.ROCM_ATTN in backend_priorities:
527
+ backend_priorities.remove(AttentionBackendEnum.ROCM_ATTN)
528
for priority, backend in enumerate(backend_priorities):
529
try:
530
backend_class = backend.get_class()
vllm/utils/torch_utils.py
+11
-45
@@ -416,26 +416,24 @@ def nvfp4_kv_cache_full_dim(head_size: int) -> int:
416
return head_size // 2 + head_size // 16
417
418
419
-def _nvfp4_split_data_scale(
419
+def nvfp4_split_data_scale(
420
kv_side: torch.Tensor,
421
) -> tuple[torch.Tensor, torch.Tensor]:
422
- """Split a single NVFP4 KV-side buffer into data and scale views.
422
+ """Split one side (K or V) of an NVFP4 KV cache into data and scale.
423
424
- The input is a 4D tensor for one KV side (K or V) whose last
425
- dimension is ``full_dim = data_dim + scale_dim``. The physical
426
- layout within each side is [data | scale], both packed contiguously.
424
+ The input is a 4D uint8 tensor whose last dimension is
425
+ ``full_dim = data_dim + scale_dim``. The physical layout within each
426
+ side is ``[data | scale]``, both packed contiguously.
427
+
428
+ The caller is responsible for slicing K and V from the combined cache
429
+ first (e.g. ``kv_cache.split(num_kv_heads, dim=1)``).
430
431
Args:
429
- kv_side: 4D uint8 tensor with shape
430
- ``(num_pages, dim_1, dim_2, full_dim)``.
431
- May be in any permutation order (NHD or HND).
432
+ kv_side: 4D uint8 tensor ``(B, H, N, full_dim)``.
433
434
Returns:
434
- ``(data, scale)`` where
435
- ``data`` is a uint8 view with shape
436
- ``(num_pages, dim_1, dim_2, data_dim)``.
437
- ``scale`` is a float8_e4m3fn view with shape
438
- ``(num_pages, dim_1, dim_2, scale_dim)``.
435
+ ``(data, scale)`` where *data* is uint8 and *scale* is
436
+ float8_e4m3fn, both views of the same storage.
437
"""
438
num_pages = kv_side.shape[0]
439
dim_1, dim_2 = kv_side.shape[1], kv_side.shape[2]
@@ -468,38 +466,6 @@ def _nvfp4_split_data_scale(
466
return data, scale
467
468
471
-def nvfp4_kv_cache_split_views(kv_cache: torch.Tensor) -> tuple[tuple, tuple]:
472
- """Split an NVFP4 KV cache tensor into data and scale views.
473
-
474
- Accepts either a 5D tensor ``(num_pages, 2, dim_2, dim_3, full_dim)``
475
- or a 4D single-side tensor ``(num_pages, dim_2, dim_3, full_dim)``.
476
-
477
- Per-page layout: [K_data | K_scale | V_data | V_scale].
478
- Each KV side is self-contained (data followed by its scale), so the
479
- 5D case simply splits each side independently.
480
-
481
- The returned views are in the same dim order as the input (NHD or
482
- HND), so callers get views matching whichever order they passed in.
483
-
484
- Args:
485
- kv_cache: 5D or 4D uint8 tensor where the last dimension is
486
- ``full_dim = data_dim + scale_dim = 9 * head_size / 16``.
487
-
488
- Returns:
489
- For 5D input:
490
- ``(k_data, v_data), (k_scale, v_scale)``
491
- For 4D input (single KV side):
492
- ``(data,), (scale,)``
493
- """
494
- if kv_cache.dim() == 4:
495
- data, scale = _nvfp4_split_data_scale(kv_cache)
496
- return (data,), (scale,)
497
-
498
- k_data, k_scale = _nvfp4_split_data_scale(kv_cache[:, 0])
499
- v_data, v_scale = _nvfp4_split_data_scale(kv_cache[:, 1])
500
- return (k_data, v_data), (k_scale, v_scale)
501
-
502
-
469
def create_kv_caches_with_random_flash(
470
num_blocks: int,
471
block_size: int,
vllm/v1/attention/backend.py
+6
-6
@@ -122,11 +122,11 @@ class AttentionBackend(ABC):
122
) -> tuple[int, ...]:
123
"""
124
Get the physical (memory layout) ordering of the kv cache dimensions.
125
- e.g. if the KV cache shape is
126
- [2, num_blocks, block_size, num_heads, head_size],
127
- and get_kv_cache_stride_order returns (1, 3, 0, 2, 4) then the physical
125
+ Standard attention backends pack K and V into the content dim, giving
126
+ the logical shape [num_blocks, num_heads, block_size, 2 * head_size].
127
+ e.g. if get_kv_cache_stride_order returns (0, 2, 1, 3) then the physical
128
ordering of dimensions is
129
- [num_blocks, num_heads, 2, block_size, head_size].
129
+ [num_blocks, block_size, num_heads, 2 * head_size].
130
131
If this function is unimplemented / raises NotImplementedError,
132
the physical layout of the KV cache will match the logical shape.
@@ -135,9 +135,9 @@ class AttentionBackend(ABC):
135
include_num_layers_dimension: if True, includes an additional
136
num_layers dimension, which is assumed to be prepended
137
to the logical KV cache shape.
138
- With the above example, a return value (2, 4, 0, 1, 3, 5)
138
+ With the above example, a return value (1, 0, 3, 2, 4)
139
corresponds to
140
- [num_blocks, num_heads, num_layers, 2, block_size, head_size].
140
+ [num_blocks, num_layers, block_size, num_heads, 2 * head_size].
141
142
If an additional dimension is NOT included in the returned
143
tuple, the physical layout will not include a layers dimension.
vllm/v1/attention/backends/flash_attn.py
+18
-13
@@ -133,25 +133,29 @@ class FlashAttentionBackend(AttentionBackend):
133
) -> tuple[int, ...]:
134
if block_size % 16 != 0:
135
raise ValueError("Block size must be a multiple of 16.")
136
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
136
+ # K and V are packed into the content dim: logical (B, H, N, 2*D).
137
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
138
139
@staticmethod
140
def get_kv_cache_stride_order(
141
include_num_layers_dimension: bool = False,
142
) -> tuple[int, ...]:
142
- # `stride_order` indicates the permutation that gets
143
- # us from `get_kv_cache_shape` to the actual memory layout we want.
143
+ # `stride_order` indicates the permutation that gets us from
144
+ # `get_kv_cache_shape` (logical (B, H, N, 2*D)) to the actual memory
145
+ # layout we want.
146
cache_layout = get_kv_cache_layout()
147
if cache_layout == "NHD" and include_num_layers_dimension:
146
- # (num_blocks, num_layers, 2, block_size, num_kv_heads, head_size)
147
- return (1, 0, 2, 3, 4, 5)
148
+ # (num_blocks, num_layers, block_size, num_kv_heads, 2*head_size)
149
+ return (1, 0, 3, 2, 4)
150
elif cache_layout == "NHD":
149
- stride_order = (0, 1, 2, 3, 4)
151
+ # (num_blocks, block_size, num_kv_heads, 2*head_size)
152
+ stride_order = (0, 2, 1, 3)
153
elif cache_layout == "HND" and include_num_layers_dimension:
151
- # (num_blocks, num_kv_heads, num_layers, 2, block_size, head_size)
152
- return (1, 4, 0, 2, 3, 5)
154
+ # (num_blocks, num_kv_heads, num_layers, block_size, 2*head_size)
155
+ return (1, 2, 0, 3, 4)
156
elif cache_layout == "HND":
154
- stride_order = (0, 1, 3, 2, 4)
157
+ # (num_blocks, num_kv_heads, block_size, 2*head_size)
158
+ stride_order = (0, 1, 2, 3)
159
else:
160
raise ValueError(f"Unknown cache layout format {cache_layout}.")
161
return stride_order
@@ -819,7 +823,7 @@ class FlashAttentionImpl(AttentionImpl):
823
key: shape = [num_tokens, num_kv_heads, head_size]
824
value: shape = [num_tokens, num_kv_heads, head_size]
825
kv_cache: shape =
822
- [num_blocks, 2, block_size, num_kv_heads, head_size]
826
+ [num_blocks, num_kv_heads, block_size, 2 * head_size]
827
attn_metadata: Metadata for attention.
828
Returns:
829
shape = [num_tokens, num_heads * head_size]
@@ -866,8 +870,8 @@ class FlashAttentionImpl(AttentionImpl):
870
layer,
871
)
872
869
- # For decoder and cross-attention, use KV cache as before
870
- key_cache, value_cache = kv_cache.unbind(1)
873
+ # (B, H, N, 2*D) -> ((B, N, H, D), (B, N, H, D))
874
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
875
# Fix degenerate strides on size-1 dims (e.g. num_kv_heads=1 with TP).
876
# FA3/4 on H100+ uses TMA, which requires ≥16-byte stride alignment.
877
# See vllm.utils.torch_utils.canonicalize_singleton_dim_strides.
@@ -1075,7 +1079,8 @@ class FlashAttentionImpl(AttentionImpl):
1079
1080
# Scatter write into the KV cache using slot_mapping indices.
1081
# No TMA kernel is invoked here, so stride canonicalization is not needed.
1078
- key_cache, value_cache = kv_cache.unbind(1)
1082
+ # (B, H, N, 2*D) -> ((B, N, H, D), (B, N, H, D))
1083
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
1084
1085
# Reshape the input keys and values and store them in the cache.
1086
# Skip this if sharing KV cache with an earlier attention layer.
vllm/v1/attention/backends/flash_attn_diffkv.py
+18
-24
@@ -83,10 +83,12 @@ class FlashAttentionDiffKVBackend(FlashAttentionBackend):
83
) -> tuple[int, ...]:
84
if block_size % 16 != 0:
85
raise ValueError("Block size must be a multiple of 16.")
86
+ # Logical (blocks-first, head-major) layout: K and V (with their
87
+ # different head sizes) packed in the content dim.
88
return (
89
num_blocks,
88
- block_size,
90
num_kv_heads,
91
+ block_size,
92
head_size + FlashAttentionDiffKVBackend.head_size_v,
93
)
94
@@ -94,21 +96,22 @@ class FlashAttentionDiffKVBackend(FlashAttentionBackend):
96
def get_kv_cache_stride_order(
97
include_num_layers_dimension: bool = False,
98
) -> tuple[int, ...]:
97
- # `stride_order` indicates the permutation that gets
98
- # us from `get_kv_cache_shape` to the actual memory layout we want.
99
+ # `stride_order` indicates the permutation that gets us from
100
+ # `get_kv_cache_shape` (logical (B, H, N, C_k+C_v)) to the actual
101
+ # memory layout we want.
102
cache_layout = get_kv_cache_layout()
103
if cache_layout == "NHD" and include_num_layers_dimension:
101
- # (num_blocks, num_layers, block_size,
102
- # num_kv_heads, head_size + head_size_v)
103
- return (1, 0, 2, 3, 4)
104
+ # (num_blocks, num_layers, block_size, num_kv_heads, C_k+C_v)
105
+ return (1, 0, 3, 2, 4)
106
elif cache_layout == "NHD":
105
- stride_order = (0, 1, 2, 3)
107
+ # (num_blocks, block_size, num_kv_heads, C_k+C_v)
108
+ stride_order = (0, 2, 1, 3)
109
elif cache_layout == "HND" and include_num_layers_dimension:
107
- # (num_blocks, num_kv_heads, num_layers,
108
- # block_size, head_size + head_size_v)
109
- return (1, 3, 0, 2, 4)
110
+ # (num_blocks, num_kv_heads, num_layers, block_size, C_k+C_v)
111
+ return (1, 2, 0, 3, 4)
112
elif cache_layout == "HND":
111
- stride_order = (0, 2, 1, 3)
113
+ # (num_blocks, num_kv_heads, block_size, C_k+C_v)
114
+ stride_order = (0, 1, 2, 3)
115
else:
116
raise ValueError(f"Unknown cache layout format {cache_layout}.")
117
return stride_order
@@ -142,21 +145,14 @@ class FlashAttentionDiffKVImpl(FlashAttentionImpl):
145
# we use direct Q, K, V tensors without caching
146
return
147
145
- # Unlike standard FlashAttn which splits kv_cache via unbind(0),
148
# DiffKV packs K and V into a single tensor along the last dim:
149
# kv_cache shape: [num_blocks, block_size, num_kv_heads,
150
# head_size_k + head_size_v]
149
- # The triton kernel handles this combined layout directly.
150
- #
151
- # NOTE(woosuk): key and value are padded while slot_mapping is
152
- # not padded. However, we don't need to do key[:num_actual_tokens]
153
- # and value[:num_actual_tokens] because the reshape_and_cache_flash
154
- # op uses the slot_mapping's shape to determine the number of
155
- # actual tokens.
151
+ # (B, H, N, C) -> (B, N, H, C) for kernel compatibility.
152
triton_reshape_and_cache_flash_diffkv(
153
key,
154
value,
159
- kv_cache,
155
+ kv_cache.transpose(1, 2),
156
slot_mapping,
157
self.kv_cache_dtype,
158
layer._k_scale,
@@ -229,10 +225,8 @@ class FlashAttentionDiffKVImpl(FlashAttentionImpl):
225
layer,
226
)
227
232
- # For decoder and cross-attention, use KV cache as before
233
- # Different head_size for K and V
234
- key_cache = kv_cache[..., : self.head_size]
235
- value_cache = kv_cache[..., self.head_size :]
228
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
229
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
230
# Fix degenerate strides on size-1 dims (e.g. num_kv_heads=1 with TP).
231
# FA3/4 on H100+ uses TMA, which requires ≥16-byte stride alignment.
232
# See vllm.utils.torch_utils.canonicalize_singleton_dim_strides.
vllm/v1/attention/backends/flashinfer.py
+97
-43
@@ -51,7 +51,7 @@ from vllm.utils.torch_utils import (
51
is_quantized_kv_cache,
52
is_strictly_contiguous,
53
nvfp4_kv_cache_full_dim,
54
- nvfp4_kv_cache_split_views,
54
+ nvfp4_split_data_scale,
55
)
56
from vllm.v1.attention.backend import (
57
AttentionBackend,
@@ -111,9 +111,12 @@ def _trtllm_prefill_attn_kvfp8_dequant(
111
src_stride_page,
112
src_stride_kv,
113
src_stride_head,
114
+ src_stride_block,
115
+ src_stride_head_size,
116
DST_K_CACHE_STRIDE: tl.constexpr,
117
DST_KV_CACHE_STRIDE: tl.constexpr,
118
HEAD_STRIDE: tl.constexpr,
119
+ HEAD_SIZE: tl.constexpr,
120
NUM_KV_HEADS: tl.constexpr,
121
):
122
batch_idx = tl.program_id(0).to(tl.int64)
@@ -129,18 +132,25 @@ def _trtllm_prefill_attn_kvfp8_dequant(
132
v_scale_val = tl.load(v_scale_ptr)
133
134
mock_page_idx = batch_idx * block_table_stride + mock_block_table_idx + 1
132
- head_offsets = tl.arange(0, HEAD_STRIDE)
135
+ logical_offsets = tl.arange(0, HEAD_STRIDE)
136
+ block_offsets = logical_offsets // HEAD_SIZE
137
+ head_size_offsets = logical_offsets % HEAD_SIZE
138
139
for h in range(NUM_KV_HEADS):
140
h_off = tl.cast(h, tl.int64)
141
142
# Read K from source (supports non-contiguous page/kv/head strides)
138
- src_k = orig_page_num * src_stride_page + h_off * src_stride_head + head_offsets
143
+ src_k = (
144
+ orig_page_num * src_stride_page
145
+ + h_off * src_stride_head
146
+ + block_offsets * src_stride_block
147
+ + head_size_offsets * src_stride_head_size
148
+ )
149
fp8_k = tl.load(kv_cache_ptr + src_k)
150
dequant_k = (fp8_k.to(tl.float32) * k_scale_val).to(dequant_dtype)
151
152
# Write K to contiguous mock cache
143
- dst_k = mock_page_idx * DST_KV_CACHE_STRIDE + h * HEAD_STRIDE + head_offsets
153
+ dst_k = mock_page_idx * DST_KV_CACHE_STRIDE + h * HEAD_STRIDE + logical_offsets
154
tl.store(mock_kv_cache_ptr + dst_k, dequant_k)
155
156
# Read V from source (offset by src_stride_kv for the V half)
@@ -148,7 +158,8 @@ def _trtllm_prefill_attn_kvfp8_dequant(
158
orig_page_num * src_stride_page
159
+ src_stride_kv
160
+ h_off * src_stride_head
151
- + head_offsets
161
+ + block_offsets * src_stride_block
162
+ + head_size_offsets * src_stride_head_size
163
)
164
fp8_v = tl.load(kv_cache_ptr + src_v)
165
dequant_v = (fp8_v.to(tl.float32) * v_scale_val).to(dequant_dtype)
@@ -158,7 +169,7 @@ def _trtllm_prefill_attn_kvfp8_dequant(
169
mock_page_idx * DST_KV_CACHE_STRIDE
170
+ DST_K_CACHE_STRIDE
171
+ h * HEAD_STRIDE
161
- + head_offsets
172
+ + logical_offsets
173
)
174
tl.store(mock_kv_cache_ptr + dst_v, dequant_v)
175
@@ -175,17 +186,14 @@ def trtllm_prefill_attn_kvfp8_dequant(
186
assert s[1] == 2
187
assert dequant_dtype in (torch.bfloat16, torch.float16)
188
189
+ # Logical source layout is (B, 2, H, N, D). The tensor may be a
190
+ # non-contiguous view, so the Triton kernel indexes it with actual strides.
191
+ strides = kv_cache.stride()
192
num_kv_heads, block_size, head_size = s[2], s[3], s[4]
193
head_stride = block_size * head_size
194
k_cache_stride = num_kv_heads * head_stride
195
kv_cache_stride = k_cache_stride * s[1]
196
183
- strides = kv_cache.stride()
184
- assert strides[3] == head_size and strides[4] == 1, (
185
- "For kv cache layouts, (block_size, head_size) "
186
- f"dimensions must be contiguous, got strides {strides}"
187
- )
188
-
197
new_s = (batch_size * num_of_page_per_token + 1, s[1], s[2], s[3], s[4])
198
# mock kv cache contains just the pages needed by this prefill
199
mock_kv_cache = torch.empty(new_s, dtype=dequant_dtype, device=kv_cache.device)
@@ -207,9 +215,12 @@ def trtllm_prefill_attn_kvfp8_dequant(
215
strides[0],
216
strides[1],
217
strides[2],
218
+ strides[3],
219
+ strides[4],
220
k_cache_stride,
221
kv_cache_stride,
222
head_stride,
223
+ head_size,
224
num_kv_heads,
225
)
226
return mock_kv_cache, mock_block_table
@@ -286,7 +297,7 @@ class BatchDCPPrefillWrapper:
297
self,
298
layer: torch.nn.Module,
299
prefill_query: torch.Tensor,
289
- kv_cache_permute: torch.Tensor,
300
+ kv_cache_tuple: tuple[torch.Tensor, torch.Tensor],
301
key: torch.Tensor,
302
value: torch.Tensor,
303
out: torch.Tensor,
@@ -296,7 +307,7 @@ class BatchDCPPrefillWrapper:
307
)
308
output_context_tmp, lse_context_tmp = self._context.run(
309
prefill_query_across_dcp,
299
- kv_cache_permute,
310
+ kv_cache_tuple,
311
k_scale=layer._k_scale_float,
312
v_scale=layer._v_scale_float,
313
return_lse=True,
@@ -386,28 +397,31 @@ class FlashInferBackend(AttentionBackend):
397
cache_dtype_str: str = "auto",
398
) -> tuple[int, ...]:
399
if cache_dtype_str == "nvfp4":
389
- # Packed layout: fp4 data + fp8 block scales in last dim
390
- last_dim = nvfp4_kv_cache_full_dim(head_size)
391
- return (num_blocks, 2, block_size, num_kv_heads, last_dim)
392
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
400
+ full_dim = nvfp4_kv_cache_full_dim(head_size)
401
+ return (num_blocks, 2 * num_kv_heads, block_size, full_dim)
402
+ # Pack K and V in the content dim (B, H, N, 2*hs).
403
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
404
405
@staticmethod
406
def get_kv_cache_stride_order(
407
include_num_layers_dimension: bool = False,
408
) -> tuple[int, ...]:
409
# `stride_order` indicates the permutation that gets us from
399
- # `get_kv_cache_shape` to the actual memory layout we want.
410
+ # `get_kv_cache_shape` (logical (B, H, N, 2*hs)) to the actual memory
411
+ # layout we want.
412
cache_layout = get_kv_cache_layout()
413
if cache_layout == "NHD" and include_num_layers_dimension:
402
- # (num_blocks, num_layers, 2, block_size, num_kv_heads, head_size)
403
- return (1, 0, 2, 3, 4, 5)
414
+ # (num_blocks, num_layers, block_size, num_kv_heads, 2*head_size)
415
+ return (1, 0, 3, 2, 4)
416
elif cache_layout == "NHD":
405
- stride_order = (0, 1, 2, 3, 4)
417
+ # (num_blocks, block_size, num_kv_heads, 2*head_size)
418
+ stride_order = (0, 2, 1, 3)
419
elif cache_layout == "HND" and include_num_layers_dimension:
407
- # (num_blocks, 2, num_kv_heads, num_layers, block_size, head_size)
408
- return (1, 2, 4, 0, 3, 5)
420
+ # (num_blocks, num_kv_heads, num_layers, block_size, 2*head_size)
421
+ return (1, 2, 0, 3, 4)
422
elif cache_layout == "HND":
410
- stride_order = (0, 1, 3, 2, 4)
423
+ # (num_blocks, num_kv_heads, block_size, 2*head_size)
424
+ stride_order = (0, 1, 2, 3)
425
else:
426
raise ValueError(f"Unknown cache layout format {cache_layout}.")
427
return stride_order
@@ -1714,7 +1728,19 @@ class FlashInferImpl(AttentionImpl):
1728
if attn_metadata.use_cascade:
1729
# Cascade attention (rare case).
1730
assert attn_metadata.cascade_wrapper is not None
1717
- output.copy_(attn_metadata.cascade_wrapper.run(query, kv_cache))
1731
+ stride_order = FlashInferBackend.get_kv_cache_stride_order()
1732
+ if self.is_kvcache_nvfp4:
1733
+ kv_cache_views = tuple(
1734
+ cache.permute(*stride_order)
1735
+ for cache in kv_cache.split(self.num_kv_heads, dim=1)
1736
+ )
1737
+ else:
1738
+ kv_perm = kv_cache.permute(*stride_order)
1739
+ kv_cache_views = kv_perm.split(self.head_size, dim=-1)
1740
+ kv_tuple = tuple(
1741
+ canonicalize_singleton_dim_strides(cache) for cache in kv_cache_views
1742
+ )
1743
+ output.copy_(attn_metadata.cascade_wrapper.run(query, kv_tuple))
1744
return output
1745
1746
# When using spec decoding, num_decodes can be < num_decode_tokens
@@ -1740,14 +1766,23 @@ class FlashInferImpl(AttentionImpl):
1766
)
1767
kv_cache_permute = fixed
1768
1743
- # For NVFP4, the kv_cache last dim is full_dim (data + scale packed).
1744
- # Split into correctly-strided data and scale views.
1769
+ # Split K/V — zero-copy views. NVFP4 stores K/V as separate head
1770
+ # groups; other dtypes pack K/V in the content dim.
1771
+ hs = self.head_size
1772
nvfp4_kv_data = None
1773
nvfp4_kv_block_scales = None
1774
if self.is_kvcache_nvfp4:
1748
- nvfp4_kv_data, nvfp4_kv_block_scales = nvfp4_kv_cache_split_views(
1749
- kv_cache_permute
1775
+ k_cache, v_cache = kv_cache.split(self.num_kv_heads, dim=1)
1776
+ kv_cache_tuple = (
1777
+ canonicalize_singleton_dim_strides(k_cache.permute(*stride_order)),
1778
+ canonicalize_singleton_dim_strides(v_cache.permute(*stride_order)),
1779
)
1780
+ k_data, k_sf = nvfp4_split_data_scale(kv_cache_tuple[0])
1781
+ v_data, v_sf = nvfp4_split_data_scale(kv_cache_tuple[1])
1782
+ nvfp4_kv_data = (k_data, v_data)
1783
+ nvfp4_kv_block_scales = (k_sf, v_sf)
1784
+ else:
1785
+ kv_cache_tuple = kv_cache_permute.split(hs, dim=-1)
1786
1787
use_dcp = self.dcp_world_size > 1
1788
@@ -1786,7 +1821,7 @@ class FlashInferImpl(AttentionImpl):
1821
prefill_wrapper.run(
1822
layer,
1823
prefill_query,
1789
- kv_cache_permute,
1824
+ kv_cache_tuple,
1825
key[num_decode_tokens:],
1826
value[num_decode_tokens:],
1827
out=output[num_decode_tokens:],
@@ -1803,7 +1838,9 @@ class FlashInferImpl(AttentionImpl):
1838
assert prefill_wrapper._causal == attn_metadata.causal
1839
1840
if self.is_kvcache_nvfp4:
1806
- kv_cache_permute = nvfp4_kv_data
1841
+ kv_cache_for_fi = nvfp4_kv_data
1842
+ else:
1843
+ kv_cache_for_fi = kv_cache_tuple
1844
kv_cache_sf = (
1845
nvfp4_kv_block_scales if self.is_kvcache_nvfp4 else None
1846
)
@@ -1821,7 +1858,7 @@ class FlashInferImpl(AttentionImpl):
1858
1859
prefill_wrapper.run(
1860
prefill_query,
1824
- kv_cache_permute,
1861
+ kv_cache_for_fi,
1862
q_scale=layer._q_scale_float,
1863
k_scale=layer._k_scale_float,
1864
v_scale=layer._v_scale_float,
@@ -1888,8 +1925,7 @@ class FlashInferImpl(AttentionImpl):
1925
# TRTLLM prefill attention does not support BF16 Q
1926
# and fp8 kv cache. So to enable prefill attention
1927
# with fp8 kv cache, we can construct a mock block
1891
- # and mock kv cache with BF16 KV involved in the prefill
1892
- #
1928
+ # and mock kv cache with BF16 KV involved in the prefill.
1929
kv_cache_permute = canonicalize_singleton_dim_strides(
1930
kv_cache_permute
1931
)
@@ -1901,15 +1937,21 @@ class FlashInferImpl(AttentionImpl):
1937
"KV cache inner dims (block_size, head_size) must be "
1938
f"contiguous, got strides {kv_strides}"
1939
)
1940
+ # fp8 uses (B, H, N, 2*hs); reshape to (B, 2, H, N, hs)
1941
+ # for the dequant kernel — zero-copy view. The dequant
1942
+ # kernel handles the interleaved K/V block stride.
1943
+ B_kv, H_kv, N_kv = kv_cache_permute.shape[:3]
1944
+ kv_cache_5d = kv_cache_permute.view(B_kv, H_kv, N_kv, 2, hs)
1945
+ kv_cache_5d = kv_cache_5d.permute(0, 3, 1, 2, 4)
1946
mock_kv_cache, mock_block_table = trtllm_prefill_attn_kvfp8_dequant(
1905
- kv_cache_permute,
1947
+ kv_cache_5d,
1948
block_tables_prefill,
1949
layer._k_scale,
1950
layer._v_scale,
1951
attn_metadata.q_data_type_prefill,
1952
)
1953
else:
1912
- mock_kv_cache = kv_cache_permute
1954
+ mock_kv_cache = kv_cache_tuple
1955
mock_block_table = block_tables_prefill
1956
1957
trtllm_batch_context_with_kv_cache(
@@ -1957,7 +1999,9 @@ class FlashInferImpl(AttentionImpl):
1999
assert decode_wrapper._sm_scale == self.scale
2000
2001
if self.is_kvcache_nvfp4:
1960
- kv_cache_permute = nvfp4_kv_data
2002
+ kv_cache_for_fi = nvfp4_kv_data
2003
+ else:
2004
+ kv_cache_for_fi = kv_cache_tuple
2005
kv_cache_sf = nvfp4_kv_block_scales if self.is_kvcache_nvfp4 else None
2006
2007
# NVFP4 kernel only supports FP8 output.
@@ -1980,7 +2024,7 @@ class FlashInferImpl(AttentionImpl):
2024
)
2025
decode_wrapper.run(
2026
decode_query,
1983
- kv_cache_permute,
2027
+ kv_cache_for_fi,
2028
q_scale=layer._q_scale_float,
2029
k_scale=layer._k_scale_float,
2030
v_scale=layer._v_scale_float,
@@ -1997,7 +2041,7 @@ class FlashInferImpl(AttentionImpl):
2041
else:
2042
decode_wrapper.run(
2043
decode_query,
2000
- kv_cache_permute,
2044
+ kv_cache_for_fi,
2045
q_scale=layer._q_scale_float,
2046
k_scale=layer._k_scale_float,
2047
v_scale=layer._v_scale_float,
@@ -2079,7 +2123,7 @@ class FlashInferImpl(AttentionImpl):
2123
trtllm_batch_decode_with_kv_cache(
2124
query=decode_query,
2125
kv_cache=(
2082
- nvfp4_kv_data if self.is_kvcache_nvfp4 else kv_cache_permute
2126
+ nvfp4_kv_data if self.is_kvcache_nvfp4 else kv_cache_tuple
2127
),
2128
workspace_buffer=workspace_buffer,
2129
block_tables=block_tables_decode,
@@ -2119,8 +2163,18 @@ class FlashInferImpl(AttentionImpl):
2163
# and value[:num_actual_tokens] because the reshape_and_cache_flash
2164
# op uses the slot_mapping's shape to determine the number of
2165
# actual tokens.
2122
- k_cache = kv_cache[:, 0]
2123
- v_cache = kv_cache[:, 1]
2166
+ if self.is_kvcache_nvfp4:
2167
+ # (B, 2*H, N, full_dim) -> ((B, N, H, full_dim),
2168
+ # (B, N, H, full_dim));
2169
+ # K heads first, then V heads.
2170
+ k_cache, v_cache = kv_cache.transpose(1, 2).split(
2171
+ self.num_kv_heads, dim=-2
2172
+ )
2173
+ else:
2174
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
2175
+ k_cache, v_cache = kv_cache.transpose(1, 2).split(
2176
+ self.head_size, dim=-1
2177
+ )
2178
torch.ops._C_cache_ops.reshape_and_cache_flash(
2179
key,
2180
value,
vllm/v1/attention/backends/flex_attention.py
+11
-7
@@ -130,15 +130,16 @@ class FlexAttentionBackend(AttentionBackend):
130
head_size: int,
131
cache_dtype_str: str = "auto",
132
) -> tuple[int, ...]:
133
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
133
+ # K and V are packed into the content dim: logical (B, H, N, 2*hs).
134
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
135
136
@staticmethod
137
def get_kv_cache_stride_order(
138
include_num_layers_dimension: bool = False,
139
) -> tuple[int, ...]:
140
if include_num_layers_dimension:
140
- return (1, 0, 3, 2, 4, 5)
141
- return (0, 2, 1, 3, 4)
141
+ return (1, 0, 3, 2, 4)
142
+ return (0, 2, 1, 3)
143
144
@staticmethod
145
def get_builder_cls() -> type["FlexAttentionMetadataBuilder"]:
@@ -1242,7 +1243,8 @@ class FlexAttentionImpl(AttentionImpl):
1243
if self.attn_type == AttentionType.ENCODER_ONLY:
1244
return
1245
1245
- key_cache, value_cache = kv_cache.unbind(1)
1246
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
1247
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
1248
torch.ops._C_cache_ops.reshape_and_cache_flash(
1249
key,
1250
value,
@@ -1273,7 +1275,7 @@ class FlexAttentionImpl(AttentionImpl):
1275
key: shape = [num_tokens, num_kv_heads, head_size]
1276
value: shape = [num_tokens, num_kv_heads, head_size]
1277
kv_cache: shape =
1276
- [num_blocks, 2, block_size, num_kv_heads, head_size]
1278
+ [num_blocks, num_kv_heads, block_size, 2 * head_size]
1279
attn_metadata: Metadata for attention.
1280
Returns:
1281
shape = [num_tokens, num_heads * head_size]
@@ -1347,9 +1349,11 @@ class FlexAttentionImpl(AttentionImpl):
1349
1350
else:
1351
assert self.attn_type == AttentionType.DECODER
1350
- key_cache, value_cache = kv_cache.unbind(1)
1352
+ kv_cache = kv_cache.transpose(1, 2)
1353
+ hs = self.head_size
1354
+ key_cache, value_cache = kv_cache.split(hs, dim=-1)
1355
1352
- # Flatten (num_blocks, block_size) into a single token dim
1356
+ # Flatten (num_blocks, block_size) into a single token dim.
1357
key_cache = key_cache.view(-1, self.num_kv_heads, self.head_size)
1358
value_cache = value_cache.view(-1, self.num_kv_heads, self.head_size)
1359
query, key_tensor, value_tensor = map(
vllm/v1/attention/backends/rocm_aiter_fa.py
+8
-4
@@ -768,7 +768,8 @@ class AiterFlashAttentionBackend(AttentionBackend):
768
) -> tuple[int, ...]:
769
if block_size % 16 != 0:
770
raise ValueError("Block size must be a multiple of 16.")
771
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
771
+ # K and V are packed into the content dim: logical (B, H, N, 2*hs).
772
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
773
774
@classmethod
775
def supports_compute_capability(cls, capability: DeviceCapability) -> bool:
@@ -1061,7 +1062,8 @@ class AiterFlashAttentionImpl(AttentionImpl):
1062
# Whenever making a change in this method, please benchmark the
1063
# performance to make sure it does not introduce any overhead.
1064
num_actual_tokens = attn_metadata.num_actual_tokens
1064
- key_cache, value_cache = kv_cache.unbind(1)
1065
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
1066
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
1067
1068
if is_quantized_kv_cache(self.kv_cache_dtype):
1069
key_cache = key_cache.view(current_platform.fp8_dtype())
@@ -1388,7 +1390,8 @@ class AiterFlashAttentionImpl(AttentionImpl):
1390
kv_cache: torch.Tensor,
1391
slot_mapping: torch.Tensor,
1392
):
1391
- key_cache, value_cache = kv_cache.unbind(1)
1393
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
1394
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
1395
1396
# key and value may be None in the case of cross attention. They are
1397
# calculated once based on the output from the encoder and then cached
@@ -1454,7 +1457,8 @@ class AiterFlashAttentionImpl(AttentionImpl):
1457
kv_cache: torch.Tensor,
1458
layer_slot_mapping: torch.Tensor,
1459
):
1457
- key_cache, value_cache = kv_cache.unbind(1)
1460
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
1461
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
1462
flash_layout = True
1463
1464
is_fp8_kv_cache = is_quantized_kv_cache(self.kv_cache_dtype)
vllm/v1/attention/backends/rocm_aiter_unified_attn.py
+4
-5
@@ -87,7 +87,8 @@ class RocmAiterUnifiedAttentionBackend(RocmAttentionBackend):
87
) -> tuple[int, ...]:
88
if block_size % 16 != 0:
89
raise ValueError("Block size must be a multiple of 16.")
90
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
90
+ # K and V are packed into the content dim: logical (B, H, N, 2*hs).
91
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
92
93
@staticmethod
94
def use_cascade_attention(*args, **kwargs) -> bool:
@@ -150,10 +151,8 @@ class RocmAiterUnifiedAttentionImpl(RocmAttentionImpl):
151
def _split_kv_cache(
152
self, kv_cache: torch.Tensor
153
) -> tuple[torch.Tensor, torch.Tensor]:
153
- # Blocks-first ``(num_blocks, 2, ...)``. The model runner normalizes any
154
- # shared decoder/cross-attention allocation to this layout, so no
155
- # per-backend restriding is needed here.
156
- return kv_cache.unbind(1)
154
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
155
+ return kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
156
157
def forward(
158
self,
vllm/v1/attention/backends/triton_attn.py
+60
-42
@@ -324,6 +324,7 @@ class TritonAttentionBackend(AttentionBackend):
324
) -> tuple[int, ...]:
325
if block_size % 16 != 0:
326
raise ValueError("Block size must be a multiple of 16.")
327
+ # K and V are packed into the content dim: logical (B, H, N, 2*hs).
328
if kv_cache_uses_per_token_head_scales(cache_dtype_str):
329
# Pad the head dim by sizeof(float32)/sizeof(cache_dtype) so the
330
# per-(token, head) scale fits inline after the quantized data;
@@ -341,26 +342,30 @@ class TritonAttentionBackend(AttentionBackend):
342
data_head_size = head_size // 2
343
else:
344
data_head_size = head_size
344
- return (num_blocks, 2, block_size, num_kv_heads, data_head_size + scale_pad)
345
- return (num_blocks, 2, block_size, num_kv_heads, head_size)
345
+ padded_hs = data_head_size + scale_pad
346
+ return (num_blocks, num_kv_heads, block_size, 2 * padded_hs)
347
+ return (num_blocks, num_kv_heads, block_size, 2 * head_size)
348
349
@staticmethod
350
def get_kv_cache_stride_order(
351
include_num_layers_dimension: bool = False,
352
) -> tuple[int, ...]:
351
- # `stride_order` indicates the permutation that gets
352
- # us from `get_kv_cache_shape` to the actual memory layout we want.
353
+ # `stride_order` indicates the permutation that gets us from
354
+ # `get_kv_cache_shape` (logical (B, H, N, 2*hs)) to the actual memory
355
+ # layout we want.
356
cache_layout = get_kv_cache_layout()
357
if cache_layout == "NHD" and include_num_layers_dimension:
355
- # (num_blocks, num_layers, 2, block_size, num_kv_heads, head_size)
356
- return (1, 0, 2, 3, 4, 5)
358
+ # (num_blocks, num_layers, block_size, num_kv_heads, 2*head_size)
359
+ return (1, 0, 3, 2, 4)
360
elif cache_layout == "NHD":
358
- stride_order = (0, 1, 2, 3, 4)
361
+ # (num_blocks, block_size, num_kv_heads, 2*head_size)
362
+ stride_order = (0, 2, 1, 3)
363
elif cache_layout == "HND" and include_num_layers_dimension:
360
- # (num_blocks, num_kv_heads, num_layers, 2, block_size, head_size)
361
- return (1, 4, 0, 2, 3, 5)
364
+ # (num_blocks, num_kv_heads, num_layers, block_size, 2*head_size)
365
+ return (1, 2, 0, 3, 4)
366
elif cache_layout == "HND":
363
- stride_order = (0, 1, 3, 2, 4)
367
+ # (num_blocks, num_kv_heads, block_size, 2*head_size)
368
+ stride_order = (0, 1, 2, 3)
369
else:
370
raise ValueError(f"Unknown cache layout: {cache_layout}")
371
return stride_order
@@ -410,12 +415,16 @@ class TritonAttentionImpl(AttentionImpl):
415
_v_scale_cache: torch.Tensor | None = None
416
417
def _ensure_scale_caches(self, kv_cache: torch.Tensor) -> None:
413
- """Extract per-head scale views from the padded head dimension.
418
+ """Extract per-head scale views from the padded content dimension.
419
415
- The KV cache shape is ``(num_blocks, 2, block_size, nkv, hs+pad)``
416
- where ``pad = sizeof(float32) / sizeof(cache_dtype)``. The last
417
- ``pad`` elements of each head hold one float32 scale. We create
418
- strided float32 views over those bytes.
420
+ The KV cache is packed as logical shape
421
+ ``(num_blocks, nkv, block_size, 2 * (hs + pad))`` where
422
+ ``pad = sizeof(float32) / sizeof(cache_dtype)``. The content dim holds
423
+ ``[K(hs) | K_scale(pad) | V(hs) | V_scale(pad)]`` per (head, slot); the
424
+ last ``pad`` elements of each half hold one float32 scale. We create
425
+ strided float32 views over those bytes. ``kv_cache`` must be the
426
+ packed logical tensor (call before any transpose), but may have HND or
427
+ NHD physical strides.
428
429
Scale shape: ``(num_blocks, block_size, num_kv_heads)``
430
"""
@@ -423,9 +432,10 @@ class TritonAttentionImpl(AttentionImpl):
432
return
433
from vllm.utils.torch_utils import get_dtype_size
434
426
- num_blocks, _, block_size, nkv, padded_hs = kv_cache.shape
435
+ num_blocks, nkv, block_size, content = kv_cache.shape
436
dtype_sz = kv_cache.element_size()
437
scale_pad = get_dtype_size(torch.float32) // dtype_sz # e.g. 4
438
+ padded_hs = content // 2
439
hs = padded_hs - scale_pad
440
441
raw = kv_cache.untyped_storage()
@@ -433,31 +443,37 @@ class TritonAttentionImpl(AttentionImpl):
443
raw
444
)
445
436
- # In the raw bytes, each (block, kv_half, slot, head) occupies
437
- # padded_hs * dtype_sz bytes. The scale float32 sits at byte
438
- # offset hs * dtype_sz within that region.
439
- kv_half_bytes = block_size * nkv * padded_hs * dtype_sz
440
- full_block_f32 = 2 * kv_half_bytes // 4 # stride between blocks
441
- slot_f32 = nkv * padded_hs * dtype_sz // 4 # stride between slots
442
- head_f32 = padded_hs * dtype_sz // 4 # stride between heads
443
- scale_off_f32 = hs * dtype_sz // 4 # offset to scale within head
444
-
445
- # K scales: kv_half=0
446
+ def to_f32_units(elements: int) -> int:
447
+ nbytes = elements * dtype_sz
448
+ assert nbytes % 4 == 0
449
+ return nbytes // 4
450
+
451
+ # Actual strides (in float32 units) from the tensor. The logical cache
452
+ # may be physically NHD, so do not assume C-contiguous HND layout.
453
+ strides = kv_cache.stride()
454
+ block_f32 = to_f32_units(strides[0])
455
+ head_f32 = to_f32_units(strides[1])
456
+ slot_f32 = to_f32_units(strides[2])
457
+ # Scale sits at byte offset hs within each (K, then V) content half.
458
+ base_off_f32 = to_f32_units(kv_cache.storage_offset())
459
+ k_scale_off_f32 = base_off_f32 + to_f32_units(hs)
460
+ v_scale_off_f32 = base_off_f32 + to_f32_units(padded_hs + hs)
461
+
462
+ # K scales (first content half)
463
self._k_scale_cache = torch.as_strided(
464
base_f32,
465
size=(num_blocks, block_size, nkv),
449
- stride=(full_block_f32, slot_f32, head_f32),
450
- storage_offset=scale_off_f32,
466
+ stride=(block_f32, slot_f32, head_f32),
467
+ storage_offset=k_scale_off_f32,
468
)
469
self._k_scale_cache.fill_(1.0)
470
454
- # V scales: kv_half=1, offset by kv_half_bytes
455
- v_base_f32 = kv_half_bytes // 4
471
+ # V scales (second content half)
472
self._v_scale_cache = torch.as_strided(
473
base_f32,
474
size=(num_blocks, block_size, nkv),
459
- stride=(full_block_f32, slot_f32, head_f32),
460
- storage_offset=v_base_f32 + scale_off_f32,
475
+ stride=(block_f32, slot_f32, head_f32),
476
+ storage_offset=v_scale_off_f32,
477
)
478
self._v_scale_cache.fill_(1.0)
479
@@ -576,7 +592,7 @@ class TritonAttentionImpl(AttentionImpl):
592
key: shape = [num_tokens, num_kv_heads, head_size]
593
value: shape = [num_tokens, num_kv_heads, head_size]
594
kv_cache: shape =
579
- [num_blocks, 2, block_size, num_kv_heads, head_size]
595
+ [num_blocks, num_kv_heads, block_size, 2 * head_size]
596
attn_metadata: Metadata for attention.
597
Returns:
598
shape = [num_tokens, num_heads * head_size]
@@ -617,6 +633,7 @@ class TritonAttentionImpl(AttentionImpl):
633
layer,
634
)
635
636
+ # KV cache arrives in logical (B, H, N, 2*hs) order.
637
# Per-token-head quantized KV cache: handled by the core unified
638
# kernel, which dequantizes per-(token, head) inline via constexpr
639
# branches (INT8 / FP8) and dispatches to the packed INT4 kernel.
@@ -627,7 +644,9 @@ class TritonAttentionImpl(AttentionImpl):
644
q_descale = k_descale = v_descale = None
645
# FP8 per-tensor / auto path (original flow).
646
else:
630
- key_cache, value_cache = kv_cache.unbind(1)
647
+ kv_cache = kv_cache.transpose(1, 2)
648
+ hs = self.head_size
649
+ key_cache, value_cache = kv_cache.split(hs, dim=-1)
650
if (
651
is_quantized_kv_cache(self.kv_cache_dtype)
652
and key_cache.dtype != self.fp8_dtype
@@ -711,7 +730,8 @@ class TritonAttentionImpl(AttentionImpl):
730
) -> tuple[torch.Tensor, torch.Tensor]:
731
"""Per-token-head K/V cache views (ensures scale caches; FP8 retyped)."""
732
self._ensure_scale_caches(kv_cache)
714
- key_cache, value_cache = kv_cache.unbind(1)
733
+ padded_hs = kv_cache.shape[-1] // 2
734
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(padded_hs, dim=-1)
735
if self._kv_quant_mode == KVQuantMode.FP8_PER_TOKEN_HEAD:
736
key_cache = key_cache.view(self.fp8_dtype)
737
value_cache = value_cache.view(self.fp8_dtype)
@@ -778,13 +798,9 @@ class TritonAttentionImpl(AttentionImpl):
798
return
799
# Reshape the input keys and values and store them in the cache.
800
if self._is_per_token_head_quant:
781
- self._ensure_scale_caches(kv_cache)
782
- key_cache, value_cache = kv_cache.unbind(1)
801
+ key_cache, value_cache = self._pth_key_value_caches(kv_cache)
802
k_scale_cache = self._k_scale_cache
803
v_scale_cache = self._v_scale_cache
785
- if self._kv_quant_mode == KVQuantMode.FP8_PER_TOKEN_HEAD:
786
- key_cache = key_cache.view(self.fp8_dtype)
787
- value_cache = value_cache.view(self.fp8_dtype)
804
triton_reshape_and_cache_flash_per_token_head_quant(
805
key,
806
value,
@@ -797,7 +813,8 @@ class TritonAttentionImpl(AttentionImpl):
813
)
814
return
815
# For decoder and cross-attention, use KV cache as before.
800
- key_cache, value_cache = kv_cache.unbind(1)
816
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
817
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
818
if is_quantized_kv_cache(self.kv_cache_dtype):
819
key_cache = key_cache.view(self.fp8_dtype)
820
value_cache = value_cache.view(self.fp8_dtype)
@@ -829,7 +846,8 @@ class TritonAttentionImpl(AttentionImpl):
846
kv_cache: torch.Tensor,
847
layer_slot_mapping: torch.Tensor,
848
):
832
- key_cache, value_cache = kv_cache.unbind(1)
849
+ # (B, H, N, 2*hs) -> ((B, N, H, hs), (B, N, H, hs))
850
+ key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
851
flash_layout = True
852
853
is_fp8_kv_cache = is_quantized_kv_cache(self.kv_cache_dtype)
vllm/v1/attention/backends/triton_attn_diffkv.py
+23
-22
@@ -2,12 +2,9 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Triton attention backend with different K/V head dimensions (DiffKV).
4
5
-The KV cache layout is identical to ``FlashAttentionDiffKVBackend`` — K
6
-and V are packed along the last dim:
7
-
8
- [num_blocks, block_size, num_kv_heads, head_size_qk + head_size_v]
9
-
10
-so existing helpers (``triton_reshape_and_cache_flash_diffkv``) are reused.
5
+The KV cache layout is identical to ``FlashAttentionDiffKVBackend``: K and V
6
+are packed along the last dim in the logical shape
7
+``[num_blocks, num_kv_heads, block_size, head_size_qk + head_size_v]``.
8
"""
9
10
from typing import ClassVar
@@ -106,10 +103,12 @@ class TritonAttentionDiffKVBackend(TritonAttentionBackend):
103
) -> tuple[int, ...]:
104
if block_size % 16 != 0:
105
raise ValueError("Block size must be a multiple of 16.")
106
+ # Logical (blocks-first, head-major) layout: K and V (with their
107
+ # different head sizes) packed in the content dim.
108
return (
109
num_blocks,
111
- block_size,
110
num_kv_heads,
111
+ block_size,
112
head_size + TritonAttentionDiffKVBackend.head_size_v,
113
)
114
@@ -117,19 +116,22 @@ class TritonAttentionDiffKVBackend(TritonAttentionBackend):
116
def get_kv_cache_stride_order(
117
include_num_layers_dimension: bool = False,
118
) -> tuple[int, ...]:
119
+ # `stride_order` indicates the permutation that gets us from
120
+ # `get_kv_cache_shape` (logical (B, H, N, C_k+C_v)) to the actual
121
+ # memory layout we want.
122
cache_layout = get_kv_cache_layout()
123
if cache_layout == "NHD" and include_num_layers_dimension:
122
- # (num_blocks, num_layers, block_size,
123
- # num_kv_heads, head_size + head_size_v)
124
- return (1, 0, 2, 3, 4)
124
+ # (num_blocks, num_layers, block_size, num_kv_heads, C_k+C_v)
125
+ return (1, 0, 3, 2, 4)
126
elif cache_layout == "NHD":
126
- return (0, 1, 2, 3)
127
+ # (num_blocks, block_size, num_kv_heads, C_k+C_v)
128
+ return (0, 2, 1, 3)
129
elif cache_layout == "HND" and include_num_layers_dimension:
128
- # (num_blocks, num_kv_heads, num_layers,
129
- # block_size, head_size + head_size_v)
130
- return (1, 3, 0, 2, 4)
130
+ # (num_blocks, num_kv_heads, num_layers, block_size, C_k+C_v)
131
+ return (1, 2, 0, 3, 4)
132
elif cache_layout == "HND":
132
- return (0, 2, 1, 3)
133
+ # (num_blocks, num_kv_heads, block_size, C_k+C_v)
134
+ return (0, 1, 2, 3)
135
else:
136
raise ValueError(f"Unknown cache layout format {cache_layout}.")
137
@@ -175,13 +177,12 @@ class TritonAttentionDiffKVImpl(TritonAttentionImpl):
177
kv_cache: torch.Tensor,
178
slot_mapping: torch.Tensor,
179
) -> None:
178
- # Cache is packed [..., head_size_qk + head_size_v]; the diffkv
179
- # reshape kernel writes K to [..., :head_size_qk] and V to
180
- # [..., head_size_qk:hqk+hv].
180
+ # Cache is logical (B, H, N, C); the diffkv reshape kernel expects
181
+ # (B, N, H, C).
182
triton_reshape_and_cache_flash_diffkv(
183
key,
184
value,
184
- kv_cache,
185
+ kv_cache.transpose(1, 2),
186
slot_mapping,
187
self.kv_cache_dtype,
188
layer._k_scale,
@@ -210,7 +211,7 @@ class TritonAttentionDiffKVImpl(TritonAttentionImpl):
211
query: [num_tokens, num_heads, head_size_qk]
212
key: [num_tokens, num_kv_heads, head_size_qk]
213
value: [num_tokens, num_kv_heads, head_size_v]
213
- kv_cache: [num_blocks, block_size, num_kv_heads,
214
+ kv_cache: [num_blocks, num_kv_heads, block_size,
215
head_size_qk + head_size_v]
216
output: [num_tokens, num_heads, head_size_v]
217
"""
@@ -231,8 +232,8 @@ class TritonAttentionDiffKVImpl(TritonAttentionImpl):
232
head_size_qk = self.head_size
233
head_size_v = TritonAttentionDiffKVBackend.head_size_v
234
234
- # Slice the packed cache into K / V views. Strides on dims 0/1/2
235
- # match the original cache; dim 3 stays contiguous (stride 1).
235
+ # Triton DiffKV kernels consume (B, N, H, D) cache views.
236
+ kv_cache = kv_cache.transpose(1, 2)
237
key_cache = kv_cache[..., :head_size_qk]
238
value_cache = kv_cache[..., head_size_qk : head_size_qk + head_size_v]
239
vllm/v1/attention/backends/turboquant_attn.py
+9
-3
@@ -141,9 +141,10 @@ class TurboQuantAttentionBackend(AttentionBackend):
141
142
Standard attention backends use (2, num_blocks, block_size, num_kv_heads,
143
head_dim) with a leading 2 to separate K and V. TurboQuant packs K+V
144
- into a single interleaved slot per head per position, so the cache is:
144
+ into a single interleaved slot per head per position. The logical
145
+ (blocks-first, head-major) shape is:
146
146
- (num_blocks, block_size, num_kv_heads, slot_size_aligned)
147
+ (num_blocks, num_kv_heads, block_size, slot_size_aligned)
148
149
Each slot = [key_packed | value_packed | padding].
150
This is safe because TQ has its own get_kv_cache_shape override and
@@ -159,7 +160,7 @@ class TurboQuantAttentionBackend(AttentionBackend):
160
)
161
162
tq_config = TurboQuantConfig.from_cache_dtype(cache_dtype_str, head_size)
162
- return (num_blocks, block_size, num_kv_heads, tq_config.slot_size_aligned)
163
+ return (num_blocks, num_kv_heads, block_size, tq_config.slot_size_aligned)
164
165
@classmethod
166
def supports_kv_cache_dtype(cls, kv_cache_dtype: CacheDType | None) -> bool:
@@ -422,6 +423,8 @@ class TurboQuantAttentionImpl(AttentionImpl["TurboQuantMetadata"]):
423
424
k = key[:N].view(N, self.num_kv_heads, self.head_size)
425
v = value[:N].view(N, self.num_kv_heads, self.head_size)
426
+ # (B, H, N, C) -> (B, N, H, C) for TQ kernels
427
+ kv_cache = kv_cache.transpose(1, 2)
428
self._store_kv(k, v, kv_cache, slot_mapping, layer)
429
430
def forward(
@@ -449,6 +452,9 @@ class TurboQuantAttentionImpl(AttentionImpl["TurboQuantMetadata"]):
452
if attn_metadata is None:
453
return output.fill_(0)
454
455
+ # (B, H, N, C) -> (B, N, H, C) for TQ kernels
456
+ kv_cache = kv_cache.transpose(1, 2)
457
+
458
# Slice to actual tokens
459
N = attn_metadata.num_actual_tokens
460
if N <= 0:
vllm/v1/attention/ops/triton_reshape_and_cache_flash.py
+5
-4
@@ -473,6 +473,7 @@ def reshape_and_cache_kernel_flash_diffkv(
473
value_stride: tl.int64,
474
block_stride: tl.int64,
475
page_stride: tl.int64,
476
+ head_stride: tl.int64,
477
num_heads: tl.constexpr,
478
head_size_k: tl.constexpr,
479
head_size_v: tl.constexpr,
@@ -498,9 +499,7 @@ def reshape_and_cache_kernel_flash_diffkv(
499
src_value_idx = token_idx * value_stride + tile_i * head_size_v
500
501
tgt_idx = (
501
- block_idx * block_stride
502
- + block_offset * page_stride
503
- + tile_i * (head_size_k + head_size_v)
502
+ block_idx * block_stride + block_offset * page_stride + tile_i * head_stride
503
)
504
505
# [TILE_SIZE]
@@ -542,7 +541,7 @@ def reshape_and_cache_kernel_flash_diffkv(
541
def triton_reshape_and_cache_flash_diffkv(
542
key: torch.Tensor, # [num_tokens, num_heads, head_size]
543
value: torch.Tensor, # [num_tokens, num_heads, head_size_v]
545
- # [num_blocks, block_size, num_heads, head_size + head_size_v]
544
+ # Strided [num_blocks, block_size, num_heads, head_size + head_size_v].
545
kv_cache: torch.Tensor,
546
slot_mapping: torch.Tensor, # [num_tokens]
547
kv_cache_dtype: str, # "auto", "fp8"
@@ -558,6 +557,7 @@ def triton_reshape_and_cache_flash_diffkv(
557
v_stride = value.stride()[0]
558
block_stride = kv_cache.stride()[0]
559
page_stride = kv_cache.stride()[1]
560
+ head_stride = kv_cache.stride()[2]
561
562
kv_cache_torch_dtype = (
563
current_platform.fp8_dtype()
@@ -599,6 +599,7 @@ def triton_reshape_and_cache_flash_diffkv(
599
value_stride=v_stride,
600
block_stride=block_stride,
601
page_stride=page_stride,
602
+ head_stride=head_stride,
603
num_heads=num_heads,
604
head_size_k=head_size_k,
605
head_size_v=head_size_v,
vllm/v1/attention/ops/triton_turboquant_store.py
+2
-2
@@ -389,7 +389,7 @@ def triton_turboquant_store(
389
_tq_fused_store_fp8[grid](
390
k_flat,
391
v_flat,
392
- kv_cache.view(-1),
392
+ kv_cache,
393
slot_mapping,
394
stride_cache_block=stride_block,
395
stride_cache_pos=stride_pos,
@@ -425,7 +425,7 @@ def triton_turboquant_store(
425
norms.squeeze(1),
426
v_flat,
427
midpoints,
428
- kv_cache.view(-1),
428
+ kv_cache,
429
slot_mapping,
430
stride_cache_block=stride_block,
431
stride_cache_pos=stride_pos,