[6/N][KV-Cache Layout Refactor] Standardize KV cache layout (#51718)

Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com>

Lucas Wilkinson committed Aug 21, 2026 at 20:47 UTC 8bdc70ec7b379279ec0152343239c2d50aced687
150 files changed +3993 -5802
.buildkite/test_areas/disaggregated.yaml
+3
@@ -243,6 +243,9 @@ steps:
243 DECODE_BLOCK_SIZE: "256"
244 MODEL_NAMES: "deepseek-ai/DeepSeek-V4-Flash"
245 VLLM_SERVE_EXTRA_ARGS: "--trust-remote-code,--kv-cache-dtype,fp8"
246 + # The V4 indexer only supports block-outermost layouts; let it pick.
247 + PREFILLER_KV_LAYOUT: ""
248 + DECODER_KV_LAYOUT: ""
249 commands:
250 - bash /vllm-workspace/.buildkite/scripts/install-kv-connectors.sh
251 - bash v1/kv_connector/nixl_integration/run_accuracy_test.sh
benchmarks/attention_benchmarks/runner.py
+47 -48
@@ -12,6 +12,7 @@ import logging
12 import statistics
13 import types
14 from contextlib import contextmanager
15 +from math import prod
16
17 import torch
18 from batch_spec import parse_batch_spec, reorder_for_flashinfer
@@ -35,12 +36,20 @@ from vllm.config import (
36 VllmConfig,
37 set_current_vllm_config,
38 )
39 +from vllm.platforms import current_platform
40 from vllm.v1.attention.backends.utils import (
41 CommonAttentionMetadata,
40 - get_kv_cache_layout,
41 - set_kv_cache_layout,
42 + get_supported_kv_cache_layouts,
43 + resolve_kv_cache_layout,
44 +)
45 +from vllm.v1.kv_cache_interface import (
46 + FullAttentionSpec,
47 + KVCacheLayout,
48 + KVCacheTensor,
49 + compute_layer_kv_cache_shape_bytes,
50 + compute_layout_strides,
51 + create_kv_cache_views,
52 )
43 -from vllm.v1.kv_cache_interface import FullAttentionSpec
53
54 # ============================================================================
55 # Backend Configuration
@@ -340,49 +349,42 @@ def _create_kv_cache(
349 backend_class,
350 device: torch.device,
351 dtype: torch.dtype,
352 + layout: KVCacheLayout,
353 ) -> list:
344 - """Create KV cache tensors for all layers using the backend's methods.
345 -
346 - Uses the backend's get_kv_cache_shape() and get_kv_cache_stride_order()
347 - to create the cache with the correct shape and memory layout.
348 - """
349 - # Get the logical shape from the backend
350 - cache_shape = backend_class.get_kv_cache_shape(
351 - num_blocks=max_num_blocks,
354 + """Create KV cache tensors for all layers using the standard allocator."""
355 + if config.kv_cache_dtype.startswith("fp8"):
356 + cache_dtype = current_platform.fp8_dtype()
357 + else:
358 + cache_dtype = dtype
359 + spec = FullAttentionSpec(
360 block_size=config.block_size,
361 num_kv_heads=config.num_kv_heads,
362 head_size=config.head_dim,
363 + dtype=cache_dtype,
364 + )
365 + # Apply the backend's page customization, as the worker does for the real spec.
366 + spec = backend_class.customize_spec(spec)
367 + total_bytes = (
368 + prod(compute_layer_kv_cache_shape_bytes(spec, max_num_blocks))
369 + * config.num_layers
370 + )
371 + buf = torch.zeros(total_bytes, device=device, dtype=torch.int8)
372 + layer_stride, block_stride, _, _, _ = compute_layout_strides(
373 + spec, max_num_blocks, config.num_layers, layout
374 + )
375 + tensor = KVCacheTensor(
376 + size=total_bytes,
377 + layers=[str(i) for i in range(config.num_layers)],
378 + layer_stride=layer_stride,
379 + block_stride=block_stride,
380 + )
381 + return create_kv_cache_views(
382 + buf,
383 + spec,
384 + max_num_blocks,
385 + layout,
386 + tensor,
387 )
356 -
357 - # Get the stride order for custom memory layout
358 - try:
359 - stride_order = backend_class.get_kv_cache_stride_order()
360 - assert len(stride_order) == len(cache_shape)
361 - except (AttributeError, NotImplementedError):
362 - stride_order = tuple(range(len(cache_shape)))
363 -
364 - # Permute shape to physical layout order
365 - physical_shape = tuple(cache_shape[i] for i in stride_order)
366 -
367 - # Compute inverse permutation to get back to logical view
368 - inv_order = [stride_order.index(i) for i in range(len(stride_order))]
369 -
370 - # Use fp8 dtype for cache when requested.
371 - cache_dtype = dtype
372 - if config.kv_cache_dtype == "fp8":
373 - from vllm.platforms import current_platform
374 -
375 - cache_dtype = current_platform.fp8_dtype()
376 -
377 - cache_list = []
378 - for _ in range(config.num_layers):
379 - # Allocate in physical layout order (contiguous in memory)
380 - cache = torch.zeros(*physical_shape, device=device, dtype=cache_dtype)
381 - # Permute to logical view
382 - cache = cache.permute(*inv_order)
383 - cache_list.append(cache)
384 -
385 - return cache_list
388
389
390 # ============================================================================
@@ -499,13 +501,10 @@ def run_attention_benchmark(config: BenchmarkConfig) -> BenchmarkResult:
501 backend_class, impl, layer = _create_backend_impl(
502 backend_cfg, config, device, dtype
503 )
502 -
504 # Set KV cache layout if the backend requires a specific one
504 - # (e.g., FlashInfer requires HND on SM100/Blackwell for TRTLLM attention)
505 - required_layout = backend_class.get_required_kv_cache_layout()
506 - if required_layout is not None:
507 - set_kv_cache_layout(required_layout)
508 - get_kv_cache_layout.cache_clear()
505 + # (e.g., FlashInfer requires LBHNC on SM100/Blackwell for TRTLLM attention)
506 + supported = get_supported_kv_cache_layouts([backend_class])
507 + layout = resolve_kv_cache_layout(vllm_config, [[m.name for m in supported]])
508
509 common_metadata = _build_common_attn_metadata(
510 q_lens, kv_lens, config.block_size, device
@@ -542,7 +541,7 @@ def run_attention_benchmark(config: BenchmarkConfig) -> BenchmarkResult:
541 )
542
543 cache_list = _create_kv_cache(
545 - config, max_num_blocks, backend_class, device, dtype
544 + config, max_num_blocks, backend_class, device, dtype, layout
545 )
546
547 timing_stats, mem_stats = _run_single_benchmark(
docs/assets/contributing/dockerfile-stages-dependency.png
Binary files a/docs/assets/contributing/dockerfile-stages-dependency.png and b/docs/assets/contributing/dockerfile-stages-dependency.png differ
docs/features/mooncake_store_connector_usage.md
-1
@@ -246,7 +246,6 @@ Strict isolation requires a Mooncake master started with `--enable_multi_tenants
246
247 - `load_async` (bool): Enable asynchronous loading for better compute-I/O overlap. Default: `true`.
248 - `lookup_async` (bool): Run the external prefix-cache lookup on a background thread so it never blocks the scheduler step. The request is held until the in-flight lookup completes, then resumed on a later step. Default: `false`.
249 -- `enable_cross_layers_blocks` (bool): Enable cross-layer block packing for reduced store operations. Default: `false`.
249 - `lookup_rpc_port` (int): Custom port for the ZMQ lookup RPC socket. Default: `0`.
250 - `cache_prefix` (str): Namespace prepended to every store key. Lets separate deployments share one Mooncake master without polluting each other — instances configured with different prefixes never see each other's cached blocks, even for identical prompts. All instances that should share a prefix cache must use the same value. Default: `""` (no prefix; keys are byte-identical to the unprefixed format).
251 - `save_decode_cache` (bool): Enable offloading decode tokens' KV cache. A `kv_consumer` does not save during prefill; when decode starts, it fills any missing block-aligned prompt prefix before appending completed decode blocks. Default: `false`.
docs/features/nixl_connector_compatibility.md
+4 -4
@@ -61,7 +61,7 @@ th:not(:first-child) {
61 See [Configuration Notes](#configuration-notes) below for what must match and
62 what may differ.
63
64 -<sup>2</sup> Requires `FLASH_ATTN` or `FLASHINFER` backend **and** `HND` KV cache layout. Enable via `--kv-transfer-config '{"kv_connector_extra_config": {"enable_cross_layers_blocks": "True"}}'`.
64 +<sup>2</sup> Cross-layer contiguity is achieved by using a `BLHNC` layout (set via `VLLM_KV_CACHE_LAYOUT=BLHNC`).
65
66 <sup>3</sup> Supported only when HMA is **not** required (i.e., non-hybrid models). Block IDs are remapped automatically. Only P block size < D block size is supported.
67
@@ -100,9 +100,9 @@ By default, a **compatibility hash** is checked during handshake. P and D instan
100
101 ### KV cache layout
102
103 -- NixlConnector defaults to **`HND`** layout for optimal transfer performance (non-MLA models).
104 -- `NHD` layout is supported but does **not** allow heterogeneous TP head splitting.
105 -- Experimental `HND` ↔ `NHD` permute: enable via `--kv-transfer-config '{"enable_permute_local_kv": true}'`. Not supported with HMA.
103 +- NixlConnector defaults to **`LBHNC`** (head-major, formerly `HND`) layout for optimal transfer performance (non-MLA models).
104 +- `LBNHC` (token-major, formerly `NHD`) layout is supported but does **not** allow heterogeneous TP head splitting.
105 +- Experimental `LBHNC` ↔ `LBNHC` permute: enable via `--kv-transfer-config '{"enable_permute_local_kv": true}'`. Not supported with HMA.
106
107 ### Quantized KV cache
108
docs/features/nixl_connector_usage.md
+1 -10
@@ -404,21 +404,12 @@ GB-series GPUs support multi-node NVLink. NIXL supports this capability, but KVC
404
405 ### Heterogeneous KV Layout support
406
407 -Support use case: Prefill with 'HND' and decode with 'NHD' with experimental configuration
407 +Support use case: Prefill with `LBHNC` and decode with `LBNHC` with experimental configuration
408
409 ```bash
410 --kv-transfer-config '{..., "enable_permute_local_kv":"True"}'
411 ```
412
413 -### Cross layers blocks
414 -
415 -By default, this feature is disabled. On attention backends that support this feature, each logical block is contiguous in physical memory. This reduces the number of buffers that need to be transferred.
416 -To enable this feature:
417 -
418 -```bash
419 ---kv-transfer-config '{..., "kv_connector_extra_config": {"enable_cross_layers_blocks": "True"}}'
420 -```
421 -
413 ## Metrics Reference
414
415 vLLM periodically logs a `KV Transfer metrics` line summarising NIXL transfer
rust/Cargo.lock
+9 -9
@@ -2340,15 +2340,6 @@ version = "0.4.29"
2340 source = "registry+https://github.com/rust-lang/crates.io-index"
2341 checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
2342
2343 -[[package]]
2344 -name = "lz4_flex"
2345 -version = "0.14.0"
2346 -source = "registry+https://github.com/rust-lang/crates.io-index"
2347 -checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
2348 -dependencies = [
2349 - "twox-hash",
2350 -]
2351 -
2343 [[package]]
2344 name = "logos"
2345 version = "0.15.1"
@@ -2415,6 +2406,15 @@ dependencies = [
2406 "logos-codegen 0.16.1",
2407 ]
2408
2409 +[[package]]
2410 +name = "lz4_flex"
2411 +version = "0.14.0"
2412 +source = "registry+https://github.com/rust-lang/crates.io-index"
2413 +checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
2414 +dependencies = [
2415 + "twox-hash",
2416 +]
2417 +
2418 [[package]]
2419 name = "macro_rules_attribute"
2420 version = "0.2.2"
tests/compile/passes/test_fusion_attn.py
+32 -24
@@ -7,7 +7,11 @@ import torch._dynamo
7
8 from tests.compile.backend import LazyInitPass, TestBackend
9 from tests.utils import TestFP8Layer, flat_product
10 -from tests.v1.attention.utils import BatchSpec, create_common_attn_metadata
10 +from tests.v1.attention.utils import (
11 + BatchSpec,
12 + create_common_attn_metadata,
13 + dense_kv_cache_views,
14 +)
15 from vllm._custom_ops import cutlass_scaled_fp4_mm, scaled_fp4_quant
16 from vllm.compilation.passes.fusion.attn_quant_fusion import (
17 ATTN_OP,
@@ -39,7 +43,14 @@ from vllm.platforms import current_platform
43 from vllm.utils.flashinfer import has_flashinfer
44 from vllm.v1.attention.backend import AttentionMetadata
45 from vllm.v1.attention.backends.registry import AttentionBackendEnum
42 -from vllm.v1.kv_cache_interface import AttentionSpec, get_kv_quant_mode
46 +from vllm.v1.attention.backends.utils import (
47 + get_supported_kv_cache_layouts,
48 + resolve_kv_cache_layout,
49 +)
50 +from vllm.v1.kv_cache_interface import (
51 + AttentionSpec,
52 + get_kv_quant_mode,
53 +)
54
55 DEVICE_TYPE = current_platform.device_type
56 FP8_DTYPE = current_platform.fp8_dtype()
@@ -108,32 +119,29 @@ class AttentionQuantPatternModel(torch.nn.Module):
119 max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
120 num_blocks = batch_size * max_blocks
121
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,
115 - self.block_size,
116 - self.num_kv_heads,
117 - self.head_size,
118 - cache_dtype_str=self.attn.kv_cache_dtype,
122 + spec = AttentionSpec(
123 + block_size=self.block_size,
124 + num_kv_heads=self.num_kv_heads,
125 + head_size=self.head_size,
126 + dtype=self.attn.kv_cache_torch_dtype,
127 + kv_quant_mode=get_kv_quant_mode(self.attn.kv_cache_dtype),
128 + )
129 + supported = get_supported_kv_cache_layouts([self.attn.attn_backend])
130 + layout = resolve_kv_cache_layout(
131 + self.vllm_config, [[m.name for m in supported]]
132 )
120 - try:
121 - kv_cache_stride_order = attn_backend.get_kv_cache_stride_order()
122 - except (AttributeError, NotImplementedError):
123 - kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
124 -
125 - kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
126 - inv_order = [
127 - kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order))
128 - ]
129 -
130 - # Create dummy KV cache
133 raw_tensor = torch.zeros(
132 - kv_cache_shape,
133 - dtype=self.attn.kv_cache_torch_dtype,
134 + num_blocks * spec.page_size_bytes,
135 + dtype=torch.int8,
136 device=self.device,
137 )
136 - kv_cache = raw_tensor.permute(*inv_order)
138 + kv_cache = dense_kv_cache_views(
139 + raw_tensor,
140 + spec,
141 + num_blocks,
142 + num_layers=1,
143 + layout=layout,
144 + )[0]
145
146 self.attn.kv_cache = kv_cache
147
tests/compile/passes/test_mla_attn_quant_fusion.py
+6 -19
@@ -150,27 +150,14 @@ class MLAAttentionQuantPatternModel(torch.nn.Module):
150 max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
151 num_blocks = batch_size * max_blocks
152
153 - # MLA KV cache is 3D: (num_blocks, block_size, head_size)
154 - attn_backend = self.mla_attn.attn_backend
155 - kv_cache_shape = attn_backend.get_kv_cache_shape(
156 - num_blocks, self.block_size, 1, self.head_size
157 - )
158 - try:
159 - kv_cache_stride_order = attn_backend.get_kv_cache_stride_order()
160 - except (AttributeError, NotImplementedError):
161 - kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
162 -
163 - ordered_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
164 - inv_order = [
165 - kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order))
166 - ]
167 -
168 - raw_tensor = torch.zeros(
169 - ordered_shape, dtype=self.kv_cache_dtype, device=self.device
153 + # MLA KV cache is 4D: (num_blocks, num_heads=1, block_size, head_size)
154 + kv_cache = torch.zeros(
155 + (num_blocks, 1, self.block_size, self.head_size),
156 + dtype=self.kv_cache_dtype,
157 + device=self.device,
158 )
171 - kv_cache = raw_tensor.permute(*inv_order)
159
173 - self.mla_attn.kv_cache = kv_cache
160 + self.mla_attn.bind_kv_cache(kv_cache)
161
162 self.attn_metadata = self.builder.build(
163 common_prefix_len=0, common_attn_metadata=common_attn_metadata
tests/compile/passes/test_mla_rope_kvcache_cat_fusion.py
+4 -19
@@ -165,29 +165,14 @@ class MLARoPEKVCacheCatTestModel(torch.nn.Module):
165 max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
166 num_blocks = batch_size * max_blocks
167
168 - # Fetch the attention backend and kv cache shape and stride order
169 - kv_cache_shape = self.attn_backend.get_kv_cache_shape(
170 - num_blocks, self.block_size, self.num_kv_heads, self.head_size
171 - )
172 - try:
173 - kv_cache_stride_order = self.attn_backend.get_kv_cache_stride_order()
174 - except (AttributeError, NotImplementedError):
175 - kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
176 -
177 - kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
178 - inv_order = [
179 - kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order))
180 - ]
181 -
182 - raw_tensor = torch.zeros(
183 - num_blocks * self.block_size * self.num_kv_heads * self.head_size,
168 + # MLA KV cache is 4D: (num_blocks, num_heads=1, block_size, head_size)
169 + kv_cache = torch.zeros(
170 + (num_blocks, 1, self.block_size, self.head_size),
171 dtype=self.kv_cache_dtype,
172 device=self.device,
173 )
187 - raw_tensor = raw_tensor.view(kv_cache_shape)
188 - kv_cache = raw_tensor.permute(*inv_order)
174
190 - self.mla_attn.kv_cache = kv_cache
175 + self.mla_attn.bind_kv_cache(kv_cache)
176
177 # Build attn metadata
178 attn_metadata = self.builder.build(
tests/compile/passes/test_rocm_aiter_qk_norm_rope_kvcache_fusion.py
+34 -33
@@ -10,7 +10,11 @@ from packaging.version import Version
10
11 import vllm.config
12 from tests.compile.backend import TestBackend
13 -from tests.v1.attention.utils import BatchSpec, create_common_attn_metadata
13 +from tests.v1.attention.utils import (
14 + BatchSpec,
15 + create_common_attn_metadata,
16 + dense_kv_cache_views,
17 +)
18 from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops
19 from vllm.compilation.passes.fusion.matcher_utils import ROTARY_OP
20 from vllm.compilation.passes.fusion.qk_norm_rope_kvcache_fusion import (
@@ -40,7 +44,7 @@ from vllm.v1.attention.backend import (
44 CommonAttentionMetadata,
45 )
46 from vllm.v1.attention.backends.registry import AttentionBackendEnum
43 -from vllm.v1.kv_cache_interface import AttentionSpec
47 +from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheLayout
48
49 pytestmark = pytest.mark.skip_global_cleanup
50
@@ -147,20 +151,25 @@ class QKNormRoPEKVCacheTestModel(torch.nn.Module):
151 self.attn._k_scale = self.attn._k_scale.to(device)
152 self.attn._v_scale = self.attn._v_scale.to(device)
153
150 - self.builder = self.attn.attn_backend.get_builder_cls()(
151 - kv_cache_spec=AttentionSpec(
154 + # Mirror the worker spec-collection loop: the backend publishes the
155 + # packing it needs.
156 + self.kv_cache_spec = self.attn.attn_backend.customize_spec(
157 + AttentionSpec(
158 block_size=self.block_size,
159 num_kv_heads=self.num_kv_heads,
160 head_size=head_size,
161 dtype=self.kv_cache_dtype,
156 - ),
162 + )
163 + )
164 + self.builder = self.attn.attn_backend.get_builder_cls()(
165 + kv_cache_spec=self.kv_cache_spec,
166 layer_names=[self.attn.layer_name],
167 vllm_config=vllm_config,
168 device=device,
169 )
170
171 def build_attn_metadata(
163 - self, batch_size: int, kv_stride_order: tuple[int, ...] | None = None
172 + self, batch_size: int, layout: KVCacheLayout
173 ) -> CommonAttentionMetadata:
174 batch_spec = BatchSpec(seq_lens=[1] * batch_size, query_lens=[1] * batch_size)
175 common_attn_metadata = create_common_attn_metadata(
@@ -170,27 +179,18 @@ class QKNormRoPEKVCacheTestModel(torch.nn.Module):
179 max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
180 num_blocks = batch_size * max_blocks
181
173 - attn_backend = self.attn.attn_backend
174 - kv_cache_shape = attn_backend.get_kv_cache_shape(
175 - num_blocks, self.block_size, self.num_kv_heads, self.head_size
176 - )
177 - # Caller can force a physical layout; else use the backend's.
178 - if kv_stride_order is None:
179 - try:
180 - kv_stride_order = attn_backend.get_kv_cache_stride_order()
181 - except (AttributeError, NotImplementedError):
182 - kv_stride_order = tuple(range(len(kv_cache_shape)))
183 -
184 - kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_stride_order)
185 - inv_order = [kv_stride_order.index(i) for i in range(len(kv_stride_order))]
186 -
182 raw_tensor = torch.zeros(
188 - 2 * num_blocks * self.block_size * self.num_kv_heads * self.head_size,
189 - dtype=self.kv_cache_dtype,
183 + num_blocks * self.kv_cache_spec.page_size_bytes,
184 + dtype=torch.int8,
185 device=self.device,
186 )
192 - raw_tensor = raw_tensor.view(kv_cache_shape)
193 - kv_cache = raw_tensor.permute(*inv_order)
187 + kv_cache = dense_kv_cache_views(
188 + raw_tensor,
189 + self.kv_cache_spec,
190 + num_blocks,
191 + num_layers=1,
192 + layout=layout,
193 + )[0]
194
195 # Store as a bare tensor (not wrapped in a list) to match production
196 # `bind_kv_cache` behavior. `get_attention_context` returns this
@@ -272,7 +272,7 @@ def _run_qk_norm_rope_kvcache_fusion_test(
272 block_size: int,
273 is_neox: bool,
274 use_shuffle_kv_layout: str,
275 - kv_stride_order: tuple[int, ...],
275 + kv_layout: KVCacheLayout,
276 dtype: torch.dtype,
277 kv_cache_dtype: str,
278 rms_norm_eps: float,
@@ -345,7 +345,7 @@ def _run_qk_norm_rope_kvcache_fusion_test(
345 # Run unfused (eager) forward
346 with set_forward_context(None, vllm_config):
347 forward_context = get_forward_context()
348 - attn_metadata = model.build_attn_metadata(num_tokens, kv_stride_order)
348 + attn_metadata = model.build_attn_metadata(num_tokens, kv_layout)
349 forward_context.slot_mapping = {
350 model.layer_name: attn_metadata.slot_mapping
351 }
@@ -360,7 +360,7 @@ def _run_qk_norm_rope_kvcache_fusion_test(
360 with set_forward_context(None, vllm_config):
361 model_fused = torch.compile(model, backend=backend)
362 forward_context = get_forward_context()
363 - attn_metadata = model_fused.build_attn_metadata(num_tokens, kv_stride_order)
363 + attn_metadata = model_fused.build_attn_metadata(num_tokens, kv_layout)
364 forward_context.slot_mapping = {
365 model.layer_name: attn_metadata.slot_mapping
366 }
@@ -437,10 +437,11 @@ _FUSION_CONFIGS = [
437 )
438 @pytest.mark.parametrize("num_tokens", [5, 2048])
439 @pytest.mark.parametrize(
440 - "kv_stride_order",
441 - # FA/unified use the 4D packed cache (num_blocks, num_kv_heads, block_size,
442 - # 2*head_size); no separate K/V dim to permute.
443 - [pytest.param((0, 1, 2, 3), id="packed_4d")],
440 + "kv_layout",
441 + [
442 + pytest.param(KVCacheLayout.LBHNC, id="head_major"),
443 + pytest.param(KVCacheLayout.LBNHC, id="token_major"),
444 + ],
445 )
446 @pytest.mark.parametrize("enable_aiter_triton_rope", [True, False])
447 @pytest.mark.parametrize("block_size", [16, 32, 64])
@@ -467,7 +468,7 @@ def test_qk_norm_rope_kvcache_fusion(
468 is_neox: bool,
469 attn_backend: AttentionBackendEnum,
470 enable_aiter_triton_rope: bool,
470 - kv_stride_order: tuple[int, ...],
471 + kv_layout: KVCacheLayout,
472 block_size: int,
473 dtype: torch.dtype,
474 kv_cache_dtype: str,
@@ -486,7 +487,7 @@ def test_qk_norm_rope_kvcache_fusion(
487 block_size=block_size,
488 is_neox=is_neox,
489 use_shuffle_kv_layout="0",
489 - kv_stride_order=kv_stride_order,
490 + kv_layout=kv_layout,
491 dtype=dtype,
492 kv_cache_dtype=kv_cache_dtype,
493 rms_norm_eps=rms_norm_eps,
tests/compile/passes/test_rope_kvcache_fusion.py
+36 -22
@@ -7,7 +7,11 @@ from torch._higher_order_ops import auto_functionalized
7
8 import vllm.config
9 from tests.compile.backend import TestBackend
10 -from tests.v1.attention.utils import BatchSpec, create_common_attn_metadata
10 +from tests.v1.attention.utils import (
11 + BatchSpec,
12 + create_common_attn_metadata,
13 + dense_kv_cache_views,
14 +)
15 from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops
16 from vllm.compilation.passes.fusion import rope_kvcache_fusion
17 from vllm.compilation.passes.fusion.matcher_utils import ROTARY_OP
@@ -38,6 +42,10 @@ from vllm.v1.attention.backend import (
42 CommonAttentionMetadata,
43 )
44 from vllm.v1.attention.backends.registry import AttentionBackendEnum
45 +from vllm.v1.kv_cache_interface import (
46 + FullAttentionSpec,
47 + KVCacheLayout,
48 +)
49
50 INDEX_SELECT_OP = torch.ops.aten.index.Tensor
51 VLLM_UNIFIED_KV_CACHE_UPDATE_OP = torch.ops.vllm.unified_kv_cache_update
@@ -149,9 +157,20 @@ class QKRoPEKVCacheTestModel(torch.nn.Module):
157 FP8_DTYPE if kv_cache_dtype_str.startswith("fp8") else self.dtype
158 )
159
160 + # Mirror the worker spec-collection loop: the backend publishes its
161 + # packing (e.g. ROCM_ATTN's separate K/V head groups).
162 + self.kv_cache_spec = self.attn_backend.customize_spec(
163 + FullAttentionSpec(
164 + block_size=self.block_size,
165 + num_kv_heads=self.num_kv_heads,
166 + head_size=self.head_size,
167 + dtype=self.kv_cache_dtype,
168 + )
169 + )
170 +
171 # Initialize attn MetadataBuilder
172 self.builder = self.attn_backend.get_builder_cls()(
154 - kv_cache_spec=self.attn.get_kv_cache_spec(vllm_config),
173 + kv_cache_spec=self.kv_cache_spec,
174 layer_names=[self.attn.layer_name],
175 vllm_config=vllm_config,
176 device=device,
@@ -168,30 +187,25 @@ class QKRoPEKVCacheTestModel(torch.nn.Module):
187 max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
188 num_blocks = batch_size * max_blocks
189
171 - # Fetch the attention backend and kv cache shape and stride order
172 - kv_cache_shape = self.attn_backend.get_kv_cache_shape(
173 - num_blocks, self.block_size, self.num_kv_heads, self.head_size
174 - )
175 - try:
176 - kv_cache_stride_order = self.attn_backend.get_kv_cache_stride_order()
177 - except (AttributeError, NotImplementedError):
178 - kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
179 -
180 - kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
181 - inv_order = [
182 - kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order))
183 - ]
190 + # A backend's preferred supported layout wins over the default, mirroring
191 + # selector-time resolution (e.g. ROCM_ATTN's head groups force LHBNC).
192 + layout = KVCacheLayout.LBNHC
193 + supported = self.attn_backend.supported_kv_cache_layouts()
194 + if supported:
195 + layout = supported[0]
196
185 - # Create dummy KV cache
197 raw_tensor = torch.zeros(
187 - 2 * num_blocks * self.block_size * self.num_kv_heads * self.head_size,
188 - dtype=self.kv_cache_dtype,
198 + num_blocks * self.kv_cache_spec.page_size_bytes,
199 + dtype=torch.int8,
200 device=self.device,
201 )
191 - raw_tensor = raw_tensor.view(kv_cache_shape)
192 - kv_cache = raw_tensor.permute(*inv_order)
193 -
194 - self.attn.kv_cache = kv_cache
202 + self.attn.kv_cache = dense_kv_cache_views(
203 + raw_tensor,
204 + self.kv_cache_spec,
205 + num_blocks,
206 + num_layers=1,
207 + layout=layout,
208 + )[0]
209
210 # Build attn metadata
211 attn_metadata = self.builder.build(
tests/distributed/test_kvlayout.py
+4 -4
@@ -21,7 +21,7 @@ def test_get_kv_connector_cache_layout_without_kv_connector():
21 with set_current_vllm_config(vllm_config):
22 # Test with default settings
23 layout = get_kv_connector_cache_layout()
24 - assert layout == "NHD"
24 + assert layout is None
25
26
27 def test_get_kv_connector_cache_layout_with_lmcache_connector():
@@ -35,7 +35,7 @@ def test_get_kv_connector_cache_layout_with_lmcache_connector():
35 with set_current_vllm_config(vllm_config):
36 # Test with default settings
37 layout = get_kv_connector_cache_layout()
38 - assert layout == "NHD"
38 + assert layout is None
39
40
41 def test_get_kv_connector_cache_layout_with_nixl_connector():
@@ -52,7 +52,7 @@ def test_get_kv_connector_cache_layout_with_nixl_connector():
52 with set_current_vllm_config(vllm_config):
53 # Test with default settings
54 layout = get_kv_connector_cache_layout()
55 - assert layout == "HND"
55 + assert layout == "LBHNC"
56
57
58 def test_get_kv_connector_cache_layout_with_multi_connector():
@@ -75,4 +75,4 @@ def test_get_kv_connector_cache_layout_with_multi_connector():
75 with set_current_vllm_config(vllm_config):
76 # Test with default settings
77 layout = get_kv_connector_cache_layout()
78 - assert layout == "HND"
78 + assert layout == "LBHNC"
tests/kernels/attention/test_cache.py
+7 -7
@@ -356,21 +356,21 @@ def test_reshape_and_cache_flash(
356 dequant_nvfp4_kv_cache,
357 )
358
359 - def dequant_nvfp4_cache_nhd(data_cache, scale_cache, global_scale):
360 - # data_cache: [N, T, H, data_dim] NHD (contiguous inner dims)
361 - # scale_cache: [N, T, H, scale_dim] NHD (contiguous inner dims)
362 - # Permute to HND layout for the dequant utility.
359 + def dequant_nvfp4_cache(data_cache, scale_cache, global_scale):
360 + # data_cache: [B, N, H, data_dim] logical view (layout in strides)
361 + # scale_cache: [B, N, H, scale_dim] logical view (layout in strides)
362 + # Permute to [B, H, N, dim] for the dequant utility.
363 data_hnd = data_cache.permute(0, 2, 1, 3)
364 scale_hnd = scale_cache.permute(0, 2, 1, 3)
365 result_hnd = dequant_nvfp4_kv_cache(
366 data_hnd, scale_hnd, global_scale, head_size, block_size
367 )
368 - return result_hnd.permute(0, 2, 1, 3) # back to [N, T, H, D]
368 + return result_hnd.permute(0, 2, 1, 3) # back to [B, N, H, dim]
369
370 - result_key_cache = dequant_nvfp4_cache_nhd(
370 + result_key_cache = dequant_nvfp4_cache(
371 nvfp4_key_data, key_scale_cache, k_scale.item()
372 )
373 - result_value_cache = dequant_nvfp4_cache_nhd(
373 + result_value_cache = dequant_nvfp4_cache(
374 nvfp4_value_data, value_scale_cache, v_scale.item()
375 )
376
tests/kernels/attention/test_minimax_m3.py
+111 -220
@@ -5,10 +5,10 @@
5 import pytest
6 import torch
7
8 +from tests.v1.attention.utils import dense_kv_cache_views
9 from vllm import _custom_ops as ops
9 -from vllm.models.minimax_m3.common.indexer import (
10 - MiniMaxM3IndexerBackend,
11 -)
10 +from vllm.config import CacheConfig
11 +from vllm.config.cache import _layout_from_name
12 from vllm.models.minimax_m3.common.ops.index_topk import (
13 minimax_m3_index_decode,
14 minimax_m3_index_score,
@@ -19,15 +19,15 @@ from vllm.models.minimax_m3.common.ops.sparse_attn import (
19 minimax_m3_sparse_attn,
20 minimax_m3_sparse_attn_decode,
21 )
22 -from vllm.models.minimax_m3.common.sparse_attention import (
23 - MiniMaxM3SparseBackend,
24 - MiniMaxM3SparseTritonImpl,
25 -)
22 +from vllm.models.minimax_m3.common.sparse_attention import MiniMaxM3SparseTritonImpl
23 from vllm.platforms import current_platform
27 -from vllm.v1.attention.backends.utils import set_kv_cache_layout
28 -from vllm.v1.kv_cache_interface import FullAttentionSpec, MLAAttentionSpec
29 -from vllm.v1.worker.gpu.attn_utils import _reshape_kv_cache
30 -from vllm.v1.worker.utils import AttentionGroup
24 +from vllm.v1.attention.backends.utils import record_kv_cache_layout
25 +from vllm.v1.kv_cache_interface import (
26 + FullAttentionSpec,
27 + KVCacheLayout,
28 + MLAAttentionSpec,
29 + compute_layer_kv_cache_shape_bytes,
30 +)
31
32 if not (current_platform.is_cuda() or current_platform.is_rocm()):
33 pytest.skip(
@@ -37,35 +37,46 @@ if not (current_platform.is_cuda() or current_platform.is_rocm()):
37
38
39 @pytest.fixture
40 -def kv_layout(request):
41 - """Set the global KV cache layout for one test and restore it after."""
42 - set_kv_cache_layout(request.param)
43 - try:
44 - yield request.param
45 - finally:
46 - set_kv_cache_layout(None)
47 -
48 -
49 -def _stride_order_for(backend: type[MiniMaxM3SparseBackend], ndim: int) -> tuple:
50 - """Mirror the allocator's stride-order resolution (identity fallback)."""
51 - try:
52 - stride_order = backend.get_kv_cache_stride_order()
53 - assert len(stride_order) == ndim
54 - except (AttributeError, NotImplementedError):
55 - stride_order = tuple(range(ndim))
40 +def kv_layout(request) -> KVCacheLayout:
41 + """Resolve the requested layout name (legacy NHD/HND aliases included)."""
42 + return _layout_from_name(request.param)
43 +
44 +
45 +def _layer_stride_order(layout: KVCacheLayout, ndim: int) -> tuple[int, ...]:
46 + """Per-layer physical stride order for the given layout; the 3-dim
47 + indexer side cache (H=1) is contiguous, so identity."""
48 + if ndim == 3:
49 + return (0, 1, 2)
50 + stride_order = layout.layer_view_order
51 + assert len(stride_order) == ndim
52 return stride_order
53
54
55 +def _main_spec() -> FullAttentionSpec:
56 + return FullAttentionSpec(
57 + block_size=BLOCK_SIZE,
58 + num_kv_heads=NUM_KV_HEADS,
59 + head_size=HEAD_DIM,
60 + head_size_v=HEAD_DIM,
61 + dtype=DTYPE,
62 + )
63 +
64 +
65 +def _main_kv_logical_shape(num_pages: int) -> tuple[int, ...]:
66 + """Standardized per-layer logical shape [B, H, N, C] for the main cache,
67 + derived the same way the production allocator does."""
68 + shape_bytes = compute_layer_kv_cache_shape_bytes(_main_spec(), num_pages)
69 + return (*shape_bytes[:-1], shape_bytes[-1] // DTYPE.itemsize)
70 +
71 +
72 def _allocate_main_kv_via_contract(
60 - num_pages: int, device: torch.device | str = "cuda"
73 + num_pages: int, layout: KVCacheLayout, device: torch.device | str = "cuda"
74 ) -> torch.Tensor:
75 """Build the main KV cache exactly as the production allocator does for the
63 - currently active layout: allocate the physical (permuted) tensor, then
64 - expose the inverse-permuted logical-NHD view the backend sees."""
65 - logical_shape = MiniMaxM3SparseBackend.get_kv_cache_shape(
66 - num_pages, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
67 - )
68 - stride_order = _stride_order_for(MiniMaxM3SparseBackend, len(logical_shape))
76 + given layout: allocate the physical (permuted) tensor, then expose the
77 + inverse-permuted logical [B, H, N, C] view the backend sees."""
78 + logical_shape = _main_kv_logical_shape(num_pages)
79 + stride_order = _layer_stride_order(layout, len(logical_shape))
80 physical_shape = tuple(logical_shape[i] for i in stride_order)
81 inv_order = [stride_order.index(i) for i in range(len(stride_order))]
82 raw = torch.randn(physical_shape, device=device, dtype=DTYPE)
@@ -817,7 +828,7 @@ def _reference_sparse_attn(
828 ],
829 )
830 def test_prefill_sparse_attention_correctness(
820 - kv_layout: str,
831 + kv_layout: KVCacheLayout,
832 q_lens: tuple[int, ...],
833 kv_lens: tuple[int, ...],
834 ):
@@ -851,7 +862,7 @@ def test_prefill_sparse_attention_correctness(
862 # Allocate the main KV cache through the backend layout contract so the
863 # physical storage matches the active layout (contiguous NHD or strided
864 # HND), while the kernels and reference see the logical-NHD view.
854 - kv_cache = _allocate_main_kv_via_contract(num_pages)
865 + kv_cache = _allocate_main_kv_via_contract(num_pages, kv_layout)
866
867 # Build sparse block indices with the same contract as the real M3 indexer:
868 # one forced local block, then score-selected older causal blocks.
@@ -904,38 +915,31 @@ def test_prefill_sparse_attention_correctness(
915 assert error.max().item() < 1.7e-2
916
917
907 -def test_main_backend_layout_contract():
908 - """The main sparse backend exposes the logical-NHD shape and the
909 - flash_attn-style stride order for each layout."""
918 +def test_main_cache_layout_contract():
919 + """The standardized per-layer logical shape is [B, H, N, C] with packed
920 + K/V content, and the legacy layout aliases resolve to the expected
921 + per-layer stride orders."""
922 nb, bs, h, d = 7, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
911 - logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
923 + logical = _main_kv_logical_shape(nb)
924 assert logical == (nb, h, bs, 2 * d)
925 # The old separate K/V-axis shape is no longer the logical shape.
926 assert logical != (nb, 2, bs, h, d)
927
916 - try:
917 - set_kv_cache_layout("HND")
918 - assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 1, 2, 3)
919 - set_kv_cache_layout("NHD")
920 - assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 2, 1, 3)
921 - finally:
922 - set_kv_cache_layout(None)
923 -
924 - for layout in ("NHD", "HND"):
925 - try:
926 - set_kv_cache_layout(layout)
927 - order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
928 - finally:
929 - set_kv_cache_layout(None)
928 + assert KVCacheLayout.LBHNC.layer_view_order == (0, 1, 2, 3)
929 + assert KVCacheLayout.LBNHC.layer_view_order == (0, 2, 1, 3)
930 +
931 + cache_config = CacheConfig()
932 + record_kv_cache_layout(cache_config, "HND")
933 + assert cache_config.get_resolved_kv_cache_layout() is KVCacheLayout.LBHNC
934 + cache_config = CacheConfig()
935 + record_kv_cache_layout(cache_config, "NHD")
936 + assert cache_config.get_resolved_kv_cache_layout() is KVCacheLayout.LBNHC
937 +
938 + for layout in (KVCacheLayout.LBNHC, KVCacheLayout.LBHNC):
939 + order = layout.layer_view_order
940 # Valid permutation: no duplicates, covers every axis.
941 assert set(order) == set(range(len(order)))
942
933 - # M3 has no cross-layer KV blocks.
934 - with pytest.raises(NotImplementedError):
935 - MiniMaxM3SparseBackend.get_kv_cache_stride_order(
936 - include_num_layers_dimension=True
937 - )
938 -
943
944 def test_aiter_sparse_pa_layout_contract(monkeypatch):
945 """The shuffle-only AITER path retains separately contiguous K/V storage."""
@@ -948,75 +952,53 @@ def test_aiter_sparse_pa_layout_contract(monkeypatch):
952 lambda: True,
953 )
954
951 - nb, bs, h, d = 7, BLOCK_SIZE, 1, HEAD_DIM
952 - logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
953 - order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
954 - assert logical == (nb, 2, bs, h, d)
955 - assert order == (1, 0, 2, 3, 4)
955 + backend = sparse_attn_mod.MiniMaxM3SparseBackend
956 + assert KVCacheLayout.LHBNC in backend.supported_kv_cache_layouts()
957
957 - physical_shape = tuple(logical[i] for i in order)
958 - inv_order = [order.index(i) for i in range(len(order))]
959 - raw = torch.empty(physical_shape, device="cuda", dtype=DTYPE)
960 - logical_view = raw.permute(*inv_order)
961 - key_cache, value_cache = logical_view.unbind(1)
958 + nb, h = 7, 1
959 + spec = FullAttentionSpec(
960 + block_size=BLOCK_SIZE,
961 + num_kv_heads=h,
962 + head_size=HEAD_DIM,
963 + dtype=DTYPE,
964 + num_head_slots=2,
965 + state_content_bytes=h * HEAD_DIM * DTYPE.itemsize,
966 + )
967 + raw = torch.zeros(nb * spec.page_size_bytes, dtype=torch.int8)
968 + view = dense_kv_cache_views(raw, spec, nb, 1, KVCacheLayout.LHBNC)[0]
969 + key_cache, value_cache = view.unbind(1)
970 assert key_cache.is_contiguous()
971 assert value_cache.is_contiguous()
972
973
966 -def test_aiter_sparse_pa_rejects_multiple_kv_heads(monkeypatch):
967 - """Do not pair AITER's separated cache layout with the Triton fallback."""
968 - import vllm.models.minimax_m3.common.sparse_attention as sparse_attn_mod
969 -
970 - monkeypatch.setattr(sparse_attn_mod.rocm_aiter_ops, "is_enabled", lambda: True)
971 - monkeypatch.setattr(
972 - sparse_attn_mod.rocm_aiter_ops,
973 - "is_shuffle_kv_cache_enabled",
974 - lambda: True,
974 +def test_indexer_cache_squeezes_to_contiguous_3d():
975 + """The indexer side cache is standardized 4D with H=1: under both layouts
976 + the allocator's logical view stays contiguous and squeezes (as
977 + `MiniMaxM3IndexerCache.bind_kv_cache` does) to the 3-dim
978 + [num_blocks, block_size, head_dim] cache the kernels consume."""
979 + nb = 5
980 + ispec = MLAAttentionSpec(
981 + block_size=BLOCK_SIZE, num_kv_heads=1, head_size=HEAD_DIM, dtype=DTYPE
982 )
983 + shape_bytes = compute_layer_kv_cache_shape_bytes(ispec, nb)
984 + assert shape_bytes == (nb, 1, BLOCK_SIZE, HEAD_DIM * DTYPE.itemsize)
985 + assert _layer_stride_order(KVCacheLayout.LBNHC, 3) == (0, 1, 2)
986
977 - with pytest.raises(ValueError, match="num_kv_heads == 1"):
978 - MiniMaxM3SparseBackend.get_kv_cache_shape(7, BLOCK_SIZE, 2, HEAD_DIM)
979 -
980 -
981 -def test_main_backend_unknown_layout_raises(monkeypatch):
982 - """An unrecognized layout (injected past env-var validation) is rejected."""
983 - import vllm.models.minimax_m3.common.sparse_attention as sparse_attn_mod
984 -
985 - monkeypatch.setattr(sparse_attn_mod, "get_kv_cache_layout", lambda: "BOGUS")
986 - with pytest.raises(ValueError, match="Unknown cache layout format"):
987 - MiniMaxM3SparseBackend.get_kv_cache_stride_order()
988 -
989 -
990 -def test_indexer_backend_stride_order_is_identity():
991 - """The 3-dim indexer cache must not inherit the parent's 4-element stride
992 - order; it overrides to the 3-element identity so the allocator keeps the
993 - contiguous layout."""
994 - assert MiniMaxM3IndexerBackend.get_kv_cache_stride_order() == (0, 1, 2)
995 -
996 - # Cross-layer (per-layer-stacked) KV blocks are not supported.
997 - with pytest.raises(NotImplementedError):
998 - MiniMaxM3IndexerBackend.get_kv_cache_stride_order(
999 - include_num_layers_dimension=True
1000 - )
1001 -
1002 - # The stride order matches the 3-dim indexer shape rank.
1003 - indexer_shape = MiniMaxM3IndexerBackend.get_kv_cache_shape(
1004 - 5, BLOCK_SIZE, 1, HEAD_DIM
1005 - )
1006 - assert len(indexer_shape) == 3
1007 - assert _stride_order_for(MiniMaxM3IndexerBackend, len(indexer_shape)) == (0, 1, 2)
987 + for layout in (KVCacheLayout.LBNHC, KVCacheLayout.LBHNC):
988 + iraw = torch.zeros(nb * ispec.page_size_bytes, dtype=torch.int8)
989 + view = dense_kv_cache_views(iraw, ispec, nb, 1, layout, BLOCK_SIZE)[0]
990 + assert tuple(view.shape) == (nb, 1, BLOCK_SIZE, HEAD_DIM)
991 + indexer_cache = view.squeeze(1)
992 + assert tuple(indexer_cache.shape) == (nb, BLOCK_SIZE, HEAD_DIM)
993 + assert indexer_cache.is_contiguous()
994
995
996 def test_hnd_allocation_is_packed_head_major():
997 """Under HND the backend-visible logical view is the packed head-major
998 physical allocation."""
999 nb, bs, h, d = 4, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
1014 - logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
1015 - try:
1016 - set_kv_cache_layout("HND")
1017 - stride_order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
1018 - finally:
1019 - set_kv_cache_layout(None)
1000 + logical = _main_kv_logical_shape(nb)
1001 + stride_order = KVCacheLayout.LBHNC.layer_view_order
1002
1003 physical_shape = tuple(logical[i] for i in stride_order)
1004 assert physical_shape == (nb, h, bs, 2 * d)
@@ -1042,27 +1024,13 @@ def test_main_cache_is_block_first_and_unpadded():
1024 """The allocator's contiguous-view branch (not the padded-strided branch)
1025 is used for the main GQA cache: its spec is unpadded and the physical
1026 layout keeps num_blocks as the first dimension under both layouts."""
1045 - from vllm.v1.kv_cache_interface import FullAttentionSpec
1046 -
1047 - spec = FullAttentionSpec(
1048 - block_size=BLOCK_SIZE,
1049 - num_kv_heads=NUM_KV_HEADS,
1050 - head_size=HEAD_DIM,
1051 - head_size_v=HEAD_DIM,
1052 - dtype=DTYPE,
1053 - )
1027 + spec = _main_spec()
1028 # Unpadded -> allocator uses kv_tensor.view(...) rather than as_strided().
1029 assert spec.page_size_padded is None
1030
1057 - logical = MiniMaxM3SparseBackend.get_kv_cache_shape(
1058 - 4, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
1059 - )
1060 - for layout in ("NHD", "HND"):
1061 - try:
1062 - set_kv_cache_layout(layout)
1063 - order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
1064 - finally:
1065 - set_kv_cache_layout(None)
1031 + logical = _main_kv_logical_shape(4)
1032 + for layout in (KVCacheLayout.LBNHC, KVCacheLayout.LBHNC):
1033 + order = layout.layer_view_order
1034 inv_order = [order.index(i) for i in range(len(order))]
1035 # Physical first dim is num_blocks (block-first); required by the
1036 # padded-strided branch's block-first assumption if it were ever taken.
@@ -1275,7 +1243,7 @@ def test_aiter_decode_sparse_block_table_supports_spec_decode():
1243 @pytest.mark.parametrize("decode_query_len", [1, 4])
1244 @pytest.mark.parametrize("num_padded_reqs", [0, 2])
1245 def test_decode_sparse_attention_correctness(
1278 - kv_layout: str,
1246 + kv_layout: KVCacheLayout,
1247 seq_lens_list: tuple[int, ...],
1248 decode_query_len: int,
1249 num_padded_reqs: int,
@@ -1287,7 +1255,7 @@ def test_decode_sparse_attention_correctness(
1255 q, block_table, seq_lens, topk_idx, num_pages = _build_decode_inputs(
1256 seq_lens_list, decode_query_len, num_padded_reqs
1257 )
1290 - kv_cache = _allocate_main_kv_via_contract(num_pages)
1258 + kv_cache = _allocate_main_kv_via_contract(num_pages, kv_layout)
1259
1260 actual = torch.empty_like(q)
1261 minimax_m3_sparse_attn_decode(
@@ -1364,93 +1332,20 @@ def test_decode_wrong_layout_breaks_parity():
1332 assert (actual.float() - expected.float()).abs().max().item() > 1.7e-2
1333
1334
1367 -def _make_attn_group(backend, spec):
1368 - return AttentionGroup(
1369 - backend=backend,
1370 - layer_names=["main"],
1371 - kv_cache_spec=spec,
1372 - kv_cache_group_id=0,
1373 - )
1374 -
1375 -
1335 def test_main_cache_byte_identical_through_production_allocator():
1377 - """AC-2: drive the real allocator (`_reshape_kv_cache`) for the M3 main
1336 + """AC-2: drive the real allocator (`create_kv_cache_views`) for the M3 main
1337 `FullAttentionSpec` under HND and assert the backend-visible view has the
1379 - same shape, stride, and storage offset as the packed-HND allocation; the
1380 - indexer `MLAAttentionSpec` allocates through the same path to its 3-dim
1381 - shape."""
1338 + same shape, stride, and storage offset as the packed-HND allocation."""
1339 nb = 4
1383 - spec = FullAttentionSpec(
1384 - block_size=BLOCK_SIZE,
1385 - num_kv_heads=NUM_KV_HEADS,
1386 - head_size=HEAD_DIM,
1387 - head_size_v=HEAD_DIM,
1388 - dtype=DTYPE,
1389 - )
1340 + spec = _main_spec()
1341 raw = torch.zeros(nb * spec.page_size_bytes, dtype=torch.int8)
1391 - group = _make_attn_group(MiniMaxM3SparseBackend, spec)
1392 - try:
1393 - set_kv_cache_layout("HND")
1394 - kv_caches = _reshape_kv_cache([group], {"main": raw}, "auto", [BLOCK_SIZE], {})
1395 - finally:
1396 - set_kv_cache_layout(None)
1397 - view = kv_caches["main"]
1342 + view = dense_kv_cache_views(raw, spec, nb, 1, KVCacheLayout.LBHNC, BLOCK_SIZE)[0]
1343
1344 oracle = raw.view(DTYPE).view((nb, NUM_KV_HEADS, BLOCK_SIZE, 2 * HEAD_DIM))
1345 assert tuple(view.shape) == tuple(oracle.shape)
1346 assert view.stride() == oracle.stride()
1347 assert view.storage_offset() == oracle.storage_offset()
1348
1404 - # Indexer cache allocates through the same path under both layouts.
1405 - ispec = MLAAttentionSpec(
1406 - block_size=BLOCK_SIZE, num_kv_heads=1, head_size=HEAD_DIM, dtype=DTYPE
1407 - )
1408 - for layout in ("NHD", "HND"):
1409 - iraw = torch.zeros(nb * ispec.page_size_bytes, dtype=torch.int8)
1410 - igroup = AttentionGroup(
1411 - backend=MiniMaxM3IndexerBackend,
1412 - layer_names=["idx"],
1413 - kv_cache_spec=ispec,
1414 - kv_cache_group_id=0,
1415 - )
1416 - try:
1417 - set_kv_cache_layout(layout)
1418 - iout = _reshape_kv_cache([igroup], {"idx": iraw}, "auto", [BLOCK_SIZE], {})
1419 - finally:
1420 - set_kv_cache_layout(None)
1421 - assert tuple(iout["idx"].shape) == (nb, BLOCK_SIZE, HEAD_DIM)
1422 -
1423 -
1424 -def test_indexer_inherited_stride_order_trips_allocator_assert():
1425 - """AC-4 negative: without the indexer override, the inherited 4-element
1426 - stride order trips the allocator's `len(stride_order) == len(shape)` assert
1427 - for the 3-dim indexer shape; the `AssertionError` is NOT swallowed by the
1428 - allocator's `(AttributeError, NotImplementedError)` fallback."""
1429 -
1430 - class _BrokenIndexerBackend(MiniMaxM3IndexerBackend):
1431 - # Simulate inheriting the parent's 4-element stride order.
1432 - get_kv_cache_stride_order = staticmethod(
1433 - MiniMaxM3SparseBackend.get_kv_cache_stride_order
1434 - )
1435 -
1436 - nb = 4
1437 - ispec = MLAAttentionSpec(
1438 - block_size=BLOCK_SIZE, num_kv_heads=1, head_size=HEAD_DIM, dtype=DTYPE
1439 - )
1440 - iraw = torch.zeros(nb * ispec.page_size_bytes, dtype=torch.int8)
1441 - igroup = AttentionGroup(
1442 - backend=_BrokenIndexerBackend,
1443 - layer_names=["idx"],
1444 - kv_cache_spec=ispec,
1445 - kv_cache_group_id=0,
1446 - )
1447 - try:
1448 - set_kv_cache_layout("HND")
1449 - with pytest.raises(AssertionError):
1450 - _reshape_kv_cache([igroup], {"idx": iraw}, "auto", [BLOCK_SIZE], {})
1451 - finally:
1452 - set_kv_cache_layout(None)
1453 -
1349
1350 def test_padded_main_cache_is_flagged():
1351 """AC-2.1 negative: the M3 main cache relies on the allocator's
@@ -1465,11 +1360,7 @@ def test_padded_main_cache_is_flagged():
1360 )
1361 assert inv_order[0] == 0, "main GQA cache must remain block-first"
1362
1468 - try:
1469 - set_kv_cache_layout("HND")
1470 - stride_order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
1471 - finally:
1472 - set_kv_cache_layout(None)
1363 + stride_order = KVCacheLayout.LBHNC.layer_view_order
1364
1365 good = FullAttentionSpec(
1366 block_size=BLOCK_SIZE,
@@ -1493,13 +1384,13 @@ def test_padded_main_cache_is_flagged():
1384
1385
1386 @pytest.mark.parametrize("kv_layout", ["NHD", "HND"], indirect=True)
1496 -def test_reshape_and_cache_flash_write_persists(kv_layout: str):
1387 +def test_reshape_and_cache_flash_write_persists(kv_layout: KVCacheLayout):
1388 """AC-5 write path: the `reshape_and_cache_flash` write site now consumes
1389 packed-content K/V split views. Writing through those views must persist
1390 into the bound storage under both layouts."""
1391 torch.manual_seed(0)
1392 num_pages = 4
1502 - kv_cache = _allocate_main_kv_via_contract(num_pages)
1393 + kv_cache = _allocate_main_kv_via_contract(num_pages, kv_layout)
1394 with torch.no_grad():
1395 kv_cache.zero_()
1396
tests/kernels/attention/test_rocm_aiter_fa.py
+13 -8
@@ -310,18 +310,23 @@ def test_aiter_mha_backend_contract():
310
311
312 def test_aiter_mha_backend_validates_kv_cache_block_size():
313 - """The backend should reject KV cache shapes that cannot be gathered
313 + """The backend should reject KV cache specs that cannot be gathered
314 correctly by the ROCm kernel."""
315 from vllm.v1.attention.backends.rocm_aiter_fa import AiterFlashAttentionBackend
316 + from vllm.v1.kv_cache_interface import FullAttentionSpec
317
317 - assert AiterFlashAttentionBackend.get_kv_cache_shape(8, 16, 8, 128) == (
318 - 8,
319 - 8,
320 - 16,
321 - 256,
322 - )
318 with pytest.raises(ValueError, match="Block size must be a multiple of 16"):
324 - AiterFlashAttentionBackend.get_kv_cache_shape(8, 15, 8, 128)
319 + AiterFlashAttentionBackend.customize_spec(
320 + FullAttentionSpec(
321 + block_size=15, num_kv_heads=8, head_size=128, dtype=torch.bfloat16
322 + )
323 + )
324 + # block_size == 1 is the per-token page-size probe and must pass through.
325 + AiterFlashAttentionBackend.customize_spec(
326 + FullAttentionSpec(
327 + block_size=1, num_kv_heads=8, head_size=128, dtype=torch.bfloat16
328 + )
329 + )
330
331
332 def test_aiter_mha_backend_supports_compute_capability_matches_mi3xx_probe():
tests/kernels/attention/test_trtllm_kvfp8_dequant.py
+41 -58
@@ -11,6 +11,7 @@ import pytest
11 import torch
12
13 from vllm.platforms import current_platform
14 +from vllm.v1.kv_cache_interface import KVCacheLayout
15
16 if current_platform.is_rocm():
17 pytest.skip(
@@ -34,51 +35,40 @@ def to_float8(x, dtype=None):
35 return x_scl_sat.to(dtype), scale.float().reciprocal()
36
37
37 -def make_contiguous_kv_cache(num_blocks, num_kv_heads, block_size, head_size):
38 - """Create a standard contiguous fp8 KV cache (HND layout)."""
39 - raw = torch.randn(
40 - num_blocks,
41 - 2,
42 - num_kv_heads,
43 - block_size,
44 - head_size,
45 - dtype=torch.bfloat16,
46 - device="cuda",
47 - )
48 - kv_cache, scale = to_float8(raw)
49 - return kv_cache, scale
50 -
51 -
52 -def make_cross_layer_kv_cache(
38 +def make_random_kv_cache(
39 num_blocks,
40 num_kv_heads,
41 block_size,
42 head_size,
57 - num_layers=4,
43 + layout=KVCacheLayout.LBHNC,
44 + num_layers=None,
45 ):
46 """
60 - Create a non-contiguous per-layer view mimicking cross-layer allocation.
47 + Create a random fp8 KV cache mimicking the production allocation.
48
62 - Physical layout: (num_blocks, 2, num_kv_heads, num_layers, block_size, head_size)
49 + Physical layout: [L, B, H, N, 2 * hs] permuted by ``layout.stride_order``
50 Returned view: (num_blocks, 2, num_kv_heads, block_size, head_size)
64 - with non-contiguous strides on dims 0, 1, 2 (they skip over num_layers).
51 + with non-contiguous strides under cross-layer layouts (they skip over
52 + num_layers), matching the actual forward path.
53 """
66 - raw = torch.randn(
54 + logical_4d = (num_blocks, num_kv_heads, block_size, 2 * head_size)
55 + if num_layers is None:
56 + num_layers = 1 if layout.is_layer_compact else 2
57 + logical_5d = (num_layers, *logical_4d)
58 + physical_5d = tuple(logical_5d[i] for i in layout.stride_order)
59 + inv_order = [layout.stride_order.index(i) for i in range(5)]
60 +
61 + raw_phys = torch.randn(*physical_5d, dtype=torch.bfloat16, device="cuda")
62 + fp8_phys, scale = to_float8(raw_phys)
63 + fp8_4d = fp8_phys.permute(*inv_order)[0]
64 + kv_5d = fp8_4d.view(
65 num_blocks,
68 - 2,
66 num_kv_heads,
70 - num_layers,
67 block_size,
68 + 2,
69 head_size,
73 - dtype=torch.bfloat16,
74 - device="cuda",
75 - )
76 - fp8_full, scale = to_float8(raw)
77 - layer_view = fp8_full[:, :, :, 0, :, :]
78 - assert not layer_view.is_contiguous(), (
79 - f"Expected non-contiguous view, got strides {layer_view.stride()}"
80 - )
81 - return layer_view, scale
70 + ).permute(0, 3, 1, 2, 4)
71 + return kv_5d, scale
72
73
74 def ref_dequant(kv_cache, block_tables, k_scale, v_scale, dequant_dtype):
@@ -114,7 +104,7 @@ def ref_dequant(kv_cache, block_tables, k_scale, v_scale, dequant_dtype):
104 @pytest.mark.parametrize("block_size", [16, 32])
105 @pytest.mark.parametrize("batch_size", [1, 4])
106 @pytest.mark.parametrize("num_pages_per_seq", [3, 8])
117 -@pytest.mark.parametrize("contiguous", [True, False])
107 +@pytest.mark.parametrize("layout", list(KVCacheLayout))
108 @torch.inference_mode()
109 def test_trtllm_kvfp8_dequant(
110 num_kv_heads: int,
@@ -122,7 +112,7 @@ def test_trtllm_kvfp8_dequant(
112 block_size: int,
113 batch_size: int,
114 num_pages_per_seq: int,
125 - contiguous: bool,
115 + layout: KVCacheLayout,
116 ):
117 from vllm.v1.attention.backends.flashinfer import (
118 trtllm_prefill_attn_kvfp8_dequant,
@@ -130,20 +120,13 @@ def test_trtllm_kvfp8_dequant(
120
121 torch.set_default_device("cuda")
122
133 - if contiguous:
134 - kv_cache, scale = make_contiguous_kv_cache(
135 - NUM_BLOCKS,
136 - num_kv_heads,
137 - block_size,
138 - head_size,
139 - )
140 - else:
141 - kv_cache, scale = make_cross_layer_kv_cache(
142 - NUM_BLOCKS,
143 - num_kv_heads,
144 - block_size,
145 - head_size,
146 - )
123 + kv_cache, scale = make_random_kv_cache(
124 + NUM_BLOCKS,
125 + num_kv_heads,
126 + block_size,
127 + head_size,
128 + layout=layout,
129 + )
130
131 k_scale = scale.clone()
132 v_scale = scale.clone()
@@ -187,7 +170,7 @@ def test_block_tables_with_zero_pages():
170 torch.set_default_device("cuda")
171 num_kv_heads, block_size, head_size = 8, 16, 64
172
190 - kv_cache, scale = make_contiguous_kv_cache(
173 + kv_cache, scale = make_random_kv_cache(
174 NUM_BLOCKS,
175 num_kv_heads,
176 block_size,
@@ -234,7 +217,7 @@ def test_all_zero_block_tables():
217 torch.set_default_device("cuda")
218 num_kv_heads, block_size, head_size = 4, 16, 64
219
237 - kv_cache, scale = make_contiguous_kv_cache(
220 + kv_cache, scale = make_random_kv_cache(
221 NUM_BLOCKS,
222 num_kv_heads,
223 block_size,
@@ -266,7 +249,7 @@ def test_different_k_v_scales():
249 torch.set_default_device("cuda")
250 num_kv_heads, block_size, head_size = 8, 16, 64
251
269 - kv_cache, _ = make_contiguous_kv_cache(
252 + kv_cache, _ = make_random_kv_cache(
253 NUM_BLOCKS,
254 num_kv_heads,
255 block_size,
@@ -299,7 +282,7 @@ def test_single_page_per_seq():
282 torch.set_default_device("cuda")
283 num_kv_heads, block_size, head_size = 8, 16, 128
284
302 - kv_cache, scale = make_contiguous_kv_cache(
285 + kv_cache, scale = make_random_kv_cache(
286 NUM_BLOCKS,
287 num_kv_heads,
288 block_size,
@@ -332,7 +315,7 @@ def test_large_page_indices():
315 num_kv_heads, block_size, head_size = 8, 16, 128
316 large_num_blocks = 32768
317
335 - kv_cache, scale = make_contiguous_kv_cache(
318 + kv_cache, scale = make_random_kv_cache(
319 large_num_blocks,
320 num_kv_heads,
321 block_size,
@@ -369,7 +352,7 @@ def test_large_block_size():
352 torch.set_default_device("cuda")
353 num_kv_heads, block_size, head_size = 4, 64, 128
354
372 - kv_cache, scale = make_contiguous_kv_cache(
355 + kv_cache, scale = make_random_kv_cache(
356 NUM_BLOCKS,
357 num_kv_heads,
358 block_size,
@@ -401,7 +384,7 @@ def test_large_block_size():
384 def test_cross_layer_many_layers():
385 """
386 Non-contiguous with 36 layers -- matches real gpt-oss-120b.
404 - Strides are far from contiguous (factor of 36 in the gaps).
387 + Cross-layer (BLHNC) strides are far from contiguous (factor of 36 in the gaps).
388 """
389 from vllm.v1.attention.backends.flashinfer import (
390 trtllm_prefill_attn_kvfp8_dequant,
@@ -409,14 +392,14 @@ def test_cross_layer_many_layers():
392
393 torch.set_default_device("cuda")
394 num_kv_heads, block_size, head_size = 8, 16, 64
412 - num_layers = 36
395
414 - kv_cache, scale = make_cross_layer_kv_cache(
396 + kv_cache, scale = make_random_kv_cache(
397 NUM_BLOCKS,
398 num_kv_heads,
399 block_size,
400 head_size,
419 - num_layers=num_layers,
401 + layout=KVCacheLayout.BLHNC,
402 + num_layers=36,
403 )
404 k_scale = v_scale = scale.clone()
405
tests/models/multimodal/generation/test_voxtral_realtime.py
+4 -1
@@ -1,5 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from fractions import Fraction
4 +
5 import pytest
6 import pytest_asyncio
7 from mistral_common.protocol.transcription.request import (
@@ -75,7 +77,8 @@ def assert_encoder_kv_cache_spec(engine: LLM) -> None:
77 assert audio_config.block_pool_size == 4
78 assert isinstance(spec, SlidingWindowSpec)
79 assert spec.block_size == 16
78 - assert spec.num_kv_heads == 128
80 + assert spec.num_kv_heads == 32
81 + assert spec.tokens_per_state == Fraction(1, 4)
82 # cdiv(750, 4) == 188 pooled tokens cover the model's window; the extra
83 # +1 is an eviction margin (see whisper_causal.py get_kv_cache_spec).
84 assert spec.sliding_window == cdiv(750, 4) + 1 == 189
tests/models/test_initialization.py
+7
@@ -8,6 +8,7 @@ import pytest
8
9 from vllm import LLM
10 from vllm.utils.mem_constants import GiB_bytes
11 +from vllm.v1.attention.backends.utils import resolve_kv_cache_layout
12 from vllm.v1.core.kv_cache_utils import (
13 generate_scheduler_kv_cache_config,
14 get_kv_cache_configs,
@@ -82,6 +83,12 @@ def can_initialize(
83 # Avoid calling model.forward()
84 def _initialize_kv_caches_v1(self, vllm_config):
85 kv_cache_specs = self.model_executor.get_kv_cache_specs()
86 + layout = resolve_kv_cache_layout(
87 + vllm_config,
88 + self.model_executor.get_supported_kv_cache_layouts(),
89 + [spec for worker_specs in kv_cache_specs for spec in worker_specs.values()],
90 + )
91 + self.model_executor.set_kv_cache_layout(layout.name)
92 kv_cache_configs = get_kv_cache_configs(
93 vllm_config,
94 kv_cache_specs,
tests/quantization/test_turboquant.py
+1
@@ -294,6 +294,7 @@ class TestTurboQuantKVCacheSpec:
294 head_size_v=128,
295 num_kv_heads=4,
296 sliding_window=None,
297 + get_attn_backend=lambda: TurboQuantAttentionBackend,
298 )
299 vllm_config = SimpleNamespace(cache_config=SimpleNamespace(block_size=32))
300
tests/test_attention_backend_registry.py
-10
@@ -38,11 +38,6 @@ class CustomAttentionBackend(AttentionBackend):
38 """Mock builder class."""
39 return None
40
41 - @staticmethod
42 - def get_required_kv_cache_layout():
43 - """Mock KV cache layout."""
44 - return None
45 -
41
42 class CustomMambaAttentionImpl(AttentionImpl):
43 """Mock custom mamba attention implementation for testing."""
@@ -71,11 +66,6 @@ class CustomMambaAttentionBackend(AttentionBackend):
66 """Mock builder class."""
67 return None
68
74 - @staticmethod
75 - def get_required_kv_cache_layout():
76 - """Mock KV cache layout."""
77 - return None
78 -
69
70 def test_custom_is_not_alias_of_any_backend():
71 # Get all members of AttentionBackendEnum
tests/v1/attention/test_attention_backends.py
+170 -103
@@ -31,10 +31,7 @@ from vllm.v1.attention.backend import (
31 CommonAttentionMetadata,
32 )
33 from vllm.v1.attention.backends.registry import AttentionBackendEnum
34 -from vllm.v1.attention.backends.utils import (
35 - set_kv_cache_layout,
36 -)
37 -from vllm.v1.kv_cache_interface import FullAttentionSpec
34 +from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheLayout
35
36 BACKENDS_TO_TEST = [
37 AttentionBackendEnum.FLASH_ATTN,
@@ -44,6 +41,14 @@ BACKENDS_TO_TEST = [
41 "FLEX_ATTENTION_SLOW",
42 ]
43
44 +
45 +def _actual_backend(backend) -> AttentionBackendEnum:
46 + """Resolve pseudo-backends (FLEX_ATTENTION_SLOW) to their real enum."""
47 + if backend == "FLEX_ATTENTION_SLOW":
48 + return AttentionBackendEnum.FLEX_ATTENTION
49 + return backend
50 +
51 +
52 DEVICE_TYPE = current_platform.device_type
53
54 # Use the platform's preferred FP8 type so the stored cache matches what the
@@ -118,6 +123,7 @@ def create_and_prepopulate_kv_cache(
123 device: torch.device,
124 num_blocks: int,
125 common_attn_metadata: CommonAttentionMetadata,
126 + layout: KVCacheLayout,
127 randomize_blocks: bool = True,
128 kv_cache_dtype: str = "auto",
129 ) -> torch.Tensor:
@@ -126,19 +132,22 @@ def create_and_prepopulate_kv_cache(
132 Args:
133 k_contexts: List of key context tensors for each sequence
134 v_contexts: List of value context tensors for each sequence
129 - seq_lens: List of sequence lengths
135 block_size: Size of each block
136 num_kv_heads: Number of KV heads
137 head_size: Size of each head
138 dtype: Data type for the cache
139 device: Device to create the cache on
140 num_blocks: Total number of blocks in the cache
136 - block_table: Block table tensor to populate
141 + common_attn_metadata: Provides seq lens, block table and slot mapping
142 + layout: Physical layout to allocate in; the cache is returned as the
143 + logical ``[B, H, N, C]`` view (as ``create_kv_cache_views`` does)
144 randomize_blocks: Whether to randomly permute blocks
145 or use sequential order
146 + kv_cache_dtype: Cache dtype string; fp8 caches use fp8 storage
147
148 Returns:
141 - Tuple of (kv_cache, updated_block_table)
149 + A 4D tensor in logical ``(num_blocks, num_kv_heads, block_size,
150 + 2 * head_size)`` order with strides determined by ``layout``.
151 """
152 batch_size = len(k_contexts)
153 seq_lens = common_attn_metadata.seq_lens.cpu()
@@ -155,26 +164,33 @@ def create_and_prepopulate_kv_cache(
164 fp8_kv_cache = is_quantized_kv_cache(kv_cache_dtype)
165 storage_dtype = FP8_KV_CACHE_DTYPES[kv_cache_dtype] if fp8_kv_cache else dtype
166
158 - kv_cache = torch.zeros(
159 - num_blocks,
160 - block_size,
161 - num_kv_heads,
162 - 2 * head_size,
163 - dtype=storage_dtype,
164 - device=device,
165 - )
166 - kv_cache_flat = kv_cache.view(-1, num_kv_heads, 2 * head_size)
167 -
168 - # Populate the cache with the context tokens
167 + # Logical 5D shape is always [L, B, H, N, C]. Cross-layer layouts need
168 + # at least two layers to reproduce the inter-layer gaps in a layer view.
169 + logical_4d = (num_blocks, num_kv_heads, block_size, 2 * head_size)
170 + num_layers = 1 if layout.is_layer_compact else 2
171 + logical_5d = (num_layers, *logical_4d)
172 + physical_5d = tuple(logical_5d[i] for i in layout.stride_order)
173 + inv_order = [layout.stride_order.index(i) for i in range(5)]
174 +
175 + kv_cache_physical = torch.zeros(physical_5d, dtype=storage_dtype, device=device)
176 + # Permute to logical [L, B, H, N, C], then select a layer. This mirrors
177 + # create_kv_cache_views and retains cross-layer strides in the 4D view.
178 + kv_cache = kv_cache_physical.permute(*inv_order)[0]
179 +
180 + # Write context tokens into the cache via the logical view:
181 + # kv_cache[block, :, token_in_block, :] routes correctly regardless
182 + # of physical layout.
183 # Start from block_id=1 since block_id=0 is considered the null block
184 start_block_idx = 1
185 for i in range(batch_size):
186 k_context, v_context = k_contexts[i], v_contexts[i]
173 - start = start_block_idx * block_size
174 - end = start + k_context.shape[0]
175 - kv_cache_flat[start:end, :, :head_size] = k_context
176 - kv_cache_flat[start:end, :, head_size:] = v_context
177 -
187 + t = torch.arange(k_context.shape[0], device=device)
188 + blk = start_block_idx + t // block_size
189 + off = t % block_size
190 + # Advanced indexing on (blk, off) yields [T, H, hs] destinations.
191 + # index_put is dtype-strict; cast like the scalar path would.
192 + kv_cache[blk, :, off, :head_size] = k_context.to(kv_cache.dtype)
193 + kv_cache[blk, :, off, head_size:] = v_context.to(kv_cache.dtype)
194 # Stay block aligned and allocate enough blocks for the new tokens
195 start_block_idx += cdiv(int(seq_lens[i]), block_size)
196
@@ -214,9 +230,6 @@ def create_and_prepopulate_kv_cache(
230 i, block_indices
231 ] * block_size + token_inter_block_offsets.to(device)
232
217 - # Transpose to logical (num_blocks, num_kv_heads, block_size, 2*hs)
218 - kv_cache = kv_cache.transpose(1, 2).contiguous()
219 -
233 if fp8_kv_cache:
234 kv_cache = kv_cache.view(torch.uint8)
235
@@ -236,6 +249,22 @@ class MockAttentionLayer:
249 self._v_scale_float = 1.0
250
251
252 +def _clone_kv_cache_in_layout(
253 + kv_cache: torch.Tensor, layout: KVCacheLayout
254 +) -> torch.Tensor:
255 + """Copy a logical [B, H, N, C] cache into a fresh allocation in `layout`."""
256 + logical_5d = (1, *kv_cache.shape)
257 + physical = torch.zeros(
258 + tuple(logical_5d[i] for i in layout.stride_order),
259 + dtype=kv_cache.dtype,
260 + device=kv_cache.device,
261 + )
262 + inv_order = [layout.stride_order.index(i) for i in range(5)]
263 + view = physical.permute(*inv_order)[0]
264 + view.copy_(kv_cache)
265 + return view
266 +
267 +
268 def run_attention_backend(
269 backend: AttentionBackendEnum,
270 kv_cache_spec: FullAttentionSpec,
@@ -254,18 +283,17 @@ def run_attention_backend(
283 ) -> torch.Tensor:
284 """Run attention computation using the specified backend's AttentionImpl."""
285
257 - # Handle special case for FLEX_ATTENTION_SLOW
258 - actual_backend = backend
259 -
260 - use_direct_block_mask = is_torch_equal_or_newer("2.9.0.dev0")
286 + use_direct_block_mask = not current_platform.is_rocm() and is_torch_equal_or_newer(
287 + "2.9.0.dev0"
288 + )
289 if backend == "FLEX_ATTENTION_SLOW":
262 - actual_backend = AttentionBackendEnum.FLEX_ATTENTION
290 use_direct_block_mask = False
291 + backend = _actual_backend(backend)
292
265 - builder_cls, impl_cls = try_get_attention_backend(actual_backend)
293 + builder_cls, impl_cls = try_get_attention_backend(backend)
294
295 # Mock flashinfer's get_per_layer_parameters if needed
268 - if actual_backend == AttentionBackendEnum.FLASHINFER:
296 + if backend == AttentionBackendEnum.FLASHINFER:
297 import unittest.mock
298
299 from vllm.v1.attention.backends.utils import PerLayerParameters
@@ -295,7 +323,7 @@ def run_attention_backend(
323 else:
324 # Build metadata
325 builder = builder_cls(kv_cache_spec, layer_names, vllm_config, device)
298 - if actual_backend == AttentionBackendEnum.FLEX_ATTENTION:
326 + if backend == AttentionBackendEnum.FLEX_ATTENTION:
327 builder.direct_build = use_direct_block_mask
328 attn_metadata = builder.build(
329 common_prefix_len=0,
@@ -311,17 +339,19 @@ def run_attention_backend(
339 )
340 head_size = vllm_config.model_config.get_head_size()
341 scale = 1.0 / (head_size**0.5)
314 - impl = impl_cls(
315 - num_heads=num_heads,
316 - head_size=head_size,
317 - scale=scale,
318 - num_kv_heads=num_kv_heads,
319 - alibi_slopes=None,
320 - sliding_window=sliding_window,
321 - attn_type=attn_type,
322 - kv_cache_dtype=kv_cache_dtype,
323 - **({"sinks": sinks} if sinks is not None else {}),
324 - )
342 + # Impls capture the current vllm config at construction, as in model loading.
343 + with set_current_vllm_config(vllm_config):
344 + impl = impl_cls(
345 + num_heads=num_heads,
346 + head_size=head_size,
347 + scale=scale,
348 + num_kv_heads=num_kv_heads,
349 + alibi_slopes=None,
350 + sliding_window=sliding_window,
351 + attn_type=attn_type,
352 + kv_cache_dtype=kv_cache_dtype,
353 + **({"sinks": sinks} if sinks is not None else {}),
354 + )
355
356 # Create mock layer and output buffer
357 mock_layer = MockAttentionLayer(device)
@@ -333,7 +363,7 @@ def run_attention_backend(
363 # Run forward pass
364 # NOTE: The query, key, and value are already shaped correctly
365 # in the calling test function.
336 - if not try_backend_includes_kv_cache_update(actual_backend):
366 + if not try_backend_includes_kv_cache_update(backend):
367 impl.do_kv_cache_update(
368 mock_layer, key, value, kv_cache, attn_metadata.slot_mapping
369 )
@@ -347,7 +377,7 @@ def run_attention_backend(
377 def _test_backend_correctness(
378 batch_spec: BatchSpec,
379 model: str,
350 - backend_to_test: list[AttentionBackendEnum | str],
380 + backend_to_test: list[AttentionBackendEnum],
381 mask_mod,
382 *,
383 causal: bool = True,
@@ -358,6 +388,7 @@ def _test_backend_correctness(
388 tensor_parallel_size: int = 1,
389 kv_cache_dtype: str = "auto",
390 use_sinks: bool = False,
391 + layout: KVCacheLayout | None = None,
392 ):
393 """
394 Test that all backends produce similar outputs to a reference implementation
@@ -535,6 +566,22 @@ def _test_backend_correctness(
566 common_attn_metadata.causal = causal
567
568 # 3. Simulate Paged KV Cache and a realistic slot_mapping
569 + # Mirror selector-time resolution locally (caller-requested layout, then
570 + # any backend-required layout, then the default chain).
571 + declared = [
572 + supported
573 + for backend in backend_to_test
574 + if (
575 + supported := _actual_backend(backend)
576 + .get_class()
577 + .supported_kv_cache_layouts()
578 + )
579 + is not None
580 + ]
581 + if layout is None:
582 + # Mirror the resolver: the shared cache uses the declared sets' preferred
583 + # layout; backends that don't support it get a per-backend copy below.
584 + layout = min(declared, key=len)[0] if declared else KVCacheLayout.LBNHC
585 kv_cache = create_and_prepopulate_kv_cache(
586 k_contexts=k_contexts,
587 v_contexts=v_contexts,
@@ -545,6 +592,7 @@ def _test_backend_correctness(
592 device=device,
593 num_blocks=vllm_config.cache_config.num_gpu_blocks or 1000,
594 common_attn_metadata=common_attn_metadata,
595 + layout=layout,
596 randomize_blocks=True,
597 kv_cache_dtype=kv_cache_dtype,
598 )
@@ -553,62 +601,44 @@ def _test_backend_correctness(
601 # Note: flex_attention has known Triton kernel compatibility issues
602 # with test infrastructures
603 for backend_name in backend_to_test:
556 - reset_kv_cache_layout = False
557 -
558 - # Resolve backend class for both enum and string names.
559 - actual_backend = backend_name
560 - if backend_name == "FLEX_ATTENTION_SLOW":
561 - actual_backend = AttentionBackendEnum.FLEX_ATTENTION
562 - if hasattr(actual_backend, "get_class"):
563 - backend_cls = actual_backend.get_class()
564 - else:
565 - backend_cls = None
604 + backend_cls = _actual_backend(backend_name).get_class()
605
606 if is_quantized_kv_cache(kv_cache_dtype) and (
568 - backend_cls is None
569 - or not backend_cls.supports_kv_cache_dtype(kv_cache_dtype)
607 + not backend_cls.supports_kv_cache_dtype(kv_cache_dtype)
608 ):
609 continue
610
573 - if backend_name == AttentionBackendEnum.FLASHINFER:
574 - set_kv_cache_layout("HND")
575 - reset_kv_cache_layout = True
576 -
611 kv_cache_for_backend = kv_cache
578 - if backend_cls is not None:
579 - try:
580 - stride_order = backend_cls.get_kv_cache_stride_order()
581 - except (AttributeError, NotImplementedError):
582 - stride_order = tuple(range(kv_cache.ndim))
583 - if stride_order != tuple(range(kv_cache.ndim)):
584 - # Apply stride order like runtime does in
585 - # _reshape_kv_cache (attn_utils.py:182-210): permute to physical
586 - # layout, make contiguous, then permute to logical layout.
587 - inv_order = [stride_order.index(i) for i in range(len(stride_order))]
588 - kv_cache_for_backend = (
589 - kv_cache.permute(*stride_order).contiguous().permute(*inv_order)
590 - )
591 -
592 - try:
593 - backend_output = run_attention_backend(
594 - backend_name,
595 - kv_cache_spec,
596 - ["placeholder"],
597 - vllm_config,
598 - device,
599 - common_attn_metadata,
600 - query_vllm,
601 - key_vllm,
602 - value_vllm,
603 - kv_cache_for_backend,
604 - sliding_window=sliding_window,
605 - attn_type=attn_type,
606 - kv_cache_dtype=kv_cache_dtype,
607 - sinks=sinks,
608 - )
609 - finally:
610 - if reset_kv_cache_layout:
611 - set_kv_cache_layout(None)
612 + backend_layout = layout
613 +
614 + backend_supported = backend_cls.supported_kv_cache_layouts()
615 + if backend_supported is not None and backend_layout not in backend_supported:
616 + # Production resolution never pairs this backend with the shared layout
617 + # (e.g. flex is LBNHC-only next to FlashInfer's LBHNC); give it a copy
618 + # of the cache in a layout it supports.
619 + backend_layout = backend_supported[0]
620 + kv_cache_for_backend = _clone_kv_cache_in_layout(kv_cache, backend_layout)
621 +
622 + # FlashInfer reads the layout at plan time; set it to match
623 + # the physical order of the test cache.
624 + vllm_config.cache_config.kv_cache_layout = backend_layout.name
625 +
626 + backend_output = run_attention_backend(
627 + backend_name,
628 + kv_cache_spec,
629 + ["placeholder"],
630 + vllm_config,
631 + device,
632 + common_attn_metadata,
633 + query_vllm,
634 + key_vllm,
635 + value_vllm,
636 + kv_cache_for_backend,
637 + sliding_window=sliding_window,
638 + attn_type=attn_type,
639 + kv_cache_dtype=kv_cache_dtype,
640 + sinks=sinks,
641 + )
642
643 # Check shape and dtype consistency
644 assert backend_output.shape == sdpa_output.shape, (
@@ -637,6 +667,38 @@ def _test_backend_correctness(
667 )
668
669
670 +@pytest.mark.parametrize("layout", ["BLHNC", "BHLNC"])
671 +@pytest.mark.parametrize("batch_spec_name", ["small_decode", "small_prefill"])
672 +@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8"])
673 +def test_flashinfer_cross_layer_layout(
674 + default_vllm_config,
675 + layout: str,
676 + batch_spec_name: str,
677 + kv_cache_dtype: str,
678 +):
679 + if AttentionBackendEnum.FLASHINFER not in BACKENDS_TO_TEST:
680 + pytest.skip("FlashInfer is not installed")
681 +
682 + def causal_mask_mod(
683 + b: torch.Tensor,
684 + h: torch.Tensor,
685 + q_idx: torch.Tensor,
686 + kv_idx: torch.Tensor,
687 + *,
688 + context_len: int,
689 + ):
690 + return (q_idx + context_len) >= kv_idx
691 +
692 + _test_backend_correctness(
693 + batch_spec=BATCH_SPECS[batch_spec_name],
694 + model="meta-llama/Meta-Llama-3-8B",
695 + backend_to_test=[AttentionBackendEnum.FLASHINFER],
696 + mask_mod=causal_mask_mod,
697 + kv_cache_dtype=kv_cache_dtype,
698 + layout=KVCacheLayout[layout],
699 + )
700 +
701 +
702 @pytest.mark.parametrize(
703 "batch_spec_name",
704 [
@@ -984,14 +1046,12 @@ if current_platform.is_rocm():
1046 SLIDING_WINDOW_BACKENDS_TO_TEST = [
1047 AttentionBackendEnum.FLEX_ATTENTION,
1048 AttentionBackendEnum.TRITON_ATTN,
987 - "FLEX_ATTENTION_SLOW",
1049 ]
1050 else:
1051 SLIDING_WINDOW_BACKENDS_TO_TEST = [
1052 AttentionBackendEnum.FLASH_ATTN,
1053 AttentionBackendEnum.FLEX_ATTENTION,
1054 AttentionBackendEnum.TRITON_ATTN,
994 - "FLEX_ATTENTION_SLOW",
1055 ]
1056
1057
@@ -1009,7 +1069,10 @@ else:
1069 @pytest.mark.parametrize("model", ["microsoft/Phi-tiny-MoE-instruct"])
1070 @pytest.mark.parametrize("tensor_parallel_size", [1, 2, 4])
1071 def test_sliding_window_backend_correctness(
1012 - default_vllm_config, batch_spec_name: str, model: str, tensor_parallel_size: int
1072 + default_vllm_config,
1073 + batch_spec_name: str,
1074 + model: str,
1075 + tensor_parallel_size: int,
1076 ):
1077 """Test backend's correctness with sliding window attention."""
1078
@@ -1071,7 +1134,10 @@ def test_sliding_window_backend_correctness(
1134 @pytest.mark.parametrize("model", ["google/embeddinggemma-300m"])
1135 @pytest.mark.parametrize("tensor_parallel_size", [1, 2])
1136 def test_sliding_window_encoder_backend_correctness(
1074 - default_vllm_config, batch_spec_name: str, model: str, tensor_parallel_size: int
1137 + default_vllm_config,
1138 + batch_spec_name: str,
1139 + model: str,
1140 + tensor_parallel_size: int,
1141 ):
1142 """Test backend's correctness with sliding window attention."""
1143
@@ -1107,7 +1173,6 @@ def test_sliding_window_encoder_backend_correctness(
1173 NON_CAUSAL_BACKENDS_TO_TEST = [
1174 AttentionBackendEnum.FLASH_ATTN,
1175 AttentionBackendEnum.FLEX_ATTENTION,
1110 - "FLEX_ATTENTION_SLOW",
1176 ]
1177
1178 if current_platform.is_rocm():
@@ -1128,7 +1193,9 @@ if current_platform.is_rocm():
1193 )
1194 @pytest.mark.parametrize("model", ["meta-llama/Meta-Llama-3-8B"])
1195 def test_non_causal_backend_correctness(
1131 - default_vllm_config, batch_spec_name: str, model: str
1196 + default_vllm_config,
1197 + batch_spec_name: str,
1198 + model: str,
1199 ):
1200 """Test backend's correctness with non-causal (bidirectional) decoder
1201 attention, as used by DFlash speculative decoding."""
tests/v1/attention/test_group_head_counts.py
+6 -53
@@ -1,6 +1,10 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -"""Attention metadata geometry must come from the builder's own group."""
3 +"""Scheduler metadata sizes a scratchpad from the query head count, so it must
4 +come from the builder's own group: the model-wide ``get_num_attention_heads()``
5 +is wrong for models that vary it per layer (e.g. Laguna), and too small a
6 +scratchpad is indexed past its end.
7 +"""
8
9 from types import SimpleNamespace
10 from unittest.mock import MagicMock, patch
@@ -13,9 +17,8 @@ from vllm.v1.attention.backends.cpu_attn import (
17 CPUAttentionBackendImpl,
18 CPUAttentionMetadataBuilder,
19 )
16 -from vllm.v1.attention.backends.flash_attn import FlashAttentionMetadataBuilder
20
18 -requires_cpu = pytest.mark.skipif(
21 +pytestmark = pytest.mark.skipif(
22 not current_platform.is_cpu(), reason="CPU attention backend"
23 )
24
@@ -66,7 +69,6 @@ def _build(layer_num_heads: list[int]) -> CPUAttentionMetadataBuilder:
69 )
70
71
69 -@requires_cpu
72 @pytest.mark.parametrize("group_num_heads", [MODEL_WIDE_NUM_HEADS, 64, 16])
73 def test_num_heads_comes_from_the_group(group_num_heads):
74 """The group's own count wins, even when it is not the model-wide one."""
@@ -74,56 +76,7 @@ def test_num_heads_comes_from_the_group(group_num_heads):
76 assert builder.num_heads == group_num_heads
77
78
77 -@requires_cpu
79 def test_mixed_head_counts_in_one_group_are_rejected():
80 """Grouping guarantees uniformity; a mixed group means that broke."""
81 with pytest.raises(AssertionError, match="share num_heads"):
82 _build([MODEL_WIDE_NUM_HEADS, 64])
82 -
83 -
84 -def test_flash_attention_geometry_comes_from_the_group():
85 - """FA3 must use group geometry for layers without an ``impl`` wrapper."""
86 - layers = {f"layer_{i}": SimpleNamespace(num_heads=16) for i in range(2)}
87 - vllm_config = MagicMock()
88 - vllm_config.model_config.get_num_attention_heads.return_value = MODEL_WIDE_NUM_HEADS
89 - vllm_config.model_config.get_num_kv_heads.return_value = NUM_KV_HEADS
90 - vllm_config.model_config.get_head_size.return_value = 128
91 - vllm_config.model_config.rswa_window = None
92 - vllm_config.model_config.is_mm_prefix_lm = False
93 - vllm_config.parallel_config.cp_kv_cache_interleave_size = 1
94 - vllm_config.compilation_config.cudagraph_mode.has_full_cudagraphs.return_value = (
95 - False
96 - )
97 - vllm_config.compilation_config.max_cudagraph_capture_size = None
98 - kv_cache_spec = SimpleNamespace(
99 - block_size=16,
100 - num_kv_heads=2,
101 - head_size=64,
102 - dtype=torch.bfloat16,
103 - )
104 -
105 - with (
106 - patch(
107 - "vllm.v1.attention.backends.utils.get_layers_from_vllm_config",
108 - return_value=layers,
109 - ),
110 - patch(
111 - "vllm.distributed.parallel_state.get_dcp_group",
112 - side_effect=AssertionError,
113 - ),
114 - patch(
115 - "vllm.v1.attention.backends.flash_attn.get_flash_attn_version",
116 - return_value=3,
117 - ),
118 - ):
119 - builder = FlashAttentionMetadataBuilder(
120 - kv_cache_spec=kv_cache_spec,
121 - layer_names=list(layers),
122 - vllm_config=vllm_config,
123 - device=torch.device("cpu"),
124 - )
125 -
126 - assert builder.aot_schedule
127 - assert builder.num_heads_q == 16
128 - assert builder.num_heads_kv == 2
129 - assert builder.headdim == 64
tests/v1/attention/test_indexer_deepseek_v4_slot_mapping.py
+4 -4
@@ -34,19 +34,19 @@ def test_indexer_warmup_normalizes_zero_compress_ratios():
34
35
36 @pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
37 -def test_indexer_builder_deepseek_v4_compressed_slot_mapping_uses_storage_block_size():
37 +def test_indexer_builder_deepseek_v4_compressed_slot_mapping_uses_num_states():
38 """Regression test: DeepseekV4 compression path must compute slot_mapping from
39 compressed positions, not reuse the uncompressed common metadata mapping.
40 """
41 device = torch.device("cuda")
42
43 - # storage_block_size = block_size // compress_ratio = 256 // 4 = 64
43 + # num_states = block_size // tokens_per_state = 256 // 4 = 64
44 kv_cache_spec = MLAAttentionSpec(
45 block_size=256,
46 num_kv_heads=1,
47 head_size=128,
48 dtype=torch.bfloat16,
49 - compress_ratio=4,
49 + tokens_per_state=4,
50 )
51 vllm_config = create_vllm_config(max_model_len=1024)
52 max_num_blocks = kv_cache_spec.max_num_blocks_per_req(vllm_config, 1024)
@@ -95,7 +95,7 @@ def test_indexer_builder_deepseek_v4_compressed_slot_mapping_uses_storage_block_
95 valid_slots = md.slot_mapping[md.slot_mapping >= 0]
96 assert valid_slots.numel() == 10 # 40 tokens / compress_ratio 4
97
98 - storage_bs = kv_cache_spec.storage_block_size # 64
98 + storage_bs = kv_cache_spec.num_states # 64
99 # Compressed positions 60..63 land in block 5, positions 64..69 in block 7.
100 expected = torch.tensor(
101 [
tests/v1/attention/test_mla_backends.py
+21 -4
@@ -250,6 +250,15 @@ def _prefill_backend_dimension_params():
250 )
251 )
252 )
253 + elif (
254 + current_platform.is_rocm()
255 + and prefill_backend == MLAPrefillBackendEnum.FLASH_ATTN
256 + ):
257 + # Pre-existing on main: flash-attn MLA prefill returns wrong
258 + # outputs and faults on ROCm, so it fails on the base commit too.
259 + marks.append(
260 + pytest.mark.skip(reason="FLASH_ATTN MLA prefill broken on ROCm")
261 + )
262 params.append(
263 pytest.param(
264 prefill_backend,
@@ -392,7 +401,7 @@ def create_and_prepopulate_kv_cache(
401 kv_entry_size = head_size
402
403 kv_cache = torch.zeros(
395 - num_blocks, block_size, kv_entry_size, dtype=torch.uint8, device=device
404 + num_blocks, 1, block_size, kv_entry_size, dtype=torch.uint8, device=device
405 )
406 scale_tensor = (
407 scale
@@ -401,9 +410,9 @@ def create_and_prepopulate_kv_cache(
410 )
411 scale_tensor = scale_tensor.to(device=device, dtype=torch.float32)
412 else:
404 - # Create MLA KV cache: (num_blocks, block_size, head_size)
413 + # Create MLA KV cache: (num_blocks, num_heads=1, block_size, head_size)
414 kv_cache = torch.zeros(
406 - num_blocks, block_size, head_size, dtype=dtype, device=device
415 + num_blocks, 1, block_size, head_size, dtype=dtype, device=device
416 )
417 kv_cache_flat = kv_cache.view(-1, head_size)
418
@@ -424,7 +433,7 @@ def create_and_prepopulate_kv_cache(
433 ops.concat_and_cache_mla(
434 kv_c_context,
435 k_pe_context.squeeze(1),
427 - kv_cache,
436 + kv_cache.squeeze(1),
437 slots,
438 kv_cache_dtype=kv_cache_dtype,
439 scale=scale_tensor,
@@ -543,6 +552,10 @@ class MockSparseMLAAttentionLayer:
552 kv_cache_dtype = getattr(self.impl, "kv_cache_dtype", "auto")
553 fp8_attention = kv_cache_dtype.startswith("fp8")
554
555 + # Impls see the bind-time-squeezed [B, N, C] cache; mirror bind_kv_cache.
556 + if kv_cache.ndim == 4:
557 + kv_cache = kv_cache.squeeze(1)
558 +
559 # Write to KV cache
560 if kv_cache.numel() > 0:
561 ops.concat_and_cache_mla(
@@ -678,6 +691,10 @@ class MockMLAAttentionLayer(MLAAttention):
691 output: torch.Tensor,
692 ) -> torch.Tensor:
693 """Replicates MLAAttention.forward_impl logic for testing."""
694 + # Impls see the bind-time-squeezed [B, N, C] cache; mirror bind_kv_cache.
695 + if kv_cache.ndim == 4:
696 + kv_cache = kv_cache.squeeze(1)
697 +
698 # Write to KV cache
699 kv_cache_dtype = getattr(self.impl, "kv_cache_dtype", "auto")
700 fp8_attention = kv_cache_dtype.startswith("fp8")
tests/v1/attention/test_mm_prefix.py
+5 -1
@@ -20,7 +20,10 @@ import pytest
20 import torch
21
22 from vllm.platforms import current_platform
23 -from vllm.v1.attention.backends.utils import fill_mm_prefix_query_ranges
23 +from vllm.v1.attention.backends.utils import (
24 + fill_mm_prefix_query_ranges,
25 +)
26 +from vllm.v1.kv_cache_interface import KVCacheLayout
27
28
29 def _fa4_available() -> bool:
@@ -585,6 +588,7 @@ def test_mm_prefix_kv_cache_path(head_size: int):
588 device=DEVICE,
589 num_blocks=2048,
590 common_attn_metadata=common,
591 + layout=KVCacheLayout.LBNHC,
592 randomize_blocks=True,
593 )
594
tests/v1/attention/test_sparse_mla_backends.py
+3 -2
@@ -813,7 +813,7 @@ def test_flashmla_forward_bf16_kv_slices_req_id_to_mqa_tokens():
813 assert attn_metadata.req_id_per_token.shape[0] == num_batch_tokens
814
815 q = torch.zeros(num_mqa_tokens, 4, 576, dtype=torch.bfloat16, device=device)
816 - kv_cache = torch.zeros(40 * block_size, 576, dtype=torch.bfloat16, device=device)
816 + kv_cache = torch.zeros(40, block_size, 576, dtype=torch.bfloat16, device=device)
817 topk_indices = torch.randint(
818 0,
819 block_size * 10,
@@ -1157,7 +1157,8 @@ def test_sparse_backend_prefill_correctness(
1157 q=query_cat,
1158 kv_c_normed=kv_c_cat,
1159 k_pe=k_pe_cat,
1160 - kv_c_and_k_pe_cache=kv_cache,
1160 + # Impls see the bind-time-squeezed [B, N, C] cache; mirror bind_kv_cache.
1161 + kv_c_and_k_pe_cache=kv_cache.squeeze(1),
1162 attn_metadata=metadata,
1163 k_scale=torch.tensor(1.0, device=device),
1164 output=out_buffer,
tests/v1/attention/test_trtllm_attention_integration.py
+120 -219
@@ -9,6 +9,7 @@ import pytest
9 import torch
10 from torch.nn.attention.flex_attention import create_block_mask, flex_attention
11
12 +from tests.v1.attention.test_attention_backends import create_and_prepopulate_kv_cache
13 from tests.v1.attention.utils import (
14 BatchSpec,
15 create_common_attn_metadata,
@@ -16,14 +17,9 @@ from tests.v1.attention.utils import (
17 )
18 from vllm.config import set_current_vllm_config
19 from vllm.platforms import current_platform
19 -from vllm.utils.math_utils import cdiv
20 from vllm.utils.torch_utils import nvfp4_kv_cache_full_dim, set_random_seed
21 -from vllm.v1.attention.backends.utils import (
22 - PerLayerParameters,
23 - get_kv_cache_layout,
24 - set_kv_cache_layout,
25 -)
26 -from vllm.v1.kv_cache_interface import FullAttentionSpec, KVQuantMode
21 +from vllm.v1.attention.backends.utils import PerLayerParameters
22 +from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheLayout, KVQuantMode
23
24 if not current_platform.is_device_capability_family(100):
25 pytest.skip(
@@ -87,96 +83,6 @@ def _mock_get_per_layer_parameters(vllm_config, layer_names, impl_cls):
83 }
84
85
90 -def _create_hnd_kv_cache(
91 - k_contexts,
92 - v_contexts,
93 - block_size,
94 - num_kv_heads,
95 - head_size,
96 - dtype,
97 - device,
98 - num_blocks,
99 - common_attn_metadata,
100 - kv_in_head_dim=False,
101 -):
102 - """Create and populate a packed KV cache with HND-compatible strides.
103 -
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 = (
110 - common_attn_metadata.query_start_loc_cpu[1:]
111 - - common_attn_metadata.query_start_loc_cpu[:-1]
112 - )
113 - block_table = common_attn_metadata.block_table_tensor
114 - slot_mapping = common_attn_metadata.slot_mapping
115 - batch_size = len(k_contexts)
116 -
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,
127 - n_heads,
128 - content,
129 - dtype=dtype,
130 - device=device,
131 - )
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]
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
148 -
149 - # Randomly permute blocks (starting from block 1; block 0 is null).
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
153 - kv_cache[1:blocks_end] = kv_cache[perm]
154 -
155 - # Build block table.
156 - start_block_idx = 1
157 - for i in range(batch_size):
158 - n_blocks = cdiv(int(seq_lens[i]), block_size)
159 - block_table[i, :n_blocks] = inv_perm[
160 - start_block_idx : start_block_idx + n_blocks
161 - ]
162 - start_block_idx += n_blocks
163 -
164 - # Build slot mapping that is consistent with the block table.
165 - for i in range(batch_size):
166 - ctx_len = int(seq_lens[i]) - int(query_lens[i])
167 - token_offsets = torch.arange(int(query_lens[i])) + ctx_len
168 - block_indices = token_offsets // block_size
169 - intra_block_offsets = token_offsets % block_size
170 - start = common_attn_metadata.query_start_loc_cpu[i]
171 - end = common_attn_metadata.query_start_loc_cpu[i + 1]
172 - slot_mapping[start:end] = block_table[
173 - i, block_indices
174 - ] * block_size + intra_block_offsets.to(device)
175 -
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 -
86 def _create_nvfp4_hnd_kv_cache(
87 k_contexts,
88 v_contexts,
@@ -191,7 +97,7 @@ def _create_nvfp4_hnd_kv_cache(
97 ):
98 """Create an nvfp4 KV cache by quantizing bf16 context via
99 reshape_and_cache_flash, using the same block-table layout as
194 - _create_hnd_kv_cache.
100 + create_and_prepopulate_kv_cache.
101
102 The returned tensor is dtype ``uint8`` with head-group layout
103 ``(num_blocks, 2 * num_kv_heads, block_size, full_dim)``
@@ -217,11 +123,10 @@ def _create_nvfp4_hnd_kv_cache(
123 during quantization.
124
125 Returns:
220 - ``torch.Tensor``: The nvfp4 kv_cache tensor (uint8, HND-strided).
126 + ``torch.Tensor``: The nvfp4 kv_cache tensor (uint8, LBHNC-strided).
127 """
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(
128 + # First create a bf16 cache so block tables are populated.
129 + bf16_cache = create_and_prepopulate_kv_cache(
130 k_contexts,
131 v_contexts,
132 block_size,
@@ -231,7 +136,7 @@ def _create_nvfp4_hnd_kv_cache(
136 device,
137 num_blocks,
138 common_attn_metadata,
234 - kv_in_head_dim=True,
139 + layout=KVCacheLayout.LBHNC,
140 )
141
142 # (num_blocks, 2 * num_kv_heads, block_size, full_dim) — K heads first, then V heads
@@ -244,7 +149,7 @@ def _create_nvfp4_hnd_kv_cache(
149 k_cache, v_cache = nvfp4_cache.split(num_kv_heads, dim=1)
150
151 # Flatten bf16 context into tokens and quantize via reshape_and_cache_flash.
247 - # bf16_cache is (B, 2*H, N, hs); split K/V on head dim.
152 + # bf16_cache is (B, H, N, 2*hs); split K/V on the content dim.
153 block_table = common_attn_metadata.block_table_tensor
154 seq_lens = common_attn_metadata.seq_lens.cpu()
155 query_lens = (
@@ -260,10 +165,16 @@ def _create_nvfp4_hnd_kv_cache(
165 # Gather context tokens from the bf16 cache using block table.
166 n_ctx_blocks = (ctx_len + block_size - 1) // block_size
167 blocks = block_table[i, :n_ctx_blocks]
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]
168 + k_ctx = (
169 + bf16_cache[blocks, :, :, :head_size]
170 + .transpose(1, 2)
171 + .reshape(-1, num_kv_heads, head_size)[:ctx_len]
172 + )
173 + v_ctx = (
174 + bf16_cache[blocks, :, :, head_size:]
175 + .transpose(1, 2)
176 + .reshape(-1, num_kv_heads, head_size)[:ctx_len]
177 + )
178 # Build slot mapping for these context tokens.
179 token_offsets = torch.arange(ctx_len, device=device)
180 block_indices = token_offsets // block_size
@@ -383,7 +294,7 @@ def _run_trtllm_integration(batch_spec, kv_cache_dtype="auto", model_name=MODEL)
294 )
295 else:
296 kv_scale_val = 1.0
386 - kv_cache = _create_hnd_kv_cache(
297 + kv_cache = create_and_prepopulate_kv_cache(
298 k_contexts,
299 v_contexts,
300 BLOCK_SIZE,
@@ -393,127 +304,117 @@ def _run_trtllm_integration(batch_spec, kv_cache_dtype="auto", model_name=MODEL)
304 device,
305 NUM_GPU_BLOCKS,
306 common_attn_metadata,
307 + layout=KVCacheLayout.LBHNC,
308 )
309
310 # 3. Run through FlashInfer with TRTLLM enabled
399 - set_kv_cache_layout("HND")
400 - get_kv_cache_layout.cache_clear()
401 -
402 - try:
403 - is_nvfp4 = kv_cache_dtype == "nvfp4"
404 - kv_quant_mode = KVQuantMode.NVFP4 if is_nvfp4 else KVQuantMode.NONE
405 - spec_dtype = torch.uint8 if is_nvfp4 else dtype
406 - kv_cache_spec = FullAttentionSpec(
407 - block_size=BLOCK_SIZE,
408 - num_kv_heads=num_kv_heads,
409 - head_size=head_size,
410 - dtype=spec_dtype,
411 - kv_quant_mode=kv_quant_mode,
311 + vllm_config.cache_config.kv_cache_layout = "LBHNC"
312 +
313 + is_nvfp4 = kv_cache_dtype == "nvfp4"
314 + kv_quant_mode = KVQuantMode.NVFP4 if is_nvfp4 else KVQuantMode.NONE
315 + spec_dtype = torch.uint8 if is_nvfp4 else dtype
316 + kv_cache_spec = FullAttentionSpec(
317 + block_size=BLOCK_SIZE,
318 + num_kv_heads=num_kv_heads,
319 + head_size=head_size,
320 + dtype=spec_dtype,
321 + kv_quant_mode=kv_quant_mode,
322 + )
323 + layer_names = ["test_layer_0"]
324 +
325 + with (
326 + set_current_vllm_config(vllm_config),
327 + unittest.mock.patch(
328 + "vllm.utils.flashinfer.supports_trtllm_attention",
329 + return_value=True,
330 + ),
331 + unittest.mock.patch(
332 + "vllm.v1.attention.backends.flashinfer.get_per_layer_parameters",
333 + _mock_get_per_layer_parameters,
334 + ),
335 + ):
336 + builder = FlashInferMetadataBuilder(
337 + kv_cache_spec, layer_names, vllm_config, device
338 + )
339 + attn_metadata = builder.build(
340 + common_prefix_len=0,
341 + common_attn_metadata=common_attn_metadata,
342 )
413 - layer_names = ["test_layer_0"]
414 -
415 - with (
416 - set_current_vllm_config(vllm_config),
417 - unittest.mock.patch(
418 - "vllm.utils.flashinfer.supports_trtllm_attention",
419 - return_value=True,
420 - ),
421 - unittest.mock.patch(
422 - "vllm.v1.attention.backends.flashinfer.get_per_layer_parameters",
423 - _mock_get_per_layer_parameters,
424 - ),
425 - ):
426 - builder = FlashInferMetadataBuilder(
427 - kv_cache_spec, layer_names, vllm_config, device
343 +
344 + # Verify the correct TRTLLM metadata types were produced.
345 + has_prefills = any(ql > 1 for ql in batch_spec.query_lens)
346 + has_decodes = any(ql == 1 for ql in batch_spec.query_lens)
347 +
348 + if has_prefills:
349 + assert isinstance(attn_metadata.prefill, TRTLLMPrefill), (
350 + f"Expected TRTLLMPrefill, got {type(attn_metadata.prefill)}"
351 )
429 - attn_metadata = builder.build(
430 - common_prefix_len=0,
431 - common_attn_metadata=common_attn_metadata,
352 + if has_decodes:
353 + assert isinstance(attn_metadata.decode, FlashInferTrtllmAPIDecode), (
354 + f"Expected FlashInferTrtllmAPIDecode, got {type(attn_metadata.decode)}"
355 )
356 + assert attn_metadata.decode.kernel == FlashInferDecodeKernel.TRTLLM_GEN
357
434 - # Verify the correct TRTLLM metadata types were produced.
435 - has_prefills = any(ql > 1 for ql in batch_spec.query_lens)
436 - has_decodes = any(ql == 1 for ql in batch_spec.query_lens)
437 -
438 - if has_prefills:
439 - assert isinstance(attn_metadata.prefill, TRTLLMPrefill), (
440 - f"Expected TRTLLMPrefill, got {type(attn_metadata.prefill)}"
441 - )
442 - if has_decodes:
443 - assert isinstance(attn_metadata.decode, FlashInferTrtllmAPIDecode), (
444 - "Expected FlashInferTrtllmAPIDecode, got "
445 - f"{type(attn_metadata.decode)}"
446 - )
447 - assert attn_metadata.decode.kernel == FlashInferDecodeKernel.TRTLLM_GEN
448 -
449 - impl = FlashInferImpl(
450 - num_heads=num_q_heads,
451 - head_size=head_size,
452 - scale=scale,
453 - num_kv_heads=num_kv_heads,
454 - alibi_slopes=None,
455 - sliding_window=None,
456 - kv_cache_dtype=kv_cache_dtype,
457 - )
358 + impl = FlashInferImpl(
359 + num_heads=num_q_heads,
360 + head_size=head_size,
361 + scale=scale,
362 + num_kv_heads=num_kv_heads,
363 + alibi_slopes=None,
364 + sliding_window=None,
365 + kv_cache_dtype=kv_cache_dtype,
366 + )
367
459 - mock_layer = MockAttentionLayer(device)
460 - if is_nvfp4:
461 - # For nvfp4, k_scale/v_scale are the global quantization
462 - # scales (amax/448) used by reshape_and_cache_flash.
463 - kv_scale_t = torch.tensor(
464 - kv_scale_val, dtype=torch.float32, device=device
465 - )
466 - mock_layer._k_scale = kv_scale_t
467 - mock_layer._v_scale = kv_scale_t
468 - mock_layer._k_scale_float = kv_scale_val
469 - mock_layer._v_scale_float = kv_scale_val
470 - output = torch.empty_like(query_vllm)
471 -
472 - impl.do_kv_cache_update(
473 - mock_layer,
474 - key_vllm,
475 - value_vllm,
476 - kv_cache,
477 - attn_metadata.slot_mapping,
478 - )
368 + mock_layer = MockAttentionLayer(device)
369 + if is_nvfp4:
370 + # For nvfp4, k_scale/v_scale are the global quantization
371 + # scales (amax/448) used by reshape_and_cache_flash.
372 + kv_scale_t = torch.tensor(kv_scale_val, dtype=torch.float32, device=device)
373 + mock_layer._k_scale = kv_scale_t
374 + mock_layer._v_scale = kv_scale_t
375 + mock_layer._k_scale_float = kv_scale_val
376 + mock_layer._v_scale_float = kv_scale_val
377 + output = torch.empty_like(query_vllm)
378 +
379 + impl.do_kv_cache_update(
380 + mock_layer,
381 + key_vllm,
382 + value_vllm,
383 + kv_cache,
384 + attn_metadata.slot_mapping,
385 + )
386
480 - # nvfp4 trtllm kernel requires FP8 queries. In the real
481 - # pipeline the attention layer handles this; here we
482 - # quantize manually.
483 - if is_nvfp4:
484 - finfo = torch.finfo(torch.float8_e4m3fn)
485 - q_amax = query_vllm.abs().amax().clamp(min=1e-12)
486 - q_s = (finfo.max / q_amax * 0.1).item()
487 - query_vllm = (
488 - (query_vllm * q_s)
489 - .clamp(finfo.min, finfo.max)
490 - .to(torch.float8_e4m3fn)
491 - )
492 - mock_layer._q_scale = torch.tensor(
493 - 1.0 / q_s, dtype=torch.float32, device=device
494 - )
495 - mock_layer._q_scale_float = 1.0 / q_s
496 -
497 - output = impl.forward(
498 - mock_layer,
499 - query_vllm,
500 - key_vllm,
501 - value_vllm,
502 - kv_cache,
503 - attn_metadata,
504 - output=output,
387 + # nvfp4 trtllm kernel requires FP8 queries. In the real
388 + # pipeline the attention layer handles this; here we
389 + # quantize manually.
390 + if is_nvfp4:
391 + finfo = torch.finfo(torch.float8_e4m3fn)
392 + q_amax = query_vllm.abs().amax().clamp(min=1e-12)
393 + q_s = (finfo.max / q_amax * 0.1).item()
394 + query_vllm = (
395 + (query_vllm * q_s).clamp(finfo.min, finfo.max).to(torch.float8_e4m3fn)
396 + )
397 + mock_layer._q_scale = torch.tensor(
398 + 1.0 / q_s, dtype=torch.float32, device=device
399 )
400 + mock_layer._q_scale_float = 1.0 / q_s
401 +
402 + output = impl.forward(
403 + mock_layer,
404 + query_vllm,
405 + key_vllm,
406 + value_vllm,
407 + kv_cache,
408 + attn_metadata,
409 + output=output,
410 + )
411
507 - # 4. Compare against SDPA reference
508 - if is_nvfp4:
509 - atol, rtol = 1.0, 1.0 # nvfp4 has higher quantization error
510 - else:
511 - atol, rtol = 1e-2, 1e-2
512 - torch.testing.assert_close(output, sdpa_output, atol=atol, rtol=rtol)
513 -
514 - finally:
515 - set_kv_cache_layout(None)
516 - get_kv_cache_layout.cache_clear()
412 + # 4. Compare against SDPA reference
413 + if is_nvfp4:
414 + atol, rtol = 1.0, 1.0 # nvfp4 has higher quantization error
415 + else:
416 + atol, rtol = 1e-2, 1e-2
417 + torch.testing.assert_close(output, sdpa_output, atol=atol, rtol=rtol)
418
419
420 @pytest.mark.parametrize(
tests/v1/attention/utils.py
+36
@@ -32,7 +32,12 @@ from vllm.v1.attention.backends.registry import AttentionBackendEnum
32 from vllm.v1.kv_cache_interface import (
33 EncoderOnlyAttentionSpec,
34 FullAttentionSpec,
35 + KVCacheLayout,
36 + KVCacheSpec,
37 + KVCacheTensor,
38 MambaSpec,
39 + compute_layout_strides,
40 + create_kv_cache_views,
41 get_kv_quant_mode,
42 )
43
@@ -215,6 +220,9 @@ def create_vllm_config(
220 # (these may be set during initialization normally)
221 cache_config.num_gpu_blocks = num_gpu_blocks
222 cache_config.num_cpu_blocks = 0
223 + # Builders read the resolved layout from the config; tests that exercise a
224 + # different layout overwrite this field.
225 + cache_config.kv_cache_layout = "LBNHC"
226
227 parallel_config = ParallelConfig(
228 tensor_parallel_size=tensor_parallel_size,
@@ -417,3 +425,31 @@ class MockMambaBuilder(BaseMambaAttentionMetadataBuilder[BaseMambaAttentionMetad
425 is_prefilling=torch.tensor(is_prefilling, dtype=torch.bool)
426 )
427 return builder.build(0, common_metadata)
428 +
429 +
430 +def dense_kv_cache_views(
431 + raw: torch.Tensor,
432 + spec: KVCacheSpec,
433 + num_blocks: int,
434 + num_layers: int,
435 + layout: KVCacheLayout,
436 + kernel_block_size: int | None = None,
437 +) -> list[torch.Tensor]:
438 + """``create_kv_cache_views`` for a dense allocation of ``num_layers`` layers."""
439 + layer_stride, block_stride, _, _, _ = compute_layout_strides(
440 + spec, num_blocks, num_layers, layout
441 + )
442 + tensor = KVCacheTensor(
443 + size=raw.numel(),
444 + layers=[str(i) for i in range(num_layers)],
445 + layer_stride=layer_stride,
446 + block_stride=block_stride,
447 + )
448 + return create_kv_cache_views(
449 + raw,
450 + spec,
451 + num_blocks,
452 + layout,
453 + tensor,
454 + kernel_block_size=kernel_block_size,
455 + )
tests/v1/core/test_contiguous_kv_packing.py
+175 -318
@@ -1,380 +1,237 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -"""Tests for contiguous KV cache packing."""
3 +"""Tests for contiguous KV cache packing.
4 +
5 +Every cache group packs its layers densely into one block; groups overlay each other
6 +(a block ID is owned by one group at a time), so the packed block stride is the
7 +largest group's packing. The layout decides whether the layer dim sits outside the
8 +block dim (a contiguous region per layer) or inside it (all layers' pages within each
9 +block); the allocation is the same either way.
10 +"""
11
12 from unittest.mock import MagicMock
13
14 import pytest
15 import torch
16
17 +from vllm.config import CacheConfig
18 from vllm.v1.core.kv_cache_utils import (
11 - _get_kv_cache_config_packed,
12 - _get_kv_cache_groups_uniform_groups,
19 + _get_kv_cache_bytes_per_block,
20 + _pool_bytes_per_block,
21 get_kv_cache_config_from_groups,
22 )
23 from vllm.v1.kv_cache_interface import (
24 FullAttentionSpec,
25 KVCacheGroupSpec,
18 - KVCacheTensor,
26 + KVCacheLayout,
27 MLAAttentionSpec,
20 - SlidingWindowMLASpec,
21 - SlidingWindowSpec,
28 UniformTypeKVCacheSpecs,
29 )
30 +from vllm.v1.worker.utils import allocate_kv_cache
31 +
32 +MEMORY = 8 * 1024 * 1024
33
34
26 -def _make_mla_spec(page_size: int, block_size: int = 256) -> MLAAttentionSpec:
35 +def _mla(head_size: int) -> MLAAttentionSpec:
36 return MLAAttentionSpec(
28 - block_size=block_size,
29 - num_kv_heads=1,
30 - head_size=512,
31 - dtype=torch.uint8,
32 - page_size_padded=page_size,
33 - cache_dtype_str="fp8_ds_mla",
34 - model_version="deepseek_v4",
35 - alignment=576,
37 + block_size=64, num_kv_heads=1, head_size=head_size, dtype=torch.uint8
38 )
39
40
39 -def _make_full_spec() -> FullAttentionSpec:
41 +def _full() -> FullAttentionSpec:
42 return FullAttentionSpec(
41 - block_size=16,
42 - num_kv_heads=2,
43 - head_size=64,
44 - dtype=torch.float16,
43 + block_size=16, num_kv_heads=2, head_size=64, dtype=torch.float16
44 )
45
46
48 -def _make_sw_spec() -> SlidingWindowSpec:
49 - return SlidingWindowSpec(
50 - block_size=16,
51 - num_kv_heads=2,
52 - head_size=64,
53 - dtype=torch.float16,
54 - sliding_window=128,
55 - )
56 -
57 -
58 -def _make_groups(n_c4, n_c128, n_swa):
59 - PS_C4_MLA = 37440
60 - PS_C4_IDX = 8640
61 - PS_C128 = 1728
62 - PS_SWA = 37440
63 -
64 - mla_specs = {}
65 - for i in range(n_c4):
66 - mla_specs[f"c4_mla.{i}"] = _make_mla_spec(PS_C4_MLA)
67 - mla_specs[f"c4_idx.{i}"] = _make_mla_spec(PS_C4_IDX)
68 - for i in range(n_c128):
69 - mla_specs[f"c128_mla.{i}"] = _make_mla_spec(PS_C128)
70 -
71 - mla_group = KVCacheGroupSpec(
72 - layer_names=list(mla_specs.keys()),
73 - kv_cache_spec=UniformTypeKVCacheSpecs(block_size=256, kv_cache_specs=mla_specs),
47 +def _uniform_group(specs: dict) -> KVCacheGroupSpec:
48 + return KVCacheGroupSpec(
49 + list(specs),
50 + UniformTypeKVCacheSpecs(block_size=64, kv_cache_specs=specs),
51 )
52
76 - swa_specs = {}
77 - for i in range(n_swa):
78 - swa_specs[f"swa.{i}"] = _make_mla_spec(PS_SWA)
79 -
80 - swa_group = KVCacheGroupSpec(
81 - layer_names=list(swa_specs.keys()),
82 - kv_cache_spec=UniformTypeKVCacheSpecs(block_size=256, kv_cache_specs=swa_specs),
83 - )
53
85 - return [mla_group, swa_group]
54 +def _mixed_page_groups(n_mla=3, n_idx=3, n_swa=5):
55 + """A DeepSeek-V4-style hybrid: two groups with different page mixes."""
56 + g1 = {f"mla.{i}": _mla(512) for i in range(n_mla)}
57 + g1.update({f"idx.{i}": _mla(128) for i in range(n_idx)})
58 + g2 = {f"swa.{i}": _mla(512) for i in range(n_swa)}
59 + return [_uniform_group(g1), _uniform_group(g2)], g1, g2
60
61
88 -def _mock_vllm_config(kv_connector_extra_config: dict[str, str] | None = None):
62 +def _mock_vllm_config(layout: str | None):
63 config = MagicMock()
64 + config.cache_config = CacheConfig()
65 config.cache_config.num_gpu_blocks_override = None
91 - config.cache_config.prefix_cache_retention_interval = 0
92 - config.kv_transfer_config = None
93 - if kv_connector_extra_config is not None:
94 - config.kv_transfer_config = MagicMock()
95 - config.kv_transfer_config.kv_connector_extra_config = kv_connector_extra_config
66 + config.cache_config.kv_cache_layout = layout
67 return config
68
69
99 -def _run(n_c4=3, n_c128=2, n_swa=5, mem=100 * 1024 * 1024):
100 - groups = _make_groups(n_c4, n_c128, n_swa)
101 - return _get_kv_cache_config_packed(_mock_vllm_config(), groups, mem)
102 -
103 -
104 -def _page_sizes_by_layer(
105 - groups: list[KVCacheGroupSpec],
106 -) -> dict[str, int]:
107 - page_sizes = {}
108 - for group in groups:
109 - specs = group.kv_cache_spec.kv_cache_specs
110 - for layer_name in group.layer_names:
111 - page_sizes[layer_name] = specs[layer_name].page_size_bytes
112 - return page_sizes
113 -
114 -
115 -def _packing_by_layer(
116 - tensors: list[KVCacheTensor],
117 -) -> dict[str, tuple[int, int]]:
70 +def _pages(groups) -> dict[str, int]:
71 return {
119 - layer_name: (tensor.offset, tensor.block_stride)
120 - for tensor in tensors
121 - for layer_name in tensor.shared_by
72 + name: group.kv_cache_spec.kv_cache_specs[name].page_size_bytes
73 + if isinstance(group.kv_cache_spec, UniformTypeKVCacheSpecs)
74 + else group.kv_cache_spec.page_size_bytes
75 + for group in groups
76 + for name in group.layer_names
77 }
78
79
125 -def _make_views(
126 - groups: list[KVCacheGroupSpec],
127 - num_blocks: int,
128 - tensors: list[KVCacheTensor],
129 -) -> dict[str, torch.Tensor]:
130 - page_sizes = _page_sizes_by_layer(groups)
131 - packing = _packing_by_layer(tensors)
132 - backing = torch.zeros(tensors[0].size, dtype=torch.uint8)
133 - return {
134 - layer_name: torch.as_strided(
135 - backing,
136 - size=(num_blocks, page_size),
137 - stride=(packing[layer_name][1], 1),
138 - storage_offset=packing[layer_name][0],
139 - )
140 - for layer_name, page_size in page_sizes.items()
141 - }
80 +def _expected_bytes_per_block(groups) -> int:
81 + pages = _pages(groups)
82 + return max(sum(pages[n] for n in g.layer_names) for g in groups)
83
84
144 -def _make_page_group(prefix: str, page_sizes: list[int]) -> KVCacheGroupSpec:
145 - specs = {
146 - f"{prefix}.{i}": MagicMock(page_size_bytes=page_size)
147 - for i, page_size in enumerate(page_sizes)
148 - }
149 - return KVCacheGroupSpec(
150 - layer_names=list(specs),
151 - kv_cache_spec=UniformTypeKVCacheSpecs(block_size=256, kv_cache_specs=specs),
152 - )
153 -
154 -
155 -class TestInterleavedPacking:
156 - def test_compact_cache_overlays_fp32_state_group(self):
157 - full_specs = {}
158 - state_specs = {}
159 - for i in range(2):
160 - full_specs[f"mla.{i}"] = MLAAttentionSpec(
161 - block_size=256,
162 - num_kv_heads=1,
163 - head_size=512,
164 - dtype=torch.uint8,
165 - page_size_padded=32768,
166 - indexes_kv_by_block_stride=True,
167 - compress_ratio=4,
168 - )
169 - full_specs[f"indexer.{i}"] = MLAAttentionSpec(
170 - block_size=256,
171 - num_kv_heads=1,
172 - head_size=68,
173 - dtype=torch.uint8,
174 - page_size_padded=4608,
175 - compress_ratio=4,
176 - )
177 - state_specs[f"mla_state.{i}"] = SlidingWindowMLASpec(
178 - block_size=4,
179 - num_kv_heads=1,
180 - head_size=2048,
181 - dtype=torch.float32,
182 - sliding_window=8,
183 - indexes_kv_by_block_stride=True,
184 - )
185 - state_specs[f"indexer_state.{i}"] = SlidingWindowMLASpec(
186 - block_size=4,
187 - num_kv_heads=1,
188 - head_size=512,
189 - dtype=torch.float32,
190 - sliding_window=8,
191 - indexes_kv_by_block_stride=True,
192 - )
85 +def _bind(config, layout: str):
86 + return allocate_kv_cache(config, torch.device("cpu"), KVCacheLayout[layout], None)
87
194 - grouped_specs = [
195 - UniformTypeKVCacheSpecs(block_size=256, kv_cache_specs=full_specs),
196 - UniformTypeKVCacheSpecs(block_size=4, kv_cache_specs=state_specs),
197 - ]
198 - groups = _get_kv_cache_groups_uniform_groups(grouped_specs)
88
200 - assert len(groups) == 2
201 - assert {full_specs[f"indexer.{i}"].page_size_bytes for i in range(2)} == {4608}
202 - assert {full_specs[f"indexer.{i}"].real_page_size_bytes for i in range(2)} == {
203 - 4352
204 - }
205 - assert {
206 - state_specs[f"indexer_state.{i}"].page_size_bytes for i in range(2)
207 - } == {8192}
208 -
209 - full_group_bytes = 2 * (32768 + 4608)
210 - state_group_bytes = 2 * (32768 + 8192)
211 - bytes_per_block = max(full_group_bytes, state_group_bytes)
212 - num_blocks, tensors = _get_kv_cache_config_packed(
213 - _mock_vllm_config(), groups, bytes_per_block * 32
89 +class TestDensePacking:
90 + def test_bytes_per_block_is_largest_group(self):
91 + groups, g1, g2 = _mixed_page_groups()
92 + assert _get_kv_cache_bytes_per_block(groups) == _expected_bytes_per_block(
93 + groups
94 )
215 - assert num_blocks == 32
216 - assert {tensor.block_stride for tensor in tensors} == {bytes_per_block}
217 -
218 - packing = _packing_by_layer(tensors)
219 - assert packing["mla.0"][0] == packing["mla_state.0"][0] == 0
220 - assert packing["indexer.0"][0] == 32768
221 - assert packing["indexer_state.0"][0] == 32768
222 -
223 - def test_deepseek_v4_pro_stride(self):
224 - groups = [
225 - _make_page_group("full", [32768, 4608] * 30 + [1024] * 31),
226 - _make_page_group("c4_state", [32768, 8192] * 30),
227 - _make_page_group("c128_state", [32768] * 31),
228 - _make_page_group("swa.0", [32768] * 31),
229 - _make_page_group("swa.1", [32768] * 30),
230 - ]
231 - expected_stride = 1_228_800
95
233 - num_blocks, tensors = _get_kv_cache_config_packed(
234 - _mock_vllm_config(), groups, expected_stride * 32
96 + config = get_kv_cache_config_from_groups(
97 + _mock_vllm_config("BLHNC"), groups, MEMORY
98 )
99 + # Groups overlay: both start at offset 0.
100 + assert [tensor.offset for tensor in config.kv_cache_tensors].count(0) == 2
101 + assert [tensor.layers for tensor in config.kv_cache_tensors] == [
102 + list(g1)[:3],
103 + list(g1)[3:],
104 + list(g2),
105 + ]
106
237 - assert num_blocks == 32
238 - assert {tensor.block_stride for tensor in tensors} == {expected_stride}
239 - assert {tensor.size for tensor in tensors} == {expected_stride * 32}
240 -
241 - def test_all_tensors_have_block_stride(self):
242 - _, tensors = _run()
243 - for t in tensors:
244 - assert t.block_stride > 0
245 -
246 - def test_all_tensors_share_same_size(self):
247 - _, tensors = _run()
248 - sizes = set(t.size for t in tensors)
249 - assert len(sizes) == 1
250 - assert sizes.pop() > 0
251 -
252 - def test_offsets_within_one_block(self):
253 - groups = _make_groups(n_c4=3, n_c128=2, n_swa=5)
254 - _, tensors = _get_kv_cache_config_packed(
255 - _mock_vllm_config(), groups, 100 * 1024 * 1024
256 - )
257 - page_sizes = _page_sizes_by_layer(groups)
258 - packing = _packing_by_layer(tensors)
259 - for layer_name, page_size in page_sizes.items():
260 - offset, block_stride = packing[layer_name]
261 - assert offset + page_size <= block_stride
262 -
263 - def test_layouts_are_disjoint_within_each_group(self):
264 - groups = _make_groups(n_c4=3, n_c128=2, n_swa=5)
265 - _, tensors = _get_kv_cache_config_packed(
266 - _mock_vllm_config(), groups, 100 * 1024 * 1024
107 + def test_layers_within_a_group_are_dense(self):
108 + groups, _, _ = _mixed_page_groups()
109 + pages = _pages(groups)
110 + config = get_kv_cache_config_from_groups(
111 + _mock_vllm_config("BLHNC"), groups, MEMORY
112 )
268 - page_sizes = _page_sizes_by_layer(groups)
269 - packing = _packing_by_layer(tensors)
270 -
113 + offsets = {
114 + name: tensor.offset + i * tensor.layer_stride
115 + for tensor in config.kv_cache_tensors
116 + for i, name in enumerate(tensor.layers)
117 + }
118 for group in groups:
272 - ranges = sorted(
273 - (packing[name][0], packing[name][0] + page_sizes[name])
274 - for name in group.layer_names
275 - )
276 - assert all(left[1] <= right[0] for left, right in zip(ranges, ranges[1:]))
277 -
278 - def test_all_layers_accounted_for(self):
279 - n_c4, n_c128, n_swa = 5, 4, 7
280 - _, tensors = _run(n_c4=n_c4, n_c128=n_c128, n_swa=n_swa)
281 - all_names = set()
282 - for t in tensors:
283 - all_names.update(t.shared_by)
284 - expected = n_c4 * 2 + n_c128 + n_swa
285 - assert len(all_names) == expected
286 -
287 - def test_group_owned_blocks_do_not_alias(self):
288 - groups = _make_groups(n_c4=3, n_c128=2, n_swa=5)
289 - num_blocks, tensors = _get_kv_cache_config_packed(
290 - _mock_vllm_config(), groups, 100 * 1024 * 1024
291 - )
292 - views = _make_views(groups, num_blocks, tensors)
293 -
294 - expected = {}
295 - value = 1
296 - for block_id, group in enumerate(groups):
297 - for layer_name in group.layer_names:
298 - views[layer_name][block_id].fill_(value)
299 - expected[layer_name] = (block_id, value)
300 - value += 1
301 -
302 - for layer_name, (block_id, value) in expected.items():
303 - assert (views[layer_name][block_id] == value).all()
304 -
305 - # Once the first group releases its block, another group may reuse it.
306 - for layer_name in groups[1].layer_names:
307 - views[layer_name][0].fill_(255)
308 - for layer_name in groups[1].layer_names:
309 - assert (views[layer_name][0] == 255).all()
310 -
311 - def test_hma_attention_groups_keep_default_backing(self):
312 - full = _make_full_spec()
313 - sw = _make_sw_spec()
314 - page_size = full.page_size_bytes
315 - groups = [
316 - KVCacheGroupSpec(["full.0", "full.1"], full),
317 - KVCacheGroupSpec(["sw.0", "sw.2"], sw),
318 - KVCacheGroupSpec(["sw.1", "sw.3"], sw),
319 - ]
320 -
119 + expected = 0
120 + for name in group.layer_names:
121 + assert offsets[name] == expected
122 + expected += pages[name]
123 +
124 + @pytest.mark.parametrize("layout", ["LBNHC", "BLHNC"])
125 + def test_allocation_is_layout_invariant(self, layout):
126 + specs = {f"l.{i}": _full() for i in range(4)}
127 + groups = [KVCacheGroupSpec(list(specs), _full())]
128 config = get_kv_cache_config_from_groups(
322 - _mock_vllm_config(), groups, available_memory=page_size * 2 * 32
129 + _mock_vllm_config(layout), groups, MEMORY
130 )
131 + (tensor,) = config.kv_cache_tensors
132 + page = _full().page_size_bytes
133 + assert tensor.layers == list(specs)
134 + assert config.num_blocks == MEMORY // (4 * page)
135 + assert tensor.size == 4 * page * config.num_blocks
136 + if layout == "LBNHC":
137 + assert (tensor.layer_stride, tensor.block_stride) == (
138 + page * config.num_blocks,
139 + page,
140 + )
141 + else:
142 + assert (tensor.layer_stride, tensor.block_stride) == (page, 4 * page)
143
325 - assert config.num_blocks == 32
326 - assert config.prefix_cache_retention_interval == 0
327 - assert sum(t.size for t in config.kv_cache_tensors) == page_size * 2 * 32
328 - assert config.kv_cache_tensors == [
329 - KVCacheTensor(size=page_size * 32, shared_by=["full.0", "sw.0", "sw.1"]),
330 - KVCacheTensor(size=page_size * 32, shared_by=["full.1", "sw.2", "sw.3"]),
331 - ]
332 -
333 - def test_hma_attention_groups_use_packed_backing_with_enable_cross_layers(self):
334 - full = _make_full_spec()
335 - sw = _make_sw_spec()
336 - page_size = full.page_size_bytes
337 - groups = [
338 - KVCacheGroupSpec(["full.0", "full.1"], full),
339 - KVCacheGroupSpec(["sw.0", "sw.2"], sw),
340 - KVCacheGroupSpec(["sw.1", "sw.3"], sw),
341 - ]
342 -
144 + @pytest.mark.parametrize("layout", ["LBNHC", "BLHNC"])
145 + def test_single_group_mixed_pages_follows_layout(self, layout):
146 + specs = {"mla.0": _mla(512), "mla.1": _mla(512), "idx.0": _mla(128)}
147 + groups = [_uniform_group(specs)]
148 config = get_kv_cache_config_from_groups(
344 - _mock_vllm_config({"enable_cross_layers_blocks": "True"}),
345 - groups,
346 - available_memory=page_size * 2 * 32,
149 + _mock_vllm_config(layout), groups, MEMORY
150 )
348 -
349 - assert config.num_blocks == 32
350 - assert {t.size for t in config.kv_cache_tensors} == {page_size * 2 * 32}
351 - assert config.kv_cache_tensors == [
352 - KVCacheTensor(
353 - size=page_size * 2 * 32,
354 - shared_by=["full.0", "sw.0", "sw.1"],
355 - offset=0,
356 - block_stride=page_size * 2,
357 - ),
358 - KVCacheTensor(
359 - size=page_size * 2 * 32,
360 - shared_by=["full.1", "sw.2", "sw.3"],
361 - offset=page_size,
362 - block_stride=page_size * 2,
363 - ),
364 - ]
365 -
366 - def test_single_group_attention_keeps_unpacked_layout(self):
367 - spec = _make_full_spec()
368 - groups = [KVCacheGroupSpec(["full.0", "full.1"], spec)]
369 -
151 + block_stride = _expected_bytes_per_block(groups)
152 + mla_tensor, idx_tensor = config.kv_cache_tensors
153 + assert mla_tensor.layers == ["mla.0", "mla.1"]
154 + assert idx_tensor.layers == ["idx.0"]
155 + assert {t.size for t in config.kv_cache_tensors} == {
156 + block_stride * config.num_blocks
157 + }
158 + if layout == "LBNHC":
159 + assert (
160 + idx_tensor.offset == 2 * _mla(512).page_size_bytes * config.num_blocks
161 + )
162 + assert mla_tensor.block_stride == _mla(512).page_size_bytes
163 + else:
164 + assert idx_tensor.offset == 2 * _mla(512).page_size_bytes
165 + assert mla_tensor.block_stride == block_stride
166 +
167 + def test_overlaid_groups_alias_and_stay_isolated(self):
168 + groups, g1, g2 = _mixed_page_groups()
169 + # Overlay models resolve to a block-outer layout at backend selection (the
170 + # model's backend declares it); mirror that here.
171 config = get_kv_cache_config_from_groups(
371 - _mock_vllm_config(), groups, available_memory=spec.page_size_bytes * 2 * 32
172 + _mock_vllm_config("BLNHC"), groups, MEMORY
173 )
373 -
374 - assert sum(t.size for t in config.kv_cache_tensors) == (
375 - spec.page_size_bytes * 2 * 32
174 + assert config.num_blocks == MEMORY // _expected_bytes_per_block(groups)
175 + assert _pool_bytes_per_block(groups) == _expected_bytes_per_block(groups)
176 +
177 + views = _bind(config, "BLNHC")
178 + assert set(views) == set(g1) | set(g2)
179 + assert views["swa.0"].data_ptr() == views["mla.0"].data_ptr()
180 +
181 + # A block is owned by one group at a time: writes to group-owned
182 + # blocks never disturb the other group's blocks.
183 + for i, name in enumerate(g1):
184 + views[name][0].fill_(i + 1)
185 + views[name][2].fill_(i + 1)
186 + for i, name in enumerate(g2):
187 + views[name][1].fill_(100 + i)
188 + views[name][3].fill_(100 + i)
189 + for i, name in enumerate(g1):
190 + assert (views[name][0].to(torch.int32) == i + 1).all()
191 + assert (views[name][2].to(torch.int32) == i + 1).all()
192 + for i, name in enumerate(g2):
193 + assert (views[name][1].to(torch.int32) == 100 + i).all()
194 + assert (views[name][3].to(torch.int32) == 100 + i).all()
195 + # Layers within a group are disjoint.
196 + views["mla.0"][0].fill_(77)
197 + for i, name in enumerate(list(g1)[1:], start=1):
198 + assert (views[name][0].to(torch.int32) == i + 1).all()
199 +
200 + def test_layer_compact_layout_rejected_for_overlaid_groups(self):
201 + # The layout has a single writer (backend-selection resolution); a layer-
202 + # compact layout reaching an overlay model's allocation is an error, not a
203 + # silent flip.
204 + groups, _, _ = _mixed_page_groups()
205 + with pytest.raises(
206 + ValueError, match="cannot express this model's mixed page sizes"
207 + ):
208 + get_kv_cache_config_from_groups(_mock_vllm_config("LBNHC"), groups, MEMORY)
209 +
210 + def test_unresolved_layout_rejected(self):
211 + groups, _, _ = _mixed_page_groups()
212 + with pytest.raises(ValueError, match="has not been resolved"):
213 + get_kv_cache_config_from_groups(_mock_vllm_config(None), groups, MEMORY)
214 +
215 + def test_head_outer_layout_rejected_for_mixed_pages(self):
216 + groups, _, _ = _mixed_page_groups()
217 + with pytest.raises(
218 + ValueError, match="cannot express this model's mixed page sizes"
219 + ):
220 + get_kv_cache_config_from_groups(_mock_vllm_config("LHBNC"), groups, MEMORY)
221 +
222 + @pytest.mark.parametrize("layout", ["LBNHC", "BLHNC"])
223 + def test_bound_views_round_trip(self, layout):
224 + specs = {"mla.0": _mla(512), "mla.1": _mla(512), "idx.0": _mla(128)}
225 + groups = [_uniform_group(specs)]
226 + config = get_kv_cache_config_from_groups(
227 + _mock_vllm_config(layout), groups, MEMORY
228 )
377 - assert [t.block_stride for t in config.kv_cache_tensors] == [0, 0]
229 + views = _bind(config, layout)
230 + for i, name in enumerate(specs):
231 + views[name].fill_(i + 1)
232 + for i, name in enumerate(specs):
233 + assert (views[name].to(torch.int32) == i + 1).all(), name
234 + assert views[name].shape[0] == config.num_blocks
235
236
237 if __name__ == "__main__":
tests/v1/core/test_kv_cache_utils.py
+300 -288
@@ -124,7 +124,6 @@ def new_kv_cache_spec(
124 page_size_padded=None,
125 sliding_window=None,
126 attention_chunk_size=None,
127 - indexes_kv_by_block_stride=False,
127 kv_quant_mode=KVQuantMode.NONE,
128 ):
129 return FullAttentionSpec(
@@ -135,7 +134,6 @@ def new_kv_cache_spec(
134 page_size_padded=page_size_padded,
135 sliding_window=sliding_window,
136 attention_chunk_size=attention_chunk_size,
138 - indexes_kv_by_block_stride=indexes_kv_by_block_stride,
137 kv_quant_mode=kv_quant_mode,
138 )
139
@@ -147,7 +145,6 @@ def new_sliding_window_spec(
145 dtype=torch.float32,
146 page_size_padded=None,
147 sliding_window=1,
150 - indexes_kv_by_block_stride=False,
148 ):
149 return SlidingWindowSpec(
150 block_size=block_size,
@@ -156,7 +153,6 @@ def new_sliding_window_spec(
153 dtype=dtype,
154 page_size_padded=page_size_padded,
155 sliding_window=sliding_window,
159 - indexes_kv_by_block_stride=indexes_kv_by_block_stride,
156 )
157
158
@@ -875,6 +871,7 @@ def test_metrics_empty_stats():
871 def test_get_kv_cache_configs_multiple_workers():
872 model_config = ModelConfig(max_model_len=16)
873 vllm_config = VllmConfig(model_config=model_config)
874 + vllm_config.cache_config.kv_cache_layout = "LBNHC"
875 vllm_config.cache_config.prefix_cache_retention_interval = None
876
877 ref_kv_cache_spec = new_kv_cache_spec()
@@ -898,36 +895,21 @@ def test_get_kv_cache_configs_multiple_workers():
895 ref_kv_cache_spec.page_size_bytes * 2 * 10,
896 ],
897 )
901 - assert kv_cache_configs == [
902 - KVCacheConfig(
903 - num_blocks=10,
904 - kv_cache_tensors=[
905 - KVCacheTensor(
906 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
907 - ),
908 - KVCacheTensor(
909 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
910 - ),
911 - ],
912 - kv_cache_groups=[
913 - KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
914 - ],
915 - ),
916 - KVCacheConfig(
917 - num_blocks=10,
918 - kv_cache_tensors=[
919 - KVCacheTensor(
920 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
921 - ),
922 - KVCacheTensor(
923 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
924 - ),
925 - ],
926 - kv_cache_groups=[
927 - KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
928 - ],
929 - ),
930 - ]
898 + expected = KVCacheConfig(
899 + num_blocks=10,
900 + kv_cache_tensors=[
901 + KVCacheTensor(
902 + size=ref_kv_cache_spec.page_size_bytes * 10 * 2,
903 + layers=["layer1", "layer2"],
904 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
905 + block_stride=ref_kv_cache_spec.page_size_bytes,
906 + ),
907 + ],
908 + kv_cache_groups=[
909 + KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
910 + ],
911 + )
912 + assert kv_cache_configs == [expected, expected]
913
914 # Different available memory. This is the case for TP.
915 # Use the smallest memory available.
@@ -939,36 +921,7 @@ def test_get_kv_cache_configs_multiple_workers():
921 ref_kv_cache_spec.page_size_bytes * 2 * 20,
922 ],
923 )
942 - assert kv_cache_configs == [
943 - KVCacheConfig(
944 - num_blocks=10,
945 - kv_cache_tensors=[
946 - KVCacheTensor(
947 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
948 - ),
949 - KVCacheTensor(
950 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
951 - ),
952 - ],
953 - kv_cache_groups=[
954 - KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
955 - ],
956 - ),
957 - KVCacheConfig(
958 - num_blocks=10,
959 - kv_cache_tensors=[
960 - KVCacheTensor(
961 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
962 - ),
963 - KVCacheTensor(
964 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
965 - ),
966 - ],
967 - kv_cache_groups=[
968 - KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
969 - ],
970 - ),
971 - ]
924 + assert kv_cache_configs == [expected, expected]
925
926 # Different KV cache specs. This is the case for PP.
927 different_layer_specs = [
@@ -995,7 +948,10 @@ def test_get_kv_cache_configs_multiple_workers():
948 num_blocks=10,
949 kv_cache_tensors=[
950 KVCacheTensor(
998 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
951 + size=ref_kv_cache_spec.page_size_bytes * 10,
952 + layers=["layer1"],
953 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
954 + block_stride=ref_kv_cache_spec.page_size_bytes,
955 ),
956 ],
957 kv_cache_groups=[
@@ -1006,10 +962,10 @@ def test_get_kv_cache_configs_multiple_workers():
962 num_blocks=10,
963 kv_cache_tensors=[
964 KVCacheTensor(
1009 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
1010 - ),
1011 - KVCacheTensor(
1012 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer3"]
965 + size=ref_kv_cache_spec.page_size_bytes * 10 * 2,
966 + layers=["layer2", "layer3"],
967 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
968 + block_stride=ref_kv_cache_spec.page_size_bytes,
969 ),
970 ],
971 kv_cache_groups=[
@@ -1046,57 +1002,39 @@ def test_get_kv_cache_configs_multiple_workers():
1002 ref_kv_cache_spec.page_size_bytes * 2 * 10,
1003 ],
1004 )
1005 + expected_12 = KVCacheConfig(
1006 + num_blocks=10,
1007 + kv_cache_tensors=[
1008 + KVCacheTensor(
1009 + size=ref_kv_cache_spec.page_size_bytes * 10 * 2,
1010 + layers=["layer1", "layer2"],
1011 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
1012 + block_stride=ref_kv_cache_spec.page_size_bytes,
1013 + ),
1014 + ],
1015 + kv_cache_groups=[
1016 + KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
1017 + ],
1018 + )
1019 + expected_3 = KVCacheConfig(
1020 + num_blocks=10,
1021 + kv_cache_tensors=[
1022 + KVCacheTensor(
1023 + size=ref_kv_cache_spec.page_size_bytes * 10,
1024 + layers=["layer3"],
1025 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
1026 + block_stride=ref_kv_cache_spec.page_size_bytes,
1027 + ),
1028 + ],
1029 + kv_cache_groups=[
1030 + KVCacheGroupSpec(["layer3"], ref_kv_cache_spec),
1031 + ],
1032 + )
1033 assert kv_cache_configs == [
1050 - KVCacheConfig(
1051 - num_blocks=10,
1052 - kv_cache_tensors=[
1053 - KVCacheTensor(
1054 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
1055 - ),
1056 - KVCacheTensor(
1057 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
1058 - ),
1059 - ],
1060 - kv_cache_groups=[
1061 - KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
1062 - ],
1063 - ),
1064 - KVCacheConfig(
1065 - num_blocks=10,
1066 - kv_cache_tensors=[
1067 - KVCacheTensor(
1068 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
1069 - ),
1070 - KVCacheTensor(
1071 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
1072 - ),
1073 - ],
1074 - kv_cache_groups=[
1075 - KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
1076 - ],
1077 - ),
1078 - KVCacheConfig(
1079 - num_blocks=10,
1080 - kv_cache_tensors=[
1081 - KVCacheTensor(
1082 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer3"]
1083 - ),
1084 - ],
1085 - kv_cache_groups=[
1086 - KVCacheGroupSpec(["layer3"], ref_kv_cache_spec),
1087 - ],
1088 - ),
1089 - KVCacheConfig(
1090 - num_blocks=10,
1091 - kv_cache_tensors=[
1092 - KVCacheTensor(
1093 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer3"]
1094 - ),
1095 - ],
1096 - kv_cache_groups=[
1097 - KVCacheGroupSpec(["layer3"], ref_kv_cache_spec),
1098 - ],
1099 - ),
1034 + expected_12,
1035 + expected_12,
1036 + expected_3,
1037 + expected_3,
1038 ]
1039
1040 # Different workers have different types of layers. This is the case for
@@ -1124,10 +1062,10 @@ def test_get_kv_cache_configs_multiple_workers():
1062 num_blocks=10,
1063 kv_cache_tensors=[
1064 KVCacheTensor(
1127 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
1128 - ),
1129 - KVCacheTensor(
1130 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
1065 + size=ref_kv_cache_spec.page_size_bytes * 10 * 2,
1066 + layers=["layer1", "layer2"],
1067 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
1068 + block_stride=ref_kv_cache_spec.page_size_bytes,
1069 ),
1070 ],
1071 kv_cache_groups=[
@@ -1139,10 +1077,10 @@ def test_get_kv_cache_configs_multiple_workers():
1077 num_blocks=10,
1078 kv_cache_tensors=[
1079 KVCacheTensor(
1142 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer3"]
1143 - ),
1144 - KVCacheTensor(
1145 - size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer4"]
1080 + size=ref_kv_cache_spec.page_size_bytes * 10 * 2,
1081 + layers=["layer3", "layer4"],
1082 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
1083 + block_stride=ref_kv_cache_spec.page_size_bytes,
1084 ),
1085 ],
1086 kv_cache_groups=[
@@ -1180,7 +1118,21 @@ def test_get_kv_cache_configs_multiple_workers():
1118 kv_cache_tensors=[
1119 KVCacheTensor(
1120 size=ref_kv_cache_spec.page_size_bytes * 10,
1183 - shared_by=["layer1", "layer2", "layer3"],
1121 + layers=["layer1"],
1122 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
1123 + block_stride=ref_kv_cache_spec.page_size_bytes,
1124 + ),
1125 + KVCacheTensor(
1126 + size=ref_kv_cache_spec.page_size_bytes * 10,
1127 + layers=["layer2"],
1128 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
1129 + block_stride=ref_kv_cache_spec.page_size_bytes,
1130 + ),
1131 + KVCacheTensor(
1132 + size=ref_kv_cache_spec.page_size_bytes * 10,
1133 + layers=["layer3"],
1134 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
1135 + block_stride=ref_kv_cache_spec.page_size_bytes,
1136 ),
1137 ],
1138 kv_cache_groups=[
@@ -1194,7 +1146,21 @@ def test_get_kv_cache_configs_multiple_workers():
1146 kv_cache_tensors=[
1147 KVCacheTensor(
1148 size=ref_kv_cache_spec.page_size_bytes * 10,
1197 - shared_by=["layer4", "layer5", "layer6"],
1149 + layers=["layer4"],
1150 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
1151 + block_stride=ref_kv_cache_spec.page_size_bytes,
1152 + ),
1153 + KVCacheTensor(
1154 + size=ref_kv_cache_spec.page_size_bytes * 10,
1155 + layers=["layer5"],
1156 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
1157 + block_stride=ref_kv_cache_spec.page_size_bytes,
1158 + ),
1159 + KVCacheTensor(
1160 + size=ref_kv_cache_spec.page_size_bytes * 10,
1161 + layers=["layer6"],
1162 + layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
1163 + block_stride=ref_kv_cache_spec.page_size_bytes,
1164 ),
1165 ],
1166 kv_cache_groups=[
@@ -1233,6 +1199,7 @@ def test_get_kv_cache_configs_multiple_workers():
1199 def test_get_kv_cache_configs_pp_sharding(asymmetric_memory):
1200 model_config = ModelConfig(max_model_len=512)
1201 vllm_config = VllmConfig(model_config=model_config)
1202 + vllm_config.cache_config.kv_cache_layout = "LBNHC"
1203 vllm_config.cache_config.prefix_cache_retention_interval = None
1204
1205 ref_kv_cache_spec = new_kv_cache_spec()
@@ -1262,7 +1229,10 @@ def test_get_kv_cache_configs_pp_sharding(asymmetric_memory):
1229 kv_cache_tensors=[
1230 KVCacheTensor(
1231 size=ref_kv_cache_spec.page_size_bytes * expected_num_blocks,
1265 - shared_by=["layer1"],
1232 + layers=["layer1"],
1233 + layer_stride=ref_kv_cache_spec.page_size_bytes
1234 + * expected_num_blocks,
1235 + block_stride=ref_kv_cache_spec.page_size_bytes,
1236 ),
1237 ],
1238 kv_cache_groups=[KVCacheGroupSpec(["layer1"], ref_kv_cache_spec)],
@@ -1272,7 +1242,10 @@ def test_get_kv_cache_configs_pp_sharding(asymmetric_memory):
1242 kv_cache_tensors=[
1243 KVCacheTensor(
1244 size=ref_kv_cache_spec.page_size_bytes * expected_num_blocks,
1275 - shared_by=["layer2"],
1245 + layers=["layer2"],
1246 + layer_stride=ref_kv_cache_spec.page_size_bytes
1247 + * expected_num_blocks,
1248 + block_stride=ref_kv_cache_spec.page_size_bytes,
1249 ),
1250 ],
1251 kv_cache_groups=[KVCacheGroupSpec(["layer2"], ref_kv_cache_spec)],
@@ -1628,78 +1601,18 @@ def test_get_max_concurrency_for_kv_cache_config():
1601 )
1602
1603
1631 -def test_get_max_concurrency_packed_kv_cache_config():
1632 - from vllm.v1.core.kv_cache_utils import (
1633 - _get_kv_cache_config_packed,
1634 - _use_packed_kv_cache_config,
1635 - )
1636 -
1637 - model_config = ModelConfig(
1638 - "Qwen/Qwen1.5-7B",
1639 - runner="generate",
1640 - dtype="float16",
1641 - max_model_len=16384,
1642 - )
1643 - scheduler_config = SchedulerConfig(
1644 - max_num_batched_tokens=1024,
1645 - enable_chunked_prefill=True,
1646 - max_model_len=model_config.max_model_len,
1647 - is_encoder_decoder=model_config.is_encoder_decoder,
1648 - async_scheduling=False,
1649 - )
1650 - vllm_config = VllmConfig(
1651 - model_config=model_config,
1652 - scheduler_config=scheduler_config,
1653 - )
1654 -
1655 - # All-UniformTypeKVCacheSpecs groups select the packed layout.
1656 - mla_specs = {f"layer_{i}": new_mla_spec() for i in range(4)}
1657 - swa_specs = {
1658 - f"layer_{i}": SlidingWindowMLASpec(
1659 - block_size=16,
1660 - num_kv_heads=1,
1661 - head_size=576,
1662 - dtype=torch.float32,
1663 - sliding_window=128,
1664 - )
1665 - for i in range(4, 6)
1666 - }
1667 - kv_cache_groups = [
1668 - KVCacheGroupSpec(
1669 - list(mla_specs),
1670 - UniformTypeKVCacheSpecs(block_size=16, kv_cache_specs=mla_specs),
1671 - ),
1672 - KVCacheGroupSpec(
1673 - list(swa_specs),
1674 - UniformTypeKVCacheSpecs(block_size=16, kv_cache_specs=swa_specs),
1675 - ),
1676 - ]
1677 - assert _use_packed_kv_cache_config(vllm_config, kv_cache_groups)
1678 - num_blocks, kv_cache_tensors = _get_kv_cache_config_packed(
1679 - vllm_config, kv_cache_groups, 2 * GiB_bytes
1680 - )
1681 - assert num_blocks > 0
1682 - kv_cache_config_packed = KVCacheConfig(
1683 - num_blocks=num_blocks,
1684 - kv_cache_tensors=kv_cache_tensors,
1685 - kv_cache_groups=kv_cache_groups,
1686 - )
1687 - # Per-request blocks: the MLA group needs cdiv(16384, 16) = 1024 pages;
1688 - # the SWA group cdiv(min(128 - 1 + 1024, 16384), 16) + 1 = 73. The
1689 - # previous formula normalized by the first group's page size and gave
1690 - # 1061 blocks per request instead of 1097.
1691 - assert get_max_concurrency_for_kv_cache_config(
1692 - vllm_config, kv_cache_config_packed
1693 - ) == num_blocks / (1024 + 73)
1694 -
1695 -
1604 def test_allocate_with_lookahead():
1605 """Verify that lookahead tokens correctly affect block allocation"""
1606 block_size = 4
1607 config = KVCacheConfig(
1608 num_blocks=10,
1609 kv_cache_tensors=[
1702 - KVCacheTensor(size=100, shared_by=["layer1"]),
1610 + KVCacheTensor(
1611 + size=100,
1612 + layers=["layer1"],
1613 + layer_stride=100,
1614 + block_stride=10,
1615 + ),
1616 ],
1617 kv_cache_groups=[
1618 KVCacheGroupSpec(["layer1"], new_kv_cache_spec(block_size=block_size)),
@@ -1763,6 +1676,7 @@ def test_get_kv_cache_config_one_worker():
1676 # pass max_model_len to pass check_enough_kv_cache_memory
1677 model_config = ModelConfig(max_model_len=16)
1678 vllm_config = VllmConfig(model_config=model_config)
1679 + vllm_config.cache_config.kv_cache_layout = "LBNHC"
1680 vllm_config.cache_config.prefix_cache_retention_interval = None
1681
1682 mem_per_block_per_layer = 16 * 2 * 64 * 4 * 2
@@ -1778,8 +1692,12 @@ def test_get_kv_cache_config_one_worker():
1692 assert kv_cache_config_full == KVCacheConfig(
1693 num_blocks=32,
1694 kv_cache_tensors=[
1781 - KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_1"]),
1782 - KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_2"]),
1695 + KVCacheTensor(
1696 + size=mem_per_block_per_layer * 32 * 2,
1697 + layers=["layer_1", "layer_2"],
1698 + layer_stride=mem_per_block_per_layer * 32,
1699 + block_stride=mem_per_block_per_layer,
1700 + ),
1701 ],
1702 kv_cache_groups=[KVCacheGroupSpec(["layer_1", "layer_2"], new_kv_cache_spec())],
1703 )
@@ -1795,8 +1713,12 @@ def test_get_kv_cache_config_one_worker():
1713 assert kv_cache_config_sliding == KVCacheConfig(
1714 num_blocks=32,
1715 kv_cache_tensors=[
1798 - KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_1"]),
1799 - KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_2"]),
1716 + KVCacheTensor(
1717 + size=mem_per_block_per_layer * 32 * 2,
1718 + layers=["layer_1", "layer_2"],
1719 + layer_stride=mem_per_block_per_layer * 32,
1720 + block_stride=mem_per_block_per_layer,
1721 + ),
1722 ],
1723 kv_cache_groups=[
1724 KVCacheGroupSpec(["layer_1", "layer_2"], new_sliding_window_spec())
@@ -1815,8 +1737,12 @@ def test_get_kv_cache_config_one_worker():
1737 assert kv_cache_config_hybrid == KVCacheConfig(
1738 num_blocks=32,
1739 kv_cache_tensors=[
1818 - KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_1"]),
1819 - KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_2"]),
1740 + KVCacheTensor(
1741 + size=mem_per_block_per_layer * 32 * 2,
1742 + layers=["layer_1", "layer_2"],
1743 + layer_stride=mem_per_block_per_layer * 32,
1744 + block_stride=mem_per_block_per_layer,
1745 + ),
1746 ],
1747 kv_cache_groups=[
1748 KVCacheGroupSpec(
@@ -1838,7 +1764,16 @@ def test_get_kv_cache_config_one_worker():
1764 num_blocks=64,
1765 kv_cache_tensors=[
1766 KVCacheTensor(
1841 - size=mem_per_block_per_layer * 64, shared_by=["layer_1", "layer_2"]
1767 + size=mem_per_block_per_layer * 64,
1768 + layers=["layer_1"],
1769 + layer_stride=mem_per_block_per_layer * 64,
1770 + block_stride=mem_per_block_per_layer,
1771 + ),
1772 + KVCacheTensor(
1773 + size=mem_per_block_per_layer * 64,
1774 + layers=["layer_2"],
1775 + layer_stride=mem_per_block_per_layer * 64,
1776 + block_stride=mem_per_block_per_layer,
1777 ),
1778 ],
1779 kv_cache_groups=[
@@ -1863,12 +1798,22 @@ def test_get_kv_cache_config_one_worker():
1798 num_blocks=32,
1799 kv_cache_tensors=[
1800 KVCacheTensor(
1866 - size=mem_per_block_per_layer * 32,
1867 - shared_by=["layer_1", "layer_3", "layer_4"],
1801 + size=mem_per_block_per_layer * 32 * 2,
1802 + layers=["layer_1", "layer_2"],
1803 + layer_stride=mem_per_block_per_layer * 32,
1804 + block_stride=mem_per_block_per_layer,
1805 ),
1806 KVCacheTensor(
1870 - size=mem_per_block_per_layer * 32,
1871 - shared_by=["layer_2", "layer_5", "layer_6"],
1807 + size=mem_per_block_per_layer * 32 * 2,
1808 + layers=["layer_3", "layer_5"],
1809 + layer_stride=mem_per_block_per_layer * 32,
1810 + block_stride=mem_per_block_per_layer,
1811 + ),
1812 + KVCacheTensor(
1813 + size=mem_per_block_per_layer * 32 * 2,
1814 + layers=["layer_4", "layer_6"],
1815 + layer_stride=mem_per_block_per_layer * 32,
1816 + block_stride=mem_per_block_per_layer,
1817 ),
1818 ],
1819 kv_cache_groups=[
@@ -1898,15 +1843,28 @@ def test_get_kv_cache_config_one_worker():
1843 num_blocks=32,
1844 kv_cache_tensors=[
1845 KVCacheTensor(
1901 - size=mem_per_block_per_layer * 32,
1902 - shared_by=["layer_1", "layer_4", "layer_5", "layer_6"],
1846 + size=mem_per_block_per_layer * 32 * 3,
1847 + layers=["layer_1", "layer_2", "layer_3"],
1848 + layer_stride=mem_per_block_per_layer * 32,
1849 + block_stride=mem_per_block_per_layer,
1850 ),
1851 KVCacheTensor(
1905 - size=mem_per_block_per_layer * 32,
1906 - shared_by=["layer_2", "layer_7", "layer_8", "layer_9"],
1852 + size=mem_per_block_per_layer * 32 * 3,
1853 + layers=["layer_4", "layer_7", "layer_10"],
1854 + layer_stride=mem_per_block_per_layer * 32,
1855 + block_stride=mem_per_block_per_layer,
1856 + ),
1857 + KVCacheTensor(
1858 + size=mem_per_block_per_layer * 32 * 3,
1859 + layers=["layer_5", "layer_8"],
1860 + layer_stride=mem_per_block_per_layer * 32,
1861 + block_stride=mem_per_block_per_layer,
1862 ),
1863 KVCacheTensor(
1909 - size=mem_per_block_per_layer * 32, shared_by=["layer_3", "layer_10"]
1864 + size=mem_per_block_per_layer * 32 * 3,
1865 + layers=["layer_6", "layer_9"],
1866 + layer_stride=mem_per_block_per_layer * 32,
1867 + block_stride=mem_per_block_per_layer,
1868 ),
1869 ],
1870 kv_cache_groups=[
@@ -1943,28 +1901,23 @@ def test_get_kv_cache_config_one_worker():
1901 num_blocks=32,
1902 kv_cache_tensors=[
1903 KVCacheTensor(
1946 - size=mem_per_block_per_layer * 32,
1947 - shared_by=["layer_1", "layer_7"],
1948 - ),
1949 - KVCacheTensor(
1950 - size=mem_per_block_per_layer * 32,
1951 - shared_by=["layer_2", "layer_8"],
1952 - ),
1953 - KVCacheTensor(
1954 - size=mem_per_block_per_layer * 32,
1955 - shared_by=["layer_3", "layer_9"],
1956 - ),
1957 - KVCacheTensor(
1958 - size=mem_per_block_per_layer * 32,
1959 - shared_by=["layer_4", "layer_10"],
1960 - ),
1961 - KVCacheTensor(
1962 - size=mem_per_block_per_layer * 32,
1963 - shared_by=["layer_5", "layer_11"],
1904 + size=mem_per_block_per_layer * 32 * 6,
1905 + layers=[
1906 + "layer_1",
1907 + "layer_2",
1908 + "layer_3",
1909 + "layer_4",
1910 + "layer_5",
1911 + "layer_6",
1912 + ],
1913 + layer_stride=mem_per_block_per_layer * 32,
1914 + block_stride=mem_per_block_per_layer,
1915 ),
1916 KVCacheTensor(
1966 - size=mem_per_block_per_layer * 32,
1967 - shared_by=["layer_6"],
1917 + size=mem_per_block_per_layer * 32 * 6,
1918 + layers=["layer_7", "layer_8", "layer_9", "layer_10", "layer_11"],
1919 + layer_stride=mem_per_block_per_layer * 32,
1920 + block_stride=mem_per_block_per_layer,
1921 ),
1922 ],
1923 kv_cache_groups=[
@@ -1987,11 +1940,24 @@ def test_get_kv_cache_config_one_worker():
1940 kv_cache_config_hybrid = get_kv_cache_configs(
1941 vllm_config, [kv_cache_specs_hybrid], [mem_per_block_per_layer * 3 * 32]
1942 )[0]
1943 + # Layers of different page sizes pack densely into one allocation: the
1944 + # 2x-larger layer_1 takes the first region, layer_2 the next.
1945 assert kv_cache_config_hybrid == KVCacheConfig(
1946 num_blocks=32,
1947 kv_cache_tensors=[
1993 - KVCacheTensor(size=mem_per_block_per_layer * 32 * 2, shared_by=["layer_1"]),
1994 - KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_2"]),
1948 + KVCacheTensor(
1949 + size=mem_per_block_per_layer * 32 * 3,
1950 + layers=["layer_1"],
1951 + layer_stride=mem_per_block_per_layer * 2 * 32,
1952 + block_stride=mem_per_block_per_layer * 2,
1953 + ),
1954 + KVCacheTensor(
1955 + size=mem_per_block_per_layer * 32 * 3,
1956 + layers=["layer_2"],
1957 + layer_stride=mem_per_block_per_layer * 32,
1958 + block_stride=mem_per_block_per_layer,
1959 + offset=mem_per_block_per_layer * 2 * 32,
1960 + ),
1961 ],
1962 kv_cache_groups=[
1963 KVCacheGroupSpec(
@@ -2015,7 +1981,16 @@ def test_get_kv_cache_config_one_worker():
1981 num_blocks=32,
1982 kv_cache_tensors=[
1983 KVCacheTensor(
2018 - size=mem_per_block_per_layer * 32, shared_by=["layer_1", "layer_2"]
1984 + size=mem_per_block_per_layer * 32,
1985 + layers=["layer_1"],
1986 + layer_stride=mem_per_block_per_layer * 32,
1987 + block_stride=mem_per_block_per_layer,
1988 + ),
1989 + KVCacheTensor(
1990 + size=mem_per_block_per_layer * 32,
1991 + layers=["layer_2"],
1992 + layer_stride=mem_per_block_per_layer * 32,
1993 + block_stride=mem_per_block_per_layer,
1994 ),
1995 ],
1996 kv_cache_groups=[
@@ -2028,9 +2003,9 @@ def test_get_kv_cache_config_one_worker():
2003
2004 # different hidden size that cannot be aligned by using different block size,
2005 # but can be aligned by padding the smaller physical page.
2031 - swa_spec = new_sliding_window_spec(head_size=96, indexes_kv_by_block_stride=True)
2006 + swa_spec = new_sliding_window_spec(head_size=96)
2007 kv_cache_specs_hybrid = {
2033 - "layer_1": new_kv_cache_spec(head_size=64, indexes_kv_by_block_stride=True),
2008 + "layer_1": new_kv_cache_spec(head_size=64),
2009 "layer_2": swa_spec,
2010 }
2011
@@ -2041,7 +2016,18 @@ def test_get_kv_cache_config_one_worker():
2016 assert kv_cache_config_hybrid == KVCacheConfig(
2017 num_blocks=42,
2018 kv_cache_tensors=[
2044 - KVCacheTensor(size=padded_page_size * 42, shared_by=["layer_1", "layer_2"]),
2019 + KVCacheTensor(
2020 + size=padded_page_size * 42,
2021 + layers=["layer_1"],
2022 + layer_stride=padded_page_size * 42,
2023 + block_stride=padded_page_size,
2024 + ),
2025 + KVCacheTensor(
2026 + size=padded_page_size * 42,
2027 + layers=["layer_2"],
2028 + layer_stride=padded_page_size * 42,
2029 + block_stride=padded_page_size,
2030 + ),
2031 ],
2032 kv_cache_groups=[
2033 KVCacheGroupSpec(
@@ -2049,12 +2035,11 @@ def test_get_kv_cache_config_one_worker():
2035 new_kv_cache_spec(
2036 head_size=64,
2037 page_size_padded=padded_page_size,
2052 - indexes_kv_by_block_stride=True,
2038 ),
2039 ),
2040 KVCacheGroupSpec(
2041 ["layer_2"],
2057 - new_sliding_window_spec(head_size=96, indexes_kv_by_block_stride=True),
2042 + new_sliding_window_spec(head_size=96),
2043 ),
2044 ],
2045 )
@@ -2067,8 +2052,12 @@ def test_get_kv_cache_config_one_worker():
2052 assert kv_cache_config_override_blocks == KVCacheConfig(
2053 num_blocks=16,
2054 kv_cache_tensors=[
2070 - KVCacheTensor(size=mem_per_block_per_layer * 16, shared_by=["layer_1"]),
2071 - KVCacheTensor(size=mem_per_block_per_layer * 16, shared_by=["layer_2"]),
2055 + KVCacheTensor(
2056 + size=mem_per_block_per_layer * 16 * 2,
2057 + layers=["layer_1", "layer_2"],
2058 + layer_stride=mem_per_block_per_layer * 16,
2059 + block_stride=mem_per_block_per_layer,
2060 + ),
2061 ],
2062 kv_cache_groups=[KVCacheGroupSpec(["layer_1", "layer_2"], new_kv_cache_spec())],
2063 )
@@ -2319,6 +2308,45 @@ def test_hidden_state_group_preserves_hybrid_prefix_cache_granularity():
2308 ) == (544, 136)
2309
2310
2311 +def test_multi_run_layer_compact_strides_place_hoisted_heads():
2312 + """A layer-compact run region is its own dense allocation: under LHBNC the head
2313 + groups sit between the layers and the blocks, so a run's block stride is one head
2314 + group's slice, not the whole page (regression test for setStorage out-of-bounds
2315 + on ROCm hybrid models)."""
2316 + full = new_kv_cache_spec()
2317 + swa = new_sliding_window_spec(sliding_window=full.block_size * 2)
2318 + page = full.page_size_bytes
2319 + assert swa.page_size_bytes == page
2320 +
2321 + vllm_config = VllmConfig(model_config=ModelConfig(max_model_len=16))
2322 + vllm_config.cache_config.kv_cache_layout = "LHBNC"
2323 + config = kv_cache_utils.get_kv_cache_config_from_groups(
2324 + vllm_config,
2325 + [
2326 + KVCacheGroupSpec(["full.0", "full.1"], full),
2327 + KVCacheGroupSpec(["swa.0", "swa.1"], swa),
2328 + ],
2329 + available_memory=4 * page * 8,
2330 + )
2331 +
2332 + num_blocks = config.num_blocks
2333 + head_group = full.block_size * full.state_content_size_bytes
2334 + for tensor in config.kv_cache_tensors:
2335 + # One head group per block, head groups between the layers and blocks.
2336 + assert tensor.block_stride == head_group
2337 + assert tensor.layer_stride == full.num_heads * num_blocks * head_group
2338 + # The last block of the last layer's last head group must stay within the
2339 + # allocation (the old page-based stride overran it).
2340 + end = (
2341 + tensor.offset
2342 + + tensor.layer_stride * (len(tensor.layers) - 1)
2343 + + (full.num_heads - 1) * num_blocks * head_group
2344 + + tensor.block_stride * (num_blocks - 1)
2345 + + head_group
2346 + )
2347 + assert end <= tensor.size
2348 +
2349 +
2350 def test_mla_draft_prefers_standard_layout_when_pages_can_be_unified():
2351 specs = {
2352 "target.0.attn": new_mla_spec(),
@@ -2640,6 +2668,7 @@ def test_auto_fit_max_model_len():
2668 # Simulate the user passing -1 by setting original_max_model_len
2669 model_config.original_max_model_len = -1
2670 vllm_config = VllmConfig(model_config=model_config)
2671 + vllm_config.cache_config.kv_cache_layout = "LBNHC"
2672
2673 mem_per_block_per_layer = 16 * 2 * 64 * 4 * 2 # 16KB per block per layer
2674 kv_cache_specs = {
@@ -2658,6 +2687,7 @@ def test_auto_fit_max_model_len():
2687 model_config = ModelConfig(max_model_len=1024)
2688 model_config.original_max_model_len = -1
2689 vllm_config = VllmConfig(model_config=model_config)
2690 + vllm_config.cache_config.kv_cache_layout = "LBNHC"
2691
2692 # With limited memory, max_model_len should be reduced
2693 # Need memory for at least max_model_len tokens
@@ -2678,6 +2708,7 @@ def test_auto_fit_max_model_len_with_hybrid():
2708 # Simulate the user passing -1 by setting original_max_model_len
2709 model_config.original_max_model_len = -1
2710 vllm_config = VllmConfig(model_config=model_config)
2711 + vllm_config.cache_config.kv_cache_layout = "LBNHC"
2712
2713 mem_per_block_per_layer = 16 * 2 * 64 * 4 * 2 # 16KB per block per layer
2714 gamma = 2
@@ -2700,6 +2731,7 @@ def test_auto_fit_max_model_len_not_triggered():
2731 model_config = ModelConfig(max_model_len=16)
2732 # original_max_model_len should be None by default, not -1
2733 vllm_config = VllmConfig(model_config=model_config)
2734 + vllm_config.cache_config.kv_cache_layout = "LBNHC"
2735
2736 mem_per_block_per_layer = 16 * 2 * 64 * 4 * 2
2737 kv_cache_specs = {
@@ -2722,6 +2754,7 @@ def test_auto_fit_max_model_len_respects_num_gpu_blocks_override():
2754 model_config = ModelConfig(max_model_len=16384)
2755 model_config.original_max_model_len = -1 # request auto-fit
2756 vllm_config = VllmConfig(model_config=model_config)
2757 + vllm_config.cache_config.kv_cache_layout = "LBNHC"
2758 # Cap the cache to 32 blocks regardless of available memory.
2759 vllm_config.cache_config.num_gpu_blocks_override = 32
2760
@@ -2747,6 +2780,7 @@ def test_check_enough_kv_cache_memory_respects_num_gpu_blocks_override():
2780 """
2781 model_config = ModelConfig(max_model_len=16384)
2782 vllm_config = VllmConfig(model_config=model_config)
2783 + vllm_config.cache_config.kv_cache_layout = "LBNHC"
2784 # 32 blocks is far too small for max_model_len=16384 (would need 1024).
2785 vllm_config.cache_config.num_gpu_blocks_override = 32
2786
@@ -2769,14 +2803,13 @@ def test_unify_kv_cache_page_size_uses_padding_for_non_divisible_sizes():
2803 128-dim KV heads. The resulting page sizes are 3:2 rather than an integer
2804 block-size multiple, so the smaller page must be padded instead.
2805 """
2772 - # Both layers' backends opt into the padded-page strided view (e.g.
2773 - # FlashAttention / its DiffKV subclass), so padding is allowed.
2806 + # Attention layers read padded pages through a strided view, so padding is
2807 + # allowed.
2808 target_spec = new_kv_cache_spec(
2809 block_size=16,
2810 num_kv_heads=1,
2811 head_size=192,
2812 dtype=torch.bfloat16,
2779 - indexes_kv_by_block_stride=True,
2813 )
2814 draft_spec = new_sliding_window_spec(
2815 block_size=16,
@@ -2784,7 +2817,6 @@ def test_unify_kv_cache_page_size_uses_padding_for_non_divisible_sizes():
2817 head_size=128,
2818 dtype=torch.bfloat16,
2819 sliding_window=1024,
2787 - indexes_kv_by_block_stride=True,
2820 )
2821
2822 unified_specs = kv_cache_utils.unify_kv_cache_spec_page_size(
@@ -2802,35 +2834,6 @@ def test_unify_kv_cache_page_size_uses_padding_for_non_divisible_sizes():
2834 assert unified_draft_spec.page_size_bytes == target_spec.page_size_bytes
2835
2836
2805 -def test_unify_kv_cache_page_size_padding_requires_backend_support():
2806 - """Padding is gated on the backend declaring ``indexes_kv_by_block_stride``.
2807 -
2808 - A backend that does not support the strided padded-page view must raise
2809 - rather than silently padding (and misreading KV at runtime).
2810 - """
2811 - target_spec = new_kv_cache_spec(
2812 - block_size=16,
2813 - num_kv_heads=1,
2814 - head_size=192,
2815 - dtype=torch.bfloat16,
2816 - indexes_kv_by_block_stride=True,
2817 - )
2818 - # The non-divisible draft layer needs padding but its backend does not
2819 - # support the strided padded-page view -> must raise, not silently pad.
2820 - draft_spec = new_sliding_window_spec(
2821 - block_size=16,
2822 - num_kv_heads=1,
2823 - head_size=128,
2824 - dtype=torch.bfloat16,
2825 - sliding_window=1024,
2826 - indexes_kv_by_block_stride=False,
2827 - )
2828 - specs = {"target_attn": target_spec, "draft_attn": draft_spec}
2829 -
2830 - with pytest.raises(NotImplementedError):
2831 - kv_cache_utils.unify_kv_cache_spec_page_size(specs)
2832 -
2833 -
2837 def test_unpadded_page_size_includes_per_token_head_scales():
2838 # Per-token-head quant carries inline fp32 scales that are carved from the
2839 # raw KV allocation, so they must be budgeted into the offload width. The
@@ -2850,10 +2853,14 @@ def test_unpadded_page_size_includes_per_token_head_scales():
2853
2854 def test_page_size_padded_wins():
2855 # An explicit padded page size takes precedence over the unpadded size.
2853 - spec = new_kv_cache_spec(
2854 - dtype=torch.uint8,
2855 - kv_quant_mode=KVQuantMode.FP8_PER_TOKEN_HEAD,
2856 - page_size_padded=65536,
2856 + from vllm.v1.attention.backends.triton_attn import TritonAttentionBackend
2857 +
2858 + spec = TritonAttentionBackend.customize_spec(
2859 + new_kv_cache_spec(
2860 + dtype=torch.uint8,
2861 + kv_quant_mode=KVQuantMode.FP8_PER_TOKEN_HEAD,
2862 + page_size_padded=65536,
2863 + )
2864 )
2865 assert spec.page_size_bytes == 65536
2866
@@ -2996,14 +3003,18 @@ def test_unify_kv_cache_spec_page_size_mamba():
3003 )
3004 assert unified["mamba_layer"].page_size_bytes == 32768
3005
2999 - # 4. Attention layers with non-divisible page sizes still raise.
3000 - with pytest.raises(NotImplementedError):
3001 - kv_cache_utils.unify_kv_cache_spec_page_size(
3002 - {
3003 - "attn_layer": new_kv_cache_spec(block_size=24), # 24576
3004 - "draft_attn_layer": draft_attn_spec, # 32768
3005 - }
3006 - )
3006 + # 4. Attention layers with non-divisible page sizes are padded too: every
3007 + # backend reads a padded page through the view's block stride, so there is
3008 + # no longer a case that must raise.
3009 + unified = kv_cache_utils.unify_kv_cache_spec_page_size(
3010 + {
3011 + "attn_layer": new_kv_cache_spec(block_size=24), # 24576
3012 + "draft_attn_layer": draft_attn_spec, # 32768
3013 + }
3014 + )
3015 + assert unified["attn_layer"].page_size_padded == 32768
3016 + assert unified["attn_layer"].page_size_bytes == 32768
3017 + assert unified["attn_layer"].block_size == 24
3018
3019 # 5. Uniform page sizes are returned unchanged.
3020 specs = {
@@ -3135,6 +3146,7 @@ def test_auto_fit_max_model_len_reserves_null_block():
3146 model_config = ModelConfig(max_model_len=1024)
3147 model_config.original_max_model_len = -1
3148 vllm_config = VllmConfig(model_config=model_config)
3149 + vllm_config.cache_config.kv_cache_layout = "LBNHC"
3150 spec = new_kv_cache_spec(block_size=block_size)
3151
3152 # Exactly the 1024 / 16 = 64 blocks a full-length request would need.
tests/v1/core/test_prefix_caching.py
+5 -5
@@ -154,7 +154,7 @@ def make_kv_cache_config_hybrid_model(
154 elif second_spec_type == "mamba":
155 second_spec = MambaSpec(
156 block_size=block_size,
157 - shapes=(1, 1),
157 + shapes=((1, 1),),
158 dtypes=(torch.float32,),
159 )
160
@@ -189,7 +189,7 @@ def make_kv_cache_config_three_types(
189 if third_spec_type == "mamba":
190 third_spec = MambaSpec(
191 block_size=block_size,
192 - shapes=(1, 1),
192 + shapes=((1, 1),),
193 dtypes=(torch.float32,),
194 )
195 elif third_spec_type == "sliding_window":
@@ -768,12 +768,12 @@ def _make_hybrid_kv_cache_config(
768 ),
769 "mamba": lambda: MambaSpec(
770 block_size=block_size,
771 - shapes=(1, 1),
771 + shapes=((1, 1),),
772 dtypes=(torch.float32,),
773 ),
774 "mamba_align": lambda: MambaSpec(
775 block_size=block_size,
776 - shapes=(1, 1),
776 + shapes=((1, 1),),
777 dtypes=(torch.float32,),
778 mamba_cache_mode="align",
779 ),
@@ -3273,7 +3273,7 @@ def test_hybrid_local_kv_retention_interval_survives_recycling():
3273 num_kv_heads=1,
3274 head_size=1,
3275 dtype=torch.uint8,
3276 - compress_ratio=4,
3276 + tokens_per_state=4,
3277 ),
3278 ),
3279 KVCacheGroupSpec(
tests/v1/engine/test_engine_core_client.py
+1
@@ -1329,6 +1329,7 @@ def test_engine_core_proc_instantiation_cuda_empty(monkeypatch: pytest.MonkeyPat
1329 )
1330
1331 mock_executor.get_kv_cache_specs.return_value = [{"default": mock_spec}]
1332 + mock_executor.get_supported_kv_cache_layouts.return_value = [["LBNHC"]]
1333 mock_executor.determine_available_memory.return_value = [1024 * 1024 * 1024]
1334 mock_executor.initialize_from_config.return_value = None
1335 mock_executor.supports_draft_weight_updates.return_value = False
tests/v1/kv_connector/nixl_integration/run_accuracy_test.sh
+12 -10
@@ -36,7 +36,9 @@ if [[ -n "$VLLM_SERVE_EXTRA_ARGS" ]]; then
36 echo "vLLM serve extra args: $VLLM_SERVE_EXTRA_ARGS"
37 fi
38
39 -DECODER_KV_LAYOUT=${DECODER_KV_LAYOUT:-"HND"} # Default to HND, optional NHD
39 +# Empty leaves the layout to the backends, for models that constrain it.
40 +PREFILLER_KV_LAYOUT=${PREFILLER_KV_LAYOUT-"HND"}
41 +DECODER_KV_LAYOUT=${DECODER_KV_LAYOUT-"HND"} # Default to HND, optional NHD
42 if [[ "$DECODER_KV_LAYOUT" == "NHD" ]]; then
43 KV_CONFIG_HETERO_LAYOUT=',"enable_permute_local_kv":"True"'
44 else
@@ -44,9 +46,9 @@ else
46 fi
47
48 if [[ "$CROSS_LAYERS_BLOCKS" == "True" ]]; then
47 - KV_EXTRA_CONFIG=',"kv_connector_extra_config":{"enable_cross_layers_blocks": "True"}'
48 -else
49 - KV_EXTRA_CONFIG=''
49 + # Cross-layer blocks are exercised via the block-outermost layout.
50 + PREFILLER_KV_LAYOUT="BLHNC"
51 + DECODER_KV_LAYOUT="BLHNC"
52 fi
53
54 # Connector: default pull NixlConnector; NixlPushConnector enables PP prefill.
@@ -54,11 +56,11 @@ KV_CONNECTOR=${KV_CONNECTOR:-NixlConnector}
56
57 # Build the kv-transfer-config for P and D
58 if [[ "$KV_BUFFER_DEVICE" == "cuda" ]]; then
57 - KV_CONFIG_P='{"kv_connector":"'"$KV_CONNECTOR"'","kv_role":"kv_producer"'${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}'}'
58 - KV_CONFIG_D='{"kv_connector":"'"$KV_CONNECTOR"'","kv_role":"kv_consumer"'${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}'}'
59 + KV_CONFIG_P='{"kv_connector":"'"$KV_CONNECTOR"'","kv_role":"kv_producer"'${KV_CONFIG_HETERO_LAYOUT}'}'
60 + KV_CONFIG_D='{"kv_connector":"'"$KV_CONNECTOR"'","kv_role":"kv_consumer"'${KV_CONFIG_HETERO_LAYOUT}'}'
61 else
60 - KV_CONFIG_P="{\"kv_connector\":\"$KV_CONNECTOR\",\"kv_role\":\"kv_producer\",\"kv_buffer_device\":\"$KV_BUFFER_DEVICE\""${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}"}"
61 - KV_CONFIG_D="{\"kv_connector\":\"$KV_CONNECTOR\",\"kv_role\":\"kv_consumer\",\"kv_buffer_device\":\"$KV_BUFFER_DEVICE\""${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}"}"
62 + KV_CONFIG_P="{\"kv_connector\":\"${KV_CONNECTOR}\",\"kv_role\":\"kv_producer\",\"kv_buffer_device\":\"${KV_BUFFER_DEVICE}\"${KV_CONFIG_HETERO_LAYOUT}}"
63 + KV_CONFIG_D="{\"kv_connector\":\"${KV_CONNECTOR}\",\"kv_role\":\"kv_consumer\",\"kv_buffer_device\":\"${KV_BUFFER_DEVICE}\"${KV_CONFIG_HETERO_LAYOUT}}"
64 fi
65
66 # Models to run
@@ -176,7 +178,7 @@ run_tests_for_model() {
178
179 # Build the command with or without model-specific args
180 BASE_CMD="CUDA_VISIBLE_DEVICES=$GPU_ID \
179 - VLLM_KV_CACHE_LAYOUT='HND' \
181 + ${PREFILLER_KV_LAYOUT:+VLLM_KV_CACHE_LAYOUT=$PREFILLER_KV_LAYOUT} \
182 VLLM_PORT=$INTERNAL_PORT \
183 UCX_NET_DEVICES=all \
184 VLLM_NIXL_SIDE_CHANNEL_PORT=$SIDE_CHANNEL_PORT \
@@ -239,7 +241,7 @@ run_tests_for_model() {
241
242 # Build the command with or without model-specific args
243 BASE_CMD="CUDA_VISIBLE_DEVICES=$GPU_ID \
242 - VLLM_KV_CACHE_LAYOUT=$DECODER_KV_LAYOUT \
244 + ${DECODER_KV_LAYOUT:+VLLM_KV_CACHE_LAYOUT=$DECODER_KV_LAYOUT} \
245 $DECODER_INTERNAL_PORT_ENV \
246 UCX_NET_DEVICES=all \
247 VLLM_NIXL_SIDE_CHANNEL_PORT=$SIDE_CHANNEL_PORT \
tests/v1/kv_connector/nixl_integration/run_multi_connector_accuracy_test.sh
+6 -23
@@ -9,8 +9,8 @@
9 # test_accuracy.py.
10 #
11 # By default runs two configurations:
12 -# 1. Normal KV layout (NixlConnector without cross-layer blocks)
13 -# 2. Cross-layer KV layout (NixlConnector with enable_cross_layers_blocks)
12 +# 1. Normal KV layout (LBHNC)
13 +# 2. Cross-layer KV layout (BLHNC) via VLLM_KV_CACHE_LAYOUT
14 #
15 # Usage:
16 # bash tests/v1/kv_connector/nixl_integration/run_multi_connector_accuracy_test.sh
@@ -55,8 +55,6 @@ fi
55
56 # ── KV transfer configs ─────────────────────────────────────────────────
57
58 -# Normal layout: OffloadingConnector prefers cross-layer but NixlConnector
59 -# does not, so MultiConnector.prefer_cross_layer_blocks = False.
58 KV_CONFIG_NORMAL='{
59 "kv_connector":"MultiConnector",
60 "kv_role":"kv_both",
@@ -72,22 +70,6 @@ KV_CONFIG_NORMAL='{
70 # Remove whitespace for CLI safety
71 KV_CONFIG_NORMAL=$(echo "$KV_CONFIG_NORMAL" | tr -d '[:space:]')
72
75 -# Cross-layer layout: both connectors prefer cross-layer blocks.
76 -KV_CONFIG_CROSS_LAYERS='{
77 - "kv_connector":"MultiConnector",
78 - "kv_role":"kv_both",
79 - "kv_connector_extra_config":{
80 - "connectors":[
81 - {"kv_connector":"NixlConnector","kv_role":"kv_both",
82 - "kv_buffer_device":"'$KV_BUFFER_DEVICE'",
83 - "kv_connector_extra_config":{"enable_cross_layers_blocks":"True"}},
84 - {"kv_connector":"OffloadingConnector","kv_role":"kv_both",
85 - "kv_connector_extra_config":{"cpu_bytes_to_use":1000000000}}
86 - ]
87 - }
88 -}'
89 -KV_CONFIG_CROSS_LAYERS=$(echo "$KV_CONFIG_CROSS_LAYERS" | tr -d '[:space:]')
90 -
73 # ── Helpers ──────────────────────────────────────────────────────────────
74
75 PROCESS_GROUPS=()
@@ -218,7 +200,7 @@ run_tests_for_model() {
200 # ── Start prefill instance ──
201 echo "Starting prefill instance on GPU $PREFILL_GPU, port $PREFILL_PORT"
202 BASE_CMD="$DEVICE_VISIBILITY_ENV=$PREFILL_GPU \
221 - VLLM_KV_CACHE_LAYOUT='HND' \
203 + VLLM_KV_CACHE_LAYOUT='${VLLM_KV_CACHE_LAYOUT:-LBHNC}' \
204 UCX_NET_DEVICES=all \
205 VLLM_NIXL_SIDE_CHANNEL_PORT=$PREFILL_SIDE_CHANNEL_PORT \
206 vllm serve $model_name \
@@ -245,7 +227,7 @@ run_tests_for_model() {
227 # ── Start decode instance ──
228 echo "Starting decode instance on GPU $DECODE_GPU, port $DECODE_PORT"
229 BASE_CMD="$DEVICE_VISIBILITY_ENV=$DECODE_GPU \
248 - VLLM_KV_CACHE_LAYOUT='HND' \
230 + VLLM_KV_CACHE_LAYOUT='${VLLM_KV_CACHE_LAYOUT:-LBHNC}' \
231 UCX_NET_DEVICES=all \
232 VLLM_NIXL_SIDE_CHANNEL_PORT=$DECODE_SIDE_CHANNEL_PORT \
233 vllm serve $model_name \
@@ -305,7 +287,8 @@ for model in "${MODELS[@]}"; do
287 fi
288
289 if [[ -z "${SKIP_CROSS_LAYERS:-}" ]]; then
308 - run_tests_for_model "$model" "$KV_CONFIG_CROSS_LAYERS" "MultiConnector cross-layer layout"
290 + VLLM_KV_CACHE_LAYOUT=BLHNC \
291 + run_tests_for_model "$model" "$KV_CONFIG_NORMAL" "MultiConnector cross-layer layout"
292 fi
293 done
294
tests/v1/kv_connector/nixl_integration/run_multi_connector_edge_case_test.sh
+2 -2
@@ -95,7 +95,7 @@ run_tests_for_model() {
95 # ── Start prefill instance ──
96 echo "Starting prefill instance on GPU $PREFILL_GPU, port $PREFILL_PORT"
97 BASE_CMD="CUDA_VISIBLE_DEVICES=$PREFILL_GPU \
98 - VLLM_KV_CACHE_LAYOUT='HND' \
98 + VLLM_KV_CACHE_LAYOUT='LBHNC' \
99 UCX_NET_DEVICES=all \
100 VLLM_NIXL_SIDE_CHANNEL_PORT=$PREFILL_SIDE_CHANNEL_PORT \
101 vllm serve \"$model_name\" \
@@ -121,7 +121,7 @@ run_tests_for_model() {
121 # ── Start decode instance ──
122 echo "Starting decode instance on GPU $DECODE_GPU, port $DECODE_PORT"
123 BASE_CMD="CUDA_VISIBLE_DEVICES=$DECODE_GPU \
124 - VLLM_KV_CACHE_LAYOUT='HND' \
124 + VLLM_KV_CACHE_LAYOUT='LBHNC' \
125 UCX_NET_DEVICES=all \
126 VLLM_NIXL_SIDE_CHANNEL_PORT=$DECODE_SIDE_CHANNEL_PORT \
127 vllm serve \"$model_name\" \
tests/v1/kv_connector/nixl_integration/spec_decode_acceptance_test.sh
+2 -2
@@ -249,7 +249,7 @@ run_test_for_device() {
249 echo "Starting prefill instance $i on GPU $GPU_ID, port $PORT"
250 env \
251 ${GPU_DEVICE_VAR}="$GPU_ID" \
252 - VLLM_KV_CACHE_LAYOUT='HND' \
252 + VLLM_KV_CACHE_LAYOUT='LBHNC' \
253 UCX_NET_DEVICES=all \
254 ${VLLM_SSM_CONV_STATE_LAYOUT:+VLLM_SSM_CONV_STATE_LAYOUT=$VLLM_SSM_CONV_STATE_LAYOUT} \
255 VLLM_NIXL_SIDE_CHANNEL_HOST="$NIXL_SIDE_CHANNEL_HOST" \
@@ -288,7 +288,7 @@ run_test_for_device() {
288 echo "Starting decode instance $i on GPU $GPU_ID, port $PORT"
289 env \
290 ${GPU_DEVICE_VAR}="$GPU_ID" \
291 - VLLM_KV_CACHE_LAYOUT='HND' \
291 + VLLM_KV_CACHE_LAYOUT='LBHNC' \
292 UCX_NET_DEVICES=all \
293 ${VLLM_SSM_CONV_STATE_LAYOUT:+VLLM_SSM_CONV_STATE_LAYOUT=$VLLM_SSM_CONV_STATE_LAYOUT} \
294 VLLM_NIXL_SIDE_CHANNEL_HOST="$NIXL_SIDE_CHANNEL_HOST" \
tests/v1/kv_connector/unit/offloading_connector/test_canonical_mapping.py
+12 -7
@@ -12,6 +12,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.canonical_mapping i
12 _verify_tiling,
13 derive_canonical_mappings,
14 )
15 +from vllm.v1.attention.backends.triton_attn import TritonAttentionBackend
16 from vllm.v1.kv_cache_interface import (
17 FullAttentionSpec,
18 KVCacheGroupSpec,
@@ -37,12 +38,14 @@ def _ctx(rank, tp=1, dcp=1, pcp=1, interleave=1, total=None, heads=2):
38
39 def _full_spec(num_kv_heads: int = 2, **kwargs) -> FullAttentionSpec:
40 # block_size=4, head_dim=64, int8
40 - return FullAttentionSpec(
41 - block_size=4,
42 - num_kv_heads=num_kv_heads,
43 - head_size=64,
44 - dtype=torch.int8,
45 - **kwargs,
41 + return TritonAttentionBackend.customize_spec(
42 + FullAttentionSpec(
43 + block_size=4,
44 + num_kv_heads=num_kv_heads,
45 + head_size=64,
46 + dtype=torch.int8,
47 + **kwargs,
48 + )
49 )
50
51
@@ -381,7 +384,9 @@ def test_fail_closed_cases():
384 quant_spec = _full_spec(kv_quant_mode=KVQuantMode.FP8_PER_TOKEN_HEAD)
385 assert _try_mapping(quant_spec, _split_nhd_cache(quant_spec), _ctx(0, tp=4)) is None
386 # Compressed MLA slots are not 1:1 with tokens
384 - assert _try_mapping(_mla_spec(compress_ratio=2), None, _ctx(0, tp=2, dcp=2)) is None
387 + assert (
388 + _try_mapping(_mla_spec(tokens_per_state=2), None, _ctx(0, tp=2, dcp=2)) is None
389 + )
390 # Unrecognized physical layouts
391 swapped = torch.zeros(
392 NUM_BLOCKS,
tests/v1/kv_connector/unit/offloading_connector/test_config.py
+128 -99
@@ -72,43 +72,54 @@ def _make_vllm_config(
72
73 def _make_kv_cache_config() -> KVCacheConfig:
74 num_blocks = 16
75 + spec = FullAttentionSpec(
76 + block_size=16,
77 + num_kv_heads=1,
78 + head_size=1,
79 + dtype=torch.float32,
80 + )
81 kv_tensor = KVCacheTensor(
76 - size=num_blocks * 8,
77 - shared_by=["layer"],
78 - block_stride=0,
82 + size=spec.page_size_bytes * num_blocks,
83 + layers=["layer"],
84 + layer_stride=spec.page_size_bytes * num_blocks,
85 + block_stride=spec.page_size_bytes,
86 )
87 return KVCacheConfig(
88 num_blocks=num_blocks,
89 kv_cache_tensors=[kv_tensor],
83 - kv_cache_groups=[
84 - KVCacheGroupSpec(
85 - ["layer"],
86 - FullAttentionSpec(
87 - block_size=16,
88 - num_kv_heads=1,
89 - head_size=1,
90 - dtype=torch.float32,
91 - ),
92 - )
93 - ],
90 + kv_cache_groups=[KVCacheGroupSpec(["layer"], spec)],
91 )
92
93
94 def _make_sizing_kv_cache_config(packed: bool) -> KVCacheConfig:
95 + """One 16 byte-per-block allocation, described two ways.
96 +
97 + Packed: both layers are one dense run. Unpacked: the same bytes as two runs, the
98 + second starting after the first layer's region. Either way the connector accounts
99 + for 16 KV bytes per block.
100 + """
101 num_blocks = 4
102 + page = 8
103 + size = 2 * page * num_blocks
104 if packed:
105 kv_cache_tensors = [
106 KVCacheTensor(
102 - size=64,
103 - shared_by=[layer_name],
104 - block_stride=16,
107 + size=size,
108 + layers=["layer0", "layer1"],
109 + layer_stride=page * num_blocks,
110 + block_stride=page,
111 )
106 - for layer_name in ("layer0", "layer1")
112 ]
113 else:
114 kv_cache_tensors = [
110 - KVCacheTensor(size=40, shared_by=["layer0"]),
111 - KVCacheTensor(size=24, shared_by=["layer1"]),
115 + KVCacheTensor(
116 + size=size,
117 + layers=[layer],
118 + layer_stride=page * num_blocks,
119 + block_stride=page,
120 + offset=i * page * num_blocks,
121 + )
122 + for i, layer in enumerate(("layer0", "layer1"))
123 ]
124
125 return KVCacheConfig(
@@ -150,6 +161,18 @@ def _mla_spec(
161 )
162
163
164 +_MAMBA_SPEC = MambaSpec(
165 + block_size=16,
166 + shapes=((16, 1),),
167 + dtypes=(torch.float32,),
168 +)
169 +# Page sizes of the specs the replicated-layout cases below are built from.
170 +_MLA_PAGE = _mla_spec().page_size_bytes
171 +_HALF_MLA_PAGE = _mla_spec(head_size=256).page_size_bytes
172 +_FULL_PAGE = _full_attention_spec().page_size_bytes
173 +_MAMBA_PAGE = _MAMBA_SPEC.page_size_bytes
174 +
175 +
176 def _make_mla_kv_cache_config(
177 layer_names: list[str] | None = None,
178 head_size: int = 512,
@@ -159,12 +182,14 @@ def _make_mla_kv_cache_config(
182 if layer_names is None:
183 layer_names = ["layer0", "layer1"]
184 spec = _mla_spec(head_size=head_size, dtype=dtype)
185 + layer_stride = spec.page_size_bytes * num_blocks
186 kv_cache_tensors = [
187 KVCacheTensor(
164 - size=spec.page_size_bytes * num_blocks,
165 - shared_by=[layer_name],
188 + size=layer_stride * len(layer_names),
189 + layers=layer_names,
190 + layer_stride=layer_stride,
191 + block_stride=spec.page_size_bytes,
192 )
167 - for layer_name in layer_names
193 ]
194 return KVCacheConfig(
195 num_blocks=num_blocks,
@@ -174,15 +199,31 @@ def _make_mla_kv_cache_config(
199
200
201 def _make_hybrid_kv_cache_config() -> KVCacheConfig:
202 + num_blocks = 4
203 + full_spec = _full_attention_spec(block_size=12)
204 + mla_spec = _mla_spec()
205 + # Mixed page sizes across overlaying groups: a block is a window of the largest
206 + # group's packing, so the layer dim sits inside the block dim.
207 + window = max(full_spec.page_size_bytes, mla_spec.page_size_bytes)
208 return KVCacheConfig(
178 - num_blocks=4,
209 + num_blocks=num_blocks,
210 kv_cache_tensors=[
180 - KVCacheTensor(size=40, shared_by=["full_layer"]),
181 - KVCacheTensor(size=24, shared_by=["mla_layer"]),
211 + KVCacheTensor(
212 + size=window * num_blocks,
213 + layers=["full_layer"],
214 + layer_stride=full_spec.page_size_bytes,
215 + block_stride=window,
216 + ),
217 + KVCacheTensor(
218 + size=window * num_blocks,
219 + layers=["mla_layer"],
220 + layer_stride=mla_spec.page_size_bytes,
221 + block_stride=window,
222 + ),
223 ],
224 kv_cache_groups=[
184 - KVCacheGroupSpec(["full_layer"], _full_attention_spec(block_size=12)),
185 - KVCacheGroupSpec(["mla_layer"], _mla_spec()),
225 + KVCacheGroupSpec(["full_layer"], full_spec),
226 + KVCacheGroupSpec(["mla_layer"], mla_spec),
227 ],
228 )
229
@@ -263,18 +304,9 @@ def test_worker_kv_bytes_preserves_tensor_layout(packed: bool):
304 assert offloading_config.cache.blocks_per_chunk == 2
305
306
266 -def test_rejects_partially_packed_tensor_layout():
267 - kv_cache_config = _make_sizing_kv_cache_config(packed=False)
268 - kv_cache_config.kv_cache_tensors[0].block_stride = 16
269 -
270 - with pytest.raises(AssertionError):
271 - build_offloading_config(_make_vllm_config(), kv_cache_config)
272 -
273 -
307 def test_zero_blocks_skips_tensor_layout_validation():
308 kv_cache_config = _make_sizing_kv_cache_config(packed=False)
309 kv_cache_config.num_blocks = 0
277 - kv_cache_config.kv_cache_tensors[0].block_stride = 16
310
311 offloading_config = build_offloading_config(_make_vllm_config(), kv_cache_config)
312
@@ -374,8 +406,10 @@ def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
406 num_blocks=4,
407 kv_cache_tensors=[
408 KVCacheTensor(
377 - size=_mla_spec().page_size_bytes * 4,
378 - shared_by=["layer"],
409 + size=_MLA_PAGE * 4,
410 + layers=["layer"],
411 + layer_stride=_MLA_PAGE * 4,
412 + block_stride=_MLA_PAGE,
413 )
414 ],
415 kv_cache_groups=[
@@ -398,8 +432,10 @@ def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
432 num_blocks=4,
433 kv_cache_tensors=[
434 KVCacheTensor(
401 - size=_mla_spec().page_size_bytes * 4,
402 - shared_by=["layer"],
435 + size=_MLA_PAGE * 4,
436 + layers=["layer"],
437 + layer_stride=_MLA_PAGE * 4,
438 + block_stride=_MLA_PAGE,
439 )
440 ],
441 kv_cache_groups=[
@@ -417,16 +453,22 @@ def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
453 "hidden-state",
454 ),
455 (
456 + # One group, two page sizes: layer1's run starts past layer0's region.
457 KVCacheConfig(
458 num_blocks=4,
459 kv_cache_tensors=[
460 KVCacheTensor(
424 - size=_mla_spec().page_size_bytes * 4,
425 - shared_by=["layer0"],
461 + size=(_MLA_PAGE + _HALF_MLA_PAGE) * 4,
462 + layers=["layer0"],
463 + layer_stride=_MLA_PAGE * 4,
464 + block_stride=_MLA_PAGE,
465 ),
466 KVCacheTensor(
428 - size=_mla_spec(head_size=256).page_size_bytes * 4,
429 - shared_by=["layer1"],
467 + size=(_MLA_PAGE + _HALF_MLA_PAGE) * 4,
468 + layers=["layer1"],
469 + layer_stride=_HALF_MLA_PAGE * 4,
470 + block_stride=_HALF_MLA_PAGE,
471 + offset=_MLA_PAGE * 4,
472 ),
473 ],
474 kv_cache_groups=[
@@ -445,16 +487,22 @@ def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
487 "uniform-wrapper",
488 ),
489 (
490 + # Overlaid groups with different page sizes: a block is a window of
491 + # the largest group's packing.
492 KVCacheConfig(
493 num_blocks=4,
494 kv_cache_tensors=[
495 KVCacheTensor(
452 - size=_mla_spec().page_size_bytes * 4,
453 - shared_by=["mla"],
496 + size=_FULL_PAGE * 4,
497 + layers=["mla"],
498 + layer_stride=_MLA_PAGE,
499 + block_stride=_FULL_PAGE,
500 ),
501 KVCacheTensor(
456 - size=_full_attention_spec().page_size_bytes * 4,
457 - shared_by=["full"],
502 + size=_FULL_PAGE * 4,
503 + layers=["full"],
504 + layer_stride=_FULL_PAGE,
505 + block_stride=_FULL_PAGE,
506 ),
507 ],
508 kv_cache_groups=[
@@ -469,21 +517,21 @@ def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
517 num_blocks=4,
518 kv_cache_tensors=[
519 KVCacheTensor(
472 - size=_mla_spec().page_size_bytes * 4,
473 - shared_by=["mla"],
520 + size=_MLA_PAGE * 4,
521 + layers=["mla"],
522 + layer_stride=_MLA_PAGE,
523 + block_stride=_MLA_PAGE,
524 + ),
525 + KVCacheTensor(
526 + size=_MLA_PAGE * 4,
527 + layers=["mamba"],
528 + layer_stride=_MAMBA_PAGE,
529 + block_stride=_MLA_PAGE,
530 ),
475 - KVCacheTensor(size=64 * 4, shared_by=["mamba"]),
531 ],
532 kv_cache_groups=[
533 KVCacheGroupSpec(["mla"], _mla_spec()),
479 - KVCacheGroupSpec(
480 - ["mamba"],
481 - MambaSpec(
482 - block_size=16,
483 - shapes=((16, 1),),
484 - dtypes=(torch.float32,),
485 - ),
486 - ),
534 + KVCacheGroupSpec(["mamba"], _MAMBA_SPEC),
535 ],
536 ),
537 "mla-mamba-hybrid",
@@ -493,13 +541,12 @@ def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
541 num_blocks=4,
542 kv_cache_tensors=[
543 KVCacheTensor(
496 - size=_mla_spec().page_size_bytes * 4,
497 - shared_by=["layer0"],
498 - ),
499 - KVCacheTensor(
500 - size=_mla_spec().page_size_bytes * 4,
501 - shared_by=["layer1"],
502 - ),
544 + size=_MLA_PAGE * 4,
545 + layers=[layer],
546 + layer_stride=_MLA_PAGE * 4,
547 + block_stride=_MLA_PAGE,
548 + )
549 + for layer in ("layer0", "layer1")
550 ],
551 kv_cache_groups=[
552 KVCacheGroupSpec(["layer0"], _mla_spec()),
@@ -531,16 +578,26 @@ def test_replicated_layout_rejects_bare_mla_with_mixed_page_accounting():
578 indexer_spec = _mla_spec(head_size=128, dtype=torch.uint8)
579 main_layers = [f"main_{i}" for i in range(61)]
580 indexer_layers = [f"indexer_{i}" for i in range(61)]
581 + # A DSA-style group: the main pages and the smaller indexer pages are packed one
582 + # after the other, so a block holds more than 61 MLA pages.
583 + main_bytes = main_spec.page_size_bytes * len(main_layers)
584 + indexer_bytes = indexer_spec.page_size_bytes * len(indexer_layers)
585 + size = (main_bytes + indexer_bytes) * num_blocks
586 kv_cache_config = KVCacheConfig(
587 num_blocks=num_blocks,
588 kv_cache_tensors=[
589 KVCacheTensor(
538 - size=main_spec.page_size_bytes * len(main_layers) * num_blocks,
539 - shared_by=main_layers,
590 + size=size,
591 + layers=main_layers,
592 + layer_stride=main_spec.page_size_bytes * num_blocks,
593 + block_stride=main_spec.page_size_bytes,
594 ),
595 KVCacheTensor(
542 - size=indexer_spec.page_size_bytes * len(indexer_layers) * num_blocks,
543 - shared_by=indexer_layers,
596 + size=size,
597 + layers=indexer_layers,
598 + layer_stride=indexer_spec.page_size_bytes * num_blocks,
599 + block_stride=indexer_spec.page_size_bytes,
600 + offset=main_bytes * num_blocks,
601 ),
602 ],
603 kv_cache_groups=[KVCacheGroupSpec(main_layers + indexer_layers, main_spec)],
@@ -665,34 +722,6 @@ def test_canonical_layout_certifies_v2_model_runner():
722 ).parallel.is_parallelism_agnostic
723
724
668 -def test_prefer_cross_layer_blocks_yields_to_canonical_layout():
669 - """The connector must not request cross-layer blocks under
670 - canonical_layout: cross-layer slabs have no per-layer refs to certify."""
671 - from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorRole
672 - from vllm.distributed.kv_transfer.kv_connector.v1.offloading_connector import (
673 - OffloadingConnector,
674 - )
675 -
676 - kv_cache_config = _make_kv_cache_config()
677 - connector_module = "vllm.distributed.kv_transfer.kv_connector.v1"
678 -
679 - def make_connector(extra_config: dict[str, Any] | None) -> OffloadingConnector:
680 - with (
681 - patch(f"{connector_module}.offloading_connector.OffloadingSpecFactory"),
682 - patch(
683 - f"{connector_module}.offloading_connector.OffloadingConnectorScheduler"
684 - ),
685 - ):
686 - return OffloadingConnector(
687 - _make_vllm_config(extra_config=extra_config),
688 - KVConnectorRole.SCHEDULER,
689 - kv_cache_config,
690 - )
691 -
692 - assert make_connector(None).prefer_cross_layer_blocks
693 - assert not make_connector({"canonical_layout": True}).prefer_cross_layer_blocks
694 -
695 -
725 def test_parallelism_agnostic_disabled_on_v2_model_runner():
726 config = _make_vllm_config()
727 config.use_v2_model_runner = True
tests/v1/kv_connector/unit/offloading_connector/test_worker.py
+33 -52
@@ -1,6 +1,5 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -from collections import defaultdict
3 from unittest.mock import MagicMock
4
5 import pytest
@@ -13,11 +12,11 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
12 from vllm.platforms import current_platform
13 from vllm.utils.torch_utils import get_dtype_size
14 from vllm.v1.attention.backends.registry import AttentionBackendEnum
16 -from vllm.v1.attention.backends.utils import set_kv_cache_layout
15 from vllm.v1.kv_cache_interface import (
16 FullAttentionSpec,
17 KVCacheConfig,
18 KVCacheGroupSpec,
19 + KVCacheLayout,
20 KVCacheTensor,
21 MambaSpec,
22 MLAAttentionSpec,
@@ -65,40 +64,18 @@ elif current_platform.is_xpu():
64 # ---------------------------------------------------------------------------
65
66
68 -def _allocate_and_reshape_kv_caches(
67 +def _allocate_kv_caches(
68 kv_cache_config: KVCacheConfig,
69 attn_groups: list[list],
70 device: torch.device,
71 ):
73 - """
74 - Use the real GPUModelRunner allocation and reshape methods to produce
75 - kv_caches, just like the model runner does during initialization.
76 - """
77 - from vllm.v1.worker.gpu_model_runner import GPUModelRunner
78 -
79 - # Some backends (e.g. FlashAttention) query the KV cache layout during
80 - # reshape, which ultimately calls get_current_vllm_config(). Setting
81 - # the layout override avoids needing a full VllmConfig context.
82 - set_kv_cache_layout("NHD")
83 - try:
84 - runner = object.__new__(GPUModelRunner)
85 - runner.device = device
86 - runner.runner_only_attn_layers = set()
87 - runner.attn_groups = attn_groups
88 - runner.kv_cache_config = kv_cache_config
89 - runner.cache_config = MagicMock(cache_dtype="auto")
90 - runner.shared_kv_cache_layers = {}
91 - runner.model_config = MagicMock()
92 - runner.model_config.hf_config.model_type = ""
93 - runner.compilation_config = MagicMock(
94 - static_forward_context=defaultdict(MagicMock)
95 - )
96 - runner.kv_caches = []
72 + """Allocate kv_caches exactly as the model runner does at startup."""
73 + from vllm.v1.worker.utils import allocate_kv_cache
74
98 - kernel_block_sizes = [BLOCK_SIZE] * len(kv_cache_config.kv_cache_groups)
99 - return runner.initialize_kv_cache_tensors(kv_cache_config, kernel_block_sizes)
100 - finally:
101 - set_kv_cache_layout(None)
75 + kernel_block_sizes = [BLOCK_SIZE] * len(kv_cache_config.kv_cache_groups)
76 + return allocate_kv_cache(
77 + kv_cache_config, device, KVCacheLayout.LBNHC, kernel_block_sizes
78 + )
79
80
81 def _single_rank_vllm_config(total_kv_heads: int):
@@ -321,10 +298,9 @@ def test_register_kv_caches(backend):
298 Creates one FullAttention group, one MLA group, one Mamba group, and
299 one Mamba-padded group. Each group has GROUP_SIZE layers.
300
324 - KVCacheTensors are shared across all groups mirroring the real allocation
325 - in kv_cache_utils.py: tensor i is shared by layer i from every group.
326 - The padded-mamba group has a different page size so its layers get their
327 - own dedicated tensors.
301 + KVCacheTensors mirror the real allocation in kv_cache_utils.py: each group is one
302 + run of the shared allocation starting at offset 0, so layer i of every group
303 + aliases the same bytes.
304
305 Uses the real GPUModelRunner.initialize_kv_cache_tensors to produce
306 the raw per-layer kv_caches registered by the connector.
@@ -413,18 +389,15 @@ def test_register_kv_caches(backend):
389 aligned_mamba_layer_names,
390 ]
391
416 - kv_cache_tensors: list[KVCacheTensor] = []
417 - for i in range(GROUP_SIZE):
418 - shared_by: list[str] = []
419 - for group_layer_names in layer_groups:
420 - if len(group_layer_names) > i:
421 - shared_by.append(group_layer_names[i])
422 - kv_cache_tensors.append(
423 - KVCacheTensor(
424 - size=PAGE_SIZE_BYTES * NUM_BLOCKS,
425 - shared_by=shared_by,
426 - )
392 + kv_cache_tensors: list[KVCacheTensor] = [
393 + KVCacheTensor(
394 + size=PAGE_SIZE_BYTES * NUM_BLOCKS * GROUP_SIZE,
395 + layers=group_layer_names,
396 + layer_stride=PAGE_SIZE_BYTES * NUM_BLOCKS,
397 + block_stride=PAGE_SIZE_BYTES,
398 )
399 + for group_layer_names in layer_groups
400 + ]
401
402 kv_cache_groups = [
403 KVCacheGroupSpec(layer_names=attn_layer_names, kv_cache_spec=attn_spec),
@@ -472,7 +445,7 @@ def test_register_kv_caches(backend):
445 kv_cache_groups=kv_cache_groups,
446 )
447
475 - kv_caches = _allocate_and_reshape_kv_caches(
448 + kv_caches = _allocate_kv_caches(
449 kv_cache_config,
450 attn_groups,
451 device=torch.device(f"{DEVICE_TYPE}:0"),
@@ -580,16 +553,24 @@ def test_register_kv_caches_uniform_type(backend):
553 kv_cache_specs={layer_a: spec_a, layer_b: spec_b},
554 )
555
556 + # The group packs its two layers densely, one run each as their pages differ:
557 + # layer_a occupies the first page of every block, layer_b the rest.
558 + window = spec_a.page_size_bytes + spec_b.page_size_bytes
559 kv_cache_config = KVCacheConfig(
560 num_blocks=NUM_BLOCKS,
561 kv_cache_tensors=[
562 KVCacheTensor(
587 - size=spec_a.page_size_bytes * NUM_BLOCKS,
588 - shared_by=[layer_a],
563 + size=window * NUM_BLOCKS,
564 + layers=[layer_a],
565 + layer_stride=spec_a.page_size_bytes * NUM_BLOCKS,
566 + block_stride=spec_a.page_size_bytes,
567 ),
568 KVCacheTensor(
591 - size=spec_b.page_size_bytes * NUM_BLOCKS,
592 - shared_by=[layer_b],
569 + size=window * NUM_BLOCKS,
570 + layers=[layer_b],
571 + layer_stride=spec_b.page_size_bytes * NUM_BLOCKS,
572 + block_stride=spec_b.page_size_bytes,
573 + offset=spec_a.page_size_bytes * NUM_BLOCKS,
574 ),
575 ],
576 kv_cache_groups=[
@@ -617,7 +598,7 @@ def test_register_kv_caches_uniform_type(backend):
598 ]
599 ]
600
620 - kv_caches = _allocate_and_reshape_kv_caches(
601 + kv_caches = _allocate_kv_caches(
602 kv_cache_config,
603 attn_groups,
604 device=torch.device(f"{DEVICE_TYPE}:0"),
tests/v1/kv_connector/unit/offloading_connector/utils.py
+9 -4
@@ -241,13 +241,19 @@ class RequestRunner:
241 )
242 ]
243
244 + # Groups overlay one allocation, sized by the largest group.
245 + block_stride = max(
246 + group.kv_cache_spec.page_size_bytes * len(group.layer_names)
247 + for group in kv_cache_groups
248 + )
249 kv_cache_tensors = [
250 KVCacheTensor(
246 - size=group.kv_cache_spec.page_size_bytes * num_gpu_blocks,
247 - shared_by=[layer_name],
251 + size=block_stride * num_gpu_blocks,
252 + layers=list(group.layer_names),
253 + layer_stride=group.kv_cache_spec.page_size_bytes * num_gpu_blocks,
254 + block_stride=group.kv_cache_spec.page_size_bytes,
255 )
256 for group in kv_cache_groups
250 - for layer_name in group.layer_names
257 ]
258
259 kv_cache_config = KVCacheConfig(
@@ -277,7 +283,6 @@ class RequestRunner:
283 )
284
285 # register worker kv_caches to enable OffloadingWorker creations
280 - # set_current_vllm_config is needed for get_kv_cache_layout() to work
286 kv_caches: dict[str, torch.Tensor] = {}
287 for group in kv_cache_groups:
288 spec = group.kv_cache_spec
tests/v1/kv_connector/unit/test_bidirectional_kv_transfer.py
+1 -1
@@ -98,7 +98,7 @@ def _make_connector_with_fake_worker(
98 )
99 worker = connector.connector_worker
100 assert isinstance(worker.nixl_wrapper, FakeNixlWrapper)
101 - worker.kv_cache_layout = "HND"
101 + worker.kv_cache_layout = "LBHNC"
102 if do_handshake:
103 remote_agents, _ = worker._nixl_handshake(
104 host="localhost",
tests/v1/kv_connector/unit/test_kv_cache_layout.py deleted
-75
@@ -1,75 +0,0 @@
1 -# SPDX-License-Identifier: Apache-2.0
2 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -
4 -import pytest
5 -
6 -from vllm.platforms import current_platform
7 -
8 -
9 -def test_mla_common_backend_rejects_cross_layer_kv_cache():
10 - """MLACommonBackend defaults to the identity permutation (layers dim
11 - first) so MLA backends whose decode kernels are not verified to honor
12 - the cache's block-dim stride stay opted out of cross-layer KV cache."""
13 - from vllm.model_executor.layers.attention.mla_attention import (
14 - MLACommonBackend,
15 - )
16 -
17 - stride_order = MLACommonBackend.get_kv_cache_stride_order(
18 - include_num_layers_dimension=True
19 - )
20 - assert stride_order == (0, 1, 2, 3)
21 - assert stride_order[0] == 0 # layers dim first => no cross-layer
22 - assert MLACommonBackend.get_kv_cache_stride_order(
23 - include_num_layers_dimension=False
24 - ) == (0, 1, 2)
25 -
26 -
27 -@pytest.mark.parametrize(
28 - "backend_path",
29 - # See: https://github.com/vllm-project/vllm/issues/46411
30 - [
31 - "vllm.v1.attention.backends.mla.triton_mla.TritonMLABackend",
32 - ]
33 - if current_platform.is_rocm() or current_platform.is_xpu()
34 - else [
35 - "vllm.v1.attention.backends.mla.triton_mla.TritonMLABackend",
36 - "vllm.v1.attention.backends.mla.cutlass_mla.CutlassMLABackend",
37 - "vllm.v1.attention.backends.mla.flashattn_mla.FlashAttnMLABackend",
38 - "vllm.v1.attention.backends.mla.flashmla.FlashMLABackend",
39 - "vllm.v1.attention.backends.mla.flashinfer_mla.FlashInferMLABackend",
40 - ],
41 -)
42 -def test_verified_mla_backends_support_cross_layer_kv_cache(backend_path):
43 - """Backends whose decode kernels honor the cache's block-dim stride opt
44 - in to the cross-layer layout with a non-identity permutation placing
45 - num_blocks first in physical layout."""
46 - module_path, name = backend_path.rsplit(".", 1)
47 - backend = getattr(
48 - pytest.importorskip(module_path, reason="backend deps unavailable"), name
49 - )
50 -
51 - stride_order = backend.get_kv_cache_stride_order(include_num_layers_dimension=True)
52 - assert stride_order == (1, 0, 2, 3)
53 - assert stride_order[0] != 0 # num_blocks first => cross-layer supported
54 - assert backend.get_kv_cache_stride_order(include_num_layers_dimension=False) == (
55 - 0,
56 - 1,
57 - 2,
58 - )
59 -
60 -
61 -def test_deepseek_v32_indexer_rejects_cross_layer_kv_cache():
62 - """DeepseekV32Indexer returns identity permutation (layers dim first)
63 - to signal cross-layer KV cache is unsupported."""
64 - from vllm.v1.attention.backends.mla.indexer import (
65 - DeepseekV32IndexerBackend,
66 - )
67 -
68 - stride_order = DeepseekV32IndexerBackend.get_kv_cache_stride_order(
69 - include_num_layers_dimension=True
70 - )
71 - assert stride_order == (0, 1, 2, 3)
72 - assert stride_order[0] == 0 # layers dim first => no cross-layer
73 - assert DeepseekV32IndexerBackend.get_kv_cache_stride_order(
74 - include_num_layers_dimension=False
75 - ) == (0, 1, 2)
tests/v1/kv_connector/unit/test_mooncake_connector.py
+60 -20
@@ -11,6 +11,7 @@ import pytest
11 import torch
12 import zmq.asyncio
13
14 +from tests.v1.attention.utils import dense_kv_cache_views
15 from vllm import envs
16 from vllm.config import set_current_vllm_config
17 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_connector import (
@@ -32,11 +33,11 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_utils import
33 MooncakeBootstrapServer,
34 )
35 from vllm.utils.network_utils import get_open_port
35 -from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
36 from vllm.v1.kv_cache_interface import (
37 FullAttentionSpec,
38 KVCacheConfig,
39 KVCacheGroupSpec,
40 + KVCacheLayout,
41 )
42 from vllm.v1.request import RequestStatus
43
@@ -1093,7 +1094,19 @@ async def test_worker_get_finished_timeout(monkeypatch):
1094 assert "tx-active" in prefill_worker.reqs_need_send
1095
1096
1096 -def test_register_kv_caches():
1097 +@pytest.mark.parametrize(
1098 + ("layout", "separate_kv_head_groups"),
1099 + [
1100 + (KVCacheLayout.LBHNC, False),
1101 + (KVCacheLayout.BLHNC, False),
1102 + (KVCacheLayout.BHLNC, False),
1103 + # LHBNC gives each head group its own region; the K/V split doubles the
1104 + # head count but the registration shape is driven by the layout.
1105 + (KVCacheLayout.LHBNC, False),
1106 + (KVCacheLayout.LHBNC, True),
1107 + ],
1108 +)
1109 +def test_register_kv_caches(layout: KVCacheLayout, separate_kv_head_groups: bool):
1110 """Tests the memory registration logic with the underlying Mooncake engine."""
1111
1112 vllm_config = create_vllm_config(
@@ -1118,15 +1131,23 @@ def test_register_kv_caches():
1131 worker = connector.connector_worker
1132 mock_thread.return_value.is_alive.return_value = False
1133
1121 - kv_cache_shape = FlashAttentionBackend.get_kv_cache_shape(
1122 - num_blocks=2, block_size=16, num_kv_heads=4, head_size=64
1134 + spec = FullAttentionSpec(
1135 + block_size=16,
1136 + num_kv_heads=4,
1137 + head_size=64,
1138 + dtype=torch.float16,
1139 + num_head_slots=2 if separate_kv_head_groups else None,
1140 + state_content_bytes=4 * 64 * 2 if separate_kv_head_groups else None,
1141 )
1124 - tensor1 = torch.zeros(*kv_cache_shape, dtype=torch.float16)
1125 - tensor2 = torch.zeros(*kv_cache_shape, dtype=torch.float16)
1126 - kv_caches = {
1127 - "model.layers.0.self_attn": tensor1,
1128 - "model.layers.1.self_attn": tensor2,
1129 - }
1142 + layer_names = [
1143 + "model.layers.0.self_attn",
1144 + "model.layers.1.self_attn",
1145 + ]
1146 + for layer_name in layer_names:
1147 + worker._layer_specs[layer_name] = spec
1148 + raw = torch.zeros(2 * 2 * spec.page_size_bytes, dtype=torch.int8)
1149 + tensor1, tensor2 = dense_kv_cache_views(raw, spec, 2, 2, layout)
1150 + kv_caches = dict(zip(layer_names, (tensor1, tensor2)))
1151
1152 with patch.object(
1153 worker.engine, "batch_register_memory", return_value=0
@@ -1135,16 +1156,35 @@ def test_register_kv_caches():
1156
1157 mock_batch_register.assert_called_once()
1158 registered_ptrs, registered_lens = mock_batch_register.call_args[0]
1138 - expected_ptrs = {tensor.data_ptr() for tensor in kv_caches.values()}
1139 - assert set(registered_ptrs) == expected_ptrs
1140 - assert set(registered_lens) == {tensor1.nbytes}
1141 -
1142 - # Verify block_len_per_layer is set correctly.
1143 - assert len(worker.block_len_per_layer) == len(registered_ptrs)
1144 - for bl in worker.block_len_per_layer:
1145 - assert bl == tensor1.nbytes // tensor1.shape[0]
1146 - assert worker.registered_layer_names == list(kv_caches)
1147 - assert worker.registered_layer_indices == [0, 1]
1159 + assert registered_ptrs == [raw.data_ptr()]
1160 + assert registered_lens == [raw.nbytes]
1161 +
1162 + if not layout.is_block_compact:
1163 + expected_addrs = [
1164 + cache[:, head_idx].data_ptr()
1165 + for cache in (tensor1, tensor2)
1166 + for head_idx in range(cache.shape[1])
1167 + ]
1168 + head_block_bytes = tensor1.stride(0) * tensor1.element_size()
1169 + assert worker.kv_caches_base_addr == expected_addrs
1170 + assert worker.block_len_per_layer == [head_block_bytes] * len(
1171 + expected_addrs
1172 + )
1173 + assert worker.kv_block_len_per_layer == [head_block_bytes] * len(
1174 + expected_addrs
1175 + )
1176 + assert worker.registered_layer_names == [
1177 + layer_name
1178 + for layer_name in layer_names
1179 + for _ in range(tensor1.shape[1])
1180 + ]
1181 + else:
1182 + assert len(worker.block_len_per_layer) == len(kv_caches)
1183 + for bl in worker.block_len_per_layer:
1184 + assert bl == tensor1.stride(0) * tensor1.element_size()
1185 + assert worker.kv_block_len_per_layer == [spec.page_size_bytes] * 2
1186 + assert worker.registered_layer_names == list(kv_caches)
1187 + assert worker.registered_layer_indices == [0, 1]
1188
1189
1190 def test_register_kv_caches_supports_mixed_mla_and_eagle_shapes():
tests/v1/kv_connector/unit/test_mooncake_connector_hybrid_mamba.py
+24 -12
@@ -14,6 +14,7 @@ from unittest.mock import patch
14 import pytest
15 import torch
16
17 +from tests.v1.attention.utils import dense_kv_cache_views
18 from vllm.config import set_current_vllm_config
19 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_connector import (
20 KVConnectorRole,
@@ -30,6 +31,7 @@ from vllm.v1.kv_cache_interface import (
31 FullAttentionSpec,
32 KVCacheConfig,
33 KVCacheGroupSpec,
34 + KVCacheLayout,
35 MambaSpec,
36 )
37
@@ -138,14 +140,22 @@ def test_register_kv_caches_emits_fa_and_gdn_regions(monkeypatch):
140 )
141 worker = connector.connector_worker
142
141 - fa_cache = torch.empty((2, 2, 11), dtype=torch.float16)
142 - gdn_conv_state = torch.empty((2, 22), dtype=torch.float16)
143 - gdn_ssm_state = torch.empty((2, 4), dtype=torch.float16)
143 + num_blocks = kv_cache_config.num_blocks
144 + fa_spec = kv_cache_config.kv_cache_groups[0].kv_cache_spec
145 + gdn_spec = kv_cache_config.kv_cache_groups[1].kv_cache_spec
146 + fa_raw = torch.empty(num_blocks * fa_spec.page_size_bytes, dtype=torch.int8)
147 + gdn_raw = torch.empty(num_blocks * gdn_spec.page_size_bytes, dtype=torch.int8)
148 + (fa_cache,) = dense_kv_cache_views(
149 + fa_raw, fa_spec, num_blocks, 1, KVCacheLayout.LBHNC
150 + )
151 + (gdn_cache,) = dense_kv_cache_views(
152 + gdn_raw, gdn_spec, num_blocks, 1, KVCacheLayout.LBHNC
153 + )
154
155 worker.register_kv_caches(
156 {
157 "model.layers.0.self_attn": fa_cache,
148 - "model.layers.1.linear_attn": (gdn_conv_state, gdn_ssm_state),
158 + "model.layers.1.linear_attn": gdn_cache,
159 }
160 )
161
@@ -154,10 +164,14 @@ def test_register_kv_caches_emits_fa_and_gdn_regions(monkeypatch):
164 "model.layers.0.self_attn",
165 "model.layers.1.linear_attn",
166 ]
167 + assert worker.block_len_per_layer == [
168 + fa_spec.page_size_bytes,
169 + gdn_spec.page_size_bytes,
170 + ]
171 assert worker.registered_group_indices == [0, 1]
172 assert worker.kv_caches_base_addr == [
173 fa_cache.data_ptr(),
160 - gdn_conv_state.data_ptr(),
174 + gdn_cache.data_ptr(),
175 ]
176
177 worker.shutdown()
@@ -185,8 +199,7 @@ def test_register_kv_caches_deduplicates_shared_backing_memory(monkeypatch):
199
200 backing = torch.empty((4, 64), dtype=torch.float16)
201 fa_cache = backing[:2, :16]
188 - gdn_conv_state = backing[:3]
189 - gdn_ssm_state = torch.empty((3, 4), dtype=torch.float16)
202 + gdn_cache = backing[:3]
203
204 with patch.object(
205 worker.engine, "batch_register_memory", return_value=0
@@ -194,13 +207,13 @@ def test_register_kv_caches_deduplicates_shared_backing_memory(monkeypatch):
207 worker.register_kv_caches(
208 {
209 "model.layers.0.self_attn": fa_cache,
197 - "model.layers.1.linear_attn": (gdn_conv_state, gdn_ssm_state),
210 + "model.layers.1.linear_attn": gdn_cache,
211 }
212 )
213
214 assert worker.kv_caches_base_addr == [
215 fa_cache.data_ptr(),
203 - gdn_conv_state.data_ptr(),
216 + gdn_cache.data_ptr(),
217 ]
218 batch_register_memory.assert_called_once()
219 registered_ptrs, registered_lens = batch_register_memory.call_args[0]
@@ -339,7 +352,7 @@ def test_logical_to_kernel_block_ids_expands_fa_not_gdn():
352 assert kernel_block_ids == [list(range(34, 51)), [2]]
353
354
342 -def test_hybrid_gdn_splits_fa_regions_but_keeps_gdn_state_whole(
355 +def test_hybrid_gdn_keeps_packed_fa_and_gdn_regions_whole(
356 monkeypatch,
357 ):
358 monkeypatch.setenv("VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT", "5")
@@ -359,7 +372,7 @@ def test_hybrid_gdn_splits_fa_regions_but_keeps_gdn_state_whole(
372 )
373 worker = connector.connector_worker
374
362 - worker.transfer_topo = SimpleNamespace(virtually_split_kv_in_blocks=True)
375 + worker.transfer_topo = SimpleNamespace(is_kv_layout_blocks_first=False)
376 regions = worker._get_transfer_regions(
377 base_addrs=[0x1000, 0x2000],
378 block_lens=[0x100, 0x100],
@@ -377,7 +390,6 @@ def test_hybrid_gdn_splits_fa_regions_but_keeps_gdn_state_whole(
390 for region in regions
391 ] == [
392 (0, 0x1000, 0x40),
380 - (0, 0x1040, 0x40),
393 (1, 0x2000, 0x100),
394 ]
395
tests/v1/kv_connector/unit/test_mooncake_store_connector.py
+8 -59
@@ -48,7 +48,14 @@ def _make_kv_cache_config() -> KVCacheConfig:
48 spec = FullAttentionSpec(block_size=16, num_kv_heads=8, head_size=64, dtype=None)
49 return KVCacheConfig(
50 num_blocks=4,
51 - kv_cache_tensors=[KVCacheTensor(size=8192, shared_by=["layer0"])],
51 + kv_cache_tensors=[
52 + KVCacheTensor(
53 + size=8192,
54 + layers=["layer0"],
55 + layer_stride=8192,
56 + block_stride=2048,
57 + )
58 + ],
59 kv_cache_groups=[KVCacheGroupSpec(["layer0"], spec)],
60 )
61
@@ -210,64 +217,6 @@ def test_get_kv_connector_kv_cache_events_wraps_worker_events():
217 assert kv_events.get_all_events() == [event]
218
219
213 -def test_prefer_cross_layer_blocks_from_config():
214 - # Default: disabled
215 - vllm_config = _make_vllm_config()
216 - kv_cache_config = _make_kv_cache_config()
217 - with (
218 - set_current_vllm_config(vllm_config),
219 - patch(
220 - "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
221 - "connector.MooncakeStoreScheduler"
222 - ),
223 - ):
224 - connector = mooncake_store_connector.MooncakeStoreConnector(
225 - vllm_config, KVConnectorRole.SCHEDULER, kv_cache_config
226 - )
227 - assert connector.prefer_cross_layer_blocks is False
228 -
229 - # Enabled via config
230 - vllm_config_enabled = create_vllm_config(
231 - kv_connector="MooncakeStoreConnector",
232 - kv_role="kv_both",
233 - kv_connector_extra_config={"enable_cross_layers_blocks": "true"},
234 - )
235 - with (
236 - set_current_vllm_config(vllm_config_enabled),
237 - patch(
238 - "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
239 - "connector.MooncakeStoreScheduler"
240 - ),
241 - ):
242 - connector_enabled = mooncake_store_connector.MooncakeStoreConnector(
243 - vllm_config_enabled, KVConnectorRole.SCHEDULER, kv_cache_config
244 - )
245 - assert connector_enabled.prefer_cross_layer_blocks is True
246 -
247 -
248 -def test_register_cross_layers_kv_cache_delegates_to_worker():
249 - vllm_config = _make_vllm_config()
250 - kv_cache_config = _make_kv_cache_config()
251 -
252 - with (
253 - set_current_vllm_config(vllm_config),
254 - patch(
255 - "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
256 - "connector.MooncakeStoreWorker"
257 - ) as mock_worker_cls,
258 - ):
259 - connector = mooncake_store_connector.MooncakeStoreConnector(
260 - vllm_config, KVConnectorRole.WORKER, kv_cache_config
261 - )
262 -
263 - fake_tensor = MagicMock()
264 - fake_backend = MagicMock()
265 - connector.register_cross_layers_kv_cache(fake_tensor, fake_backend)
266 -
267 - worker = mock_worker_cls.return_value
268 - worker.register_cross_layers_kv_caches.assert_called_once_with(fake_tensor)
269 -
270 -
220 def test_update_connector_output_and_take_events():
221 vllm_config = _make_vllm_config()
222 kv_cache_config = _make_kv_cache_config()
tests/v1/kv_connector/unit/test_mooncake_store_hma_e2e.py
+18 -4
@@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch
10
11 import torch
12
13 +from tests.v1.attention.utils import dense_kv_cache_views
14 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store import (
15 worker as mooncake_store_worker,
16 )
@@ -35,6 +36,7 @@ from vllm.v1.kv_cache_interface import (
36 MambaSpec,
37 SlidingWindowSpec,
38 )
39 +from vllm.v1.kv_cache_layout import KVCacheLayout
40
41
42 class _DictStore:
@@ -160,8 +162,18 @@ def test_e2e_swa_plus_full_save_then_lookup_hits():
162 cfg = KVCacheConfig(
163 num_blocks=4,
164 kv_cache_tensors=[
163 - KVCacheTensor(size=8192, shared_by=["L0"]),
164 - KVCacheTensor(size=8192, shared_by=["L1"]),
165 + KVCacheTensor(
166 + size=4 * full.page_size_bytes,
167 + layers=["L0"],
168 + layer_stride=4 * full.page_size_bytes,
169 + block_stride=full.page_size_bytes,
170 + ),
171 + KVCacheTensor(
172 + size=4 * swa.page_size_bytes,
173 + layers=["L1"],
174 + layer_stride=4 * swa.page_size_bytes,
175 + block_stride=swa.page_size_bytes,
176 + ),
177 ],
178 kv_cache_groups=[
179 KVCacheGroupSpec(["L0"], full),
@@ -178,9 +190,11 @@ def test_e2e_swa_plus_full_save_then_lookup_hits():
190
191 # Register kv_caches using mocked thread classes so register_kv_caches
192 # doesn't try to start real background threads (which set ready_event).
193 + raw_full = torch.zeros(4 * full.page_size_bytes, dtype=torch.int8)
194 + raw_swa = torch.zeros(4 * swa.page_size_bytes, dtype=torch.int8)
195 kv_caches = {
182 - "L0": torch.zeros(2, 4, 8, 8, 64),
183 - "L1": torch.zeros(2, 4, 8, 8, 64),
196 + "L0": dense_kv_cache_views(raw_full, full, 4, 1, KVCacheLayout.LBNHC)[0],
197 + "L1": dense_kv_cache_views(raw_swa, swa, 4, 1, KVCacheLayout.LBNHC)[0],
198 }
199
200 def _fake_thread_init(*args, **kwargs):
tests/v1/kv_connector/unit/test_mooncake_store_worker.py
+72 -95
@@ -16,6 +16,7 @@ from unittest.mock import MagicMock, patch
16 import pytest
17 import torch
18
19 +from tests.v1.attention.utils import dense_kv_cache_views
20 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake import (
21 rdma_utils,
22 )
@@ -38,6 +39,11 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.metrics import
39 )
40 from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
41 from vllm.v1.core.kv_cache_utils import BlockHash, maybe_convert_block_hash
42 +from vllm.v1.kv_cache_interface import (
43 + FullAttentionSpec,
44 + KVCacheGroupSpec,
45 + KVCacheLayout,
46 +)
47
48
49 class _RecordingBlockHashes:
@@ -2961,112 +2967,83 @@ def test_putting_consumer_queues_decode_save():
2967 assert req.current_event is event
2968
2969
2964 -def test_register_kv_caches_blocks_first_single_segment():
2965 - """Blocks-first layout (FlashInfer/MLA): one segment per layer."""
2970 +@pytest.mark.parametrize("layout", list(KVCacheLayout))
2971 +def test_register_kv_caches_shared_storage(layout: KVCacheLayout):
2972 num_blocks = 10
2967 - page_size_elements = 64
2973 + num_layers = 2
2974 worker = _make_bare_worker(num_gpu_blocks=num_blocks)
2969 -
2970 - # Shape: (num_blocks, page_size_elements) — blocks outermost, no outer_dims
2971 - tensor = torch.zeros(num_blocks, page_size_elements, dtype=torch.float16)
2972 - _register_with_mocked_threads(worker, {"layer0": tensor})
2973 -
2974 - db = worker.token_dbs[0]
2975 - assert db.kv_caches_base_addr == [tensor.untyped_storage().data_ptr()]
2976 - assert db.block_len == [tensor.untyped_storage().nbytes() // num_blocks]
2977 - worker.store.register_buffer.assert_called_once_with(
2978 - tensor.untyped_storage().data_ptr(),
2979 - tensor.untyped_storage().nbytes(),
2975 + spec = FullAttentionSpec(
2976 + block_size=16,
2977 + num_kv_heads=2,
2978 + head_size=8,
2979 + dtype=torch.float16,
2980 )
2981 + raw = torch.zeros(
2982 + num_blocks * num_layers * spec.page_size_bytes,
2983 + dtype=torch.int8,
2984 + )
2985 + caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
2986
2982 -
2983 -def test_register_kv_caches_kv_first_two_segments():
2984 - """K/V-first layout (FlashAttn): two segments (K, V) per layer."""
2985 - num_blocks = 10
2986 - block_size_tokens = 16
2987 - num_kv_heads = 4
2988 - head_size = 8
2989 - worker = _make_bare_worker(num_gpu_blocks=num_blocks)
2990 -
2991 - # Shape: (2, num_blocks, block_size, num_kv_heads, head_size) — K/V outermost
2992 - tensor = torch.zeros(
2993 - 2,
2994 - num_blocks,
2995 - block_size_tokens,
2996 - num_kv_heads,
2997 - head_size,
2998 - dtype=torch.float16,
2987 + _register_with_mocked_threads(
2988 + worker,
2989 + {"layer0": caches[0], "__cross_layer__": caches[1]},
2990 )
3000 - _register_with_mocked_threads(worker, {"layer0": tensor})
2991
2992 db = worker.token_dbs[0]
3003 - seg_stride = tensor.stride(0) * tensor.element_size()
3004 - base = tensor.untyped_storage().data_ptr()
3005 - assert db.kv_caches_base_addr == [base, base + seg_stride]
3006 - assert db.block_len == [seg_stride // num_blocks] * 2
3007 -
3008 -
3009 -def test_register_kv_caches_cross_layer_single_segment():
3010 - """Cross-layer tensor: single segment with block_len = page_size * num_layers."""
3011 - num_blocks = 10
3012 - num_layers = 4
3013 - per_layer_page_elements = 64 # elements per layer per block
3014 -
2993 + if not layout.is_block_compact:
2994 + # Each head group is its own region, so a block spans L*H regions.
2995 + head_cache = caches[0][:, 0]
2996 + assert db.kv_caches_base_addr == [
2997 + cache[:, head_idx].data_ptr()
2998 + for cache in caches
2999 + for head_idx in range(cache.shape[1])
3000 + ]
3001 + assert db.block_len == [head_cache.stride(0) * head_cache.element_size()] * (
3002 + num_layers * caches[0].shape[1]
3003 + )
3004 + elif layout.is_layer_compact:
3005 + assert db.kv_caches_base_addr == [cache.data_ptr() for cache in caches]
3006 + assert db.block_len == [spec.page_size_bytes] * num_layers
3007 + else:
3008 + assert db.kv_caches_base_addr == [raw.data_ptr()]
3009 + assert db.block_len == [num_layers * spec.page_size_bytes]
3010 + worker.store.register_buffer.assert_called_once_with(raw.data_ptr(), raw.nbytes)
3011 +
3012 +
3013 +def test_register_kv_caches_separate_head_groups():
3014 + # LHBNC gives each head group its own region; the K/V split doubles heads.
3015 + layout = KVCacheLayout.LHBNC
3016 + num_blocks = 3
3017 + num_layers = 2
3018 worker = _make_bare_worker(num_gpu_blocks=num_blocks)
3016 -
3017 - # Cross-layer blocks-first tensor: all layers packed into a single
3018 - # contiguous block. Shape (num_blocks, num_layers * per_layer_page)
3019 - # mimics the physical layout after stride reordering.
3020 - total_page_elements = num_layers * per_layer_page_elements
3021 - tensor = torch.zeros(num_blocks, total_page_elements, dtype=torch.float16)
3022 -
3023 - with (
3024 - patch(
3025 - "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
3026 - "worker.KVCacheStoreSendingThread",
3027 - side_effect=_auto_set_ready_event,
3028 - ),
3029 - patch(
3030 - "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
3031 - "worker.KVCacheStoreRecvingThread",
3032 - side_effect=_auto_set_ready_event,
3033 - ),
3034 - ):
3035 - # Use the cross-layer wrapper key, same as register_cross_layers_kv_caches
3036 - worker.register_kv_caches({"__cross_layer__": tensor})
3037 -
3038 - db = worker.token_dbs[0]
3039 - assert len(db.kv_caches_base_addr) == 1
3040 - assert db.kv_caches_base_addr[0] == tensor.untyped_storage().data_ptr()
3041 -
3042 - expected_block_len = tensor.untyped_storage().nbytes() // num_blocks
3043 - # block_len should be per_layer_page_size * num_layers
3044 - assert (
3045 - expected_block_len
3046 - == num_layers * per_layer_page_elements * tensor.element_size()
3019 + spec = FullAttentionSpec(
3020 + block_size=4,
3021 + num_kv_heads=2,
3022 + head_size=8,
3023 + dtype=torch.float16,
3024 + num_head_slots=2,
3025 + state_content_bytes=2 * 8 * 2,
3026 )
3048 - assert len(db.block_len) == 1
3049 - assert db.block_len[0] == expected_block_len
3027 + layer_names = ["layer0", "__cross_layer__"]
3028 + worker._kv_cache_groups = [KVCacheGroupSpec(layer_names, spec)]
3029 + raw = torch.zeros(
3030 + num_blocks * num_layers * spec.page_size_bytes,
3031 + dtype=torch.int8,
3032 + )
3033 + caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
3034
3051 - # Also verify via register_cross_layers_kv_caches wrapper
3052 - worker2 = _make_bare_worker(num_gpu_blocks=num_blocks)
3053 - with (
3054 - patch(
3055 - "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
3056 - "worker.KVCacheStoreSendingThread",
3057 - side_effect=_auto_set_ready_event,
3058 - ),
3059 - patch(
3060 - "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
3061 - "worker.KVCacheStoreRecvingThread",
3062 - side_effect=_auto_set_ready_event,
3063 - ),
3064 - ):
3065 - worker2.register_cross_layers_kv_caches(tensor)
3035 + _register_with_mocked_threads(worker, dict(zip(layer_names, caches)))
3036
3067 - db2 = worker2.token_dbs[0]
3068 - assert db2.kv_caches_base_addr == db.kv_caches_base_addr
3069 - assert db2.block_len == db.block_len
3037 + head_block_bytes = caches[0].stride(0) * caches[0].element_size()
3038 + expected_addrs = [
3039 + cache[:, head_idx].data_ptr()
3040 + for cache in caches
3041 + for head_idx in range(cache.shape[1])
3042 + ]
3043 + db = worker.token_dbs[0]
3044 + assert db.kv_caches_base_addr == expected_addrs
3045 + assert db.block_len == [head_block_bytes] * len(expected_addrs)
3046 + worker.store.register_buffer.assert_called_once_with(raw.data_ptr(), raw.nbytes)
3047
3048
3049 # ---------------------------------------------------------------------------
tests/v1/kv_connector/unit/test_moriio_connector.py
+34 -30
@@ -43,6 +43,7 @@ from vllm.v1.kv_cache_interface import (
43 KVCacheConfig,
44 KVCacheGroupSpec,
45 KVCacheTensor,
46 + compute_layer_kv_cache_shape_bytes,
47 )
48
49 from .utils import create_request, create_scheduler
@@ -56,20 +57,25 @@ def _find_free_port() -> int:
57
58 def _make_test_kv_cache_config() -> KVCacheConfig:
59 layer_names = ["layer0", "layer1", "layer2"]
60 + num_blocks = 2
61 + spec = FullAttentionSpec(
62 + block_size=16,
63 + num_kv_heads=4,
64 + head_size=64,
65 + dtype=torch.float16,
66 + )
67 + page = spec.page_size_bytes
68 return KVCacheConfig(
60 - num_blocks=2,
61 - kv_cache_tensors=[KVCacheTensor(size=0, shared_by=layer_names)],
62 - kv_cache_groups=[
63 - KVCacheGroupSpec(
64 - layer_names=layer_names,
65 - kv_cache_spec=FullAttentionSpec(
66 - block_size=16,
67 - num_kv_heads=4,
68 - head_size=64,
69 - dtype=torch.float16,
70 - ),
69 + num_blocks=num_blocks,
70 + kv_cache_tensors=[
71 + KVCacheTensor(
72 + size=len(layer_names) * num_blocks * page,
73 + layers=layer_names,
74 + layer_stride=num_blocks * page,
75 + block_stride=page,
76 )
77 ],
78 + kv_cache_groups=[KVCacheGroupSpec(layer_names=layer_names, kv_cache_spec=spec)],
79 )
80
81
@@ -225,7 +231,7 @@ class FakeMoRIIOConnectorWorker(MoRIIOConnectorWorker):
231 engine_id,
232 *args,
233 hand_shake_latency: float = 1.8,
228 - kv_cache_layout="HND",
234 + kv_cache_layout="LBHNC",
235 kv_cache_config=None,
236 **kwargs,
237 ):
@@ -531,16 +537,15 @@ def test_register_kv_caches(mock_parallel_groups):
537 DEFAULT_PORT = 6301
538 TP_RANK = 0
539 DP_RANK = 0
534 - from vllm.v1.attention.backends.rocm_aiter_fa import AiterFlashAttentionBackend
535 -
536 - backend_cls = AiterFlashAttentionBackend
537 -
538 - # Create test kv cache tensors using proper backend shape
539 - kv_cache_shape = backend_cls.get_kv_cache_shape(
540 - num_blocks=2, block_size=16, num_kv_heads=4, head_size=64
540 + # Create test kv cache tensors using KVCacheSpec layout
541 + shape = compute_layer_kv_cache_shape_bytes(
542 + FullAttentionSpec(
543 + block_size=16, num_kv_heads=4, head_size=64, dtype=torch.float16
544 + ),
545 + 2,
546 )
542 - shared_tensor = torch.zeros(*kv_cache_shape, dtype=torch.float16)
543 - unique_tensor = torch.zeros(*kv_cache_shape, dtype=torch.float16)
547 + shared_tensor = torch.zeros(*shape, dtype=torch.int8).view(torch.float16)
548 + unique_tensor = torch.zeros(*shape, dtype=torch.int8).view(torch.float16)
549 kv_caches = {
550 "layer0": shared_tensor,
551 "layer1": unique_tensor,
@@ -627,16 +632,15 @@ def test_moriio_handshake_returns_metadata(mock_parallel_groups):
632
633 ROLE = "kv_consumer"
634 vllm_config = create_vllm_config(role=ROLE)
630 - from vllm.v1.attention.backends.rocm_aiter_fa import AiterFlashAttentionBackend
631 -
632 - backend_cls = AiterFlashAttentionBackend
633 -
634 - # Create test kv cache tensors using proper backend shape
635 - kv_cache_shape = backend_cls.get_kv_cache_shape(
636 - num_blocks=2, block_size=16, num_kv_heads=4, head_size=64
635 + # Create test kv cache tensors using KVCacheSpec layout
636 + shape = compute_layer_kv_cache_shape_bytes(
637 + FullAttentionSpec(
638 + block_size=16, num_kv_heads=4, head_size=64, dtype=torch.float16
639 + ),
640 + 2,
641 )
638 - shared_tensor = torch.zeros(*kv_cache_shape, dtype=torch.float16)
639 - unique_tensor = torch.zeros(*kv_cache_shape, dtype=torch.float16)
642 + shared_tensor = torch.zeros(*shape, dtype=torch.int8).view(torch.float16)
643 + unique_tensor = torch.zeros(*shape, dtype=torch.int8).view(torch.float16)
644 kv_caches = {
645 "layer0": shared_tensor,
646 "layer1": unique_tensor,
tests/v1/kv_connector/unit/test_moriio_kv_layout.py
+11 -6
@@ -12,7 +12,10 @@ import pytest
12 import torch
13
14 from vllm.platforms import current_platform
15 -from vllm.v1.kv_cache_interface import FullAttentionSpec, MLAAttentionSpec
15 +from vllm.v1.kv_cache_interface import (
16 + FullAttentionSpec,
17 + MLAAttentionSpec,
18 +)
19
20 aiter_available = importlib.util.find_spec("aiter") is not None
21 mori_available = importlib.util.find_spec("mori") is not None
@@ -241,7 +244,7 @@ def _write_task(layer_name: str, transfer_id: str = "xfer") -> Any:
244 id="interleaved-kernel-axis-from-spec",
245 ),
246 pytest.param(
244 - (8, 4, 3),
247 + (8, 1, 4, 3),
248 _mla_spec(),
249 16,
250 {
@@ -297,7 +300,7 @@ def test_mixed_layers_compute_distinct_offsets_per_layer():
300 kv_caches = {
301 "separated": torch.empty((2, 8, 4, 2, 3), dtype=torch.bfloat16),
302 "interleaved": torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16),
300 - "indexer": torch.empty((8, 4, 3), dtype=torch.bfloat16),
303 + "indexer": torch.empty((8, 1, 4, 3), dtype=torch.bfloat16),
304 }
305 worker = _worker(
306 kv_caches,
@@ -342,7 +345,7 @@ def test_write_transfer_plan_caches_offsets_per_geometry():
345 kv_caches = {
346 "dense0": torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16),
347 "dense1": torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16),
345 - "indexer": torch.empty((8, 4, 3), dtype=torch.bfloat16),
348 + "indexer": torch.empty((8, 1, 4, 3), dtype=torch.bfloat16),
349 }
350 calls: list[str] = []
351
@@ -692,7 +695,7 @@ def test_empty_local_block_ids_is_free_only_noop():
695 def test_registration_regions_do_not_split_interleaved_or_mla_cache():
696 separated = torch.empty((2, 8, 4, 2, 3), dtype=torch.bfloat16)
697 interleaved = torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16)
695 - indexer = torch.empty((8, 4, 3), dtype=torch.bfloat16)
698 + indexer = torch.empty((8, 1, 4, 3), dtype=torch.bfloat16)
699 worker = _worker(
700 {
701 "separated": separated,
@@ -745,7 +748,9 @@ def test_registration_regions_use_layer_num_blocks():
748
749
750 def test_unsupported_shape_raises_value_error():
748 - cache = torch.empty((8, 4, 2, 3), dtype=torch.bfloat16)
751 + # A 4-D view whose head/state/content dims all disagree with the spec (a
752 + # standardized [B, H, N, C] view for _full_spec would be (8, 2, 4, 6)).
753 + cache = torch.empty((8, 3, 5, 7), dtype=torch.bfloat16)
754 worker = _worker({"layer": cache}, {"layer": _full_spec()})
755
756 with pytest.raises(ValueError, match="Unsupported MoRIIO K/V cache shape"):
tests/v1/kv_connector/unit/test_multi_connector.py
-10
@@ -889,16 +889,6 @@ Options:
889 """)
890
891
892 -def test_multi_connector_prefer_cross_layer_blocks(mc):
893 - mc._connectors[0].prefer_cross_layer_blocks = False
894 - mc._connectors[1].prefer_cross_layer_blocks = True
895 - assert mc.prefer_cross_layer_blocks is False
896 -
897 - mc._connectors[0].prefer_cross_layer_blocks = True
898 - mc._connectors[1].prefer_cross_layer_blocks = True
899 - assert mc.prefer_cross_layer_blocks is True
900 -
901 -
892 def test_multi_connector_worker_metadata(mc):
893 class MockConnectorWorkerMetadata(KVConnectorWorkerMetadata):
894 def __init__(self, data: set[str]):
tests/v1/kv_connector/unit/test_nixl_connector.py
+235 -221
@@ -19,6 +19,7 @@ import pytest
19 import ray
20 import torch
21
22 +from tests.v1.attention.utils import dense_kv_cache_views
23 from vllm import LLM
24 from vllm.config import KVTransferConfig, set_current_vllm_config
25 from vllm.distributed.kv_transfer.kv_connector.utils import (
@@ -54,8 +55,6 @@ from vllm.outputs import RequestOutput
55 from vllm.platforms import current_platform
56 from vllm.platforms.interface import Platform
57 from vllm.sampling_params import SamplingParams
57 -from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
58 -from vllm.v1.attention.backends.utils import set_kv_cache_layout
58 from vllm.v1.engine import EngineCoreRequest
59 from vllm.v1.engine.output_processor import OutputProcessor
60 from vllm.v1.kv_cache_interface import (
@@ -63,12 +62,12 @@ from vllm.v1.kv_cache_interface import (
62 FullAttentionSpec,
63 KVCacheConfig,
64 KVCacheGroupSpec,
66 - KVCacheTensor,
65 + KVCacheLayout,
66 + MLAAttentionSpec,
67 + compute_layer_kv_cache_shape_bytes,
68 )
69 from vllm.v1.outputs import KVConnectorOutput, ModelRunnerOutput
70 from vllm.v1.request import RequestStatus
70 -from vllm.v1.worker.kv_connector_model_runner_mixin import KVConnectorModelRunnerMixin
71 -from vllm.v1.worker.utils import AttentionGroup
71
72 from .utils import (
73 create_request,
@@ -96,9 +95,6 @@ def clear_kv_transfer():
95 yield
96 if has_kv_transfer_group():
97 ensure_kv_transfer_shutdown()
99 - # Reset any KV cache layout override set during tests so it doesn't
100 - # leak into tests in other modules.
101 - set_kv_cache_layout(None)
98
99
100 def get_default_xfer_telemetry(
@@ -350,12 +346,12 @@ def test_abort_immediately_remote_prefill_enqueues_empty_recv():
346 )
347 def test_kv_transfer_handshake(dist_init):
348 """Unit test for basic NixlConnector interface functionality."""
353 - from vllm.config import set_current_vllm_config
349
350 # Test setup, we creates a scheduler that contains a NixlConnector
351 # of role SCHEDULER, and expect it to be serving NixlAgentMetadata from
352 # all workers of the instance.
353 vllm_config = create_vllm_config()
354 + vllm_config.cache_config.kv_cache_layout = "BLHNC"
355 # in case the test runs on non-GPU machine
356 vllm_config.kv_transfer_config.kv_buffer_device = "cpu"
357 scheduler = create_scheduler(vllm_config)
@@ -386,18 +382,19 @@ def test_kv_transfer_handshake(dist_init):
382 kv_cache_spec = cast(
383 AttentionSpec, kv_cache_config.kv_cache_groups[0].kv_cache_spec
384 )
389 - kv_cache_shape = FlashAttentionBackend.get_kv_cache_shape(
390 - num_blocks=kv_cache_config.num_blocks,
391 - block_size=kv_cache_spec.block_size,
392 - num_kv_heads=kv_cache_spec.num_kv_heads,
393 - head_size=kv_cache_spec.head_size,
385 + raw = torch.zeros(
386 + kv_cache_spec.page_size_bytes * kv_cache_config.num_blocks * 3,
387 + dtype=torch.int8,
388 + )
389 + caches = dense_kv_cache_views(
390 + raw,
391 + kv_cache_spec,
392 + kv_cache_config.num_blocks,
393 + num_layers=3,
394 + layout=KVCacheLayout.BLHNC,
395 )
395 - shared_tensor = torch.zeros(*kv_cache_shape, dtype=kv_cache_spec.dtype)
396 - unique_tensor = torch.zeros(*kv_cache_shape, dtype=kv_cache_spec.dtype)
396 kv_caches = {
398 - "layer0": shared_tensor,
399 - "layer1": unique_tensor,
400 - "layer2": shared_tensor,
397 + f"layer{layer_idx}": cache for layer_idx, cache in enumerate(caches)
398 }
399 prefill_connector.register_kv_caches(kv_caches)
400
@@ -474,7 +471,7 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
471 self,
472 *args,
473 hand_shake_latency: float = 1.8,
477 - kv_cache_layout="HND",
474 + kv_cache_layout="LBHNC",
475 kv_cache_config=None,
476 **kwargs,
477 ):
@@ -486,9 +483,8 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
483 # Mock register_kv_caches attributes needed for tests that do not call it.
484 self.src_xfer_handles_by_block_size = {self.block_size: 1}
485 self.src_blocks_data = np.empty((0, 3), dtype=np.uint64)
489 - test_shape = self.attn_backends[0].get_kv_cache_shape(
490 - num_blocks=1, block_size=16, num_kv_heads=1, head_size=1
491 - )
486 + rep_spec = self.kv_cache_config.kv_cache_groups[0].kv_cache_spec
487 + test_shape = compute_layer_kv_cache_shape_bytes(rep_spec, 1)
488 self.transfer_topo = TransferTopology(
489 tp_rank=self.tp_rank,
490 tp_size=self.world_size,
@@ -502,7 +498,7 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
498 )
499
500 self.compat_hash = compute_nixl_compatibility_hash(
505 - self.vllm_config, self.backend_name, self.transfer_topo.cross_layers_blocks
501 + self.vllm_config, self.backend_name
502 )
503
504 def _nixl_handshake(
@@ -553,9 +549,8 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
549 device_id=remote_tp_rank,
550 num_blocks=1,
551 block_lens=remote_block_lens,
556 - # `self.kv_cache_layout` is only forced to HND when vllm engine
557 - # is started. We mock HND here.
558 - kv_cache_layout="HND",
552 + block_strides=remote_block_lens,
553 + kv_cache_layout="LBHNC",
554 block_size=self.block_size,
555 ssm_sizes=(0, 0),
556 attn_backend_name=self.backend_name,
@@ -579,6 +574,8 @@ class TestNixlHandshake:
574 self, default_vllm_config, dist_init, pcp_rank
575 ):
576 """Only PCP rank zero publishes and reports sending completion."""
577 + from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
578 +
579 vllm_config = create_vllm_config(kv_role="kv_producer")
580 vllm_config.parallel_config.prefill_context_parallel_size = 2
581 with (
@@ -656,7 +653,7 @@ class TestNixlHandshake:
653 worker.dst_xfer_side_handles = {
654 FakeNixlConnectorWorker.REMOTE_ENGINE_ID: {0: 1}
655 }
659 - worker.kv_cache_layout = "HND"
656 + worker.kv_cache_layout = "LBHNC"
657 num_xfers = 4
658 while True:
659 # For the same request_id, initiate multiple xfers across different
@@ -1053,7 +1050,9 @@ class TestNixlHandshake:
1050 worker.dst_num_blocks[worker.engine_id] = worker.num_blocks
1051
1052 # Metadata with different kv_cache_layout than local worker
1056 - mismatched_layout = "HND" if worker.kv_cache_layout != "HND" else "NHD"
1053 + mismatched_layout = (
1054 + "LBHNC" if worker.kv_cache_layout != "LBHNC" else "LBNHC"
1055 + )
1056 meta = NixlAgentMetadata(
1057 engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
1058 agent_metadata=FakeNixlWrapper.AGENT_METADATA,
@@ -1061,6 +1060,7 @@ class TestNixlHandshake:
1060 device_id=0,
1061 num_blocks=1,
1062 block_lens=worker.block_len_per_layer,
1063 + block_strides=worker.block_len_per_layer,
1064 kv_cache_layout=mismatched_layout,
1065 block_size=worker.block_size,
1066 ssm_sizes=(0, 0),
@@ -1100,7 +1100,7 @@ class TestNixlHandshake:
1100 vllm_config,
1101 connector.engine_id,
1102 hand_shake_latency=0,
1103 - kv_cache_layout="NHD",
1103 + kv_cache_layout="LBNHC",
1104 )
1105 worker = connector.connector_worker
1106
@@ -1111,15 +1111,17 @@ class TestNixlHandshake:
1111 worker.dst_num_blocks[worker.engine_id] = worker.num_blocks
1112
1113 # Metadata with different kv_cache_layout than local worker
1114 + # prefill TP=1, decode TP=2, remote block_lens is double to local
1115 + remote_block_lens = [i * 2 for i in worker.block_len_per_layer]
1116 meta = NixlAgentMetadata(
1117 engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
1118 agent_metadata=FakeNixlWrapper.AGENT_METADATA,
1119 kv_caches_base_addr=[0],
1120 device_id=0,
1121 num_blocks=1,
1120 - # prefill TP=1, decode TP=2, remote block_lens is double to local
1121 - block_lens=[i * 2 for i in worker.block_len_per_layer],
1122 - kv_cache_layout="HND",
1122 + block_lens=remote_block_lens,
1123 + block_strides=remote_block_lens,
1124 + kv_cache_layout="LBHNC",
1125 block_size=worker.block_size,
1126 ssm_sizes=(0, 0),
1127 attn_backend_name=worker.backend_name,
@@ -1160,8 +1162,6 @@ class TestNixlHandshake:
1162 attn_backends=worker.attn_backends,
1163 tensor_shape=None,
1164 )
1163 - assert worker.transfer_topo.virtually_split_kv_in_blocks
1164 -
1165 plan = MagicMock(
1166 source_ranks_per_group=((0,), (0,)),
1167 rank_offset_factor=1,
@@ -1171,6 +1171,7 @@ class TestNixlHandshake:
1171 device_id=0,
1172 num_blocks=1,
1173 block_lens=[remote_block_len],
1174 + block_strides=[remote_block_len],
1175 )
1176
1177 assert worker._build_fa_remote(plan, meta, block_size_ratio=1).tolist() == [
@@ -1226,6 +1227,7 @@ class TestNixlHandshake:
1227 device_id=0,
1228 num_blocks=1,
1229 block_lens=[fa_len * tp_ratio, idx_len],
1230 + block_strides=[fa_len * tp_ratio, idx_len],
1231 kv_cache_layout=worker.kv_cache_layout,
1232 block_size=worker.block_size,
1233 ssm_sizes=(0, 0),
@@ -1252,6 +1254,7 @@ class TestNixlHandshake:
1254 num_blocks=1,
1255 # WRONG: MLA region scaled by tp_ratio (it should be replicated).
1256 block_lens=[fa_len * tp_ratio, idx_len * tp_ratio],
1257 + block_strides=[fa_len * tp_ratio, idx_len * tp_ratio],
1258 kv_cache_layout=worker2.kv_cache_layout,
1259 block_size=worker2.block_size,
1260 ssm_sizes=(0, 0),
@@ -1295,7 +1298,7 @@ class TestNixlHandshake:
1298
1299 worker.transfer_topo.total_num_kv_heads = 8
1300 worker.transfer_topo.local_physical_heads = 1
1298 - worker.kv_cache_layout = "HND"
1301 + worker.kv_cache_layout = "LBHNC"
1302
1303 worker.slot_size_per_layer = [4096]
1304 worker.block_len_per_layer = [4096 * worker.block_size]
@@ -1311,7 +1314,8 @@ class TestNixlHandshake:
1314 device_id=0,
1315 num_blocks=1,
1316 block_lens=list(worker.block_len_per_layer),
1314 - kv_cache_layout="HND",
1317 + block_strides=list(worker.block_len_per_layer),
1318 + kv_cache_layout="LBHNC",
1319 block_size=worker.block_size,
1320 ssm_sizes=(0, 0),
1321 attn_backend_name=worker.backend_name,
@@ -1350,7 +1354,7 @@ class TestNixlHandshake:
1354
1355 worker.transfer_topo.total_num_kv_heads = 32
1356 worker.transfer_topo.local_physical_heads = 8 # 32 // 4
1353 - worker.kv_cache_layout = "HND"
1357 + worker.kv_cache_layout = "LBHNC"
1358
1359 slot_size = 4096
1360 worker.slot_size_per_layer = [slot_size]
@@ -1368,7 +1372,8 @@ class TestNixlHandshake:
1372 device_id=0,
1373 num_blocks=1,
1374 block_lens=list(worker.block_len_per_layer),
1371 - kv_cache_layout="HND",
1375 + block_strides=list(worker.block_len_per_layer),
1376 + kv_cache_layout="LBHNC",
1377 block_size=worker.block_size,
1378 ssm_sizes=(0, 0),
1379 attn_backend_name=worker.backend_name,
@@ -1839,7 +1844,19 @@ def _run_abort_timeout_test(llm: LLM, timeout: int):
1844 llm.llm_engine.engine_core.shutdown()
1845
1846
1842 -@pytest.mark.parametrize("enable_cross_layers", ["False", "True"])
1847 +def element_byte_addrs(view: torch.Tensor) -> list[int]:
1848 + """Absolute byte addresses of every element of a (possibly strided) view."""
1849 + offsets = torch.zeros(view.shape, dtype=torch.int64)
1850 + for dim, (size, stride) in enumerate(zip(view.shape, view.stride())):
1851 + shape = [1] * view.ndim
1852 + shape[dim] = size
1853 + offsets = offsets + torch.arange(size, dtype=torch.int64).view(shape) * stride
1854 + esize = view.element_size()
1855 + byte_offsets = (offsets.flatten() * esize).tolist()
1856 + base = view.data_ptr()
1857 + return [base + offset + i for offset in byte_offsets for i in range(esize)]
1858 +
1859 +
1860 @pytest.mark.parametrize(
1861 "attn_backend",
1862 [
@@ -1853,8 +1870,14 @@ def _run_abort_timeout_test(llm: LLM, timeout: int):
1870 "TRITON_ATTN",
1871 ],
1872 )
1873 +@pytest.mark.parametrize("layout", [layout.name for layout in KVCacheLayout])
1874 +@pytest.mark.parametrize("separate_kv_head_groups", [False, True])
1875 def test_register_kv_caches(
1857 - default_vllm_config, dist_init, attn_backend, enable_cross_layers
1876 + default_vllm_config,
1877 + dist_init,
1878 + attn_backend,
1879 + layout,
1880 + separate_kv_head_groups,
1881 ):
1882 """
1883 Test that register_kv_caches() properly calls nixl_wrapper methods with
@@ -1868,12 +1891,7 @@ def test_register_kv_caches(
1891 """
1892
1893 vllm_config = create_vllm_config(attention_backend=attn_backend)
1871 -
1872 - # Enable cross layers blocks
1873 - vllm_config.kv_transfer_config.kv_connector_extra_config[
1874 - "enable_cross_layers_blocks"
1875 - ] = enable_cross_layers
1876 - set_kv_cache_layout("HND")
1894 + vllm_config.cache_config.kv_cache_layout = layout
1895
1896 # Import the appropriate backend based on the parameter
1897 if attn_backend == "FLASH_ATTN":
@@ -1890,59 +1908,37 @@ def test_register_kv_caches(
1908 backend_cls = TritonAttentionBackend
1909
1910 nixl_worker = "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker"
1893 - nixl_connector = "vllm.distributed.kv_transfer.kv_connector.v1.nixl.connector"
1911 with (
1912 patch(f"{nixl_worker}.NixlWrapper") as mock_nixl_wrapper,
1913 patch(f"{nixl_worker}.threading.Event"),
1914 patch(f"{nixl_worker}.threading.Thread") as mock_thread,
1898 - patch(f"{nixl_connector}.get_current_attn_backend") as mock_get_attn_backend,
1915 patch(f"{nixl_worker}.get_current_attn_backends") as mock_get_attn_backends,
1916 ):
1901 - # Ensure get_attn_backend returns the correct value due to
1902 - # _cached_get_attn_backend returning the backend from previous
1903 - # test run if not mocking.
1904 - mock_get_attn_backend.return_value = backend_cls
1917 mock_get_attn_backends.return_value = [backend_cls]
1906 - num_layers = 32
1918 block_size = 16
1919 num_blocks = 8
1920 num_heads = 4
1921 head_size = 16
1922
1912 - # TODO (NickLucche) the fact that connector depends on kv_cache_config for init
1913 - # but cross-layer preference cant be inferred prior to creating kv_cache_config
1914 - # is a bit awkward.
1915 - dummy_connector = NixlConnector(
1916 - vllm_config,
1917 - KVConnectorRole.WORKER,
1918 - make_kv_cache_config(block_size=block_size),
1919 - )
1923 kv_cache_spec = FullAttentionSpec(
1924 block_size=block_size,
1925 num_kv_heads=num_heads,
1926 head_size=head_size,
1927 dtype=torch.float16,
1928 + num_head_slots=2 if separate_kv_head_groups else None,
1929 + state_content_bytes=num_heads * head_size * 2
1930 + if separate_kv_head_groups
1931 + else None,
1932 + )
1933 + kv_cache_config = KVCacheConfig(
1934 + num_blocks=num_blocks,
1935 + kv_cache_tensors=[],
1936 + kv_cache_groups=[
1937 + KVCacheGroupSpec(
1938 + ["layer0", "layer1", "layer2", "layer3"], kv_cache_spec
1939 + )
1940 + ],
1941 )
1926 - if dummy_connector.prefer_cross_layer_blocks:
1927 - kv_cache_config = KVCacheConfig(
1928 - num_blocks=num_blocks,
1929 - kv_cache_tensors=[
1930 - KVCacheTensor(
1931 - size=kv_cache_spec.page_size_bytes * num_blocks,
1932 - shared_by=["all-layers"],
1933 - )
1934 - for _ in range(num_layers)
1935 - ],
1936 - kv_cache_groups=[KVCacheGroupSpec(["all-layers"], kv_cache_spec)],
1937 - )
1938 - else:
1939 - kv_cache_config = KVCacheConfig(
1940 - num_blocks=num_blocks,
1941 - kv_cache_tensors=[],
1942 - kv_cache_groups=[
1943 - KVCacheGroupSpec(["layer0", "layer1", "layer2"], kv_cache_spec)
1944 - ],
1945 - )
1942 # Create connector
1943 connector = NixlConnector(vllm_config, KVConnectorRole.WORKER, kv_cache_config)
1944 connector.connector_worker = FakeNixlConnectorWorker(
@@ -1962,93 +1958,36 @@ def test_register_kv_caches(
1958 # Reassure the shutdown() check that the thread is terminated
1959 mock_thread.return_value.is_alive.return_value = False
1960
1965 - expected_tensor_size: int
1966 - expected_base_addrs: list[int]
1967 - expected_num_entries: int
1968 - kv_caches: dict[str, torch.Tensor]
1969 - if str(enable_cross_layers).lower() == "true":
1970 - assert connector.prefer_cross_layer_blocks == (
1971 - attn_backend in ("FLASH_ATTN", "FLASHINFER", "TRITON_ATTN")
1972 - )
1973 - else:
1974 - assert not connector.prefer_cross_layer_blocks
1975 -
1976 - test_shape = backend_cls.get_kv_cache_shape(
1977 - num_blocks=1, block_size=16, num_kv_heads=1, head_size=1
1961 + raw0 = torch.zeros(
1962 + kv_cache_spec.page_size_bytes * kv_cache_config.num_blocks * 2,
1963 + dtype=torch.int8,
1964 + device=current_platform.device_type,
1965 )
1979 - is_blocks_first = len(test_shape) == 4 and test_shape[0] == 1
1980 -
1981 - if connector.prefer_cross_layer_blocks:
1982 - with set_current_vllm_config(vllm_config):
1983 - _, cross_layers_kv_cache, _ = (
1984 - KVConnectorModelRunnerMixin.allocate_uniform_kv_caches(
1985 - kv_cache_config=kv_cache_config,
1986 - attn_groups=[
1987 - [
1988 - AttentionGroup(
1989 - backend=backend_cls,
1990 - layer_names=[],
1991 - kv_cache_spec=kv_cache_spec,
1992 - kv_cache_group_id=0,
1993 - )
1994 - ]
1995 - ],
1996 - cache_dtype="bfloat16",
1997 - device=torch.accelerator.current_device_index(),
1998 - kernel_block_sizes=[block_size],
1999 - )
2000 - )
2001 - # Store tensor info for validation
2002 - expected_tensor_size = (
2003 - cross_layers_kv_cache.element_size() * cross_layers_kv_cache.numel()
2004 - )
2005 - expected_base_addrs = [
2006 - cross_layers_kv_cache.data_ptr(),
2007 - ]
2008 - expected_num_entries = 1
2009 -
2010 - expected_blocks_count = num_blocks
2011 -
2012 - kv_caches = {"all-layers": cross_layers_kv_cache}
2013 - else:
2014 - # Create test kv cache tensors using proper backend shape
2015 - kv_cache_shape = backend_cls.get_kv_cache_shape(
2016 - num_blocks=kv_cache_config.num_blocks,
2017 - block_size=kv_cache_spec.block_size,
2018 - num_kv_heads=kv_cache_spec.num_kv_heads,
2019 - head_size=kv_cache_spec.head_size,
2020 - )
2021 - shared_tensor = torch.zeros(*kv_cache_shape, dtype=kv_cache_spec.dtype)
2022 - unique_tensor = torch.zeros(*kv_cache_shape, dtype=kv_cache_spec.dtype)
2023 - kv_caches = {
2024 - "layer0": shared_tensor,
2025 - "layer1": unique_tensor,
2026 - "layer2": shared_tensor,
2027 - }
2028 -
2029 - # Store tensor info for validation
2030 - if is_blocks_first:
2031 - expected_tensor_size = (
2032 - shared_tensor.element_size() * shared_tensor.numel()
2033 - )
2034 - expected_base_addrs = [
2035 - shared_tensor.data_ptr(),
2036 - unique_tensor.data_ptr(),
2037 - ]
2038 - expected_num_entries = 2
2039 - expected_blocks_count = kv_cache_config.num_blocks * 2
2040 - else:
2041 - expected_tensor_size = (
2042 - shared_tensor[0].element_size() * shared_tensor[0].numel()
2043 - )
2044 - expected_base_addrs = [
2045 - shared_tensor[0].data_ptr(),
2046 - shared_tensor[1].data_ptr(),
2047 - unique_tensor[0].data_ptr(),
2048 - unique_tensor[1].data_ptr(),
2049 - ]
2050 - expected_num_entries = 4
2051 - expected_blocks_count = kv_cache_config.num_blocks * 4
1966 + raw1 = torch.zeros(
1967 + kv_cache_spec.page_size_bytes * kv_cache_config.num_blocks,
1968 + dtype=torch.int8,
1969 + device=current_platform.device_type,
1970 + )
1971 + tensor0, tensor1 = dense_kv_cache_views(
1972 + raw0,
1973 + kv_cache_spec,
1974 + kv_cache_config.num_blocks,
1975 + num_layers=2,
1976 + layout=KVCacheLayout[layout],
1977 + )
1978 + (tensor2,) = dense_kv_cache_views(
1979 + raw1,
1980 + kv_cache_spec,
1981 + kv_cache_config.num_blocks,
1982 + num_layers=1,
1983 + layout=KVCacheLayout[layout],
1984 + )
1985 + kv_caches = {
1986 + "layer0": tensor0,
1987 + "layer1": tensor1,
1988 + "layer2": tensor2,
1989 + "layer3": tensor0,
1990 + }
1991
1992 # Execute register_kv_caches
1993 connector.register_kv_caches(kv_caches)
@@ -2056,42 +1995,121 @@ def test_register_kv_caches(
1995 # Verify get_reg_descs was called with caches_data
1996 assert mock_wrapper_instance.get_reg_descs.called
1997 caches_data, _ = mock_wrapper_instance.get_reg_descs.call_args[0]
2059 - assert len(caches_data) == expected_num_entries
1998 + assert len(caches_data) == 2
1999
2061 - for i, cache_entry in enumerate(caches_data):
2000 + for cache_entry, raw in zip(caches_data, (raw0, raw1)):
2001 base_addr, size, _tp_rank, _ = cache_entry
2063 - assert size == expected_tensor_size, (
2064 - f"Entry {i}: Expected tensor size {expected_tensor_size}, got {size}"
2065 - )
2066 - assert base_addr == expected_base_addrs[i], (
2067 - f"Entry {i}: Expected base address {expected_base_addrs[i]}, "
2068 - f"got {base_addr}"
2069 - )
2002 + assert size == raw.nbytes
2003 + assert base_addr == raw.data_ptr()
2004
2005 # Verify get_xfer_descs was called with blocks_data
2006 assert mock_wrapper_instance.get_xfer_descs.called
2007 blocks_data, _ = mock_wrapper_instance.get_xfer_descs.call_args[0]
2008
2075 - # Validate blocks_data structure and size
2076 - assert len(blocks_data) == expected_blocks_count, (
2077 - f"Expected {expected_blocks_count} blocks, got {len(blocks_data)}"
2078 - )
2009 + # Layout-blind contract: whatever regions the worker carves out,
2010 + # transferring "block b" must move exactly logical block b's bytes for
2011 + # every layer. Map each registered byte to the block that owns it, then
2012 + # require every descriptor window to hold bytes of a single block and
2013 + # the windows of block b to cover exactly block b's bytes.
2014 + owner: dict[int, int] = {}
2015 + for cache in (tensor0, tensor1, tensor2):
2016 + for blk in range(num_blocks):
2017 + for addr in element_byte_addrs(cache[blk]):
2018 + assert owner.setdefault(addr, blk) == blk
2019 + block_bytes = defaultdict(set)
2020 + for addr, blk in owner.items():
2021 + block_bytes[blk].add(addr)
2022 +
2023 + covered: defaultdict[int, set[int]] = defaultdict(set)
2024 + for block_start_addr, block_len, _tp_rank in blocks_data:
2025 + window = range(block_start_addr, block_start_addr + block_len)
2026 + owners = {owner[addr] for addr in window}
2027 + assert len(owners) == 1, "descriptor window spans logical blocks"
2028 + covered[owners.pop()].update(window)
2029 + assert covered == block_bytes
2030 +
2031 + # Region bases are exactly the block-0 window starts, one per region.
2032 + base_addrs = connector.connector_worker.kv_caches_base_addr[
2033 + connector.connector_worker.engine_id
2034 + ][0]
2035 + if layout == "BLHNC":
2036 + assert len(base_addrs) == 3
2037 + assert set(base_addrs) == {
2038 + start for start, _len, _tp in blocks_data if owner[start] == 0
2039 + }
2040 + assert len(blocks_data) == num_blocks * len(base_addrs)
2041
2080 - if connector.prefer_cross_layer_blocks:
2081 - num_blocks = 8
2082 - else:
2083 - num_blocks = kv_cache_config.num_blocks
2042 + assert connector.connector_worker.block_size == 16
2043
2085 - expected_block_len = expected_tensor_size // num_blocks
2044
2087 - for i, block_entry in enumerate(blocks_data):
2088 - block_start_addr, block_len, tp_rank = block_entry
2089 - assert block_len == expected_block_len, (
2090 - f"Block entry {i}: Expected block len {expected_block_len}, "
2091 - f"got {block_len}"
2092 - )
2045 +def test_register_packed_dsv4_mla_cache_as_single_region(
2046 + default_vllm_config, dist_init
2047 +):
2048 + from vllm.v1.attention.backends.triton_attn import TritonAttentionBackend
2049
2094 - assert connector.connector_worker.block_size == 16
2050 + nixl_worker = "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker"
2051 + with (
2052 + patch(f"{nixl_worker}.NixlWrapper") as mock_nixl_wrapper,
2053 + patch(f"{nixl_worker}.threading.Event"),
2054 + patch(f"{nixl_worker}.threading.Thread") as mock_thread,
2055 + patch(f"{nixl_worker}.get_current_attn_backends") as mock_backends,
2056 + ):
2057 + mock_backends.return_value = [TritonAttentionBackend]
2058 + num_blocks = 2
2059 + num_layers = 4
2060 + block_size = 256
2061 + spec = MLAAttentionSpec(
2062 + block_size=block_size,
2063 + num_kv_heads=1,
2064 + head_size=512,
2065 + dtype=torch.uint8,
2066 + cache_dtype_str="fp8_ds_mla",
2067 + tokens_per_state=4,
2068 + alignment=576,
2069 + model_version="deepseek_v4",
2070 + state_content_bytes=584,
2071 + )
2072 + layer_names = [f"layer{idx}" for idx in range(num_layers)]
2073 + kv_cache_config = KVCacheConfig(
2074 + num_blocks=num_blocks,
2075 + kv_cache_tensors=[],
2076 + kv_cache_groups=[KVCacheGroupSpec(layer_names, spec)],
2077 + )
2078 + vllm_config = create_vllm_config(attention_backend="TRITON_ATTN")
2079 + vllm_config.cache_config.block_size = block_size
2080 + vllm_config.cache_config.kv_cache_layout = "BLHNC"
2081 + connector = NixlConnector(vllm_config, KVConnectorRole.WORKER, kv_cache_config)
2082 + connector.connector_worker = FakeNixlConnectorWorker(
2083 + vllm_config,
2084 + connector.engine_id,
2085 + hand_shake_latency=0,
2086 + kv_cache_layout="BLHNC",
2087 + kv_cache_config=kv_cache_config,
2088 + )
2089 + wrapper = mock_nixl_wrapper.return_value
2090 + connector.connector_worker.nixl_wrapper = wrapper
2091 + wrapper.get_agent_metadata.return_value = b"fake_agent_metadata"
2092 + mock_thread.return_value.is_alive.return_value = False
2093 +
2094 + raw = torch.zeros(
2095 + spec.page_size_bytes * num_blocks * num_layers,
2096 + dtype=torch.int8,
2097 + device=current_platform.device_type,
2098 + )
2099 + views = dense_kv_cache_views(
2100 + raw, spec, num_blocks, num_layers, KVCacheLayout.BLHNC
2101 + )
2102 + connector.register_kv_caches(dict(zip(layer_names, views)))
2103 +
2104 + blocks_data, _ = wrapper.get_xfer_descs.call_args[0]
2105 + packed_block_len = num_layers * spec.page_size_bytes
2106 + assert connector.connector_worker.kv_caches_base_addr[
2107 + connector.connector_worker.engine_id
2108 + ][0] == [raw.data_ptr()]
2109 + assert blocks_data.tolist() == [
2110 + [raw.data_ptr() + block_idx * packed_block_len, packed_block_len, 0]
2111 + for block_idx in range(num_blocks)
2112 + ]
2113
2114
2115 class FakePlatform(Platform):
@@ -2318,6 +2336,8 @@ def test_engine_ttl_disabled(default_vllm_config, dist_init):
2336
2337 def test_transfer_topology_unregister():
2338 """TransferTopology.unregister_remote_engine removes the engine."""
2339 + from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
2340 +
2341 topo = TransferTopology(
2342 tp_rank=0,
2343 tp_size=1,
@@ -2908,8 +2928,8 @@ def test_speculative_config_compatibility_hash(
2928 _set_test_speculative_config(local_config)
2929 _set_test_speculative_config(remote_config, **remote_overrides)
2930
2911 - local_hash = compute_nixl_compatibility_hash(local_config, "FLASH_ATTN", False)
2912 - remote_hash = compute_nixl_compatibility_hash(remote_config, "FLASH_ATTN", False)
2931 + local_hash = compute_nixl_compatibility_hash(local_config, "FLASH_ATTN")
2932 + remote_hash = compute_nixl_compatibility_hash(remote_config, "FLASH_ATTN")
2933
2934 assert (local_hash == remote_hash) is should_match
2935
@@ -2920,10 +2940,8 @@ def test_missing_speculative_config_changes_compatibility_hash():
2940 speculative_config = create_vllm_config()
2941 _set_test_speculative_config(speculative_config)
2942
2923 - regular_hash = compute_nixl_compatibility_hash(regular_config, "FLASH_ATTN", False)
2924 - speculative_hash = compute_nixl_compatibility_hash(
2925 - speculative_config, "FLASH_ATTN", False
2926 - )
2943 + regular_hash = compute_nixl_compatibility_hash(regular_config, "FLASH_ATTN")
2944 + speculative_hash = compute_nixl_compatibility_hash(speculative_config, "FLASH_ATTN")
2945
2946 assert regular_hash != speculative_hash
2947
@@ -2938,8 +2956,8 @@ def test_speculative_kv_cache_dtype_resolves_to_target():
2956 _set_test_speculative_config(local_config, kv_cache_dtype="fp8") # explicit
2957 _set_test_speculative_config(remote_config, kv_cache_dtype=None) # inherits
2958
2941 - local_hash = compute_nixl_compatibility_hash(local_config, "FLASH_ATTN", False)
2942 - remote_hash = compute_nixl_compatibility_hash(remote_config, "FLASH_ATTN", False)
2959 + local_hash = compute_nixl_compatibility_hash(local_config, "FLASH_ATTN")
2960 + remote_hash = compute_nixl_compatibility_hash(remote_config, "FLASH_ATTN")
2961
2962 assert local_hash == remote_hash
2963
@@ -2955,8 +2973,8 @@ def test_speculative_attention_backend_not_in_compatibility_hash():
2973 _set_test_speculative_config(local_config, attention_backend=None)
2974 _set_test_speculative_config(remote_config, attention_backend="FLASHINFER")
2975
2958 - local_hash = compute_nixl_compatibility_hash(local_config, "FLASH_ATTN", False)
2959 - remote_hash = compute_nixl_compatibility_hash(remote_config, "FLASH_ATTN", False)
2976 + local_hash = compute_nixl_compatibility_hash(local_config, "FLASH_ATTN")
2977 + remote_hash = compute_nixl_compatibility_hash(remote_config, "FLASH_ATTN")
2978
2979 assert local_hash == remote_hash
2980
@@ -2969,17 +2987,17 @@ def test_transfer_mode_changes_compatibility_hash():
2987 config = create_vllm_config()
2988
2989 pull_hash = compute_nixl_compatibility_hash(
2972 - config, "FLASH_ATTN", False, transfer_mode="pull"
2990 + config, "FLASH_ATTN", transfer_mode="pull"
2991 )
2992 push_hash = compute_nixl_compatibility_hash(
2975 - config, "FLASH_ATTN", False, transfer_mode="push"
2993 + config, "FLASH_ATTN", transfer_mode="push"
2994 )
2995
2996 assert pull_hash != push_hash
2997 assert pull_hash == compute_nixl_compatibility_hash(
2980 - config, "FLASH_ATTN", False, transfer_mode="pull"
2998 + config, "FLASH_ATTN", transfer_mode="pull"
2999 )
2982 - assert compute_nixl_compatibility_hash(config, "FLASH_ATTN", False) == pull_hash
3000 + assert compute_nixl_compatibility_hash(config, "FLASH_ATTN") == pull_hash
3001
3002
3003 @pytest.mark.skip_global_cleanup
@@ -3057,14 +3075,11 @@ def test_compatibility_hash_validation(
3075 kv_cache_spec = cast(
3076 AttentionSpec, kv_cache_config.kv_cache_groups[0].kv_cache_spec
3077 )
3060 - kv_cache_shape = decode_worker.attn_backends[0].get_kv_cache_shape(
3061 - num_blocks=kv_cache_config.num_blocks,
3062 - block_size=kv_cache_spec.block_size,
3063 - num_kv_heads=kv_cache_spec.num_kv_heads,
3064 - head_size=kv_cache_spec.head_size,
3078 + shape = compute_layer_kv_cache_shape_bytes(
3079 + kv_cache_spec, kv_cache_config.num_blocks
3080 )
3066 - shared_tensor = torch.zeros(*kv_cache_shape, dtype=kv_cache_spec.dtype)
3067 - unique_tensor = torch.zeros(*kv_cache_shape, dtype=kv_cache_spec.dtype)
3081 + shared_tensor = torch.zeros(*shape, dtype=torch.int8).view(kv_cache_spec.dtype)
3082 + unique_tensor = torch.zeros(*shape, dtype=torch.int8).view(kv_cache_spec.dtype)
3083 # Build kv_caches from the actual layer names in kv_cache_config so that
3084 # _layer_specs lookups in register_kv_caches always find a matching key.
3085 layer_names = [
@@ -3099,18 +3114,19 @@ def test_compatibility_hash_validation(
3114 remote_hash = compute_nixl_compatibility_hash(
3115 remote_vllm_config,
3116 decode_worker.backend_name,
3102 - decode_worker.transfer_topo.cross_layers_blocks,
3117 )
3118
3119 prefill_block_size = config_overrides.get("block_size", 16)
3120 + prefill_block_lens = [4096 * prefill_block_size]
3121 prefill_metadata = NixlAgentMetadata(
3122 engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
3123 agent_metadata=FakeNixlWrapper.AGENT_METADATA,
3124 kv_caches_base_addr=[0],
3125 device_id=0,
3126 num_blocks=1,
3112 - block_lens=[4096 * prefill_block_size], # slot_size * block_size
3113 - kv_cache_layout="HND",
3127 + block_lens=prefill_block_lens,
3128 + block_strides=prefill_block_lens,
3129 + kv_cache_layout="LBHNC",
3130 block_size=prefill_block_size,
3131 ssm_sizes=(0, 0),
3132 attn_backend_name=decode_worker.backend_name,
@@ -3187,9 +3203,8 @@ def test_handshake_decode_errors(default_vllm_config, dist_init, error_scenario)
3203 decode_worker = decode_connector.connector_worker
3204
3205 backend = get_current_attn_backend(local_vllm_config)
3190 - test_shape = backend.get_kv_cache_shape(
3191 - num_blocks=1, block_size=16, num_kv_heads=1, head_size=1
3192 - )
3206 + probe_spec = decode_worker.kv_cache_config.kv_cache_groups[0].kv_cache_spec
3207 + test_shape = compute_layer_kv_cache_shape_bytes(probe_spec, 1)
3208 decode_worker.transfer_topo = TransferTopology(
3209 tp_rank=decode_worker.tp_rank,
3210 tp_size=decode_worker.world_size,
@@ -3205,7 +3220,6 @@ def test_handshake_decode_errors(default_vllm_config, dist_init, error_scenario)
3220 decode_worker.compat_hash = compute_nixl_compatibility_hash(
3221 decode_worker.vllm_config,
3222 decode_worker.backend_name,
3208 - decode_worker.transfer_topo.cross_layers_blocks,
3223 )
3224
3225 if error_scenario == "handshake_decode_error":
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+8 -3
@@ -1505,14 +1505,19 @@ def _make_hybrid_mla_kv_cache_config(num_blocks: int = 4):
1505 mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
1506 )
1507 assert kda_spec.page_size_bytes == unified_page
1508 + # The three groups overlay each other, so layer i of every group aliases the same
1509 + # region: mla.i, kda_a.i and kda_b.i all live at i * layer_stride.
1510 + layer_stride = num_blocks * unified_page
1511 return KVCacheConfig(
1512 num_blocks=num_blocks,
1513 kv_cache_tensors=[
1514 KVCacheTensor(
1512 - size=num_blocks * unified_page,
1513 - shared_by=[f"mla.{i}", f"kda_a.{i}", f"kda_b.{i}"],
1515 + size=2 * layer_stride,
1516 + layers=[f"{prefix}.0", f"{prefix}.1"],
1517 + layer_stride=layer_stride,
1518 + block_stride=unified_page,
1519 )
1515 - for i in range(2)
1520 + for prefix in ("mla", "kda_a", "kda_b")
1521 ],
1522 kv_cache_groups=[
1523 KVCacheGroupSpec(["mla.0", "mla.1"], mla_spec),
tests/v1/kv_connector/unit/test_nixl_desc_geometry.py
+10 -3
@@ -129,14 +129,19 @@ def _make_mla_hybrid_worker(local_block_size, kernel_block_size, num_logical_blo
129 page_size_padded=unified_page,
130 mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
131 )
132 + # The three groups overlay each other, so layer i of every group aliases the same
133 + # region: mla.i, kda_a.i and kda_b.i all live at i * layer_stride.
134 + layer_stride = num_logical_blocks * unified_page
135 kv_cache_config = KVCacheConfig(
136 num_blocks=num_logical_blocks,
137 kv_cache_tensors=[
138 KVCacheTensor(
136 - size=num_logical_blocks * unified_page,
137 - shared_by=[f"mla.{i}", f"kda_a.{i}", f"kda_b.{i}"],
139 + size=2 * layer_stride,
140 + layers=[f"{prefix}.0", f"{prefix}.1"],
141 + layer_stride=layer_stride,
142 + block_stride=unified_page,
143 )
139 - for i in range(2)
144 + for prefix in ("mla", "kda_a", "kda_b")
145 ],
146 kv_cache_groups=[
147 KVCacheGroupSpec(["mla.0", "mla.1"], mla_spec),
@@ -230,6 +235,8 @@ def _make_remote_meta(
235 kv_caches_base_addr=[0x10_000_000, 0x20_000_000],
236 num_blocks=remote_num_logical * remote_ppl,
237 block_lens=[kernel_page, kernel_page],
238 + # Non-interleaved remote: consecutive blocks abut, so stride == page length.
239 + block_strides=[kernel_page, kernel_page],
240 kv_cache_layout=worker.kv_cache_layout,
241 block_size=remote_kernel_block_size,
242 ssm_sizes=remote_ssm_sizes,
tests/v1/kv_connector/unit/test_nixl_push_connector.py
+2
@@ -1081,6 +1081,8 @@ class TestPushPipelineParallel:
1081 device_id=0,
1082 num_blocks=4,
1083 block_lens=[block_len] * 4,
1084 + # Non-interleaved: consecutive blocks abut, so stride == block length.
1085 + block_strides=[block_len] * 4,
1086 kv_cache_layout="HND",
1087 block_size=16,
1088 ssm_sizes=(0, 0),
tests/v1/kv_connector/unit/test_nixl_simple_cpu_offload.py
+11 -4
@@ -145,10 +145,15 @@ def _make_kv_cache_config(
145 ),
146 )
147 ]
148 + # Each group packs its two layers densely; groups overlay one allocation.
149 + layer_stride = _BYTES_PER_BLOCK * num_blocks
150 + size = 2 * layer_stride
151 tensors = [
152 KVCacheTensor(
150 - size=_BYTES_PER_BLOCK * num_blocks,
151 - shared_by=fa_layers,
153 + size=size,
154 + layers=fa_layers,
155 + layer_stride=layer_stride,
156 + block_stride=_BYTES_PER_BLOCK,
157 )
158 ]
159 if swa_enabled:
@@ -167,8 +172,10 @@ def _make_kv_cache_config(
172 )
173 tensors.append(
174 KVCacheTensor(
170 - size=_BYTES_PER_BLOCK * num_blocks,
171 - shared_by=sw_layers,
175 + size=size,
176 + layers=sw_layers,
177 + layer_stride=layer_stride,
178 + block_stride=_BYTES_PER_BLOCK,
179 )
180 )
181 return KVCacheConfig(
tests/v1/kv_connector/unit/test_transfer_topology_sharded.py
+1 -8
@@ -12,14 +12,7 @@ pytestmark = pytest.mark.cpu_test
12
13
14 class _FakeAttentionBackend:
15 - @staticmethod
16 - def get_kv_cache_shape(
17 - num_blocks: int,
18 - block_size: int,
19 - num_kv_heads: int,
20 - head_size: int,
21 - ) -> tuple[int, int, int, int]:
22 - return (num_blocks, num_kv_heads, block_size, 2 * head_size)
15 + pass
16
17
18 def _make_topology(
tests/v1/kv_connector/unit/utils.py
+2
@@ -130,6 +130,8 @@ def create_vllm_config(
130 cache_dtype=cache_dtype,
131 enable_prefix_caching=True,
132 )
133 + # Connectors are constructed after layout resolution; mirror that here.
134 + cache_config.kv_cache_layout = "LBNHC"
135 kv_transfer_config = KVTransferConfig(
136 kv_connector=kv_connector,
137 kv_connector_module_path=kv_connector_module_path,
tests/v1/kv_offload/test_file_mapper.py
+3 -8
@@ -55,6 +55,7 @@ def make_mapper_from_offloading_spec(**kwargs) -> FileMapper:
55 ),
56 replicated_layout=kwargs.get("replicated_layout", False),
57 canonical_layout=kwargs.get("canonical_layout", False),
58 + kv_cache_layout=kwargs.get("kv_cache_layout", "LBNHC"),
59 )
60 spec = MagicMock(spec=OffloadingSpec)
61 spec.config = config
@@ -214,14 +215,8 @@ def test_parallel_agnostic_separates_persistent_layouts():
215 def test_canonical_layout_changes_storage_namespace():
216 # Canonical bytes are not interchangeable with the direct layout, so the
217 # format id must fork the storage namespace.
217 - from vllm.v1.attention.backends.utils import set_kv_cache_layout
218 -
219 - set_kv_cache_layout("NHD")
220 - try:
221 - direct = make_mapper_from_offloading_spec()
222 - canonical = make_mapper_from_offloading_spec(canonical_layout=True)
223 - finally:
224 - set_kv_cache_layout(None)
218 + direct = make_mapper_from_offloading_spec()
219 + canonical = make_mapper_from_offloading_spec(canonical_layout=True)
220 assert "canonical_format" not in direct.fields
221 assert canonical.fields["canonical_format"] == "v1-nhd"
222 assert direct.base_path != canonical.base_path
tests/v1/simple_kv_offload/test_scheduler.py
+3 -1
@@ -91,7 +91,9 @@ def _make_kv_cache_config(
91 tensors.append(
92 KVCacheTensor(
93 size=_BYTES_PER_BLOCK * num_blocks,
94 - shared_by=layer_names,
94 + layers=layer_names,
95 + layer_stride=_BYTES_PER_BLOCK * num_blocks,
96 + block_stride=_BYTES_PER_BLOCK,
97 )
98 )
99 return KVCacheConfig(
tests/v1/simple_kv_offload/test_worker.py
+90
@@ -10,6 +10,7 @@ read partially written / stale blocks and silently corrupt the CPU cache.
10 from __future__ import annotations
11
12 import time
13 +from unittest.mock import MagicMock
14
15 import pytest
16 import torch
@@ -19,6 +20,8 @@ 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
25 from vllm.v1.simple_kv_offload.copy_backend import DmaCopyBackend
26 from vllm.v1.simple_kv_offload.cuda_mem_ops import (
27 CU_MEMCPY_SRC_ACCESS_ORDER_ANY,
@@ -188,3 +191,90 @@ def test_build_params_src_access_order():
191 gpu, cpu, stream, src_access_order=CU_MEMCPY_SRC_ACCESS_ORDER_STREAM
192 )
193 assert ordered.attrs.srcAccessOrder == CU_MEMCPY_SRC_ACCESS_ORDER_STREAM
194 +
195 +
196 +@pytest.mark.parametrize("layout", list(KVCacheLayout))
197 +def test_register_shared_kv_cache_storage(monkeypatch, layout: KVCacheLayout):
198 + num_blocks = 4
199 + num_layers = 2
200 + spec = FullAttentionSpec(
201 + block_size=2,
202 + num_kv_heads=2,
203 + head_size=2,
204 + dtype=torch.float16,
205 + )
206 + raw = torch.zeros(
207 + num_blocks * num_layers * spec.page_size_bytes,
208 + dtype=torch.int8,
209 + device="cuda",
210 + )
211 + caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
212 + cache_config = MagicMock(num_blocks=num_blocks)
213 + worker = SimpleCPUOffloadWorker(
214 + vllm_config=None,
215 + kv_cache_config=cache_config,
216 + cpu_capacity_bytes=raw.nbytes,
217 + )
218 + worker._backend = MagicMock()
219 + monkeypatch.setattr("vllm.v1.simple_kv_offload.worker.PIN_MEMORY", False)
220 +
221 + worker.register_kv_caches(
222 + {f"layer.{layer_idx}": cache for layer_idx, cache in enumerate(caches)}
223 + )
224 +
225 + assert worker.gpu_kv_caches is not None
226 + if layout.is_layer_compact and not layout.is_block_compact:
227 + expected_regions = num_layers * spec.num_heads
228 + expected_block_bytes = spec.page_size_bytes // spec.num_heads
229 + elif layout.is_layer_compact:
230 + expected_regions = num_layers
231 + expected_block_bytes = spec.page_size_bytes
232 + else:
233 + expected_regions = 1
234 + expected_block_bytes = spec.page_size_bytes * num_layers
235 + assert len(worker.gpu_kv_caches) == expected_regions
236 + assert {cache.shape for cache in worker.gpu_kv_caches.values()} == {
237 + (num_blocks, expected_block_bytes)
238 + }
239 +
240 +
241 +def test_register_separate_kv_head_groups(monkeypatch):
242 + # LHBNC hoists the K/V head groups outside the block dim, so each layer's
243 + # blocks are registered as one region per group (K, V).
244 + layout = KVCacheLayout.LHBNC
245 + num_blocks = 4
246 + num_layers = 2
247 + spec = FullAttentionSpec(
248 + block_size=2,
249 + num_kv_heads=2,
250 + head_size=2,
251 + dtype=torch.float16,
252 + num_head_slots=2,
253 + state_content_bytes=2 * 2 * 2,
254 + )
255 + raw = torch.zeros(
256 + num_blocks * num_layers * spec.page_size_bytes,
257 + dtype=torch.int8,
258 + device="cuda",
259 + )
260 + caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
261 + worker = SimpleCPUOffloadWorker(
262 + vllm_config=None,
263 + kv_cache_config=MagicMock(num_blocks=num_blocks),
264 + cpu_capacity_bytes=raw.nbytes,
265 + )
266 + worker._backend = MagicMock()
267 + monkeypatch.setattr("vllm.v1.simple_kv_offload.worker.PIN_MEMORY", False)
268 +
269 + worker.register_kv_caches(
270 + {f"layer.{layer_idx}": cache for layer_idx, cache in enumerate(caches)}
271 + )
272 +
273 + assert worker.gpu_kv_caches is not None
274 + assert len(worker.gpu_kv_caches) == num_layers * spec.num_heads
275 + per_group_block_bytes = (
276 + spec.num_kv_heads * spec.block_size * spec.head_size * spec.dtype.itemsize
277 + )
278 + assert {cache.shape for cache in worker.gpu_kv_caches.values()} == {
279 + (num_blocks, per_group_block_bytes)
280 + }
tests/v1/worker/test_attn_utils.py
+171 -286
@@ -1,48 +1,33 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Padded-page handling in create_kv_cache_views.
4
5 +Guards that a page_size_padded spec strides the block dimension by the padded page
6 +while keeping per-block content compact, so padding bytes at the end of each page are
7 +never addressed by the logical view.
8 +"""
9 +
10 +import pytest
11 import torch
12
13 +from tests.v1.attention.utils import dense_kv_cache_views
14 +from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
15 from vllm.v1.kv_cache_interface import (
16 FullAttentionSpec,
17 KVCacheConfig,
18 + KVCacheGroupSpec,
19 + KVCacheLayout,
20 KVCacheTensor,
10 - KVQuantMode,
11 - MambaSpec,
21 + MLAAttentionSpec,
22 + compute_layout_strides,
23 +)
24 +from vllm.v1.worker.utils import (
25 + allocate_kv_cache,
26 + copy_kv_cache_blocks_inplace,
27 )
13 -from vllm.v1.worker.gpu.attn_utils import _reshape_kv_cache
14 -from vllm.v1.worker.utils import AttentionGroup
15 -
16 -
17 -class FakeFlashAttentionBackend:
18 - @staticmethod
19 - def get_kv_cache_shape(
20 - num_blocks: int,
21 - block_size: int,
22 - num_kv_heads: int,
23 - head_size: int,
24 - cache_dtype_str: str = "auto",
25 - ) -> tuple[int, ...]:
26 - return (num_blocks, 2, block_size, num_kv_heads, head_size)
27 -
28 - @staticmethod
29 - def get_kv_cache_stride_order(
30 - include_num_layers_dimension: bool = False,
31 - ) -> tuple[int, ...]:
32 - assert not include_num_layers_dimension
33 - return (0, 1, 2, 3, 4)
34 -
35 -
36 -class FakeHNDFlashAttentionBackend(FakeFlashAttentionBackend):
37 - @staticmethod
38 - def get_kv_cache_stride_order(
39 - include_num_layers_dimension: bool = False,
40 - ) -> tuple[int, ...]:
41 - assert not include_num_layers_dimension
42 - return (0, 1, 3, 2, 4)
28
29
45 -def test_reshape_padded_flash_attention_kv_cache_strides_by_page():
30 +def test_reshape_padded_kv_cache_strides_by_padded_page():
31 num_blocks = 3
32 spec = FullAttentionSpec(
33 block_size=16,
@@ -53,292 +38,192 @@ def test_reshape_padded_flash_attention_kv_cache_strides_by_page():
38 )
39 assert spec.real_page_size_bytes == 256
40
56 - raw_tensors = {
57 - "layer": torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
58 - }
59 - attn_groups = [
60 - AttentionGroup(
61 - backend=FakeFlashAttentionBackend,
62 - layer_names=["layer"],
63 - kv_cache_spec=spec,
64 - kv_cache_group_id=0,
65 - )
66 - ]
67 -
68 - kv_cache = _reshape_kv_cache(
69 - attn_groups,
70 - raw_tensors,
71 - "auto",
72 - [spec.block_size],
73 - {},
74 - )["layer"]
41 + raw = torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
42 + (kv_cache,) = dense_kv_cache_views(raw, spec, num_blocks, 1, KVCacheLayout.LBHNC)
43 +
44 + elem_size = 4 # float32
45 + # Content dim packs K and V: 2 * head_size.
46 + assert kv_cache.shape == (num_blocks, 1, 16, 2 * spec.head_size)
47 + assert kv_cache.dtype == spec.dtype
48 + assert kv_cache.stride(0) == spec.page_size_padded // elem_size
49 + assert kv_cache[1].storage_offset() == spec.page_size_padded // elem_size
50 + # Within one block the (unpadded) content stays compact.
51 + assert kv_cache[0].is_contiguous()
52 +
53 +
54 +@pytest.mark.parametrize(
55 + ("kernel_block_sizes", "expected_num_blocks", "expected_num_states"),
56 + [
57 + (None, 4, 64),
58 + ([256], 4, 64),
59 + ([64], 16, 16),
60 + ],
61 +)
62 +def test_allocate_compressed_mla_cache(
63 + kernel_block_sizes: list[int] | None,
64 + expected_num_blocks: int,
65 + expected_num_states: int,
66 +):
67 + spec = MLAAttentionSpec(
68 + block_size=256,
69 + num_kv_heads=1,
70 + head_size=128,
71 + dtype=torch.bfloat16,
72 + tokens_per_state=4,
73 + )
74 + num_pages = 4
75 + config = KVCacheConfig(
76 + num_blocks=num_pages,
77 + kv_cache_tensors=[
78 + KVCacheTensor(
79 + size=num_pages * spec.page_size_bytes,
80 + layers=["layer.0"],
81 + layer_stride=num_pages * spec.page_size_bytes,
82 + block_stride=spec.page_size_bytes,
83 + )
84 + ],
85 + kv_cache_groups=[KVCacheGroupSpec(["layer.0"], spec)],
86 + )
87
76 - assert kv_cache.shape == (num_blocks, 2, 16, 1, 2)
77 - assert kv_cache.stride(0) == spec.page_size_bytes // 4
78 - assert kv_cache.stride(1) == spec.real_page_size_bytes // 2 // 4
79 - assert kv_cache[1, 0].storage_offset() == spec.page_size_bytes // 4
80 - assert (
81 - kv_cache[1, 1].storage_offset()
82 - == (spec.page_size_bytes + spec.real_page_size_bytes // 2) // 4
88 + caches = allocate_kv_cache(
89 + config, torch.device("cpu"), KVCacheLayout.LBHNC, kernel_block_sizes
90 )
91
92 + assert caches["layer.0"].shape == (expected_num_blocks, 1, expected_num_states, 128)
93
86 -def test_reshape_padded_hnd_flash_attention_kv_cache_strides_by_page():
87 - num_blocks = 3
94 +
95 +@pytest.mark.parametrize("layout", list(KVCacheLayout))
96 +def test_copy_kv_cache_blocks_shared_storage(layout: KVCacheLayout):
97 + num_blocks = 4
98 + num_layers = 2
99 spec = FullAttentionSpec(
89 - block_size=16,
90 - num_kv_heads=3,
100 + block_size=2,
101 + num_kv_heads=2,
102 head_size=2,
103 dtype=torch.float32,
93 - page_size_padded=1024,
104 )
95 - assert spec.real_page_size_bytes == 768
96 -
97 - raw_tensors = {
98 - "layer": torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
99 - }
100 - attn_groups = [
101 - AttentionGroup(
102 - backend=FakeHNDFlashAttentionBackend,
103 - layer_names=["layer"],
104 - kv_cache_spec=spec,
105 - kv_cache_group_id=0,
106 - )
107 - ]
105 + raw = torch.zeros(num_blocks * num_layers * spec.page_size_bytes, dtype=torch.int8)
106 + caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
107
109 - kv_cache = _reshape_kv_cache(
110 - attn_groups,
111 - raw_tensors,
112 - "auto",
113 - [spec.block_size],
114 - {},
115 - )["layer"]
108 + for layer_idx, cache in enumerate(caches):
109 + for block_idx in range(num_blocks):
110 + cache[block_idx].fill_(10 * layer_idx + block_idx)
111
117 - assert kv_cache.shape == (num_blocks, 2, 16, 3, 2)
118 - assert kv_cache.stride(0) == spec.page_size_bytes // 4
119 - assert kv_cache.stride(1) == spec.real_page_size_bytes // 2 // 4
120 - assert kv_cache.stride(2) == 2
121 - assert kv_cache.stride(3) == spec.block_size * spec.head_size
122 - assert kv_cache[1, 0].storage_offset() == spec.page_size_bytes // 4
123 - assert (
124 - kv_cache[1, 1].storage_offset()
125 - == (spec.page_size_bytes + spec.real_page_size_bytes // 2) // 4
126 - )
127 - assert (
128 - kv_cache[1, 1, 3, 2].storage_offset()
129 - == (
130 - spec.page_size_bytes
131 - + spec.real_page_size_bytes // 2
132 - + 3 * spec.head_size * 4
133 - + 2 * spec.block_size * spec.head_size * 4
134 - )
135 - // 4
136 - )
112 + expected = [[cache[i].clone() for i in range(num_blocks)] for cache in caches]
113 + copies = [KVCacheBlockCopy(src_block_id=0, dst_block_id=2)]
114
115 + copy_kv_cache_blocks_inplace(caches, num_blocks, copies)
116
139 -class FakeDiffKVBackend:
140 - @staticmethod
141 - def get_kv_cache_shape(
142 - num_blocks: int,
143 - block_size: int,
144 - num_kv_heads: int,
145 - head_size: int,
146 - cache_dtype_str: str = "auto",
147 - ) -> tuple[int, ...]:
148 - return (num_blocks, block_size, num_kv_heads, head_size * 2)
117 + for layer_idx, cache in enumerate(caches):
118 + torch.testing.assert_close(cache[2], expected[layer_idx][0])
119 + torch.testing.assert_close(cache[1], expected[layer_idx][1])
120
150 - @staticmethod
151 - def get_kv_cache_stride_order(
152 - include_num_layers_dimension: bool = False,
153 - ) -> tuple[int, ...]:
154 - assert not include_num_layers_dimension
155 - return (0, 1, 2, 3)
121
157 -
158 -def test_reshape_padded_diff_kv_cache_does_not_infer_kv_dim():
122 +def test_fixed_block_stride_propagates_outward_in_lhbnc():
123 num_blocks = 3
124 + num_layers = 2
125 spec = FullAttentionSpec(
161 - block_size=16,
162 - num_kv_heads=1,
126 + block_size=2,
127 + num_kv_heads=2,
128 head_size=2,
129 dtype=torch.float32,
165 - page_size_padded=384,
130 + )
131 + natural = compute_layout_strides(spec, num_blocks, num_layers, KVCacheLayout.LHBNC)
132 + block_stride = natural[1] + 8
133 +
134 + strides = compute_layout_strides(
135 + spec,
136 + num_blocks,
137 + num_layers,
138 + KVCacheLayout.LHBNC,
139 + fixed_strides=(None, block_stride, None, None, None),
140 )
141
168 - raw_tensors = {
169 - "layer": torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
170 - }
171 - attn_groups = [
172 - AttentionGroup(
173 - backend=FakeDiffKVBackend,
174 - layer_names=["layer"],
175 - kv_cache_spec=spec,
176 - kv_cache_group_id=0,
177 - )
178 - ]
179 -
180 - kv_cache = _reshape_kv_cache(
181 - attn_groups,
182 - raw_tensors,
183 - "auto",
184 - [spec.block_size],
185 - {},
186 - )["layer"]
187 -
188 - assert kv_cache.shape == (num_blocks, 16, 1, 4)
189 - assert kv_cache.stride(0) == spec.page_size_bytes // 4
190 - assert kv_cache.stride(1) == 4
191 -
192 -
193 -class FakePerTokenScaleBackend:
194 - @staticmethod
195 - def get_kv_cache_shape(
196 - num_blocks: int,
197 - block_size: int,
198 - num_kv_heads: int,
199 - head_size: int,
200 - cache_dtype_str: str = "auto",
201 - ) -> tuple[int, ...]:
202 - return (num_blocks, 2, block_size, num_kv_heads, head_size + 4)
203 -
204 - @staticmethod
205 - def get_kv_cache_stride_order(
206 - include_num_layers_dimension: bool = False,
207 - ) -> tuple[int, ...]:
208 - assert not include_num_layers_dimension
209 - return (0, 1, 2, 3, 4)
142 + assert strides[1] == block_stride
143 + assert strides[2] == block_stride * num_blocks
144 + assert strides[0] == strides[2] * spec.num_heads
145
146
212 -def test_reshape_padded_quantized_kv_cache_preserves_scale_stride():
213 - num_blocks = 3
147 +def test_copy_kv_cache_blocks_separate_head_groups():
148 + # LHBNC stores each head group separately, so a block's bytes are scattered
149 + # across L*H regions.
150 + layout = KVCacheLayout.LHBNC
151 + num_blocks = 4
152 + num_layers = 2
153 spec = FullAttentionSpec(
215 - block_size=16,
216 - num_kv_heads=1,
217 - head_size=4,
218 - dtype=torch.int8,
219 - kv_quant_mode=KVQuantMode.INT8_PER_TOKEN_HEAD,
220 - page_size_padded=384,
154 + block_size=2,
155 + num_kv_heads=2,
156 + head_size=2,
157 + dtype=torch.float32,
158 + num_head_slots=2,
159 + state_content_bytes=2 * 2 * 4,
160 + )
161 + raw = torch.zeros(num_blocks * num_layers * spec.page_size_bytes, dtype=torch.int8)
162 + caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
163 +
164 + for layer_idx, cache in enumerate(caches):
165 + for block_idx in range(num_blocks):
166 + for head_idx in range(cache.shape[1]):
167 + cache[block_idx, head_idx].fill_(
168 + 100 * layer_idx + 10 * head_idx + block_idx
169 + )
170 +
171 + expected = [[cache[i].clone() for i in range(num_blocks)] for cache in caches]
172 + copy_kv_cache_blocks_inplace(
173 + caches,
174 + num_blocks,
175 + [KVCacheBlockCopy(src_block_id=0, dst_block_id=2)],
176 )
222 - assert spec.real_page_size_bytes == 128
223 - assert spec.page_size_bytes == 384
224 -
225 - raw_tensors = {
226 - "layer": torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
227 - }
228 - attn_groups = [
229 - AttentionGroup(
230 - backend=FakePerTokenScaleBackend,
231 - layer_names=["layer"],
232 - kv_cache_spec=spec,
233 - kv_cache_group_id=0,
234 - )
235 - ]
236 -
237 - kv_cache = _reshape_kv_cache(
238 - attn_groups,
239 - raw_tensors,
240 - "int8_per_token_head",
241 - [spec.block_size],
242 - {},
243 - )["layer"]
244 -
245 - assert kv_cache.shape == (num_blocks, 2, 16, 1, 8)
246 - assert kv_cache.stride(0) == spec.page_size_bytes
247 - assert kv_cache.stride(1) == 16 * 1 * 8
248 - assert kv_cache[1, 1].storage_offset() == spec.page_size_bytes + 16 * 1 * 8
249 -
250 -
251 -class FakeKVFirstBackend:
252 - """ROCm-style backend that puts K and V ahead of the block dim."""
253 -
254 - @staticmethod
255 - def get_kv_cache_shape(
256 - num_blocks: int,
257 - block_size: int,
258 - num_kv_heads: int,
259 - head_size: int,
260 - cache_dtype_str: str = "auto",
261 - ) -> tuple[int, ...]:
262 - return (2, num_blocks, block_size, num_kv_heads, head_size)
177
264 - @staticmethod
265 - def get_kv_cache_block_dim(
266 - block_size: int,
267 - num_kv_heads: int,
268 - head_size: int,
269 - cache_dtype_str: str = "auto",
270 - ) -> int:
271 - return 1
178 + for layer_idx, cache in enumerate(caches):
179 + torch.testing.assert_close(cache[2], expected[layer_idx][0])
180 + torch.testing.assert_close(cache[1], expected[layer_idx][1])
181
182
274 -def _kv_first_setup(shared_by: list[str]):
275 - num_blocks = 3
276 - attn_spec = FullAttentionSpec(
277 - block_size=16,
183 +@pytest.mark.parametrize(
184 + "layout,num_layers",
185 + [
186 + (KVCacheLayout.LBHNC, 2),
187 + # Splitting needs a manager block to be one dense page, which a
188 + # block-outermost layout only gives when the block holds one layer.
189 + (KVCacheLayout.BLHNC, 1),
190 + ],
191 +)
192 +def test_copy_kv_cache_blocks_with_virtual_block_splitting(
193 + layout: KVCacheLayout, num_layers: int
194 +):
195 + num_blocks = 4
196 + physical_per_logical = 2
197 + spec = FullAttentionSpec(
198 + block_size=4,
199 num_kv_heads=1,
200 head_size=2,
201 dtype=torch.float32,
202 )
282 - mamba_spec = MambaSpec(
283 - block_size=16,
284 - shapes=((64,),),
285 - dtypes=(torch.float32,),
203 + raw = torch.zeros(num_blocks * num_layers * spec.page_size_bytes, dtype=torch.int8)
204 + caches = dense_kv_cache_views(
205 + raw,
206 + spec,
207 + num_blocks,
208 + num_layers,
209 + layout,
210 + kernel_block_size=spec.block_size // physical_per_logical,
211 )
287 - assert attn_spec.page_size_bytes == mamba_spec.page_size_bytes == 256
212
289 - raw_tensor = torch.zeros(attn_spec.page_size_bytes * num_blocks, dtype=torch.int8)
290 - raw_tensors = {name: raw_tensor for name in shared_by}
291 - attn_groups = [
292 - AttentionGroup(
293 - backend=FakeKVFirstBackend,
294 - layer_names=["attn"],
295 - kv_cache_spec=attn_spec,
296 - kv_cache_group_id=0,
297 - )
298 - ]
299 - if "mamba" in shared_by:
300 - attn_groups.append(
301 - AttentionGroup(
302 - backend=FakeKVFirstBackend,
303 - layer_names=["mamba"],
304 - kv_cache_spec=mamba_spec,
305 - kv_cache_group_id=1,
306 - )
307 - )
308 - kv_cache_config = KVCacheConfig(
309 - num_blocks=num_blocks,
310 - kv_cache_tensors=[
311 - KVCacheTensor(size=raw_tensor.numel(), shared_by=list(shared_by))
312 - ],
313 - kv_cache_groups=[],
314 - )
213 + for layer_idx, cache in enumerate(caches):
214 + for block_idx in range(cache.shape[0]):
215 + cache[block_idx].fill_(100 * layer_idx + block_idx)
216 + expected = [[cache[i].clone() for i in range(cache.shape[0])] for cache in caches]
217
316 - kv_caches = _reshape_kv_cache(
317 - attn_groups,
318 - raw_tensors,
319 - "auto",
320 - [attn_spec.block_size] * len(attn_groups),
321 - {},
322 - kv_cache_config,
218 + copy_kv_cache_blocks_inplace(
219 + caches,
220 + num_blocks,
221 + [KVCacheBlockCopy(src_block_id=0, dst_block_id=2)],
222 )
324 - return num_blocks, kv_caches["attn"]
325 -
326 -
327 -def test_reshape_kv_first_kv_cache_pages_blocks_when_shared_with_mamba():
328 - num_blocks, kv_cache = _kv_first_setup(["attn", "mamba"])
329 -
330 - assert kv_cache.shape == (2, num_blocks, 16, 1, 2)
331 - # Block b has to own page b, so its K and V sit side by side within it.
332 - page = 16 * 1 * 2 * 2
333 - for block in range(num_blocks):
334 - assert kv_cache[0, block].storage_offset() == block * page
335 - assert kv_cache[1, block].storage_offset() == block * page + page // 2
223
337 -
338 -def test_reshape_kv_first_kv_cache_keeps_layout_without_mamba():
339 - num_blocks, kv_cache = _kv_first_setup(["attn"])
340 -
341 - assert kv_cache.shape == (2, num_blocks, 16, 1, 2)
342 - # Nothing else indexes this allocation by page, so K and V stay split into
343 - # one contiguous half each.
344 - assert kv_cache[1, 0].storage_offset() == num_blocks * 16 * 1 * 2
224 + dst_start = 2 * physical_per_logical
225 + for layer_idx, cache in enumerate(caches):
226 + for physical_idx in range(physical_per_logical):
227 + torch.testing.assert_close(
228 + cache[dst_start + physical_idx], expected[layer_idx][physical_idx]
229 + )
tests/v1/worker/test_dsv4_packed_zeroer_geometry.py
+146 -173
@@ -1,35 +1,14 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -"""CPU regression for the packed-DSV4 KV zeroer geometry.
4 -
5 -Constructor-crossing regression for the packed-KV zeroing stride fix (upstream
6 -PR #50276): every number below comes from instantiating the REAL production
7 -code (no arithmetic is reimplemented here):
8 -
9 - - ``vllm.v1.core.kv_cache_utils._get_packed_kv_cache_layout`` (packed
10 - offsets / block stride)
11 - - ``vllm.v1.worker.gpu.attn_utils._reshape_attention_kv_cache``
12 - (per-layer column views)
13 - - ``vllm.models.deepseek_v4.sparse_mla.DeepseekV4FlashMLABackend``
14 - (``get_kv_cache_shape`` / ``get_kv_cache_block_dim``)
15 - - ``vllm.v1.worker.utils.AttentionGroup`` / ``KVBlockZeroer``
16 - (``KVBlockZeroer.__init__`` precomputes the segment tables)
17 -
18 -CPU only: no Triton launch, no CUDA tensor, no CUDA call is made. The test
19 -asserts the packed-DSV4 constructor metadata:
20 -
21 - * block stride = the full packed row (4 aligned pages)
22 - * per-layer zero = the meaningful page (the spec's unpadded
23 - ``real_page_size_bytes``), so adjacent packed layers are
24 - never wiped
25 - * 4 packed layer views; block 99 of the highest-offset layer is the last
26 - written region and stays within the packed backing.
27 -
28 -The pre-fix constructor exposed a five-field ``_meta`` with no block stride, so
29 -its zero width equals the FULL packed row; the extraction below records that
30 -legacy defect and then asserts the fixed semantics, so failure on the old code
31 -is a semantic one (wrong zero width / out-of-bounds block 99), not a
32 -schema-shape accident.
3 +"""CPU regression for the packed-DSV4 KV zeroer geometry (upstream #50276).
4 +
5 +Four DSV4 fp8_ds_mla layers share one cross-layer allocation: each block row packs
6 +the four alignment-padded pages side by side (layout BLHNC). The zeroer must step by
7 +the full packed row per block while zeroing only each layer's meaningful page — never
8 +the alignment padding, never an adjacent layer's page.
9 +
10 +CPU only: the zeroer's ``__init__`` precomputes the segment tables; no kernel is
11 +launched.
12 """
13
14 from types import SimpleNamespace
@@ -37,160 +16,154 @@ from types import SimpleNamespace
16 import pytest
17 import torch
18
40 -from vllm.models.deepseek_v4.sparse_mla import DeepseekV4FlashMLABackend
41 -from vllm.v1.core.kv_cache_utils import _get_packed_kv_cache_layout
42 -from vllm.v1.kv_cache_interface import MLAAttentionSpec, UniformTypeKVCacheSpecs
43 -from vllm.v1.worker.gpu.attn_utils import _reshape_attention_kv_cache
44 -from vllm.v1.worker.utils import AttentionGroup, KVBlockZeroer
19 +from tests.v1.attention.utils import dense_kv_cache_views
20 +from vllm.v1.kv_cache_interface import KVCacheLayout, MLAAttentionSpec
21 +from vllm.v1.worker.utils import (
22 + AttentionGroup,
23 + KVBlockZeroer,
24 + allocate_kv_cache,
25 +)
26
27 pytestmark = pytest.mark.cpu_test
28
29 NUM_BLOCKS = 100
49 -BLOCK_SIZE_CFG = 256 # vllm_config.cache_config.block_size for DSV4 (group 0)
50 -COMPRESS = 4 # main-MLA compress_ratio
51 -STORAGE_BS = BLOCK_SIZE_CFG // COMPRESS # 64
52 -KERNEL_BS = 256 # DeepseekV4FlashMLABackend supports [256]
53 -LAST_BLOCK = NUM_BLOCKS - 1 # block 99
54 -
55 -
56 -def _extract_segments(meta, base):
57 - """Read (offset_bytes, block_stride_bytes, zero_span_bytes) per segment.
58 -
59 - Accepts both the pre-fix five-field ``_meta`` (no block stride; the zero
60 - width is the FULL packed row -- the defect) and the fixed six-field ``_meta``.
61 - All values come from the real constructor output; nothing is recomputed. Returns
62 - ``(segs, legacy_why)`` where ``legacy_why`` describes the pre-fix defect
63 - when detected.
64 - """
65 - legacy_why = None
66 - if len(meta) == 6:
67 - seg_addrs, seg_block_strides, seg_page_sizes, _max_chunks, _blk, n_segs = meta
68 - else:
69 - seg_addrs, seg_page_sizes, _max_chunks, _blk, n_segs = meta
70 - seg_block_strides = seg_page_sizes
71 - legacy_why = (
72 - f"legacy {len(meta)}-field _meta (no block stride): zero width is "
73 - f"the FULL packed row ({int(seg_page_sizes[0]) * 4} B) -- the defect"
74 - )
75 - segs = [
76 - (int(a) - base, int(bs) * 4, int(ps) * 4)
77 - for a, bs, ps in zip(seg_addrs, seg_block_strides, seg_page_sizes)
78 - ]
79 - return segs, legacy_why, n_segs
80 -
81 -
82 -def test_dsv4_packed_zeroer_geometry():
83 - """Real DSV4 constructor metadata separates block stride from zero span."""
84 - layer_names = [f"model.layers.{i}.self_attn" for i in range(4)]
85 -
86 - specs = [
87 - MLAAttentionSpec(
88 - block_size=BLOCK_SIZE_CFG,
89 - num_kv_heads=1,
90 - head_size=512,
91 - dtype=torch.uint8,
92 - compress_ratio=COMPRESS,
93 - cache_dtype_str="fp8_ds_mla",
94 - alignment=576,
95 - model_version="deepseek_v4",
96 - state_content_bytes=584, # >576 to allocate room for scales at back of page
97 - )
98 - for _ in layer_names
99 - ]
100 - uniform = UniformTypeKVCacheSpecs.from_specs(
101 - {ln: sp for ln, sp in zip(layer_names, specs)}
30 +NUM_LAYERS = 4
31 +ALIGNMENT = 576
32 +
33 +
34 +def test_packed_dsv4_zeroer_zeroes_only_each_layers_page():
35 + spec = MLAAttentionSpec(
36 + block_size=256,
37 + num_kv_heads=1,
38 + head_size=512,
39 + dtype=torch.uint8,
40 + cache_dtype_str="fp8_ds_mla",
41 + model_version="deepseek_v4",
42 + tokens_per_state=4,
43 + alignment=ALIGNMENT,
44 + # DeepseekV4 fp8_ds_mla: 584B per token, published by the layer.
45 + state_content_bytes=584,
46 )
103 - assert uniform is not None
104 -
105 - # --- Real packed layout planner -------------------------------------------
106 - block_stride, layers_by_offset = _get_packed_kv_cache_layout(
107 - [
108 - AttentionGroup(
109 - backend=DeepseekV4FlashMLABackend,
110 - layer_names=layer_names,
111 - kv_cache_spec=uniform,
112 - kv_cache_group_id=0,
113 - )
114 - ]
115 - )
116 - page_bytes = specs[0].page_size_bytes # DSV4 fp8_ds_mla page after 576B alignment
117 - real_page_bytes = specs[0].real_page_size_bytes # unpadded meaningful page
118 - alignment_gap = page_bytes - real_page_bytes # trailing planner padding
119 - total_size = block_stride * NUM_BLOCKS
120 - offsets = sorted(layers_by_offset)
121 - assert block_stride == 4 * page_bytes, (block_stride, 4 * page_bytes)
122 - assert offsets == [i * page_bytes for i in range(4)]
123 -
124 - # --- Real per-layer views (packed branch) --------------------------------
125 - backing = torch.zeros(total_size, dtype=torch.uint8)
126 - base = backing.data_ptr()
127 - views = {}
128 - for ln, off in zip(layer_names, offsets):
129 - packing = (off, block_stride)
130 - kv_shape = DeepseekV4FlashMLABackend.get_kv_cache_shape(
131 - NUM_BLOCKS, STORAGE_BS, 1, 512, cache_dtype_str="fp8_ds_mla"
132 - )
133 - stride_order = tuple(range(len(kv_shape)))
134 - view = _reshape_attention_kv_cache(
135 - backing, specs[0], kv_shape, stride_order, NUM_BLOCKS, packing
136 - )
137 - views[ln] = view
138 - assert view.data_ptr() - base == off, "data_ptr must be base+offset"
139 - assert view.stride(0) * view.element_size() == block_stride, (
140 - "row stride == block_stride (logical block stride)"
141 - )
47 + # DSV4: 448B NoPE + 128B RoPE + 8B fp8 scale = 584B per stored state.
48 + assert spec.state_content_size_bytes == 584
49 + assert spec.num_states == 64
50 + unpadded_page = spec.unpadded_page_size_bytes
51 + padded_page = spec.page_size_bytes
52 + assert unpadded_page == 64 * 584
53 + assert padded_page > unpadded_page, "alignment must pad the page"
54 +
55 + raw = torch.zeros(NUM_BLOCKS * NUM_LAYERS * padded_page, dtype=torch.int8)
56 + views = dense_kv_cache_views(raw, spec, NUM_BLOCKS, NUM_LAYERS, KVCacheLayout.BLHNC)
57 + base = raw.data_ptr()
58 + block_row = NUM_LAYERS * padded_page
59 + for i, view in enumerate(views):
60 + assert view.data_ptr() - base == i * padded_page
61 + assert view.stride(0) * view.element_size() == block_row
62
143 - # --- Real KVBlockZeroer.__init__ (production arithmetic, CPU) ----------
144 - sctx = {ln: SimpleNamespace(kv_cache=v) for ln, v in views.items()}
63 zeroer = KVBlockZeroer(
64 torch.device("cpu"),
147 - attn_groups_iter=[
148 - AttentionGroup(
149 - backend=DeepseekV4FlashMLABackend,
150 - layer_names=layer_names,
151 - kv_cache_spec=specs[0],
152 - kv_cache_group_id=0,
153 - )
154 - ],
155 - kernel_block_sizes=[KERNEL_BS],
156 - cache_dtype="fp8_ds_mla",
157 - static_forward_context=sctx,
65 + attn_groups_iter=iter(
66 + [
67 + AttentionGroup(
68 + backend=None,
69 + layer_names=[f"layer.{i}" for i in range(NUM_LAYERS)],
70 + kv_cache_spec=spec,
71 + kv_cache_group_id=0,
72 + )
73 + ]
74 + ),
75 + kernel_block_sizes=[spec.block_size],
76 + static_forward_context={
77 + f"layer.{i}": SimpleNamespace(kv_cache=views[i]) for i in range(NUM_LAYERS)
78 + },
79 )
159 - segs, legacy_why, n_segs = _extract_segments(zeroer._meta, base)
160 -
161 - # --- Fixed semantics (proven by metadata values, not by schema shape) ----
162 - assert n_segs == 4, "all 4 packed layers must register as segments"
163 - assert [off for off, _, _ in segs] == offsets, "one segment per layer offset"
164 - assert all(bs == block_stride for _, bs, _ in segs), (
165 - "block stride must be the full packed row (4 aligned pages)"
80 + seg_addrs, seg_block_strides, seg_page_sizes, _, _, n_segs = zeroer._meta
81 +
82 + assert n_segs == NUM_LAYERS
83 + # Segments step by the full packed row per block...
84 + assert (seg_block_strides * 4 == block_row).all()
85 + # ...but zero only the layer's meaningful page: no alignment padding, no
86 + # adjacent layer's page.
87 + assert (seg_page_sizes * 4 == unpadded_page).all()
88 + assert sorted(a - base for a in seg_addrs.tolist()) == [
89 + i * padded_page for i in range(NUM_LAYERS)
90 + ]
91 + # Block 99 of the highest-offset layer stays within the packed backing.
92 + last_end = (
93 + max(seg_addrs.tolist())
94 + + (NUM_BLOCKS - 1) * int(seg_block_strides[0]) * 4
95 + + int(seg_page_sizes[0]) * 4
96 )
167 - assert all(zs == real_page_bytes for _, _, zs in segs), (
168 - f"zero span must be the meaningful page ({real_page_bytes} B), got "
169 - f"{[zs for _, _, zs in segs]} B" + (f"; {legacy_why}" if legacy_why else "")
97 + assert last_end <= base + raw.numel()
98 +
99 +
100 +def test_overlaid_zeroer_dedups_segments_with_max_span():
101 + """Two groups overlay one allocation; the zeroer must emit one segment per distinct
102 + byte offset, spanning the widest overlaid page, so a newly allocated block is fully
103 + zeroed no matter which group owns it."""
104 + from unittest.mock import MagicMock
105 +
106 + from vllm.v1.core.kv_cache_utils import get_kv_cache_config_from_groups
107 + from vllm.v1.kv_cache_interface import (
108 + KVCacheGroupSpec,
109 + KVCacheLayout,
110 + UniformTypeKVCacheSpecs,
111 )
112
172 - # --- Block 99 (the real final block) bounds, via constructor metadata ---
173 - # Production write for block b of a segment is
174 - # [off + b*block_stride_bytes, off + b*block_stride_bytes + zero_span_bytes).
175 - worst = 0
176 - for off, bs, zs in segs:
177 - start = off + LAST_BLOCK * bs
178 - end = start + zs
179 - oob = end - total_size
180 - worst = max(worst, oob)
181 - assert end <= total_size, (
182 - f"segment@off={off} block {LAST_BLOCK} writes [{start}, {end}) "
183 - f"-> {oob} bytes past backing ({total_size})"
113 + def make_spec(head_size):
114 + return MLAAttentionSpec(
115 + block_size=64, num_kv_heads=1, head_size=head_size, dtype=torch.uint8
116 )
185 - assert worst == 0, f"highest-offset final block goes {worst} bytes OOB"
186 -
187 - # Highest-offset segment's block 99 is the last written region and ends exactly at
188 - # total_size - alignment_gap: the trailing planner padding stays untouched.
189 - off_last, bs_last, zs_last = segs[-1]
190 - assert off_last + LAST_BLOCK * bs_last + zs_last == total_size - alignment_gap, (
191 - "highest-offset final block must end at total_size - alignment_gap "
192 - f"({alignment_gap}-byte gap intact)"
193 - )
117
195 - # Zero span < block stride proves adjacent packed layers are never wiped.
196 - assert max(zs for _, _, zs in segs) < block_stride
118 + g1_specs = {"g1.big": make_spec(512), "g1.small": make_spec(128)}
119 + g2_specs = {"g2.huge": make_spec(1024)}
120 + groups = [
121 + KVCacheGroupSpec(
122 + list(g1_specs),
123 + UniformTypeKVCacheSpecs(block_size=64, kv_cache_specs=g1_specs),
124 + ),
125 + KVCacheGroupSpec(
126 + list(g2_specs),
127 + UniformTypeKVCacheSpecs(block_size=64, kv_cache_specs=g2_specs),
128 + ),
129 + ]
130 + from vllm.config import CacheConfig
131 +
132 + vllm_config = MagicMock()
133 + vllm_config.cache_config = CacheConfig()
134 + vllm_config.cache_config.num_gpu_blocks_override = None
135 + vllm_config.cache_config.kv_cache_layout = "BLHNC"
136 + config = get_kv_cache_config_from_groups(vllm_config, groups, 8 * 1024 * 1024)
137 + views = allocate_kv_cache(config, torch.device("cpu"), KVCacheLayout.BLHNC, None)
138 + buf_ptr = views["g1.big"].data_ptr()
139 +
140 + attn_groups = [
141 + AttentionGroup(
142 + backend=None,
143 + layer_names=list(specs),
144 + kv_cache_spec=next(iter(specs.values())),
145 + kv_cache_group_id=gid,
146 + )
147 + for gid, specs in enumerate((g1_specs, g2_specs))
148 + ]
149 + zeroer = KVBlockZeroer(
150 + torch.device("cpu"),
151 + attn_groups_iter=iter(attn_groups),
152 + kernel_block_sizes=[64, 64],
153 + static_forward_context={
154 + name: SimpleNamespace(kv_cache=views[name]) for name in views
155 + },
156 + )
157 + seg_addrs, seg_block_strides, seg_page_sizes, _, _, n_segs = zeroer._meta
158 +
159 + # g1.big and g2.huge overlay at offset 0 -> one segment with g2's wider
160 + # span; g1.small keeps its own segment.
161 + assert n_segs == 2
162 + pages = {n: s.page_size_bytes for n, s in (g1_specs | g2_specs).items()}
163 + by_offset = {
164 + a - buf_ptr: p * 4 for a, p in zip(seg_addrs.tolist(), seg_page_sizes.tolist())
165 + }
166 + assert by_offset[0] == max(pages["g1.big"], pages["g2.huge"])
167 + assert by_offset[pages["g1.big"]] == pages["g1.small"]
168 + packed_block_stride = max(sum(pages[n] for n in g) for g in (g1_specs, g2_specs))
169 + assert (seg_block_strides * 4 == packed_block_stride).all()
tests/v1/worker/test_gpu_model_runner.py
+27 -61
@@ -92,7 +92,12 @@ def initialize_kv_cache(runner: GPUModelRunner):
92 kv_cache_config = KVCacheConfig(
93 num_blocks=NUM_BLOCKS,
94 kv_cache_tensors=[
95 - KVCacheTensor(size=tensor_size, shared_by=["layer.0"]),
95 + KVCacheTensor(
96 + size=tensor_size,
97 + layers=["layer.0"],
98 + layer_stride=tensor_size,
99 + block_stride=attn_spec.page_size_bytes,
100 + ),
101 ],
102 kv_cache_groups=[
103 KVCacheGroupSpec(layer_names=["layer.0"], kv_cache_spec=attn_spec)
@@ -131,6 +136,7 @@ def get_vllm_config():
136 gpu_memory_utilization=0.9,
137 cache_dtype="auto",
138 )
139 + cache_config.kv_cache_layout = "LBNHC"
140 parallel_config = ParallelConfig()
141 vllm_config = VllmConfig(
142 model_config=model_config,
@@ -839,56 +845,6 @@ def test_update_states_pp_async_multi_request_keeps_rank_state_consistent(
845 )
846
847
842 -def test_kv_cache_stride_order(monkeypatch, model_runner):
843 - # This test checks if GPUModelRunner initializes correctly when an attention
844 - # backend enforces a non-default KV cache stride order.
845 - n_heads = model_runner.model_config.get_num_kv_heads(model_runner.parallel_config)
846 - head_size = model_runner.model_config.get_head_size()
847 -
848 - # Get the expected shape from the backend's get_kv_cache_shape method
849 - # to ensure compatibility with different backends (triton vs flexattention)
850 - attn_backend = None
851 - for attn_group in model_runner._attn_group_iterator():
852 - attn_backend = attn_group.backend
853 - break
854 -
855 - assert attn_backend is not None, "No attention backend found"
856 - expected_kv_cache_shape = list(
857 - attn_backend.get_kv_cache_shape(NUM_BLOCKS, BLOCK_SIZE, n_heads, head_size)
858 - )
859 -
860 - # TODO mla test
861 - default_stride = tuple(range(len(expected_kv_cache_shape)))
862 - non_default_stride = (*default_stride[1:], default_stride[0])
863 - # Permutation that gets you back to expected kv shape
864 - for test_stride in (non_default_stride, default_stride):
865 -
866 - def rnd_stride_order(
867 - include_num_layers_dimension: bool = False, test_stride=test_stride
868 - ):
869 - assert not include_num_layers_dimension
870 - return test_stride
871 -
872 - # Patch the attention backend class and re-trigger the KV cache creation
873 - for attn_group in model_runner._attn_group_iterator():
874 - attn_backend = attn_group.backend
875 - monkeypatch.setattr(
876 - attn_backend, "get_kv_cache_stride_order", rnd_stride_order
877 - )
878 -
879 - model_runner.attn_groups = []
880 - model_runner.kv_caches = []
881 - model_runner.initialize_kv_cache(model_runner.kv_cache_config)
882 -
883 - # Shape is unchanged, but layout may differ
884 - kv_cache_shape = model_runner.kv_caches[0].shape
885 - assert list(kv_cache_shape) == expected_kv_cache_shape
886 - if default_stride == test_stride:
887 - assert all(kv.is_contiguous() for kv in model_runner.kv_caches)
888 - else:
889 - assert all(not kv.is_contiguous() for kv in model_runner.kv_caches)
890 -
891 -
848 def test_update_config(model_runner):
849 # Simple update
850 model_runner.update_config({"load_config": {"load_format": "dummy"}})
@@ -1124,21 +1080,22 @@ def test_init_kv_cache_without_kv_sharing(default_vllm_config):
1080 vllm_config, [kv_cache_spec], [available_memory]
1081 )[0]
1082 assert kv_cache_config.num_blocks == num_expected_blocks
1127 - assert len(kv_cache_config.kv_cache_tensors) == 2
1128 - assert kv_cache_config.kv_cache_tensors[0].size == available_memory // 2
1129 - assert kv_cache_config.kv_cache_tensors[1].size == available_memory // 2
1083 + assert len(kv_cache_config.kv_cache_tensors) == 1
1084 + assert kv_cache_config.kv_cache_tensors[0].size == available_memory
1085
1086 max_context_len = estimate_max_model_len(vllm_config, kv_cache_spec, 5 * GiB_bytes)
1087 # max context len with KV sharing should be 2x as large as without
1088 assert max_context_len == 1310720
1089
1090 # important: override tensor size to prevent large mem alloc during test
1136 - # this will only allocate 2 block worth of memory (2 * 32kb)
1091 + # this will only allocate 1 block worth of memory per layer (2 layers * 32kb)
1092 kv_cache_config.num_blocks = 1
1093 for kv_cache_tensor in kv_cache_config.kv_cache_tensors:
1139 - kv_cache_tensor.size = kv_cache_spec[
1140 - kv_cache_tensor.shared_by[0]
1141 - ].page_size_bytes
1094 + page_size = kv_cache_spec[kv_cache_tensor.layers[0]].page_size_bytes
1095 + kv_cache_tensor.size = page_size * len(kv_cache_tensor.layers)
1096 + # One block per layer: a layer's region is exactly one page.
1097 + kv_cache_tensor.layer_stride = page_size
1098 + kv_cache_tensor.block_stride = page_size
1099
1100 runner.initialize_kv_cache(kv_cache_config)
1101
@@ -1207,7 +1164,9 @@ def test_init_kv_cache_with_kv_sharing_valid(default_vllm_config):
1164 # important: override tensor size to prevent large mem alloc during test
1165 # this will only allocate 1 block worth of memory (32kb)
1166 kv_cache_config.num_blocks = 1
1210 - kv_cache_config.kv_cache_tensors[0].size = kv_cache_spec[layer_0].page_size_bytes
1167 + page_size = kv_cache_spec[layer_0].page_size_bytes
1168 + kv_cache_config.kv_cache_tensors[0].size = page_size
1169 + kv_cache_config.kv_cache_tensors[0].layer_stride = page_size
1170
1171 runner.initialize_kv_cache(kv_cache_config)
1172 kv_cache_config_after_init = runner.kv_cache_config
@@ -1232,7 +1191,7 @@ def test_hybrid_attention_mamba_tensor_shapes():
1191 """
1192 The GPU model runner creates different views into the
1193 KVCacheTensors for the attention and mamba layers
1235 - (via _reshape_kv_cache_tensors function). This test verifies
1194 + (via _allocate_kv_caches). This test verifies
1195 that the views are compatible: writing a mamba block
1196 will not corrupt an attention block and vice versa
1197 """
@@ -1270,6 +1229,7 @@ def test_hybrid_attention_mamba_tensor_shapes():
1229 gpu_memory_utilization=0.9,
1230 cache_dtype="auto",
1231 )
1232 + cache_config.kv_cache_layout = "LBNHC"
1233 parallel_config = ParallelConfig()
1234 attention_config = AttentionConfig(backend=AttentionBackendEnum.FLASHINFER)
1235 vllm_config = VllmConfig(
@@ -1561,7 +1521,12 @@ def test_hybrid_cache_integration(default_vllm_config, dist_init):
1521 kv_cache_config = KVCacheConfig(
1522 num_blocks=NUM_BLOCKS,
1523 kv_cache_tensors=[
1564 - KVCacheTensor(size=tensor_size, shared_by=["layer.0"]),
1524 + KVCacheTensor(
1525 + size=tensor_size,
1526 + layers=["layer.0"],
1527 + layer_stride=tensor_size,
1528 + block_stride=attn_spec.page_size_bytes,
1529 + ),
1530 ],
1531 kv_cache_groups=[
1532 KVCacheGroupSpec(layer_names=["layer.0"], kv_cache_spec=attn_spec)
@@ -1726,6 +1691,7 @@ def test_mamba_cache_raises_when_max_num_seqs_exceeds_blocks():
1691 gpu_memory_utilization=0.9,
1692 cache_dtype="auto",
1693 )
1694 + cache_config.kv_cache_layout = "LBNHC"
1695 parallel_config = ParallelConfig()
1696 attention_config = AttentionConfig(backend=AttentionBackendEnum.FLASHINFER)
1697 vllm_config = VllmConfig(
tests/v1/worker/test_kv_block_zeroer.py
+48 -17
@@ -7,8 +7,11 @@ from types import SimpleNamespace
7 import pytest
8 import torch
9
10 +from tests.v1.attention.utils import dense_kv_cache_views
11 from vllm.v1.kv_cache_interface import (
12 ChunkedLocalAttentionSpec,
13 + FullAttentionSpec,
14 + KVCacheLayout,
15 SlidingWindowSpec,
16 )
17 from vllm.v1.worker import utils as worker_utils
@@ -19,12 +22,6 @@ from vllm.v1.worker.utils import (
22 )
23
24
22 -class _BlockFirstBackend:
23 - @staticmethod
24 - def get_kv_cache_block_dim(*args, **kwargs):
25 - return 0
26 -
27 -
25 @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
26 @pytest.mark.parametrize(
27 "spec",
@@ -52,11 +49,8 @@ def test_attention_blocks_are_zeroed(spec):
49 layer_name = "draft.self_attn"
50 zeroer = KVBlockZeroer(
51 device,
55 - attn_groups_iter=[
56 - AttentionGroup(_BlockFirstBackend, [layer_name], spec, 0) # type: ignore[arg-type]
57 - ],
52 + attn_groups_iter=[AttentionGroup(None, [layer_name], spec, 0)],
53 kernel_block_sizes=[2],
59 - cache_dtype="fp8",
54 static_forward_context={
55 layer_name: SimpleNamespace(kv_cache=storage),
56 },
@@ -205,16 +199,10 @@ def test_large_dsv4_launch_geometry(monkeypatch):
199 zeroer = KVBlockZeroer(
200 device,
201 attn_groups_iter=[
208 - AttentionGroup(
209 - _BlockFirstBackend, # type: ignore[arg-type]
210 - [name],
211 - spec,
212 - group_id,
213 - )
202 + AttentionGroup(None, [name], spec, group_id)
203 for group_id, name in enumerate(layer_names)
204 ],
205 kernel_block_sizes=[1] * n_segs,
217 - cache_dtype="auto",
206 static_forward_context={
207 name: SimpleNamespace(kv_cache=storage)
208 for name, storage in storages.items()
@@ -311,3 +299,46 @@ def test_warmup_respects_available_block_count():
299 torch.accelerator.synchronize()
300
301 assert torch.all(storage == 1)
302 +
303 +
304 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
305 +@pytest.mark.parametrize("layout", list(KVCacheLayout))
306 +def test_zeroes_exactly_one_block_per_layer(layout: KVCacheLayout):
307 + """The zeroer must zero every byte of the target block in every layer and nothing
308 + outside it — per head-group region under LHBNC, and never past the target block's
309 + tile under block-major layouts (no out-of-bounds writes, no clobbering)."""
310 + device = torch.device("cuda")
311 + num_blocks, num_layers = 4, 2
312 + spec = FullAttentionSpec(
313 + block_size=4, num_kv_heads=2, head_size=8, dtype=torch.float32
314 + )
315 + raw = torch.empty(
316 + num_blocks * num_layers * spec.page_size_bytes,
317 + dtype=torch.int8,
318 + device=device,
319 + ).fill_(1)
320 + views = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
321 + groups = [
322 + AttentionGroup(
323 + backend=None,
324 + layer_names=[f"layer.{i}" for i in range(num_layers)],
325 + kv_cache_spec=spec,
326 + kv_cache_group_id=0,
327 + )
328 + ]
329 + ctx = {f"layer.{i}": SimpleNamespace(kv_cache=views[i]) for i in range(num_layers)}
330 + zeroer = KVBlockZeroer(
331 + device,
332 + attn_groups_iter=iter(groups),
333 + kernel_block_sizes=[spec.block_size],
334 + static_forward_context=ctx,
335 + )
336 + zeroer.zero_block_ids([2])
337 + torch.accelerator.synchronize()
338 +
339 + for view in views:
340 + assert (view[2] == 0).all(), layout
341 + for b in (0, 1, 3):
342 + assert (view[b].view(torch.int8) == 1).all(), layout
343 + zero_bytes = int((raw == 0).sum().item())
344 + assert zero_bytes == num_layers * spec.page_size_bytes, layout
vllm/compilation/passes/fusion/mla_rope_kvcache_cat_fusion.py
+1 -1
@@ -45,7 +45,7 @@ def fused_rope_unified_mla_kv_cache_update_impl(
45 cos_sin_cache,
46 is_neox,
47 layer_slot_mapping,
48 - kv_cache,
48 + kv_cache.squeeze(1),
49 kv_cache_dtype,
50 kv_cache_scale,
51 )
vllm/config/cache.py
+40
@@ -3,6 +3,7 @@
3
4 from collections.abc import Callable
5 from dataclasses import field
6 +from functools import cache
7 from typing import Any, ClassVar, Literal
8
9 from pydantic import Field, field_validator, model_validator
@@ -13,9 +14,28 @@ from vllm.utils.torch_utils import (
14 is_quantized_kv_cache,
15 kv_cache_uses_per_token_head_scales,
16 )
17 +from vllm.v1.kv_cache_layout import KVCacheLayout
18
19 logger = init_logger(__name__)
20
21 +_LAYOUT_COMPAT_ALIASES = {
22 + "NHD": "LBNHC",
23 + "HND": "LBHNC",
24 +}
25 +
26 +
27 +@cache
28 +def _layout_from_name(layout_name: str) -> KVCacheLayout:
29 + layout_name = _LAYOUT_COMPAT_ALIASES.get(layout_name, layout_name)
30 + try:
31 + return KVCacheLayout[layout_name]
32 + except KeyError:
33 + raise ValueError(
34 + f"Unknown KV cache layout {layout_name!r}. "
35 + f"Valid layouts: {[m.name for m in KVCacheLayout]}"
36 + ) from None
37 +
38 +
39 CacheDType = Literal[
40 "auto",
41 "float16",
@@ -65,6 +85,17 @@ class CacheConfig:
85 """Whether block_size was explicitly provided. Derived automatically."""
86 user_specified_mamba_block_size: bool = field(default=False, init=False)
87 """Whether mamba_block_size was explicitly provided. Derived automatically."""
88 + kv_cache_layout: str | None = field(default=None, init=False)
89 + """Resolved physical KV cache layout name (a ``KVCacheLayout`` member).
90 +
91 + ``None`` means the layout has not been resolved yet. The engine core
92 + resolves it once (``resolve_kv_cache_layout``) before memory profiling, and
93 + every worker process adopts the resolved name — via the
94 + ``set_kv_cache_layout`` RPC, or ``KVCacheConfig.kv_cache_layout`` for
95 + workers spawned after resolution — before the KV cache is allocated. Once
96 + set the value is final and read with ``get_resolved_kv_cache_layout``,
97 + which raises on ``None``. Tests and standalone tools may pre-set a value,
98 + which resolution then honors as-is."""
99 prefix_match_unit: int | None = Field(default=None, gt=0)
100 """The finest token boundary (in tokens) a prefix-cache hit can land on.
101
@@ -309,3 +340,12 @@ class CacheConfig:
340 str(cache_dtype),
341 )
342 return cache_dtype
343 +
344 + def get_resolved_kv_cache_layout(self) -> KVCacheLayout:
345 + if self.kv_cache_layout is None:
346 + raise ValueError(
347 + "KV cache layout has not been resolved yet; it is resolved once "
348 + "by the engine core (resolve_kv_cache_layout) unless explicitly "
349 + "set by the user."
350 + )
351 + return _layout_from_name(self.kv_cache_layout)
vllm/distributed/kv_transfer/kv_connector/utils.py
+11 -71
@@ -12,7 +12,7 @@ import torch
12
13 from vllm.config import (
14 VllmConfig,
15 - get_current_vllm_config,
15 + get_current_vllm_config_or_none,
16 get_layers_from_vllm_config,
17 set_current_vllm_config,
18 )
@@ -35,20 +35,20 @@ EngineId = str
35 BlockIds = tuple[list[int], ...] | list[list[int]]
36
37
38 -def get_kv_connector_cache_layout():
39 - # NOTE (NickLucche) When running disaggregated PD with NIXL, HND layout is
38 +def get_kv_connector_cache_layout(vllm_config: VllmConfig | None = None):
39 + # NOTE (NickLucche) When running disaggregated PD with NIXL, LBHNC layout is
40 # used for faster transfer.
41 - vllm_config = get_current_vllm_config()
41 + if vllm_config is None:
42 + vllm_config = get_current_vllm_config_or_none()
43 + if vllm_config is None:
44 + return None
45 kv_config = vllm_config.kv_transfer_config
46 if kv_config is not None:
47 connector_cls = KVConnectorFactory.get_connector_class(kv_config)
48 required_kvcache_layout = connector_cls.get_required_kvcache_layout(vllm_config)
49 if required_kvcache_layout is not None:
50 return required_kvcache_layout
48 - logger.info_once(
49 - "Connectors do not specify a kv cache layout, defaulting to NHD."
50 - )
51 - return "NHD"
51 + return None
52
53
54 class KVOutputAggregator:
@@ -288,11 +288,11 @@ def kv_postprocess_layout_on_receive(cache, indices):
288
289 def kv_postprocess_blksize_and_layout_on_receive(cache, indices, block_size_ratio):
290 """
291 - Transforms the layout of received KV cache to the local block_size and HND.
291 + Transforms the layout of received KV cache to the local block_size and LBHNC.
292 (Only works for local blocksize > remote blocksize)
293
294 - prefill is HND, smaller block_size
295 - decode(local) is NHD, larger block_size
294 + prefill is LBHNC, smaller block_size
295 + decode(local) is LBNHC, larger block_size
296 """
297 blocks_to_update = cache.index_select(0, indices)
298
@@ -427,48 +427,6 @@ class TransferTopology:
427
428 self._engines: dict[tuple[EngineId, int], EngineTransferInfo] = {}
429
430 - # Figure out whether the first dimension of the cache is K/V
431 - # or num_blocks.
432 - attn_backend = self.attn_backends[0]
433 - if not self.is_mamba:
434 - _MOCK_BLOCK_SIZE = 16
435 - kv_cache_shape: tuple[int, ...] = attn_backend.get_kv_cache_shape(
436 - num_blocks=1,
437 - block_size=_MOCK_BLOCK_SIZE,
438 - num_kv_heads=1,
439 - head_size=1,
440 - )
441 - logger.debug("Test kv_cache_shape: %s", kv_cache_shape)
442 - assert kv_cache_shape[0] == 1, (
443 - "KV cache layout must be blocks-first; expected mocked "
444 - f"num_blocks=1 in leading dim, got shape {kv_cache_shape}."
445 - )
446 - if not self.is_mla:
447 - assert len(kv_cache_shape) == 4, (
448 - "Attention KV cache layout must be standardized as "
449 - "[num_blocks, num_kv_heads, block_size, content_size], "
450 - f"got shape {kv_cache_shape}."
451 - )
452 -
453 - self._cross_layers_blocks = False
454 - if self.tensor_shape is not None:
455 - self._cross_layers_blocks = (
456 - len(self.tensor_shape) == len(kv_cache_shape) + 1
457 - )
458 -
459 - if self._cross_layers_blocks:
460 - logger.debug("Using cross-layer KV cache")
461 - _MOCK_NUM_LAYERS = 80
462 - kv_cache_shape = (_MOCK_NUM_LAYERS,) + kv_cache_shape
463 - try:
464 - kv_cache_stride_order = attn_backend.get_kv_cache_stride_order(
465 - include_num_layers_dimension=self._cross_layers_blocks
466 - )
467 - except (AttributeError, NotImplementedError):
468 - assert self.tensor_shape is not None
469 - kv_cache_stride_order = tuple(range(len(self.tensor_shape)))
470 - kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
471 -
430 # ============================================================
431 # Engine registration
432 # ============================================================
@@ -503,24 +461,6 @@ class TransferTopology:
461 for key in [k for k in self._engines if k[0] == remote_engine_id]:
462 del self._engines[key]
463
506 - # ============================================================
507 - # Layout properties
508 - # ============================================================
509 -
510 - @property
511 - def cross_layers_blocks(self) -> bool:
512 - return self._cross_layers_blocks
513 -
514 - @property
515 - def virtually_split_kv_in_blocks(self) -> bool:
516 - # Whether to logically split each block into two separately-indexable
517 - # sub-regions. With K and V packed into the content dim, an attention
518 - # block transfers as a single unit — no K/V sub-split is needed. Only
519 - # Mamba still needs this, to index its two state regions (conv/ssm)
520 - # separately. Not applicable to cross-layer blocks (per-layer
521 - # interleaving means a simple half-split does not separate the parts).
522 - return self.is_mamba and not self._cross_layers_blocks
523 -
464 # ============================================================
465 # Common methods
466 # ============================================================
vllm/distributed/kv_transfer/kv_connector/v1/base.py
+1 -26
@@ -48,7 +48,7 @@ from typing import TYPE_CHECKING, Any, Literal
48 import torch
49
50 from vllm.logger import init_logger
51 -from vllm.v1.attention.backend import AttentionBackend, AttentionMetadata
51 +from vllm.v1.attention.backend import AttentionMetadata
52 from vllm.v1.core.sched.output import SchedulerOutput
53 from vllm.v1.outputs import KVConnectorOutput
54
@@ -182,14 +182,6 @@ class KVConnectorBase_V1(ABC):
182 """
183 return False
184
185 - @property
186 - def prefer_cross_layer_blocks(self) -> bool:
187 - """
188 - Indicates whether this connector prefers KV blocks that hold KV data for all
189 - layers, which can speed up KV data transfers. Defaults to False.
190 - """
191 - return False
192 -
185 @property
186 def requires_kv_delivery(self) -> bool:
187 """Whether this connector hands off KV that must be reliably delivered.
@@ -279,23 +271,6 @@ class KVConnectorBase_V1(ABC):
271 """
272 return
273
282 - def register_cross_layers_kv_cache(
283 - self, kv_cache: torch.Tensor, attn_backend: type["AttentionBackend"]
284 - ):
285 - """
286 - Initialize with a single KV cache tensor used by all layers.
287 - The first dimension should be num_layers.
288 - This function will only be called for models with uniform layers,
289 - and only if the prefers_cross_layer_blocks is set to True.
290 - Only one of the functions
291 - {register_kv_caches, register_cross_layers_kv_cache} will be called.
292 -
293 - Args:
294 - kv_cache: a cross-layers kv cache tensor
295 - attn_backend: The attention backend that corresponds to all layers
296 - """
297 - return
298 -
274 def set_host_xfer_buffer_ops(self, copy_operation: CopyBlocksOp):
275 """
276 Set the xPU-specific ops for copying KV between host and device.
vllm/distributed/kv_transfer/kv_connector/v1/example_connector.py
+7 -11
@@ -129,20 +129,17 @@ class ExampleConnector(KVConnectorBase_V1):
129 """Inject the KV cache into the layer.
130
131 Args:
132 - dst_kv_cache_layer (torch.Tensor): the destination KV cache
133 - layer. In shape [num_pages, page_size, xxx] for MLA,
134 - [num_pages, 2, page_size, xxx] otherwise.
132 + dst_kv_cache_layer (torch.Tensor): the destination KV cache layer,
133 + a standardized [B, H, N, C] per-layer view (H == 1 for MLA).
134 src_kv_cache (torch.Tensor): the source KV cache.
135 slot_mapping (torch.Tensor): the slot mapping. In shape
136 [num_tokens].
137 """
138 slot_mapping = slot_mapping.to(dst_kv_cache_layer.device, non_blocking=True)
139 if isinstance(attn_metadata, MLACommonMetadata):
141 - dst_kv_cache_layer_shape = dst_kv_cache_layer.shape
142 - num_pages = dst_kv_cache_layer_shape[0]
143 - page_size = dst_kv_cache_layer_shape[1]
140 + # [B, 1, N, C] -> [B * N, C]; slot_mapping indexes B * N slots.
141 dst_kv_cache_layer = dst_kv_cache_layer.reshape(
145 - num_pages * page_size, -1
142 + -1, dst_kv_cache_layer.shape[-1]
143 )
144 dst_kv_cache_layer[slot_mapping, ...] = src_kv_cache
145 else:
@@ -225,13 +222,12 @@ class ExampleConnector(KVConnectorBase_V1):
222 ) -> torch.Tensor:
223 """Extract the KV cache from the layer.
224
228 - Assume the shape of the layer is (num_pages, page_size, xxx)
229 - for MLA, and (num_pages, 2, page_size, xxx) otherwise.
225 + The layer is a standardized [B, H, N, C] per-layer view (H == 1 for MLA).
226 """
227 slot_mapping = slot_mapping.to(layer.device, non_blocking=True)
228 if isinstance(attn_metadata, MLACommonMetadata):
233 - num_pages, page_size = layer.shape[0], layer.shape[1]
234 - return layer.reshape(num_pages * page_size, -1)[slot_mapping, ...]
229 + # [B, 1, N, C] -> [B * N, C]; slot_mapping indexes B * N slots.
230 + return layer.reshape(-1, layer.shape[-1])[slot_mapping, ...]
231 block_idxs = slot_mapping // self._block_size
232 offsets = slot_mapping % self._block_size
233 return layer[block_idxs, :, offsets]
vllm/distributed/kv_transfer/kv_connector/v1/example_hidden_states_connector.py
+13 -18
@@ -38,8 +38,10 @@ def extract_from_kv_cache(
38 num_tokens: int,
39 ) -> torch.Tensor:
40 """Extract data from KV cache."""
41 - block_size = kv_cache.shape[1]
42 - return kv_cache[slot_mapping // block_size, slot_mapping % block_size][:num_tokens]
41 + block_size = kv_cache.shape[2]
42 + return kv_cache[slot_mapping // block_size, :, slot_mapping % block_size][
43 + :num_tokens
44 + ]
45
46
47 def load_hidden_states(path: str) -> dict[str, torch.Tensor]:
@@ -100,15 +102,6 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
102 Must be used in conjunction with the `extract_hidden_states` spec decoding method.
103 """
104
103 - @property
104 - def prefer_cross_layer_blocks(self) -> bool:
105 - """
106 - Indicates whether this connector prefers KV blocks that hold KV data for all
107 - layers, which can speed up KV data transfers. Defaults to False.
108 - """
109 - # Must be False so that drafter kv cache isn't merged with verifier's
110 - return False
111 -
105 @classmethod
106 def _find_cache_kv_group_id(cls, kv_cache_config: "KVCacheConfig | None") -> int:
107 """Index of the KV cache group holding the extracted hidden states.
@@ -310,10 +303,12 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
303
304 # Block size must match the indexed buffer, else reads hit the wrong
305 # slots. Raise (not assert) so the check survives `python -O`.
313 - if self._block_size != self._kv_cache.shape[1]:
306 + # Views are [num_blocks, num_heads, block_size, head_size], matching what
307 + # extract_from_kv_cache() indexes.
308 + if self._block_size != self._kv_cache.shape[2]:
309 raise ValueError(
310 f"Hidden-states block-size mismatch: derived {self._block_size} "
316 - f"but buffer block size is {self._kv_cache.shape[1]}; read slots "
311 + f"but buffer block size is {self._kv_cache.shape[2]}; read slots "
312 "would be wrong (likely a hybrid block-size resolution bug)."
313 )
314
@@ -605,9 +600,9 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
600 "get_required_kvcache_layout should not be called "
601 "on the abstract base class"
602 )
608 - # NHD means we have (num_tokens, num_heads)
609 - # HND means we have (num_heads, num_tokens)
610 - # For now, we only support NHD layout since this keeps the
603 + # LBNHC means we have (num_tokens, num_heads)
604 + # LBHNC means we have (num_heads, num_tokens)
605 + # For now, we only support LBNHC layout since this keeps the
606 # hidden states for each token together in memory.
612 - # HND is primarily used when sharding heads across devices.
613 - return "NHD"
607 + # LBHNC is primarily used when sharding heads across devices.
608 + return "LBNHC"
vllm/distributed/kv_transfer/kv_connector/v1/hf3fs/hf3fs_connector.py
+31 -28
@@ -541,35 +541,32 @@ class HF3FSKVConnector(KVConnectorBase_V1):
541 self._dtype = first_cache.dtype
542 element_size = first_cache.element_size()
543
544 - if self._use_mla:
545 - assert len(first_cache.shape) == 3, "MLA format should have 3 dimensions"
546 - # MLA format: [num_blocks, block_size, head_size]
547 - num_blocks, block_size, head_size = first_cache.shape
548 - num_heads = 1
549 - else:
550 - # MHA format: [2, num_blocks, block_size, num_heads, head_size]
551 - _, num_blocks, block_size, num_heads, head_size = first_cache.shape
544 + # Standardized per-layer [B, H, N, C] view; MLA is just H == 1 with the
545 + # latent vector as the content dim.
546 + assert len(first_cache.shape) == 4, (
547 + f"expected a [B, H, N, C] KV cache view, got {tuple(first_cache.shape)}"
548 + )
549 + num_blocks, num_heads, block_size, content_dim = first_cache.shape
550 + cache_strides = first_cache.stride()
551 + assert all(
552 + cache.shape == first_cache.shape and cache.stride() == cache_strides
553 + for cache in self._kv_caches.values()
554 + ), "HF3FS requires uniform KV cache shapes and strides."
555
556 self._local_total_tokens = num_blocks * block_size
557 self._local_block_size = block_size
555 -
556 - if self._use_mla:
557 - layer_block_size = block_size * head_size * element_size
558 - self._bytes_per_page = layer_block_size * len(self._kv_caches)
559 - self._shape_per_page = [
560 - len(self._kv_caches),
561 - block_size,
562 - head_size,
563 - ]
564 - else:
565 - layer_block_size = 2 * block_size * num_heads * head_size * element_size
566 - self._bytes_per_page = layer_block_size * len(self._kv_caches)
567 - self._shape_per_page = [
568 - len(self._kv_caches),
569 - 2,
570 - block_size,
571 - num_heads * head_size,
572 - ]
558 + self._num_heads = num_heads
559 + self._content_dim = content_dim
560 + self._kv_cache_strides = cache_strides
561 +
562 + layer_block_size = num_heads * block_size * content_dim * element_size
563 + self._bytes_per_page = layer_block_size * len(self._kv_caches)
564 + self._shape_per_page = [
565 + len(self._kv_caches),
566 + num_heads,
567 + block_size,
568 + content_dim,
569 + ]
570
571 self._kvcache_ptrs = torch.tensor(
572 [cache.data_ptr() for cache in self._kv_caches.values()],
@@ -993,7 +990,10 @@ class HF3FSKVConnector(KVConnectorBase_V1):
990 self._local_total_tokens,
991 buffer_tensor,
992 token_indices,
996 - is_mla=self._use_mla,
993 + self._local_block_size,
994 + self._num_heads,
995 + self._content_dim,
996 + self._kv_cache_strides,
997 )
998 else:
999 gather_scatter_helper.scatter_kv_caches(
@@ -1001,7 +1001,10 @@ class HF3FSKVConnector(KVConnectorBase_V1):
1001 self._local_total_tokens,
1002 buffer_tensor,
1003 token_indices,
1004 - is_mla=self._use_mla,
1004 + self._local_block_size,
1005 + self._num_heads,
1006 + self._content_dim,
1007 + self._kv_cache_strides,
1008 )
1009
1010 def _compute_prefix_hash(
vllm/distributed/kv_transfer/kv_connector/v1/hf3fs/utils/gather_scatter_helper.py
+92 -121
@@ -12,10 +12,15 @@ def kv_cache_scatter_kernel(
12 source_ptr,
13 token_indices_ptr,
14 num_tokens_in_block,
15 - hidden_size,
15 total_token_in_kvcache,
16 num_layers,
18 - is_mla,
17 + tokens_per_block,
18 + block_stride,
19 + head_stride,
20 + token_stride,
21 + content_stride,
22 + num_heads: tl.constexpr,
23 + content_size: tl.constexpr,
24 BLOCK_SIZE: tl.constexpr,
25 ):
26 layer_idx = tl.program_id(0)
@@ -30,39 +35,25 @@ def kv_cache_scatter_kernel(
35 if token_idx >= total_token_in_kvcache:
36 return
37
33 - if is_mla:
34 - # MLA format: source [num_layers, num_tokens_in_block, hidden_size]
35 - # MLA format: target [total_token_in_kvcache, hidden_size] (per layer)
36 - source_offset = (layer_idx * num_tokens_in_block + token_pos) * hidden_size
37 - target_offset = token_idx * hidden_size
38 -
39 - for i in range(0, hidden_size, BLOCK_SIZE):
40 - offset = i + tl.arange(0, BLOCK_SIZE)
41 - mask = offset < hidden_size
42 - val = tl.load(source_ptr + source_offset + offset, mask=mask)
43 - tl.store(kv_cache_ptr + target_offset + offset, val, mask=mask)
44 - else:
45 - # MHA format: source [num_layers, 2, num_tokens_in_block, hidden_size]
46 - # MHA format: target [2, total_token_in_kvcache, hidden_size]
47 - source_offset_k = (
48 - layer_idx * num_tokens_in_block * 2 + token_pos
49 - ) * hidden_size
50 - source_offset_v = (
51 - layer_idx * num_tokens_in_block * 2 + num_tokens_in_block + token_pos
52 - ) * hidden_size
53 -
54 - target_offset_k = token_idx * hidden_size
55 - target_offset_v = (total_token_in_kvcache + token_idx) * hidden_size
56 -
57 - for i in range(0, hidden_size, BLOCK_SIZE):
58 - offset = i + tl.arange(0, BLOCK_SIZE)
59 - mask = offset < hidden_size
60 -
61 - val_k = tl.load(source_ptr + source_offset_k + offset, mask=mask)
62 - val_v = tl.load(source_ptr + source_offset_v + offset, mask=mask)
63 -
64 - tl.store(kv_cache_ptr + target_offset_k + offset, val_k, mask=mask)
65 - tl.store(kv_cache_ptr + target_offset_v + offset, val_v, mask=mask)
38 + block_idx = token_idx // tokens_per_block
39 + state_idx = token_idx % tokens_per_block
40 + hidden_size = num_heads * content_size
41 + for i in range(0, hidden_size, BLOCK_SIZE):
42 + offset = i + tl.arange(0, BLOCK_SIZE)
43 + mask = offset < hidden_size
44 + head_idx = offset // content_size
45 + content_idx = offset % content_size
46 + source_offset = (
47 + (layer_idx * num_heads + head_idx) * num_tokens_in_block + token_pos
48 + ) * content_size + content_idx
49 + target_offset = (
50 + block_idx * block_stride
51 + + head_idx * head_stride
52 + + state_idx * token_stride
53 + + content_idx * content_stride
54 + )
55 + value = tl.load(source_ptr + source_offset, mask=mask)
56 + tl.store(kv_cache_ptr + target_offset, value, mask=mask)
57
58
59 @triton.jit
@@ -71,10 +62,15 @@ def kv_cache_gather_kernel(
62 dst_ptr,
63 token_indices_ptr,
64 num_tokens_in_block,
74 - hidden_size,
65 total_token_in_kvcache,
66 num_layers,
77 - is_mla,
67 + tokens_per_block,
68 + block_stride,
69 + head_stride,
70 + token_stride,
71 + content_stride,
72 + num_heads: tl.constexpr,
73 + content_size: tl.constexpr,
74 BLOCK_SIZE: tl.constexpr,
75 ):
76 layer_idx = tl.program_id(0)
@@ -89,37 +85,25 @@ def kv_cache_gather_kernel(
85 if token_idx >= total_token_in_kvcache:
86 return
87
92 - if is_mla:
93 - # MLA format: source [total_token_in_kvcache, hidden_size] (per layer)
94 - # MLA format: dst [num_layers, num_tokens_in_block, hidden_size]
95 - kvcache_offset = token_idx * hidden_size
96 - dst_offset = (layer_idx * num_tokens_in_block + token_pos) * hidden_size
97 -
98 - for i in range(0, hidden_size, BLOCK_SIZE):
99 - offset = i + tl.arange(0, BLOCK_SIZE)
100 - mask = offset < hidden_size
101 - val = tl.load(kv_cache_ptr + kvcache_offset + offset, mask=mask)
102 - tl.store(dst_ptr + dst_offset + offset, val, mask=mask)
103 - else:
104 - # MHA format: source [2, total_token_in_kvcache, hidden_size]
105 - # MHA format: dst [num_layers, 2, num_tokens_in_block, hidden_size]
106 - dst_offset_k = (layer_idx * num_tokens_in_block * 2 + token_pos) * hidden_size
107 - dst_offset_v = (
108 - layer_idx * num_tokens_in_block * 2 + num_tokens_in_block + token_pos
109 - ) * hidden_size
110 -
111 - kvcache_offset_k = token_idx * hidden_size
112 - kvcache_offset_v = (total_token_in_kvcache + token_idx) * hidden_size
113 -
114 - for i in range(0, hidden_size, BLOCK_SIZE):
115 - offset = i + tl.arange(0, BLOCK_SIZE)
116 - mask = offset < hidden_size
117 -
118 - val_k = tl.load(kv_cache_ptr + kvcache_offset_k + offset, mask=mask)
119 - val_v = tl.load(kv_cache_ptr + kvcache_offset_v + offset, mask=mask)
120 -
121 - tl.store(dst_ptr + dst_offset_k + offset, val_k, mask=mask)
122 - tl.store(dst_ptr + dst_offset_v + offset, val_v, mask=mask)
88 + block_idx = token_idx // tokens_per_block
89 + state_idx = token_idx % tokens_per_block
90 + hidden_size = num_heads * content_size
91 + for i in range(0, hidden_size, BLOCK_SIZE):
92 + offset = i + tl.arange(0, BLOCK_SIZE)
93 + mask = offset < hidden_size
94 + head_idx = offset // content_size
95 + content_idx = offset % content_size
96 + kvcache_offset = (
97 + block_idx * block_stride
98 + + head_idx * head_stride
99 + + state_idx * token_stride
100 + + content_idx * content_stride
101 + )
102 + dst_offset = (
103 + (layer_idx * num_heads + head_idx) * num_tokens_in_block + token_pos
104 + ) * content_size + content_idx
105 + value = tl.load(kv_cache_ptr + kvcache_offset, mask=mask)
106 + tl.store(dst_ptr + dst_offset, value, mask=mask)
107
108
109 def scatter_kv_caches(
@@ -127,34 +111,33 @@ def scatter_kv_caches(
111 total_token_in_kvcache: int,
112 src_tensor: torch.Tensor,
113 token_indices: list[int],
130 - is_mla: bool = False,
114 + tokens_per_block: int,
115 + num_heads: int,
116 + content_size: int,
117 + kv_cache_strides: tuple[int, ...],
118 ) -> None:
119 """Scatter KV cache data from source tensor to KV cache storage.
120
121 Args:
122 kv_caches_ptrs: Tensor of KV cache pointers (one per layer)
123 total_token_in_kvcache: Total number of tokens in KV cache
137 - src_tensor: Source tensor containing data to scatter
138 - - MHA format: [num_layers, 2, num_tokens_in_block, hidden_size]
139 - - MLA format: [num_layers, num_tokens_in_block, hidden_size]
124 + src_tensor: Source ``[L, H, N, C]`` tensor containing data to scatter
125 token_indices: List of token positions to update
141 - is_mla: Whether using MLA model format
126 + tokens_per_block: Number of stored states in each cache block
127 + num_heads: Size of the H axis
128 + content_size: Size of the C axis
129 + kv_cache_strides: Element strides of each ``[B, H, N, C]`` layer view
130 """
131 num_layers = len(kv_caches_ptrs)
132 num_tokens_in_block = len(token_indices)
133
146 - if is_mla:
147 - # MLA: src_tensor is [num_layers, num_tokens_in_block, hidden_size]
148 - assert len(src_tensor.shape) == 3, (
149 - f"MLA src_tensor should be 3D, got {src_tensor.shape}"
150 - )
151 - hidden_size = src_tensor.shape[2]
152 - else:
153 - # MHA: src_tensor is [num_layers, 2, num_tokens_in_block, hidden_size]
154 - assert len(src_tensor.shape) == 4, (
155 - f"MHA src_tensor should be 4D, got {src_tensor.shape}"
156 - )
157 - hidden_size = src_tensor.shape[3]
134 + assert src_tensor.shape == (
135 + num_layers,
136 + num_heads,
137 + num_tokens_in_block,
138 + content_size,
139 + )
140 + assert len(kv_cache_strides) == 4
141
142 device = src_tensor.device
143 token_indices_tensor = torch.tensor(
@@ -169,10 +152,12 @@ def scatter_kv_caches(
152 src_tensor,
153 token_indices_tensor,
154 num_tokens_in_block,
172 - hidden_size,
155 total_token_in_kvcache,
156 num_layers,
175 - is_mla,
157 + tokens_per_block,
158 + *kv_cache_strides,
159 + num_heads=num_heads,
160 + content_size=content_size,
161 BLOCK_SIZE=BLOCK_SIZE,
162 )
163
@@ -182,49 +167,33 @@ def gather_kv_caches(
167 total_token_in_kvcache: int,
168 dst_tensor: torch.Tensor,
169 token_indices: list[int],
185 - is_mla: bool = False,
170 + tokens_per_block: int,
171 + num_heads: int,
172 + content_size: int,
173 + kv_cache_strides: tuple[int, ...],
174 ) -> None:
175 """Gather KV cache data from KV cache storage to destination tensor.
176
177 Args:
178 kv_caches_ptrs: Tensor of KV cache pointers (one per layer)
179 total_token_in_kvcache: Total number of tokens in KV cache
192 - dst_tensor: Destination tensor to store gathered data
193 - - MHA format: [num_layers, 2, num_tokens_in_block, hidden_size]
194 - - MLA format: [num_layers, num_tokens_in_block, hidden_size]
180 + dst_tensor: Destination ``[L, H, N, C]`` tensor
181 token_indices: List of token positions to gather
196 - is_mla: Whether using MLA model format
182 + tokens_per_block: Number of stored states in each cache block
183 + num_heads: Size of the H axis
184 + content_size: Size of the C axis
185 + kv_cache_strides: Element strides of each ``[B, H, N, C]`` layer view
186 """
187 num_layers = kv_caches_ptrs.shape[0]
188 num_tokens_in_block = len(token_indices)
189
201 - if is_mla:
202 - # MLA: dst_tensor is [num_layers, num_tokens_in_block, hidden_size]
203 - assert len(dst_tensor.shape) == 3, (
204 - f"MLA dst_tensor should be 3D, got {dst_tensor.shape}"
205 - )
206 - assert dst_tensor.shape[0] == num_layers, (
207 - f"Layer count mismatch: {dst_tensor.shape[0]} vs {num_layers}"
208 - )
209 - assert dst_tensor.shape[1] == num_tokens_in_block, (
210 - f"Token count mismatch: {dst_tensor.shape[1]} vs {num_tokens_in_block}"
211 - )
212 - hidden_size = dst_tensor.shape[2]
213 - else:
214 - # MHA: dst_tensor is [num_layers, 2, num_tokens_in_block, hidden_size]
215 - assert len(dst_tensor.shape) == 4, (
216 - f"MHA dst_tensor should be 4D, got {dst_tensor.shape}"
217 - )
218 - assert dst_tensor.shape[0] == num_layers, (
219 - f"Layer count mismatch: {dst_tensor.shape[0]} vs {num_layers}"
220 - )
221 - assert dst_tensor.shape[1] == 2, (
222 - f"MHA should have 2 (K,V) components, got {dst_tensor.shape[1]}"
223 - )
224 - assert dst_tensor.shape[2] == num_tokens_in_block, (
225 - f"Token count mismatch: {dst_tensor.shape[2]} vs {num_tokens_in_block}"
226 - )
227 - hidden_size = dst_tensor.shape[3]
190 + assert dst_tensor.shape == (
191 + num_layers,
192 + num_heads,
193 + num_tokens_in_block,
194 + content_size,
195 + )
196 + assert len(kv_cache_strides) == 4
197
198 device = dst_tensor.device
199 token_indices_tensor = torch.tensor(
@@ -239,10 +208,12 @@ def gather_kv_caches(
208 dst_tensor,
209 token_indices_tensor,
210 num_tokens_in_block,
242 - hidden_size,
211 total_token_in_kvcache,
212 num_layers,
245 - is_mla,
213 + tokens_per_block,
214 + *kv_cache_strides,
215 + num_heads=num_heads,
216 + content_size=content_size,
217 BLOCK_SIZE=BLOCK_SIZE,
218 )
219
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py
+32 -71
@@ -49,15 +49,15 @@ from vllm.model_executor.models.utils import extract_layer_index
49 from vllm.platforms import current_platform
50 from vllm.utils.math_utils import cdiv
51 from vllm.utils.network_utils import get_ip, make_zmq_path, make_zmq_socket
52 +from vllm.utils.torch_utils import is_non_overlapping_and_dense
53 from vllm.v1.attention.backend import AttentionMetadata
53 -from vllm.v1.attention.backends.utils import NULL_BLOCK_ID, get_kv_cache_layout
54 +from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
55 from vllm.v1.core.sched.output import SchedulerOutput
56 from vllm.v1.kv_cache_interface import (
57 + AttentionSpec,
58 FullAttentionSpec,
59 KVCacheSpec,
60 MambaSpec,
59 - MLAAttentionSpec,
60 - SlidingWindowMLASpec,
61 SlidingWindowSpec,
62 )
63 from vllm.v1.request import RequestStatus
@@ -122,9 +122,7 @@ def _expand_transfer_regions(
122 kv_block_lens: list[int],
123 layer_names: list[str],
124 layer_indices: list[int],
125 - is_kv_layout_blocks_first: bool,
125 group_indices: list[int] | None = None,
127 - split_kv_regions: list[bool] | None = None,
126 ) -> list[TransferRegion]:
127 """Expand registered KV tensors into the regions transferred by Mooncake."""
128 assert (
@@ -146,13 +144,6 @@ def _expand_transfer_regions(
144 "Mooncake transfer regions require matching group metadata lengths, "
145 f"got group_indices={len(group_indices)}, layer_names={len(layer_names)}."
146 )
149 - if split_kv_regions is None:
150 - split_kv_regions = [is_kv_layout_blocks_first] * len(layer_names)
151 - assert len(split_kv_regions) == len(layer_names), (
152 - "Mooncake transfer regions require matching split metadata, "
153 - f"got split_kv_regions={len(split_kv_regions)}, "
154 - f"layer_names={len(layer_names)}."
155 - )
147 regions: list[TransferRegion] = []
148 for (
149 base_addr,
@@ -161,7 +152,6 @@ def _expand_transfer_regions(
152 layer_name,
153 layer_index,
154 group_index,
164 - split_kv_region,
155 ) in zip(
156 base_addrs,
157 block_lens,
@@ -169,7 +159,6 @@ def _expand_transfer_regions(
159 layer_names,
160 layer_indices,
161 group_indices,
172 - split_kv_regions,
162 ):
163 regions.append(
164 TransferRegion(
@@ -181,17 +170,6 @@ def _expand_transfer_regions(
170 group_index=group_index,
171 )
172 )
184 - if split_kv_region:
185 - regions.append(
186 - TransferRegion(
187 - layer_name=layer_name,
188 - layer_index=layer_index,
189 - base_addr=base_addr + kv_block_len,
190 - block_len=block_len,
191 - kv_block_len=kv_block_len,
192 - group_index=group_index,
193 - )
194 - )
173 return regions
174
175
@@ -506,10 +484,10 @@ class MooncakeConnector(KVConnectorBase_V1, SupportsHMA):
484 if vllm_config.model_config.use_mla:
485 return None
486 logger.info_once(
509 - "MooncakeConnector setting KV cache layout to HND for "
487 + "MooncakeConnector setting KV cache layout to LBHNC for "
488 "heterogeneous TP-safe KV transfer."
489 )
512 - return "HND"
490 + return "LBHNC"
491
492 ############################################################
493 # Scheduler Side Methods
@@ -1023,12 +1001,10 @@ class MooncakeConnectorWorker:
1001 self._sync_block_size_with_kernel()
1002
1003 self.attn_backends = get_current_attn_backends(vllm_config)
1026 - self.kv_cache_layout = get_kv_cache_layout()
1004 logger.debug(
1005 "Detected attention backends %s",
1006 [backend.get_name() for backend in self.attn_backends],
1007 )
1031 - logger.debug("Detected kv cache layout %s", self.kv_cache_layout)
1008
1009 self._tp_size: dict[EngineId, int] = {self.engine_id: self.tp_size}
1010 self._layer_specs: dict[str, KVCacheSpec] = {}
@@ -1665,7 +1641,7 @@ class MooncakeConnectorWorker:
1641 self.registered_layer_indices = []
1642 self.registered_group_indices = []
1643
1668 - for layer_name, cache_or_caches in kv_caches.items():
1644 + for layer_name, cache in kv_caches.items():
1645 layer_index = extract_layer_index(layer_name)
1646 layer_spec = self._layer_specs.get(layer_name)
1647 if layer_spec is None:
@@ -1674,34 +1650,30 @@ class MooncakeConnectorWorker:
1650 layer_name,
1651 )
1652 continue
1677 - if isinstance(layer_spec, MambaSpec):
1678 - conv, _ = cache_or_caches
1679 - cache_list = [conv]
1653 + # One raw page tensor per layer; for Mamba that page holds all the
1654 + # recurrent states, unpacked only when binding the cache for execution.
1655 + self._log_debug_cache_registration(layer_name, cache)
1656 + block_is_contiguous = is_non_overlapping_and_dense(cache[0])
1657 + if not block_is_contiguous:
1658 + # Non-block-compact layouts scatter a block across per-head
1659 + # regions; each region's blocks are contiguous.
1660 + region_caches = [cache[:, head] for head in range(cache.shape[1])]
1661 + assert all(
1662 + is_non_overlapping_and_dense(region[0]) for region in region_caches
1663 + )
1664 else:
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)",
1687 - layer_name,
1688 - len(cache_list),
1689 - )
1665 + region_caches = [cache]
1666
1691 - for cache in cache_list:
1692 - self._log_debug_cache_registration(layer_name, cache)
1693 - base_addr = cache.data_ptr()
1694 - block_len = cache.stride(0) * cache.element_size()
1667 + for region_cache in region_caches:
1668 + base_addr = region_cache.data_ptr()
1669 + block_len = region_cache.stride(0) * region_cache.element_size()
1670 region_base_addresses.append(base_addr)
1671
1697 - if isinstance(layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)):
1698 - kv_block_len = layer_spec.page_size_bytes
1699 - elif self.transfer_topo.virtually_split_kv_in_blocks and not isinstance(
1700 - layer_spec, MambaSpec
1701 - ):
1702 - kv_block_len = block_len // 2
1703 - else:
1704 - kv_block_len = block_len
1672 + kv_block_len = (
1673 + layer_spec.page_size_bytes
1674 + if isinstance(layer_spec, AttentionSpec) and block_is_contiguous
1675 + else block_len
1676 + )
1677 self.block_len_per_layer.append(block_len)
1678 self.kv_block_len_per_layer.append(kv_block_len)
1679 self.registered_layer_names.append(layer_name)
@@ -1709,12 +1681,12 @@ class MooncakeConnectorWorker:
1681 self.registered_group_indices.append(
1682 self._layer_group_indices[layer_name]
1683 )
1712 - storage = cache.untyped_storage()
1713 - storage_addr = storage.data_ptr()
1714 - if storage_addr not in seen_storage_ptrs:
1715 - seen_storage_ptrs.add(storage_addr)
1716 - kv_data_ptrs.append(storage_addr)
1717 - kv_data_lens.append(storage.nbytes())
1684 + storage = cache.untyped_storage()
1685 + storage_addr = storage.data_ptr()
1686 + if storage_addr not in seen_storage_ptrs:
1687 + seen_storage_ptrs.add(storage_addr)
1688 + kv_data_ptrs.append(storage_addr)
1689 + kv_data_lens.append(storage.nbytes())
1690
1691 self.kv_caches_base_addr = region_base_addresses
1692 self.seen_base_addresses = kv_data_ptrs
@@ -2055,24 +2027,13 @@ class MooncakeConnectorWorker:
2027 self._layer_group_indices.get(layer_name, 0)
2028 for layer_name in layer_names
2029 ]
2058 - split_kv_regions = None
2059 - if self.transfer_topo.virtually_split_kv_in_blocks:
2060 - split_kv_regions = [
2061 - not isinstance(
2062 - self._layer_specs[layer_name],
2063 - (MambaSpec, MLAAttentionSpec, SlidingWindowMLASpec),
2064 - )
2065 - for layer_name in layer_names
2066 - ]
2030 return _expand_transfer_regions(
2031 base_addrs=base_addrs,
2032 block_lens=block_lens,
2033 kv_block_lens=kv_block_lens,
2034 layer_names=layer_names,
2035 layer_indices=layer_indices,
2073 - is_kv_layout_blocks_first=self.transfer_topo.virtually_split_kv_in_blocks,
2036 group_indices=group_indices,
2075 - split_kv_regions=split_kv_regions,
2037 )
2038
2039 def _get_sender_transfer_plan(
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/connector.py
-18
@@ -89,14 +89,6 @@ class MooncakeStoreKVEvents(KVConnectorKVEvents):
89 class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
90 """KV connector using MooncakeDistributedStore as shared KV pool."""
91
92 - @property
93 - def prefer_cross_layer_blocks(self) -> bool:
94 - extra_config = self._kv_transfer_config.kv_connector_extra_config
95 - return (
96 - str(extra_config.get("enable_cross_layers_blocks", "False")).lower()
97 - == "true"
98 - )
99 -
92 @staticmethod
93 def _validate_kv_cache_config(
94 vllm_config: VllmConfig, kv_cache_config: KVCacheConfig
@@ -290,16 +282,6 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
282 assert self.connector_worker is not None
283 self.connector_worker.register_kv_caches(kv_caches)
284
293 - def register_cross_layers_kv_cache(
294 - self, kv_cache: torch.Tensor, attn_backend: type
295 - ):
296 - assert self.connector_worker is not None
297 - assert (
298 - self._kv_cache_config is not None
299 - and len(self._kv_cache_config.kv_cache_groups) == 1
300 - ), "Cross-layer KV cache does not supported with hybrid models"
301 - self.connector_worker.register_cross_layers_kv_caches(kv_cache)
302 -
285 def start_load_kv(self, forward_context: ForwardContext, **kwargs: Any) -> None:
286 # No-op: loads are issued in get_finished() for compute overlap.
287 pass
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+46 -49
@@ -58,6 +58,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.protocol import
58 from vllm.logger import init_logger
59 from vllm.utils.math_utils import cdiv
60 from vllm.utils.network_utils import get_ip, make_zmq_socket
61 +from vllm.utils.torch_utils import is_non_overlapping_and_dense
62 from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
63 from vllm.v1.core.kv_cache_utils import (
64 BlockHash,
@@ -72,6 +73,7 @@ from vllm.v1.kv_cache_interface import (
73 MLAAttentionSpec,
74 SlidingWindowMLASpec,
75 UniformTypeKVCacheSpecs,
76 + group_kernel_blocks,
77 )
78
79 from .metrics import MooncakeStoreConnectorStats
@@ -1617,18 +1619,9 @@ class MooncakeStoreWorker:
1619 for g_idx, db in enumerate(self.token_dbs)
1620 )
1621
1620 - def register_cross_layers_kv_caches(self, kv_cache: torch.Tensor) -> None:
1621 - """Register a cross-layers KV cache tensor.
1622 -
1623 - Wraps the unified tensor in a single-entry dict so that the
1624 - existing stride-based logic in register_kv_caches() produces
1625 - the correct single-segment result (block_len = page_size * num_layers).
1626 - """
1627 - self.register_kv_caches({"__cross_layer__": kv_cache})
1628 -
1622 def register_kv_caches(
1623 self,
1631 - kv_caches: dict[str, torch.Tensor | list[torch.Tensor]],
1624 + kv_caches: dict[str, torch.Tensor],
1625 ) -> None:
1626 """Register KV cache tensors and start transfer threads."""
1627 if self._capacity_only:
@@ -1637,58 +1630,62 @@ class MooncakeStoreWorker:
1630 logger.warning("No KV caches to offload.")
1631 return
1632
1640 - # Resolve each entry to a representative tensor for storage
1641 - # deduplication. For attention layers the value is already a tensor;
1642 - # for Mamba layers it is a list of tensors that all share the same
1643 - # underlying raw storage, so we take the first one.
1644 - def _repr_tensor(v: torch.Tensor | list[torch.Tensor]) -> torch.Tensor:
1645 - assert isinstance(v, torch.Tensor | list)
1646 - return v if isinstance(v, torch.Tensor) else v[0]
1647 -
1633 assert self.cache_config.num_gpu_blocks is not None
1634 self.num_blocks = self.cache_config.num_gpu_blocks
1635
1651 - seen_ptrs: set[int] = set()
1636 + seen_storage_ptrs: set[int] = set()
1637 + seen_region_ptrs: set[int] = set()
1638 addrs: list[int] = []
1639 block_lens: list[int] = []
1640
1655 - for value in kv_caches.values():
1656 - cache = _repr_tensor(value)
1641 + for cache in kv_caches.values():
1642 + cache = group_kernel_blocks(cache, self.num_blocks)
1643 cache_storage = cache.untyped_storage()
1644 base_addr = cache_storage.data_ptr()
1659 - if base_addr in seen_ptrs:
1660 - continue
1661 - seen_ptrs.add(base_addr)
1645 region_len = cache_storage.nbytes()
1646
1664 - ret = self.store.register_buffer(base_addr, region_len)
1665 - if ret != 0:
1666 - logger.error(
1667 - "register_buffer failed for addr %#x len %d: %d",
1668 - base_addr,
1669 - region_len,
1670 - ret,
1671 - )
1647 + if base_addr not in seen_storage_ptrs:
1648 + seen_storage_ptrs.add(base_addr)
1649 + ret = self.store.register_buffer(base_addr, region_len)
1650 + if ret != 0:
1651 + logger.error(
1652 + "register_buffer failed for addr %#x len %d: %d",
1653 + base_addr,
1654 + region_len,
1655 + ret,
1656 + )
1657
1673 - # Detect layout via stride: a dim whose byte-stride exceeds
1674 - # page_size_bytes is an outer segment dim (e.g. the K/V dim of
1675 - # FlashAttn's (2, num_blocks, ...)). FlashInfer/MLA's blocks-
1676 - # outermost layout has no such dim and yields a single segment.
1677 - el = cache.element_size()
1678 - page_size_bytes = region_len // self.num_blocks
1679 - outer_dims = [
1680 - d for d in range(cache.ndim) if cache.stride(d) * el > page_size_bytes
1681 - ]
1682 - if not outer_dims:
1683 - # Blocks-first layout (FlashInfer / MLA): one segment.
1658 + if not is_non_overlapping_and_dense(cache[0]):
1659 + # A block is scattered across per-head regions; each region's
1660 + # blocks are contiguous.
1661 + for head_idx in range(cache.shape[1]):
1662 + head_cache = cache[:, head_idx]
1663 + assert is_non_overlapping_and_dense(head_cache[0])
1664 + region_addr = head_cache.data_ptr()
1665 + if region_addr in seen_region_ptrs:
1666 + continue
1667 + seen_region_ptrs.add(region_addr)
1668 + addrs.append(region_addr)
1669 + block_lens.append(head_cache.stride(0) * head_cache.element_size())
1670 + elif cache.stride(0) * cache.element_size() * self.num_blocks == region_len:
1671 + # The block stride spans the whole per-block window
1672 + # (block-outermost and packed layouts, and single-layer
1673 + # tensors), which may hold other layers' pages at higher
1674 + # offsets. Register the storage once as one whole-window
1675 + # region; per-layer regions would copy overlapping windows and
1676 + # run past the storage for offset layers.
1677 + if base_addr in seen_region_ptrs:
1678 + continue
1679 + seen_region_ptrs.add(base_addr)
1680 addrs.append(base_addr)
1685 - block_lens.append(page_size_bytes)
1681 + block_lens.append(region_len // self.num_blocks)
1682 else:
1687 - # K/V-first layout (FlashAttn / ROCm): split segments.
1688 - seg_stride = cache.stride(outer_dims[0]) * el
1689 - for idx in range(cache.shape[outer_dims[0]]):
1690 - addrs.append(base_addr + idx * seg_stride)
1691 - block_lens.append(seg_stride // self.num_blocks)
1683 + region_addr = cache.data_ptr()
1684 + if region_addr in seen_region_ptrs:
1685 + continue
1686 + seen_region_ptrs.add(region_addr)
1687 + addrs.append(region_addr)
1688 + block_lens.append(cache.stride(0) * cache.element_size())
1689
1690 logger.info(
1691 "Registered KV caches: num_groups=%d, num_segments=%d, num_blocks=%d",
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_layout.py
+19 -31
@@ -57,11 +57,6 @@ 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 -
60 def _spec_dim_matches(value: int, expected: int | None) -> bool:
61 return expected is None or value == expected
62
@@ -110,23 +105,6 @@ def get_layer_transfer_geometry(
105 spec = layer_to_spec[layer_name]
106 is_mla_cache = is_mla_cache_layer(layer_to_spec, layer_name)
107
113 - if is_mla_cache and len(shape) == 3:
114 - num_blocks, block_size, latent_dim = shape
115 - slot_size_bytes = latent_dim * element_size
116 - block_len = block_size * slot_size_bytes
117 - return LayerTransferGeometry(
118 - num_blocks=num_blocks,
119 - block_size=block_size,
120 - block_len=block_len,
121 - slot_size_bytes=slot_size_bytes,
122 - block_stride=stride[0],
123 - local_kv_stride=None,
124 - remote_kv_stride=None,
125 - transfers_per_block=1,
126 - regions_per_block=1,
127 - split_kv_regions=False,
128 - )
129 -
108 if not is_mla_cache and len(shape) == 5 and shape[0] == 2:
109 _, num_blocks = shape[:2]
110 kernel_blocks_per_block = 1
@@ -230,19 +208,19 @@ def get_layer_transfer_geometry(
208 )
209
210 if (
233 - not is_mla_cache
234 - and isinstance(spec, AttentionSpec)
211 + isinstance(spec, AttentionSpec)
212 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)
213 + and shape[1] == spec.num_heads
214 + and shape[2] == spec.num_states
215 + and shape[3] * element_size == spec.state_content_size_bytes
216 ):
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
217 + # Standardized per-layer [B, H, N, C] view (MLA is just H == 1).
218 + num_blocks, num_heads, num_states, content_dim = shape
219 + slot_size_bytes = num_heads * content_dim * element_size
220 + block_len = num_states * slot_size_bytes
221 return LayerTransferGeometry(
222 num_blocks=num_blocks,
245 - block_size=block_size,
223 + block_size=spec.block_size,
224 block_len=block_len,
225 slot_size_bytes=slot_size_bytes,
226 block_stride=stride[0],
@@ -267,6 +245,16 @@ def iter_layer_registration_regions(
245 ) -> list[tuple[torch.Tensor, int]]:
246 geometry = get_layer_transfer_geometry(layer_name, kv_cache, layer_to_spec)
247 region_len = geometry.num_blocks * geometry.regions_per_block * geometry.block_len
248 + if geometry.regions_per_block == 1:
249 + # With padded or interleaved pages the block stride exceeds the
250 + # meaningful block_len; register the strided span. The span ends with
251 + # the last block's meaningful bytes, not a whole stride, so it never
252 + # runs past the backing allocation of a strided layer view.
253 + block_stride_bytes = geometry.block_stride * kv_cache.element_size()
254 + region_len = max(
255 + region_len,
256 + (geometry.num_blocks - 1) * block_stride_bytes + geometry.block_len,
257 + )
258 if geometry.split_kv_regions:
259 return [(cache, region_len) for cache in kv_cache]
260 return [(kv_cache, region_len)]
vllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py
+1 -14
@@ -27,7 +27,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.metrics import (
27 PromMetricT,
28 )
29 from vllm.logger import init_logger
30 -from vllm.v1.attention.backend import AttentionBackend, AttentionMetadata
30 +from vllm.v1.attention.backend import AttentionMetadata
31 from vllm.v1.core.sched.output import SchedulerOutput
32 from vllm.v1.outputs import KVConnectorOutput
33
@@ -209,12 +209,6 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
209 c.supports_divergent_local_hybrid_hits for c in self._connectors
210 )
211
212 - @property
213 - def prefer_cross_layer_blocks(self) -> bool:
214 - if not self._connectors:
215 - return False
216 - return all(c.prefer_cross_layer_blocks for c in self._connectors)
217 -
212 @property
213 def requires_kv_delivery(self) -> bool:
214 return any(c.requires_kv_delivery for c in self._connectors)
@@ -245,13 +239,6 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
239 )
240 return ret
241
248 - def register_cross_layers_kv_cache(
249 - self, kv_cache: torch.Tensor, attn_backend: type[AttentionBackend]
250 - ):
251 - # Register on all connectors
252 - for c in self._connectors:
253 - c.register_cross_layers_kv_cache(kv_cache, attn_backend)
254 -
242 def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
243 for c in self._connectors:
244 c.register_kv_caches(kv_caches)
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+118 -177
@@ -71,9 +71,9 @@ from vllm.platforms import current_platform
71 from vllm.utils.gpu_sync_debug import gpu_sync_allowed
72 from vllm.utils.network_utils import make_zmq_path
73 from vllm.utils.torch_utils import async_tensor_h2d
74 -from vllm.v1.attention.backends.utils import get_kv_cache_layout
74 from vllm.v1.kv_cache_interface import (
75 FullAttentionSpec,
76 + KVCacheLayout,
77 MambaSpec,
78 MLAAttentionSpec,
79 SlidingWindowMLASpec,
@@ -89,6 +89,13 @@ if TYPE_CHECKING:
89 logger = init_logger(__name__)
90
91
92 +def _share_storage_and_block_stride(caches: list[torch.Tensor]) -> bool:
93 + """Return whether all views share storage and a block stride."""
94 + block_strides = {cache.stride(0) * cache.element_size() for cache in caches}
95 + storage_ptrs = {cache.untyped_storage().data_ptr() for cache in caches}
96 + return len(block_strides) == len(storage_ptrs) == 1
97 +
98 +
99 class NixlBaseConnectorWorker:
100 """Base implementation of Worker side methods shared by pull and push."""
101
@@ -302,7 +309,6 @@ class NixlBaseConnectorWorker:
309 for group in kv_cache_config.kv_cache_groups
310 for layer in group.layer_names
311 }
305 - self.hma_group_size = len(kv_cache_config.kv_cache_tensors)
312
313 # ---- Model state (derived from model config) ----
314 mamba_ssm_size = (0, 0)
@@ -523,7 +529,9 @@ class NixlBaseConnectorWorker:
529 self.attn_backends = get_current_attn_backends(vllm_config)
530 self.backend_name = self.attn_backends[0].get_name()
531
526 - self.kv_cache_layout = get_kv_cache_layout()
532 + self.kv_cache_layout = (
533 + vllm_config.cache_config.get_resolved_kv_cache_layout().name
534 + )
535 self.host_buffer_kv_cache_layout = self.kv_cache_layout
536 logger.info(
537 "Detected attention backend(s) %s",
@@ -558,6 +566,11 @@ class NixlBaseConnectorWorker:
566 # This is not used for SSM layers, which use the counterpart `mamba_ssm_size`.
567 self.block_len_per_layer = list[int]()
568
569 + # Per-region block stride in bytes. Taken from the registered tensor's
570 + # stride(0) so it stays correct under layouts that interleave layers
571 + # within a block (BLHNC/BHLNC), where stride > block_len.
572 + self.block_stride_per_layer = list[int]()
573 +
574 # Per-engine TP mappings. Generated during handshake.
575 self.tp_mappings: dict[EngineId, TPMapping] = {}
576
@@ -788,16 +801,16 @@ class NixlBaseConnectorWorker:
801 if not self.use_mla:
802 assert kv_cache.ndim == 4
803
791 - if self.kv_cache_layout == "NHD":
804 + if self.kv_cache_layout == "LBNHC":
805 if self.kv_transfer_config.enable_permute_local_kv:
806 logger.info_once(
807 "'enable_permute_local_kv' flag is enabled while "
795 - "device KV Layout is NHD. Init host buffer with"
796 - " HND to better support Decode/Prefill TP_ratio > 1."
808 + "device KV Layout is LBNHC. Init host buffer with"
809 + " LBHNC to better support Decode/Prefill TP_ratio > 1."
810 )
798 - # Since NHD will not support Decode/Prefill TP_ratio > 1,
811 + # Since LBNHC will not support Decode/Prefill TP_ratio > 1,
812 # we can leverage host_buffer for permute.
800 - self.host_buffer_kv_cache_layout = "HND"
813 + self.host_buffer_kv_cache_layout = "LBHNC"
814 else:
815 # Packed KV layout is logical (B, H, N, 2*D). Allocate
816 # (B, N, H, 2*D) and view it as logical (B, H, N, 2*D)
@@ -969,120 +982,9 @@ class NixlBaseConnectorWorker:
982
983 fut.add_done_callback(request_ready)
984
972 - def register_cross_layers_kv_caches(self, kv_cache: torch.Tensor) -> None:
973 - """Register a cross-layers KV cache tensor with NIXL.
974 -
975 - `use_uniform_kv_cache()` guarantees a single KV cache group whose
976 - layers all share the same `AttentionSpec`, so any layer name from
977 - `_layer_specs` yields the correct per-layer spec for `page_size_bytes`.
978 - """
979 - first_layer = next(iter(self._layer_specs))
980 - # Forwarding a real layer name rather than a synthetic key
981 - self.register_kv_caches({first_layer: kv_cache})
982 -
983 - def _register_packed_kv_cache(
984 - self,
985 - storage: torch.UntypedStorage,
986 - ) -> None:
987 - """Register a packed KV cache as a single NIXL region.
988 -
989 - The packed allocation interleaves all layers per block, so each
990 - block_stride-byte chunk is one logical block. We register 1
991 - NIXL region and create 1 descriptor per block.
992 - """
993 - self.transfer_topo = TransferTopology(
994 - tp_rank=self.tp_rank,
995 - tp_size=self.world_size,
996 - block_size=self.block_size,
997 - engine_id=self.engine_id,
998 - is_mla=self.use_mla,
999 - total_num_kv_heads=self.model_config.get_total_num_kv_heads(),
1000 - attn_backends=self.attn_backends,
1001 - tensor_shape=None,
1002 - is_mamba=self._has_mamba,
1003 - )
1004 - self.compat_hash = compute_nixl_compatibility_hash(
1005 - self.vllm_config,
1006 - self.backend_name,
1007 - self.transfer_topo.cross_layers_blocks,
1008 - transfer_mode=self._TRANSFER_MODE,
1009 - )
1010 -
1011 - total_size = storage.nbytes()
1012 - block_stride = total_size // self.num_blocks
1013 - base_addr = storage.data_ptr()
1014 - device_id = storage.device.index
1015 - assert device_id is not None
1016 -
1017 - logger.info(
1018 - "Registering packed KV cache: total_size=%s, block_stride=%s, "
1019 - "num_blocks=%s, num_regions=1",
1020 - total_size,
1021 - block_stride,
1022 - self.num_blocks,
1023 - )
1024 -
1025 - self.device_id = device_id
1026 - caches_data = [(base_addr, total_size, self.device_id, "")]
1027 -
1028 - self.block_len_per_layer = [block_stride]
1029 - self.num_regions = 1
1030 - self.num_descs = self.num_blocks
1031 - self.kv_caches_base_addr[self.engine_id][self.tp_rank] = [base_addr]
1032 -
1033 - descs = self.nixl_wrapper.get_reg_descs(caches_data, self.nixl_memory_type)
1034 - self.nixl_wrapper.register_memory(descs, backends=self.nixl_backends)
1035 - self._registered_descs.append(descs)
1036 -
1037 - self.dst_num_blocks[self.engine_id] = self.num_blocks
1038 -
1039 - self.src_xfer_handles_by_block_size[self.block_size], (self.src_blocks_data) = (
1040 - self.register_local_xfer_handler(self.block_size)
1041 - )
1042 -
1043 - agent_metadata = NixlAgentMetadata(
1044 - engine_id=self.engine_id,
1045 - agent_metadata=self.nixl_wrapper.get_agent_metadata(),
1046 - device_id=self.device_id,
1047 - kv_caches_base_addr=(
1048 - self.kv_caches_base_addr[self.engine_id][self.tp_rank]
1049 - ),
1050 - num_blocks=self.num_blocks,
1051 - block_lens=self.block_len_per_layer,
1052 - kv_cache_layout=self.kv_cache_layout,
1053 - block_size=self.block_size,
1054 - ssm_sizes=self._mamba_ssm_size,
1055 - attn_backend_name=self.backend_name,
1056 - physical_blocks_per_logical_kv_block=(
1057 - self._physical_blocks_per_logical_kv_block
1058 - ),
1059 - dcp_size=self.dcp_size,
1060 - pcp_size=self.pcp_size,
1061 - )
1062 - assert self.compat_hash is not None
1063 - encoder = msgspec.msgpack.Encoder()
1064 - self.xfer_handshake_metadata = NixlHandshakePayload(
1065 - compatibility_hash=self.compat_hash,
1066 - agent_metadata_bytes=encoder.encode(agent_metadata),
1067 - )
1068 -
985 def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
986 """Register the KV Cache data in nixl."""
987
1072 - # Detect packed allocation: all tensors are strided views into the
1073 - # same backing storage (different data_ptr but same storage).
1074 - # This happens with DSv4-style contiguous per-block packing.
1075 - if len(kv_caches) > 1 and not self._has_mamba:
1076 - storage = next(iter(kv_caches.values())).untyped_storage()
1077 - storage_ptrs = {
1078 - cache.untyped_storage().data_ptr() for cache in kv_caches.values()
1079 - }
1080 - data_ptrs = {cache.data_ptr() for cache in kv_caches.values()}
1081 - if len(storage_ptrs) == 1 and len(data_ptrs) > 1:
1082 - self._register_packed_kv_cache(storage)
1083 - self.device_kv_caches = kv_caches
1084 - return
1085 -
988 self.transfer_topo = TransferTopology(
989 tp_rank=self.tp_rank,
990 tp_size=self.world_size,
@@ -1100,7 +1002,6 @@ class NixlBaseConnectorWorker:
1002 self.compat_hash = compute_nixl_compatibility_hash(
1003 self.vllm_config,
1004 self.backend_name,
1103 - self.transfer_topo.cross_layers_blocks,
1005 transfer_mode=self._TRANSFER_MODE,
1006 )
1007
@@ -1127,14 +1028,14 @@ class NixlBaseConnectorWorker:
1028 )
1029
1030 caches_data = []
1130 - # With hybrid allocator, layers can share a kv cache tensor
1031 + seen_storage_addresses: set[int] = set()
1032 seen_base_addresses: list[int] = []
1033
1034 + packed_storage = _share_storage_and_block_stride(list(xfer_buffers.values()))
1035 +
1036 # K and V are packed into the content dim, so each attention layer is a
1037 # single NIXL region whose block transfers as one unit. Mamba layers instead
1038 # register separate conv/ssm sub-regions (see `_build_mamba_local`).
1136 - tensor_size_bytes = None
1137 -
1039 for layer_name, cache in xfer_buffers.items():
1040 # NOTE (NickLucche) Hybrid SSM mamba/FA physical page_size may differ when
1041 # kernel requires a specific block size. This leads to SSM and FA layers
@@ -1159,55 +1060,98 @@ class NixlBaseConnectorWorker:
1060 else layer_spec.page_size_bytes
1061 // self._physical_blocks_per_logical_kv_block
1062 )
1162 - if self.transfer_topo._cross_layers_blocks:
1163 - # When cross-layers blocks are used, multiply by number of layers
1164 - physical_page_size = physical_page_size * len(
1165 - self.kv_cache_config.kv_cache_tensors
1166 - )
1063 num_blocks = (
1064 self._logical_num_blocks
1065 if isinstance(layer_spec, MambaSpec)
1066 else self.num_blocks
1067 )
1172 - # `page_size` accounts for physical blocks, st KVCache is always
1173 - # [`num_blocks` * `page_size`]
1174 - curr_tensor_size_bytes = num_blocks * physical_page_size
1068 + logger.debug(
1069 + "Registering layer %s with cache shape: %s", layer_name, cache.shape
1070 + )
1071 + storage = cache.untyped_storage()
1072 + storage_addr = storage.data_ptr()
1073 + # Memory registration follows allocations, while transfer regions follow
1074 + # logical layers (or contiguous head segments). This keeps strided
1075 + # cross-layer views inside their registered allocation.
1076 + if storage_addr not in seen_storage_addresses:
1077 + seen_storage_addresses.add(storage_addr)
1078 + self.device_id = max(cache.get_device(), 0)
1079 + caches_data.append((storage_addr, storage.nbytes(), self.device_id, ""))
1080
1176 - base_addr = cache.data_ptr()
1081 is_mla_region = isinstance(
1082 layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)
1083 )
1180 - if base_addr in seen_base_addresses:
1181 - # NOTE (NickLucche) HMA employs memory pooling to share tensors
1182 - # across groups. This results in skipping all tensors but the ones
1183 - # pointed to by group0. Also, generally we will have more blocks
1184 - # per tensor but fewer regions.
1185 - # A shared tensor may back both SSM and attention layers (e.g.
1186 - # KDA+MLA in KimiLinear); the region's FA view is MLA whichever
1187 - # layer registered it first.
1188 - idx = seen_base_addresses.index(base_addr)
1189 - self._region_is_mla[idx] |= is_mla_region
1190 - logger.debug("Skipping %s because it's already seen", layer_name)
1191 - continue
1192 - logger.debug(
1193 - "Registering layer %s with cache shape: %s", layer_name, cache.shape
1194 - )
1195 - seen_base_addresses.append(base_addr)
1196 - # Only record non-Mamba page sizes.
1084 +
1085 if isinstance(layer_spec, MambaSpec):
1198 - self.block_len_per_layer.append(
1199 - physical_page_size // self._physical_blocks_per_logical_kv_block
1200 - )
1086 + region_specs = [
1087 + (
1088 + cache.data_ptr(),
1089 + physical_page_size
1090 + // self._physical_blocks_per_logical_kv_block,
1091 + physical_page_size
1092 + // self._physical_blocks_per_logical_kv_block,
1093 + )
1094 + ]
1095 else:
1202 - self.block_len_per_layer.append(physical_page_size)
1203 - self._region_is_mla.append(is_mla_region)
1204 -
1205 - if not is_mla_region:
1206 - if tensor_size_bytes is None:
1207 - tensor_size_bytes = curr_tensor_size_bytes
1208 - assert tensor_size_bytes == curr_tensor_size_bytes, (
1209 - "All non-MLA kv cache tensors must have the same size"
1096 + if cache.ndim == 1:
1097 + # Flat byte view: HMA tensors shared between layer types carry
1098 + # no block dimension, so stride(0) is 1 byte. Blocks abut, so
1099 + # the stride is the page size.
1100 + block_stride = physical_page_size
1101 + else:
1102 + block_stride = cache.stride(0) * cache.element_size()
1103 + storage_is_block_major = num_blocks * block_stride == storage.nbytes()
1104 + # A layer whose [H, N, C] interior is dense addresses its own page
1105 + # as one chunk, so it can own a region. When it does not, or when
1106 + # every layer is packed into this one storage, the block's whole
1107 + # row is the only contiguous unit.
1108 + hnc_contiguous = (
1109 + cache.ndim == 4
1110 + and cache.stride(2) == cache.shape[3]
1111 + and cache.stride(1) == cache.shape[2] * cache.shape[3]
1112 )
1113 + if storage_is_block_major and (
1114 + (packed_storage and is_mla_region) or not hnc_contiguous
1115 + ):
1116 + # TODO(Lucas): handle TP slicing for packed_storage; for now
1117 + # restrict to MLA (DSv4) where kv is replicated.
1118 + storage_block_len = storage.nbytes() // num_blocks
1119 + region_specs = [
1120 + (storage_addr, storage_block_len, storage_block_len)
1121 + ]
1122 + elif storage_is_block_major:
1123 + region_specs = [
1124 + (cache.data_ptr(), physical_page_size, block_stride)
1125 + ]
1126 + else:
1127 + segment_bytes = num_blocks * block_stride
1128 + assert cache.nbytes % segment_bytes == 0
1129 + num_segments = cache.nbytes // segment_bytes
1130 + region_block_len = (
1131 + block_stride if num_segments > 1 else physical_page_size
1132 + )
1133 + region_specs = [
1134 + (
1135 + cache.data_ptr() + segment_idx * segment_bytes,
1136 + region_block_len,
1137 + block_stride,
1138 + )
1139 + for segment_idx in range(num_segments)
1140 + ]
1141 +
1142 + for base_addr, block_len, block_stride in region_specs:
1143 + if base_addr in seen_base_addresses:
1144 + if is_mla_region:
1145 + # Dual-purpose HMA tensor: an MLA layer shares a region
1146 + # that a non-MLA layer registered first. MLA is not
1147 + # head-sharded, so the region must be flagged MLA.
1148 + idx = seen_base_addresses.index(base_addr)
1149 + self._region_is_mla[idx] = True
1150 + continue
1151 + seen_base_addresses.append(base_addr)
1152 + self.block_len_per_layer.append(block_len)
1153 + self.block_stride_per_layer.append(block_stride)
1154 + self._region_is_mla.append(is_mla_region)
1155
1156 # When there's a mismatch between kbs<>bs, we rely on HMA to ensure
1157 # caches are either [NB, PS] or [NB*r, PS/r] where r is bs/kbs.
@@ -1228,11 +1172,6 @@ class NixlBaseConnectorWorker:
1172 f"kv_cache_layout={self.kv_cache_layout}"
1173 )
1174
1231 - # Need to make sure the device ID is non-negative for NIXL,
1232 - # Torch uses -1 to indicate CPU tensors.
1233 - self.device_id = max(cache.get_device(), 0)
1234 - caches_data.append((base_addr, curr_tensor_size_bytes, self.device_id, ""))
1235 -
1175 logger.debug(
1176 "Different block lengths collected: %s", set(self.block_len_per_layer)
1177 )
@@ -1240,10 +1179,11 @@ class NixlBaseConnectorWorker:
1179 len(self.block_len_per_layer)
1180 == len(seen_base_addresses)
1181 == len(self._region_is_mla)
1182 + == len(self.block_stride_per_layer)
1183 )
1184
1185 self.kv_caches_base_addr[self.engine_id][self.tp_rank] = seen_base_addresses
1246 - self.num_regions = len(caches_data)
1186 + self.num_regions = len(seen_base_addresses)
1187
1188 if self.pp_size > 1:
1189 start_layer, end_layer = self.model_config.get_layers_start_end_indices(
@@ -1293,6 +1233,7 @@ class NixlBaseConnectorWorker:
1233 kv_caches_base_addr=self.kv_caches_base_addr[self.engine_id][self.tp_rank],
1234 num_blocks=self.num_blocks,
1235 block_lens=self.block_len_per_layer,
1236 + block_strides=self.block_stride_per_layer,
1237 kv_cache_layout=self.kv_cache_layout
1238 if not self.use_host_buffer
1239 else self.host_buffer_kv_cache_layout,
@@ -1431,10 +1372,9 @@ class NixlBaseConnectorWorker:
1372 block_arange = np.arange(num_blocks, dtype=np.uint64)
1373 parts: list[np.ndarray] = []
1374 for i, base_addr in enumerate(base_addresses):
1434 - # K/V are packed into the content dim, so the whole block transfers
1435 - # as one unit: desc length equals the block stride.
1375 block_len = self.block_len_per_layer[i] // block_size_ratio
1437 - addrs = base_addr + block_arange * block_len
1376 + block_stride = self.block_stride_per_layer[i] // block_size_ratio
1377 + addrs = base_addr + block_arange * block_stride
1378 parts.append(self._stack_descs(addrs, block_len, device_id))
1379 return np.concatenate(parts)
1380
@@ -1476,7 +1416,7 @@ class NixlBaseConnectorWorker:
1416 )
1417 local_block_len = local_block_len // num_reads
1418
1479 - page_size = nixl_agent_meta.block_lens[i]
1419 + page_size = nixl_agent_meta.block_strides[i]
1420 addrs = base_addr + rank_offset + block_arange * page_size
1421 parts.append(self._stack_descs(addrs, local_block_len, device_id))
1422 return np.concatenate(parts)
@@ -1560,7 +1500,7 @@ class NixlBaseConnectorWorker:
1500 tp_ratio = 4 // 2 = 2
1501
1502 Considering the KV Caches, if P-Worker_i has cache size [2, num_blocksP, kv_heads, block_size, head_dim]
1563 - then D-Worker_j has [2, num_blocksD, kv_heads//tp_ratio, block_size, head_dim]. Mind the "HND" layout format.
1503 + then D-Worker_j has [2, num_blocksD, kv_heads//tp_ratio, block_size, head_dim]. Mind the "LBHNC" layout format.
1504 Assuming num_blocksD >= num_blocksP, D-Worker0 reads from P-Worker0 by preparing the kv_heads//tp_ratio
1505 first heads from all the slots of all the blocks. D-Worker1 will do the same, but reading the second split
1506 along the kv_heads dimension, and so forth until "tp_ratio" D TP workers have pulled from P-Worker0.
@@ -1600,6 +1540,7 @@ class NixlBaseConnectorWorker:
1540 start:end
1541 ]
1542 nixl_agent_meta.block_lens = nixl_agent_meta.block_lens[start:end]
1543 + nixl_agent_meta.block_strides = nixl_agent_meta.block_strides[start:end]
1544
1545 ### Register remote engine in TransferTopology (idempotent).
1546 assert self.transfer_topo is not None
@@ -1790,10 +1731,10 @@ class NixlBaseConnectorWorker:
1731 if not self.use_mla and nixl_agent_meta.kv_cache_layout != kv_cache_layout:
1732 if (
1733 self.kv_transfer_config.enable_permute_local_kv
1793 - and nixl_agent_meta.kv_cache_layout == "HND"
1734 + and nixl_agent_meta.kv_cache_layout == "LBHNC"
1735 ):
1736 logger.info(
1796 - "Remote is HND and local is NHD, enabled additional permute "
1737 + "Remote is LBHNC and local is LBNHC, enabled additional permute "
1738 "on local device KV."
1739 )
1740 assert not self._is_hma_required, (
@@ -1823,18 +1764,18 @@ class NixlBaseConnectorWorker:
1764 self.enable_heterogeneous_attn_post_process = True
1765
1766 # Heterogeneous TP requires head-splitting, which only works with
1826 - # HND layout. MLA and replicated-KV cases don't split on heads.
1827 - # Mamba doesn't support heterogeneous TP.
1767 + # block-contiguous layouts (e.g. LBHNC). MLA and replicated-KV cases
1768 + # don't split on heads. Mamba doesn't support heterogeneous TP.
1769 if (
1770 abs(tp_ratio) != 1
1771 and not self.use_mla
1772 and not self.transfer_topo.is_kv_replicated(remote_engine_id)
1832 - and kv_cache_layout != "HND"
1773 + and not KVCacheLayout[kv_cache_layout].is_block_contiguous
1774 and not self.enable_permute_local_kv
1775 ):
1776 raise RuntimeError(
1777 "Heterogeneous TP head-dimension splitting requires contiguous heads. "
1837 - "Use HND layout on the prefill side."
1778 + "Use a block-contiguous layout (e.g. LBHNC) on the prefill side."
1779 )
1780
1781 # Per-region block_len validation enforcing the P/D invariant.
vllm/distributed/kv_transfer/kv_connector/v1/nixl/connector.py
+4 -42
@@ -19,7 +19,6 @@ import torch
19 from vllm.config import VllmConfig
20 from vllm.distributed.kv_transfer.kv_connector.utils import (
21 EngineId,
22 - get_current_attn_backend,
22 )
23 from vllm.distributed.kv_transfer.kv_connector.v1.base import (
24 CopyBlocksOp,
@@ -56,10 +55,8 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.stats import (
55 )
56 from vllm.forward_context import ForwardContext
57 from vllm.logger import init_logger
59 -from vllm.v1.attention.backend import AttentionBackend, AttentionMetadata
60 -from vllm.v1.attention.backends.utils import get_kv_cache_layout
58 +from vllm.v1.attention.backend import AttentionMetadata
59 from vllm.v1.core.sched.output import SchedulerOutput
62 -from vllm.v1.kv_cache_interface import MambaSpec
60 from vllm.v1.outputs import KVConnectorOutput
61
62 if TYPE_CHECKING:
@@ -83,36 +80,6 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
80 def supports_divergent_local_hybrid_hits(self) -> bool:
81 return True
82
86 - @property
87 - def prefer_cross_layer_blocks(self) -> bool:
88 - if any(
89 - [
90 - isinstance(group.kv_cache_spec, MambaSpec)
91 - for group in self.kv_cache_config.kv_cache_groups
92 - ]
93 - ):
94 - # Hybrid SSM models do not yet support cross-layer layout
95 - return False
96 -
97 - backend = get_current_attn_backend(self._vllm_config)
98 - if backend.get_name() not in (
99 - "FLASH_ATTN",
100 - "FLASHINFER",
101 - "TRITON_ATTN",
102 - ):
103 - return False
104 -
105 - # For now there is no benefit to run cross layers when backend
106 - # does not support on HND
107 - if get_kv_cache_layout() != "HND":
108 - return False
109 -
110 - extra_config = self.kv_transfer_config.kv_connector_extra_config
111 - return (
112 - str(extra_config.get("enable_cross_layers_blocks", "False")).lower()
113 - == "true"
114 - )
115 -
83 def __init__(
84 self,
85 vllm_config: VllmConfig,
@@ -179,9 +146,10 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
146 # which fallback to the default behavior.
147 return None
148 logger.info_once(
182 - "NixlConnector setting KV cache layout to HND for better xfer performance."
149 + "NixlConnector setting KV cache layout to LBHNC for "
150 + "better xfer performance."
151 )
184 - return "HND"
152 + return "LBHNC"
153
154 ############################################################
155 # Scheduler Side Methods
@@ -266,12 +234,6 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
234 assert self.connector_worker is not None
235 self.connector_worker.register_kv_caches(kv_caches)
236
269 - def register_cross_layers_kv_cache(
270 - self, kv_cache: torch.Tensor, attn_backend: type[AttentionBackend]
271 - ):
272 - assert self.connector_worker is not None
273 - self.connector_worker.register_cross_layers_kv_caches(kv_cache)
274 -
237 def set_host_xfer_buffer_ops(self, copy_operation: CopyBlocksOp):
238 assert self.connector_worker is not None
239 self.connector_worker.set_host_xfer_buffer_ops(copy_operation)
vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py
+3 -3
@@ -44,8 +44,9 @@ PUSH_REG_NOTIF_PREFIX = b"PUSH_REG:"
44 # 6: Validate EAGLE/MTP speculative configuration compatibility
45 # 7: Include NIXL transfer mode (push vs pull) in the compatibility hash
46 # 8: Add dcp_size and pcp_size to NixlAgentMetadata
47 +# 9: Add block_strides
48 #
48 -NIXL_CONNECTOR_VERSION: int = 8
49 +NIXL_CONNECTOR_VERSION: int = 9
50
51
52 @dataclass
@@ -56,6 +57,7 @@ class NixlAgentMetadata:
57 device_id: int
58 num_blocks: int
59 block_lens: list[int]
60 + block_strides: list[int]
61 kv_cache_layout: str
62 block_size: int
63 ssm_sizes: tuple[int, int]
@@ -129,7 +131,6 @@ def _get_speculative_compatibility_factors(
131 def compute_nixl_compatibility_hash(
132 vllm_config: VllmConfig,
133 attn_backend_name: str,
132 - cross_layers_blocks: bool,
134 transfer_mode: str = "pull",
135 ) -> str:
136 """
@@ -180,7 +181,6 @@ def compute_nixl_compatibility_hash(
181 # Attention backend and KV cache dtype affect memory layout
182 "attn_backend_name": attn_backend_name,
183 "cache_dtype": str(cache_config.cache_dtype),
183 - "cross_layers_blocks": cross_layers_blocks,
184 "is_hma_enabled": is_hma_enabled,
185 "speculative_config": _get_speculative_compatibility_factors(vllm_config),
186 # push (WRITE) and pull (READ) connectors are protocol-incompatible
vllm/distributed/kv_transfer/kv_connector/v1/offloading/canonical_mapping.py
+10 -6
@@ -17,6 +17,7 @@ import torch
17 from vllm.v1.kv_cache_interface import (
18 AttentionSpec,
19 KVCacheConfig,
20 + KVCacheLayout,
21 KVCacheSpec,
22 MambaSpec,
23 MLAAttentionSpec,
@@ -31,13 +32,16 @@ if TYPE_CHECKING:
32 CANONICAL_FORMAT_VERSION = 1
33
34
34 -def canonical_format_id() -> str:
35 +def canonical_format_id(kv_cache_layout: str) -> str:
36 """Identity of the canonical byte format, for namespacing persisted KV.
37 Canonical pages keep the worker's KV layout family, so the id couples the
37 - format version with that family; consumers must match it exactly."""
38 - from vllm.v1.attention.backends.utils import get_kv_cache_layout
39 -
40 - return f"v{CANONICAL_FORMAT_VERSION}-{get_kv_cache_layout().lower()}"
38 + format version with that family; consumers must match it exactly. The family
39 + keeps its historical NHD/HND spelling so ids stay stable for KV persisted
40 + before the layout enum existed."""
41 + layout = KVCacheLayout[kv_cache_layout]
42 + legacy = {KVCacheLayout.LBNHC: "nhd", KVCacheLayout.LBHNC: "hnd"}
43 + family = legacy.get(layout, layout.name.lower())
44 + return f"v{CANONICAL_FORMAT_VERSION}-{family}"
45
46
47 @dataclass(frozen=True)
@@ -278,7 +282,7 @@ def _layer_mapping(
282 if isinstance(spec, MLAAttentionSpec):
283 # TP-replicated latent; CP shards its tokens across the DCP groups
284 if (
281 - spec.compress_ratio != 1
285 + spec.tokens_per_state != 1
286 or page % bs
287 or ctx.tp_size % ctx.dcp_size
288 or spec.kv_quant_mode.is_per_token_head
vllm/distributed/kv_transfer/kv_connector/v1/offloading/config.py
+7 -19
@@ -20,12 +20,7 @@ from vllm.v1.kv_offload.config import (
20
21 if TYPE_CHECKING:
22 from vllm.config import VllmConfig
23 - from vllm.v1.kv_cache_interface import KVCacheConfig, KVCacheTensor
24 -
25 -
26 -def is_kv_cache_tensor_packed(kv_cache_tensor: "KVCacheTensor") -> bool:
27 - """Return whether a KV cache tensor uses a packed block stride."""
28 - return bool(kv_cache_tensor.block_stride)
23 + from vllm.v1.kv_cache_interface import KVCacheConfig
24
25
26 def build_offloading_config(
@@ -96,18 +91,10 @@ def build_offloading_config(
91 blocks_per_chunk = tokens_per_chunk_int // tokens_per_block
92
93 worker_kv_bytes_per_block = 0
99 - if kv_cache_config.num_blocks > 0:
100 - packed_tensors = tuple(
101 - is_kv_cache_tensor_packed(tensor)
102 - for tensor in kv_cache_config.kv_cache_tensors
103 - )
104 - is_packed = any(packed_tensors)
105 - assert not is_packed or all(packed_tensors)
106 - total_gpu_kv_bytes = (
107 - kv_cache_config.kv_cache_tensors[0].size
108 - if is_packed
109 - else sum(tensor.size for tensor in kv_cache_config.kv_cache_tensors)
110 - )
94 + if kv_cache_config.num_blocks > 0 and kv_cache_config.kv_cache_tensors:
95 + # Every KVCacheTensor describes placement within the same backing allocation,
96 + # so its size is the total, not a per-tensor share.
97 + total_gpu_kv_bytes = kv_cache_config.kv_cache_tensors[0].size
98 worker_kv_bytes_per_block = total_gpu_kv_bytes // kv_cache_config.num_blocks
99
100 single_group_spec = (
@@ -164,7 +151,7 @@ def build_offloading_config(
151 if isinstance(single_group_spec, FullAttentionSpec):
152 if type(single_group_spec) is MLAAttentionSpec:
153 spec_certifiable = (
167 - single_group_spec.compress_ratio == 1
154 + single_group_spec.tokens_per_state == 1
155 and single_group_spec.real_page_size_bytes
156 % single_group_spec.block_size
157 == 0
@@ -219,4 +206,5 @@ def build_offloading_config(
206 ),
207 replicated_layout=replicated_layout,
208 canonical_layout=canonical_layout,
209 + kv_cache_layout=vllm_config.cache_config.kv_cache_layout,
210 )
vllm/distributed/kv_transfer/kv_connector/v1/offloading/worker.py
+40 -124
@@ -14,16 +14,13 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
14 OffloadingWorkerMetadata,
15 ReqId,
16 )
17 -from vllm.distributed.kv_transfer.kv_connector.v1.offloading.config import (
18 - is_kv_cache_tensor_packed,
19 -)
17 from vllm.logger import init_logger
21 -from vllm.v1.attention.backend import AttentionBackend
18 from vllm.v1.kv_cache_interface import (
19 AttentionSpec,
20 KVCacheConfig,
21 MambaSpec,
22 UniformTypeKVCacheSpecs,
23 + group_kernel_blocks,
24 )
25 from vllm.v1.kv_offload.base import (
26 CanonicalKVCacheRef,
@@ -73,16 +70,6 @@ class OffloadingConnectorWorker:
70 self.vllm_config, kv_cache_config, kv_caches
71 )
72
76 - # Packed layouts (e.g. DSv4) set block_stride > 0; their tensors use
77 - # stride(0) as the manager-block stride (equals total_num_bytes_per_block).
78 - # General (non-packed) layouts size the tensor at page_size_bytes per
79 - # manager block, so page_size_bytes is the correct offloading stride.
80 - layer_is_packed: dict[str, bool] = {
81 - ln: is_kv_cache_tensor_packed(kv_tensor)
82 - for kv_tensor in kv_cache_config.kv_cache_tensors
83 - for ln in kv_tensor.shared_by
84 - }
85 -
73 # layer_name -> (num_blocks, page_size_bytes) tensor
74 tensors_per_block: dict[str, tuple[torch.Tensor, ...]] = {}
75 # layer_name -> size of (un-padded) page in bytes
@@ -100,63 +87,52 @@ class OffloadingConnectorWorker:
87 layer_kv_cache_spec = per_layer_specs.get(
88 layer_name, group_kv_cache_spec
89 )
103 - if isinstance(layer_kv_cache_spec, AttentionSpec):
104 - layer_kv_cache = kv_caches[layer_name]
105 - assert isinstance(layer_kv_cache, torch.Tensor)
90 + ref = group_kernel_blocks(kv_caches[layer_name], num_blocks)
91 + page = layer_kv_cache_spec.page_size_bytes
92 + elem_size = ref.element_size()
93 + byte_offset = ref.storage_offset() * elem_size
94 + # Packed layouts (e.g. DSv4) interleave layers per block, so the
95 + # attention tensor's stride(0) (the manager-block stride) exceeds
96 + # page_size_bytes; other layouts have stride(0) == page_size_bytes.
97 + block_stride_bytes = (
98 + ref.stride(0) * elem_size
99 + if isinstance(layer_kv_cache_spec, AttentionSpec)
100 + else page
101 + )
102 + tensors_per_block[layer_name] = (
103 + torch.tensor([], dtype=torch.int8, device=ref.device).set_(
104 + ref.untyped_storage(),
105 + byte_offset,
106 + (num_blocks, page),
107 + (block_stride_bytes, 1),
108 + ),
109 + )
110 + page_size_bytes[layer_name] = page
111
107 - page = layer_kv_cache_spec.page_size_bytes
108 - elem_size = layer_kv_cache.element_size()
109 - byte_offset = layer_kv_cache.storage_offset() * elem_size
110 - block_stride_bytes = (
111 - layer_kv_cache.stride(0) * elem_size
112 - if layer_is_packed[layer_name]
113 - else page
114 - )
115 - raw = torch.empty(
116 - 0,
117 - dtype=torch.int8,
118 - device=layer_kv_cache.device,
119 - ).set_(layer_kv_cache.untyped_storage())
120 - tensors_per_block[layer_name] = (
121 - torch.as_strided(
122 - raw,
123 - (num_blocks, page),
124 - (block_stride_bytes, 1),
125 - byte_offset,
126 - ),
127 - )
128 - page_size_bytes[layer_name] = layer_kv_cache_spec.page_size_bytes
112 + if isinstance(layer_kv_cache_spec, AttentionSpec):
113 unpadded_page_size_bytes[layer_name] = (
114 layer_kv_cache_spec.unpadded_page_size_bytes
115 )
132 -
116 elif isinstance(layer_kv_cache_spec, MambaSpec):
134 - layer_kv_cache = kv_caches[layer_name]
135 - assert layer_kv_cache.dtype == torch.int8
136 - tensors_per_block[layer_name] = (
137 - layer_kv_cache.view(
138 - num_blocks, layer_kv_cache_spec.page_size_bytes
139 - ),
140 - )
141 -
142 - page_size_bytes[layer_name] = layer_kv_cache_spec.page_size_bytes
117 unpadded_page_size_bytes[layer_name] = replace(
118 layer_kv_cache_spec, page_size_padded=None
119 ).page_size_bytes
146 -
120 else:
121 raise NotImplementedError
122
150 - packed_kv_cache_tensor = next(
123 + # Packed layouts (e.g. DSv4) interleave all layers within each manager
124 + # block: a layer view's block stride exceeds its page size. Offload the
125 + # whole packed block as a single transfer region.
126 + packed_layer_name = next(
127 (
152 - t
153 - for t in kv_cache_config.kv_cache_tensors
154 - if is_kv_cache_tensor_packed(t) and t.shared_by
128 + layer_name
129 + for layer_name, (tensor,) in tensors_per_block.items()
130 + if tensor.stride(0) != tensor.shape[1]
131 ),
132 None,
133 )
158 - if packed_kv_cache_tensor is not None:
159 - (tensor,) = tensors_per_block[packed_kv_cache_tensor.shared_by[0]]
134 + if packed_layer_name is not None:
135 + (tensor,) = tensors_per_block[packed_layer_name]
136 block_stride = tensor.stride(0)
137 packed_tensor = tensor.as_strided(
138 (num_blocks, block_stride),
@@ -176,29 +152,16 @@ class OffloadingConnectorWorker:
152
153 block_tensors: list[CanonicalKVCacheTensor] = []
154 block_data_refs: dict[str, list[CanonicalKVCacheRef]] = defaultdict(list)
179 - for kv_cache_tensor in kv_cache_config.kv_cache_tensors:
180 - # Filter to layers that were actually processed above.
181 - # Packed KV allocation emits KVCacheTensor entries for
182 - # every (tuple_idx, page_size) slot; slots where no group has a
183 - # layer at that index produce an empty shared_by (reserved memory
184 - # with no corresponding model layer).
185 - tensor_layer_names = [
186 - n for n in kv_cache_tensor.shared_by if n in tensors_per_block
187 - ]
188 - if not tensor_layer_names:
189 - continue
190 -
191 - # verify all layers in the group reference the exact same tensors
155 + # Layers that alias the same bytes (cache groups overlay each other) share
156 + # one transfer region: exactly the views with equal address and strides.
157 + aliased_layers: dict[tuple[int, tuple[int, ...]], list[str]] = defaultdict(list)
158 + for layer_name, layer_tensors in tensors_per_block.items():
159 + view = layer_tensors[0]
160 + aliased_layers[(view.data_ptr(), view.stride())].append(layer_name)
161 +
162 + for tensor_layer_names in aliased_layers.values():
163 assert len({len(tensors_per_block[n]) for n in tensor_layer_names}) == 1
193 - assert (
194 - len({tensors_per_block[n][0].data_ptr() for n in tensor_layer_names})
195 - == 1
196 - )
197 - assert (
198 - len({tensors_per_block[n][0].stride() for n in tensor_layer_names}) == 1
199 - )
164
201 - # pick the first layer to represent the group
165 first_layer_name = tensor_layer_names[0]
166 for tensor in tensors_per_block[first_layer_name]:
167 block_tensors.append(
@@ -242,53 +205,6 @@ class OffloadingConnectorWorker:
205
206 self._init_worker(canonical_kv_caches)
207
245 - def register_cross_layers_kv_cache(
246 - self, kv_cache: torch.Tensor, attn_backend: type[AttentionBackend]
247 - ):
248 - # verify that num_blocks is at physical position 0 in the cross-layers
249 - # tensor layout.
250 - test_shape = attn_backend.get_kv_cache_shape(
251 - num_blocks=1234, block_size=16, num_kv_heads=1, head_size=256
252 - )
253 - num_blocks_logical_dim = test_shape.index(1234) + 1
254 - physical_to_logical = attn_backend.get_kv_cache_stride_order(
255 - include_num_layers_dimension=True
256 - )
257 - num_blocks_physical_dim = physical_to_logical.index(num_blocks_logical_dim)
258 - assert num_blocks_physical_dim == 0
259 -
260 - kv_cache_groups = self.kv_cache_config.kv_cache_groups
261 - assert len(kv_cache_groups) == 1
262 - kv_cache_spec = kv_cache_groups[0].kv_cache_spec
263 - num_layers = len(kv_cache_groups[0].layer_names)
264 - page_size_bytes = kv_cache_spec.page_size_bytes * num_layers
265 -
266 - assert kv_cache.storage_offset() == 0
267 - storage = kv_cache.untyped_storage()
268 - assert len(storage) % page_size_bytes == 0
269 - num_blocks = len(storage) // page_size_bytes
270 - tensor = (
271 - torch.tensor(
272 - [],
273 - dtype=torch.int8,
274 - device=kv_cache.device,
275 - )
276 - .set_(storage)
277 - .view(num_blocks, page_size_bytes)
278 - )
279 - kv_cache_tensor = CanonicalKVCacheTensor(
280 - tensor=tensor, page_size_bytes=page_size_bytes
281 - )
282 - # in cross layers layout, there's currently only a single group
283 - kv_cache_data_ref = CanonicalKVCacheRef(
284 - tensor_idx=0, page_size_bytes=page_size_bytes
285 - )
286 - canonical_kv_caches = CanonicalKVCaches(
287 - tensors=[kv_cache_tensor], group_data_refs=[[kv_cache_data_ref]]
288 - )
289 -
290 - self._init_worker(canonical_kv_caches)
291 -
208 def handle_preemptions(self, kv_connector_metadata: OffloadingConnectorMetadata):
209 assert self.worker is not None
210
vllm/distributed/kv_transfer/kv_connector/v1/offloading_connector.py
+2 -13
@@ -37,7 +37,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.worker import (
37 OffloadingConnectorWorker,
38 )
39 from vllm.forward_context import ForwardContext
40 -from vllm.v1.attention.backend import AttentionBackend, AttentionMetadata
40 +from vllm.v1.attention.backend import AttentionMetadata
41 from vllm.v1.core.kv_cache_manager import KVCacheBlocks
42 from vllm.v1.core.sched.output import SchedulerOutput
43 from vllm.v1.kv_cache_interface import KVCacheConfig
@@ -47,11 +47,6 @@ from vllm.v1.request import Request
47
48
49 class OffloadingConnector(KVConnectorBase_V1, SupportsHMA):
50 - @property
51 - def prefer_cross_layer_blocks(self) -> bool:
52 - # Cross-layer slabs have no per-layer refs to certify canonical mappings
53 - return not self._canonical_layout
54 -
50 @property
51 def requires_kv_delivery(self) -> bool:
52 # Runs as kv_both, but is a best-effort cache: a dropped save is just a
@@ -91,12 +86,6 @@ class OffloadingConnector(KVConnectorBase_V1, SupportsHMA):
86 assert self.connector_worker is not None
87 self.connector_worker.register_kv_caches(kv_caches)
88
94 - def register_cross_layers_kv_cache(
95 - self, kv_cache: torch.Tensor, attn_backend: type[AttentionBackend]
96 - ):
97 - assert self.connector_worker is not None
98 - self.connector_worker.register_cross_layers_kv_cache(kv_cache, attn_backend)
99 -
89 def handle_preemptions(self, kv_connector_metadata: KVConnectorMetadata):
90 assert self.connector_worker is not None
91 assert isinstance(kv_connector_metadata, OffloadingConnectorMetadata)
@@ -196,7 +185,7 @@ class OffloadingConnector(KVConnectorBase_V1, SupportsHMA):
185
186 @classmethod
187 def get_required_kvcache_layout(cls, vllm_config: VllmConfig) -> str | None:
199 - return "HND"
188 + return "LBHNC"
189
190 def reset_cache(self) -> bool | None:
191 assert self.connector_scheduler is not None
vllm/envs.py
+13 -6
@@ -242,7 +242,10 @@ if TYPE_CHECKING:
242 VLLM_MQ_MAX_CHUNK_BYTES_MB: int = 16
243 VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS: int = 300
244 VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS: int = 5
245 - VLLM_KV_CACHE_LAYOUT: Literal["NHD", "HND"] | None = None
245 + VLLM_KV_CACHE_LAYOUT: (
246 + Literal["LBNHC", "LBHNC", "LHBNC", "NHD", "HND", "BLHNC", "BLNHC", "BHLNC"]
247 + | None
248 + ) = None
249 VLLM_SSM_CONV_STATE_LAYOUT: Literal["SD", "DS"] | None = None
250 VLLM_COMPUTE_NANS_IN_LOGITS: bool = False
251 VLLM_RAISE_ON_LOGIT_NANS: bool = False
@@ -1784,18 +1787,22 @@ environment_variables: dict[str, Callable[[], Any]] = {
1787 ),
1788 # KV Cache layout used throughout vllm.
1789 # Some common values are:
1787 - # - NHD
1788 - # - HND
1789 - # Where N=num_blocks, H=num_heads and D=head_size. The default value will
1790 + # - LBNHC
1791 + # - LBHNC
1792 + # Where N=num_states, H=num_heads and C=state_content. The default value will
1793 # leave the layout choice to the backend. Mind that backends may only
1794 # implement and support a subset of all possible layouts.
1795 + # LHBNC hoists the head dim outside the block dim; backends must opt in via
1796 + # AttentionBackend.supported_kv_cache_layouts().
1797 "VLLM_KV_CACHE_LAYOUT": env_with_choices(
1793 - "VLLM_KV_CACHE_LAYOUT", None, ["NHD", "HND"]
1798 + "VLLM_KV_CACHE_LAYOUT",
1799 + None,
1800 + ["LBNHC", "LBHNC", "LHBNC", "NHD", "HND", "BLHNC", "BLNHC", "BHLNC"],
1801 ),
1802 # SSM conv state layout used for Mamba models.
1803 # - SD: (state_len, dim) — dim contiguous (default)
1804 # - DS: (dim, state_len) — TP-sharded dim on dim1,
1798 - # consistent with SSM temporal state and HND KV cache layout.
1805 + # consistent with SSM temporal state and LBHNC KV cache layout.
1806 "VLLM_SSM_CONV_STATE_LAYOUT": env_with_choices(
1807 "VLLM_SSM_CONV_STATE_LAYOUT", None, ["SD", "DS"]
1808 ),
vllm/model_executor/layers/attention/mla_attention.py
+4 -21
@@ -649,6 +649,10 @@ class MLAAttention(nn.Module, AttentionLayerBase):
649 compile_native=True,
650 )
651
652 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
653 + # [B, H=1, N, C] -> [B, N, C]
654 + self.kv_cache = kv_cache.squeeze(1)
655 +
656 @property
657 def chunked_prefill_workspace_size(self) -> int:
658 if self._chunked_prefill_workspace_size is None:
@@ -1451,27 +1455,6 @@ class MLACommonBackend(AttentionBackend):
1455 def get_builder_cls() -> type["MLACommonMetadataBuilder"]:
1456 return MLACommonMetadataBuilder
1457
1454 - @staticmethod
1455 - def get_kv_cache_shape(
1456 - num_blocks: int,
1457 - block_size: int,
1458 - num_kv_heads: int, # assumed to be 1 for MLA
1459 - head_size: int,
1460 - cache_dtype_str: str = "auto",
1461 - ) -> tuple[int, ...]:
1462 - return (num_blocks, block_size, head_size)
1463 -
1464 - @staticmethod
1465 - def get_kv_cache_stride_order(
1466 - include_num_layers_dimension: bool = False,
1467 - ) -> tuple[int, ...]:
1468 - if include_num_layers_dimension:
1469 - # Default to identity permutation to signal cross-layer allocation
1470 - # is unsupported. Each MLA backend must opt in to support cross-layer
1471 - # allocation by overriding this method.
1472 - return (0, 1, 2, 3)
1473 - return (0, 1, 2)
1474 -
1458 @classmethod
1459 def get_supported_head_sizes(cls) -> list[int]:
1460 return [320, 576]
vllm/model_executor/layers/hpc/rope_norm.py
+15 -12
@@ -61,16 +61,16 @@ def hpc_rope_norm_forward(
61 return
62
63 attn_layer = forward_context.no_compile_layers[layer_name]
64 - # bind_kv_cache stores the per-layer KV cache as a single 5D tensor
65 - # (num_blocks, 2, block_size, num_kv_heads, head_size), so use it directly.
64 + # bind_kv_cache stores the per-layer KV cache as a single tensor
65 + # (num_blocks, num_kv_heads, block_size, 2 * head_size), so use it directly.
66 kv_cache = attn_layer.kv_cache
67
68 if kv_cache.numel() == 0:
69 output.zero_()
70 return
71
72 - assert kv_cache.dim() == 5, (
73 - f"Expected kv_cache to have 5 dims, got {tuple(kv_cache.shape)}"
72 + assert kv_cache.dim() == 4, (
73 + f"Expected kv_cache to have 4 dims, got {tuple(kv_cache.shape)}"
74 )
75
76 rope_norm = _hpc_rope_norm_instances[layer_name]
@@ -295,6 +295,9 @@ class HpcRopeNorm(CustomOp, HpcModule):
295 """
296 import hpc
297
298 + # (B, H, N, 2*hs) -> two (B, N, H, hs) K/V views, as in HpcAttentionImpl.
299 + key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_dim, dim=-1)
300 +
301 num_actual_tokens = attn_metadata.num_actual_tokens
302 num_prefill_reqs = attn_metadata.num_prefills
303 num_decode_reqs = attn_metadata.num_decodes
@@ -333,8 +336,8 @@ class HpcRopeNorm(CustomOp, HpcModule):
336
337 if self.use_fp8:
338 _, q_scale, split_k_flag = hpc.rope_norm_store_kv_fp8(
336 - key_cache=kv_cache[:, 0],
337 - value_cache=kv_cache[:, 1],
339 + key_cache=key_cache,
340 + value_cache=value_cache,
341 qkv=qkv_prefill,
342 cos_sin=self.cos_sin_cache,
343 num_seqlen_per_req=seq_lens_prefill,
@@ -353,8 +356,8 @@ class HpcRopeNorm(CustomOp, HpcModule):
356 attn_metadata.hpc_prefill_q_scale = q_scale
357 else:
358 hpc.rope_norm_store_kv(
356 - kv_cache[:, 0],
357 - kv_cache[:, 1],
359 + key_cache,
360 + value_cache,
361 qkv_prefill,
362 self.cos_sin_cache,
363 seq_lens_prefill,
@@ -379,8 +382,8 @@ class HpcRopeNorm(CustomOp, HpcModule):
382
383 if self.use_fp8:
384 _, q_scale, split_k_flag = hpc.rope_norm_store_kv_fp8(
382 - key_cache=kv_cache[:, 0],
383 - value_cache=kv_cache[:, 1],
385 + key_cache=key_cache,
386 + value_cache=value_cache,
387 qkv=qkv_decode,
388 cos_sin=self.cos_sin_cache,
389 num_seqlen_per_req=num_seq_kvcache,
@@ -401,8 +404,8 @@ class HpcRopeNorm(CustomOp, HpcModule):
404 attn_metadata.hpc_split_k_flag = split_k_flag
405 else:
406 hpc.rope_norm_store_kv(
404 - kv_cache[:, 0],
405 - kv_cache[:, 1],
407 + key_cache,
408 + value_cache,
409 qkv_decode,
410 self.cos_sin_cache,
411 num_seq_kvcache,
vllm/model_executor/models/deepseek_v2.py
+5
@@ -645,6 +645,11 @@ class DeepseekV32IndexerCache(torch.nn.Module, AttentionLayerBase):
645 raise ValueError(f"Duplicate layer name: {prefix}")
646 compilation_config.static_forward_context[prefix] = self
647
648 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
649 + # [B, H=1, N, C] -> [B, N, C]: the indexer kernels and
650 + # kv_cache_as_quant_view index a 3-D block-major cache.
651 + self.kv_cache = kv_cache.squeeze(1)
652 +
653 def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
654 return MLAAttentionSpec(
655 block_size=self.cache_config.block_size,
vllm/model_executor/models/extract_hidden_states.py
+3 -15
@@ -80,14 +80,14 @@ def dummy_attention(layer_name, _placeholder):
80
81 def basic_cache(
82 to_cache: torch.Tensor, # shape: [seq_len, num_heads, head_size]
83 - kv_cache: torch.Tensor, # shape: [num_blocks, block_size, num_heads, head_size]
83 + kv_cache: torch.Tensor, # shape: [num_blocks, num_heads, block_size, head_size]
84 slot_mapping: torch.Tensor, # shape: [seq_len]
85 ):
86 # Padding slots are -1; redirect them to the null block (block 0, never
87 # allocated to a request) so the scatter stays branch-free and sync-free.
88 - block_size = kv_cache.shape[1]
88 + block_size = kv_cache.shape[2]
89 slot_mapping = slot_mapping.clamp_min(0)
90 - kv_cache[slot_mapping // block_size, slot_mapping % block_size] = to_cache
90 + kv_cache[slot_mapping // block_size, :, slot_mapping % block_size] = to_cache
91
92
93 ######### CacheOnlyAttentionBackend ########
@@ -123,18 +123,6 @@ class CacheOnlyAttentionBackend(AttentionBackend):
123 def get_impl_cls() -> type["CacheOnlyAttentionImpl"]:
124 return CacheOnlyAttentionImpl
125
126 - @staticmethod
127 - def get_kv_cache_shape(
128 - num_blocks: int,
129 - block_size: int,
130 - num_kv_heads: int,
131 - head_size: int,
132 - cache_dtype_str: str = "auto",
133 - ) -> tuple[int, ...]:
134 - # We set `num_kv_heads = num_hidden_layers` and `head_size = hidden_size`
135 - # We also don't use a k/v (2) dim
136 - return (num_blocks, block_size, num_kv_heads, head_size)
137 -
126 @staticmethod
127 def get_builder_cls() -> type["CacheOnlyAttentionMetadataBuilder"]:
128 return CacheOnlyAttentionMetadataBuilder
vllm/model_executor/models/whisper_causal.py
+6 -22
@@ -5,6 +5,7 @@ import functools
5 import logging
6 import math
7 from dataclasses import replace
8 +from fractions import Fraction
9 from functools import partial
10
11 import torch
@@ -128,14 +129,10 @@ def create_whisper_attention_backend_with_block_pooling(
129 vllm_config: VllmConfig,
130 device: torch.device,
131 ):
131 - assert kv_cache_spec.num_kv_heads % block_pool_size == 0
132 - # Scale pooled-unit quantities back to unpooled (encoder-token)
133 - # units for the underlying attention metadata: the KV cache spec
134 - # counts blocks in pooled units, but the kernel runs on the
135 - # `block_pool_size`x-expanded sequence built below.
136 - spec_overrides = dict(
132 + kv_cache_spec = replace(
133 + kv_cache_spec,
134 block_size=kv_cache_spec.block_size * block_pool_size,
138 - num_kv_heads=kv_cache_spec.num_kv_heads // block_pool_size,
135 + tokens_per_state=1,
136 )
137 if isinstance(kv_cache_spec, SlidingWindowSpec):
138 # The manager keeps `sliding_window` in pooled units; the kernel
@@ -143,8 +140,7 @@ def create_whisper_attention_backend_with_block_pooling(
140 # unpooled units (`get_kv_cache_spec` sizes the pooled window so
141 # this window always stays resident).
142 assert sliding_window is not None
146 - spec_overrides["sliding_window"] = sliding_window
147 - kv_cache_spec = replace(kv_cache_spec, **spec_overrides)
143 + kv_cache_spec = replace(kv_cache_spec, sliding_window=sliding_window)
144 super().__init__(kv_cache_spec, layer_names, vllm_config, device)
145 # Override model_config-derived values with the actual
146 # encoder values from kv_cache_spec
@@ -261,18 +257,6 @@ def create_whisper_attention_backend_with_block_pooling(
257 overrides={
258 "get_builder_cls": lambda: WhisperCausalAttentionWithBlockPoolingBuilder,
259 "get_impl_cls": lambda: WhisperCausalAttentionWithBlockPoolingImpl,
264 - "get_kv_cache_shape": lambda num_blocks,
265 - block_size,
266 - num_kv_heads,
267 - head_size,
268 - cache_dtype_str: underlying_attn_backend.get_kv_cache_shape(
269 - num_blocks,
270 - # we stretch each block by `block_pool_size`
271 - block_size * block_pool_size,
272 - num_kv_heads // block_pool_size,
273 - head_size,
274 - cache_dtype_str,
275 - ),
260 "forward_includes_kv_cache_update": True,
261 },
262 )
@@ -341,7 +325,7 @@ class WhisperCausalAttentionWithBlockPooling(Attention):
325 assert isinstance(kv_cache_spec, AttentionSpec)
326 kv_cache_spec = replace(
327 kv_cache_spec,
344 - num_kv_heads=self.block_pool_size * kv_cache_spec.num_kv_heads,
328 + tokens_per_state=Fraction(1, self.block_pool_size),
329 )
330 if isinstance(kv_cache_spec, SlidingWindowSpec):
331 # The manager counts blocks in pooled units, so express the window in
vllm/models/deepseek_v4/attention.py
+20 -4
@@ -653,7 +653,8 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
653 # per-tensor fp8 writes a separately-allocated fp8 q and quantizes the
654 # KV row.
655 block_size = swa_metadata.block_size
656 - swa_kv_cache_3d = swa_kv_cache.view(-1, block_size, self.head_dim)
656 + assert swa_kv_cache.shape[1:] == (block_size, self.head_dim)
657 + swa_kv_cache_3d = swa_kv_cache
658 if cache_dtype == torch.bfloat16:
659 torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert(
660 q,
@@ -684,6 +685,17 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
685 )
686 return q_fp8
687
688 + def _global_topk_output_buffers(
689 + self, topk_indices: torch.Tensor
690 + ) -> tuple[torch.Tensor, torch.Tensor] | None:
691 + if self.compress_ratio != 4 or self.eager_scratch_pool is None:
692 + return None
693 + return self.eager_scratch_pool.global_topk_outputs(topk_indices)
694 +
695 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
696 + # [B, H=1, N, C] -> [B, N, C]
697 + self.kv_cache = kv_cache.squeeze(1)
698 +
699 def get_attn_backend(self) -> type[AttentionBackend]:
700 return self.backend_cls
701
@@ -701,7 +713,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
713 num_kv_heads=1,
714 head_size=self.head_dim,
715 dtype=torch.uint8 if uses_fp8_ds_mla_layout else self.kv_cache_torch_dtype,
704 - compress_ratio=self.compress_ratio,
716 + tokens_per_state=self.compress_ratio,
717 cache_dtype_str=self.kv_cache_dtype,
718 alignment=576 if uses_fp8_ds_mla_layout else 512,
719 model_version="deepseek_v4",
@@ -733,16 +745,20 @@ class DeepseekV4IndexerCache(torch.nn.Module, AttentionLayerBase):
745 raise ValueError(f"Duplicate layer name: {prefix}")
746 compilation_config.static_forward_context[prefix] = self
747
748 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
749 + # [B, H=1, N, C] -> [B, N, C]
750 + self.kv_cache = kv_cache.squeeze(1)
751 +
752 def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
753 # head_dim already carries the fp8 scale padding
738 - # compress_ratio=1 for V3.2, >1 for DeepseekV4; both use the same cache layout.
754 + # tokens_per_state=1 for V3.2, >1 for DeepseekV4; same cache layout.
755 uses_fp8_ds_mla_layout = vllm_config.cache_config.cache_dtype == "fp8_ds_mla"
756 return MLAAttentionSpec(
757 block_size=self.cache_config.block_size,
758 num_kv_heads=1,
759 head_size=self.head_dim,
760 dtype=self.dtype,
745 - compress_ratio=self.compress_ratio,
761 + tokens_per_state=self.compress_ratio,
762 # 576B for FlashMLA packing; 512B for FlashInfer sparse (#44577).
763 alignment=576 if uses_fp8_ds_mla_layout else 512,
764 )
vllm/models/deepseek_v4/compressor.py
+4 -19
@@ -75,25 +75,6 @@ class CompressorBackend(AttentionBackend):
75 def get_builder_cls() -> type["CompressorMetadataBuilder"]:
76 return CompressorMetadataBuilder
77
78 - @staticmethod
79 - def get_kv_cache_shape(
80 - num_blocks: int,
81 - block_size: int,
82 - num_kv_heads: int,
83 - head_size: int,
84 - cache_dtype_str: str = "auto",
85 - ) -> tuple[int, ...]:
86 - assert num_kv_heads == 1
87 - return (num_blocks, block_size, head_size)
88 -
89 - @staticmethod
90 - def get_kv_cache_stride_order(
91 - include_num_layers_dimension: bool = False,
92 - ) -> tuple[int, ...]:
93 - if include_num_layers_dimension:
94 - return (0, 1, 2, 3)
95 - return (0, 1, 2)
96 -
78
79 @dataclass
80 class CompressorMetadata:
@@ -185,6 +166,10 @@ class CompressorStateCache(torch.nn.Module, AttentionLayerBase):
166 else:
167 raise ValueError(f"Invalid compress ratio: {compress_ratio}")
168
169 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
170 + # [B, H=1, N, C] -> [B, N, C]
171 + self.kv_cache = kv_cache.squeeze(1)
172 +
173 def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
174 # fp8_ds_mla is the UE8M0 paged layout and needs 576B alignment. Plain
175 # full-cache rows share state pages with contiguous KV pages, so padding
vllm/models/deepseek_v4/nvidia/flashinfer_sparse.py
-21
@@ -22,7 +22,6 @@ from vllm.models.deepseek_v4.sparse_mla import (
22 DeepseekV4FlashMLAMetadata,
23 DeepseekV4SparseMLABackend,
24 )
25 -from vllm.platforms import current_platform
25 from vllm.platforms.interface import DeviceCapability
26 from vllm.utils.flashinfer import flashinfer_trtllm_batch_decode_sparse_mla_dsv4
27 from vllm.v1.attention.backend import MultipleOf
@@ -165,26 +164,6 @@ class DeepseekV4FlashInferMLASparseBackend(DeepseekV4SparseMLABackend):
164 return None
165 return "FLASHINFER_MLA_SPARSE_DSV4 requires SM10x or SM12x"
166
168 - @staticmethod
169 - def get_kv_cache_shape(
170 - num_blocks: int,
171 - block_size: int,
172 - num_kv_heads: int,
173 - head_size: int,
174 - cache_dtype_str: str = "auto",
175 - ) -> tuple[int, ...]:
176 - device_capability = current_platform.get_device_capability()
177 - if device_capability is not None and device_capability.major == 12:
178 - return DeepseekV4SparseMLABackend.get_kv_cache_shape(
179 - num_blocks,
180 - block_size,
181 - num_kv_heads,
182 - head_size,
183 - cache_dtype_str,
184 - )
185 - assert num_kv_heads == 1
186 - return (num_blocks, block_size, head_size)
187 -
167
168 class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
169 """FlashInfer TRTLLM-gen sparse MLA attention layer for SM100 DeepSeek V4."""
vllm/models/deepseek_v4/sparse_mla.py
+3 -18
@@ -88,21 +88,6 @@ class DeepseekV4SparseMLABackend(AttentionBackend):
88 def supports_compute_capability(cls, capability: DeviceCapability) -> bool:
89 return capability.major in [9, 10]
90
91 - @staticmethod
92 - def get_kv_cache_shape(
93 - num_blocks: int,
94 - block_size: int,
95 - num_kv_heads: int,
96 - head_size: int,
97 - cache_dtype_str: str = "auto",
98 - ) -> tuple[int, ...]:
99 - if cache_dtype_str == "fp8_ds_mla":
100 - # DeepseekV4 main MLA: 584B per token (448 NoPE + 128 RoPE + 8 fp8 scale).
101 - # head_size passed in is the semantic head_dim (512).
102 - return (num_blocks, block_size, 584)
103 - else:
104 - return (num_blocks, block_size, head_size)
105 -
91
92 @dataclass
93 class DeepseekV4FlashMLAMetadata(AttentionMetadata):
@@ -151,8 +136,8 @@ class DeepseekV4SparseMLAMetadataBuilder(
136 (max_num_batched_tokens,), dtype=torch.int32, device=device
137 )
138
154 - assert hasattr(self.kv_cache_spec, "compress_ratio")
155 - self.compress_ratio = self.kv_cache_spec.compress_ratio
139 + assert isinstance(self.kv_cache_spec.tokens_per_state, int)
140 + self.compress_ratio = self.kv_cache_spec.tokens_per_state
141
142 # Pre-allocate compressed slot mapping buffer for CUDA graph address
143 # stability when compress_ratio > 1.
@@ -206,7 +191,7 @@ class DeepseekV4SparseMLAMetadataBuilder(
191 cm.query_start_loc,
192 cm.seq_lens,
193 cm.block_table_tensor.clamp_(min=0),
209 - int(self.kv_cache_spec.storage_block_size),
194 + int(self.kv_cache_spec.num_states),
195 self.compress_ratio,
196 out=self.compressed_slot_mapping_buffer,
197 )
vllm/models/inkling/amd/sconv_swa_attn.py
-26
@@ -107,32 +107,6 @@ class InklingSconvBackend(AttentionBackend):
107 def get_name() -> str:
108 return "INKLING_SCONV_SWA"
109
110 - @classmethod
111 - def indexes_kv_by_block_stride(cls) -> bool:
112 - # num_blocks is the outermost dim (HND, see get_kv_cache_shape), so the
113 - # padded conv page is read through a strided view.
114 - return True
115 -
116 - @staticmethod
117 - def get_kv_cache_shape(
118 - num_blocks: int,
119 - block_size: int,
120 - num_kv_heads: int,
121 - head_size: int,
122 - cache_dtype_str: str = "auto",
123 - ) -> tuple[int, ...]:
124 - # HND, num-blocks-first, head-major: [num_blocks, H, N, D].
125 - return (num_blocks, num_kv_heads, block_size, head_size)
126 -
127 - @staticmethod
128 - def get_kv_cache_stride_order(
129 - include_num_layers_dimension: bool = False,
130 - ) -> tuple[int, ...]:
131 - # Identity: physical layout == logical [num_blocks, H, N, D].
132 - if include_num_layers_dimension:
133 - return (0, 1, 2, 3, 4)
134 - return (0, 1, 2, 3)
135 -
110 @staticmethod
111 def get_impl_cls():
112 raise NotImplementedError(
vllm/models/inkling/nvidia/sconv_swa_attn.py
-26
@@ -107,32 +107,6 @@ class InklingSconvBackend(AttentionBackend):
107 def get_name() -> str:
108 return "INKLING_SCONV_SWA"
109
110 - @classmethod
111 - def indexes_kv_by_block_stride(cls) -> bool:
112 - # num_blocks is the outermost dim (HND, see get_kv_cache_shape), so the
113 - # padded conv page is read through a strided view.
114 - return True
115 -
116 - @staticmethod
117 - def get_kv_cache_shape(
118 - num_blocks: int,
119 - block_size: int,
120 - num_kv_heads: int,
121 - head_size: int,
122 - cache_dtype_str: str = "auto",
123 - ) -> tuple[int, ...]:
124 - # HND, num-blocks-first, head-major: [num_blocks, H, N, D].
125 - return (num_blocks, num_kv_heads, block_size, head_size)
126 -
127 - @staticmethod
128 - def get_kv_cache_stride_order(
129 - include_num_layers_dimension: bool = False,
130 - ) -> tuple[int, ...]:
131 - # Identity: physical layout == logical [num_blocks, H, N, D].
132 - if include_num_layers_dimension:
133 - return (0, 1, 2, 3, 4)
134 - return (0, 1, 2, 3)
135 -
110 @staticmethod
111 def get_impl_cls():
112 raise NotImplementedError(
vllm/models/kimi_k3/nvidia/mla.py
+4
@@ -384,6 +384,10 @@ class MultiHeadLatentAttention(nn.Module, AttentionLayerBase):
384 # ------------------------------------------------------------------
385 # AttentionLayerBase interface
386 # ------------------------------------------------------------------
387 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
388 + # [B, H=1, N, C] -> [B, N, C]
389 + self.kv_cache = kv_cache.squeeze(1)
390 +
391 def get_attn_backend(self) -> type[AttentionBackend]:
392 return self.attn_backend
393
vllm/models/minimax_m3/amd/model.py
+8
@@ -728,6 +728,12 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
728
729 def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec | None:
730 # Main GQA K/V cache. Block size may change after load, refresh it.
731 + # AITER sparse PA wants K and V as two separate head groups
732 + # (H folded into the content dim).
733 + sparse_pa = self.use_aiter_sparse_pa
734 + kv_bytes = (
735 + self.num_kv_heads * self.head_dim * self.kv_cache_torch_dtype.itemsize
736 + )
737 return FullAttentionSpec(
738 block_size=vllm_config.cache_config.block_size,
739 num_kv_heads=self.num_kv_heads,
@@ -735,6 +741,8 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
741 head_size_v=self.head_dim,
742 dtype=self.kv_cache_torch_dtype,
743 kv_quant_mode=get_kv_quant_mode(self.kv_cache_dtype),
744 + num_head_slots=2 if sparse_pa else None,
745 + state_content_bytes=kv_bytes if sparse_pa else None,
746 )
747
748 def _ensure_aiter_sparse_pa_kv_cache(self) -> None:
vllm/models/minimax_m3/common/indexer.py
+4 -19
@@ -97,25 +97,6 @@ class MiniMaxM3IndexerBackend(AttentionBackend):
97 def is_sparse(cls) -> bool:
98 return True
99
100 - @staticmethod
101 - def get_kv_cache_shape(
102 - num_blocks: int,
103 - block_size: int,
104 - num_kv_heads: int,
105 - head_size: int,
106 - cache_dtype_str: str = "auto",
107 - ) -> tuple[int, ...]:
108 - return (num_blocks, block_size, head_size)
109 -
110 - @staticmethod
111 - def get_kv_cache_stride_order(
112 - include_num_layers_dimension: bool = False,
113 - ) -> tuple[int, ...]:
114 - if include_num_layers_dimension:
115 - # M3 does not use cross-layer (per-layer-stacked) KV blocks.
116 - raise NotImplementedError
117 - return (0, 1, 2)
118 -
100
101 class MiniMaxM3IndexerCache(nn.Module, AttentionLayerBase):
102 """Side KV cache for the indexer's per-token index keys (key-only).
@@ -156,6 +137,10 @@ class MiniMaxM3IndexerCache(nn.Module, AttentionLayerBase):
137 raise ValueError(f"Duplicate layer name: {prefix}")
138 compilation_config.static_forward_context[prefix] = self
139
140 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
141 + # [B, H=1, N, C] -> [B, N, C]
142 + self.kv_cache = kv_cache.squeeze(1)
143 +
144 def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
145 # Key-only: MLAAttentionSpec budgets one vector/token (not 2x for K+V).
146 return MLAAttentionSpec(
vllm/models/minimax_m3/common/sparse_attention.py
+11 -42
@@ -51,10 +51,13 @@ from vllm.v1.attention.backend import (
51 MultipleOf,
52 )
53 from vllm.v1.attention.backends.utils import (
54 - get_kv_cache_layout,
54 split_decodes_and_prefills,
55 )
57 -from vllm.v1.kv_cache_interface import AttentionSpec, is_quantized_kv_cache
56 +from vllm.v1.kv_cache_interface import (
57 + AttentionSpec,
58 + KVCacheLayout,
59 + is_quantized_kv_cache,
60 +)
61
62 logger = init_logger(__name__)
63
@@ -112,47 +115,13 @@ class MiniMaxM3SparseBackend(AttentionBackend):
115 def is_sparse(cls) -> bool:
116 return True
117
115 - @staticmethod
116 - def get_kv_cache_shape(
117 - num_blocks: int,
118 - block_size: int,
119 - num_kv_heads: int,
120 - head_size: int,
121 - cache_dtype_str: str = "auto",
122 - ) -> tuple[int, ...]:
123 - if minimax_m3_use_aiter_sparse_pa(num_kv_heads):
124 - # AITER's assembly paged-attention kernels require independently
125 - # contiguous K and V storage. Keep that specialized layout behind
126 - # the shuffle flag while every other implementation uses the
127 - # packed-content contract introduced by #44455.
128 - return (num_blocks, 2, block_size, num_kv_heads, head_size)
129 - # K and V are packed into the content dim: logical (B, H, N, 2*hs).
130 - return (num_blocks, num_kv_heads, block_size, 2 * head_size)
131 -
132 - @staticmethod
133 - def get_kv_cache_stride_order(
134 - include_num_layers_dimension: bool = False,
135 - ) -> tuple[int, ...]:
136 - # `stride_order` indicates the permutation that gets us from
137 - # `get_kv_cache_shape` (logical (B, H, N, 2*hs)) to the actual memory
138 - # layout we want.
139 - if include_num_layers_dimension:
140 - raise NotImplementedError # no cross-layer KV blocks in M3
118 + @classmethod
119 + def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...] | None:
120 + # AITER sparse PA unbinds K and V as separate contiguous regions, which
121 + # requires the head dimension to sit outside the block dimension.
122 if _minimax_m3_aiter_sparse_pa_requested():
142 - # The AITER page-16 sparse PA path reinterprets the K and V slices
143 - # as separate SHUFFLE caches. Keep K/V physically separated so
144 - # kv_cache[:, 0] and kv_cache[:, 1] are contiguous byte ranges.
145 - return (1, 0, 2, 3, 4)
146 - cache_layout = get_kv_cache_layout()
147 - if cache_layout == "NHD":
148 - # (num_blocks, block_size, num_kv_heads, 2*head_size)
149 - stride_order = (0, 2, 1, 3)
150 - elif cache_layout == "HND":
151 - # (num_blocks, num_kv_heads, block_size, 2*head_size)
152 - stride_order = (0, 1, 2, 3)
153 - else:
154 - raise ValueError(f"Unknown cache layout format {cache_layout}.")
155 - return stride_order
123 + return (KVCacheLayout.LHBNC,)
124 + return super().supported_kv_cache_layouts()
125
126
127 @dataclass
vllm/utils/torch_utils.py
+15
@@ -117,6 +117,21 @@ def is_strictly_contiguous(t: torch.Tensor) -> bool:
117 return True
118
119
120 +def is_non_overlapping_and_dense(t: torch.Tensor) -> bool:
121 + """Check if the tensor's elements cover one gapless, non-overlapping byte
122 + range, in any dimension order (i.e. a permuted view of a contiguous
123 + tensor); ``is_contiguous()`` additionally requires row-major order.
124 + """
125 + expected_stride = 1
126 + for size, stride in sorted(zip(t.shape, t.stride()), key=lambda p: p[1]):
127 + if size == 1:
128 + continue
129 + if stride != expected_stride:
130 + return False
131 + expected_stride *= size
132 + return True
133 +
134 +
135 def canonicalize_singleton_dim_strides(t: torch.Tensor) -> torch.Tensor:
136 """Fix degenerate strides on size=1 dimensions for CUDA TMA compatibility.
137
vllm/v1/attention/backend.py
+10 -98
@@ -23,10 +23,14 @@ if TYPE_CHECKING:
23 from vllm.model_executor.layers.linear import ColumnParallelLinear
24 from vllm.model_executor.layers.quantization.utils.quant_utils import QuantKey
25 from vllm.platforms.interface import DeviceCapability
26 - from vllm.v1.attention.backends.utils import KVCacheLayoutType
27 - from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheSpec, KVQuantMode
26 + from vllm.v1.kv_cache_interface import (
27 + AttentionSpec,
28 + KVCacheLayout,
29 + KVCacheSpec,
30 + KVQuantMode,
31 + )
32
29 -from vllm.v1.kv_cache_interface import get_kv_quant_mode
33 +from vllm.v1.kv_cache_interface import KVCacheLayout, get_kv_quant_mode
34
35
36 class AttentionType(str, Enum):
@@ -84,68 +88,6 @@ class AttentionBackend(ABC):
88 def get_builder_cls(): # -> Type["AttentionMetadataBuilder"]:
89 raise NotImplementedError
90
87 - @staticmethod
88 - @abstractmethod
89 - def get_kv_cache_shape(
90 - num_blocks: int,
91 - block_size: int,
92 - num_kv_heads: int,
93 - head_size: int,
94 - cache_dtype_str: str = "auto",
95 - ) -> tuple[int, ...]:
96 - raise NotImplementedError
97 -
98 - @classmethod
99 - def get_kv_cache_block_dim(
100 - cls,
101 - block_size: int,
102 - num_kv_heads: int,
103 - head_size: int,
104 - cache_dtype_str: str = "auto",
105 - ) -> int:
106 - """Discover which tensor dim is the block index, since different
107 - backends lay out dims differently."""
108 - _S = 1234567
109 - shape = cls.get_kv_cache_shape(
110 - _S,
111 - block_size,
112 - num_kv_heads,
113 - head_size,
114 - cache_dtype_str=cache_dtype_str,
115 - )
116 - return shape.index(_S)
117 -
118 - @staticmethod
119 - def get_kv_cache_stride_order(
120 - include_num_layers_dimension: bool = False,
121 - ) -> tuple[int, ...]:
122 - """
123 - Get the physical (memory layout) ordering of the kv cache dimensions.
124 - Standard attention backends pack K and V into the content dim, giving
125 - the logical shape [num_blocks, num_heads, block_size, 2 * head_size].
126 - e.g. if get_kv_cache_stride_order returns (0, 2, 1, 3) then the physical
127 - ordering of dimensions is
128 - [num_blocks, block_size, num_heads, 2 * head_size].
129 -
130 - If this function is unimplemented / raises NotImplementedError,
131 - the physical layout of the KV cache will match the logical shape.
132 -
133 - Args:
134 - include_num_layers_dimension: if True, includes an additional
135 - num_layers dimension, which is assumed to be prepended
136 - to the logical KV cache shape.
137 - With the above example, a return value (1, 0, 3, 2, 4)
138 - corresponds to
139 - [num_blocks, num_layers, block_size, num_heads, 2 * head_size].
140 -
141 - If an additional dimension is NOT included in the returned
142 - tuple, the physical layout will not include a layers dimension.
143 -
144 - Returns:
145 - A tuple of ints which is a permutation of range(len(shape)).
146 - """
147 - raise NotImplementedError
148 -
91 @classmethod
92 def full_cls_name(cls) -> tuple[str, str]:
93 return (cls.__module__, cls.__qualname__)
@@ -215,38 +157,6 @@ class AttentionBackend(ABC):
157
158 return min(s.base if isinstance(s, MultipleOf) else s for s in supported_sizes)
159
218 - @classmethod
219 - def indexes_kv_by_block_stride(cls) -> bool:
220 - """Whether the backend reads KV pages by the runtime block stride.
221 -
222 - True when ``num_blocks`` is the outermost physical dimension of the KV
223 - cache, so the backend tolerates a non-contiguous block dim. This gates
224 - page size padding and cross-layer uniform KV layout.
225 -
226 - Returns:
227 - True if the backend's physical KV layout is num-blocks-first. False
228 - otherwise, including when the backend does not define a layered
229 - stride order.
230 - """
231 - try:
232 - kv_cache_stride_order = cls.get_kv_cache_stride_order(
233 - include_num_layers_dimension=False
234 - )
235 - layered_kv_cache_stride_order = cls.get_kv_cache_stride_order(
236 - include_num_layers_dimension=True
237 - )
238 - except (AttributeError, NotImplementedError):
239 - return False
240 -
241 - # Check that attention backend includes a layers dimension.
242 - if len(layered_kv_cache_stride_order) != len(kv_cache_stride_order) + 1:
243 - return False
244 -
245 - # stride_order[0] == 0 means num_layers stays first in physical
246 - # layout (identity permutation), so indexing by block stride is
247 - # not supported.
248 - return layered_kv_cache_stride_order[0] != 0
249 -
160 @classmethod
161 def is_mla(cls) -> bool:
162 return False
@@ -438,7 +348,9 @@ class AttentionBackend(ABC):
348 return invalid_reasons
349
350 @classmethod
441 - def get_required_kv_cache_layout(cls) -> "KVCacheLayoutType | None":
351 + def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...] | None:
352 + """Layouts this backend's kernels can consume, most preferred first, or
353 + None when the kernels consume any layout and express no preference."""
354 return None
355
356 @classmethod
vllm/v1/attention/backends/cpu_attn.py
+6 -15
@@ -30,13 +30,13 @@ from vllm.v1.attention.backend import (
30 MultipleOf,
31 )
32 from vllm.v1.attention.backends.utils import (
33 - KVCacheLayoutType,
33 get_num_attention_heads_from_layers,
34 )
35 from vllm.v1.kv_cache_interface import (
36 AttentionSpec,
37 CrossAttentionSpec,
38 EncoderOnlyAttentionSpec,
39 + KVCacheLayout,
40 )
41
42 logger = init_logger(__name__)
@@ -65,6 +65,11 @@ class CPUAttentionBackend(AttentionBackend):
65 def get_supported_head_sizes(cls) -> list[int]:
66 return [32, 64, 80, 96, 112, 128, 160, 192, 224, 256, 512]
67
68 + @classmethod
69 + def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...]:
70 + # The CPU backend only reads head-major block interiors.
71 + return (KVCacheLayout.LBHNC,)
72 +
73 @staticmethod
74 def get_name() -> str:
75 return "CPU_ATTN"
@@ -96,20 +101,6 @@ class CPUAttentionBackend(AttentionBackend):
101 def get_builder_cls() -> type["CPUAttentionMetadataBuilder"]:
102 return CPUAttentionMetadataBuilder
103
99 - @staticmethod
100 - def get_kv_cache_shape(
101 - num_blocks: int,
102 - block_size: int,
103 - num_kv_heads: int,
104 - head_size: int,
105 - cache_dtype_str: str = "auto",
106 - ) -> tuple[int, ...]:
107 - return num_blocks, num_kv_heads, block_size, 2 * head_size
108 -
109 - @classmethod
110 - def get_required_kv_cache_layout(cls) -> "KVCacheLayoutType | None":
111 - return "HND"
112 -
104 @staticmethod
105 def use_cascade_attention(*args, **kwargs) -> bool:
106 return False
vllm/v1/attention/backends/flash_attn.py
+5 -44
@@ -33,7 +33,6 @@ from vllm.v1.attention.backends.fa_utils import (
33 from vllm.v1.attention.backends.utils import (
34 fill_mm_prefix_query_ranges,
35 get_dcp_local_seq_lens,
36 - get_num_attention_heads_from_layers,
36 )
37 from vllm.v1.attention.ops.dcp import (
38 cp_lse_ag_out_rs,
@@ -65,7 +64,6 @@ from vllm.v1.attention.backend import (
64 AttentionMetadataBuilder,
65 CommonAttentionMetadata,
66 )
68 -from vllm.v1.attention.backends.utils import get_kv_cache_layout
67 from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheSpec
68 from vllm.v1.worker.cp_utils import (
69 run_split_fa2_dcp_context_attention,
@@ -138,43 +136,6 @@ class FlashAttentionBackend(AttentionBackend):
136 def get_builder_cls() -> type["FlashAttentionMetadataBuilder"]:
137 return FlashAttentionMetadataBuilder
138
141 - @staticmethod
142 - def get_kv_cache_shape(
143 - num_blocks: int,
144 - block_size: int,
145 - num_kv_heads: int,
146 - head_size: int,
147 - cache_dtype_str: str = "auto",
148 - ) -> tuple[int, ...]:
149 - if block_size % 16 != 0:
150 - raise ValueError("Block size must be a multiple of 16.")
151 - # K and V are packed into the content dim: logical (B, H, N, 2*D).
152 - return (num_blocks, num_kv_heads, block_size, 2 * head_size)
153 -
154 - @staticmethod
155 - def get_kv_cache_stride_order(
156 - include_num_layers_dimension: bool = False,
157 - ) -> tuple[int, ...]:
158 - # `stride_order` indicates the permutation that gets us from
159 - # `get_kv_cache_shape` (logical (B, H, N, 2*D)) to the actual memory
160 - # layout we want.
161 - cache_layout = get_kv_cache_layout()
162 - if cache_layout == "NHD" and include_num_layers_dimension:
163 - # (num_blocks, num_layers, block_size, num_kv_heads, 2*head_size)
164 - return (1, 0, 3, 2, 4)
165 - elif cache_layout == "NHD":
166 - # (num_blocks, block_size, num_kv_heads, 2*head_size)
167 - stride_order = (0, 2, 1, 3)
168 - elif cache_layout == "HND" and include_num_layers_dimension:
169 - # (num_blocks, num_kv_heads, num_layers, block_size, 2*head_size)
170 - return (1, 2, 0, 3, 4)
171 - elif cache_layout == "HND":
172 - # (num_blocks, num_kv_heads, block_size, 2*head_size)
173 - stride_order = (0, 1, 2, 3)
174 - else:
175 - raise ValueError(f"Unknown cache layout format {cache_layout}.")
176 - return stride_order
177 -
139 @classmethod
140 def supports_head_size(cls, head_size: int) -> bool:
141 if head_size % 8 != 0:
@@ -436,12 +397,12 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
397 self.compilation_config = vllm_config.compilation_config
398 self.attention_config = vllm_config.attention_config
399
439 - self.num_heads_q = get_num_attention_heads_from_layers(
440 - vllm_config, layer_names
441 - ) or self.model_config.get_num_attention_heads(self.parallel_config)
442 - self.num_heads_kv = kv_cache_spec.num_kv_heads
400 + self.num_heads_q = self.model_config.get_num_attention_heads(
401 + self.parallel_config
402 + )
403 + self.num_heads_kv = self.model_config.get_num_kv_heads(self.parallel_config)
404 self.kv_cache_dtype = kv_cache_spec.dtype
444 - self.headdim = kv_cache_spec.head_size
405 + self.headdim = self.model_config.get_head_size()
406 self.block_size = kv_cache_spec.block_size
407
408 self.max_num_splits = 0 # No upper bound on the number of splits.
vllm/v1/attention/backends/flash_attn_diffkv.py
-44
@@ -21,7 +21,6 @@ from vllm.v1.attention.ops.triton_reshape_and_cache_flash import (
21
22 if is_flash_attn_varlen_func_available():
23 from vllm.v1.attention.backends.fa_utils import flash_attn_varlen_func
24 -from vllm.v1.attention.backends.utils import get_kv_cache_layout
24
25 from .flash_attn import (
26 FlashAttentionBackend,
@@ -73,49 +72,6 @@ class FlashAttentionDiffKVBackend(FlashAttentionBackend):
72 def get_impl_cls() -> type["FlashAttentionImpl"]:
73 return FlashAttentionDiffKVImpl
74
76 - @staticmethod
77 - def get_kv_cache_shape(
78 - num_blocks: int,
79 - block_size: int,
80 - num_kv_heads: int,
81 - head_size: int,
82 - cache_dtype_str: str = "auto",
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,
90 - num_kv_heads,
91 - block_size,
92 - head_size + FlashAttentionDiffKVBackend.head_size_v,
93 - )
94 -
95 - @staticmethod
96 - def get_kv_cache_stride_order(
97 - include_num_layers_dimension: bool = False,
98 - ) -> tuple[int, ...]:
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:
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":
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:
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":
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
118 -
75
76 class FlashAttentionDiffKVImpl(FlashAttentionImpl):
77 vllm_flash_attn_version: int | None
vllm/v1/attention/backends/flashinfer.py
+36 -61
@@ -67,9 +67,8 @@ from vllm.v1.attention.backend import (
67 MultipleOf,
68 )
69 from vllm.v1.attention.backends.utils import (
70 - KVCacheLayoutType,
70 get_dcp_local_seq_lens,
72 - get_kv_cache_layout,
71 + get_flashinfer_layout_string,
72 get_num_attention_heads_from_layers,
73 get_per_layer_parameters,
74 infer_global_hyperparameters,
@@ -83,6 +82,7 @@ from vllm.v1.attention.ops.dcp import (
82 from vllm.v1.attention.ops.merge_attn_states import merge_attn_states
83 from vllm.v1.kv_cache_interface import (
84 AttentionSpec,
85 + KVCacheLayout,
86 KVCacheSpec,
87 KVQuantMode,
88 UniformTypeKVCacheSpecs,
@@ -282,6 +282,7 @@ def trtllm_prefill_attn_kvfp8_dequant(
282 class BatchDCPPrefillWrapper:
283 def __init__(
284 self,
285 + kv_layout: str,
286 workspace_buffer: torch.Tensor | None = None,
287 dcp_a2a: bool = False,
288 ):
@@ -289,9 +290,7 @@ class BatchDCPPrefillWrapper:
290 self._dcp_combine = partial(dcp_a2a_lse_reduce, is_lse_base_on_e=False)
291 else:
292 self._dcp_combine = partial(cp_lse_ag_out_rs, is_lse_base_on_e=False)
292 - self._context = BatchPrefillWithPagedKVCacheWrapper(
293 - workspace_buffer, get_kv_cache_layout()
294 - )
293 + self._context = BatchPrefillWithPagedKVCacheWrapper(workspace_buffer, kv_layout)
294 self._new_tokens = BatchPrefillWithRaggedKVCacheWrapper(workspace_buffer)
295
296 def plan(
@@ -467,44 +466,6 @@ class FlashInferBackend(AttentionBackend):
466 def get_builder_cls() -> type["FlashInferMetadataBuilder"]:
467 return FlashInferMetadataBuilder
468
470 - @staticmethod
471 - def get_kv_cache_shape(
472 - num_blocks: int,
473 - block_size: int,
474 - num_kv_heads: int,
475 - head_size: int,
476 - cache_dtype_str: str = "auto",
477 - ) -> tuple[int, ...]:
478 - if cache_dtype_str.startswith("nvfp4"):
479 - full_dim = nvfp4_kv_cache_full_dim(head_size)
480 - return (num_blocks, 2 * num_kv_heads, block_size, full_dim)
481 - # Pack K and V in the content dim (B, H, N, 2*hs).
482 - return (num_blocks, num_kv_heads, block_size, 2 * head_size)
483 -
484 - @staticmethod
485 - def get_kv_cache_stride_order(
486 - include_num_layers_dimension: bool = False,
487 - ) -> tuple[int, ...]:
488 - # `stride_order` indicates the permutation that gets us from
489 - # `get_kv_cache_shape` (logical (B, H, N, 2*hs)) to the actual memory
490 - # layout we want.
491 - cache_layout = get_kv_cache_layout()
492 - if cache_layout == "NHD" and include_num_layers_dimension:
493 - # (num_blocks, num_layers, block_size, num_kv_heads, 2*head_size)
494 - return (1, 0, 3, 2, 4)
495 - elif cache_layout == "NHD":
496 - # (num_blocks, block_size, num_kv_heads, 2*head_size)
497 - stride_order = (0, 2, 1, 3)
498 - elif cache_layout == "HND" and include_num_layers_dimension:
499 - # (num_blocks, num_kv_heads, num_layers, block_size, 2*head_size)
500 - return (1, 2, 0, 3, 4)
501 - elif cache_layout == "HND":
502 - # (num_blocks, num_kv_heads, block_size, 2*head_size)
503 - stride_order = (0, 1, 2, 3)
504 - else:
505 - raise ValueError(f"Unknown cache layout format {cache_layout}.")
506 - return stride_order
507 -
469 @staticmethod
470 def get_dtype_for_flashinfer(kv_cache_dtype: str) -> torch.dtype:
471 if kv_cache_dtype in ("fp8", "fp8_e4m3"):
@@ -563,11 +524,13 @@ class FlashInferBackend(AttentionBackend):
524 ) and supports_trtllm_attention(is_prefill=True)
525
526 @classmethod
566 - def get_required_kv_cache_layout(cls) -> KVCacheLayoutType | None:
527 + def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...] | None:
528 capability = current_platform.get_device_capability()
529 if capability is not None and capability.major == 10:
569 - return "HND"
570 - return None
530 + # The trtllm-gen kernels consume head-major block interiors; the L/B
531 + # nesting outside the block is immaterial to them.
532 + return (KVCacheLayout.LBHNC, KVCacheLayout.BLHNC)
533 + return super().supported_kv_cache_layouts()
534
535 forward_includes_kv_cache_update: bool = False
536
@@ -945,6 +908,10 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
908 self.paged_kv_indices = self._make_buffer(max_num_pages)
909 self.paged_kv_last_page_len = self._make_buffer(max_num_reqs)
910
911 + @property
912 + def kv_cache_layout(self) -> KVCacheLayout:
913 + return self.cache_config.get_resolved_kv_cache_layout()
914 +
915 # Keep SM90 prefill/decode Q dtype selection in one place.
916 def get_q_data_type(self, is_prefill: bool) -> torch.dtype:
917 # The user sets --attention-config.disable_flashinfer_q_quantization
@@ -1155,7 +1122,7 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1122 self._noncausal_prefill_wrapper = (
1123 BatchAttentionWithAttentionSinkWrapper(
1124 self._get_workspace_buffer(),
1158 - get_kv_cache_layout(),
1125 + get_flashinfer_layout_string(self.kv_cache_layout),
1126 backend="auto",
1127 q_data_type=self.q_data_type_prefill,
1128 kv_data_type=self.kv_cache_dtype,
@@ -1168,7 +1135,7 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1135 self._noncausal_prefill_wrapper = (
1136 BatchPrefillWithPagedKVCacheWrapper(
1137 self._get_workspace_buffer(),
1171 - get_kv_cache_layout(),
1138 + get_flashinfer_layout_string(self.kv_cache_layout),
1139 backend="auto",
1140 )
1141 )
@@ -1177,6 +1144,7 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1144 if self._prefill_wrapper is None:
1145 if self.use_dcp:
1146 self._prefill_wrapper = BatchDCPPrefillWrapper(
1147 + kv_layout=get_flashinfer_layout_string(self.kv_cache_layout),
1148 workspace_buffer=self._get_workspace_buffer(),
1149 dcp_a2a=self.dcp_a2a,
1150 )
@@ -1185,7 +1153,7 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1153 assert not self.is_kvcache_nvfp4
1154 self._prefill_wrapper = BatchAttentionWithAttentionSinkWrapper(
1155 self._get_workspace_buffer(),
1188 - get_kv_cache_layout(),
1156 + get_flashinfer_layout_string(self.kv_cache_layout),
1157 backend="auto",
1158 q_data_type=self.q_data_type_prefill,
1159 kv_data_type=self.kv_cache_dtype,
@@ -1199,7 +1167,7 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1167 backend = "trtllm-gen" if self.is_kvcache_nvfp4 else "auto"
1168 self._prefill_wrapper = BatchPrefillWithPagedKVCacheWrapper(
1169 self._get_workspace_buffer(),
1202 - get_kv_cache_layout(),
1170 + get_flashinfer_layout_string(self.kv_cache_layout),
1171 backend=backend,
1172 )
1173 assert self._prefill_wrapper is not None
@@ -1225,7 +1193,7 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1193 backend = "trtllm-gen" if self.is_kvcache_nvfp4 else "auto"
1194 decode_wrapper = BatchDecodeWithPagedKVCacheWrapper(
1195 self._get_workspace_buffer(),
1228 - get_kv_cache_layout(),
1196 + get_flashinfer_layout_string(self.kv_cache_layout),
1197 use_cuda_graph=use_cudagraph,
1198 paged_kv_indptr_buffer=paged_kv_indptr,
1199 paged_kv_indices_buffer=paged_kv_indices,
@@ -1248,7 +1216,9 @@ class FlashInferMetadataBuilder(AttentionMetadataBuilder[FlashInferMetadata]):
1216 def _get_cascade_wrapper(self):
1217 if self._cascade_wrapper is None:
1218 self._cascade_wrapper = MultiLevelCascadeAttentionWrapper(
1251 - 2, self._get_workspace_buffer(), get_kv_cache_layout()
1219 + 2,
1220 + self._get_workspace_buffer(),
1221 + get_flashinfer_layout_string(self.kv_cache_layout),
1222 )
1223 return self._cascade_wrapper
1224
@@ -1813,6 +1783,8 @@ class FlashInferImpl(AttentionImpl):
1783 num_heads, num_kv_heads, is_prefill=False
1784 )
1785 vllm_config = get_current_vllm_config_or_none()
1786 + # The layout is resolved after model construction, so read it lazily.
1787 + self.cache_config = vllm_config.cache_config if vllm_config else None
1788 # Query pre-quantization needs a single dtype for the whole query tensor.
1789 # SM90 XQA needs BF16/FP16-Q for decode and FP8 for prefill,
1790 # so only enable this for SM100 trtllm-gen where both use FP8-Q.
@@ -1848,6 +1820,11 @@ class FlashInferImpl(AttentionImpl):
1820 else:
1821 self.dcp_combine = partial(cp_lse_ag_out_rs, is_lse_base_on_e=False)
1822
1823 + @property
1824 + def kv_cache_layout(self) -> KVCacheLayout:
1825 + assert self.cache_config is not None
1826 + return self.cache_config.get_resolved_kv_cache_layout()
1827 +
1828 def fused_output_quant_supported(self, quant_key: QuantKey):
1829 # XQA does not support FP8/NVFP4 output, so require trtllm-gen
1830 # (SM100+) here. Without that we cannot fuse the output quant.
@@ -1919,9 +1896,7 @@ class FlashInferImpl(AttentionImpl):
1896 query: shape = [num_tokens, num_heads, head_size]
1897 key: shape = [num_tokens, num_kv_heads, head_size]
1898 value: shape = [num_tokens, num_kv_heads, head_size]
1922 - kv_cache: KV cache tensor with different possible shapes:
1923 - - NHD: [num_blocks, 2, block_size, num_kv_heads, head_size]
1924 - - HND: [num_blocks, 2, num_kv_heads, block_size, head_size]
1899 + kv_cache: [num_blocks, num_kv_heads, block_size, 2*head_size]
1900 attn_metadata: Metadata for attention.
1901 Returns:
1902 shape = [num_tokens, num_heads * head_size]
@@ -2019,7 +1994,7 @@ class FlashInferImpl(AttentionImpl):
1994 if attn_metadata.use_cascade:
1995 # Cascade attention (rare case).
1996 assert attn_metadata.cascade_wrapper is not None
2022 - stride_order = FlashInferBackend.get_kv_cache_stride_order()
1997 + stride_order = self.kv_cache_layout.layer_view_order
1998 if self.is_kvcache_nvfp4:
1999 kv_cache_views = tuple(
2000 cache.permute(*stride_order)
@@ -2039,7 +2014,7 @@ class FlashInferImpl(AttentionImpl):
2014 num_decode_tokens = attn_metadata.num_decode_tokens
2015 num_prefill_tokens = attn_metadata.num_prefill_tokens
2016
2042 - stride_order = FlashInferBackend.get_kv_cache_stride_order()
2017 + stride_order = self.kv_cache_layout.layer_view_order
2018 kv_cache_permute = kv_cache.permute(*stride_order) # HND and contiguous
2019 # Fix degenerate strides on any size-1 dimension (e.g. num_kv_heads=1
2020 # with TP=8). PyTorch permits non-canonical strides on size-1 dims;
@@ -2195,7 +2170,7 @@ class FlashInferImpl(AttentionImpl):
2170 seq_lens_prefill = attn_metadata.prefill.seq_lens
2171
2172 # This path needs to be enabled with VLLM_KV_CACHE_LAYOUT = HND
2198 - assert get_kv_cache_layout() == "HND"
2173 + assert get_flashinfer_layout_string(self.kv_cache_layout) == "HND"
2174 assert is_strictly_contiguous(prefill_query)
2175 assert is_strictly_contiguous(workspace_buffer)
2176 assert is_strictly_contiguous(block_tables_prefill)
@@ -2380,7 +2355,7 @@ class FlashInferImpl(AttentionImpl):
2355 # trtllm-gen needs HND layout on SM100. XQA is selected
2356 # separately on SM90 and does not use this SM100 layout gate.
2357 if decode_with_trtllm_gen:
2383 - assert get_kv_cache_layout() == "HND"
2358 + assert get_flashinfer_layout_string(self.kv_cache_layout) == "HND"
2359 else:
2360 assert decode_with_xqa
2361 assert is_strictly_contiguous(decode_query)
@@ -2426,7 +2401,7 @@ class FlashInferImpl(AttentionImpl):
2401 window_left=self.window_left,
2402 out=output[:num_decode_tokens],
2403 sinks=self.sinks,
2429 - kv_layout=get_kv_cache_layout(),
2404 + kv_layout=get_flashinfer_layout_string(self.kv_cache_layout),
2405 q_len_per_req=q_len_per_req,
2406 mask=attn_metadata.decode.mask,
2407 q_cu_seq_lens=attn_metadata.decode.q_cu_seq_lens,
@@ -2499,7 +2474,7 @@ class FlashInferImpl(AttentionImpl):
2474 sinks=self.sinks,
2475 o_sf_scale=self.o_sf_scale,
2476 out=out,
2502 - kv_layout=get_kv_cache_layout(),
2477 + kv_layout=get_flashinfer_layout_string(self.kv_cache_layout),
2478 backend=attn_metadata.decode.kernel.value,
2479 q_len_per_req=q_len_per_req,
2480 kv_cache_sf=(
vllm/v1/attention/backends/flex_attention.py
+11 -20
@@ -43,7 +43,11 @@ from vllm.v1.attention.backend import (
43 CommonAttentionMetadata,
44 MultipleOf,
45 )
46 -from vllm.v1.kv_cache_interface import AttentionSpec, EncoderOnlyAttentionSpec
46 +from vllm.v1.kv_cache_interface import (
47 + AttentionSpec,
48 + EncoderOnlyAttentionSpec,
49 + KVCacheLayout,
50 +)
51
52 logger = init_logger(__name__)
53
@@ -118,6 +122,12 @@ class FlexAttentionBackend(AttentionBackend):
122 def supports_batch_invariance(cls) -> bool:
123 return True
124
125 + @classmethod
126 + def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...]:
127 + # Flex flattens the (B, N) block/token axes into a single token dim, which
128 + # only LBNHC's strides allow as a zero-copy view of the layer cache.
129 + return (KVCacheLayout.LBNHC,)
130 +
131 @classmethod
132 def supports_mm_prefix(cls) -> bool:
133 """FlexAttention supports full attention for image tokens."""
@@ -127,25 +137,6 @@ class FlexAttentionBackend(AttentionBackend):
137 def get_impl_cls() -> type["FlexAttentionImpl"]:
138 return FlexAttentionImpl
139
130 - @staticmethod
131 - def get_kv_cache_shape(
132 - num_blocks: int,
133 - block_size: int,
134 - num_kv_heads: int,
135 - head_size: int,
136 - cache_dtype_str: str = "auto",
137 - ) -> tuple[int, ...]:
138 - # K and V are packed into the content dim: logical (B, H, N, 2*hs).
139 - return (num_blocks, num_kv_heads, block_size, 2 * head_size)
140 -
141 - @staticmethod
142 - def get_kv_cache_stride_order(
143 - include_num_layers_dimension: bool = False,
144 - ) -> tuple[int, ...]:
145 - if include_num_layers_dimension:
146 - return (1, 0, 3, 2, 4)
147 - return (0, 2, 1, 3)
148 -
140 @staticmethod
141 def get_builder_cls() -> type["FlexAttentionMetadataBuilder"]:
142 return FlexAttentionMetadataBuilder
vllm/v1/attention/backends/hpc_attn.py
+19 -34
@@ -30,10 +30,9 @@ from vllm.v1.attention.backend import (
30 MultipleOf,
31 )
32 from vllm.v1.attention.backends.utils import (
33 - KVCacheLayoutType,
33 split_decodes_and_prefills,
34 )
36 -from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheSpec
35 +from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheLayout, KVCacheSpec
36
37 logger = init_logger(__name__)
38
@@ -265,7 +264,7 @@ class HpcAttnMetadataBuilder(AttentionMetadataBuilder[HpcAttnMetadata]):
264 class HpcAttentionBackend(AttentionBackend):
265 """HPC attention backend (pure attention, no RoPE/Norm).
266
268 - KV cache layout: NHD (num_blocks, 2, block_size, num_kv_heads, head_size).
267 + KV cache layout: LBNHC (num_blocks, num_kv_heads, block_size, 2 * head_size).
268 """
269
270 accept_output_buffer: bool = True
@@ -298,24 +297,6 @@ class HpcAttentionBackend(AttentionBackend):
297 def get_builder_cls() -> type["HpcAttnMetadataBuilder"]:
298 return HpcAttnMetadataBuilder
299
301 - @staticmethod
302 - def get_kv_cache_shape(
303 - num_blocks: int,
304 - block_size: int,
305 - num_kv_heads: int,
306 - head_size: int,
307 - cache_dtype_str: str = "auto",
308 - ) -> tuple[int, ...]:
309 - return (num_blocks, 2, block_size, num_kv_heads, head_size)
310 -
311 - @staticmethod
312 - def get_kv_cache_stride_order(
313 - include_num_layers_dimension: bool = False,
314 - ) -> tuple[int, ...]:
315 - if include_num_layers_dimension:
316 - return (1, 0, 2, 3, 4, 5)
317 - return (0, 1, 2, 3, 4)
318 -
300 @classmethod
301 def get_supported_head_sizes(cls) -> list[int]:
302 return [128]
@@ -331,8 +312,8 @@ class HpcAttentionBackend(AttentionBackend):
312 return kv_cache_dtype in cls.supported_kv_cache_dtypes
313
314 @classmethod
334 - def get_required_kv_cache_layout(cls) -> KVCacheLayoutType | None:
335 - return "NHD"
315 + def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...]:
316 + return (KVCacheLayout.LBNHC,)
317
318
319 class HpcAttentionImpl(AttentionImpl[HpcAttnMetadata]):
@@ -452,6 +433,9 @@ class HpcAttentionImpl(AttentionImpl[HpcAttnMetadata]):
433 num_decode_reqs = attn_metadata.num_decodes
434 num_decode_tokens = attn_metadata.num_decode_tokens
435
436 + # Logical (B, H, N, 2*head_size) -> (B, N, H, head_size) K/V views.
437 + key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_size, dim=-1)
438 +
439 # Standard-path KV write: when no upstream fused op wrote KV cache
440 # (i.e. the model didn't wire HpcRopeNorm), fall back to the generic
441 # reshape_and_cache_flash. This is what lets non-HunYuan-V3 models use
@@ -466,8 +450,8 @@ class HpcAttentionImpl(AttentionImpl[HpcAttnMetadata]):
450 torch.ops._C_cache_ops.reshape_and_cache_flash(
451 key,
452 value,
469 - kv_cache[:, 0],
470 - kv_cache[:, 1],
453 + key_cache,
454 + value_cache,
455 attn_metadata.slot_mapping,
456 self.kv_cache_dtype,
457 layer._k_scale,
@@ -485,7 +469,8 @@ class HpcAttentionImpl(AttentionImpl[HpcAttnMetadata]):
469 f" (layer={getattr(layer, 'layer_name', '?')})"
470 )
471 torch_dtype = _get_fp8_dtype_for_kv_cache(self.kv_cache_dtype)
488 - kv_cache = kv_cache.view(torch_dtype)
472 + key_cache = key_cache.view(torch_dtype)
473 + value_cache = value_cache.view(torch_dtype)
474 k_scale = layer._k_scale.reshape(1)
475 v_scale = layer._v_scale.reshape(1)
476
@@ -508,8 +493,8 @@ class HpcAttentionImpl(AttentionImpl[HpcAttnMetadata]):
493 if self.use_fp8:
494 hpc.attention_with_kvcache_prefill_fp8(
495 q_prefill,
511 - kv_cache[:, 0],
512 - kv_cache[:, 1],
496 + key_cache,
497 + value_cache,
498 hpc_prefill_q_scale,
499 k_scale,
500 v_scale,
@@ -523,8 +508,8 @@ class HpcAttentionImpl(AttentionImpl[HpcAttnMetadata]):
508 else:
509 hpc.attention_with_kvcache_prefill_bf16(
510 q_prefill,
526 - kv_cache[:, 0],
527 - kv_cache[:, 1],
511 + key_cache,
512 + value_cache,
513 cu_seqlens_prefill,
514 block_table_prefill,
515 seq_lens_prefill,
@@ -545,8 +530,8 @@ class HpcAttentionImpl(AttentionImpl[HpcAttnMetadata]):
530 if self.use_fp8:
531 hpc.attention_decode_fp8(
532 q_decode,
548 - kv_cache[:, 0],
549 - kv_cache[:, 1],
533 + key_cache,
534 + value_cache,
535 block_table_decode,
536 num_seq_kvcache,
537 hpc_decode_q_scale,
@@ -575,8 +560,8 @@ class HpcAttentionImpl(AttentionImpl[HpcAttnMetadata]):
560 )
561 hpc.attention_decode_bf16(
562 q_decode,
578 - kv_cache[:, 0],
579 - kv_cache[:, 1],
563 + key_cache,
564 + value_cache,
565 block_table_decode,
566 num_seq_kvcache,
567 mtp=mtp,
vllm/v1/attention/backends/mla/cutlass_mla.py
-8
@@ -49,14 +49,6 @@ class CutlassMLABackend(MLACommonBackend):
49 def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
50 return [128]
51
52 - @staticmethod
53 - def get_kv_cache_stride_order(
54 - include_num_layers_dimension: bool = False,
55 - ) -> tuple[int, ...]:
56 - if include_num_layers_dimension:
57 - return (1, 0, 2, 3)
58 - return (0, 1, 2)
59 -
52 @staticmethod
53 def get_name() -> str:
54 return "CUTLASS_MLA"
vllm/v1/attention/backends/mla/flashattn_mla.py
-8
@@ -52,14 +52,6 @@ class FlashAttnMLABackend(MLACommonBackend):
52 def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
53 return [MultipleOf(16)]
54
55 - @staticmethod
56 - def get_kv_cache_stride_order(
57 - include_num_layers_dimension: bool = False,
58 - ) -> tuple[int, ...]:
59 - if include_num_layers_dimension:
60 - return (1, 0, 2, 3)
61 - return (0, 1, 2)
62 -
55 @staticmethod
56 def get_name() -> str:
57 return "FLASH_ATTN_MLA"
vllm/v1/attention/backends/mla/flashattn_mla_sparse.py
+7 -17
@@ -24,6 +24,7 @@ from vllm.v1.attention.backend import (
24 )
25 from vllm.v1.attention.backends.fa_utils import flash_attn_supports_mla
26 from vllm.v1.attention.backends.mla.sparse_utils import (
27 + flat_kv_row_view,
28 triton_convert_req_index_to_global_index,
29 )
30 from vllm.v1.kv_cache_interface import AttentionSpec
@@ -103,16 +104,6 @@ class FlashAttnMLASparseBackend(AttentionBackend):
104 return "FlashAttention MLA Sparse requires model with index_topk"
105 return None
106
106 - @staticmethod
107 - def get_kv_cache_shape(
108 - num_blocks: int,
109 - block_size: int,
110 - num_kv_heads: int,
111 - head_size: int,
112 - cache_dtype_str: str = "auto",
113 - ) -> tuple[int, ...]:
114 - return (num_blocks, block_size, head_size)
115 -
107
108 @dataclass
109 class FlashAttnMLASparseMetadata(AttentionMetadata):
@@ -223,11 +214,15 @@ class FlashAttnMLASparseImpl(SparseMLACommonImpl[FlashAttnMLASparseMetadata]):
214
215 assert self.topk_indices_buffer is not None
216 topk_indices = self.topk_indices_buffer[:num_actual_toks]
217 + kv_rows, block_stride_rows = flat_kv_row_view(
218 + kv_c_and_k_pe_cache, attn_metadata.block_size
219 + )
220 topk_indices, valid_counts = triton_convert_req_index_to_global_index(
221 attn_metadata.req_id_per_token[:num_actual_toks],
222 attn_metadata.block_table,
223 topk_indices,
224 BLOCK_SIZE=attn_metadata.block_size,
225 + BLOCK_STRIDE_ROWS=block_stride_rows,
226 NUM_TOPK_TOKENS=topk_indices.shape[1],
227 return_valid_counts=True,
228 )
@@ -235,13 +230,8 @@ class FlashAttnMLASparseImpl(SparseMLACommonImpl[FlashAttnMLASparseMetadata]):
230 cu_seqlens_q = torch.arange(
231 0, num_actual_toks + 1, dtype=torch.int32, device=q_rope.device
232 )
238 - kv_cache = kv_c_and_k_pe_cache.view(
239 - -1, attn_metadata.block_size, self.head_size
240 - )
241 - k_cache = kv_cache[:, :, self.kv_lora_rank :].view(
242 - -1, 1, 1, self.qk_rope_head_dim
243 - )
244 - v_cache = kv_cache[:, :, : self.kv_lora_rank].view(-1, 1, 1, self.kv_lora_rank)
233 + k_cache = kv_rows[:, self.kv_lora_rank :].unsqueeze(1).unsqueeze(1)
234 + v_cache = kv_rows[:, : self.kv_lora_rank].unsqueeze(1).unsqueeze(1)
235
236 out = flash_attn_varlen_func(
237 q=q_rope,
vllm/v1/attention/backends/mla/flashinfer_mla.py
-13
@@ -28,7 +28,6 @@ from vllm.v1.attention.backend import (
28 AttentionType,
29 MultipleOf,
30 )
31 -from vllm.v1.attention.backends.utils import KVCacheLayoutType
31
32 logger = init_logger(__name__)
33
@@ -133,14 +132,6 @@ class FlashInferMLABackend(MLACommonBackend):
132 def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
133 return [32, 64]
134
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:
141 - return (1, 0, 2, 3)
142 - return (0, 1, 2)
143 -
135 @staticmethod
136 def get_name() -> str:
137 return "FLASHINFER_MLA"
@@ -194,10 +185,6 @@ class FlashInferMLABackend(MLACommonBackend):
185 )
186 return None
187
197 - @classmethod
198 - def get_required_kv_cache_layout(cls) -> "KVCacheLayoutType | None":
199 - return "HND"
200 -
188
189 class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
190 can_return_lse_for_decode: bool = True
vllm/v1/attention/backends/mla/flashinfer_mla_sparse.py
-32
@@ -31,7 +31,6 @@ from vllm.v1.attention.backends.mla.sparse_utils import (
31 triton_convert_req_index_to_global_index,
32 triton_filter_and_convert_dcp_index,
33 )
34 -from vllm.v1.attention.backends.utils import KVCacheLayoutType
34 from vllm.v1.kv_cache_interface import AttentionSpec
35
36 if TYPE_CHECKING:
@@ -123,20 +122,6 @@ class FlashInferMLASparseTRTLLMBackend(_FlashInferMLASparseBackendBase):
122 return "FlashInfer MLA Sparse requires model with index_topk config"
123 return None
124
126 - @staticmethod
127 - def get_kv_cache_shape(
128 - num_blocks: int,
129 - block_size: int,
130 - num_kv_heads: int, # assumed to be 1 for MLA
131 - head_size: int,
132 - cache_dtype_str: str = "auto",
133 - ) -> tuple[int, ...]:
134 - return (num_blocks, block_size, head_size)
135 -
136 - @classmethod
137 - def get_required_kv_cache_layout(cls) -> "KVCacheLayoutType | None":
138 - return "HND"
139 -
125
126 class FlashInferMLASparseSM120Backend(_FlashInferMLASparseBackendBase):
127 """FlashInfer sparse MLA backend for SM120."""
@@ -216,23 +201,6 @@ class FlashInferMLASparseSM120Backend(_FlashInferMLASparseBackendBase):
201 )
202 return None
203
219 - @staticmethod
220 - def get_kv_cache_shape(
221 - num_blocks: int,
222 - block_size: int,
223 - num_kv_heads: int, # assumed to be 1 for MLA
224 - head_size: int,
225 - cache_dtype_str: str = "auto",
226 - ) -> tuple[int, ...]:
227 - if cache_dtype_str in ("auto", "fp8", "fp8_e4m3", "fp8_ds_mla"):
228 - # fp8_ds_mla packed layout: 512 NoPE + 16 scales + 128 RoPE.
229 - return (num_blocks, block_size, 656)
230 - return (num_blocks, block_size, head_size)
231 -
232 - @classmethod
233 - def get_required_kv_cache_layout(cls) -> "KVCacheLayoutType | None":
234 - return None
235 -
204
205 @dataclass
206 class FlashInferMLASparseMetadata(AttentionMetadata):
vllm/v1/attention/backends/mla/flashmla.py
-8
@@ -58,14 +58,6 @@ class FlashMLABackend(MLACommonBackend):
58 def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
59 return [64]
60
61 - @staticmethod
62 - def get_kv_cache_stride_order(
63 - include_num_layers_dimension: bool = False,
64 - ) -> tuple[int, ...]:
65 - if include_num_layers_dimension:
66 - return (1, 0, 2, 3)
67 - return (0, 1, 2)
68 -
61 @staticmethod
62 def get_name() -> str:
63 return "FLASHMLA"
vllm/v1/attention/backends/mla/flashmla_sparse.py
+6 -15
@@ -27,6 +27,7 @@ from vllm.v1.attention.backend import (
27 MultipleOf,
28 )
29 from vllm.v1.attention.backends.mla.sparse_utils import (
30 + flat_kv_row_view,
31 triton_convert_req_index_to_global_index,
32 triton_filter_and_convert_dcp_index,
33 )
@@ -128,20 +129,6 @@ class FlashMLASparseBackend(AttentionBackend):
129 def supports_compute_capability(cls, capability: DeviceCapability) -> bool:
130 return capability.major in [9, 10]
131
131 - @staticmethod
132 - def get_kv_cache_shape(
133 - num_blocks: int,
134 - block_size: int,
135 - num_kv_heads: int, # assumed to be 1 for MLA
136 - head_size: int,
137 - cache_dtype_str: str = "auto",
138 - ) -> tuple[int, ...]:
139 - if cache_dtype_str == "fp8_ds_mla":
140 - # V3.2 main MLA: 656-byte custom storage format. See module docstring.
141 - return (num_blocks, block_size, 656)
142 - else:
143 - return (num_blocks, block_size, head_size)
144 -
132
133 @dataclass
134 class FlashMLASparseMetadata(AttentionMetadata):
@@ -643,18 +630,22 @@ class FlashMLASparseImpl(SparseMLACommonImpl[FlashMLASparseMetadata]):
630 # Convert per-request indices to global slots (decode) or workspace
631 # offsets (prefill). req_id_per_token covers the whole batch; slice it
632 # to the MQA tokens (q may exclude prefill tokens routed to dense MHA).
633 + kv_rows, block_stride_rows = flat_kv_row_view(
634 + kv_c_and_k_pe_cache, attn_metadata.block_size
635 + )
636 topk_indices, topk_length = triton_convert_req_index_to_global_index(
637 attn_metadata.req_id_per_token[: topk_indices.shape[0]],
638 attn_metadata.block_table,
639 topk_indices,
640 BLOCK_SIZE=attn_metadata.block_size,
641 + BLOCK_STRIDE_ROWS=block_stride_rows,
642 NUM_TOPK_TOKENS=topk_indices.shape[1],
643 return_valid_counts=True,
644 )
645
646 return self._bf16_flash_mla_kernel(
647 q,
657 - kv_c_and_k_pe_cache,
648 + kv_rows,
649 topk_indices,
650 topk_length,
651 )
vllm/v1/attention/backends/mla/indexer.py
+13 -26
@@ -36,7 +36,7 @@ from vllm.v1.attention.backends.utils import (
36 get_dcp_local_seq_lens,
37 split_decodes_and_prefills,
38 )
39 -from vllm.v1.kv_cache_interface import KVCacheSpec, MLAAttentionSpec
39 +from vllm.v1.kv_cache_interface import KVCacheLayout, KVCacheSpec, MLAAttentionSpec
40
41 logger = init_logger(__name__)
42
@@ -179,34 +179,18 @@ class DeepseekV32IndexerBackend(AttentionBackend):
179 def get_builder_cls() -> type["DeepseekV32IndexerMetadataBuilder"]:
180 return DeepseekV32IndexerMetadataBuilder
181
182 - @staticmethod
183 - def get_kv_cache_shape(
184 - num_blocks: int,
185 - block_size: int,
186 - num_kv_heads: int,
187 - head_size: int,
188 - cache_dtype_str: str = "auto",
189 - ) -> tuple[int, ...]:
190 - assert num_kv_heads == 1
191 - return (num_blocks, block_size, head_size)
192 -
193 - @staticmethod
194 - def get_kv_cache_stride_order(
195 - include_num_layers_dimension: bool = False,
196 - ) -> tuple[int, ...]:
197 - if include_num_layers_dimension:
198 - # DeepseekV32Indexer kernels do not support cross-layer
199 - # KV cache layout. Identity permutation keeps num_layers
200 - # first, signaling incompatibility.
201 - return (0, 1, 2, 3)
202 - return (0, 1, 2)
203 -
182
183 class DeepseekV4IndexerBackend(DeepseekV32IndexerBackend):
184 @staticmethod
185 def get_name() -> str:
186 return "DEEPSEEK_V4_INDEXER"
187
188 + @classmethod
189 + def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...]:
190 + # DeepSeek-V4 packs the indexer pages beside the MLA latent pages inside
191 + # each block, so the layer dim must sit inside the block dim.
192 + return (KVCacheLayout.BLHNC, KVCacheLayout.BLNHC)
193 +
194 @staticmethod
195 def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
196 return [256]
@@ -638,7 +622,10 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
622 self.compress_ratio = 1
623 # Get compress_ratio for DeepseekV4 support
624 if isinstance(self.kv_cache_spec, MLAAttentionSpec):
641 - self.compress_ratio = self.kv_cache_spec.compress_ratio
625 + # MLA compression is a whole number of tokens per state (fractions
626 + # are whisper block pooling and never reach MLA).
627 + assert isinstance(self.kv_cache_spec.tokens_per_state, int)
628 + self.compress_ratio = self.kv_cache_spec.tokens_per_state
629 if self.dcp_world_size > 1 and self.compress_ratio > 1:
630 raise NotImplementedError(
631 "DCP is not supported with sparse indexer KV compression "
@@ -885,7 +872,7 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
872 query_start_loc,
873 seq_lens,
874 block_table,
888 - self.kv_cache_spec.storage_block_size,
875 + self.kv_cache_spec.num_states,
876 self.compress_ratio,
877 out=self.compressed_slot_mapping_buffer,
878 )
@@ -1053,7 +1040,7 @@ class DeepseekV32IndexerMetadataBuilder(AttentionMetadataBuilder):
1040 if current_platform.is_cuda() and has_deep_gemm():
1041 metadata = get_paged_mqa_logits_metadata(
1042 seq_lens,
1056 - self.kv_cache_spec.storage_block_size,
1043 + self.kv_cache_spec.num_states,
1044 self.num_sms,
1045 indices=decode_indices,
1046 )
vllm/v1/attention/backends/mla/rocm_aiter_mla_sparse.py
-10
@@ -293,16 +293,6 @@ class ROCMAiterMLASparseBackend(AttentionBackend):
293 def get_impl_cls() -> type["ROCMAiterMLASparseImpl"]:
294 return ROCMAiterMLASparseImpl
295
296 - @staticmethod
297 - def get_kv_cache_shape(
298 - num_blocks: int,
299 - block_size: int,
300 - num_kv_heads: int, # assumed to be 1 for MLA
301 - head_size: int,
302 - cache_dtype_str: str = "auto",
303 - ) -> tuple[int, ...]:
304 - return (num_blocks, block_size, head_size)
305 -
296 @classmethod
297 def is_mla(cls) -> bool:
298 return True
vllm/v1/attention/backends/mla/sparse_swa.py
+6 -25
@@ -89,6 +89,10 @@ class DeepseekV4SWACache(torch.nn.Module, AttentionLayerBase):
89 # contiguous full-cache layout.
90 assert self.dtype in (torch.uint8, torch.bfloat16, torch.float8_e4m3fn)
91
92 + def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
93 + # [B, H=1, N, C] -> [B, N, C]
94 + self.kv_cache = kv_cache.squeeze(1)
95 +
96 def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
97 # fp8_ds_mla's UE8M0 paged layout needs 576B alignment; contiguous
98 # bf16/fp8 cache uses the natural element-size page.
@@ -141,30 +145,6 @@ class DeepseekSparseSWABackend(AttentionBackend):
145 return DeepseekV4ROCMAiterSparseSWAMetadataBuilder
146 return DeepseekSparseSWAMetadataBuilder
147
144 - @staticmethod
145 - def get_kv_cache_shape(
146 - num_blocks: int,
147 - block_size: int,
148 - num_kv_heads: int,
149 - head_size: int,
150 - cache_dtype_str: str = "auto",
151 - ) -> tuple[int, ...]:
152 - assert num_kv_heads == 1
153 - if cache_dtype_str == "fp8_ds_mla":
154 - # DeepseekV4 SWA: 584B per token (448 NoPE + 128 RoPE + 8 fp8 scale).
155 - # head_size passed in is the semantic head_dim (512).
156 - return (num_blocks, block_size, 584)
157 - else:
158 - return (num_blocks, block_size, head_size)
159 -
160 - @staticmethod
161 - def get_kv_cache_stride_order(
162 - include_num_layers_dimension: bool = False,
163 - ) -> tuple[int, ...]:
164 - if include_num_layers_dimension:
165 - return (0, 1, 2, 3)
166 - return (0, 1, 2)
167 -
148
149 @dataclass
150 class DeepseekSparseSWAMetadata:
@@ -417,7 +397,8 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
397 assert isinstance(self.kv_cache_spec, SlidingWindowMLASpec | MLAAttentionSpec)
398 mla_spec = cast(SlidingWindowMLASpec | MLAAttentionSpec, self.kv_cache_spec)
399 self.head_size = mla_spec.head_size # Already considered quantization.
420 - self.compress_ratio = mla_spec.compress_ratio
400 + assert isinstance(mla_spec.tokens_per_state, int)
401 + self.compress_ratio = mla_spec.tokens_per_state
402 self.block_size = mla_spec.block_size
403 self.max_model_len = self.vllm_config.model_config.max_model_len
404 self.max_num_batched_tokens = (
vllm/v1/attention/backends/mla/sparse_utils.py
+27 -1
@@ -7,6 +7,28 @@ import torch
7 from vllm.triton_utils import tl, triton
8
9
10 +def flat_kv_row_view(
11 + kv_cache: torch.Tensor, # [num_blocks, block_size, head_dim]
12 + block_size: int,
13 +) -> tuple[torch.Tensor, int]:
14 + """Flat [row, head_dim] view of a paged cache and its physical rows per block.
15 +
16 + Token offset is ``block_idx * block_stride_rows + offset_in_block``.
17 + When other layers' pages sit between consecutive blocks of this cache,
18 + ``block_stride_rows`` exceeds ``block_size``; those in-between rows are never
19 + indexed (`triton_convert_req_index_to_global_index` ensures this).
20 + """
21 + num_blocks, _, head_dim = kv_cache.shape
22 + assert kv_cache.stride(0) % head_dim == 0, (
23 + "block stride is not a whole number of rows; flat row indexing would "
24 + "silently misaddress"
25 + )
26 + block_stride_rows = kv_cache.stride(0) // head_dim
27 + num_rows = (num_blocks - 1) * block_stride_rows + block_size
28 + rows = kv_cache.as_strided((num_rows, head_dim), (head_dim, 1))
29 + return rows, block_stride_rows
30 +
31 +
32 # Kernel with prefill workspace support and valid count tracking
33 @triton.jit
34 def _convert_req_index_to_global_index_kernel(
@@ -20,6 +42,7 @@ def _convert_req_index_to_global_index_kernel(
42 # shapes (compile-time where possible)
43 max_num_blocks_per_req: tl.constexpr,
44 BLOCK_SIZE: tl.constexpr,
45 + BLOCK_STRIDE_ROWS: tl.constexpr,
46 BLOCK_N: tl.constexpr, # tile width along columns
47 HAS_PREFILL: tl.constexpr,
48 COUNT_VALID: tl.constexpr, # whether to count valid indices
@@ -86,7 +109,7 @@ def _convert_req_index_to_global_index_kernel(
109 bt_ptr = block_table_ptr + req * bt_stride0 + block_id * bt_stride1
110 is_invalid_tok |= ~valid_block | is_remote
111 base = tl.load(bt_ptr, mask=valid_block & ~is_prefill & ~is_remote, other=0)
89 - out_val = base * BLOCK_SIZE + inblock_off
112 + out_val = base * BLOCK_STRIDE_ROWS + inblock_off
113
114 # Override with prefill output if prefill is enabled
115 if HAS_PREFILL:
@@ -156,6 +179,7 @@ def triton_convert_req_index_to_global_index(
179 block_table: torch.Tensor, # int32 [num_requests, max_num_blocks_per_req]
180 token_indices: torch.Tensor, # int32 [num_tokens, NUM_TOPK_TOKENS]
181 BLOCK_SIZE: int = 64,
182 + BLOCK_STRIDE_ROWS: int | None = None,
183 NUM_TOPK_TOKENS: int = 2048,
184 BLOCK_N: int = 128, # tile width along columns
185 HAS_PREFILL_WORKSPACE: bool = False,
@@ -249,6 +273,7 @@ def triton_convert_req_index_to_global_index(
273 # shapes / constexprs
274 max_num_blocks_per_req,
275 BLOCK_SIZE,
276 + BLOCK_STRIDE_ROWS if BLOCK_STRIDE_ROWS is not None else BLOCK_SIZE,
277 block_n,
278 HAS_PREFILL_WORKSPACE,
279 return_valid_counts,
@@ -365,6 +390,7 @@ def triton_filter_and_convert_dcp_index(
390 None,
391 max_num_blocks_per_req,
392 BLOCK_SIZE,
393 + BLOCK_SIZE, # dense caches on the DCP path
394 block_n,
395 False, # HAS_PREFILL
396 count_valid,
vllm/v1/attention/backends/mla/tokenspeed_mla.py
-5
@@ -23,7 +23,6 @@ from vllm.v1.attention.backend import (
23 AttentionType,
24 MultipleOf,
25 )
26 -from vllm.v1.attention.backends.utils import KVCacheLayoutType
26
27 if TYPE_CHECKING:
28 from vllm.config import VllmConfig
@@ -152,10 +151,6 @@ class TokenspeedMLABackend(MLACommonBackend):
151 )
152 return None
153
155 - @classmethod
156 - def get_required_kv_cache_layout(cls) -> "KVCacheLayoutType | None":
157 - return "HND"
158 -
154
155 class TokenspeedMLAImpl(MLACommonImpl[MLACommonMetadata]):
156 can_return_lse_for_decode: bool = True
vllm/v1/attention/backends/mla/triton_mla.py
-8
@@ -121,14 +121,6 @@ class TritonMLABackend(MLACommonBackend):
121 return True
122 return block_size % 16 == 0
123
124 - @staticmethod
125 - def get_kv_cache_stride_order(
126 - include_num_layers_dimension: bool = False,
127 - ) -> tuple[int, ...]:
128 - if include_num_layers_dimension:
129 - return (1, 0, 2, 3)
130 - return (0, 1, 2)
131 -
124 @staticmethod
125 def get_name() -> str:
126 return "TRITON_MLA"
vllm/v1/attention/backends/mla/xpu_mla_sparse.py
+7 -14
@@ -23,7 +23,8 @@ from vllm.v1.attention.backend import (
23 CommonAttentionMetadata,
24 MLAAttentionImpl,
25 )
26 -from vllm.v1.attention.backends.mla.flashmla_sparse import (
26 +from vllm.v1.attention.backends.mla.sparse_utils import (
27 + flat_kv_row_view,
28 triton_convert_req_index_to_global_index,
29 )
30 from vllm.v1.attention.ops.xpu_mla_sparse import triton_bf16_mla_sparse_interface
@@ -66,16 +67,6 @@ class XPUMLASparseBackend(AttentionBackend):
67 def is_sparse(cls) -> bool:
68 return True
69
69 - @staticmethod
70 - def get_kv_cache_shape(
71 - num_blocks: int,
72 - block_size: int,
73 - num_kv_heads: int, # assumed to be 1 for MLA
74 - head_size: int,
75 - cache_dtype_str: str = "auto",
76 - ) -> tuple[int, ...]:
77 - return (num_blocks, block_size, head_size)
78 -
70 @classmethod
71 def get_supported_head_sizes(cls) -> list[int]:
72 return [576]
@@ -249,16 +240,18 @@ class XPUMLASparseImpl(MLAAttentionImpl[XPUMLASparseMetadata]):
240 assert self.topk_indices_buffer is not None
241 topk_indices = self.topk_indices_buffer[:num_actual_toks]
242
243 + kv_rows, block_stride_rows = flat_kv_row_view(
244 + kv_c_and_k_pe_cache, attn_metadata.block_size
245 + )
246 topk_indices_global = triton_convert_req_index_to_global_index(
247 attn_metadata.req_id_per_token,
248 attn_metadata.block_table,
249 topk_indices,
250 BLOCK_SIZE=attn_metadata.block_size,
251 + BLOCK_STRIDE_ROWS=block_stride_rows,
252 NUM_TOPK_TOKENS=attn_metadata.topk_tokens,
253 )
254
260 - attn_out = self._forward_bf16_kv(
261 - q, kv_c_and_k_pe_cache, topk_indices_global, attn_metadata
262 - )
255 + attn_out = self._forward_bf16_kv(q, kv_rows, topk_indices_global, attn_metadata)
256
257 return attn_out, None
vllm/v1/attention/backends/rocm_aiter_fa.py
+16 -12
@@ -31,7 +31,7 @@ from vllm.v1.attention.backends.utils import (
31 split_decodes_prefills_and_extends,
32 )
33 from vllm.v1.attention.ops.merge_attn_states import merge_attn_states
34 -from vllm.v1.kv_cache_interface import AttentionSpec
34 +from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheLayout
35
36 _PARTITION_SIZE_ROCM = 256
37 _CP_TOKENS_PER_ITER_ROCM = 32 * 1024
@@ -761,18 +761,22 @@ class AiterFlashAttentionBackend(AttentionBackend):
761 def get_builder_cls() -> type["AiterFlashAttentionMetadataBuilder"]:
762 return AiterFlashAttentionMetadataBuilder
763
764 - @staticmethod
765 - def get_kv_cache_shape(
766 - num_blocks: int,
767 - block_size: int,
768 - num_kv_heads: int,
769 - head_size: int,
770 - cache_dtype_str: str = "auto",
771 - ) -> tuple[int, ...]:
772 - if block_size % 16 != 0:
764 + @classmethod
765 + def customize_spec(cls, spec: AttentionSpec) -> AttentionSpec:
766 + """Validate the block size the ROCm gather kernels require."""
767 + # block_size == 1 is the per-token page-size probe (see
768 + # Platform.get_page_size_bytes); real blocks must be gatherable in
769 + # 16-token units by the ROCm kernel.
770 + if spec.block_size != 1 and spec.block_size % 16 != 0:
771 raise ValueError("Block size must be a multiple of 16.")
774 - # K and V are packed into the content dim: logical (B, H, N, 2*hs).
775 - return (num_blocks, num_kv_heads, block_size, 2 * head_size)
772 + return spec
773 +
774 + @classmethod
775 + def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...]:
776 + # K and V come out of the content dim as transposed views rather than
777 + # copies, so the head dim may sit on either side of the block dim, but
778 + # the layer must stay outermost.
779 + return (KVCacheLayout.LBHNC, KVCacheLayout.LHBNC)
780
781 @classmethod
782 def supports_compute_capability(cls, capability: DeviceCapability) -> bool:
vllm/v1/attention/backends/rocm_aiter_unified_attn.py
+17 -11
@@ -22,6 +22,7 @@ from vllm.v1.attention.backends.rocm_attn import (
22 RocmAttentionMetadata,
23 RocmAttentionMetadataBuilder,
24 )
25 +from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheLayout
26
27 logger = init_logger(__name__)
28
@@ -77,18 +78,23 @@ class RocmAiterUnifiedAttentionBackend(RocmAttentionBackend):
78 def get_impl_cls() -> type["RocmAiterUnifiedAttentionImpl"]:
79 return RocmAiterUnifiedAttentionImpl
80
80 - @staticmethod
81 - def get_kv_cache_shape(
82 - num_blocks: int,
83 - block_size: int,
84 - num_kv_heads: int,
85 - head_size: int,
86 - cache_dtype_str: str = "auto",
87 - ) -> tuple[int, ...]:
88 - if block_size % 16 != 0:
81 + @classmethod
82 + def customize_spec(cls, spec: AttentionSpec) -> AttentionSpec:
83 + """Keep K and V packed in the content dim, unlike the native HIP
84 + kernels the base class targets."""
85 + # block_size == 1 is the per-token page-size probe (see
86 + # Platform.get_page_size_bytes); real blocks must be gatherable in
87 + # 16-token units by the ROCm kernel.
88 + if spec.block_size != 1 and spec.block_size % 16 != 0:
89 raise ValueError("Block size must be a multiple of 16.")
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)
90 + return spec
91 +
92 + @classmethod
93 + def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...]:
94 + # K and V come out of the content dim as transposed views rather than
95 + # copies, so the head dim may sit on either side of the block dim, but
96 + # the layer must stay outermost.
97 + return (KVCacheLayout.LBHNC, KVCacheLayout.LHBNC)
98
99 @staticmethod
100 def use_cascade_attention(*args, **kwargs) -> bool:
vllm/v1/attention/backends/rocm_attn.py
+34 -19
@@ -2,7 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Attention layer with PagedAttention and Triton prefix prefill."""
4
5 -from dataclasses import dataclass
5 +from dataclasses import dataclass, replace
6 from typing import ClassVar
7
8 import torch
@@ -16,7 +16,7 @@ from vllm.model_executor.layers.quantization.utils.quant_utils import (
16 kFp8StaticTensorSym,
17 )
18 from vllm.platforms import current_platform
19 -from vllm.utils.torch_utils import is_quantized_kv_cache
19 +from vllm.utils.torch_utils import get_dtype_size, is_quantized_kv_cache
20 from vllm.v1.attention.backend import (
21 AttentionBackend,
22 AttentionCGSupport,
@@ -35,7 +35,7 @@ from vllm.v1.attention.ops.paged_attn import PagedAttention
35 from vllm.v1.attention.ops.triton_reshape_and_cache_flash import (
36 triton_reshape_and_cache_flash,
37 )
38 -from vllm.v1.kv_cache_interface import AttentionSpec
38 +from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheLayout
39
40 logger = init_logger(__name__)
41
@@ -241,17 +241,30 @@ class RocmAttentionBackend(AttentionBackend):
241 AttentionType.ENCODER_ONLY,
242 )
243
244 - @staticmethod
245 - def get_kv_cache_shape(
246 - num_blocks: int,
247 - block_size: int,
248 - num_kv_heads: int,
249 - head_size: int,
250 - cache_dtype_str: str = "auto",
251 - ) -> tuple[int, ...]:
252 - if block_size % 16 != 0:
253 - raise ValueError("Block size must be a multiple of 16.")
254 - return (2, num_blocks, block_size, num_kv_heads, head_size)
244 + @classmethod
245 + def customize_spec(cls, spec: AttentionSpec) -> AttentionSpec:
246 + """K and V as two head groups so the native HIP kernels address each side
247 + as one contiguous region (x-packed interior applied in split_kv_cache)."""
248 + if spec.state_content_bytes is not None:
249 + return spec
250 + assert spec.head_size == spec.head_size_v, (
251 + "Separate K/V head groups require symmetric K/V head sizes."
252 + )
253 + return replace(
254 + spec,
255 + num_head_slots=2,
256 + state_content_bytes=spec.num_kv_heads
257 + * spec.head_size
258 + * get_dtype_size(spec.dtype),
259 + )
260 +
261 + @classmethod
262 + def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...]:
263 + # The native HIP kernels hardcode group-contiguous block addressing, so
264 + # they need the K and V groups to span all blocks (H outermost within the layer)
265 + # The Triton fallbacks are stride-aware, so block-interior LBHNC is also
266 + # supported, just without the native kernels.
267 + return (KVCacheLayout.LHBNC, KVCacheLayout.LBHNC)
268
269 @staticmethod
270 def use_cascade_attention(*args, **kwargs) -> bool:
@@ -380,8 +393,8 @@ class RocmAttentionImpl(AttentionImpl):
393 query: shape = [num_tokens, num_heads, head_size]
394 key: shape = [num_tokens, num_kv_heads, head_size]
395 value: shape = [num_tokens, num_kv_heads, head_size]
383 - kv_cache: shape =
384 - [2, num_blocks, block_size, num_kv_heads, head_size]
396 + kv_cache: logical [num_blocks, 2, block_size, num_kv_heads *
397 + head_size] under LHBNC (physically K/V-group-first)
398 attn_metadata: Metadata for attention.
399 Returns:
400 shape = [num_tokens, num_heads * head_size]
@@ -419,8 +432,10 @@ class RocmAttentionImpl(AttentionImpl):
432 layer,
433 )
434
435 + # The bound view is logical [B, 2, N, H*hs]; split_kv_cache expects
436 + # the K/V groups first.
437 key_cache, value_cache = PagedAttention.split_kv_cache(
423 - kv_cache, self.num_kv_heads, self.head_size
438 + kv_cache.transpose(0, 1), self.num_kv_heads, self.head_size
439 )
440
441 if is_quantized_kv_cache(self.kv_cache_dtype):
@@ -477,7 +492,7 @@ class RocmAttentionImpl(AttentionImpl):
492 if self.attn_type in (AttentionType.ENCODER_ONLY, AttentionType.ENCODER):
493 return
494 key_cache, value_cache = PagedAttention.split_kv_cache(
480 - kv_cache, self.num_kv_heads, self.head_size
495 + kv_cache.transpose(0, 1), self.num_kv_heads, self.head_size
496 )
497
498 # Reshape the input keys and values and store them in the cache.
@@ -532,7 +547,7 @@ class RocmAttentionImpl(AttentionImpl):
547 if self.attn_type in (AttentionType.ENCODER_ONLY, AttentionType.ENCODER):
548 return
549 key_cache, value_cache = PagedAttention.split_kv_cache(
535 - kv_cache,
550 + kv_cache.transpose(0, 1),
551 layer.num_kv_heads, # type: ignore[attr-defined]
552 layer.head_size, # type: ignore[attr-defined]
553 )
vllm/v1/attention/backends/triton_attn.py
-58
@@ -32,7 +32,6 @@ from vllm.v1.attention.backend import (
32 )
33 from vllm.v1.attention.backends.utils import (
34 compute_mm_prefix_range_tensor,
35 - get_kv_cache_layout,
35 get_num_attention_heads_from_layers,
36 )
37 from vllm.v1.attention.ops.triton_prefill_attention import context_attention_fwd
@@ -45,7 +44,6 @@ from vllm.v1.kv_cache_interface import (
44 AttentionSpec,
45 KVQuantMode,
46 get_kv_quant_mode,
48 - kv_cache_uses_per_token_head_scales,
47 )
48
49 logger = init_logger(__name__)
@@ -337,62 +335,6 @@ class TritonAttentionBackend(AttentionBackend):
335 def get_impl_cls() -> type["TritonAttentionImpl"]:
336 return TritonAttentionImpl
337
340 - @staticmethod
341 - def get_kv_cache_shape(
342 - num_blocks: int,
343 - block_size: int,
344 - num_kv_heads: int,
345 - head_size: int,
346 - cache_dtype_str: str = "auto",
347 - ) -> tuple[int, ...]:
348 - if block_size % 16 != 0:
349 - raise ValueError("Block size must be a multiple of 16.")
350 - # K and V are packed into the content dim: logical (B, H, N, 2*hs).
351 - if kv_cache_uses_per_token_head_scales(cache_dtype_str):
352 - # Pad the head dim by sizeof(float32)/sizeof(cache_dtype) so the
353 - # per-(token, head) scale fits inline after the quantized data;
354 - # the backend extracts data[:head_size] and scale[head_size:] via
355 - # typed views (see _ensure_scale_caches). INT4 packs two values
356 - # per byte, so the data occupies only head_size // 2 bytes.
357 - from vllm.utils.torch_utils import (
358 - STR_DTYPE_TO_TORCH_DTYPE,
359 - get_dtype_size,
360 - )
361 -
362 - cache_dtype = STR_DTYPE_TO_TORCH_DTYPE[cache_dtype_str]
363 - scale_pad = get_dtype_size(torch.float32) // get_dtype_size(cache_dtype)
364 - if get_kv_quant_mode(cache_dtype_str) == KVQuantMode.INT4_PER_TOKEN_HEAD:
365 - data_head_size = head_size // 2
366 - else:
367 - data_head_size = head_size
368 - padded_hs = data_head_size + scale_pad
369 - return (num_blocks, num_kv_heads, block_size, 2 * padded_hs)
370 - return (num_blocks, num_kv_heads, block_size, 2 * head_size)
371 -
372 - @staticmethod
373 - def get_kv_cache_stride_order(
374 - include_num_layers_dimension: bool = False,
375 - ) -> tuple[int, ...]:
376 - # `stride_order` indicates the permutation that gets us from
377 - # `get_kv_cache_shape` (logical (B, H, N, 2*hs)) to the actual memory
378 - # layout we want.
379 - cache_layout = get_kv_cache_layout()
380 - if cache_layout == "NHD" and include_num_layers_dimension:
381 - # (num_blocks, num_layers, block_size, num_kv_heads, 2*head_size)
382 - return (1, 0, 3, 2, 4)
383 - elif cache_layout == "NHD":
384 - # (num_blocks, block_size, num_kv_heads, 2*head_size)
385 - stride_order = (0, 2, 1, 3)
386 - elif cache_layout == "HND" and include_num_layers_dimension:
387 - # (num_blocks, num_kv_heads, num_layers, block_size, 2*head_size)
388 - return (1, 2, 0, 3, 4)
389 - elif cache_layout == "HND":
390 - # (num_blocks, num_kv_heads, block_size, 2*head_size)
391 - stride_order = (0, 1, 2, 3)
392 - else:
393 - raise ValueError(f"Unknown cache layout: {cache_layout}")
394 - return stride_order
395 -
338 @staticmethod
339 def use_cascade_attention(*args, **kwargs) -> bool:
340 return False
vllm/v1/attention/backends/triton_attn_diffkv.py
-43
@@ -23,7 +23,6 @@ from vllm.v1.attention.backends.triton_attn import (
23 TritonAttentionMetadata,
24 TritonAttentionMetadataBuilder,
25 )
26 -from vllm.v1.attention.backends.utils import get_kv_cache_layout
26 from vllm.v1.attention.ops.triton_reshape_and_cache_flash import (
27 triton_reshape_and_cache_flash_diffkv,
28 )
@@ -93,48 +92,6 @@ class TritonAttentionDiffKVBackend(TritonAttentionBackend):
92 def get_builder_cls() -> type["TritonAttentionDiffKVMetadataBuilder"]:
93 return TritonAttentionDiffKVMetadataBuilder
94
96 - @staticmethod
97 - def get_kv_cache_shape(
98 - num_blocks: int,
99 - block_size: int,
100 - num_kv_heads: int,
101 - head_size: int,
102 - cache_dtype_str: str = "auto",
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,
110 - num_kv_heads,
111 - block_size,
112 - head_size + TritonAttentionDiffKVBackend.head_size_v,
113 - )
114 -
115 - @staticmethod
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:
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":
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:
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":
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 -
95 @classmethod
96 def supports_head_size(cls, head_size: int) -> bool:
97 # DiffKV K head sizes (e.g. 192 for MiMo-V2.5) need to be allowed.
vllm/v1/attention/backends/turboquant_attn.py
+5 -34
@@ -64,7 +64,7 @@ from vllm.v1.attention.ops.triton_turboquant_decode import (
64 triton_turboquant_decode_attention,
65 )
66 from vllm.v1.attention.ops.triton_turboquant_store import triton_turboquant_store
67 -from vllm.v1.kv_cache_interface import AttentionSpec
67 +from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheLayout
68 from vllm.v1.worker.workspace import (
69 current_workspace_manager,
70 is_workspace_manager_initialized,
@@ -156,6 +156,10 @@ class TurboQuantAttentionBackend(AttentionBackend):
156 def get_name() -> str:
157 return "TURBOQUANT"
158
159 + @classmethod
160 + def supported_kv_cache_layouts(cls) -> tuple[KVCacheLayout, ...]:
161 + return (KVCacheLayout.LBNHC,)
162 +
163 @staticmethod
164 def get_supported_kernel_block_sizes() -> list[int | MultipleOf]:
165 return [16, 32, 64, 128]
@@ -176,39 +180,6 @@ class TurboQuantAttentionBackend(AttentionBackend):
180 def get_builder_cls() -> type["TurboQuantMetadataBuilder"]:
181 return TurboQuantMetadataBuilder
182
179 - @staticmethod
180 - def get_kv_cache_shape(
181 - num_blocks: int,
182 - block_size: int,
183 - num_kv_heads: int,
184 - head_size: int,
185 - cache_dtype_str: str = "turboquant_4bit_nc",
186 - ) -> tuple[int, ...]:
187 - """Combined K+V cache shape — no leading 2 dimension.
188 -
189 - Standard attention backends use (2, num_blocks, block_size, num_kv_heads,
190 - head_dim) with a leading 2 to separate K and V. TurboQuant packs K+V
191 - into a single interleaved slot per head per position. The logical
192 - (blocks-first, head-major) shape is:
193 -
194 - (num_blocks, num_kv_heads, block_size, slot_size_aligned)
195 -
196 - Each slot = [key_packed | value_packed | padding].
197 - This is safe because TQ has its own get_kv_cache_shape override and
198 - never shares cache tensors with other backends. Layers that fall back
199 - to native dtype via kv_cache_dtype_skip_layers get their own
200 - standard-shaped cache allocation.
201 -
202 - head_size is the model's real head_dim. slot_size_aligned is computed
203 - from the TQ config to ensure correct cache allocation for all head dims.
204 - """
205 - from vllm.model_executor.layers.quantization.turboquant.config import (
206 - TurboQuantConfig,
207 - )
208 -
209 - tq_config = TurboQuantConfig.from_cache_dtype(cache_dtype_str, head_size)
210 - return (num_blocks, num_kv_heads, block_size, tq_config.slot_size_aligned)
211 -
183 @classmethod
184 def supports_kv_cache_dtype(cls, kv_cache_dtype: CacheDType | None) -> bool:
185 if kv_cache_dtype is None:
vllm/v1/attention/backends/utils.py
+151 -43
@@ -1,26 +1,24 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -import functools
3 import math
5 -from collections.abc import Callable
4 +from collections import defaultdict
5 +from collections.abc import Callable, Iterable, Sequence
6 from dataclasses import dataclass, field, fields, make_dataclass
7 from typing import (
8 TYPE_CHECKING,
9 Any,
10 - Literal,
10 Protocol,
12 - cast,
13 - get_args,
11 )
12
13 import numpy as np
14 import torch
15 from typing_extensions import runtime_checkable
16
20 -from vllm.config import VllmConfig, get_layers_from_vllm_config
17 +from vllm.config import CacheConfig, VllmConfig, get_layers_from_vllm_config
18 +from vllm.config.cache import _layout_from_name
19 from vllm.utils.math_utils import cdiv
20 from vllm.utils.torch_utils import PIN_MEMORY, async_tensor_h2d, np_to_pinned_tensor
23 -from vllm.v1.kv_cache_interface import KVCacheSpec, MambaSpec
21 +from vllm.v1.kv_cache_interface import KVCacheLayout, KVCacheSpec, MambaSpec
22
23 if TYPE_CHECKING:
24 from vllm.v1.core.sched.output import SchedulerOutput
@@ -41,8 +39,6 @@ from vllm.v1.attention.backend import (
39 )
40
41 logger = init_logger(__name__)
44 -KVCacheLayoutType = Literal["NHD", "HND"]
45 -_KV_CACHE_LAYOUT_OVERRIDE: KVCacheLayoutType | None = None
42
43 PAD_SLOT_ID = -1
44 NULL_BLOCK_ID = 0
@@ -155,44 +151,159 @@ def fill_mm_prefix_query_ranges(
151 return num_actual_tokens
152
153
158 -def is_valid_kv_cache_layout(value: str) -> bool:
159 - return value in get_args(KVCacheLayoutType)
154 +_FLASHINFER_LAYOUT_NAMES = {
155 + "LBNHC": "NHD",
156 + "LBHNC": "HND",
157 + "BLHNC": "HND",
158 + "BLNHC": "NHD",
159 + "BHLNC": "HND",
160 +}
161
162
162 -@functools.lru_cache
163 -def get_kv_cache_layout():
164 - # Format specified by the code.
165 - global _KV_CACHE_LAYOUT_OVERRIDE
163 +def get_flashinfer_layout_string(layout: KVCacheLayout) -> str:
164 + """Return the layout name in FlashInfer's convention (NHD/HND)."""
165 + assert layout.name in _FLASHINFER_LAYOUT_NAMES, (
166 + f"KV cache layout {layout.name} has no FlashInfer equivalent; FlashInfer "
167 + "rejects it in supported_kv_cache_layouts"
168 + )
169 + return _FLASHINFER_LAYOUT_NAMES[layout.name]
170 +
171 +
172 +# Preference order when no backend declares a supported set; LBNHC (NHD)
173 +# first to match main's default.
174 +_DEFAULT_LAYOUT_PREFERENCE = (
175 + KVCacheLayout.LBNHC,
176 + KVCacheLayout.LBHNC,
177 + KVCacheLayout.BLNHC,
178 + KVCacheLayout.BLHNC,
179 + KVCacheLayout.BHLNC,
180 + KVCacheLayout.LHBNC,
181 +)
182 +
183 +
184 +def _layout_names(layouts: Iterable[KVCacheLayout]) -> list[str]:
185 + return [layout.name for layout in layouts]
186 +
187
167 - cache_layout: Literal["NHD", "HND"] | None = None
168 - if _KV_CACHE_LAYOUT_OVERRIDE is not None:
169 - cache_layout = _KV_CACHE_LAYOUT_OVERRIDE
170 - logger.debug_once(
171 - "`_KV_CACHE_LAYOUT_OVERRIDE` variable detected. "
172 - "Setting KV cache layout to %s.",
173 - cache_layout,
188 +def get_supported_kv_cache_layouts(
189 + backends: Iterable[type[AttentionBackend]],
190 +) -> list[KVCacheLayout]:
191 + """Layouts every one of the worker's backends supports, most preferred first.
192 +
193 + Every backend declares the layouts its kernels support, most preferred first
194 + (``supported_kv_cache_layouts``), or None when any layout works; workers where
195 + nothing declares follow the default preference. Identical declarations keep
196 + their order; otherwise the layout the most backends put first wins, ties
197 + keeping the enum order. An empty intersection is a hard error.
198 + """
199 + supported_layouts_lists: list[Sequence[KVCacheLayout]] = [
200 + layouts
201 + for backend in backends
202 + if (layouts := backend.supported_kv_cache_layouts()) is not None
203 + ] or [_DEFAULT_LAYOUT_PREFERENCE]
204 +
205 + first = supported_layouts_lists[0]
206 + if all(layouts == first for layouts in supported_layouts_lists[1:]):
207 + return list(first)
208 +
209 + priorities: dict[KVCacheLayout, int] = defaultdict(int)
210 + for preferred_layout, *_ in supported_layouts_lists:
211 + priorities[preferred_layout] += 1
212 + supported_layouts = set.intersection(*map(set, supported_layouts_lists))
213 + candidates = sorted(
214 + (layout for layout in KVCacheLayout if layout in supported_layouts),
215 + key=lambda layout: priorities[layout],
216 + reverse=True,
217 + )
218 + if not candidates:
219 + raise ValueError(
220 + "No KV cache layout satisfies every supported set: "
221 + f"{list(map(_layout_names, supported_layouts_lists))}."
222 )
175 - return cache_layout
223 + return candidates
224
177 - # Format specified by the user.
178 - cache_layout = envs.VLLM_KV_CACHE_LAYOUT
179 - # When neither the user nor the override specified a layout, get default
180 - if cache_layout is None:
181 - cache_layout = get_kv_connector_cache_layout()
182 - else:
183 - assert is_valid_kv_cache_layout(cache_layout)
184 - logger.info_once(
185 - "`VLLM_KV_CACHE_LAYOUT` environment variable "
186 - "detected. Setting KV cache layout to %s.",
187 - cache_layout,
225 +
226 +def record_kv_cache_layout(cache_config: CacheConfig, layout_name: str) -> None:
227 + """Adopt a layout resolved elsewhere (the engine core) in this process."""
228 + layout = _layout_from_name(layout_name)
229 + existing = cache_config.kv_cache_layout
230 + if existing is not None and existing != layout.name:
231 + raise ValueError(
232 + f"KV cache layout is already resolved to {existing}; "
233 + f"cannot change it to {layout.name}."
234 )
189 - return cache_layout
235 + cache_config.kv_cache_layout = layout.name
236 +
237 +
238 +def resolve_kv_cache_layout(
239 + vllm_config: VllmConfig,
240 + supported_layouts: list[list[str]],
241 + kv_cache_specs: Iterable[KVCacheSpec] | None = None,
242 +) -> KVCacheLayout:
243 + """Resolve one KV cache layout for the whole model.
244 +
245 + Runs once in the engine core. Every worker reports the layouts its backends
246 + support, most preferred first (``get_supported_kv_cache_layouts``); all
247 + ranks run the same backends, so their lists must agree. Specs mixing HNC
248 + shapes narrow the candidates to block-compact layouts. An explicit
249 + ``VLLM_KV_CACHE_LAYOUT`` must be one of the candidates or resolution fails,
250 + with the legacy ``NHD``/``HND`` names as aliases for ``LBNHC``/``LBHNC``; the
251 + connector's preference is used when compatible and dropped with a warning
252 + otherwise. A layout already present on ``cache_config`` wins outright, and
253 + the result is recorded there (see ``CacheConfig.kv_cache_layout``); it
254 + reaches workers through the ``set_kv_cache_layout`` RPC and
255 + ``KVCacheConfig.kv_cache_layout``.
256 + """
257 + cache_config = vllm_config.cache_config
258 + if cache_config.kv_cache_layout is not None:
259 + return cache_config.get_resolved_kv_cache_layout()
260 +
261 + assert supported_layouts and all(supported_layouts), (
262 + "No worker reported supported KV cache layouts."
263 + )
264 + assert all(names == supported_layouts[0] for names in supported_layouts[1:]), (
265 + f"Workers disagree on supported KV cache layouts: {supported_layouts}."
266 + )
267 + candidates = [_layout_from_name(name) for name in supported_layouts[0]]
268 +
269 + # A block-compact layout means the block is densely packed in memory, so any mix of
270 + # specs can re-interpret HNC with different sizes as long as the total number of
271 + # bytes is the same. If not block-compact, each spec must agree on HNC to alias
272 + # the same page (this aliasing is done by the Hybrid Memory Allocator, HMA).
273 + hnc_shapes = {
274 + (spec.num_heads, spec.num_states, spec.page_size_bytes)
275 + for spec in kv_cache_specs or ()
276 + }
277 + if len(hnc_shapes) > 1:
278 + candidates = [m for m in candidates if m.is_block_compact]
279 + if not candidates:
280 + raise ValueError(
281 + "Specs with mixed HNC shapes need a block-compact layout, but "
282 + f"none is in every supported set: {supported_layouts}."
283 + )
284
285 + if (requested := envs.VLLM_KV_CACHE_LAYOUT) is not None:
286 + layout = _layout_from_name(requested)
287 + if layout not in candidates:
288 + raise ValueError(
289 + f"VLLM_KV_CACHE_LAYOUT={requested} does not satisfy every "
290 + f"supported set; valid layouts: {_layout_names(candidates)}."
291 + )
292 + elif (connector := get_kv_connector_cache_layout(vllm_config)) is not None:
293 + layout = _layout_from_name(connector)
294 + if layout not in candidates:
295 + logger.warning_once(
296 + f"KV connector cache layout {connector} does not satisfy every "
297 + f"supported set; valid layouts: {_layout_names(candidates)}. "
298 + f"Using {candidates[0].name} instead."
299 + )
300 + layout = candidates[0]
301 + else:
302 + layout = candidates[0]
303
192 -def set_kv_cache_layout(cache_layout: KVCacheLayoutType | None):
193 - global _KV_CACHE_LAYOUT_OVERRIDE
194 - _KV_CACHE_LAYOUT_OVERRIDE = cache_layout
195 - get_kv_cache_layout.cache_clear()
304 + logger.info_once("Using %s KV cache layout.", layout.name)
305 + cache_config.kv_cache_layout = layout.name
306 + return layout
307
308
309 @dataclass
@@ -264,10 +375,7 @@ def get_num_attention_heads_from_layers(
375 )
376 if not attn_layers:
377 return None
267 - heads = {
268 - cast(Any, getattr(layer, "impl", layer)).num_heads
269 - for layer in attn_layers.values()
270 - }
378 + heads = {layer.impl.num_heads for layer in attn_layers.values()}
379 assert len(heads) == 1, (
380 f"All layers in one attention group must share num_heads; "
381 f"got {heads} for {layer_names}."
vllm/v1/attention/selector.py
+2 -16
@@ -8,7 +8,6 @@ import torch
8
9 import vllm.envs as envs
10 from vllm.config.cache import CacheDType
11 -from vllm.logger import init_logger
11 from vllm.utils.import_utils import resolve_obj_by_qualname
12 from vllm.v1.attention.backend import AttentionBackend, AttentionType
13 from vllm.v1.attention.backends.registry import (
@@ -18,8 +17,6 @@ from vllm.v1.attention.backends.registry import (
17 if TYPE_CHECKING:
18 from vllm.v1.kv_cache_interface import KVCacheSpecKind
19
21 -logger = init_logger(__name__)
22 -
20
21 class AttentionSelectorConfig(NamedTuple):
22 head_size: int
@@ -185,6 +182,8 @@ def get_attn_backend(
182 )
183 backend = attention_config.backend_per_kind.get(kind.value, backend)
184
185 + # The KV cache layout is resolved across all of the model's backends at once
186 + # in get_kv_cache_spec(); a single selection cannot see its peers.
187 return _cached_get_attn_backend(
188 backend=backend,
189 attn_selector_config=attn_selector_config,
@@ -210,19 +209,6 @@ def _cached_get_attn_backend(
209 f"Invalid attention backend for {current_platform.device_name}"
210 )
211 backend = resolve_obj_by_qualname(attention_cls)
213 -
214 - # Adjust kv cache layout if the selected backend requires a specific one
215 - required_layout = backend.get_required_kv_cache_layout()
216 - if required_layout is not None:
217 - from vllm.v1.attention.backends.utils import set_kv_cache_layout
218 -
219 - set_kv_cache_layout(required_layout)
220 - logger.info_once(
221 - "Using %s KV cache layout for %s backend.",
222 - required_layout,
223 - backend.get_name(),
224 - )
225 -
212 return backend
213
214
vllm/v1/core/kv_cache_utils.py
+138 -214
@@ -10,7 +10,7 @@ from collections import defaultdict
10 from collections.abc import Callable, Iterable, Iterator, Sequence
11 from dataclasses import dataclass, replace
12 from functools import partial
13 -from typing import Any, NamedTuple, NewType, TypeAlias, cast, overload
13 +from typing import Any, NamedTuple, NewType, TypeAlias, overload
14
15 from vllm import envs
16 from vllm.config import VllmConfig
@@ -26,6 +26,7 @@ from vllm.v1.kv_cache_interface import (
26 HiddenStateCacheSpec,
27 KVCacheConfig,
28 KVCacheGroupSpec,
29 + KVCacheLayout,
30 KVCacheSpec,
31 KVCacheTensor,
32 MambaSpec,
@@ -33,6 +34,7 @@ from vllm.v1.kv_cache_interface import (
34 SlidingWindowMLASpec,
35 SlidingWindowSpec,
36 UniformTypeKVCacheSpecs,
37 + compute_layout_strides,
38 replace_as,
39 )
40 from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
@@ -897,7 +899,7 @@ def check_enough_kv_cache_memory(
899 # of the specs since grouping may unify them in-place.
900 groups = get_kv_cache_groups(vllm_config, dict(kv_cache_spec))
901 check_memory = (
900 - available_memory - _pool_bytes_per_block(vllm_config, groups)
902 + available_memory - _pool_bytes_per_block(groups)
903 if groups
904 else available_memory
905 )
@@ -999,45 +1001,14 @@ def may_override_num_blocks(vllm_config: VllmConfig, num_blocks: int) -> int:
1001 return num_blocks
1002
1003
1002 -def _pool_bytes_per_block(
1003 - vllm_config: VllmConfig, kv_cache_groups: list[KVCacheGroupSpec]
1004 -) -> int:
1004 +def _pool_bytes_per_block(kv_cache_groups: list[KVCacheGroupSpec]) -> int:
1005 """
1006 Bytes consumed by one block in the worker's shared KV cache pool, mirroring
1007 the divisor used by `get_kv_cache_config_from_groups` to convert
1008 `available_memory` into `num_blocks`. Used to compute the effective KV cache
1009 capacity once `num_gpu_blocks_override` is applied.
1010 """
1011 - if len(kv_cache_groups) == 1 and isinstance(
1012 - kv_cache_groups[0].kv_cache_spec, UniformTypeKVCacheSpecs
1013 - ):
1014 - return kv_cache_groups[0].kv_cache_spec.page_size_bytes
1015 - if _use_packed_kv_cache_config(vllm_config, kv_cache_groups):
1016 - block_stride, _ = _get_packed_kv_cache_layout(kv_cache_groups)
1017 - return block_stride
1018 - group_size = max(len(g.layer_names) for g in kv_cache_groups)
1019 - page_size = get_uniform_page_size([g.kv_cache_spec for g in kv_cache_groups])
1020 - return page_size * group_size
1021 -
1022 -
1023 -def get_num_blocks(
1024 - vllm_config: VllmConfig,
1025 - num_layers: int,
1026 - available_memory: int,
1027 - page_size: int,
1028 -) -> int:
1029 - """
1030 - Get the number of kv cache blocks.
1031 -
1032 - Args:
1033 - vllm_config: The global VllmConfig
1034 - num_layers: The number of layers
1035 - available_memory: Memory available for KV cache in bytes.
1036 - page_size: The page size of the KV cache.
1037 - """
1038 - num_blocks = int(available_memory // page_size // num_layers)
1039 - num_blocks = max(num_blocks, 0)
1040 - return may_override_num_blocks(vllm_config, num_blocks)
1011 + return _get_kv_cache_bytes_per_block(kv_cache_groups)
1012
1013
1014 def get_uniform_page_size(kv_cache_specs: Iterable[KVCacheSpec]) -> int:
@@ -1092,11 +1063,14 @@ def unify_kv_cache_spec_page_size(
1063 size by increasing the block size of layers with smaller page size. Two
1064 cases cannot be unified by block size alone and pad their physical page to
1065 the maximum instead: Mamba layers, whose page size comes from state shapes
1095 - and is independent of block size; and attention layers whose page does not
1096 - evenly divide the maximum and whose backend opts in via
1097 - ``AttentionSpec.indexes_kv_by_block_stride`` (the padded page is read through
1098 - a strided view, which not every backend handles). Raise NotImplementedError
1099 - if failed to unify the page size.
1066 + and is independent of block size; and non-MLA attention layers whose page
1067 + does not evenly divide the maximum (the padded page is read through a
1068 + strided view). MLA is excluded because sparse MLA indexes the cache in
1069 + whole token rows (see ``flat_kv_row_view``), so its block stride can only
1070 + be padded by its own row-aligned ``alignment``, not to an arbitrary page
1071 + size. Raise NotImplementedError if failed to unify the page size;
1072 + ``get_kv_cache_groups`` catches it to try the full-allocation fallback
1073 + (e.g. MLA next to an incompatible sliding-window draft).
1074
1075 Args:
1076 kv_cache_spec: The KVCacheSpec of each attention layer in the model
@@ -1130,18 +1104,15 @@ def unify_kv_cache_spec_page_size(
1104 ratio = max_page_size // layer_page_size
1105 new_block_size = layer_spec.block_size * ratio
1106 new_spec = replace(layer_spec, block_size=new_block_size)
1133 - elif (
1134 - isinstance(layer_spec, AttentionSpec)
1135 - and layer_spec.indexes_kv_by_block_stride
1107 + elif isinstance(layer_spec, AttentionSpec) and not isinstance(
1108 + layer_spec, MLAAttentionSpec
1109 ):
1110 new_spec = replace(layer_spec, page_size_padded=max_page_size)
1111 else:
1112 raise NotImplementedError(
1113 f"Layer {layer_name}: page size is not divisible by the "
1114 "maximum page size and cannot be padded. Padding is only "
1142 - "supported for attention layers whose backend indexes KV "
1143 - "pages by the block stride (indexes_kv_by_block_stride is "
1144 - "True)."
1115 + "supported for non-MLA attention layers."
1116 )
1117 assert new_spec.page_size_bytes == max_page_size
1118 new_kv_cache_spec[layer_name] = new_spec
@@ -1296,83 +1267,64 @@ def _get_kv_cache_groups_uniform_page_size(
1267 return create_kv_cache_group_specs(kv_cache_spec, grouped_layers)
1268
1269
1299 -def _get_packed_kv_cache_layout(
1300 - kv_cache_groups: list[KVCacheGroupSpec],
1301 -) -> tuple[int, dict[int, list[str]]]:
1302 - """Lay out each cache group densely in one shared block slab.
1303 -
1304 - A block ID is owned by one cache group at a time, so layouts from different
1305 - groups may overlap. Layers within a group remain disjoint.
1306 - """
1307 - layers_by_offset: dict[int, list[str]] = defaultdict(list)
1308 - block_stride = 0
1309 - for group in kv_cache_groups:
1310 - spec = group.kv_cache_spec
1311 - byte_offset = 0
1312 - for layer_name in group.layer_names:
1313 - if isinstance(spec, UniformTypeKVCacheSpecs):
1314 - page_size = spec.kv_cache_specs[layer_name].page_size_bytes
1315 - else:
1316 - page_size = spec.page_size_bytes
1317 - layers_by_offset[byte_offset].append(layer_name)
1318 - byte_offset += page_size
1319 - block_stride = max(block_stride, byte_offset)
1320 - assert block_stride > 0
1321 - return block_stride, layers_by_offset
1270 +def _get_per_layer_spec(
1271 + group: KVCacheGroupSpec,
1272 + layer_name: str,
1273 +) -> KVCacheSpec:
1274 + spec = group.kv_cache_spec
1275 + if isinstance(spec, UniformTypeKVCacheSpecs):
1276 + return spec.kv_cache_specs[layer_name]
1277 + return spec
1278
1279
1324 -def _use_packed_kv_cache_config(
1325 - vllm_config: VllmConfig,
1280 +def _get_kv_cache_bytes_per_block(
1281 kv_cache_groups: list[KVCacheGroupSpec],
1327 -) -> bool:
1328 - is_dsv4 = all(
1329 - isinstance(group.kv_cache_spec, UniformTypeKVCacheSpecs)
1282 +) -> int:
1283 + """Return the largest cache group's bytes per block."""
1284 + bytes_per_block = max(
1285 + sum(
1286 + _get_per_layer_spec(group, layer_name).page_size_bytes
1287 + for layer_name in group.layer_names
1288 + )
1289 for group in kv_cache_groups
1290 )
1332 - kv_transfer_config = vllm_config.kv_transfer_config
1333 - extra_config = (
1334 - kv_transfer_config.kv_connector_extra_config
1335 - if kv_transfer_config is not None
1336 - else {}
1337 - )
1338 - # NOTE: enable_cross_layers_blocks is an experimental API and subject to change with
1339 - # https://github.com/vllm-project/vllm/issues/42082
1340 - enable_cross_layers = (
1341 - str(extra_config.get("enable_cross_layers_blocks", "False")).lower() == "true"
1342 - )
1343 - return is_dsv4 or (enable_cross_layers and len(kv_cache_groups) > 1)
1291 + assert bytes_per_block > 0
1292 + return bytes_per_block
1293
1294
1346 -def _get_kv_cache_config_packed(
1347 - vllm_config: VllmConfig,
1295 +def validate_kv_cache_layout(
1296 + layout: KVCacheLayout,
1297 kv_cache_groups: list[KVCacheGroupSpec],
1349 - available_memory: int,
1350 -) -> tuple[int, list[KVCacheTensor]]:
1351 - """Plan a packed per-block KV cache tensor layout.
1298 +) -> None:
1299 + """Validate that the resolved layout can express this model's packing.
1300
1353 - Cache groups use dense, overlapping layouts within one block slab. Each
1354 - emitted tensor aliases the same physical backing allocation.
1301 + The layout was chosen once in the engine core from the backends' supported
1302 + sets; a backend whose model packs pages side by side (e.g. the DeepSeek-V4
1303 + indexer) declares block-outermost layouts there, so an inexpressible
1304 + layout reaching this point is an error.
1305 """
1356 - block_stride, layers_by_offset = _get_packed_kv_cache_layout(kv_cache_groups)
1357 -
1358 - num_blocks = available_memory // block_stride
1359 - num_blocks = may_override_num_blocks(vllm_config, num_blocks)
1360 -
1361 - total_size = block_stride * num_blocks
1306 + page_sizes = {
1307 + _get_per_layer_spec(group, layer_name).page_size_bytes
1308 + for group in kv_cache_groups
1309 + for layer_name in group.layer_names
1310 + }
1311 + if len(page_sizes) == 1:
1312 + # A rectangular layer dim exists; every layout can express it.
1313 + return
1314
1363 - kv_cache_tensors: list[KVCacheTensor] = []
1364 - for byte_offset in sorted(layers_by_offset):
1365 - kv_cache_tensors.append(
1366 - KVCacheTensor(
1367 - size=total_size,
1368 - shared_by=layers_by_offset[byte_offset],
1369 - offset=byte_offset,
1370 - block_stride=block_stride,
1371 - )
1315 + # Mixed page sizes pack pages side by side within a block, which needs each page
1316 + # to be one contiguous chunk inside its block (a block-compact layout) and, with
1317 + # multiple KV cache groups, the layer dim inside the block dim.
1318 + if not layout.is_block_compact or (
1319 + len(kv_cache_groups) > 1 and layout.is_layer_compact
1320 + ):
1321 + raise ValueError(
1322 + f"KV cache layout {layout.name} cannot express this model's "
1323 + f"mixed page sizes ({sorted(page_sizes)}); a backend should "
1324 + "declare block-outermost supported layouts (e.g. BLHNC), or "
1325 + "set VLLM_KV_CACHE_LAYOUT=BLHNC."
1326 )
1327
1374 - return num_blocks, kv_cache_tensors
1375 -
1328
1329 def get_kv_cache_config_from_groups(
1330 vllm_config: VllmConfig,
@@ -1402,58 +1354,60 @@ def get_kv_cache_config_from_groups(
1354 ),
1355 )
1356
1405 - # Determine how model runners should initialize the KV cache tensors.
1406 - if len(kv_cache_groups) == 1 and isinstance(
1407 - kv_cache_groups[0].kv_cache_spec, UniformTypeKVCacheSpecs
1408 - ):
1409 - # Special case: all layers have the same type of KV cache but with
1410 - # different hidden sizes. Allocate different amount of memory for each
1411 - # layer based on its hidden size.
1412 - num_blocks = (
1413 - available_memory // kv_cache_groups[0].kv_cache_spec.page_size_bytes
1414 - )
1415 - num_blocks = may_override_num_blocks(vllm_config, num_blocks)
1416 - per_layer_specs = kv_cache_groups[0].kv_cache_spec.kv_cache_specs
1417 - kv_cache_tensors = [
1418 - KVCacheTensor(
1419 - size=per_layer_specs[layer_name].page_size_bytes * num_blocks,
1420 - shared_by=[layer_name],
1357 + layout = vllm_config.cache_config.get_resolved_kv_cache_layout()
1358 + validate_kv_cache_layout(layout, kv_cache_groups)
1359 + bytes_per_block = _get_kv_cache_bytes_per_block(kv_cache_groups)
1360 + interleaved_block_stride = bytes_per_block if layout.is_block_outermost else None
1361 +
1362 + num_blocks = available_memory // bytes_per_block
1363 + num_blocks = may_override_num_blocks(vllm_config, num_blocks)
1364 + size = bytes_per_block * num_blocks
1365 +
1366 + # Groups alias from byte 0. Spec regions are laid out differently:
1367 + #
1368 + # block-outer (the same packing repeats for every block):
1369 + # group 0: | blk 0 [ A | B | pad ] | blk 1 [ A | B | pad ] | ...
1370 + # group 1: | blk 0 [ C | D ] | blk 1 [ C | D ] | ...
1371 + # |<--- bytes_per_block -->|
1372 + #
1373 + # layer-outer (only supported for uniform page sizes or single-group models):
1374 + # group 0: | A [ blk 0 | blk 1 | ... ] | B [ blk 0 | blk 1 | ... ] |
1375 + # group 1: | C [ blk 0 | blk 1 | ... ] | D [ blk 0 | blk 1 | ... ] |
1376 +
1377 + kv_cache_tensors = []
1378 + for group in kv_cache_groups:
1379 + group_spec = group.kv_cache_spec
1380 + layers_by_spec: defaultdict[KVCacheSpec, list[str]] = defaultdict(list)
1381 + if isinstance(group_spec, UniformTypeKVCacheSpecs):
1382 + for layer_name, spec in group_spec.kv_cache_specs.items():
1383 + layers_by_spec[spec].append(layer_name)
1384 + elif group.layer_names:
1385 + layers_by_spec[group_spec].extend(group.layer_names)
1386 +
1387 + byte_offset = 0
1388 + for spec, layer_names in layers_by_spec.items():
1389 + layer_stride, block_stride, _, _, _ = compute_layout_strides(
1390 + spec,
1391 + num_blocks,
1392 + len(layer_names),
1393 + layout,
1394 + fixed_strides=(None, interleaved_block_stride, None, None, None),
1395 + )
1396 + offset = (
1397 + byte_offset
1398 + * max(layer_stride, spec.page_size_bytes)
1399 + // spec.page_size_bytes
1400 )
1422 - for layer_name in kv_cache_groups[0].layer_names
1423 - ]
1424 - elif _use_packed_kv_cache_config(vllm_config, kv_cache_groups):
1425 - # DeepSeek V4 uses the packed layout by default. Other multi-group
1426 - # layouts can opt in with --enable-cross-layers.
1427 - num_blocks, kv_cache_tensors = _get_kv_cache_config_packed(
1428 - vllm_config, kv_cache_groups, available_memory
1429 - )
1430 - else:
1431 - # General case:
1432 - # We will have group_size memory pools, each is shared by one layer from
1433 - # each group. As layers of different groups have different block table,
1434 - # they will use different parts of the shared Tensor.
1435 - # The memory layout for 3 groups (full.0, full.1), (sw.0, sw.2),
1436 - # (sw.1, padding) will be: (group_size = 2)
1437 - # full.0, sw.0, sw.1: share a Tensor with size=available_memory//2
1438 - # full.1, sw.2: share another Tensor with size=available_memory//2
1439 - group_size = max(len(group.layer_names) for group in kv_cache_groups)
1440 -
1441 - page_size = get_uniform_page_size(
1442 - [group.kv_cache_spec for group in kv_cache_groups]
1443 - )
1444 - assert group_size > 0, "group_size must be greater than 0"
1445 - num_blocks = get_num_blocks(
1446 - vllm_config, group_size, available_memory, page_size
1447 - )
1448 - kv_cache_tensors = []
1449 - for i in range(group_size):
1450 - shared_by = []
1451 - for j in range(len(kv_cache_groups)):
1452 - if i < len(kv_cache_groups[j].layer_names):
1453 - shared_by.append(kv_cache_groups[j].layer_names[i])
1401 kv_cache_tensors.append(
1455 - KVCacheTensor(size=page_size * num_blocks, shared_by=shared_by)
1402 + KVCacheTensor(
1403 + size=size,
1404 + layers=layer_names,
1405 + layer_stride=layer_stride,
1406 + block_stride=block_stride,
1407 + offset=offset,
1408 + )
1409 )
1410 + byte_offset += len(layer_names) * spec.page_size_bytes
1411
1412 return KVCacheConfig(
1413 num_blocks=num_blocks,
@@ -1941,57 +1895,26 @@ def _max_memory_usage_bytes_from_groups(
1895 This correctly accounts for padding in hybrid models. For example, if a
1896 model has 8 full attention layers and 9 sliding window layers, they will
1897 be padded to 9 full + 9 sliding window for uniform group sizes.
1898 +
1899 + Each group independently claims blocks from the shared pool, so a request consumes
1900 + the sum of the per-group block counts, i.e. ``bytes_per_block * total_blocks``.
1901 """
1902 if not kv_cache_groups:
1903 return 0
1904
1948 - if len(kv_cache_groups) == 1 and isinstance(
1949 - kv_cache_groups[0].kv_cache_spec, UniformTypeKVCacheSpecs
1950 - ):
1951 - # UniformTypeKVCacheSpecs special case (single group, per-layer specs)
1952 - per_layer_specs = kv_cache_groups[0].kv_cache_spec.kv_cache_specs
1953 - return sum(
1954 - spec.max_memory_usage_bytes(vllm_config)
1955 - for spec in per_layer_specs.values()
1956 - )
1957 - elif all(
1958 - isinstance(group.kv_cache_spec, UniformTypeKVCacheSpecs)
1959 - for group in kv_cache_groups
1960 - ):
1961 - # Special case (only DeepseekV4 for now): all groups are
1962 - # UniformTypeKVCacheSpecs.
1963 - # They must already be page_size aligned and share a common padded
1964 - # layer-tuple layout. Even groups with fewer actual tuples still reserve
1965 - # the global number of tuple slots in the shared tensor layout.
1966 - full_mla_spec = cast(UniformTypeKVCacheSpecs, kv_cache_groups[0].kv_cache_spec)
1967 - layer_tuple_bytes = sum(full_mla_spec.get_page_sizes())
1968 - num_layer_tuples = max(
1969 - cast(UniformTypeKVCacheSpecs, group.kv_cache_spec).get_num_layer_tuples()
1970 - for group in kv_cache_groups
1971 - )
1972 -
1973 - total_max_mem_usage_bytes = 0
1974 - for group in kv_cache_groups:
1975 - group_spec = cast(UniformTypeKVCacheSpecs, group.kv_cache_spec)
1976 - g_max_mem_usage_pages = group_spec.max_memory_usage_pages(vllm_config)
1977 - g_max_mem_usage_page_bytes = (
1978 - num_layer_tuples * g_max_mem_usage_pages * layer_tuple_bytes
1905 + bytes_per_block = _pool_bytes_per_block(kv_cache_groups)
1906 + total_blocks = 0
1907 + for group in kv_cache_groups:
1908 + spec = group.kv_cache_spec
1909 + if isinstance(spec, UniformTypeKVCacheSpecs):
1910 + total_blocks += spec.max_memory_usage_pages(vllm_config)
1911 + else:
1912 + total_blocks += cdiv(
1913 + spec.max_memory_usage_bytes(vllm_config),
1914 + spec.page_size_bytes,
1915 )
1980 - total_max_mem_usage_bytes += g_max_mem_usage_page_bytes
1981 - return total_max_mem_usage_bytes
1982 -
1983 - # General case: group_size pools, each shared by one layer per group
1984 - # Memory = group_size * page_size * blocks_for_max_len
1985 - group_size = max(len(group.layer_names) for group in kv_cache_groups)
1986 - page_size = get_uniform_page_size(
1987 - [group.kv_cache_spec for group in kv_cache_groups]
1988 - )
1989 - blocks_needed = sum(
1990 - cdiv(group.kv_cache_spec.max_memory_usage_bytes(vllm_config), page_size)
1991 - for group in kv_cache_groups
1992 - )
1916
1994 - return group_size * page_size * blocks_needed
1917 + return bytes_per_block * total_blocks
1918
1919
1920 def _estimate_max_model_len_from_groups(
@@ -2228,7 +2151,7 @@ def get_kv_cache_configs(
2151 if not groups:
2152 adjusted_memory.append(avail_mem)
2153 continue
2231 - bytes_per_block = _pool_bytes_per_block(vllm_config, groups)
2154 + bytes_per_block = _pool_bytes_per_block(groups)
2155 logger.info(
2156 "Overriding num_gpu_blocks=%d with num_gpu_blocks_override=%d",
2157 avail_mem // bytes_per_block,
@@ -2241,7 +2164,7 @@ def get_kv_cache_configs(
2164 # the capacity check both plan against usable blocks. Allocation below
2165 # still uses the full memory.
2166 check_memory = [
2244 - avail_mem - _pool_bytes_per_block(vllm_config, groups) if groups else avail_mem
2167 + avail_mem - _pool_bytes_per_block(groups) if groups else avail_mem
2168 for groups, avail_mem in zip(projected_groups_per_worker, available_memory)
2169 ]
2170
@@ -2278,14 +2201,15 @@ def get_kv_cache_configs(
2201 min_num_blocks = min(
2202 kv_cache_config.num_blocks for kv_cache_config in kv_cache_configs
2203 )
2281 - for kv_cache_config in kv_cache_configs:
2282 - num_blocks_old = kv_cache_config.num_blocks
2283 - kv_cache_config.num_blocks = min_num_blocks
2284 -
2285 - # Shrink tensor size proportionally
2286 - for tensor in kv_cache_config.kv_cache_tensors:
2287 - assert tensor.size % num_blocks_old == 0
2288 - tensor.size = tensor.size // num_blocks_old * min_num_blocks
2204 + for i, kv_cache_config in enumerate(kv_cache_configs):
2205 + if kv_cache_config.num_blocks == min_num_blocks:
2206 + continue
2207 + # Re-plan with exactly the memory the smallest rank can afford, so
2208 + # strides and offsets stay consistent with the shrunken allocation.
2209 + groups = kv_cache_config.kv_cache_groups
2210 + kv_cache_configs[i] = get_kv_cache_config_from_groups(
2211 + vllm_config, groups, min_num_blocks * _pool_bytes_per_block(groups)
2212 + )
2213
2214 return kv_cache_configs
2215
vllm/v1/engine/core.py
+14
@@ -44,6 +44,7 @@ from vllm.utils.gc_utils import (
44 from vllm.utils.hashing import get_hash_fn_by_name
45 from vllm.utils.network_utils import make_zmq_socket
46 from vllm.utils.system_utils import decorate_logs, set_process_title
47 +from vllm.v1.attention.backends.utils import resolve_kv_cache_layout
48 from vllm.v1.core.kv_cache_utils import (
49 BlockHash,
50 generate_scheduler_kv_cache_config,
@@ -281,6 +282,17 @@ class EngineCore:
282 )
283 vllm_config.cache_config.enable_prefix_caching = False
284
285 + # Resolve the KV cache layout before memory profiling: workers that
286 + # capture full cudagraphs initialize a minimal KV cache during it.
287 + # Attention-free models resolve the default so layout reads never precede
288 + # resolution.
289 + layout = resolve_kv_cache_layout(
290 + vllm_config,
291 + self.model_executor.get_supported_kv_cache_layouts(),
292 + [s for specs in kv_cache_specs for s in specs.values()],
293 + )
294 + self.model_executor.set_kv_cache_layout(layout.name)
295 +
296 has_kv_cache = any(kv_cache_spec for kv_cache_spec in kv_cache_specs)
297 if has_kv_cache:
298 if envs.VLLM_ELASTIC_EP_SCALE_UP_LAUNCH:
@@ -307,6 +319,8 @@ class EngineCore:
319 kv_cache_configs = get_kv_cache_configs(
320 vllm_config, kv_cache_specs, available_gpu_memory
321 )
322 + for kv_cache_config in kv_cache_configs:
323 + kv_cache_config.kv_cache_layout = vllm_config.cache_config.kv_cache_layout
324
325 # If auto-fit reduced max_model_len, sync the new value to workers.
326 # This is needed because workers were spawned before memory profiling
vllm/v1/executor/abstract.py
+8
@@ -151,6 +151,14 @@ class Executor(ABC):
151 def get_kv_cache_specs(self) -> list[dict[str, KVCacheSpec]]:
152 return self.collective_rpc("get_kv_cache_spec")
153
154 + def get_supported_kv_cache_layouts(self) -> list[list[str]]:
155 + """Layouts each worker's backends support, most preferred first."""
156 + return self.collective_rpc("get_supported_kv_cache_layouts")
157 +
158 + def set_kv_cache_layout(self, layout_name: str) -> None:
159 + """Publish the resolved KV cache layout to the workers."""
160 + self.collective_rpc("set_kv_cache_layout", args=(layout_name,))
161 +
162 @overload
163 def collective_rpc(
164 self,
vllm/v1/kv_cache_interface.py
+202 -40
@@ -8,6 +8,7 @@ from collections import Counter
8 from collections.abc import Collection
9 from dataclasses import dataclass, fields, replace
10 from enum import Enum, IntEnum
11 +from fractions import Fraction
12 from math import prod
13 from typing import TYPE_CHECKING, TypeVar
14
@@ -18,6 +19,7 @@ from vllm.logger import init_logger
19 from vllm.utils.math_utils import cdiv, round_up
20 from vllm.utils.torch_utils import get_dtype_size
21 from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
22 +from vllm.v1.kv_cache_layout import _DIM_B, _DIM_L, KVCacheLayout
23 from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
24
25 if TYPE_CHECKING:
@@ -147,6 +149,18 @@ class KVCacheSpec:
149 # number of tokens in a block
150 block_size: int
151
152 + @property
153 + def num_heads(self) -> int:
154 + raise NotImplementedError
155 +
156 + @property
157 + def tokens_per_state(self) -> int | Fraction:
158 + raise NotImplementedError
159 +
160 + @property
161 + def state_content_size_bytes(self) -> int:
162 + raise NotImplementedError
163 +
164 @property
165 def page_size_bytes(self) -> int:
166 """
@@ -158,8 +172,13 @@ class KVCacheSpec:
172 raise NotImplementedError
173
174 @property
161 - def storage_block_size(self) -> int:
162 - return self.block_size
175 + def num_states(self) -> int:
176 + return self.get_num_kernel_states(self.block_size)
177 +
178 + def get_num_kernel_states(self, kernel_block_size: int) -> int:
179 + if self.tokens_per_state > 0:
180 + return kernel_block_size // self.tokens_per_state
181 + return 1
182
183 def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int:
184 """
@@ -214,6 +233,143 @@ class KVCacheSpec:
233 )
234
235
236 +def group_kernel_blocks(cache: torch.Tensor, num_blocks: int) -> torch.Tensor:
237 + """View a kernel-block-granular layer cache with manager blocks as dim 0.
238 +
239 + Kernel block splitting subdivides each manager block into uniformly strided
240 + kernel blocks, so grouping is a pure view: ``(num_blocks * ratio, ...)``
241 + """
242 + if cache.shape[0] == num_blocks:
243 + return cache
244 + assert cache.shape[0] % num_blocks == 0
245 + return cache.unflatten(0, (num_blocks, -1))
246 +
247 +
248 +def compute_layer_kv_cache_shape_bytes(
249 + spec: KVCacheSpec,
250 + num_blocks: int,
251 + kernel_block_size: int | None = None,
252 +) -> tuple[int, ...]:
253 + """Return the 4D logical shape ``(B, H, N, C)`` where C is in bytes."""
254 + bs = kernel_block_size if kernel_block_size is not None else spec.block_size
255 + assert spec.block_size % bs == 0, (
256 + f"Kernel block size {bs} must divide KV cache block size {spec.block_size}."
257 + )
258 + blocks_per_page = spec.block_size // bs
259 + return (
260 + num_blocks * blocks_per_page,
261 + spec.num_heads,
262 + spec.get_num_kernel_states(bs),
263 + spec.state_content_size_bytes,
264 + )
265 +
266 +
267 +def compute_layout_strides(
268 + spec: KVCacheSpec,
269 + num_blocks: int,
270 + num_layers: int,
271 + layout: KVCacheLayout,
272 + kernel_block_size: int | None = None,
273 + fixed_strides: tuple[int | None, ...] = (None,) * 5,
274 +) -> tuple[int, ...]:
275 + """Byte strides in logical ``[L, B, H, N, C]`` axis order."""
276 + assert len(fixed_strides) == 5
277 + assert all(stride is None or stride > 0 for stride in fixed_strides)
278 + shape = (
279 + num_layers,
280 + *compute_layer_kv_cache_shape_bytes(spec, num_blocks, kernel_block_size),
281 + )
282 + order = layout.stride_order
283 + padded_page_size = getattr(spec, "page_size_padded", None)
284 + if padded_page_size is not None:
285 + assert kernel_block_size is None or kernel_block_size == spec.block_size, (
286 + "Padded KV pages do not support kernel block splitting."
287 + )
288 + page_grid_end = max(order.index(_DIM_L), order.index(_DIM_B)) + 1
289 + page_grid_shape = tuple(shape[dim] for dim in order[:page_grid_end])
290 + assert prod(page_grid_shape) == num_layers * num_blocks, (
291 + "Page padding requires dimensions outside the page tail to be L, B, "
292 + f"or singleton; got {layout.name} with shape {shape}."
293 + )
294 +
295 + strides = [0] * 5
296 + current_stride = 1
297 + for physical_idx, dim in reversed(tuple(enumerate(order))):
298 + if padded_page_size is not None and physical_idx == page_grid_end - 1:
299 + current_stride = max(current_stride, padded_page_size)
300 + assert current_stride % padded_page_size == 0
301 + strides[dim] = fixed_strides[dim] or current_stride
302 + current_stride = strides[dim] * shape[dim]
303 + return tuple(strides)
304 +
305 +
306 +def create_kv_cache_views(
307 + raw: torch.Tensor,
308 + spec: KVCacheSpec,
309 + num_blocks: int,
310 + layout: KVCacheLayout,
311 + kv_cache_tensor: KVCacheTensor,
312 + kernel_block_size: int | None = None,
313 +) -> list[torch.Tensor]:
314 + """View a flat int8 buffer as one 4D ``[B, H, N, C]`` view per layer.
315 +
316 + Block ``b`` of layer ``l`` starts at the tensor offset plus its layer and
317 + block stride contributions.
318 + """
319 + num_layers = len(kv_cache_tensor.layers)
320 + layer_stride = kv_cache_tensor.layer_stride
321 + block_stride = kv_cache_tensor.block_stride
322 + shape_bytes = compute_layer_kv_cache_shape_bytes(
323 + spec, num_blocks, kernel_block_size
324 + )
325 + ratio = shape_bytes[0] // num_blocks
326 + if ratio > 1:
327 + # Kernel blocks subdivide a manager block into `ratio` equal pieces, so
328 + # they sit a constant stride apart only if a block is one dense page: no
329 + # padding at its end, and no other layer's page before the next block.
330 + dense_page_size = prod(compute_layer_kv_cache_shape_bytes(spec, 1)[1:])
331 + if block_stride != dense_page_size:
332 + raise ValueError(
333 + f"The resolved KV cache layout ({layout.name}) does not store "
334 + "blocks as dense, unpadded pages (block stride "
335 + f"{block_stride} != page {dense_page_size}), so a manager "
336 + f"block cannot be split into {ratio} kernel blocks of "
337 + f"{kernel_block_size} tokens. Reduce --block-size to "
338 + f"{kernel_block_size} or set VLLM_KV_CACHE_LAYOUT to a "
339 + "layer-compact layout (e.g. LBNHC)."
340 + )
341 + assert block_stride % ratio == 0, (
342 + f"Block stride {block_stride} must divide into {ratio} equal kernel blocks."
343 + )
344 + block_stride //= ratio
345 +
346 + logical_shape = (num_layers, *shape_bytes)
347 + strides = compute_layout_strides(
348 + spec,
349 + num_blocks,
350 + num_layers,
351 + layout,
352 + kernel_block_size,
353 + fixed_strides=(layer_stride, block_stride, None, None, None),
354 + )
355 + dtype = getattr(spec, "dtype", None)
356 +
357 + view_5d = torch.as_strided(
358 + raw,
359 + size=logical_shape,
360 + stride=strides,
361 + storage_offset=raw.storage_offset() + kv_cache_tensor.offset,
362 + )
363 +
364 + views = []
365 + for layer_idx in range(num_layers):
366 + cache_logical = view_5d[layer_idx]
367 + if dtype is not None:
368 + cache_logical = cache_logical.view(dtype)
369 + views.append(cache_logical)
370 + return views
371 +
372 +
373 @dataclass(frozen=True, kw_only=True)
374 class AttentionSpec(KVCacheSpec):
375 num_kv_heads: int
@@ -222,13 +378,16 @@ class AttentionSpec(KVCacheSpec):
378 head_size_v: int = None # type: ignore[assignment]
379 kv_quant_mode: KVQuantMode = KVQuantMode.NONE
380 page_size_padded: int | None = None
225 - indexes_kv_by_block_stride: bool = False
381 num_head_slots: int | None = None
382 """H of the logical ``[B, H, N, C]`` page when packing diverges from one
383 slot per KV head. None means one slot per KV head. Published by the backend.
384 """
385 state_content_bytes: int | None = None
386 """C in bytes when packed; None means dense K/V content."""
387 + tokens_per_state: int | Fraction = 1
388 + """Tokens covered by one stored state. Ints > 1 compress multiple tokens
389 + into one state (DSv4 sparse MLA); fractions < 1 store multiple states per
390 + token (Whisper block pooling: ``Fraction(1, block_pool_size)``)."""
391
392 def __post_init__(self):
393 if self.head_size_v is None:
@@ -249,7 +408,7 @@ class AttentionSpec(KVCacheSpec):
408
409 @property
410 def unpadded_page_size_bytes(self) -> int:
252 - return self.num_heads * self.storage_block_size * self.state_content_size_bytes
411 + return self.num_heads * self.num_states * self.state_content_size_bytes
412
413 @property
414 def page_size_bytes(self) -> int:
@@ -345,9 +504,9 @@ class FullAttentionSpec(AttentionSpec):
504 dtype=specs[0].dtype,
505 kv_quant_mode=specs[0].kv_quant_mode,
506 page_size_padded=specs[0].page_size_padded,
348 - indexes_kv_by_block_stride=specs[0].indexes_kv_by_block_stride,
507 num_head_slots=specs[0].num_head_slots,
508 state_content_bytes=specs[0].state_content_bytes,
509 + tokens_per_state=specs[0].tokens_per_state,
510 sliding_window=cls.merge_window_sizes(sliding_window),
511 attention_chunk_size=cls.merge_window_sizes(attention_chunk_size),
512 # If any layer in the group is non-causal, treat the group as
@@ -384,7 +543,6 @@ class MLAAttentionSpec(FullAttentionSpec):
543 cache_dtype_str: str | None = None
544 # DeepseekV4 only fields. Non-DeepseekV4 MLA models leave these at defaults.
545 alignment: int | None = None # Default to None for no padding.
387 - compress_ratio: int = 1 # Default to 1 for no compression.
546 model_version: str | None = None
547 # Marks draft groups that flatten a non-causal query block into decode rows.
548 non_causal_multi_token_decode: bool = False
@@ -395,28 +553,21 @@ class MLAAttentionSpec(FullAttentionSpec):
553 super().__post_init__()
554 _apply_alignment_padding(self)
555
398 - @property
399 - def storage_block_size(self) -> int:
400 - return self.block_size // self.compress_ratio
401 -
556 @classmethod
557 def merge(cls, specs: list[Self]) -> Self:
558 assert all(isinstance(spec, MLAAttentionSpec) for spec in specs), (
559 "All attention layers in the same KV cache group must be MLAAttentionSpec."
560 )
561 cache_dtype_str_set = set(spec.cache_dtype_str for spec in specs)
408 - compress_ratio_set = set(spec.compress_ratio for spec in specs)
562 + tokens_per_state_set = set(spec.tokens_per_state for spec in specs)
563 model_version_set = set(spec.model_version for spec in specs)
410 - block_stride_set = set(spec.indexes_kv_by_block_stride for spec in specs)
564 assert (
565 len(cache_dtype_str_set) == 1
413 - and len(compress_ratio_set) == 1
566 + and len(tokens_per_state_set) == 1
567 and len(model_version_set) == 1
415 - and len(block_stride_set) == 1
568 ), (
569 "All attention layers in the same KV cache group must use the same "
418 - "quantization method, compress ratio, model version, and KV block "
419 - "stride indexing."
570 + "quantization method, tokens per state, and model version."
571 )
572 merged_spec = cls(
573 block_size=specs[0].block_size,
@@ -427,9 +578,8 @@ class MLAAttentionSpec(FullAttentionSpec):
578 page_size_padded=specs[0].page_size_padded,
579 num_head_slots=specs[0].num_head_slots,
580 state_content_bytes=specs[0].state_content_bytes,
430 - indexes_kv_by_block_stride=block_stride_set.pop(),
581 cache_dtype_str=cache_dtype_str_set.pop(),
432 - compress_ratio=compress_ratio_set.pop(),
582 + tokens_per_state=tokens_per_state_set.pop(),
583 model_version=model_version_set.pop(),
584 non_causal_multi_token_decode=any(
585 spec.non_causal_multi_token_decode for spec in specs
@@ -483,9 +633,9 @@ class RSWASpec(FullAttentionSpec):
633 dtype=base.dtype,
634 kv_quant_mode=base.kv_quant_mode,
635 page_size_padded=base.page_size_padded,
486 - indexes_kv_by_block_stride=base.indexes_kv_by_block_stride,
636 num_head_slots=base.num_head_slots,
637 state_content_bytes=base.state_content_bytes,
638 + tokens_per_state=base.tokens_per_state,
639 sliding_window=base.sliding_window,
640 attention_chunk_size=base.attention_chunk_size,
641 non_causal=base.non_causal,
@@ -598,8 +748,8 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
748 cache_dtype_str: str | None = None
749 # DeepseekV4-only: see MLAAttentionSpec.model_version.
750 alignment: int | None = None # Default to None for no padding.
601 - compress_ratio: int = 1
751 model_version: str | None = None
752 +
753 # MLA stores a single latent vector per state; there is no separate V.
754 head_size_v: int = 0
755
@@ -610,10 +760,6 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
760 super().__post_init__()
761 _apply_alignment_padding(self)
762
613 - @property
614 - def storage_block_size(self) -> int:
615 - return self.block_size // self.compress_ratio
616 -
763 @classmethod
764 def merge(cls, specs: list[Self]) -> Self:
765 assert all(isinstance(spec, SlidingWindowMLASpec) for spec in specs), (
@@ -621,22 +767,20 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
767 "SlidingWindowMLASpec."
768 )
769 cache_dtype_str_set = set(spec.cache_dtype_str for spec in specs)
624 - compress_ratio_set = set(spec.compress_ratio for spec in specs)
770 + tokens_per_state_set = set(spec.tokens_per_state for spec in specs)
771 model_version_set = set(spec.model_version for spec in specs)
772 sliding_window_set = set(spec.sliding_window for spec in specs)
627 - block_stride_set = set(spec.indexes_kv_by_block_stride for spec in specs)
773 extra_retained_set = set(spec.extra_retained_tokens for spec in specs)
774 assert (
775 len(cache_dtype_str_set) == 1
631 - and len(compress_ratio_set) == 1
776 + and len(tokens_per_state_set) == 1
777 and len(model_version_set) == 1
778 and len(sliding_window_set) == 1
634 - and len(block_stride_set) == 1
779 and len(extra_retained_set) == 1
780 ), (
781 "All attention layers in the same KV cache group must use the same "
638 - "quantization method, compress ratio, model version, sliding "
639 - "window size, KV block stride indexing, and retained token count."
782 + "quantization method, tokens per state, model version, sliding "
783 + "window size, and retained token count."
784 )
785 return cls(
786 block_size=specs[0].block_size,
@@ -646,11 +790,10 @@ class SlidingWindowMLASpec(SlidingWindowSpec):
790 page_size_padded=specs[0].page_size_padded,
791 num_head_slots=specs[0].num_head_slots,
792 state_content_bytes=specs[0].state_content_bytes,
649 - indexes_kv_by_block_stride=block_stride_set.pop(),
793 sliding_window=sliding_window_set.pop(),
794 extra_retained_tokens=extra_retained_set.pop(),
795 cache_dtype_str=cache_dtype_str_set.pop(),
653 - compress_ratio=compress_ratio_set.pop(),
796 + tokens_per_state=tokens_per_state_set.pop(),
797 model_version=model_version_set.pop(),
798 )
799
@@ -672,6 +815,15 @@ class MambaSpec(KVCacheSpec):
815 mamba_type: MambaAttentionBackendEnum = MambaAttentionBackendEnum.MAMBA2
816 mamba_cache_mode: str = "none"
817 num_speculative_blocks: int = 0
818 + num_heads: int = 1
819 + tokens_per_state: int = -1
820 +
821 + @property
822 + def state_content_size_bytes(self) -> int:
823 + return sum(
824 + prod(shape) * get_dtype_size(dtype)
825 + for (shape, dtype) in zip(self.shapes, self.dtypes)
826 + )
827
828 @property
829 def page_size_bytes(self) -> int:
@@ -771,7 +923,6 @@ class SinkFullAttentionSpec(FullAttentionSpec):
923 dtype=specs[0].dtype,
924 kv_quant_mode=specs[0].kv_quant_mode,
925 page_size_padded=specs[0].page_size_padded,
774 - indexes_kv_by_block_stride=specs[0].indexes_kv_by_block_stride,
926 num_head_slots=specs[0].num_head_slots,
927 state_content_bytes=specs[0].state_content_bytes,
928 sliding_window=cls.merge_window_sizes(sliding_window),
@@ -857,9 +1008,6 @@ class UniformTypeKVCacheSpecs(KVCacheSpec):
1008 return None
1009
1010 # NOTE: below util functions are only used by DeepseekV4 for now.
860 - def get_page_sizes(self) -> list[int]:
861 - return list(set(spec.page_size_bytes for spec in self.kv_cache_specs.values()))
862 -
1011 def get_num_layer_tuples(self) -> int:
1012 return Counter(
1013 spec.page_size_bytes for spec in self.kv_cache_specs.values()
@@ -929,12 +1077,24 @@ def get_kv_cache_spec_sliding_window(kv_cache_spec: KVCacheSpec) -> int | None:
1077 class KVCacheTensor:
1078 """
1079 A class for specifying how the workers should initialize the KV cache.
1080 +
1081 + Placement of a set of same-shaped layers in the KV cache allocation.
1082 + Layer ``layers[l]``'s page for block ``b`` starts at
1083 + ``offset + l * layer_stride + b * block_stride`` bytes into the backing
1084 + allocation of ``size`` bytes. Layer-outermost layouts give each layer a
1085 + contiguous region (``layer_stride = page * num_blocks``,
1086 + ``block_stride = page``); block-outermost layouts make each block a
1087 + block of all layers' pages (``layer_stride = page``, ``block_stride`` =
1088 + the packed block). Tensors whose address ranges overlap
1089 + alias the same bytes: cache groups overlay each other, which is sound
1090 + because a block ID is owned by one group at a time.
1091 """
1092
934 - size: int # size of the KV cache tensor in bytes
935 - shared_by: list[str] # layer names that share the same KV cache tensor
936 - offset: int = 0 # byte offset of this layer within a contiguous block
937 - block_stride: int = 0 # total bytes per block in a packed layout (0 = not packed)
1093 + size: int # total size of the backing allocation in bytes
1094 + layers: list[str] # layer names in L order
1095 + layer_stride: int
1096 + block_stride: int
1097 + offset: int = 0 # byte offset of layers[0]'s block 0
1098
1099
1100 @dataclass
@@ -972,6 +1132,8 @@ class KVCacheConfig:
1132 """
1133 prefix_cache_retention_interval: int | None = None
1134 """Resolved retention policy for local prefix-cache checkpoints."""
1135 + kv_cache_layout: str | None = None
1136 + """The KV cache layout resolved by the engine core, adopted by all workers."""
1137
1138 @property
1139 def has_mamba_layers(self) -> bool:
vllm/v1/kv_cache_layout.py new
+58
@@ -0,0 +1,58 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Physical KV cache layout descriptor.
4 +
5 +A leaf module so ``vllm.config`` can import the enum without pulling in the
6 +full KV cache interface.
7 +"""
8 +
9 +from enum import Enum
10 +
11 +# Logical dim indices in the 5D stride permutation [L, B, H, N, C] (see: RFC #42082).
12 +_DIM_L, _DIM_B, _DIM_H, _DIM_N, _DIM_C = 0, 1, 2, 3, 4
13 +
14 +
15 +class KVCacheLayout(Enum):
16 + """Physical layout descriptor for a KV cache group.
17 +
18 + The logical shape is always [L, B, H, N, <content>] (RFC #42082).
19 + Each member's value is a stride permutation that maps logical axes
20 + to physical (memory) order.
21 + """
22 +
23 + LBHNC = (0, 1, 2, 3, 4) # [L, B, H, N, C] (identity)
24 + LBNHC = (0, 1, 3, 2, 4) # [L, B, N, H, C]
25 + LHBNC = (0, 2, 1, 3, 4) # [L, H, B, N, C]
26 + BLHNC = (1, 0, 2, 3, 4) # [B, L, H, N, C]
27 + BLNHC = (1, 0, 3, 2, 4) # [B, L, N, H, C]
28 + BHLNC = (1, 2, 0, 3, 4) # [B, H, L, N, C]
29 +
30 + @property
31 + def stride_order(self) -> tuple[int, ...]:
32 + return self.value
33 +
34 + @property
35 + def layer_view_order(self) -> tuple[int, ...]:
36 + """Physical axis order of a logical 4D per-layer cache view."""
37 + return tuple(i - 1 for i in self.value if i != _DIM_L)
38 +
39 + @property
40 + def is_layer_compact(self) -> bool:
41 + """True when the layer is compact; i.e. the L dimension is outermost."""
42 + return self.value[_DIM_L] == 0
43 +
44 + @property
45 + def is_block_contiguous(self) -> bool:
46 + """True when [H, N, C] is contiguous within a block."""
47 + return self.value[-3:] == (_DIM_H, _DIM_N, _DIM_C)
48 +
49 + @property
50 + def is_block_compact(self) -> bool:
51 + """True when each page's [H, N, C] bytes form one contiguous run; i.e.
52 + the L and B dimensions are outermost."""
53 + return set(self.value[:2]) == {_DIM_L, _DIM_B}
54 +
55 + @property
56 + def is_block_outermost(self) -> bool:
57 + """True when B is the outermost physical dimension."""
58 + return self.value[0] == _DIM_B
vllm/v1/kv_offload/base.py
+2 -4
@@ -445,10 +445,8 @@ class CanonicalKVCacheTensor:
445 """
446 A canonicalized KV cache tensor whose first dimension is num_blocks.
447
448 - For attention backends where the raw tensor has num_blocks at a
449 - non-leading physical dimension (e.g. FlashAttention's
450 - (2, num_blocks, ...) layout), the tensor is split so that each
451 - resulting CanonicalKVCacheTensor starts with (num_blocks, ...).
448 + With standardized layouts (RFC #42082) num_blocks is always the leading
449 + logical dimension.
450 """
451
452 # The KV cache tensor with shape (num_blocks, ...)
vllm/v1/kv_offload/config.py
+2
@@ -84,3 +84,5 @@ class OffloadingConfig:
84 # True when the canonical per-layer host byte layout was requested via
85 # kv_connector_extra_config; certified per-layer at worker registration.
86 canonical_layout: bool = False
87 + # Resolved KVCacheLayout name of the worker KV cache.
88 + kv_cache_layout: str | None = None
vllm/v1/kv_offload/file_mapper.py
+4 -1
@@ -94,7 +94,10 @@ class FileMapper:
94 for group in config.groups
95 ]
96 parallel = config.parallel
97 - canonical_format = canonical_format_id() if config.canonical_layout else None
97 + canonical_format = None
98 + if config.canonical_layout:
99 + assert config.kv_cache_layout is not None
100 + canonical_format = canonical_format_id(config.kv_cache_layout)
101 return cls(
102 root_dir=root_dir,
103 model_name=config.model.name,
vllm/v1/simple_kv_offload/manager.py
+6 -9
@@ -194,22 +194,19 @@ class SimpleCPUOffloadScheduler:
194
195 assert len(gpu_config.kv_cache_tensors) > 0
196
197 - is_packed = any(t.block_stride for t in gpu_config.kv_cache_tensors)
198 - assert not is_packed or all(t.block_stride for t in gpu_config.kv_cache_tensors)
199 - gpu_total_bytes = (
200 - gpu_config.kv_cache_tensors[0].size
201 - if is_packed
202 - else sum(t.size for t in gpu_config.kv_cache_tensors)
203 - )
197 + # Every KVCacheTensor describes placement within the same backing allocation,
198 + # so its size is the total GPU KV cache size.
199 + gpu_total_bytes = gpu_config.kv_cache_tensors[0].size
200 num_gpu_blocks = gpu_config.num_blocks
201 num_cpu_blocks = max(1, num_gpu_blocks * cpu_capacity_bytes // gpu_total_bytes)
202 # Create CPU kv_cache_tensors mirroring GPU by scaling size proportionally.
203 cpu_tensors = [
204 KVCacheTensor(
205 size=t.size // num_gpu_blocks * num_cpu_blocks,
210 - shared_by=list(t.shared_by),
211 - offset=t.offset,
206 + layers=list(t.layers),
207 + layer_stride=t.layer_stride,
208 block_stride=t.block_stride,
209 + offset=t.offset,
210 )
211 for t in gpu_config.kv_cache_tensors
212 ]
vllm/v1/simple_kv_offload/worker.py
+20 -44
@@ -95,57 +95,33 @@ class SimpleCPUOffloadWorker:
95 logger.warning("No KV caches to offload.")
96 return
97
98 - # Resolve each entry to a representative tensor for storage
99 - # deduplication. For attention layers the value is already a tensor;
100 - # for Mamba layers it is a list of tensors that all share the same
101 - # underlying raw storage, so we take the first one.
102 - def _repr_tensor(v: torch.Tensor | list[torch.Tensor]) -> torch.Tensor:
103 - assert isinstance(v, torch.Tensor | list)
104 - return v if isinstance(v, torch.Tensor) else v[0]
105 -
106 - any_tensor = _repr_tensor(next(iter(kv_caches.values())))
107 - self.device = any_tensor.device
98 + self.device = next(iter(kv_caches.values())).device
99
100 assert self.kv_cache_config is not None
101 num_blocks = self.kv_cache_config.num_blocks
102
112 - # Deduplicate: multiple layers may share the same backing storage.
113 - seen_ptrs: dict[int, tuple[str, torch.Tensor]] = {}
114 - for name, value in kv_caches.items():
115 - tensor = _repr_tensor(value)
116 - ptr = tensor.untyped_storage().data_ptr()
117 - if ptr not in seen_ptrs:
118 - seen_ptrs[ptr] = (name, tensor)
119 -
120 - # Build [num_blocks, block_bytes] int8 views from each unique
121 - # storage so that stride(0) gives block_bytes for the copy op.
122 - #
123 - # The physical layout varies across attention backends:
124 - # FlashAttn/ROCm: (2, num_blocks, ...) -> K/V outermost, 2 segments
125 - # FlashInfer/MLA: (num_blocks, ...) -> blocks outermost, 1 segment
126 - # We derive page_size_bytes = storage.nbytes() // num_blocks, then
127 - # classify dims: any dim whose byte-stride exceeds page_size_bytes
128 - # must be an outer segment dim (e.g. the K/V dim of size 2). A less
129 - # hacky way is to update the interface with the layout.
103 + # The DMA backend copies whole blocks as base + block_id * stride(0),
104 + # so view each unique allocation as [num_blocks, block_bytes].
105 unique_gpu_caches: dict[str, torch.Tensor] = {}
131 - for name, tensor in seen_ptrs.values():
106 + seen: set[tuple[torch.device, int]] = set()
107 + for name, tensor in kv_caches.items():
108 storage = tensor.untyped_storage()
133 - raw = torch.empty(0, dtype=torch.int8, device=self.device).set_(
134 - storage, 0, (storage.nbytes(),)
109 + key = (tensor.device, storage.data_ptr())
110 + if key in seen:
111 + continue
112 + seen.add(key)
113 +
114 + physical_per_block, remainder = divmod(tensor.shape[0], num_blocks)
115 + assert remainder == 0, (
116 + f"KV cache {name!r} has {tensor.shape[0]} physical blocks, which "
117 + f"is not divisible by {num_blocks} scheduler blocks"
118 )
136 - el = tensor.element_size()
137 - page_size_bytes = storage.nbytes() // num_blocks
138 - outer_dims = [
139 - d for d in range(tensor.ndim) if tensor.stride(d) * el > page_size_bytes
140 - ]
141 - if not outer_dims:
142 - unique_gpu_caches[name] = raw.view(num_blocks, -1)
143 - else:
144 - seg_stride = tensor.stride(outer_dims[0]) * el
145 - for idx in range(tensor.shape[outer_dims[0]]):
146 - offset = idx * seg_stride
147 - chunk = raw[offset : offset + seg_stride]
148 - unique_gpu_caches[f"{name}.{idx}"] = chunk.view(num_blocks, -1)
119 + block_bytes = tensor.stride(0) * tensor.element_size() * physical_per_block
120 + raw = torch.empty(0, dtype=torch.int8, device=tensor.device).set_(storage)
121 + regions = raw.view(-1, num_blocks, block_bytes)
122 + for idx, region in enumerate(regions):
123 + key_name = name if len(regions) == 1 else f"{name}.{idx}"
124 + unique_gpu_caches[key_name] = region
125
126 # Compute per-tensor bytes_per_block. Tensors may have different
127 # page_size_bytes (e.g., UniformTypeKVCacheSpecs with varying head_size).
vllm/v1/worker/gpu/attn_utils.py
+12 -380
@@ -1,22 +1,15 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -from collections.abc import Iterable, Mapping, Sequence
4 -from dataclasses import dataclass, replace
5 -from math import prod
3 +from collections.abc import Mapping, Sequence
4 +from dataclasses import dataclass
5 from typing import Any, cast
6
7 import torch
8
10 -from vllm.config import (
11 - VllmConfig,
12 - get_layers_from_vllm_config,
13 - set_current_vllm_config,
14 -)
15 -from vllm.logger import init_logger
9 +from vllm.config import VllmConfig, get_layers_from_vllm_config
10 from vllm.model_executor.layers.attention import Attention
11 from vllm.model_executor.layers.attention_layer_base import AttentionLayerBase
12 from vllm.multimodal.inputs import MultiModalFeatureSpec
19 -from vllm.utils.torch_utils import get_dtype_size
13 from vllm.v1.attention.backend import (
14 AttentionCGSupport,
15 CommonAttentionMetadata,
@@ -25,20 +18,17 @@ from vllm.v1.kv_cache_interface import (
18 AttentionSpec,
19 KVCacheConfig,
20 KVCacheSpec,
28 - KVQuantMode,
29 - MambaSpec,
21 UniformTypeKVCacheSpecs,
22 )
23 from vllm.v1.worker.gpu.model_states.interface import ModelSpecificAttnMetadata
24 from vllm.v1.worker.utils import (
25 AttentionGroup,
26 add_kv_sharing_layers_to_kv_cache_groups,
27 + allocate_kv_cache,
28 bind_kv_cache,
29 prepare_kernel_block_sizes,
30 )
31
40 -logger = init_logger(__name__)
41 -
32
33 @dataclass(frozen=True)
34 class AttentionCGSupportInfo:
@@ -69,13 +59,7 @@ def get_kv_cache_spec(vllm_config: VllmConfig) -> dict[str, KVCacheSpec]:
59 # Skip modules that don't need KV cache (eg encoder-only attention)
60 if spec := attn_module.get_kv_cache_spec(vllm_config):
61 if isinstance(spec, AttentionSpec):
72 - backend = attn_module.get_attn_backend()
73 - # indexes_kv_by_block_stride() -> get_kv_cache_stride_order() ->
74 - # get_kv_cache_layout() needs the current vLLM config.
75 - with set_current_vllm_config(vllm_config):
76 - indexes = backend.indexes_kv_by_block_stride()
77 - spec = replace(spec, indexes_kv_by_block_stride=indexes)
78 - spec = backend.customize_spec(spec)
62 + spec = attn_module.get_attn_backend().customize_spec(spec)
63 kv_cache_spec[layer_name] = spec
64 return kv_cache_spec
65
@@ -197,374 +181,22 @@ def get_query_lens_mismatch_unsupported_backend(
181 return None
182
183
200 -def _allocate_kv_cache(
201 - kv_cache_config: KVCacheConfig, shared_layers: dict[str, str], device: torch.device
202 -):
203 - kv_cache_raw_tensors: dict[str, torch.Tensor] = {}
204 - packed_backing: torch.Tensor | None = None
205 - for kv_cache_tensor in kv_cache_config.kv_cache_tensors:
206 - if kv_cache_tensor.block_stride > 0:
207 - # Allocate once; all packed tensors alias the same backing.
208 - if packed_backing is None:
209 - packed_backing = torch.zeros(
210 - kv_cache_tensor.size, dtype=torch.int8, device=device
211 - )
212 - tensor = packed_backing
213 - else:
214 - tensor = torch.zeros(kv_cache_tensor.size, dtype=torch.int8, device=device)
215 - for layer_name in kv_cache_tensor.shared_by:
216 - kv_cache_raw_tensors[layer_name] = tensor
217 -
218 - layer_names = set()
219 - for group in kv_cache_config.kv_cache_groups:
220 - for layer_name in group.layer_names:
221 - layer_names.add(layer_name)
222 - assert layer_names == (kv_cache_raw_tensors.keys() | shared_layers.keys()), (
223 - "Some layers are not correctly initialized"
224 - )
225 - return kv_cache_raw_tensors
226 -
227 -
228 -def _kv_first_layers_sharing_pool_with_mamba(
229 - attn_groups: Sequence[AttentionGroup],
230 - kernel_block_sizes: list[int],
231 - cache_dtype: str,
232 - kv_cache_config: "KVCacheConfig | None",
233 -) -> set[str]:
234 - """KV-first attention layers that share an allocation with Mamba state.
235 -
236 - Mamba addresses its state by page, so these layers have to lay their blocks
237 - out page by page as well; otherwise block ids from the two groups resolve
238 - to overlapping bytes.
239 - """
240 - if kv_cache_config is None:
241 - return set()
242 -
243 - kv_first_layers: set[str] = set()
244 - mamba_layers: set[str] = set()
245 - for group in attn_groups:
246 - kv_cache_spec = group.kv_cache_spec
247 - if isinstance(kv_cache_spec, MambaSpec):
248 - mamba_layers.update(group.layer_names)
249 - continue
250 - if not isinstance(kv_cache_spec, AttentionSpec):
251 - continue
252 - if group.kv_cache_group_id >= len(kernel_block_sizes):
253 - continue
254 - block_dim = group.backend.get_kv_cache_block_dim(
255 - kernel_block_sizes[group.kv_cache_group_id],
256 - kv_cache_spec.num_kv_heads,
257 - kv_cache_spec.head_size,
258 - cache_dtype_str=cache_dtype,
259 - )
260 - if block_dim != 0:
261 - kv_first_layers.update(group.layer_names)
262 -
263 - page_aligned: set[str] = set()
264 - for kv_tensor in kv_cache_config.kv_cache_tensors:
265 - if mamba_layers.isdisjoint(kv_tensor.shared_by):
266 - continue
267 - page_aligned.update(kv_first_layers.intersection(kv_tensor.shared_by))
268 - return page_aligned
269 -
270 -
271 -def _reshape_attention_kv_cache(
272 - kv_raw_tensor: torch.Tensor,
273 - kv_cache_spec: AttentionSpec,
274 - kv_cache_shape: tuple[int, ...],
275 - kv_cache_stride_order: tuple[int, ...],
276 - num_blocks: int,
277 - packing: tuple[int, int] | None,
278 - page_aligned_blocks: bool = False,
279 -) -> torch.Tensor:
280 - permuted_kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
281 - inv_order = [
282 - kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order))
283 - ]
284 - dtype = kv_cache_spec.dtype
285 -
286 - if packing is not None:
287 - offset, block_stride = packing
288 - assert inv_order[0] == 0
289 - page_bytes = prod(kv_cache_shape[1:]) * get_dtype_size(dtype)
290 - kv_cache = (
291 - kv_raw_tensor.view(-1, block_stride)[:, offset : offset + page_bytes]
292 - .view(dtype)
293 - .view(permuted_kv_cache_shape)
294 - )
295 - elif kv_cache_spec.page_size_padded is not None:
296 - # Use a strided view to skip the padding between physical pages.
297 - #
298 - # Only num-blocks-first layouts are supported (the block dimension is
299 - # dim 0 of the unpermuted shape). kv-first layouts such as ROCm's
300 - # ``(2, num_blocks, ...)`` are intentionally not supported here. For a
301 - # num-blocks-first layout the only stride that must change is the block
302 - # stride: every other (contiguous) stride already steps within the
303 - # unpadded region of a page, so no further adjustment is needed.
304 - assert kv_cache_shape[0] == num_blocks, (
305 - "Padded KV pages require a num-blocks-first KV cache layout (got "
306 - f"shape {kv_cache_shape} with num_blocks={num_blocks}); "
307 - "kv-first layouts are not supported."
308 - )
309 - dtype_size = get_dtype_size(kv_cache_spec.dtype)
310 - page_stride = kv_cache_spec.page_size_bytes // dtype_size
311 -
312 - num_blocks_dim = inv_order[0]
313 - strides = list(torch.empty(permuted_kv_cache_shape, device="meta").stride())
314 - strides[num_blocks_dim] = page_stride
315 -
316 - kv_cache = torch.as_strided(
317 - kv_raw_tensor.view(dtype),
318 - size=permuted_kv_cache_shape,
319 - stride=tuple(strides),
320 - )
321 - elif page_aligned_blocks:
322 - # A KV-first layout such as ROCm's ``(2, num_blocks, ...)`` puts block
323 - # ``b``'s K and V in two far-apart halves of the allocation, so block
324 - # ``b`` does not cover page ``b``. Mamba layers sharing the allocation
325 - # do address their state by page, so the two would resolve the same
326 - # bytes. Build the view page-first instead, then swap the dims back.
327 - assert kv_cache_shape[1] == num_blocks and kv_cache_stride_order == tuple(
328 - range(len(kv_cache_shape))
329 - ), (
330 - "Page-aligned KV blocks expect a default-strided (kv, num_blocks, "
331 - f"...) layout, got shape {kv_cache_shape} with stride order "
332 - f"{kv_cache_stride_order} and num_blocks={num_blocks}."
333 - )
334 - kv_cache = (
335 - kv_raw_tensor.view(dtype)
336 - .view(num_blocks, kv_cache_shape[0], *kv_cache_shape[2:])
337 - .transpose(0, 1)
338 - )
339 - else:
340 - # No padding — safe to use a contiguous view.
341 - kv_cache = kv_raw_tensor.view(dtype).view(permuted_kv_cache_shape)
342 -
343 - return kv_cache.permute(*inv_order)
344 -
345 -
346 -def _reshape_kv_cache(
347 - attn_groups: Sequence[AttentionGroup],
348 - kv_cache_raw_tensors: dict[str, torch.Tensor],
349 - cache_dtype: str,
350 - kernel_block_sizes: list[int],
351 - shared_kv_cache_layers: dict[str, str],
352 - kv_cache_config: "KVCacheConfig | None" = None,
353 -) -> dict[str, Any]:
354 - kv_caches: dict[str, Any] = {}
355 - has_attn = False
356 -
357 - layer_packing: dict[str, tuple[int, int]] = {}
358 - if kv_cache_config is not None:
359 - for kv_tensor in kv_cache_config.kv_cache_tensors:
360 - if kv_tensor.block_stride > 0:
361 - for ln in kv_tensor.shared_by:
362 - layer_packing[ln] = (kv_tensor.offset, kv_tensor.block_stride)
363 -
364 - page_aligned_layers = _kv_first_layers_sharing_pool_with_mamba(
365 - attn_groups, kernel_block_sizes, cache_dtype, kv_cache_config
366 - )
367 -
368 - for group in attn_groups:
369 - if group.kv_cache_group_id >= len(kernel_block_sizes):
370 - continue
371 -
372 - kv_cache_spec = group.kv_cache_spec
373 - if kv_cache_spec.storage_block_size != kv_cache_spec.block_size:
374 - # use storage_block_size as the kernel block size for groups
375 - # that apply a compression on block size (eg. DeepSeek V4).
376 - kernel_block_size = kv_cache_spec.storage_block_size
377 - else:
378 - kernel_block_size = kernel_block_sizes[group.kv_cache_group_id]
379 -
380 - for layer_name in group.layer_names:
381 - if layer_name in shared_kv_cache_layers:
382 - # Shared layer — tensor will be aliased to its target later.
383 - continue
384 -
385 - kv_raw_tensor = kv_cache_raw_tensors[layer_name]
386 - packing = layer_packing.get(layer_name)
387 - if packing is not None:
388 - _, blk_stride = packing
389 - num_blocks = kv_raw_tensor.numel() // blk_stride
390 - else:
391 - assert kv_raw_tensor.numel() % kv_cache_spec.page_size_bytes == 0
392 - num_blocks = kv_raw_tensor.numel() // kv_cache_spec.page_size_bytes
393 -
394 - if isinstance(kv_cache_spec, AttentionSpec):
395 - has_attn = True
396 - # Use storage_block_size: it equals block_size for uncompressed
397 - # specs but is smaller for compressed ones (DeepSeek V4), which
398 - # store block_size tokens in block_size // compress_ratio slots.
399 - num_blocks_per_kv_block = (
400 - kv_cache_spec.storage_block_size // kernel_block_size
401 - )
402 - kernel_num_blocks = num_blocks * num_blocks_per_kv_block
403 - # Skipped layers (--kv-cache-dtype-skip-layers) keep the
404 - # unquantized shape; only the quantized primary uses the
405 - # quantized cache dtype's (possibly packed) layout.
406 - layer_cache_dtype = (
407 - "auto"
408 - if kv_cache_spec.kv_quant_mode == KVQuantMode.NONE
409 - else cache_dtype
410 - )
411 - kv_cache_shape = group.backend.get_kv_cache_shape(
412 - kernel_num_blocks,
413 - kernel_block_size,
414 - kv_cache_spec.num_kv_heads,
415 - kv_cache_spec.head_size,
416 - cache_dtype_str=layer_cache_dtype,
417 - )
418 -
419 - # FIXME(woosuk): Add kv_cache_stride_order to all attention backends.
420 - try:
421 - kv_cache_stride_order = group.backend.get_kv_cache_stride_order()
422 - assert len(kv_cache_stride_order) == len(kv_cache_shape)
423 - except (AttributeError, NotImplementedError):
424 - kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
425 -
426 - kv_caches[layer_name] = _reshape_attention_kv_cache(
427 - kv_raw_tensor,
428 - kv_cache_spec,
429 - kv_cache_shape,
430 - kv_cache_stride_order,
431 - kernel_num_blocks,
432 - packing,
433 - page_aligned_blocks=layer_name in page_aligned_layers,
434 - )
435 -
436 - elif isinstance(kv_cache_spec, MambaSpec):
437 - page_size_bytes = kv_cache_spec.page_size_bytes
438 - # Hold a single contiguous [num_blocks, 1, 1, page_size_bytes]
439 - # int8 page view per layer; the layer's bind_kv_cache unpacks
440 - # each block's bytes into its conv/ssm state views. Keeping
441 - # one tensor per layer lets the KV connector register it
442 - # without special-casing Mamba.
443 - kv_caches[layer_name] = kv_raw_tensor[
444 - : num_blocks * page_size_bytes
445 - ].view(num_blocks, 1, 1, page_size_bytes)
446 - else:
447 - raise NotImplementedError(
448 - f"Unsupported KV cache spec type: {type(kv_cache_spec)}"
449 - )
450 -
451 - if has_attn and kv_cache_config is not None:
452 - _align_mixed_attention_kv_cache_views(
453 - attn_groups=attn_groups,
454 - kv_caches=kv_caches,
455 - kernel_block_sizes=kernel_block_sizes,
456 - cache_dtype=cache_dtype,
457 - kv_cache_config=kv_cache_config,
458 - page_aligned_layers=page_aligned_layers,
459 - )
460 -
461 - # Map any sharing layers to their target layer's KV cache.
462 - for layer_name, target_layer_name in shared_kv_cache_layers.items():
463 - kv_caches[layer_name] = kv_caches[target_layer_name]
464 -
465 - return kv_caches
466 -
467 -
468 -def _align_mixed_attention_kv_cache_views(
469 - attn_groups: Iterable[AttentionGroup],
470 - kv_caches: dict[str, Any],
471 - kernel_block_sizes: list[int],
472 - cache_dtype: str,
473 - kv_cache_config: KVCacheConfig,
474 - page_aligned_layers: set[str],
475 -) -> None:
476 - """Align shared attention KV views when backends disagree on layout.
477 -
478 - Encoder-decoder models can share one raw allocation between decoder
479 - self-attention (K/V-first ROCM_ATTN, block dim 1) and cross-attention
480 - (blocks-first backends, block dim 0). Keep the physical storage in the
481 - K/V-first layout expected by ROCM_ATTN, and restride the blocks-first
482 - logical views so block IDs address the same bytes.
483 - """
484 - block_dims_by_layer: dict[str, int] = {}
485 - for group in attn_groups:
486 - kv_cache_spec = group.kv_cache_spec
487 - if not isinstance(kv_cache_spec, AttentionSpec):
488 - continue
489 - if group.kv_cache_group_id >= len(kernel_block_sizes):
490 - continue
491 - block_dim = group.backend.get_kv_cache_block_dim(
492 - kernel_block_sizes[group.kv_cache_group_id],
493 - kv_cache_spec.num_kv_heads,
494 - kv_cache_spec.head_size,
495 - cache_dtype_str=cache_dtype,
496 - )
497 - for layer_name in group.layer_names:
498 - if layer_name in kv_caches:
499 - block_dims_by_layer[layer_name] = block_dim
500 -
501 - for kv_tensor in kv_cache_config.kv_cache_tensors:
502 - if kv_tensor.block_stride > 0:
503 - continue
504 - if not page_aligned_layers.isdisjoint(kv_tensor.shared_by):
505 - # These KV-first views already address blocks by page, just like
506 - # blocks-first views, so they must not be restrided onto the
507 - # unaligned KV-first storage layout.
508 - continue
509 - shared_block_dims = {
510 - block_dims_by_layer[layer_name]
511 - for layer_name in kv_tensor.shared_by
512 - if layer_name in block_dims_by_layer
513 - }
514 - if 0 not in shared_block_dims or 1 not in shared_block_dims:
515 - continue
516 -
517 - for layer_name in kv_tensor.shared_by:
518 - if block_dims_by_layer.get(layer_name) == 0:
519 - _restride_blocks_first_kv_cache_to_kv_first_storage(
520 - kv_caches[layer_name]
521 - )
522 -
523 -
524 -def _restride_blocks_first_kv_cache_to_kv_first_storage(
525 - kv_cache: torch.Tensor,
526 -) -> None:
527 - assert kv_cache.ndim >= 3
528 - assert kv_cache.shape[1] == 2
529 - page_size = kv_cache.shape[2:].numel()
530 - num_blocks = kv_cache.shape[0]
531 - expected_tail_stride = torch.empty(kv_cache.shape[2:]).stride()
532 - if kv_cache.stride()[2:] != expected_tail_stride:
533 - logger.warning_once(
534 - "Skipping mixed KV-cache layout alignment for a non-NHD "
535 - "blocks-first attention view with stride %s.",
536 - kv_cache.stride(),
537 - )
538 - return
539 - kv_cache.as_strided_(
540 - size=kv_cache.shape,
541 - stride=(page_size, num_blocks * page_size, *expected_tail_stride),
542 - )
543 -
544 -
184 def init_kv_cache(
185 runner_kv_caches: list[torch.Tensor | list[torch.Tensor]],
186 forward_context: dict[str, Any],
187 kv_cache_config: KVCacheConfig,
549 - attn_groups: list[list[AttentionGroup]],
188 device: torch.device,
551 - cache_dtype: str,
189 kernel_block_sizes: list[int],
190 vllm_config: VllmConfig,
191 ) -> dict[str, Any]:
555 - shared_kv_cache_layers = get_shared_kv_cache_layers(vllm_config)
556 - kv_cache_raw_tensors = _allocate_kv_cache(
557 - kv_cache_config, shared_kv_cache_layers, device
558 - )
559 - flattened_attn_groups = list(group for groups in attn_groups for group in groups)
560 - kv_caches = _reshape_kv_cache(
561 - attn_groups=flattened_attn_groups,
562 - kv_cache_raw_tensors=kv_cache_raw_tensors,
563 - kernel_block_sizes=kernel_block_sizes,
564 - cache_dtype=cache_dtype,
565 - shared_kv_cache_layers=shared_kv_cache_layers,
566 - kv_cache_config=kv_cache_config,
192 + kv_caches = allocate_kv_cache(
193 + kv_cache_config,
194 + device,
195 + vllm_config.cache_config.get_resolved_kv_cache_layout(),
196 + kernel_block_sizes,
197 )
198 + for layer_name, target in get_shared_kv_cache_layers(vllm_config).items():
199 + kv_caches[layer_name] = kv_caches[target]
200 # Dual-attention models (e.g. LongCat-Flash) put two Attention modules per
201 # decoder layer, so a layer name carries two integers (layer + module index).
202 num_attn_module = (
vllm/v1/worker/gpu/kv_connector.py
-2
@@ -51,8 +51,6 @@ class ActiveKVConnector(KVConnector):
51 self.vllm_config = vllm_config
52 self.kv_connector = get_kv_transfer_group()
53 # Register kv caches with KV Connector if applicable.
54 - # TODO: support cross_layers_kv_cache
55 - # (see https://github.com/vllm-project/vllm/pull/27743)
54 self.kv_connector.register_kv_caches(kv_caches_dict)
55 self.kv_connector.set_host_xfer_buffer_ops(copy_kv_blocks)
56
vllm/v1/worker/gpu/model_runner.py
-3
@@ -618,9 +618,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
618 self.kv_caches,
619 self.compilation_config.static_forward_context,
620 self.kv_cache_config,
621 - self.attn_groups,
621 self.device,
623 - self.cache_config.cache_dtype,
622 self.kernel_block_sizes,
623 self.vllm_config,
624 )
@@ -632,7 +630,6 @@ class GPUModelRunner(LoRAModelRunnerMixin):
630 self.device,
631 attn_groups_iter=(g for groups in self.attn_groups for g in groups),
632 kernel_block_sizes=self.kernel_block_sizes,
635 - cache_dtype=self.cache_config.cache_dtype,
633 static_forward_context=self.compilation_config.static_forward_context,
634 )
635
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+6 -6
@@ -1,6 +1,5 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -import copy
3 from collections.abc import Mapping
4 from typing import Any
5
@@ -104,12 +103,13 @@ class DFlashSpeculator(DraftModelSpeculator):
103 @property
104 def attn_vllm_config(self) -> VllmConfig:
105 # The draft's attention differs from the target's in causality.
107 - config = copy.copy(super().attn_vllm_config)
108 - config.attention_config = replace(
109 - self.vllm_config.attention_config,
110 - use_non_causal=self.requires_non_causal,
106 + return replace(
107 + self.vllm_config,
108 + attention_config=replace(
109 + self.vllm_config.attention_config,
110 + use_non_causal=self.requires_non_causal,
111 + ),
112 )
112 - return config
113
114 def init_cudagraph_manager(self, cudagraph_mode: CUDAGraphMode) -> None:
115 wants_full = cudagraph_mode.decode_mode() == CUDAGraphMode.FULL
vllm/v1/worker/gpu_model_runner.py
+12 -268
@@ -42,7 +42,9 @@ from vllm.config.model import PROCESSED_LOGPROBS_MODES
42 from vllm.distributed.ec_transfer import get_ec_transfer, has_ec_transfer
43 from vllm.distributed.eplb.eplb_state import EplbState
44 from vllm.distributed.kv_transfer import get_kv_transfer_group, has_kv_transfer_group
45 -from vllm.distributed.kv_transfer.kv_connector.utils import copy_kv_blocks
45 +from vllm.distributed.kv_transfer.kv_connector.utils import (
46 + copy_kv_blocks,
47 +)
48 from vllm.distributed.parallel_state import (
49 GraphCaptureContext,
50 get_dcp_group,
@@ -164,8 +166,6 @@ from vllm.v1.kv_cache_interface import (
166 KVCacheGroupSpec,
167 KVCacheSpec,
168 KVCacheSpecKind,
167 - KVQuantMode,
168 - MambaSpec,
169 SlidingWindowSpec,
170 UniformTypeKVCacheSpecs,
171 get_kv_cache_spec_kind,
@@ -221,7 +221,6 @@ from vllm.v1.worker.cp_utils import (
221 )
222 from vllm.v1.worker.dp_utils import coordinate_batch_across_dp
223 from vllm.v1.worker.ec_connector_model_runner_mixin import ECConnectorModelRunnerMixin
224 -from vllm.v1.worker.gpu.attn_utils import _reshape_attention_kv_cache
224 from vllm.v1.worker.gpu_input_batch import CachedRequestState, InputBatch
225 from vllm.v1.worker.gpu_ubatch_wrapper import UBatchWrapper
226 from vllm.v1.worker.kv_connector_model_runner_mixin import KVConnectorModelRunnerMixin
@@ -243,6 +242,7 @@ from .utils import (
242 AttentionGroup,
243 KVBlockZeroer,
244 add_kv_sharing_layers_to_kv_cache_groups,
245 + allocate_kv_cache,
246 bind_kv_cache,
247 copy_kv_cache_blocks_inplace,
248 prepare_kernel_block_sizes,
@@ -612,9 +612,6 @@ class GPUModelRunner(
612 # self.model: nn.Module # Set after load_model
613 # Initialize in initialize_kv_cache
614 self.kv_caches: list[torch.Tensor] = []
615 - # Initialize in initialize_kv_cache_tensors
616 - self.cross_layers_kv_cache: torch.Tensor | None = None
617 - self.cross_layers_attn_backend: type[AttentionBackend] | None = None
615 # indexes: [kv_cache_group_id][attn_group]
616 self.attn_groups: list[list[AttentionGroup]] = []
617 # self.kv_cache_config: KVCacheConfig
@@ -1200,7 +1197,6 @@ class GPUModelRunner(
1197 self.device,
1198 attn_groups_iter=self._kv_cache_spec_attn_group_iterator(),
1199 kernel_block_sizes=self._kernel_block_sizes,
1203 - cache_dtype=self.cache_config.cache_dtype,
1200 runner_only_attn_layers=self.runner_only_attn_layers,
1201 static_forward_context=self.compilation_config.static_forward_context,
1202 )
@@ -6707,9 +6703,6 @@ class GPUModelRunner(
6703 for i in range(len(self.kv_caches)):
6704 self.kv_caches[i] = None # type: ignore
6705 self.kv_caches.clear()
6710 - if hasattr(self, "cross_layers_kv_cache"):
6711 - self.cross_layers_kv_cache = None
6712 - self.cross_layers_attn_backend = None
6706 if hasattr(self, "attn_groups"):
6707 self.attn_groups.clear()
6708 if hasattr(self, "kv_cache_config"):
@@ -7441,49 +7434,6 @@ class GPUModelRunner(
7434 f"!= kv_cache kernel_block_sizes {kernel_block_sizes}"
7435 )
7436
7444 - def _allocate_kv_cache_tensors(
7445 - self, kv_cache_config: KVCacheConfig
7446 - ) -> dict[str, torch.Tensor]:
7447 - """
7448 - Initializes the KV cache buffer with the correct size. The buffer needs
7449 - to be reshaped to the desired shape before being used by the models.
7450 -
7451 - Args:
7452 - kv_cache_config: The KV cache config
7453 - Returns:
7454 - dict[str, torch.Tensor]: A map between layer names to their
7455 - corresponding memory buffer for KV cache.
7456 - """
7457 - kv_cache_raw_tensors: dict[str, torch.Tensor] = {}
7458 - packed_backing: torch.Tensor | None = None
7459 - for kv_cache_tensor in kv_cache_config.kv_cache_tensors:
7460 - if kv_cache_tensor.block_stride > 0:
7461 - # Allocate once; all packed tensors alias the same backing.
7462 - if packed_backing is None:
7463 - packed_backing = torch.zeros(
7464 - kv_cache_tensor.size,
7465 - dtype=torch.int8,
7466 - device=self.device,
7467 - )
7468 - tensor = packed_backing
7469 - else:
7470 - tensor = torch.zeros(
7471 - kv_cache_tensor.size, dtype=torch.int8, device=self.device
7472 - )
7473 - for layer_name in kv_cache_tensor.shared_by:
7474 - kv_cache_raw_tensors[layer_name] = tensor
7475 -
7476 - layer_names = set()
7477 - for group in kv_cache_config.kv_cache_groups:
7478 - for layer_name in group.layer_names:
7479 - if layer_name in self.runner_only_attn_layers:
7480 - continue
7481 - layer_names.add(layer_name)
7482 - assert layer_names == set(kv_cache_raw_tensors.keys()), (
7483 - "Some layers are not correctly initialized"
7484 - )
7485 - return kv_cache_raw_tensors
7486 -
7437 def _attn_group_iterator(self) -> Iterator[AttentionGroup]:
7438 return itertools.chain.from_iterable(self.attn_groups)
7439
@@ -7493,183 +7443,6 @@ class GPUModelRunner(
7443 for attn_groups in self.attn_groups:
7444 yield from attn_groups
7445
7496 - def _reshape_kv_cache_tensors(
7497 - self,
7498 - kv_cache_raw_tensors: dict[str, torch.Tensor],
7499 - kernel_block_sizes: list[int],
7500 - ) -> dict[str, torch.Tensor]:
7501 - """
7502 - Reshape the KV cache tensors to the desired shape and dtype.
7503 -
7504 - Args:
7505 - kv_cache_raw_tensors: The KV cache buffer of each layer, with
7506 - correct size but uninitialized shape.
7507 - kernel_block_sizes: The kernel block sizes for each KV cache group.
7508 - Returns:
7509 - Dict[str, torch.Tensor]: A map between layer names to their
7510 - corresponding memory buffer for KV cache.
7511 - """
7512 - kv_caches: dict[str, torch.Tensor] = {}
7513 - has_attn, has_mamba = False, False
7514 -
7515 - # Map layer names to (offset, block_stride) within the packed
7516 - # backing tensor so we can create strided views per layer.
7517 - layer_packing: dict[str, tuple[int, int]] = {}
7518 - for kv_tensor in self.kv_cache_config.kv_cache_tensors:
7519 - if kv_tensor.block_stride > 0:
7520 - for ln in kv_tensor.shared_by:
7521 - layer_packing[ln] = (kv_tensor.offset, kv_tensor.block_stride)
7522 - for group in self._kv_cache_spec_attn_group_iterator():
7523 - kv_cache_spec = group.kv_cache_spec
7524 - attn_backend = group.backend
7525 - if group.kv_cache_group_id == len(kernel_block_sizes):
7526 - # There may be a last group for layers without kv cache.
7527 - continue
7528 - kernel_block_size = kernel_block_sizes[group.kv_cache_group_id]
7529 - for layer_name in group.layer_names:
7530 - if layer_name in self.runner_only_attn_layers:
7531 - continue
7532 - raw_tensor = kv_cache_raw_tensors[layer_name]
7533 - packing = layer_packing.get(layer_name)
7534 - if packing is not None:
7535 - _, blk_stride = packing
7536 - num_blocks = raw_tensor.numel() // blk_stride
7537 - else:
7538 - assert raw_tensor.numel() % kv_cache_spec.page_size_bytes == 0
7539 - num_blocks = raw_tensor.numel() // kv_cache_spec.page_size_bytes
7540 - if isinstance(kv_cache_spec, AttentionSpec):
7541 - has_attn = True
7542 - num_blocks_per_kv_block = (
7543 - kv_cache_spec.block_size // kernel_block_size
7544 - )
7545 - kernel_num_blocks = num_blocks * num_blocks_per_kv_block
7546 -
7547 - # For MLA with compression, storage_block_size != block_size
7548 - if kv_cache_spec.storage_block_size != kv_cache_spec.block_size:
7549 - shape_block_size = kv_cache_spec.storage_block_size
7550 - else:
7551 - shape_block_size = kernel_block_size
7552 -
7553 - # Skipped layers (--kv-cache-dtype-skip-layers) need
7554 - # the unquantized shape.
7555 - layer_cache_dtype_str = (
7556 - "auto"
7557 - if kv_cache_spec.kv_quant_mode == KVQuantMode.NONE
7558 - else getattr(
7559 - kv_cache_spec,
7560 - "cache_dtype_str",
7561 - None,
7562 - )
7563 - or self.cache_config.cache_dtype
7564 - )
7565 - kv_cache_shape = attn_backend.get_kv_cache_shape(
7566 - kernel_num_blocks,
7567 - shape_block_size,
7568 - kv_cache_spec.num_kv_heads,
7569 - kv_cache_spec.head_size,
7570 - cache_dtype_str=layer_cache_dtype_str,
7571 - )
7572 - try:
7573 - kv_cache_stride_order = attn_backend.get_kv_cache_stride_order()
7574 - assert len(kv_cache_stride_order) == len(kv_cache_shape)
7575 - except (AttributeError, NotImplementedError):
7576 - kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
7577 - raw_tensor = kv_cache_raw_tensors[layer_name]
7578 - kv_caches[layer_name] = _reshape_attention_kv_cache(
7579 - raw_tensor,
7580 - kv_cache_spec,
7581 - kv_cache_shape,
7582 - kv_cache_stride_order,
7583 - kernel_num_blocks,
7584 - packing,
7585 - )
7586 -
7587 - elif isinstance(kv_cache_spec, MambaSpec):
7588 - has_mamba = True
7589 - raw_tensor = kv_cache_raw_tensors[layer_name]
7590 - page_size_bytes = kv_cache_spec.page_size_bytes
7591 - # Hold a single contiguous [num_blocks, 1, 1, page_size_bytes]
7592 - # int8 page view per layer; the layer's bind_kv_cache unpacks
7593 - # each block's bytes into its conv/ssm state views. Keeping
7594 - # one tensor per layer lets the KV connector register it
7595 - # without special-casing Mamba.
7596 - kv_caches[layer_name] = raw_tensor[
7597 - : num_blocks * page_size_bytes
7598 - ].view(num_blocks, 1, 1, page_size_bytes)
7599 - else:
7600 - raise NotImplementedError
7601 -
7602 - # Reconcile divergent KV layouts to blocks-first. Triggered by hybrid
7603 - # attention/mamba models, and by encoder-decoder models whose shared
7604 - # decoder/cross-attention allocation mixes K/V-first and blocks-first
7605 - # backends (see _has_mixed_attention_kv_layout).
7606 - if has_attn and (
7607 - has_mamba or self._has_mixed_attention_kv_layout(kernel_block_sizes)
7608 - ):
7609 - self._update_hybrid_attention_mamba_layout(kv_caches, kernel_block_sizes)
7610 -
7611 - return kv_caches
7612 -
7613 - def _has_mixed_attention_kv_layout(self, kernel_block_sizes: list[int]) -> bool:
7614 - """Whether attention groups disagree on the physical KV cache layout.
7615 -
7616 - Encoder-decoder models (e.g. Whisper) share one raw KV allocation
7617 - between a decoder self-attention layer (K/V-first ROCM_ATTN, block dim
7618 - 1) and a cross-attention layer (blocks-first, block dim 0). Mixed block
7619 - dims mean a block ID maps to different bytes per layer, so the shared
7620 - buffer must be normalized to a single (blocks-first) layout.
7621 - """
7622 - block_dims: set[int] = set()
7623 - for group in self._kv_cache_spec_attn_group_iterator():
7624 - kv_cache_spec = group.kv_cache_spec
7625 - if not isinstance(kv_cache_spec, AttentionSpec):
7626 - continue
7627 - if group.kv_cache_group_id == len(kernel_block_sizes):
7628 - continue
7629 - block_dims.add(
7630 - group.backend.get_kv_cache_block_dim(
7631 - kernel_block_sizes[group.kv_cache_group_id],
7632 - kv_cache_spec.num_kv_heads,
7633 - kv_cache_spec.head_size,
7634 - cache_dtype_str=self.cache_config.cache_dtype,
7635 - )
7636 - )
7637 - return len(block_dims) > 1
7638 -
7639 - def _update_hybrid_attention_mamba_layout(
7640 - self, kv_caches: dict[str, torch.Tensor], kernel_block_sizes: list[int]
7641 - ) -> None:
7642 - """
7643 - Update the layout of attention layers from (2, num_blocks, ...) to
7644 - (num_blocks, 2, ...).
7645 -
7646 - Args:
7647 - kv_caches: The KV cache buffer of each layer.
7648 - kernel_block_sizes: The kernel block sizes for each KV cache group.
7649 - """
7650 -
7651 - for group in self._kv_cache_spec_attn_group_iterator():
7652 - kv_cache_spec = group.kv_cache_spec
7653 - if not isinstance(kv_cache_spec, AttentionSpec):
7654 - continue
7655 - block_dim = group.backend.get_kv_cache_block_dim(
7656 - kernel_block_sizes[group.kv_cache_group_id],
7657 - kv_cache_spec.num_kv_heads,
7658 - kv_cache_spec.head_size,
7659 - cache_dtype_str=self.cache_config.cache_dtype,
7660 - )
7661 - # block_dim: 0 means (num_blocks, 2, ...); 1 means (2, num_blocks, ...).
7662 - if block_dim == 0:
7663 - continue
7664 - assert block_dim == 1
7665 - for layer_name in group.layer_names:
7666 - kv_cache = kv_caches[layer_name]
7667 - hidden_size = kv_cache.shape[2:].numel()
7668 - kv_cache.as_strided_(
7669 - size=kv_cache.shape,
7670 - stride=(hidden_size, 2 * hidden_size, *kv_cache.stride()[2:]),
7671 - )
7672 -
7446 def initialize_kv_cache_tensors(
7447 self, kv_cache_config: KVCacheConfig, kernel_block_sizes: list[int]
7448 ) -> dict[str, torch.Tensor]:
@@ -7685,29 +7458,12 @@ class GPUModelRunner(
7458 corresponding memory buffer for KV cache.
7459 """
7460
7688 - # Try creating KV caches optimized for kv-connector transfers
7689 - cache_dtype = self.cache_config.cache_dtype
7690 - if self.use_uniform_kv_cache(self.attn_groups):
7691 - kv_caches, cross_layers_kv_cache, attn_backend = (
7692 - self.allocate_uniform_kv_caches(
7693 - kv_cache_config,
7694 - self.attn_groups,
7695 - cache_dtype,
7696 - self.device,
7697 - kernel_block_sizes,
7698 - )
7699 - )
7700 - self.cross_layers_kv_cache = cross_layers_kv_cache
7701 - self.cross_layers_attn_backend = attn_backend
7702 - else:
7703 - # Fallback to the general case
7704 - # Initialize the memory buffer for KV cache
7705 - kv_cache_raw_tensors = self._allocate_kv_cache_tensors(kv_cache_config)
7706 -
7707 - # Change the memory buffer to the desired shape
7708 - kv_caches = self._reshape_kv_cache_tensors(
7709 - kv_cache_raw_tensors, kernel_block_sizes
7710 - )
7461 + kv_caches = allocate_kv_cache(
7462 + kv_cache_config,
7463 + self.device,
7464 + self.cache_config.get_resolved_kv_cache_layout(),
7465 + kernel_block_sizes,
7466 + )
7467
7468 # Set up cross-layer KV cache sharing
7469 for layer_name, target_layer_name in self.shared_kv_cache_layers.items():
@@ -7803,13 +7559,7 @@ class GPUModelRunner(
7559
7560 if has_kv_transfer_group() and not is_profiling:
7561 kv_transfer_group = get_kv_transfer_group()
7806 - if self.cross_layers_kv_cache is not None:
7807 - assert self.cross_layers_attn_backend is not None
7808 - kv_transfer_group.register_cross_layers_kv_cache(
7809 - self.cross_layers_kv_cache, self.cross_layers_attn_backend
7810 - )
7811 - else:
7812 - kv_transfer_group.register_kv_caches(kv_caches)
7562 + kv_transfer_group.register_kv_caches(kv_caches)
7563 kv_transfer_group.set_host_xfer_buffer_ops(copy_kv_blocks)
7564
7565 def get_routed_experts(
@@ -7922,13 +7672,7 @@ class GPUModelRunner(
7672 # Skip modules that don't need KV cache (eg encoder-only attention)
7673 if spec := attn_module.get_kv_cache_spec(self.vllm_config):
7674 if isinstance(spec, AttentionSpec):
7925 - backend = attn_module.get_attn_backend()
7926 - # indexes_kv_by_block_stride() -> get_kv_cache_stride_order()
7927 - # -> get_kv_cache_layout() needs the current vLLM config.
7928 - with set_current_vllm_config(self.vllm_config):
7929 - indexes = backend.indexes_kv_by_block_stride()
7930 - spec = replace(spec, indexes_kv_by_block_stride=indexes)
7931 - spec = backend.customize_spec(spec)
7675 + spec = attn_module.get_attn_backend().customize_spec(spec)
7676 kv_cache_spec[layer_name] = spec
7677
7678 return kv_cache_spec
vllm/v1/worker/gpu_worker.py
+6
@@ -68,6 +68,7 @@ from vllm.utils.gpu_sync_debug import enable_gpu_sync_check, with_gpu_sync_check
68 from vllm.utils.mem_constants import GiB_bytes
69 from vllm.utils.mem_utils import MemorySnapshot, format_gib, memory_profiling
70 from vllm.utils.torch_utils import set_random_seed, set_torch_threads_for_runtime
71 +from vllm.v1.attention.backends.utils import record_kv_cache_layout
72 from vllm.v1.core.sched.output import GrammarOutput, SchedulerOutput
73 from vllm.v1.kv_cache_interface import KVCacheConfig, KVCacheSpec
74 from vllm.v1.outputs import (
@@ -669,6 +670,11 @@ class Worker(WorkerBase):
670 # so that it's available to the warmup stage.
671 self.cache_config.num_gpu_blocks = kv_cache_config.num_blocks
672
673 + # Adopt the engine core's layout; workers spawned after resolution
674 + # (e.g. elastic EP scale-up) only see it through the config.
675 + if kv_cache_config.kv_cache_layout is not None:
676 + record_kv_cache_layout(self.cache_config, kv_cache_config.kv_cache_layout)
677 +
678 # Init kv cache connector here, because it requires
679 # `kv_cache_config`.
680 # NOTE(Kuntai): This need to be done before `initialize_kv_cache`,
vllm/v1/worker/kv_connector_model_runner_mixin.py
-152
@@ -8,27 +8,18 @@ from collections.abc import Generator
8 from contextlib import AbstractContextManager, contextmanager, nullcontext
9 from typing import TYPE_CHECKING
10
11 -import torch
12 -
11 from vllm.config import VllmConfig
14 -from vllm.config.cache import CacheDType
12 from vllm.distributed.kv_transfer import get_kv_transfer_group, has_kv_transfer_group
13 from vllm.distributed.kv_transfer.kv_connector.base import KVConnectorBase
14 from vllm.forward_context import get_forward_context, set_forward_context
18 -from vllm.logger import init_logger
19 -from vllm.v1.attention.backend import AttentionBackend
20 -from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheConfig
15 from vllm.v1.outputs import (
16 KVConnectorOutput,
17 ModelRunnerOutput,
18 )
25 -from vllm.v1.worker.utils import AttentionGroup
19
20 if TYPE_CHECKING:
21 from vllm.v1.core.sched.output import SchedulerOutput
22
30 -logger = init_logger(__name__)
31 -
23
24 # Defined as a kv connector functionality mixin for ModelRunner (GPU, TPU)
25 class KVConnectorModelRunnerMixin:
@@ -110,146 +101,3 @@ class KVConnectorModelRunnerMixin:
101
102 if not defer_finalize:
103 kv_connector.clear_connector_metadata()
113 -
114 - @staticmethod
115 - def use_uniform_kv_cache(
116 - attn_groups: list[list[AttentionGroup]],
117 - ) -> bool:
118 - """
119 - Determines whether a uniform KV layout should be used.
120 - A uniform layout means all layers KV caches will share the same
121 - underlying tensor, where for a given block number, the respective
122 - KV data for all layers will be contiguous.
123 - This will allow efficient KV transfer of per-block KV data for all
124 - layers at once.
125 - Note this layout will only be applied given 3 conditions:
126 - 1. The KV Cache config contains just a single group where all layers
127 - have the same page size.
128 - 2. A KV connector is configured, and the KV connector instance prefers
129 - to use this layout (prefer_cross_layer_blocks() returns True)
130 - 3. The attention backend indexes KV by the block stride
131 - (kv_cache_spec.indexes_kv_by_block_stride), i.e. num_blocks is the
132 - outermost physical dim so per-block all-layers data is contiguous.
133 -
134 - Note that the actual placement of the num_layers dimensions
135 - in the unified layers tensors will be determined by the attention
136 - backend.
137 - Thus, the layers KV data may still not be contiguous per block
138 - if the attention backend does not support it.
139 -
140 - Args:
141 - attn_groups: The list of attention groups for this model
142 - Returns:
143 - True if we should use a uniform KV cache layout.
144 - """
145 -
146 - if not has_kv_transfer_group():
147 - return False
148 - if not get_kv_transfer_group().prefer_cross_layer_blocks:
149 - return False
150 -
151 - if len(attn_groups) != 1 or len(attn_groups[0]) != 1:
152 - return False
153 -
154 - attn_group = attn_groups[0][0]
155 - kv_cache_spec = attn_group.kv_cache_spec
156 - if not isinstance(kv_cache_spec, AttentionSpec):
157 - return False
158 - # Per-token-head quant carves inline-scale views that assume per-layer
159 - # contiguous KV buffers; the cross-layer layout breaks this and corrupts KV.
160 - if kv_cache_spec.kv_quant_mode.is_per_token_head:
161 - return False
162 - return kv_cache_spec.indexes_kv_by_block_stride
163 -
164 - @staticmethod
165 - def allocate_uniform_kv_caches(
166 - kv_cache_config: KVCacheConfig,
167 - attn_groups: list[list[AttentionGroup]],
168 - cache_dtype: CacheDType,
169 - device: torch.device,
170 - kernel_block_sizes: list[int],
171 - ) -> tuple[dict[str, torch.Tensor], torch.Tensor, type[AttentionBackend]]:
172 - """
173 - Initializes and reshapes KV caches for the simple case where all
174 - layers have the same layout.
175 -
176 - This function assumes use_uniform_kv_cache() returned True.
177 -
178 - Args:
179 - kv_cache_config: The KV cache config
180 - attn_groups: The list of attention groups for this model
181 - cache_dtype: The KV cache dtype
182 - device: The torch device to allocate on.
183 - kernel_block_sizes: The kernel block sizes for each KV cache group.
184 - Returns:
185 - A tuple (kv_caches, cross_layers_kv_cache, attn_backend) where:
186 - kv_caches is a dict mapping between layer names to their
187 - corresponding memory buffer for KV cache.
188 - cross_layers_kv_cache is the cross layers kv cache tensor
189 - attn_backend is the attention backend matching this tensor
190 - """
191 - attn_group = attn_groups[0][0]
192 - kv_cache_spec = attn_group.kv_cache_spec
193 - assert isinstance(kv_cache_spec, AttentionSpec)
194 -
195 - tensor_sizes = set(
196 - kv_cache_tensor.size for kv_cache_tensor in kv_cache_config.kv_cache_tensors
197 - )
198 - assert len(tensor_sizes) == 1
199 - tensor_size = tensor_sizes.pop()
200 -
201 - page_size = kv_cache_spec.page_size_bytes
202 - assert tensor_size % page_size == 0
203 - num_blocks = tensor_size // page_size
204 - num_layers = len(kv_cache_config.kv_cache_tensors)
205 - total_size = tensor_size * num_layers
206 -
207 - assert len(kernel_block_sizes) == 1
208 - kernel_block_size = kernel_block_sizes[0]
209 - num_blocks_per_kv_block = kv_cache_spec.block_size // kernel_block_size
210 - kernel_num_blocks = num_blocks * num_blocks_per_kv_block
211 -
212 - attn_backend = attn_group.backend
213 - kv_cache_shape = attn_backend.get_kv_cache_shape(
214 - kernel_num_blocks,
215 - kernel_block_size,
216 - kv_cache_spec.num_kv_heads,
217 - kv_cache_spec.head_size,
218 - cache_dtype_str=cache_dtype,
219 - )
220 -
221 - # prepend a num_layers dimension into the shape
222 - kv_cache_shape = (num_layers,) + kv_cache_shape
223 -
224 - try:
225 - kv_cache_stride_order = attn_backend.get_kv_cache_stride_order(
226 - include_num_layers_dimension=True
227 - )
228 - assert len(kv_cache_stride_order) == len(kv_cache_shape)
229 - except (AttributeError, NotImplementedError):
230 - kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
231 -
232 - kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
233 -
234 - logger.info("Allocating a cross layer KV cache of shape %s", kv_cache_shape)
235 -
236 - # allocate one contiguous buffer for all layers
237 - cross_layers_kv_cache = (
238 - torch.zeros(total_size, dtype=torch.int8, device=device)
239 - .view(kv_cache_spec.dtype)
240 - .view(kv_cache_shape)
241 - )
242 -
243 - # Maintain original KV shape view.
244 - inv_order = [
245 - kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order))
246 - ]
247 - permuted_kv_cache = cross_layers_kv_cache.permute(*inv_order)
248 -
249 - kv_caches = {}
250 - for i, kv_cache_tensor in enumerate(kv_cache_config.kv_cache_tensors):
251 - tensor = permuted_kv_cache[i]
252 - for layer_name in kv_cache_tensor.shared_by:
253 - kv_caches[layer_name] = tensor
254 -
255 - return kv_caches, cross_layers_kv_cache, attn_backend
vllm/v1/worker/utils.py
+119 -53
@@ -30,9 +30,11 @@ from vllm.v1.kv_cache_interface import (
30 EncoderOnlyAttentionSpec,
31 KVCacheConfig,
32 KVCacheGroupSpec,
33 + KVCacheLayout,
34 KVCacheSpec,
35 MambaSpec,
36 UniformTypeKVCacheSpecs,
37 + create_kv_cache_views,
38 )
39 from vllm.v1.worker.block_table import get_block_table_width
40
@@ -61,10 +63,10 @@ def _zero_kv_blocks_kernel(
63 ):
64 """Zero KV cache blocks across all segments in a single launch.
65
64 - Each segment is a contiguous region of one block's data. For backends
65 - where blocks are outermost (block_dim=0) there is one segment per
66 - buffer. For backends where K/V is outermost (block_dim=1) there are
67 - two segments per buffer (one for K, one for V).
66 + Each segment is a contiguous region of one block's data. Layer-compact
67 + layouts have one segment per layer buffer; dimensions physically outside
68 + the block dim (separate head groups under LHBNC) and virtual block splits
69 + each get their own segment.
70
71 Segments may have different block strides and page sizes (e.g. packed
72 KV views or models with multiple KV cache groups like MLA + DSA
@@ -110,7 +112,6 @@ class KVBlockZeroer:
112 device: torch.device,
113 attn_groups_iter: Iterable["AttentionGroup"],
114 kernel_block_sizes: list[int],
113 - cache_dtype: str,
115 static_forward_context: dict[str, Any],
116 runner_only_attn_layers: set[str] | None = None,
117 ) -> None:
@@ -119,6 +120,12 @@ class KVBlockZeroer:
120 Each entry is the absolute byte address of a segment start on the
121 GPU, so segments in different CUDA allocations work correctly.
122
123 + Per-layer views are standardized ``[B, H, N, C]`` with blocks at dim 0; dims
124 + physically outside B (separate head groups under LHBNC) each get their own
125 + segment. A segment's page spans everything inside its block, so under BHLNC it
126 + also covers the block's other layers -- safe, since block IDs are global pool
127 + indices and a newly allocated block owns its whole tile.
128 +
129 Block IDs from the scheduler reference logical blocks whose size
130 may differ from the kernel block size (virtual block splitting).
131 Each virtual block is represented as an independent segment so its
@@ -133,7 +140,10 @@ class KVBlockZeroer:
140
141 if runner_only_attn_layers is None:
142 runner_only_attn_layers = set()
136 - seen_ptrs: set[int] = set()
143 + # Overlaid layers (packed layouts) share a base address but may have
144 + # different page sizes; keep the widest span per address so newly
145 + # allocated blocks are fully zeroed for every overlaying group.
146 + seen_ptrs: dict[int, int] = {}
147 seg_addrs: list[int] = []
148 seg_block_strides: list[int] = []
149 seg_page_sizes: list[int] = []
@@ -147,12 +157,6 @@ class KVBlockZeroer:
157 kernel_bs = kernel_block_sizes[group.kv_cache_group_id]
158 assert spec.block_size % kernel_bs == 0
159 ratio = spec.block_size // kernel_bs
150 - block_dim = group.backend.get_kv_cache_block_dim(
151 - kernel_bs,
152 - spec.num_kv_heads,
153 - spec.head_size,
154 - cache_dtype_str=cache_dtype,
155 - )
160
161 for layer_name in group.layer_names:
162 if layer_name in runner_only_attn_layers:
@@ -161,23 +165,18 @@ class KVBlockZeroer:
165 if not isinstance(kv, torch.Tensor):
166 continue
167 dp = kv.data_ptr()
164 - if dp in seen_ptrs:
165 - continue
166 - seen_ptrs.add(dp)
168
169 el = kv.element_size()
169 - block_stride_bytes = kv.stride(block_dim) * el
170 + block_stride_bytes = kv.stride(0) * el
171 assert block_stride_bytes % 4 == 0
171 - assert kv.shape[block_dim] % ratio == 0
172 + assert kv.shape[0] % ratio == 0
173 outer_dims = [
174 d
174 - for d in range(block_dim)
175 + for d in range(1, kv.ndim)
176 if kv.stride(d) * el > block_stride_bytes
177 ]
178 outer_strides = [kv.stride(d) * el for d in outer_dims]
178 - inner_dims = [
179 - d for d in range(kv.ndim) if d != block_dim and d not in outer_dims
180 - ]
179 + inner_dims = [d for d in range(1, kv.ndim) if d not in outer_dims]
180 kernel_page_bytes = el + sum(
181 (kv.shape[d] - 1) * kv.stride(d) * el for d in inner_dims
182 )
@@ -187,9 +186,18 @@ class KVBlockZeroer:
186 off_bytes = sum(i * s for i, s in zip(outer, outer_strides))
187 assert (dp + off_bytes) % 4 == 0
188 for virtual_index in range(ratio):
190 - seg_addrs.append(
191 - dp + off_bytes + virtual_index * block_stride_bytes
192 - )
189 + addr = dp + off_bytes + virtual_index * block_stride_bytes
190 + if (idx := seen_ptrs.get(addr)) is not None:
191 + assert (
192 + seg_block_strides[idx]
193 + == logical_block_stride_bytes // 4
194 + )
195 + seg_page_sizes[idx] = max(
196 + seg_page_sizes[idx], kernel_page_bytes // 4
197 + )
198 + continue
199 + seen_ptrs[addr] = len(seg_addrs)
200 + seg_addrs.append(addr)
201 seg_block_strides.append(logical_block_stride_bytes // 4)
202 seg_page_sizes.append(kernel_page_bytes // 4)
203
@@ -367,6 +375,54 @@ def select_common_block_size(
375 raise ValueError(f"No common block size for {kv_manager_block_size}. ")
376
377
378 +def allocate_kv_cache(
379 + kv_cache_config: KVCacheConfig,
380 + device: torch.device,
381 + layout: KVCacheLayout,
382 + kernel_block_sizes: list[int] | None = None,
383 +) -> dict[str, torch.Tensor]:
384 + """Allocate the KV cache and view it as ``[B, H, N, C]`` per layer.
385 +
386 + Every KVCacheTensor places its layers in the same backing allocation: layer ``l`` of
387 + block ``b`` starts at ``offset + l * layer_stride + b * block_stride``. Cache
388 + groups overlay each other, so tensors may address the same bytes.
389 + """
390 + if not kv_cache_config.kv_cache_tensors:
391 + return {}
392 +
393 + sizes = {tensor.size for tensor in kv_cache_config.kv_cache_tensors}
394 + assert len(sizes) == 1, "KV cache tensors must share one backing allocation."
395 + buf = torch.zeros(sizes.pop(), dtype=torch.int8, device=device)
396 +
397 + kv_caches: dict[str, torch.Tensor] = {}
398 + for tensor in kv_cache_config.kv_cache_tensors:
399 + layer_name = tensor.layers[0]
400 + group_id, group = next(
401 + (group_id, group)
402 + for group_id, group in enumerate(kv_cache_config.kv_cache_groups)
403 + if layer_name in group.layer_names
404 + )
405 + spec = group.kv_cache_spec
406 + if isinstance(spec, UniformTypeKVCacheSpecs):
407 + spec = spec.kv_cache_specs[layer_name]
408 +
409 + num_blocks = kv_cache_config.num_blocks
410 + kernel_block_size = None
411 + if kernel_block_sizes is not None and group_id < len(kernel_block_sizes):
412 + kernel_block_size = kernel_block_sizes[group_id]
413 +
414 + views = create_kv_cache_views(
415 + buf,
416 + spec,
417 + num_blocks,
418 + layout,
419 + tensor,
420 + kernel_block_size=kernel_block_size,
421 + )
422 + kv_caches.update(zip(tensor.layers, views))
423 + return kv_caches
424 +
425 +
426 def prepare_kernel_block_sizes(
427 kv_cache_config: KVCacheConfig, attn_groups: list[list[AttentionGroup]]
428 ) -> list[int]:
@@ -551,42 +607,52 @@ def bind_kv_cache(
607
608
609 def copy_kv_cache_blocks_inplace(
554 - kv_caches: Iterable[torch.Tensor | list[torch.Tensor]],
610 + kv_caches: Iterable[torch.Tensor],
611 num_blocks: int,
612 kv_cache_block_copies: Sequence[KVCacheBlockCopy],
613 ) -> None:
614 if not kv_cache_block_copies:
615 return
616
561 - storage_tensors: list[torch.Tensor] = []
562 - seen_storage: set[int] = set()
563 - for entry in kv_caches:
564 - # Mamba layers hold a list of state tensors; attention layers a single
565 - # tensor. Both alias the shared block-major backing storage.
566 - tensors = entry if isinstance(entry, (list, tuple)) else (entry,)
567 - for tensor in tensors:
568 - ptr = tensor.untyped_storage().data_ptr()
569 - if ptr in seen_storage:
570 - continue
571 - seen_storage.add(ptr)
572 - storage_tensors.append(tensor)
573 -
574 - if not storage_tensors:
575 - return
576 - device = storage_tensors[0].device
617 indices_np = np.array(kv_cache_block_copies, dtype=np.int64)
578 - indices = async_tensor_h2d(indices_np, device=device)
579 - src_indices, dst_indices = indices.unbind(dim=1)
580 -
581 - for tensor in storage_tensors:
582 - assert tensor.device == device
583 - blocks = torch.empty(0, dtype=torch.uint8, device=device)
584 - blocks.set_(tensor.untyped_storage())
585 - # Block-major backing storage: block i owns the contiguous byte range
586 - # [i * page_size, (i + 1) * page_size).
587 - assert blocks.numel() % num_blocks == 0
588 - blocks = blocks.view(num_blocks, -1)
589 - blocks[dst_indices] = blocks[src_indices]
618 + indices: torch.Tensor | None = None
619 + seen: set[tuple[torch.device, int]] = set()
620 + copied_storages: set[tuple[torch.device, int]] = set()
621 + for cache in kv_caches:
622 + # Layers sharing KV (cross-layer sharing) alias the same view; copy it
623 + # once. data_ptr distinguishes per-layer views of a shared allocation.
624 + key = (cache.device, cache.data_ptr())
625 + if key in seen:
626 + continue
627 + seen.add(key)
628 +
629 + if indices is None:
630 + indices = async_tensor_h2d(indices_np, device=cache.device)
631 + assert cache.device == indices.device
632 + src, dst = indices.unbind(dim=1)
633 +
634 + kernel_blocks_per_block, remainder = divmod(cache.shape[0], num_blocks)
635 + assert remainder == 0, (
636 + f"{cache.shape[0]} kernel blocks not divisible by "
637 + f"{num_blocks} scheduler blocks"
638 + )
639 + storage = cache.untyped_storage()
640 + storage_key = (cache.device, storage.data_ptr())
641 + scheduler_block_stride = (
642 + cache.stride(0) * cache.element_size() * kernel_blocks_per_block
643 + )
644 + if storage.nbytes() == num_blocks * scheduler_block_stride:
645 + if storage_key in copied_storages:
646 + continue
647 + copied_storages.add(storage_key)
648 + blocks = torch.empty(0, dtype=torch.uint8, device=cache.device)
649 + blocks.set_(storage)
650 + blocks = blocks.view(num_blocks, -1)
651 + else:
652 + # Fold virtual block splitting into the shape so that dim 0 counts
653 + # scheduler blocks; unflatten of dim 0 is always a view.
654 + blocks = cache.unflatten(0, (num_blocks, kernel_blocks_per_block))
655 + blocks[dst] = blocks[src]
656
657
658 def is_uniform_query_len(num_reqs: int, num_tokens: int, max_query_len: int) -> bool:
vllm/v1/worker/worker_base.py
+14
@@ -9,12 +9,17 @@ import torch.nn as nn
9
10 import vllm.ir
11 from vllm.config import VllmConfig, set_current_vllm_config
12 +from vllm.distributed.kv_transfer.kv_connector.utils import get_current_attn_backends
13 from vllm.logger import init_logger
14 from vllm.lora.request import LoRARequest
15 from vllm.multimodal import MULTIMODAL_REGISTRY
16 from vllm.tracing import instrument
17 from vllm.utils.import_utils import resolve_obj_by_qualname
18 from vllm.utils.system_utils import update_environment_variables
19 +from vllm.v1.attention.backends.utils import (
20 + get_supported_kv_cache_layouts,
21 + record_kv_cache_layout,
22 +)
23 from vllm.v1.kv_cache_interface import KVCacheSpec
24
25 if TYPE_CHECKING:
@@ -99,6 +104,15 @@ class WorkerBase:
104 """Get specifications for KV cache implementation."""
105 raise NotImplementedError
106
107 + def get_supported_kv_cache_layouts(self) -> list[str]:
108 + """Layout names every attention backend supports, most preferred first."""
109 + backends = get_current_attn_backends(self.vllm_config)
110 + return [layout.name for layout in get_supported_kv_cache_layouts(backends)]
111 +
112 + def set_kv_cache_layout(self, kv_cache_layout: str) -> None:
113 + """Adopt the KV cache layout resolved by the engine core."""
114 + record_kv_cache_layout(self.vllm_config.cache_config, kv_cache_layout)
115 +
116 def compile_or_warm_up_model(self) -> CompilationTimes:
117 """Prepare model for execution through compilation/warmup.
118