[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>
Chang Guo committed
Jul 25, 2026 at 22:22 UTC
7eca0e1a649e9fb85e88c101432d8bfdbb5b9bfe
14 files changed
+1135
-498
.buildkite/test_areas/lm_eval.yaml
+2
-2
@@ -337,7 +337,7 @@ steps:
337
338
- label: LM Eval KV-Offload (2xH100)
339
key: kv-offload-medium
340
- timeout_in_minutes: 30
340
+ timeout_in_minutes: 45
341
device: h100
342
num_devices: 2
343
source_file_dependencies:
@@ -347,7 +347,7 @@ steps:
347
- vllm/v1/simple_kv_offload/
348
- tests/evals/gsm8k/test_gsm8k_offloading.py
349
commands:
350
- - pytest -s -v evals/gsm8k/test_gsm8k_offloading.py -k "qwen3.5-35b"
350
+ - pytest -s -v evals/gsm8k/test_gsm8k_offloading.py -k "qwen3.5-35b or deepseek-v2-lite"
351
352
- label: LM Eval KV-Offload (4xH100)
353
key: kv-offload-large
tests/evals/gsm8k/test_gsm8k_offloading.py
+25
-5
@@ -16,11 +16,13 @@ KV drops accuracy below threshold. The reload itself is not asserted, so
16
a silently skipped reload (e.g. offloading disabled) would not be flagged.
17
18
Covers both KV offloading connectors (OffloadingConnector and
19
-SimpleCPUOffloadConnector) across four architecture families:
19
+SimpleCPUOffloadConnector) across five architecture families:
20
- Hybrid Mamba (NemotronH: attention + Mamba)
21
- Heterogeneous head dim (Gemma 4)
22
- Hybrid GDN (Qwen 3.5: attention + GatedDeltaNet)
23
- Compressed attention (DeepSeek-V4-Flash: CSA)
24
+ - Pure MLA (DeepSeek-V2-Lite: TieringOffloadingSpec, TP=2, replicated
25
+ single-slot host layout)
26
27
Usage:
28
pytest -s -v evals/gsm8k/test_gsm8k_offloading.py
@@ -47,14 +49,16 @@ NUM_FEWSHOT = 5
49
_OFFLOAD_SYNC_TIMEOUT = 60
50
51
50
-def _kv_transfer_config(connector: str, cpu_gib: int = 4) -> str:
52
+def _kv_transfer_config(
53
+ connector: str, cpu_gib: int = 4, spec_name: str = "CPUOffloadingSpec"
54
+) -> str:
55
if connector == "OffloadingConnector":
56
return json.dumps(
57
{
58
"kv_connector": "OffloadingConnector",
59
"kv_role": "kv_both",
60
"kv_connector_extra_config": {
57
- "spec_name": "CPUOffloadingSpec",
61
+ "spec_name": spec_name,
62
"cpu_bytes_to_use": cpu_gib << 30,
63
"eviction_policy": "lru",
64
},
@@ -115,8 +119,10 @@ class OffloadingModelConfig:
119
accuracy_threshold: float
120
tolerance: float = 0.05
121
extra_server_args: list[str] = field(default_factory=list)
122
+ env_dict: dict[str, str] = field(default_factory=dict)
123
cpu_offload_gib: int = 4
124
startup_timeout: int = 600
125
+ spec_name: str = "CPUOffloadingSpec"
126
127
128
MODELS = [
@@ -165,6 +171,20 @@ MODELS = [
171
cpu_offload_gib=16,
172
startup_timeout=1200,
173
),
174
+ OffloadingModelConfig(
175
+ id="offloading-deepseek-v2-lite-tiering-tp2",
176
+ model="deepseek-ai/DeepSeek-V2-Lite",
177
+ connector="OffloadingConnector",
178
+ # Baseline 0.360/0.345 over two runs (measured on 2xA100).
179
+ accuracy_threshold=0.35,
180
+ extra_server_args=[
181
+ "--tensor-parallel-size",
182
+ "2",
183
+ ],
184
+ cpu_offload_gib=8,
185
+ startup_timeout=1200,
186
+ spec_name="TieringOffloadingSpec",
187
+ ),
188
# ── SimpleCPUOffloadConnector ────────────────────────────────────
189
OffloadingModelConfig(
190
id="simple-nemotron-h-8b",
@@ -229,7 +249,7 @@ def test_gsm8k_offloading_correctness(cfg: OffloadingModelConfig):
249
"--enable-prefix-caching",
250
"--no-disable-hybrid-kv-cache-manager",
251
"--kv-transfer-config",
232
- _kv_transfer_config(cfg.connector, cfg.cpu_offload_gib),
252
+ _kv_transfer_config(cfg.connector, cfg.cpu_offload_gib, cfg.spec_name),
253
"--trust-remote-code",
254
"--disable-uvicorn-access-log",
255
*cfg.extra_server_args,
@@ -239,7 +259,7 @@ def test_gsm8k_offloading_correctness(cfg: OffloadingModelConfig):
259
cfg.model,
260
server_args,
261
# /reset_prefix_cache requires dev mode.
242
- env_dict={"VLLM_SERVER_DEV_MODE": "1"},
262
+ env_dict={"VLLM_SERVER_DEV_MODE": "1", **cfg.env_dict},
263
max_wait_seconds=cfg.startup_timeout,
264
) as server:
265
base_url = f"http://{server.host}:{server.port}"
tests/v1/kv_connector/unit/offloading_connector/test_config.py
new
+587
@@ -0,0 +1,587 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Tests for translating vLLM cache metadata to native offloading config."""
4
+
5
+from typing import Any, cast
6
+from unittest.mock import MagicMock, patch
7
+
8
+import pytest
9
+import torch
10
+
11
+from vllm.config import KVTransferConfig, ParallelConfig, VllmConfig
12
+from vllm.distributed.kv_transfer.kv_connector.v1.offloading.config import (
13
+ build_offloading_config,
14
+)
15
+from vllm.platforms import current_platform
16
+from vllm.v1.kv_cache_interface import (
17
+ FullAttentionSpec,
18
+ HiddenStateCacheSpec,
19
+ KVCacheConfig,
20
+ KVCacheGroupSpec,
21
+ KVCacheTensor,
22
+ MambaSpec,
23
+ MLAAttentionSpec,
24
+ SlidingWindowMLASpec,
25
+ SlidingWindowSpec,
26
+ UniformTypeKVCacheSpecs,
27
+)
28
+
29
+
30
+def _make_vllm_config(
31
+ *,
32
+ extra_config: dict[str, Any] | None = None,
33
+ tensor_parallel_size: int = 1,
34
+ pipeline_parallel_size: int = 1,
35
+ prefill_context_parallel_size: int = 1,
36
+ decode_context_parallel_size: int = 1,
37
+) -> VllmConfig:
38
+ config = MagicMock()
39
+ config.cache_config.block_size = 16
40
+ config.cache_config.enable_prefix_caching = True
41
+ config.cache_config.prefix_match_unit = None
42
+ config.cache_config.cache_dtype = torch.float16
43
+ config.model_config.model = "test-model"
44
+ config.model_config.use_mla = False
45
+ world_size = (
46
+ tensor_parallel_size * pipeline_parallel_size * prefill_context_parallel_size
47
+ )
48
+ with patch.object(current_platform, "device_count", return_value=world_size):
49
+ config.parallel_config = ParallelConfig(
50
+ tensor_parallel_size=tensor_parallel_size,
51
+ pipeline_parallel_size=pipeline_parallel_size,
52
+ prefill_context_parallel_size=prefill_context_parallel_size,
53
+ decode_context_parallel_size=decode_context_parallel_size,
54
+ )
55
+ config.kv_events_config = None
56
+ config.use_v2_model_runner = False
57
+ config.kv_transfer_config = KVTransferConfig(
58
+ kv_connector="OffloadingConnector",
59
+ kv_role="kv_both",
60
+ kv_connector_extra_config=dict(extra_config or {}),
61
+ )
62
+ return cast(VllmConfig, config)
63
+
64
+
65
+def _make_kv_cache_config() -> KVCacheConfig:
66
+ num_blocks = 16
67
+ kv_tensor = KVCacheTensor(
68
+ size=num_blocks * 8,
69
+ shared_by=["layer"],
70
+ block_stride=0,
71
+ )
72
+ return KVCacheConfig(
73
+ num_blocks=num_blocks,
74
+ kv_cache_tensors=[kv_tensor],
75
+ kv_cache_groups=[
76
+ KVCacheGroupSpec(
77
+ ["layer"],
78
+ FullAttentionSpec(
79
+ block_size=16,
80
+ num_kv_heads=1,
81
+ head_size=1,
82
+ dtype=torch.float32,
83
+ ),
84
+ )
85
+ ],
86
+ )
87
+
88
+
89
+def _make_sizing_kv_cache_config(packed: bool) -> KVCacheConfig:
90
+ num_blocks = 4
91
+ if packed:
92
+ kv_cache_tensors = [
93
+ KVCacheTensor(
94
+ size=64,
95
+ shared_by=[layer_name],
96
+ block_stride=16,
97
+ )
98
+ for layer_name in ("layer0", "layer1")
99
+ ]
100
+ else:
101
+ kv_cache_tensors = [
102
+ KVCacheTensor(size=40, shared_by=["layer0"]),
103
+ KVCacheTensor(size=24, shared_by=["layer1"]),
104
+ ]
105
+
106
+ return KVCacheConfig(
107
+ num_blocks=num_blocks,
108
+ kv_cache_tensors=kv_cache_tensors,
109
+ kv_cache_groups=[
110
+ KVCacheGroupSpec(
111
+ ["layer0", "layer1"],
112
+ FullAttentionSpec(
113
+ block_size=16,
114
+ num_kv_heads=1,
115
+ head_size=1,
116
+ dtype=torch.float32,
117
+ ),
118
+ )
119
+ ],
120
+ )
121
+
122
+
123
+def _full_attention_spec(block_size: int = 16) -> FullAttentionSpec:
124
+ return FullAttentionSpec(
125
+ block_size=block_size,
126
+ num_kv_heads=4,
127
+ head_size=128,
128
+ dtype=torch.float32,
129
+ )
130
+
131
+
132
+def _mla_spec(
133
+ block_size: int = 16,
134
+ head_size: int = 512,
135
+ dtype: torch.dtype = torch.float32,
136
+) -> MLAAttentionSpec:
137
+ return MLAAttentionSpec(
138
+ block_size=block_size,
139
+ num_kv_heads=1,
140
+ head_size=head_size,
141
+ dtype=dtype,
142
+ )
143
+
144
+
145
+def _make_mla_kv_cache_config(
146
+ layer_names: list[str] | None = None,
147
+ head_size: int = 512,
148
+ dtype: torch.dtype = torch.float32,
149
+ num_blocks: int = 4,
150
+) -> KVCacheConfig:
151
+ if layer_names is None:
152
+ layer_names = ["layer0", "layer1"]
153
+ spec = _mla_spec(head_size=head_size, dtype=dtype)
154
+ kv_cache_tensors = [
155
+ KVCacheTensor(
156
+ size=spec.page_size_bytes * num_blocks,
157
+ shared_by=[layer_name],
158
+ )
159
+ for layer_name in layer_names
160
+ ]
161
+ return KVCacheConfig(
162
+ num_blocks=num_blocks,
163
+ kv_cache_tensors=kv_cache_tensors,
164
+ kv_cache_groups=[KVCacheGroupSpec(layer_names, spec)],
165
+ )
166
+
167
+
168
+def _make_hybrid_kv_cache_config() -> KVCacheConfig:
169
+ return KVCacheConfig(
170
+ num_blocks=4,
171
+ kv_cache_tensors=[
172
+ KVCacheTensor(size=40, shared_by=["full_layer"]),
173
+ KVCacheTensor(size=24, shared_by=["mla_layer"]),
174
+ ],
175
+ kv_cache_groups=[
176
+ KVCacheGroupSpec(["full_layer"], _full_attention_spec(block_size=12)),
177
+ KVCacheGroupSpec(["mla_layer"], _mla_spec()),
178
+ ],
179
+ )
180
+
181
+
182
+def _parallelism_agnostic(kv_cache_groups: list[KVCacheGroupSpec]) -> bool:
183
+ config = _make_vllm_config()
184
+ kv_cache_config = KVCacheConfig(
185
+ num_blocks=0,
186
+ kv_cache_tensors=[],
187
+ kv_cache_groups=kv_cache_groups,
188
+ )
189
+ return build_offloading_config(
190
+ config, kv_cache_config
191
+ ).parallel.is_parallelism_agnostic
192
+
193
+
194
+def _replicated_layout(
195
+ kv_cache_config: KVCacheConfig,
196
+ *,
197
+ tensor_parallel_size: int = 4,
198
+ pipeline_parallel_size: int = 1,
199
+ prefill_context_parallel_size: int = 1,
200
+ decode_context_parallel_size: int = 1,
201
+ use_mla: bool = True,
202
+ use_v2_model_runner: bool = False,
203
+ distributed_executor_backend: Any = "mp",
204
+ nnodes: int = 1,
205
+ world_size: int | None = None,
206
+) -> bool:
207
+ config = _make_vllm_config(
208
+ tensor_parallel_size=tensor_parallel_size,
209
+ pipeline_parallel_size=pipeline_parallel_size,
210
+ prefill_context_parallel_size=prefill_context_parallel_size,
211
+ decode_context_parallel_size=decode_context_parallel_size,
212
+ )
213
+ config.model_config.use_mla = use_mla
214
+ config.use_v2_model_runner = use_v2_model_runner
215
+ config.parallel_config.distributed_executor_backend = distributed_executor_backend
216
+ config.parallel_config.nnodes = nnodes
217
+ if world_size is not None:
218
+ config.parallel_config.world_size = world_size
219
+ return build_offloading_config(config, kv_cache_config).replicated_layout
220
+
221
+
222
+@pytest.mark.parametrize("packed", [False, True])
223
+def test_worker_kv_bytes_preserves_tensor_layout(packed: bool):
224
+ config = _make_vllm_config(
225
+ extra_config={"block_size": 32},
226
+ tensor_parallel_size=3,
227
+ pipeline_parallel_size=2,
228
+ )
229
+
230
+ offloading_config = build_offloading_config(
231
+ config, _make_sizing_kv_cache_config(packed)
232
+ )
233
+
234
+ assert offloading_config.worker_kv_bytes_per_block == 16
235
+ assert offloading_config.parallel.world_size == 6
236
+ assert offloading_config.cache.blocks_per_chunk == 2
237
+
238
+
239
+def test_rejects_partially_packed_tensor_layout():
240
+ kv_cache_config = _make_sizing_kv_cache_config(packed=False)
241
+ kv_cache_config.kv_cache_tensors[0].block_stride = 16
242
+
243
+ with pytest.raises(AssertionError):
244
+ build_offloading_config(_make_vllm_config(), kv_cache_config)
245
+
246
+
247
+def test_zero_blocks_skips_tensor_layout_validation():
248
+ kv_cache_config = _make_sizing_kv_cache_config(packed=False)
249
+ kv_cache_config.num_blocks = 0
250
+ kv_cache_config.kv_cache_tensors[0].block_stride = 16
251
+
252
+ offloading_config = build_offloading_config(_make_vllm_config(), kv_cache_config)
253
+
254
+ assert offloading_config.worker_kv_bytes_per_block == 0
255
+
256
+
257
+def test_prefill_context_parallelism_does_not_scale_group_blocks():
258
+ config = _make_vllm_config(
259
+ extra_config={"block_size": 64},
260
+ prefill_context_parallel_size=2,
261
+ )
262
+
263
+ offloading_config = build_offloading_config(config, _make_kv_cache_config())
264
+
265
+ assert tuple(group.tokens_per_block for group in offloading_config.groups) == (16,)
266
+ assert offloading_config.cache.tokens_per_hash == 16
267
+ assert offloading_config.cache.blocks_per_chunk == 4
268
+
269
+
270
+def test_preserves_data_parallel_index():
271
+ config = _make_vllm_config()
272
+ config.parallel_config.data_parallel_index = 2
273
+
274
+ offloading_config = build_offloading_config(config, _make_kv_cache_config())
275
+
276
+ assert offloading_config.parallel.data_parallel_index == 2
277
+
278
+
279
+def test_resolves_heterogeneous_hybrid_block_sizes():
280
+ config = _make_vllm_config()
281
+ config.cache_config.block_size = 4
282
+
283
+ offloading_config = build_offloading_config(config, _make_hybrid_kv_cache_config())
284
+
285
+ assert tuple(group.tokens_per_block for group in offloading_config.groups) == (
286
+ 12,
287
+ 16,
288
+ )
289
+ assert offloading_config.cache.tokens_per_hash == 4
290
+ assert offloading_config.cache.blocks_per_chunk == 1
291
+
292
+
293
+@pytest.mark.parametrize("world_size", [2, 4, 8])
294
+@pytest.mark.parametrize("use_v2_model_runner", [False, True], ids=["v1", "v2"])
295
+def test_replicated_layout_enabled_for_pure_mla_tp_mp_single_node(
296
+ world_size: int,
297
+ use_v2_model_runner: bool,
298
+):
299
+ assert _replicated_layout(
300
+ _make_mla_kv_cache_config(),
301
+ tensor_parallel_size=world_size,
302
+ use_v2_model_runner=use_v2_model_runner,
303
+ )
304
+
305
+
306
+@pytest.mark.parametrize(
307
+ ("kv_cache_config", "case"),
308
+ [
309
+ (
310
+ KVCacheConfig(
311
+ num_blocks=4,
312
+ kv_cache_tensors=[
313
+ KVCacheTensor(
314
+ size=_mla_spec().page_size_bytes * 4,
315
+ shared_by=["layer"],
316
+ )
317
+ ],
318
+ kv_cache_groups=[
319
+ KVCacheGroupSpec(
320
+ ["layer"],
321
+ SlidingWindowMLASpec(
322
+ block_size=16,
323
+ num_kv_heads=1,
324
+ head_size=512,
325
+ dtype=torch.float32,
326
+ sliding_window=128,
327
+ ),
328
+ )
329
+ ],
330
+ ),
331
+ "sliding-window-mla",
332
+ ),
333
+ (
334
+ KVCacheConfig(
335
+ num_blocks=4,
336
+ kv_cache_tensors=[
337
+ KVCacheTensor(
338
+ size=_mla_spec().page_size_bytes * 4,
339
+ shared_by=["layer"],
340
+ )
341
+ ],
342
+ kv_cache_groups=[
343
+ KVCacheGroupSpec(
344
+ ["layer"],
345
+ HiddenStateCacheSpec(
346
+ block_size=16,
347
+ num_kv_heads=1,
348
+ head_size=512,
349
+ dtype=torch.float32,
350
+ ),
351
+ )
352
+ ],
353
+ ),
354
+ "hidden-state",
355
+ ),
356
+ (
357
+ KVCacheConfig(
358
+ num_blocks=4,
359
+ kv_cache_tensors=[
360
+ KVCacheTensor(
361
+ size=_mla_spec().page_size_bytes * 4,
362
+ shared_by=["layer0"],
363
+ ),
364
+ KVCacheTensor(
365
+ size=_mla_spec(head_size=256).page_size_bytes * 4,
366
+ shared_by=["layer1"],
367
+ ),
368
+ ],
369
+ kv_cache_groups=[
370
+ KVCacheGroupSpec(
371
+ ["layer0", "layer1"],
372
+ UniformTypeKVCacheSpecs(
373
+ block_size=16,
374
+ kv_cache_specs={
375
+ "layer0": _mla_spec(),
376
+ "layer1": _mla_spec(head_size=256),
377
+ },
378
+ ),
379
+ )
380
+ ],
381
+ ),
382
+ "uniform-wrapper",
383
+ ),
384
+ (
385
+ KVCacheConfig(
386
+ num_blocks=4,
387
+ kv_cache_tensors=[
388
+ KVCacheTensor(
389
+ size=_mla_spec().page_size_bytes * 4,
390
+ shared_by=["mla"],
391
+ ),
392
+ KVCacheTensor(
393
+ size=_full_attention_spec().page_size_bytes * 4,
394
+ shared_by=["full"],
395
+ ),
396
+ ],
397
+ kv_cache_groups=[
398
+ KVCacheGroupSpec(["mla"], _mla_spec()),
399
+ KVCacheGroupSpec(["full"], _full_attention_spec()),
400
+ ],
401
+ ),
402
+ "mla-full-hybrid",
403
+ ),
404
+ (
405
+ KVCacheConfig(
406
+ num_blocks=4,
407
+ kv_cache_tensors=[
408
+ KVCacheTensor(
409
+ size=_mla_spec().page_size_bytes * 4,
410
+ shared_by=["mla"],
411
+ ),
412
+ KVCacheTensor(size=64 * 4, shared_by=["mamba"]),
413
+ ],
414
+ kv_cache_groups=[
415
+ KVCacheGroupSpec(["mla"], _mla_spec()),
416
+ KVCacheGroupSpec(
417
+ ["mamba"],
418
+ MambaSpec(
419
+ block_size=16,
420
+ shapes=((16, 1),),
421
+ dtypes=(torch.float32,),
422
+ ),
423
+ ),
424
+ ],
425
+ ),
426
+ "mla-mamba-hybrid",
427
+ ),
428
+ (
429
+ KVCacheConfig(
430
+ num_blocks=4,
431
+ kv_cache_tensors=[
432
+ KVCacheTensor(
433
+ size=_mla_spec().page_size_bytes * 4,
434
+ shared_by=["layer0"],
435
+ ),
436
+ KVCacheTensor(
437
+ size=_mla_spec().page_size_bytes * 4,
438
+ shared_by=["layer1"],
439
+ ),
440
+ ],
441
+ kv_cache_groups=[
442
+ KVCacheGroupSpec(["layer0"], _mla_spec()),
443
+ KVCacheGroupSpec(["layer1"], _mla_spec()),
444
+ ],
445
+ ),
446
+ "multi-group-mla",
447
+ ),
448
+ ],
449
+ ids=[
450
+ "sliding-window-mla",
451
+ "hidden-state",
452
+ "uniform-wrapper",
453
+ "mla-full-hybrid",
454
+ "mla-mamba-hybrid",
455
+ "multi-group-mla",
456
+ ],
457
+)
458
+def test_replicated_layout_excludes_unproven_cache_shapes(
459
+ kv_cache_config: KVCacheConfig,
460
+ case: str,
461
+):
462
+ assert not _replicated_layout(kv_cache_config), case
463
+
464
+
465
+def test_replicated_layout_rejects_bare_mla_with_mixed_page_accounting():
466
+ num_blocks = 4
467
+ main_spec = _mla_spec(head_size=512)
468
+ indexer_spec = _mla_spec(head_size=128, dtype=torch.uint8)
469
+ main_layers = [f"main_{i}" for i in range(61)]
470
+ indexer_layers = [f"indexer_{i}" for i in range(61)]
471
+ kv_cache_config = KVCacheConfig(
472
+ num_blocks=num_blocks,
473
+ kv_cache_tensors=[
474
+ KVCacheTensor(
475
+ size=main_spec.page_size_bytes * len(main_layers) * num_blocks,
476
+ shared_by=main_layers,
477
+ ),
478
+ KVCacheTensor(
479
+ size=indexer_spec.page_size_bytes * len(indexer_layers) * num_blocks,
480
+ shared_by=indexer_layers,
481
+ ),
482
+ ],
483
+ kv_cache_groups=[KVCacheGroupSpec(main_layers + indexer_layers, main_spec)],
484
+ )
485
+
486
+ assert not _replicated_layout(kv_cache_config)
487
+
488
+
489
+@pytest.mark.parametrize(
490
+ ("kwargs", "case"),
491
+ [
492
+ ({"tensor_parallel_size": 1}, "tp1"),
493
+ ({"use_mla": False}, "use-mla-false"),
494
+ ({"pipeline_parallel_size": 2, "world_size": 4}, "pp2"),
495
+ ({"prefill_context_parallel_size": 2, "world_size": 4}, "pcp2"),
496
+ ({"decode_context_parallel_size": 2}, "dcp2"),
497
+ ({"world_size": 8}, "world-ne-tp"),
498
+ ({"distributed_executor_backend": "ray"}, "ray"),
499
+ ({"distributed_executor_backend": "uni"}, "uni"),
500
+ ({"distributed_executor_backend": type("DummyExecutor", (), {})}, "class"),
501
+ ({"nnodes": 2}, "multi-node"),
502
+ ],
503
+ ids=[
504
+ "tp1",
505
+ "use-mla-false",
506
+ "pp2",
507
+ "pcp2",
508
+ "dcp2",
509
+ "world-ne-tp",
510
+ "ray",
511
+ "uni",
512
+ "class",
513
+ "multi-node",
514
+ ],
515
+)
516
+def test_replicated_layout_parallel_gate(kwargs: dict[str, Any], case: str):
517
+ assert not _replicated_layout(_make_mla_kv_cache_config(), **kwargs), case
518
+
519
+
520
+def test_parallelism_agnostic_for_single_full_attention_group():
521
+ assert _parallelism_agnostic([KVCacheGroupSpec(["l0"], _full_attention_spec())])
522
+
523
+
524
+@pytest.mark.parametrize(
525
+ "kv_cache_groups",
526
+ [
527
+ [KVCacheGroupSpec(["l0"], _mla_spec(head_size=576))],
528
+ [
529
+ KVCacheGroupSpec(
530
+ ["l0"],
531
+ SlidingWindowSpec(
532
+ block_size=16,
533
+ num_kv_heads=4,
534
+ head_size=128,
535
+ dtype=torch.float32,
536
+ sliding_window=128,
537
+ ),
538
+ )
539
+ ],
540
+ [
541
+ KVCacheGroupSpec(["l0"], _full_attention_spec()),
542
+ KVCacheGroupSpec(["l1"], _full_attention_spec()),
543
+ ],
544
+ ],
545
+)
546
+def test_parallelism_agnostic_excluded(kv_cache_groups: list[KVCacheGroupSpec]):
547
+ assert not _parallelism_agnostic(kv_cache_groups)
548
+
549
+
550
+def test_parallelism_agnostic_disabled_on_v2_model_runner():
551
+ config = _make_vllm_config()
552
+ config.use_v2_model_runner = True
553
+ kv_cache_config = KVCacheConfig(
554
+ num_blocks=0,
555
+ kv_cache_tensors=[],
556
+ kv_cache_groups=[KVCacheGroupSpec(["l0"], _full_attention_spec())],
557
+ )
558
+
559
+ offloading_config = build_offloading_config(config, kv_cache_config)
560
+
561
+ assert not offloading_config.parallel.is_parallelism_agnostic
562
+
563
+
564
+def test_accepts_blocks_per_chunk_for_heterogeneous_groups():
565
+ config = _make_vllm_config(extra_config={"blocks_per_chunk": 2})
566
+
567
+ offloading_config = build_offloading_config(config, _make_hybrid_kv_cache_config())
568
+
569
+ assert tuple(group.tokens_per_block for group in offloading_config.groups) == (
570
+ 12,
571
+ 16,
572
+ )
573
+ assert offloading_config.cache.blocks_per_chunk == 2
574
+
575
+
576
+def test_block_size_and_blocks_per_chunk_are_mutually_exclusive():
577
+ config = _make_vllm_config(extra_config={"block_size": 64, "blocks_per_chunk": 2})
578
+
579
+ with pytest.raises(ValueError, match="Specify only one"):
580
+ build_offloading_config(config, _make_kv_cache_config())
581
+
582
+
583
+def test_blocks_per_chunk_must_be_positive():
584
+ config = _make_vllm_config(extra_config={"blocks_per_chunk": 0})
585
+
586
+ with pytest.raises(ValueError, match="greater than 0"):
587
+ build_offloading_config(config, _make_kv_cache_config())
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+47
@@ -14,6 +14,7 @@ from tests.v1.kv_connector.unit.utils import EOS_TOKEN_ID
14
from vllm.distributed.kv_events import MEDIUM_CPU, BlockRemoved, BlockStored
15
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
16
OffloadingConnectorMetadata,
17
+ OffloadingWorkerMetadata,
18
)
19
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
20
OffloadingConnectorStats,
@@ -40,6 +41,7 @@ from vllm.v1.kv_offload.base import (
41
get_offload_block_hash,
42
make_offload_key,
43
)
44
+from vllm.v1.outputs import KVConnectorOutput
45
from vllm.v1.request import RequestStatus
46
47
@@ -1539,6 +1541,51 @@ def test_complete_store_called_per_job(request_runner, async_scheduling: bool):
1541
assert runner.manager.complete_store.call_count == 0
1542
1543
1544
+@pytest.mark.parametrize("async_scheduling", [True, False])
1545
+def test_complete_store_waits_for_all_worker_acks(
1546
+ request_runner, async_scheduling: bool
1547
+):
1548
+ tokens_per_block = 4
1549
+ blocks_per_chunk = 3
1550
+ tokens_per_chunk = tokens_per_block * blocks_per_chunk
1551
+ runner = request_runner(
1552
+ blocks_per_chunk=blocks_per_chunk,
1553
+ block_size=tokens_per_block,
1554
+ num_gpu_blocks=100,
1555
+ async_scheduling=async_scheduling,
1556
+ worker_count=3,
1557
+ )
1558
+ runner.new_request(token_ids=[0] * tokens_per_chunk)
1559
+ runner.manager.prepare_store.side_effect = lambda keys, req_context: (
1560
+ generate_store_output(keys)
1561
+ )
1562
+ runner.run(decoded_tokens=[0, 0], complete_transfers=False)
1563
+ assert len(runner.connector_scheduler._jobs) == 1
1564
+ job_id = next(iter(runner.connector_scheduler._jobs))
1565
+ assert runner.connector_scheduler._jobs[job_id].pending_count == 3
1566
+ runner.manager.complete_store.reset_mock()
1567
+
1568
+ runner.connector_scheduler.update_connector_output(
1569
+ KVConnectorOutput(
1570
+ kv_connector_worker_meta=OffloadingWorkerMetadata(
1571
+ completed_jobs={job_id: 1}
1572
+ )
1573
+ )
1574
+ )
1575
+ assert runner.manager.complete_store.call_count == 0
1576
+ assert runner.connector_scheduler._jobs[job_id].pending_count == 2
1577
+
1578
+ runner.connector_scheduler.update_connector_output(
1579
+ KVConnectorOutput(
1580
+ kv_connector_worker_meta=OffloadingWorkerMetadata(
1581
+ completed_jobs={job_id: 2}
1582
+ )
1583
+ )
1584
+ )
1585
+ assert runner.manager.complete_store.call_count == 1
1586
+ assert job_id not in runner.connector_scheduler._jobs
1587
+
1588
+
1589
@pytest.mark.parametrize("async_scheduling", [True, False])
1590
def test_max_offload_tokens_validation(request_runner, async_scheduling: bool):
1591
"""Validates max_offload_tokens: type coercion, boundary values, and capping.
tests/v1/kv_connector/unit/offloading_connector/test_worker.py
+184
-1
@@ -6,6 +6,10 @@ from unittest.mock import MagicMock
6
import pytest
7
import torch
8
9
+from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
10
+ OffloadingConnectorMetadata,
11
+ TransferJob,
12
+)
13
from vllm.platforms import current_platform
14
from vllm.utils.torch_utils import get_dtype_size
15
from vllm.v1.attention.backends.registry import AttentionBackendEnum
@@ -22,7 +26,17 @@ from vllm.v1.kv_cache_interface import (
26
from vllm.v1.kv_offload.base import (
27
CanonicalKVCacheRef,
28
CanonicalKVCaches,
29
+ GPULoadStoreSpec,
30
+ LoadStoreSpec,
31
+ OffloadingManager,
32
OffloadingSpec,
33
+ OffloadingWorker,
34
+)
35
+from vllm.v1.kv_offload.config import (
36
+ OffloadingCacheConfig,
37
+ OffloadingConfig,
38
+ OffloadingModelConfig,
39
+ OffloadingParallelConfig,
40
)
41
42
NUM_BLOCKS = 10
@@ -87,7 +101,11 @@ def _allocate_and_reshape_kv_caches(
101
set_kv_cache_layout(None)
102
103
90
-def _make_worker(kv_cache_config: KVCacheConfig):
104
+def _make_worker(
105
+ kv_cache_config: KVCacheConfig,
106
+ replicated_layout: bool = False,
107
+ rank: int = 0,
108
+):
109
"""
110
Create an OffloadingConnectorWorker with mocked dependencies.
111
"""
@@ -96,6 +114,9 @@ def _make_worker(kv_cache_config: KVCacheConfig):
114
)
115
116
spec = MagicMock(spec=OffloadingSpec)
117
+ spec.replicated_layout = replicated_layout
118
+ spec.config = MagicMock()
119
+ spec.config.parallel.rank = rank
120
spec.get_worker.return_value = MagicMock()
121
122
worker = OffloadingConnectorWorker(
@@ -107,11 +128,173 @@ def _make_worker(kv_cache_config: KVCacheConfig):
128
return worker, spec
129
130
131
+def _store_metadata(job_id: int) -> OffloadingConnectorMetadata:
132
+ return OffloadingConnectorMetadata(
133
+ load_jobs={},
134
+ store_jobs={
135
+ job_id: TransferJob(
136
+ req_id="req",
137
+ src_spec=GPULoadStoreSpec([0], group_sizes=(1,), block_indices=(0,)),
138
+ dst_spec=LoadStoreSpec(),
139
+ )
140
+ },
141
+ )
142
+
143
+
144
+def _load_metadata(job_id: int) -> OffloadingConnectorMetadata:
145
+ return OffloadingConnectorMetadata(
146
+ load_jobs={
147
+ job_id: TransferJob(
148
+ req_id="req",
149
+ src_spec=LoadStoreSpec(),
150
+ dst_spec=GPULoadStoreSpec([0], group_sizes=(1,), block_indices=(0,)),
151
+ )
152
+ },
153
+ store_jobs={},
154
+ )
155
+
156
+
157
+def _empty_metadata() -> OffloadingConnectorMetadata:
158
+ return OffloadingConnectorMetadata(load_jobs={}, store_jobs={})
159
+
160
+
161
+def _offloading_config(rank: int = 0) -> OffloadingConfig:
162
+ return OffloadingConfig(
163
+ groups=(),
164
+ worker_kv_bytes_per_block=0,
165
+ enable_kv_cache_events=False,
166
+ extra_config={},
167
+ engine_id="test-engine",
168
+ model=OffloadingModelConfig(name="test-model", dtype="float16"),
169
+ cache=OffloadingCacheConfig(tokens_per_hash=16, blocks_per_chunk=1),
170
+ parallel=OffloadingParallelConfig(
171
+ rank=rank,
172
+ world_size=2,
173
+ tp_size=2,
174
+ pp_size=1,
175
+ pcp_size=1,
176
+ dcp_size=1,
177
+ data_parallel_index=0,
178
+ is_parallelism_agnostic=False,
179
+ ),
180
+ )
181
+
182
+
183
+class BareExternalOffloadingSpec(OffloadingSpec):
184
+ def get_manager(self) -> OffloadingManager:
185
+ raise NotImplementedError
186
+
187
+ def get_worker(self, kv_caches: CanonicalKVCaches) -> OffloadingWorker:
188
+ raise NotImplementedError
189
+
190
+
191
# ---------------------------------------------------------------------------
192
# Tests
193
# ---------------------------------------------------------------------------
194
195
196
+def test_prepare_store_kv_non_writer_marks_completed_without_submit():
197
+ worker, _ = _make_worker(
198
+ KVCacheConfig(num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]),
199
+ replicated_layout=True,
200
+ rank=1,
201
+ )
202
+
203
+ worker.prepare_store_kv(_store_metadata(7))
204
+ worker.start_kv_transfers(_empty_metadata())
205
+
206
+ assert worker._unsubmitted_store_jobs == []
207
+ assert worker.worker is not None
208
+ worker.worker.submit_store.assert_not_called()
209
+ meta = worker.build_connector_worker_meta()
210
+ assert meta is not None
211
+ assert meta.completed_jobs == {7: 1}
212
+
213
+
214
+def test_prepare_store_kv_writer_submits_store():
215
+ worker, _ = _make_worker(
216
+ KVCacheConfig(num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]),
217
+ replicated_layout=True,
218
+ rank=0,
219
+ )
220
+
221
+ worker.prepare_store_kv(_store_metadata(8))
222
+ assert worker.build_connector_worker_meta() is None
223
+ worker.start_kv_transfers(_empty_metadata())
224
+
225
+ assert worker.worker is not None
226
+ worker.worker.submit_store.assert_called_once()
227
+
228
+
229
+def test_prepare_store_kv_non_replicated_rank_gt_zero_queues_store():
230
+ worker, _ = _make_worker(
231
+ KVCacheConfig(num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]),
232
+ replicated_layout=False,
233
+ rank=1,
234
+ )
235
+
236
+ worker.prepare_store_kv(_store_metadata(9))
237
+ assert worker.build_connector_worker_meta() is None
238
+ assert len(worker._unsubmitted_store_jobs) == 1
239
+
240
+ worker.start_kv_transfers(_empty_metadata())
241
+
242
+ assert worker.worker is not None
243
+ worker.worker.submit_store.assert_called_once()
244
+ assert worker._unsubmitted_store_jobs == []
245
+
246
+
247
+def test_handle_preemptions_non_writer_acks_flushed_store():
248
+ worker, _ = _make_worker(
249
+ KVCacheConfig(num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]),
250
+ replicated_layout=True,
251
+ rank=1,
252
+ )
253
+ metadata = _store_metadata(10)
254
+ metadata.jobs_to_flush = {10}
255
+
256
+ worker.handle_preemptions(metadata)
257
+
258
+ assert worker.worker is not None
259
+ worker.worker.submit_store.assert_not_called()
260
+ worker.worker.wait.assert_called_once_with({10})
261
+ assert metadata.store_jobs == {}
262
+ meta = worker.build_connector_worker_meta()
263
+ assert meta is not None
264
+ assert meta.completed_jobs == {10: 1}
265
+
266
+
267
+def test_start_kv_transfers_non_writer_still_submits_load():
268
+ worker, _ = _make_worker(
269
+ KVCacheConfig(num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]),
270
+ replicated_layout=True,
271
+ rank=1,
272
+ )
273
+
274
+ worker.start_kv_transfers(_load_metadata(10))
275
+
276
+ assert worker.worker is not None
277
+ worker.worker.submit_load.assert_called_once()
278
+ assert worker.build_connector_worker_meta() is None
279
+
280
+
281
+def test_offloading_connector_worker_accepts_plugin_spec_default_layout():
282
+ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.worker import (
283
+ OffloadingConnectorWorker,
284
+ )
285
+
286
+ spec = BareExternalOffloadingSpec(_offloading_config(rank=1))
287
+
288
+ OffloadingConnectorWorker(
289
+ spec=spec,
290
+ kv_cache_config=KVCacheConfig(
291
+ num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]
292
+ ),
293
+ )
294
+
295
+ assert spec.replicated_layout is False
296
+
297
+
298
@pytest.mark.parametrize("backend", ATTN_BACKENDS)
299
def test_register_kv_caches(backend):
300
"""Test register_kv_caches with multiple groups covering all layer types.
tests/v1/kv_connector/unit/offloading_connector/utils.py
+4
@@ -179,6 +179,7 @@ class RequestRunner:
179
async_scheduling: bool = True,
180
kv_cache_groups: list[KVCacheGroupSpec] | None = None,
181
extra_config_overrides: dict[str, Any] | None = None,
182
+ worker_count: int = 1,
183
):
184
assert blocks_per_chunk == 1 or kv_cache_groups is None, (
185
"blocks_per_chunk > 1 requires all groups to have the same "
@@ -198,6 +199,7 @@ class RequestRunner:
199
disable_hybrid_kv_cache_manager=False,
200
)
201
vllm_config.scheduler_config.async_scheduling = async_scheduling
202
+ vllm_config.parallel_config.world_size = worker_count
203
204
extra_config: dict[str, Any] = {
205
"spec_name": "MockOffloadingSpec",
@@ -668,6 +670,7 @@ def request_runner():
670
blocks_per_chunk=1,
671
kv_cache_groups=None,
672
extra_config_overrides=None,
673
+ worker_count=1,
674
):
675
runner = RequestRunner(
676
block_size=block_size,
@@ -676,6 +679,7 @@ def request_runner():
679
async_scheduling=async_scheduling,
680
kv_cache_groups=kv_cache_groups,
681
extra_config_overrides=extra_config_overrides,
682
+ worker_count=worker_count,
683
)
684
runners.append(runner)
685
return runner
tests/v1/kv_offload/cpu/test_gpu_worker.py
+10
-2
@@ -41,7 +41,10 @@ NUM_MAPPINGS_PER_GROUP = [2]
41
@pytest.mark.parametrize("num_tensors", NUM_TENSORS)
42
@pytest.mark.parametrize("seed", SEEDS)
43
@pytest.mark.parametrize("device", DEVICES)
44
-@pytest.mark.parametrize("use_shared_memory", [False, True])
44
+@pytest.mark.parametrize(
45
+ ("use_shared_memory", "replicated_layout"),
46
+ [(False, False), (True, False), (True, True)],
47
+)
48
@torch.inference_mode()
49
def test_transfer(
50
default_vllm_config,
@@ -55,6 +58,7 @@ def test_transfer(
58
seed: int,
59
device: str,
60
use_shared_memory: bool,
61
+ replicated_layout: bool,
62
) -> None:
63
set_random_seed(seed)
64
@@ -95,11 +99,15 @@ def test_transfer(
99
gpu_page_size_bytes * num_tensors * blocks_per_chunk,
100
SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT,
101
)
102
+ simulated_world_size = 2
103
+ kv_bytes_per_block = (
104
+ cpu_page_size if replicated_layout else cpu_page_size * simulated_world_size
105
+ )
106
mmap_region = SharedOffloadRegion(
107
engine_id=str(uuid.uuid4()),
108
num_blocks=num_cpu_blocks,
109
rank=0,
102
- kv_bytes_per_block=cpu_page_size,
110
+ kv_bytes_per_block=kv_bytes_per_block,
111
cpu_page_size=cpu_page_size,
112
)
113
tests/v1/kv_offload/test_factory.py
+215
-476
@@ -1,35 +1,12 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""
4
-Unit tests for OffloadingSpecFactory.
3
+"""Unit tests for native offloading specs and their factory."""
4
6
-These tests verify:
7
-1. Pre-registration integrity — registered module paths can actually import
8
- and yield correct OffloadingSpec subclasses (CI sentinel against file moves).
9
-2. End-to-end factory → spec construction with real configs.
10
-3. Downstream collaboration — build_metric_definitions delegation.
11
-4. Error paths — unregistered specs, missing config, duplicate registration.
12
-"""
13
-
14
-from typing import cast
15
-from unittest.mock import MagicMock, patch
5
+from typing import Any
6
+from unittest.mock import MagicMock
7
8
import pytest
18
-import torch
9
20
-from vllm.config import KVTransferConfig, ParallelConfig, VllmConfig
21
-from vllm.distributed.kv_transfer.kv_connector.v1.offloading.config import (
22
- build_offloading_config,
23
-)
24
-from vllm.platforms import current_platform
25
-from vllm.v1.kv_cache_interface import (
26
- FullAttentionSpec,
27
- KVCacheConfig,
28
- KVCacheGroupSpec,
29
- KVCacheTensor,
30
- MLAAttentionSpec,
31
- SlidingWindowSpec,
32
-)
10
from vllm.v1.kv_offload.base import (
11
CanonicalKVCaches,
12
OffloadingHistogramMetadata,
@@ -37,220 +14,81 @@ from vllm.v1.kv_offload.base import (
14
OffloadingSpec,
15
OffloadingWorker,
16
)
17
+from vllm.v1.kv_offload.config import (
18
+ OffloadingCacheConfig,
19
+ OffloadingConfig,
20
+ OffloadingGroupConfig,
21
+ OffloadingModelConfig,
22
+ OffloadingParallelConfig,
23
+)
24
from vllm.v1.kv_offload.cpu.shared_offload_region import SharedOffloadRegion
25
from vllm.v1.kv_offload.cpu.spec import CPUOffloadingSpec
26
from vllm.v1.kv_offload.factory import OffloadingSpecFactory
27
from vllm.v1.kv_offload.tiering.spec import TieringOffloadingSpec
28
45
-# ---------------------------------------------------------------------------
46
-# Fixtures
47
-# ---------------------------------------------------------------------------
48
-
29
30
@pytest.fixture(autouse=True)
31
def restore_registry():
52
- """Save and restore OffloadingSpecFactory._registry between tests."""
32
original = dict(OffloadingSpecFactory._registry)
33
yield
34
OffloadingSpecFactory._registry = original
35
36
58
-def _get_extra_config(config: VllmConfig) -> dict:
59
- assert config.kv_transfer_config is not None
60
- return config.kv_transfer_config.kv_connector_extra_config
61
-
62
-
63
-def _create_spec(config: VllmConfig, kv_cache_config: KVCacheConfig) -> OffloadingSpec:
64
- return OffloadingSpecFactory.create_spec(
65
- build_offloading_config(config, kv_cache_config)
66
- )
67
-
68
-
69
-def _make_vllm_config(
37
+def _make_offloading_config(
38
+ *,
39
spec_name: str | None = "CPUOffloadingSpec",
71
- cpu_bytes_to_use: int | None = None,
72
- store_threshold: int = 0,
73
- extra_config: dict | None = None,
74
-):
75
- """Build a real VllmConfig with kv_transfer_config set for offloading."""
76
- from vllm.config import (
77
- CacheConfig,
78
- DeviceConfig,
79
- ModelConfig,
80
- SchedulerConfig,
81
- VllmConfig,
82
- )
83
-
84
- model_config = ModelConfig(
85
- model="facebook/opt-125m",
86
- trust_remote_code=True,
87
- dtype="float16",
88
- seed=42,
89
- )
90
- scheduler_config = SchedulerConfig(
91
- max_num_seqs=16,
92
- max_num_batched_tokens=64,
93
- max_model_len=10000,
94
- enable_chunked_prefill=True,
95
- is_encoder_decoder=model_config.is_encoder_decoder,
96
- )
97
- cache_config = CacheConfig(
98
- block_size=16,
99
- gpu_memory_utilization=0.9,
100
- cache_dtype="auto",
101
- enable_prefix_caching=True,
102
- )
103
-
104
- cfg = extra_config or {}
105
- if cpu_bytes_to_use is not None:
106
- cfg["cpu_bytes_to_use"] = cpu_bytes_to_use
107
- cfg["spec_name"] = spec_name
108
- if store_threshold > 0:
109
- cfg["store_threshold"] = store_threshold
110
-
111
- kv_transfer_config = KVTransferConfig(
112
- kv_connector="OffloadingConnector",
113
- kv_role="kv_both",
114
- kv_connector_extra_config=cfg,
115
- )
116
- return VllmConfig(
117
- scheduler_config=scheduler_config,
118
- model_config=model_config,
119
- cache_config=cache_config,
120
- kv_transfer_config=kv_transfer_config,
121
- device_config=DeviceConfig("cpu"),
122
- )
123
-
124
-
125
-def _make_layout_vllm_config(
126
- spec_name: str = "CPUOffloadingSpec",
127
- cpu_bytes_to_use: int | None = None,
128
- extra_config: dict | None = None,
129
- tensor_parallel_size: int = 1,
130
- pipeline_parallel_size: int = 1,
131
- prefill_context_parallel_size: int = 1,
132
- decode_context_parallel_size: int = 1,
133
-) -> VllmConfig:
134
- config = MagicMock()
135
- config.cache_config.block_size = 16
136
- config.cache_config.enable_prefix_caching = True
137
- config.cache_config.prefix_match_unit = None
138
- config.cache_config.cache_dtype = torch.float16
139
- config.model_config.model = "test-model"
140
- world_size = (
141
- tensor_parallel_size * pipeline_parallel_size * prefill_context_parallel_size
142
- )
143
- with patch.object(current_platform, "device_count", return_value=world_size):
144
- config.parallel_config = ParallelConfig(
145
- tensor_parallel_size=tensor_parallel_size,
146
- pipeline_parallel_size=pipeline_parallel_size,
147
- prefill_context_parallel_size=prefill_context_parallel_size,
148
- decode_context_parallel_size=decode_context_parallel_size,
149
- )
150
- config.kv_events_config = None
151
- config.use_v2_model_runner = False
152
-
153
- connector_extra_config = dict(extra_config or {})
154
- connector_extra_config["spec_name"] = spec_name
40
+ cpu_bytes_to_use: int | None = 65536,
41
+ worker_kv_bytes_per_block: int = 8,
42
+ groups: tuple[OffloadingGroupConfig, ...] | None = None,
43
+ tokens_per_hash: int = 16,
44
+ blocks_per_chunk: int = 1,
45
+ rank: int = 0,
46
+ world_size: int = 1,
47
+ tp_size: int | None = None,
48
+ pp_size: int = 1,
49
+ pcp_size: int = 1,
50
+ dcp_size: int = 1,
51
+ data_parallel_index: int = 0,
52
+ is_parallelism_agnostic: bool = False,
53
+ replicated_layout: bool = False,
54
+ extra_config: dict[str, Any] | None = None,
55
+) -> OffloadingConfig:
56
+ normalized_extra_config = dict(extra_config or {})
57
+ if spec_name is not None:
58
+ normalized_extra_config["spec_name"] = spec_name
59
if cpu_bytes_to_use is not None:
156
- connector_extra_config["cpu_bytes_to_use"] = cpu_bytes_to_use
157
- config.kv_transfer_config = KVTransferConfig(
158
- kv_connector="OffloadingConnector",
159
- kv_role="kv_both",
160
- kv_connector_extra_config=connector_extra_config,
161
- )
162
- return cast(VllmConfig, config)
163
-
164
-
165
-def _make_kv_cache_config():
166
- """Build a minimal KVCacheConfig with one KV cache tensor."""
167
- num_blocks = 16
168
- num_kv_heads = 1
169
- head_size = 1
170
- dtype = torch.float32
171
- page_size = 2 * num_kv_heads * head_size * torch.finfo(dtype).bits // 8
172
- kv_tensor = KVCacheTensor(
173
- size=num_blocks * page_size, shared_by=["layer"], block_stride=0
60
+ normalized_extra_config["cpu_bytes_to_use"] = cpu_bytes_to_use
61
+
62
+ if groups is None:
63
+ groups = (OffloadingGroupConfig(16, ("layer",)),)
64
+
65
+ return OffloadingConfig(
66
+ groups=groups,
67
+ worker_kv_bytes_per_block=worker_kv_bytes_per_block,
68
+ enable_kv_cache_events=False,
69
+ extra_config=normalized_extra_config,
70
+ engine_id="test-engine",
71
+ model=OffloadingModelConfig(name="test-model", dtype="float16"),
72
+ cache=OffloadingCacheConfig(
73
+ tokens_per_hash=tokens_per_hash,
74
+ blocks_per_chunk=blocks_per_chunk,
75
+ ),
76
+ parallel=OffloadingParallelConfig(
77
+ rank=rank,
78
+ world_size=world_size,
79
+ tp_size=world_size if tp_size is None else tp_size,
80
+ pp_size=pp_size,
81
+ pcp_size=pcp_size,
82
+ dcp_size=dcp_size,
83
+ data_parallel_index=data_parallel_index,
84
+ is_parallelism_agnostic=is_parallelism_agnostic,
85
+ ),
86
+ replicated_layout=replicated_layout,
87
)
175
- return KVCacheConfig(
176
- num_blocks=num_blocks,
177
- kv_cache_tensors=[kv_tensor],
178
- kv_cache_groups=[
179
- KVCacheGroupSpec(
180
- ["layer"],
181
- FullAttentionSpec(
182
- block_size=16,
183
- num_kv_heads=num_kv_heads,
184
- head_size=head_size,
185
- dtype=dtype,
186
- ),
187
- )
188
- ],
189
- )
190
-
88
192
-def _make_sizing_kv_cache_config(packed: bool) -> KVCacheConfig:
193
- num_blocks = 4
194
- if packed:
195
- kv_cache_tensors = [
196
- KVCacheTensor(
197
- size=64,
198
- shared_by=[layer_name],
199
- block_stride=16,
200
- )
201
- for layer_name in ("layer0", "layer1")
202
- ]
203
- else:
204
- kv_cache_tensors = [
205
- KVCacheTensor(size=40, shared_by=["layer0"]),
206
- KVCacheTensor(size=24, shared_by=["layer1"]),
207
- ]
208
-
209
- return KVCacheConfig(
210
- num_blocks=num_blocks,
211
- kv_cache_tensors=kv_cache_tensors,
212
- kv_cache_groups=[
213
- KVCacheGroupSpec(
214
- ["layer0", "layer1"],
215
- FullAttentionSpec(
216
- block_size=16,
217
- num_kv_heads=1,
218
- head_size=1,
219
- dtype=torch.float32,
220
- ),
221
- )
222
- ],
223
- )
89
225
-
226
-def _make_hybrid_kv_cache_config() -> KVCacheConfig:
227
- return KVCacheConfig(
228
- num_blocks=4,
229
- kv_cache_tensors=[
230
- KVCacheTensor(size=40, shared_by=["full_layer"]),
231
- KVCacheTensor(size=24, shared_by=["mla_layer"]),
232
- ],
233
- kv_cache_groups=[
234
- KVCacheGroupSpec(
235
- ["full_layer"],
236
- FullAttentionSpec(
237
- block_size=12,
238
- num_kv_heads=1,
239
- head_size=1,
240
- dtype=torch.float32,
241
- ),
242
- ),
243
- KVCacheGroupSpec(
244
- ["mla_layer"],
245
- MLAAttentionSpec(
246
- block_size=16,
247
- num_kv_heads=1,
248
- head_size=576,
249
- dtype=torch.float32,
250
- ),
251
- ),
252
- ],
253
- )
90
+def _create_spec(**kwargs: Any) -> OffloadingSpec:
91
+ return OffloadingSpecFactory.create_spec(_make_offloading_config(**kwargs))
92
93
94
class SingleArgExternalOffloadingSpec(OffloadingSpec):
@@ -261,104 +99,60 @@ class SingleArgExternalOffloadingSpec(OffloadingSpec):
99
raise NotImplementedError
100
101
264
-# ---------------------------------------------------------------------------
265
-# Pre-registration integrity (CI sentinel)
266
-# ---------------------------------------------------------------------------
267
-
268
-
102
def test_pre_registered_specs_can_be_imported():
270
- """If someone moves cpu/spec.py but forgets to update factory.py, CI fails."""
103
for name in OffloadingSpecFactory._registry:
104
cls = OffloadingSpecFactory._registry[name]()
105
assert issubclass(cls, OffloadingSpec)
106
107
108
def test_cpu_spec_registered():
277
- """CPUOffloadingSpec is registered and importable."""
109
cls = OffloadingSpecFactory._registry["CPUOffloadingSpec"]()
110
assert cls is CPUOffloadingSpec
111
112
113
def test_tiering_spec_registered():
283
- """TieringOffloadingSpec is registered and importable."""
114
cls = OffloadingSpecFactory._registry["TieringOffloadingSpec"]()
115
assert cls is TieringOffloadingSpec
116
117
288
-# ---------------------------------------------------------------------------
289
-# Normal path — get_spec_cls
290
-# ---------------------------------------------------------------------------
291
-
292
-
118
def test_get_spec_cls_returns_registered_class():
294
- """Registered spec_name returns correct class."""
295
- config = _make_vllm_config(spec_name="CPUOffloadingSpec")
296
- spec_cls = OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
119
+ spec_cls = OffloadingSpecFactory.get_spec_cls(
120
+ _make_offloading_config().extra_config
121
+ )
122
assert spec_cls is CPUOffloadingSpec
123
124
300
-def test_get_spec_cls_default_to_cpu():
301
- """Default spec_name (absent from config) resolves to CPUOffloadingSpec."""
302
- config = _make_vllm_config(spec_name=None)
303
- config.kv_transfer_config.kv_connector_extra_config.pop("spec_name", None)
304
- spec_cls = OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
125
+def test_get_spec_cls_defaults_to_cpu():
126
+ spec_cls = OffloadingSpecFactory.get_spec_cls(
127
+ _make_offloading_config(spec_name=None).extra_config
128
+ )
129
assert spec_cls is CPUOffloadingSpec
130
131
308
-# ---------------------------------------------------------------------------
309
-# End-to-end — create_spec
310
-# ---------------------------------------------------------------------------
311
-
312
-
313
-def test_create_cpu_offloading_spec_end_to_end():
314
- """Full factory → spec construction with real VllmConfig/KVCacheConfig.
315
-
316
- Verifies:
317
- - cpu_bytes_to_use validation and num_blocks calculation
318
- - block_size % tokens_per_hash assertion
319
- - spec instance is CPUOffloadingSpec
320
- """
321
- config = _make_vllm_config(cpu_bytes_to_use=65536)
322
- kv_cache_config = _make_kv_cache_config()
323
- spec = _create_spec(config, kv_cache_config)
132
+def test_create_cpu_offloading_spec():
133
+ spec = _create_spec()
134
assert isinstance(spec, CPUOffloadingSpec)
135
assert spec.num_blocks > 0
136
137
328
-@pytest.mark.parametrize("packed", [False, True])
329
-def test_cpu_spec_sizing_preserves_tensor_layout(packed: bool):
330
- cpu_bytes_to_use = 1920
331
- config = _make_layout_vllm_config(
332
- cpu_bytes_to_use=cpu_bytes_to_use,
333
- extra_config={"block_size": 32},
334
- tensor_parallel_size=3,
335
- pipeline_parallel_size=2,
138
+def test_cpu_spec_sizes_normalized_worker_layout():
139
+ spec = _create_spec(
140
+ cpu_bytes_to_use=1920,
141
+ worker_kv_bytes_per_block=16,
142
+ blocks_per_chunk=2,
143
+ world_size=6,
144
+ tp_size=3,
145
+ pp_size=2,
146
)
147
338
- spec = _create_spec(config, _make_sizing_kv_cache_config(packed))
339
-
148
assert isinstance(spec, CPUOffloadingSpec)
149
assert spec.cpu_page_size_per_worker == 32
150
assert spec.kv_bytes_per_chunk == 192
343
- assert spec.num_blocks == cpu_bytes_to_use // 192
344
-
345
-
346
-def test_cpu_spec_rejects_partially_packed_tensor_layout():
347
- config = _make_layout_vllm_config(cpu_bytes_to_use=65536)
348
- kv_cache_config = _make_sizing_kv_cache_config(packed=False)
349
- kv_cache_config.kv_cache_tensors[0].block_stride = 16
350
-
351
- with pytest.raises(AssertionError):
352
- _create_spec(config, kv_cache_config)
353
-
151
+ assert spec.num_blocks == 10
152
355
-def test_cpu_spec_zero_blocks_skips_tensor_layout_validation():
356
- config = _make_layout_vllm_config(cpu_bytes_to_use=65536)
357
- kv_cache_config = _make_sizing_kv_cache_config(packed=False)
358
- kv_cache_config.num_blocks = 0
359
- kv_cache_config.kv_cache_tensors[0].block_stride = 16
153
361
- spec = _create_spec(config, kv_cache_config)
154
+def test_cpu_spec_zero_worker_bytes_produces_empty_cache():
155
+ spec = _create_spec(worker_kv_bytes_per_block=0, world_size=4)
156
157
assert isinstance(spec, CPUOffloadingSpec)
158
assert spec.cpu_page_size_per_worker == 0
@@ -368,225 +162,209 @@ def test_cpu_spec_zero_blocks_skips_tensor_layout_validation():
162
163
def test_tiering_spec_aligns_row_size():
164
alignment = SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT
371
- cpu_bytes_to_use = alignment * 3
372
- config = _make_layout_vllm_config(
165
+ spec = _create_spec(
166
spec_name="TieringOffloadingSpec",
374
- cpu_bytes_to_use=cpu_bytes_to_use,
375
- extra_config={"block_size": 32},
376
- tensor_parallel_size=3,
377
- pipeline_parallel_size=2,
167
+ cpu_bytes_to_use=alignment * 3,
168
+ worker_kv_bytes_per_block=16,
169
+ blocks_per_chunk=2,
170
+ world_size=6,
171
+ tp_size=3,
172
+ pp_size=2,
173
)
174
380
- spec = _create_spec(config, _make_sizing_kv_cache_config(packed=False))
381
-
175
assert isinstance(spec, TieringOffloadingSpec)
176
assert spec.cpu_page_size_per_worker == 32
177
assert spec.kv_bytes_per_chunk == alignment
385
- assert spec.num_blocks == cpu_bytes_to_use // alignment
178
+ assert spec.num_blocks == 3
179
180
388
-def test_offloading_spec_kv_sharding_ignores_prefill_context_parallel():
389
- config = _make_layout_vllm_config(
390
- cpu_bytes_to_use=65536,
391
- extra_config={"block_size": 64},
392
- prefill_context_parallel_size=2,
181
+@pytest.mark.parametrize("world_size", [2, 4, 8])
182
+def test_tiering_spec_replicated_sizing_removes_world_factor(world_size: int):
183
+ worker_kv_bytes_per_block = SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT
184
+ spec = _create_spec(
185
+ spec_name="TieringOffloadingSpec",
186
+ cpu_bytes_to_use=worker_kv_bytes_per_block * 8,
187
+ worker_kv_bytes_per_block=worker_kv_bytes_per_block,
188
+ world_size=world_size,
189
+ replicated_layout=True,
190
)
191
395
- spec = _create_spec(config, _make_kv_cache_config())
192
+ assert isinstance(spec, TieringOffloadingSpec)
193
+ assert spec.replicated_layout is True
194
+ assert spec.cpu_page_size_per_worker == worker_kv_bytes_per_block
195
+ assert spec.kv_bytes_per_chunk == worker_kv_bytes_per_block
196
+ assert spec.num_blocks == 8
197
397
- assert spec.tokens_per_block == (16,)
398
- assert spec.tokens_per_hash == 16
399
- assert spec.blocks_per_chunk == 4
198
199
+def test_tiering_spec_create_worker_uses_single_slot_for_replicated_layout(monkeypatch):
200
+ import vllm.v1.kv_offload.tiering.spec as tiering_spec_module
201
402
-def test_offloading_config_preserves_data_parallel_index():
403
- config = _make_layout_vllm_config()
404
- config.parallel_config.data_parallel_index = 2
202
+ worker_kv_bytes_per_block = SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT
203
+ spec = _create_spec(
204
+ spec_name="TieringOffloadingSpec",
205
+ cpu_bytes_to_use=worker_kv_bytes_per_block * 8,
206
+ worker_kv_bytes_per_block=worker_kv_bytes_per_block,
207
+ world_size=4,
208
+ replicated_layout=True,
209
+ )
210
+ assert isinstance(spec, TieringOffloadingSpec)
211
406
- offloading_config = build_offloading_config(config, _make_kv_cache_config())
212
+ region = MagicMock()
213
+ region_calls: list[dict[str, Any]] = []
214
+ worker_calls: list[dict[str, Any]] = []
215
408
- assert offloading_config.parallel.data_parallel_index == 2
216
+ def fake_region_ctor(**kwargs):
217
+ region_calls.append(kwargs)
218
+ return region
219
220
+ def fake_worker_ctor(**kwargs):
221
+ worker_calls.append(kwargs)
222
+ return MagicMock()
223
411
-def test_offloading_spec_resolves_heterogeneous_hybrid_block_sizes():
412
- config = _make_layout_vllm_config(cpu_bytes_to_use=65536)
413
- config.cache_config.block_size = 4
224
+ monkeypatch.setattr(tiering_spec_module, "SharedOffloadRegion", fake_region_ctor)
225
+ monkeypatch.setattr(tiering_spec_module, "CPUOffloadingWorker", fake_worker_ctor)
226
+ monkeypatch.setattr(
227
+ tiering_spec_module.torch.accelerator, "current_device_index", lambda: 5
228
+ )
229
415
- spec = _create_spec(config, _make_hybrid_kv_cache_config())
230
+ kv_caches = MagicMock()
231
+ spec.create_worker(kv_caches)
232
+
233
+ assert region_calls[0]["rank"] == 0
234
+ assert region_calls[0]["kv_bytes_per_block"] == worker_kv_bytes_per_block
235
+ assert worker_calls[0]["kv_caches"] is kv_caches
236
+ assert worker_calls[0]["mmap_region"] is region
237
417
- assert spec.tokens_per_block == (12, 16)
418
- assert spec.tokens_per_hash == 4
419
- assert spec.blocks_per_chunk == 1
238
239
+def test_tiering_spec_create_worker_folds_device_index_for_sharded_layout(monkeypatch):
240
+ import vllm.v1.kv_offload.tiering.spec as tiering_spec_module
241
422
-def _full_attention_spec(block_size: int = 16) -> FullAttentionSpec:
423
- return FullAttentionSpec(
424
- block_size=block_size, num_kv_heads=4, head_size=128, dtype=torch.float32
242
+ spec = _create_spec(
243
+ spec_name="TieringOffloadingSpec",
244
+ worker_kv_bytes_per_block=4096,
245
+ world_size=4,
246
)
247
+ assert isinstance(spec, TieringOffloadingSpec)
248
+
249
+ region_calls: list[dict[str, Any]] = []
250
251
+ def fake_region_ctor(**kwargs):
252
+ region_calls.append(kwargs)
253
+ return MagicMock()
254
428
-def _parallelism_agnostic(kv_cache_groups: list[KVCacheGroupSpec]) -> bool:
429
- config = _make_layout_vllm_config()
430
- kv_cache_config = KVCacheConfig(
431
- num_blocks=0, kv_cache_tensors=[], kv_cache_groups=kv_cache_groups
255
+ monkeypatch.setattr(tiering_spec_module, "SharedOffloadRegion", fake_region_ctor)
256
+ monkeypatch.setattr(tiering_spec_module, "CPUOffloadingWorker", MagicMock())
257
+ monkeypatch.setattr(
258
+ tiering_spec_module.torch.accelerator,
259
+ "current_device_index",
260
+ lambda: 5,
261
)
433
- offloading_config = build_offloading_config(config, kv_cache_config)
434
- return offloading_config.parallel.is_parallelism_agnostic
435
-
436
-
437
-def test_parallelism_agnostic_for_single_full_attention_group():
438
- assert _parallelism_agnostic([KVCacheGroupSpec(["l0"], _full_attention_spec())])
439
-
440
-
441
-@pytest.mark.parametrize(
442
- "kv_cache_groups",
443
- [
444
- # MLA latent KV is replicated per rank, never head-sharded.
445
- [
446
- KVCacheGroupSpec(
447
- ["l0"],
448
- MLAAttentionSpec(
449
- block_size=16, num_kv_heads=1, head_size=576, dtype=torch.float32
450
- ),
451
- )
452
- ],
453
- # Sliding window is not full attention.
454
- [
455
- KVCacheGroupSpec(
456
- ["l0"],
457
- SlidingWindowSpec(
458
- block_size=16,
459
- num_kv_heads=4,
460
- head_size=128,
461
- dtype=torch.float32,
462
- sliding_window=128,
463
- ),
464
- )
465
- ],
466
- # Hybrid model: more than one KV cache group.
467
- [
468
- KVCacheGroupSpec(["l0"], _full_attention_spec()),
469
- KVCacheGroupSpec(["l1"], _full_attention_spec()),
470
- ],
471
- ],
472
-)
473
-def test_parallelism_agnostic_excluded(kv_cache_groups: list[KVCacheGroupSpec]):
474
- assert not _parallelism_agnostic(kv_cache_groups)
262
+
263
+ spec.create_worker(MagicMock())
264
+
265
+ assert region_calls[0]["rank"] == 1
266
267
477
-def test_parallelism_agnostic_disabled_on_v2_model_runner():
478
- config = _make_layout_vllm_config()
479
- config.use_v2_model_runner = True
480
- kv_cache_config = KVCacheConfig(
481
- num_blocks=0,
482
- kv_cache_tensors=[],
483
- kv_cache_groups=[KVCacheGroupSpec(["l0"], _full_attention_spec())],
268
+@pytest.mark.parametrize("world_size", [2, 4, 8])
269
+def test_cpu_spec_replicated_config_preserves_per_rank_sizing(world_size: int):
270
+ worker_kv_bytes_per_block = 4096
271
+ spec = _create_spec(
272
+ cpu_bytes_to_use=worker_kv_bytes_per_block * world_size * 2,
273
+ worker_kv_bytes_per_block=worker_kv_bytes_per_block,
274
+ world_size=world_size,
275
+ replicated_layout=True,
276
)
485
- offloading_config = build_offloading_config(config, kv_cache_config)
486
- assert not offloading_config.parallel.is_parallelism_agnostic
277
278
+ assert isinstance(spec, CPUOffloadingSpec)
279
+ assert spec.replicated_layout is False
280
+ assert spec.cpu_page_size_per_worker == worker_kv_bytes_per_block
281
+ assert spec.kv_bytes_per_chunk == worker_kv_bytes_per_block * world_size
282
+ assert spec.num_blocks == 2
283
489
-def test_create_dynamic_spec_receives_translated_config():
490
- config = _make_layout_vllm_config(
491
- spec_name="SingleArgExternalOffloadingSpec",
492
- extra_config={
493
- "spec_module_path": "tests.v1.kv_offload.test_factory",
494
- },
284
+
285
+def test_offloading_spec_has_replicated_layout_default():
286
+ spec = SingleArgExternalOffloadingSpec(_make_offloading_config())
287
+ assert spec.replicated_layout is False
288
+
289
+
290
+def test_offloading_spec_uses_normalized_chunk_geometry():
291
+ groups = (
292
+ OffloadingGroupConfig(12, ("full_layer",)),
293
+ OffloadingGroupConfig(16, ("mla_layer",)),
294
+ )
295
+ spec = _create_spec(
296
+ groups=groups,
297
+ tokens_per_hash=4,
298
+ blocks_per_chunk=2,
299
)
496
- kv_cache_config = _make_kv_cache_config()
497
- offloading_config = build_offloading_config(config, kv_cache_config)
300
499
- spec = OffloadingSpecFactory.create_spec(offloading_config)
301
+ assert spec.tokens_per_block == (12, 16)
302
+ assert spec.tokens_per_hash == 4
303
+ assert spec.blocks_per_chunk == 2
304
+
305
501
- assert isinstance(spec, SingleArgExternalOffloadingSpec)
502
- assert spec.config is offloading_config
306
+def test_create_dynamic_spec_receives_config():
307
+ config = _make_offloading_config(
308
+ spec_name="SingleArgExternalOffloadingSpec",
309
+ extra_config={"spec_module_path": "tests.v1.kv_offload.test_factory"},
310
+ )
311
312
+ spec = OffloadingSpecFactory.create_spec(config)
313
505
-# ---------------------------------------------------------------------------
506
-# Dynamic import via spec_module_path
507
-# ---------------------------------------------------------------------------
314
+ assert isinstance(spec, SingleArgExternalOffloadingSpec)
315
+ assert spec.config is config
316
317
318
def test_dynamic_load_via_spec_module_path():
511
- """External spec loaded via spec_module_path.
512
-
513
- This is how external projects (e.g., llm-d-kv-cache SharedStorageOffloadingSpec)
514
- integrate with vLLM without being pre-registered in the factory.
515
- The fallback path: registry miss → spec_module_path → importlib.import_module.
516
- """
517
- config = _make_vllm_config(spec_name="CPUOffloadingSpec")
518
- # Delete from registry to force the dynamic import path
319
del OffloadingSpecFactory._registry["CPUOffloadingSpec"]
520
- # spec_name not in registry → falls through to spec_module_path
521
- config.kv_transfer_config.kv_connector_extra_config["spec_module_path"] = (
522
- "vllm.v1.kv_offload.cpu.spec"
320
+ config = _make_offloading_config(
321
+ extra_config={"spec_module_path": "vllm.v1.kv_offload.cpu.spec"}
322
)
524
- spec_cls = OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
525
- assert spec_cls is CPUOffloadingSpec
323
324
+ spec_cls = OffloadingSpecFactory.get_spec_cls(config.extra_config)
325
528
-# ---------------------------------------------------------------------------
529
-# Error paths
530
-# ---------------------------------------------------------------------------
326
+ assert spec_cls is CPUOffloadingSpec
327
328
329
def test_unregistered_spec_without_module_path_raises():
534
- """spec_name not in registry + no spec_module_path → ValueError."""
535
- config = _make_vllm_config(spec_name="NonexistentSpec")
330
+ config = _make_offloading_config(spec_name="NonexistentSpec")
331
with pytest.raises(ValueError, match="Unsupported spec type"):
537
- OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
332
+ OffloadingSpecFactory.get_spec_cls(config.extra_config)
333
539
- # create_spec should also fail (calls get_spec_cls internally)
540
- kv_cache_config = _make_kv_cache_config()
334
with pytest.raises(ValueError, match="Unsupported spec type"):
542
- _create_spec(config, kv_cache_config)
335
+ OffloadingSpecFactory.create_spec(config)
336
337
338
def test_cpu_spec_missing_cpu_bytes_to_use_raises():
546
- """CPUOffloadingSpec requires cpu_bytes_to_use → Exception."""
547
- config = _make_vllm_config(cpu_bytes_to_use=None)
548
- config.kv_transfer_config.kv_connector_extra_config.pop("cpu_bytes_to_use", None)
549
- kv_cache_config = _make_kv_cache_config()
339
with pytest.raises(Exception, match="cpu_bytes_to_use must be specified"):
551
- _create_spec(config, kv_cache_config)
340
+ _create_spec(cpu_bytes_to_use=None)
341
342
343
def test_duplicate_registration_raises():
555
- """register_spec with existing name → ValueError."""
344
with pytest.raises(ValueError, match="is already registered"):
345
OffloadingSpecFactory.register_spec(
346
"CPUOffloadingSpec", "some.module", "SomeClass"
347
)
348
349
562
-# ---------------------------------------------------------------------------
563
-# Downstream collaboration — build_metric_definitions
564
-# ---------------------------------------------------------------------------
565
-
566
-
350
def test_build_metric_definitions_below_threshold():
568
- """store_threshold < 2 keeps stores_skipped disabled."""
351
from vllm.v1.kv_offload.cpu.common import CPUOffloadingMetrics
352
571
- config = _make_vllm_config(store_threshold=1)
572
- spec_cls = OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
573
- metrics = spec_cls.build_metric_definitions(
574
- config.kv_transfer_config.kv_connector_extra_config
575
- )
353
+ extra_config = {"store_threshold": 1}
354
+ spec_cls = OffloadingSpecFactory.get_spec_cls({"spec_name": "CPUOffloadingSpec"})
355
+ metrics = spec_cls.build_metric_definitions(extra_config)
356
+
357
assert CPUOffloadingMetrics.STORES_SKIPPED not in metrics
358
assert CPUOffloadingMetrics.CPU_ALLOCATION_SIZE in metrics
359
360
361
def test_build_metric_definitions_allocation_size_histogram():
581
- """CPU allocation size is always reported as a histogram."""
362
from vllm.v1.kv_offload.cpu.common import CPUOffloadingMetrics
363
584
- config = _make_vllm_config(store_threshold=0)
585
- spec_cls = OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
586
- metrics = spec_cls.build_metric_definitions(
587
- config.kv_transfer_config.kv_connector_extra_config
588
- )
364
+ spec_cls = OffloadingSpecFactory.get_spec_cls({"spec_name": "CPUOffloadingSpec"})
365
+ metrics = spec_cls.build_metric_definitions({})
366
metadata = metrics[CPUOffloadingMetrics.CPU_ALLOCATION_SIZE]
367
+
368
assert isinstance(metadata, OffloadingHistogramMetadata)
369
assert metadata.buckets == (
370
1,
@@ -603,49 +381,10 @@ def test_build_metric_definitions_allocation_size_histogram():
381
382
383
def test_build_metric_definitions_returns_counter_at_threshold():
606
- """store_threshold >= 2 → returns stores_skipped counter definition."""
384
from vllm.v1.kv_offload.cpu.common import CPUOffloadingMetrics
385
609
- config = _make_vllm_config(store_threshold=2)
610
- spec_cls = OffloadingSpecFactory.get_spec_cls(_get_extra_config(config))
611
- metrics = spec_cls.build_metric_definitions(
612
- config.kv_transfer_config.kv_connector_extra_config
613
- )
614
- assert CPUOffloadingMetrics.STORES_SKIPPED in metrics
615
-
616
-
617
-def test_offloading_spec_accepts_blocks_per_chunk_for_heterogeneous_groups():
618
- config = _make_layout_vllm_config(
619
- cpu_bytes_to_use=65536,
620
- extra_config={"blocks_per_chunk": 2},
621
- )
622
-
623
- spec = _create_spec(config, _make_hybrid_kv_cache_config())
624
-
625
- assert spec.tokens_per_block == (12, 16)
626
- assert spec.blocks_per_chunk == 2
627
-
628
-
629
-def test_block_size_and_blocks_per_chunk_are_mutually_exclusive():
630
- config = _make_layout_vllm_config(
631
- cpu_bytes_to_use=65536,
632
- extra_config={
633
- "block_size": 64,
634
- "blocks_per_chunk": 2,
635
- },
636
- )
637
-
638
- with pytest.raises(ValueError, match="Specify only one"):
639
- _create_spec(config, _make_kv_cache_config())
640
-
386
+ extra_config = {"store_threshold": 2}
387
+ spec_cls = OffloadingSpecFactory.get_spec_cls({"spec_name": "CPUOffloadingSpec"})
388
+ metrics = spec_cls.build_metric_definitions(extra_config)
389
642
-def test_blocks_per_chunk_must_be_positive():
643
- config = _make_layout_vllm_config(
644
- cpu_bytes_to_use=65536,
645
- extra_config={
646
- "blocks_per_chunk": 0,
647
- },
648
- )
649
-
650
- with pytest.raises(ValueError, match="greater than 0"):
651
- _create_spec(config, _make_kv_cache_config())
390
+ assert CPUOffloadingMetrics.STORES_SKIPPED in metrics
vllm/distributed/kv_transfer/kv_connector/v1/offloading/config.py
+28
-7
@@ -102,19 +102,39 @@ def build_offloading_config(
102
)
103
worker_kv_bytes_per_block = total_gpu_kv_bytes // kv_cache_config.num_blocks
104
105
- # Only a single non-MLA full-attention group is parallelism-invariant:
106
- # MLA latent KV is replicated per rank (never head-sharded), and the V2
107
- # model runner's KV layout is not known to be parallelism-invariant.
108
- single_group = (
105
+ single_group_spec = (
106
kv_cache_config.kv_cache_groups[0].kv_cache_spec
107
if len(kv_cache_config.kv_cache_groups) == 1
108
else None
109
)
110
+ replicated_layout = (
111
+ vllm_config.model_config.use_mla
112
+ # Exact type: fail closed on wrappers and sliding-window variants.
113
+ and type(single_group_spec) is MLAAttentionSpec
114
+ # Page accounting: one MLA page per layer, no packed/mixed rows.
115
+ and worker_kv_bytes_per_block > 0
116
+ and worker_kv_bytes_per_block
117
+ == single_group_spec.page_size_bytes
118
+ * len(kv_cache_config.kv_cache_groups[0].layer_names)
119
+ # Safe MVP boundary: TP-only, no other parallel axes.
120
+ and parallel_config.tensor_parallel_size > 1
121
+ and parallel_config.pipeline_parallel_size == 1
122
+ and parallel_config.prefill_context_parallel_size == 1
123
+ and parallel_config.decode_context_parallel_size == 1
124
+ and parallel_config.world_size == parallel_config.tensor_parallel_size
125
+ # Shared /dev/shm mmap layout is single-node mp only.
126
+ and parallel_config.distributed_executor_backend == "mp"
127
+ and parallel_config.nnodes_within_dp == 1
128
+ )
129
+
130
+ # Only a single non-MLA full-attention group is parallelism-invariant:
131
+ # MLA latent KV is replicated per rank (never head-sharded), and the V2
132
+ # model runner's KV layout is not known to be parallelism-invariant.
133
is_parallelism_agnostic = (
134
not vllm_config.use_v2_model_runner
115
- and single_group is not None
116
- and isinstance(single_group, FullAttentionSpec)
117
- and not isinstance(single_group, MLAAttentionSpec)
135
+ and single_group_spec is not None
136
+ and isinstance(single_group_spec, FullAttentionSpec)
137
+ and not isinstance(single_group_spec, MLAAttentionSpec)
138
)
139
140
kv_events_config = vllm_config.kv_events_config
@@ -144,4 +164,5 @@ def build_offloading_config(
164
data_parallel_index=parallel_config.data_parallel_index,
165
is_parallelism_agnostic=is_parallelism_agnostic,
166
),
167
+ replicated_layout=replicated_layout,
168
)
vllm/distributed/kv_transfer/kv_connector/v1/offloading/worker.py
+11
@@ -45,6 +45,10 @@ class OffloadingConnectorWorker:
45
self.spec = spec
46
self.kv_cache_config = kv_cache_config
47
self.worker: OffloadingWorker | None = None
48
+ # Non-writers still ack: pending_count waits for world_size per job.
49
+ self._is_store_writer = (
50
+ not self.spec.replicated_layout or self.spec.config.parallel.rank == 0
51
+ )
52
53
# job_id -> req_id for in-flight loads.
54
self._load_jobs: dict[int, ReqId] = {}
@@ -274,6 +278,9 @@ class OffloadingConnectorWorker:
278
for job_id in kv_connector_metadata.jobs_to_flush:
279
entry = kv_connector_metadata.store_jobs.pop(job_id, None)
280
if entry is not None:
281
+ if not self._is_store_writer:
282
+ self._connector_worker_meta.mark_completed(job_id)
283
+ continue
284
assert isinstance(entry.src_spec, GPULoadStoreSpec)
285
self._unsubmitted_store_jobs.append(
286
(job_id, entry.src_spec, entry.dst_spec)
@@ -304,6 +311,10 @@ class OffloadingConnectorWorker:
311
312
def prepare_store_kv(self, metadata: OffloadingConnectorMetadata):
313
for job_id, entry in metadata.store_jobs.items():
314
+ if not self._is_store_writer:
315
+ # Gate before queueing: no _unsubmitted_store_jobs entry.
316
+ self._connector_worker_meta.mark_completed(job_id)
317
+ continue
318
# NOTE(orozery): defer the store to the beginning of the next
319
# engine step, so that offloading starts AFTER transfers related
320
# to token sampling, thereby avoiding delays to token generation.
vllm/v1/kv_offload/base.py
+1
@@ -496,6 +496,7 @@ class OffloadingSpec(ABC):
496
)
497
self.config = config
498
self.extra_config = config.extra_config
499
+ self.replicated_layout: bool = False
500
self.kv_events_config = OffloadingKVEventsConfig(
501
enable_kv_cache_events=config.enable_kv_cache_events,
502
self_describing_kv_events=bool(
vllm/v1/kv_offload/config.py
+6
@@ -68,3 +68,9 @@ class OffloadingConfig:
68
model: OffloadingModelConfig
69
cache: OffloadingCacheConfig
70
parallel: OffloadingParallelConfig
71
+ # True when the offloaded bytes of every worker are expected to be
72
+ # byte-identical per block (pure-MLA model, single-node TP-only
73
+ # parallelism), enabling a single-copy host layout in backends that
74
+ # support it. Aggregate layout decision; per-layer replication metadata
75
+ # is planned for CanonicalKVCacheRef (#48408).
76
+ replicated_layout: bool = False
vllm/v1/kv_offload/cpu/spec.py
+8
-2
@@ -24,6 +24,7 @@ from vllm.v1.kv_offload.cpu.manager import CPUOffloadingManager
24
25
class CPUOffloadingSpec(OffloadingSpec):
26
BLOCK_SIZE_ALIGNMENT = 1
27
+ SUPPORTS_REPLICATED_LAYOUT = False
28
29
@classmethod
30
def build_metric_definitions(
@@ -85,12 +86,16 @@ class CPUOffloadingSpec(OffloadingSpec):
86
self.num_blocks = 0
87
self.kv_bytes_per_chunk = 0
88
self.cpu_page_size_per_worker = 0
89
+ self.replicated_layout = (
90
+ config.replicated_layout and self.SUPPORTS_REPLICATED_LAYOUT
91
+ )
92
if config.worker_kv_bytes_per_block > 0 and world_size > 0:
89
- kv_bytes_per_block = config.worker_kv_bytes_per_block * world_size
93
+ num_copies = 1 if self.replicated_layout else world_size
94
+ kv_bytes_per_block = config.worker_kv_bytes_per_block * num_copies
95
kv_bytes_per_chunk = kv_bytes_per_block * self.blocks_per_chunk
96
97
# calculate cpu_page_size_per_worker
93
- self.cpu_page_size_per_worker = kv_bytes_per_chunk // world_size
98
+ self.cpu_page_size_per_worker = kv_bytes_per_chunk // num_copies
99
100
# calculate num_blocks
101
aligned_kv_bytes_per_chunk = round_up(
@@ -102,6 +107,7 @@ class CPUOffloadingSpec(OffloadingSpec):
107
# kv_bytes_per_chunk. Note that this might contain
108
# some padding. i.e. each offloaded block is of the form,
109
# |--- W0-B0---|---- W1-B0---| ... |---- Wn-B0---| *** maybe-pad *** |
110
+ # or |--- B0 (single copy) ---| *** maybe-pad *** |
111
self.kv_bytes_per_chunk = aligned_kv_bytes_per_chunk
112
113
# scheduler-side
vllm/v1/kv_offload/tiering/spec.py
+7
-3
@@ -71,6 +71,7 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
71
"""
72
73
BLOCK_SIZE_ALIGNMENT = SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT
74
+ SUPPORTS_REPLICATED_LAYOUT = True
75
76
@classmethod
77
@override
@@ -229,10 +230,13 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
230
231
@override
232
def create_worker(self, kv_caches: CanonicalKVCaches) -> CPUOffloadingWorker:
232
- # Fold the global physical device index into the replica-local
233
- # [0, world_size) slot range.
233
world_size = self.config.parallel.world_size
235
- rank = torch.accelerator.current_device_index() % world_size
234
+ if self.replicated_layout:
235
+ rank = 0
236
+ else:
237
+ # Fold the global physical device index into the replica-local
238
+ # [0, world_size) slot range.
239
+ rank = torch.accelerator.current_device_index() % world_size
240
worker_mmap = SharedOffloadRegion(
241
engine_id=self._engine_id,
242
num_blocks=self.num_blocks,