[KV Connector] Canonical CPU layout for parallelism-agnostic KV offload (#48414)

Signed-off-by: Itay Etelis <itay.etelis@ibm.com> Co-authored-by: Itay Etelis <itay.etelis@ibm.com>

Itay Etelis committed Aug 10, 2026 at 10:44 UTC 81840a172f2886f4c59dafd9dce9917bb789280c
12 files changed +891 -103
tests/v1/kv_connector/unit/offloading_connector/test_config.py
+58
@@ -47,6 +47,9 @@ def _make_vllm_config(
47 config.cache_config.cache_dtype = torch.float16
48 config.model_config.model = "test-model"
49 config.model_config.use_mla = False
50 + # _full_attention_spec's heads at tp=1: the parallelism-agnostic gate
51 + # requires the head shard to cover the model's KV heads exactly
52 + config.model_config.get_total_num_kv_heads.return_value = 4
53 world_size = (
54 tensor_parallel_size * pipeline_parallel_size * prefill_context_parallel_size
55 )
@@ -603,6 +606,61 @@ def test_parallelism_agnostic_excluded(kv_cache_groups: list[KVCacheGroupSpec]):
606 assert not _parallelism_agnostic(kv_cache_groups)
607
608
609 +def test_canonical_layout_widens_parallelism_agnostic_to_mla():
610 + """The canonical layout dedups the TP-replicated MLA latent into one
611 + portable copy, so the gate admits MLA — but only when canonical_layout
612 + is requested."""
613 + mla_groups = [KVCacheGroupSpec(["l0"], _mla_spec(head_size=576))]
614 + assert not _parallelism_agnostic(mla_groups)
615 +
616 + config = _make_vllm_config(extra_config={"canonical_layout": True})
617 + kv_cache_config = KVCacheConfig(
618 + num_blocks=0,
619 + kv_cache_tensors=[],
620 + kv_cache_groups=mla_groups,
621 + )
622 + offloading_config = build_offloading_config(config, kv_cache_config)
623 + assert offloading_config.parallel.is_parallelism_agnostic
624 + assert offloading_config.canonical_layout
625 +
626 + # hybrid groupings stay out: their non-full-attention layers can only
627 + # derive opaque (exact-topology) mappings
628 + hybrid_config = KVCacheConfig(
629 + num_blocks=0,
630 + kv_cache_tensors=[],
631 + kv_cache_groups=[
632 + KVCacheGroupSpec(["l0"], _full_attention_spec()),
633 + KVCacheGroupSpec(["l1"], _full_attention_spec()),
634 + ],
635 + )
636 + assert not build_offloading_config(
637 + config, hybrid_config
638 + ).parallel.is_parallelism_agnostic
639 +
640 +
641 +def test_canonical_layout_certifies_v2_model_runner():
642 + """Canonical bytes are certified per layer against live tensor strides at
643 + registration, so the static gate must not depend on the model-runner
644 + version — the v2 runner is the case the canonical layout exists for."""
645 + kv_cache_config = KVCacheConfig(
646 + num_blocks=0,
647 + kv_cache_tensors=[],
648 + kv_cache_groups=[KVCacheGroupSpec(["l0"], _full_attention_spec())],
649 + )
650 +
651 + config = _make_vllm_config()
652 + config.use_v2_model_runner = True
653 + assert not build_offloading_config(
654 + config, kv_cache_config
655 + ).parallel.is_parallelism_agnostic
656 +
657 + config = _make_vllm_config(extra_config={"canonical_layout": True})
658 + config.use_v2_model_runner = True
659 + assert build_offloading_config(
660 + config, kv_cache_config
661 + ).parallel.is_parallelism_agnostic
662 +
663 +
664 def test_parallelism_agnostic_disabled_on_v2_model_runner():
665 config = _make_vllm_config()
666 config.use_v2_model_runner = True
tests/v1/kv_offload/cpu/test_canonical_layout.py new
+274
@@ -0,0 +1,274 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +import time
4 +import uuid
5 +
6 +import numpy as np
7 +import pytest
8 +import torch
9 +
10 +from vllm.v1.kv_offload.base import (
11 + CanonicalKVCacheRef,
12 + CanonicalPageMapping,
13 + CopyRun,
14 + GPULoadStoreSpec,
15 +)
16 +from vllm.v1.kv_offload.cpu.common import CPULoadStoreSpec
17 +from vllm.v1.kv_offload.cpu.gpu_worker import (
18 + SingleDirectionOffloadingHandler,
19 + _build_copy_plan,
20 + _canonical_block_sizes,
21 + _canonical_page_ids,
22 + pin_mmap_region,
23 +)
24 +from vllm.v1.kv_offload.cpu.shared_offload_region import SharedOffloadRegion
25 +
26 +
27 +def _ref(mapping: CanonicalPageMapping, tensor_idx: int = 0) -> CanonicalKVCacheRef:
28 + return CanonicalKVCacheRef(
29 + tensor_idx=tensor_idx,
30 + page_size_bytes=mapping.local_page_size_bytes,
31 + mapping=mapping,
32 + )
33 +
34 +
35 +def _nhd_mapping() -> CanonicalPageMapping:
36 + # 4-token NHD page at tp=4, rank 2: K and V runs of 4x128B fragments
37 + runs = (
38 + CopyRun(0, 256, 128, 4, 128, 512),
39 + CopyRun(512, 2304, 128, 4, 128, 512),
40 + )
41 + return CanonicalPageMapping(4096, 1024, runs, 1, 0, True)
42 +
43 +
44 +def test_copy_plan_unrolls_runs():
45 + plan = _build_copy_plan(_ref(_nhd_mapping()), gpu_to_cpu=True)
46 + k_dst = [256, 768, 1280, 1792]
47 + assert plan.frag_offsets_src.tolist() == [0, 128, 256, 384, 512, 640, 768, 896]
48 + assert plan.frag_offsets_dst.tolist() == k_dst + [2048 + o for o in k_dst]
49 + assert plan.frag_sizes.tolist() == [128] * 8
50 + assert plan.num_frags == 8
51 +
52 +
53 +def test_load_direction_swaps_offsets():
54 + store = _build_copy_plan(_ref(_nhd_mapping()), gpu_to_cpu=True)
55 + load = _build_copy_plan(_ref(_nhd_mapping()), gpu_to_cpu=False)
56 + assert np.array_equal(store.frag_offsets_src, load.frag_offsets_dst)
57 + assert np.array_equal(store.frag_offsets_dst, load.frag_offsets_src)
58 + assert np.array_equal(store.frag_sizes, load.frag_sizes)
59 +
60 +
61 +def test_writer_rotation_matches_is_writer():
62 + # Replicas take turns writing shared canonical pages, keyed by the
63 + # CPU-side canonical page id; the enumeration must agree with is_writer
64 + identity = CopyRun(0, 0, 256, 1, 256, 256)
65 + mapping = CanonicalPageMapping(256, 256, (identity,), 2, 1, True)
66 + ids = _canonical_page_ids(
67 + np.array([3, 7, 9]), blocks_per_chunk=4, count=10, skip_count=2
68 + )
69 + assert ids.tolist() == [14, 15, 28, 29, 30, 31, 36, 37, 38, 39]
70 + mask = ids % mapping.num_writers == mapping.writer_index
71 + assert mask.tolist() == [mapping.is_writer(int(b)) for b in ids]
72 +
73 +
74 +def test_canonical_block_sizes_take_max_per_tensor():
75 + identity = CopyRun(0, 0, 512, 1, 512, 512)
76 + small = CanonicalPageMapping(2048, 512, (identity,), 1, 0, False)
77 + refs = [[_ref(_nhd_mapping(), 0), _ref(small, 0)], [_ref(small, 1)]]
78 + assert _canonical_block_sizes(refs, 2) == [4096, 2048]
79 +
80 +
81 +def _tp2_rank_mapping(rank: int) -> CanonicalPageMapping:
82 + # 4-token NHD page, 4 total heads of 64B, tp=2: rank holds 2 heads,
83 + # so K and V each scatter as 4 per-token 128B fragments
84 + runs = (
85 + CopyRun(0, rank * 128, 128, 4, 128, 256),
86 + CopyRun(512, 1024 + rank * 128, 128, 4, 128, 256),
87 + )
88 + return CanonicalPageMapping(2048, 1024, runs, 1, 0, True)
89 +
90 +
91 +def _whole_page_mapping() -> CanonicalPageMapping:
92 + identity = CopyRun(0, 0, 2048, 1, 2048, 2048)
93 + return CanonicalPageMapping(2048, 2048, (identity,), 1, 0, True)
94 +
95 +
96 +def _transfer(handler, num_blocks: int, gpu_to_cpu: bool) -> None:
97 + block_ids = list(range(num_blocks))
98 + gpu_spec = GPULoadStoreSpec(
99 + block_ids, group_sizes=(num_blocks,), block_indices=(0,)
100 + )
101 + cpu_spec = CPULoadStoreSpec(block_ids)
102 + src, dst = (gpu_spec, cpu_spec) if gpu_to_cpu else (cpu_spec, gpu_spec)
103 + assert handler.transfer_async(0, src, dst)
104 + deadline = time.time() + 30
105 + while time.time() < deadline:
106 + if handler.get_finished():
107 + return
108 + time.sleep(0.001)
109 + raise TimeoutError("transfer did not complete")
110 +
111 +
112 +def _canonical_handler(gpu_tensor, cpu_tensor, mapping, gpu_to_cpu):
113 + page = mapping.local_page_size_bytes
114 + refs = [[CanonicalKVCacheRef(tensor_idx=0, page_size_bytes=page, mapping=mapping)]]
115 + return SingleDirectionOffloadingHandler(
116 + gpu_tensors=[gpu_tensor],
117 + cpu_tensors=[cpu_tensor],
118 + blocks_per_chunk=1,
119 + layer_refs_per_group=refs,
120 + gpu_to_cpu=gpu_to_cpu,
121 + canonical_layout=True,
122 + )
123 +
124 +
125 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
126 +def test_gpu_roundtrip_assembles_canonical_page_across_ranks():
127 + """Two TP2-style rank handlers must scatter into one shared canonical
128 + CPU page such that each rank reloads bit-exact and a whole-page (TP1)
129 + reader sees both shards — the cross-topology contract."""
130 + torch.manual_seed(0)
131 + num_blocks = 4
132 + gpu_rank = [
133 + torch.randint(-128, 128, (num_blocks, 1024), dtype=torch.int8, device="cuda")
134 + for _ in range(2)
135 + ]
136 + cpu_canonical = torch.zeros(num_blocks, 2048, dtype=torch.int8, pin_memory=True)
137 +
138 + for rank in (0, 1):
139 + store = _canonical_handler(
140 + gpu_rank[rank], cpu_canonical, _tp2_rank_mapping(rank), gpu_to_cpu=True
141 + )
142 + _transfer(store, num_blocks, gpu_to_cpu=True)
143 + torch.accelerator.synchronize()
144 +
145 + # independent oracle: replay each rank's runs in numpy
146 + expected = np.zeros((num_blocks, 2048), dtype=np.int8)
147 + for rank in (0, 1):
148 + local = gpu_rank[rank].cpu().numpy()
149 + for run in _tp2_rank_mapping(rank).runs:
150 + for i in range(run.num_fragments):
151 + lo = run.local_offset + i * run.local_stride
152 + co = run.canonical_offset + i * run.canonical_stride
153 + expected[:, co : co + run.fragment_size] = local[
154 + :, lo : lo + run.fragment_size
155 + ]
156 + assert np.array_equal(cpu_canonical.numpy(), expected)
157 +
158 + # each rank reloads its shard bit-exact
159 + gpu_back = torch.zeros(num_blocks, 1024, dtype=torch.int8, device="cuda")
160 + load = _canonical_handler(
161 + gpu_back, cpu_canonical, _tp2_rank_mapping(0), gpu_to_cpu=False
162 + )
163 + _transfer(load, num_blocks, gpu_to_cpu=False)
164 + torch.accelerator.synchronize()
165 + assert torch.equal(gpu_back, gpu_rank[0])
166 +
167 + # a whole-page reader (TP1 topology) sees the assembled page
168 + gpu_full = torch.zeros(num_blocks, 2048, dtype=torch.int8, device="cuda")
169 + load_full = _canonical_handler(
170 + gpu_full, cpu_canonical, _whole_page_mapping(), gpu_to_cpu=False
171 + )
172 + _transfer(load_full, num_blocks, gpu_to_cpu=False)
173 + torch.accelerator.synchronize()
174 + assert torch.equal(gpu_full.cpu(), cpu_canonical)
175 +
176 +
177 +# 4-token NHD page, 4 total heads of 64 bytes: canonical page holds
178 +# [K: token x head][V: token x head] = 2048 bytes per block
179 +_TOTAL_HEADS = 4
180 +_HEAD_BYTES = 64
181 +_BLOCK_TOKENS = 4
182 +_CANONICAL_PAGE = 2 * _BLOCK_TOKENS * _TOTAL_HEADS * _HEAD_BYTES
183 +
184 +
185 +def _nhd_shard_mapping(tp: int, rank: int) -> CanonicalPageMapping:
186 + """Rank's head-shard mapping into the canonical NHD page at the given tp."""
187 + local_heads = _TOTAL_HEADS // tp
188 + frag = local_heads * _HEAD_BYTES
189 + canonical_row = _TOTAL_HEADS * _HEAD_BYTES
190 + k_run = CopyRun(0, rank * frag, frag, _BLOCK_TOKENS, frag, canonical_row)
191 + v_run = CopyRun(
192 + _BLOCK_TOKENS * frag,
193 + _BLOCK_TOKENS * canonical_row + rank * frag,
194 + frag,
195 + _BLOCK_TOKENS,
196 + frag,
197 + canonical_row,
198 + )
199 + return CanonicalPageMapping(
200 + _CANONICAL_PAGE, 2 * _BLOCK_TOKENS * frag, (k_run, v_run), 1, 0, True
201 + )
202 +
203 +
204 +def _head_shard(full_kv: torch.Tensor, tp: int, rank: int) -> torch.Tensor:
205 + """This rank's local page rows out of the (blocks, 2, tokens, heads,
206 + head_bytes) ground truth."""
207 + local_heads = _TOTAL_HEADS // tp
208 + shard = full_kv[:, :, :, rank * local_heads : (rank + 1) * local_heads, :]
209 + return shard.reshape(full_kv.shape[0], -1).contiguous()
210 +
211 +
212 +@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
213 +@pytest.mark.parametrize("writer_tp,reader_tp", [(2, 4), (4, 2), (2, 1), (4, 4)])
214 +def test_cross_topology_roundtrip(writer_tp: int, reader_tp: int):
215 + """KV written at one tp must be readable at another: writer ranks scatter
216 + head shards into a shared canonical region, reader ranks gather their own
217 + shards, and every reader must see the writers' ground-truth bytes."""
218 + torch.manual_seed(0)
219 + num_blocks = 3
220 + full_kv = torch.randint(
221 + -128,
222 + 128,
223 + (num_blocks, 2, _BLOCK_TOKENS, _TOTAL_HEADS, _HEAD_BYTES),
224 + dtype=torch.int8,
225 + )
226 +
227 + engine_id = str(uuid.uuid4())
228 + row_stride = SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT
229 + assert row_stride >= _CANONICAL_PAGE
230 + regions: list[SharedOffloadRegion] = []
231 +
232 + def canonical_view(rank: int, world_size: int) -> torch.Tensor:
233 + region = SharedOffloadRegion(
234 + engine_id=engine_id,
235 + num_blocks=num_blocks,
236 + rank=rank,
237 + kv_bytes_per_block=row_stride,
238 + cpu_page_size=row_stride // world_size,
239 + )
240 + regions.append(region)
241 + # The Triton load path dereferences CPU pointers on the GPU, which is
242 + # only legal on pinned memory; production pins via CPUOffloadingWorker
243 + pin_mmap_region(region)
244 + return region.create_next_canonical_view(_CANONICAL_PAGE)
245 +
246 + try:
247 + for rank in range(writer_tp):
248 + store = _canonical_handler(
249 + _head_shard(full_kv, writer_tp, rank).cuda(),
250 + canonical_view(rank, writer_tp),
251 + _nhd_shard_mapping(writer_tp, rank),
252 + gpu_to_cpu=True,
253 + )
254 + _transfer(store, num_blocks, gpu_to_cpu=True)
255 + torch.accelerator.synchronize()
256 +
257 + for rank in range(reader_tp):
258 + expected = _head_shard(full_kv, reader_tp, rank)
259 + gpu_out = torch.zeros_like(expected, device="cuda")
260 + load = _canonical_handler(
261 + gpu_out,
262 + canonical_view(rank, reader_tp),
263 + _nhd_shard_mapping(reader_tp, rank),
264 + gpu_to_cpu=False,
265 + )
266 + _transfer(load, num_blocks, gpu_to_cpu=False)
267 + torch.accelerator.synchronize()
268 + assert torch.equal(gpu_out.cpu(), expected), (
269 + f"reader tp={reader_tp} rank={rank} bytes diverge from the "
270 + f"tp={writer_tp} writers' ground truth"
271 + )
272 + finally:
273 + for region in regions:
274 + region.cleanup()
tests/v1/kv_offload/cpu/test_gpu_worker.py
+2 -2
@@ -195,7 +195,7 @@ def test_transfer(
195 assert finished[0].success
196 assert finished[0].transfer_size == (
197 len(gpu_blocks)
198 - * sum([x.page_size_bytes for x in handler.kv_cache_groups_data_refs[0]])
198 + * sum([x.page_size_bytes for x in handler.layer_refs_per_group[0]])
199 )
200 assert finished[0].transfer_time > 0
201 assert finished[0].transfer_time < (time.time() - start_time)
@@ -409,7 +409,7 @@ def test_transfer_multi_group(
409 expected_bytes = sum(
410 group_size * sum([x.page_size_bytes for x in data_refs])
411 for group_size, data_refs in zip(
412 - group_sizes, handler.kv_cache_groups_data_refs
412 + group_sizes, handler.layer_refs_per_group
413 )
414 )
415 assert finished[0].transfer_size == expected_bytes
tests/v1/kv_offload/cpu/test_shared_offload_region.py
+48 -46
@@ -173,7 +173,7 @@ def _mp_race_construct_and_write(
173 kv_bytes_per_block=num_workers * cpu_page_size,
174 cpu_page_size=cpu_page_size,
175 )
176 - t = region.create_next_view(cpu_page_size)
176 + t = region.create_next_worker_view(cpu_page_size)
177 t[:, :] = fill_value
178 done_queue.put({"rank": rank, "error": None})
179 cleanup_queue.get() # wait for parent's verification to finish
@@ -190,110 +190,112 @@ def iid():
190
191
192 # ---------------------------------------------------------------------------
193 -# create_next_view — shape, stride and storage offset
193 +# create_next_worker_view — shape, stride and storage offset
194 # ---------------------------------------------------------------------------
195
196
197 -def test_create_next_view_shape_and_stride(iid):
197 +def test_create_next_worker_view_shape_and_stride(iid):
198 """Returned tensor must have shape (num_blocks, tensor_page_size) and
199 stride (row_stride, 1) where row_stride = cpu_page_size * num_workers."""
200 with _region(iid, num_blocks=4, cpu_page_size=2 * PAGE_SIZE) as r:
201 - t = r.create_next_view(PAGE_SIZE)
201 + t = r.create_next_worker_view(PAGE_SIZE)
202 assert t.shape == (4, PAGE_SIZE)
203 # num_workers=1 → row_stride = cpu_page_size
204 assert t.stride() == (2 * PAGE_SIZE, 1)
205 del t
206
207
208 -def test_create_next_view_storage_offset_rank0(iid):
208 +def test_create_next_worker_view_storage_offset_rank0(iid):
209 """rank=0 worker's first tensor must start at byte 0 of the mmap."""
210 with _region(iid, cpu_page_size=PAGE_SIZE, num_workers=2, rank=0) as r:
211 - t = r.create_next_view(PAGE_SIZE)
211 + t = r.create_next_worker_view(PAGE_SIZE)
212 assert t.data_ptr() == r._base.data_ptr() # storage_offset == 0
213 del t
214
215
216 -def test_create_next_view_storage_offset_rank1(iid):
216 +def test_create_next_worker_view_storage_offset_rank1(iid):
217 """rank=1 worker's first tensor must start cpu_page_size bytes into the mmap."""
218 with _multi_region(iid, num_workers=2, num_blocks=4) as (r0, r1):
219 - t1 = r1.create_next_view(PAGE_SIZE)
219 + t1 = r1.create_next_worker_view(PAGE_SIZE)
220 assert t1.data_ptr() == r1._base.data_ptr() + PAGE_SIZE
221 del t1
222
223
224 -def test_create_next_view_row_stride_with_multiple_workers(iid):
224 +def test_create_next_worker_view_row_stride_with_multiple_workers(iid):
225 """With num_workers=4, row_stride must be 4 * cpu_page_size."""
226 with _region(iid, num_blocks=2, num_workers=4) as r:
227 - t = r.create_next_view(PAGE_SIZE)
227 + t = r.create_next_worker_view(PAGE_SIZE)
228 assert t.stride(0) == 4 * PAGE_SIZE
229 del t
230
231
232 # ---------------------------------------------------------------------------
233 -# create_next_view — cursor advancement
233 +# create_next_worker_view — cursor advancement
234 # ---------------------------------------------------------------------------
235
236
237 -def test_create_next_view_cursor_advances(iid):
238 - """Each call to create_next_view must advance _worker_offset by tensor_page_size."""
237 +def test_create_next_worker_view_cursor_advances(iid):
238 + """Each create_next_worker_view call must advance _worker_offset by
239 + tensor_page_size."""
240 with _region(iid, cpu_page_size=3 * PAGE_SIZE) as r:
241 assert r._worker_offset == 0
241 - r.create_next_view(PAGE_SIZE)
242 + r.create_next_worker_view(PAGE_SIZE)
243 assert r._worker_offset == PAGE_SIZE
243 - r.create_next_view(PAGE_SIZE)
244 + r.create_next_worker_view(PAGE_SIZE)
245 assert r._worker_offset == 2 * PAGE_SIZE
245 - r.create_next_view(PAGE_SIZE)
246 + r.create_next_worker_view(PAGE_SIZE)
247 assert r._worker_offset == 3 * PAGE_SIZE # exactly at area end
248
249
249 -def test_create_next_view_exact_fill_succeeds(iid):
250 +def test_create_next_worker_view_exact_fill_succeeds(iid):
251 """Allocations whose total exactly equals cpu_page_size must all succeed."""
252 with _region(iid, cpu_page_size=2 * PAGE_SIZE) as r:
252 - r.create_next_view(PAGE_SIZE) # first half
253 - r.create_next_view(PAGE_SIZE) # fills to area end — must not raise
253 + r.create_next_worker_view(PAGE_SIZE) # first half
254 + r.create_next_worker_view(PAGE_SIZE) # fills to area end — must not raise
255
256
257 # ---------------------------------------------------------------------------
257 -# create_next_view — overflow guard
258 +# create_next_worker_view — overflow guard
259 # ---------------------------------------------------------------------------
260
261
261 -def test_create_next_view_single_overflow_raises(iid):
262 +def test_create_next_worker_view_single_overflow_raises(iid):
263 """A single allocation larger than cpu_page_size must raise AssertionError."""
264 with (
265 _region(iid) as r,
266 pytest.raises(AssertionError, match="exceeds worker area end"),
267 ):
267 - r.create_next_view(PAGE_SIZE + 1)
268 + r.create_next_worker_view(PAGE_SIZE + 1)
269
270
270 -def test_create_next_view_cumulative_overflow_raises(iid):
271 +def test_create_next_worker_view_cumulative_overflow_raises(iid):
272 """Successive allocations that cumulatively exceed cpu_page_size must raise."""
273 with _region(iid, cpu_page_size=2 * PAGE_SIZE) as r:
273 - r.create_next_view(PAGE_SIZE) # ok — half used
274 - r.create_next_view(PAGE_SIZE) # ok — full
274 + r.create_next_worker_view(PAGE_SIZE) # ok — half used
275 + r.create_next_worker_view(PAGE_SIZE) # ok — full
276 with pytest.raises(AssertionError, match="exceeds worker area end"):
276 - r.create_next_view(1) # one byte too many
277 + r.create_next_worker_view(1) # one byte too many
278
279
279 -def test_create_next_view_overflow_does_not_mutate_cursor(iid):
280 - """A failed create_next_view must leave _worker_offset unchanged."""
280 +def test_create_next_worker_view_overflow_does_not_mutate_cursor(iid):
281 + """A failed create_next_worker_view must leave _worker_offset unchanged."""
282 with _region(iid) as r:
283 offset_before = r._worker_offset
284 with pytest.raises(AssertionError):
284 - r.create_next_view(PAGE_SIZE + 1)
285 + r.create_next_worker_view(PAGE_SIZE + 1)
286 assert r._worker_offset == offset_before
287
288
289 # ---------------------------------------------------------------------------
289 -# create_next_view — data correctness and layout
290 +# create_next_worker_view — data correctness and layout
291 # ---------------------------------------------------------------------------
292
293
293 -def test_create_next_view_write_visible_in_raw_mmap(iid):
294 - """Writes into a create_next_view view must appear at the correct raw mmap offset"""
294 +def test_create_next_worker_view_write_visible_in_raw_mmap(iid):
295 + """Writes into a create_next_worker_view view must appear at the correct
296 + raw mmap offset"""
297 with _region(iid, num_blocks=4) as r:
296 - t = r.create_next_view(PAGE_SIZE)
298 + t = r.create_next_worker_view(PAGE_SIZE)
299 t[2, :] = 42 # write to block row 2
300
301 raw = memoryview(r.mmap_obj)
@@ -303,11 +305,11 @@ def test_create_next_view_write_visible_in_raw_mmap(iid):
305 del raw, t
306
307
306 -def test_create_next_view_multi_tensor_layout(iid):
308 +def test_create_next_worker_view_multi_tensor_layout(iid):
309 """Two tensors from the same worker land at consecutive byte offsets per row."""
310 with _region(iid, num_blocks=2, cpu_page_size=2 * PAGE_SIZE) as r:
309 - ta = r.create_next_view(PAGE_SIZE)
310 - tb = r.create_next_view(PAGE_SIZE)
311 + ta = r.create_next_worker_view(PAGE_SIZE)
312 + tb = r.create_next_worker_view(PAGE_SIZE)
313
314 ta[:, :] = 1
315 tb[:, :] = 2
@@ -322,8 +324,8 @@ def test_create_next_view_multi_tensor_layout(iid):
324 del raw, ta, tb
325
326
325 -def test_create_next_view_multiprocess_slots(iid):
326 - """Each worker process calls create_next_view and writes distinct data;
327 +def test_create_next_worker_view_multiprocess_slots(iid):
328 + """Each worker process calls create_next_worker_view and writes distinct data;
329 the parent verifies each slot lands at the correct interleaved offset."""
330 num_workers = 2
331 num_blocks = 4
@@ -356,7 +358,7 @@ def test_create_next_view_multiprocess_slots(iid):
358 )
359 child.start()
360
359 - t0 = region.create_next_view(PAGE_SIZE)
361 + t0 = region.create_next_worker_view(PAGE_SIZE)
362 t0[:, :] = 11
363
364 result = done_queue.get(timeout=30)
@@ -379,13 +381,13 @@ def test_create_next_view_multiprocess_slots(iid):
381 _cleanup_file(region.mmap_path)
382
383
382 -def test_create_next_view_worker_isolation(iid):
384 +def test_create_next_worker_view_worker_isolation(iid):
385 """Writes by worker 0 must not affect worker 1's slot and vice versa."""
386 num_workers = 2
387 num_blocks = 4
388 with _multi_region(iid, num_workers=num_workers, num_blocks=num_blocks) as regions:
387 - t0 = regions[0].create_next_view(PAGE_SIZE)
388 - t1 = regions[1].create_next_view(PAGE_SIZE)
389 + t0 = regions[0].create_next_worker_view(PAGE_SIZE)
390 + t1 = regions[1].create_next_worker_view(PAGE_SIZE)
391
392 t0[:, :] = 11
393 t1[:, :] = 22
@@ -712,14 +714,14 @@ def test_cleanup_idempotent(iid):
714 r.cleanup() # must be a no-op
715
716
715 -def test_cleanup_after_create_next_view_releases_mmap(iid):
716 - """cleanup() must close the mmap even after create_next_view was called.
717 - create_next_view returns a view that shares storage with _base; both must be
717 +def test_cleanup_after_create_next_worker_view_releases_mmap(iid):
718 + """cleanup() must close the mmap even after create_next_worker_view was called.
719 + create_next_worker_view returns a view that shares storage with _base; both must be
720 released before mmap.close() can succeed."""
721 r = _make_region(iid)
722 mmap_obj = r.mmap_obj
723
722 - t = r.create_next_view(PAGE_SIZE)
724 + t = r.create_next_worker_view(PAGE_SIZE)
725 del t
726
727 r.cleanup()
tests/v1/kv_offload/test_file_mapper.py
+17
@@ -52,6 +52,7 @@ def make_mapper_from_offloading_spec(**kwargs) -> FileMapper:
52 is_parallelism_agnostic=kwargs.get("is_parallelism_agnostic", False),
53 ),
54 replicated_layout=kwargs.get("replicated_layout", False),
55 + canonical_layout=kwargs.get("canonical_layout", False),
56 )
57 spec = MagicMock(spec=OffloadingSpec)
58 spec.config = config
@@ -208,6 +209,22 @@ def test_parallel_agnostic_separates_persistent_layouts():
209 assert specific.fields["parallel_agnostic"] is False
210
211
212 +def test_canonical_layout_changes_storage_namespace():
213 + # Canonical bytes are not interchangeable with the direct layout, so the
214 + # format id must fork the storage namespace.
215 + from vllm.v1.attention.backends.utils import set_kv_cache_layout
216 +
217 + set_kv_cache_layout("NHD")
218 + try:
219 + direct = make_mapper_from_offloading_spec()
220 + canonical = make_mapper_from_offloading_spec(canonical_layout=True)
221 + finally:
222 + set_kv_cache_layout(None)
223 + assert "canonical_format" not in direct.fields
224 + assert canonical.fields["canonical_format"] == "v1-nhd"
225 + assert direct.base_path != canonical.base_path
226 +
227 +
228 # ---------------------------------------------------------------------------
229 # replicated_layout: OR'd into parallel-agnostic identity for compact rows
230 # ---------------------------------------------------------------------------
vllm/distributed/kv_transfer/kv_connector/v1/offloading/canonical_mapping.py
+12
@@ -27,6 +27,18 @@ from vllm.v1.kv_offload.base import CanonicalPageMapping, CopyRun
27 if TYPE_CHECKING:
28 from vllm.config import VllmConfig
29
30 +# Version of the canonical byte format; bump on any layout change
31 +CANONICAL_FORMAT_VERSION = 1
32 +
33 +
34 +def canonical_format_id() -> str:
35 + """Identity of the canonical byte format, for namespacing persisted KV.
36 + Canonical pages keep the worker's KV layout family, so the id couples the
37 + format version with that family; consumers must match it exactly."""
38 + from vllm.v1.attention.backends.utils import get_kv_cache_layout
39 +
40 + return f"v{CANONICAL_FORMAT_VERSION}-{get_kv_cache_layout().lower()}"
41 +
42
43 @dataclass(frozen=True)
44 class _RankContext:
vllm/distributed/kv_transfer/kv_connector/v1/offloading/config.py
+41 -3
@@ -135,15 +135,52 @@ def build_offloading_config(
135 and parallel_config.nnodes_within_dp == 1
136 )
137
138 - # Only a single non-MLA full-attention group is parallelism-invariant:
139 - # MLA latent KV is replicated per rank (never head-sharded), and the V2
140 - # model runner's KV layout is not known to be parallelism-invariant.
138 + canonical_layout = bool(extra_config.get("canonical_layout", False))
139 +
140 + # Only a single non-MLA full-attention group with genuinely head-sharded
141 + # pages is parallelism-invariant: replicated latent or GQA heads,
142 + # per-token-head scales, CP token sharding, and the V2 model runner's
143 + # layout are all excluded.
144 is_parallelism_agnostic = (
145 not vllm_config.use_v2_model_runner
146 and single_group_spec is not None
147 and isinstance(single_group_spec, FullAttentionSpec)
148 and not isinstance(single_group_spec, MLAAttentionSpec)
149 + and single_group_spec.num_kv_heads * parallel_config.tensor_parallel_size
150 + == vllm_config.model_config.get_total_num_kv_heads()
151 + and not single_group_spec.kv_quant_mode.is_per_token_head
152 + and parallel_config.decode_context_parallel_size == 1
153 + and parallel_config.prefill_context_parallel_size == 1
154 )
155 + # Canonical pages are topology-free by construction, so the canonical
156 + # layout widens the gate to every config whose mappings derive portable:
157 + # exactly-sharded or replicated GQA heads (writer rotation) and the
158 + # TP-replicated MLA latent (one canonical copy for all replicas). The
159 + # model-runner version is irrelevant here — certification happens per
160 + # layer against live tensor strides at registration, and create_worker
161 + # fails closed against this flag if any layer cannot be certified.
162 + if canonical_layout and not is_parallelism_agnostic:
163 + tp_size = parallel_config.tensor_parallel_size
164 + if isinstance(single_group_spec, FullAttentionSpec):
165 + if type(single_group_spec) is MLAAttentionSpec:
166 + spec_certifiable = (
167 + single_group_spec.compress_ratio == 1
168 + and single_group_spec.real_page_size_bytes
169 + % single_group_spec.block_size
170 + == 0
171 + )
172 + else:
173 + total_kv_heads = vllm_config.model_config.get_total_num_kv_heads()
174 + spec_certifiable = (
175 + total_kv_heads % tp_size == 0 or tp_size % total_kv_heads == 0
176 + )
177 + is_parallelism_agnostic = (
178 + spec_certifiable
179 + and not single_group_spec.kv_quant_mode.is_per_token_head
180 + and parallel_config.decode_context_parallel_size == 1
181 + and parallel_config.prefill_context_parallel_size == 1
182 + and parallel_config.world_size == tp_size
183 + )
184
185 kv_events_config = vllm_config.kv_events_config
186 cache_dtype = (
@@ -179,4 +216,5 @@ def build_offloading_config(
216 is_parallelism_agnostic=is_parallelism_agnostic,
217 ),
218 replicated_layout=replicated_layout,
219 + canonical_layout=canonical_layout,
220 )
vllm/v1/kv_offload/config.py
+8 -2
@@ -48,8 +48,11 @@ class OffloadingParallelConfig:
48 dcp_size: int
49 # Data parallel replica index of this engine.
50 data_parallel_index: int
51 - # True when concatenating a block's data across all workers yields
52 - # the same result regardless of the parallelism configuration.
51 + # True when the bytes that will be persisted for a block are portable
52 + # across parallelism configurations: for the direct layout, concatenating
53 + # the block's data across all workers in rank order yields the same bytes
54 + # under any topology; for the canonical layout, the canonical page itself
55 + # is topology-free.
56 is_parallelism_agnostic: bool
57
58
@@ -74,3 +77,6 @@ class OffloadingConfig:
77 # support it. Aggregate layout decision; per-layer replication metadata
78 # is planned for CanonicalKVCacheRef (#48408).
79 replicated_layout: bool = False
80 + # True when the canonical per-layer host byte layout was requested via
81 + # kv_connector_extra_config; certified per-layer at worker registration.
82 + canonical_layout: bool = False
vllm/v1/kv_offload/cpu/gpu_worker.py
+328 -49
@@ -3,7 +3,9 @@
3 import functools
4 import time
5 from collections import deque
6 +from collections.abc import Sequence
7 from dataclasses import dataclass
8 +from typing import NamedTuple
9
10 import numpy as np
11 import torch
@@ -18,6 +20,7 @@ from vllm.v1.kv_offload.base import (
20 BlockIDsLoadStoreSpec,
21 CanonicalKVCacheRef,
22 CanonicalKVCaches,
23 + CanonicalPageMapping,
24 GPULoadStoreSpec,
25 LoadStoreSpec,
26 OffloadingWorker,
@@ -33,7 +36,7 @@ logger = init_logger(__name__)
36
37
38 def _select_swap_blocks_fn(
36 - kv_cache_groups_data_refs: list[list[CanonicalKVCacheRef]],
39 + layer_refs_per_group: list[list[CanonicalKVCacheRef]],
40 gpu_to_cpu: bool,
41 ):
42 """Resolve the swap_blocks function for a handler at init time."""
@@ -46,7 +49,7 @@ def _select_swap_blocks_fn(
49 # so the Triton kernel's tl.load(cpu_ptr) is invalid on XPU).
50 if not HAS_TRITON or current_platform.is_xpu() or current_platform.is_rocm():
51 return ops.swap_blocks_batch
49 - page_sizes = [r.page_size_bytes for g in kv_cache_groups_data_refs for r in g]
52 + page_sizes = [r.page_size_bytes for g in layer_refs_per_group for r in g]
53 # Triton wins only on small, 8-byte-aligned payloads.
54 if (
55 not page_sizes
@@ -120,6 +123,73 @@ def compute_sub_block_ptrs(
123 output[:] = flat[skip_count : skip_count + num_sub_blocks]
124
125
126 +class CopyPlan(NamedTuple):
127 + """Precomputed fragment-copy template for one data ref under the canonical
128 + CPU layout, unrolled from the ref's mapped runs. Offsets are relative to
129 + the per-block base pointers on each side."""
130 +
131 + frag_offsets_src: np.ndarray
132 + frag_offsets_dst: np.ndarray
133 + frag_sizes: np.ndarray
134 + total_bytes: int
135 +
136 + @property
137 + def num_frags(self) -> int:
138 + return len(self.frag_sizes)
139 +
140 +
141 +def _build_copy_plan(ref: CanonicalKVCacheRef, gpu_to_cpu: bool) -> CopyPlan:
142 + """Unroll one data ref's mapped runs into a per-fragment CopyPlan."""
143 + mapping = ref.mapping
144 + assert mapping is not None
145 + local: list[int] = []
146 + canonical: list[int] = []
147 + sizes: list[int] = []
148 + for run in mapping.runs:
149 + for i in range(run.num_fragments):
150 + local.append(run.local_offset + i * run.local_stride)
151 + canonical.append(run.canonical_offset + i * run.canonical_stride)
152 + sizes.append(run.fragment_size)
153 + src, dst = (local, canonical) if gpu_to_cpu else (canonical, local)
154 + return CopyPlan(
155 + frag_offsets_src=np.asarray(src, dtype=np.uint64),
156 + frag_offsets_dst=np.asarray(dst, dtype=np.uint64),
157 + frag_sizes=np.asarray(sizes, dtype=np.int64),
158 + total_bytes=sum(sizes),
159 + )
160 +
161 +
162 +def _canonical_page_ids(
163 + block_ids: np.ndarray, blocks_per_chunk: int, count: int, skip_count: int
164 +) -> np.ndarray:
165 + """Global canonical page ids matching compute_sub_block_ptrs' enumeration.
166 + These identify canonical pages consistently across ranks, so they key
167 + CanonicalPageMapping.is_writer rotation."""
168 + if blocks_per_chunk == 1:
169 + return block_ids[:count]
170 + flat = (
171 + block_ids[:, np.newaxis] * blocks_per_chunk + np.arange(blocks_per_chunk)
172 + ).ravel()
173 + return flat[skip_count : skip_count + count]
174 +
175 +
176 +def _canonical_block_sizes(
177 + layer_refs_per_group: list[list[CanonicalKVCacheRef]], num_tensors: int
178 +) -> list[int]:
179 + """Canonical CPU bytes per GPU block for each tensor, taken from the refs'
180 + mappings. Requires every ref to carry a mapping."""
181 + canonical_bytes_per_block = [0] * num_tensors
182 + for layer_refs in layer_refs_per_group:
183 + for ref in layer_refs:
184 + assert ref.mapping is not None
185 + canonical_bytes_per_block[ref.tensor_idx] = max(
186 + canonical_bytes_per_block[ref.tensor_idx],
187 + ref.mapping.canonical_page_size_bytes,
188 + )
189 + assert all(size > 0 for size in canonical_bytes_per_block)
190 + return canonical_bytes_per_block
191 +
192 +
193 def pin_mmap_region(region: SharedOffloadRegion) -> None:
194 """Register the entire mmap as CUDA pinned memory via cudaHostRegister."""
195 if not current_platform.is_cuda_alike():
@@ -176,9 +246,10 @@ class SingleDirectionOffloadingHandler:
246 gpu_tensors: list[torch.Tensor],
247 cpu_tensors: list[torch.Tensor],
248 blocks_per_chunk: int,
179 - kv_cache_groups_data_refs: list[list[CanonicalKVCacheRef]],
249 + layer_refs_per_group: list[list[CanonicalKVCacheRef]],
250 gpu_to_cpu: bool,
251 mmap_region: SharedOffloadRegion | None = None,
252 + canonical_layout: bool = False,
253 ):
254 """
255 Initialize a SingleDirectionOffloadingHandler.
@@ -189,14 +260,22 @@ class SingleDirectionOffloadingHandler:
260 cpu_tensors: list of CPU KV cache tensors.
261 Each of shape (num_cpu_blocks, cpu_page_size_bytes) with dtype int8.
262 Order should match gpu_tensors.
192 - kv_cache_groups_data_refs: list of CanonicalKVCacheRef per group.
263 + layer_refs_per_group: list of CanonicalKVCacheRef per group.
264 gpu_to_cpu: if True, transfer from GPU to CPU; otherwise CPU to GPU.
265 + canonical_layout: if True, CPU pages use the canonical layout
266 + described by the refs' mappings.
267 """
268 assert len(gpu_tensors) == len(cpu_tensors)
269 assert len(gpu_tensors) > 0
270
271 + canonical_bytes_per_block = (
272 + _canonical_block_sizes(layer_refs_per_group, len(gpu_tensors))
273 + if canonical_layout
274 + else None
275 + )
276 +
277 # assert input tensors are as expected
199 - for gpu_tensor, cpu_tensor in zip(gpu_tensors, cpu_tensors):
278 + for t_idx, (gpu_tensor, cpu_tensor) in enumerate(zip(gpu_tensors, cpu_tensors)):
279 assert gpu_tensor.dtype == torch.int8
280 assert gpu_tensor.ndim == 2
281 assert gpu_tensor.is_cuda or gpu_tensor.is_xpu
@@ -205,7 +284,12 @@ class SingleDirectionOffloadingHandler:
284 assert cpu_tensor.device.type == "cpu"
285 _, gpu_page_size = gpu_tensor.shape
286 _, cpu_page_size = cpu_tensor.shape
208 - assert cpu_page_size == gpu_page_size * blocks_per_chunk
287 + if canonical_bytes_per_block is not None:
288 + assert (
289 + cpu_page_size == canonical_bytes_per_block[t_idx] * blocks_per_chunk
290 + )
291 + else:
292 + assert cpu_page_size == gpu_page_size * blocks_per_chunk
293
294 self.src_tensors: list[torch.Tensor] = (
295 gpu_tensors if gpu_to_cpu else cpu_tensors
@@ -214,9 +298,9 @@ class SingleDirectionOffloadingHandler:
298 cpu_tensors if gpu_to_cpu else gpu_tensors
299 )
300 self.gpu_to_cpu: bool = gpu_to_cpu
217 - self.kv_cache_groups_data_refs = kv_cache_groups_data_refs
301 + self.layer_refs_per_group = layer_refs_per_group
302 self._swap_blocks_batch = _select_swap_blocks_fn(
219 - kv_cache_groups_data_refs, gpu_to_cpu
303 + layer_refs_per_group, gpu_to_cpu
304 )
305
306 # GPU blocks may be smaller
@@ -224,6 +308,24 @@ class SingleDirectionOffloadingHandler:
308 self.src_blocks_per_chunk = 1 if self.gpu_to_cpu else blocks_per_chunk
309 self.dst_blocks_per_chunk = blocks_per_chunk if self.gpu_to_cpu else 1
310
311 + # Per (group, ref) static copy plans for the canonical layout
312 + self._canonical_copy_plans: list[list[CopyPlan]] | None = (
313 + [
314 + [_build_copy_plan(ref, gpu_to_cpu) for ref in layer_refs]
315 + for layer_refs in layer_refs_per_group
316 + ]
317 + if canonical_layout
318 + else None
319 + )
320 + self._fill_group_ops = (
321 + self._fill_canonical_ops if canonical_layout else self._fill_direct_ops
322 + )
323 + # Reusable per-block base-pointer scratch for the canonical fill,
324 + # sized to the largest possible group (grown on demand)
325 + num_scratch_blocks = gpu_tensors[0].shape[0] if canonical_layout else 0
326 + self._scratch_bases_src = np.empty(num_scratch_blocks, dtype=np.uint64)
327 + self._scratch_bases_dst = np.empty(num_scratch_blocks, dtype=np.uint64)
328 +
329 # mmap_region to clean up on shutdown (gpu_to_cpu handler owns it)
330 self._mmap_region = mmap_region
331 # job_id -> event
@@ -237,6 +339,179 @@ class SingleDirectionOffloadingHandler:
339 # list of pinned descriptor buffer sets available for re-use
340 self._buffer_pool: list[tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = []
341
342 + def _estimate_max_copy_ops(self, group_sizes: Sequence[int]) -> int:
343 + """Upper bound on the number of copy descriptors for a transfer.
344 +
345 + Exact for the direct layout. The canonical path may fill fewer:
346 + writer rotation later drops the blocks this rank does not write."""
347 + num_copy_ops = 0
348 + for g_idx, (group_size, layer_refs) in enumerate(
349 + zip(group_sizes, self.layer_refs_per_group)
350 + ):
351 + if self._canonical_copy_plans is None:
352 + num_copy_ops += group_size * len(layer_refs)
353 + else:
354 + num_copy_ops += group_size * sum(
355 + plan.num_frags for plan in self._canonical_copy_plans[g_idx]
356 + )
357 + return num_copy_ops
358 +
359 + def _fill_direct_ops(
360 + self,
361 + g_idx: int,
362 + group_src: np.ndarray,
363 + group_dst: np.ndarray,
364 + group_size: int,
365 + src_skip_count: int,
366 + dst_skip_count: int,
367 + all_src: np.ndarray,
368 + all_dst: np.ndarray,
369 + all_sizes: np.ndarray,
370 + op_idx: int,
371 + ) -> tuple[int, int]:
372 + """Fill one group's copy descriptors for the direct (worker-private)
373 + layout: one whole-page copy per (block, ref).
374 +
375 + Returns (op_idx past the filled descriptors, bytes added)."""
376 + num_bytes = 0
377 + for data_ref in self.layer_refs_per_group[g_idx]:
378 + t_idx = data_ref.tensor_idx
379 + end_idx = op_idx + group_size
380 +
381 + compute_sub_block_ptrs(
382 + group_src,
383 + self.src_blocks_per_chunk,
384 + all_src[op_idx:end_idx],
385 + self.src_tensors[t_idx],
386 + skip_count=src_skip_count,
387 + )
388 + compute_sub_block_ptrs(
389 + group_dst,
390 + self.dst_blocks_per_chunk,
391 + all_dst[op_idx:end_idx],
392 + self.dst_tensors[t_idx],
393 + skip_count=dst_skip_count,
394 + )
395 +
396 + all_sizes[op_idx:end_idx] = data_ref.page_size_bytes
397 + num_bytes += group_size * data_ref.page_size_bytes
398 + op_idx = end_idx
399 + return op_idx, num_bytes
400 +
401 + def _fill_canonical_ops(
402 + self,
403 + g_idx: int,
404 + group_src: np.ndarray,
405 + group_dst: np.ndarray,
406 + group_size: int,
407 + src_skip_count: int,
408 + dst_skip_count: int,
409 + all_src: np.ndarray,
410 + all_dst: np.ndarray,
411 + all_sizes: np.ndarray,
412 + op_idx: int,
413 + ) -> tuple[int, int]:
414 + """Fill one group's copy descriptors for the canonical layout:
415 + scatter each block through the ref's precomputed CopyPlan, keeping
416 + only the blocks this rank writes.
417 +
418 + Returns (op_idx past the filled descriptors, bytes added)."""
419 + assert self._canonical_copy_plans is not None
420 + # Zero-copy reinterpretation for pointer arithmetic: uint64 and the
421 + # buffers' int64 are bit-equivalent for addresses
422 + all_src_u64 = all_src.view(np.uint64)
423 + all_dst_u64 = all_dst.view(np.uint64)
424 + if group_size > len(self._scratch_bases_src):
425 + self._scratch_bases_src = np.empty(group_size, dtype=np.uint64)
426 + self._scratch_bases_dst = np.empty(group_size, dtype=np.uint64)
427 +
428 + num_bytes = 0
429 + for plan, data_ref in zip(
430 + self._canonical_copy_plans[g_idx], self.layer_refs_per_group[g_idx]
431 + ):
432 + if plan.num_frags == 0:
433 + continue
434 + t_idx = data_ref.tensor_idx
435 +
436 + # 1. Base byte pointer of every block on each side
437 + block_bases_src = self._scratch_bases_src[:group_size]
438 + block_bases_dst = self._scratch_bases_dst[:group_size]
439 + compute_sub_block_ptrs(
440 + group_src,
441 + self.src_blocks_per_chunk,
442 + block_bases_src,
443 + self.src_tensors[t_idx],
444 + skip_count=src_skip_count,
445 + )
446 + compute_sub_block_ptrs(
447 + group_dst,
448 + self.dst_blocks_per_chunk,
449 + block_bases_dst,
450 + self.dst_tensors[t_idx],
451 + skip_count=dst_skip_count,
452 + )
453 +
454 + # 2. On store, keep only the blocks this rank is elected to write
455 + mapping = data_ref.mapping
456 + assert mapping is not None
457 + if self.gpu_to_cpu and mapping.num_writers > 1:
458 + block_bases_src, block_bases_dst = self._filter_writer_blocks(
459 + block_bases_src,
460 + block_bases_dst,
461 + mapping,
462 + group_dst,
463 + group_size,
464 + dst_skip_count,
465 + )
466 + num_active_blocks = len(block_bases_src)
467 +
468 + # 3. Expand (block base + fragment offset) into one descriptor
469 + # per (block, fragment), writing straight into the descriptor
470 + # buffers: reshaping a contiguous 1D slice is a view, so the
471 + # broadcasts below allocate nothing
472 + end_idx = op_idx + num_active_blocks * plan.num_frags
473 + np.add(
474 + block_bases_src[:, None],
475 + plan.frag_offsets_src[None, :],
476 + out=all_src_u64[op_idx:end_idx].reshape(
477 + num_active_blocks, plan.num_frags
478 + ),
479 + )
480 + np.add(
481 + block_bases_dst[:, None],
482 + plan.frag_offsets_dst[None, :],
483 + out=all_dst_u64[op_idx:end_idx].reshape(
484 + num_active_blocks, plan.num_frags
485 + ),
486 + )
487 + all_sizes[op_idx:end_idx].reshape(num_active_blocks, plan.num_frags)[:] = (
488 + plan.frag_sizes
489 + )
490 + num_bytes += num_active_blocks * plan.total_bytes
491 + op_idx = end_idx
492 + return op_idx, num_bytes
493 +
494 + def _filter_writer_blocks(
495 + self,
496 + block_bases_src: np.ndarray,
497 + block_bases_dst: np.ndarray,
498 + mapping: CanonicalPageMapping,
499 + group_dst: np.ndarray,
500 + group_size: int,
501 + dst_skip_count: int,
502 + ) -> tuple[np.ndarray, np.ndarray]:
503 + """Keep only the blocks this rank writes: replicated ranks take turns
504 + writing shared canonical pages, keyed by the rank-consistent CPU-side
505 + canonical page id."""
506 + cpu_page_ids = _canonical_page_ids(
507 + group_dst,
508 + self.dst_blocks_per_chunk,
509 + group_size,
510 + dst_skip_count,
511 + )
512 + writer_mask = cpu_page_ids % mapping.num_writers == mapping.writer_index
513 + return block_bases_src[writer_mask], block_bases_dst[writer_mask]
514 +
515 def transfer_async(
516 self, job_id: int, src_spec: LoadStoreSpec, dst_spec: LoadStoreSpec
517 ) -> bool:
@@ -274,17 +549,13 @@ class SingleDirectionOffloadingHandler:
549 gpu_spec = src_spec if self.gpu_to_cpu else dst_spec
550 assert isinstance(gpu_spec, GPULoadStoreSpec)
551 group_sizes = gpu_spec.group_sizes
277 - assert len(group_sizes) == len(self.kv_cache_groups_data_refs)
552 + assert len(group_sizes) == len(self.layer_refs_per_group)
553
554 # extract block indices from the GPU spec
555 block_indices = gpu_spec.block_indices
281 - assert len(block_indices) == len(self.kv_cache_groups_data_refs)
556 + assert len(block_indices) == len(self.layer_refs_per_group)
557
283 - num_copy_ops = 0
284 - for group_size, group_data_refs in zip(
285 - group_sizes, self.kv_cache_groups_data_refs
286 - ):
287 - num_copy_ops += group_size * len(group_data_refs)
558 + num_copy_ops = self._estimate_max_copy_ops(group_sizes)
559
560 # reuse a pooled buffer set, growing it if this transfer needs more room
561 batch_src, batch_dst, batch_sizes = (
@@ -307,8 +578,8 @@ class SingleDirectionOffloadingHandler:
578 op_idx = 0
579 # count total number of bytes copied
580 num_transfer_bytes = 0
310 - for group_size, block_idx, group_data_refs in zip(
311 - group_sizes, block_indices, self.kv_cache_groups_data_refs
581 + for g_idx, (group_size, block_idx) in enumerate(
582 + zip(group_sizes, block_indices)
583 ):
584 if group_size == 0:
585 continue
@@ -326,38 +597,31 @@ class SingleDirectionOffloadingHandler:
597 src_end_offset = src_offset + src_blocks_count
598 assert src_end_offset <= num_src_blocks
599
329 - group_src = src_blocks[src_offset:src_end_offset]
330 - group_dst = dst_blocks[dst_offset:dst_end_offset]
331 -
332 - for data_ref in group_data_refs:
333 - t_idx = data_ref.tensor_idx
334 - end_idx = op_idx + group_size
335 -
336 - compute_sub_block_ptrs(
337 - group_src,
338 - self.src_blocks_per_chunk,
339 - all_src[op_idx:end_idx],
340 - self.src_tensors[t_idx],
341 - skip_count=src_logical_blocks_to_skip,
342 - )
343 - compute_sub_block_ptrs(
344 - group_dst,
345 - self.dst_blocks_per_chunk,
346 - all_dst[op_idx:end_idx],
347 - self.dst_tensors[t_idx],
348 - skip_count=dst_logical_blocks_to_skip,
349 - )
350 -
351 - all_sizes[op_idx:end_idx] = data_ref.page_size_bytes
352 - num_transfer_bytes += group_size * data_ref.page_size_bytes
353 - op_idx = end_idx
600 + op_idx, group_bytes = self._fill_group_ops(
601 + g_idx,
602 + group_src=src_blocks[src_offset:src_end_offset],
603 + group_dst=dst_blocks[dst_offset:dst_end_offset],
604 + group_size=group_size,
605 + src_skip_count=src_logical_blocks_to_skip,
606 + dst_skip_count=dst_logical_blocks_to_skip,
607 + all_src=all_src,
608 + all_dst=all_dst,
609 + all_sizes=all_sizes,
610 + op_idx=op_idx,
611 + )
612 + num_transfer_bytes += group_bytes
613
614 src_offset = src_end_offset
615 dst_offset = dst_end_offset
616
617 assert src_offset == num_src_blocks
618 assert dst_offset == num_dst_blocks
360 - assert op_idx == num_copy_ops
619 + # Writer rotation may skip non-writer blocks, leaving op_idx below
620 + # the sized upper bound
621 + assert op_idx <= num_copy_ops
622 + src = src[:op_idx]
623 + dst = dst[:op_idx]
624 + sizes = sizes[:op_idx]
625
626 stream = (
627 self._stream_pool.pop() if self._stream_pool else current_platform.Stream()
@@ -390,7 +654,7 @@ class SingleDirectionOffloadingHandler:
654 is_src_access_order_any = not self.gpu_to_cpu
655 with current_platform.stream(stream):
656 start_event.record(stream)
393 - if num_copy_ops > 0:
657 + if op_idx > 0:
658 self._swap_blocks_batch(
659 src,
660 dst,
@@ -475,23 +739,36 @@ class CPUOffloadingWorker(OffloadingWorker):
739 blocks_per_chunk: int,
740 num_cpu_blocks: int,
741 mmap_region: SharedOffloadRegion | None = None,
742 + canonical_layout: bool = False,
743 ):
744 + assert not canonical_layout or mmap_region is not None
745 pin_memory = PIN_MEMORY
746 logger.info("Allocating %d CPU tensors...", len(kv_caches.tensors))
747 if mmap_region is not None and pin_memory:
748 pin_mmap_region(mmap_region)
749
750 + canonical_bytes_per_block = (
751 + _canonical_block_sizes(kv_caches.group_data_refs, len(kv_caches.tensors))
752 + if canonical_layout
753 + else None
754 + )
755 +
756 gpu_tensors: list[torch.Tensor] = []
757 cpu_tensors: list[torch.Tensor] = []
486 - for kv_cache_tensor in kv_caches.tensors:
758 + for t_idx, kv_cache_tensor in enumerate(kv_caches.tensors):
759 gpu_page_size_bytes = kv_cache_tensor.page_size_bytes
760 gpu_tensor = kv_cache_tensor.tensor.view(torch.int8).view(
761 (-1, gpu_page_size_bytes)
762 )
763 cpu_page_size_bytes = gpu_page_size_bytes * blocks_per_chunk
764
493 - if mmap_region is not None:
494 - cpu_tensor = mmap_region.create_next_view(cpu_page_size_bytes)
765 + if canonical_bytes_per_block is not None:
766 + assert mmap_region is not None
767 + cpu_tensor = mmap_region.create_next_canonical_view(
768 + canonical_bytes_per_block[t_idx] * blocks_per_chunk
769 + )
770 + elif mmap_region is not None:
771 + cpu_tensor = mmap_region.create_next_worker_view(cpu_page_size_bytes)
772 else:
773 t0 = time.monotonic()
774 cpu_tensor = torch.zeros(
@@ -515,17 +792,19 @@ class CPUOffloadingWorker(OffloadingWorker):
792 gpu_tensors=gpu_tensors,
793 cpu_tensors=cpu_tensors,
794 blocks_per_chunk=blocks_per_chunk,
518 - kv_cache_groups_data_refs=kv_caches.group_data_refs,
795 + layer_refs_per_group=kv_caches.group_data_refs,
796 gpu_to_cpu=True,
797 mmap_region=mmap_region,
798 + canonical_layout=canonical_layout,
799 )
800
801 self._load_handler = SingleDirectionOffloadingHandler(
802 gpu_tensors=gpu_tensors,
803 cpu_tensors=cpu_tensors,
804 blocks_per_chunk=blocks_per_chunk,
527 - kv_cache_groups_data_refs=kv_caches.group_data_refs,
805 + layer_refs_per_group=kv_caches.group_data_refs,
806 gpu_to_cpu=False,
807 + canonical_layout=canonical_layout,
808 )
809
810 def submit_store(
vllm/v1/kv_offload/cpu/shared_offload_region.py
+46 -1
@@ -167,9 +167,10 @@ class SharedOffloadRegion:
167
168 self._base = torch.frombuffer(memoryview(self.mmap_obj), dtype=torch.int8)
169 self._views: list[torch.Tensor] = []
170 + self._canonical_offset = 0
171 self.is_pinned: bool = False
172
172 - def create_next_view(self, tensor_page_size: int) -> torch.Tensor:
173 + def create_next_worker_view(self, tensor_page_size: int) -> torch.Tensor:
174 """Allocate a strided int8 view for this worker, one canonical tensor.
175
176 Must be called once per canonical tensor. The full mmap layout is:
@@ -208,6 +209,50 @@ class SharedOffloadRegion:
209 self._views.append(worker_layer_view)
210 return worker_layer_view
211
212 + def create_next_canonical_view(self, tensor_page_size: int) -> torch.Tensor:
213 + """Allocate a strided int8 view shared by all workers for one
214 + canonical tensor (canonical layout).
215 +
216 + Must be called once per canonical tensor, instead of
217 + create_next_worker_view. The full mmap layout is:
218 +
219 + |<-------- canonical area ------->|<-------- unused ------->|
220 + | all workers share this area | |
221 + | | |
222 + | [ canonical_t0 | canonical_t1 ] | |
223 + | [ canonical_t0 | canonical_t1 ] | |
224 + | [ canonical_t0 | canonical_t1 ] | |
225 + ^ ^
226 + _canonical_offset=0, then advances by each tensor's size
227 +
228 + Each canonical_t{i} cell is that tensor's canonical page for the
229 + block. Canonical areas are carved consecutively from the start of
230 + each block row; consecutive rows are separated by row_stride. Every
231 + worker gets the identical byte ranges and writes only its disjoint
232 + bytes within them, as described by its canonical mappings — unlike
233 + create_next_worker_view, which gives each worker a private
234 + cpu_page_size slot per row.
235 +
236 + The trailing unused bytes exist only when the canonical pages sum to
237 + less than row_stride: page-alignment padding of the row, or
238 + deduplication of KV replicated across workers (e.g. the MLA latent),
239 + where one canonical copy replaces world_size worker copies.
240 +
241 + Args:
242 + tensor_page_size: Canonical bytes per block for this tensor.
243 + """
244 + new_offset = self._canonical_offset + tensor_page_size
245 + assert new_offset <= self._row_stride
246 + view = torch.as_strided(
247 + self._base,
248 + size=(self.num_blocks, tensor_page_size),
249 + stride=(self._row_stride, 1),
250 + storage_offset=self._canonical_offset,
251 + )
252 + self._canonical_offset = new_offset
253 + self._views.append(view)
254 + return view
255 +
256 def create_kv_memoryview(self) -> memoryview:
257 """Return a zero-copy memoryview over the entire KV buffer.
258
vllm/v1/kv_offload/file_mapper.py
+11
@@ -4,6 +4,9 @@
4 import hashlib
5 import json
6
7 +from vllm.distributed.kv_transfer.kv_connector.v1.offloading.canonical_mapping import (
8 + canonical_format_id,
9 +)
10 from vllm.v1.kv_offload.base import (
11 OffloadingSpec,
12 OffloadKey,
@@ -36,6 +39,7 @@ class FileMapper:
39 inference_engine: str = "vllm",
40 parallel_agnostic: bool = False,
41 replicated_layout: bool = False,
42 + canonical_format: str | None = None,
43 ):
44 """
45 Initialize the file mapper. Each worker constructs its own, but
@@ -65,6 +69,11 @@ class FileMapper:
69 # unchanged (False is the historical default and must not appear).
70 if replicated_layout:
71 self.fields["replicated_layout"] = True
72 + # The canonical byte format is not interchangeable with the direct
73 + # layout (or with other canonical format versions/families), so its
74 + # identity participates in the storage namespace.
75 + if canonical_format is not None:
76 + self.fields["canonical_format"] = canonical_format
77 self.base_path: str = self._compute_base_path(root_dir, self.fields)
78
79 @classmethod
@@ -85,6 +94,7 @@ class FileMapper:
94 for group in config.groups
95 ]
96 parallel = config.parallel
97 + canonical_format = canonical_format_id() if config.canonical_layout else None
98 return cls(
99 root_dir=root_dir,
100 model_name=config.model.name,
@@ -102,6 +112,7 @@ class FileMapper:
112 and (parallel.is_parallelism_agnostic or config.replicated_layout)
113 ),
114 replicated_layout=(parallel_agnostic and config.replicated_layout),
115 + canonical_format=canonical_format,
116 )
117
118 def get_file_name(self, key: OffloadKey) -> str:
vllm/v1/kv_offload/tiering/spec.py
+46
@@ -264,6 +264,11 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
264 # Scheduler-side mmap (rank=None); kept for cleanup
265 self._scheduler_mmap: SharedOffloadRegion | None = None
266
267 + # Set by create_worker when canonical_layout is enabled: True when
268 + # every layer's canonical bytes are parallelism-agnostic (portable),
269 + # False when some layers use the opaque fallback (exact-topology only)
270 + self.all_layers_portable: bool | None = None
271 +
272 # engine_id is unique per DP replica (suffixed with _dp{rank} in both
273 # the Ray and multiprocessing paths), so it names a per-replica offload
274 # region.
@@ -394,12 +399,53 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
399 cpu_page_size=self.cpu_page_size_per_worker,
400 )
401 try:
402 + if self.config.canonical_layout:
403 + self._validate_canonical_refs(kv_caches)
404 return CPUOffloadingWorker(
405 kv_caches=kv_caches,
406 blocks_per_chunk=self.blocks_per_chunk,
407 num_cpu_blocks=self.num_blocks,
408 mmap_region=worker_mmap,
409 + canonical_layout=self.config.canonical_layout,
410 )
411 except Exception:
412 worker_mmap.cleanup()
413 raise
414 +
415 + def _validate_canonical_refs(self, kv_caches: CanonicalKVCaches) -> None:
416 + """Require a mapping on every ref and record layer portability.
417 +
418 + Fails loudly rather than persist direct-layout bytes under a
419 + canonical format identity."""
420 + all_refs = [
421 + ref for group_refs in kv_caches.group_data_refs for ref in group_refs
422 + ]
423 + if any(ref.mapping is None for ref in all_refs):
424 + raise RuntimeError(
425 + "canonical_layout was requested but the KV cache layout "
426 + "could not be certified for canonical offload (offload "
427 + "workers must be exactly the TP group, and packed / "
428 + "cross-layer KV layouts are not supported). Remove "
429 + "canonical_layout from kv_connector_extra_config."
430 + )
431 + self.all_layers_portable = all(
432 + ref.mapping is not None and ref.mapping.parallelism_agnostic
433 + for ref in all_refs
434 + )
435 + if self.config.parallel.is_parallelism_agnostic and not (
436 + self.all_layers_portable
437 + ):
438 + # The scheduler-side storage namespace was already collapsed on
439 + # the static portability claim; opaque per-topology bytes must
440 + # not land in it.
441 + raise RuntimeError(
442 + "canonical_layout could not certify every layer as "
443 + "parallelism-agnostic, but the storage namespace is shared "
444 + "across topologies. Remove canonical_layout from "
445 + "kv_connector_extra_config or disable parallel-agnostic "
446 + "secondary tiers."
447 + )
448 + logger.info(
449 + "Canonical KV layout enabled (all_layers_portable=%s)",
450 + self.all_layers_portable,
451 + )