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

Signed-off-by: Lucas Wilkinson <lwilkins@redhat.com>
This commit is contained in:
Lucas Wilkinson
2026-08-21 17:47:49 -07:00
committed by GitHub
parent 9ff7041b55
commit 8bdc70ec7b
150 changed files with 4006 additions and 5815 deletions
+3
View File
@@ -243,6 +243,9 @@ steps:
DECODE_BLOCK_SIZE: "256"
MODEL_NAMES: "deepseek-ai/DeepSeek-V4-Flash"
VLLM_SERVE_EXTRA_ARGS: "--trust-remote-code,--kv-cache-dtype,fp8"
# The V4 indexer only supports block-outermost layouts; let it pick.
PREFILLER_KV_LAYOUT: ""
DECODER_KV_LAYOUT: ""
commands:
- bash /vllm-workspace/.buildkite/scripts/install-kv-connectors.sh
- bash v1/kv_connector/nixl_integration/run_accuracy_test.sh
+47 -48
View File
@@ -12,6 +12,7 @@ import logging
import statistics
import types
from contextlib import contextmanager
from math import prod
import torch
from batch_spec import parse_batch_spec, reorder_for_flashinfer
@@ -35,12 +36,20 @@ from vllm.config import (
VllmConfig,
set_current_vllm_config,
)
from vllm.platforms import current_platform
from vllm.v1.attention.backends.utils import (
CommonAttentionMetadata,
get_kv_cache_layout,
set_kv_cache_layout,
get_supported_kv_cache_layouts,
resolve_kv_cache_layout,
)
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheLayout,
KVCacheTensor,
compute_layer_kv_cache_shape_bytes,
compute_layout_strides,
create_kv_cache_views,
)
from vllm.v1.kv_cache_interface import FullAttentionSpec
# ============================================================================
# Backend Configuration
@@ -340,49 +349,42 @@ def _create_kv_cache(
backend_class,
device: torch.device,
dtype: torch.dtype,
layout: KVCacheLayout,
) -> list:
"""Create KV cache tensors for all layers using the backend's methods.
Uses the backend's get_kv_cache_shape() and get_kv_cache_stride_order()
to create the cache with the correct shape and memory layout.
"""
# Get the logical shape from the backend
cache_shape = backend_class.get_kv_cache_shape(
num_blocks=max_num_blocks,
"""Create KV cache tensors for all layers using the standard allocator."""
if config.kv_cache_dtype.startswith("fp8"):
cache_dtype = current_platform.fp8_dtype()
else:
cache_dtype = dtype
spec = FullAttentionSpec(
block_size=config.block_size,
num_kv_heads=config.num_kv_heads,
head_size=config.head_dim,
dtype=cache_dtype,
)
# Apply the backend's page customization, as the worker does for the real spec.
spec = backend_class.customize_spec(spec)
total_bytes = (
prod(compute_layer_kv_cache_shape_bytes(spec, max_num_blocks))
* config.num_layers
)
buf = torch.zeros(total_bytes, device=device, dtype=torch.int8)
layer_stride, block_stride, _, _, _ = compute_layout_strides(
spec, max_num_blocks, config.num_layers, layout
)
tensor = KVCacheTensor(
size=total_bytes,
layers=[str(i) for i in range(config.num_layers)],
layer_stride=layer_stride,
block_stride=block_stride,
)
return create_kv_cache_views(
buf,
spec,
max_num_blocks,
layout,
tensor,
)
# Get the stride order for custom memory layout
try:
stride_order = backend_class.get_kv_cache_stride_order()
assert len(stride_order) == len(cache_shape)
except (AttributeError, NotImplementedError):
stride_order = tuple(range(len(cache_shape)))
# Permute shape to physical layout order
physical_shape = tuple(cache_shape[i] for i in stride_order)
# Compute inverse permutation to get back to logical view
inv_order = [stride_order.index(i) for i in range(len(stride_order))]
# Use fp8 dtype for cache when requested.
cache_dtype = dtype
if config.kv_cache_dtype == "fp8":
from vllm.platforms import current_platform
cache_dtype = current_platform.fp8_dtype()
cache_list = []
for _ in range(config.num_layers):
# Allocate in physical layout order (contiguous in memory)
cache = torch.zeros(*physical_shape, device=device, dtype=cache_dtype)
# Permute to logical view
cache = cache.permute(*inv_order)
cache_list.append(cache)
return cache_list
# ============================================================================
@@ -499,13 +501,10 @@ def run_attention_benchmark(config: BenchmarkConfig) -> BenchmarkResult:
backend_class, impl, layer = _create_backend_impl(
backend_cfg, config, device, dtype
)
# Set KV cache layout if the backend requires a specific one
# (e.g., FlashInfer requires HND on SM100/Blackwell for TRTLLM attention)
required_layout = backend_class.get_required_kv_cache_layout()
if required_layout is not None:
set_kv_cache_layout(required_layout)
get_kv_cache_layout.cache_clear()
# (e.g., FlashInfer requires LBHNC on SM100/Blackwell for TRTLLM attention)
supported = get_supported_kv_cache_layouts([backend_class])
layout = resolve_kv_cache_layout(vllm_config, [[m.name for m in supported]])
common_metadata = _build_common_attn_metadata(
q_lens, kv_lens, config.block_size, device
@@ -542,7 +541,7 @@ def run_attention_benchmark(config: BenchmarkConfig) -> BenchmarkResult:
)
cache_list = _create_kv_cache(
config, max_num_blocks, backend_class, device, dtype
config, max_num_blocks, backend_class, device, dtype, layout
)
timing_stats, mem_stats = _run_single_benchmark(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

After

Width:  |  Height:  |  Size: 353 KiB

@@ -246,7 +246,6 @@ Strict isolation requires a Mooncake master started with `--enable_multi_tenants
- `load_async` (bool): Enable asynchronous loading for better compute-I/O overlap. Default: `true`.
- `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`.
- `enable_cross_layers_blocks` (bool): Enable cross-layer block packing for reduced store operations. Default: `false`.
- `lookup_rpc_port` (int): Custom port for the ZMQ lookup RPC socket. Default: `0`.
- `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).
- `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`.
@@ -61,7 +61,7 @@ th:not(:first-child) {
See [Configuration Notes](#configuration-notes) below for what must match and
what may differ.
<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"}}'`.
<sup>2</sup> Cross-layer contiguity is achieved by using a `BLHNC` layout (set via `VLLM_KV_CACHE_LAYOUT=BLHNC`).
<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.
@@ -100,9 +100,9 @@ By default, a **compatibility hash** is checked during handshake. P and D instan
### KV cache layout
- NixlConnector defaults to **`HND`** layout for optimal transfer performance (non-MLA models).
- `NHD` layout is supported but does **not** allow heterogeneous TP head splitting.
- Experimental `HND` ↔ `NHD` permute: enable via `--kv-transfer-config '{"enable_permute_local_kv": true}'`. Not supported with HMA.
- NixlConnector defaults to **`LBHNC`** (head-major, formerly `HND`) layout for optimal transfer performance (non-MLA models).
- `LBNHC` (token-major, formerly `NHD`) layout is supported but does **not** allow heterogeneous TP head splitting.
- Experimental `LBHNC` ↔ `LBNHC` permute: enable via `--kv-transfer-config '{"enable_permute_local_kv": true}'`. Not supported with HMA.
### Quantized KV cache
+1 -10
View File
@@ -404,21 +404,12 @@ GB-series GPUs support multi-node NVLink. NIXL supports this capability, but KVC
### Heterogeneous KV Layout support
Support use case: Prefill with 'HND' and decode with 'NHD' with experimental configuration
Support use case: Prefill with `LBHNC` and decode with `LBNHC` with experimental configuration
```bash
--kv-transfer-config '{..., "enable_permute_local_kv":"True"}'
```
### Cross layers blocks
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.
To enable this feature:
```bash
--kv-transfer-config '{..., "kv_connector_extra_config": {"enable_cross_layers_blocks": "True"}}'
```
## Metrics Reference
vLLM periodically logs a `KV Transfer metrics` line summarising NIXL transfer
+9 -9
View File
@@ -2340,15 +2340,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lz4_flex"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
dependencies = [
"twox-hash",
]
[[package]]
name = "logos"
version = "0.15.1"
@@ -2415,6 +2406,15 @@ dependencies = [
"logos-codegen 0.16.1",
]
[[package]]
name = "lz4_flex"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
dependencies = [
"twox-hash",
]
[[package]]
name = "macro_rules_attribute"
version = "0.2.2"
+33 -25
View File
@@ -7,7 +7,11 @@ import torch._dynamo
from tests.compile.backend import LazyInitPass, TestBackend
from tests.utils import TestFP8Layer, flat_product
from tests.v1.attention.utils import BatchSpec, create_common_attn_metadata
from tests.v1.attention.utils import (
BatchSpec,
create_common_attn_metadata,
dense_kv_cache_views,
)
from vllm._custom_ops import cutlass_scaled_fp4_mm, scaled_fp4_quant
from vllm.compilation.passes.fusion.attn_quant_fusion import (
ATTN_OP,
@@ -39,7 +43,14 @@ from vllm.platforms import current_platform
from vllm.utils.flashinfer import has_flashinfer
from vllm.v1.attention.backend import AttentionMetadata
from vllm.v1.attention.backends.registry import AttentionBackendEnum
from vllm.v1.kv_cache_interface import AttentionSpec, get_kv_quant_mode
from vllm.v1.attention.backends.utils import (
get_supported_kv_cache_layouts,
resolve_kv_cache_layout,
)
from vllm.v1.kv_cache_interface import (
AttentionSpec,
get_kv_quant_mode,
)
DEVICE_TYPE = current_platform.device_type
FP8_DTYPE = current_platform.fp8_dtype()
@@ -108,32 +119,29 @@ class AttentionQuantPatternModel(torch.nn.Module):
max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
num_blocks = batch_size * max_blocks
# Fetch the attention backend and kv cache shape and stride order
attn_backend = self.attn.attn_backend
kv_cache_shape = attn_backend.get_kv_cache_shape(
num_blocks,
self.block_size,
self.num_kv_heads,
self.head_size,
cache_dtype_str=self.attn.kv_cache_dtype,
)
try:
kv_cache_stride_order = attn_backend.get_kv_cache_stride_order()
except (AttributeError, NotImplementedError):
kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
inv_order = [
kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order))
]
# Create dummy KV cache
raw_tensor = torch.zeros(
kv_cache_shape,
spec = AttentionSpec(
block_size=self.block_size,
num_kv_heads=self.num_kv_heads,
head_size=self.head_size,
dtype=self.attn.kv_cache_torch_dtype,
kv_quant_mode=get_kv_quant_mode(self.attn.kv_cache_dtype),
)
supported = get_supported_kv_cache_layouts([self.attn.attn_backend])
layout = resolve_kv_cache_layout(
self.vllm_config, [[m.name for m in supported]]
)
raw_tensor = torch.zeros(
num_blocks * spec.page_size_bytes,
dtype=torch.int8,
device=self.device,
)
kv_cache = raw_tensor.permute(*inv_order)
kv_cache = dense_kv_cache_views(
raw_tensor,
spec,
num_blocks,
num_layers=1,
layout=layout,
)[0]
self.attn.kv_cache = kv_cache
@@ -150,27 +150,14 @@ class MLAAttentionQuantPatternModel(torch.nn.Module):
max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
num_blocks = batch_size * max_blocks
# MLA KV cache is 3D: (num_blocks, block_size, head_size)
attn_backend = self.mla_attn.attn_backend
kv_cache_shape = attn_backend.get_kv_cache_shape(
num_blocks, self.block_size, 1, self.head_size
# MLA KV cache is 4D: (num_blocks, num_heads=1, block_size, head_size)
kv_cache = torch.zeros(
(num_blocks, 1, self.block_size, self.head_size),
dtype=self.kv_cache_dtype,
device=self.device,
)
try:
kv_cache_stride_order = attn_backend.get_kv_cache_stride_order()
except (AttributeError, NotImplementedError):
kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
ordered_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
inv_order = [
kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order))
]
raw_tensor = torch.zeros(
ordered_shape, dtype=self.kv_cache_dtype, device=self.device
)
kv_cache = raw_tensor.permute(*inv_order)
self.mla_attn.kv_cache = kv_cache
self.mla_attn.bind_kv_cache(kv_cache)
self.attn_metadata = self.builder.build(
common_prefix_len=0, common_attn_metadata=common_attn_metadata
@@ -165,29 +165,14 @@ class MLARoPEKVCacheCatTestModel(torch.nn.Module):
max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
num_blocks = batch_size * max_blocks
# Fetch the attention backend and kv cache shape and stride order
kv_cache_shape = self.attn_backend.get_kv_cache_shape(
num_blocks, self.block_size, self.num_kv_heads, self.head_size
)
try:
kv_cache_stride_order = self.attn_backend.get_kv_cache_stride_order()
except (AttributeError, NotImplementedError):
kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
inv_order = [
kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order))
]
raw_tensor = torch.zeros(
num_blocks * self.block_size * self.num_kv_heads * self.head_size,
# MLA KV cache is 4D: (num_blocks, num_heads=1, block_size, head_size)
kv_cache = torch.zeros(
(num_blocks, 1, self.block_size, self.head_size),
dtype=self.kv_cache_dtype,
device=self.device,
)
raw_tensor = raw_tensor.view(kv_cache_shape)
kv_cache = raw_tensor.permute(*inv_order)
self.mla_attn.kv_cache = kv_cache
self.mla_attn.bind_kv_cache(kv_cache)
# Build attn metadata
attn_metadata = self.builder.build(
@@ -10,7 +10,11 @@ from packaging.version import Version
import vllm.config
from tests.compile.backend import TestBackend
from tests.v1.attention.utils import BatchSpec, create_common_attn_metadata
from tests.v1.attention.utils import (
BatchSpec,
create_common_attn_metadata,
dense_kv_cache_views,
)
from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops
from vllm.compilation.passes.fusion.matcher_utils import ROTARY_OP
from vllm.compilation.passes.fusion.qk_norm_rope_kvcache_fusion import (
@@ -40,7 +44,7 @@ from vllm.v1.attention.backend import (
CommonAttentionMetadata,
)
from vllm.v1.attention.backends.registry import AttentionBackendEnum
from vllm.v1.kv_cache_interface import AttentionSpec
from vllm.v1.kv_cache_interface import AttentionSpec, KVCacheLayout
pytestmark = pytest.mark.skip_global_cleanup
@@ -147,20 +151,25 @@ class QKNormRoPEKVCacheTestModel(torch.nn.Module):
self.attn._k_scale = self.attn._k_scale.to(device)
self.attn._v_scale = self.attn._v_scale.to(device)
self.builder = self.attn.attn_backend.get_builder_cls()(
kv_cache_spec=AttentionSpec(
# Mirror the worker spec-collection loop: the backend publishes the
# packing it needs.
self.kv_cache_spec = self.attn.attn_backend.customize_spec(
AttentionSpec(
block_size=self.block_size,
num_kv_heads=self.num_kv_heads,
head_size=head_size,
dtype=self.kv_cache_dtype,
),
)
)
self.builder = self.attn.attn_backend.get_builder_cls()(
kv_cache_spec=self.kv_cache_spec,
layer_names=[self.attn.layer_name],
vllm_config=vllm_config,
device=device,
)
def build_attn_metadata(
self, batch_size: int, kv_stride_order: tuple[int, ...] | None = None
self, batch_size: int, layout: KVCacheLayout
) -> CommonAttentionMetadata:
batch_spec = BatchSpec(seq_lens=[1] * batch_size, query_lens=[1] * batch_size)
common_attn_metadata = create_common_attn_metadata(
@@ -170,27 +179,18 @@ class QKNormRoPEKVCacheTestModel(torch.nn.Module):
max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
num_blocks = batch_size * max_blocks
attn_backend = self.attn.attn_backend
kv_cache_shape = attn_backend.get_kv_cache_shape(
num_blocks, self.block_size, self.num_kv_heads, self.head_size
)
# Caller can force a physical layout; else use the backend's.
if kv_stride_order is None:
try:
kv_stride_order = attn_backend.get_kv_cache_stride_order()
except (AttributeError, NotImplementedError):
kv_stride_order = tuple(range(len(kv_cache_shape)))
kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_stride_order)
inv_order = [kv_stride_order.index(i) for i in range(len(kv_stride_order))]
raw_tensor = torch.zeros(
2 * num_blocks * self.block_size * self.num_kv_heads * self.head_size,
dtype=self.kv_cache_dtype,
num_blocks * self.kv_cache_spec.page_size_bytes,
dtype=torch.int8,
device=self.device,
)
raw_tensor = raw_tensor.view(kv_cache_shape)
kv_cache = raw_tensor.permute(*inv_order)
kv_cache = dense_kv_cache_views(
raw_tensor,
self.kv_cache_spec,
num_blocks,
num_layers=1,
layout=layout,
)[0]
# Store as a bare tensor (not wrapped in a list) to match production
# `bind_kv_cache` behavior. `get_attention_context` returns this
@@ -272,7 +272,7 @@ def _run_qk_norm_rope_kvcache_fusion_test(
block_size: int,
is_neox: bool,
use_shuffle_kv_layout: str,
kv_stride_order: tuple[int, ...],
kv_layout: KVCacheLayout,
dtype: torch.dtype,
kv_cache_dtype: str,
rms_norm_eps: float,
@@ -345,7 +345,7 @@ def _run_qk_norm_rope_kvcache_fusion_test(
# Run unfused (eager) forward
with set_forward_context(None, vllm_config):
forward_context = get_forward_context()
attn_metadata = model.build_attn_metadata(num_tokens, kv_stride_order)
attn_metadata = model.build_attn_metadata(num_tokens, kv_layout)
forward_context.slot_mapping = {
model.layer_name: attn_metadata.slot_mapping
}
@@ -360,7 +360,7 @@ def _run_qk_norm_rope_kvcache_fusion_test(
with set_forward_context(None, vllm_config):
model_fused = torch.compile(model, backend=backend)
forward_context = get_forward_context()
attn_metadata = model_fused.build_attn_metadata(num_tokens, kv_stride_order)
attn_metadata = model_fused.build_attn_metadata(num_tokens, kv_layout)
forward_context.slot_mapping = {
model.layer_name: attn_metadata.slot_mapping
}
@@ -437,10 +437,11 @@ _FUSION_CONFIGS = [
)
@pytest.mark.parametrize("num_tokens", [5, 2048])
@pytest.mark.parametrize(
"kv_stride_order",
# FA/unified use the 4D packed cache (num_blocks, num_kv_heads, block_size,
# 2*head_size); no separate K/V dim to permute.
[pytest.param((0, 1, 2, 3), id="packed_4d")],
"kv_layout",
[
pytest.param(KVCacheLayout.LBHNC, id="head_major"),
pytest.param(KVCacheLayout.LBNHC, id="token_major"),
],
)
@pytest.mark.parametrize("enable_aiter_triton_rope", [True, False])
@pytest.mark.parametrize("block_size", [16, 32, 64])
@@ -467,7 +468,7 @@ def test_qk_norm_rope_kvcache_fusion(
is_neox: bool,
attn_backend: AttentionBackendEnum,
enable_aiter_triton_rope: bool,
kv_stride_order: tuple[int, ...],
kv_layout: KVCacheLayout,
block_size: int,
dtype: torch.dtype,
kv_cache_dtype: str,
@@ -486,7 +487,7 @@ def test_qk_norm_rope_kvcache_fusion(
block_size=block_size,
is_neox=is_neox,
use_shuffle_kv_layout="0",
kv_stride_order=kv_stride_order,
kv_layout=kv_layout,
dtype=dtype,
kv_cache_dtype=kv_cache_dtype,
rms_norm_eps=rms_norm_eps,
@@ -7,7 +7,11 @@ from torch._higher_order_ops import auto_functionalized
import vllm.config
from tests.compile.backend import TestBackend
from tests.v1.attention.utils import BatchSpec, create_common_attn_metadata
from tests.v1.attention.utils import (
BatchSpec,
create_common_attn_metadata,
dense_kv_cache_views,
)
from vllm._aiter_ops import is_aiter_found_and_supported, rocm_aiter_ops
from vllm.compilation.passes.fusion import rope_kvcache_fusion
from vllm.compilation.passes.fusion.matcher_utils import ROTARY_OP
@@ -38,6 +42,10 @@ from vllm.v1.attention.backend import (
CommonAttentionMetadata,
)
from vllm.v1.attention.backends.registry import AttentionBackendEnum
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheLayout,
)
INDEX_SELECT_OP = torch.ops.aten.index.Tensor
VLLM_UNIFIED_KV_CACHE_UPDATE_OP = torch.ops.vllm.unified_kv_cache_update
@@ -149,9 +157,20 @@ class QKRoPEKVCacheTestModel(torch.nn.Module):
FP8_DTYPE if kv_cache_dtype_str.startswith("fp8") else self.dtype
)
# Mirror the worker spec-collection loop: the backend publishes its
# packing (e.g. ROCM_ATTN's separate K/V head groups).
self.kv_cache_spec = self.attn_backend.customize_spec(
FullAttentionSpec(
block_size=self.block_size,
num_kv_heads=self.num_kv_heads,
head_size=self.head_size,
dtype=self.kv_cache_dtype,
)
)
# Initialize attn MetadataBuilder
self.builder = self.attn_backend.get_builder_cls()(
kv_cache_spec=self.attn.get_kv_cache_spec(vllm_config),
kv_cache_spec=self.kv_cache_spec,
layer_names=[self.attn.layer_name],
vllm_config=vllm_config,
device=device,
@@ -168,30 +187,25 @@ class QKRoPEKVCacheTestModel(torch.nn.Module):
max_blocks = (max(batch_spec.seq_lens) + self.block_size - 1) // self.block_size
num_blocks = batch_size * max_blocks
# Fetch the attention backend and kv cache shape and stride order
kv_cache_shape = self.attn_backend.get_kv_cache_shape(
num_blocks, self.block_size, self.num_kv_heads, self.head_size
)
try:
kv_cache_stride_order = self.attn_backend.get_kv_cache_stride_order()
except (AttributeError, NotImplementedError):
kv_cache_stride_order = tuple(range(len(kv_cache_shape)))
# A backend's preferred supported layout wins over the default, mirroring
# selector-time resolution (e.g. ROCM_ATTN's head groups force LHBNC).
layout = KVCacheLayout.LBNHC
supported = self.attn_backend.supported_kv_cache_layouts()
if supported:
layout = supported[0]
kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
inv_order = [
kv_cache_stride_order.index(i) for i in range(len(kv_cache_stride_order))
]
# Create dummy KV cache
raw_tensor = torch.zeros(
2 * num_blocks * self.block_size * self.num_kv_heads * self.head_size,
dtype=self.kv_cache_dtype,
num_blocks * self.kv_cache_spec.page_size_bytes,
dtype=torch.int8,
device=self.device,
)
raw_tensor = raw_tensor.view(kv_cache_shape)
kv_cache = raw_tensor.permute(*inv_order)
self.attn.kv_cache = kv_cache
self.attn.kv_cache = dense_kv_cache_views(
raw_tensor,
self.kv_cache_spec,
num_blocks,
num_layers=1,
layout=layout,
)[0]
# Build attn metadata
attn_metadata = self.builder.build(
+4 -4
View File
@@ -21,7 +21,7 @@ def test_get_kv_connector_cache_layout_without_kv_connector():
with set_current_vllm_config(vllm_config):
# Test with default settings
layout = get_kv_connector_cache_layout()
assert layout == "NHD"
assert layout is None
def test_get_kv_connector_cache_layout_with_lmcache_connector():
@@ -35,7 +35,7 @@ def test_get_kv_connector_cache_layout_with_lmcache_connector():
with set_current_vllm_config(vllm_config):
# Test with default settings
layout = get_kv_connector_cache_layout()
assert layout == "NHD"
assert layout is None
def test_get_kv_connector_cache_layout_with_nixl_connector():
@@ -52,7 +52,7 @@ def test_get_kv_connector_cache_layout_with_nixl_connector():
with set_current_vllm_config(vllm_config):
# Test with default settings
layout = get_kv_connector_cache_layout()
assert layout == "HND"
assert layout == "LBHNC"
def test_get_kv_connector_cache_layout_with_multi_connector():
@@ -75,4 +75,4 @@ def test_get_kv_connector_cache_layout_with_multi_connector():
with set_current_vllm_config(vllm_config):
# Test with default settings
layout = get_kv_connector_cache_layout()
assert layout == "HND"
assert layout == "LBHNC"
+7 -7
View File
@@ -356,21 +356,21 @@ def test_reshape_and_cache_flash(
dequant_nvfp4_kv_cache,
)
def dequant_nvfp4_cache_nhd(data_cache, scale_cache, global_scale):
# data_cache: [N, T, H, data_dim] NHD (contiguous inner dims)
# scale_cache: [N, T, H, scale_dim] NHD (contiguous inner dims)
# Permute to HND layout for the dequant utility.
def dequant_nvfp4_cache(data_cache, scale_cache, global_scale):
# data_cache: [B, N, H, data_dim] logical view (layout in strides)
# scale_cache: [B, N, H, scale_dim] logical view (layout in strides)
# Permute to [B, H, N, dim] for the dequant utility.
data_hnd = data_cache.permute(0, 2, 1, 3)
scale_hnd = scale_cache.permute(0, 2, 1, 3)
result_hnd = dequant_nvfp4_kv_cache(
data_hnd, scale_hnd, global_scale, head_size, block_size
)
return result_hnd.permute(0, 2, 1, 3) # back to [N, T, H, D]
return result_hnd.permute(0, 2, 1, 3) # back to [B, N, H, dim]
result_key_cache = dequant_nvfp4_cache_nhd(
result_key_cache = dequant_nvfp4_cache(
nvfp4_key_data, key_scale_cache, k_scale.item()
)
result_value_cache = dequant_nvfp4_cache_nhd(
result_value_cache = dequant_nvfp4_cache(
nvfp4_value_data, value_scale_cache, v_scale.item()
)
+108 -217
View File
@@ -5,10 +5,10 @@
import pytest
import torch
from tests.v1.attention.utils import dense_kv_cache_views
from vllm import _custom_ops as ops
from vllm.models.minimax_m3.common.indexer import (
MiniMaxM3IndexerBackend,
)
from vllm.config import CacheConfig
from vllm.config.cache import _layout_from_name
from vllm.models.minimax_m3.common.ops.index_topk import (
minimax_m3_index_decode,
minimax_m3_index_score,
@@ -19,15 +19,15 @@ from vllm.models.minimax_m3.common.ops.sparse_attn import (
minimax_m3_sparse_attn,
minimax_m3_sparse_attn_decode,
)
from vllm.models.minimax_m3.common.sparse_attention import (
MiniMaxM3SparseBackend,
MiniMaxM3SparseTritonImpl,
)
from vllm.models.minimax_m3.common.sparse_attention import MiniMaxM3SparseTritonImpl
from vllm.platforms import current_platform
from vllm.v1.attention.backends.utils import set_kv_cache_layout
from vllm.v1.kv_cache_interface import FullAttentionSpec, MLAAttentionSpec
from vllm.v1.worker.gpu.attn_utils import _reshape_kv_cache
from vllm.v1.worker.utils import AttentionGroup
from vllm.v1.attention.backends.utils import record_kv_cache_layout
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheLayout,
MLAAttentionSpec,
compute_layer_kv_cache_shape_bytes,
)
if not (current_platform.is_cuda() or current_platform.is_rocm()):
pytest.skip(
@@ -37,35 +37,46 @@ if not (current_platform.is_cuda() or current_platform.is_rocm()):
@pytest.fixture
def kv_layout(request):
"""Set the global KV cache layout for one test and restore it after."""
set_kv_cache_layout(request.param)
try:
yield request.param
finally:
set_kv_cache_layout(None)
def kv_layout(request) -> KVCacheLayout:
"""Resolve the requested layout name (legacy NHD/HND aliases included)."""
return _layout_from_name(request.param)
def _stride_order_for(backend: type[MiniMaxM3SparseBackend], ndim: int) -> tuple:
"""Mirror the allocator's stride-order resolution (identity fallback)."""
try:
stride_order = backend.get_kv_cache_stride_order()
assert len(stride_order) == ndim
except (AttributeError, NotImplementedError):
stride_order = tuple(range(ndim))
def _layer_stride_order(layout: KVCacheLayout, ndim: int) -> tuple[int, ...]:
"""Per-layer physical stride order for the given layout; the 3-dim
indexer side cache (H=1) is contiguous, so identity."""
if ndim == 3:
return (0, 1, 2)
stride_order = layout.layer_view_order
assert len(stride_order) == ndim
return stride_order
def _main_spec() -> FullAttentionSpec:
return FullAttentionSpec(
block_size=BLOCK_SIZE,
num_kv_heads=NUM_KV_HEADS,
head_size=HEAD_DIM,
head_size_v=HEAD_DIM,
dtype=DTYPE,
)
def _main_kv_logical_shape(num_pages: int) -> tuple[int, ...]:
"""Standardized per-layer logical shape [B, H, N, C] for the main cache,
derived the same way the production allocator does."""
shape_bytes = compute_layer_kv_cache_shape_bytes(_main_spec(), num_pages)
return (*shape_bytes[:-1], shape_bytes[-1] // DTYPE.itemsize)
def _allocate_main_kv_via_contract(
num_pages: int, device: torch.device | str = "cuda"
num_pages: int, layout: KVCacheLayout, device: torch.device | str = "cuda"
) -> torch.Tensor:
"""Build the main KV cache exactly as the production allocator does for the
currently active layout: allocate the physical (permuted) tensor, then
expose the inverse-permuted logical-NHD view the backend sees."""
logical_shape = MiniMaxM3SparseBackend.get_kv_cache_shape(
num_pages, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
)
stride_order = _stride_order_for(MiniMaxM3SparseBackend, len(logical_shape))
given layout: allocate the physical (permuted) tensor, then expose the
inverse-permuted logical [B, H, N, C] view the backend sees."""
logical_shape = _main_kv_logical_shape(num_pages)
stride_order = _layer_stride_order(layout, len(logical_shape))
physical_shape = tuple(logical_shape[i] for i in stride_order)
inv_order = [stride_order.index(i) for i in range(len(stride_order))]
raw = torch.randn(physical_shape, device=device, dtype=DTYPE)
@@ -817,7 +828,7 @@ def _reference_sparse_attn(
],
)
def test_prefill_sparse_attention_correctness(
kv_layout: str,
kv_layout: KVCacheLayout,
q_lens: tuple[int, ...],
kv_lens: tuple[int, ...],
):
@@ -851,7 +862,7 @@ def test_prefill_sparse_attention_correctness(
# Allocate the main KV cache through the backend layout contract so the
# physical storage matches the active layout (contiguous NHD or strided
# HND), while the kernels and reference see the logical-NHD view.
kv_cache = _allocate_main_kv_via_contract(num_pages)
kv_cache = _allocate_main_kv_via_contract(num_pages, kv_layout)
# Build sparse block indices with the same contract as the real M3 indexer:
# one forced local block, then score-selected older causal blocks.
@@ -904,38 +915,31 @@ def test_prefill_sparse_attention_correctness(
assert error.max().item() < 1.7e-2
def test_main_backend_layout_contract():
"""The main sparse backend exposes the logical-NHD shape and the
flash_attn-style stride order for each layout."""
def test_main_cache_layout_contract():
"""The standardized per-layer logical shape is [B, H, N, C] with packed
K/V content, and the legacy layout aliases resolve to the expected
per-layer stride orders."""
nb, bs, h, d = 7, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
logical = _main_kv_logical_shape(nb)
assert logical == (nb, h, bs, 2 * d)
# The old separate K/V-axis shape is no longer the logical shape.
assert logical != (nb, 2, bs, h, d)
try:
set_kv_cache_layout("HND")
assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 1, 2, 3)
set_kv_cache_layout("NHD")
assert MiniMaxM3SparseBackend.get_kv_cache_stride_order() == (0, 2, 1, 3)
finally:
set_kv_cache_layout(None)
assert KVCacheLayout.LBHNC.layer_view_order == (0, 1, 2, 3)
assert KVCacheLayout.LBNHC.layer_view_order == (0, 2, 1, 3)
for layout in ("NHD", "HND"):
try:
set_kv_cache_layout(layout)
order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
finally:
set_kv_cache_layout(None)
cache_config = CacheConfig()
record_kv_cache_layout(cache_config, "HND")
assert cache_config.get_resolved_kv_cache_layout() is KVCacheLayout.LBHNC
cache_config = CacheConfig()
record_kv_cache_layout(cache_config, "NHD")
assert cache_config.get_resolved_kv_cache_layout() is KVCacheLayout.LBNHC
for layout in (KVCacheLayout.LBNHC, KVCacheLayout.LBHNC):
order = layout.layer_view_order
# Valid permutation: no duplicates, covers every axis.
assert set(order) == set(range(len(order)))
# M3 has no cross-layer KV blocks.
with pytest.raises(NotImplementedError):
MiniMaxM3SparseBackend.get_kv_cache_stride_order(
include_num_layers_dimension=True
)
def test_aiter_sparse_pa_layout_contract(monkeypatch):
"""The shuffle-only AITER path retains separately contiguous K/V storage."""
@@ -948,75 +952,53 @@ def test_aiter_sparse_pa_layout_contract(monkeypatch):
lambda: True,
)
nb, bs, h, d = 7, BLOCK_SIZE, 1, HEAD_DIM
logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
assert logical == (nb, 2, bs, h, d)
assert order == (1, 0, 2, 3, 4)
backend = sparse_attn_mod.MiniMaxM3SparseBackend
assert KVCacheLayout.LHBNC in backend.supported_kv_cache_layouts()
physical_shape = tuple(logical[i] for i in order)
inv_order = [order.index(i) for i in range(len(order))]
raw = torch.empty(physical_shape, device="cuda", dtype=DTYPE)
logical_view = raw.permute(*inv_order)
key_cache, value_cache = logical_view.unbind(1)
nb, h = 7, 1
spec = FullAttentionSpec(
block_size=BLOCK_SIZE,
num_kv_heads=h,
head_size=HEAD_DIM,
dtype=DTYPE,
num_head_slots=2,
state_content_bytes=h * HEAD_DIM * DTYPE.itemsize,
)
raw = torch.zeros(nb * spec.page_size_bytes, dtype=torch.int8)
view = dense_kv_cache_views(raw, spec, nb, 1, KVCacheLayout.LHBNC)[0]
key_cache, value_cache = view.unbind(1)
assert key_cache.is_contiguous()
assert value_cache.is_contiguous()
def test_aiter_sparse_pa_rejects_multiple_kv_heads(monkeypatch):
"""Do not pair AITER's separated cache layout with the Triton fallback."""
import vllm.models.minimax_m3.common.sparse_attention as sparse_attn_mod
monkeypatch.setattr(sparse_attn_mod.rocm_aiter_ops, "is_enabled", lambda: True)
monkeypatch.setattr(
sparse_attn_mod.rocm_aiter_ops,
"is_shuffle_kv_cache_enabled",
lambda: True,
def test_indexer_cache_squeezes_to_contiguous_3d():
"""The indexer side cache is standardized 4D with H=1: under both layouts
the allocator's logical view stays contiguous and squeezes (as
`MiniMaxM3IndexerCache.bind_kv_cache` does) to the 3-dim
[num_blocks, block_size, head_dim] cache the kernels consume."""
nb = 5
ispec = MLAAttentionSpec(
block_size=BLOCK_SIZE, num_kv_heads=1, head_size=HEAD_DIM, dtype=DTYPE
)
shape_bytes = compute_layer_kv_cache_shape_bytes(ispec, nb)
assert shape_bytes == (nb, 1, BLOCK_SIZE, HEAD_DIM * DTYPE.itemsize)
assert _layer_stride_order(KVCacheLayout.LBNHC, 3) == (0, 1, 2)
with pytest.raises(ValueError, match="num_kv_heads == 1"):
MiniMaxM3SparseBackend.get_kv_cache_shape(7, BLOCK_SIZE, 2, HEAD_DIM)
def test_main_backend_unknown_layout_raises(monkeypatch):
"""An unrecognized layout (injected past env-var validation) is rejected."""
import vllm.models.minimax_m3.common.sparse_attention as sparse_attn_mod
monkeypatch.setattr(sparse_attn_mod, "get_kv_cache_layout", lambda: "BOGUS")
with pytest.raises(ValueError, match="Unknown cache layout format"):
MiniMaxM3SparseBackend.get_kv_cache_stride_order()
def test_indexer_backend_stride_order_is_identity():
"""The 3-dim indexer cache must not inherit the parent's 4-element stride
order; it overrides to the 3-element identity so the allocator keeps the
contiguous layout."""
assert MiniMaxM3IndexerBackend.get_kv_cache_stride_order() == (0, 1, 2)
# Cross-layer (per-layer-stacked) KV blocks are not supported.
with pytest.raises(NotImplementedError):
MiniMaxM3IndexerBackend.get_kv_cache_stride_order(
include_num_layers_dimension=True
)
# The stride order matches the 3-dim indexer shape rank.
indexer_shape = MiniMaxM3IndexerBackend.get_kv_cache_shape(
5, BLOCK_SIZE, 1, HEAD_DIM
)
assert len(indexer_shape) == 3
assert _stride_order_for(MiniMaxM3IndexerBackend, len(indexer_shape)) == (0, 1, 2)
for layout in (KVCacheLayout.LBNHC, KVCacheLayout.LBHNC):
iraw = torch.zeros(nb * ispec.page_size_bytes, dtype=torch.int8)
view = dense_kv_cache_views(iraw, ispec, nb, 1, layout, BLOCK_SIZE)[0]
assert tuple(view.shape) == (nb, 1, BLOCK_SIZE, HEAD_DIM)
indexer_cache = view.squeeze(1)
assert tuple(indexer_cache.shape) == (nb, BLOCK_SIZE, HEAD_DIM)
assert indexer_cache.is_contiguous()
def test_hnd_allocation_is_packed_head_major():
"""Under HND the backend-visible logical view is the packed head-major
physical allocation."""
nb, bs, h, d = 4, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
logical = MiniMaxM3SparseBackend.get_kv_cache_shape(nb, bs, h, d)
try:
set_kv_cache_layout("HND")
stride_order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
finally:
set_kv_cache_layout(None)
logical = _main_kv_logical_shape(nb)
stride_order = KVCacheLayout.LBHNC.layer_view_order
physical_shape = tuple(logical[i] for i in stride_order)
assert physical_shape == (nb, h, bs, 2 * d)
@@ -1042,27 +1024,13 @@ def test_main_cache_is_block_first_and_unpadded():
"""The allocator's contiguous-view branch (not the padded-strided branch)
is used for the main GQA cache: its spec is unpadded and the physical
layout keeps num_blocks as the first dimension under both layouts."""
from vllm.v1.kv_cache_interface import FullAttentionSpec
spec = FullAttentionSpec(
block_size=BLOCK_SIZE,
num_kv_heads=NUM_KV_HEADS,
head_size=HEAD_DIM,
head_size_v=HEAD_DIM,
dtype=DTYPE,
)
spec = _main_spec()
# Unpadded -> allocator uses kv_tensor.view(...) rather than as_strided().
assert spec.page_size_padded is None
logical = MiniMaxM3SparseBackend.get_kv_cache_shape(
4, BLOCK_SIZE, NUM_KV_HEADS, HEAD_DIM
)
for layout in ("NHD", "HND"):
try:
set_kv_cache_layout(layout)
order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
finally:
set_kv_cache_layout(None)
logical = _main_kv_logical_shape(4)
for layout in (KVCacheLayout.LBNHC, KVCacheLayout.LBHNC):
order = layout.layer_view_order
inv_order = [order.index(i) for i in range(len(order))]
# Physical first dim is num_blocks (block-first); required by the
# 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():
@pytest.mark.parametrize("decode_query_len", [1, 4])
@pytest.mark.parametrize("num_padded_reqs", [0, 2])
def test_decode_sparse_attention_correctness(
kv_layout: str,
kv_layout: KVCacheLayout,
seq_lens_list: tuple[int, ...],
decode_query_len: int,
num_padded_reqs: int,
@@ -1287,7 +1255,7 @@ def test_decode_sparse_attention_correctness(
q, block_table, seq_lens, topk_idx, num_pages = _build_decode_inputs(
seq_lens_list, decode_query_len, num_padded_reqs
)
kv_cache = _allocate_main_kv_via_contract(num_pages)
kv_cache = _allocate_main_kv_via_contract(num_pages, kv_layout)
actual = torch.empty_like(q)
minimax_m3_sparse_attn_decode(
@@ -1364,93 +1332,20 @@ def test_decode_wrong_layout_breaks_parity():
assert (actual.float() - expected.float()).abs().max().item() > 1.7e-2
def _make_attn_group(backend, spec):
return AttentionGroup(
backend=backend,
layer_names=["main"],
kv_cache_spec=spec,
kv_cache_group_id=0,
)
def test_main_cache_byte_identical_through_production_allocator():
"""AC-2: drive the real allocator (`_reshape_kv_cache`) for the M3 main
"""AC-2: drive the real allocator (`create_kv_cache_views`) for the M3 main
`FullAttentionSpec` under HND and assert the backend-visible view has the
same shape, stride, and storage offset as the packed-HND allocation; the
indexer `MLAAttentionSpec` allocates through the same path to its 3-dim
shape."""
same shape, stride, and storage offset as the packed-HND allocation."""
nb = 4
spec = FullAttentionSpec(
block_size=BLOCK_SIZE,
num_kv_heads=NUM_KV_HEADS,
head_size=HEAD_DIM,
head_size_v=HEAD_DIM,
dtype=DTYPE,
)
spec = _main_spec()
raw = torch.zeros(nb * spec.page_size_bytes, dtype=torch.int8)
group = _make_attn_group(MiniMaxM3SparseBackend, spec)
try:
set_kv_cache_layout("HND")
kv_caches = _reshape_kv_cache([group], {"main": raw}, "auto", [BLOCK_SIZE], {})
finally:
set_kv_cache_layout(None)
view = kv_caches["main"]
view = dense_kv_cache_views(raw, spec, nb, 1, KVCacheLayout.LBHNC, BLOCK_SIZE)[0]
oracle = raw.view(DTYPE).view((nb, NUM_KV_HEADS, BLOCK_SIZE, 2 * HEAD_DIM))
assert tuple(view.shape) == tuple(oracle.shape)
assert view.stride() == oracle.stride()
assert view.storage_offset() == oracle.storage_offset()
# Indexer cache allocates through the same path under both layouts.
ispec = MLAAttentionSpec(
block_size=BLOCK_SIZE, num_kv_heads=1, head_size=HEAD_DIM, dtype=DTYPE
)
for layout in ("NHD", "HND"):
iraw = torch.zeros(nb * ispec.page_size_bytes, dtype=torch.int8)
igroup = AttentionGroup(
backend=MiniMaxM3IndexerBackend,
layer_names=["idx"],
kv_cache_spec=ispec,
kv_cache_group_id=0,
)
try:
set_kv_cache_layout(layout)
iout = _reshape_kv_cache([igroup], {"idx": iraw}, "auto", [BLOCK_SIZE], {})
finally:
set_kv_cache_layout(None)
assert tuple(iout["idx"].shape) == (nb, BLOCK_SIZE, HEAD_DIM)
def test_indexer_inherited_stride_order_trips_allocator_assert():
"""AC-4 negative: without the indexer override, the inherited 4-element
stride order trips the allocator's `len(stride_order) == len(shape)` assert
for the 3-dim indexer shape; the `AssertionError` is NOT swallowed by the
allocator's `(AttributeError, NotImplementedError)` fallback."""
class _BrokenIndexerBackend(MiniMaxM3IndexerBackend):
# Simulate inheriting the parent's 4-element stride order.
get_kv_cache_stride_order = staticmethod(
MiniMaxM3SparseBackend.get_kv_cache_stride_order
)
nb = 4
ispec = MLAAttentionSpec(
block_size=BLOCK_SIZE, num_kv_heads=1, head_size=HEAD_DIM, dtype=DTYPE
)
iraw = torch.zeros(nb * ispec.page_size_bytes, dtype=torch.int8)
igroup = AttentionGroup(
backend=_BrokenIndexerBackend,
layer_names=["idx"],
kv_cache_spec=ispec,
kv_cache_group_id=0,
)
try:
set_kv_cache_layout("HND")
with pytest.raises(AssertionError):
_reshape_kv_cache([igroup], {"idx": iraw}, "auto", [BLOCK_SIZE], {})
finally:
set_kv_cache_layout(None)
def test_padded_main_cache_is_flagged():
"""AC-2.1 negative: the M3 main cache relies on the allocator's
@@ -1465,11 +1360,7 @@ def test_padded_main_cache_is_flagged():
)
assert inv_order[0] == 0, "main GQA cache must remain block-first"
try:
set_kv_cache_layout("HND")
stride_order = MiniMaxM3SparseBackend.get_kv_cache_stride_order()
finally:
set_kv_cache_layout(None)
stride_order = KVCacheLayout.LBHNC.layer_view_order
good = FullAttentionSpec(
block_size=BLOCK_SIZE,
@@ -1493,13 +1384,13 @@ def test_padded_main_cache_is_flagged():
@pytest.mark.parametrize("kv_layout", ["NHD", "HND"], indirect=True)
def test_reshape_and_cache_flash_write_persists(kv_layout: str):
def test_reshape_and_cache_flash_write_persists(kv_layout: KVCacheLayout):
"""AC-5 write path: the `reshape_and_cache_flash` write site now consumes
packed-content K/V split views. Writing through those views must persist
into the bound storage under both layouts."""
torch.manual_seed(0)
num_pages = 4
kv_cache = _allocate_main_kv_via_contract(num_pages)
kv_cache = _allocate_main_kv_via_contract(num_pages, kv_layout)
with torch.no_grad():
kv_cache.zero_()
+13 -8
View File
@@ -310,18 +310,23 @@ def test_aiter_mha_backend_contract():
def test_aiter_mha_backend_validates_kv_cache_block_size():
"""The backend should reject KV cache shapes that cannot be gathered
"""The backend should reject KV cache specs that cannot be gathered
correctly by the ROCm kernel."""
from vllm.v1.attention.backends.rocm_aiter_fa import AiterFlashAttentionBackend
from vllm.v1.kv_cache_interface import FullAttentionSpec
assert AiterFlashAttentionBackend.get_kv_cache_shape(8, 16, 8, 128) == (
8,
8,
16,
256,
)
with pytest.raises(ValueError, match="Block size must be a multiple of 16"):
AiterFlashAttentionBackend.get_kv_cache_shape(8, 15, 8, 128)
AiterFlashAttentionBackend.customize_spec(
FullAttentionSpec(
block_size=15, num_kv_heads=8, head_size=128, dtype=torch.bfloat16
)
)
# block_size == 1 is the per-token page-size probe and must pass through.
AiterFlashAttentionBackend.customize_spec(
FullAttentionSpec(
block_size=1, num_kv_heads=8, head_size=128, dtype=torch.bfloat16
)
)
def test_aiter_mha_backend_supports_compute_capability_matches_mi3xx_probe():
@@ -11,6 +11,7 @@ import pytest
import torch
from vllm.platforms import current_platform
from vllm.v1.kv_cache_interface import KVCacheLayout
if current_platform.is_rocm():
pytest.skip(
@@ -34,51 +35,40 @@ def to_float8(x, dtype=None):
return x_scl_sat.to(dtype), scale.float().reciprocal()
def make_contiguous_kv_cache(num_blocks, num_kv_heads, block_size, head_size):
"""Create a standard contiguous fp8 KV cache (HND layout)."""
raw = torch.randn(
num_blocks,
2,
num_kv_heads,
block_size,
head_size,
dtype=torch.bfloat16,
device="cuda",
)
kv_cache, scale = to_float8(raw)
return kv_cache, scale
def make_cross_layer_kv_cache(
def make_random_kv_cache(
num_blocks,
num_kv_heads,
block_size,
head_size,
num_layers=4,
layout=KVCacheLayout.LBHNC,
num_layers=None,
):
"""
Create a non-contiguous per-layer view mimicking cross-layer allocation.
Create a random fp8 KV cache mimicking the production allocation.
Physical layout: (num_blocks, 2, num_kv_heads, num_layers, block_size, head_size)
Physical layout: [L, B, H, N, 2 * hs] permuted by ``layout.stride_order``
Returned view: (num_blocks, 2, num_kv_heads, block_size, head_size)
with non-contiguous strides on dims 0, 1, 2 (they skip over num_layers).
with non-contiguous strides under cross-layer layouts (they skip over
num_layers), matching the actual forward path.
"""
raw = torch.randn(
logical_4d = (num_blocks, num_kv_heads, block_size, 2 * head_size)
if num_layers is None:
num_layers = 1 if layout.is_layer_compact else 2
logical_5d = (num_layers, *logical_4d)
physical_5d = tuple(logical_5d[i] for i in layout.stride_order)
inv_order = [layout.stride_order.index(i) for i in range(5)]
raw_phys = torch.randn(*physical_5d, dtype=torch.bfloat16, device="cuda")
fp8_phys, scale = to_float8(raw_phys)
fp8_4d = fp8_phys.permute(*inv_order)[0]
kv_5d = fp8_4d.view(
num_blocks,
2,
num_kv_heads,
num_layers,
block_size,
2,
head_size,
dtype=torch.bfloat16,
device="cuda",
)
fp8_full, scale = to_float8(raw)
layer_view = fp8_full[:, :, :, 0, :, :]
assert not layer_view.is_contiguous(), (
f"Expected non-contiguous view, got strides {layer_view.stride()}"
)
return layer_view, scale
).permute(0, 3, 1, 2, 4)
return kv_5d, scale
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):
@pytest.mark.parametrize("block_size", [16, 32])
@pytest.mark.parametrize("batch_size", [1, 4])
@pytest.mark.parametrize("num_pages_per_seq", [3, 8])
@pytest.mark.parametrize("contiguous", [True, False])
@pytest.mark.parametrize("layout", list(KVCacheLayout))
@torch.inference_mode()
def test_trtllm_kvfp8_dequant(
num_kv_heads: int,
@@ -122,7 +112,7 @@ def test_trtllm_kvfp8_dequant(
block_size: int,
batch_size: int,
num_pages_per_seq: int,
contiguous: bool,
layout: KVCacheLayout,
):
from vllm.v1.attention.backends.flashinfer import (
trtllm_prefill_attn_kvfp8_dequant,
@@ -130,20 +120,13 @@ def test_trtllm_kvfp8_dequant(
torch.set_default_device("cuda")
if contiguous:
kv_cache, scale = make_contiguous_kv_cache(
NUM_BLOCKS,
num_kv_heads,
block_size,
head_size,
)
else:
kv_cache, scale = make_cross_layer_kv_cache(
NUM_BLOCKS,
num_kv_heads,
block_size,
head_size,
)
kv_cache, scale = make_random_kv_cache(
NUM_BLOCKS,
num_kv_heads,
block_size,
head_size,
layout=layout,
)
k_scale = scale.clone()
v_scale = scale.clone()
@@ -187,7 +170,7 @@ def test_block_tables_with_zero_pages():
torch.set_default_device("cuda")
num_kv_heads, block_size, head_size = 8, 16, 64
kv_cache, scale = make_contiguous_kv_cache(
kv_cache, scale = make_random_kv_cache(
NUM_BLOCKS,
num_kv_heads,
block_size,
@@ -234,7 +217,7 @@ def test_all_zero_block_tables():
torch.set_default_device("cuda")
num_kv_heads, block_size, head_size = 4, 16, 64
kv_cache, scale = make_contiguous_kv_cache(
kv_cache, scale = make_random_kv_cache(
NUM_BLOCKS,
num_kv_heads,
block_size,
@@ -266,7 +249,7 @@ def test_different_k_v_scales():
torch.set_default_device("cuda")
num_kv_heads, block_size, head_size = 8, 16, 64
kv_cache, _ = make_contiguous_kv_cache(
kv_cache, _ = make_random_kv_cache(
NUM_BLOCKS,
num_kv_heads,
block_size,
@@ -299,7 +282,7 @@ def test_single_page_per_seq():
torch.set_default_device("cuda")
num_kv_heads, block_size, head_size = 8, 16, 128
kv_cache, scale = make_contiguous_kv_cache(
kv_cache, scale = make_random_kv_cache(
NUM_BLOCKS,
num_kv_heads,
block_size,
@@ -332,7 +315,7 @@ def test_large_page_indices():
num_kv_heads, block_size, head_size = 8, 16, 128
large_num_blocks = 32768
kv_cache, scale = make_contiguous_kv_cache(
kv_cache, scale = make_random_kv_cache(
large_num_blocks,
num_kv_heads,
block_size,
@@ -369,7 +352,7 @@ def test_large_block_size():
torch.set_default_device("cuda")
num_kv_heads, block_size, head_size = 4, 64, 128
kv_cache, scale = make_contiguous_kv_cache(
kv_cache, scale = make_random_kv_cache(
NUM_BLOCKS,
num_kv_heads,
block_size,
@@ -401,7 +384,7 @@ def test_large_block_size():
def test_cross_layer_many_layers():
"""
Non-contiguous with 36 layers -- matches real gpt-oss-120b.
Strides are far from contiguous (factor of 36 in the gaps).
Cross-layer (BLHNC) strides are far from contiguous (factor of 36 in the gaps).
"""
from vllm.v1.attention.backends.flashinfer import (
trtllm_prefill_attn_kvfp8_dequant,
@@ -409,14 +392,14 @@ def test_cross_layer_many_layers():
torch.set_default_device("cuda")
num_kv_heads, block_size, head_size = 8, 16, 64
num_layers = 36
kv_cache, scale = make_cross_layer_kv_cache(
kv_cache, scale = make_random_kv_cache(
NUM_BLOCKS,
num_kv_heads,
block_size,
head_size,
num_layers=num_layers,
layout=KVCacheLayout.BLHNC,
num_layers=36,
)
k_scale = v_scale = scale.clone()
@@ -1,5 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from fractions import Fraction
import pytest
import pytest_asyncio
from mistral_common.protocol.transcription.request import (
@@ -75,7 +77,8 @@ def assert_encoder_kv_cache_spec(engine: LLM) -> None:
assert audio_config.block_pool_size == 4
assert isinstance(spec, SlidingWindowSpec)
assert spec.block_size == 16
assert spec.num_kv_heads == 128
assert spec.num_kv_heads == 32
assert spec.tokens_per_state == Fraction(1, 4)
# cdiv(750, 4) == 188 pooled tokens cover the model's window; the extra
# +1 is an eviction margin (see whisper_causal.py get_kv_cache_spec).
assert spec.sliding_window == cdiv(750, 4) + 1 == 189
+7
View File
@@ -8,6 +8,7 @@ import pytest
from vllm import LLM
from vllm.utils.mem_constants import GiB_bytes
from vllm.v1.attention.backends.utils import resolve_kv_cache_layout
from vllm.v1.core.kv_cache_utils import (
generate_scheduler_kv_cache_config,
get_kv_cache_configs,
@@ -82,6 +83,12 @@ def can_initialize(
# Avoid calling model.forward()
def _initialize_kv_caches_v1(self, vllm_config):
kv_cache_specs = self.model_executor.get_kv_cache_specs()
layout = resolve_kv_cache_layout(
vllm_config,
self.model_executor.get_supported_kv_cache_layouts(),
[spec for worker_specs in kv_cache_specs for spec in worker_specs.values()],
)
self.model_executor.set_kv_cache_layout(layout.name)
kv_cache_configs = get_kv_cache_configs(
vllm_config,
kv_cache_specs,
+1
View File
@@ -294,6 +294,7 @@ class TestTurboQuantKVCacheSpec:
head_size_v=128,
num_kv_heads=4,
sliding_window=None,
get_attn_backend=lambda: TurboQuantAttentionBackend,
)
vllm_config = SimpleNamespace(cache_config=SimpleNamespace(block_size=32))
-10
View File
@@ -38,11 +38,6 @@ class CustomAttentionBackend(AttentionBackend):
"""Mock builder class."""
return None
@staticmethod
def get_required_kv_cache_layout():
"""Mock KV cache layout."""
return None
class CustomMambaAttentionImpl(AttentionImpl):
"""Mock custom mamba attention implementation for testing."""
@@ -71,11 +66,6 @@ class CustomMambaAttentionBackend(AttentionBackend):
"""Mock builder class."""
return None
@staticmethod
def get_required_kv_cache_layout():
"""Mock KV cache layout."""
return None
def test_custom_is_not_alias_of_any_backend():
# Get all members of AttentionBackendEnum
+168 -101
View File
@@ -31,10 +31,7 @@ from vllm.v1.attention.backend import (
CommonAttentionMetadata,
)
from vllm.v1.attention.backends.registry import AttentionBackendEnum
from vllm.v1.attention.backends.utils import (
set_kv_cache_layout,
)
from vllm.v1.kv_cache_interface import FullAttentionSpec
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheLayout
BACKENDS_TO_TEST = [
AttentionBackendEnum.FLASH_ATTN,
@@ -44,6 +41,14 @@ BACKENDS_TO_TEST = [
"FLEX_ATTENTION_SLOW",
]
def _actual_backend(backend) -> AttentionBackendEnum:
"""Resolve pseudo-backends (FLEX_ATTENTION_SLOW) to their real enum."""
if backend == "FLEX_ATTENTION_SLOW":
return AttentionBackendEnum.FLEX_ATTENTION
return backend
DEVICE_TYPE = current_platform.device_type
# Use the platform's preferred FP8 type so the stored cache matches what the
@@ -118,6 +123,7 @@ def create_and_prepopulate_kv_cache(
device: torch.device,
num_blocks: int,
common_attn_metadata: CommonAttentionMetadata,
layout: KVCacheLayout,
randomize_blocks: bool = True,
kv_cache_dtype: str = "auto",
) -> torch.Tensor:
@@ -126,19 +132,22 @@ def create_and_prepopulate_kv_cache(
Args:
k_contexts: List of key context tensors for each sequence
v_contexts: List of value context tensors for each sequence
seq_lens: List of sequence lengths
block_size: Size of each block
num_kv_heads: Number of KV heads
head_size: Size of each head
dtype: Data type for the cache
device: Device to create the cache on
num_blocks: Total number of blocks in the cache
block_table: Block table tensor to populate
common_attn_metadata: Provides seq lens, block table and slot mapping
layout: Physical layout to allocate in; the cache is returned as the
logical ``[B, H, N, C]`` view (as ``create_kv_cache_views`` does)
randomize_blocks: Whether to randomly permute blocks
or use sequential order
kv_cache_dtype: Cache dtype string; fp8 caches use fp8 storage
Returns:
Tuple of (kv_cache, updated_block_table)
A 4D tensor in logical ``(num_blocks, num_kv_heads, block_size,
2 * head_size)`` order with strides determined by ``layout``.
"""
batch_size = len(k_contexts)
seq_lens = common_attn_metadata.seq_lens.cpu()
@@ -155,26 +164,33 @@ def create_and_prepopulate_kv_cache(
fp8_kv_cache = is_quantized_kv_cache(kv_cache_dtype)
storage_dtype = FP8_KV_CACHE_DTYPES[kv_cache_dtype] if fp8_kv_cache else dtype
kv_cache = torch.zeros(
num_blocks,
block_size,
num_kv_heads,
2 * head_size,
dtype=storage_dtype,
device=device,
)
kv_cache_flat = kv_cache.view(-1, num_kv_heads, 2 * head_size)
# Logical 5D shape is always [L, B, H, N, C]. Cross-layer layouts need
# at least two layers to reproduce the inter-layer gaps in a layer view.
logical_4d = (num_blocks, num_kv_heads, block_size, 2 * head_size)
num_layers = 1 if layout.is_layer_compact else 2
logical_5d = (num_layers, *logical_4d)
physical_5d = tuple(logical_5d[i] for i in layout.stride_order)
inv_order = [layout.stride_order.index(i) for i in range(5)]
# Populate the cache with the context tokens
kv_cache_physical = torch.zeros(physical_5d, dtype=storage_dtype, device=device)
# Permute to logical [L, B, H, N, C], then select a layer. This mirrors
# create_kv_cache_views and retains cross-layer strides in the 4D view.
kv_cache = kv_cache_physical.permute(*inv_order)[0]
# Write context tokens into the cache via the logical view:
# kv_cache[block, :, token_in_block, :] routes correctly regardless
# of physical layout.
# Start from block_id=1 since block_id=0 is considered the null block
start_block_idx = 1
for i in range(batch_size):
k_context, v_context = k_contexts[i], v_contexts[i]
start = start_block_idx * block_size
end = start + k_context.shape[0]
kv_cache_flat[start:end, :, :head_size] = k_context
kv_cache_flat[start:end, :, head_size:] = v_context
t = torch.arange(k_context.shape[0], device=device)
blk = start_block_idx + t // block_size
off = t % block_size
# Advanced indexing on (blk, off) yields [T, H, hs] destinations.
# index_put is dtype-strict; cast like the scalar path would.
kv_cache[blk, :, off, :head_size] = k_context.to(kv_cache.dtype)
kv_cache[blk, :, off, head_size:] = v_context.to(kv_cache.dtype)
# Stay block aligned and allocate enough blocks for the new tokens
start_block_idx += cdiv(int(seq_lens[i]), block_size)
@@ -214,9 +230,6 @@ def create_and_prepopulate_kv_cache(
i, block_indices
] * block_size + token_inter_block_offsets.to(device)
# Transpose to logical (num_blocks, num_kv_heads, block_size, 2*hs)
kv_cache = kv_cache.transpose(1, 2).contiguous()
if fp8_kv_cache:
kv_cache = kv_cache.view(torch.uint8)
@@ -236,6 +249,22 @@ class MockAttentionLayer:
self._v_scale_float = 1.0
def _clone_kv_cache_in_layout(
kv_cache: torch.Tensor, layout: KVCacheLayout
) -> torch.Tensor:
"""Copy a logical [B, H, N, C] cache into a fresh allocation in `layout`."""
logical_5d = (1, *kv_cache.shape)
physical = torch.zeros(
tuple(logical_5d[i] for i in layout.stride_order),
dtype=kv_cache.dtype,
device=kv_cache.device,
)
inv_order = [layout.stride_order.index(i) for i in range(5)]
view = physical.permute(*inv_order)[0]
view.copy_(kv_cache)
return view
def run_attention_backend(
backend: AttentionBackendEnum,
kv_cache_spec: FullAttentionSpec,
@@ -254,18 +283,17 @@ def run_attention_backend(
) -> torch.Tensor:
"""Run attention computation using the specified backend's AttentionImpl."""
# Handle special case for FLEX_ATTENTION_SLOW
actual_backend = backend
use_direct_block_mask = is_torch_equal_or_newer("2.9.0.dev0")
use_direct_block_mask = not current_platform.is_rocm() and is_torch_equal_or_newer(
"2.9.0.dev0"
)
if backend == "FLEX_ATTENTION_SLOW":
actual_backend = AttentionBackendEnum.FLEX_ATTENTION
use_direct_block_mask = False
backend = _actual_backend(backend)
builder_cls, impl_cls = try_get_attention_backend(actual_backend)
builder_cls, impl_cls = try_get_attention_backend(backend)
# Mock flashinfer's get_per_layer_parameters if needed
if actual_backend == AttentionBackendEnum.FLASHINFER:
if backend == AttentionBackendEnum.FLASHINFER:
import unittest.mock
from vllm.v1.attention.backends.utils import PerLayerParameters
@@ -295,7 +323,7 @@ def run_attention_backend(
else:
# Build metadata
builder = builder_cls(kv_cache_spec, layer_names, vllm_config, device)
if actual_backend == AttentionBackendEnum.FLEX_ATTENTION:
if backend == AttentionBackendEnum.FLEX_ATTENTION:
builder.direct_build = use_direct_block_mask
attn_metadata = builder.build(
common_prefix_len=0,
@@ -311,17 +339,19 @@ def run_attention_backend(
)
head_size = vllm_config.model_config.get_head_size()
scale = 1.0 / (head_size**0.5)
impl = impl_cls(
num_heads=num_heads,
head_size=head_size,
scale=scale,
num_kv_heads=num_kv_heads,
alibi_slopes=None,
sliding_window=sliding_window,
attn_type=attn_type,
kv_cache_dtype=kv_cache_dtype,
**({"sinks": sinks} if sinks is not None else {}),
)
# Impls capture the current vllm config at construction, as in model loading.
with set_current_vllm_config(vllm_config):
impl = impl_cls(
num_heads=num_heads,
head_size=head_size,
scale=scale,
num_kv_heads=num_kv_heads,
alibi_slopes=None,
sliding_window=sliding_window,
attn_type=attn_type,
kv_cache_dtype=kv_cache_dtype,
**({"sinks": sinks} if sinks is not None else {}),
)
# Create mock layer and output buffer
mock_layer = MockAttentionLayer(device)
@@ -333,7 +363,7 @@ def run_attention_backend(
# Run forward pass
# NOTE: The query, key, and value are already shaped correctly
# in the calling test function.
if not try_backend_includes_kv_cache_update(actual_backend):
if not try_backend_includes_kv_cache_update(backend):
impl.do_kv_cache_update(
mock_layer, key, value, kv_cache, attn_metadata.slot_mapping
)
@@ -347,7 +377,7 @@ def run_attention_backend(
def _test_backend_correctness(
batch_spec: BatchSpec,
model: str,
backend_to_test: list[AttentionBackendEnum | str],
backend_to_test: list[AttentionBackendEnum],
mask_mod,
*,
causal: bool = True,
@@ -358,6 +388,7 @@ def _test_backend_correctness(
tensor_parallel_size: int = 1,
kv_cache_dtype: str = "auto",
use_sinks: bool = False,
layout: KVCacheLayout | None = None,
):
"""
Test that all backends produce similar outputs to a reference implementation
@@ -535,6 +566,22 @@ def _test_backend_correctness(
common_attn_metadata.causal = causal
# 3. Simulate Paged KV Cache and a realistic slot_mapping
# Mirror selector-time resolution locally (caller-requested layout, then
# any backend-required layout, then the default chain).
declared = [
supported
for backend in backend_to_test
if (
supported := _actual_backend(backend)
.get_class()
.supported_kv_cache_layouts()
)
is not None
]
if layout is None:
# Mirror the resolver: the shared cache uses the declared sets' preferred
# layout; backends that don't support it get a per-backend copy below.
layout = min(declared, key=len)[0] if declared else KVCacheLayout.LBNHC
kv_cache = create_and_prepopulate_kv_cache(
k_contexts=k_contexts,
v_contexts=v_contexts,
@@ -545,6 +592,7 @@ def _test_backend_correctness(
device=device,
num_blocks=vllm_config.cache_config.num_gpu_blocks or 1000,
common_attn_metadata=common_attn_metadata,
layout=layout,
randomize_blocks=True,
kv_cache_dtype=kv_cache_dtype,
)
@@ -553,62 +601,44 @@ def _test_backend_correctness(
# Note: flex_attention has known Triton kernel compatibility issues
# with test infrastructures
for backend_name in backend_to_test:
reset_kv_cache_layout = False
# Resolve backend class for both enum and string names.
actual_backend = backend_name
if backend_name == "FLEX_ATTENTION_SLOW":
actual_backend = AttentionBackendEnum.FLEX_ATTENTION
if hasattr(actual_backend, "get_class"):
backend_cls = actual_backend.get_class()
else:
backend_cls = None
backend_cls = _actual_backend(backend_name).get_class()
if is_quantized_kv_cache(kv_cache_dtype) and (
backend_cls is None
or not backend_cls.supports_kv_cache_dtype(kv_cache_dtype)
not backend_cls.supports_kv_cache_dtype(kv_cache_dtype)
):
continue
if backend_name == AttentionBackendEnum.FLASHINFER:
set_kv_cache_layout("HND")
reset_kv_cache_layout = True
kv_cache_for_backend = kv_cache
if backend_cls is not None:
try:
stride_order = backend_cls.get_kv_cache_stride_order()
except (AttributeError, NotImplementedError):
stride_order = tuple(range(kv_cache.ndim))
if stride_order != tuple(range(kv_cache.ndim)):
# Apply stride order like runtime does in
# _reshape_kv_cache (attn_utils.py:182-210): permute to physical
# layout, make contiguous, then permute to logical layout.
inv_order = [stride_order.index(i) for i in range(len(stride_order))]
kv_cache_for_backend = (
kv_cache.permute(*stride_order).contiguous().permute(*inv_order)
)
backend_layout = layout
try:
backend_output = run_attention_backend(
backend_name,
kv_cache_spec,
["placeholder"],
vllm_config,
device,
common_attn_metadata,
query_vllm,
key_vllm,
value_vllm,
kv_cache_for_backend,
sliding_window=sliding_window,
attn_type=attn_type,
kv_cache_dtype=kv_cache_dtype,
sinks=sinks,
)
finally:
if reset_kv_cache_layout:
set_kv_cache_layout(None)
backend_supported = backend_cls.supported_kv_cache_layouts()
if backend_supported is not None and backend_layout not in backend_supported:
# Production resolution never pairs this backend with the shared layout
# (e.g. flex is LBNHC-only next to FlashInfer's LBHNC); give it a copy
# of the cache in a layout it supports.
backend_layout = backend_supported[0]
kv_cache_for_backend = _clone_kv_cache_in_layout(kv_cache, backend_layout)
# FlashInfer reads the layout at plan time; set it to match
# the physical order of the test cache.
vllm_config.cache_config.kv_cache_layout = backend_layout.name
backend_output = run_attention_backend(
backend_name,
kv_cache_spec,
["placeholder"],
vllm_config,
device,
common_attn_metadata,
query_vllm,
key_vllm,
value_vllm,
kv_cache_for_backend,
sliding_window=sliding_window,
attn_type=attn_type,
kv_cache_dtype=kv_cache_dtype,
sinks=sinks,
)
# Check shape and dtype consistency
assert backend_output.shape == sdpa_output.shape, (
@@ -637,6 +667,38 @@ def _test_backend_correctness(
)
@pytest.mark.parametrize("layout", ["BLHNC", "BHLNC"])
@pytest.mark.parametrize("batch_spec_name", ["small_decode", "small_prefill"])
@pytest.mark.parametrize("kv_cache_dtype", ["auto", "fp8"])
def test_flashinfer_cross_layer_layout(
default_vllm_config,
layout: str,
batch_spec_name: str,
kv_cache_dtype: str,
):
if AttentionBackendEnum.FLASHINFER not in BACKENDS_TO_TEST:
pytest.skip("FlashInfer is not installed")
def causal_mask_mod(
b: torch.Tensor,
h: torch.Tensor,
q_idx: torch.Tensor,
kv_idx: torch.Tensor,
*,
context_len: int,
):
return (q_idx + context_len) >= kv_idx
_test_backend_correctness(
batch_spec=BATCH_SPECS[batch_spec_name],
model="meta-llama/Meta-Llama-3-8B",
backend_to_test=[AttentionBackendEnum.FLASHINFER],
mask_mod=causal_mask_mod,
kv_cache_dtype=kv_cache_dtype,
layout=KVCacheLayout[layout],
)
@pytest.mark.parametrize(
"batch_spec_name",
[
@@ -984,14 +1046,12 @@ if current_platform.is_rocm():
SLIDING_WINDOW_BACKENDS_TO_TEST = [
AttentionBackendEnum.FLEX_ATTENTION,
AttentionBackendEnum.TRITON_ATTN,
"FLEX_ATTENTION_SLOW",
]
else:
SLIDING_WINDOW_BACKENDS_TO_TEST = [
AttentionBackendEnum.FLASH_ATTN,
AttentionBackendEnum.FLEX_ATTENTION,
AttentionBackendEnum.TRITON_ATTN,
"FLEX_ATTENTION_SLOW",
]
@@ -1009,7 +1069,10 @@ else:
@pytest.mark.parametrize("model", ["microsoft/Phi-tiny-MoE-instruct"])
@pytest.mark.parametrize("tensor_parallel_size", [1, 2, 4])
def test_sliding_window_backend_correctness(
default_vllm_config, batch_spec_name: str, model: str, tensor_parallel_size: int
default_vllm_config,
batch_spec_name: str,
model: str,
tensor_parallel_size: int,
):
"""Test backend's correctness with sliding window attention."""
@@ -1071,7 +1134,10 @@ def test_sliding_window_backend_correctness(
@pytest.mark.parametrize("model", ["google/embeddinggemma-300m"])
@pytest.mark.parametrize("tensor_parallel_size", [1, 2])
def test_sliding_window_encoder_backend_correctness(
default_vllm_config, batch_spec_name: str, model: str, tensor_parallel_size: int
default_vllm_config,
batch_spec_name: str,
model: str,
tensor_parallel_size: int,
):
"""Test backend's correctness with sliding window attention."""
@@ -1107,7 +1173,6 @@ def test_sliding_window_encoder_backend_correctness(
NON_CAUSAL_BACKENDS_TO_TEST = [
AttentionBackendEnum.FLASH_ATTN,
AttentionBackendEnum.FLEX_ATTENTION,
"FLEX_ATTENTION_SLOW",
]
if current_platform.is_rocm():
@@ -1128,7 +1193,9 @@ if current_platform.is_rocm():
)
@pytest.mark.parametrize("model", ["meta-llama/Meta-Llama-3-8B"])
def test_non_causal_backend_correctness(
default_vllm_config, batch_spec_name: str, model: str
default_vllm_config,
batch_spec_name: str,
model: str,
):
"""Test backend's correctness with non-causal (bidirectional) decoder
attention, as used by DFlash speculative decoding."""
+6 -53
View File
@@ -1,6 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Attention metadata geometry must come from the builder's own group."""
"""Scheduler metadata sizes a scratchpad from the query head count, so it must
come from the builder's own group: the model-wide ``get_num_attention_heads()``
is wrong for models that vary it per layer (e.g. Laguna), and too small a
scratchpad is indexed past its end.
"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -13,9 +17,8 @@ from vllm.v1.attention.backends.cpu_attn import (
CPUAttentionBackendImpl,
CPUAttentionMetadataBuilder,
)
from vllm.v1.attention.backends.flash_attn import FlashAttentionMetadataBuilder
requires_cpu = pytest.mark.skipif(
pytestmark = pytest.mark.skipif(
not current_platform.is_cpu(), reason="CPU attention backend"
)
@@ -66,7 +69,6 @@ def _build(layer_num_heads: list[int]) -> CPUAttentionMetadataBuilder:
)
@requires_cpu
@pytest.mark.parametrize("group_num_heads", [MODEL_WIDE_NUM_HEADS, 64, 16])
def test_num_heads_comes_from_the_group(group_num_heads):
"""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):
assert builder.num_heads == group_num_heads
@requires_cpu
def test_mixed_head_counts_in_one_group_are_rejected():
"""Grouping guarantees uniformity; a mixed group means that broke."""
with pytest.raises(AssertionError, match="share num_heads"):
_build([MODEL_WIDE_NUM_HEADS, 64])
def test_flash_attention_geometry_comes_from_the_group():
"""FA3 must use group geometry for layers without an ``impl`` wrapper."""
layers = {f"layer_{i}": SimpleNamespace(num_heads=16) for i in range(2)}
vllm_config = MagicMock()
vllm_config.model_config.get_num_attention_heads.return_value = MODEL_WIDE_NUM_HEADS
vllm_config.model_config.get_num_kv_heads.return_value = NUM_KV_HEADS
vllm_config.model_config.get_head_size.return_value = 128
vllm_config.model_config.rswa_window = None
vllm_config.model_config.is_mm_prefix_lm = False
vllm_config.parallel_config.cp_kv_cache_interleave_size = 1
vllm_config.compilation_config.cudagraph_mode.has_full_cudagraphs.return_value = (
False
)
vllm_config.compilation_config.max_cudagraph_capture_size = None
kv_cache_spec = SimpleNamespace(
block_size=16,
num_kv_heads=2,
head_size=64,
dtype=torch.bfloat16,
)
with (
patch(
"vllm.v1.attention.backends.utils.get_layers_from_vllm_config",
return_value=layers,
),
patch(
"vllm.distributed.parallel_state.get_dcp_group",
side_effect=AssertionError,
),
patch(
"vllm.v1.attention.backends.flash_attn.get_flash_attn_version",
return_value=3,
),
):
builder = FlashAttentionMetadataBuilder(
kv_cache_spec=kv_cache_spec,
layer_names=list(layers),
vllm_config=vllm_config,
device=torch.device("cpu"),
)
assert builder.aot_schedule
assert builder.num_heads_q == 16
assert builder.num_heads_kv == 2
assert builder.headdim == 64
@@ -34,19 +34,19 @@ def test_indexer_warmup_normalizes_zero_compress_ratios():
@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
def test_indexer_builder_deepseek_v4_compressed_slot_mapping_uses_storage_block_size():
def test_indexer_builder_deepseek_v4_compressed_slot_mapping_uses_num_states():
"""Regression test: DeepseekV4 compression path must compute slot_mapping from
compressed positions, not reuse the uncompressed common metadata mapping.
"""
device = torch.device("cuda")
# storage_block_size = block_size // compress_ratio = 256 // 4 = 64
# num_states = block_size // tokens_per_state = 256 // 4 = 64
kv_cache_spec = MLAAttentionSpec(
block_size=256,
num_kv_heads=1,
head_size=128,
dtype=torch.bfloat16,
compress_ratio=4,
tokens_per_state=4,
)
vllm_config = create_vllm_config(max_model_len=1024)
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_
valid_slots = md.slot_mapping[md.slot_mapping >= 0]
assert valid_slots.numel() == 10 # 40 tokens / compress_ratio 4
storage_bs = kv_cache_spec.storage_block_size # 64
storage_bs = kv_cache_spec.num_states # 64
# Compressed positions 60..63 land in block 5, positions 64..69 in block 7.
expected = torch.tensor(
[
+21 -4
View File
@@ -250,6 +250,15 @@ def _prefill_backend_dimension_params():
)
)
)
elif (
current_platform.is_rocm()
and prefill_backend == MLAPrefillBackendEnum.FLASH_ATTN
):
# Pre-existing on main: flash-attn MLA prefill returns wrong
# outputs and faults on ROCm, so it fails on the base commit too.
marks.append(
pytest.mark.skip(reason="FLASH_ATTN MLA prefill broken on ROCm")
)
params.append(
pytest.param(
prefill_backend,
@@ -392,7 +401,7 @@ def create_and_prepopulate_kv_cache(
kv_entry_size = head_size
kv_cache = torch.zeros(
num_blocks, block_size, kv_entry_size, dtype=torch.uint8, device=device
num_blocks, 1, block_size, kv_entry_size, dtype=torch.uint8, device=device
)
scale_tensor = (
scale
@@ -401,9 +410,9 @@ def create_and_prepopulate_kv_cache(
)
scale_tensor = scale_tensor.to(device=device, dtype=torch.float32)
else:
# Create MLA KV cache: (num_blocks, block_size, head_size)
# Create MLA KV cache: (num_blocks, num_heads=1, block_size, head_size)
kv_cache = torch.zeros(
num_blocks, block_size, head_size, dtype=dtype, device=device
num_blocks, 1, block_size, head_size, dtype=dtype, device=device
)
kv_cache_flat = kv_cache.view(-1, head_size)
@@ -424,7 +433,7 @@ def create_and_prepopulate_kv_cache(
ops.concat_and_cache_mla(
kv_c_context,
k_pe_context.squeeze(1),
kv_cache,
kv_cache.squeeze(1),
slots,
kv_cache_dtype=kv_cache_dtype,
scale=scale_tensor,
@@ -543,6 +552,10 @@ class MockSparseMLAAttentionLayer:
kv_cache_dtype = getattr(self.impl, "kv_cache_dtype", "auto")
fp8_attention = kv_cache_dtype.startswith("fp8")
# Impls see the bind-time-squeezed [B, N, C] cache; mirror bind_kv_cache.
if kv_cache.ndim == 4:
kv_cache = kv_cache.squeeze(1)
# Write to KV cache
if kv_cache.numel() > 0:
ops.concat_and_cache_mla(
@@ -678,6 +691,10 @@ class MockMLAAttentionLayer(MLAAttention):
output: torch.Tensor,
) -> torch.Tensor:
"""Replicates MLAAttention.forward_impl logic for testing."""
# Impls see the bind-time-squeezed [B, N, C] cache; mirror bind_kv_cache.
if kv_cache.ndim == 4:
kv_cache = kv_cache.squeeze(1)
# Write to KV cache
kv_cache_dtype = getattr(self.impl, "kv_cache_dtype", "auto")
fp8_attention = kv_cache_dtype.startswith("fp8")
+5 -1
View File
@@ -20,7 +20,10 @@ import pytest
import torch
from vllm.platforms import current_platform
from vllm.v1.attention.backends.utils import fill_mm_prefix_query_ranges
from vllm.v1.attention.backends.utils import (
fill_mm_prefix_query_ranges,
)
from vllm.v1.kv_cache_interface import KVCacheLayout
def _fa4_available() -> bool:
@@ -585,6 +588,7 @@ def test_mm_prefix_kv_cache_path(head_size: int):
device=DEVICE,
num_blocks=2048,
common_attn_metadata=common,
layout=KVCacheLayout.LBNHC,
randomize_blocks=True,
)
@@ -813,7 +813,7 @@ def test_flashmla_forward_bf16_kv_slices_req_id_to_mqa_tokens():
assert attn_metadata.req_id_per_token.shape[0] == num_batch_tokens
q = torch.zeros(num_mqa_tokens, 4, 576, dtype=torch.bfloat16, device=device)
kv_cache = torch.zeros(40 * block_size, 576, dtype=torch.bfloat16, device=device)
kv_cache = torch.zeros(40, block_size, 576, dtype=torch.bfloat16, device=device)
topk_indices = torch.randint(
0,
block_size * 10,
@@ -1157,7 +1157,8 @@ def test_sparse_backend_prefill_correctness(
q=query_cat,
kv_c_normed=kv_c_cat,
k_pe=k_pe_cat,
kv_c_and_k_pe_cache=kv_cache,
# Impls see the bind-time-squeezed [B, N, C] cache; mirror bind_kv_cache.
kv_c_and_k_pe_cache=kv_cache.squeeze(1),
attn_metadata=metadata,
k_scale=torch.tensor(1.0, device=device),
output=out_buffer,
@@ -9,6 +9,7 @@ import pytest
import torch
from torch.nn.attention.flex_attention import create_block_mask, flex_attention
from tests.v1.attention.test_attention_backends import create_and_prepopulate_kv_cache
from tests.v1.attention.utils import (
BatchSpec,
create_common_attn_metadata,
@@ -16,14 +17,9 @@ from tests.v1.attention.utils import (
)
from vllm.config import set_current_vllm_config
from vllm.platforms import current_platform
from vllm.utils.math_utils import cdiv
from vllm.utils.torch_utils import nvfp4_kv_cache_full_dim, set_random_seed
from vllm.v1.attention.backends.utils import (
PerLayerParameters,
get_kv_cache_layout,
set_kv_cache_layout,
)
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVQuantMode
from vllm.v1.attention.backends.utils import PerLayerParameters
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheLayout, KVQuantMode
if not current_platform.is_device_capability_family(100):
pytest.skip(
@@ -87,96 +83,6 @@ def _mock_get_per_layer_parameters(vllm_config, layer_names, impl_cls):
}
def _create_hnd_kv_cache(
k_contexts,
v_contexts,
block_size,
num_kv_heads,
head_size,
dtype,
device,
num_blocks,
common_attn_metadata,
kv_in_head_dim=False,
):
"""Create and populate a packed KV cache with HND-compatible strides.
When kv_in_head_dim=False (default), returns (B, H, N, 2*hs) with K/V
packed in the content dim. When kv_in_head_dim=True, returns
(B, 2*H, N, hs) with K/V as separate head groups.
"""
seq_lens = common_attn_metadata.seq_lens.cpu()
query_lens = (
common_attn_metadata.query_start_loc_cpu[1:]
- common_attn_metadata.query_start_loc_cpu[:-1]
)
block_table = common_attn_metadata.block_table_tensor
slot_mapping = common_attn_metadata.slot_mapping
batch_size = len(k_contexts)
# kv_in_head_dim: (B, N, 2*H, hs) — K/V as separate head groups
# else: (B, N, H, 2*hs) — K/V packed in content dim
n_heads, content = (
(2 * num_kv_heads, head_size)
if kv_in_head_dim
else (num_kv_heads, 2 * head_size)
)
kv_cache = torch.zeros(
num_blocks,
block_size,
n_heads,
content,
dtype=dtype,
device=device,
)
kv_cache_flat = kv_cache.view(-1, n_heads, content)
start_block_idx = 1
for i in range(batch_size):
k_ctx, v_ctx = k_contexts[i], v_contexts[i]
start = start_block_idx * block_size
end = start + k_ctx.shape[0]
if kv_in_head_dim:
kv_cache_flat[start:end, :num_kv_heads] = k_ctx
kv_cache_flat[start:end, num_kv_heads:] = v_ctx
else:
kv_cache_flat[start:end, :, :head_size] = k_ctx
kv_cache_flat[start:end, :, head_size:] = v_ctx
start_block_idx += cdiv(int(seq_lens[i]), block_size)
blocks_end = start_block_idx
# Randomly permute blocks (starting from block 1; block 0 is null).
perm = torch.randperm(blocks_end - 1) + 1
inv_perm = torch.zeros(blocks_end, dtype=torch.long, device=device)
inv_perm[1:] = torch.argsort(perm) + 1
kv_cache[1:blocks_end] = kv_cache[perm]
# Build block table.
start_block_idx = 1
for i in range(batch_size):
n_blocks = cdiv(int(seq_lens[i]), block_size)
block_table[i, :n_blocks] = inv_perm[
start_block_idx : start_block_idx + n_blocks
]
start_block_idx += n_blocks
# Build slot mapping that is consistent with the block table.
for i in range(batch_size):
ctx_len = int(seq_lens[i]) - int(query_lens[i])
token_offsets = torch.arange(int(query_lens[i])) + ctx_len
block_indices = token_offsets // block_size
intra_block_offsets = token_offsets % block_size
start = common_attn_metadata.query_start_loc_cpu[i]
end = common_attn_metadata.query_start_loc_cpu[i + 1]
slot_mapping[start:end] = block_table[
i, block_indices
] * block_size + intra_block_offsets.to(device)
# Transpose to canonical: (B, H, N, 2*hs) or (B, 2*H, N, hs)
return kv_cache.transpose(1, 2).contiguous()
def _create_nvfp4_hnd_kv_cache(
k_contexts,
v_contexts,
@@ -191,7 +97,7 @@ def _create_nvfp4_hnd_kv_cache(
):
"""Create an nvfp4 KV cache by quantizing bf16 context via
reshape_and_cache_flash, using the same block-table layout as
_create_hnd_kv_cache.
create_and_prepopulate_kv_cache.
The returned tensor is dtype ``uint8`` with head-group layout
``(num_blocks, 2 * num_kv_heads, block_size, full_dim)``
@@ -217,11 +123,10 @@ def _create_nvfp4_hnd_kv_cache(
during quantization.
Returns:
``torch.Tensor``: The nvfp4 kv_cache tensor (uint8, HND-strided).
``torch.Tensor``: The nvfp4 kv_cache tensor (uint8, LBHNC-strided).
"""
# First create a bf16 HND cache so block tables are populated.
# Use kv_in_head_dim=True so K/V are separate head groups (B, 2*H, N, hs).
bf16_cache = _create_hnd_kv_cache(
# First create a bf16 cache so block tables are populated.
bf16_cache = create_and_prepopulate_kv_cache(
k_contexts,
v_contexts,
block_size,
@@ -231,7 +136,7 @@ def _create_nvfp4_hnd_kv_cache(
device,
num_blocks,
common_attn_metadata,
kv_in_head_dim=True,
layout=KVCacheLayout.LBHNC,
)
# (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(
k_cache, v_cache = nvfp4_cache.split(num_kv_heads, dim=1)
# Flatten bf16 context into tokens and quantize via reshape_and_cache_flash.
# bf16_cache is (B, 2*H, N, hs); split K/V on head dim.
# bf16_cache is (B, H, N, 2*hs); split K/V on the content dim.
block_table = common_attn_metadata.block_table_tensor
seq_lens = common_attn_metadata.seq_lens.cpu()
query_lens = (
@@ -260,10 +165,16 @@ def _create_nvfp4_hnd_kv_cache(
# Gather context tokens from the bf16 cache using block table.
n_ctx_blocks = (ctx_len + block_size - 1) // block_size
blocks = block_table[i, :n_ctx_blocks]
# bf16_cache is (B, 2*H, N, hs); split K and V head groups.
k_bf16, v_bf16 = bf16_cache[blocks].split(num_kv_heads, dim=1)
k_ctx = k_bf16.transpose(1, 2).reshape(-1, num_kv_heads, head_size)[:ctx_len]
v_ctx = v_bf16.transpose(1, 2).reshape(-1, num_kv_heads, head_size)[:ctx_len]
k_ctx = (
bf16_cache[blocks, :, :, :head_size]
.transpose(1, 2)
.reshape(-1, num_kv_heads, head_size)[:ctx_len]
)
v_ctx = (
bf16_cache[blocks, :, :, head_size:]
.transpose(1, 2)
.reshape(-1, num_kv_heads, head_size)[:ctx_len]
)
# Build slot mapping for these context tokens.
token_offsets = torch.arange(ctx_len, device=device)
block_indices = token_offsets // block_size
@@ -383,7 +294,7 @@ def _run_trtllm_integration(batch_spec, kv_cache_dtype="auto", model_name=MODEL)
)
else:
kv_scale_val = 1.0
kv_cache = _create_hnd_kv_cache(
kv_cache = create_and_prepopulate_kv_cache(
k_contexts,
v_contexts,
BLOCK_SIZE,
@@ -393,127 +304,117 @@ def _run_trtllm_integration(batch_spec, kv_cache_dtype="auto", model_name=MODEL)
device,
NUM_GPU_BLOCKS,
common_attn_metadata,
layout=KVCacheLayout.LBHNC,
)
# 3. Run through FlashInfer with TRTLLM enabled
set_kv_cache_layout("HND")
get_kv_cache_layout.cache_clear()
vllm_config.cache_config.kv_cache_layout = "LBHNC"
try:
is_nvfp4 = kv_cache_dtype == "nvfp4"
kv_quant_mode = KVQuantMode.NVFP4 if is_nvfp4 else KVQuantMode.NONE
spec_dtype = torch.uint8 if is_nvfp4 else dtype
kv_cache_spec = FullAttentionSpec(
block_size=BLOCK_SIZE,
num_kv_heads=num_kv_heads,
head_size=head_size,
dtype=spec_dtype,
kv_quant_mode=kv_quant_mode,
is_nvfp4 = kv_cache_dtype == "nvfp4"
kv_quant_mode = KVQuantMode.NVFP4 if is_nvfp4 else KVQuantMode.NONE
spec_dtype = torch.uint8 if is_nvfp4 else dtype
kv_cache_spec = FullAttentionSpec(
block_size=BLOCK_SIZE,
num_kv_heads=num_kv_heads,
head_size=head_size,
dtype=spec_dtype,
kv_quant_mode=kv_quant_mode,
)
layer_names = ["test_layer_0"]
with (
set_current_vllm_config(vllm_config),
unittest.mock.patch(
"vllm.utils.flashinfer.supports_trtllm_attention",
return_value=True,
),
unittest.mock.patch(
"vllm.v1.attention.backends.flashinfer.get_per_layer_parameters",
_mock_get_per_layer_parameters,
),
):
builder = FlashInferMetadataBuilder(
kv_cache_spec, layer_names, vllm_config, device
)
attn_metadata = builder.build(
common_prefix_len=0,
common_attn_metadata=common_attn_metadata,
)
layer_names = ["test_layer_0"]
with (
set_current_vllm_config(vllm_config),
unittest.mock.patch(
"vllm.utils.flashinfer.supports_trtllm_attention",
return_value=True,
),
unittest.mock.patch(
"vllm.v1.attention.backends.flashinfer.get_per_layer_parameters",
_mock_get_per_layer_parameters,
),
):
builder = FlashInferMetadataBuilder(
kv_cache_spec, layer_names, vllm_config, device
# Verify the correct TRTLLM metadata types were produced.
has_prefills = any(ql > 1 for ql in batch_spec.query_lens)
has_decodes = any(ql == 1 for ql in batch_spec.query_lens)
if has_prefills:
assert isinstance(attn_metadata.prefill, TRTLLMPrefill), (
f"Expected TRTLLMPrefill, got {type(attn_metadata.prefill)}"
)
attn_metadata = builder.build(
common_prefix_len=0,
common_attn_metadata=common_attn_metadata,
if has_decodes:
assert isinstance(attn_metadata.decode, FlashInferTrtllmAPIDecode), (
f"Expected FlashInferTrtllmAPIDecode, got {type(attn_metadata.decode)}"
)
assert attn_metadata.decode.kernel == FlashInferDecodeKernel.TRTLLM_GEN
# Verify the correct TRTLLM metadata types were produced.
has_prefills = any(ql > 1 for ql in batch_spec.query_lens)
has_decodes = any(ql == 1 for ql in batch_spec.query_lens)
impl = FlashInferImpl(
num_heads=num_q_heads,
head_size=head_size,
scale=scale,
num_kv_heads=num_kv_heads,
alibi_slopes=None,
sliding_window=None,
kv_cache_dtype=kv_cache_dtype,
)
if has_prefills:
assert isinstance(attn_metadata.prefill, TRTLLMPrefill), (
f"Expected TRTLLMPrefill, got {type(attn_metadata.prefill)}"
)
if has_decodes:
assert isinstance(attn_metadata.decode, FlashInferTrtllmAPIDecode), (
"Expected FlashInferTrtllmAPIDecode, got "
f"{type(attn_metadata.decode)}"
)
assert attn_metadata.decode.kernel == FlashInferDecodeKernel.TRTLLM_GEN
impl = FlashInferImpl(
num_heads=num_q_heads,
head_size=head_size,
scale=scale,
num_kv_heads=num_kv_heads,
alibi_slopes=None,
sliding_window=None,
kv_cache_dtype=kv_cache_dtype,
)
mock_layer = MockAttentionLayer(device)
if is_nvfp4:
# For nvfp4, k_scale/v_scale are the global quantization
# scales (amax/448) used by reshape_and_cache_flash.
kv_scale_t = torch.tensor(
kv_scale_val, dtype=torch.float32, device=device
)
mock_layer._k_scale = kv_scale_t
mock_layer._v_scale = kv_scale_t
mock_layer._k_scale_float = kv_scale_val
mock_layer._v_scale_float = kv_scale_val
output = torch.empty_like(query_vllm)
impl.do_kv_cache_update(
mock_layer,
key_vllm,
value_vllm,
kv_cache,
attn_metadata.slot_mapping,
)
# nvfp4 trtllm kernel requires FP8 queries. In the real
# pipeline the attention layer handles this; here we
# quantize manually.
if is_nvfp4:
finfo = torch.finfo(torch.float8_e4m3fn)
q_amax = query_vllm.abs().amax().clamp(min=1e-12)
q_s = (finfo.max / q_amax * 0.1).item()
query_vllm = (
(query_vllm * q_s)
.clamp(finfo.min, finfo.max)
.to(torch.float8_e4m3fn)
)
mock_layer._q_scale = torch.tensor(
1.0 / q_s, dtype=torch.float32, device=device
)
mock_layer._q_scale_float = 1.0 / q_s
output = impl.forward(
mock_layer,
query_vllm,
key_vllm,
value_vllm,
kv_cache,
attn_metadata,
output=output,
)
# 4. Compare against SDPA reference
mock_layer = MockAttentionLayer(device)
if is_nvfp4:
atol, rtol = 1.0, 1.0 # nvfp4 has higher quantization error
else:
atol, rtol = 1e-2, 1e-2
torch.testing.assert_close(output, sdpa_output, atol=atol, rtol=rtol)
# For nvfp4, k_scale/v_scale are the global quantization
# scales (amax/448) used by reshape_and_cache_flash.
kv_scale_t = torch.tensor(kv_scale_val, dtype=torch.float32, device=device)
mock_layer._k_scale = kv_scale_t
mock_layer._v_scale = kv_scale_t
mock_layer._k_scale_float = kv_scale_val
mock_layer._v_scale_float = kv_scale_val
output = torch.empty_like(query_vllm)
finally:
set_kv_cache_layout(None)
get_kv_cache_layout.cache_clear()
impl.do_kv_cache_update(
mock_layer,
key_vllm,
value_vllm,
kv_cache,
attn_metadata.slot_mapping,
)
# nvfp4 trtllm kernel requires FP8 queries. In the real
# pipeline the attention layer handles this; here we
# quantize manually.
if is_nvfp4:
finfo = torch.finfo(torch.float8_e4m3fn)
q_amax = query_vllm.abs().amax().clamp(min=1e-12)
q_s = (finfo.max / q_amax * 0.1).item()
query_vllm = (
(query_vllm * q_s).clamp(finfo.min, finfo.max).to(torch.float8_e4m3fn)
)
mock_layer._q_scale = torch.tensor(
1.0 / q_s, dtype=torch.float32, device=device
)
mock_layer._q_scale_float = 1.0 / q_s
output = impl.forward(
mock_layer,
query_vllm,
key_vllm,
value_vllm,
kv_cache,
attn_metadata,
output=output,
)
# 4. Compare against SDPA reference
if is_nvfp4:
atol, rtol = 1.0, 1.0 # nvfp4 has higher quantization error
else:
atol, rtol = 1e-2, 1e-2
torch.testing.assert_close(output, sdpa_output, atol=atol, rtol=rtol)
@pytest.mark.parametrize(
+36
View File
@@ -32,7 +32,12 @@ from vllm.v1.attention.backends.registry import AttentionBackendEnum
from vllm.v1.kv_cache_interface import (
EncoderOnlyAttentionSpec,
FullAttentionSpec,
KVCacheLayout,
KVCacheSpec,
KVCacheTensor,
MambaSpec,
compute_layout_strides,
create_kv_cache_views,
get_kv_quant_mode,
)
@@ -215,6 +220,9 @@ def create_vllm_config(
# (these may be set during initialization normally)
cache_config.num_gpu_blocks = num_gpu_blocks
cache_config.num_cpu_blocks = 0
# Builders read the resolved layout from the config; tests that exercise a
# different layout overwrite this field.
cache_config.kv_cache_layout = "LBNHC"
parallel_config = ParallelConfig(
tensor_parallel_size=tensor_parallel_size,
@@ -417,3 +425,31 @@ class MockMambaBuilder(BaseMambaAttentionMetadataBuilder[BaseMambaAttentionMetad
is_prefilling=torch.tensor(is_prefilling, dtype=torch.bool)
)
return builder.build(0, common_metadata)
def dense_kv_cache_views(
raw: torch.Tensor,
spec: KVCacheSpec,
num_blocks: int,
num_layers: int,
layout: KVCacheLayout,
kernel_block_size: int | None = None,
) -> list[torch.Tensor]:
"""``create_kv_cache_views`` for a dense allocation of ``num_layers`` layers."""
layer_stride, block_stride, _, _, _ = compute_layout_strides(
spec, num_blocks, num_layers, layout
)
tensor = KVCacheTensor(
size=raw.numel(),
layers=[str(i) for i in range(num_layers)],
layer_stride=layer_stride,
block_stride=block_stride,
)
return create_kv_cache_views(
raw,
spec,
num_blocks,
layout,
tensor,
kernel_block_size=kernel_block_size,
)
+175 -318
View File
@@ -1,380 +1,237 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for contiguous KV cache packing."""
"""Tests for contiguous KV cache packing.
Every cache group packs its layers densely into one block; groups overlay each other
(a block ID is owned by one group at a time), so the packed block stride is the
largest group's packing. The layout decides whether the layer dim sits outside the
block dim (a contiguous region per layer) or inside it (all layers' pages within each
block); the allocation is the same either way.
"""
from unittest.mock import MagicMock
import pytest
import torch
from vllm.config import CacheConfig
from vllm.v1.core.kv_cache_utils import (
_get_kv_cache_config_packed,
_get_kv_cache_groups_uniform_groups,
_get_kv_cache_bytes_per_block,
_pool_bytes_per_block,
get_kv_cache_config_from_groups,
)
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheGroupSpec,
KVCacheTensor,
KVCacheLayout,
MLAAttentionSpec,
SlidingWindowMLASpec,
SlidingWindowSpec,
UniformTypeKVCacheSpecs,
)
from vllm.v1.worker.utils import allocate_kv_cache
MEMORY = 8 * 1024 * 1024
def _make_mla_spec(page_size: int, block_size: int = 256) -> MLAAttentionSpec:
def _mla(head_size: int) -> MLAAttentionSpec:
return MLAAttentionSpec(
block_size=block_size,
num_kv_heads=1,
head_size=512,
dtype=torch.uint8,
page_size_padded=page_size,
cache_dtype_str="fp8_ds_mla",
model_version="deepseek_v4",
alignment=576,
block_size=64, num_kv_heads=1, head_size=head_size, dtype=torch.uint8
)
def _make_full_spec() -> FullAttentionSpec:
def _full() -> FullAttentionSpec:
return FullAttentionSpec(
block_size=16,
num_kv_heads=2,
head_size=64,
dtype=torch.float16,
block_size=16, num_kv_heads=2, head_size=64, dtype=torch.float16
)
def _make_sw_spec() -> SlidingWindowSpec:
return SlidingWindowSpec(
block_size=16,
num_kv_heads=2,
head_size=64,
dtype=torch.float16,
sliding_window=128,
def _uniform_group(specs: dict) -> KVCacheGroupSpec:
return KVCacheGroupSpec(
list(specs),
UniformTypeKVCacheSpecs(block_size=64, kv_cache_specs=specs),
)
def _make_groups(n_c4, n_c128, n_swa):
PS_C4_MLA = 37440
PS_C4_IDX = 8640
PS_C128 = 1728
PS_SWA = 37440
mla_specs = {}
for i in range(n_c4):
mla_specs[f"c4_mla.{i}"] = _make_mla_spec(PS_C4_MLA)
mla_specs[f"c4_idx.{i}"] = _make_mla_spec(PS_C4_IDX)
for i in range(n_c128):
mla_specs[f"c128_mla.{i}"] = _make_mla_spec(PS_C128)
mla_group = KVCacheGroupSpec(
layer_names=list(mla_specs.keys()),
kv_cache_spec=UniformTypeKVCacheSpecs(block_size=256, kv_cache_specs=mla_specs),
)
swa_specs = {}
for i in range(n_swa):
swa_specs[f"swa.{i}"] = _make_mla_spec(PS_SWA)
swa_group = KVCacheGroupSpec(
layer_names=list(swa_specs.keys()),
kv_cache_spec=UniformTypeKVCacheSpecs(block_size=256, kv_cache_specs=swa_specs),
)
return [mla_group, swa_group]
def _mixed_page_groups(n_mla=3, n_idx=3, n_swa=5):
"""A DeepSeek-V4-style hybrid: two groups with different page mixes."""
g1 = {f"mla.{i}": _mla(512) for i in range(n_mla)}
g1.update({f"idx.{i}": _mla(128) for i in range(n_idx)})
g2 = {f"swa.{i}": _mla(512) for i in range(n_swa)}
return [_uniform_group(g1), _uniform_group(g2)], g1, g2
def _mock_vllm_config(kv_connector_extra_config: dict[str, str] | None = None):
def _mock_vllm_config(layout: str | None):
config = MagicMock()
config.cache_config = CacheConfig()
config.cache_config.num_gpu_blocks_override = None
config.cache_config.prefix_cache_retention_interval = 0
config.kv_transfer_config = None
if kv_connector_extra_config is not None:
config.kv_transfer_config = MagicMock()
config.kv_transfer_config.kv_connector_extra_config = kv_connector_extra_config
config.cache_config.kv_cache_layout = layout
return config
def _run(n_c4=3, n_c128=2, n_swa=5, mem=100 * 1024 * 1024):
groups = _make_groups(n_c4, n_c128, n_swa)
return _get_kv_cache_config_packed(_mock_vllm_config(), groups, mem)
def _page_sizes_by_layer(
groups: list[KVCacheGroupSpec],
) -> dict[str, int]:
page_sizes = {}
for group in groups:
specs = group.kv_cache_spec.kv_cache_specs
for layer_name in group.layer_names:
page_sizes[layer_name] = specs[layer_name].page_size_bytes
return page_sizes
def _packing_by_layer(
tensors: list[KVCacheTensor],
) -> dict[str, tuple[int, int]]:
def _pages(groups) -> dict[str, int]:
return {
layer_name: (tensor.offset, tensor.block_stride)
for tensor in tensors
for layer_name in tensor.shared_by
name: group.kv_cache_spec.kv_cache_specs[name].page_size_bytes
if isinstance(group.kv_cache_spec, UniformTypeKVCacheSpecs)
else group.kv_cache_spec.page_size_bytes
for group in groups
for name in group.layer_names
}
def _make_views(
groups: list[KVCacheGroupSpec],
num_blocks: int,
tensors: list[KVCacheTensor],
) -> dict[str, torch.Tensor]:
page_sizes = _page_sizes_by_layer(groups)
packing = _packing_by_layer(tensors)
backing = torch.zeros(tensors[0].size, dtype=torch.uint8)
return {
layer_name: torch.as_strided(
backing,
size=(num_blocks, page_size),
stride=(packing[layer_name][1], 1),
storage_offset=packing[layer_name][0],
def _expected_bytes_per_block(groups) -> int:
pages = _pages(groups)
return max(sum(pages[n] for n in g.layer_names) for g in groups)
def _bind(config, layout: str):
return allocate_kv_cache(config, torch.device("cpu"), KVCacheLayout[layout], None)
class TestDensePacking:
def test_bytes_per_block_is_largest_group(self):
groups, g1, g2 = _mixed_page_groups()
assert _get_kv_cache_bytes_per_block(groups) == _expected_bytes_per_block(
groups
)
for layer_name, page_size in page_sizes.items()
}
def _make_page_group(prefix: str, page_sizes: list[int]) -> KVCacheGroupSpec:
specs = {
f"{prefix}.{i}": MagicMock(page_size_bytes=page_size)
for i, page_size in enumerate(page_sizes)
}
return KVCacheGroupSpec(
layer_names=list(specs),
kv_cache_spec=UniformTypeKVCacheSpecs(block_size=256, kv_cache_specs=specs),
)
class TestInterleavedPacking:
def test_compact_cache_overlays_fp32_state_group(self):
full_specs = {}
state_specs = {}
for i in range(2):
full_specs[f"mla.{i}"] = MLAAttentionSpec(
block_size=256,
num_kv_heads=1,
head_size=512,
dtype=torch.uint8,
page_size_padded=32768,
indexes_kv_by_block_stride=True,
compress_ratio=4,
)
full_specs[f"indexer.{i}"] = MLAAttentionSpec(
block_size=256,
num_kv_heads=1,
head_size=68,
dtype=torch.uint8,
page_size_padded=4608,
compress_ratio=4,
)
state_specs[f"mla_state.{i}"] = SlidingWindowMLASpec(
block_size=4,
num_kv_heads=1,
head_size=2048,
dtype=torch.float32,
sliding_window=8,
indexes_kv_by_block_stride=True,
)
state_specs[f"indexer_state.{i}"] = SlidingWindowMLASpec(
block_size=4,
num_kv_heads=1,
head_size=512,
dtype=torch.float32,
sliding_window=8,
indexes_kv_by_block_stride=True,
)
grouped_specs = [
UniformTypeKVCacheSpecs(block_size=256, kv_cache_specs=full_specs),
UniformTypeKVCacheSpecs(block_size=4, kv_cache_specs=state_specs),
config = get_kv_cache_config_from_groups(
_mock_vllm_config("BLHNC"), groups, MEMORY
)
# Groups overlay: both start at offset 0.
assert [tensor.offset for tensor in config.kv_cache_tensors].count(0) == 2
assert [tensor.layers for tensor in config.kv_cache_tensors] == [
list(g1)[:3],
list(g1)[3:],
list(g2),
]
groups = _get_kv_cache_groups_uniform_groups(grouped_specs)
assert len(groups) == 2
assert {full_specs[f"indexer.{i}"].page_size_bytes for i in range(2)} == {4608}
assert {full_specs[f"indexer.{i}"].real_page_size_bytes for i in range(2)} == {
4352
def test_layers_within_a_group_are_dense(self):
groups, _, _ = _mixed_page_groups()
pages = _pages(groups)
config = get_kv_cache_config_from_groups(
_mock_vllm_config("BLHNC"), groups, MEMORY
)
offsets = {
name: tensor.offset + i * tensor.layer_stride
for tensor in config.kv_cache_tensors
for i, name in enumerate(tensor.layers)
}
assert {
state_specs[f"indexer_state.{i}"].page_size_bytes for i in range(2)
} == {8192}
full_group_bytes = 2 * (32768 + 4608)
state_group_bytes = 2 * (32768 + 8192)
bytes_per_block = max(full_group_bytes, state_group_bytes)
num_blocks, tensors = _get_kv_cache_config_packed(
_mock_vllm_config(), groups, bytes_per_block * 32
)
assert num_blocks == 32
assert {tensor.block_stride for tensor in tensors} == {bytes_per_block}
packing = _packing_by_layer(tensors)
assert packing["mla.0"][0] == packing["mla_state.0"][0] == 0
assert packing["indexer.0"][0] == 32768
assert packing["indexer_state.0"][0] == 32768
def test_deepseek_v4_pro_stride(self):
groups = [
_make_page_group("full", [32768, 4608] * 30 + [1024] * 31),
_make_page_group("c4_state", [32768, 8192] * 30),
_make_page_group("c128_state", [32768] * 31),
_make_page_group("swa.0", [32768] * 31),
_make_page_group("swa.1", [32768] * 30),
]
expected_stride = 1_228_800
num_blocks, tensors = _get_kv_cache_config_packed(
_mock_vllm_config(), groups, expected_stride * 32
)
assert num_blocks == 32
assert {tensor.block_stride for tensor in tensors} == {expected_stride}
assert {tensor.size for tensor in tensors} == {expected_stride * 32}
def test_all_tensors_have_block_stride(self):
_, tensors = _run()
for t in tensors:
assert t.block_stride > 0
def test_all_tensors_share_same_size(self):
_, tensors = _run()
sizes = set(t.size for t in tensors)
assert len(sizes) == 1
assert sizes.pop() > 0
def test_offsets_within_one_block(self):
groups = _make_groups(n_c4=3, n_c128=2, n_swa=5)
_, tensors = _get_kv_cache_config_packed(
_mock_vllm_config(), groups, 100 * 1024 * 1024
)
page_sizes = _page_sizes_by_layer(groups)
packing = _packing_by_layer(tensors)
for layer_name, page_size in page_sizes.items():
offset, block_stride = packing[layer_name]
assert offset + page_size <= block_stride
def test_layouts_are_disjoint_within_each_group(self):
groups = _make_groups(n_c4=3, n_c128=2, n_swa=5)
_, tensors = _get_kv_cache_config_packed(
_mock_vllm_config(), groups, 100 * 1024 * 1024
)
page_sizes = _page_sizes_by_layer(groups)
packing = _packing_by_layer(tensors)
for group in groups:
ranges = sorted(
(packing[name][0], packing[name][0] + page_sizes[name])
for name in group.layer_names
expected = 0
for name in group.layer_names:
assert offsets[name] == expected
expected += pages[name]
@pytest.mark.parametrize("layout", ["LBNHC", "BLHNC"])
def test_allocation_is_layout_invariant(self, layout):
specs = {f"l.{i}": _full() for i in range(4)}
groups = [KVCacheGroupSpec(list(specs), _full())]
config = get_kv_cache_config_from_groups(
_mock_vllm_config(layout), groups, MEMORY
)
(tensor,) = config.kv_cache_tensors
page = _full().page_size_bytes
assert tensor.layers == list(specs)
assert config.num_blocks == MEMORY // (4 * page)
assert tensor.size == 4 * page * config.num_blocks
if layout == "LBNHC":
assert (tensor.layer_stride, tensor.block_stride) == (
page * config.num_blocks,
page,
)
assert all(left[1] <= right[0] for left, right in zip(ranges, ranges[1:]))
def test_all_layers_accounted_for(self):
n_c4, n_c128, n_swa = 5, 4, 7
_, tensors = _run(n_c4=n_c4, n_c128=n_c128, n_swa=n_swa)
all_names = set()
for t in tensors:
all_names.update(t.shared_by)
expected = n_c4 * 2 + n_c128 + n_swa
assert len(all_names) == expected
def test_group_owned_blocks_do_not_alias(self):
groups = _make_groups(n_c4=3, n_c128=2, n_swa=5)
num_blocks, tensors = _get_kv_cache_config_packed(
_mock_vllm_config(), groups, 100 * 1024 * 1024
)
views = _make_views(groups, num_blocks, tensors)
expected = {}
value = 1
for block_id, group in enumerate(groups):
for layer_name in group.layer_names:
views[layer_name][block_id].fill_(value)
expected[layer_name] = (block_id, value)
value += 1
for layer_name, (block_id, value) in expected.items():
assert (views[layer_name][block_id] == value).all()
# Once the first group releases its block, another group may reuse it.
for layer_name in groups[1].layer_names:
views[layer_name][0].fill_(255)
for layer_name in groups[1].layer_names:
assert (views[layer_name][0] == 255).all()
def test_hma_attention_groups_keep_default_backing(self):
full = _make_full_spec()
sw = _make_sw_spec()
page_size = full.page_size_bytes
groups = [
KVCacheGroupSpec(["full.0", "full.1"], full),
KVCacheGroupSpec(["sw.0", "sw.2"], sw),
KVCacheGroupSpec(["sw.1", "sw.3"], sw),
]
else:
assert (tensor.layer_stride, tensor.block_stride) == (page, 4 * page)
@pytest.mark.parametrize("layout", ["LBNHC", "BLHNC"])
def test_single_group_mixed_pages_follows_layout(self, layout):
specs = {"mla.0": _mla(512), "mla.1": _mla(512), "idx.0": _mla(128)}
groups = [_uniform_group(specs)]
config = get_kv_cache_config_from_groups(
_mock_vllm_config(), groups, available_memory=page_size * 2 * 32
_mock_vllm_config(layout), groups, MEMORY
)
block_stride = _expected_bytes_per_block(groups)
mla_tensor, idx_tensor = config.kv_cache_tensors
assert mla_tensor.layers == ["mla.0", "mla.1"]
assert idx_tensor.layers == ["idx.0"]
assert {t.size for t in config.kv_cache_tensors} == {
block_stride * config.num_blocks
}
if layout == "LBNHC":
assert (
idx_tensor.offset == 2 * _mla(512).page_size_bytes * config.num_blocks
)
assert mla_tensor.block_stride == _mla(512).page_size_bytes
else:
assert idx_tensor.offset == 2 * _mla(512).page_size_bytes
assert mla_tensor.block_stride == block_stride
assert config.num_blocks == 32
assert config.prefix_cache_retention_interval == 0
assert sum(t.size for t in config.kv_cache_tensors) == page_size * 2 * 32
assert config.kv_cache_tensors == [
KVCacheTensor(size=page_size * 32, shared_by=["full.0", "sw.0", "sw.1"]),
KVCacheTensor(size=page_size * 32, shared_by=["full.1", "sw.2", "sw.3"]),
]
def test_hma_attention_groups_use_packed_backing_with_enable_cross_layers(self):
full = _make_full_spec()
sw = _make_sw_spec()
page_size = full.page_size_bytes
groups = [
KVCacheGroupSpec(["full.0", "full.1"], full),
KVCacheGroupSpec(["sw.0", "sw.2"], sw),
KVCacheGroupSpec(["sw.1", "sw.3"], sw),
]
def test_overlaid_groups_alias_and_stay_isolated(self):
groups, g1, g2 = _mixed_page_groups()
# Overlay models resolve to a block-outer layout at backend selection (the
# model's backend declares it); mirror that here.
config = get_kv_cache_config_from_groups(
_mock_vllm_config({"enable_cross_layers_blocks": "True"}),
groups,
available_memory=page_size * 2 * 32,
_mock_vllm_config("BLNHC"), groups, MEMORY
)
assert config.num_blocks == MEMORY // _expected_bytes_per_block(groups)
assert _pool_bytes_per_block(groups) == _expected_bytes_per_block(groups)
assert config.num_blocks == 32
assert {t.size for t in config.kv_cache_tensors} == {page_size * 2 * 32}
assert config.kv_cache_tensors == [
KVCacheTensor(
size=page_size * 2 * 32,
shared_by=["full.0", "sw.0", "sw.1"],
offset=0,
block_stride=page_size * 2,
),
KVCacheTensor(
size=page_size * 2 * 32,
shared_by=["full.1", "sw.2", "sw.3"],
offset=page_size,
block_stride=page_size * 2,
),
]
views = _bind(config, "BLNHC")
assert set(views) == set(g1) | set(g2)
assert views["swa.0"].data_ptr() == views["mla.0"].data_ptr()
def test_single_group_attention_keeps_unpacked_layout(self):
spec = _make_full_spec()
groups = [KVCacheGroupSpec(["full.0", "full.1"], spec)]
# A block is owned by one group at a time: writes to group-owned
# blocks never disturb the other group's blocks.
for i, name in enumerate(g1):
views[name][0].fill_(i + 1)
views[name][2].fill_(i + 1)
for i, name in enumerate(g2):
views[name][1].fill_(100 + i)
views[name][3].fill_(100 + i)
for i, name in enumerate(g1):
assert (views[name][0].to(torch.int32) == i + 1).all()
assert (views[name][2].to(torch.int32) == i + 1).all()
for i, name in enumerate(g2):
assert (views[name][1].to(torch.int32) == 100 + i).all()
assert (views[name][3].to(torch.int32) == 100 + i).all()
# Layers within a group are disjoint.
views["mla.0"][0].fill_(77)
for i, name in enumerate(list(g1)[1:], start=1):
assert (views[name][0].to(torch.int32) == i + 1).all()
def test_layer_compact_layout_rejected_for_overlaid_groups(self):
# The layout has a single writer (backend-selection resolution); a layer-
# compact layout reaching an overlay model's allocation is an error, not a
# silent flip.
groups, _, _ = _mixed_page_groups()
with pytest.raises(
ValueError, match="cannot express this model's mixed page sizes"
):
get_kv_cache_config_from_groups(_mock_vllm_config("LBNHC"), groups, MEMORY)
def test_unresolved_layout_rejected(self):
groups, _, _ = _mixed_page_groups()
with pytest.raises(ValueError, match="has not been resolved"):
get_kv_cache_config_from_groups(_mock_vllm_config(None), groups, MEMORY)
def test_head_outer_layout_rejected_for_mixed_pages(self):
groups, _, _ = _mixed_page_groups()
with pytest.raises(
ValueError, match="cannot express this model's mixed page sizes"
):
get_kv_cache_config_from_groups(_mock_vllm_config("LHBNC"), groups, MEMORY)
@pytest.mark.parametrize("layout", ["LBNHC", "BLHNC"])
def test_bound_views_round_trip(self, layout):
specs = {"mla.0": _mla(512), "mla.1": _mla(512), "idx.0": _mla(128)}
groups = [_uniform_group(specs)]
config = get_kv_cache_config_from_groups(
_mock_vllm_config(), groups, available_memory=spec.page_size_bytes * 2 * 32
_mock_vllm_config(layout), groups, MEMORY
)
assert sum(t.size for t in config.kv_cache_tensors) == (
spec.page_size_bytes * 2 * 32
)
assert [t.block_stride for t in config.kv_cache_tensors] == [0, 0]
views = _bind(config, layout)
for i, name in enumerate(specs):
views[name].fill_(i + 1)
for i, name in enumerate(specs):
assert (views[name].to(torch.int32) == i + 1).all(), name
assert views[name].shape[0] == config.num_blocks
if __name__ == "__main__":
+300 -288
View File
@@ -124,7 +124,6 @@ def new_kv_cache_spec(
page_size_padded=None,
sliding_window=None,
attention_chunk_size=None,
indexes_kv_by_block_stride=False,
kv_quant_mode=KVQuantMode.NONE,
):
return FullAttentionSpec(
@@ -135,7 +134,6 @@ def new_kv_cache_spec(
page_size_padded=page_size_padded,
sliding_window=sliding_window,
attention_chunk_size=attention_chunk_size,
indexes_kv_by_block_stride=indexes_kv_by_block_stride,
kv_quant_mode=kv_quant_mode,
)
@@ -147,7 +145,6 @@ def new_sliding_window_spec(
dtype=torch.float32,
page_size_padded=None,
sliding_window=1,
indexes_kv_by_block_stride=False,
):
return SlidingWindowSpec(
block_size=block_size,
@@ -156,7 +153,6 @@ def new_sliding_window_spec(
dtype=dtype,
page_size_padded=page_size_padded,
sliding_window=sliding_window,
indexes_kv_by_block_stride=indexes_kv_by_block_stride,
)
@@ -875,6 +871,7 @@ def test_metrics_empty_stats():
def test_get_kv_cache_configs_multiple_workers():
model_config = ModelConfig(max_model_len=16)
vllm_config = VllmConfig(model_config=model_config)
vllm_config.cache_config.kv_cache_layout = "LBNHC"
vllm_config.cache_config.prefix_cache_retention_interval = None
ref_kv_cache_spec = new_kv_cache_spec()
@@ -898,36 +895,21 @@ def test_get_kv_cache_configs_multiple_workers():
ref_kv_cache_spec.page_size_bytes * 2 * 10,
],
)
assert kv_cache_configs == [
KVCacheConfig(
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
],
),
KVCacheConfig(
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
],
),
]
expected = KVCacheConfig(
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10 * 2,
layers=["layer1", "layer2"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
],
)
assert kv_cache_configs == [expected, expected]
# Different available memory. This is the case for TP.
# Use the smallest memory available.
@@ -939,36 +921,7 @@ def test_get_kv_cache_configs_multiple_workers():
ref_kv_cache_spec.page_size_bytes * 2 * 20,
],
)
assert kv_cache_configs == [
KVCacheConfig(
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
],
),
KVCacheConfig(
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
],
),
]
assert kv_cache_configs == [expected, expected]
# Different KV cache specs. This is the case for PP.
different_layer_specs = [
@@ -995,7 +948,10 @@ def test_get_kv_cache_configs_multiple_workers():
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
size=ref_kv_cache_spec.page_size_bytes * 10,
layers=["layer1"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
],
kv_cache_groups=[
@@ -1006,10 +962,10 @@ def test_get_kv_cache_configs_multiple_workers():
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer3"]
size=ref_kv_cache_spec.page_size_bytes * 10 * 2,
layers=["layer2", "layer3"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
],
kv_cache_groups=[
@@ -1046,57 +1002,39 @@ def test_get_kv_cache_configs_multiple_workers():
ref_kv_cache_spec.page_size_bytes * 2 * 10,
],
)
expected_12 = KVCacheConfig(
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10 * 2,
layers=["layer1", "layer2"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
],
)
expected_3 = KVCacheConfig(
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10,
layers=["layer3"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer3"], ref_kv_cache_spec),
],
)
assert kv_cache_configs == [
KVCacheConfig(
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
],
),
KVCacheConfig(
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer1", "layer2"], ref_kv_cache_spec),
],
),
KVCacheConfig(
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer3"]
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer3"], ref_kv_cache_spec),
],
),
KVCacheConfig(
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer3"]
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer3"], ref_kv_cache_spec),
],
),
expected_12,
expected_12,
expected_3,
expected_3,
]
# Different workers have different types of layers. This is the case for
@@ -1124,10 +1062,10 @@ def test_get_kv_cache_configs_multiple_workers():
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer1"]
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer2"]
size=ref_kv_cache_spec.page_size_bytes * 10 * 2,
layers=["layer1", "layer2"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
],
kv_cache_groups=[
@@ -1139,10 +1077,10 @@ def test_get_kv_cache_configs_multiple_workers():
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer3"]
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10, shared_by=["layer4"]
size=ref_kv_cache_spec.page_size_bytes * 10 * 2,
layers=["layer3", "layer4"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
],
kv_cache_groups=[
@@ -1180,7 +1118,21 @@ def test_get_kv_cache_configs_multiple_workers():
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10,
shared_by=["layer1", "layer2", "layer3"],
layers=["layer1"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10,
layers=["layer2"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10,
layers=["layer3"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
],
kv_cache_groups=[
@@ -1194,7 +1146,21 @@ def test_get_kv_cache_configs_multiple_workers():
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10,
shared_by=["layer4", "layer5", "layer6"],
layers=["layer4"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10,
layers=["layer5"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * 10,
layers=["layer6"],
layer_stride=ref_kv_cache_spec.page_size_bytes * 10,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
],
kv_cache_groups=[
@@ -1233,6 +1199,7 @@ def test_get_kv_cache_configs_multiple_workers():
def test_get_kv_cache_configs_pp_sharding(asymmetric_memory):
model_config = ModelConfig(max_model_len=512)
vllm_config = VllmConfig(model_config=model_config)
vllm_config.cache_config.kv_cache_layout = "LBNHC"
vllm_config.cache_config.prefix_cache_retention_interval = None
ref_kv_cache_spec = new_kv_cache_spec()
@@ -1262,7 +1229,10 @@ def test_get_kv_cache_configs_pp_sharding(asymmetric_memory):
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * expected_num_blocks,
shared_by=["layer1"],
layers=["layer1"],
layer_stride=ref_kv_cache_spec.page_size_bytes
* expected_num_blocks,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
],
kv_cache_groups=[KVCacheGroupSpec(["layer1"], ref_kv_cache_spec)],
@@ -1272,7 +1242,10 @@ def test_get_kv_cache_configs_pp_sharding(asymmetric_memory):
kv_cache_tensors=[
KVCacheTensor(
size=ref_kv_cache_spec.page_size_bytes * expected_num_blocks,
shared_by=["layer2"],
layers=["layer2"],
layer_stride=ref_kv_cache_spec.page_size_bytes
* expected_num_blocks,
block_stride=ref_kv_cache_spec.page_size_bytes,
),
],
kv_cache_groups=[KVCacheGroupSpec(["layer2"], ref_kv_cache_spec)],
@@ -1628,78 +1601,18 @@ def test_get_max_concurrency_for_kv_cache_config():
)
def test_get_max_concurrency_packed_kv_cache_config():
from vllm.v1.core.kv_cache_utils import (
_get_kv_cache_config_packed,
_use_packed_kv_cache_config,
)
model_config = ModelConfig(
"Qwen/Qwen1.5-7B",
runner="generate",
dtype="float16",
max_model_len=16384,
)
scheduler_config = SchedulerConfig(
max_num_batched_tokens=1024,
enable_chunked_prefill=True,
max_model_len=model_config.max_model_len,
is_encoder_decoder=model_config.is_encoder_decoder,
async_scheduling=False,
)
vllm_config = VllmConfig(
model_config=model_config,
scheduler_config=scheduler_config,
)
# All-UniformTypeKVCacheSpecs groups select the packed layout.
mla_specs = {f"layer_{i}": new_mla_spec() for i in range(4)}
swa_specs = {
f"layer_{i}": SlidingWindowMLASpec(
block_size=16,
num_kv_heads=1,
head_size=576,
dtype=torch.float32,
sliding_window=128,
)
for i in range(4, 6)
}
kv_cache_groups = [
KVCacheGroupSpec(
list(mla_specs),
UniformTypeKVCacheSpecs(block_size=16, kv_cache_specs=mla_specs),
),
KVCacheGroupSpec(
list(swa_specs),
UniformTypeKVCacheSpecs(block_size=16, kv_cache_specs=swa_specs),
),
]
assert _use_packed_kv_cache_config(vllm_config, kv_cache_groups)
num_blocks, kv_cache_tensors = _get_kv_cache_config_packed(
vllm_config, kv_cache_groups, 2 * GiB_bytes
)
assert num_blocks > 0
kv_cache_config_packed = KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=kv_cache_tensors,
kv_cache_groups=kv_cache_groups,
)
# Per-request blocks: the MLA group needs cdiv(16384, 16) = 1024 pages;
# the SWA group cdiv(min(128 - 1 + 1024, 16384), 16) + 1 = 73. The
# previous formula normalized by the first group's page size and gave
# 1061 blocks per request instead of 1097.
assert get_max_concurrency_for_kv_cache_config(
vllm_config, kv_cache_config_packed
) == num_blocks / (1024 + 73)
def test_allocate_with_lookahead():
"""Verify that lookahead tokens correctly affect block allocation"""
block_size = 4
config = KVCacheConfig(
num_blocks=10,
kv_cache_tensors=[
KVCacheTensor(size=100, shared_by=["layer1"]),
KVCacheTensor(
size=100,
layers=["layer1"],
layer_stride=100,
block_stride=10,
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer1"], new_kv_cache_spec(block_size=block_size)),
@@ -1763,6 +1676,7 @@ def test_get_kv_cache_config_one_worker():
# pass max_model_len to pass check_enough_kv_cache_memory
model_config = ModelConfig(max_model_len=16)
vllm_config = VllmConfig(model_config=model_config)
vllm_config.cache_config.kv_cache_layout = "LBNHC"
vllm_config.cache_config.prefix_cache_retention_interval = None
mem_per_block_per_layer = 16 * 2 * 64 * 4 * 2
@@ -1778,8 +1692,12 @@ def test_get_kv_cache_config_one_worker():
assert kv_cache_config_full == KVCacheConfig(
num_blocks=32,
kv_cache_tensors=[
KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_1"]),
KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_2"]),
KVCacheTensor(
size=mem_per_block_per_layer * 32 * 2,
layers=["layer_1", "layer_2"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
],
kv_cache_groups=[KVCacheGroupSpec(["layer_1", "layer_2"], new_kv_cache_spec())],
)
@@ -1795,8 +1713,12 @@ def test_get_kv_cache_config_one_worker():
assert kv_cache_config_sliding == KVCacheConfig(
num_blocks=32,
kv_cache_tensors=[
KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_1"]),
KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_2"]),
KVCacheTensor(
size=mem_per_block_per_layer * 32 * 2,
layers=["layer_1", "layer_2"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
],
kv_cache_groups=[
KVCacheGroupSpec(["layer_1", "layer_2"], new_sliding_window_spec())
@@ -1815,8 +1737,12 @@ def test_get_kv_cache_config_one_worker():
assert kv_cache_config_hybrid == KVCacheConfig(
num_blocks=32,
kv_cache_tensors=[
KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_1"]),
KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_2"]),
KVCacheTensor(
size=mem_per_block_per_layer * 32 * 2,
layers=["layer_1", "layer_2"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
],
kv_cache_groups=[
KVCacheGroupSpec(
@@ -1838,7 +1764,16 @@ def test_get_kv_cache_config_one_worker():
num_blocks=64,
kv_cache_tensors=[
KVCacheTensor(
size=mem_per_block_per_layer * 64, shared_by=["layer_1", "layer_2"]
size=mem_per_block_per_layer * 64,
layers=["layer_1"],
layer_stride=mem_per_block_per_layer * 64,
block_stride=mem_per_block_per_layer,
),
KVCacheTensor(
size=mem_per_block_per_layer * 64,
layers=["layer_2"],
layer_stride=mem_per_block_per_layer * 64,
block_stride=mem_per_block_per_layer,
),
],
kv_cache_groups=[
@@ -1863,12 +1798,22 @@ def test_get_kv_cache_config_one_worker():
num_blocks=32,
kv_cache_tensors=[
KVCacheTensor(
size=mem_per_block_per_layer * 32,
shared_by=["layer_1", "layer_3", "layer_4"],
size=mem_per_block_per_layer * 32 * 2,
layers=["layer_1", "layer_2"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
KVCacheTensor(
size=mem_per_block_per_layer * 32,
shared_by=["layer_2", "layer_5", "layer_6"],
size=mem_per_block_per_layer * 32 * 2,
layers=["layer_3", "layer_5"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
KVCacheTensor(
size=mem_per_block_per_layer * 32 * 2,
layers=["layer_4", "layer_6"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
],
kv_cache_groups=[
@@ -1898,15 +1843,28 @@ def test_get_kv_cache_config_one_worker():
num_blocks=32,
kv_cache_tensors=[
KVCacheTensor(
size=mem_per_block_per_layer * 32,
shared_by=["layer_1", "layer_4", "layer_5", "layer_6"],
size=mem_per_block_per_layer * 32 * 3,
layers=["layer_1", "layer_2", "layer_3"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
KVCacheTensor(
size=mem_per_block_per_layer * 32,
shared_by=["layer_2", "layer_7", "layer_8", "layer_9"],
size=mem_per_block_per_layer * 32 * 3,
layers=["layer_4", "layer_7", "layer_10"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
KVCacheTensor(
size=mem_per_block_per_layer * 32, shared_by=["layer_3", "layer_10"]
size=mem_per_block_per_layer * 32 * 3,
layers=["layer_5", "layer_8"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
KVCacheTensor(
size=mem_per_block_per_layer * 32 * 3,
layers=["layer_6", "layer_9"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
],
kv_cache_groups=[
@@ -1943,28 +1901,23 @@ def test_get_kv_cache_config_one_worker():
num_blocks=32,
kv_cache_tensors=[
KVCacheTensor(
size=mem_per_block_per_layer * 32,
shared_by=["layer_1", "layer_7"],
size=mem_per_block_per_layer * 32 * 6,
layers=[
"layer_1",
"layer_2",
"layer_3",
"layer_4",
"layer_5",
"layer_6",
],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
KVCacheTensor(
size=mem_per_block_per_layer * 32,
shared_by=["layer_2", "layer_8"],
),
KVCacheTensor(
size=mem_per_block_per_layer * 32,
shared_by=["layer_3", "layer_9"],
),
KVCacheTensor(
size=mem_per_block_per_layer * 32,
shared_by=["layer_4", "layer_10"],
),
KVCacheTensor(
size=mem_per_block_per_layer * 32,
shared_by=["layer_5", "layer_11"],
),
KVCacheTensor(
size=mem_per_block_per_layer * 32,
shared_by=["layer_6"],
size=mem_per_block_per_layer * 32 * 6,
layers=["layer_7", "layer_8", "layer_9", "layer_10", "layer_11"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
],
kv_cache_groups=[
@@ -1987,11 +1940,24 @@ def test_get_kv_cache_config_one_worker():
kv_cache_config_hybrid = get_kv_cache_configs(
vllm_config, [kv_cache_specs_hybrid], [mem_per_block_per_layer * 3 * 32]
)[0]
# Layers of different page sizes pack densely into one allocation: the
# 2x-larger layer_1 takes the first region, layer_2 the next.
assert kv_cache_config_hybrid == KVCacheConfig(
num_blocks=32,
kv_cache_tensors=[
KVCacheTensor(size=mem_per_block_per_layer * 32 * 2, shared_by=["layer_1"]),
KVCacheTensor(size=mem_per_block_per_layer * 32, shared_by=["layer_2"]),
KVCacheTensor(
size=mem_per_block_per_layer * 32 * 3,
layers=["layer_1"],
layer_stride=mem_per_block_per_layer * 2 * 32,
block_stride=mem_per_block_per_layer * 2,
),
KVCacheTensor(
size=mem_per_block_per_layer * 32 * 3,
layers=["layer_2"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
offset=mem_per_block_per_layer * 2 * 32,
),
],
kv_cache_groups=[
KVCacheGroupSpec(
@@ -2015,7 +1981,16 @@ def test_get_kv_cache_config_one_worker():
num_blocks=32,
kv_cache_tensors=[
KVCacheTensor(
size=mem_per_block_per_layer * 32, shared_by=["layer_1", "layer_2"]
size=mem_per_block_per_layer * 32,
layers=["layer_1"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
KVCacheTensor(
size=mem_per_block_per_layer * 32,
layers=["layer_2"],
layer_stride=mem_per_block_per_layer * 32,
block_stride=mem_per_block_per_layer,
),
],
kv_cache_groups=[
@@ -2028,9 +2003,9 @@ def test_get_kv_cache_config_one_worker():
# different hidden size that cannot be aligned by using different block size,
# but can be aligned by padding the smaller physical page.
swa_spec = new_sliding_window_spec(head_size=96, indexes_kv_by_block_stride=True)
swa_spec = new_sliding_window_spec(head_size=96)
kv_cache_specs_hybrid = {
"layer_1": new_kv_cache_spec(head_size=64, indexes_kv_by_block_stride=True),
"layer_1": new_kv_cache_spec(head_size=64),
"layer_2": swa_spec,
}
@@ -2041,7 +2016,18 @@ def test_get_kv_cache_config_one_worker():
assert kv_cache_config_hybrid == KVCacheConfig(
num_blocks=42,
kv_cache_tensors=[
KVCacheTensor(size=padded_page_size * 42, shared_by=["layer_1", "layer_2"]),
KVCacheTensor(
size=padded_page_size * 42,
layers=["layer_1"],
layer_stride=padded_page_size * 42,
block_stride=padded_page_size,
),
KVCacheTensor(
size=padded_page_size * 42,
layers=["layer_2"],
layer_stride=padded_page_size * 42,
block_stride=padded_page_size,
),
],
kv_cache_groups=[
KVCacheGroupSpec(
@@ -2049,12 +2035,11 @@ def test_get_kv_cache_config_one_worker():
new_kv_cache_spec(
head_size=64,
page_size_padded=padded_page_size,
indexes_kv_by_block_stride=True,
),
),
KVCacheGroupSpec(
["layer_2"],
new_sliding_window_spec(head_size=96, indexes_kv_by_block_stride=True),
new_sliding_window_spec(head_size=96),
),
],
)
@@ -2067,8 +2052,12 @@ def test_get_kv_cache_config_one_worker():
assert kv_cache_config_override_blocks == KVCacheConfig(
num_blocks=16,
kv_cache_tensors=[
KVCacheTensor(size=mem_per_block_per_layer * 16, shared_by=["layer_1"]),
KVCacheTensor(size=mem_per_block_per_layer * 16, shared_by=["layer_2"]),
KVCacheTensor(
size=mem_per_block_per_layer * 16 * 2,
layers=["layer_1", "layer_2"],
layer_stride=mem_per_block_per_layer * 16,
block_stride=mem_per_block_per_layer,
),
],
kv_cache_groups=[KVCacheGroupSpec(["layer_1", "layer_2"], new_kv_cache_spec())],
)
@@ -2319,6 +2308,45 @@ def test_hidden_state_group_preserves_hybrid_prefix_cache_granularity():
) == (544, 136)
def test_multi_run_layer_compact_strides_place_hoisted_heads():
"""A layer-compact run region is its own dense allocation: under LHBNC the head
groups sit between the layers and the blocks, so a run's block stride is one head
group's slice, not the whole page (regression test for setStorage out-of-bounds
on ROCm hybrid models)."""
full = new_kv_cache_spec()
swa = new_sliding_window_spec(sliding_window=full.block_size * 2)
page = full.page_size_bytes
assert swa.page_size_bytes == page
vllm_config = VllmConfig(model_config=ModelConfig(max_model_len=16))
vllm_config.cache_config.kv_cache_layout = "LHBNC"
config = kv_cache_utils.get_kv_cache_config_from_groups(
vllm_config,
[
KVCacheGroupSpec(["full.0", "full.1"], full),
KVCacheGroupSpec(["swa.0", "swa.1"], swa),
],
available_memory=4 * page * 8,
)
num_blocks = config.num_blocks
head_group = full.block_size * full.state_content_size_bytes
for tensor in config.kv_cache_tensors:
# One head group per block, head groups between the layers and blocks.
assert tensor.block_stride == head_group
assert tensor.layer_stride == full.num_heads * num_blocks * head_group
# The last block of the last layer's last head group must stay within the
# allocation (the old page-based stride overran it).
end = (
tensor.offset
+ tensor.layer_stride * (len(tensor.layers) - 1)
+ (full.num_heads - 1) * num_blocks * head_group
+ tensor.block_stride * (num_blocks - 1)
+ head_group
)
assert end <= tensor.size
def test_mla_draft_prefers_standard_layout_when_pages_can_be_unified():
specs = {
"target.0.attn": new_mla_spec(),
@@ -2640,6 +2668,7 @@ def test_auto_fit_max_model_len():
# Simulate the user passing -1 by setting original_max_model_len
model_config.original_max_model_len = -1
vllm_config = VllmConfig(model_config=model_config)
vllm_config.cache_config.kv_cache_layout = "LBNHC"
mem_per_block_per_layer = 16 * 2 * 64 * 4 * 2 # 16KB per block per layer
kv_cache_specs = {
@@ -2658,6 +2687,7 @@ def test_auto_fit_max_model_len():
model_config = ModelConfig(max_model_len=1024)
model_config.original_max_model_len = -1
vllm_config = VllmConfig(model_config=model_config)
vllm_config.cache_config.kv_cache_layout = "LBNHC"
# With limited memory, max_model_len should be reduced
# Need memory for at least max_model_len tokens
@@ -2678,6 +2708,7 @@ def test_auto_fit_max_model_len_with_hybrid():
# Simulate the user passing -1 by setting original_max_model_len
model_config.original_max_model_len = -1
vllm_config = VllmConfig(model_config=model_config)
vllm_config.cache_config.kv_cache_layout = "LBNHC"
mem_per_block_per_layer = 16 * 2 * 64 * 4 * 2 # 16KB per block per layer
gamma = 2
@@ -2700,6 +2731,7 @@ def test_auto_fit_max_model_len_not_triggered():
model_config = ModelConfig(max_model_len=16)
# original_max_model_len should be None by default, not -1
vllm_config = VllmConfig(model_config=model_config)
vllm_config.cache_config.kv_cache_layout = "LBNHC"
mem_per_block_per_layer = 16 * 2 * 64 * 4 * 2
kv_cache_specs = {
@@ -2722,6 +2754,7 @@ def test_auto_fit_max_model_len_respects_num_gpu_blocks_override():
model_config = ModelConfig(max_model_len=16384)
model_config.original_max_model_len = -1 # request auto-fit
vllm_config = VllmConfig(model_config=model_config)
vllm_config.cache_config.kv_cache_layout = "LBNHC"
# Cap the cache to 32 blocks regardless of available memory.
vllm_config.cache_config.num_gpu_blocks_override = 32
@@ -2747,6 +2780,7 @@ def test_check_enough_kv_cache_memory_respects_num_gpu_blocks_override():
"""
model_config = ModelConfig(max_model_len=16384)
vllm_config = VllmConfig(model_config=model_config)
vllm_config.cache_config.kv_cache_layout = "LBNHC"
# 32 blocks is far too small for max_model_len=16384 (would need 1024).
vllm_config.cache_config.num_gpu_blocks_override = 32
@@ -2769,14 +2803,13 @@ def test_unify_kv_cache_page_size_uses_padding_for_non_divisible_sizes():
128-dim KV heads. The resulting page sizes are 3:2 rather than an integer
block-size multiple, so the smaller page must be padded instead.
"""
# Both layers' backends opt into the padded-page strided view (e.g.
# FlashAttention / its DiffKV subclass), so padding is allowed.
# Attention layers read padded pages through a strided view, so padding is
# allowed.
target_spec = new_kv_cache_spec(
block_size=16,
num_kv_heads=1,
head_size=192,
dtype=torch.bfloat16,
indexes_kv_by_block_stride=True,
)
draft_spec = new_sliding_window_spec(
block_size=16,
@@ -2784,7 +2817,6 @@ def test_unify_kv_cache_page_size_uses_padding_for_non_divisible_sizes():
head_size=128,
dtype=torch.bfloat16,
sliding_window=1024,
indexes_kv_by_block_stride=True,
)
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():
assert unified_draft_spec.page_size_bytes == target_spec.page_size_bytes
def test_unify_kv_cache_page_size_padding_requires_backend_support():
"""Padding is gated on the backend declaring ``indexes_kv_by_block_stride``.
A backend that does not support the strided padded-page view must raise
rather than silently padding (and misreading KV at runtime).
"""
target_spec = new_kv_cache_spec(
block_size=16,
num_kv_heads=1,
head_size=192,
dtype=torch.bfloat16,
indexes_kv_by_block_stride=True,
)
# The non-divisible draft layer needs padding but its backend does not
# support the strided padded-page view -> must raise, not silently pad.
draft_spec = new_sliding_window_spec(
block_size=16,
num_kv_heads=1,
head_size=128,
dtype=torch.bfloat16,
sliding_window=1024,
indexes_kv_by_block_stride=False,
)
specs = {"target_attn": target_spec, "draft_attn": draft_spec}
with pytest.raises(NotImplementedError):
kv_cache_utils.unify_kv_cache_spec_page_size(specs)
def test_unpadded_page_size_includes_per_token_head_scales():
# Per-token-head quant carries inline fp32 scales that are carved from the
# 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():
def test_page_size_padded_wins():
# An explicit padded page size takes precedence over the unpadded size.
spec = new_kv_cache_spec(
dtype=torch.uint8,
kv_quant_mode=KVQuantMode.FP8_PER_TOKEN_HEAD,
page_size_padded=65536,
from vllm.v1.attention.backends.triton_attn import TritonAttentionBackend
spec = TritonAttentionBackend.customize_spec(
new_kv_cache_spec(
dtype=torch.uint8,
kv_quant_mode=KVQuantMode.FP8_PER_TOKEN_HEAD,
page_size_padded=65536,
)
)
assert spec.page_size_bytes == 65536
@@ -2996,14 +3003,18 @@ def test_unify_kv_cache_spec_page_size_mamba():
)
assert unified["mamba_layer"].page_size_bytes == 32768
# 4. Attention layers with non-divisible page sizes still raise.
with pytest.raises(NotImplementedError):
kv_cache_utils.unify_kv_cache_spec_page_size(
{
"attn_layer": new_kv_cache_spec(block_size=24), # 24576
"draft_attn_layer": draft_attn_spec, # 32768
}
)
# 4. Attention layers with non-divisible page sizes are padded too: every
# backend reads a padded page through the view's block stride, so there is
# no longer a case that must raise.
unified = kv_cache_utils.unify_kv_cache_spec_page_size(
{
"attn_layer": new_kv_cache_spec(block_size=24), # 24576
"draft_attn_layer": draft_attn_spec, # 32768
}
)
assert unified["attn_layer"].page_size_padded == 32768
assert unified["attn_layer"].page_size_bytes == 32768
assert unified["attn_layer"].block_size == 24
# 5. Uniform page sizes are returned unchanged.
specs = {
@@ -3135,6 +3146,7 @@ def test_auto_fit_max_model_len_reserves_null_block():
model_config = ModelConfig(max_model_len=1024)
model_config.original_max_model_len = -1
vllm_config = VllmConfig(model_config=model_config)
vllm_config.cache_config.kv_cache_layout = "LBNHC"
spec = new_kv_cache_spec(block_size=block_size)
# Exactly the 1024 / 16 = 64 blocks a full-length request would need.
+5 -5
View File
@@ -154,7 +154,7 @@ def make_kv_cache_config_hybrid_model(
elif second_spec_type == "mamba":
second_spec = MambaSpec(
block_size=block_size,
shapes=(1, 1),
shapes=((1, 1),),
dtypes=(torch.float32,),
)
@@ -189,7 +189,7 @@ def make_kv_cache_config_three_types(
if third_spec_type == "mamba":
third_spec = MambaSpec(
block_size=block_size,
shapes=(1, 1),
shapes=((1, 1),),
dtypes=(torch.float32,),
)
elif third_spec_type == "sliding_window":
@@ -768,12 +768,12 @@ def _make_hybrid_kv_cache_config(
),
"mamba": lambda: MambaSpec(
block_size=block_size,
shapes=(1, 1),
shapes=((1, 1),),
dtypes=(torch.float32,),
),
"mamba_align": lambda: MambaSpec(
block_size=block_size,
shapes=(1, 1),
shapes=((1, 1),),
dtypes=(torch.float32,),
mamba_cache_mode="align",
),
@@ -3273,7 +3273,7 @@ def test_hybrid_local_kv_retention_interval_survives_recycling():
num_kv_heads=1,
head_size=1,
dtype=torch.uint8,
compress_ratio=4,
tokens_per_state=4,
),
),
KVCacheGroupSpec(
@@ -1329,6 +1329,7 @@ def test_engine_core_proc_instantiation_cuda_empty(monkeypatch: pytest.MonkeyPat
)
mock_executor.get_kv_cache_specs.return_value = [{"default": mock_spec}]
mock_executor.get_supported_kv_cache_layouts.return_value = [["LBNHC"]]
mock_executor.determine_available_memory.return_value = [1024 * 1024 * 1024]
mock_executor.initialize_from_config.return_value = None
mock_executor.supports_draft_weight_updates.return_value = False
@@ -36,7 +36,9 @@ if [[ -n "$VLLM_SERVE_EXTRA_ARGS" ]]; then
echo "vLLM serve extra args: $VLLM_SERVE_EXTRA_ARGS"
fi
DECODER_KV_LAYOUT=${DECODER_KV_LAYOUT:-"HND"} # Default to HND, optional NHD
# Empty leaves the layout to the backends, for models that constrain it.
PREFILLER_KV_LAYOUT=${PREFILLER_KV_LAYOUT-"HND"}
DECODER_KV_LAYOUT=${DECODER_KV_LAYOUT-"HND"} # Default to HND, optional NHD
if [[ "$DECODER_KV_LAYOUT" == "NHD" ]]; then
KV_CONFIG_HETERO_LAYOUT=',"enable_permute_local_kv":"True"'
else
@@ -44,9 +46,9 @@ else
fi
if [[ "$CROSS_LAYERS_BLOCKS" == "True" ]]; then
KV_EXTRA_CONFIG=',"kv_connector_extra_config":{"enable_cross_layers_blocks": "True"}'
else
KV_EXTRA_CONFIG=''
# Cross-layer blocks are exercised via the block-outermost layout.
PREFILLER_KV_LAYOUT="BLHNC"
DECODER_KV_LAYOUT="BLHNC"
fi
# Connector: default pull NixlConnector; NixlPushConnector enables PP prefill.
@@ -54,11 +56,11 @@ KV_CONNECTOR=${KV_CONNECTOR:-NixlConnector}
# Build the kv-transfer-config for P and D
if [[ "$KV_BUFFER_DEVICE" == "cuda" ]]; then
KV_CONFIG_P='{"kv_connector":"'"$KV_CONNECTOR"'","kv_role":"kv_producer"'${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}'}'
KV_CONFIG_D='{"kv_connector":"'"$KV_CONNECTOR"'","kv_role":"kv_consumer"'${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}'}'
KV_CONFIG_P='{"kv_connector":"'"$KV_CONNECTOR"'","kv_role":"kv_producer"'${KV_CONFIG_HETERO_LAYOUT}'}'
KV_CONFIG_D='{"kv_connector":"'"$KV_CONNECTOR"'","kv_role":"kv_consumer"'${KV_CONFIG_HETERO_LAYOUT}'}'
else
KV_CONFIG_P="{\"kv_connector\":\"$KV_CONNECTOR\",\"kv_role\":\"kv_producer\",\"kv_buffer_device\":\"$KV_BUFFER_DEVICE\""${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}"}"
KV_CONFIG_D="{\"kv_connector\":\"$KV_CONNECTOR\",\"kv_role\":\"kv_consumer\",\"kv_buffer_device\":\"$KV_BUFFER_DEVICE\""${KV_CONFIG_HETERO_LAYOUT}${KV_EXTRA_CONFIG}"}"
KV_CONFIG_P="{\"kv_connector\":\"${KV_CONNECTOR}\",\"kv_role\":\"kv_producer\",\"kv_buffer_device\":\"${KV_BUFFER_DEVICE}\"${KV_CONFIG_HETERO_LAYOUT}}"
KV_CONFIG_D="{\"kv_connector\":\"${KV_CONNECTOR}\",\"kv_role\":\"kv_consumer\",\"kv_buffer_device\":\"${KV_BUFFER_DEVICE}\"${KV_CONFIG_HETERO_LAYOUT}}"
fi
# Models to run
@@ -176,7 +178,7 @@ run_tests_for_model() {
# Build the command with or without model-specific args
BASE_CMD="CUDA_VISIBLE_DEVICES=$GPU_ID \
VLLM_KV_CACHE_LAYOUT='HND' \
${PREFILLER_KV_LAYOUT:+VLLM_KV_CACHE_LAYOUT=$PREFILLER_KV_LAYOUT} \
VLLM_PORT=$INTERNAL_PORT \
UCX_NET_DEVICES=all \
VLLM_NIXL_SIDE_CHANNEL_PORT=$SIDE_CHANNEL_PORT \
@@ -239,7 +241,7 @@ run_tests_for_model() {
# Build the command with or without model-specific args
BASE_CMD="CUDA_VISIBLE_DEVICES=$GPU_ID \
VLLM_KV_CACHE_LAYOUT=$DECODER_KV_LAYOUT \
${DECODER_KV_LAYOUT:+VLLM_KV_CACHE_LAYOUT=$DECODER_KV_LAYOUT} \
$DECODER_INTERNAL_PORT_ENV \
UCX_NET_DEVICES=all \
VLLM_NIXL_SIDE_CHANNEL_PORT=$SIDE_CHANNEL_PORT \
@@ -9,8 +9,8 @@
# test_accuracy.py.
#
# By default runs two configurations:
# 1. Normal KV layout (NixlConnector without cross-layer blocks)
# 2. Cross-layer KV layout (NixlConnector with enable_cross_layers_blocks)
# 1. Normal KV layout (LBHNC)
# 2. Cross-layer KV layout (BLHNC) via VLLM_KV_CACHE_LAYOUT
#
# Usage:
# bash tests/v1/kv_connector/nixl_integration/run_multi_connector_accuracy_test.sh
@@ -55,8 +55,6 @@ fi
# ── KV transfer configs ─────────────────────────────────────────────────
# Normal layout: OffloadingConnector prefers cross-layer but NixlConnector
# does not, so MultiConnector.prefer_cross_layer_blocks = False.
KV_CONFIG_NORMAL='{
"kv_connector":"MultiConnector",
"kv_role":"kv_both",
@@ -72,22 +70,6 @@ KV_CONFIG_NORMAL='{
# Remove whitespace for CLI safety
KV_CONFIG_NORMAL=$(echo "$KV_CONFIG_NORMAL" | tr -d '[:space:]')
# Cross-layer layout: both connectors prefer cross-layer blocks.
KV_CONFIG_CROSS_LAYERS='{
"kv_connector":"MultiConnector",
"kv_role":"kv_both",
"kv_connector_extra_config":{
"connectors":[
{"kv_connector":"NixlConnector","kv_role":"kv_both",
"kv_buffer_device":"'$KV_BUFFER_DEVICE'",
"kv_connector_extra_config":{"enable_cross_layers_blocks":"True"}},
{"kv_connector":"OffloadingConnector","kv_role":"kv_both",
"kv_connector_extra_config":{"cpu_bytes_to_use":1000000000}}
]
}
}'
KV_CONFIG_CROSS_LAYERS=$(echo "$KV_CONFIG_CROSS_LAYERS" | tr -d '[:space:]')
# ── Helpers ──────────────────────────────────────────────────────────────
PROCESS_GROUPS=()
@@ -218,7 +200,7 @@ run_tests_for_model() {
# ── Start prefill instance ──
echo "Starting prefill instance on GPU $PREFILL_GPU, port $PREFILL_PORT"
BASE_CMD="$DEVICE_VISIBILITY_ENV=$PREFILL_GPU \
VLLM_KV_CACHE_LAYOUT='HND' \
VLLM_KV_CACHE_LAYOUT='${VLLM_KV_CACHE_LAYOUT:-LBHNC}' \
UCX_NET_DEVICES=all \
VLLM_NIXL_SIDE_CHANNEL_PORT=$PREFILL_SIDE_CHANNEL_PORT \
vllm serve $model_name \
@@ -245,7 +227,7 @@ run_tests_for_model() {
# ── Start decode instance ──
echo "Starting decode instance on GPU $DECODE_GPU, port $DECODE_PORT"
BASE_CMD="$DEVICE_VISIBILITY_ENV=$DECODE_GPU \
VLLM_KV_CACHE_LAYOUT='HND' \
VLLM_KV_CACHE_LAYOUT='${VLLM_KV_CACHE_LAYOUT:-LBHNC}' \
UCX_NET_DEVICES=all \
VLLM_NIXL_SIDE_CHANNEL_PORT=$DECODE_SIDE_CHANNEL_PORT \
vllm serve $model_name \
@@ -305,7 +287,8 @@ for model in "${MODELS[@]}"; do
fi
if [[ -z "${SKIP_CROSS_LAYERS:-}" ]]; then
run_tests_for_model "$model" "$KV_CONFIG_CROSS_LAYERS" "MultiConnector cross-layer layout"
VLLM_KV_CACHE_LAYOUT=BLHNC \
run_tests_for_model "$model" "$KV_CONFIG_NORMAL" "MultiConnector cross-layer layout"
fi
done
@@ -95,7 +95,7 @@ run_tests_for_model() {
# ── Start prefill instance ──
echo "Starting prefill instance on GPU $PREFILL_GPU, port $PREFILL_PORT"
BASE_CMD="CUDA_VISIBLE_DEVICES=$PREFILL_GPU \
VLLM_KV_CACHE_LAYOUT='HND' \
VLLM_KV_CACHE_LAYOUT='LBHNC' \
UCX_NET_DEVICES=all \
VLLM_NIXL_SIDE_CHANNEL_PORT=$PREFILL_SIDE_CHANNEL_PORT \
vllm serve \"$model_name\" \
@@ -121,7 +121,7 @@ run_tests_for_model() {
# ── Start decode instance ──
echo "Starting decode instance on GPU $DECODE_GPU, port $DECODE_PORT"
BASE_CMD="CUDA_VISIBLE_DEVICES=$DECODE_GPU \
VLLM_KV_CACHE_LAYOUT='HND' \
VLLM_KV_CACHE_LAYOUT='LBHNC' \
UCX_NET_DEVICES=all \
VLLM_NIXL_SIDE_CHANNEL_PORT=$DECODE_SIDE_CHANNEL_PORT \
vllm serve \"$model_name\" \
@@ -249,7 +249,7 @@ run_test_for_device() {
echo "Starting prefill instance $i on GPU $GPU_ID, port $PORT"
env \
${GPU_DEVICE_VAR}="$GPU_ID" \
VLLM_KV_CACHE_LAYOUT='HND' \
VLLM_KV_CACHE_LAYOUT='LBHNC' \
UCX_NET_DEVICES=all \
${VLLM_SSM_CONV_STATE_LAYOUT:+VLLM_SSM_CONV_STATE_LAYOUT=$VLLM_SSM_CONV_STATE_LAYOUT} \
VLLM_NIXL_SIDE_CHANNEL_HOST="$NIXL_SIDE_CHANNEL_HOST" \
@@ -288,7 +288,7 @@ run_test_for_device() {
echo "Starting decode instance $i on GPU $GPU_ID, port $PORT"
env \
${GPU_DEVICE_VAR}="$GPU_ID" \
VLLM_KV_CACHE_LAYOUT='HND' \
VLLM_KV_CACHE_LAYOUT='LBHNC' \
UCX_NET_DEVICES=all \
${VLLM_SSM_CONV_STATE_LAYOUT:+VLLM_SSM_CONV_STATE_LAYOUT=$VLLM_SSM_CONV_STATE_LAYOUT} \
VLLM_NIXL_SIDE_CHANNEL_HOST="$NIXL_SIDE_CHANNEL_HOST" \
@@ -12,6 +12,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.canonical_mapping i
_verify_tiling,
derive_canonical_mappings,
)
from vllm.v1.attention.backends.triton_attn import TritonAttentionBackend
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheGroupSpec,
@@ -37,12 +38,14 @@ def _ctx(rank, tp=1, dcp=1, pcp=1, interleave=1, total=None, heads=2):
def _full_spec(num_kv_heads: int = 2, **kwargs) -> FullAttentionSpec:
# block_size=4, head_dim=64, int8
return FullAttentionSpec(
block_size=4,
num_kv_heads=num_kv_heads,
head_size=64,
dtype=torch.int8,
**kwargs,
return TritonAttentionBackend.customize_spec(
FullAttentionSpec(
block_size=4,
num_kv_heads=num_kv_heads,
head_size=64,
dtype=torch.int8,
**kwargs,
)
)
@@ -381,7 +384,9 @@ def test_fail_closed_cases():
quant_spec = _full_spec(kv_quant_mode=KVQuantMode.FP8_PER_TOKEN_HEAD)
assert _try_mapping(quant_spec, _split_nhd_cache(quant_spec), _ctx(0, tp=4)) is None
# Compressed MLA slots are not 1:1 with tokens
assert _try_mapping(_mla_spec(compress_ratio=2), None, _ctx(0, tp=2, dcp=2)) is None
assert (
_try_mapping(_mla_spec(tokens_per_state=2), None, _ctx(0, tp=2, dcp=2)) is None
)
# Unrecognized physical layouts
swapped = torch.zeros(
NUM_BLOCKS,
@@ -72,43 +72,54 @@ def _make_vllm_config(
def _make_kv_cache_config() -> KVCacheConfig:
num_blocks = 16
spec = FullAttentionSpec(
block_size=16,
num_kv_heads=1,
head_size=1,
dtype=torch.float32,
)
kv_tensor = KVCacheTensor(
size=num_blocks * 8,
shared_by=["layer"],
block_stride=0,
size=spec.page_size_bytes * num_blocks,
layers=["layer"],
layer_stride=spec.page_size_bytes * num_blocks,
block_stride=spec.page_size_bytes,
)
return KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=[kv_tensor],
kv_cache_groups=[
KVCacheGroupSpec(
["layer"],
FullAttentionSpec(
block_size=16,
num_kv_heads=1,
head_size=1,
dtype=torch.float32,
),
)
],
kv_cache_groups=[KVCacheGroupSpec(["layer"], spec)],
)
def _make_sizing_kv_cache_config(packed: bool) -> KVCacheConfig:
"""One 16 byte-per-block allocation, described two ways.
Packed: both layers are one dense run. Unpacked: the same bytes as two runs, the
second starting after the first layer's region. Either way the connector accounts
for 16 KV bytes per block.
"""
num_blocks = 4
page = 8
size = 2 * page * num_blocks
if packed:
kv_cache_tensors = [
KVCacheTensor(
size=64,
shared_by=[layer_name],
block_stride=16,
size=size,
layers=["layer0", "layer1"],
layer_stride=page * num_blocks,
block_stride=page,
)
for layer_name in ("layer0", "layer1")
]
else:
kv_cache_tensors = [
KVCacheTensor(size=40, shared_by=["layer0"]),
KVCacheTensor(size=24, shared_by=["layer1"]),
KVCacheTensor(
size=size,
layers=[layer],
layer_stride=page * num_blocks,
block_stride=page,
offset=i * page * num_blocks,
)
for i, layer in enumerate(("layer0", "layer1"))
]
return KVCacheConfig(
@@ -150,6 +161,18 @@ def _mla_spec(
)
_MAMBA_SPEC = MambaSpec(
block_size=16,
shapes=((16, 1),),
dtypes=(torch.float32,),
)
# Page sizes of the specs the replicated-layout cases below are built from.
_MLA_PAGE = _mla_spec().page_size_bytes
_HALF_MLA_PAGE = _mla_spec(head_size=256).page_size_bytes
_FULL_PAGE = _full_attention_spec().page_size_bytes
_MAMBA_PAGE = _MAMBA_SPEC.page_size_bytes
def _make_mla_kv_cache_config(
layer_names: list[str] | None = None,
head_size: int = 512,
@@ -159,12 +182,14 @@ def _make_mla_kv_cache_config(
if layer_names is None:
layer_names = ["layer0", "layer1"]
spec = _mla_spec(head_size=head_size, dtype=dtype)
layer_stride = spec.page_size_bytes * num_blocks
kv_cache_tensors = [
KVCacheTensor(
size=spec.page_size_bytes * num_blocks,
shared_by=[layer_name],
size=layer_stride * len(layer_names),
layers=layer_names,
layer_stride=layer_stride,
block_stride=spec.page_size_bytes,
)
for layer_name in layer_names
]
return KVCacheConfig(
num_blocks=num_blocks,
@@ -174,15 +199,31 @@ def _make_mla_kv_cache_config(
def _make_hybrid_kv_cache_config() -> KVCacheConfig:
num_blocks = 4
full_spec = _full_attention_spec(block_size=12)
mla_spec = _mla_spec()
# Mixed page sizes across overlaying groups: a block is a window of the largest
# group's packing, so the layer dim sits inside the block dim.
window = max(full_spec.page_size_bytes, mla_spec.page_size_bytes)
return KVCacheConfig(
num_blocks=4,
num_blocks=num_blocks,
kv_cache_tensors=[
KVCacheTensor(size=40, shared_by=["full_layer"]),
KVCacheTensor(size=24, shared_by=["mla_layer"]),
KVCacheTensor(
size=window * num_blocks,
layers=["full_layer"],
layer_stride=full_spec.page_size_bytes,
block_stride=window,
),
KVCacheTensor(
size=window * num_blocks,
layers=["mla_layer"],
layer_stride=mla_spec.page_size_bytes,
block_stride=window,
),
],
kv_cache_groups=[
KVCacheGroupSpec(["full_layer"], _full_attention_spec(block_size=12)),
KVCacheGroupSpec(["mla_layer"], _mla_spec()),
KVCacheGroupSpec(["full_layer"], full_spec),
KVCacheGroupSpec(["mla_layer"], mla_spec),
],
)
@@ -263,18 +304,9 @@ def test_worker_kv_bytes_preserves_tensor_layout(packed: bool):
assert offloading_config.cache.blocks_per_chunk == 2
def test_rejects_partially_packed_tensor_layout():
kv_cache_config = _make_sizing_kv_cache_config(packed=False)
kv_cache_config.kv_cache_tensors[0].block_stride = 16
with pytest.raises(AssertionError):
build_offloading_config(_make_vllm_config(), kv_cache_config)
def test_zero_blocks_skips_tensor_layout_validation():
kv_cache_config = _make_sizing_kv_cache_config(packed=False)
kv_cache_config.num_blocks = 0
kv_cache_config.kv_cache_tensors[0].block_stride = 16
offloading_config = build_offloading_config(_make_vllm_config(), kv_cache_config)
@@ -374,8 +406,10 @@ def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
num_blocks=4,
kv_cache_tensors=[
KVCacheTensor(
size=_mla_spec().page_size_bytes * 4,
shared_by=["layer"],
size=_MLA_PAGE * 4,
layers=["layer"],
layer_stride=_MLA_PAGE * 4,
block_stride=_MLA_PAGE,
)
],
kv_cache_groups=[
@@ -398,8 +432,10 @@ def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
num_blocks=4,
kv_cache_tensors=[
KVCacheTensor(
size=_mla_spec().page_size_bytes * 4,
shared_by=["layer"],
size=_MLA_PAGE * 4,
layers=["layer"],
layer_stride=_MLA_PAGE * 4,
block_stride=_MLA_PAGE,
)
],
kv_cache_groups=[
@@ -417,16 +453,22 @@ def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
"hidden-state",
),
(
# One group, two page sizes: layer1's run starts past layer0's region.
KVCacheConfig(
num_blocks=4,
kv_cache_tensors=[
KVCacheTensor(
size=_mla_spec().page_size_bytes * 4,
shared_by=["layer0"],
size=(_MLA_PAGE + _HALF_MLA_PAGE) * 4,
layers=["layer0"],
layer_stride=_MLA_PAGE * 4,
block_stride=_MLA_PAGE,
),
KVCacheTensor(
size=_mla_spec(head_size=256).page_size_bytes * 4,
shared_by=["layer1"],
size=(_MLA_PAGE + _HALF_MLA_PAGE) * 4,
layers=["layer1"],
layer_stride=_HALF_MLA_PAGE * 4,
block_stride=_HALF_MLA_PAGE,
offset=_MLA_PAGE * 4,
),
],
kv_cache_groups=[
@@ -445,16 +487,22 @@ def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
"uniform-wrapper",
),
(
# Overlaid groups with different page sizes: a block is a window of
# the largest group's packing.
KVCacheConfig(
num_blocks=4,
kv_cache_tensors=[
KVCacheTensor(
size=_mla_spec().page_size_bytes * 4,
shared_by=["mla"],
size=_FULL_PAGE * 4,
layers=["mla"],
layer_stride=_MLA_PAGE,
block_stride=_FULL_PAGE,
),
KVCacheTensor(
size=_full_attention_spec().page_size_bytes * 4,
shared_by=["full"],
size=_FULL_PAGE * 4,
layers=["full"],
layer_stride=_FULL_PAGE,
block_stride=_FULL_PAGE,
),
],
kv_cache_groups=[
@@ -469,21 +517,21 @@ def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
num_blocks=4,
kv_cache_tensors=[
KVCacheTensor(
size=_mla_spec().page_size_bytes * 4,
shared_by=["mla"],
size=_MLA_PAGE * 4,
layers=["mla"],
layer_stride=_MLA_PAGE,
block_stride=_MLA_PAGE,
),
KVCacheTensor(
size=_MLA_PAGE * 4,
layers=["mamba"],
layer_stride=_MAMBA_PAGE,
block_stride=_MLA_PAGE,
),
KVCacheTensor(size=64 * 4, shared_by=["mamba"]),
],
kv_cache_groups=[
KVCacheGroupSpec(["mla"], _mla_spec()),
KVCacheGroupSpec(
["mamba"],
MambaSpec(
block_size=16,
shapes=((16, 1),),
dtypes=(torch.float32,),
),
),
KVCacheGroupSpec(["mamba"], _MAMBA_SPEC),
],
),
"mla-mamba-hybrid",
@@ -493,13 +541,12 @@ def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
num_blocks=4,
kv_cache_tensors=[
KVCacheTensor(
size=_mla_spec().page_size_bytes * 4,
shared_by=["layer0"],
),
KVCacheTensor(
size=_mla_spec().page_size_bytes * 4,
shared_by=["layer1"],
),
size=_MLA_PAGE * 4,
layers=[layer],
layer_stride=_MLA_PAGE * 4,
block_stride=_MLA_PAGE,
)
for layer in ("layer0", "layer1")
],
kv_cache_groups=[
KVCacheGroupSpec(["layer0"], _mla_spec()),
@@ -531,16 +578,26 @@ def test_replicated_layout_rejects_bare_mla_with_mixed_page_accounting():
indexer_spec = _mla_spec(head_size=128, dtype=torch.uint8)
main_layers = [f"main_{i}" for i in range(61)]
indexer_layers = [f"indexer_{i}" for i in range(61)]
# A DSA-style group: the main pages and the smaller indexer pages are packed one
# after the other, so a block holds more than 61 MLA pages.
main_bytes = main_spec.page_size_bytes * len(main_layers)
indexer_bytes = indexer_spec.page_size_bytes * len(indexer_layers)
size = (main_bytes + indexer_bytes) * num_blocks
kv_cache_config = KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=[
KVCacheTensor(
size=main_spec.page_size_bytes * len(main_layers) * num_blocks,
shared_by=main_layers,
size=size,
layers=main_layers,
layer_stride=main_spec.page_size_bytes * num_blocks,
block_stride=main_spec.page_size_bytes,
),
KVCacheTensor(
size=indexer_spec.page_size_bytes * len(indexer_layers) * num_blocks,
shared_by=indexer_layers,
size=size,
layers=indexer_layers,
layer_stride=indexer_spec.page_size_bytes * num_blocks,
block_stride=indexer_spec.page_size_bytes,
offset=main_bytes * num_blocks,
),
],
kv_cache_groups=[KVCacheGroupSpec(main_layers + indexer_layers, main_spec)],
@@ -665,34 +722,6 @@ def test_canonical_layout_certifies_v2_model_runner():
).parallel.is_parallelism_agnostic
def test_prefer_cross_layer_blocks_yields_to_canonical_layout():
"""The connector must not request cross-layer blocks under
canonical_layout: cross-layer slabs have no per-layer refs to certify."""
from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorRole
from vllm.distributed.kv_transfer.kv_connector.v1.offloading_connector import (
OffloadingConnector,
)
kv_cache_config = _make_kv_cache_config()
connector_module = "vllm.distributed.kv_transfer.kv_connector.v1"
def make_connector(extra_config: dict[str, Any] | None) -> OffloadingConnector:
with (
patch(f"{connector_module}.offloading_connector.OffloadingSpecFactory"),
patch(
f"{connector_module}.offloading_connector.OffloadingConnectorScheduler"
),
):
return OffloadingConnector(
_make_vllm_config(extra_config=extra_config),
KVConnectorRole.SCHEDULER,
kv_cache_config,
)
assert make_connector(None).prefer_cross_layer_blocks
assert not make_connector({"canonical_layout": True}).prefer_cross_layer_blocks
def test_parallelism_agnostic_disabled_on_v2_model_runner():
config = _make_vllm_config()
config.use_v2_model_runner = True
@@ -1,6 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections import defaultdict
from unittest.mock import MagicMock
import pytest
@@ -13,11 +12,11 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
from vllm.platforms import current_platform
from vllm.utils.torch_utils import get_dtype_size
from vllm.v1.attention.backends.registry import AttentionBackendEnum
from vllm.v1.attention.backends.utils import set_kv_cache_layout
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheConfig,
KVCacheGroupSpec,
KVCacheLayout,
KVCacheTensor,
MambaSpec,
MLAAttentionSpec,
@@ -65,40 +64,18 @@ elif current_platform.is_xpu():
# ---------------------------------------------------------------------------
def _allocate_and_reshape_kv_caches(
def _allocate_kv_caches(
kv_cache_config: KVCacheConfig,
attn_groups: list[list],
device: torch.device,
):
"""
Use the real GPUModelRunner allocation and reshape methods to produce
kv_caches, just like the model runner does during initialization.
"""
from vllm.v1.worker.gpu_model_runner import GPUModelRunner
"""Allocate kv_caches exactly as the model runner does at startup."""
from vllm.v1.worker.utils import allocate_kv_cache
# Some backends (e.g. FlashAttention) query the KV cache layout during
# reshape, which ultimately calls get_current_vllm_config(). Setting
# the layout override avoids needing a full VllmConfig context.
set_kv_cache_layout("NHD")
try:
runner = object.__new__(GPUModelRunner)
runner.device = device
runner.runner_only_attn_layers = set()
runner.attn_groups = attn_groups
runner.kv_cache_config = kv_cache_config
runner.cache_config = MagicMock(cache_dtype="auto")
runner.shared_kv_cache_layers = {}
runner.model_config = MagicMock()
runner.model_config.hf_config.model_type = ""
runner.compilation_config = MagicMock(
static_forward_context=defaultdict(MagicMock)
)
runner.kv_caches = []
kernel_block_sizes = [BLOCK_SIZE] * len(kv_cache_config.kv_cache_groups)
return runner.initialize_kv_cache_tensors(kv_cache_config, kernel_block_sizes)
finally:
set_kv_cache_layout(None)
kernel_block_sizes = [BLOCK_SIZE] * len(kv_cache_config.kv_cache_groups)
return allocate_kv_cache(
kv_cache_config, device, KVCacheLayout.LBNHC, kernel_block_sizes
)
def _single_rank_vllm_config(total_kv_heads: int):
@@ -321,10 +298,9 @@ def test_register_kv_caches(backend):
Creates one FullAttention group, one MLA group, one Mamba group, and
one Mamba-padded group. Each group has GROUP_SIZE layers.
KVCacheTensors are shared across all groups mirroring the real allocation
in kv_cache_utils.py: tensor i is shared by layer i from every group.
The padded-mamba group has a different page size so its layers get their
own dedicated tensors.
KVCacheTensors mirror the real allocation in kv_cache_utils.py: each group is one
run of the shared allocation starting at offset 0, so layer i of every group
aliases the same bytes.
Uses the real GPUModelRunner.initialize_kv_cache_tensors to produce
the raw per-layer kv_caches registered by the connector.
@@ -413,18 +389,15 @@ def test_register_kv_caches(backend):
aligned_mamba_layer_names,
]
kv_cache_tensors: list[KVCacheTensor] = []
for i in range(GROUP_SIZE):
shared_by: list[str] = []
for group_layer_names in layer_groups:
if len(group_layer_names) > i:
shared_by.append(group_layer_names[i])
kv_cache_tensors.append(
KVCacheTensor(
size=PAGE_SIZE_BYTES * NUM_BLOCKS,
shared_by=shared_by,
)
kv_cache_tensors: list[KVCacheTensor] = [
KVCacheTensor(
size=PAGE_SIZE_BYTES * NUM_BLOCKS * GROUP_SIZE,
layers=group_layer_names,
layer_stride=PAGE_SIZE_BYTES * NUM_BLOCKS,
block_stride=PAGE_SIZE_BYTES,
)
for group_layer_names in layer_groups
]
kv_cache_groups = [
KVCacheGroupSpec(layer_names=attn_layer_names, kv_cache_spec=attn_spec),
@@ -472,7 +445,7 @@ def test_register_kv_caches(backend):
kv_cache_groups=kv_cache_groups,
)
kv_caches = _allocate_and_reshape_kv_caches(
kv_caches = _allocate_kv_caches(
kv_cache_config,
attn_groups,
device=torch.device(f"{DEVICE_TYPE}:0"),
@@ -580,16 +553,24 @@ def test_register_kv_caches_uniform_type(backend):
kv_cache_specs={layer_a: spec_a, layer_b: spec_b},
)
# The group packs its two layers densely, one run each as their pages differ:
# layer_a occupies the first page of every block, layer_b the rest.
window = spec_a.page_size_bytes + spec_b.page_size_bytes
kv_cache_config = KVCacheConfig(
num_blocks=NUM_BLOCKS,
kv_cache_tensors=[
KVCacheTensor(
size=spec_a.page_size_bytes * NUM_BLOCKS,
shared_by=[layer_a],
size=window * NUM_BLOCKS,
layers=[layer_a],
layer_stride=spec_a.page_size_bytes * NUM_BLOCKS,
block_stride=spec_a.page_size_bytes,
),
KVCacheTensor(
size=spec_b.page_size_bytes * NUM_BLOCKS,
shared_by=[layer_b],
size=window * NUM_BLOCKS,
layers=[layer_b],
layer_stride=spec_b.page_size_bytes * NUM_BLOCKS,
block_stride=spec_b.page_size_bytes,
offset=spec_a.page_size_bytes * NUM_BLOCKS,
),
],
kv_cache_groups=[
@@ -617,7 +598,7 @@ def test_register_kv_caches_uniform_type(backend):
]
]
kv_caches = _allocate_and_reshape_kv_caches(
kv_caches = _allocate_kv_caches(
kv_cache_config,
attn_groups,
device=torch.device(f"{DEVICE_TYPE}:0"),
@@ -241,13 +241,19 @@ class RequestRunner:
)
]
# Groups overlay one allocation, sized by the largest group.
block_stride = max(
group.kv_cache_spec.page_size_bytes * len(group.layer_names)
for group in kv_cache_groups
)
kv_cache_tensors = [
KVCacheTensor(
size=group.kv_cache_spec.page_size_bytes * num_gpu_blocks,
shared_by=[layer_name],
size=block_stride * num_gpu_blocks,
layers=list(group.layer_names),
layer_stride=group.kv_cache_spec.page_size_bytes * num_gpu_blocks,
block_stride=group.kv_cache_spec.page_size_bytes,
)
for group in kv_cache_groups
for layer_name in group.layer_names
]
kv_cache_config = KVCacheConfig(
@@ -277,7 +283,6 @@ class RequestRunner:
)
# register worker kv_caches to enable OffloadingWorker creations
# set_current_vllm_config is needed for get_kv_cache_layout() to work
kv_caches: dict[str, torch.Tensor] = {}
for group in kv_cache_groups:
spec = group.kv_cache_spec
@@ -98,7 +98,7 @@ def _make_connector_with_fake_worker(
)
worker = connector.connector_worker
assert isinstance(worker.nixl_wrapper, FakeNixlWrapper)
worker.kv_cache_layout = "HND"
worker.kv_cache_layout = "LBHNC"
if do_handshake:
remote_agents, _ = worker._nixl_handshake(
host="localhost",
@@ -1,75 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import pytest
from vllm.platforms import current_platform
def test_mla_common_backend_rejects_cross_layer_kv_cache():
"""MLACommonBackend defaults to the identity permutation (layers dim
first) so MLA backends whose decode kernels are not verified to honor
the cache's block-dim stride stay opted out of cross-layer KV cache."""
from vllm.model_executor.layers.attention.mla_attention import (
MLACommonBackend,
)
stride_order = MLACommonBackend.get_kv_cache_stride_order(
include_num_layers_dimension=True
)
assert stride_order == (0, 1, 2, 3)
assert stride_order[0] == 0 # layers dim first => no cross-layer
assert MLACommonBackend.get_kv_cache_stride_order(
include_num_layers_dimension=False
) == (0, 1, 2)
@pytest.mark.parametrize(
"backend_path",
# See: https://github.com/vllm-project/vllm/issues/46411
[
"vllm.v1.attention.backends.mla.triton_mla.TritonMLABackend",
]
if current_platform.is_rocm() or current_platform.is_xpu()
else [
"vllm.v1.attention.backends.mla.triton_mla.TritonMLABackend",
"vllm.v1.attention.backends.mla.cutlass_mla.CutlassMLABackend",
"vllm.v1.attention.backends.mla.flashattn_mla.FlashAttnMLABackend",
"vllm.v1.attention.backends.mla.flashmla.FlashMLABackend",
"vllm.v1.attention.backends.mla.flashinfer_mla.FlashInferMLABackend",
],
)
def test_verified_mla_backends_support_cross_layer_kv_cache(backend_path):
"""Backends whose decode kernels honor the cache's block-dim stride opt
in to the cross-layer layout with a non-identity permutation placing
num_blocks first in physical layout."""
module_path, name = backend_path.rsplit(".", 1)
backend = getattr(
pytest.importorskip(module_path, reason="backend deps unavailable"), name
)
stride_order = backend.get_kv_cache_stride_order(include_num_layers_dimension=True)
assert stride_order == (1, 0, 2, 3)
assert stride_order[0] != 0 # num_blocks first => cross-layer supported
assert backend.get_kv_cache_stride_order(include_num_layers_dimension=False) == (
0,
1,
2,
)
def test_deepseek_v32_indexer_rejects_cross_layer_kv_cache():
"""DeepseekV32Indexer returns identity permutation (layers dim first)
to signal cross-layer KV cache is unsupported."""
from vllm.v1.attention.backends.mla.indexer import (
DeepseekV32IndexerBackend,
)
stride_order = DeepseekV32IndexerBackend.get_kv_cache_stride_order(
include_num_layers_dimension=True
)
assert stride_order == (0, 1, 2, 3)
assert stride_order[0] == 0 # layers dim first => no cross-layer
assert DeepseekV32IndexerBackend.get_kv_cache_stride_order(
include_num_layers_dimension=False
) == (0, 1, 2)
@@ -11,6 +11,7 @@ import pytest
import torch
import zmq.asyncio
from tests.v1.attention.utils import dense_kv_cache_views
from vllm import envs
from vllm.config import set_current_vllm_config
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
MooncakeBootstrapServer,
)
from vllm.utils.network_utils import get_open_port
from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheConfig,
KVCacheGroupSpec,
KVCacheLayout,
)
from vllm.v1.request import RequestStatus
@@ -1093,7 +1094,19 @@ async def test_worker_get_finished_timeout(monkeypatch):
assert "tx-active" in prefill_worker.reqs_need_send
def test_register_kv_caches():
@pytest.mark.parametrize(
("layout", "separate_kv_head_groups"),
[
(KVCacheLayout.LBHNC, False),
(KVCacheLayout.BLHNC, False),
(KVCacheLayout.BHLNC, False),
# LHBNC gives each head group its own region; the K/V split doubles the
# head count but the registration shape is driven by the layout.
(KVCacheLayout.LHBNC, False),
(KVCacheLayout.LHBNC, True),
],
)
def test_register_kv_caches(layout: KVCacheLayout, separate_kv_head_groups: bool):
"""Tests the memory registration logic with the underlying Mooncake engine."""
vllm_config = create_vllm_config(
@@ -1118,15 +1131,23 @@ def test_register_kv_caches():
worker = connector.connector_worker
mock_thread.return_value.is_alive.return_value = False
kv_cache_shape = FlashAttentionBackend.get_kv_cache_shape(
num_blocks=2, block_size=16, num_kv_heads=4, head_size=64
spec = FullAttentionSpec(
block_size=16,
num_kv_heads=4,
head_size=64,
dtype=torch.float16,
num_head_slots=2 if separate_kv_head_groups else None,
state_content_bytes=4 * 64 * 2 if separate_kv_head_groups else None,
)
tensor1 = torch.zeros(*kv_cache_shape, dtype=torch.float16)
tensor2 = torch.zeros(*kv_cache_shape, dtype=torch.float16)
kv_caches = {
"model.layers.0.self_attn": tensor1,
"model.layers.1.self_attn": tensor2,
}
layer_names = [
"model.layers.0.self_attn",
"model.layers.1.self_attn",
]
for layer_name in layer_names:
worker._layer_specs[layer_name] = spec
raw = torch.zeros(2 * 2 * spec.page_size_bytes, dtype=torch.int8)
tensor1, tensor2 = dense_kv_cache_views(raw, spec, 2, 2, layout)
kv_caches = dict(zip(layer_names, (tensor1, tensor2)))
with patch.object(
worker.engine, "batch_register_memory", return_value=0
@@ -1135,16 +1156,35 @@ def test_register_kv_caches():
mock_batch_register.assert_called_once()
registered_ptrs, registered_lens = mock_batch_register.call_args[0]
expected_ptrs = {tensor.data_ptr() for tensor in kv_caches.values()}
assert set(registered_ptrs) == expected_ptrs
assert set(registered_lens) == {tensor1.nbytes}
assert registered_ptrs == [raw.data_ptr()]
assert registered_lens == [raw.nbytes]
# Verify block_len_per_layer is set correctly.
assert len(worker.block_len_per_layer) == len(registered_ptrs)
for bl in worker.block_len_per_layer:
assert bl == tensor1.nbytes // tensor1.shape[0]
assert worker.registered_layer_names == list(kv_caches)
assert worker.registered_layer_indices == [0, 1]
if not layout.is_block_compact:
expected_addrs = [
cache[:, head_idx].data_ptr()
for cache in (tensor1, tensor2)
for head_idx in range(cache.shape[1])
]
head_block_bytes = tensor1.stride(0) * tensor1.element_size()
assert worker.kv_caches_base_addr == expected_addrs
assert worker.block_len_per_layer == [head_block_bytes] * len(
expected_addrs
)
assert worker.kv_block_len_per_layer == [head_block_bytes] * len(
expected_addrs
)
assert worker.registered_layer_names == [
layer_name
for layer_name in layer_names
for _ in range(tensor1.shape[1])
]
else:
assert len(worker.block_len_per_layer) == len(kv_caches)
for bl in worker.block_len_per_layer:
assert bl == tensor1.stride(0) * tensor1.element_size()
assert worker.kv_block_len_per_layer == [spec.page_size_bytes] * 2
assert worker.registered_layer_names == list(kv_caches)
assert worker.registered_layer_indices == [0, 1]
def test_register_kv_caches_supports_mixed_mla_and_eagle_shapes():
@@ -14,6 +14,7 @@ from unittest.mock import patch
import pytest
import torch
from tests.v1.attention.utils import dense_kv_cache_views
from vllm.config import set_current_vllm_config
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_connector import (
KVConnectorRole,
@@ -30,6 +31,7 @@ from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheConfig,
KVCacheGroupSpec,
KVCacheLayout,
MambaSpec,
)
@@ -138,14 +140,22 @@ def test_register_kv_caches_emits_fa_and_gdn_regions(monkeypatch):
)
worker = connector.connector_worker
fa_cache = torch.empty((2, 2, 11), dtype=torch.float16)
gdn_conv_state = torch.empty((2, 22), dtype=torch.float16)
gdn_ssm_state = torch.empty((2, 4), dtype=torch.float16)
num_blocks = kv_cache_config.num_blocks
fa_spec = kv_cache_config.kv_cache_groups[0].kv_cache_spec
gdn_spec = kv_cache_config.kv_cache_groups[1].kv_cache_spec
fa_raw = torch.empty(num_blocks * fa_spec.page_size_bytes, dtype=torch.int8)
gdn_raw = torch.empty(num_blocks * gdn_spec.page_size_bytes, dtype=torch.int8)
(fa_cache,) = dense_kv_cache_views(
fa_raw, fa_spec, num_blocks, 1, KVCacheLayout.LBHNC
)
(gdn_cache,) = dense_kv_cache_views(
gdn_raw, gdn_spec, num_blocks, 1, KVCacheLayout.LBHNC
)
worker.register_kv_caches(
{
"model.layers.0.self_attn": fa_cache,
"model.layers.1.linear_attn": (gdn_conv_state, gdn_ssm_state),
"model.layers.1.linear_attn": gdn_cache,
}
)
@@ -154,10 +164,14 @@ def test_register_kv_caches_emits_fa_and_gdn_regions(monkeypatch):
"model.layers.0.self_attn",
"model.layers.1.linear_attn",
]
assert worker.block_len_per_layer == [
fa_spec.page_size_bytes,
gdn_spec.page_size_bytes,
]
assert worker.registered_group_indices == [0, 1]
assert worker.kv_caches_base_addr == [
fa_cache.data_ptr(),
gdn_conv_state.data_ptr(),
gdn_cache.data_ptr(),
]
worker.shutdown()
@@ -185,8 +199,7 @@ def test_register_kv_caches_deduplicates_shared_backing_memory(monkeypatch):
backing = torch.empty((4, 64), dtype=torch.float16)
fa_cache = backing[:2, :16]
gdn_conv_state = backing[:3]
gdn_ssm_state = torch.empty((3, 4), dtype=torch.float16)
gdn_cache = backing[:3]
with patch.object(
worker.engine, "batch_register_memory", return_value=0
@@ -194,13 +207,13 @@ def test_register_kv_caches_deduplicates_shared_backing_memory(monkeypatch):
worker.register_kv_caches(
{
"model.layers.0.self_attn": fa_cache,
"model.layers.1.linear_attn": (gdn_conv_state, gdn_ssm_state),
"model.layers.1.linear_attn": gdn_cache,
}
)
assert worker.kv_caches_base_addr == [
fa_cache.data_ptr(),
gdn_conv_state.data_ptr(),
gdn_cache.data_ptr(),
]
batch_register_memory.assert_called_once()
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():
assert kernel_block_ids == [list(range(34, 51)), [2]]
def test_hybrid_gdn_splits_fa_regions_but_keeps_gdn_state_whole(
def test_hybrid_gdn_keeps_packed_fa_and_gdn_regions_whole(
monkeypatch,
):
monkeypatch.setenv("VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT", "5")
@@ -359,7 +372,7 @@ def test_hybrid_gdn_splits_fa_regions_but_keeps_gdn_state_whole(
)
worker = connector.connector_worker
worker.transfer_topo = SimpleNamespace(virtually_split_kv_in_blocks=True)
worker.transfer_topo = SimpleNamespace(is_kv_layout_blocks_first=False)
regions = worker._get_transfer_regions(
base_addrs=[0x1000, 0x2000],
block_lens=[0x100, 0x100],
@@ -377,7 +390,6 @@ def test_hybrid_gdn_splits_fa_regions_but_keeps_gdn_state_whole(
for region in regions
] == [
(0, 0x1000, 0x40),
(0, 0x1040, 0x40),
(1, 0x2000, 0x100),
]
@@ -48,7 +48,14 @@ def _make_kv_cache_config() -> KVCacheConfig:
spec = FullAttentionSpec(block_size=16, num_kv_heads=8, head_size=64, dtype=None)
return KVCacheConfig(
num_blocks=4,
kv_cache_tensors=[KVCacheTensor(size=8192, shared_by=["layer0"])],
kv_cache_tensors=[
KVCacheTensor(
size=8192,
layers=["layer0"],
layer_stride=8192,
block_stride=2048,
)
],
kv_cache_groups=[KVCacheGroupSpec(["layer0"], spec)],
)
@@ -210,64 +217,6 @@ def test_get_kv_connector_kv_cache_events_wraps_worker_events():
assert kv_events.get_all_events() == [event]
def test_prefer_cross_layer_blocks_from_config():
# Default: disabled
vllm_config = _make_vllm_config()
kv_cache_config = _make_kv_cache_config()
with (
set_current_vllm_config(vllm_config),
patch(
"vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
"connector.MooncakeStoreScheduler"
),
):
connector = mooncake_store_connector.MooncakeStoreConnector(
vllm_config, KVConnectorRole.SCHEDULER, kv_cache_config
)
assert connector.prefer_cross_layer_blocks is False
# Enabled via config
vllm_config_enabled = create_vllm_config(
kv_connector="MooncakeStoreConnector",
kv_role="kv_both",
kv_connector_extra_config={"enable_cross_layers_blocks": "true"},
)
with (
set_current_vllm_config(vllm_config_enabled),
patch(
"vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
"connector.MooncakeStoreScheduler"
),
):
connector_enabled = mooncake_store_connector.MooncakeStoreConnector(
vllm_config_enabled, KVConnectorRole.SCHEDULER, kv_cache_config
)
assert connector_enabled.prefer_cross_layer_blocks is True
def test_register_cross_layers_kv_cache_delegates_to_worker():
vllm_config = _make_vllm_config()
kv_cache_config = _make_kv_cache_config()
with (
set_current_vllm_config(vllm_config),
patch(
"vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
"connector.MooncakeStoreWorker"
) as mock_worker_cls,
):
connector = mooncake_store_connector.MooncakeStoreConnector(
vllm_config, KVConnectorRole.WORKER, kv_cache_config
)
fake_tensor = MagicMock()
fake_backend = MagicMock()
connector.register_cross_layers_kv_cache(fake_tensor, fake_backend)
worker = mock_worker_cls.return_value
worker.register_cross_layers_kv_caches.assert_called_once_with(fake_tensor)
def test_update_connector_output_and_take_events():
vllm_config = _make_vllm_config()
kv_cache_config = _make_kv_cache_config()
@@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch
import torch
from tests.v1.attention.utils import dense_kv_cache_views
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store import (
worker as mooncake_store_worker,
)
@@ -35,6 +36,7 @@ from vllm.v1.kv_cache_interface import (
MambaSpec,
SlidingWindowSpec,
)
from vllm.v1.kv_cache_layout import KVCacheLayout
class _DictStore:
@@ -160,8 +162,18 @@ def test_e2e_swa_plus_full_save_then_lookup_hits():
cfg = KVCacheConfig(
num_blocks=4,
kv_cache_tensors=[
KVCacheTensor(size=8192, shared_by=["L0"]),
KVCacheTensor(size=8192, shared_by=["L1"]),
KVCacheTensor(
size=4 * full.page_size_bytes,
layers=["L0"],
layer_stride=4 * full.page_size_bytes,
block_stride=full.page_size_bytes,
),
KVCacheTensor(
size=4 * swa.page_size_bytes,
layers=["L1"],
layer_stride=4 * swa.page_size_bytes,
block_stride=swa.page_size_bytes,
),
],
kv_cache_groups=[
KVCacheGroupSpec(["L0"], full),
@@ -178,9 +190,11 @@ def test_e2e_swa_plus_full_save_then_lookup_hits():
# Register kv_caches using mocked thread classes so register_kv_caches
# doesn't try to start real background threads (which set ready_event).
raw_full = torch.zeros(4 * full.page_size_bytes, dtype=torch.int8)
raw_swa = torch.zeros(4 * swa.page_size_bytes, dtype=torch.int8)
kv_caches = {
"L0": torch.zeros(2, 4, 8, 8, 64),
"L1": torch.zeros(2, 4, 8, 8, 64),
"L0": dense_kv_cache_views(raw_full, full, 4, 1, KVCacheLayout.LBNHC)[0],
"L1": dense_kv_cache_views(raw_swa, swa, 4, 1, KVCacheLayout.LBNHC)[0],
}
def _fake_thread_init(*args, **kwargs):
@@ -16,6 +16,7 @@ from unittest.mock import MagicMock, patch
import pytest
import torch
from tests.v1.attention.utils import dense_kv_cache_views
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake import (
rdma_utils,
)
@@ -38,6 +39,11 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.metrics import
)
from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
from vllm.v1.core.kv_cache_utils import BlockHash, maybe_convert_block_hash
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheGroupSpec,
KVCacheLayout,
)
class _RecordingBlockHashes:
@@ -2961,112 +2967,83 @@ def test_putting_consumer_queues_decode_save():
assert req.current_event is event
def test_register_kv_caches_blocks_first_single_segment():
"""Blocks-first layout (FlashInfer/MLA): one segment per layer."""
@pytest.mark.parametrize("layout", list(KVCacheLayout))
def test_register_kv_caches_shared_storage(layout: KVCacheLayout):
num_blocks = 10
page_size_elements = 64
num_layers = 2
worker = _make_bare_worker(num_gpu_blocks=num_blocks)
# Shape: (num_blocks, page_size_elements) — blocks outermost, no outer_dims
tensor = torch.zeros(num_blocks, page_size_elements, dtype=torch.float16)
_register_with_mocked_threads(worker, {"layer0": tensor})
db = worker.token_dbs[0]
assert db.kv_caches_base_addr == [tensor.untyped_storage().data_ptr()]
assert db.block_len == [tensor.untyped_storage().nbytes() // num_blocks]
worker.store.register_buffer.assert_called_once_with(
tensor.untyped_storage().data_ptr(),
tensor.untyped_storage().nbytes(),
)
def test_register_kv_caches_kv_first_two_segments():
"""K/V-first layout (FlashAttn): two segments (K, V) per layer."""
num_blocks = 10
block_size_tokens = 16
num_kv_heads = 4
head_size = 8
worker = _make_bare_worker(num_gpu_blocks=num_blocks)
# Shape: (2, num_blocks, block_size, num_kv_heads, head_size) — K/V outermost
tensor = torch.zeros(
2,
num_blocks,
block_size_tokens,
num_kv_heads,
head_size,
spec = FullAttentionSpec(
block_size=16,
num_kv_heads=2,
head_size=8,
dtype=torch.float16,
)
_register_with_mocked_threads(worker, {"layer0": tensor})
db = worker.token_dbs[0]
seg_stride = tensor.stride(0) * tensor.element_size()
base = tensor.untyped_storage().data_ptr()
assert db.kv_caches_base_addr == [base, base + seg_stride]
assert db.block_len == [seg_stride // num_blocks] * 2
def test_register_kv_caches_cross_layer_single_segment():
"""Cross-layer tensor: single segment with block_len = page_size * num_layers."""
num_blocks = 10
num_layers = 4
per_layer_page_elements = 64 # elements per layer per block
worker = _make_bare_worker(num_gpu_blocks=num_blocks)
# Cross-layer blocks-first tensor: all layers packed into a single
# contiguous block. Shape (num_blocks, num_layers * per_layer_page)
# mimics the physical layout after stride reordering.
total_page_elements = num_layers * per_layer_page_elements
tensor = torch.zeros(num_blocks, total_page_elements, dtype=torch.float16)
with (
patch(
"vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
"worker.KVCacheStoreSendingThread",
side_effect=_auto_set_ready_event,
),
patch(
"vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
"worker.KVCacheStoreRecvingThread",
side_effect=_auto_set_ready_event,
),
):
# Use the cross-layer wrapper key, same as register_cross_layers_kv_caches
worker.register_kv_caches({"__cross_layer__": tensor})
db = worker.token_dbs[0]
assert len(db.kv_caches_base_addr) == 1
assert db.kv_caches_base_addr[0] == tensor.untyped_storage().data_ptr()
expected_block_len = tensor.untyped_storage().nbytes() // num_blocks
# block_len should be per_layer_page_size * num_layers
assert (
expected_block_len
== num_layers * per_layer_page_elements * tensor.element_size()
raw = torch.zeros(
num_blocks * num_layers * spec.page_size_bytes,
dtype=torch.int8,
)
assert len(db.block_len) == 1
assert db.block_len[0] == expected_block_len
caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
# Also verify via register_cross_layers_kv_caches wrapper
worker2 = _make_bare_worker(num_gpu_blocks=num_blocks)
with (
patch(
"vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
"worker.KVCacheStoreSendingThread",
side_effect=_auto_set_ready_event,
),
patch(
"vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
"worker.KVCacheStoreRecvingThread",
side_effect=_auto_set_ready_event,
),
):
worker2.register_cross_layers_kv_caches(tensor)
_register_with_mocked_threads(
worker,
{"layer0": caches[0], "__cross_layer__": caches[1]},
)
db2 = worker2.token_dbs[0]
assert db2.kv_caches_base_addr == db.kv_caches_base_addr
assert db2.block_len == db.block_len
db = worker.token_dbs[0]
if not layout.is_block_compact:
# Each head group is its own region, so a block spans L*H regions.
head_cache = caches[0][:, 0]
assert db.kv_caches_base_addr == [
cache[:, head_idx].data_ptr()
for cache in caches
for head_idx in range(cache.shape[1])
]
assert db.block_len == [head_cache.stride(0) * head_cache.element_size()] * (
num_layers * caches[0].shape[1]
)
elif layout.is_layer_compact:
assert db.kv_caches_base_addr == [cache.data_ptr() for cache in caches]
assert db.block_len == [spec.page_size_bytes] * num_layers
else:
assert db.kv_caches_base_addr == [raw.data_ptr()]
assert db.block_len == [num_layers * spec.page_size_bytes]
worker.store.register_buffer.assert_called_once_with(raw.data_ptr(), raw.nbytes)
def test_register_kv_caches_separate_head_groups():
# LHBNC gives each head group its own region; the K/V split doubles heads.
layout = KVCacheLayout.LHBNC
num_blocks = 3
num_layers = 2
worker = _make_bare_worker(num_gpu_blocks=num_blocks)
spec = FullAttentionSpec(
block_size=4,
num_kv_heads=2,
head_size=8,
dtype=torch.float16,
num_head_slots=2,
state_content_bytes=2 * 8 * 2,
)
layer_names = ["layer0", "__cross_layer__"]
worker._kv_cache_groups = [KVCacheGroupSpec(layer_names, spec)]
raw = torch.zeros(
num_blocks * num_layers * spec.page_size_bytes,
dtype=torch.int8,
)
caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
_register_with_mocked_threads(worker, dict(zip(layer_names, caches)))
head_block_bytes = caches[0].stride(0) * caches[0].element_size()
expected_addrs = [
cache[:, head_idx].data_ptr()
for cache in caches
for head_idx in range(cache.shape[1])
]
db = worker.token_dbs[0]
assert db.kv_caches_base_addr == expected_addrs
assert db.block_len == [head_block_bytes] * len(expected_addrs)
worker.store.register_buffer.assert_called_once_with(raw.data_ptr(), raw.nbytes)
# ---------------------------------------------------------------------------
@@ -43,6 +43,7 @@ from vllm.v1.kv_cache_interface import (
KVCacheConfig,
KVCacheGroupSpec,
KVCacheTensor,
compute_layer_kv_cache_shape_bytes,
)
from .utils import create_request, create_scheduler
@@ -56,20 +57,25 @@ def _find_free_port() -> int:
def _make_test_kv_cache_config() -> KVCacheConfig:
layer_names = ["layer0", "layer1", "layer2"]
num_blocks = 2
spec = FullAttentionSpec(
block_size=16,
num_kv_heads=4,
head_size=64,
dtype=torch.float16,
)
page = spec.page_size_bytes
return KVCacheConfig(
num_blocks=2,
kv_cache_tensors=[KVCacheTensor(size=0, shared_by=layer_names)],
kv_cache_groups=[
KVCacheGroupSpec(
layer_names=layer_names,
kv_cache_spec=FullAttentionSpec(
block_size=16,
num_kv_heads=4,
head_size=64,
dtype=torch.float16,
),
num_blocks=num_blocks,
kv_cache_tensors=[
KVCacheTensor(
size=len(layer_names) * num_blocks * page,
layers=layer_names,
layer_stride=num_blocks * page,
block_stride=page,
)
],
kv_cache_groups=[KVCacheGroupSpec(layer_names=layer_names, kv_cache_spec=spec)],
)
@@ -225,7 +231,7 @@ class FakeMoRIIOConnectorWorker(MoRIIOConnectorWorker):
engine_id,
*args,
hand_shake_latency: float = 1.8,
kv_cache_layout="HND",
kv_cache_layout="LBHNC",
kv_cache_config=None,
**kwargs,
):
@@ -531,16 +537,15 @@ def test_register_kv_caches(mock_parallel_groups):
DEFAULT_PORT = 6301
TP_RANK = 0
DP_RANK = 0
from vllm.v1.attention.backends.rocm_aiter_fa import AiterFlashAttentionBackend
backend_cls = AiterFlashAttentionBackend
# Create test kv cache tensors using proper backend shape
kv_cache_shape = backend_cls.get_kv_cache_shape(
num_blocks=2, block_size=16, num_kv_heads=4, head_size=64
# Create test kv cache tensors using KVCacheSpec layout
shape = compute_layer_kv_cache_shape_bytes(
FullAttentionSpec(
block_size=16, num_kv_heads=4, head_size=64, dtype=torch.float16
),
2,
)
shared_tensor = torch.zeros(*kv_cache_shape, dtype=torch.float16)
unique_tensor = torch.zeros(*kv_cache_shape, dtype=torch.float16)
shared_tensor = torch.zeros(*shape, dtype=torch.int8).view(torch.float16)
unique_tensor = torch.zeros(*shape, dtype=torch.int8).view(torch.float16)
kv_caches = {
"layer0": shared_tensor,
"layer1": unique_tensor,
@@ -627,16 +632,15 @@ def test_moriio_handshake_returns_metadata(mock_parallel_groups):
ROLE = "kv_consumer"
vllm_config = create_vllm_config(role=ROLE)
from vllm.v1.attention.backends.rocm_aiter_fa import AiterFlashAttentionBackend
backend_cls = AiterFlashAttentionBackend
# Create test kv cache tensors using proper backend shape
kv_cache_shape = backend_cls.get_kv_cache_shape(
num_blocks=2, block_size=16, num_kv_heads=4, head_size=64
# Create test kv cache tensors using KVCacheSpec layout
shape = compute_layer_kv_cache_shape_bytes(
FullAttentionSpec(
block_size=16, num_kv_heads=4, head_size=64, dtype=torch.float16
),
2,
)
shared_tensor = torch.zeros(*kv_cache_shape, dtype=torch.float16)
unique_tensor = torch.zeros(*kv_cache_shape, dtype=torch.float16)
shared_tensor = torch.zeros(*shape, dtype=torch.int8).view(torch.float16)
unique_tensor = torch.zeros(*shape, dtype=torch.int8).view(torch.float16)
kv_caches = {
"layer0": shared_tensor,
"layer1": unique_tensor,
@@ -12,7 +12,10 @@ import pytest
import torch
from vllm.platforms import current_platform
from vllm.v1.kv_cache_interface import FullAttentionSpec, MLAAttentionSpec
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
MLAAttentionSpec,
)
aiter_available = importlib.util.find_spec("aiter") is not None
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:
id="interleaved-kernel-axis-from-spec",
),
pytest.param(
(8, 4, 3),
(8, 1, 4, 3),
_mla_spec(),
16,
{
@@ -297,7 +300,7 @@ def test_mixed_layers_compute_distinct_offsets_per_layer():
kv_caches = {
"separated": torch.empty((2, 8, 4, 2, 3), dtype=torch.bfloat16),
"interleaved": torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16),
"indexer": torch.empty((8, 4, 3), dtype=torch.bfloat16),
"indexer": torch.empty((8, 1, 4, 3), dtype=torch.bfloat16),
}
worker = _worker(
kv_caches,
@@ -342,7 +345,7 @@ def test_write_transfer_plan_caches_offsets_per_geometry():
kv_caches = {
"dense0": torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16),
"dense1": torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16),
"indexer": torch.empty((8, 4, 3), dtype=torch.bfloat16),
"indexer": torch.empty((8, 1, 4, 3), dtype=torch.bfloat16),
}
calls: list[str] = []
@@ -692,7 +695,7 @@ def test_empty_local_block_ids_is_free_only_noop():
def test_registration_regions_do_not_split_interleaved_or_mla_cache():
separated = torch.empty((2, 8, 4, 2, 3), dtype=torch.bfloat16)
interleaved = torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16)
indexer = torch.empty((8, 4, 3), dtype=torch.bfloat16)
indexer = torch.empty((8, 1, 4, 3), dtype=torch.bfloat16)
worker = _worker(
{
"separated": separated,
@@ -745,7 +748,9 @@ def test_registration_regions_use_layer_num_blocks():
def test_unsupported_shape_raises_value_error():
cache = torch.empty((8, 4, 2, 3), dtype=torch.bfloat16)
# A 4-D view whose head/state/content dims all disagree with the spec (a
# standardized [B, H, N, C] view for _full_spec would be (8, 2, 4, 6)).
cache = torch.empty((8, 3, 5, 7), dtype=torch.bfloat16)
worker = _worker({"layer": cache}, {"layer": _full_spec()})
with pytest.raises(ValueError, match="Unsupported MoRIIO K/V cache shape"):
@@ -889,16 +889,6 @@ Options:
""")
def test_multi_connector_prefer_cross_layer_blocks(mc):
mc._connectors[0].prefer_cross_layer_blocks = False
mc._connectors[1].prefer_cross_layer_blocks = True
assert mc.prefer_cross_layer_blocks is False
mc._connectors[0].prefer_cross_layer_blocks = True
mc._connectors[1].prefer_cross_layer_blocks = True
assert mc.prefer_cross_layer_blocks is True
def test_multi_connector_worker_metadata(mc):
class MockConnectorWorkerMetadata(KVConnectorWorkerMetadata):
def __init__(self, data: set[str]):
+235 -221
View File
@@ -19,6 +19,7 @@ import pytest
import ray
import torch
from tests.v1.attention.utils import dense_kv_cache_views
from vllm import LLM
from vllm.config import KVTransferConfig, set_current_vllm_config
from vllm.distributed.kv_transfer.kv_connector.utils import (
@@ -54,8 +55,6 @@ from vllm.outputs import RequestOutput
from vllm.platforms import current_platform
from vllm.platforms.interface import Platform
from vllm.sampling_params import SamplingParams
from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
from vllm.v1.attention.backends.utils import set_kv_cache_layout
from vllm.v1.engine import EngineCoreRequest
from vllm.v1.engine.output_processor import OutputProcessor
from vllm.v1.kv_cache_interface import (
@@ -63,12 +62,12 @@ from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheConfig,
KVCacheGroupSpec,
KVCacheTensor,
KVCacheLayout,
MLAAttentionSpec,
compute_layer_kv_cache_shape_bytes,
)
from vllm.v1.outputs import KVConnectorOutput, ModelRunnerOutput
from vllm.v1.request import RequestStatus
from vllm.v1.worker.kv_connector_model_runner_mixin import KVConnectorModelRunnerMixin
from vllm.v1.worker.utils import AttentionGroup
from .utils import (
create_request,
@@ -96,9 +95,6 @@ def clear_kv_transfer():
yield
if has_kv_transfer_group():
ensure_kv_transfer_shutdown()
# Reset any KV cache layout override set during tests so it doesn't
# leak into tests in other modules.
set_kv_cache_layout(None)
def get_default_xfer_telemetry(
@@ -350,12 +346,12 @@ def test_abort_immediately_remote_prefill_enqueues_empty_recv():
)
def test_kv_transfer_handshake(dist_init):
"""Unit test for basic NixlConnector interface functionality."""
from vllm.config import set_current_vllm_config
# Test setup, we creates a scheduler that contains a NixlConnector
# of role SCHEDULER, and expect it to be serving NixlAgentMetadata from
# all workers of the instance.
vllm_config = create_vllm_config()
vllm_config.cache_config.kv_cache_layout = "BLHNC"
# in case the test runs on non-GPU machine
vllm_config.kv_transfer_config.kv_buffer_device = "cpu"
scheduler = create_scheduler(vllm_config)
@@ -386,18 +382,19 @@ def test_kv_transfer_handshake(dist_init):
kv_cache_spec = cast(
AttentionSpec, kv_cache_config.kv_cache_groups[0].kv_cache_spec
)
kv_cache_shape = FlashAttentionBackend.get_kv_cache_shape(
num_blocks=kv_cache_config.num_blocks,
block_size=kv_cache_spec.block_size,
num_kv_heads=kv_cache_spec.num_kv_heads,
head_size=kv_cache_spec.head_size,
raw = torch.zeros(
kv_cache_spec.page_size_bytes * kv_cache_config.num_blocks * 3,
dtype=torch.int8,
)
caches = dense_kv_cache_views(
raw,
kv_cache_spec,
kv_cache_config.num_blocks,
num_layers=3,
layout=KVCacheLayout.BLHNC,
)
shared_tensor = torch.zeros(*kv_cache_shape, dtype=kv_cache_spec.dtype)
unique_tensor = torch.zeros(*kv_cache_shape, dtype=kv_cache_spec.dtype)
kv_caches = {
"layer0": shared_tensor,
"layer1": unique_tensor,
"layer2": shared_tensor,
f"layer{layer_idx}": cache for layer_idx, cache in enumerate(caches)
}
prefill_connector.register_kv_caches(kv_caches)
@@ -474,7 +471,7 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
self,
*args,
hand_shake_latency: float = 1.8,
kv_cache_layout="HND",
kv_cache_layout="LBHNC",
kv_cache_config=None,
**kwargs,
):
@@ -486,9 +483,8 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
# Mock register_kv_caches attributes needed for tests that do not call it.
self.src_xfer_handles_by_block_size = {self.block_size: 1}
self.src_blocks_data = np.empty((0, 3), dtype=np.uint64)
test_shape = self.attn_backends[0].get_kv_cache_shape(
num_blocks=1, block_size=16, num_kv_heads=1, head_size=1
)
rep_spec = self.kv_cache_config.kv_cache_groups[0].kv_cache_spec
test_shape = compute_layer_kv_cache_shape_bytes(rep_spec, 1)
self.transfer_topo = TransferTopology(
tp_rank=self.tp_rank,
tp_size=self.world_size,
@@ -502,7 +498,7 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
)
self.compat_hash = compute_nixl_compatibility_hash(
self.vllm_config, self.backend_name, self.transfer_topo.cross_layers_blocks
self.vllm_config, self.backend_name
)
def _nixl_handshake(
@@ -553,9 +549,8 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
device_id=remote_tp_rank,
num_blocks=1,
block_lens=remote_block_lens,
# `self.kv_cache_layout` is only forced to HND when vllm engine
# is started. We mock HND here.
kv_cache_layout="HND",
block_strides=remote_block_lens,
kv_cache_layout="LBHNC",
block_size=self.block_size,
ssm_sizes=(0, 0),
attn_backend_name=self.backend_name,
@@ -579,6 +574,8 @@ class TestNixlHandshake:
self, default_vllm_config, dist_init, pcp_rank
):
"""Only PCP rank zero publishes and reports sending completion."""
from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
vllm_config = create_vllm_config(kv_role="kv_producer")
vllm_config.parallel_config.prefill_context_parallel_size = 2
with (
@@ -656,7 +653,7 @@ class TestNixlHandshake:
worker.dst_xfer_side_handles = {
FakeNixlConnectorWorker.REMOTE_ENGINE_ID: {0: 1}
}
worker.kv_cache_layout = "HND"
worker.kv_cache_layout = "LBHNC"
num_xfers = 4
while True:
# For the same request_id, initiate multiple xfers across different
@@ -1053,7 +1050,9 @@ class TestNixlHandshake:
worker.dst_num_blocks[worker.engine_id] = worker.num_blocks
# Metadata with different kv_cache_layout than local worker
mismatched_layout = "HND" if worker.kv_cache_layout != "HND" else "NHD"
mismatched_layout = (
"LBHNC" if worker.kv_cache_layout != "LBHNC" else "LBNHC"
)
meta = NixlAgentMetadata(
engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
agent_metadata=FakeNixlWrapper.AGENT_METADATA,
@@ -1061,6 +1060,7 @@ class TestNixlHandshake:
device_id=0,
num_blocks=1,
block_lens=worker.block_len_per_layer,
block_strides=worker.block_len_per_layer,
kv_cache_layout=mismatched_layout,
block_size=worker.block_size,
ssm_sizes=(0, 0),
@@ -1100,7 +1100,7 @@ class TestNixlHandshake:
vllm_config,
connector.engine_id,
hand_shake_latency=0,
kv_cache_layout="NHD",
kv_cache_layout="LBNHC",
)
worker = connector.connector_worker
@@ -1111,15 +1111,17 @@ class TestNixlHandshake:
worker.dst_num_blocks[worker.engine_id] = worker.num_blocks
# Metadata with different kv_cache_layout than local worker
# prefill TP=1, decode TP=2, remote block_lens is double to local
remote_block_lens = [i * 2 for i in worker.block_len_per_layer]
meta = NixlAgentMetadata(
engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
agent_metadata=FakeNixlWrapper.AGENT_METADATA,
kv_caches_base_addr=[0],
device_id=0,
num_blocks=1,
# prefill TP=1, decode TP=2, remote block_lens is double to local
block_lens=[i * 2 for i in worker.block_len_per_layer],
kv_cache_layout="HND",
block_lens=remote_block_lens,
block_strides=remote_block_lens,
kv_cache_layout="LBHNC",
block_size=worker.block_size,
ssm_sizes=(0, 0),
attn_backend_name=worker.backend_name,
@@ -1160,8 +1162,6 @@ class TestNixlHandshake:
attn_backends=worker.attn_backends,
tensor_shape=None,
)
assert worker.transfer_topo.virtually_split_kv_in_blocks
plan = MagicMock(
source_ranks_per_group=((0,), (0,)),
rank_offset_factor=1,
@@ -1171,6 +1171,7 @@ class TestNixlHandshake:
device_id=0,
num_blocks=1,
block_lens=[remote_block_len],
block_strides=[remote_block_len],
)
assert worker._build_fa_remote(plan, meta, block_size_ratio=1).tolist() == [
@@ -1226,6 +1227,7 @@ class TestNixlHandshake:
device_id=0,
num_blocks=1,
block_lens=[fa_len * tp_ratio, idx_len],
block_strides=[fa_len * tp_ratio, idx_len],
kv_cache_layout=worker.kv_cache_layout,
block_size=worker.block_size,
ssm_sizes=(0, 0),
@@ -1252,6 +1254,7 @@ class TestNixlHandshake:
num_blocks=1,
# WRONG: MLA region scaled by tp_ratio (it should be replicated).
block_lens=[fa_len * tp_ratio, idx_len * tp_ratio],
block_strides=[fa_len * tp_ratio, idx_len * tp_ratio],
kv_cache_layout=worker2.kv_cache_layout,
block_size=worker2.block_size,
ssm_sizes=(0, 0),
@@ -1295,7 +1298,7 @@ class TestNixlHandshake:
worker.transfer_topo.total_num_kv_heads = 8
worker.transfer_topo.local_physical_heads = 1
worker.kv_cache_layout = "HND"
worker.kv_cache_layout = "LBHNC"
worker.slot_size_per_layer = [4096]
worker.block_len_per_layer = [4096 * worker.block_size]
@@ -1311,7 +1314,8 @@ class TestNixlHandshake:
device_id=0,
num_blocks=1,
block_lens=list(worker.block_len_per_layer),
kv_cache_layout="HND",
block_strides=list(worker.block_len_per_layer),
kv_cache_layout="LBHNC",
block_size=worker.block_size,
ssm_sizes=(0, 0),
attn_backend_name=worker.backend_name,
@@ -1350,7 +1354,7 @@ class TestNixlHandshake:
worker.transfer_topo.total_num_kv_heads = 32
worker.transfer_topo.local_physical_heads = 8 # 32 // 4
worker.kv_cache_layout = "HND"
worker.kv_cache_layout = "LBHNC"
slot_size = 4096
worker.slot_size_per_layer = [slot_size]
@@ -1368,7 +1372,8 @@ class TestNixlHandshake:
device_id=0,
num_blocks=1,
block_lens=list(worker.block_len_per_layer),
kv_cache_layout="HND",
block_strides=list(worker.block_len_per_layer),
kv_cache_layout="LBHNC",
block_size=worker.block_size,
ssm_sizes=(0, 0),
attn_backend_name=worker.backend_name,
@@ -1839,7 +1844,19 @@ def _run_abort_timeout_test(llm: LLM, timeout: int):
llm.llm_engine.engine_core.shutdown()
@pytest.mark.parametrize("enable_cross_layers", ["False", "True"])
def element_byte_addrs(view: torch.Tensor) -> list[int]:
"""Absolute byte addresses of every element of a (possibly strided) view."""
offsets = torch.zeros(view.shape, dtype=torch.int64)
for dim, (size, stride) in enumerate(zip(view.shape, view.stride())):
shape = [1] * view.ndim
shape[dim] = size
offsets = offsets + torch.arange(size, dtype=torch.int64).view(shape) * stride
esize = view.element_size()
byte_offsets = (offsets.flatten() * esize).tolist()
base = view.data_ptr()
return [base + offset + i for offset in byte_offsets for i in range(esize)]
@pytest.mark.parametrize(
"attn_backend",
[
@@ -1853,8 +1870,14 @@ def _run_abort_timeout_test(llm: LLM, timeout: int):
"TRITON_ATTN",
],
)
@pytest.mark.parametrize("layout", [layout.name for layout in KVCacheLayout])
@pytest.mark.parametrize("separate_kv_head_groups", [False, True])
def test_register_kv_caches(
default_vllm_config, dist_init, attn_backend, enable_cross_layers
default_vllm_config,
dist_init,
attn_backend,
layout,
separate_kv_head_groups,
):
"""
Test that register_kv_caches() properly calls nixl_wrapper methods with
@@ -1868,12 +1891,7 @@ def test_register_kv_caches(
"""
vllm_config = create_vllm_config(attention_backend=attn_backend)
# Enable cross layers blocks
vllm_config.kv_transfer_config.kv_connector_extra_config[
"enable_cross_layers_blocks"
] = enable_cross_layers
set_kv_cache_layout("HND")
vllm_config.cache_config.kv_cache_layout = layout
# Import the appropriate backend based on the parameter
if attn_backend == "FLASH_ATTN":
@@ -1890,59 +1908,37 @@ def test_register_kv_caches(
backend_cls = TritonAttentionBackend
nixl_worker = "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker"
nixl_connector = "vllm.distributed.kv_transfer.kv_connector.v1.nixl.connector"
with (
patch(f"{nixl_worker}.NixlWrapper") as mock_nixl_wrapper,
patch(f"{nixl_worker}.threading.Event"),
patch(f"{nixl_worker}.threading.Thread") as mock_thread,
patch(f"{nixl_connector}.get_current_attn_backend") as mock_get_attn_backend,
patch(f"{nixl_worker}.get_current_attn_backends") as mock_get_attn_backends,
):
# Ensure get_attn_backend returns the correct value due to
# _cached_get_attn_backend returning the backend from previous
# test run if not mocking.
mock_get_attn_backend.return_value = backend_cls
mock_get_attn_backends.return_value = [backend_cls]
num_layers = 32
block_size = 16
num_blocks = 8
num_heads = 4
head_size = 16
# TODO (NickLucche) the fact that connector depends on kv_cache_config for init
# but cross-layer preference cant be inferred prior to creating kv_cache_config
# is a bit awkward.
dummy_connector = NixlConnector(
vllm_config,
KVConnectorRole.WORKER,
make_kv_cache_config(block_size=block_size),
)
kv_cache_spec = FullAttentionSpec(
block_size=block_size,
num_kv_heads=num_heads,
head_size=head_size,
dtype=torch.float16,
num_head_slots=2 if separate_kv_head_groups else None,
state_content_bytes=num_heads * head_size * 2
if separate_kv_head_groups
else None,
)
kv_cache_config = KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=[],
kv_cache_groups=[
KVCacheGroupSpec(
["layer0", "layer1", "layer2", "layer3"], kv_cache_spec
)
],
)
if dummy_connector.prefer_cross_layer_blocks:
kv_cache_config = KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=[
KVCacheTensor(
size=kv_cache_spec.page_size_bytes * num_blocks,
shared_by=["all-layers"],
)
for _ in range(num_layers)
],
kv_cache_groups=[KVCacheGroupSpec(["all-layers"], kv_cache_spec)],
)
else:
kv_cache_config = KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=[],
kv_cache_groups=[
KVCacheGroupSpec(["layer0", "layer1", "layer2"], kv_cache_spec)
],
)
# Create connector
connector = NixlConnector(vllm_config, KVConnectorRole.WORKER, kv_cache_config)
connector.connector_worker = FakeNixlConnectorWorker(
@@ -1962,93 +1958,36 @@ def test_register_kv_caches(
# Reassure the shutdown() check that the thread is terminated
mock_thread.return_value.is_alive.return_value = False
expected_tensor_size: int
expected_base_addrs: list[int]
expected_num_entries: int
kv_caches: dict[str, torch.Tensor]
if str(enable_cross_layers).lower() == "true":
assert connector.prefer_cross_layer_blocks == (
attn_backend in ("FLASH_ATTN", "FLASHINFER", "TRITON_ATTN")
)
else:
assert not connector.prefer_cross_layer_blocks
test_shape = backend_cls.get_kv_cache_shape(
num_blocks=1, block_size=16, num_kv_heads=1, head_size=1
raw0 = torch.zeros(
kv_cache_spec.page_size_bytes * kv_cache_config.num_blocks * 2,
dtype=torch.int8,
device=current_platform.device_type,
)
is_blocks_first = len(test_shape) == 4 and test_shape[0] == 1
if connector.prefer_cross_layer_blocks:
with set_current_vllm_config(vllm_config):
_, cross_layers_kv_cache, _ = (
KVConnectorModelRunnerMixin.allocate_uniform_kv_caches(
kv_cache_config=kv_cache_config,
attn_groups=[
[
AttentionGroup(
backend=backend_cls,
layer_names=[],
kv_cache_spec=kv_cache_spec,
kv_cache_group_id=0,
)
]
],
cache_dtype="bfloat16",
device=torch.accelerator.current_device_index(),
kernel_block_sizes=[block_size],
)
)
# Store tensor info for validation
expected_tensor_size = (
cross_layers_kv_cache.element_size() * cross_layers_kv_cache.numel()
)
expected_base_addrs = [
cross_layers_kv_cache.data_ptr(),
]
expected_num_entries = 1
expected_blocks_count = num_blocks
kv_caches = {"all-layers": cross_layers_kv_cache}
else:
# Create test kv cache tensors using proper backend shape
kv_cache_shape = backend_cls.get_kv_cache_shape(
num_blocks=kv_cache_config.num_blocks,
block_size=kv_cache_spec.block_size,
num_kv_heads=kv_cache_spec.num_kv_heads,
head_size=kv_cache_spec.head_size,
)
shared_tensor = torch.zeros(*kv_cache_shape, dtype=kv_cache_spec.dtype)
unique_tensor = torch.zeros(*kv_cache_shape, dtype=kv_cache_spec.dtype)
kv_caches = {
"layer0": shared_tensor,
"layer1": unique_tensor,
"layer2": shared_tensor,
}
# Store tensor info for validation
if is_blocks_first:
expected_tensor_size = (
shared_tensor.element_size() * shared_tensor.numel()
)
expected_base_addrs = [
shared_tensor.data_ptr(),
unique_tensor.data_ptr(),
]
expected_num_entries = 2
expected_blocks_count = kv_cache_config.num_blocks * 2
else:
expected_tensor_size = (
shared_tensor[0].element_size() * shared_tensor[0].numel()
)
expected_base_addrs = [
shared_tensor[0].data_ptr(),
shared_tensor[1].data_ptr(),
unique_tensor[0].data_ptr(),
unique_tensor[1].data_ptr(),
]
expected_num_entries = 4
expected_blocks_count = kv_cache_config.num_blocks * 4
raw1 = torch.zeros(
kv_cache_spec.page_size_bytes * kv_cache_config.num_blocks,
dtype=torch.int8,
device=current_platform.device_type,
)
tensor0, tensor1 = dense_kv_cache_views(
raw0,
kv_cache_spec,
kv_cache_config.num_blocks,
num_layers=2,
layout=KVCacheLayout[layout],
)
(tensor2,) = dense_kv_cache_views(
raw1,
kv_cache_spec,
kv_cache_config.num_blocks,
num_layers=1,
layout=KVCacheLayout[layout],
)
kv_caches = {
"layer0": tensor0,
"layer1": tensor1,
"layer2": tensor2,
"layer3": tensor0,
}
# Execute register_kv_caches
connector.register_kv_caches(kv_caches)
@@ -2056,44 +1995,123 @@ def test_register_kv_caches(
# Verify get_reg_descs was called with caches_data
assert mock_wrapper_instance.get_reg_descs.called
caches_data, _ = mock_wrapper_instance.get_reg_descs.call_args[0]
assert len(caches_data) == expected_num_entries
assert len(caches_data) == 2
for i, cache_entry in enumerate(caches_data):
for cache_entry, raw in zip(caches_data, (raw0, raw1)):
base_addr, size, _tp_rank, _ = cache_entry
assert size == expected_tensor_size, (
f"Entry {i}: Expected tensor size {expected_tensor_size}, got {size}"
)
assert base_addr == expected_base_addrs[i], (
f"Entry {i}: Expected base address {expected_base_addrs[i]}, "
f"got {base_addr}"
)
assert size == raw.nbytes
assert base_addr == raw.data_ptr()
# Verify get_xfer_descs was called with blocks_data
assert mock_wrapper_instance.get_xfer_descs.called
blocks_data, _ = mock_wrapper_instance.get_xfer_descs.call_args[0]
# Validate blocks_data structure and size
assert len(blocks_data) == expected_blocks_count, (
f"Expected {expected_blocks_count} blocks, got {len(blocks_data)}"
)
# Layout-blind contract: whatever regions the worker carves out,
# transferring "block b" must move exactly logical block b's bytes for
# every layer. Map each registered byte to the block that owns it, then
# require every descriptor window to hold bytes of a single block and
# the windows of block b to cover exactly block b's bytes.
owner: dict[int, int] = {}
for cache in (tensor0, tensor1, tensor2):
for blk in range(num_blocks):
for addr in element_byte_addrs(cache[blk]):
assert owner.setdefault(addr, blk) == blk
block_bytes = defaultdict(set)
for addr, blk in owner.items():
block_bytes[blk].add(addr)
if connector.prefer_cross_layer_blocks:
num_blocks = 8
else:
num_blocks = kv_cache_config.num_blocks
covered: defaultdict[int, set[int]] = defaultdict(set)
for block_start_addr, block_len, _tp_rank in blocks_data:
window = range(block_start_addr, block_start_addr + block_len)
owners = {owner[addr] for addr in window}
assert len(owners) == 1, "descriptor window spans logical blocks"
covered[owners.pop()].update(window)
assert covered == block_bytes
expected_block_len = expected_tensor_size // num_blocks
for i, block_entry in enumerate(blocks_data):
block_start_addr, block_len, tp_rank = block_entry
assert block_len == expected_block_len, (
f"Block entry {i}: Expected block len {expected_block_len}, "
f"got {block_len}"
)
# Region bases are exactly the block-0 window starts, one per region.
base_addrs = connector.connector_worker.kv_caches_base_addr[
connector.connector_worker.engine_id
][0]
if layout == "BLHNC":
assert len(base_addrs) == 3
assert set(base_addrs) == {
start for start, _len, _tp in blocks_data if owner[start] == 0
}
assert len(blocks_data) == num_blocks * len(base_addrs)
assert connector.connector_worker.block_size == 16
def test_register_packed_dsv4_mla_cache_as_single_region(
default_vllm_config, dist_init
):
from vllm.v1.attention.backends.triton_attn import TritonAttentionBackend
nixl_worker = "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker"
with (
patch(f"{nixl_worker}.NixlWrapper") as mock_nixl_wrapper,
patch(f"{nixl_worker}.threading.Event"),
patch(f"{nixl_worker}.threading.Thread") as mock_thread,
patch(f"{nixl_worker}.get_current_attn_backends") as mock_backends,
):
mock_backends.return_value = [TritonAttentionBackend]
num_blocks = 2
num_layers = 4
block_size = 256
spec = MLAAttentionSpec(
block_size=block_size,
num_kv_heads=1,
head_size=512,
dtype=torch.uint8,
cache_dtype_str="fp8_ds_mla",
tokens_per_state=4,
alignment=576,
model_version="deepseek_v4",
state_content_bytes=584,
)
layer_names = [f"layer{idx}" for idx in range(num_layers)]
kv_cache_config = KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=[],
kv_cache_groups=[KVCacheGroupSpec(layer_names, spec)],
)
vllm_config = create_vllm_config(attention_backend="TRITON_ATTN")
vllm_config.cache_config.block_size = block_size
vllm_config.cache_config.kv_cache_layout = "BLHNC"
connector = NixlConnector(vllm_config, KVConnectorRole.WORKER, kv_cache_config)
connector.connector_worker = FakeNixlConnectorWorker(
vllm_config,
connector.engine_id,
hand_shake_latency=0,
kv_cache_layout="BLHNC",
kv_cache_config=kv_cache_config,
)
wrapper = mock_nixl_wrapper.return_value
connector.connector_worker.nixl_wrapper = wrapper
wrapper.get_agent_metadata.return_value = b"fake_agent_metadata"
mock_thread.return_value.is_alive.return_value = False
raw = torch.zeros(
spec.page_size_bytes * num_blocks * num_layers,
dtype=torch.int8,
device=current_platform.device_type,
)
views = dense_kv_cache_views(
raw, spec, num_blocks, num_layers, KVCacheLayout.BLHNC
)
connector.register_kv_caches(dict(zip(layer_names, views)))
blocks_data, _ = wrapper.get_xfer_descs.call_args[0]
packed_block_len = num_layers * spec.page_size_bytes
assert connector.connector_worker.kv_caches_base_addr[
connector.connector_worker.engine_id
][0] == [raw.data_ptr()]
assert blocks_data.tolist() == [
[raw.data_ptr() + block_idx * packed_block_len, packed_block_len, 0]
for block_idx in range(num_blocks)
]
class FakePlatform(Platform):
device_type: str = "oot"
@@ -2318,6 +2336,8 @@ def test_engine_ttl_disabled(default_vllm_config, dist_init):
def test_transfer_topology_unregister():
"""TransferTopology.unregister_remote_engine removes the engine."""
from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
topo = TransferTopology(
tp_rank=0,
tp_size=1,
@@ -2908,8 +2928,8 @@ def test_speculative_config_compatibility_hash(
_set_test_speculative_config(local_config)
_set_test_speculative_config(remote_config, **remote_overrides)
local_hash = compute_nixl_compatibility_hash(local_config, "FLASH_ATTN", False)
remote_hash = compute_nixl_compatibility_hash(remote_config, "FLASH_ATTN", False)
local_hash = compute_nixl_compatibility_hash(local_config, "FLASH_ATTN")
remote_hash = compute_nixl_compatibility_hash(remote_config, "FLASH_ATTN")
assert (local_hash == remote_hash) is should_match
@@ -2920,10 +2940,8 @@ def test_missing_speculative_config_changes_compatibility_hash():
speculative_config = create_vllm_config()
_set_test_speculative_config(speculative_config)
regular_hash = compute_nixl_compatibility_hash(regular_config, "FLASH_ATTN", False)
speculative_hash = compute_nixl_compatibility_hash(
speculative_config, "FLASH_ATTN", False
)
regular_hash = compute_nixl_compatibility_hash(regular_config, "FLASH_ATTN")
speculative_hash = compute_nixl_compatibility_hash(speculative_config, "FLASH_ATTN")
assert regular_hash != speculative_hash
@@ -2938,8 +2956,8 @@ def test_speculative_kv_cache_dtype_resolves_to_target():
_set_test_speculative_config(local_config, kv_cache_dtype="fp8") # explicit
_set_test_speculative_config(remote_config, kv_cache_dtype=None) # inherits
local_hash = compute_nixl_compatibility_hash(local_config, "FLASH_ATTN", False)
remote_hash = compute_nixl_compatibility_hash(remote_config, "FLASH_ATTN", False)
local_hash = compute_nixl_compatibility_hash(local_config, "FLASH_ATTN")
remote_hash = compute_nixl_compatibility_hash(remote_config, "FLASH_ATTN")
assert local_hash == remote_hash
@@ -2955,8 +2973,8 @@ def test_speculative_attention_backend_not_in_compatibility_hash():
_set_test_speculative_config(local_config, attention_backend=None)
_set_test_speculative_config(remote_config, attention_backend="FLASHINFER")
local_hash = compute_nixl_compatibility_hash(local_config, "FLASH_ATTN", False)
remote_hash = compute_nixl_compatibility_hash(remote_config, "FLASH_ATTN", False)
local_hash = compute_nixl_compatibility_hash(local_config, "FLASH_ATTN")
remote_hash = compute_nixl_compatibility_hash(remote_config, "FLASH_ATTN")
assert local_hash == remote_hash
@@ -2969,17 +2987,17 @@ def test_transfer_mode_changes_compatibility_hash():
config = create_vllm_config()
pull_hash = compute_nixl_compatibility_hash(
config, "FLASH_ATTN", False, transfer_mode="pull"
config, "FLASH_ATTN", transfer_mode="pull"
)
push_hash = compute_nixl_compatibility_hash(
config, "FLASH_ATTN", False, transfer_mode="push"
config, "FLASH_ATTN", transfer_mode="push"
)
assert pull_hash != push_hash
assert pull_hash == compute_nixl_compatibility_hash(
config, "FLASH_ATTN", False, transfer_mode="pull"
config, "FLASH_ATTN", transfer_mode="pull"
)
assert compute_nixl_compatibility_hash(config, "FLASH_ATTN", False) == pull_hash
assert compute_nixl_compatibility_hash(config, "FLASH_ATTN") == pull_hash
@pytest.mark.skip_global_cleanup
@@ -3057,14 +3075,11 @@ def test_compatibility_hash_validation(
kv_cache_spec = cast(
AttentionSpec, kv_cache_config.kv_cache_groups[0].kv_cache_spec
)
kv_cache_shape = decode_worker.attn_backends[0].get_kv_cache_shape(
num_blocks=kv_cache_config.num_blocks,
block_size=kv_cache_spec.block_size,
num_kv_heads=kv_cache_spec.num_kv_heads,
head_size=kv_cache_spec.head_size,
shape = compute_layer_kv_cache_shape_bytes(
kv_cache_spec, kv_cache_config.num_blocks
)
shared_tensor = torch.zeros(*kv_cache_shape, dtype=kv_cache_spec.dtype)
unique_tensor = torch.zeros(*kv_cache_shape, dtype=kv_cache_spec.dtype)
shared_tensor = torch.zeros(*shape, dtype=torch.int8).view(kv_cache_spec.dtype)
unique_tensor = torch.zeros(*shape, dtype=torch.int8).view(kv_cache_spec.dtype)
# Build kv_caches from the actual layer names in kv_cache_config so that
# _layer_specs lookups in register_kv_caches always find a matching key.
layer_names = [
@@ -3099,18 +3114,19 @@ def test_compatibility_hash_validation(
remote_hash = compute_nixl_compatibility_hash(
remote_vllm_config,
decode_worker.backend_name,
decode_worker.transfer_topo.cross_layers_blocks,
)
prefill_block_size = config_overrides.get("block_size", 16)
prefill_block_lens = [4096 * prefill_block_size]
prefill_metadata = NixlAgentMetadata(
engine_id=FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
agent_metadata=FakeNixlWrapper.AGENT_METADATA,
kv_caches_base_addr=[0],
device_id=0,
num_blocks=1,
block_lens=[4096 * prefill_block_size], # slot_size * block_size
kv_cache_layout="HND",
block_lens=prefill_block_lens,
block_strides=prefill_block_lens,
kv_cache_layout="LBHNC",
block_size=prefill_block_size,
ssm_sizes=(0, 0),
attn_backend_name=decode_worker.backend_name,
@@ -3187,9 +3203,8 @@ def test_handshake_decode_errors(default_vllm_config, dist_init, error_scenario)
decode_worker = decode_connector.connector_worker
backend = get_current_attn_backend(local_vllm_config)
test_shape = backend.get_kv_cache_shape(
num_blocks=1, block_size=16, num_kv_heads=1, head_size=1
)
probe_spec = decode_worker.kv_cache_config.kv_cache_groups[0].kv_cache_spec
test_shape = compute_layer_kv_cache_shape_bytes(probe_spec, 1)
decode_worker.transfer_topo = TransferTopology(
tp_rank=decode_worker.tp_rank,
tp_size=decode_worker.world_size,
@@ -3205,7 +3220,6 @@ def test_handshake_decode_errors(default_vllm_config, dist_init, error_scenario)
decode_worker.compat_hash = compute_nixl_compatibility_hash(
decode_worker.vllm_config,
decode_worker.backend_name,
decode_worker.transfer_topo.cross_layers_blocks,
)
if error_scenario == "handshake_decode_error":
@@ -1505,14 +1505,19 @@ def _make_hybrid_mla_kv_cache_config(num_blocks: int = 4):
mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
)
assert kda_spec.page_size_bytes == unified_page
# The three groups overlay each other, so layer i of every group aliases the same
# region: mla.i, kda_a.i and kda_b.i all live at i * layer_stride.
layer_stride = num_blocks * unified_page
return KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=[
KVCacheTensor(
size=num_blocks * unified_page,
shared_by=[f"mla.{i}", f"kda_a.{i}", f"kda_b.{i}"],
size=2 * layer_stride,
layers=[f"{prefix}.0", f"{prefix}.1"],
layer_stride=layer_stride,
block_stride=unified_page,
)
for i in range(2)
for prefix in ("mla", "kda_a", "kda_b")
],
kv_cache_groups=[
KVCacheGroupSpec(["mla.0", "mla.1"], mla_spec),
@@ -129,14 +129,19 @@ def _make_mla_hybrid_worker(local_block_size, kernel_block_size, num_logical_blo
page_size_padded=unified_page,
mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
)
# The three groups overlay each other, so layer i of every group aliases the same
# region: mla.i, kda_a.i and kda_b.i all live at i * layer_stride.
layer_stride = num_logical_blocks * unified_page
kv_cache_config = KVCacheConfig(
num_blocks=num_logical_blocks,
kv_cache_tensors=[
KVCacheTensor(
size=num_logical_blocks * unified_page,
shared_by=[f"mla.{i}", f"kda_a.{i}", f"kda_b.{i}"],
size=2 * layer_stride,
layers=[f"{prefix}.0", f"{prefix}.1"],
layer_stride=layer_stride,
block_stride=unified_page,
)
for i in range(2)
for prefix in ("mla", "kda_a", "kda_b")
],
kv_cache_groups=[
KVCacheGroupSpec(["mla.0", "mla.1"], mla_spec),
@@ -230,6 +235,8 @@ def _make_remote_meta(
kv_caches_base_addr=[0x10_000_000, 0x20_000_000],
num_blocks=remote_num_logical * remote_ppl,
block_lens=[kernel_page, kernel_page],
# Non-interleaved remote: consecutive blocks abut, so stride == page length.
block_strides=[kernel_page, kernel_page],
kv_cache_layout=worker.kv_cache_layout,
block_size=remote_kernel_block_size,
ssm_sizes=remote_ssm_sizes,
@@ -1081,6 +1081,8 @@ class TestPushPipelineParallel:
device_id=0,
num_blocks=4,
block_lens=[block_len] * 4,
# Non-interleaved: consecutive blocks abut, so stride == block length.
block_strides=[block_len] * 4,
kv_cache_layout="HND",
block_size=16,
ssm_sizes=(0, 0),
@@ -145,10 +145,15 @@ def _make_kv_cache_config(
),
)
]
# Each group packs its two layers densely; groups overlay one allocation.
layer_stride = _BYTES_PER_BLOCK * num_blocks
size = 2 * layer_stride
tensors = [
KVCacheTensor(
size=_BYTES_PER_BLOCK * num_blocks,
shared_by=fa_layers,
size=size,
layers=fa_layers,
layer_stride=layer_stride,
block_stride=_BYTES_PER_BLOCK,
)
]
if swa_enabled:
@@ -167,8 +172,10 @@ def _make_kv_cache_config(
)
tensors.append(
KVCacheTensor(
size=_BYTES_PER_BLOCK * num_blocks,
shared_by=sw_layers,
size=size,
layers=sw_layers,
layer_stride=layer_stride,
block_stride=_BYTES_PER_BLOCK,
)
)
return KVCacheConfig(
@@ -12,14 +12,7 @@ pytestmark = pytest.mark.cpu_test
class _FakeAttentionBackend:
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
) -> tuple[int, int, int, int]:
return (num_blocks, num_kv_heads, block_size, 2 * head_size)
pass
def _make_topology(
+2
View File
@@ -130,6 +130,8 @@ def create_vllm_config(
cache_dtype=cache_dtype,
enable_prefix_caching=True,
)
# Connectors are constructed after layout resolution; mirror that here.
cache_config.kv_cache_layout = "LBNHC"
kv_transfer_config = KVTransferConfig(
kv_connector=kv_connector,
kv_connector_module_path=kv_connector_module_path,
+3 -8
View File
@@ -55,6 +55,7 @@ def make_mapper_from_offloading_spec(**kwargs) -> FileMapper:
),
replicated_layout=kwargs.get("replicated_layout", False),
canonical_layout=kwargs.get("canonical_layout", False),
kv_cache_layout=kwargs.get("kv_cache_layout", "LBNHC"),
)
spec = MagicMock(spec=OffloadingSpec)
spec.config = config
@@ -214,14 +215,8 @@ def test_parallel_agnostic_separates_persistent_layouts():
def test_canonical_layout_changes_storage_namespace():
# Canonical bytes are not interchangeable with the direct layout, so the
# format id must fork the storage namespace.
from vllm.v1.attention.backends.utils import set_kv_cache_layout
set_kv_cache_layout("NHD")
try:
direct = make_mapper_from_offloading_spec()
canonical = make_mapper_from_offloading_spec(canonical_layout=True)
finally:
set_kv_cache_layout(None)
direct = make_mapper_from_offloading_spec()
canonical = make_mapper_from_offloading_spec(canonical_layout=True)
assert "canonical_format" not in direct.fields
assert canonical.fields["canonical_format"] == "v1-nhd"
assert direct.base_path != canonical.base_path
+3 -1
View File
@@ -91,7 +91,9 @@ def _make_kv_cache_config(
tensors.append(
KVCacheTensor(
size=_BYTES_PER_BLOCK * num_blocks,
shared_by=layer_names,
layers=layer_names,
layer_stride=_BYTES_PER_BLOCK * num_blocks,
block_stride=_BYTES_PER_BLOCK,
)
)
return KVCacheConfig(
+90
View File
@@ -10,6 +10,7 @@ read partially written / stale blocks and silently corrupt the CPU cache.
from __future__ import annotations
import time
from unittest.mock import MagicMock
import pytest
import torch
@@ -19,6 +20,8 @@ from vllm.platforms import current_platform
if not current_platform.is_cuda_alike():
pytest.skip("Requires CUDA or ROCm", allow_module_level=True)
from tests.v1.attention.utils import dense_kv_cache_views
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheLayout
from vllm.v1.simple_kv_offload.copy_backend import DmaCopyBackend
from vllm.v1.simple_kv_offload.cuda_mem_ops import (
CU_MEMCPY_SRC_ACCESS_ORDER_ANY,
@@ -188,3 +191,90 @@ def test_build_params_src_access_order():
gpu, cpu, stream, src_access_order=CU_MEMCPY_SRC_ACCESS_ORDER_STREAM
)
assert ordered.attrs.srcAccessOrder == CU_MEMCPY_SRC_ACCESS_ORDER_STREAM
@pytest.mark.parametrize("layout", list(KVCacheLayout))
def test_register_shared_kv_cache_storage(monkeypatch, layout: KVCacheLayout):
num_blocks = 4
num_layers = 2
spec = FullAttentionSpec(
block_size=2,
num_kv_heads=2,
head_size=2,
dtype=torch.float16,
)
raw = torch.zeros(
num_blocks * num_layers * spec.page_size_bytes,
dtype=torch.int8,
device="cuda",
)
caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
cache_config = MagicMock(num_blocks=num_blocks)
worker = SimpleCPUOffloadWorker(
vllm_config=None,
kv_cache_config=cache_config,
cpu_capacity_bytes=raw.nbytes,
)
worker._backend = MagicMock()
monkeypatch.setattr("vllm.v1.simple_kv_offload.worker.PIN_MEMORY", False)
worker.register_kv_caches(
{f"layer.{layer_idx}": cache for layer_idx, cache in enumerate(caches)}
)
assert worker.gpu_kv_caches is not None
if layout.is_layer_compact and not layout.is_block_compact:
expected_regions = num_layers * spec.num_heads
expected_block_bytes = spec.page_size_bytes // spec.num_heads
elif layout.is_layer_compact:
expected_regions = num_layers
expected_block_bytes = spec.page_size_bytes
else:
expected_regions = 1
expected_block_bytes = spec.page_size_bytes * num_layers
assert len(worker.gpu_kv_caches) == expected_regions
assert {cache.shape for cache in worker.gpu_kv_caches.values()} == {
(num_blocks, expected_block_bytes)
}
def test_register_separate_kv_head_groups(monkeypatch):
# LHBNC hoists the K/V head groups outside the block dim, so each layer's
# blocks are registered as one region per group (K, V).
layout = KVCacheLayout.LHBNC
num_blocks = 4
num_layers = 2
spec = FullAttentionSpec(
block_size=2,
num_kv_heads=2,
head_size=2,
dtype=torch.float16,
num_head_slots=2,
state_content_bytes=2 * 2 * 2,
)
raw = torch.zeros(
num_blocks * num_layers * spec.page_size_bytes,
dtype=torch.int8,
device="cuda",
)
caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
worker = SimpleCPUOffloadWorker(
vllm_config=None,
kv_cache_config=MagicMock(num_blocks=num_blocks),
cpu_capacity_bytes=raw.nbytes,
)
worker._backend = MagicMock()
monkeypatch.setattr("vllm.v1.simple_kv_offload.worker.PIN_MEMORY", False)
worker.register_kv_caches(
{f"layer.{layer_idx}": cache for layer_idx, cache in enumerate(caches)}
)
assert worker.gpu_kv_caches is not None
assert len(worker.gpu_kv_caches) == num_layers * spec.num_heads
per_group_block_bytes = (
spec.num_kv_heads * spec.block_size * spec.head_size * spec.dtype.itemsize
)
assert {cache.shape for cache in worker.gpu_kv_caches.values()} == {
(num_blocks, per_group_block_bytes)
}
+191 -306
View File
@@ -1,48 +1,33 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Padded-page handling in create_kv_cache_views.
Guards that a page_size_padded spec strides the block dimension by the padded page
while keeping per-block content compact, so padding bytes at the end of each page are
never addressed by the logical view.
"""
import pytest
import torch
from tests.v1.attention.utils import dense_kv_cache_views
from vllm.v1.core.kv_cache_utils import KVCacheBlockCopy
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheConfig,
KVCacheGroupSpec,
KVCacheLayout,
KVCacheTensor,
KVQuantMode,
MambaSpec,
MLAAttentionSpec,
compute_layout_strides,
)
from vllm.v1.worker.utils import (
allocate_kv_cache,
copy_kv_cache_blocks_inplace,
)
from vllm.v1.worker.gpu.attn_utils import _reshape_kv_cache
from vllm.v1.worker.utils import AttentionGroup
class FakeFlashAttentionBackend:
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
return (num_blocks, 2, block_size, num_kv_heads, head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
assert not include_num_layers_dimension
return (0, 1, 2, 3, 4)
class FakeHNDFlashAttentionBackend(FakeFlashAttentionBackend):
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
assert not include_num_layers_dimension
return (0, 1, 3, 2, 4)
def test_reshape_padded_flash_attention_kv_cache_strides_by_page():
def test_reshape_padded_kv_cache_strides_by_padded_page():
num_blocks = 3
spec = FullAttentionSpec(
block_size=16,
@@ -53,292 +38,192 @@ def test_reshape_padded_flash_attention_kv_cache_strides_by_page():
)
assert spec.real_page_size_bytes == 256
raw_tensors = {
"layer": torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
}
attn_groups = [
AttentionGroup(
backend=FakeFlashAttentionBackend,
layer_names=["layer"],
kv_cache_spec=spec,
kv_cache_group_id=0,
)
]
raw = torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
(kv_cache,) = dense_kv_cache_views(raw, spec, num_blocks, 1, KVCacheLayout.LBHNC)
kv_cache = _reshape_kv_cache(
attn_groups,
raw_tensors,
"auto",
[spec.block_size],
{},
)["layer"]
assert kv_cache.shape == (num_blocks, 2, 16, 1, 2)
assert kv_cache.stride(0) == spec.page_size_bytes // 4
assert kv_cache.stride(1) == spec.real_page_size_bytes // 2 // 4
assert kv_cache[1, 0].storage_offset() == spec.page_size_bytes // 4
assert (
kv_cache[1, 1].storage_offset()
== (spec.page_size_bytes + spec.real_page_size_bytes // 2) // 4
)
elem_size = 4 # float32
# Content dim packs K and V: 2 * head_size.
assert kv_cache.shape == (num_blocks, 1, 16, 2 * spec.head_size)
assert kv_cache.dtype == spec.dtype
assert kv_cache.stride(0) == spec.page_size_padded // elem_size
assert kv_cache[1].storage_offset() == spec.page_size_padded // elem_size
# Within one block the (unpadded) content stays compact.
assert kv_cache[0].is_contiguous()
def test_reshape_padded_hnd_flash_attention_kv_cache_strides_by_page():
num_blocks = 3
spec = FullAttentionSpec(
block_size=16,
num_kv_heads=3,
head_size=2,
dtype=torch.float32,
page_size_padded=1024,
)
assert spec.real_page_size_bytes == 768
raw_tensors = {
"layer": torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
}
attn_groups = [
AttentionGroup(
backend=FakeHNDFlashAttentionBackend,
layer_names=["layer"],
kv_cache_spec=spec,
kv_cache_group_id=0,
)
]
kv_cache = _reshape_kv_cache(
attn_groups,
raw_tensors,
"auto",
[spec.block_size],
{},
)["layer"]
assert kv_cache.shape == (num_blocks, 2, 16, 3, 2)
assert kv_cache.stride(0) == spec.page_size_bytes // 4
assert kv_cache.stride(1) == spec.real_page_size_bytes // 2 // 4
assert kv_cache.stride(2) == 2
assert kv_cache.stride(3) == spec.block_size * spec.head_size
assert kv_cache[1, 0].storage_offset() == spec.page_size_bytes // 4
assert (
kv_cache[1, 1].storage_offset()
== (spec.page_size_bytes + spec.real_page_size_bytes // 2) // 4
)
assert (
kv_cache[1, 1, 3, 2].storage_offset()
== (
spec.page_size_bytes
+ spec.real_page_size_bytes // 2
+ 3 * spec.head_size * 4
+ 2 * spec.block_size * spec.head_size * 4
)
// 4
)
class FakeDiffKVBackend:
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
return (num_blocks, block_size, num_kv_heads, head_size * 2)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
assert not include_num_layers_dimension
return (0, 1, 2, 3)
def test_reshape_padded_diff_kv_cache_does_not_infer_kv_dim():
num_blocks = 3
spec = FullAttentionSpec(
block_size=16,
@pytest.mark.parametrize(
("kernel_block_sizes", "expected_num_blocks", "expected_num_states"),
[
(None, 4, 64),
([256], 4, 64),
([64], 16, 16),
],
)
def test_allocate_compressed_mla_cache(
kernel_block_sizes: list[int] | None,
expected_num_blocks: int,
expected_num_states: int,
):
spec = MLAAttentionSpec(
block_size=256,
num_kv_heads=1,
head_size=2,
dtype=torch.float32,
page_size_padded=384,
head_size=128,
dtype=torch.bfloat16,
tokens_per_state=4,
)
raw_tensors = {
"layer": torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
}
attn_groups = [
AttentionGroup(
backend=FakeDiffKVBackend,
layer_names=["layer"],
kv_cache_spec=spec,
kv_cache_group_id=0,
)
]
kv_cache = _reshape_kv_cache(
attn_groups,
raw_tensors,
"auto",
[spec.block_size],
{},
)["layer"]
assert kv_cache.shape == (num_blocks, 16, 1, 4)
assert kv_cache.stride(0) == spec.page_size_bytes // 4
assert kv_cache.stride(1) == 4
class FakePerTokenScaleBackend:
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
return (num_blocks, 2, block_size, num_kv_heads, head_size + 4)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
assert not include_num_layers_dimension
return (0, 1, 2, 3, 4)
def test_reshape_padded_quantized_kv_cache_preserves_scale_stride():
num_blocks = 3
spec = FullAttentionSpec(
block_size=16,
num_kv_heads=1,
head_size=4,
dtype=torch.int8,
kv_quant_mode=KVQuantMode.INT8_PER_TOKEN_HEAD,
page_size_padded=384,
)
assert spec.real_page_size_bytes == 128
assert spec.page_size_bytes == 384
raw_tensors = {
"layer": torch.zeros(spec.page_size_bytes * num_blocks, dtype=torch.int8)
}
attn_groups = [
AttentionGroup(
backend=FakePerTokenScaleBackend,
layer_names=["layer"],
kv_cache_spec=spec,
kv_cache_group_id=0,
)
]
kv_cache = _reshape_kv_cache(
attn_groups,
raw_tensors,
"int8_per_token_head",
[spec.block_size],
{},
)["layer"]
assert kv_cache.shape == (num_blocks, 2, 16, 1, 8)
assert kv_cache.stride(0) == spec.page_size_bytes
assert kv_cache.stride(1) == 16 * 1 * 8
assert kv_cache[1, 1].storage_offset() == spec.page_size_bytes + 16 * 1 * 8
class FakeKVFirstBackend:
"""ROCm-style backend that puts K and V ahead of the block dim."""
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
return (2, num_blocks, block_size, num_kv_heads, head_size)
@staticmethod
def get_kv_cache_block_dim(
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> int:
return 1
def _kv_first_setup(shared_by: list[str]):
num_blocks = 3
attn_spec = FullAttentionSpec(
block_size=16,
num_kv_heads=1,
head_size=2,
dtype=torch.float32,
)
mamba_spec = MambaSpec(
block_size=16,
shapes=((64,),),
dtypes=(torch.float32,),
)
assert attn_spec.page_size_bytes == mamba_spec.page_size_bytes == 256
raw_tensor = torch.zeros(attn_spec.page_size_bytes * num_blocks, dtype=torch.int8)
raw_tensors = {name: raw_tensor for name in shared_by}
attn_groups = [
AttentionGroup(
backend=FakeKVFirstBackend,
layer_names=["attn"],
kv_cache_spec=attn_spec,
kv_cache_group_id=0,
)
]
if "mamba" in shared_by:
attn_groups.append(
AttentionGroup(
backend=FakeKVFirstBackend,
layer_names=["mamba"],
kv_cache_spec=mamba_spec,
kv_cache_group_id=1,
)
)
kv_cache_config = KVCacheConfig(
num_blocks=num_blocks,
num_pages = 4
config = KVCacheConfig(
num_blocks=num_pages,
kv_cache_tensors=[
KVCacheTensor(size=raw_tensor.numel(), shared_by=list(shared_by))
KVCacheTensor(
size=num_pages * spec.page_size_bytes,
layers=["layer.0"],
layer_stride=num_pages * spec.page_size_bytes,
block_stride=spec.page_size_bytes,
)
],
kv_cache_groups=[],
kv_cache_groups=[KVCacheGroupSpec(["layer.0"], spec)],
)
kv_caches = _reshape_kv_cache(
attn_groups,
raw_tensors,
"auto",
[attn_spec.block_size] * len(attn_groups),
{},
kv_cache_config,
caches = allocate_kv_cache(
config, torch.device("cpu"), KVCacheLayout.LBHNC, kernel_block_sizes
)
return num_blocks, kv_caches["attn"]
assert caches["layer.0"].shape == (expected_num_blocks, 1, expected_num_states, 128)
def test_reshape_kv_first_kv_cache_pages_blocks_when_shared_with_mamba():
num_blocks, kv_cache = _kv_first_setup(["attn", "mamba"])
@pytest.mark.parametrize("layout", list(KVCacheLayout))
def test_copy_kv_cache_blocks_shared_storage(layout: KVCacheLayout):
num_blocks = 4
num_layers = 2
spec = FullAttentionSpec(
block_size=2,
num_kv_heads=2,
head_size=2,
dtype=torch.float32,
)
raw = torch.zeros(num_blocks * num_layers * spec.page_size_bytes, dtype=torch.int8)
caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
assert kv_cache.shape == (2, num_blocks, 16, 1, 2)
# Block b has to own page b, so its K and V sit side by side within it.
page = 16 * 1 * 2 * 2
for block in range(num_blocks):
assert kv_cache[0, block].storage_offset() == block * page
assert kv_cache[1, block].storage_offset() == block * page + page // 2
for layer_idx, cache in enumerate(caches):
for block_idx in range(num_blocks):
cache[block_idx].fill_(10 * layer_idx + block_idx)
expected = [[cache[i].clone() for i in range(num_blocks)] for cache in caches]
copies = [KVCacheBlockCopy(src_block_id=0, dst_block_id=2)]
copy_kv_cache_blocks_inplace(caches, num_blocks, copies)
for layer_idx, cache in enumerate(caches):
torch.testing.assert_close(cache[2], expected[layer_idx][0])
torch.testing.assert_close(cache[1], expected[layer_idx][1])
def test_reshape_kv_first_kv_cache_keeps_layout_without_mamba():
num_blocks, kv_cache = _kv_first_setup(["attn"])
def test_fixed_block_stride_propagates_outward_in_lhbnc():
num_blocks = 3
num_layers = 2
spec = FullAttentionSpec(
block_size=2,
num_kv_heads=2,
head_size=2,
dtype=torch.float32,
)
natural = compute_layout_strides(spec, num_blocks, num_layers, KVCacheLayout.LHBNC)
block_stride = natural[1] + 8
assert kv_cache.shape == (2, num_blocks, 16, 1, 2)
# Nothing else indexes this allocation by page, so K and V stay split into
# one contiguous half each.
assert kv_cache[1, 0].storage_offset() == num_blocks * 16 * 1 * 2
strides = compute_layout_strides(
spec,
num_blocks,
num_layers,
KVCacheLayout.LHBNC,
fixed_strides=(None, block_stride, None, None, None),
)
assert strides[1] == block_stride
assert strides[2] == block_stride * num_blocks
assert strides[0] == strides[2] * spec.num_heads
def test_copy_kv_cache_blocks_separate_head_groups():
# LHBNC stores each head group separately, so a block's bytes are scattered
# across L*H regions.
layout = KVCacheLayout.LHBNC
num_blocks = 4
num_layers = 2
spec = FullAttentionSpec(
block_size=2,
num_kv_heads=2,
head_size=2,
dtype=torch.float32,
num_head_slots=2,
state_content_bytes=2 * 2 * 4,
)
raw = torch.zeros(num_blocks * num_layers * spec.page_size_bytes, dtype=torch.int8)
caches = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
for layer_idx, cache in enumerate(caches):
for block_idx in range(num_blocks):
for head_idx in range(cache.shape[1]):
cache[block_idx, head_idx].fill_(
100 * layer_idx + 10 * head_idx + block_idx
)
expected = [[cache[i].clone() for i in range(num_blocks)] for cache in caches]
copy_kv_cache_blocks_inplace(
caches,
num_blocks,
[KVCacheBlockCopy(src_block_id=0, dst_block_id=2)],
)
for layer_idx, cache in enumerate(caches):
torch.testing.assert_close(cache[2], expected[layer_idx][0])
torch.testing.assert_close(cache[1], expected[layer_idx][1])
@pytest.mark.parametrize(
"layout,num_layers",
[
(KVCacheLayout.LBHNC, 2),
# Splitting needs a manager block to be one dense page, which a
# block-outermost layout only gives when the block holds one layer.
(KVCacheLayout.BLHNC, 1),
],
)
def test_copy_kv_cache_blocks_with_virtual_block_splitting(
layout: KVCacheLayout, num_layers: int
):
num_blocks = 4
physical_per_logical = 2
spec = FullAttentionSpec(
block_size=4,
num_kv_heads=1,
head_size=2,
dtype=torch.float32,
)
raw = torch.zeros(num_blocks * num_layers * spec.page_size_bytes, dtype=torch.int8)
caches = dense_kv_cache_views(
raw,
spec,
num_blocks,
num_layers,
layout,
kernel_block_size=spec.block_size // physical_per_logical,
)
for layer_idx, cache in enumerate(caches):
for block_idx in range(cache.shape[0]):
cache[block_idx].fill_(100 * layer_idx + block_idx)
expected = [[cache[i].clone() for i in range(cache.shape[0])] for cache in caches]
copy_kv_cache_blocks_inplace(
caches,
num_blocks,
[KVCacheBlockCopy(src_block_id=0, dst_block_id=2)],
)
dst_start = 2 * physical_per_logical
for layer_idx, cache in enumerate(caches):
for physical_idx in range(physical_per_logical):
torch.testing.assert_close(
cache[dst_start + physical_idx], expected[layer_idx][physical_idx]
)
@@ -1,35 +1,14 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""CPU regression for the packed-DSV4 KV zeroer geometry.
"""CPU regression for the packed-DSV4 KV zeroer geometry (upstream #50276).
Constructor-crossing regression for the packed-KV zeroing stride fix (upstream
PR #50276): every number below comes from instantiating the REAL production
code (no arithmetic is reimplemented here):
Four DSV4 fp8_ds_mla layers share one cross-layer allocation: each block row packs
the four alignment-padded pages side by side (layout BLHNC). The zeroer must step by
the full packed row per block while zeroing only each layer's meaningful page — never
the alignment padding, never an adjacent layer's page.
- ``vllm.v1.core.kv_cache_utils._get_packed_kv_cache_layout`` (packed
offsets / block stride)
- ``vllm.v1.worker.gpu.attn_utils._reshape_attention_kv_cache``
(per-layer column views)
- ``vllm.models.deepseek_v4.sparse_mla.DeepseekV4FlashMLABackend``
(``get_kv_cache_shape`` / ``get_kv_cache_block_dim``)
- ``vllm.v1.worker.utils.AttentionGroup`` / ``KVBlockZeroer``
(``KVBlockZeroer.__init__`` precomputes the segment tables)
CPU only: no Triton launch, no CUDA tensor, no CUDA call is made. The test
asserts the packed-DSV4 constructor metadata:
* block stride = the full packed row (4 aligned pages)
* per-layer zero = the meaningful page (the spec's unpadded
``real_page_size_bytes``), so adjacent packed layers are
never wiped
* 4 packed layer views; block 99 of the highest-offset layer is the last
written region and stays within the packed backing.
The pre-fix constructor exposed a five-field ``_meta`` with no block stride, so
its zero width equals the FULL packed row; the extraction below records that
legacy defect and then asserts the fixed semantics, so failure on the old code
is a semantic one (wrong zero width / out-of-bounds block 99), not a
schema-shape accident.
CPU only: the zeroer's ``__init__`` precomputes the segment tables; no kernel is
launched.
"""
from types import SimpleNamespace
@@ -37,160 +16,154 @@ from types import SimpleNamespace
import pytest
import torch
from vllm.models.deepseek_v4.sparse_mla import DeepseekV4FlashMLABackend
from vllm.v1.core.kv_cache_utils import _get_packed_kv_cache_layout
from vllm.v1.kv_cache_interface import MLAAttentionSpec, UniformTypeKVCacheSpecs
from vllm.v1.worker.gpu.attn_utils import _reshape_attention_kv_cache
from vllm.v1.worker.utils import AttentionGroup, KVBlockZeroer
from tests.v1.attention.utils import dense_kv_cache_views
from vllm.v1.kv_cache_interface import KVCacheLayout, MLAAttentionSpec
from vllm.v1.worker.utils import (
AttentionGroup,
KVBlockZeroer,
allocate_kv_cache,
)
pytestmark = pytest.mark.cpu_test
NUM_BLOCKS = 100
BLOCK_SIZE_CFG = 256 # vllm_config.cache_config.block_size for DSV4 (group 0)
COMPRESS = 4 # main-MLA compress_ratio
STORAGE_BS = BLOCK_SIZE_CFG // COMPRESS # 64
KERNEL_BS = 256 # DeepseekV4FlashMLABackend supports [256]
LAST_BLOCK = NUM_BLOCKS - 1 # block 99
NUM_LAYERS = 4
ALIGNMENT = 576
def _extract_segments(meta, base):
"""Read (offset_bytes, block_stride_bytes, zero_span_bytes) per segment.
Accepts both the pre-fix five-field ``_meta`` (no block stride; the zero
width is the FULL packed row -- the defect) and the fixed six-field ``_meta``.
All values come from the real constructor output; nothing is recomputed. Returns
``(segs, legacy_why)`` where ``legacy_why`` describes the pre-fix defect
when detected.
"""
legacy_why = None
if len(meta) == 6:
seg_addrs, seg_block_strides, seg_page_sizes, _max_chunks, _blk, n_segs = meta
else:
seg_addrs, seg_page_sizes, _max_chunks, _blk, n_segs = meta
seg_block_strides = seg_page_sizes
legacy_why = (
f"legacy {len(meta)}-field _meta (no block stride): zero width is "
f"the FULL packed row ({int(seg_page_sizes[0]) * 4} B) -- the defect"
)
segs = [
(int(a) - base, int(bs) * 4, int(ps) * 4)
for a, bs, ps in zip(seg_addrs, seg_block_strides, seg_page_sizes)
]
return segs, legacy_why, n_segs
def test_dsv4_packed_zeroer_geometry():
"""Real DSV4 constructor metadata separates block stride from zero span."""
layer_names = [f"model.layers.{i}.self_attn" for i in range(4)]
specs = [
MLAAttentionSpec(
block_size=BLOCK_SIZE_CFG,
num_kv_heads=1,
head_size=512,
dtype=torch.uint8,
compress_ratio=COMPRESS,
cache_dtype_str="fp8_ds_mla",
alignment=576,
model_version="deepseek_v4",
state_content_bytes=584, # >576 to allocate room for scales at back of page
)
for _ in layer_names
]
uniform = UniformTypeKVCacheSpecs.from_specs(
{ln: sp for ln, sp in zip(layer_names, specs)}
def test_packed_dsv4_zeroer_zeroes_only_each_layers_page():
spec = MLAAttentionSpec(
block_size=256,
num_kv_heads=1,
head_size=512,
dtype=torch.uint8,
cache_dtype_str="fp8_ds_mla",
model_version="deepseek_v4",
tokens_per_state=4,
alignment=ALIGNMENT,
# DeepseekV4 fp8_ds_mla: 584B per token, published by the layer.
state_content_bytes=584,
)
assert uniform is not None
# DSV4: 448B NoPE + 128B RoPE + 8B fp8 scale = 584B per stored state.
assert spec.state_content_size_bytes == 584
assert spec.num_states == 64
unpadded_page = spec.unpadded_page_size_bytes
padded_page = spec.page_size_bytes
assert unpadded_page == 64 * 584
assert padded_page > unpadded_page, "alignment must pad the page"
# --- Real packed layout planner -------------------------------------------
block_stride, layers_by_offset = _get_packed_kv_cache_layout(
[
AttentionGroup(
backend=DeepseekV4FlashMLABackend,
layer_names=layer_names,
kv_cache_spec=uniform,
kv_cache_group_id=0,
)
]
)
page_bytes = specs[0].page_size_bytes # DSV4 fp8_ds_mla page after 576B alignment
real_page_bytes = specs[0].real_page_size_bytes # unpadded meaningful page
alignment_gap = page_bytes - real_page_bytes # trailing planner padding
total_size = block_stride * NUM_BLOCKS
offsets = sorted(layers_by_offset)
assert block_stride == 4 * page_bytes, (block_stride, 4 * page_bytes)
assert offsets == [i * page_bytes for i in range(4)]
raw = torch.zeros(NUM_BLOCKS * NUM_LAYERS * padded_page, dtype=torch.int8)
views = dense_kv_cache_views(raw, spec, NUM_BLOCKS, NUM_LAYERS, KVCacheLayout.BLHNC)
base = raw.data_ptr()
block_row = NUM_LAYERS * padded_page
for i, view in enumerate(views):
assert view.data_ptr() - base == i * padded_page
assert view.stride(0) * view.element_size() == block_row
# --- Real per-layer views (packed branch) --------------------------------
backing = torch.zeros(total_size, dtype=torch.uint8)
base = backing.data_ptr()
views = {}
for ln, off in zip(layer_names, offsets):
packing = (off, block_stride)
kv_shape = DeepseekV4FlashMLABackend.get_kv_cache_shape(
NUM_BLOCKS, STORAGE_BS, 1, 512, cache_dtype_str="fp8_ds_mla"
)
stride_order = tuple(range(len(kv_shape)))
view = _reshape_attention_kv_cache(
backing, specs[0], kv_shape, stride_order, NUM_BLOCKS, packing
)
views[ln] = view
assert view.data_ptr() - base == off, "data_ptr must be base+offset"
assert view.stride(0) * view.element_size() == block_stride, (
"row stride == block_stride (logical block stride)"
)
# --- Real KVBlockZeroer.__init__ (production arithmetic, CPU) ----------
sctx = {ln: SimpleNamespace(kv_cache=v) for ln, v in views.items()}
zeroer = KVBlockZeroer(
torch.device("cpu"),
attn_groups_iter=[
AttentionGroup(
backend=DeepseekV4FlashMLABackend,
layer_names=layer_names,
kv_cache_spec=specs[0],
kv_cache_group_id=0,
)
],
kernel_block_sizes=[KERNEL_BS],
cache_dtype="fp8_ds_mla",
static_forward_context=sctx,
attn_groups_iter=iter(
[
AttentionGroup(
backend=None,
layer_names=[f"layer.{i}" for i in range(NUM_LAYERS)],
kv_cache_spec=spec,
kv_cache_group_id=0,
)
]
),
kernel_block_sizes=[spec.block_size],
static_forward_context={
f"layer.{i}": SimpleNamespace(kv_cache=views[i]) for i in range(NUM_LAYERS)
},
)
segs, legacy_why, n_segs = _extract_segments(zeroer._meta, base)
seg_addrs, seg_block_strides, seg_page_sizes, _, _, n_segs = zeroer._meta
# --- Fixed semantics (proven by metadata values, not by schema shape) ----
assert n_segs == 4, "all 4 packed layers must register as segments"
assert [off for off, _, _ in segs] == offsets, "one segment per layer offset"
assert all(bs == block_stride for _, bs, _ in segs), (
"block stride must be the full packed row (4 aligned pages)"
assert n_segs == NUM_LAYERS
# Segments step by the full packed row per block...
assert (seg_block_strides * 4 == block_row).all()
# ...but zero only the layer's meaningful page: no alignment padding, no
# adjacent layer's page.
assert (seg_page_sizes * 4 == unpadded_page).all()
assert sorted(a - base for a in seg_addrs.tolist()) == [
i * padded_page for i in range(NUM_LAYERS)
]
# Block 99 of the highest-offset layer stays within the packed backing.
last_end = (
max(seg_addrs.tolist())
+ (NUM_BLOCKS - 1) * int(seg_block_strides[0]) * 4
+ int(seg_page_sizes[0]) * 4
)
assert all(zs == real_page_bytes for _, _, zs in segs), (
f"zero span must be the meaningful page ({real_page_bytes} B), got "
f"{[zs for _, _, zs in segs]} B" + (f"; {legacy_why}" if legacy_why else "")
assert last_end <= base + raw.numel()
def test_overlaid_zeroer_dedups_segments_with_max_span():
"""Two groups overlay one allocation; the zeroer must emit one segment per distinct
byte offset, spanning the widest overlaid page, so a newly allocated block is fully
zeroed no matter which group owns it."""
from unittest.mock import MagicMock
from vllm.v1.core.kv_cache_utils import get_kv_cache_config_from_groups
from vllm.v1.kv_cache_interface import (
KVCacheGroupSpec,
KVCacheLayout,
UniformTypeKVCacheSpecs,
)
# --- Block 99 (the real final block) bounds, via constructor metadata ---
# Production write for block b of a segment is
# [off + b*block_stride_bytes, off + b*block_stride_bytes + zero_span_bytes).
worst = 0
for off, bs, zs in segs:
start = off + LAST_BLOCK * bs
end = start + zs
oob = end - total_size
worst = max(worst, oob)
assert end <= total_size, (
f"segment@off={off} block {LAST_BLOCK} writes [{start}, {end}) "
f"-> {oob} bytes past backing ({total_size})"
def make_spec(head_size):
return MLAAttentionSpec(
block_size=64, num_kv_heads=1, head_size=head_size, dtype=torch.uint8
)
assert worst == 0, f"highest-offset final block goes {worst} bytes OOB"
# Highest-offset segment's block 99 is the last written region and ends exactly at
# total_size - alignment_gap: the trailing planner padding stays untouched.
off_last, bs_last, zs_last = segs[-1]
assert off_last + LAST_BLOCK * bs_last + zs_last == total_size - alignment_gap, (
"highest-offset final block must end at total_size - alignment_gap "
f"({alignment_gap}-byte gap intact)"
g1_specs = {"g1.big": make_spec(512), "g1.small": make_spec(128)}
g2_specs = {"g2.huge": make_spec(1024)}
groups = [
KVCacheGroupSpec(
list(g1_specs),
UniformTypeKVCacheSpecs(block_size=64, kv_cache_specs=g1_specs),
),
KVCacheGroupSpec(
list(g2_specs),
UniformTypeKVCacheSpecs(block_size=64, kv_cache_specs=g2_specs),
),
]
from vllm.config import CacheConfig
vllm_config = MagicMock()
vllm_config.cache_config = CacheConfig()
vllm_config.cache_config.num_gpu_blocks_override = None
vllm_config.cache_config.kv_cache_layout = "BLHNC"
config = get_kv_cache_config_from_groups(vllm_config, groups, 8 * 1024 * 1024)
views = allocate_kv_cache(config, torch.device("cpu"), KVCacheLayout.BLHNC, None)
buf_ptr = views["g1.big"].data_ptr()
attn_groups = [
AttentionGroup(
backend=None,
layer_names=list(specs),
kv_cache_spec=next(iter(specs.values())),
kv_cache_group_id=gid,
)
for gid, specs in enumerate((g1_specs, g2_specs))
]
zeroer = KVBlockZeroer(
torch.device("cpu"),
attn_groups_iter=iter(attn_groups),
kernel_block_sizes=[64, 64],
static_forward_context={
name: SimpleNamespace(kv_cache=views[name]) for name in views
},
)
seg_addrs, seg_block_strides, seg_page_sizes, _, _, n_segs = zeroer._meta
# Zero span < block stride proves adjacent packed layers are never wiped.
assert max(zs for _, _, zs in segs) < block_stride
# g1.big and g2.huge overlay at offset 0 -> one segment with g2's wider
# span; g1.small keeps its own segment.
assert n_segs == 2
pages = {n: s.page_size_bytes for n, s in (g1_specs | g2_specs).items()}
by_offset = {
a - buf_ptr: p * 4 for a, p in zip(seg_addrs.tolist(), seg_page_sizes.tolist())
}
assert by_offset[0] == max(pages["g1.big"], pages["g2.huge"])
assert by_offset[pages["g1.big"]] == pages["g1.small"]
packed_block_stride = max(sum(pages[n] for n in g) for g in (g1_specs, g2_specs))
assert (seg_block_strides * 4 == packed_block_stride).all()
+27 -61
View File
@@ -92,7 +92,12 @@ def initialize_kv_cache(runner: GPUModelRunner):
kv_cache_config = KVCacheConfig(
num_blocks=NUM_BLOCKS,
kv_cache_tensors=[
KVCacheTensor(size=tensor_size, shared_by=["layer.0"]),
KVCacheTensor(
size=tensor_size,
layers=["layer.0"],
layer_stride=tensor_size,
block_stride=attn_spec.page_size_bytes,
),
],
kv_cache_groups=[
KVCacheGroupSpec(layer_names=["layer.0"], kv_cache_spec=attn_spec)
@@ -131,6 +136,7 @@ def get_vllm_config():
gpu_memory_utilization=0.9,
cache_dtype="auto",
)
cache_config.kv_cache_layout = "LBNHC"
parallel_config = ParallelConfig()
vllm_config = VllmConfig(
model_config=model_config,
@@ -839,56 +845,6 @@ def test_update_states_pp_async_multi_request_keeps_rank_state_consistent(
)
def test_kv_cache_stride_order(monkeypatch, model_runner):
# This test checks if GPUModelRunner initializes correctly when an attention
# backend enforces a non-default KV cache stride order.
n_heads = model_runner.model_config.get_num_kv_heads(model_runner.parallel_config)
head_size = model_runner.model_config.get_head_size()
# Get the expected shape from the backend's get_kv_cache_shape method
# to ensure compatibility with different backends (triton vs flexattention)
attn_backend = None
for attn_group in model_runner._attn_group_iterator():
attn_backend = attn_group.backend
break
assert attn_backend is not None, "No attention backend found"
expected_kv_cache_shape = list(
attn_backend.get_kv_cache_shape(NUM_BLOCKS, BLOCK_SIZE, n_heads, head_size)
)
# TODO mla test
default_stride = tuple(range(len(expected_kv_cache_shape)))
non_default_stride = (*default_stride[1:], default_stride[0])
# Permutation that gets you back to expected kv shape
for test_stride in (non_default_stride, default_stride):
def rnd_stride_order(
include_num_layers_dimension: bool = False, test_stride=test_stride
):
assert not include_num_layers_dimension
return test_stride
# Patch the attention backend class and re-trigger the KV cache creation
for attn_group in model_runner._attn_group_iterator():
attn_backend = attn_group.backend
monkeypatch.setattr(
attn_backend, "get_kv_cache_stride_order", rnd_stride_order
)
model_runner.attn_groups = []
model_runner.kv_caches = []
model_runner.initialize_kv_cache(model_runner.kv_cache_config)
# Shape is unchanged, but layout may differ
kv_cache_shape = model_runner.kv_caches[0].shape
assert list(kv_cache_shape) == expected_kv_cache_shape
if default_stride == test_stride:
assert all(kv.is_contiguous() for kv in model_runner.kv_caches)
else:
assert all(not kv.is_contiguous() for kv in model_runner.kv_caches)
def test_update_config(model_runner):
# Simple update
model_runner.update_config({"load_config": {"load_format": "dummy"}})
@@ -1124,21 +1080,22 @@ def test_init_kv_cache_without_kv_sharing(default_vllm_config):
vllm_config, [kv_cache_spec], [available_memory]
)[0]
assert kv_cache_config.num_blocks == num_expected_blocks
assert len(kv_cache_config.kv_cache_tensors) == 2
assert kv_cache_config.kv_cache_tensors[0].size == available_memory // 2
assert kv_cache_config.kv_cache_tensors[1].size == available_memory // 2
assert len(kv_cache_config.kv_cache_tensors) == 1
assert kv_cache_config.kv_cache_tensors[0].size == available_memory
max_context_len = estimate_max_model_len(vllm_config, kv_cache_spec, 5 * GiB_bytes)
# max context len with KV sharing should be 2x as large as without
assert max_context_len == 1310720
# important: override tensor size to prevent large mem alloc during test
# this will only allocate 2 block worth of memory (2 * 32kb)
# this will only allocate 1 block worth of memory per layer (2 layers * 32kb)
kv_cache_config.num_blocks = 1
for kv_cache_tensor in kv_cache_config.kv_cache_tensors:
kv_cache_tensor.size = kv_cache_spec[
kv_cache_tensor.shared_by[0]
].page_size_bytes
page_size = kv_cache_spec[kv_cache_tensor.layers[0]].page_size_bytes
kv_cache_tensor.size = page_size * len(kv_cache_tensor.layers)
# One block per layer: a layer's region is exactly one page.
kv_cache_tensor.layer_stride = page_size
kv_cache_tensor.block_stride = page_size
runner.initialize_kv_cache(kv_cache_config)
@@ -1207,7 +1164,9 @@ def test_init_kv_cache_with_kv_sharing_valid(default_vllm_config):
# important: override tensor size to prevent large mem alloc during test
# this will only allocate 1 block worth of memory (32kb)
kv_cache_config.num_blocks = 1
kv_cache_config.kv_cache_tensors[0].size = kv_cache_spec[layer_0].page_size_bytes
page_size = kv_cache_spec[layer_0].page_size_bytes
kv_cache_config.kv_cache_tensors[0].size = page_size
kv_cache_config.kv_cache_tensors[0].layer_stride = page_size
runner.initialize_kv_cache(kv_cache_config)
kv_cache_config_after_init = runner.kv_cache_config
@@ -1232,7 +1191,7 @@ def test_hybrid_attention_mamba_tensor_shapes():
"""
The GPU model runner creates different views into the
KVCacheTensors for the attention and mamba layers
(via _reshape_kv_cache_tensors function). This test verifies
(via _allocate_kv_caches). This test verifies
that the views are compatible: writing a mamba block
will not corrupt an attention block and vice versa
"""
@@ -1270,6 +1229,7 @@ def test_hybrid_attention_mamba_tensor_shapes():
gpu_memory_utilization=0.9,
cache_dtype="auto",
)
cache_config.kv_cache_layout = "LBNHC"
parallel_config = ParallelConfig()
attention_config = AttentionConfig(backend=AttentionBackendEnum.FLASHINFER)
vllm_config = VllmConfig(
@@ -1561,7 +1521,12 @@ def test_hybrid_cache_integration(default_vllm_config, dist_init):
kv_cache_config = KVCacheConfig(
num_blocks=NUM_BLOCKS,
kv_cache_tensors=[
KVCacheTensor(size=tensor_size, shared_by=["layer.0"]),
KVCacheTensor(
size=tensor_size,
layers=["layer.0"],
layer_stride=tensor_size,
block_stride=attn_spec.page_size_bytes,
),
],
kv_cache_groups=[
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():
gpu_memory_utilization=0.9,
cache_dtype="auto",
)
cache_config.kv_cache_layout = "LBNHC"
parallel_config = ParallelConfig()
attention_config = AttentionConfig(backend=AttentionBackendEnum.FLASHINFER)
vllm_config = VllmConfig(
+48 -17
View File
@@ -7,8 +7,11 @@ from types import SimpleNamespace
import pytest
import torch
from tests.v1.attention.utils import dense_kv_cache_views
from vllm.v1.kv_cache_interface import (
ChunkedLocalAttentionSpec,
FullAttentionSpec,
KVCacheLayout,
SlidingWindowSpec,
)
from vllm.v1.worker import utils as worker_utils
@@ -19,12 +22,6 @@ from vllm.v1.worker.utils import (
)
class _BlockFirstBackend:
@staticmethod
def get_kv_cache_block_dim(*args, **kwargs):
return 0
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
@pytest.mark.parametrize(
"spec",
@@ -52,11 +49,8 @@ def test_attention_blocks_are_zeroed(spec):
layer_name = "draft.self_attn"
zeroer = KVBlockZeroer(
device,
attn_groups_iter=[
AttentionGroup(_BlockFirstBackend, [layer_name], spec, 0) # type: ignore[arg-type]
],
attn_groups_iter=[AttentionGroup(None, [layer_name], spec, 0)],
kernel_block_sizes=[2],
cache_dtype="fp8",
static_forward_context={
layer_name: SimpleNamespace(kv_cache=storage),
},
@@ -205,16 +199,10 @@ def test_large_dsv4_launch_geometry(monkeypatch):
zeroer = KVBlockZeroer(
device,
attn_groups_iter=[
AttentionGroup(
_BlockFirstBackend, # type: ignore[arg-type]
[name],
spec,
group_id,
)
AttentionGroup(None, [name], spec, group_id)
for group_id, name in enumerate(layer_names)
],
kernel_block_sizes=[1] * n_segs,
cache_dtype="auto",
static_forward_context={
name: SimpleNamespace(kv_cache=storage)
for name, storage in storages.items()
@@ -311,3 +299,46 @@ def test_warmup_respects_available_block_count():
torch.accelerator.synchronize()
assert torch.all(storage == 1)
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
@pytest.mark.parametrize("layout", list(KVCacheLayout))
def test_zeroes_exactly_one_block_per_layer(layout: KVCacheLayout):
"""The zeroer must zero every byte of the target block in every layer and nothing
outside it — per head-group region under LHBNC, and never past the target block's
tile under block-major layouts (no out-of-bounds writes, no clobbering)."""
device = torch.device("cuda")
num_blocks, num_layers = 4, 2
spec = FullAttentionSpec(
block_size=4, num_kv_heads=2, head_size=8, dtype=torch.float32
)
raw = torch.empty(
num_blocks * num_layers * spec.page_size_bytes,
dtype=torch.int8,
device=device,
).fill_(1)
views = dense_kv_cache_views(raw, spec, num_blocks, num_layers, layout)
groups = [
AttentionGroup(
backend=None,
layer_names=[f"layer.{i}" for i in range(num_layers)],
kv_cache_spec=spec,
kv_cache_group_id=0,
)
]
ctx = {f"layer.{i}": SimpleNamespace(kv_cache=views[i]) for i in range(num_layers)}
zeroer = KVBlockZeroer(
device,
attn_groups_iter=iter(groups),
kernel_block_sizes=[spec.block_size],
static_forward_context=ctx,
)
zeroer.zero_block_ids([2])
torch.accelerator.synchronize()
for view in views:
assert (view[2] == 0).all(), layout
for b in (0, 1, 3):
assert (view[b].view(torch.int8) == 1).all(), layout
zero_bytes = int((raw == 0).sum().item())
assert zero_bytes == num_layers * spec.page_size_bytes, layout
@@ -45,7 +45,7 @@ def fused_rope_unified_mla_kv_cache_update_impl(
cos_sin_cache,
is_neox,
layer_slot_mapping,
kv_cache,
kv_cache.squeeze(1),
kv_cache_dtype,
kv_cache_scale,
)
+40
View File
@@ -3,6 +3,7 @@
from collections.abc import Callable
from dataclasses import field
from functools import cache
from typing import Any, ClassVar, Literal
from pydantic import Field, field_validator, model_validator
@@ -13,9 +14,28 @@ from vllm.utils.torch_utils import (
is_quantized_kv_cache,
kv_cache_uses_per_token_head_scales,
)
from vllm.v1.kv_cache_layout import KVCacheLayout
logger = init_logger(__name__)
_LAYOUT_COMPAT_ALIASES = {
"NHD": "LBNHC",
"HND": "LBHNC",
}
@cache
def _layout_from_name(layout_name: str) -> KVCacheLayout:
layout_name = _LAYOUT_COMPAT_ALIASES.get(layout_name, layout_name)
try:
return KVCacheLayout[layout_name]
except KeyError:
raise ValueError(
f"Unknown KV cache layout {layout_name!r}. "
f"Valid layouts: {[m.name for m in KVCacheLayout]}"
) from None
CacheDType = Literal[
"auto",
"float16",
@@ -65,6 +85,17 @@ class CacheConfig:
"""Whether block_size was explicitly provided. Derived automatically."""
user_specified_mamba_block_size: bool = field(default=False, init=False)
"""Whether mamba_block_size was explicitly provided. Derived automatically."""
kv_cache_layout: str | None = field(default=None, init=False)
"""Resolved physical KV cache layout name (a ``KVCacheLayout`` member).
``None`` means the layout has not been resolved yet. The engine core
resolves it once (``resolve_kv_cache_layout``) before memory profiling, and
every worker process adopts the resolved name — via the
``set_kv_cache_layout`` RPC, or ``KVCacheConfig.kv_cache_layout`` for
workers spawned after resolution — before the KV cache is allocated. Once
set the value is final and read with ``get_resolved_kv_cache_layout``,
which raises on ``None``. Tests and standalone tools may pre-set a value,
which resolution then honors as-is."""
prefix_match_unit: int | None = Field(default=None, gt=0)
"""The finest token boundary (in tokens) a prefix-cache hit can land on.
@@ -309,3 +340,12 @@ class CacheConfig:
str(cache_dtype),
)
return cache_dtype
def get_resolved_kv_cache_layout(self) -> KVCacheLayout:
if self.kv_cache_layout is None:
raise ValueError(
"KV cache layout has not been resolved yet; it is resolved once "
"by the engine core (resolve_kv_cache_layout) unless explicitly "
"set by the user."
)
return _layout_from_name(self.kv_cache_layout)
@@ -12,7 +12,7 @@ import torch
from vllm.config import (
VllmConfig,
get_current_vllm_config,
get_current_vllm_config_or_none,
get_layers_from_vllm_config,
set_current_vllm_config,
)
@@ -35,20 +35,20 @@ EngineId = str
BlockIds = tuple[list[int], ...] | list[list[int]]
def get_kv_connector_cache_layout():
# NOTE (NickLucche) When running disaggregated PD with NIXL, HND layout is
def get_kv_connector_cache_layout(vllm_config: VllmConfig | None = None):
# NOTE (NickLucche) When running disaggregated PD with NIXL, LBHNC layout is
# used for faster transfer.
vllm_config = get_current_vllm_config()
if vllm_config is None:
vllm_config = get_current_vllm_config_or_none()
if vllm_config is None:
return None
kv_config = vllm_config.kv_transfer_config
if kv_config is not None:
connector_cls = KVConnectorFactory.get_connector_class(kv_config)
required_kvcache_layout = connector_cls.get_required_kvcache_layout(vllm_config)
if required_kvcache_layout is not None:
return required_kvcache_layout
logger.info_once(
"Connectors do not specify a kv cache layout, defaulting to NHD."
)
return "NHD"
return None
class KVOutputAggregator:
@@ -288,11 +288,11 @@ def kv_postprocess_layout_on_receive(cache, indices):
def kv_postprocess_blksize_and_layout_on_receive(cache, indices, block_size_ratio):
"""
Transforms the layout of received KV cache to the local block_size and HND.
Transforms the layout of received KV cache to the local block_size and LBHNC.
(Only works for local blocksize > remote blocksize)
prefill is HND, smaller block_size
decode(local) is NHD, larger block_size
prefill is LBHNC, smaller block_size
decode(local) is LBNHC, larger block_size
"""
blocks_to_update = cache.index_select(0, indices)
@@ -427,48 +427,6 @@ class TransferTopology:
self._engines: dict[tuple[EngineId, int], EngineTransferInfo] = {}
# Figure out whether the first dimension of the cache is K/V
# or num_blocks.
attn_backend = self.attn_backends[0]
if not self.is_mamba:
_MOCK_BLOCK_SIZE = 16
kv_cache_shape: tuple[int, ...] = attn_backend.get_kv_cache_shape(
num_blocks=1,
block_size=_MOCK_BLOCK_SIZE,
num_kv_heads=1,
head_size=1,
)
logger.debug("Test kv_cache_shape: %s", kv_cache_shape)
assert kv_cache_shape[0] == 1, (
"KV cache layout must be blocks-first; expected mocked "
f"num_blocks=1 in leading dim, got shape {kv_cache_shape}."
)
if not self.is_mla:
assert len(kv_cache_shape) == 4, (
"Attention KV cache layout must be standardized as "
"[num_blocks, num_kv_heads, block_size, content_size], "
f"got shape {kv_cache_shape}."
)
self._cross_layers_blocks = False
if self.tensor_shape is not None:
self._cross_layers_blocks = (
len(self.tensor_shape) == len(kv_cache_shape) + 1
)
if self._cross_layers_blocks:
logger.debug("Using cross-layer KV cache")
_MOCK_NUM_LAYERS = 80
kv_cache_shape = (_MOCK_NUM_LAYERS,) + kv_cache_shape
try:
kv_cache_stride_order = attn_backend.get_kv_cache_stride_order(
include_num_layers_dimension=self._cross_layers_blocks
)
except (AttributeError, NotImplementedError):
assert self.tensor_shape is not None
kv_cache_stride_order = tuple(range(len(self.tensor_shape)))
kv_cache_shape = tuple(kv_cache_shape[i] for i in kv_cache_stride_order)
# ============================================================
# Engine registration
# ============================================================
@@ -503,24 +461,6 @@ class TransferTopology:
for key in [k for k in self._engines if k[0] == remote_engine_id]:
del self._engines[key]
# ============================================================
# Layout properties
# ============================================================
@property
def cross_layers_blocks(self) -> bool:
return self._cross_layers_blocks
@property
def virtually_split_kv_in_blocks(self) -> bool:
# Whether to logically split each block into two separately-indexable
# sub-regions. With K and V packed into the content dim, an attention
# block transfers as a single unit — no K/V sub-split is needed. Only
# Mamba still needs this, to index its two state regions (conv/ssm)
# separately. Not applicable to cross-layer blocks (per-layer
# interleaving means a simple half-split does not separate the parts).
return self.is_mamba and not self._cross_layers_blocks
# ============================================================
# Common methods
# ============================================================
@@ -48,7 +48,7 @@ from typing import TYPE_CHECKING, Any, Literal
import torch
from vllm.logger import init_logger
from vllm.v1.attention.backend import AttentionBackend, AttentionMetadata
from vllm.v1.attention.backend import AttentionMetadata
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.outputs import KVConnectorOutput
@@ -182,14 +182,6 @@ class KVConnectorBase_V1(ABC):
"""
return False
@property
def prefer_cross_layer_blocks(self) -> bool:
"""
Indicates whether this connector prefers KV blocks that hold KV data for all
layers, which can speed up KV data transfers. Defaults to False.
"""
return False
@property
def requires_kv_delivery(self) -> bool:
"""Whether this connector hands off KV that must be reliably delivered.
@@ -279,23 +271,6 @@ class KVConnectorBase_V1(ABC):
"""
return
def register_cross_layers_kv_cache(
self, kv_cache: torch.Tensor, attn_backend: type["AttentionBackend"]
):
"""
Initialize with a single KV cache tensor used by all layers.
The first dimension should be num_layers.
This function will only be called for models with uniform layers,
and only if the prefers_cross_layer_blocks is set to True.
Only one of the functions
{register_kv_caches, register_cross_layers_kv_cache} will be called.
Args:
kv_cache: a cross-layers kv cache tensor
attn_backend: The attention backend that corresponds to all layers
"""
return
def set_host_xfer_buffer_ops(self, copy_operation: CopyBlocksOp):
"""
Set the xPU-specific ops for copying KV between host and device.
@@ -129,20 +129,17 @@ class ExampleConnector(KVConnectorBase_V1):
"""Inject the KV cache into the layer.
Args:
dst_kv_cache_layer (torch.Tensor): the destination KV cache
layer. In shape [num_pages, page_size, xxx] for MLA,
[num_pages, 2, page_size, xxx] otherwise.
dst_kv_cache_layer (torch.Tensor): the destination KV cache layer,
a standardized [B, H, N, C] per-layer view (H == 1 for MLA).
src_kv_cache (torch.Tensor): the source KV cache.
slot_mapping (torch.Tensor): the slot mapping. In shape
[num_tokens].
"""
slot_mapping = slot_mapping.to(dst_kv_cache_layer.device, non_blocking=True)
if isinstance(attn_metadata, MLACommonMetadata):
dst_kv_cache_layer_shape = dst_kv_cache_layer.shape
num_pages = dst_kv_cache_layer_shape[0]
page_size = dst_kv_cache_layer_shape[1]
# [B, 1, N, C] -> [B * N, C]; slot_mapping indexes B * N slots.
dst_kv_cache_layer = dst_kv_cache_layer.reshape(
num_pages * page_size, -1
-1, dst_kv_cache_layer.shape[-1]
)
dst_kv_cache_layer[slot_mapping, ...] = src_kv_cache
else:
@@ -225,13 +222,12 @@ class ExampleConnector(KVConnectorBase_V1):
) -> torch.Tensor:
"""Extract the KV cache from the layer.
Assume the shape of the layer is (num_pages, page_size, xxx)
for MLA, and (num_pages, 2, page_size, xxx) otherwise.
The layer is a standardized [B, H, N, C] per-layer view (H == 1 for MLA).
"""
slot_mapping = slot_mapping.to(layer.device, non_blocking=True)
if isinstance(attn_metadata, MLACommonMetadata):
num_pages, page_size = layer.shape[0], layer.shape[1]
return layer.reshape(num_pages * page_size, -1)[slot_mapping, ...]
# [B, 1, N, C] -> [B * N, C]; slot_mapping indexes B * N slots.
return layer.reshape(-1, layer.shape[-1])[slot_mapping, ...]
block_idxs = slot_mapping // self._block_size
offsets = slot_mapping % self._block_size
return layer[block_idxs, :, offsets]
@@ -38,8 +38,10 @@ def extract_from_kv_cache(
num_tokens: int,
) -> torch.Tensor:
"""Extract data from KV cache."""
block_size = kv_cache.shape[1]
return kv_cache[slot_mapping // block_size, slot_mapping % block_size][:num_tokens]
block_size = kv_cache.shape[2]
return kv_cache[slot_mapping // block_size, :, slot_mapping % block_size][
:num_tokens
]
def load_hidden_states(path: str) -> dict[str, torch.Tensor]:
@@ -100,15 +102,6 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
Must be used in conjunction with the `extract_hidden_states` spec decoding method.
"""
@property
def prefer_cross_layer_blocks(self) -> bool:
"""
Indicates whether this connector prefers KV blocks that hold KV data for all
layers, which can speed up KV data transfers. Defaults to False.
"""
# Must be False so that drafter kv cache isn't merged with verifier's
return False
@classmethod
def _find_cache_kv_group_id(cls, kv_cache_config: "KVCacheConfig | None") -> int:
"""Index of the KV cache group holding the extracted hidden states.
@@ -310,10 +303,12 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
# Block size must match the indexed buffer, else reads hit the wrong
# slots. Raise (not assert) so the check survives `python -O`.
if self._block_size != self._kv_cache.shape[1]:
# Views are [num_blocks, num_heads, block_size, head_size], matching what
# extract_from_kv_cache() indexes.
if self._block_size != self._kv_cache.shape[2]:
raise ValueError(
f"Hidden-states block-size mismatch: derived {self._block_size} "
f"but buffer block size is {self._kv_cache.shape[1]}; read slots "
f"but buffer block size is {self._kv_cache.shape[2]}; read slots "
"would be wrong (likely a hybrid block-size resolution bug)."
)
@@ -605,9 +600,9 @@ class ExampleHiddenStatesConnector(KVConnectorBase_V1, SupportsHMA):
"get_required_kvcache_layout should not be called "
"on the abstract base class"
)
# NHD means we have (num_tokens, num_heads)
# HND means we have (num_heads, num_tokens)
# For now, we only support NHD layout since this keeps the
# LBNHC means we have (num_tokens, num_heads)
# LBHNC means we have (num_heads, num_tokens)
# For now, we only support LBNHC layout since this keeps the
# hidden states for each token together in memory.
# HND is primarily used when sharding heads across devices.
return "NHD"
# LBHNC is primarily used when sharding heads across devices.
return "LBNHC"
@@ -541,35 +541,32 @@ class HF3FSKVConnector(KVConnectorBase_V1):
self._dtype = first_cache.dtype
element_size = first_cache.element_size()
if self._use_mla:
assert len(first_cache.shape) == 3, "MLA format should have 3 dimensions"
# MLA format: [num_blocks, block_size, head_size]
num_blocks, block_size, head_size = first_cache.shape
num_heads = 1
else:
# MHA format: [2, num_blocks, block_size, num_heads, head_size]
_, num_blocks, block_size, num_heads, head_size = first_cache.shape
# Standardized per-layer [B, H, N, C] view; MLA is just H == 1 with the
# latent vector as the content dim.
assert len(first_cache.shape) == 4, (
f"expected a [B, H, N, C] KV cache view, got {tuple(first_cache.shape)}"
)
num_blocks, num_heads, block_size, content_dim = first_cache.shape
cache_strides = first_cache.stride()
assert all(
cache.shape == first_cache.shape and cache.stride() == cache_strides
for cache in self._kv_caches.values()
), "HF3FS requires uniform KV cache shapes and strides."
self._local_total_tokens = num_blocks * block_size
self._local_block_size = block_size
self._num_heads = num_heads
self._content_dim = content_dim
self._kv_cache_strides = cache_strides
if self._use_mla:
layer_block_size = block_size * head_size * element_size
self._bytes_per_page = layer_block_size * len(self._kv_caches)
self._shape_per_page = [
len(self._kv_caches),
block_size,
head_size,
]
else:
layer_block_size = 2 * block_size * num_heads * head_size * element_size
self._bytes_per_page = layer_block_size * len(self._kv_caches)
self._shape_per_page = [
len(self._kv_caches),
2,
block_size,
num_heads * head_size,
]
layer_block_size = num_heads * block_size * content_dim * element_size
self._bytes_per_page = layer_block_size * len(self._kv_caches)
self._shape_per_page = [
len(self._kv_caches),
num_heads,
block_size,
content_dim,
]
self._kvcache_ptrs = torch.tensor(
[cache.data_ptr() for cache in self._kv_caches.values()],
@@ -993,7 +990,10 @@ class HF3FSKVConnector(KVConnectorBase_V1):
self._local_total_tokens,
buffer_tensor,
token_indices,
is_mla=self._use_mla,
self._local_block_size,
self._num_heads,
self._content_dim,
self._kv_cache_strides,
)
else:
gather_scatter_helper.scatter_kv_caches(
@@ -1001,7 +1001,10 @@ class HF3FSKVConnector(KVConnectorBase_V1):
self._local_total_tokens,
buffer_tensor,
token_indices,
is_mla=self._use_mla,
self._local_block_size,
self._num_heads,
self._content_dim,
self._kv_cache_strides,
)
def _compute_prefix_hash(
@@ -12,10 +12,15 @@ def kv_cache_scatter_kernel(
source_ptr,
token_indices_ptr,
num_tokens_in_block,
hidden_size,
total_token_in_kvcache,
num_layers,
is_mla,
tokens_per_block,
block_stride,
head_stride,
token_stride,
content_stride,
num_heads: tl.constexpr,
content_size: tl.constexpr,
BLOCK_SIZE: tl.constexpr,
):
layer_idx = tl.program_id(0)
@@ -30,39 +35,25 @@ def kv_cache_scatter_kernel(
if token_idx >= total_token_in_kvcache:
return
if is_mla:
# MLA format: source [num_layers, num_tokens_in_block, hidden_size]
# MLA format: target [total_token_in_kvcache, hidden_size] (per layer)
source_offset = (layer_idx * num_tokens_in_block + token_pos) * hidden_size
target_offset = token_idx * hidden_size
for i in range(0, hidden_size, BLOCK_SIZE):
offset = i + tl.arange(0, BLOCK_SIZE)
mask = offset < hidden_size
val = tl.load(source_ptr + source_offset + offset, mask=mask)
tl.store(kv_cache_ptr + target_offset + offset, val, mask=mask)
else:
# MHA format: source [num_layers, 2, num_tokens_in_block, hidden_size]
# MHA format: target [2, total_token_in_kvcache, hidden_size]
source_offset_k = (
layer_idx * num_tokens_in_block * 2 + token_pos
) * hidden_size
source_offset_v = (
layer_idx * num_tokens_in_block * 2 + num_tokens_in_block + token_pos
) * hidden_size
target_offset_k = token_idx * hidden_size
target_offset_v = (total_token_in_kvcache + token_idx) * hidden_size
for i in range(0, hidden_size, BLOCK_SIZE):
offset = i + tl.arange(0, BLOCK_SIZE)
mask = offset < hidden_size
val_k = tl.load(source_ptr + source_offset_k + offset, mask=mask)
val_v = tl.load(source_ptr + source_offset_v + offset, mask=mask)
tl.store(kv_cache_ptr + target_offset_k + offset, val_k, mask=mask)
tl.store(kv_cache_ptr + target_offset_v + offset, val_v, mask=mask)
block_idx = token_idx // tokens_per_block
state_idx = token_idx % tokens_per_block
hidden_size = num_heads * content_size
for i in range(0, hidden_size, BLOCK_SIZE):
offset = i + tl.arange(0, BLOCK_SIZE)
mask = offset < hidden_size
head_idx = offset // content_size
content_idx = offset % content_size
source_offset = (
(layer_idx * num_heads + head_idx) * num_tokens_in_block + token_pos
) * content_size + content_idx
target_offset = (
block_idx * block_stride
+ head_idx * head_stride
+ state_idx * token_stride
+ content_idx * content_stride
)
value = tl.load(source_ptr + source_offset, mask=mask)
tl.store(kv_cache_ptr + target_offset, value, mask=mask)
@triton.jit
@@ -71,10 +62,15 @@ def kv_cache_gather_kernel(
dst_ptr,
token_indices_ptr,
num_tokens_in_block,
hidden_size,
total_token_in_kvcache,
num_layers,
is_mla,
tokens_per_block,
block_stride,
head_stride,
token_stride,
content_stride,
num_heads: tl.constexpr,
content_size: tl.constexpr,
BLOCK_SIZE: tl.constexpr,
):
layer_idx = tl.program_id(0)
@@ -89,37 +85,25 @@ def kv_cache_gather_kernel(
if token_idx >= total_token_in_kvcache:
return
if is_mla:
# MLA format: source [total_token_in_kvcache, hidden_size] (per layer)
# MLA format: dst [num_layers, num_tokens_in_block, hidden_size]
kvcache_offset = token_idx * hidden_size
dst_offset = (layer_idx * num_tokens_in_block + token_pos) * hidden_size
for i in range(0, hidden_size, BLOCK_SIZE):
offset = i + tl.arange(0, BLOCK_SIZE)
mask = offset < hidden_size
val = tl.load(kv_cache_ptr + kvcache_offset + offset, mask=mask)
tl.store(dst_ptr + dst_offset + offset, val, mask=mask)
else:
# MHA format: source [2, total_token_in_kvcache, hidden_size]
# MHA format: dst [num_layers, 2, num_tokens_in_block, hidden_size]
dst_offset_k = (layer_idx * num_tokens_in_block * 2 + token_pos) * hidden_size
dst_offset_v = (
layer_idx * num_tokens_in_block * 2 + num_tokens_in_block + token_pos
) * hidden_size
kvcache_offset_k = token_idx * hidden_size
kvcache_offset_v = (total_token_in_kvcache + token_idx) * hidden_size
for i in range(0, hidden_size, BLOCK_SIZE):
offset = i + tl.arange(0, BLOCK_SIZE)
mask = offset < hidden_size
val_k = tl.load(kv_cache_ptr + kvcache_offset_k + offset, mask=mask)
val_v = tl.load(kv_cache_ptr + kvcache_offset_v + offset, mask=mask)
tl.store(dst_ptr + dst_offset_k + offset, val_k, mask=mask)
tl.store(dst_ptr + dst_offset_v + offset, val_v, mask=mask)
block_idx = token_idx // tokens_per_block
state_idx = token_idx % tokens_per_block
hidden_size = num_heads * content_size
for i in range(0, hidden_size, BLOCK_SIZE):
offset = i + tl.arange(0, BLOCK_SIZE)
mask = offset < hidden_size
head_idx = offset // content_size
content_idx = offset % content_size
kvcache_offset = (
block_idx * block_stride
+ head_idx * head_stride
+ state_idx * token_stride
+ content_idx * content_stride
)
dst_offset = (
(layer_idx * num_heads + head_idx) * num_tokens_in_block + token_pos
) * content_size + content_idx
value = tl.load(kv_cache_ptr + kvcache_offset, mask=mask)
tl.store(dst_ptr + dst_offset, value, mask=mask)
def scatter_kv_caches(
@@ -127,34 +111,33 @@ def scatter_kv_caches(
total_token_in_kvcache: int,
src_tensor: torch.Tensor,
token_indices: list[int],
is_mla: bool = False,
tokens_per_block: int,
num_heads: int,
content_size: int,
kv_cache_strides: tuple[int, ...],
) -> None:
"""Scatter KV cache data from source tensor to KV cache storage.
Args:
kv_caches_ptrs: Tensor of KV cache pointers (one per layer)
total_token_in_kvcache: Total number of tokens in KV cache
src_tensor: Source tensor containing data to scatter
- MHA format: [num_layers, 2, num_tokens_in_block, hidden_size]
- MLA format: [num_layers, num_tokens_in_block, hidden_size]
src_tensor: Source ``[L, H, N, C]`` tensor containing data to scatter
token_indices: List of token positions to update
is_mla: Whether using MLA model format
tokens_per_block: Number of stored states in each cache block
num_heads: Size of the H axis
content_size: Size of the C axis
kv_cache_strides: Element strides of each ``[B, H, N, C]`` layer view
"""
num_layers = len(kv_caches_ptrs)
num_tokens_in_block = len(token_indices)
if is_mla:
# MLA: src_tensor is [num_layers, num_tokens_in_block, hidden_size]
assert len(src_tensor.shape) == 3, (
f"MLA src_tensor should be 3D, got {src_tensor.shape}"
)
hidden_size = src_tensor.shape[2]
else:
# MHA: src_tensor is [num_layers, 2, num_tokens_in_block, hidden_size]
assert len(src_tensor.shape) == 4, (
f"MHA src_tensor should be 4D, got {src_tensor.shape}"
)
hidden_size = src_tensor.shape[3]
assert src_tensor.shape == (
num_layers,
num_heads,
num_tokens_in_block,
content_size,
)
assert len(kv_cache_strides) == 4
device = src_tensor.device
token_indices_tensor = torch.tensor(
@@ -169,10 +152,12 @@ def scatter_kv_caches(
src_tensor,
token_indices_tensor,
num_tokens_in_block,
hidden_size,
total_token_in_kvcache,
num_layers,
is_mla,
tokens_per_block,
*kv_cache_strides,
num_heads=num_heads,
content_size=content_size,
BLOCK_SIZE=BLOCK_SIZE,
)
@@ -182,49 +167,33 @@ def gather_kv_caches(
total_token_in_kvcache: int,
dst_tensor: torch.Tensor,
token_indices: list[int],
is_mla: bool = False,
tokens_per_block: int,
num_heads: int,
content_size: int,
kv_cache_strides: tuple[int, ...],
) -> None:
"""Gather KV cache data from KV cache storage to destination tensor.
Args:
kv_caches_ptrs: Tensor of KV cache pointers (one per layer)
total_token_in_kvcache: Total number of tokens in KV cache
dst_tensor: Destination tensor to store gathered data
- MHA format: [num_layers, 2, num_tokens_in_block, hidden_size]
- MLA format: [num_layers, num_tokens_in_block, hidden_size]
dst_tensor: Destination ``[L, H, N, C]`` tensor
token_indices: List of token positions to gather
is_mla: Whether using MLA model format
tokens_per_block: Number of stored states in each cache block
num_heads: Size of the H axis
content_size: Size of the C axis
kv_cache_strides: Element strides of each ``[B, H, N, C]`` layer view
"""
num_layers = kv_caches_ptrs.shape[0]
num_tokens_in_block = len(token_indices)
if is_mla:
# MLA: dst_tensor is [num_layers, num_tokens_in_block, hidden_size]
assert len(dst_tensor.shape) == 3, (
f"MLA dst_tensor should be 3D, got {dst_tensor.shape}"
)
assert dst_tensor.shape[0] == num_layers, (
f"Layer count mismatch: {dst_tensor.shape[0]} vs {num_layers}"
)
assert dst_tensor.shape[1] == num_tokens_in_block, (
f"Token count mismatch: {dst_tensor.shape[1]} vs {num_tokens_in_block}"
)
hidden_size = dst_tensor.shape[2]
else:
# MHA: dst_tensor is [num_layers, 2, num_tokens_in_block, hidden_size]
assert len(dst_tensor.shape) == 4, (
f"MHA dst_tensor should be 4D, got {dst_tensor.shape}"
)
assert dst_tensor.shape[0] == num_layers, (
f"Layer count mismatch: {dst_tensor.shape[0]} vs {num_layers}"
)
assert dst_tensor.shape[1] == 2, (
f"MHA should have 2 (K,V) components, got {dst_tensor.shape[1]}"
)
assert dst_tensor.shape[2] == num_tokens_in_block, (
f"Token count mismatch: {dst_tensor.shape[2]} vs {num_tokens_in_block}"
)
hidden_size = dst_tensor.shape[3]
assert dst_tensor.shape == (
num_layers,
num_heads,
num_tokens_in_block,
content_size,
)
assert len(kv_cache_strides) == 4
device = dst_tensor.device
token_indices_tensor = torch.tensor(
@@ -239,10 +208,12 @@ def gather_kv_caches(
dst_tensor,
token_indices_tensor,
num_tokens_in_block,
hidden_size,
total_token_in_kvcache,
num_layers,
is_mla,
tokens_per_block,
*kv_cache_strides,
num_heads=num_heads,
content_size=content_size,
BLOCK_SIZE=BLOCK_SIZE,
)
@@ -49,15 +49,15 @@ from vllm.model_executor.models.utils import extract_layer_index
from vllm.platforms import current_platform
from vllm.utils.math_utils import cdiv
from vllm.utils.network_utils import get_ip, make_zmq_path, make_zmq_socket
from vllm.utils.torch_utils import is_non_overlapping_and_dense
from vllm.v1.attention.backend import AttentionMetadata
from vllm.v1.attention.backends.utils import NULL_BLOCK_ID, get_kv_cache_layout
from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.kv_cache_interface import (
AttentionSpec,
FullAttentionSpec,
KVCacheSpec,
MambaSpec,
MLAAttentionSpec,
SlidingWindowMLASpec,
SlidingWindowSpec,
)
from vllm.v1.request import RequestStatus
@@ -122,9 +122,7 @@ def _expand_transfer_regions(
kv_block_lens: list[int],
layer_names: list[str],
layer_indices: list[int],
is_kv_layout_blocks_first: bool,
group_indices: list[int] | None = None,
split_kv_regions: list[bool] | None = None,
) -> list[TransferRegion]:
"""Expand registered KV tensors into the regions transferred by Mooncake."""
assert (
@@ -146,13 +144,6 @@ def _expand_transfer_regions(
"Mooncake transfer regions require matching group metadata lengths, "
f"got group_indices={len(group_indices)}, layer_names={len(layer_names)}."
)
if split_kv_regions is None:
split_kv_regions = [is_kv_layout_blocks_first] * len(layer_names)
assert len(split_kv_regions) == len(layer_names), (
"Mooncake transfer regions require matching split metadata, "
f"got split_kv_regions={len(split_kv_regions)}, "
f"layer_names={len(layer_names)}."
)
regions: list[TransferRegion] = []
for (
base_addr,
@@ -161,7 +152,6 @@ def _expand_transfer_regions(
layer_name,
layer_index,
group_index,
split_kv_region,
) in zip(
base_addrs,
block_lens,
@@ -169,7 +159,6 @@ def _expand_transfer_regions(
layer_names,
layer_indices,
group_indices,
split_kv_regions,
):
regions.append(
TransferRegion(
@@ -181,17 +170,6 @@ def _expand_transfer_regions(
group_index=group_index,
)
)
if split_kv_region:
regions.append(
TransferRegion(
layer_name=layer_name,
layer_index=layer_index,
base_addr=base_addr + kv_block_len,
block_len=block_len,
kv_block_len=kv_block_len,
group_index=group_index,
)
)
return regions
@@ -506,10 +484,10 @@ class MooncakeConnector(KVConnectorBase_V1, SupportsHMA):
if vllm_config.model_config.use_mla:
return None
logger.info_once(
"MooncakeConnector setting KV cache layout to HND for "
"MooncakeConnector setting KV cache layout to LBHNC for "
"heterogeneous TP-safe KV transfer."
)
return "HND"
return "LBHNC"
############################################################
# Scheduler Side Methods
@@ -1023,12 +1001,10 @@ class MooncakeConnectorWorker:
self._sync_block_size_with_kernel()
self.attn_backends = get_current_attn_backends(vllm_config)
self.kv_cache_layout = get_kv_cache_layout()
logger.debug(
"Detected attention backends %s",
[backend.get_name() for backend in self.attn_backends],
)
logger.debug("Detected kv cache layout %s", self.kv_cache_layout)
self._tp_size: dict[EngineId, int] = {self.engine_id: self.tp_size}
self._layer_specs: dict[str, KVCacheSpec] = {}
@@ -1665,7 +1641,7 @@ class MooncakeConnectorWorker:
self.registered_layer_indices = []
self.registered_group_indices = []
for layer_name, cache_or_caches in kv_caches.items():
for layer_name, cache in kv_caches.items():
layer_index = extract_layer_index(layer_name)
layer_spec = self._layer_specs.get(layer_name)
if layer_spec is None:
@@ -1674,34 +1650,30 @@ class MooncakeConnectorWorker:
layer_name,
)
continue
if isinstance(layer_spec, MambaSpec):
conv, _ = cache_or_caches
cache_list = [conv]
# One raw page tensor per layer; for Mamba that page holds all the
# recurrent states, unpacked only when binding the cache for execution.
self._log_debug_cache_registration(layer_name, cache)
block_is_contiguous = is_non_overlapping_and_dense(cache[0])
if not block_is_contiguous:
# Non-block-compact layouts scatter a block across per-head
# regions; each region's blocks are contiguous.
region_caches = [cache[:, head] for head in range(cache.shape[1])]
assert all(
is_non_overlapping_and_dense(region[0]) for region in region_caches
)
else:
# K and V are packed into one blocks-first tensor per layer,
# so each layer registers as a single region.
cache_list = [cache_or_caches]
region_caches = [cache]
logger.debug(
"registering layer %s with %d cache tensor(s)",
layer_name,
len(cache_list),
)
for cache in cache_list:
self._log_debug_cache_registration(layer_name, cache)
base_addr = cache.data_ptr()
block_len = cache.stride(0) * cache.element_size()
for region_cache in region_caches:
base_addr = region_cache.data_ptr()
block_len = region_cache.stride(0) * region_cache.element_size()
region_base_addresses.append(base_addr)
if isinstance(layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)):
kv_block_len = layer_spec.page_size_bytes
elif self.transfer_topo.virtually_split_kv_in_blocks and not isinstance(
layer_spec, MambaSpec
):
kv_block_len = block_len // 2
else:
kv_block_len = block_len
kv_block_len = (
layer_spec.page_size_bytes
if isinstance(layer_spec, AttentionSpec) and block_is_contiguous
else block_len
)
self.block_len_per_layer.append(block_len)
self.kv_block_len_per_layer.append(kv_block_len)
self.registered_layer_names.append(layer_name)
@@ -1709,12 +1681,12 @@ class MooncakeConnectorWorker:
self.registered_group_indices.append(
self._layer_group_indices[layer_name]
)
storage = cache.untyped_storage()
storage_addr = storage.data_ptr()
if storage_addr not in seen_storage_ptrs:
seen_storage_ptrs.add(storage_addr)
kv_data_ptrs.append(storage_addr)
kv_data_lens.append(storage.nbytes())
storage = cache.untyped_storage()
storage_addr = storage.data_ptr()
if storage_addr not in seen_storage_ptrs:
seen_storage_ptrs.add(storage_addr)
kv_data_ptrs.append(storage_addr)
kv_data_lens.append(storage.nbytes())
self.kv_caches_base_addr = region_base_addresses
self.seen_base_addresses = kv_data_ptrs
@@ -2055,24 +2027,13 @@ class MooncakeConnectorWorker:
self._layer_group_indices.get(layer_name, 0)
for layer_name in layer_names
]
split_kv_regions = None
if self.transfer_topo.virtually_split_kv_in_blocks:
split_kv_regions = [
not isinstance(
self._layer_specs[layer_name],
(MambaSpec, MLAAttentionSpec, SlidingWindowMLASpec),
)
for layer_name in layer_names
]
return _expand_transfer_regions(
base_addrs=base_addrs,
block_lens=block_lens,
kv_block_lens=kv_block_lens,
layer_names=layer_names,
layer_indices=layer_indices,
is_kv_layout_blocks_first=self.transfer_topo.virtually_split_kv_in_blocks,
group_indices=group_indices,
split_kv_regions=split_kv_regions,
)
def _get_sender_transfer_plan(
@@ -89,14 +89,6 @@ class MooncakeStoreKVEvents(KVConnectorKVEvents):
class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
"""KV connector using MooncakeDistributedStore as shared KV pool."""
@property
def prefer_cross_layer_blocks(self) -> bool:
extra_config = self._kv_transfer_config.kv_connector_extra_config
return (
str(extra_config.get("enable_cross_layers_blocks", "False")).lower()
== "true"
)
@staticmethod
def _validate_kv_cache_config(
vllm_config: VllmConfig, kv_cache_config: KVCacheConfig
@@ -290,16 +282,6 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
assert self.connector_worker is not None
self.connector_worker.register_kv_caches(kv_caches)
def register_cross_layers_kv_cache(
self, kv_cache: torch.Tensor, attn_backend: type
):
assert self.connector_worker is not None
assert (
self._kv_cache_config is not None
and len(self._kv_cache_config.kv_cache_groups) == 1
), "Cross-layer KV cache does not supported with hybrid models"
self.connector_worker.register_cross_layers_kv_caches(kv_cache)
def start_load_kv(self, forward_context: ForwardContext, **kwargs: Any) -> None:
# No-op: loads are issued in get_finished() for compute overlap.
pass
@@ -58,6 +58,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.protocol import
from vllm.logger import init_logger
from vllm.utils.math_utils import cdiv
from vllm.utils.network_utils import get_ip, make_zmq_socket
from vllm.utils.torch_utils import is_non_overlapping_and_dense
from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
from vllm.v1.core.kv_cache_utils import (
BlockHash,
@@ -72,6 +73,7 @@ from vllm.v1.kv_cache_interface import (
MLAAttentionSpec,
SlidingWindowMLASpec,
UniformTypeKVCacheSpecs,
group_kernel_blocks,
)
from .metrics import MooncakeStoreConnectorStats
@@ -1617,18 +1619,9 @@ class MooncakeStoreWorker:
for g_idx, db in enumerate(self.token_dbs)
)
def register_cross_layers_kv_caches(self, kv_cache: torch.Tensor) -> None:
"""Register a cross-layers KV cache tensor.
Wraps the unified tensor in a single-entry dict so that the
existing stride-based logic in register_kv_caches() produces
the correct single-segment result (block_len = page_size * num_layers).
"""
self.register_kv_caches({"__cross_layer__": kv_cache})
def register_kv_caches(
self,
kv_caches: dict[str, torch.Tensor | list[torch.Tensor]],
kv_caches: dict[str, torch.Tensor],
) -> None:
"""Register KV cache tensors and start transfer threads."""
if self._capacity_only:
@@ -1637,58 +1630,62 @@ class MooncakeStoreWorker:
logger.warning("No KV caches to offload.")
return
# Resolve each entry to a representative tensor for storage
# deduplication. For attention layers the value is already a tensor;
# for Mamba layers it is a list of tensors that all share the same
# underlying raw storage, so we take the first one.
def _repr_tensor(v: torch.Tensor | list[torch.Tensor]) -> torch.Tensor:
assert isinstance(v, torch.Tensor | list)
return v if isinstance(v, torch.Tensor) else v[0]
assert self.cache_config.num_gpu_blocks is not None
self.num_blocks = self.cache_config.num_gpu_blocks
seen_ptrs: set[int] = set()
seen_storage_ptrs: set[int] = set()
seen_region_ptrs: set[int] = set()
addrs: list[int] = []
block_lens: list[int] = []
for value in kv_caches.values():
cache = _repr_tensor(value)
for cache in kv_caches.values():
cache = group_kernel_blocks(cache, self.num_blocks)
cache_storage = cache.untyped_storage()
base_addr = cache_storage.data_ptr()
if base_addr in seen_ptrs:
continue
seen_ptrs.add(base_addr)
region_len = cache_storage.nbytes()
ret = self.store.register_buffer(base_addr, region_len)
if ret != 0:
logger.error(
"register_buffer failed for addr %#x len %d: %d",
base_addr,
region_len,
ret,
)
if base_addr not in seen_storage_ptrs:
seen_storage_ptrs.add(base_addr)
ret = self.store.register_buffer(base_addr, region_len)
if ret != 0:
logger.error(
"register_buffer failed for addr %#x len %d: %d",
base_addr,
region_len,
ret,
)
# Detect layout via stride: a dim whose byte-stride exceeds
# page_size_bytes is an outer segment dim (e.g. the K/V dim of
# FlashAttn's (2, num_blocks, ...)). FlashInfer/MLA's blocks-
# outermost layout has no such dim and yields a single segment.
el = cache.element_size()
page_size_bytes = region_len // self.num_blocks
outer_dims = [
d for d in range(cache.ndim) if cache.stride(d) * el > page_size_bytes
]
if not outer_dims:
# Blocks-first layout (FlashInfer / MLA): one segment.
if not is_non_overlapping_and_dense(cache[0]):
# A block is scattered across per-head regions; each region's
# blocks are contiguous.
for head_idx in range(cache.shape[1]):
head_cache = cache[:, head_idx]
assert is_non_overlapping_and_dense(head_cache[0])
region_addr = head_cache.data_ptr()
if region_addr in seen_region_ptrs:
continue
seen_region_ptrs.add(region_addr)
addrs.append(region_addr)
block_lens.append(head_cache.stride(0) * head_cache.element_size())
elif cache.stride(0) * cache.element_size() * self.num_blocks == region_len:
# The block stride spans the whole per-block window
# (block-outermost and packed layouts, and single-layer
# tensors), which may hold other layers' pages at higher
# offsets. Register the storage once as one whole-window
# region; per-layer regions would copy overlapping windows and
# run past the storage for offset layers.
if base_addr in seen_region_ptrs:
continue
seen_region_ptrs.add(base_addr)
addrs.append(base_addr)
block_lens.append(page_size_bytes)
block_lens.append(region_len // self.num_blocks)
else:
# K/V-first layout (FlashAttn / ROCm): split segments.
seg_stride = cache.stride(outer_dims[0]) * el
for idx in range(cache.shape[outer_dims[0]]):
addrs.append(base_addr + idx * seg_stride)
block_lens.append(seg_stride // self.num_blocks)
region_addr = cache.data_ptr()
if region_addr in seen_region_ptrs:
continue
seen_region_ptrs.add(region_addr)
addrs.append(region_addr)
block_lens.append(cache.stride(0) * cache.element_size())
logger.info(
"Registered KV caches: num_groups=%d, num_segments=%d, num_blocks=%d",
@@ -57,11 +57,6 @@ def is_mla_cache_layer(
return isinstance(spec, (MLAAttentionSpec, SlidingWindowMLASpec))
def _content_packed_dim(spec: AttentionSpec) -> int:
head_size_v = getattr(spec, "head_size_v", spec.head_size)
return spec.head_size + head_size_v
def _spec_dim_matches(value: int, expected: int | None) -> bool:
return expected is None or value == expected
@@ -110,23 +105,6 @@ def get_layer_transfer_geometry(
spec = layer_to_spec[layer_name]
is_mla_cache = is_mla_cache_layer(layer_to_spec, layer_name)
if is_mla_cache and len(shape) == 3:
num_blocks, block_size, latent_dim = shape
slot_size_bytes = latent_dim * element_size
block_len = block_size * slot_size_bytes
return LayerTransferGeometry(
num_blocks=num_blocks,
block_size=block_size,
block_len=block_len,
slot_size_bytes=slot_size_bytes,
block_stride=stride[0],
local_kv_stride=None,
remote_kv_stride=None,
transfers_per_block=1,
regions_per_block=1,
split_kv_regions=False,
)
if not is_mla_cache and len(shape) == 5 and shape[0] == 2:
_, num_blocks = shape[:2]
kernel_blocks_per_block = 1
@@ -230,19 +208,19 @@ def get_layer_transfer_geometry(
)
if (
not is_mla_cache
and isinstance(spec, AttentionSpec)
isinstance(spec, AttentionSpec)
and len(shape) == 4
and shape[1] == spec.num_kv_heads
and shape[2] == spec.block_size
and shape[3] == _content_packed_dim(spec)
and shape[1] == spec.num_heads
and shape[2] == spec.num_states
and shape[3] * element_size == spec.state_content_size_bytes
):
num_blocks, num_kv_heads, block_size, packed_dim = shape
slot_size_bytes = num_kv_heads * packed_dim * element_size
block_len = block_size * slot_size_bytes
# Standardized per-layer [B, H, N, C] view (MLA is just H == 1).
num_blocks, num_heads, num_states, content_dim = shape
slot_size_bytes = num_heads * content_dim * element_size
block_len = num_states * slot_size_bytes
return LayerTransferGeometry(
num_blocks=num_blocks,
block_size=block_size,
block_size=spec.block_size,
block_len=block_len,
slot_size_bytes=slot_size_bytes,
block_stride=stride[0],
@@ -267,6 +245,16 @@ def iter_layer_registration_regions(
) -> list[tuple[torch.Tensor, int]]:
geometry = get_layer_transfer_geometry(layer_name, kv_cache, layer_to_spec)
region_len = geometry.num_blocks * geometry.regions_per_block * geometry.block_len
if geometry.regions_per_block == 1:
# With padded or interleaved pages the block stride exceeds the
# meaningful block_len; register the strided span. The span ends with
# the last block's meaningful bytes, not a whole stride, so it never
# runs past the backing allocation of a strided layer view.
block_stride_bytes = geometry.block_stride * kv_cache.element_size()
region_len = max(
region_len,
(geometry.num_blocks - 1) * block_stride_bytes + geometry.block_len,
)
if geometry.split_kv_regions:
return [(cache, region_len) for cache in kv_cache]
return [(kv_cache, region_len)]
@@ -27,7 +27,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.metrics import (
PromMetricT,
)
from vllm.logger import init_logger
from vllm.v1.attention.backend import AttentionBackend, AttentionMetadata
from vllm.v1.attention.backend import AttentionMetadata
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.outputs import KVConnectorOutput
@@ -209,12 +209,6 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
c.supports_divergent_local_hybrid_hits for c in self._connectors
)
@property
def prefer_cross_layer_blocks(self) -> bool:
if not self._connectors:
return False
return all(c.prefer_cross_layer_blocks for c in self._connectors)
@property
def requires_kv_delivery(self) -> bool:
return any(c.requires_kv_delivery for c in self._connectors)
@@ -245,13 +239,6 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
)
return ret
def register_cross_layers_kv_cache(
self, kv_cache: torch.Tensor, attn_backend: type[AttentionBackend]
):
# Register on all connectors
for c in self._connectors:
c.register_cross_layers_kv_cache(kv_cache, attn_backend)
def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
for c in self._connectors:
c.register_kv_caches(kv_caches)
@@ -71,9 +71,9 @@ from vllm.platforms import current_platform
from vllm.utils.gpu_sync_debug import gpu_sync_allowed
from vllm.utils.network_utils import make_zmq_path
from vllm.utils.torch_utils import async_tensor_h2d
from vllm.v1.attention.backends.utils import get_kv_cache_layout
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheLayout,
MambaSpec,
MLAAttentionSpec,
SlidingWindowMLASpec,
@@ -89,6 +89,13 @@ if TYPE_CHECKING:
logger = init_logger(__name__)
def _share_storage_and_block_stride(caches: list[torch.Tensor]) -> bool:
"""Return whether all views share storage and a block stride."""
block_strides = {cache.stride(0) * cache.element_size() for cache in caches}
storage_ptrs = {cache.untyped_storage().data_ptr() for cache in caches}
return len(block_strides) == len(storage_ptrs) == 1
class NixlBaseConnectorWorker:
"""Base implementation of Worker side methods shared by pull and push."""
@@ -302,7 +309,6 @@ class NixlBaseConnectorWorker:
for group in kv_cache_config.kv_cache_groups
for layer in group.layer_names
}
self.hma_group_size = len(kv_cache_config.kv_cache_tensors)
# ---- Model state (derived from model config) ----
mamba_ssm_size = (0, 0)
@@ -523,7 +529,9 @@ class NixlBaseConnectorWorker:
self.attn_backends = get_current_attn_backends(vllm_config)
self.backend_name = self.attn_backends[0].get_name()
self.kv_cache_layout = get_kv_cache_layout()
self.kv_cache_layout = (
vllm_config.cache_config.get_resolved_kv_cache_layout().name
)
self.host_buffer_kv_cache_layout = self.kv_cache_layout
logger.info(
"Detected attention backend(s) %s",
@@ -558,6 +566,11 @@ class NixlBaseConnectorWorker:
# This is not used for SSM layers, which use the counterpart `mamba_ssm_size`.
self.block_len_per_layer = list[int]()
# Per-region block stride in bytes. Taken from the registered tensor's
# stride(0) so it stays correct under layouts that interleave layers
# within a block (BLHNC/BHLNC), where stride > block_len.
self.block_stride_per_layer = list[int]()
# Per-engine TP mappings. Generated during handshake.
self.tp_mappings: dict[EngineId, TPMapping] = {}
@@ -788,16 +801,16 @@ class NixlBaseConnectorWorker:
if not self.use_mla:
assert kv_cache.ndim == 4
if self.kv_cache_layout == "NHD":
if self.kv_cache_layout == "LBNHC":
if self.kv_transfer_config.enable_permute_local_kv:
logger.info_once(
"'enable_permute_local_kv' flag is enabled while "
"device KV Layout is NHD. Init host buffer with"
" HND to better support Decode/Prefill TP_ratio > 1."
"device KV Layout is LBNHC. Init host buffer with"
" LBHNC to better support Decode/Prefill TP_ratio > 1."
)
# Since NHD will not support Decode/Prefill TP_ratio > 1,
# Since LBNHC will not support Decode/Prefill TP_ratio > 1,
# we can leverage host_buffer for permute.
self.host_buffer_kv_cache_layout = "HND"
self.host_buffer_kv_cache_layout = "LBHNC"
else:
# Packed KV layout is logical (B, H, N, 2*D). Allocate
# (B, N, H, 2*D) and view it as logical (B, H, N, 2*D)
@@ -969,120 +982,9 @@ class NixlBaseConnectorWorker:
fut.add_done_callback(request_ready)
def register_cross_layers_kv_caches(self, kv_cache: torch.Tensor) -> None:
"""Register a cross-layers KV cache tensor with NIXL.
`use_uniform_kv_cache()` guarantees a single KV cache group whose
layers all share the same `AttentionSpec`, so any layer name from
`_layer_specs` yields the correct per-layer spec for `page_size_bytes`.
"""
first_layer = next(iter(self._layer_specs))
# Forwarding a real layer name rather than a synthetic key
self.register_kv_caches({first_layer: kv_cache})
def _register_packed_kv_cache(
self,
storage: torch.UntypedStorage,
) -> None:
"""Register a packed KV cache as a single NIXL region.
The packed allocation interleaves all layers per block, so each
block_stride-byte chunk is one logical block. We register 1
NIXL region and create 1 descriptor per block.
"""
self.transfer_topo = TransferTopology(
tp_rank=self.tp_rank,
tp_size=self.world_size,
block_size=self.block_size,
engine_id=self.engine_id,
is_mla=self.use_mla,
total_num_kv_heads=self.model_config.get_total_num_kv_heads(),
attn_backends=self.attn_backends,
tensor_shape=None,
is_mamba=self._has_mamba,
)
self.compat_hash = compute_nixl_compatibility_hash(
self.vllm_config,
self.backend_name,
self.transfer_topo.cross_layers_blocks,
transfer_mode=self._TRANSFER_MODE,
)
total_size = storage.nbytes()
block_stride = total_size // self.num_blocks
base_addr = storage.data_ptr()
device_id = storage.device.index
assert device_id is not None
logger.info(
"Registering packed KV cache: total_size=%s, block_stride=%s, "
"num_blocks=%s, num_regions=1",
total_size,
block_stride,
self.num_blocks,
)
self.device_id = device_id
caches_data = [(base_addr, total_size, self.device_id, "")]
self.block_len_per_layer = [block_stride]
self.num_regions = 1
self.num_descs = self.num_blocks
self.kv_caches_base_addr[self.engine_id][self.tp_rank] = [base_addr]
descs = self.nixl_wrapper.get_reg_descs(caches_data, self.nixl_memory_type)
self.nixl_wrapper.register_memory(descs, backends=self.nixl_backends)
self._registered_descs.append(descs)
self.dst_num_blocks[self.engine_id] = self.num_blocks
self.src_xfer_handles_by_block_size[self.block_size], (self.src_blocks_data) = (
self.register_local_xfer_handler(self.block_size)
)
agent_metadata = NixlAgentMetadata(
engine_id=self.engine_id,
agent_metadata=self.nixl_wrapper.get_agent_metadata(),
device_id=self.device_id,
kv_caches_base_addr=(
self.kv_caches_base_addr[self.engine_id][self.tp_rank]
),
num_blocks=self.num_blocks,
block_lens=self.block_len_per_layer,
kv_cache_layout=self.kv_cache_layout,
block_size=self.block_size,
ssm_sizes=self._mamba_ssm_size,
attn_backend_name=self.backend_name,
physical_blocks_per_logical_kv_block=(
self._physical_blocks_per_logical_kv_block
),
dcp_size=self.dcp_size,
pcp_size=self.pcp_size,
)
assert self.compat_hash is not None
encoder = msgspec.msgpack.Encoder()
self.xfer_handshake_metadata = NixlHandshakePayload(
compatibility_hash=self.compat_hash,
agent_metadata_bytes=encoder.encode(agent_metadata),
)
def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
"""Register the KV Cache data in nixl."""
# Detect packed allocation: all tensors are strided views into the
# same backing storage (different data_ptr but same storage).
# This happens with DSv4-style contiguous per-block packing.
if len(kv_caches) > 1 and not self._has_mamba:
storage = next(iter(kv_caches.values())).untyped_storage()
storage_ptrs = {
cache.untyped_storage().data_ptr() for cache in kv_caches.values()
}
data_ptrs = {cache.data_ptr() for cache in kv_caches.values()}
if len(storage_ptrs) == 1 and len(data_ptrs) > 1:
self._register_packed_kv_cache(storage)
self.device_kv_caches = kv_caches
return
self.transfer_topo = TransferTopology(
tp_rank=self.tp_rank,
tp_size=self.world_size,
@@ -1100,7 +1002,6 @@ class NixlBaseConnectorWorker:
self.compat_hash = compute_nixl_compatibility_hash(
self.vllm_config,
self.backend_name,
self.transfer_topo.cross_layers_blocks,
transfer_mode=self._TRANSFER_MODE,
)
@@ -1127,14 +1028,14 @@ class NixlBaseConnectorWorker:
)
caches_data = []
# With hybrid allocator, layers can share a kv cache tensor
seen_storage_addresses: set[int] = set()
seen_base_addresses: list[int] = []
packed_storage = _share_storage_and_block_stride(list(xfer_buffers.values()))
# K and V are packed into the content dim, so each attention layer is a
# single NIXL region whose block transfers as one unit. Mamba layers instead
# register separate conv/ssm sub-regions (see `_build_mamba_local`).
tensor_size_bytes = None
for layer_name, cache in xfer_buffers.items():
# NOTE (NickLucche) Hybrid SSM mamba/FA physical page_size may differ when
# kernel requires a specific block size. This leads to SSM and FA layers
@@ -1159,55 +1060,98 @@ class NixlBaseConnectorWorker:
else layer_spec.page_size_bytes
// self._physical_blocks_per_logical_kv_block
)
if self.transfer_topo._cross_layers_blocks:
# When cross-layers blocks are used, multiply by number of layers
physical_page_size = physical_page_size * len(
self.kv_cache_config.kv_cache_tensors
)
num_blocks = (
self._logical_num_blocks
if isinstance(layer_spec, MambaSpec)
else self.num_blocks
)
# `page_size` accounts for physical blocks, st KVCache is always
# [`num_blocks` * `page_size`]
curr_tensor_size_bytes = num_blocks * physical_page_size
base_addr = cache.data_ptr()
is_mla_region = isinstance(
layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)
)
if base_addr in seen_base_addresses:
# NOTE (NickLucche) HMA employs memory pooling to share tensors
# across groups. This results in skipping all tensors but the ones
# pointed to by group0. Also, generally we will have more blocks
# per tensor but fewer regions.
# A shared tensor may back both SSM and attention layers (e.g.
# KDA+MLA in KimiLinear); the region's FA view is MLA whichever
# layer registered it first.
idx = seen_base_addresses.index(base_addr)
self._region_is_mla[idx] |= is_mla_region
logger.debug("Skipping %s because it's already seen", layer_name)
continue
logger.debug(
"Registering layer %s with cache shape: %s", layer_name, cache.shape
)
seen_base_addresses.append(base_addr)
# Only record non-Mamba page sizes.
if isinstance(layer_spec, MambaSpec):
self.block_len_per_layer.append(
physical_page_size // self._physical_blocks_per_logical_kv_block
)
else:
self.block_len_per_layer.append(physical_page_size)
self._region_is_mla.append(is_mla_region)
storage = cache.untyped_storage()
storage_addr = storage.data_ptr()
# Memory registration follows allocations, while transfer regions follow
# logical layers (or contiguous head segments). This keeps strided
# cross-layer views inside their registered allocation.
if storage_addr not in seen_storage_addresses:
seen_storage_addresses.add(storage_addr)
self.device_id = max(cache.get_device(), 0)
caches_data.append((storage_addr, storage.nbytes(), self.device_id, ""))
if not is_mla_region:
if tensor_size_bytes is None:
tensor_size_bytes = curr_tensor_size_bytes
assert tensor_size_bytes == curr_tensor_size_bytes, (
"All non-MLA kv cache tensors must have the same size"
is_mla_region = isinstance(
layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)
)
if isinstance(layer_spec, MambaSpec):
region_specs = [
(
cache.data_ptr(),
physical_page_size
// self._physical_blocks_per_logical_kv_block,
physical_page_size
// self._physical_blocks_per_logical_kv_block,
)
]
else:
if cache.ndim == 1:
# Flat byte view: HMA tensors shared between layer types carry
# no block dimension, so stride(0) is 1 byte. Blocks abut, so
# the stride is the page size.
block_stride = physical_page_size
else:
block_stride = cache.stride(0) * cache.element_size()
storage_is_block_major = num_blocks * block_stride == storage.nbytes()
# A layer whose [H, N, C] interior is dense addresses its own page
# as one chunk, so it can own a region. When it does not, or when
# every layer is packed into this one storage, the block's whole
# row is the only contiguous unit.
hnc_contiguous = (
cache.ndim == 4
and cache.stride(2) == cache.shape[3]
and cache.stride(1) == cache.shape[2] * cache.shape[3]
)
if storage_is_block_major and (
(packed_storage and is_mla_region) or not hnc_contiguous
):
# TODO(Lucas): handle TP slicing for packed_storage; for now
# restrict to MLA (DSv4) where kv is replicated.
storage_block_len = storage.nbytes() // num_blocks
region_specs = [
(storage_addr, storage_block_len, storage_block_len)
]
elif storage_is_block_major:
region_specs = [
(cache.data_ptr(), physical_page_size, block_stride)
]
else:
segment_bytes = num_blocks * block_stride
assert cache.nbytes % segment_bytes == 0
num_segments = cache.nbytes // segment_bytes
region_block_len = (
block_stride if num_segments > 1 else physical_page_size
)
region_specs = [
(
cache.data_ptr() + segment_idx * segment_bytes,
region_block_len,
block_stride,
)
for segment_idx in range(num_segments)
]
for base_addr, block_len, block_stride in region_specs:
if base_addr in seen_base_addresses:
if is_mla_region:
# Dual-purpose HMA tensor: an MLA layer shares a region
# that a non-MLA layer registered first. MLA is not
# head-sharded, so the region must be flagged MLA.
idx = seen_base_addresses.index(base_addr)
self._region_is_mla[idx] = True
continue
seen_base_addresses.append(base_addr)
self.block_len_per_layer.append(block_len)
self.block_stride_per_layer.append(block_stride)
self._region_is_mla.append(is_mla_region)
# When there's a mismatch between kbs<>bs, we rely on HMA to ensure
# caches are either [NB, PS] or [NB*r, PS/r] where r is bs/kbs.
@@ -1228,11 +1172,6 @@ class NixlBaseConnectorWorker:
f"kv_cache_layout={self.kv_cache_layout}"
)
# Need to make sure the device ID is non-negative for NIXL,
# Torch uses -1 to indicate CPU tensors.
self.device_id = max(cache.get_device(), 0)
caches_data.append((base_addr, curr_tensor_size_bytes, self.device_id, ""))
logger.debug(
"Different block lengths collected: %s", set(self.block_len_per_layer)
)
@@ -1240,10 +1179,11 @@ class NixlBaseConnectorWorker:
len(self.block_len_per_layer)
== len(seen_base_addresses)
== len(self._region_is_mla)
== len(self.block_stride_per_layer)
)
self.kv_caches_base_addr[self.engine_id][self.tp_rank] = seen_base_addresses
self.num_regions = len(caches_data)
self.num_regions = len(seen_base_addresses)
if self.pp_size > 1:
start_layer, end_layer = self.model_config.get_layers_start_end_indices(
@@ -1293,6 +1233,7 @@ class NixlBaseConnectorWorker:
kv_caches_base_addr=self.kv_caches_base_addr[self.engine_id][self.tp_rank],
num_blocks=self.num_blocks,
block_lens=self.block_len_per_layer,
block_strides=self.block_stride_per_layer,
kv_cache_layout=self.kv_cache_layout
if not self.use_host_buffer
else self.host_buffer_kv_cache_layout,
@@ -1431,10 +1372,9 @@ class NixlBaseConnectorWorker:
block_arange = np.arange(num_blocks, dtype=np.uint64)
parts: list[np.ndarray] = []
for i, base_addr in enumerate(base_addresses):
# K/V are packed into the content dim, so the whole block transfers
# as one unit: desc length equals the block stride.
block_len = self.block_len_per_layer[i] // block_size_ratio
addrs = base_addr + block_arange * block_len
block_stride = self.block_stride_per_layer[i] // block_size_ratio
addrs = base_addr + block_arange * block_stride
parts.append(self._stack_descs(addrs, block_len, device_id))
return np.concatenate(parts)
@@ -1476,7 +1416,7 @@ class NixlBaseConnectorWorker:
)
local_block_len = local_block_len // num_reads
page_size = nixl_agent_meta.block_lens[i]
page_size = nixl_agent_meta.block_strides[i]
addrs = base_addr + rank_offset + block_arange * page_size
parts.append(self._stack_descs(addrs, local_block_len, device_id))
return np.concatenate(parts)
@@ -1560,7 +1500,7 @@ class NixlBaseConnectorWorker:
tp_ratio = 4 // 2 = 2
Considering the KV Caches, if P-Worker_i has cache size [2, num_blocksP, kv_heads, block_size, head_dim]
then D-Worker_j has [2, num_blocksD, kv_heads//tp_ratio, block_size, head_dim]. Mind the "HND" layout format.
then D-Worker_j has [2, num_blocksD, kv_heads//tp_ratio, block_size, head_dim]. Mind the "LBHNC" layout format.
Assuming num_blocksD >= num_blocksP, D-Worker0 reads from P-Worker0 by preparing the kv_heads//tp_ratio
first heads from all the slots of all the blocks. D-Worker1 will do the same, but reading the second split
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:
start:end
]
nixl_agent_meta.block_lens = nixl_agent_meta.block_lens[start:end]
nixl_agent_meta.block_strides = nixl_agent_meta.block_strides[start:end]
### Register remote engine in TransferTopology (idempotent).
assert self.transfer_topo is not None
@@ -1790,10 +1731,10 @@ class NixlBaseConnectorWorker:
if not self.use_mla and nixl_agent_meta.kv_cache_layout != kv_cache_layout:
if (
self.kv_transfer_config.enable_permute_local_kv
and nixl_agent_meta.kv_cache_layout == "HND"
and nixl_agent_meta.kv_cache_layout == "LBHNC"
):
logger.info(
"Remote is HND and local is NHD, enabled additional permute "
"Remote is LBHNC and local is LBNHC, enabled additional permute "
"on local device KV."
)
assert not self._is_hma_required, (
@@ -1823,18 +1764,18 @@ class NixlBaseConnectorWorker:
self.enable_heterogeneous_attn_post_process = True
# Heterogeneous TP requires head-splitting, which only works with
# HND layout. MLA and replicated-KV cases don't split on heads.
# Mamba doesn't support heterogeneous TP.
# block-contiguous layouts (e.g. LBHNC). MLA and replicated-KV cases
# don't split on heads. Mamba doesn't support heterogeneous TP.
if (
abs(tp_ratio) != 1
and not self.use_mla
and not self.transfer_topo.is_kv_replicated(remote_engine_id)
and kv_cache_layout != "HND"
and not KVCacheLayout[kv_cache_layout].is_block_contiguous
and not self.enable_permute_local_kv
):
raise RuntimeError(
"Heterogeneous TP head-dimension splitting requires contiguous heads. "
"Use HND layout on the prefill side."
"Use a block-contiguous layout (e.g. LBHNC) on the prefill side."
)
# Per-region block_len validation enforcing the P/D invariant.
@@ -19,7 +19,6 @@ import torch
from vllm.config import VllmConfig
from vllm.distributed.kv_transfer.kv_connector.utils import (
EngineId,
get_current_attn_backend,
)
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
CopyBlocksOp,
@@ -56,10 +55,8 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl.stats import (
)
from vllm.forward_context import ForwardContext
from vllm.logger import init_logger
from vllm.v1.attention.backend import AttentionBackend, AttentionMetadata
from vllm.v1.attention.backends.utils import get_kv_cache_layout
from vllm.v1.attention.backend import AttentionMetadata
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.kv_cache_interface import MambaSpec
from vllm.v1.outputs import KVConnectorOutput
if TYPE_CHECKING:
@@ -83,36 +80,6 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
def supports_divergent_local_hybrid_hits(self) -> bool:
return True
@property
def prefer_cross_layer_blocks(self) -> bool:
if any(
[
isinstance(group.kv_cache_spec, MambaSpec)
for group in self.kv_cache_config.kv_cache_groups
]
):
# Hybrid SSM models do not yet support cross-layer layout
return False
backend = get_current_attn_backend(self._vllm_config)
if backend.get_name() not in (
"FLASH_ATTN",
"FLASHINFER",
"TRITON_ATTN",
):
return False
# For now there is no benefit to run cross layers when backend
# does not support on HND
if get_kv_cache_layout() != "HND":
return False
extra_config = self.kv_transfer_config.kv_connector_extra_config
return (
str(extra_config.get("enable_cross_layers_blocks", "False")).lower()
== "true"
)
def __init__(
self,
vllm_config: VllmConfig,
@@ -179,9 +146,10 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
# which fallback to the default behavior.
return None
logger.info_once(
"NixlConnector setting KV cache layout to HND for better xfer performance."
"NixlConnector setting KV cache layout to LBHNC for "
"better xfer performance."
)
return "HND"
return "LBHNC"
############################################################
# Scheduler Side Methods
@@ -266,12 +234,6 @@ class NixlBaseConnector(KVConnectorBase_V1, SupportsHMA):
assert self.connector_worker is not None
self.connector_worker.register_kv_caches(kv_caches)
def register_cross_layers_kv_cache(
self, kv_cache: torch.Tensor, attn_backend: type[AttentionBackend]
):
assert self.connector_worker is not None
self.connector_worker.register_cross_layers_kv_caches(kv_cache)
def set_host_xfer_buffer_ops(self, copy_operation: CopyBlocksOp):
assert self.connector_worker is not None
self.connector_worker.set_host_xfer_buffer_ops(copy_operation)
@@ -44,8 +44,9 @@ PUSH_REG_NOTIF_PREFIX = b"PUSH_REG:"
# 6: Validate EAGLE/MTP speculative configuration compatibility
# 7: Include NIXL transfer mode (push vs pull) in the compatibility hash
# 8: Add dcp_size and pcp_size to NixlAgentMetadata
# 9: Add block_strides
#
NIXL_CONNECTOR_VERSION: int = 8
NIXL_CONNECTOR_VERSION: int = 9
@dataclass
@@ -56,6 +57,7 @@ class NixlAgentMetadata:
device_id: int
num_blocks: int
block_lens: list[int]
block_strides: list[int]
kv_cache_layout: str
block_size: int
ssm_sizes: tuple[int, int]
@@ -129,7 +131,6 @@ def _get_speculative_compatibility_factors(
def compute_nixl_compatibility_hash(
vllm_config: VllmConfig,
attn_backend_name: str,
cross_layers_blocks: bool,
transfer_mode: str = "pull",
) -> str:
"""
@@ -180,7 +181,6 @@ def compute_nixl_compatibility_hash(
# Attention backend and KV cache dtype affect memory layout
"attn_backend_name": attn_backend_name,
"cache_dtype": str(cache_config.cache_dtype),
"cross_layers_blocks": cross_layers_blocks,
"is_hma_enabled": is_hma_enabled,
"speculative_config": _get_speculative_compatibility_factors(vllm_config),
# push (WRITE) and pull (READ) connectors are protocol-incompatible
@@ -17,6 +17,7 @@ import torch
from vllm.v1.kv_cache_interface import (
AttentionSpec,
KVCacheConfig,
KVCacheLayout,
KVCacheSpec,
MambaSpec,
MLAAttentionSpec,
@@ -31,13 +32,16 @@ if TYPE_CHECKING:
CANONICAL_FORMAT_VERSION = 1
def canonical_format_id() -> str:
def canonical_format_id(kv_cache_layout: str) -> str:
"""Identity of the canonical byte format, for namespacing persisted KV.
Canonical pages keep the worker's KV layout family, so the id couples the
format version with that family; consumers must match it exactly."""
from vllm.v1.attention.backends.utils import get_kv_cache_layout
return f"v{CANONICAL_FORMAT_VERSION}-{get_kv_cache_layout().lower()}"
format version with that family; consumers must match it exactly. The family
keeps its historical NHD/HND spelling so ids stay stable for KV persisted
before the layout enum existed."""
layout = KVCacheLayout[kv_cache_layout]
legacy = {KVCacheLayout.LBNHC: "nhd", KVCacheLayout.LBHNC: "hnd"}
family = legacy.get(layout, layout.name.lower())
return f"v{CANONICAL_FORMAT_VERSION}-{family}"
@dataclass(frozen=True)
@@ -278,7 +282,7 @@ def _layer_mapping(
if isinstance(spec, MLAAttentionSpec):
# TP-replicated latent; CP shards its tokens across the DCP groups
if (
spec.compress_ratio != 1
spec.tokens_per_state != 1
or page % bs
or ctx.tp_size % ctx.dcp_size
or spec.kv_quant_mode.is_per_token_head
@@ -20,12 +20,7 @@ from vllm.v1.kv_offload.config import (
if TYPE_CHECKING:
from vllm.config import VllmConfig
from vllm.v1.kv_cache_interface import KVCacheConfig, KVCacheTensor
def is_kv_cache_tensor_packed(kv_cache_tensor: "KVCacheTensor") -> bool:
"""Return whether a KV cache tensor uses a packed block stride."""
return bool(kv_cache_tensor.block_stride)
from vllm.v1.kv_cache_interface import KVCacheConfig
def build_offloading_config(
@@ -96,18 +91,10 @@ def build_offloading_config(
blocks_per_chunk = tokens_per_chunk_int // tokens_per_block
worker_kv_bytes_per_block = 0
if kv_cache_config.num_blocks > 0:
packed_tensors = tuple(
is_kv_cache_tensor_packed(tensor)
for tensor in kv_cache_config.kv_cache_tensors
)
is_packed = any(packed_tensors)
assert not is_packed or all(packed_tensors)
total_gpu_kv_bytes = (
kv_cache_config.kv_cache_tensors[0].size
if is_packed
else sum(tensor.size for tensor in kv_cache_config.kv_cache_tensors)
)
if kv_cache_config.num_blocks > 0 and kv_cache_config.kv_cache_tensors:
# Every KVCacheTensor describes placement within the same backing allocation,
# so its size is the total, not a per-tensor share.
total_gpu_kv_bytes = kv_cache_config.kv_cache_tensors[0].size
worker_kv_bytes_per_block = total_gpu_kv_bytes // kv_cache_config.num_blocks
single_group_spec = (
@@ -164,7 +151,7 @@ def build_offloading_config(
if isinstance(single_group_spec, FullAttentionSpec):
if type(single_group_spec) is MLAAttentionSpec:
spec_certifiable = (
single_group_spec.compress_ratio == 1
single_group_spec.tokens_per_state == 1
and single_group_spec.real_page_size_bytes
% single_group_spec.block_size
== 0
@@ -219,4 +206,5 @@ def build_offloading_config(
),
replicated_layout=replicated_layout,
canonical_layout=canonical_layout,
kv_cache_layout=vllm_config.cache_config.kv_cache_layout,
)
@@ -14,16 +14,13 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
OffloadingWorkerMetadata,
ReqId,
)
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.config import (
is_kv_cache_tensor_packed,
)
from vllm.logger import init_logger
from vllm.v1.attention.backend import AttentionBackend
from vllm.v1.kv_cache_interface import (
AttentionSpec,
KVCacheConfig,
MambaSpec,
UniformTypeKVCacheSpecs,
group_kernel_blocks,
)
from vllm.v1.kv_offload.base import (
CanonicalKVCacheRef,
@@ -73,16 +70,6 @@ class OffloadingConnectorWorker:
self.vllm_config, kv_cache_config, kv_caches
)
# Packed layouts (e.g. DSv4) set block_stride > 0; their tensors use
# stride(0) as the manager-block stride (equals total_num_bytes_per_block).
# General (non-packed) layouts size the tensor at page_size_bytes per
# manager block, so page_size_bytes is the correct offloading stride.
layer_is_packed: dict[str, bool] = {
ln: is_kv_cache_tensor_packed(kv_tensor)
for kv_tensor in kv_cache_config.kv_cache_tensors
for ln in kv_tensor.shared_by
}
# layer_name -> (num_blocks, page_size_bytes) tensor
tensors_per_block: dict[str, tuple[torch.Tensor, ...]] = {}
# layer_name -> size of (un-padded) page in bytes
@@ -100,63 +87,52 @@ class OffloadingConnectorWorker:
layer_kv_cache_spec = per_layer_specs.get(
layer_name, group_kv_cache_spec
)
if isinstance(layer_kv_cache_spec, AttentionSpec):
layer_kv_cache = kv_caches[layer_name]
assert isinstance(layer_kv_cache, torch.Tensor)
ref = group_kernel_blocks(kv_caches[layer_name], num_blocks)
page = layer_kv_cache_spec.page_size_bytes
elem_size = ref.element_size()
byte_offset = ref.storage_offset() * elem_size
# Packed layouts (e.g. DSv4) interleave layers per block, so the
# attention tensor's stride(0) (the manager-block stride) exceeds
# page_size_bytes; other layouts have stride(0) == page_size_bytes.
block_stride_bytes = (
ref.stride(0) * elem_size
if isinstance(layer_kv_cache_spec, AttentionSpec)
else page
)
tensors_per_block[layer_name] = (
torch.tensor([], dtype=torch.int8, device=ref.device).set_(
ref.untyped_storage(),
byte_offset,
(num_blocks, page),
(block_stride_bytes, 1),
),
)
page_size_bytes[layer_name] = page
page = layer_kv_cache_spec.page_size_bytes
elem_size = layer_kv_cache.element_size()
byte_offset = layer_kv_cache.storage_offset() * elem_size
block_stride_bytes = (
layer_kv_cache.stride(0) * elem_size
if layer_is_packed[layer_name]
else page
)
raw = torch.empty(
0,
dtype=torch.int8,
device=layer_kv_cache.device,
).set_(layer_kv_cache.untyped_storage())
tensors_per_block[layer_name] = (
torch.as_strided(
raw,
(num_blocks, page),
(block_stride_bytes, 1),
byte_offset,
),
)
page_size_bytes[layer_name] = layer_kv_cache_spec.page_size_bytes
if isinstance(layer_kv_cache_spec, AttentionSpec):
unpadded_page_size_bytes[layer_name] = (
layer_kv_cache_spec.unpadded_page_size_bytes
)
elif isinstance(layer_kv_cache_spec, MambaSpec):
layer_kv_cache = kv_caches[layer_name]
assert layer_kv_cache.dtype == torch.int8
tensors_per_block[layer_name] = (
layer_kv_cache.view(
num_blocks, layer_kv_cache_spec.page_size_bytes
),
)
page_size_bytes[layer_name] = layer_kv_cache_spec.page_size_bytes
unpadded_page_size_bytes[layer_name] = replace(
layer_kv_cache_spec, page_size_padded=None
).page_size_bytes
else:
raise NotImplementedError
packed_kv_cache_tensor = next(
# Packed layouts (e.g. DSv4) interleave all layers within each manager
# block: a layer view's block stride exceeds its page size. Offload the
# whole packed block as a single transfer region.
packed_layer_name = next(
(
t
for t in kv_cache_config.kv_cache_tensors
if is_kv_cache_tensor_packed(t) and t.shared_by
layer_name
for layer_name, (tensor,) in tensors_per_block.items()
if tensor.stride(0) != tensor.shape[1]
),
None,
)
if packed_kv_cache_tensor is not None:
(tensor,) = tensors_per_block[packed_kv_cache_tensor.shared_by[0]]
if packed_layer_name is not None:
(tensor,) = tensors_per_block[packed_layer_name]
block_stride = tensor.stride(0)
packed_tensor = tensor.as_strided(
(num_blocks, block_stride),
@@ -176,29 +152,16 @@ class OffloadingConnectorWorker:
block_tensors: list[CanonicalKVCacheTensor] = []
block_data_refs: dict[str, list[CanonicalKVCacheRef]] = defaultdict(list)
for kv_cache_tensor in kv_cache_config.kv_cache_tensors:
# Filter to layers that were actually processed above.
# Packed KV allocation emits KVCacheTensor entries for
# every (tuple_idx, page_size) slot; slots where no group has a
# layer at that index produce an empty shared_by (reserved memory
# with no corresponding model layer).
tensor_layer_names = [
n for n in kv_cache_tensor.shared_by if n in tensors_per_block
]
if not tensor_layer_names:
continue
# Layers that alias the same bytes (cache groups overlay each other) share
# one transfer region: exactly the views with equal address and strides.
aliased_layers: dict[tuple[int, tuple[int, ...]], list[str]] = defaultdict(list)
for layer_name, layer_tensors in tensors_per_block.items():
view = layer_tensors[0]
aliased_layers[(view.data_ptr(), view.stride())].append(layer_name)
# verify all layers in the group reference the exact same tensors
for tensor_layer_names in aliased_layers.values():
assert len({len(tensors_per_block[n]) for n in tensor_layer_names}) == 1
assert (
len({tensors_per_block[n][0].data_ptr() for n in tensor_layer_names})
== 1
)
assert (
len({tensors_per_block[n][0].stride() for n in tensor_layer_names}) == 1
)
# pick the first layer to represent the group
first_layer_name = tensor_layer_names[0]
for tensor in tensors_per_block[first_layer_name]:
block_tensors.append(
@@ -242,53 +205,6 @@ class OffloadingConnectorWorker:
self._init_worker(canonical_kv_caches)
def register_cross_layers_kv_cache(
self, kv_cache: torch.Tensor, attn_backend: type[AttentionBackend]
):
# verify that num_blocks is at physical position 0 in the cross-layers
# tensor layout.
test_shape = attn_backend.get_kv_cache_shape(
num_blocks=1234, block_size=16, num_kv_heads=1, head_size=256
)
num_blocks_logical_dim = test_shape.index(1234) + 1
physical_to_logical = attn_backend.get_kv_cache_stride_order(
include_num_layers_dimension=True
)
num_blocks_physical_dim = physical_to_logical.index(num_blocks_logical_dim)
assert num_blocks_physical_dim == 0
kv_cache_groups = self.kv_cache_config.kv_cache_groups
assert len(kv_cache_groups) == 1
kv_cache_spec = kv_cache_groups[0].kv_cache_spec
num_layers = len(kv_cache_groups[0].layer_names)
page_size_bytes = kv_cache_spec.page_size_bytes * num_layers
assert kv_cache.storage_offset() == 0
storage = kv_cache.untyped_storage()
assert len(storage) % page_size_bytes == 0
num_blocks = len(storage) // page_size_bytes
tensor = (
torch.tensor(
[],
dtype=torch.int8,
device=kv_cache.device,
)
.set_(storage)
.view(num_blocks, page_size_bytes)
)
kv_cache_tensor = CanonicalKVCacheTensor(
tensor=tensor, page_size_bytes=page_size_bytes
)
# in cross layers layout, there's currently only a single group
kv_cache_data_ref = CanonicalKVCacheRef(
tensor_idx=0, page_size_bytes=page_size_bytes
)
canonical_kv_caches = CanonicalKVCaches(
tensors=[kv_cache_tensor], group_data_refs=[[kv_cache_data_ref]]
)
self._init_worker(canonical_kv_caches)
def handle_preemptions(self, kv_connector_metadata: OffloadingConnectorMetadata):
assert self.worker is not None
@@ -37,7 +37,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.worker import (
OffloadingConnectorWorker,
)
from vllm.forward_context import ForwardContext
from vllm.v1.attention.backend import AttentionBackend, AttentionMetadata
from vllm.v1.attention.backend import AttentionMetadata
from vllm.v1.core.kv_cache_manager import KVCacheBlocks
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.kv_cache_interface import KVCacheConfig
@@ -47,11 +47,6 @@ from vllm.v1.request import Request
class OffloadingConnector(KVConnectorBase_V1, SupportsHMA):
@property
def prefer_cross_layer_blocks(self) -> bool:
# Cross-layer slabs have no per-layer refs to certify canonical mappings
return not self._canonical_layout
@property
def requires_kv_delivery(self) -> bool:
# 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):
assert self.connector_worker is not None
self.connector_worker.register_kv_caches(kv_caches)
def register_cross_layers_kv_cache(
self, kv_cache: torch.Tensor, attn_backend: type[AttentionBackend]
):
assert self.connector_worker is not None
self.connector_worker.register_cross_layers_kv_cache(kv_cache, attn_backend)
def handle_preemptions(self, kv_connector_metadata: KVConnectorMetadata):
assert self.connector_worker is not None
assert isinstance(kv_connector_metadata, OffloadingConnectorMetadata)
@@ -196,7 +185,7 @@ class OffloadingConnector(KVConnectorBase_V1, SupportsHMA):
@classmethod
def get_required_kvcache_layout(cls, vllm_config: VllmConfig) -> str | None:
return "HND"
return "LBHNC"
def reset_cache(self) -> bool | None:
assert self.connector_scheduler is not None
+13 -6
View File
@@ -242,7 +242,10 @@ if TYPE_CHECKING:
VLLM_MQ_MAX_CHUNK_BYTES_MB: int = 16
VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS: int = 300
VLLM_WORKER_SHUTDOWN_TIMEOUT_SECONDS: int = 5
VLLM_KV_CACHE_LAYOUT: Literal["NHD", "HND"] | None = None
VLLM_KV_CACHE_LAYOUT: (
Literal["LBNHC", "LBHNC", "LHBNC", "NHD", "HND", "BLHNC", "BLNHC", "BHLNC"]
| None
) = None
VLLM_SSM_CONV_STATE_LAYOUT: Literal["SD", "DS"] | None = None
VLLM_COMPUTE_NANS_IN_LOGITS: bool = False
VLLM_RAISE_ON_LOGIT_NANS: bool = False
@@ -1784,18 +1787,22 @@ environment_variables: dict[str, Callable[[], Any]] = {
),
# KV Cache layout used throughout vllm.
# Some common values are:
# - NHD
# - HND
# Where N=num_blocks, H=num_heads and D=head_size. The default value will
# - LBNHC
# - LBHNC
# Where N=num_states, H=num_heads and C=state_content. The default value will
# leave the layout choice to the backend. Mind that backends may only
# implement and support a subset of all possible layouts.
# LHBNC hoists the head dim outside the block dim; backends must opt in via
# AttentionBackend.supported_kv_cache_layouts().
"VLLM_KV_CACHE_LAYOUT": env_with_choices(
"VLLM_KV_CACHE_LAYOUT", None, ["NHD", "HND"]
"VLLM_KV_CACHE_LAYOUT",
None,
["LBNHC", "LBHNC", "LHBNC", "NHD", "HND", "BLHNC", "BLNHC", "BHLNC"],
),
# SSM conv state layout used for Mamba models.
# - SD: (state_len, dim) — dim contiguous (default)
# - DS: (dim, state_len) — TP-sharded dim on dim1,
# consistent with SSM temporal state and HND KV cache layout.
# consistent with SSM temporal state and LBHNC KV cache layout.
"VLLM_SSM_CONV_STATE_LAYOUT": env_with_choices(
"VLLM_SSM_CONV_STATE_LAYOUT", None, ["SD", "DS"]
),
@@ -649,6 +649,10 @@ class MLAAttention(nn.Module, AttentionLayerBase):
compile_native=True,
)
def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
# [B, H=1, N, C] -> [B, N, C]
self.kv_cache = kv_cache.squeeze(1)
@property
def chunked_prefill_workspace_size(self) -> int:
if self._chunked_prefill_workspace_size is None:
@@ -1451,27 +1455,6 @@ class MLACommonBackend(AttentionBackend):
def get_builder_cls() -> type["MLACommonMetadataBuilder"]:
return MLACommonMetadataBuilder
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int, # assumed to be 1 for MLA
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
return (num_blocks, block_size, head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
if include_num_layers_dimension:
# Default to identity permutation to signal cross-layer allocation
# is unsupported. Each MLA backend must opt in to support cross-layer
# allocation by overriding this method.
return (0, 1, 2, 3)
return (0, 1, 2)
@classmethod
def get_supported_head_sizes(cls) -> list[int]:
return [320, 576]
+15 -12
View File
@@ -61,16 +61,16 @@ def hpc_rope_norm_forward(
return
attn_layer = forward_context.no_compile_layers[layer_name]
# bind_kv_cache stores the per-layer KV cache as a single 5D tensor
# (num_blocks, 2, block_size, num_kv_heads, head_size), so use it directly.
# bind_kv_cache stores the per-layer KV cache as a single tensor
# (num_blocks, num_kv_heads, block_size, 2 * head_size), so use it directly.
kv_cache = attn_layer.kv_cache
if kv_cache.numel() == 0:
output.zero_()
return
assert kv_cache.dim() == 5, (
f"Expected kv_cache to have 5 dims, got {tuple(kv_cache.shape)}"
assert kv_cache.dim() == 4, (
f"Expected kv_cache to have 4 dims, got {tuple(kv_cache.shape)}"
)
rope_norm = _hpc_rope_norm_instances[layer_name]
@@ -295,6 +295,9 @@ class HpcRopeNorm(CustomOp, HpcModule):
"""
import hpc
# (B, H, N, 2*hs) -> two (B, N, H, hs) K/V views, as in HpcAttentionImpl.
key_cache, value_cache = kv_cache.transpose(1, 2).split(self.head_dim, dim=-1)
num_actual_tokens = attn_metadata.num_actual_tokens
num_prefill_reqs = attn_metadata.num_prefills
num_decode_reqs = attn_metadata.num_decodes
@@ -333,8 +336,8 @@ class HpcRopeNorm(CustomOp, HpcModule):
if self.use_fp8:
_, q_scale, split_k_flag = hpc.rope_norm_store_kv_fp8(
key_cache=kv_cache[:, 0],
value_cache=kv_cache[:, 1],
key_cache=key_cache,
value_cache=value_cache,
qkv=qkv_prefill,
cos_sin=self.cos_sin_cache,
num_seqlen_per_req=seq_lens_prefill,
@@ -353,8 +356,8 @@ class HpcRopeNorm(CustomOp, HpcModule):
attn_metadata.hpc_prefill_q_scale = q_scale
else:
hpc.rope_norm_store_kv(
kv_cache[:, 0],
kv_cache[:, 1],
key_cache,
value_cache,
qkv_prefill,
self.cos_sin_cache,
seq_lens_prefill,
@@ -379,8 +382,8 @@ class HpcRopeNorm(CustomOp, HpcModule):
if self.use_fp8:
_, q_scale, split_k_flag = hpc.rope_norm_store_kv_fp8(
key_cache=kv_cache[:, 0],
value_cache=kv_cache[:, 1],
key_cache=key_cache,
value_cache=value_cache,
qkv=qkv_decode,
cos_sin=self.cos_sin_cache,
num_seqlen_per_req=num_seq_kvcache,
@@ -401,8 +404,8 @@ class HpcRopeNorm(CustomOp, HpcModule):
attn_metadata.hpc_split_k_flag = split_k_flag
else:
hpc.rope_norm_store_kv(
kv_cache[:, 0],
kv_cache[:, 1],
key_cache,
value_cache,
qkv_decode,
self.cos_sin_cache,
num_seq_kvcache,
@@ -645,6 +645,11 @@ class DeepseekV32IndexerCache(torch.nn.Module, AttentionLayerBase):
raise ValueError(f"Duplicate layer name: {prefix}")
compilation_config.static_forward_context[prefix] = self
def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
# [B, H=1, N, C] -> [B, N, C]: the indexer kernels and
# kv_cache_as_quant_view index a 3-D block-major cache.
self.kv_cache = kv_cache.squeeze(1)
def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
return MLAAttentionSpec(
block_size=self.cache_config.block_size,
@@ -80,14 +80,14 @@ def dummy_attention(layer_name, _placeholder):
def basic_cache(
to_cache: torch.Tensor, # shape: [seq_len, num_heads, head_size]
kv_cache: torch.Tensor, # shape: [num_blocks, block_size, num_heads, head_size]
kv_cache: torch.Tensor, # shape: [num_blocks, num_heads, block_size, head_size]
slot_mapping: torch.Tensor, # shape: [seq_len]
):
# Padding slots are -1; redirect them to the null block (block 0, never
# allocated to a request) so the scatter stays branch-free and sync-free.
block_size = kv_cache.shape[1]
block_size = kv_cache.shape[2]
slot_mapping = slot_mapping.clamp_min(0)
kv_cache[slot_mapping // block_size, slot_mapping % block_size] = to_cache
kv_cache[slot_mapping // block_size, :, slot_mapping % block_size] = to_cache
######### CacheOnlyAttentionBackend ########
@@ -123,18 +123,6 @@ class CacheOnlyAttentionBackend(AttentionBackend):
def get_impl_cls() -> type["CacheOnlyAttentionImpl"]:
return CacheOnlyAttentionImpl
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
# We set `num_kv_heads = num_hidden_layers` and `head_size = hidden_size`
# We also don't use a k/v (2) dim
return (num_blocks, block_size, num_kv_heads, head_size)
@staticmethod
def get_builder_cls() -> type["CacheOnlyAttentionMetadataBuilder"]:
return CacheOnlyAttentionMetadataBuilder
+6 -22
View File
@@ -5,6 +5,7 @@ import functools
import logging
import math
from dataclasses import replace
from fractions import Fraction
from functools import partial
import torch
@@ -128,14 +129,10 @@ def create_whisper_attention_backend_with_block_pooling(
vllm_config: VllmConfig,
device: torch.device,
):
assert kv_cache_spec.num_kv_heads % block_pool_size == 0
# Scale pooled-unit quantities back to unpooled (encoder-token)
# units for the underlying attention metadata: the KV cache spec
# counts blocks in pooled units, but the kernel runs on the
# `block_pool_size`x-expanded sequence built below.
spec_overrides = dict(
kv_cache_spec = replace(
kv_cache_spec,
block_size=kv_cache_spec.block_size * block_pool_size,
num_kv_heads=kv_cache_spec.num_kv_heads // block_pool_size,
tokens_per_state=1,
)
if isinstance(kv_cache_spec, SlidingWindowSpec):
# The manager keeps `sliding_window` in pooled units; the kernel
@@ -143,8 +140,7 @@ def create_whisper_attention_backend_with_block_pooling(
# unpooled units (`get_kv_cache_spec` sizes the pooled window so
# this window always stays resident).
assert sliding_window is not None
spec_overrides["sliding_window"] = sliding_window
kv_cache_spec = replace(kv_cache_spec, **spec_overrides)
kv_cache_spec = replace(kv_cache_spec, sliding_window=sliding_window)
super().__init__(kv_cache_spec, layer_names, vllm_config, device)
# Override model_config-derived values with the actual
# encoder values from kv_cache_spec
@@ -261,18 +257,6 @@ def create_whisper_attention_backend_with_block_pooling(
overrides={
"get_builder_cls": lambda: WhisperCausalAttentionWithBlockPoolingBuilder,
"get_impl_cls": lambda: WhisperCausalAttentionWithBlockPoolingImpl,
"get_kv_cache_shape": lambda num_blocks,
block_size,
num_kv_heads,
head_size,
cache_dtype_str: underlying_attn_backend.get_kv_cache_shape(
num_blocks,
# we stretch each block by `block_pool_size`
block_size * block_pool_size,
num_kv_heads // block_pool_size,
head_size,
cache_dtype_str,
),
"forward_includes_kv_cache_update": True,
},
)
@@ -341,7 +325,7 @@ class WhisperCausalAttentionWithBlockPooling(Attention):
assert isinstance(kv_cache_spec, AttentionSpec)
kv_cache_spec = replace(
kv_cache_spec,
num_kv_heads=self.block_pool_size * kv_cache_spec.num_kv_heads,
tokens_per_state=Fraction(1, self.block_pool_size),
)
if isinstance(kv_cache_spec, SlidingWindowSpec):
# The manager counts blocks in pooled units, so express the window in
+20 -4
View File
@@ -653,7 +653,8 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
# per-tensor fp8 writes a separately-allocated fp8 q and quantizes the
# KV row.
block_size = swa_metadata.block_size
swa_kv_cache_3d = swa_kv_cache.view(-1, block_size, self.head_dim)
assert swa_kv_cache.shape[1:] == (block_size, self.head_dim)
swa_kv_cache_3d = swa_kv_cache
if cache_dtype == torch.bfloat16:
torch.ops._C.fused_deepseek_v4_qnorm_rope_kv_rope_full_cache_bf16_insert(
q,
@@ -684,6 +685,17 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
)
return q_fp8
def _global_topk_output_buffers(
self, topk_indices: torch.Tensor
) -> tuple[torch.Tensor, torch.Tensor] | None:
if self.compress_ratio != 4 or self.eager_scratch_pool is None:
return None
return self.eager_scratch_pool.global_topk_outputs(topk_indices)
def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
# [B, H=1, N, C] -> [B, N, C]
self.kv_cache = kv_cache.squeeze(1)
def get_attn_backend(self) -> type[AttentionBackend]:
return self.backend_cls
@@ -701,7 +713,7 @@ class DeepseekV4Attention(nn.Module, AttentionLayerBase, ABC):
num_kv_heads=1,
head_size=self.head_dim,
dtype=torch.uint8 if uses_fp8_ds_mla_layout else self.kv_cache_torch_dtype,
compress_ratio=self.compress_ratio,
tokens_per_state=self.compress_ratio,
cache_dtype_str=self.kv_cache_dtype,
alignment=576 if uses_fp8_ds_mla_layout else 512,
model_version="deepseek_v4",
@@ -733,16 +745,20 @@ class DeepseekV4IndexerCache(torch.nn.Module, AttentionLayerBase):
raise ValueError(f"Duplicate layer name: {prefix}")
compilation_config.static_forward_context[prefix] = self
def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
# [B, H=1, N, C] -> [B, N, C]
self.kv_cache = kv_cache.squeeze(1)
def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
# head_dim already carries the fp8 scale padding
# compress_ratio=1 for V3.2, >1 for DeepseekV4; both use the same cache layout.
# tokens_per_state=1 for V3.2, >1 for DeepseekV4; same cache layout.
uses_fp8_ds_mla_layout = vllm_config.cache_config.cache_dtype == "fp8_ds_mla"
return MLAAttentionSpec(
block_size=self.cache_config.block_size,
num_kv_heads=1,
head_size=self.head_dim,
dtype=self.dtype,
compress_ratio=self.compress_ratio,
tokens_per_state=self.compress_ratio,
# 576B for FlashMLA packing; 512B for FlashInfer sparse (#44577).
alignment=576 if uses_fp8_ds_mla_layout else 512,
)
+4 -19
View File
@@ -75,25 +75,6 @@ class CompressorBackend(AttentionBackend):
def get_builder_cls() -> type["CompressorMetadataBuilder"]:
return CompressorMetadataBuilder
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
assert num_kv_heads == 1
return (num_blocks, block_size, head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
if include_num_layers_dimension:
return (0, 1, 2, 3)
return (0, 1, 2)
@dataclass
class CompressorMetadata:
@@ -185,6 +166,10 @@ class CompressorStateCache(torch.nn.Module, AttentionLayerBase):
else:
raise ValueError(f"Invalid compress ratio: {compress_ratio}")
def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
# [B, H=1, N, C] -> [B, N, C]
self.kv_cache = kv_cache.squeeze(1)
def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
# fp8_ds_mla is the UE8M0 paged layout and needs 576B alignment. Plain
# full-cache rows share state pages with contiguous KV pages, so padding
@@ -22,7 +22,6 @@ from vllm.models.deepseek_v4.sparse_mla import (
DeepseekV4FlashMLAMetadata,
DeepseekV4SparseMLABackend,
)
from vllm.platforms import current_platform
from vllm.platforms.interface import DeviceCapability
from vllm.utils.flashinfer import flashinfer_trtllm_batch_decode_sparse_mla_dsv4
from vllm.v1.attention.backend import MultipleOf
@@ -165,26 +164,6 @@ class DeepseekV4FlashInferMLASparseBackend(DeepseekV4SparseMLABackend):
return None
return "FLASHINFER_MLA_SPARSE_DSV4 requires SM10x or SM12x"
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
device_capability = current_platform.get_device_capability()
if device_capability is not None and device_capability.major == 12:
return DeepseekV4SparseMLABackend.get_kv_cache_shape(
num_blocks,
block_size,
num_kv_heads,
head_size,
cache_dtype_str,
)
assert num_kv_heads == 1
return (num_blocks, block_size, head_size)
class DeepseekV4FlashInferMLAAttention(DeepseekV4Attention):
"""FlashInfer TRTLLM-gen sparse MLA attention layer for SM100 DeepSeek V4."""
+3 -18
View File
@@ -88,21 +88,6 @@ class DeepseekV4SparseMLABackend(AttentionBackend):
def supports_compute_capability(cls, capability: DeviceCapability) -> bool:
return capability.major in [9, 10]
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
if cache_dtype_str == "fp8_ds_mla":
# DeepseekV4 main MLA: 584B per token (448 NoPE + 128 RoPE + 8 fp8 scale).
# head_size passed in is the semantic head_dim (512).
return (num_blocks, block_size, 584)
else:
return (num_blocks, block_size, head_size)
@dataclass
class DeepseekV4FlashMLAMetadata(AttentionMetadata):
@@ -151,8 +136,8 @@ class DeepseekV4SparseMLAMetadataBuilder(
(max_num_batched_tokens,), dtype=torch.int32, device=device
)
assert hasattr(self.kv_cache_spec, "compress_ratio")
self.compress_ratio = self.kv_cache_spec.compress_ratio
assert isinstance(self.kv_cache_spec.tokens_per_state, int)
self.compress_ratio = self.kv_cache_spec.tokens_per_state
# Pre-allocate compressed slot mapping buffer for CUDA graph address
# stability when compress_ratio > 1.
@@ -206,7 +191,7 @@ class DeepseekV4SparseMLAMetadataBuilder(
cm.query_start_loc,
cm.seq_lens,
cm.block_table_tensor.clamp_(min=0),
int(self.kv_cache_spec.storage_block_size),
int(self.kv_cache_spec.num_states),
self.compress_ratio,
out=self.compressed_slot_mapping_buffer,
)
-26
View File
@@ -107,32 +107,6 @@ class InklingSconvBackend(AttentionBackend):
def get_name() -> str:
return "INKLING_SCONV_SWA"
@classmethod
def indexes_kv_by_block_stride(cls) -> bool:
# num_blocks is the outermost dim (HND, see get_kv_cache_shape), so the
# padded conv page is read through a strided view.
return True
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
# HND, num-blocks-first, head-major: [num_blocks, H, N, D].
return (num_blocks, num_kv_heads, block_size, head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
# Identity: physical layout == logical [num_blocks, H, N, D].
if include_num_layers_dimension:
return (0, 1, 2, 3, 4)
return (0, 1, 2, 3)
@staticmethod
def get_impl_cls():
raise NotImplementedError(
@@ -107,32 +107,6 @@ class InklingSconvBackend(AttentionBackend):
def get_name() -> str:
return "INKLING_SCONV_SWA"
@classmethod
def indexes_kv_by_block_stride(cls) -> bool:
# num_blocks is the outermost dim (HND, see get_kv_cache_shape), so the
# padded conv page is read through a strided view.
return True
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
# HND, num-blocks-first, head-major: [num_blocks, H, N, D].
return (num_blocks, num_kv_heads, block_size, head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
# Identity: physical layout == logical [num_blocks, H, N, D].
if include_num_layers_dimension:
return (0, 1, 2, 3, 4)
return (0, 1, 2, 3)
@staticmethod
def get_impl_cls():
raise NotImplementedError(
+4
View File
@@ -384,6 +384,10 @@ class MultiHeadLatentAttention(nn.Module, AttentionLayerBase):
# ------------------------------------------------------------------
# AttentionLayerBase interface
# ------------------------------------------------------------------
def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
# [B, H=1, N, C] -> [B, N, C]
self.kv_cache = kv_cache.squeeze(1)
def get_attn_backend(self) -> type[AttentionBackend]:
return self.attn_backend
+8
View File
@@ -728,6 +728,12 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec | None:
# Main GQA K/V cache. Block size may change after load, refresh it.
# AITER sparse PA wants K and V as two separate head groups
# (H folded into the content dim).
sparse_pa = self.use_aiter_sparse_pa
kv_bytes = (
self.num_kv_heads * self.head_dim * self.kv_cache_torch_dtype.itemsize
)
return FullAttentionSpec(
block_size=vllm_config.cache_config.block_size,
num_kv_heads=self.num_kv_heads,
@@ -735,6 +741,8 @@ class MiniMaxM3SparseAttention(nn.Module, AttentionLayerBase):
head_size_v=self.head_dim,
dtype=self.kv_cache_torch_dtype,
kv_quant_mode=get_kv_quant_mode(self.kv_cache_dtype),
num_head_slots=2 if sparse_pa else None,
state_content_bytes=kv_bytes if sparse_pa else None,
)
def _ensure_aiter_sparse_pa_kv_cache(self) -> None:
+4 -19
View File
@@ -97,25 +97,6 @@ class MiniMaxM3IndexerBackend(AttentionBackend):
def is_sparse(cls) -> bool:
return True
@staticmethod
def get_kv_cache_shape(
num_blocks: int,
block_size: int,
num_kv_heads: int,
head_size: int,
cache_dtype_str: str = "auto",
) -> tuple[int, ...]:
return (num_blocks, block_size, head_size)
@staticmethod
def get_kv_cache_stride_order(
include_num_layers_dimension: bool = False,
) -> tuple[int, ...]:
if include_num_layers_dimension:
# M3 does not use cross-layer (per-layer-stacked) KV blocks.
raise NotImplementedError
return (0, 1, 2)
class MiniMaxM3IndexerCache(nn.Module, AttentionLayerBase):
"""Side KV cache for the indexer's per-token index keys (key-only).
@@ -156,6 +137,10 @@ class MiniMaxM3IndexerCache(nn.Module, AttentionLayerBase):
raise ValueError(f"Duplicate layer name: {prefix}")
compilation_config.static_forward_context[prefix] = self
def bind_kv_cache(self, kv_cache: torch.Tensor) -> None:
# [B, H=1, N, C] -> [B, N, C]
self.kv_cache = kv_cache.squeeze(1)
def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
# Key-only: MLAAttentionSpec budgets one vector/token (not 2x for K+V).
return MLAAttentionSpec(

Some files were not shown because too many files have changed in this diff Show More