[KV Offload] Support packed HMA KV cache layout (#46205)
Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Tyler Michael Smith <tlrmchlsmth@gmail.com>
Lucas Wilkinson committed
Jun 20, 2026 at 17:19 UTC
cc22621b51207e1af96269a840108ea654af9b42
6 files changed
+175
-21
tests/v1/core/test_contiguous_kv_packing.py
+95
-2
@@ -1,16 +1,23 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""Tests for contiguous KV cache packing in _get_kv_cache_config_deepseek_v4."""
3
+"""Tests for contiguous KV cache packing."""
4
5
from unittest.mock import MagicMock
6
7
import pytest
8
import torch
9
10
-from vllm.v1.core.kv_cache_utils import _get_kv_cache_config_deepseek_v4
10
+from vllm import envs
11
+from vllm.v1.core.kv_cache_utils import (
12
+ _get_kv_cache_config_deepseek_v4,
13
+ get_kv_cache_config_from_groups,
14
+)
15
from vllm.v1.kv_cache_interface import (
16
+ FullAttentionSpec,
17
KVCacheGroupSpec,
18
+ KVCacheTensor,
19
MLAAttentionSpec,
20
+ SlidingWindowSpec,
21
UniformTypeKVCacheSpecs,
22
)
23
@@ -28,6 +35,25 @@ def _make_mla_spec(page_size: int, block_size: int = 256) -> MLAAttentionSpec:
35
)
36
37
38
+def _make_full_spec() -> FullAttentionSpec:
39
+ return FullAttentionSpec(
40
+ block_size=16,
41
+ num_kv_heads=2,
42
+ head_size=64,
43
+ dtype=torch.float16,
44
+ )
45
+
46
+
47
+def _make_sw_spec() -> SlidingWindowSpec:
48
+ return SlidingWindowSpec(
49
+ block_size=16,
50
+ num_kv_heads=2,
51
+ head_size=64,
52
+ dtype=torch.float16,
53
+ sliding_window=128,
54
+ )
55
+
56
+
57
def _make_groups(n_c4, n_c128, n_swa):
58
PS_C4_MLA = 37440
59
PS_C4_IDX = 8640
@@ -130,6 +156,73 @@ class TestInterleavedPacking:
156
for i, v in enumerate(views):
157
assert (v == i + 1).all(), f"View {i} was corrupted"
158
159
+ def test_hma_attention_groups_keep_default_backing(self, monkeypatch):
160
+ monkeypatch.setattr(envs, "VLLM_USE_PACKED_HMA_KV_CACHE", False, raising=False)
161
+ full = _make_full_spec()
162
+ sw = _make_sw_spec()
163
+ page_size = full.page_size_bytes
164
+ groups = [
165
+ KVCacheGroupSpec(["full.0", "full.1"], full),
166
+ KVCacheGroupSpec(["sw.0", "sw.2"], sw),
167
+ KVCacheGroupSpec(["sw.1", "sw.3"], sw),
168
+ ]
169
+
170
+ config = get_kv_cache_config_from_groups(
171
+ _mock_vllm_config(), groups, available_memory=page_size * 2 * 32
172
+ )
173
+
174
+ assert config.num_blocks == 32
175
+ assert sum(t.size for t in config.kv_cache_tensors) == page_size * 2 * 32
176
+ assert config.kv_cache_tensors == [
177
+ KVCacheTensor(size=page_size * 32, shared_by=["full.0", "sw.0", "sw.1"]),
178
+ KVCacheTensor(size=page_size * 32, shared_by=["full.1", "sw.2", "sw.3"]),
179
+ ]
180
+
181
+ def test_hma_attention_groups_use_packed_backing_with_flag(self, monkeypatch):
182
+ monkeypatch.setattr(envs, "VLLM_USE_PACKED_HMA_KV_CACHE", True, raising=False)
183
+ full = _make_full_spec()
184
+ sw = _make_sw_spec()
185
+ page_size = full.page_size_bytes
186
+ groups = [
187
+ KVCacheGroupSpec(["full.0", "full.1"], full),
188
+ KVCacheGroupSpec(["sw.0", "sw.2"], sw),
189
+ KVCacheGroupSpec(["sw.1", "sw.3"], sw),
190
+ ]
191
+
192
+ config = get_kv_cache_config_from_groups(
193
+ _mock_vllm_config(), groups, available_memory=page_size * 2 * 32
194
+ )
195
+
196
+ assert config.num_blocks == 32
197
+ assert {t.size for t in config.kv_cache_tensors} == {page_size * 2 * 32}
198
+ assert config.kv_cache_tensors == [
199
+ KVCacheTensor(
200
+ size=page_size * 2 * 32,
201
+ shared_by=["full.0", "sw.0", "sw.1"],
202
+ offset=0,
203
+ block_stride=page_size * 2,
204
+ ),
205
+ KVCacheTensor(
206
+ size=page_size * 2 * 32,
207
+ shared_by=["full.1", "sw.2", "sw.3"],
208
+ offset=page_size,
209
+ block_stride=page_size * 2,
210
+ ),
211
+ ]
212
+
213
+ def test_single_group_attention_keeps_unpacked_layout(self):
214
+ spec = _make_full_spec()
215
+ groups = [KVCacheGroupSpec(["full.0", "full.1"], spec)]
216
+
217
+ config = get_kv_cache_config_from_groups(
218
+ _mock_vllm_config(), groups, available_memory=spec.page_size_bytes * 2 * 32
219
+ )
220
+
221
+ assert sum(t.size for t in config.kv_cache_tensors) == (
222
+ spec.page_size_bytes * 2 * 32
223
+ )
224
+ assert [t.block_stride for t in config.kv_cache_tensors] == [0, 0]
225
+
226
227
if __name__ == "__main__":
228
pytest.main([__file__, "-v"])
vllm/distributed/kv_transfer/kv_connector/v1/offloading/worker.py
+31
-4
@@ -50,7 +50,8 @@ class OffloadingConnectorWorker:
50
def register_kv_caches(
51
self, kv_caches: dict[str, torch.Tensor | list[torch.Tensor]]
52
):
53
- num_blocks = self.spec.kv_cache_config.num_blocks
53
+ kv_cache_config = self.spec.kv_cache_config
54
+ num_blocks = kv_cache_config.num_blocks
55
56
# layer_name -> (num_blocks, page_size_bytes) tensor
57
tensors_per_block: dict[str, tuple[torch.Tensor, ...]] = {}
@@ -58,7 +59,7 @@ class OffloadingConnectorWorker:
59
unpadded_page_size_bytes: dict[str, int] = {}
60
# layer_name -> size of page in bytes
61
page_size_bytes: dict[str, int] = {}
61
- for kv_cache_group in self.spec.kv_cache_config.kv_cache_groups:
62
+ for kv_cache_group in kv_cache_config.kv_cache_groups:
63
group_layer_names = kv_cache_group.layer_names
64
group_kv_cache_spec = kv_cache_group.kv_cache_spec
65
if isinstance(group_kv_cache_spec, UniformTypeKVCacheSpecs):
@@ -122,9 +123,35 @@ class OffloadingConnectorWorker:
123
else:
124
raise NotImplementedError
125
126
+ packed_kv_cache_tensor = next(
127
+ (t for t in kv_cache_config.kv_cache_tensors if t.block_stride), None
128
+ )
129
+ is_dsv4 = all(
130
+ isinstance(group.kv_cache_spec, UniformTypeKVCacheSpecs)
131
+ for group in kv_cache_config.kv_cache_groups
132
+ )
133
+ if packed_kv_cache_tensor is not None and not is_dsv4:
134
+ (tensor,) = tensors_per_block[packed_kv_cache_tensor.shared_by[0]]
135
+ block_stride = tensor.stride(0)
136
+ packed_tensor = tensor.as_strided(
137
+ (num_blocks, block_stride),
138
+ (block_stride, 1),
139
+ storage_offset=0,
140
+ )
141
+ self._register_handlers(
142
+ CanonicalKVCaches(
143
+ [CanonicalKVCacheTensor(packed_tensor, block_stride)],
144
+ [
145
+ [CanonicalKVCacheRef(0, block_stride)]
146
+ for _ in kv_cache_config.kv_cache_groups
147
+ ],
148
+ )
149
+ )
150
+ return
151
+
152
block_tensors: list[CanonicalKVCacheTensor] = []
153
block_data_refs: dict[str, list[CanonicalKVCacheRef]] = defaultdict(list)
127
- for kv_cache_tensor in self.spec.kv_cache_config.kv_cache_tensors:
154
+ for kv_cache_tensor in kv_cache_config.kv_cache_tensors:
155
# Filter to layers that were actually processed above.
156
# _get_kv_cache_config_deepseek_v4 emits KVCacheTensor entries for
157
# every (tuple_idx, page_size) slot; slots where no group has a
@@ -166,7 +193,7 @@ class OffloadingConnectorWorker:
193
)
194
195
group_data_refs: list[list[CanonicalKVCacheRef]] = []
169
- for kv_cache_group in self.spec.kv_cache_config.kv_cache_groups:
196
+ for kv_cache_group in kv_cache_config.kv_cache_groups:
197
group_refs: list[CanonicalKVCacheRef] = []
198
for layer_name in kv_cache_group.layer_names:
199
group_refs += block_data_refs[layer_name]
vllm/envs.py
+6
@@ -209,6 +209,7 @@ if TYPE_CHECKING:
209
VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS: int = 300
210
VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS: int = 5
211
VLLM_KV_CACHE_LAYOUT: Literal["NHD", "HND"] | None = None
212
+ VLLM_USE_PACKED_HMA_KV_CACHE: bool = False
213
VLLM_SSM_CONV_STATE_LAYOUT: Literal["SD", "DS"] | None = None
214
VLLM_COMPUTE_NANS_IN_LOGITS: bool = False
215
VLLM_ROCM_QUICK_REDUCE_QUANTIZATION: Literal[
@@ -1608,6 +1609,11 @@ environment_variables: dict[str, Callable[[], Any]] = {
1609
"VLLM_KV_CACHE_LAYOUT": env_with_choices(
1610
"VLLM_KV_CACHE_LAYOUT", None, ["NHD", "HND"]
1611
),
1612
+ # Opt into packed per-block KV cache allocation for multi-group
1613
+ # attention-only HMA models (e.g. gpt-oss, Gemma 3/4).
1614
+ "VLLM_USE_PACKED_HMA_KV_CACHE": lambda: bool(
1615
+ int(os.getenv("VLLM_USE_PACKED_HMA_KV_CACHE", "0"))
1616
+ ),
1617
# SSM conv state layout used for Mamba models.
1618
# - SD: (state_len, dim) — dim contiguous (default)
1619
# - DS: (dim, state_len) — TP-sharded dim on dim1,
vllm/v1/core/kv_cache_utils.py
+25
-13
@@ -938,9 +938,7 @@ def _pool_bytes_per_block(kv_cache_groups: list[KVCacheGroupSpec]) -> int:
938
kv_cache_groups[0].kv_cache_spec, UniformTypeKVCacheSpecs
939
):
940
return kv_cache_groups[0].kv_cache_spec.page_size_bytes
941
- if all(
942
- isinstance(g.kv_cache_spec, UniformTypeKVCacheSpecs) for g in kv_cache_groups
943
- ):
941
+ if _use_packed_kv_cache_groups(kv_cache_groups):
942
# buckets = {page_size: [[layer_names], [layer_names], ...]}
943
buckets = _bucket_layers_by_page_size(kv_cache_groups)
944
return sum(ps * len(slots) for ps, slots in buckets.items())
@@ -1218,16 +1216,29 @@ def _bucket_layers_by_page_size(
1216
return buckets
1217
1218
1221
-def _get_kv_cache_config_deepseek_v4(
1219
+def _use_packed_kv_cache_groups(
1220
+ kv_cache_groups: list[KVCacheGroupSpec],
1221
+) -> bool:
1222
+ is_dsv4 = all(
1223
+ isinstance(group.kv_cache_spec, UniformTypeKVCacheSpecs)
1224
+ for group in kv_cache_groups
1225
+ )
1226
+ return is_dsv4 or (
1227
+ bool(envs.VLLM_USE_PACKED_HMA_KV_CACHE) and len(kv_cache_groups) > 1
1228
+ )
1229
+
1230
+
1231
+def _get_kv_cache_config_packed(
1232
vllm_config: VllmConfig,
1233
kv_cache_groups: list[KVCacheGroupSpec],
1234
available_memory: int,
1235
) -> tuple[int, list[KVCacheTensor]]:
1226
- """DeepseekV4 KV cache tensor layout planning.
1236
+ """Plan a packed per-block KV cache tensor layout.
1237
1238
Emit one KVCacheTensor per (slot_idx, page_size). Layers from different
1239
groups at the same slot share a tensor (they have independent block
1230
- tables so block-id namespaces never collide).
1240
+ tables so block-id namespaces never collide). Each emitted tensor aliases
1241
+ one physical backing allocation, with per-block data laid out contiguously.
1242
"""
1243
# buckets = {page_size: [[layer_names], [layer_names], ...]}
1244
buckets = _bucket_layers_by_page_size(kv_cache_groups)
@@ -1255,6 +1266,9 @@ def _get_kv_cache_config_deepseek_v4(
1266
return num_blocks, kv_cache_tensors
1267
1268
1269
+_get_kv_cache_config_deepseek_v4 = _get_kv_cache_config_packed
1270
+
1271
+
1272
def get_kv_cache_config_from_groups(
1273
vllm_config: VllmConfig,
1274
kv_cache_groups: list[KVCacheGroupSpec],
@@ -1299,13 +1313,11 @@ def get_kv_cache_config_from_groups(
1313
)
1314
for layer_name in kv_cache_groups[0].layer_names
1315
]
1302
- elif all(
1303
- isinstance(group.kv_cache_spec, UniformTypeKVCacheSpecs)
1304
- for group in kv_cache_groups
1305
- ):
1306
- # DeepseekV4: UniformTypeKVCacheSpecs but multiple groups.
1307
- # Delegate to the DeepseekV4-specific allocator.
1308
- num_blocks, kv_cache_tensors = _get_kv_cache_config_deepseek_v4(
1316
+ elif _use_packed_kv_cache_groups(kv_cache_groups):
1317
+ # DeepSeek V4 keeps the existing packed layout. Other multi-group
1318
+ # attention-only HMA layouts can opt in with
1319
+ # VLLM_USE_PACKED_HMA_KV_CACHE=1.
1320
+ num_blocks, kv_cache_tensors = _get_kv_cache_config_packed(
1321
vllm_config, kv_cache_groups, available_memory
1322
)
1323
else:
vllm/v1/kv_offload/cpu/spec.py
+9
-1
@@ -58,7 +58,15 @@ class CPUOffloadingSpec(OffloadingSpec):
58
self.cpu_page_size_per_worker = 0
59
assert kv_cache_config is not None
60
if kv_cache_config.num_blocks > 0 and world_size > 0:
61
- total_gpu_kv_bytes = sum(t.size for t in kv_cache_config.kv_cache_tensors)
61
+ is_packed = any(t.block_stride for t in kv_cache_config.kv_cache_tensors)
62
+ assert not is_packed or all(
63
+ t.block_stride for t in kv_cache_config.kv_cache_tensors
64
+ )
65
+ total_gpu_kv_bytes = (
66
+ kv_cache_config.kv_cache_tensors[0].size
67
+ if is_packed
68
+ else sum(t.size for t in kv_cache_config.kv_cache_tensors)
69
+ )
70
kv_bytes_per_block = (
71
total_gpu_kv_bytes // kv_cache_config.num_blocks
72
) * world_size
vllm/v1/simple_kv_offload/manager.py
+9
-1
@@ -187,7 +187,13 @@ class SimpleCPUOffloadScheduler:
187
188
assert len(gpu_config.kv_cache_tensors) > 0
189
190
- gpu_total_bytes = sum(t.size for t in gpu_config.kv_cache_tensors)
190
+ is_packed = any(t.block_stride for t in gpu_config.kv_cache_tensors)
191
+ assert not is_packed or all(t.block_stride for t in gpu_config.kv_cache_tensors)
192
+ gpu_total_bytes = (
193
+ gpu_config.kv_cache_tensors[0].size
194
+ if is_packed
195
+ else sum(t.size for t in gpu_config.kv_cache_tensors)
196
+ )
197
num_gpu_blocks = gpu_config.num_blocks
198
num_cpu_blocks = max(1, num_gpu_blocks * cpu_capacity_bytes // gpu_total_bytes)
199
# Create CPU kv_cache_tensors mirroring GPU by scaling size proportionally.
@@ -195,6 +201,8 @@ class SimpleCPUOffloadScheduler:
201
KVCacheTensor(
202
size=t.size // num_gpu_blocks * num_cpu_blocks,
203
shared_by=list(t.shared_by),
204
+ offset=t.offset,
205
+ block_stride=t.block_stride,
206
)
207
for t in gpu_config.kv_cache_tensors
208
]