[KV Offload] Deduplicate replicated MLA KV in the shared CPU region (#48906)
Signed-off-by: Change72 <changg@nvidia.com> Co-authored-by: OpenAI Codex <noreply@openai.com>
This commit is contained in:
co-authored by
OpenAI Codex
parent
7a29a3c54c
commit
7eca0e1a64
@@ -337,7 +337,7 @@ steps:
|
||||
|
||||
- label: LM Eval KV-Offload (2xH100)
|
||||
key: kv-offload-medium
|
||||
timeout_in_minutes: 30
|
||||
timeout_in_minutes: 45
|
||||
device: h100
|
||||
num_devices: 2
|
||||
source_file_dependencies:
|
||||
@@ -347,7 +347,7 @@ steps:
|
||||
- vllm/v1/simple_kv_offload/
|
||||
- tests/evals/gsm8k/test_gsm8k_offloading.py
|
||||
commands:
|
||||
- pytest -s -v evals/gsm8k/test_gsm8k_offloading.py -k "qwen3.5-35b"
|
||||
- pytest -s -v evals/gsm8k/test_gsm8k_offloading.py -k "qwen3.5-35b or deepseek-v2-lite"
|
||||
|
||||
- label: LM Eval KV-Offload (4xH100)
|
||||
key: kv-offload-large
|
||||
|
||||
@@ -16,11 +16,13 @@ KV drops accuracy below threshold. The reload itself is not asserted, so
|
||||
a silently skipped reload (e.g. offloading disabled) would not be flagged.
|
||||
|
||||
Covers both KV offloading connectors (OffloadingConnector and
|
||||
SimpleCPUOffloadConnector) across four architecture families:
|
||||
SimpleCPUOffloadConnector) across five architecture families:
|
||||
- Hybrid Mamba (NemotronH: attention + Mamba)
|
||||
- Heterogeneous head dim (Gemma 4)
|
||||
- Hybrid GDN (Qwen 3.5: attention + GatedDeltaNet)
|
||||
- Compressed attention (DeepSeek-V4-Flash: CSA)
|
||||
- Pure MLA (DeepSeek-V2-Lite: TieringOffloadingSpec, TP=2, replicated
|
||||
single-slot host layout)
|
||||
|
||||
Usage:
|
||||
pytest -s -v evals/gsm8k/test_gsm8k_offloading.py
|
||||
@@ -47,14 +49,16 @@ NUM_FEWSHOT = 5
|
||||
_OFFLOAD_SYNC_TIMEOUT = 60
|
||||
|
||||
|
||||
def _kv_transfer_config(connector: str, cpu_gib: int = 4) -> str:
|
||||
def _kv_transfer_config(
|
||||
connector: str, cpu_gib: int = 4, spec_name: str = "CPUOffloadingSpec"
|
||||
) -> str:
|
||||
if connector == "OffloadingConnector":
|
||||
return json.dumps(
|
||||
{
|
||||
"kv_connector": "OffloadingConnector",
|
||||
"kv_role": "kv_both",
|
||||
"kv_connector_extra_config": {
|
||||
"spec_name": "CPUOffloadingSpec",
|
||||
"spec_name": spec_name,
|
||||
"cpu_bytes_to_use": cpu_gib << 30,
|
||||
"eviction_policy": "lru",
|
||||
},
|
||||
@@ -115,8 +119,10 @@ class OffloadingModelConfig:
|
||||
accuracy_threshold: float
|
||||
tolerance: float = 0.05
|
||||
extra_server_args: list[str] = field(default_factory=list)
|
||||
env_dict: dict[str, str] = field(default_factory=dict)
|
||||
cpu_offload_gib: int = 4
|
||||
startup_timeout: int = 600
|
||||
spec_name: str = "CPUOffloadingSpec"
|
||||
|
||||
|
||||
MODELS = [
|
||||
@@ -165,6 +171,20 @@ MODELS = [
|
||||
cpu_offload_gib=16,
|
||||
startup_timeout=1200,
|
||||
),
|
||||
OffloadingModelConfig(
|
||||
id="offloading-deepseek-v2-lite-tiering-tp2",
|
||||
model="deepseek-ai/DeepSeek-V2-Lite",
|
||||
connector="OffloadingConnector",
|
||||
# Baseline 0.360/0.345 over two runs (measured on 2xA100).
|
||||
accuracy_threshold=0.35,
|
||||
extra_server_args=[
|
||||
"--tensor-parallel-size",
|
||||
"2",
|
||||
],
|
||||
cpu_offload_gib=8,
|
||||
startup_timeout=1200,
|
||||
spec_name="TieringOffloadingSpec",
|
||||
),
|
||||
# ── SimpleCPUOffloadConnector ────────────────────────────────────
|
||||
OffloadingModelConfig(
|
||||
id="simple-nemotron-h-8b",
|
||||
@@ -229,7 +249,7 @@ def test_gsm8k_offloading_correctness(cfg: OffloadingModelConfig):
|
||||
"--enable-prefix-caching",
|
||||
"--no-disable-hybrid-kv-cache-manager",
|
||||
"--kv-transfer-config",
|
||||
_kv_transfer_config(cfg.connector, cfg.cpu_offload_gib),
|
||||
_kv_transfer_config(cfg.connector, cfg.cpu_offload_gib, cfg.spec_name),
|
||||
"--trust-remote-code",
|
||||
"--disable-uvicorn-access-log",
|
||||
*cfg.extra_server_args,
|
||||
@@ -239,7 +259,7 @@ def test_gsm8k_offloading_correctness(cfg: OffloadingModelConfig):
|
||||
cfg.model,
|
||||
server_args,
|
||||
# /reset_prefix_cache requires dev mode.
|
||||
env_dict={"VLLM_SERVER_DEV_MODE": "1"},
|
||||
env_dict={"VLLM_SERVER_DEV_MODE": "1", **cfg.env_dict},
|
||||
max_wait_seconds=cfg.startup_timeout,
|
||||
) as server:
|
||||
base_url = f"http://{server.host}:{server.port}"
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests for translating vLLM cache metadata to native offloading config."""
|
||||
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm.config import KVTransferConfig, ParallelConfig, VllmConfig
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.config import (
|
||||
build_offloading_config,
|
||||
)
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
FullAttentionSpec,
|
||||
HiddenStateCacheSpec,
|
||||
KVCacheConfig,
|
||||
KVCacheGroupSpec,
|
||||
KVCacheTensor,
|
||||
MambaSpec,
|
||||
MLAAttentionSpec,
|
||||
SlidingWindowMLASpec,
|
||||
SlidingWindowSpec,
|
||||
UniformTypeKVCacheSpecs,
|
||||
)
|
||||
|
||||
|
||||
def _make_vllm_config(
|
||||
*,
|
||||
extra_config: dict[str, Any] | None = None,
|
||||
tensor_parallel_size: int = 1,
|
||||
pipeline_parallel_size: int = 1,
|
||||
prefill_context_parallel_size: int = 1,
|
||||
decode_context_parallel_size: int = 1,
|
||||
) -> VllmConfig:
|
||||
config = MagicMock()
|
||||
config.cache_config.block_size = 16
|
||||
config.cache_config.enable_prefix_caching = True
|
||||
config.cache_config.prefix_match_unit = None
|
||||
config.cache_config.cache_dtype = torch.float16
|
||||
config.model_config.model = "test-model"
|
||||
config.model_config.use_mla = False
|
||||
world_size = (
|
||||
tensor_parallel_size * pipeline_parallel_size * prefill_context_parallel_size
|
||||
)
|
||||
with patch.object(current_platform, "device_count", return_value=world_size):
|
||||
config.parallel_config = ParallelConfig(
|
||||
tensor_parallel_size=tensor_parallel_size,
|
||||
pipeline_parallel_size=pipeline_parallel_size,
|
||||
prefill_context_parallel_size=prefill_context_parallel_size,
|
||||
decode_context_parallel_size=decode_context_parallel_size,
|
||||
)
|
||||
config.kv_events_config = None
|
||||
config.use_v2_model_runner = False
|
||||
config.kv_transfer_config = KVTransferConfig(
|
||||
kv_connector="OffloadingConnector",
|
||||
kv_role="kv_both",
|
||||
kv_connector_extra_config=dict(extra_config or {}),
|
||||
)
|
||||
return cast(VllmConfig, config)
|
||||
|
||||
|
||||
def _make_kv_cache_config() -> KVCacheConfig:
|
||||
num_blocks = 16
|
||||
kv_tensor = KVCacheTensor(
|
||||
size=num_blocks * 8,
|
||||
shared_by=["layer"],
|
||||
block_stride=0,
|
||||
)
|
||||
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,
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _make_sizing_kv_cache_config(packed: bool) -> KVCacheConfig:
|
||||
num_blocks = 4
|
||||
if packed:
|
||||
kv_cache_tensors = [
|
||||
KVCacheTensor(
|
||||
size=64,
|
||||
shared_by=[layer_name],
|
||||
block_stride=16,
|
||||
)
|
||||
for layer_name in ("layer0", "layer1")
|
||||
]
|
||||
else:
|
||||
kv_cache_tensors = [
|
||||
KVCacheTensor(size=40, shared_by=["layer0"]),
|
||||
KVCacheTensor(size=24, shared_by=["layer1"]),
|
||||
]
|
||||
|
||||
return KVCacheConfig(
|
||||
num_blocks=num_blocks,
|
||||
kv_cache_tensors=kv_cache_tensors,
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(
|
||||
["layer0", "layer1"],
|
||||
FullAttentionSpec(
|
||||
block_size=16,
|
||||
num_kv_heads=1,
|
||||
head_size=1,
|
||||
dtype=torch.float32,
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _full_attention_spec(block_size: int = 16) -> FullAttentionSpec:
|
||||
return FullAttentionSpec(
|
||||
block_size=block_size,
|
||||
num_kv_heads=4,
|
||||
head_size=128,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
def _mla_spec(
|
||||
block_size: int = 16,
|
||||
head_size: int = 512,
|
||||
dtype: torch.dtype = torch.float32,
|
||||
) -> MLAAttentionSpec:
|
||||
return MLAAttentionSpec(
|
||||
block_size=block_size,
|
||||
num_kv_heads=1,
|
||||
head_size=head_size,
|
||||
dtype=dtype,
|
||||
)
|
||||
|
||||
|
||||
def _make_mla_kv_cache_config(
|
||||
layer_names: list[str] | None = None,
|
||||
head_size: int = 512,
|
||||
dtype: torch.dtype = torch.float32,
|
||||
num_blocks: int = 4,
|
||||
) -> KVCacheConfig:
|
||||
if layer_names is None:
|
||||
layer_names = ["layer0", "layer1"]
|
||||
spec = _mla_spec(head_size=head_size, dtype=dtype)
|
||||
kv_cache_tensors = [
|
||||
KVCacheTensor(
|
||||
size=spec.page_size_bytes * num_blocks,
|
||||
shared_by=[layer_name],
|
||||
)
|
||||
for layer_name in layer_names
|
||||
]
|
||||
return KVCacheConfig(
|
||||
num_blocks=num_blocks,
|
||||
kv_cache_tensors=kv_cache_tensors,
|
||||
kv_cache_groups=[KVCacheGroupSpec(layer_names, spec)],
|
||||
)
|
||||
|
||||
|
||||
def _make_hybrid_kv_cache_config() -> KVCacheConfig:
|
||||
return KVCacheConfig(
|
||||
num_blocks=4,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(size=40, shared_by=["full_layer"]),
|
||||
KVCacheTensor(size=24, shared_by=["mla_layer"]),
|
||||
],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(["full_layer"], _full_attention_spec(block_size=12)),
|
||||
KVCacheGroupSpec(["mla_layer"], _mla_spec()),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _parallelism_agnostic(kv_cache_groups: list[KVCacheGroupSpec]) -> bool:
|
||||
config = _make_vllm_config()
|
||||
kv_cache_config = KVCacheConfig(
|
||||
num_blocks=0,
|
||||
kv_cache_tensors=[],
|
||||
kv_cache_groups=kv_cache_groups,
|
||||
)
|
||||
return build_offloading_config(
|
||||
config, kv_cache_config
|
||||
).parallel.is_parallelism_agnostic
|
||||
|
||||
|
||||
def _replicated_layout(
|
||||
kv_cache_config: KVCacheConfig,
|
||||
*,
|
||||
tensor_parallel_size: int = 4,
|
||||
pipeline_parallel_size: int = 1,
|
||||
prefill_context_parallel_size: int = 1,
|
||||
decode_context_parallel_size: int = 1,
|
||||
use_mla: bool = True,
|
||||
use_v2_model_runner: bool = False,
|
||||
distributed_executor_backend: Any = "mp",
|
||||
nnodes: int = 1,
|
||||
world_size: int | None = None,
|
||||
) -> bool:
|
||||
config = _make_vllm_config(
|
||||
tensor_parallel_size=tensor_parallel_size,
|
||||
pipeline_parallel_size=pipeline_parallel_size,
|
||||
prefill_context_parallel_size=prefill_context_parallel_size,
|
||||
decode_context_parallel_size=decode_context_parallel_size,
|
||||
)
|
||||
config.model_config.use_mla = use_mla
|
||||
config.use_v2_model_runner = use_v2_model_runner
|
||||
config.parallel_config.distributed_executor_backend = distributed_executor_backend
|
||||
config.parallel_config.nnodes = nnodes
|
||||
if world_size is not None:
|
||||
config.parallel_config.world_size = world_size
|
||||
return build_offloading_config(config, kv_cache_config).replicated_layout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("packed", [False, True])
|
||||
def test_worker_kv_bytes_preserves_tensor_layout(packed: bool):
|
||||
config = _make_vllm_config(
|
||||
extra_config={"block_size": 32},
|
||||
tensor_parallel_size=3,
|
||||
pipeline_parallel_size=2,
|
||||
)
|
||||
|
||||
offloading_config = build_offloading_config(
|
||||
config, _make_sizing_kv_cache_config(packed)
|
||||
)
|
||||
|
||||
assert offloading_config.worker_kv_bytes_per_block == 16
|
||||
assert offloading_config.parallel.world_size == 6
|
||||
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)
|
||||
|
||||
assert offloading_config.worker_kv_bytes_per_block == 0
|
||||
|
||||
|
||||
def test_prefill_context_parallelism_does_not_scale_group_blocks():
|
||||
config = _make_vllm_config(
|
||||
extra_config={"block_size": 64},
|
||||
prefill_context_parallel_size=2,
|
||||
)
|
||||
|
||||
offloading_config = build_offloading_config(config, _make_kv_cache_config())
|
||||
|
||||
assert tuple(group.tokens_per_block for group in offloading_config.groups) == (16,)
|
||||
assert offloading_config.cache.tokens_per_hash == 16
|
||||
assert offloading_config.cache.blocks_per_chunk == 4
|
||||
|
||||
|
||||
def test_preserves_data_parallel_index():
|
||||
config = _make_vllm_config()
|
||||
config.parallel_config.data_parallel_index = 2
|
||||
|
||||
offloading_config = build_offloading_config(config, _make_kv_cache_config())
|
||||
|
||||
assert offloading_config.parallel.data_parallel_index == 2
|
||||
|
||||
|
||||
def test_resolves_heterogeneous_hybrid_block_sizes():
|
||||
config = _make_vllm_config()
|
||||
config.cache_config.block_size = 4
|
||||
|
||||
offloading_config = build_offloading_config(config, _make_hybrid_kv_cache_config())
|
||||
|
||||
assert tuple(group.tokens_per_block for group in offloading_config.groups) == (
|
||||
12,
|
||||
16,
|
||||
)
|
||||
assert offloading_config.cache.tokens_per_hash == 4
|
||||
assert offloading_config.cache.blocks_per_chunk == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("world_size", [2, 4, 8])
|
||||
@pytest.mark.parametrize("use_v2_model_runner", [False, True], ids=["v1", "v2"])
|
||||
def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
|
||||
world_size: int,
|
||||
use_v2_model_runner: bool,
|
||||
):
|
||||
assert _replicated_layout(
|
||||
_make_mla_kv_cache_config(),
|
||||
tensor_parallel_size=world_size,
|
||||
use_v2_model_runner=use_v2_model_runner,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kv_cache_config", "case"),
|
||||
[
|
||||
(
|
||||
KVCacheConfig(
|
||||
num_blocks=4,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(
|
||||
size=_mla_spec().page_size_bytes * 4,
|
||||
shared_by=["layer"],
|
||||
)
|
||||
],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(
|
||||
["layer"],
|
||||
SlidingWindowMLASpec(
|
||||
block_size=16,
|
||||
num_kv_heads=1,
|
||||
head_size=512,
|
||||
dtype=torch.float32,
|
||||
sliding_window=128,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
"sliding-window-mla",
|
||||
),
|
||||
(
|
||||
KVCacheConfig(
|
||||
num_blocks=4,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(
|
||||
size=_mla_spec().page_size_bytes * 4,
|
||||
shared_by=["layer"],
|
||||
)
|
||||
],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(
|
||||
["layer"],
|
||||
HiddenStateCacheSpec(
|
||||
block_size=16,
|
||||
num_kv_heads=1,
|
||||
head_size=512,
|
||||
dtype=torch.float32,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
"hidden-state",
|
||||
),
|
||||
(
|
||||
KVCacheConfig(
|
||||
num_blocks=4,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(
|
||||
size=_mla_spec().page_size_bytes * 4,
|
||||
shared_by=["layer0"],
|
||||
),
|
||||
KVCacheTensor(
|
||||
size=_mla_spec(head_size=256).page_size_bytes * 4,
|
||||
shared_by=["layer1"],
|
||||
),
|
||||
],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(
|
||||
["layer0", "layer1"],
|
||||
UniformTypeKVCacheSpecs(
|
||||
block_size=16,
|
||||
kv_cache_specs={
|
||||
"layer0": _mla_spec(),
|
||||
"layer1": _mla_spec(head_size=256),
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
"uniform-wrapper",
|
||||
),
|
||||
(
|
||||
KVCacheConfig(
|
||||
num_blocks=4,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(
|
||||
size=_mla_spec().page_size_bytes * 4,
|
||||
shared_by=["mla"],
|
||||
),
|
||||
KVCacheTensor(
|
||||
size=_full_attention_spec().page_size_bytes * 4,
|
||||
shared_by=["full"],
|
||||
),
|
||||
],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(["mla"], _mla_spec()),
|
||||
KVCacheGroupSpec(["full"], _full_attention_spec()),
|
||||
],
|
||||
),
|
||||
"mla-full-hybrid",
|
||||
),
|
||||
(
|
||||
KVCacheConfig(
|
||||
num_blocks=4,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(
|
||||
size=_mla_spec().page_size_bytes * 4,
|
||||
shared_by=["mla"],
|
||||
),
|
||||
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,),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
"mla-mamba-hybrid",
|
||||
),
|
||||
(
|
||||
KVCacheConfig(
|
||||
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"],
|
||||
),
|
||||
],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(["layer0"], _mla_spec()),
|
||||
KVCacheGroupSpec(["layer1"], _mla_spec()),
|
||||
],
|
||||
),
|
||||
"multi-group-mla",
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"sliding-window-mla",
|
||||
"hidden-state",
|
||||
"uniform-wrapper",
|
||||
"mla-full-hybrid",
|
||||
"mla-mamba-hybrid",
|
||||
"multi-group-mla",
|
||||
],
|
||||
)
|
||||
def test_replicated_layout_excludes_unproven_cache_shapes(
|
||||
kv_cache_config: KVCacheConfig,
|
||||
case: str,
|
||||
):
|
||||
assert not _replicated_layout(kv_cache_config), case
|
||||
|
||||
|
||||
def test_replicated_layout_rejects_bare_mla_with_mixed_page_accounting():
|
||||
num_blocks = 4
|
||||
main_spec = _mla_spec(head_size=512)
|
||||
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)]
|
||||
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,
|
||||
),
|
||||
KVCacheTensor(
|
||||
size=indexer_spec.page_size_bytes * len(indexer_layers) * num_blocks,
|
||||
shared_by=indexer_layers,
|
||||
),
|
||||
],
|
||||
kv_cache_groups=[KVCacheGroupSpec(main_layers + indexer_layers, main_spec)],
|
||||
)
|
||||
|
||||
assert not _replicated_layout(kv_cache_config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "case"),
|
||||
[
|
||||
({"tensor_parallel_size": 1}, "tp1"),
|
||||
({"use_mla": False}, "use-mla-false"),
|
||||
({"pipeline_parallel_size": 2, "world_size": 4}, "pp2"),
|
||||
({"prefill_context_parallel_size": 2, "world_size": 4}, "pcp2"),
|
||||
({"decode_context_parallel_size": 2}, "dcp2"),
|
||||
({"world_size": 8}, "world-ne-tp"),
|
||||
({"distributed_executor_backend": "ray"}, "ray"),
|
||||
({"distributed_executor_backend": "uni"}, "uni"),
|
||||
({"distributed_executor_backend": type("DummyExecutor", (), {})}, "class"),
|
||||
({"nnodes": 2}, "multi-node"),
|
||||
],
|
||||
ids=[
|
||||
"tp1",
|
||||
"use-mla-false",
|
||||
"pp2",
|
||||
"pcp2",
|
||||
"dcp2",
|
||||
"world-ne-tp",
|
||||
"ray",
|
||||
"uni",
|
||||
"class",
|
||||
"multi-node",
|
||||
],
|
||||
)
|
||||
def test_replicated_layout_parallel_gate(kwargs: dict[str, Any], case: str):
|
||||
assert not _replicated_layout(_make_mla_kv_cache_config(), **kwargs), case
|
||||
|
||||
|
||||
def test_parallelism_agnostic_for_single_full_attention_group():
|
||||
assert _parallelism_agnostic([KVCacheGroupSpec(["l0"], _full_attention_spec())])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kv_cache_groups",
|
||||
[
|
||||
[KVCacheGroupSpec(["l0"], _mla_spec(head_size=576))],
|
||||
[
|
||||
KVCacheGroupSpec(
|
||||
["l0"],
|
||||
SlidingWindowSpec(
|
||||
block_size=16,
|
||||
num_kv_heads=4,
|
||||
head_size=128,
|
||||
dtype=torch.float32,
|
||||
sliding_window=128,
|
||||
),
|
||||
)
|
||||
],
|
||||
[
|
||||
KVCacheGroupSpec(["l0"], _full_attention_spec()),
|
||||
KVCacheGroupSpec(["l1"], _full_attention_spec()),
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_parallelism_agnostic_excluded(kv_cache_groups: list[KVCacheGroupSpec]):
|
||||
assert not _parallelism_agnostic(kv_cache_groups)
|
||||
|
||||
|
||||
def test_parallelism_agnostic_disabled_on_v2_model_runner():
|
||||
config = _make_vllm_config()
|
||||
config.use_v2_model_runner = True
|
||||
kv_cache_config = KVCacheConfig(
|
||||
num_blocks=0,
|
||||
kv_cache_tensors=[],
|
||||
kv_cache_groups=[KVCacheGroupSpec(["l0"], _full_attention_spec())],
|
||||
)
|
||||
|
||||
offloading_config = build_offloading_config(config, kv_cache_config)
|
||||
|
||||
assert not offloading_config.parallel.is_parallelism_agnostic
|
||||
|
||||
|
||||
def test_accepts_blocks_per_chunk_for_heterogeneous_groups():
|
||||
config = _make_vllm_config(extra_config={"blocks_per_chunk": 2})
|
||||
|
||||
offloading_config = build_offloading_config(config, _make_hybrid_kv_cache_config())
|
||||
|
||||
assert tuple(group.tokens_per_block for group in offloading_config.groups) == (
|
||||
12,
|
||||
16,
|
||||
)
|
||||
assert offloading_config.cache.blocks_per_chunk == 2
|
||||
|
||||
|
||||
def test_block_size_and_blocks_per_chunk_are_mutually_exclusive():
|
||||
config = _make_vllm_config(extra_config={"block_size": 64, "blocks_per_chunk": 2})
|
||||
|
||||
with pytest.raises(ValueError, match="Specify only one"):
|
||||
build_offloading_config(config, _make_kv_cache_config())
|
||||
|
||||
|
||||
def test_blocks_per_chunk_must_be_positive():
|
||||
config = _make_vllm_config(extra_config={"blocks_per_chunk": 0})
|
||||
|
||||
with pytest.raises(ValueError, match="greater than 0"):
|
||||
build_offloading_config(config, _make_kv_cache_config())
|
||||
@@ -14,6 +14,7 @@ from tests.v1.kv_connector.unit.utils import EOS_TOKEN_ID
|
||||
from vllm.distributed.kv_events import MEDIUM_CPU, BlockRemoved, BlockStored
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
|
||||
OffloadingConnectorMetadata,
|
||||
OffloadingWorkerMetadata,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
|
||||
OffloadingConnectorStats,
|
||||
@@ -40,6 +41,7 @@ from vllm.v1.kv_offload.base import (
|
||||
get_offload_block_hash,
|
||||
make_offload_key,
|
||||
)
|
||||
from vllm.v1.outputs import KVConnectorOutput
|
||||
from vllm.v1.request import RequestStatus
|
||||
|
||||
|
||||
@@ -1539,6 +1541,51 @@ def test_complete_store_called_per_job(request_runner, async_scheduling: bool):
|
||||
assert runner.manager.complete_store.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("async_scheduling", [True, False])
|
||||
def test_complete_store_waits_for_all_worker_acks(
|
||||
request_runner, async_scheduling: bool
|
||||
):
|
||||
tokens_per_block = 4
|
||||
blocks_per_chunk = 3
|
||||
tokens_per_chunk = tokens_per_block * blocks_per_chunk
|
||||
runner = request_runner(
|
||||
blocks_per_chunk=blocks_per_chunk,
|
||||
block_size=tokens_per_block,
|
||||
num_gpu_blocks=100,
|
||||
async_scheduling=async_scheduling,
|
||||
worker_count=3,
|
||||
)
|
||||
runner.new_request(token_ids=[0] * tokens_per_chunk)
|
||||
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
|
||||
generate_store_output(keys)
|
||||
)
|
||||
runner.run(decoded_tokens=[0, 0], complete_transfers=False)
|
||||
assert len(runner.connector_scheduler._jobs) == 1
|
||||
job_id = next(iter(runner.connector_scheduler._jobs))
|
||||
assert runner.connector_scheduler._jobs[job_id].pending_count == 3
|
||||
runner.manager.complete_store.reset_mock()
|
||||
|
||||
runner.connector_scheduler.update_connector_output(
|
||||
KVConnectorOutput(
|
||||
kv_connector_worker_meta=OffloadingWorkerMetadata(
|
||||
completed_jobs={job_id: 1}
|
||||
)
|
||||
)
|
||||
)
|
||||
assert runner.manager.complete_store.call_count == 0
|
||||
assert runner.connector_scheduler._jobs[job_id].pending_count == 2
|
||||
|
||||
runner.connector_scheduler.update_connector_output(
|
||||
KVConnectorOutput(
|
||||
kv_connector_worker_meta=OffloadingWorkerMetadata(
|
||||
completed_jobs={job_id: 2}
|
||||
)
|
||||
)
|
||||
)
|
||||
assert runner.manager.complete_store.call_count == 1
|
||||
assert job_id not in runner.connector_scheduler._jobs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("async_scheduling", [True, False])
|
||||
def test_max_offload_tokens_validation(request_runner, async_scheduling: bool):
|
||||
"""Validates max_offload_tokens: type coercion, boundary values, and capping.
|
||||
|
||||
@@ -6,6 +6,10 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
|
||||
OffloadingConnectorMetadata,
|
||||
TransferJob,
|
||||
)
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.utils.torch_utils import get_dtype_size
|
||||
from vllm.v1.attention.backends.registry import AttentionBackendEnum
|
||||
@@ -22,7 +26,17 @@ from vllm.v1.kv_cache_interface import (
|
||||
from vllm.v1.kv_offload.base import (
|
||||
CanonicalKVCacheRef,
|
||||
CanonicalKVCaches,
|
||||
GPULoadStoreSpec,
|
||||
LoadStoreSpec,
|
||||
OffloadingManager,
|
||||
OffloadingSpec,
|
||||
OffloadingWorker,
|
||||
)
|
||||
from vllm.v1.kv_offload.config import (
|
||||
OffloadingCacheConfig,
|
||||
OffloadingConfig,
|
||||
OffloadingModelConfig,
|
||||
OffloadingParallelConfig,
|
||||
)
|
||||
|
||||
NUM_BLOCKS = 10
|
||||
@@ -87,7 +101,11 @@ def _allocate_and_reshape_kv_caches(
|
||||
set_kv_cache_layout(None)
|
||||
|
||||
|
||||
def _make_worker(kv_cache_config: KVCacheConfig):
|
||||
def _make_worker(
|
||||
kv_cache_config: KVCacheConfig,
|
||||
replicated_layout: bool = False,
|
||||
rank: int = 0,
|
||||
):
|
||||
"""
|
||||
Create an OffloadingConnectorWorker with mocked dependencies.
|
||||
"""
|
||||
@@ -96,6 +114,9 @@ def _make_worker(kv_cache_config: KVCacheConfig):
|
||||
)
|
||||
|
||||
spec = MagicMock(spec=OffloadingSpec)
|
||||
spec.replicated_layout = replicated_layout
|
||||
spec.config = MagicMock()
|
||||
spec.config.parallel.rank = rank
|
||||
spec.get_worker.return_value = MagicMock()
|
||||
|
||||
worker = OffloadingConnectorWorker(
|
||||
@@ -107,11 +128,173 @@ def _make_worker(kv_cache_config: KVCacheConfig):
|
||||
return worker, spec
|
||||
|
||||
|
||||
def _store_metadata(job_id: int) -> OffloadingConnectorMetadata:
|
||||
return OffloadingConnectorMetadata(
|
||||
load_jobs={},
|
||||
store_jobs={
|
||||
job_id: TransferJob(
|
||||
req_id="req",
|
||||
src_spec=GPULoadStoreSpec([0], group_sizes=(1,), block_indices=(0,)),
|
||||
dst_spec=LoadStoreSpec(),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _load_metadata(job_id: int) -> OffloadingConnectorMetadata:
|
||||
return OffloadingConnectorMetadata(
|
||||
load_jobs={
|
||||
job_id: TransferJob(
|
||||
req_id="req",
|
||||
src_spec=LoadStoreSpec(),
|
||||
dst_spec=GPULoadStoreSpec([0], group_sizes=(1,), block_indices=(0,)),
|
||||
)
|
||||
},
|
||||
store_jobs={},
|
||||
)
|
||||
|
||||
|
||||
def _empty_metadata() -> OffloadingConnectorMetadata:
|
||||
return OffloadingConnectorMetadata(load_jobs={}, store_jobs={})
|
||||
|
||||
|
||||
def _offloading_config(rank: int = 0) -> OffloadingConfig:
|
||||
return OffloadingConfig(
|
||||
groups=(),
|
||||
worker_kv_bytes_per_block=0,
|
||||
enable_kv_cache_events=False,
|
||||
extra_config={},
|
||||
engine_id="test-engine",
|
||||
model=OffloadingModelConfig(name="test-model", dtype="float16"),
|
||||
cache=OffloadingCacheConfig(tokens_per_hash=16, blocks_per_chunk=1),
|
||||
parallel=OffloadingParallelConfig(
|
||||
rank=rank,
|
||||
world_size=2,
|
||||
tp_size=2,
|
||||
pp_size=1,
|
||||
pcp_size=1,
|
||||
dcp_size=1,
|
||||
data_parallel_index=0,
|
||||
is_parallelism_agnostic=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class BareExternalOffloadingSpec(OffloadingSpec):
|
||||
def get_manager(self) -> OffloadingManager:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_worker(self, kv_caches: CanonicalKVCaches) -> OffloadingWorker:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prepare_store_kv_non_writer_marks_completed_without_submit():
|
||||
worker, _ = _make_worker(
|
||||
KVCacheConfig(num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]),
|
||||
replicated_layout=True,
|
||||
rank=1,
|
||||
)
|
||||
|
||||
worker.prepare_store_kv(_store_metadata(7))
|
||||
worker.start_kv_transfers(_empty_metadata())
|
||||
|
||||
assert worker._unsubmitted_store_jobs == []
|
||||
assert worker.worker is not None
|
||||
worker.worker.submit_store.assert_not_called()
|
||||
meta = worker.build_connector_worker_meta()
|
||||
assert meta is not None
|
||||
assert meta.completed_jobs == {7: 1}
|
||||
|
||||
|
||||
def test_prepare_store_kv_writer_submits_store():
|
||||
worker, _ = _make_worker(
|
||||
KVCacheConfig(num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]),
|
||||
replicated_layout=True,
|
||||
rank=0,
|
||||
)
|
||||
|
||||
worker.prepare_store_kv(_store_metadata(8))
|
||||
assert worker.build_connector_worker_meta() is None
|
||||
worker.start_kv_transfers(_empty_metadata())
|
||||
|
||||
assert worker.worker is not None
|
||||
worker.worker.submit_store.assert_called_once()
|
||||
|
||||
|
||||
def test_prepare_store_kv_non_replicated_rank_gt_zero_queues_store():
|
||||
worker, _ = _make_worker(
|
||||
KVCacheConfig(num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]),
|
||||
replicated_layout=False,
|
||||
rank=1,
|
||||
)
|
||||
|
||||
worker.prepare_store_kv(_store_metadata(9))
|
||||
assert worker.build_connector_worker_meta() is None
|
||||
assert len(worker._unsubmitted_store_jobs) == 1
|
||||
|
||||
worker.start_kv_transfers(_empty_metadata())
|
||||
|
||||
assert worker.worker is not None
|
||||
worker.worker.submit_store.assert_called_once()
|
||||
assert worker._unsubmitted_store_jobs == []
|
||||
|
||||
|
||||
def test_handle_preemptions_non_writer_acks_flushed_store():
|
||||
worker, _ = _make_worker(
|
||||
KVCacheConfig(num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]),
|
||||
replicated_layout=True,
|
||||
rank=1,
|
||||
)
|
||||
metadata = _store_metadata(10)
|
||||
metadata.jobs_to_flush = {10}
|
||||
|
||||
worker.handle_preemptions(metadata)
|
||||
|
||||
assert worker.worker is not None
|
||||
worker.worker.submit_store.assert_not_called()
|
||||
worker.worker.wait.assert_called_once_with({10})
|
||||
assert metadata.store_jobs == {}
|
||||
meta = worker.build_connector_worker_meta()
|
||||
assert meta is not None
|
||||
assert meta.completed_jobs == {10: 1}
|
||||
|
||||
|
||||
def test_start_kv_transfers_non_writer_still_submits_load():
|
||||
worker, _ = _make_worker(
|
||||
KVCacheConfig(num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]),
|
||||
replicated_layout=True,
|
||||
rank=1,
|
||||
)
|
||||
|
||||
worker.start_kv_transfers(_load_metadata(10))
|
||||
|
||||
assert worker.worker is not None
|
||||
worker.worker.submit_load.assert_called_once()
|
||||
assert worker.build_connector_worker_meta() is None
|
||||
|
||||
|
||||
def test_offloading_connector_worker_accepts_plugin_spec_default_layout():
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.worker import (
|
||||
OffloadingConnectorWorker,
|
||||
)
|
||||
|
||||
spec = BareExternalOffloadingSpec(_offloading_config(rank=1))
|
||||
|
||||
OffloadingConnectorWorker(
|
||||
spec=spec,
|
||||
kv_cache_config=KVCacheConfig(
|
||||
num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]
|
||||
),
|
||||
)
|
||||
|
||||
assert spec.replicated_layout is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", ATTN_BACKENDS)
|
||||
def test_register_kv_caches(backend):
|
||||
"""Test register_kv_caches with multiple groups covering all layer types.
|
||||
|
||||
@@ -179,6 +179,7 @@ class RequestRunner:
|
||||
async_scheduling: bool = True,
|
||||
kv_cache_groups: list[KVCacheGroupSpec] | None = None,
|
||||
extra_config_overrides: dict[str, Any] | None = None,
|
||||
worker_count: int = 1,
|
||||
):
|
||||
assert blocks_per_chunk == 1 or kv_cache_groups is None, (
|
||||
"blocks_per_chunk > 1 requires all groups to have the same "
|
||||
@@ -198,6 +199,7 @@ class RequestRunner:
|
||||
disable_hybrid_kv_cache_manager=False,
|
||||
)
|
||||
vllm_config.scheduler_config.async_scheduling = async_scheduling
|
||||
vllm_config.parallel_config.world_size = worker_count
|
||||
|
||||
extra_config: dict[str, Any] = {
|
||||
"spec_name": "MockOffloadingSpec",
|
||||
@@ -668,6 +670,7 @@ def request_runner():
|
||||
blocks_per_chunk=1,
|
||||
kv_cache_groups=None,
|
||||
extra_config_overrides=None,
|
||||
worker_count=1,
|
||||
):
|
||||
runner = RequestRunner(
|
||||
block_size=block_size,
|
||||
@@ -676,6 +679,7 @@ def request_runner():
|
||||
async_scheduling=async_scheduling,
|
||||
kv_cache_groups=kv_cache_groups,
|
||||
extra_config_overrides=extra_config_overrides,
|
||||
worker_count=worker_count,
|
||||
)
|
||||
runners.append(runner)
|
||||
return runner
|
||||
|
||||
@@ -41,7 +41,10 @@ NUM_MAPPINGS_PER_GROUP = [2]
|
||||
@pytest.mark.parametrize("num_tensors", NUM_TENSORS)
|
||||
@pytest.mark.parametrize("seed", SEEDS)
|
||||
@pytest.mark.parametrize("device", DEVICES)
|
||||
@pytest.mark.parametrize("use_shared_memory", [False, True])
|
||||
@pytest.mark.parametrize(
|
||||
("use_shared_memory", "replicated_layout"),
|
||||
[(False, False), (True, False), (True, True)],
|
||||
)
|
||||
@torch.inference_mode()
|
||||
def test_transfer(
|
||||
default_vllm_config,
|
||||
@@ -55,6 +58,7 @@ def test_transfer(
|
||||
seed: int,
|
||||
device: str,
|
||||
use_shared_memory: bool,
|
||||
replicated_layout: bool,
|
||||
) -> None:
|
||||
set_random_seed(seed)
|
||||
|
||||
@@ -95,11 +99,15 @@ def test_transfer(
|
||||
gpu_page_size_bytes * num_tensors * blocks_per_chunk,
|
||||
SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT,
|
||||
)
|
||||
simulated_world_size = 2
|
||||
kv_bytes_per_block = (
|
||||
cpu_page_size if replicated_layout else cpu_page_size * simulated_world_size
|
||||
)
|
||||
mmap_region = SharedOffloadRegion(
|
||||
engine_id=str(uuid.uuid4()),
|
||||
num_blocks=num_cpu_blocks,
|
||||
rank=0,
|
||||
kv_bytes_per_block=cpu_page_size,
|
||||
kv_bytes_per_block=kv_bytes_per_block,
|
||||
cpu_page_size=cpu_page_size,
|
||||
)
|
||||
|
||||
|
||||
+223
-484
@@ -1,35 +1,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""
|
||||
Unit tests for OffloadingSpecFactory.
|
||||
"""Unit tests for native offloading specs and their factory."""
|
||||
|
||||
These tests verify:
|
||||
1. Pre-registration integrity — registered module paths can actually import
|
||||
and yield correct OffloadingSpec subclasses (CI sentinel against file moves).
|
||||
2. End-to-end factory → spec construction with real configs.
|
||||
3. Downstream collaboration — build_metric_definitions delegation.
|
||||
4. Error paths — unregistered specs, missing config, duplicate registration.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm.config import KVTransferConfig, ParallelConfig, VllmConfig
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.config import (
|
||||
build_offloading_config,
|
||||
)
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
FullAttentionSpec,
|
||||
KVCacheConfig,
|
||||
KVCacheGroupSpec,
|
||||
KVCacheTensor,
|
||||
MLAAttentionSpec,
|
||||
SlidingWindowSpec,
|
||||
)
|
||||
from vllm.v1.kv_offload.base import (
|
||||
CanonicalKVCaches,
|
||||
OffloadingHistogramMetadata,
|
||||
@@ -37,220 +14,81 @@ from vllm.v1.kv_offload.base import (
|
||||
OffloadingSpec,
|
||||
OffloadingWorker,
|
||||
)
|
||||
from vllm.v1.kv_offload.config import (
|
||||
OffloadingCacheConfig,
|
||||
OffloadingConfig,
|
||||
OffloadingGroupConfig,
|
||||
OffloadingModelConfig,
|
||||
OffloadingParallelConfig,
|
||||
)
|
||||
from vllm.v1.kv_offload.cpu.shared_offload_region import SharedOffloadRegion
|
||||
from vllm.v1.kv_offload.cpu.spec import CPUOffloadingSpec
|
||||
from vllm.v1.kv_offload.factory import OffloadingSpecFactory
|
||||
from vllm.v1.kv_offload.tiering.spec import TieringOffloadingSpec
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_registry():
|
||||
"""Save and restore OffloadingSpecFactory._registry between tests."""
|
||||
original = dict(OffloadingSpecFactory._registry)
|
||||
yield
|
||||
OffloadingSpecFactory._registry = original
|
||||
|
||||
|
||||
def _get_extra_config(config: VllmConfig) -> dict:
|
||||
assert config.kv_transfer_config is not None
|
||||
return config.kv_transfer_config.kv_connector_extra_config
|
||||
|
||||
|
||||
def _create_spec(config: VllmConfig, kv_cache_config: KVCacheConfig) -> OffloadingSpec:
|
||||
return OffloadingSpecFactory.create_spec(
|
||||
build_offloading_config(config, kv_cache_config)
|
||||
)
|
||||
|
||||
|
||||
def _make_vllm_config(
|
||||
def _make_offloading_config(
|
||||
*,
|
||||
spec_name: str | None = "CPUOffloadingSpec",
|
||||
cpu_bytes_to_use: int | None = None,
|
||||
store_threshold: int = 0,
|
||||
extra_config: dict | None = None,
|
||||
):
|
||||
"""Build a real VllmConfig with kv_transfer_config set for offloading."""
|
||||
from vllm.config import (
|
||||
CacheConfig,
|
||||
DeviceConfig,
|
||||
ModelConfig,
|
||||
SchedulerConfig,
|
||||
VllmConfig,
|
||||
)
|
||||
|
||||
model_config = ModelConfig(
|
||||
model="facebook/opt-125m",
|
||||
trust_remote_code=True,
|
||||
dtype="float16",
|
||||
seed=42,
|
||||
)
|
||||
scheduler_config = SchedulerConfig(
|
||||
max_num_seqs=16,
|
||||
max_num_batched_tokens=64,
|
||||
max_model_len=10000,
|
||||
enable_chunked_prefill=True,
|
||||
is_encoder_decoder=model_config.is_encoder_decoder,
|
||||
)
|
||||
cache_config = CacheConfig(
|
||||
block_size=16,
|
||||
gpu_memory_utilization=0.9,
|
||||
cache_dtype="auto",
|
||||
enable_prefix_caching=True,
|
||||
)
|
||||
|
||||
cfg = extra_config or {}
|
||||
cpu_bytes_to_use: int | None = 65536,
|
||||
worker_kv_bytes_per_block: int = 8,
|
||||
groups: tuple[OffloadingGroupConfig, ...] | None = None,
|
||||
tokens_per_hash: int = 16,
|
||||
blocks_per_chunk: int = 1,
|
||||
rank: int = 0,
|
||||
world_size: int = 1,
|
||||
tp_size: int | None = None,
|
||||
pp_size: int = 1,
|
||||
pcp_size: int = 1,
|
||||
dcp_size: int = 1,
|
||||
data_parallel_index: int = 0,
|
||||
is_parallelism_agnostic: bool = False,
|
||||
replicated_layout: bool = False,
|
||||
extra_config: dict[str, Any] | None = None,
|
||||
) -> OffloadingConfig:
|
||||
normalized_extra_config = dict(extra_config or {})
|
||||
if spec_name is not None:
|
||||
normalized_extra_config["spec_name"] = spec_name
|
||||
if cpu_bytes_to_use is not None:
|
||||
cfg["cpu_bytes_to_use"] = cpu_bytes_to_use
|
||||
cfg["spec_name"] = spec_name
|
||||
if store_threshold > 0:
|
||||
cfg["store_threshold"] = store_threshold
|
||||
normalized_extra_config["cpu_bytes_to_use"] = cpu_bytes_to_use
|
||||
|
||||
kv_transfer_config = KVTransferConfig(
|
||||
kv_connector="OffloadingConnector",
|
||||
kv_role="kv_both",
|
||||
kv_connector_extra_config=cfg,
|
||||
)
|
||||
return VllmConfig(
|
||||
scheduler_config=scheduler_config,
|
||||
model_config=model_config,
|
||||
cache_config=cache_config,
|
||||
kv_transfer_config=kv_transfer_config,
|
||||
device_config=DeviceConfig("cpu"),
|
||||
if groups is None:
|
||||
groups = (OffloadingGroupConfig(16, ("layer",)),)
|
||||
|
||||
return OffloadingConfig(
|
||||
groups=groups,
|
||||
worker_kv_bytes_per_block=worker_kv_bytes_per_block,
|
||||
enable_kv_cache_events=False,
|
||||
extra_config=normalized_extra_config,
|
||||
engine_id="test-engine",
|
||||
model=OffloadingModelConfig(name="test-model", dtype="float16"),
|
||||
cache=OffloadingCacheConfig(
|
||||
tokens_per_hash=tokens_per_hash,
|
||||
blocks_per_chunk=blocks_per_chunk,
|
||||
),
|
||||
parallel=OffloadingParallelConfig(
|
||||
rank=rank,
|
||||
world_size=world_size,
|
||||
tp_size=world_size if tp_size is None else tp_size,
|
||||
pp_size=pp_size,
|
||||
pcp_size=pcp_size,
|
||||
dcp_size=dcp_size,
|
||||
data_parallel_index=data_parallel_index,
|
||||
is_parallelism_agnostic=is_parallelism_agnostic,
|
||||
),
|
||||
replicated_layout=replicated_layout,
|
||||
)
|
||||
|
||||
|
||||
def _make_layout_vllm_config(
|
||||
spec_name: str = "CPUOffloadingSpec",
|
||||
cpu_bytes_to_use: int | None = None,
|
||||
extra_config: dict | None = None,
|
||||
tensor_parallel_size: int = 1,
|
||||
pipeline_parallel_size: int = 1,
|
||||
prefill_context_parallel_size: int = 1,
|
||||
decode_context_parallel_size: int = 1,
|
||||
) -> VllmConfig:
|
||||
config = MagicMock()
|
||||
config.cache_config.block_size = 16
|
||||
config.cache_config.enable_prefix_caching = True
|
||||
config.cache_config.prefix_match_unit = None
|
||||
config.cache_config.cache_dtype = torch.float16
|
||||
config.model_config.model = "test-model"
|
||||
world_size = (
|
||||
tensor_parallel_size * pipeline_parallel_size * prefill_context_parallel_size
|
||||
)
|
||||
with patch.object(current_platform, "device_count", return_value=world_size):
|
||||
config.parallel_config = ParallelConfig(
|
||||
tensor_parallel_size=tensor_parallel_size,
|
||||
pipeline_parallel_size=pipeline_parallel_size,
|
||||
prefill_context_parallel_size=prefill_context_parallel_size,
|
||||
decode_context_parallel_size=decode_context_parallel_size,
|
||||
)
|
||||
config.kv_events_config = None
|
||||
config.use_v2_model_runner = False
|
||||
|
||||
connector_extra_config = dict(extra_config or {})
|
||||
connector_extra_config["spec_name"] = spec_name
|
||||
if cpu_bytes_to_use is not None:
|
||||
connector_extra_config["cpu_bytes_to_use"] = cpu_bytes_to_use
|
||||
config.kv_transfer_config = KVTransferConfig(
|
||||
kv_connector="OffloadingConnector",
|
||||
kv_role="kv_both",
|
||||
kv_connector_extra_config=connector_extra_config,
|
||||
)
|
||||
return cast(VllmConfig, config)
|
||||
|
||||
|
||||
def _make_kv_cache_config():
|
||||
"""Build a minimal KVCacheConfig with one KV cache tensor."""
|
||||
num_blocks = 16
|
||||
num_kv_heads = 1
|
||||
head_size = 1
|
||||
dtype = torch.float32
|
||||
page_size = 2 * num_kv_heads * head_size * torch.finfo(dtype).bits // 8
|
||||
kv_tensor = KVCacheTensor(
|
||||
size=num_blocks * page_size, shared_by=["layer"], block_stride=0
|
||||
)
|
||||
return KVCacheConfig(
|
||||
num_blocks=num_blocks,
|
||||
kv_cache_tensors=[kv_tensor],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(
|
||||
["layer"],
|
||||
FullAttentionSpec(
|
||||
block_size=16,
|
||||
num_kv_heads=num_kv_heads,
|
||||
head_size=head_size,
|
||||
dtype=dtype,
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _make_sizing_kv_cache_config(packed: bool) -> KVCacheConfig:
|
||||
num_blocks = 4
|
||||
if packed:
|
||||
kv_cache_tensors = [
|
||||
KVCacheTensor(
|
||||
size=64,
|
||||
shared_by=[layer_name],
|
||||
block_stride=16,
|
||||
)
|
||||
for layer_name in ("layer0", "layer1")
|
||||
]
|
||||
else:
|
||||
kv_cache_tensors = [
|
||||
KVCacheTensor(size=40, shared_by=["layer0"]),
|
||||
KVCacheTensor(size=24, shared_by=["layer1"]),
|
||||
]
|
||||
|
||||
return KVCacheConfig(
|
||||
num_blocks=num_blocks,
|
||||
kv_cache_tensors=kv_cache_tensors,
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(
|
||||
["layer0", "layer1"],
|
||||
FullAttentionSpec(
|
||||
block_size=16,
|
||||
num_kv_heads=1,
|
||||
head_size=1,
|
||||
dtype=torch.float32,
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _make_hybrid_kv_cache_config() -> KVCacheConfig:
|
||||
return KVCacheConfig(
|
||||
num_blocks=4,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(size=40, shared_by=["full_layer"]),
|
||||
KVCacheTensor(size=24, shared_by=["mla_layer"]),
|
||||
],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(
|
||||
["full_layer"],
|
||||
FullAttentionSpec(
|
||||
block_size=12,
|
||||
num_kv_heads=1,
|
||||
head_size=1,
|
||||
dtype=torch.float32,
|
||||
),
|
||||
),
|
||||
KVCacheGroupSpec(
|
||||
["mla_layer"],
|
||||
MLAAttentionSpec(
|
||||
block_size=16,
|
||||
num_kv_heads=1,
|
||||
head_size=576,
|
||||
dtype=torch.float32,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def _create_spec(**kwargs: Any) -> OffloadingSpec:
|
||||
return OffloadingSpecFactory.create_spec(_make_offloading_config(**kwargs))
|
||||
|
||||
|
||||
class SingleArgExternalOffloadingSpec(OffloadingSpec):
|
||||
@@ -261,104 +99,60 @@ class SingleArgExternalOffloadingSpec(OffloadingSpec):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-registration integrity (CI sentinel)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pre_registered_specs_can_be_imported():
|
||||
"""If someone moves cpu/spec.py but forgets to update factory.py, CI fails."""
|
||||
for name in OffloadingSpecFactory._registry:
|
||||
cls = OffloadingSpecFactory._registry[name]()
|
||||
assert issubclass(cls, OffloadingSpec)
|
||||
|
||||
|
||||
def test_cpu_spec_registered():
|
||||
"""CPUOffloadingSpec is registered and importable."""
|
||||
cls = OffloadingSpecFactory._registry["CPUOffloadingSpec"]()
|
||||
assert cls is CPUOffloadingSpec
|
||||
|
||||
|
||||
def test_tiering_spec_registered():
|
||||
"""TieringOffloadingSpec is registered and importable."""
|
||||
cls = OffloadingSpecFactory._registry["TieringOffloadingSpec"]()
|
||||
assert cls is TieringOffloadingSpec
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Normal path — get_spec_cls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_spec_cls_returns_registered_class():
|
||||
"""Registered spec_name returns correct class."""
|
||||
config = _make_vllm_config(spec_name="CPUOffloadingSpec")
|
||||
spec_cls = OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
|
||||
spec_cls = OffloadingSpecFactory.get_spec_cls(
|
||||
_make_offloading_config().extra_config
|
||||
)
|
||||
assert spec_cls is CPUOffloadingSpec
|
||||
|
||||
|
||||
def test_get_spec_cls_default_to_cpu():
|
||||
"""Default spec_name (absent from config) resolves to CPUOffloadingSpec."""
|
||||
config = _make_vllm_config(spec_name=None)
|
||||
config.kv_transfer_config.kv_connector_extra_config.pop("spec_name", None)
|
||||
spec_cls = OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
|
||||
def test_get_spec_cls_defaults_to_cpu():
|
||||
spec_cls = OffloadingSpecFactory.get_spec_cls(
|
||||
_make_offloading_config(spec_name=None).extra_config
|
||||
)
|
||||
assert spec_cls is CPUOffloadingSpec
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end — create_spec
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_cpu_offloading_spec_end_to_end():
|
||||
"""Full factory → spec construction with real VllmConfig/KVCacheConfig.
|
||||
|
||||
Verifies:
|
||||
- cpu_bytes_to_use validation and num_blocks calculation
|
||||
- block_size % tokens_per_hash assertion
|
||||
- spec instance is CPUOffloadingSpec
|
||||
"""
|
||||
config = _make_vllm_config(cpu_bytes_to_use=65536)
|
||||
kv_cache_config = _make_kv_cache_config()
|
||||
spec = _create_spec(config, kv_cache_config)
|
||||
def test_create_cpu_offloading_spec():
|
||||
spec = _create_spec()
|
||||
assert isinstance(spec, CPUOffloadingSpec)
|
||||
assert spec.num_blocks > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("packed", [False, True])
|
||||
def test_cpu_spec_sizing_preserves_tensor_layout(packed: bool):
|
||||
cpu_bytes_to_use = 1920
|
||||
config = _make_layout_vllm_config(
|
||||
cpu_bytes_to_use=cpu_bytes_to_use,
|
||||
extra_config={"block_size": 32},
|
||||
tensor_parallel_size=3,
|
||||
pipeline_parallel_size=2,
|
||||
def test_cpu_spec_sizes_normalized_worker_layout():
|
||||
spec = _create_spec(
|
||||
cpu_bytes_to_use=1920,
|
||||
worker_kv_bytes_per_block=16,
|
||||
blocks_per_chunk=2,
|
||||
world_size=6,
|
||||
tp_size=3,
|
||||
pp_size=2,
|
||||
)
|
||||
|
||||
spec = _create_spec(config, _make_sizing_kv_cache_config(packed))
|
||||
|
||||
assert isinstance(spec, CPUOffloadingSpec)
|
||||
assert spec.cpu_page_size_per_worker == 32
|
||||
assert spec.kv_bytes_per_chunk == 192
|
||||
assert spec.num_blocks == cpu_bytes_to_use // 192
|
||||
assert spec.num_blocks == 10
|
||||
|
||||
|
||||
def test_cpu_spec_rejects_partially_packed_tensor_layout():
|
||||
config = _make_layout_vllm_config(cpu_bytes_to_use=65536)
|
||||
kv_cache_config = _make_sizing_kv_cache_config(packed=False)
|
||||
kv_cache_config.kv_cache_tensors[0].block_stride = 16
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
_create_spec(config, kv_cache_config)
|
||||
|
||||
|
||||
def test_cpu_spec_zero_blocks_skips_tensor_layout_validation():
|
||||
config = _make_layout_vllm_config(cpu_bytes_to_use=65536)
|
||||
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
|
||||
|
||||
spec = _create_spec(config, kv_cache_config)
|
||||
def test_cpu_spec_zero_worker_bytes_produces_empty_cache():
|
||||
spec = _create_spec(worker_kv_bytes_per_block=0, world_size=4)
|
||||
|
||||
assert isinstance(spec, CPUOffloadingSpec)
|
||||
assert spec.cpu_page_size_per_worker == 0
|
||||
@@ -368,225 +162,209 @@ def test_cpu_spec_zero_blocks_skips_tensor_layout_validation():
|
||||
|
||||
def test_tiering_spec_aligns_row_size():
|
||||
alignment = SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT
|
||||
cpu_bytes_to_use = alignment * 3
|
||||
config = _make_layout_vllm_config(
|
||||
spec = _create_spec(
|
||||
spec_name="TieringOffloadingSpec",
|
||||
cpu_bytes_to_use=cpu_bytes_to_use,
|
||||
extra_config={"block_size": 32},
|
||||
tensor_parallel_size=3,
|
||||
pipeline_parallel_size=2,
|
||||
cpu_bytes_to_use=alignment * 3,
|
||||
worker_kv_bytes_per_block=16,
|
||||
blocks_per_chunk=2,
|
||||
world_size=6,
|
||||
tp_size=3,
|
||||
pp_size=2,
|
||||
)
|
||||
|
||||
spec = _create_spec(config, _make_sizing_kv_cache_config(packed=False))
|
||||
|
||||
assert isinstance(spec, TieringOffloadingSpec)
|
||||
assert spec.cpu_page_size_per_worker == 32
|
||||
assert spec.kv_bytes_per_chunk == alignment
|
||||
assert spec.num_blocks == cpu_bytes_to_use // alignment
|
||||
assert spec.num_blocks == 3
|
||||
|
||||
|
||||
def test_offloading_spec_kv_sharding_ignores_prefill_context_parallel():
|
||||
config = _make_layout_vllm_config(
|
||||
cpu_bytes_to_use=65536,
|
||||
extra_config={"block_size": 64},
|
||||
prefill_context_parallel_size=2,
|
||||
@pytest.mark.parametrize("world_size", [2, 4, 8])
|
||||
def test_tiering_spec_replicated_sizing_removes_world_factor(world_size: int):
|
||||
worker_kv_bytes_per_block = SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT
|
||||
spec = _create_spec(
|
||||
spec_name="TieringOffloadingSpec",
|
||||
cpu_bytes_to_use=worker_kv_bytes_per_block * 8,
|
||||
worker_kv_bytes_per_block=worker_kv_bytes_per_block,
|
||||
world_size=world_size,
|
||||
replicated_layout=True,
|
||||
)
|
||||
|
||||
spec = _create_spec(config, _make_kv_cache_config())
|
||||
|
||||
assert spec.tokens_per_block == (16,)
|
||||
assert spec.tokens_per_hash == 16
|
||||
assert spec.blocks_per_chunk == 4
|
||||
assert isinstance(spec, TieringOffloadingSpec)
|
||||
assert spec.replicated_layout is True
|
||||
assert spec.cpu_page_size_per_worker == worker_kv_bytes_per_block
|
||||
assert spec.kv_bytes_per_chunk == worker_kv_bytes_per_block
|
||||
assert spec.num_blocks == 8
|
||||
|
||||
|
||||
def test_offloading_config_preserves_data_parallel_index():
|
||||
config = _make_layout_vllm_config()
|
||||
config.parallel_config.data_parallel_index = 2
|
||||
def test_tiering_spec_create_worker_uses_single_slot_for_replicated_layout(monkeypatch):
|
||||
import vllm.v1.kv_offload.tiering.spec as tiering_spec_module
|
||||
|
||||
offloading_config = build_offloading_config(config, _make_kv_cache_config())
|
||||
worker_kv_bytes_per_block = SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT
|
||||
spec = _create_spec(
|
||||
spec_name="TieringOffloadingSpec",
|
||||
cpu_bytes_to_use=worker_kv_bytes_per_block * 8,
|
||||
worker_kv_bytes_per_block=worker_kv_bytes_per_block,
|
||||
world_size=4,
|
||||
replicated_layout=True,
|
||||
)
|
||||
assert isinstance(spec, TieringOffloadingSpec)
|
||||
|
||||
assert offloading_config.parallel.data_parallel_index == 2
|
||||
region = MagicMock()
|
||||
region_calls: list[dict[str, Any]] = []
|
||||
worker_calls: list[dict[str, Any]] = []
|
||||
|
||||
def fake_region_ctor(**kwargs):
|
||||
region_calls.append(kwargs)
|
||||
return region
|
||||
|
||||
def fake_worker_ctor(**kwargs):
|
||||
worker_calls.append(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr(tiering_spec_module, "SharedOffloadRegion", fake_region_ctor)
|
||||
monkeypatch.setattr(tiering_spec_module, "CPUOffloadingWorker", fake_worker_ctor)
|
||||
monkeypatch.setattr(
|
||||
tiering_spec_module.torch.accelerator, "current_device_index", lambda: 5
|
||||
)
|
||||
|
||||
kv_caches = MagicMock()
|
||||
spec.create_worker(kv_caches)
|
||||
|
||||
assert region_calls[0]["rank"] == 0
|
||||
assert region_calls[0]["kv_bytes_per_block"] == worker_kv_bytes_per_block
|
||||
assert worker_calls[0]["kv_caches"] is kv_caches
|
||||
assert worker_calls[0]["mmap_region"] is region
|
||||
|
||||
|
||||
def test_offloading_spec_resolves_heterogeneous_hybrid_block_sizes():
|
||||
config = _make_layout_vllm_config(cpu_bytes_to_use=65536)
|
||||
config.cache_config.block_size = 4
|
||||
def test_tiering_spec_create_worker_folds_device_index_for_sharded_layout(monkeypatch):
|
||||
import vllm.v1.kv_offload.tiering.spec as tiering_spec_module
|
||||
|
||||
spec = _create_spec(config, _make_hybrid_kv_cache_config())
|
||||
spec = _create_spec(
|
||||
spec_name="TieringOffloadingSpec",
|
||||
worker_kv_bytes_per_block=4096,
|
||||
world_size=4,
|
||||
)
|
||||
assert isinstance(spec, TieringOffloadingSpec)
|
||||
|
||||
region_calls: list[dict[str, Any]] = []
|
||||
|
||||
def fake_region_ctor(**kwargs):
|
||||
region_calls.append(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr(tiering_spec_module, "SharedOffloadRegion", fake_region_ctor)
|
||||
monkeypatch.setattr(tiering_spec_module, "CPUOffloadingWorker", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
tiering_spec_module.torch.accelerator,
|
||||
"current_device_index",
|
||||
lambda: 5,
|
||||
)
|
||||
|
||||
spec.create_worker(MagicMock())
|
||||
|
||||
assert region_calls[0]["rank"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("world_size", [2, 4, 8])
|
||||
def test_cpu_spec_replicated_config_preserves_per_rank_sizing(world_size: int):
|
||||
worker_kv_bytes_per_block = 4096
|
||||
spec = _create_spec(
|
||||
cpu_bytes_to_use=worker_kv_bytes_per_block * world_size * 2,
|
||||
worker_kv_bytes_per_block=worker_kv_bytes_per_block,
|
||||
world_size=world_size,
|
||||
replicated_layout=True,
|
||||
)
|
||||
|
||||
assert isinstance(spec, CPUOffloadingSpec)
|
||||
assert spec.replicated_layout is False
|
||||
assert spec.cpu_page_size_per_worker == worker_kv_bytes_per_block
|
||||
assert spec.kv_bytes_per_chunk == worker_kv_bytes_per_block * world_size
|
||||
assert spec.num_blocks == 2
|
||||
|
||||
|
||||
def test_offloading_spec_has_replicated_layout_default():
|
||||
spec = SingleArgExternalOffloadingSpec(_make_offloading_config())
|
||||
assert spec.replicated_layout is False
|
||||
|
||||
|
||||
def test_offloading_spec_uses_normalized_chunk_geometry():
|
||||
groups = (
|
||||
OffloadingGroupConfig(12, ("full_layer",)),
|
||||
OffloadingGroupConfig(16, ("mla_layer",)),
|
||||
)
|
||||
spec = _create_spec(
|
||||
groups=groups,
|
||||
tokens_per_hash=4,
|
||||
blocks_per_chunk=2,
|
||||
)
|
||||
|
||||
assert spec.tokens_per_block == (12, 16)
|
||||
assert spec.tokens_per_hash == 4
|
||||
assert spec.blocks_per_chunk == 1
|
||||
assert spec.blocks_per_chunk == 2
|
||||
|
||||
|
||||
def _full_attention_spec(block_size: int = 16) -> FullAttentionSpec:
|
||||
return FullAttentionSpec(
|
||||
block_size=block_size, num_kv_heads=4, head_size=128, dtype=torch.float32
|
||||
)
|
||||
|
||||
|
||||
def _parallelism_agnostic(kv_cache_groups: list[KVCacheGroupSpec]) -> bool:
|
||||
config = _make_layout_vllm_config()
|
||||
kv_cache_config = KVCacheConfig(
|
||||
num_blocks=0, kv_cache_tensors=[], kv_cache_groups=kv_cache_groups
|
||||
)
|
||||
offloading_config = build_offloading_config(config, kv_cache_config)
|
||||
return offloading_config.parallel.is_parallelism_agnostic
|
||||
|
||||
|
||||
def test_parallelism_agnostic_for_single_full_attention_group():
|
||||
assert _parallelism_agnostic([KVCacheGroupSpec(["l0"], _full_attention_spec())])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kv_cache_groups",
|
||||
[
|
||||
# MLA latent KV is replicated per rank, never head-sharded.
|
||||
[
|
||||
KVCacheGroupSpec(
|
||||
["l0"],
|
||||
MLAAttentionSpec(
|
||||
block_size=16, num_kv_heads=1, head_size=576, dtype=torch.float32
|
||||
),
|
||||
)
|
||||
],
|
||||
# Sliding window is not full attention.
|
||||
[
|
||||
KVCacheGroupSpec(
|
||||
["l0"],
|
||||
SlidingWindowSpec(
|
||||
block_size=16,
|
||||
num_kv_heads=4,
|
||||
head_size=128,
|
||||
dtype=torch.float32,
|
||||
sliding_window=128,
|
||||
),
|
||||
)
|
||||
],
|
||||
# Hybrid model: more than one KV cache group.
|
||||
[
|
||||
KVCacheGroupSpec(["l0"], _full_attention_spec()),
|
||||
KVCacheGroupSpec(["l1"], _full_attention_spec()),
|
||||
],
|
||||
],
|
||||
)
|
||||
def test_parallelism_agnostic_excluded(kv_cache_groups: list[KVCacheGroupSpec]):
|
||||
assert not _parallelism_agnostic(kv_cache_groups)
|
||||
|
||||
|
||||
def test_parallelism_agnostic_disabled_on_v2_model_runner():
|
||||
config = _make_layout_vllm_config()
|
||||
config.use_v2_model_runner = True
|
||||
kv_cache_config = KVCacheConfig(
|
||||
num_blocks=0,
|
||||
kv_cache_tensors=[],
|
||||
kv_cache_groups=[KVCacheGroupSpec(["l0"], _full_attention_spec())],
|
||||
)
|
||||
offloading_config = build_offloading_config(config, kv_cache_config)
|
||||
assert not offloading_config.parallel.is_parallelism_agnostic
|
||||
|
||||
|
||||
def test_create_dynamic_spec_receives_translated_config():
|
||||
config = _make_layout_vllm_config(
|
||||
def test_create_dynamic_spec_receives_config():
|
||||
config = _make_offloading_config(
|
||||
spec_name="SingleArgExternalOffloadingSpec",
|
||||
extra_config={
|
||||
"spec_module_path": "tests.v1.kv_offload.test_factory",
|
||||
},
|
||||
extra_config={"spec_module_path": "tests.v1.kv_offload.test_factory"},
|
||||
)
|
||||
kv_cache_config = _make_kv_cache_config()
|
||||
offloading_config = build_offloading_config(config, kv_cache_config)
|
||||
|
||||
spec = OffloadingSpecFactory.create_spec(offloading_config)
|
||||
spec = OffloadingSpecFactory.create_spec(config)
|
||||
|
||||
assert isinstance(spec, SingleArgExternalOffloadingSpec)
|
||||
assert spec.config is offloading_config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamic import via spec_module_path
|
||||
# ---------------------------------------------------------------------------
|
||||
assert spec.config is config
|
||||
|
||||
|
||||
def test_dynamic_load_via_spec_module_path():
|
||||
"""External spec loaded via spec_module_path.
|
||||
|
||||
This is how external projects (e.g., llm-d-kv-cache SharedStorageOffloadingSpec)
|
||||
integrate with vLLM without being pre-registered in the factory.
|
||||
The fallback path: registry miss → spec_module_path → importlib.import_module.
|
||||
"""
|
||||
config = _make_vllm_config(spec_name="CPUOffloadingSpec")
|
||||
# Delete from registry to force the dynamic import path
|
||||
del OffloadingSpecFactory._registry["CPUOffloadingSpec"]
|
||||
# spec_name not in registry → falls through to spec_module_path
|
||||
config.kv_transfer_config.kv_connector_extra_config["spec_module_path"] = (
|
||||
"vllm.v1.kv_offload.cpu.spec"
|
||||
config = _make_offloading_config(
|
||||
extra_config={"spec_module_path": "vllm.v1.kv_offload.cpu.spec"}
|
||||
)
|
||||
spec_cls = OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
|
||||
|
||||
spec_cls = OffloadingSpecFactory.get_spec_cls(config.extra_config)
|
||||
|
||||
assert spec_cls is CPUOffloadingSpec
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unregistered_spec_without_module_path_raises():
|
||||
"""spec_name not in registry + no spec_module_path → ValueError."""
|
||||
config = _make_vllm_config(spec_name="NonexistentSpec")
|
||||
config = _make_offloading_config(spec_name="NonexistentSpec")
|
||||
with pytest.raises(ValueError, match="Unsupported spec type"):
|
||||
OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
|
||||
OffloadingSpecFactory.get_spec_cls(config.extra_config)
|
||||
|
||||
# create_spec should also fail (calls get_spec_cls internally)
|
||||
kv_cache_config = _make_kv_cache_config()
|
||||
with pytest.raises(ValueError, match="Unsupported spec type"):
|
||||
_create_spec(config, kv_cache_config)
|
||||
OffloadingSpecFactory.create_spec(config)
|
||||
|
||||
|
||||
def test_cpu_spec_missing_cpu_bytes_to_use_raises():
|
||||
"""CPUOffloadingSpec requires cpu_bytes_to_use → Exception."""
|
||||
config = _make_vllm_config(cpu_bytes_to_use=None)
|
||||
config.kv_transfer_config.kv_connector_extra_config.pop("cpu_bytes_to_use", None)
|
||||
kv_cache_config = _make_kv_cache_config()
|
||||
with pytest.raises(Exception, match="cpu_bytes_to_use must be specified"):
|
||||
_create_spec(config, kv_cache_config)
|
||||
_create_spec(cpu_bytes_to_use=None)
|
||||
|
||||
|
||||
def test_duplicate_registration_raises():
|
||||
"""register_spec with existing name → ValueError."""
|
||||
with pytest.raises(ValueError, match="is already registered"):
|
||||
OffloadingSpecFactory.register_spec(
|
||||
"CPUOffloadingSpec", "some.module", "SomeClass"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Downstream collaboration — build_metric_definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_metric_definitions_below_threshold():
|
||||
"""store_threshold < 2 keeps stores_skipped disabled."""
|
||||
from vllm.v1.kv_offload.cpu.common import CPUOffloadingMetrics
|
||||
|
||||
config = _make_vllm_config(store_threshold=1)
|
||||
spec_cls = OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
|
||||
metrics = spec_cls.build_metric_definitions(
|
||||
config.kv_transfer_config.kv_connector_extra_config
|
||||
)
|
||||
extra_config = {"store_threshold": 1}
|
||||
spec_cls = OffloadingSpecFactory.get_spec_cls({"spec_name": "CPUOffloadingSpec"})
|
||||
metrics = spec_cls.build_metric_definitions(extra_config)
|
||||
|
||||
assert CPUOffloadingMetrics.STORES_SKIPPED not in metrics
|
||||
assert CPUOffloadingMetrics.CPU_ALLOCATION_SIZE in metrics
|
||||
|
||||
|
||||
def test_build_metric_definitions_allocation_size_histogram():
|
||||
"""CPU allocation size is always reported as a histogram."""
|
||||
from vllm.v1.kv_offload.cpu.common import CPUOffloadingMetrics
|
||||
|
||||
config = _make_vllm_config(store_threshold=0)
|
||||
spec_cls = OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
|
||||
metrics = spec_cls.build_metric_definitions(
|
||||
config.kv_transfer_config.kv_connector_extra_config
|
||||
)
|
||||
spec_cls = OffloadingSpecFactory.get_spec_cls({"spec_name": "CPUOffloadingSpec"})
|
||||
metrics = spec_cls.build_metric_definitions({})
|
||||
metadata = metrics[CPUOffloadingMetrics.CPU_ALLOCATION_SIZE]
|
||||
|
||||
assert isinstance(metadata, OffloadingHistogramMetadata)
|
||||
assert metadata.buckets == (
|
||||
1,
|
||||
@@ -603,49 +381,10 @@ def test_build_metric_definitions_allocation_size_histogram():
|
||||
|
||||
|
||||
def test_build_metric_definitions_returns_counter_at_threshold():
|
||||
"""store_threshold >= 2 → returns stores_skipped counter definition."""
|
||||
from vllm.v1.kv_offload.cpu.common import CPUOffloadingMetrics
|
||||
|
||||
config = _make_vllm_config(store_threshold=2)
|
||||
spec_cls = OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
|
||||
metrics = spec_cls.build_metric_definitions(
|
||||
config.kv_transfer_config.kv_connector_extra_config
|
||||
)
|
||||
extra_config = {"store_threshold": 2}
|
||||
spec_cls = OffloadingSpecFactory.get_spec_cls({"spec_name": "CPUOffloadingSpec"})
|
||||
metrics = spec_cls.build_metric_definitions(extra_config)
|
||||
|
||||
assert CPUOffloadingMetrics.STORES_SKIPPED in metrics
|
||||
|
||||
|
||||
def test_offloading_spec_accepts_blocks_per_chunk_for_heterogeneous_groups():
|
||||
config = _make_layout_vllm_config(
|
||||
cpu_bytes_to_use=65536,
|
||||
extra_config={"blocks_per_chunk": 2},
|
||||
)
|
||||
|
||||
spec = _create_spec(config, _make_hybrid_kv_cache_config())
|
||||
|
||||
assert spec.tokens_per_block == (12, 16)
|
||||
assert spec.blocks_per_chunk == 2
|
||||
|
||||
|
||||
def test_block_size_and_blocks_per_chunk_are_mutually_exclusive():
|
||||
config = _make_layout_vllm_config(
|
||||
cpu_bytes_to_use=65536,
|
||||
extra_config={
|
||||
"block_size": 64,
|
||||
"blocks_per_chunk": 2,
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Specify only one"):
|
||||
_create_spec(config, _make_kv_cache_config())
|
||||
|
||||
|
||||
def test_blocks_per_chunk_must_be_positive():
|
||||
config = _make_layout_vllm_config(
|
||||
cpu_bytes_to_use=65536,
|
||||
extra_config={
|
||||
"blocks_per_chunk": 0,
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="greater than 0"):
|
||||
_create_spec(config, _make_kv_cache_config())
|
||||
|
||||
@@ -102,19 +102,39 @@ def build_offloading_config(
|
||||
)
|
||||
worker_kv_bytes_per_block = total_gpu_kv_bytes // kv_cache_config.num_blocks
|
||||
|
||||
# Only a single non-MLA full-attention group is parallelism-invariant:
|
||||
# MLA latent KV is replicated per rank (never head-sharded), and the V2
|
||||
# model runner's KV layout is not known to be parallelism-invariant.
|
||||
single_group = (
|
||||
single_group_spec = (
|
||||
kv_cache_config.kv_cache_groups[0].kv_cache_spec
|
||||
if len(kv_cache_config.kv_cache_groups) == 1
|
||||
else None
|
||||
)
|
||||
replicated_layout = (
|
||||
vllm_config.model_config.use_mla
|
||||
# Exact type: fail closed on wrappers and sliding-window variants.
|
||||
and type(single_group_spec) is MLAAttentionSpec
|
||||
# Page accounting: one MLA page per layer, no packed/mixed rows.
|
||||
and worker_kv_bytes_per_block > 0
|
||||
and worker_kv_bytes_per_block
|
||||
== single_group_spec.page_size_bytes
|
||||
* len(kv_cache_config.kv_cache_groups[0].layer_names)
|
||||
# Safe MVP boundary: TP-only, no other parallel axes.
|
||||
and parallel_config.tensor_parallel_size > 1
|
||||
and parallel_config.pipeline_parallel_size == 1
|
||||
and parallel_config.prefill_context_parallel_size == 1
|
||||
and parallel_config.decode_context_parallel_size == 1
|
||||
and parallel_config.world_size == parallel_config.tensor_parallel_size
|
||||
# Shared /dev/shm mmap layout is single-node mp only.
|
||||
and parallel_config.distributed_executor_backend == "mp"
|
||||
and parallel_config.nnodes_within_dp == 1
|
||||
)
|
||||
|
||||
# Only a single non-MLA full-attention group is parallelism-invariant:
|
||||
# MLA latent KV is replicated per rank (never head-sharded), and the V2
|
||||
# model runner's KV layout is not known to be parallelism-invariant.
|
||||
is_parallelism_agnostic = (
|
||||
not vllm_config.use_v2_model_runner
|
||||
and single_group is not None
|
||||
and isinstance(single_group, FullAttentionSpec)
|
||||
and not isinstance(single_group, MLAAttentionSpec)
|
||||
and single_group_spec is not None
|
||||
and isinstance(single_group_spec, FullAttentionSpec)
|
||||
and not isinstance(single_group_spec, MLAAttentionSpec)
|
||||
)
|
||||
|
||||
kv_events_config = vllm_config.kv_events_config
|
||||
@@ -144,4 +164,5 @@ def build_offloading_config(
|
||||
data_parallel_index=parallel_config.data_parallel_index,
|
||||
is_parallelism_agnostic=is_parallelism_agnostic,
|
||||
),
|
||||
replicated_layout=replicated_layout,
|
||||
)
|
||||
|
||||
@@ -45,6 +45,10 @@ class OffloadingConnectorWorker:
|
||||
self.spec = spec
|
||||
self.kv_cache_config = kv_cache_config
|
||||
self.worker: OffloadingWorker | None = None
|
||||
# Non-writers still ack: pending_count waits for world_size per job.
|
||||
self._is_store_writer = (
|
||||
not self.spec.replicated_layout or self.spec.config.parallel.rank == 0
|
||||
)
|
||||
|
||||
# job_id -> req_id for in-flight loads.
|
||||
self._load_jobs: dict[int, ReqId] = {}
|
||||
@@ -274,6 +278,9 @@ class OffloadingConnectorWorker:
|
||||
for job_id in kv_connector_metadata.jobs_to_flush:
|
||||
entry = kv_connector_metadata.store_jobs.pop(job_id, None)
|
||||
if entry is not None:
|
||||
if not self._is_store_writer:
|
||||
self._connector_worker_meta.mark_completed(job_id)
|
||||
continue
|
||||
assert isinstance(entry.src_spec, GPULoadStoreSpec)
|
||||
self._unsubmitted_store_jobs.append(
|
||||
(job_id, entry.src_spec, entry.dst_spec)
|
||||
@@ -304,6 +311,10 @@ class OffloadingConnectorWorker:
|
||||
|
||||
def prepare_store_kv(self, metadata: OffloadingConnectorMetadata):
|
||||
for job_id, entry in metadata.store_jobs.items():
|
||||
if not self._is_store_writer:
|
||||
# Gate before queueing: no _unsubmitted_store_jobs entry.
|
||||
self._connector_worker_meta.mark_completed(job_id)
|
||||
continue
|
||||
# NOTE(orozery): defer the store to the beginning of the next
|
||||
# engine step, so that offloading starts AFTER transfers related
|
||||
# to token sampling, thereby avoiding delays to token generation.
|
||||
|
||||
@@ -496,6 +496,7 @@ class OffloadingSpec(ABC):
|
||||
)
|
||||
self.config = config
|
||||
self.extra_config = config.extra_config
|
||||
self.replicated_layout: bool = False
|
||||
self.kv_events_config = OffloadingKVEventsConfig(
|
||||
enable_kv_cache_events=config.enable_kv_cache_events,
|
||||
self_describing_kv_events=bool(
|
||||
|
||||
@@ -68,3 +68,9 @@ class OffloadingConfig:
|
||||
model: OffloadingModelConfig
|
||||
cache: OffloadingCacheConfig
|
||||
parallel: OffloadingParallelConfig
|
||||
# True when the offloaded bytes of every worker are expected to be
|
||||
# byte-identical per block (pure-MLA model, single-node TP-only
|
||||
# parallelism), enabling a single-copy host layout in backends that
|
||||
# support it. Aggregate layout decision; per-layer replication metadata
|
||||
# is planned for CanonicalKVCacheRef (#48408).
|
||||
replicated_layout: bool = False
|
||||
|
||||
@@ -24,6 +24,7 @@ from vllm.v1.kv_offload.cpu.manager import CPUOffloadingManager
|
||||
|
||||
class CPUOffloadingSpec(OffloadingSpec):
|
||||
BLOCK_SIZE_ALIGNMENT = 1
|
||||
SUPPORTS_REPLICATED_LAYOUT = False
|
||||
|
||||
@classmethod
|
||||
def build_metric_definitions(
|
||||
@@ -85,12 +86,16 @@ class CPUOffloadingSpec(OffloadingSpec):
|
||||
self.num_blocks = 0
|
||||
self.kv_bytes_per_chunk = 0
|
||||
self.cpu_page_size_per_worker = 0
|
||||
self.replicated_layout = (
|
||||
config.replicated_layout and self.SUPPORTS_REPLICATED_LAYOUT
|
||||
)
|
||||
if config.worker_kv_bytes_per_block > 0 and world_size > 0:
|
||||
kv_bytes_per_block = config.worker_kv_bytes_per_block * world_size
|
||||
num_copies = 1 if self.replicated_layout else world_size
|
||||
kv_bytes_per_block = config.worker_kv_bytes_per_block * num_copies
|
||||
kv_bytes_per_chunk = kv_bytes_per_block * self.blocks_per_chunk
|
||||
|
||||
# calculate cpu_page_size_per_worker
|
||||
self.cpu_page_size_per_worker = kv_bytes_per_chunk // world_size
|
||||
self.cpu_page_size_per_worker = kv_bytes_per_chunk // num_copies
|
||||
|
||||
# calculate num_blocks
|
||||
aligned_kv_bytes_per_chunk = round_up(
|
||||
@@ -102,6 +107,7 @@ class CPUOffloadingSpec(OffloadingSpec):
|
||||
# kv_bytes_per_chunk. Note that this might contain
|
||||
# some padding. i.e. each offloaded block is of the form,
|
||||
# |--- W0-B0---|---- W1-B0---| ... |---- Wn-B0---| *** maybe-pad *** |
|
||||
# or |--- B0 (single copy) ---| *** maybe-pad *** |
|
||||
self.kv_bytes_per_chunk = aligned_kv_bytes_per_chunk
|
||||
|
||||
# scheduler-side
|
||||
|
||||
@@ -71,6 +71,7 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
|
||||
"""
|
||||
|
||||
BLOCK_SIZE_ALIGNMENT = SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT
|
||||
SUPPORTS_REPLICATED_LAYOUT = True
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
@@ -229,10 +230,13 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
|
||||
|
||||
@override
|
||||
def create_worker(self, kv_caches: CanonicalKVCaches) -> CPUOffloadingWorker:
|
||||
# Fold the global physical device index into the replica-local
|
||||
# [0, world_size) slot range.
|
||||
world_size = self.config.parallel.world_size
|
||||
rank = torch.accelerator.current_device_index() % world_size
|
||||
if self.replicated_layout:
|
||||
rank = 0
|
||||
else:
|
||||
# Fold the global physical device index into the replica-local
|
||||
# [0, world_size) slot range.
|
||||
rank = torch.accelerator.current_device_index() % world_size
|
||||
worker_mmap = SharedOffloadRegion(
|
||||
engine_id=self._engine_id,
|
||||
num_blocks=self.num_blocks,
|
||||
|
||||
Reference in New Issue
Block a user