[Bugfix][KV Offload] Register mixed page sizes in one cache group (#54756)

Signed-off-by: heliubj18 <chengfeng@ppio.com> Co-authored-by: Kimi <noreply@moonshot.cn>

He Liu committed Sep 8, 2026 at 11:50 UTC eb6b619ab256df1100cb75684a9ba78485506c3a
3 files changed +275 -31
tests/v1/attention/utils.py
+18 -6
@@ -426,24 +426,36 @@ class MockMambaBuilder(BaseMambaAttentionMetadataBuilder[BaseMambaAttentionMetad
426 return builder.build(0, common_metadata)
427
428
429 -def dense_kv_cache_views(
429 +def dense_kv_cache_tensor(
430 raw: torch.Tensor,
431 spec: KVCacheSpec,
432 num_blocks: int,
433 num_layers: int,
434 layout: KVCacheLayout,
435 - kernel_block_size: int | None = None,
436 -) -> list[torch.Tensor]:
437 - """``create_kv_cache_views`` for a dense allocation of ``num_layers`` layers."""
435 + layer_names: list[str] | None = None,
436 +) -> KVCacheTensor:
437 + """The ``KVCacheTensor`` for a dense allocation of ``num_layers`` layers."""
438 layer_stride, block_stride, _, _, _ = compute_layout_strides(
439 spec, num_blocks, num_layers, layout
440 )
441 - tensor = KVCacheTensor(
441 + return KVCacheTensor(
442 size=raw.numel(),
443 - layers=[str(i) for i in range(num_layers)],
443 + layers=layer_names or [str(i) for i in range(num_layers)],
444 layer_stride=layer_stride,
445 block_stride=block_stride,
446 )
447 +
448 +
449 +def dense_kv_cache_views(
450 + raw: torch.Tensor,
451 + spec: KVCacheSpec,
452 + num_blocks: int,
453 + num_layers: int,
454 + layout: KVCacheLayout,
455 + kernel_block_size: int | None = None,
456 +) -> list[torch.Tensor]:
457 + """``create_kv_cache_views`` for a dense allocation of ``num_layers`` layers."""
458 + tensor = dense_kv_cache_tensor(raw, spec, num_blocks, num_layers, layout)
459 return create_kv_cache_views(
460 raw,
461 spec,
tests/v1/simple_kv_offload/test_worker.py
+215 -5
@@ -20,8 +20,22 @@ from vllm.platforms import current_platform
20 if not current_platform.is_cuda_alike():
21 pytest.skip("Requires CUDA or ROCm", allow_module_level=True)
22
23 -from tests.v1.attention.utils import dense_kv_cache_views
24 -from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheLayout
23 +from tests.v1.attention.utils import dense_kv_cache_tensor, dense_kv_cache_views
24 +from vllm.config import CacheConfig
25 +from vllm.v1.core.kv_cache_utils import (
26 + get_kv_cache_config_from_groups,
27 + is_kv_cache_spec_uniform,
28 + resolve_kv_cache_block_sizes,
29 +)
30 +from vllm.v1.kv_cache_interface import (
31 + FullAttentionSpec,
32 + KVCacheGroupSpec,
33 + KVCacheLayout,
34 + KVCacheSpec,
35 + KVCacheTensor,
36 + MLAAttentionSpec,
37 + UniformTypeKVCacheSpecs,
38 +)
39 from vllm.v1.simple_kv_offload.copy_backend import DmaCopyBackend
40 from vllm.v1.simple_kv_offload.cuda_mem_ops import (
41 CU_MEMCPY_SRC_ACCESS_ORDER_ANY,
@@ -31,6 +45,7 @@ from vllm.v1.simple_kv_offload.cuda_mem_ops import (
45 )
46 from vllm.v1.simple_kv_offload.metadata import SimpleCPUOffloadMetadata
47 from vllm.v1.simple_kv_offload.worker import SimpleCPUOffloadWorker
48 +from vllm.v1.worker.utils import allocate_kv_cache
49
50 NUM_BLOCKS = 64
51 BLOCK_BYTES = 4096
@@ -210,9 +225,14 @@ def test_register_shared_kv_cache_storage(monkeypatch, layout: KVCacheLayout):
225 device="cuda",
226 )
227 caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
228 + layer_names = [f"layer.{i}" for i in range(num_layers)]
229 cache_config = MagicMock(
230 num_blocks=num_blocks,
215 - kv_cache_tensors=[MagicMock(size=raw.nbytes)],
231 + kv_cache_tensors=[
232 + dense_kv_cache_tensor(
233 + raw, spec, num_blocks, num_layers, layout, layer_names
234 + )
235 + ],
236 )
237 worker = SimpleCPUOffloadWorker(
238 vllm_config=None,
@@ -252,7 +272,14 @@ def test_register_kv_cache_storage_with_trailing_padding(monkeypatch):
272 vllm_config=None,
273 kv_cache_config=MagicMock(
274 num_blocks=num_blocks,
255 - kv_cache_tensors=[MagicMock(size=cache_bytes)],
275 + kv_cache_tensors=[
276 + KVCacheTensor(
277 + size=cache_bytes,
278 + layers=["layer.0"],
279 + layer_stride=cache_bytes,
280 + block_stride=block_bytes,
281 + )
282 + ],
283 ),
284 cpu_capacity_bytes=cache_bytes,
285 )
@@ -286,11 +313,16 @@ def test_register_separate_kv_head_groups(monkeypatch):
313 device="cuda",
314 )
315 caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
316 + layer_names = [f"layer.{i}" for i in range(num_layers)]
317 worker = SimpleCPUOffloadWorker(
318 vllm_config=None,
319 kv_cache_config=MagicMock(
320 num_blocks=num_blocks,
293 - kv_cache_tensors=[MagicMock(size=raw.nbytes)],
321 + kv_cache_tensors=[
322 + dense_kv_cache_tensor(
323 + raw, spec, num_blocks, num_layers, layout, layer_names
324 + )
325 + ],
326 ),
327 cpu_capacity_bytes=raw.nbytes,
328 )
@@ -309,3 +341,181 @@ def test_register_separate_kv_head_groups(monkeypatch):
341 assert {cache.shape for cache in worker.gpu_kv_caches.values()} == {
342 (num_blocks, per_group_block_bytes)
343 }
344 +
345 +
346 +def _dsa_specs(num_layers: int, block_size: int) -> dict[str, KVCacheSpec]:
347 + """A DSA model's per-layer specs (DeepSeek-V3.2, GLM-5.2).
348 +
349 + Each decoder layer contributes an MLA latent cache and the indexer's key
350 + cache, whose pages differ in size (``MLAAttentionSpec`` off ``head_size``:
351 + 512 + 64 for the latent, 128 fp8 keys + one fp32 scale per 128 elements for
352 + the indexer). Both are ``MLAAttentionSpec`` with the same block size, so the
353 + hybrid allocator puts them in one ``UniformTypeKVCacheSpecs`` cache group.
354 + See ``DeepseekV32IndexerCache.get_kv_cache_spec`` and
355 + ``MLAAttention.get_kv_cache_spec``.
356 + """
357 + specs: dict[str, KVCacheSpec] = {}
358 + for i in range(num_layers):
359 + specs[f"model.layers.{i}.self_attn.attn"] = MLAAttentionSpec(
360 + block_size=block_size,
361 + num_kv_heads=1,
362 + head_size=512 + 64,
363 + dtype=torch.uint8,
364 + cache_dtype_str="fp8",
365 + )
366 + specs[f"model.layers.{i}.self_attn.indexer.k_cache"] = MLAAttentionSpec(
367 + block_size=block_size,
368 + num_kv_heads=1,
369 + head_size=128 + 128 // 128 * 4,
370 + dtype=torch.uint8,
371 + )
372 + return specs
373 +
374 +
375 +def test_register_mixed_page_sizes_in_one_cache_group(monkeypatch):
376 + """Sparse-MLA models mix page sizes inside one cache group.
377 +
378 + The MLA latent and indexer key caches of a layer have different page sizes,
379 + so the allocation has no single per-layer block size to reinterpret it with:
380 + ``num_layers * (mla_page + indexer_page)`` is not a multiple of either page.
381 + Registration must derive each region from the placement metadata instead.
382 + """
383 + num_layers = 4
384 + block_size = 64
385 + specs = _dsa_specs(num_layers, block_size)
386 + # Differing head sizes make the specs non-identical but same-type, which is
387 + # what lands both caches of a layer in one group.
388 + assert not is_kv_cache_spec_uniform(specs)
389 + assert UniformTypeKVCacheSpecs.is_uniform_type(specs)
390 + group = KVCacheGroupSpec(
391 + list(specs),
392 + UniformTypeKVCacheSpecs(block_size=block_size, kv_cache_specs=specs),
393 + )
394 +
395 + # DEEPSEEK_V32_INDEXER declares no supported layouts, so resolution lands on
396 + # the default preference: layer-outermost, one region per layer.
397 + layout = KVCacheLayout.LBNHC
398 + vllm_config = MagicMock()
399 + vllm_config.cache_config = CacheConfig()
400 + vllm_config.cache_config.kv_cache_layout = layout.name
401 + vllm_config.cache_config.num_gpu_blocks_override = None
402 +
403 + pages = [spec.page_size_bytes for spec in specs.values()]
404 + num_blocks = 4
405 + kv_cache_config = get_kv_cache_config_from_groups(
406 + vllm_config, [group], sum(pages) * num_blocks
407 + )
408 + assert kv_cache_config.num_blocks == num_blocks
409 + assert len(set(pages)) > 1, "the mixed page sizes are what this test covers"
410 +
411 + kv_caches = allocate_kv_cache(kv_cache_config, torch.device("cuda"), layout)
412 + worker = SimpleCPUOffloadWorker(
413 + vllm_config=None,
414 + kv_cache_config=kv_cache_config,
415 + cpu_capacity_bytes=sum(pages) * num_blocks,
416 + )
417 + worker._backend = MagicMock()
418 + monkeypatch.setattr("vllm.v1.simple_kv_offload.worker.PIN_MEMORY", False)
419 +
420 + worker.register_kv_caches(kv_caches)
421 +
422 + assert worker.gpu_kv_caches is not None
423 + # One region per layer, each with that layer's own page as its block stride.
424 + assert sorted(worker.gpu_kv_caches) == sorted(specs)
425 + assert {name: cache.shape[1] for name, cache in worker.gpu_kv_caches.items()} == {
426 + name: spec.page_size_bytes for name, spec in specs.items()
427 + }
428 + assert all(cache.shape[0] == num_blocks for cache in worker.gpu_kv_caches.values())
429 +
430 + # Every registered region must alias the bytes the model writes through, or
431 + # offloaded blocks would be copied from the wrong place.
432 + for name, cache in kv_caches.items():
433 + region = worker.gpu_kv_caches[name]
434 + assert region.data_ptr() == cache.data_ptr()
435 + assert region.stride(0) == specs[name].page_size_bytes
436 +
437 +
438 +@pytest.mark.parametrize("rank_blocks", [1, 5])
439 +def test_register_mixed_page_sizes_odd_block_counts(monkeypatch, rank_blocks):
440 + """Registration holds at block counts that are not powers of two.
441 +
442 + ``num_blocks`` comes out of ``available_memory // bytes_per_block``, so
443 + any integer is reachable. Registration derives regions from the config's
444 + own block count and strides, and the CPU pool rounds down from the same
445 + per-block byte total; none of it assumes anything about how the count
446 + was reached.
447 + """
448 + num_layers = 4
449 + block_size = 64
450 + specs = _dsa_specs(num_layers, block_size)
451 + group = KVCacheGroupSpec(
452 + list(specs),
453 + UniformTypeKVCacheSpecs(block_size=block_size, kv_cache_specs=specs),
454 + )
455 + layout = KVCacheLayout.LBNHC
456 + vllm_config = MagicMock()
457 + vllm_config.cache_config = CacheConfig()
458 + vllm_config.cache_config.kv_cache_layout = layout.name
459 + vllm_config.cache_config.num_gpu_blocks_override = None
460 +
461 + pages = [spec.page_size_bytes for spec in specs.values()]
462 + kv_cache_config = get_kv_cache_config_from_groups(
463 + vllm_config, [group], sum(pages) * rank_blocks
464 + )
465 + assert kv_cache_config.num_blocks == rank_blocks
466 +
467 + kv_caches = allocate_kv_cache(kv_cache_config, torch.device("cuda"), layout)
468 + worker = SimpleCPUOffloadWorker(
469 + vllm_config=None,
470 + kv_cache_config=kv_cache_config,
471 + cpu_capacity_bytes=sum(pages) * rank_blocks,
472 + )
473 + worker._backend = MagicMock()
474 + monkeypatch.setattr("vllm.v1.simple_kv_offload.worker.PIN_MEMORY", False)
475 +
476 + worker.register_kv_caches(kv_caches)
477 +
478 + assert worker.gpu_kv_caches is not None
479 + assert {n: c.shape for n, c in worker.gpu_kv_caches.items()} == {
480 + name: (rank_blocks, spec.page_size_bytes) for name, spec in specs.items()
481 + }
482 + assert worker.num_cpu_blocks == rank_blocks
483 +
484 +
485 +def test_mixed_page_byte_placement_is_dcp_invariant():
486 + """DCP scales a block's token span, not its byte placement.
487 +
488 + ``get_kv_cache_config_from_groups`` never reads
489 + ``decode_context_parallel_size``, so every placement field registration
490 + derives regions from is identical at dcp=1 and dcp=2. What DCP scales is
491 + the block's token span, reported by ``resolve_kv_cache_block_sizes``;
492 + checking that it doubles proves DCP was actually in effect, or the
493 + identity above would be trivially true.
494 + """
495 + specs = _dsa_specs(4, block_size=128)
496 +
497 + placements = []
498 + for dcp_size in (1, 2):
499 + group = KVCacheGroupSpec(
500 + list(specs),
501 + UniformTypeKVCacheSpecs(block_size=128, kv_cache_specs=specs),
502 + )
503 + vllm_config = MagicMock()
504 + vllm_config.cache_config = CacheConfig()
505 + vllm_config.cache_config.block_size = 128
506 + vllm_config.cache_config.kv_cache_layout = KVCacheLayout.LBNHC.name
507 + vllm_config.cache_config.num_gpu_blocks_override = None
508 + vllm_config.parallel_config.decode_context_parallel_size = dcp_size
509 +
510 + page_bytes = sum(spec.page_size_bytes for spec in specs.values())
511 + config = get_kv_cache_config_from_groups(vllm_config, [group], page_bytes * 4)
512 + scheduler_block_size, _ = resolve_kv_cache_block_sizes(config, vllm_config)
513 + assert scheduler_block_size == 128 * dcp_size
514 + placements.append(
515 + [
516 + (t.size, tuple(t.layers), t.layer_stride, t.block_stride, t.offset)
517 + for t in config.kv_cache_tensors
518 + ]
519 + )
520 +
521 + assert placements[0] == placements[1]
vllm/v1/simple_kv_offload/worker.py
+42 -20
@@ -100,33 +100,55 @@ class SimpleCPUOffloadWorker:
100 assert self.kv_cache_config is not None
101 num_blocks = self.kv_cache_config.num_blocks
102 assert self.kv_cache_config.kv_cache_tensors
103 - logical_storage_bytes = self.kv_cache_config.kv_cache_tensors[0].size
103
105 - # The DMA backend copies whole blocks as base + block_id * stride(0),
106 - # so view each unique allocation as [num_blocks, block_bytes].
104 + # The DMA backend copies blocks as base + block_id * stride(0), so every
105 + # region is a [num_blocks, block_bytes] view. Block bytes come from each
106 + # tensor's placement -- layers in one allocation can differ in page size.
107 unique_gpu_caches: dict[str, torch.Tensor] = {}
108 seen: set[tuple[torch.device, int]] = set()
109 - for name, tensor in kv_caches.items():
110 - storage = tensor.untyped_storage()
111 - key = (tensor.device, storage.data_ptr())
112 - if key in seen:
109 + for kv_cache_tensor in self.kv_cache_config.kv_cache_tensors:
110 + name = next((n for n in kv_cache_tensor.layers if n in kv_caches), None)
111 + if name is None:
112 continue
114 - seen.add(key)
115 -
116 - physical_per_block, remainder = divmod(tensor.shape[0], num_blocks)
117 - assert remainder == 0, (
118 - f"KV cache {name!r} has {tensor.shape[0]} physical blocks, which "
119 - f"is not divisible by {num_blocks} scheduler blocks"
113 + num_layers = len(kv_cache_tensor.layers)
114 + block_bytes = kv_cache_tensor.block_stride
115 + blocks_span = num_blocks * block_bytes
116 + layer_outermost = kv_cache_tensor.layer_stride >= blocks_span
117 + if layer_outermost:
118 + # One region per layer, or per KV head group under LHBNC.
119 + start = kv_cache_tensor.offset
120 + span = num_layers * kv_cache_tensor.layer_stride
121 + layer_names = kv_cache_tensor.layers
122 + else:
123 + start = 0
124 + span = blocks_span
125 + layer_names = kv_cache_tensor.layers[:1]
126 +
127 + cache = kv_caches[name]
128 + raw = torch.empty(0, dtype=torch.int8, device=cache.device).set_(
129 + cache.untyped_storage()
130 )
121 - block_bytes = tensor.stride(0) * tensor.element_size() * physical_per_block
122 - raw = torch.empty(0, dtype=torch.int8, device=tensor.device).set_(storage)
123 - assert raw.numel() >= logical_storage_bytes, (
124 - f"KV cache {name!r} storage has {raw.numel()} bytes, smaller "
125 - f"than the configured {logical_storage_bytes}-byte allocation"
131 + assert raw.numel() >= start + span, (
132 + f"KV cache {name!r} storage has {raw.numel()} bytes, smaller than "
133 + f"the {span} bytes it places at offset {start}"
134 + )
135 + regions = raw[start : start + span].view(-1, num_blocks, block_bytes)
136 + groups_per_layer, remainder = divmod(len(regions), len(layer_names))
137 + assert remainder == 0, (
138 + f"KV cache {name!r} places {len(layer_names)} layers in "
139 + f"{len(regions)} block-strided regions"
140 )
127 - regions = raw[:logical_storage_bytes].view(-1, num_blocks, block_bytes)
141 for idx, region in enumerate(regions):
129 - key_name = name if len(regions) == 1 else f"{name}.{idx}"
142 + key = (region.device, region.data_ptr())
143 + if key in seen:
144 + continue
145 + seen.add(key)
146 + layer_name = layer_names[idx // groups_per_layer]
147 + key_name = (
148 + layer_name
149 + if groups_per_layer == 1
150 + else f"{layer_name}.{idx % groups_per_layer}"
151 + )
152 unique_gpu_caches[key_name] = region
153
154 # Compute per-tensor bytes_per_block. Tensors may have different