[3/N][KV-Cache Layout Refactor] Standardize Mamba cache; drop `get_transfer_cache_regions` (#44456)

Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: OpenAI Codex <codex@openai.com>

Lucas Wilkinson committed Jul 21, 2026 at 11:16 UTC 6700813f86552a2e0787989dd9f468fddcb88bac
10 files changed +115 -225
tests/v1/kv_connector/unit/offloading_connector/test_worker.py
+1 -2
@@ -125,8 +125,7 @@ def test_register_kv_caches(backend):
125 own dedicated tensors.
126
127 Uses the real GPUModelRunner.initialize_kv_cache_tensors to produce
128 - kv_caches, which automatically applies
129 - _update_hybrid_attention_mamba_layout for hybrid models.
128 + the raw per-layer kv_caches registered by the connector.
129
130 Verifies that the canonicalized CanonicalKVCaches has the correct
131 block tensors, tensor_idx references, and page sizes across all groups.
vllm/distributed/kv_transfer/kv_connector/utils.py
-28
@@ -21,12 +21,10 @@ from vllm.logger import init_logger
21 from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
22 from vllm.platforms import current_platform
23 from vllm.v1.attention.backend import AttentionBackend
24 -from vllm.v1.kv_cache_interface import MambaSpec
24 from vllm.v1.outputs import KVConnectorOutput, ModelRunnerOutput
25
26 if TYPE_CHECKING:
27 from vllm.distributed.kv_transfer.kv_connector.base import KVConnectorBase
29 - from vllm.v1.kv_cache_interface import KVCacheSpec
28
29 logger = init_logger(__name__)
30
@@ -594,32 +592,6 @@ class TransferTopology:
592 abs_ratio = -tp_ratio
593 return [self.tp_rank * abs_ratio + i for i in range(abs_ratio)]
594
597 - def get_transfer_cache_regions(
598 - self, cache: torch.Tensor, layer_spec: "KVCacheSpec"
599 - ) -> list[torch.Tensor] | torch.Tensor:
600 - """Return the cache tensor(s) to register as NIXL memory regions,
601 - also accounting for hybrid SSM models specificities.
602 - """
603 - if isinstance(layer_spec, MambaSpec):
604 - # Register the whole kv cache shared tensor, including
605 - # SSM/Conv.
606 - conv, ssm = cache
607 - return [conv]
608 -
609 - # Check may be hacky but it's matching
610 - # `_update_hybrid_attention_mamba_layout`.
611 - if self.is_mamba and cache.shape[0] == 2:
612 - # When MAMBA is present, all backends are blocks first, so
613 - # that blocks can be shared between attention layers and mamba
614 - # layers. Runner already adjusted strides for FlashAttn-like
615 - # backends so its num_blocks first.
616 - # Swap [2<>num_blocks] dims for hybrid SSM layout.
617 - cache = cache.transpose(0, 1)
618 -
619 - # K and V are packed into one tensor (content dim), so each layer
620 - # registers as a single region.
621 - return [cache]
622 -
595 def describe(self, remote_engine_id: EngineId, remote_pp_rank: int = 0) -> str:
596 """One-line summary of transfer config for logging."""
597 info = self._engines[(remote_engine_id, remote_pp_rank)]
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py
+3 -3
@@ -1678,9 +1678,9 @@ class MooncakeConnectorWorker:
1678 conv, _ = cache_or_caches
1679 cache_list = [conv]
1680 else:
1681 - cache_list = self.transfer_topo.get_transfer_cache_regions(
1682 - cache_or_caches, layer_spec
1683 - )
1681 + # K and V are packed into one blocks-first tensor per layer,
1682 + # so each layer registers as a single region.
1683 + cache_list = [cache_or_caches]
1684
1685 logger.debug(
1686 "registering layer %s with %d cache tensor(s)",
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+51 -61
@@ -1092,7 +1092,7 @@ class NixlBaseConnectorWorker:
1092 # to better exploit the memory layout (ie num_blocks is the first dim).
1093 tensor_size_bytes = None
1094
1095 - for layer_name, cache_or_caches in xfer_buffers.items():
1095 + for layer_name, cache in xfer_buffers.items():
1096 # NOTE (NickLucche) Hybrid SSM models assume a layout that is similar to
1097 # that of FI, with block laid out as in `get_backend_aware_kv_block_len`.
1098 # However, physical page_size may differ when kernel requires a specific
@@ -1109,9 +1109,6 @@ class NixlBaseConnectorWorker:
1109 if isinstance(layer_spec, UniformTypeKVCacheSpecs):
1110 # MLA DSv32 Indexer case: UniformTypeKVCacheSpecs merges kv_cache_specs
1111 layer_spec = layer_spec.kv_cache_specs[layer_name]
1112 - cache_list = self.transfer_topo.get_transfer_cache_regions(
1113 - cache_or_caches, layer_spec
1114 - )
1112 # `layer_spec.page_size_bytes` only accounts for logical page_size, that is
1113 # the page_size assuming constant `self._logical_num_blocks`.
1114 physical_page_size = (
@@ -1120,8 +1117,6 @@ class NixlBaseConnectorWorker:
1117 else layer_spec.page_size_bytes
1118 // self._physical_blocks_per_logical_kv_block
1119 )
1123 - # For when registering multiple tensors eg K/V in separate regions.
1124 - physical_page_size = physical_page_size // len(cache_list)
1120 if self.transfer_topo._cross_layers_blocks:
1121 # When cross-layers blocks are used, multiply by number of layers
1122 physical_page_size = physical_page_size * len(
@@ -1136,66 +1131,61 @@ class NixlBaseConnectorWorker:
1131 # [`num_blocks` * `page_size`]
1132 curr_tensor_size_bytes = num_blocks * physical_page_size
1133
1139 - # TODO (NickLucche) we could eventually unify how we handle FA/FI regions,
1140 - # registering a single tensor for both K/V and splitting logically like FI.
1141 - for cache in cache_list:
1142 - base_addr = cache.data_ptr()
1143 - if base_addr in seen_base_addresses:
1144 - # NOTE (NickLucche) HMA employs memory pooling to share tensors
1145 - # across groups. This results in skipping all tensors but the ones
1146 - # pointed to by group0. Also, generally we will have more blocks
1147 - # per tensor but fewer regions.
1148 - logger.debug("Skipping %s because it's already seen", layer_name)
1149 - continue
1150 - logger.debug(
1151 - "Registering layer %s with cache shape: %s", layer_name, cache.shape
1152 - )
1153 - seen_base_addresses.append(base_addr)
1154 - # Only record non-Mamba page sizes.
1155 - if isinstance(layer_spec, MambaSpec):
1156 - self.block_len_per_layer.append(
1157 - physical_page_size // self._physical_blocks_per_logical_kv_block
1158 - )
1159 - else:
1160 - self.block_len_per_layer.append(physical_page_size)
1161 - is_mla_region = isinstance(
1162 - layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)
1134 + base_addr = cache.data_ptr()
1135 + if base_addr in seen_base_addresses:
1136 + # NOTE (NickLucche) HMA employs memory pooling to share tensors
1137 + # across groups. This results in skipping all tensors but the ones
1138 + # pointed to by group0. Also, generally we will have more blocks
1139 + # per tensor but fewer regions.
1140 + logger.debug("Skipping %s because it's already seen", layer_name)
1141 + continue
1142 + logger.debug(
1143 + "Registering layer %s with cache shape: %s", layer_name, cache.shape
1144 + )
1145 + seen_base_addresses.append(base_addr)
1146 + # Only record non-Mamba page sizes.
1147 + if isinstance(layer_spec, MambaSpec):
1148 + self.block_len_per_layer.append(
1149 + physical_page_size // self._physical_blocks_per_logical_kv_block
1150 )
1164 - self._region_is_mla.append(is_mla_region)
1165 -
1166 - if not is_mla_region:
1167 - if tensor_size_bytes is None:
1168 - tensor_size_bytes = curr_tensor_size_bytes
1169 - assert tensor_size_bytes == curr_tensor_size_bytes, (
1170 - "All non-MLA kv cache tensors must have the same size"
1171 - )
1151 + else:
1152 + self.block_len_per_layer.append(physical_page_size)
1153 + is_mla_region = isinstance(
1154 + layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)
1155 + )
1156 + self._region_is_mla.append(is_mla_region)
1157
1173 - # When there's a mismatch between kbs<>bs, we rely on HMA to ensure
1174 - # caches are either [NB, PS] or [NB*r, PS/r] where r is bs/kbs.
1175 - if (
1176 - self._physical_blocks_per_logical_kv_block == 1
1177 - and cache.shape[0] != num_blocks
1178 - ):
1179 - raise AssertionError(
1180 - "All kv cache tensors must have the same number of "
1181 - f"blocks; layer={layer_name}, "
1182 - f"expected_num_blocks={num_blocks}, "
1183 - f"cache_shape={tuple(cache.shape)}, "
1184 - f"cache_stride={tuple(cache.stride())}, "
1185 - f"layer_spec={type(layer_spec).__name__}, "
1186 - f"backend={self.backend_name}, "
1187 - "all_backends="
1188 - f"{[backend.get_name() for backend in self.attn_backends]}, "
1189 - f"kv_cache_layout={self.kv_cache_layout}"
1190 - )
1158 + if not is_mla_region:
1159 + if tensor_size_bytes is None:
1160 + tensor_size_bytes = curr_tensor_size_bytes
1161 + assert tensor_size_bytes == curr_tensor_size_bytes, (
1162 + "All non-MLA kv cache tensors must have the same size"
1163 + )
1164
1192 - # Need to make sure the device ID is non-negative for NIXL,
1193 - # Torch uses -1 to indicate CPU tensors.
1194 - self.device_id = max(cache.get_device(), 0)
1195 - caches_data.append(
1196 - (base_addr, curr_tensor_size_bytes, self.device_id, "")
1165 + # When there's a mismatch between kbs<>bs, we rely on HMA to ensure
1166 + # caches are either [NB, PS] or [NB*r, PS/r] where r is bs/kbs.
1167 + if (
1168 + self._physical_blocks_per_logical_kv_block == 1
1169 + and cache.shape[0] != num_blocks
1170 + ):
1171 + raise AssertionError(
1172 + "All kv cache tensors must have the same number of "
1173 + f"blocks; layer={layer_name}, "
1174 + f"expected_num_blocks={num_blocks}, "
1175 + f"cache_shape={tuple(cache.shape)}, "
1176 + f"cache_stride={tuple(cache.stride())}, "
1177 + f"layer_spec={type(layer_spec).__name__}, "
1178 + f"backend={self.backend_name}, "
1179 + "all_backends="
1180 + f"{[backend.get_name() for backend in self.attn_backends]}, "
1181 + f"kv_cache_layout={self.kv_cache_layout}"
1182 )
1183
1184 + # Need to make sure the device ID is non-negative for NIXL,
1185 + # Torch uses -1 to indicate CPU tensors.
1186 + self.device_id = max(cache.get_device(), 0)
1187 + caches_data.append((base_addr, curr_tensor_size_bytes, self.device_id, ""))
1188 +
1189 logger.debug(
1190 "Different block lengths collected: %s", set(self.block_len_per_layer)
1191 )
vllm/distributed/kv_transfer/kv_connector/v1/offloading/worker.py
+7 -20
@@ -56,9 +56,7 @@ class OffloadingConnectorWorker:
56 def _init_worker(self, kv_caches: CanonicalKVCaches) -> None:
57 self.worker = self.spec.get_worker(kv_caches)
58
59 - def register_kv_caches(
60 - self, kv_caches: dict[str, torch.Tensor | list[torch.Tensor]]
61 - ):
59 + def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
60 kv_cache_config = self.kv_cache_config
61 num_blocks = kv_cache_config.num_blocks
62
@@ -120,24 +118,13 @@ class OffloadingConnectorWorker:
118 )
119
120 elif isinstance(layer_kv_cache_spec, MambaSpec):
123 - state_tensors = kv_caches[layer_name]
124 - assert isinstance(state_tensors, list)
125 -
126 - # re-construct the raw (num_blocks, page_size) tensor
127 - # from the first state tensor
128 - assert len(state_tensors) > 0
129 - first_state_tensor = state_tensors[0]
130 - assert first_state_tensor.storage_offset() == 0
131 - tensor = (
132 - torch.tensor(
133 - [],
134 - dtype=torch.int8,
135 - device=first_state_tensor.device,
136 - )
137 - .set_(first_state_tensor.untyped_storage())
138 - .view((num_blocks, layer_kv_cache_spec.page_size_bytes))
121 + layer_kv_cache = kv_caches[layer_name]
122 + assert layer_kv_cache.dtype == torch.int8
123 + tensors_per_block[layer_name] = (
124 + layer_kv_cache.view(
125 + num_blocks, layer_kv_cache_spec.page_size_bytes
126 + ),
127 )
140 - tensors_per_block[layer_name] = (tensor,)
128
129 page_size_bytes[layer_name] = layer_kv_cache_spec.page_size_bytes
130 unpadded_page_size_bytes[layer_name] = replace(
vllm/model_executor/layers/attention_layer_base.py
+10
@@ -4,6 +4,8 @@
4
5 from abc import ABC, abstractmethod
6
7 +import torch
8 +
9 from vllm.config import VllmConfig
10 from vllm.v1.attention.backend import AttentionBackend, AttentionImpl
11 from vllm.v1.kv_cache_interface import KVCacheSpec
@@ -21,6 +23,14 @@ class AttentionLayerBase(ABC):
23 impl: "AttentionImpl"
24 supports_dcp: bool = True
25
26 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
27 + """Bind the allocated KV cache tensor to this layer.
28 +
29 + The default stores the cache view as-is; subclasses (e.g. Mamba)
30 + override this to unpack the raw buffer into per-state views.
31 + """
32 + self.kv_cache = kv_cache
33 +
34 @abstractmethod
35 def get_attn_backend(self) -> type[AttentionBackend]:
36 """Get the attention backend class for this layer."""
vllm/model_executor/layers/mamba/abstract.py
+18
@@ -2,11 +2,13 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 from abc import abstractmethod
4 from collections.abc import Iterable
5 +from math import prod
6
7 import torch
8
9 from vllm.config import VllmConfig
10 from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
11 +from vllm.utils.torch_utils import get_dtype_size
12 from vllm.v1.attention.backend import AttentionBackend
13 from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
14 from vllm.v1.attention.selector import get_mamba_attn_backend
@@ -24,6 +26,22 @@ class MambaBase(AttentionLayerBase):
26 kv_cache: tuple[torch.Tensor, ...]
27 supports_dcp: bool = False
28
29 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
30 + """Unpack a raw ``[B, 1, 1, C]`` int8 page view into per-state views.
31 +
32 + Each block's ``C`` bytes hold the layer's states (e.g. conv, ssm)
33 + packed contiguously; slice them out and reinterpret per dtype/shape.
34 + """
35 + pages = kv_cache.squeeze(dim=(1, 2))
36 + states: list[torch.Tensor] = []
37 + offset = 0
38 + for shape, dtype in zip(self.get_state_shape(), self.get_state_dtype()):
39 + nbytes = prod(shape) * get_dtype_size(dtype)
40 + state = pages[:, offset : offset + nbytes].view(dtype)
41 + states.append(state.view(-1, *shape))
42 + offset += nbytes
43 + self.kv_cache = tuple(states)
44 +
45 @abstractmethod
46 def get_state_shape(self) -> Iterable[tuple[int, ...]]:
47 """
vllm/v1/worker/gpu/attn_utils.py
+11 -87
@@ -262,7 +262,7 @@ def _reshape_kv_cache(
262 kv_cache_config: "KVCacheConfig | None" = None,
263 ) -> dict[str, Any]:
264 kv_caches: dict[str, Any] = {}
265 - has_attn, has_mamba = False, False
265 + has_attn = False
266
267 layer_packing: dict[str, tuple[int, int]] = {}
268 if kv_cache_config is not None:
@@ -340,38 +340,21 @@ def _reshape_kv_cache(
340 )
341
342 elif isinstance(kv_cache_spec, MambaSpec):
343 - has_mamba = True
344 - state_tensors = []
345 - storage_offset_bytes = 0
346 - for shape, dtype in zip(kv_cache_spec.shapes, kv_cache_spec.dtypes):
347 - dtype_size = get_dtype_size(dtype)
348 - num_element_per_page = kv_cache_spec.page_size_bytes // dtype_size
349 - target_shape = (num_blocks, *shape)
350 - stride = torch.empty(target_shape).stride()
351 - target_stride = (num_element_per_page, *stride[1:])
352 - assert storage_offset_bytes % dtype_size == 0
353 - tensor = torch.as_strided(
354 - kv_raw_tensor.view(dtype),
355 - size=target_shape,
356 - stride=target_stride,
357 - storage_offset=storage_offset_bytes // dtype_size,
358 - )
359 - state_tensors.append(tensor)
360 - storage_offset_bytes += stride[0] * dtype_size
361 - kv_caches[layer_name] = state_tensors
343 + page_size_bytes = kv_cache_spec.page_size_bytes
344 + # Hold a single contiguous [num_blocks, 1, 1, page_size_bytes]
345 + # int8 page view per layer; the layer's bind_kv_cache unpacks
346 + # each block's bytes into its conv/ssm state views. Keeping
347 + # one tensor per layer lets the KV connector register it
348 + # without special-casing Mamba.
349 + kv_caches[layer_name] = kv_raw_tensor[
350 + : num_blocks * page_size_bytes
351 + ].view(num_blocks, 1, 1, page_size_bytes)
352 else:
353 raise NotImplementedError(
354 f"Unsupported KV cache spec type: {type(kv_cache_spec)}"
355 )
356
367 - if has_attn and has_mamba:
368 - _update_hybrid_attention_layout(
369 - attn_groups=attn_groups,
370 - kv_caches=kv_caches,
371 - kernel_block_sizes=kernel_block_sizes,
372 - cache_dtype=cache_dtype,
373 - )
374 - elif has_attn and kv_cache_config is not None:
357 + if has_attn and kv_cache_config is not None:
358 _align_mixed_attention_kv_cache_views(
359 attn_groups=attn_groups,
360 kv_caches=kv_caches,
@@ -458,65 +441,6 @@ def _restride_blocks_first_kv_cache_to_kv_first_storage(
441 )
442
443
461 -def _update_hybrid_attention_layout(
462 - attn_groups: Iterable[AttentionGroup],
463 - kv_caches: dict[str, Any],
464 - kernel_block_sizes: list[int],
465 - cache_dtype: str,
466 -) -> None:
467 - for group in attn_groups:
468 - if group.kv_cache_group_id >= len(kernel_block_sizes):
469 - continue
470 -
471 - kv_cache_spec = group.kv_cache_spec
472 - if not isinstance(kv_cache_spec, AttentionSpec):
473 - continue
474 - # Mirror the per-layer dtype selection used when building the shape
475 - # above. The block-dim index is dtype-independent for current backends
476 - # (quantization only changes the last dim), so this is a no-op today,
477 - # but it keeps both call sites consistent for skip layers.
478 - layer_cache_dtype = (
479 - "auto"
480 - if kv_cache_spec.kv_quant_mode == KVQuantMode.NONE
481 - and not isinstance(kv_cache_spec, TQFullAttentionSpec)
482 - else cache_dtype
483 - )
484 - block_dim = group.backend.get_kv_cache_block_dim(
485 - kernel_block_sizes[group.kv_cache_group_id],
486 - kv_cache_spec.num_kv_heads,
487 - kv_cache_spec.head_size,
488 - cache_dtype_str=layer_cache_dtype,
489 - )
490 - # if the first dim of the kvcache's layout is already num_blocks, continue
491 - if block_dim == 0:
492 - continue
493 -
494 - assert block_dim == 1, (
495 - "Expected the dim `num_blocks` at the second dim when updating"
496 - " the kvcache's layout of full attention layer"
497 - )
498 -
499 - for layer_name in group.layer_names:
500 - if layer_name not in kv_caches:
501 - # Shared layer — will be aliased to its target after this pass.
502 - continue
503 -
504 - kv_cache = kv_caches[layer_name]
505 - if kv_cache.shape[0] == 2:
506 - assert kv_cache.shape[1] != 2, (
507 - f"Cannot determine layout for tensor of shape {kv_cache.shape}"
508 - )
509 - hidden_size = kv_cache.shape[2:].numel()
510 - kv_cache.as_strided_(
511 - size=kv_cache.shape,
512 - stride=(
513 - hidden_size,
514 - 2 * hidden_size,
515 - *kv_cache.stride()[2:],
516 - ),
517 - )
518 -
519 -
444 def init_kv_cache(
445 runner_kv_caches: list[torch.Tensor | list[torch.Tensor]],
446 forward_context: dict[str, Any],
vllm/v1/worker/gpu_model_runner.py
+9 -22
@@ -126,7 +126,6 @@ from vllm.utils.torch_utils import (
126 PIN_MEMORY,
127 async_tensor_h2d,
128 current_stream,
129 - get_dtype_size,
129 is_quantized_kv_cache,
130 kv_cache_dtype_str_to_dtype,
131 )
@@ -7381,27 +7380,15 @@ class GPUModelRunner(
7380 elif isinstance(kv_cache_spec, MambaSpec):
7381 has_mamba = True
7382 raw_tensor = kv_cache_raw_tensors[layer_name]
7384 - state_tensors = []
7385 - storage_offset_bytes = 0
7386 - for shape, dtype in zip(kv_cache_spec.shapes, kv_cache_spec.dtypes):
7387 - dtype_size = get_dtype_size(dtype)
7388 - num_element_per_page = (
7389 - kv_cache_spec.page_size_bytes // dtype_size
7390 - )
7391 - target_shape = (num_blocks, *shape)
7392 - stride = torch.empty(target_shape).stride()
7393 - target_stride = (num_element_per_page, *stride[1:])
7394 - assert storage_offset_bytes % dtype_size == 0
7395 - tensor = torch.as_strided(
7396 - raw_tensor.view(dtype),
7397 - size=target_shape,
7398 - stride=target_stride,
7399 - storage_offset=storage_offset_bytes // dtype_size,
7400 - )
7401 - state_tensors.append(tensor)
7402 - storage_offset_bytes += stride[0] * dtype_size
7403 -
7404 - kv_caches[layer_name] = state_tensors
7383 + page_size_bytes = kv_cache_spec.page_size_bytes
7384 + # Hold a single contiguous [num_blocks, 1, 1, page_size_bytes]
7385 + # int8 page view per layer; the layer's bind_kv_cache unpacks
7386 + # each block's bytes into its conv/ssm state views. Keeping
7387 + # one tensor per layer lets the KV connector register it
7388 + # without special-casing Mamba.
7389 + kv_caches[layer_name] = raw_tensor[
7390 + : num_blocks * page_size_bytes
7391 + ].view(num_blocks, 1, 1, page_size_bytes)
7392 else:
7393 raise NotImplementedError
7394
vllm/v1/worker/utils.py
+5 -2
@@ -533,9 +533,12 @@ def bind_kv_cache(
533 for layer_name in layer_names:
534 runner_kv_caches.append(kv_caches[layer_name])
535
536 - # Bind kv_caches to forward context
536 + # Bind kv_caches to forward context. Each layer's bind_kv_cache unpacks
537 + # its raw allocation into the per-layer view(s) it needs (e.g. Mamba
538 + # splits conv/ssm), so the kv_caches dict can hold a single tensor per
539 + # layer for the KV connector to register.
540 for layer_name, kv_cache in kv_caches.items():
538 - forward_context[layer_name].kv_cache = kv_cache
541 + forward_context[layer_name].bind_kv_cache(kv_cache)
542
543
544 def copy_kv_cache_blocks_inplace(