[Core][DSV4] Compact MXFP4 indexer KV cache and packed group overlays (#48993)

Summer Yang committed Jul 22, 2026 at 16:58 UTC f3a920a0764012555fa0d3b6559ac0ddeb746200
3 files changed +216 -84
tests/v1/core/test_contiguous_kv_packing.py
+170 -22
@@ -9,6 +9,7 @@ import torch
9
10 from vllm.v1.core.kv_cache_utils import (
11 _get_kv_cache_config_packed,
12 + _get_kv_cache_groups_uniform_groups,
13 get_kv_cache_config_from_groups,
14 )
15 from vllm.v1.kv_cache_interface import (
@@ -16,6 +17,7 @@ from vllm.v1.kv_cache_interface import (
17 KVCacheGroupSpec,
18 KVCacheTensor,
19 MLAAttentionSpec,
20 + SlidingWindowMLASpec,
21 SlidingWindowSpec,
22 UniformTypeKVCacheSpecs,
23 )
@@ -109,7 +111,132 @@ def _page_sizes_by_layer(
111 return page_sizes
112
113
114 +def _packing_by_layer(
115 + tensors: list[KVCacheTensor],
116 +) -> dict[str, tuple[int, int]]:
117 + return {
118 + layer_name: (tensor.offset, tensor.block_stride)
119 + for tensor in tensors
120 + for layer_name in tensor.shared_by
121 + }
122 +
123 +
124 +def _make_views(
125 + groups: list[KVCacheGroupSpec],
126 + num_blocks: int,
127 + tensors: list[KVCacheTensor],
128 +) -> dict[str, torch.Tensor]:
129 + page_sizes = _page_sizes_by_layer(groups)
130 + packing = _packing_by_layer(tensors)
131 + backing = torch.zeros(tensors[0].size, dtype=torch.uint8)
132 + return {
133 + layer_name: torch.as_strided(
134 + backing,
135 + size=(num_blocks, page_size),
136 + stride=(packing[layer_name][1], 1),
137 + storage_offset=packing[layer_name][0],
138 + )
139 + for layer_name, page_size in page_sizes.items()
140 + }
141 +
142 +
143 +def _make_page_group(prefix: str, page_sizes: list[int]) -> KVCacheGroupSpec:
144 + specs = {
145 + f"{prefix}.{i}": MagicMock(page_size_bytes=page_size)
146 + for i, page_size in enumerate(page_sizes)
147 + }
148 + return KVCacheGroupSpec(
149 + layer_names=list(specs),
150 + kv_cache_spec=UniformTypeKVCacheSpecs(block_size=256, kv_cache_specs=specs),
151 + )
152 +
153 +
154 class TestInterleavedPacking:
155 + def test_compact_cache_overlays_fp32_state_group(self):
156 + full_specs = {}
157 + state_specs = {}
158 + for i in range(2):
159 + full_specs[f"mla.{i}"] = MLAAttentionSpec(
160 + block_size=256,
161 + num_kv_heads=1,
162 + head_size=512,
163 + dtype=torch.uint8,
164 + page_size_padded=32768,
165 + indexes_kv_by_block_stride=True,
166 + compress_ratio=4,
167 + )
168 + full_specs[f"indexer.{i}"] = MLAAttentionSpec(
169 + block_size=256,
170 + num_kv_heads=1,
171 + head_size=68,
172 + dtype=torch.uint8,
173 + page_size_padded=4608,
174 + compress_ratio=4,
175 + )
176 + state_specs[f"mla_state.{i}"] = SlidingWindowMLASpec(
177 + block_size=4,
178 + num_kv_heads=1,
179 + head_size=2048,
180 + dtype=torch.float32,
181 + sliding_window=8,
182 + indexes_kv_by_block_stride=True,
183 + )
184 + state_specs[f"indexer_state.{i}"] = SlidingWindowMLASpec(
185 + block_size=4,
186 + num_kv_heads=1,
187 + head_size=512,
188 + dtype=torch.float32,
189 + sliding_window=8,
190 + indexes_kv_by_block_stride=True,
191 + )
192 +
193 + grouped_specs = [
194 + UniformTypeKVCacheSpecs(block_size=256, kv_cache_specs=full_specs),
195 + UniformTypeKVCacheSpecs(block_size=4, kv_cache_specs=state_specs),
196 + ]
197 + groups = _get_kv_cache_groups_uniform_groups(grouped_specs)
198 +
199 + assert len(groups) == 2
200 + assert {full_specs[f"indexer.{i}"].page_size_bytes for i in range(2)} == {4608}
201 + assert {full_specs[f"indexer.{i}"].real_page_size_bytes for i in range(2)} == {
202 + 4352
203 + }
204 + assert {
205 + state_specs[f"indexer_state.{i}"].page_size_bytes for i in range(2)
206 + } == {8192}
207 +
208 + full_group_bytes = 2 * (32768 + 4608)
209 + state_group_bytes = 2 * (32768 + 8192)
210 + bytes_per_block = max(full_group_bytes, state_group_bytes)
211 + num_blocks, tensors = _get_kv_cache_config_packed(
212 + _mock_vllm_config(), groups, bytes_per_block * 32
213 + )
214 + assert num_blocks == 32
215 + assert {tensor.block_stride for tensor in tensors} == {bytes_per_block}
216 +
217 + packing = _packing_by_layer(tensors)
218 + assert packing["mla.0"][0] == packing["mla_state.0"][0] == 0
219 + assert packing["indexer.0"][0] == 32768
220 + assert packing["indexer_state.0"][0] == 32768
221 +
222 + def test_deepseek_v4_pro_stride(self):
223 + groups = [
224 + _make_page_group("full", [32768, 4608] * 30 + [1024] * 31),
225 + _make_page_group("c4_state", [32768, 8192] * 30),
226 + _make_page_group("c128_state", [32768] * 31),
227 + _make_page_group("swa.0", [32768] * 31),
228 + _make_page_group("swa.1", [32768] * 30),
229 + ]
230 + expected_stride = 1_228_800
231 +
232 + num_blocks, tensors = _get_kv_cache_config_packed(
233 + _mock_vllm_config(), groups, expected_stride * 32
234 + )
235 +
236 + assert num_blocks == 32
237 + assert {tensor.block_stride for tensor in tensors} == {expected_stride}
238 + assert {tensor.size for tensor in tensors} == {expected_stride * 32}
239 +
240 def test_all_tensors_have_block_stride(self):
241 _, tensors = _run()
242 for t in tensors:
@@ -122,9 +249,30 @@ class TestInterleavedPacking:
249 assert sizes.pop() > 0
250
251 def test_offsets_within_one_block(self):
125 - _, tensors = _run()
126 - for t in tensors:
127 - assert t.offset < t.block_stride
252 + groups = _make_groups(n_c4=3, n_c128=2, n_swa=5)
253 + _, tensors = _get_kv_cache_config_packed(
254 + _mock_vllm_config(), groups, 100 * 1024 * 1024
255 + )
256 + page_sizes = _page_sizes_by_layer(groups)
257 + packing = _packing_by_layer(tensors)
258 + for layer_name, page_size in page_sizes.items():
259 + offset, block_stride = packing[layer_name]
260 + assert offset + page_size <= block_stride
261 +
262 + def test_layouts_are_disjoint_within_each_group(self):
263 + groups = _make_groups(n_c4=3, n_c128=2, n_swa=5)
264 + _, tensors = _get_kv_cache_config_packed(
265 + _mock_vllm_config(), groups, 100 * 1024 * 1024
266 + )
267 + page_sizes = _page_sizes_by_layer(groups)
268 + packing = _packing_by_layer(tensors)
269 +
270 + for group in groups:
271 + ranges = sorted(
272 + (packing[name][0], packing[name][0] + page_sizes[name])
273 + for name in group.layer_names
274 + )
275 + assert all(left[1] <= right[0] for left, right in zip(ranges, ranges[1:]))
276
277 def test_all_layers_accounted_for(self):
278 n_c4, n_c128, n_swa = 5, 4, 7
@@ -135,29 +283,29 @@ class TestInterleavedPacking:
283 expected = n_c4 * 2 + n_c128 + n_swa
284 assert len(all_names) == expected
285
138 - def test_strided_views_are_independent(self):
286 + def test_group_owned_blocks_do_not_alias(self):
287 groups = _make_groups(n_c4=3, n_c128=2, n_swa=5)
140 - page_sizes = _page_sizes_by_layer(groups)
288 num_blocks, tensors = _get_kv_cache_config_packed(
289 _mock_vllm_config(), groups, 100 * 1024 * 1024
290 )
144 - backing = torch.zeros(tensors[0].size, dtype=torch.uint8)
145 - views = []
146 - for t in tensors:
147 - page_size = page_sizes[t.shared_by[0]]
148 - v = torch.as_strided(
149 - backing,
150 - size=(num_blocks, page_size),
151 - stride=(t.block_stride, 1),
152 - storage_offset=t.offset,
153 - )
154 - views.append(v)
155 -
156 - for i, v in enumerate(views):
157 - v.fill_(i + 1)
158 -
159 - for i, v in enumerate(views):
160 - assert (v == i + 1).all(), f"View {i} was corrupted"
291 + views = _make_views(groups, num_blocks, tensors)
292 +
293 + expected = {}
294 + value = 1
295 + for block_id, group in enumerate(groups):
296 + for layer_name in group.layer_names:
297 + views[layer_name][block_id].fill_(value)
298 + expected[layer_name] = (block_id, value)
299 + value += 1
300 +
301 + for layer_name, (block_id, value) in expected.items():
302 + assert (views[layer_name][block_id] == value).all()
303 +
304 + # Once the first group releases its block, another group may reuse it.
305 + for layer_name in groups[1].layer_names:
306 + views[layer_name][0].fill_(255)
307 + for layer_name in groups[1].layer_names:
308 + assert (views[layer_name][0] == 255).all()
309
310 def test_hma_attention_groups_keep_default_backing(self):
311 full = _make_full_spec()
vllm/models/deepseek_v4/attention.py
+11 -5
@@ -26,6 +26,7 @@ from vllm.models.deepseek_v4.common.ops import (
26 fused_indexer_q_rope_quant,
27 fused_q_kv_rmsnorm,
28 )
29 +from vllm.models.deepseek_v4.common.ops.fused_indexer_q import MXFP4_BLOCK_SIZE
30
31 if TYPE_CHECKING:
32 from vllm.v1.attention.backends.mla.sparse_swa import (
@@ -727,11 +728,16 @@ class DeepseekV4Indexer(nn.Module):
728 )
729
730 assert cache_config is not None, "Deepseek V4 indexer requires cache_config"
730 - # NOTE(yifan): FP8 indxer cache use the same layout as V3.2:
731 - # head_dim bytes = 128 fp8 + 4 fp32 scale = 132.
732 - # For FP4 indexer cache, we still allocate the same amount of memory as FP8,
733 - # but only use the first half of the memory.
734 - k_cache_head_dim = self.head_dim + self.head_dim // self.quant_block_size * 4
731 + if self.use_fp4_kv:
732 + # MXFP4 stores two values per byte plus one UE8M0 byte per 32 values.
733 + # head_dim bytes = 64 packed values + 4 UE8M0 scales = 68.
734 + k_cache_head_dim = self.head_dim // 2 + self.head_dim // MXFP4_BLOCK_SIZE
735 + else:
736 + # NOTE(yifan): FP8 indexer cache uses the same layout as V3.2:
737 + # head_dim bytes = 128 fp8 + 4 fp32 scale = 132.
738 + k_cache_head_dim = (
739 + self.head_dim + self.head_dim // self.quant_block_size * 4
740 + )
741 self.k_cache = DeepseekV4IndexerCache(
742 head_dim=k_cache_head_dim,
743 dtype=torch.uint8,
vllm/v1/core/kv_cache_utils.py
+35 -57
@@ -983,9 +983,8 @@ def _pool_bytes_per_block(
983 ):
984 return kv_cache_groups[0].kv_cache_spec.page_size_bytes
985 if _use_packed_kv_cache_config(vllm_config, kv_cache_groups):
986 - # buckets = {page_size: [[layer_names], [layer_names], ...]}
987 - buckets = _bucket_layers_by_page_size(kv_cache_groups)
988 - return sum(ps * len(slots) for ps, slots in buckets.items())
986 + block_stride, _ = _get_packed_kv_cache_layout(kv_cache_groups)
987 + return block_stride
988 group_size = max(len(g.layer_names) for g in kv_cache_groups)
989 page_size = get_uniform_page_size([g.kv_cache_spec for g in kv_cache_groups])
990 return page_size * group_size
@@ -1260,29 +1259,29 @@ def _get_kv_cache_groups_uniform_page_size(
1259 return create_kv_cache_group_specs(kv_cache_spec, grouped_layers)
1260
1261
1263 -def _bucket_layers_by_page_size(
1262 +def _get_packed_kv_cache_layout(
1263 kv_cache_groups: list[KVCacheGroupSpec],
1265 -) -> dict[int, list[list[str]]]:
1266 - """Bucket layers by page size: ``result[ps][slot_idx] = [layer_names]``.
1264 +) -> tuple[int, dict[int, list[str]]]:
1265 + """Lay out each cache group densely in one shared block slab.
1266
1268 - Layers from different groups at the same ``slot_idx`` share an underlying tensor
1269 - (they have independent block tables so block-id namespaces never collide).
1267 + A block ID is owned by one cache group at a time, so layouts from different
1268 + groups may overlap. Layers within a group remain disjoint.
1269 """
1271 - buckets: dict[int, list[list[str]]] = defaultdict(list)
1270 + layers_by_offset: dict[int, list[str]] = defaultdict(list)
1271 + block_stride = 0
1272 for group in kv_cache_groups:
1273 spec = group.kv_cache_spec
1274 - slot_count: dict[int, int] = defaultdict(int)
1274 + byte_offset = 0
1275 for layer_name in group.layer_names:
1276 if isinstance(spec, UniformTypeKVCacheSpecs):
1277 - ps = spec.kv_cache_specs[layer_name].page_size_bytes
1277 + page_size = spec.kv_cache_specs[layer_name].page_size_bytes
1278 else:
1279 - ps = spec.page_size_bytes
1280 - slot_idx = slot_count[ps]
1281 - slot_count[ps] += 1
1282 - if slot_idx == len(buckets[ps]):
1283 - buckets[ps].append([])
1284 - buckets[ps][slot_idx].append(layer_name)
1285 - return buckets
1279 + page_size = spec.page_size_bytes
1280 + layers_by_offset[byte_offset].append(layer_name)
1281 + byte_offset += page_size
1282 + block_stride = max(block_stride, byte_offset)
1283 + assert block_stride > 0
1284 + return block_stride, layers_by_offset
1285
1286
1287 def _use_packed_kv_cache_config(
@@ -1314,33 +1313,26 @@ def _get_kv_cache_config_packed(
1313 ) -> tuple[int, list[KVCacheTensor]]:
1314 """Plan a packed per-block KV cache tensor layout.
1315
1317 - Emit one KVCacheTensor per (slot_idx, page_size). Layers from different
1318 - groups at the same slot share a tensor (they have independent block
1319 - tables so block-id namespaces never collide). Each emitted tensor aliases
1320 - one physical backing allocation, with per-block data laid out contiguously.
1316 + Cache groups use dense, overlapping layouts within one block slab. Each
1317 + emitted tensor aliases the same physical backing allocation.
1318 """
1322 - # buckets = {page_size: [[layer_names], [layer_names], ...]}
1323 - buckets = _bucket_layers_by_page_size(kv_cache_groups)
1324 - total_num_bytes_per_block = sum(ps * len(slots) for ps, slots in buckets.items())
1319 + block_stride, layers_by_offset = _get_packed_kv_cache_layout(kv_cache_groups)
1320
1326 - num_blocks = available_memory // total_num_bytes_per_block
1321 + num_blocks = available_memory // block_stride
1322 num_blocks = may_override_num_blocks(vllm_config, num_blocks)
1323
1329 - total_size = total_num_bytes_per_block * num_blocks
1324 + total_size = block_stride * num_blocks
1325
1326 kv_cache_tensors: list[KVCacheTensor] = []
1332 - byte_offset = 0
1333 - for ps, slots in buckets.items():
1334 - for slot in slots:
1335 - kv_cache_tensors.append(
1336 - KVCacheTensor(
1337 - size=total_size,
1338 - shared_by=slot,
1339 - offset=byte_offset,
1340 - block_stride=total_num_bytes_per_block,
1341 - )
1327 + for byte_offset in sorted(layers_by_offset):
1328 + kv_cache_tensors.append(
1329 + KVCacheTensor(
1330 + size=total_size,
1331 + shared_by=layers_by_offset[byte_offset],
1332 + offset=byte_offset,
1333 + block_stride=block_stride,
1334 )
1343 - byte_offset += ps
1335 + )
1336
1337 return num_blocks, kv_cache_tensors
1338
@@ -1651,29 +1643,15 @@ def _get_kv_cache_groups_uniform_groups(
1643 for spec in group.kv_cache_specs.values()
1644 )
1645
1654 - # Split each SWA UniformKV group into smaller groups to align their #(layer tuples)
1655 - # Possibly padding layer tuples for this.
1656 - # Additionally, we also pad KV blocks in each SWA layer, to align the page size
1657 - # with the corresponding layer in the full-MLA group.
1658 - all_page_sizes = full_mla_spec.get_page_sizes()
1646 + # Split each SWA UniformKV group into smaller groups to align their
1647 + # numbers of layer tuples. The packed block planner overlays groups, so
1648 + # their page sizes do not need to match.
1649 swa_mla_groups = []
1650 for sm_spec in swa_mla_specs:
1661 - sm_page_sizes = sm_spec.get_page_sizes()
1651 layers_per_size: dict[int, list[str]] = defaultdict(list)
1663 - assert max(sm_page_sizes) <= max(all_page_sizes)
1664 -
1665 - # Unify page size by padding layers' page_size to the nearest larger page_size.
1666 - # Compute candidate (nearest larger page_size) for each unique page size.
1667 - size_to_candidate: dict[int, int] = {}
1668 - for ps in sm_page_sizes:
1669 - size_to_candidate[ps] = min(x for x in all_page_sizes if x >= ps)
1670 - # Pad and collect layer names per page size.
1652 +
1653 for layer_name, layer_spec in sm_spec.kv_cache_specs.items():
1672 - current_size = layer_spec.page_size_bytes
1673 - candidate = size_to_candidate[current_size]
1674 - if current_size < candidate:
1675 - object.__setattr__(layer_spec, "page_size_padded", candidate)
1676 - layers_per_size[candidate].append(layer_name)
1654 + layers_per_size[layer_spec.page_size_bytes].append(layer_name)
1655 # NOTE(yifan): for now, inside a UniformKV group, each page_size should
1656 # have the same number of layers. This also means we don't need to pad layers
1657 # inside a partial-full layer tuple.