[KV Connector] Add per-layer canonical KV page mappings for parallelism-agnostic offload (#48408)
Signed-off-by: Itay Etelis <itay.etelis@ibm.com> Co-authored-by: Itay Etelis <itay.etelis@ibm.com> Co-authored-by: Or Ozeri <oro@il.ibm.com> Co-authored-by: Itay Etelis <Itay.etelis@gmail.com>
Itay Etelis committed
Jul 31, 2026 at 14:42 UTC
2c4d3488483a4ce90b44661aefdb0cd31c6fbffa
6 files changed
+1016
-7
tests/v1/kv_connector/unit/offloading_connector/test_canonical_mapping.py
new
+474
@@ -0,0 +1,474 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from unittest.mock import MagicMock
4
+
5
+import pytest
6
+import torch
7
+
8
+from vllm.distributed.kv_transfer.kv_connector.v1.offloading.canonical_mapping import (
9
+ _layer_mapping,
10
+ _opaque_fallback_mapping,
11
+ _RankContext,
12
+ _verify_tiling,
13
+ derive_canonical_mappings,
14
+)
15
+from vllm.v1.kv_cache_interface import (
16
+ FullAttentionSpec,
17
+ KVCacheGroupSpec,
18
+ KVCacheSpec,
19
+ KVQuantMode,
20
+ MLAAttentionSpec,
21
+)
22
+from vllm.v1.kv_offload.base import CanonicalPageMapping, CopyRun
23
+
24
+NUM_BLOCKS = 3
25
+
26
+
27
+def _ctx(rank, tp=1, dcp=1, pcp=1, interleave=1, total=None, heads=2):
28
+ return _RankContext(
29
+ tp_size=tp,
30
+ dcp_size=dcp,
31
+ pcp_size=pcp,
32
+ interleave=interleave,
33
+ total_kv_heads=heads * tp if total is None else total,
34
+ rank=rank,
35
+ )
36
+
37
+
38
+def _full_spec(num_kv_heads: int = 2, **kwargs) -> FullAttentionSpec:
39
+ # block_size=4, head_dim=64, int8
40
+ return FullAttentionSpec(
41
+ block_size=4,
42
+ num_kv_heads=num_kv_heads,
43
+ head_size=64,
44
+ dtype=torch.int8,
45
+ **kwargs,
46
+ )
47
+
48
+
49
+def _mla_spec(**kwargs) -> MLAAttentionSpec:
50
+ # page = 4 * 64 = 256B, one 64B latent row per token
51
+ return MLAAttentionSpec(
52
+ block_size=4, num_kv_heads=1, head_size=64, dtype=torch.int8, **kwargs
53
+ )
54
+
55
+
56
+def _split_nhd_cache(spec) -> torch.Tensor:
57
+ return torch.zeros(
58
+ NUM_BLOCKS,
59
+ 2,
60
+ spec.block_size,
61
+ spec.num_kv_heads,
62
+ spec.head_size,
63
+ dtype=torch.int8,
64
+ )
65
+
66
+
67
+def _split_hnd_cache(spec) -> torch.Tensor:
68
+ return torch.zeros(
69
+ NUM_BLOCKS,
70
+ 2,
71
+ spec.num_kv_heads,
72
+ spec.block_size,
73
+ spec.head_size,
74
+ dtype=torch.int8,
75
+ ).permute(0, 1, 3, 2, 4)
76
+
77
+
78
+def _packed_nhd_cache(spec) -> torch.Tensor:
79
+ """Logical (num_blocks, heads, block_size, 2 * head_size) over an NHD
80
+ physical layout — the FlashAttention/FlashInfer/Triton/Flex form."""
81
+ return torch.zeros(
82
+ NUM_BLOCKS,
83
+ spec.block_size,
84
+ spec.num_kv_heads,
85
+ 2 * spec.head_size,
86
+ dtype=torch.int8,
87
+ ).permute(0, 2, 1, 3)
88
+
89
+
90
+def _packed_hnd_cache(spec) -> torch.Tensor:
91
+ return torch.zeros(
92
+ NUM_BLOCKS,
93
+ spec.num_kv_heads,
94
+ spec.block_size,
95
+ 2 * spec.head_size,
96
+ dtype=torch.int8,
97
+ )
98
+
99
+
100
+CACHE_BUILDERS = {
101
+ "split_nhd": _split_nhd_cache,
102
+ "split_hnd": _split_hnd_cache,
103
+ "packed_nhd": _packed_nhd_cache,
104
+ "packed_hnd": _packed_hnd_cache,
105
+}
106
+
107
+
108
+def _try_mapping(spec, kv_cache, ctx) -> CanonicalPageMapping | None:
109
+ return _layer_mapping(spec, kv_cache, NUM_BLOCKS, ctx)
110
+
111
+
112
+def _mapping(spec, kv_cache, ctx) -> CanonicalPageMapping:
113
+ mapping = _try_mapping(spec, kv_cache, ctx)
114
+ assert mapping is not None
115
+ return mapping
116
+
117
+
118
+def _triples(runs: tuple[CopyRun, ...]) -> list[tuple[int, int, int]]:
119
+ """Expand runs to explicit (local_offset, canonical_offset, size) copies."""
120
+ out = []
121
+ for run in runs:
122
+ for i in range(run.num_fragments):
123
+ out.append(
124
+ (
125
+ run.local_offset + i * run.local_stride,
126
+ run.canonical_offset + i * run.canonical_stride,
127
+ run.fragment_size,
128
+ )
129
+ )
130
+ return out
131
+
132
+
133
+# ---------------------------------------------------------------------------
134
+# TP-only placement (byte-compatible with the uniform interleave layout)
135
+# ---------------------------------------------------------------------------
136
+
137
+
138
+def test_split_nhd_placement_rank2_of_4():
139
+ spec = _full_spec()
140
+ mapping = _mapping(spec, _split_nhd_cache(spec), _ctx(rank=2, tp=4))
141
+ assert mapping.canonical_page_size_bytes == 4 * 1024
142
+ assert mapping.parallelism_agnostic
143
+ k_dst = [256, 768, 1280, 1792]
144
+ assert _triples(mapping.runs) == [
145
+ (local, canonical, 128)
146
+ for local, canonical in zip(
147
+ [0, 128, 256, 384, 512, 640, 768, 896],
148
+ k_dst + [2048 + o for o in k_dst],
149
+ )
150
+ ]
151
+ # Heads are sharded, not replicated: this rank writes every block
152
+ assert mapping.num_writers == 1
153
+
154
+
155
+def test_packed_nhd_placement_rank2_of_4():
156
+ spec = _full_spec()
157
+ mapping = _mapping(spec, _packed_nhd_cache(spec), _ctx(rank=2, tp=4))
158
+ assert _triples(mapping.runs) == [
159
+ (0, 512, 256),
160
+ (256, 1536, 256),
161
+ (512, 2560, 256),
162
+ (768, 3584, 256),
163
+ ]
164
+
165
+
166
+def test_packed_hnd_placement_rank1_of_4():
167
+ spec = _full_spec()
168
+ mapping = _mapping(spec, _packed_hnd_cache(spec), _ctx(rank=1, tp=4))
169
+ # Two heads, each a contiguous canonical head region of 4 tokens x 128B
170
+ assert _triples(mapping.runs) == [(0, 1024, 1024)]
171
+
172
+
173
+@pytest.mark.parametrize("form", sorted(CACHE_BUILDERS))
174
+def test_single_rank_coalesces_to_one_run(form):
175
+ spec = _full_spec()
176
+ mapping = _mapping(spec, CACHE_BUILDERS[form](spec), _ctx(rank=0))
177
+ assert mapping.canonical_page_size_bytes == 1024
178
+ assert _triples(mapping.runs) == [(0, 0, 1024)]
179
+
180
+
181
+# ---------------------------------------------------------------------------
182
+# Replication and writer election
183
+# ---------------------------------------------------------------------------
184
+
185
+
186
+def test_gqa_replicated_heads_rotate_writer():
187
+ # total 2 KV heads on tp=4: replication factor 2, head shard = rank // 2
188
+ spec = _full_spec(num_kv_heads=1)
189
+ cache = _split_nhd_cache(spec)
190
+ ctx = lambda rank: _ctx(rank, tp=4, total=2) # noqa: E731
191
+ rank2 = _mapping(spec, cache, ctx(2))
192
+ rank3 = _mapping(spec, cache, ctx(3))
193
+ assert rank2.canonical_page_size_bytes == 2 * 512
194
+ # K region: head shard 1 at 64B offsets within 128B token rows
195
+ assert _triples(rank2.runs)[:4] == [
196
+ (0, 64, 64),
197
+ (64, 192, 64),
198
+ (128, 320, 64),
199
+ (192, 448, 64),
200
+ ]
201
+ # Same head shard, so identical bytes: the two take alternate blocks
202
+ assert rank3.runs == rank2.runs
203
+ assert (rank2.is_writer(0), rank3.is_writer(0)) == (True, False)
204
+ assert (rank2.is_writer(1), rank3.is_writer(1)) == (False, True)
205
+ _verify_tiling("gqa", [_mapping(spec, cache, ctx(r)) for r in range(4)])
206
+
207
+
208
+def test_mla_replicas_rotate_writer():
209
+ spec = _mla_spec()
210
+ rank0 = _mapping(spec, None, _ctx(rank=0, tp=2))
211
+ rank1 = _mapping(spec, None, _ctx(rank=1, tp=2))
212
+ # Latent pages are stored once per block, not once per rank
213
+ assert rank0.canonical_page_size_bytes == 256
214
+ assert _triples(rank0.runs) == [(0, 0, 256)]
215
+ assert rank1.runs == rank0.runs
216
+ assert (rank0.is_writer(0), rank1.is_writer(0)) == (True, False)
217
+ assert (rank0.is_writer(1), rank1.is_writer(1)) == (False, True)
218
+
219
+
220
+# ---------------------------------------------------------------------------
221
+# DCP / PCP token sharding
222
+# ---------------------------------------------------------------------------
223
+
224
+
225
+def test_dcp_interleaves_tokens_within_replicas():
226
+ # tp=4, dcp=2, total 2 KV heads: head shard = rank // 2, cp rank = rank % 2
227
+ spec = _full_spec(num_kv_heads=1)
228
+ cache = _split_nhd_cache(spec)
229
+ ctx = lambda rank: _ctx(rank, tp=4, dcp=2, total=2) # noqa: E731
230
+ per_rank = [_mapping(spec, cache, ctx(rank)) for rank in range(4)]
231
+ assert all(m is not None for m in per_rank)
232
+ # 8 canonical tokens x 2 heads x 64B per region
233
+ assert per_rank[0].canonical_page_size_bytes == 2048
234
+ assert not per_rank[0].parallelism_agnostic
235
+ # rank 2 = head shard 1, cp rank 0: K tokens 0,2,4,6 at head offset 64
236
+ assert _triples(per_rank[2].runs)[:4] == [
237
+ (0, 64, 64),
238
+ (64, 320, 64),
239
+ (128, 576, 64),
240
+ (192, 832, 64),
241
+ ]
242
+ # every rank contributes (dcp == replication: no residual replicas)
243
+ assert all(m.num_writers == 1 for m in per_rank)
244
+ _verify_tiling("dcp", per_rank)
245
+
246
+
247
+def test_mla_dcp_shards_latent_tokens():
248
+ spec = _mla_spec()
249
+ ctx = lambda rank: _ctx(rank, tp=2, dcp=2) # noqa: E731
250
+ rank0 = _mapping(spec, None, ctx(0))
251
+ rank1 = _mapping(spec, None, ctx(1))
252
+ assert rank0.canonical_page_size_bytes == 512
253
+ assert _triples(rank0.runs) == [(o, 2 * o, 64) for o in (0, 64, 128, 192)]
254
+ assert _triples(rank1.runs) == [(o, 2 * o + 64, 64) for o in (0, 64, 128, 192)]
255
+ _verify_tiling("mla-dcp", [rank0, rank1])
256
+
257
+
258
+def test_pcp_tokens_and_tp_heads_compose():
259
+ # tp=2 x pcp=2: rank = pcp_rank * 2 + tp_rank; 4 workers tile the page
260
+ spec = _full_spec(num_kv_heads=1)
261
+ cache = _packed_nhd_cache(spec)
262
+ per_rank = [
263
+ _mapping(spec, cache, _ctx(rank, tp=2, pcp=2, total=2)) for rank in range(4)
264
+ ]
265
+ assert all(m is not None and m.runs for m in per_rank)
266
+ _verify_tiling("pcp", per_rank)
267
+
268
+
269
+def test_interleave_chunks_stay_contiguous():
270
+ # interleave=2: chunks of 2 tokens alternate between the 2 cp ranks and
271
+ # coalesce into one contiguous fragment per chunk
272
+ spec = _mla_spec()
273
+ mapping = _mapping(spec, None, _ctx(rank=0, tp=2, dcp=2, interleave=2))
274
+ assert _triples(mapping.runs) == [(0, 0, 128), (128, 256, 128)]
275
+ _verify_tiling(
276
+ "interleave",
277
+ [
278
+ _mapping(spec, None, _ctx(rank, tp=2, dcp=2, interleave=2))
279
+ for rank in range(2)
280
+ ],
281
+ )
282
+
283
+
284
+@pytest.mark.parametrize("form", sorted(CACHE_BUILDERS))
285
+def test_all_ranks_tile_canonical_page(form):
286
+ spec = _full_spec()
287
+ per_rank = [
288
+ _mapping(spec, CACHE_BUILDERS[form](spec), _ctx(rank, tp=4))
289
+ for rank in range(4)
290
+ ]
291
+ _verify_tiling("layer", per_rank)
292
+
293
+
294
+# ---------------------------------------------------------------------------
295
+# Byte-level round trips
296
+# ---------------------------------------------------------------------------
297
+
298
+
299
+def _store_all(mappings, pages, size: int, block_id: int = 0) -> bytes:
300
+ buf = bytearray(size)
301
+ for mapping, page in zip(mappings, pages):
302
+ if not mapping.is_writer(block_id):
303
+ continue
304
+ for local, canonical, n in _triples(mapping.runs):
305
+ buf[canonical : canonical + n] = page[local : local + n]
306
+ return bytes(buf)
307
+
308
+
309
+def _load_one(mapping, canonical_bytes: bytes) -> bytes:
310
+ page = bytearray(mapping.local_page_size_bytes)
311
+ for local, canonical, n in _triples(mapping.runs):
312
+ page[local : local + n] = canonical_bytes[canonical : canonical + n]
313
+ return bytes(page)
314
+
315
+
316
+@pytest.mark.parametrize("form", sorted(CACHE_BUILDERS))
317
+def test_cross_tp_store_load(form):
318
+ """Bytes stored under one TP size are the bytes another TP size loads."""
319
+ total_heads, canonical_size = 8, 4096
320
+ reference = bytes((7 + 31 * i) % 256 for i in range(canonical_size))
321
+
322
+ def mappings_at(tp: int):
323
+ spec = _full_spec(num_kv_heads=total_heads // tp)
324
+ cache = CACHE_BUILDERS[form](spec)
325
+ return [
326
+ _mapping(spec, cache, _ctx(rank, tp=tp, total=total_heads))
327
+ for rank in range(tp)
328
+ ]
329
+
330
+ for tp in (4, 2, 1):
331
+ mappings = mappings_at(tp)
332
+ assert all(m is not None for m in mappings)
333
+ pages = [_load_one(m, reference) for m in mappings]
334
+ assert _store_all(mappings, pages, canonical_size) == reference
335
+
336
+
337
+def test_cp_round_trip():
338
+ # tp=4 / dcp=2 / 2 KV heads: 4 workers jointly hold one canonical page
339
+ spec = _full_spec(num_kv_heads=1)
340
+ cache = _split_nhd_cache(spec)
341
+ mappings = [
342
+ _mapping(spec, cache, _ctx(rank, tp=4, dcp=2, total=2)) for rank in range(4)
343
+ ]
344
+ reference = bytes((3 + 17 * i) % 256 for i in range(2048))
345
+ pages = [_load_one(m, reference) for m in mappings]
346
+ assert _store_all(mappings, pages, 2048) == reference
347
+
348
+
349
+def test_replica_rotation_round_trip():
350
+ """Whichever replica a block elects reproduces the same canonical page."""
351
+ spec = _mla_spec()
352
+ mappings = [_mapping(spec, None, _ctx(rank, tp=2)) for rank in range(2)]
353
+ reference = bytes((5 + 11 * i) % 256 for i in range(256))
354
+ pages = [_load_one(m, reference) for m in mappings]
355
+ for block_id in (0, 1):
356
+ assert _store_all(mappings, pages, 256, block_id) == reference
357
+
358
+
359
+# ---------------------------------------------------------------------------
360
+# Fail-closed gates
361
+# ---------------------------------------------------------------------------
362
+
363
+
364
+def test_fail_closed_cases():
365
+ spec = _full_spec()
366
+ nhd = _split_nhd_cache(spec)
367
+ # Spec heads inconsistent with total heads / tp
368
+ assert _try_mapping(spec, nhd, _ctx(0, tp=4, total=2)) is None
369
+ # tp not divisible by total KV heads
370
+ one_head = _full_spec(num_kv_heads=1)
371
+ assert (
372
+ _try_mapping(one_head, _split_nhd_cache(one_head), _ctx(0, tp=3, total=2))
373
+ is None
374
+ )
375
+ # DCP wider than the KV replication factor (tokens would shard across
376
+ # ranks holding different heads)
377
+ assert _try_mapping(spec, nhd, _ctx(0, tp=4, dcp=2)) is None
378
+ # Interleave must divide the block size
379
+ assert _try_mapping(spec, nhd, _ctx(0, tp=2, dcp=2, interleave=3, total=2)) is None
380
+ # Per-token-head scales are packed with the data
381
+ quant_spec = _full_spec(kv_quant_mode=KVQuantMode.FP8_PER_TOKEN_HEAD)
382
+ assert _try_mapping(quant_spec, _split_nhd_cache(quant_spec), _ctx(0, tp=4)) is None
383
+ # Compressed MLA slots are not 1:1 with tokens
384
+ assert _try_mapping(_mla_spec(compress_ratio=2), None, _ctx(0, tp=2, dcp=2)) is None
385
+ # Unrecognized physical layouts
386
+ swapped = torch.zeros(
387
+ NUM_BLOCKS,
388
+ spec.block_size,
389
+ 2,
390
+ spec.num_kv_heads,
391
+ spec.head_size,
392
+ dtype=torch.int8,
393
+ ).permute(0, 2, 1, 3, 4)
394
+ assert _try_mapping(spec, swapped, _ctx(0, tp=4)) is None
395
+ # Non-attention specs
396
+ assert _try_mapping(KVCacheSpec(block_size=4), None, _ctx(0, tp=4, total=8)) is None
397
+
398
+
399
+def test_opaque_fallback_places_page_whole():
400
+ mapping = _opaque_fallback_mapping(1024, 4, 2)
401
+ assert mapping.canonical_page_size_bytes == 4096
402
+ assert not mapping.parallelism_agnostic
403
+ assert _triples(mapping.runs) == [(0, 2048, 1024)]
404
+ assert mapping.num_writers == 1
405
+ _verify_tiling("opaque", [_opaque_fallback_mapping(1024, 4, r) for r in range(4)])
406
+
407
+
408
+# ---------------------------------------------------------------------------
409
+# derive_canonical_mappings end to end
410
+# ---------------------------------------------------------------------------
411
+
412
+
413
+def _vllm_config(tp=1, dcp=1, pcp=1, pp=1, interleave=1, total_kv_heads=2):
414
+ config = MagicMock()
415
+ config.parallel_config.tensor_parallel_size = tp
416
+ config.parallel_config.decode_context_parallel_size = dcp
417
+ config.parallel_config.prefill_context_parallel_size = pcp
418
+ config.parallel_config.cp_kv_cache_interleave_size = interleave
419
+ config.parallel_config.world_size = pp * tp * pcp
420
+ config.parallel_config.rank = 0
421
+ config.model_config.get_total_num_kv_heads.return_value = total_kv_heads
422
+ return config
423
+
424
+
425
+def _kv_cache_config(groups):
426
+ config = MagicMock()
427
+ config.kv_cache_groups = groups
428
+ config.num_blocks = NUM_BLOCKS
429
+ return config
430
+
431
+
432
+def test_derive_mixed_model_with_dcp():
433
+ attn_spec = _full_spec(num_kv_heads=1)
434
+ mla_spec = _mla_spec()
435
+ quant_spec = _full_spec(
436
+ num_kv_heads=1, kv_quant_mode=KVQuantMode.FP8_PER_TOKEN_HEAD
437
+ )
438
+ kv_cache_config = _kv_cache_config(
439
+ [
440
+ KVCacheGroupSpec(layer_names=["attn"], kv_cache_spec=attn_spec),
441
+ KVCacheGroupSpec(layer_names=["mla"], kv_cache_spec=mla_spec),
442
+ KVCacheGroupSpec(layer_names=["quant"], kv_cache_spec=quant_spec),
443
+ ]
444
+ )
445
+ kv_caches = {
446
+ "attn": _split_nhd_cache(attn_spec),
447
+ "quant": _split_nhd_cache(quant_spec),
448
+ }
449
+ mappings = derive_canonical_mappings(
450
+ _vllm_config(tp=4, dcp=2, total_kv_heads=2), kv_cache_config, kv_caches
451
+ )
452
+ assert set(mappings) == {"attn", "mla", "quant"}
453
+ assert not mappings["attn"].parallelism_agnostic
454
+ assert mappings["attn"].runs
455
+ # Uncertifiable layers degrade to an opaque page, never disappear
456
+ assert not mappings["quant"].parallelism_agnostic
457
+ assert (
458
+ mappings["quant"].canonical_page_size_bytes
459
+ == 4 * quant_spec.unpadded_page_size_bytes
460
+ )
461
+
462
+
463
+def test_derive_refuses_foreign_worker_groups():
464
+ attn_spec = _full_spec()
465
+ kv_cache_config = _kv_cache_config(
466
+ [KVCacheGroupSpec(layer_names=["attn"], kv_cache_spec=attn_spec)]
467
+ )
468
+ kv_caches = {"attn": _split_nhd_cache(attn_spec)}
469
+ assert (
470
+ derive_canonical_mappings(
471
+ _vllm_config(tp=2, pp=2, total_kv_heads=4), kv_cache_config, kv_caches
472
+ )
473
+ == {}
474
+ )
tests/v1/kv_connector/unit/offloading_connector/test_worker.py
+31
-6
@@ -101,6 +101,21 @@ def _allocate_and_reshape_kv_caches(
101
set_kv_cache_layout(None)
102
103
104
+def _single_rank_vllm_config(total_kv_heads: int):
105
+ """A one-rank (TP=1) parallel config, as canonical mappings are derived
106
+ from it."""
107
+ vllm_config = MagicMock()
108
+ parallel_config = vllm_config.parallel_config
109
+ parallel_config.tensor_parallel_size = 1
110
+ parallel_config.decode_context_parallel_size = 1
111
+ parallel_config.prefill_context_parallel_size = 1
112
+ parallel_config.cp_kv_cache_interleave_size = 1
113
+ parallel_config.world_size = 1
114
+ parallel_config.rank = 0
115
+ vllm_config.model_config.get_total_num_kv_heads.return_value = total_kv_heads
116
+ return vllm_config
117
+
118
+
119
def _make_worker(
120
kv_cache_config: KVCacheConfig,
121
replicated_layout: bool = False,
@@ -121,6 +136,7 @@ def _make_worker(
136
137
worker = OffloadingConnectorWorker(
138
spec=spec,
139
+ vllm_config=_single_rank_vllm_config(NUM_KV_HEADS),
140
kv_cache_config=kv_cache_config,
141
)
142
worker.worker = MagicMock()
@@ -287,6 +303,7 @@ def test_offloading_connector_worker_accepts_plugin_spec_default_layout():
303
304
OffloadingConnectorWorker(
305
spec=spec,
306
+ vllm_config=_single_rank_vllm_config(NUM_KV_HEADS),
307
kv_cache_config=KVCacheConfig(
308
num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[]
309
),
@@ -522,6 +539,8 @@ def test_register_kv_caches(backend):
539
for actual, expected in zip(actual_refs, exp_refs):
540
assert actual.tensor_idx == expected.tensor_idx
541
assert actual.page_size_bytes == expected.page_size_bytes
542
+ # Every layer gets a canonical mapping, certified or opaque
543
+ assert actual.mapping is not None
544
545
546
@pytest.mark.parametrize("backend", ATTN_BACKENDS)
@@ -622,9 +641,15 @@ def test_register_kv_caches_uniform_type(backend):
641
assert canonical.tensors[0].tensor.shape == (NUM_BLOCKS, spec_a.page_size_bytes)
642
assert canonical.tensors[1].tensor.shape == (NUM_BLOCKS, spec_b.page_size_bytes)
643
625
- assert group_refs[0] == CanonicalKVCacheRef(
626
- tensor_idx=0, page_size_bytes=spec_a.page_size_bytes
627
- )
628
- assert group_refs[1] == CanonicalKVCacheRef(
629
- tensor_idx=1, page_size_bytes=spec_b.page_size_bytes
630
- )
644
+ for ref, expected_tensor_idx, expected_spec in (
645
+ (group_refs[0], 0, spec_a),
646
+ (group_refs[1], 1, spec_b),
647
+ ):
648
+ assert ref.tensor_idx == expected_tensor_idx
649
+ assert ref.page_size_bytes == expected_spec.page_size_bytes
650
+ assert ref.mapping is not None
651
+
652
+ # Only layer_a matches the model's total KV head count, so layer_b gets an
653
+ # opaque mapping rather than a certified, parallelism-agnostic one
654
+ assert group_refs[0].mapping.parallelism_agnostic
655
+ assert not group_refs[1].mapping.parallelism_agnostic
vllm/distributed/kv_transfer/kv_connector/v1/offloading/canonical_mapping.py
new
+444
@@ -0,0 +1,444 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Derivation of canonical page mappings for KV offloading.
4
+
5
+The only place in the offloading stack that reasons about parallelism
6
+(TP/DCP/PCP); everything downstream consumes byte mappings. The canonical
7
+page of a layer is the full offloaded block without parallelism: all KV
8
+heads, all block_size * dcp * pcp tokens, in the worker's page encoding.
9
+Uncertifiable layers get an opaque fallback mapping (fail closed).
10
+"""
11
+
12
+from dataclasses import dataclass, replace
13
+from typing import TYPE_CHECKING
14
+
15
+import torch
16
+
17
+from vllm.v1.kv_cache_interface import (
18
+ AttentionSpec,
19
+ KVCacheConfig,
20
+ KVCacheSpec,
21
+ MambaSpec,
22
+ MLAAttentionSpec,
23
+ UniformTypeKVCacheSpecs,
24
+)
25
+from vllm.v1.kv_offload.base import CanonicalPageMapping, CopyRun
26
+
27
+if TYPE_CHECKING:
28
+ from vllm.config import VllmConfig
29
+
30
+
31
+@dataclass(frozen=True)
32
+class _RankContext:
33
+ """Sharding parameters of one worker rank within the offload group."""
34
+
35
+ tp_size: int
36
+ dcp_size: int
37
+ pcp_size: int
38
+ interleave: int
39
+ total_kv_heads: int
40
+ rank: int
41
+
42
+ @property
43
+ def cp_size(self) -> int:
44
+ return self.dcp_size * self.pcp_size
45
+
46
+ @property
47
+ def tp_rank(self) -> int:
48
+ return self.rank % self.tp_size
49
+
50
+ @property
51
+ def total_cp_rank(self) -> int:
52
+ pcp_rank = self.rank // self.tp_size
53
+ return pcp_rank * self.dcp_size + self.tp_rank % self.dcp_size
54
+
55
+
56
+@dataclass(frozen=True)
57
+class ByteRegion:
58
+ """A byte region within a page that repeats once per token."""
59
+
60
+ local_offset: int
61
+ canonical_offset: int
62
+ bytes_per_token: int
63
+ canonical_token_stride: int
64
+
65
+
66
+def _coalesce_runs(runs: list[CopyRun]) -> tuple[CopyRun, ...]:
67
+ """Collapse contiguous fragments within and across runs to minimize the
68
+ number of copy ops (e.g. a single-rank mapping becomes one whole-page run).
69
+ """
70
+ out: list[CopyRun] = []
71
+ for run in runs:
72
+ if (
73
+ run.num_fragments > 1
74
+ and run.local_stride == run.fragment_size
75
+ and run.canonical_stride == run.fragment_size
76
+ ):
77
+ size = run.fragment_size * run.num_fragments
78
+ run = CopyRun(run.local_offset, run.canonical_offset, size, 1, size, size)
79
+ prev = out[-1] if out else None
80
+ if (
81
+ prev is not None
82
+ and prev.num_fragments == 1
83
+ and run.num_fragments == 1
84
+ and prev.local_offset + prev.fragment_size == run.local_offset
85
+ and prev.canonical_offset + prev.fragment_size == run.canonical_offset
86
+ ):
87
+ size = prev.fragment_size + run.fragment_size
88
+ out[-1] = CopyRun(
89
+ prev.local_offset, prev.canonical_offset, size, 1, size, size
90
+ )
91
+ else:
92
+ out.append(run)
93
+ return tuple(out)
94
+
95
+
96
+def _local_to_canonical_token(local_idx: int, ctx: _RankContext) -> int:
97
+ """Canonical position of one of this rank's local token indices."""
98
+ chunk, pos_in_chunk = divmod(local_idx, ctx.interleave)
99
+ return (chunk * ctx.cp_size + ctx.total_cp_rank) * ctx.interleave + pos_in_chunk
100
+
101
+
102
+def _interleave_cp_tokens(
103
+ regions: list[ByteRegion],
104
+ num_tokens: int,
105
+ ctx: _RankContext,
106
+) -> tuple[CopyRun, ...]:
107
+ """Place each region's num_tokens rows at their canonical token positions,
108
+ one run per chunk of interleaved tokens."""
109
+ runs: list[CopyRun] = []
110
+ for region in regions:
111
+ if ctx.cp_size == 1:
112
+ runs.append(
113
+ CopyRun(
114
+ region.local_offset,
115
+ region.canonical_offset,
116
+ region.bytes_per_token,
117
+ num_tokens,
118
+ region.bytes_per_token,
119
+ region.canonical_token_stride,
120
+ )
121
+ )
122
+ continue
123
+ for chunk_start in range(0, num_tokens, ctx.interleave):
124
+ canonical_token = _local_to_canonical_token(chunk_start, ctx)
125
+ runs.append(
126
+ CopyRun(
127
+ region.local_offset + chunk_start * region.bytes_per_token,
128
+ region.canonical_offset
129
+ + canonical_token * region.canonical_token_stride,
130
+ region.bytes_per_token,
131
+ ctx.interleave,
132
+ region.bytes_per_token,
133
+ region.canonical_token_stride,
134
+ )
135
+ )
136
+ return _coalesce_runs(runs)
137
+
138
+
139
+def _packed_kv_regions(
140
+ kv_cache: torch.Tensor,
141
+ spec: AttentionSpec,
142
+ head_shard: int,
143
+ num_head_shards: int,
144
+ cp_size: int,
145
+) -> list[ByteRegion] | None:
146
+ """K and V adjacent per (token, head), in NHD or HND stride order."""
147
+ bs, heads = spec.block_size, spec.num_kv_heads
148
+ elem = kv_cache.element_size()
149
+ head_elems = 2 * spec.head_size
150
+ if heads * bs * head_elems * elem != spec.real_page_size_bytes:
151
+ return None
152
+ _, head_stride, token_stride, inner_stride = kv_cache.stride()
153
+ if inner_stride != 1:
154
+ return None
155
+ head_bytes = head_elems * elem
156
+ token_row_bytes = heads * head_bytes
157
+
158
+ if head_stride == head_elems and token_stride == heads * head_elems: # NHD
159
+ return [
160
+ ByteRegion(
161
+ local_offset=0,
162
+ canonical_offset=head_shard * token_row_bytes,
163
+ bytes_per_token=token_row_bytes,
164
+ canonical_token_stride=num_head_shards * token_row_bytes,
165
+ )
166
+ ]
167
+
168
+ if head_stride == bs * head_elems and token_stride == head_elems: # HND
169
+ canonical_span = bs * cp_size # canonical tokens per offloaded block
170
+ return [
171
+ ByteRegion(
172
+ local_offset=head * bs * head_bytes,
173
+ canonical_offset=(head_shard * heads + head)
174
+ * canonical_span
175
+ * head_bytes,
176
+ bytes_per_token=head_bytes,
177
+ canonical_token_stride=head_bytes,
178
+ )
179
+ for head in range(heads)
180
+ ]
181
+ return None
182
+
183
+
184
+def _split_kv_regions(
185
+ kv_cache: torch.Tensor,
186
+ spec: AttentionSpec,
187
+ head_shard: int,
188
+ num_head_shards: int,
189
+ cp_size: int,
190
+) -> list[ByteRegion] | None:
191
+ """K and V in separate page halves, in NHD or HND stride order."""
192
+ bs, heads, head_size = spec.block_size, spec.num_kv_heads, spec.head_size
193
+ elem = kv_cache.element_size()
194
+ if 2 * bs * heads * head_size * elem != spec.real_page_size_bytes:
195
+ return None
196
+ _, half_stride, token_stride, head_stride, inner_stride = kv_cache.stride()
197
+ if inner_stride != 1 or half_stride != bs * heads * head_size:
198
+ return None
199
+ head_bytes = head_size * elem
200
+ token_row_bytes = heads * head_bytes
201
+ canonical_span = bs * cp_size # canonical tokens per offloaded block
202
+
203
+ if token_stride == heads * head_size and head_stride == head_size: # NHD
204
+ canonical_half_bytes = canonical_span * num_head_shards * token_row_bytes
205
+ return [
206
+ ByteRegion(
207
+ local_offset=half * bs * token_row_bytes,
208
+ canonical_offset=half * canonical_half_bytes
209
+ + head_shard * token_row_bytes,
210
+ bytes_per_token=token_row_bytes,
211
+ canonical_token_stride=num_head_shards * token_row_bytes,
212
+ )
213
+ for half in range(2) # K, then V
214
+ ]
215
+
216
+ if head_stride == bs * head_size and token_stride == head_size: # HND
217
+ local_half_bytes = bs * heads * head_bytes
218
+ total_heads = num_head_shards * heads
219
+ return [
220
+ ByteRegion(
221
+ local_offset=half * local_half_bytes + head * bs * head_bytes,
222
+ canonical_offset=(half * total_heads + head_shard * heads + head)
223
+ * canonical_span
224
+ * head_bytes,
225
+ bytes_per_token=head_bytes,
226
+ canonical_token_stride=head_bytes,
227
+ )
228
+ for half in range(2)
229
+ for head in range(heads)
230
+ ]
231
+ return None
232
+
233
+
234
+def _attention_byte_regions(
235
+ kv_cache: torch.Tensor,
236
+ spec: AttentionSpec,
237
+ num_blocks: int,
238
+ head_shard: int,
239
+ num_head_shards: int,
240
+ cp_size: int,
241
+) -> list[ByteRegion] | None:
242
+ """Byte regions of an attention page, given this rank's head shard.
243
+ None when the physical layout is not recognized (fail closed)."""
244
+ bs, heads, head_size = spec.block_size, spec.num_kv_heads, spec.head_size
245
+ if tuple(kv_cache.shape) == (num_blocks, heads, bs, 2 * head_size):
246
+ return _packed_kv_regions(kv_cache, spec, head_shard, num_head_shards, cp_size)
247
+ if tuple(kv_cache.shape) == (num_blocks, 2, bs, heads, head_size):
248
+ return _split_kv_regions(kv_cache, spec, head_shard, num_head_shards, cp_size)
249
+ return None
250
+
251
+
252
+def _layer_mapping(
253
+ spec: KVCacheSpec,
254
+ kv_cache: torch.Tensor | list[torch.Tensor] | None,
255
+ num_blocks: int,
256
+ ctx: _RankContext,
257
+) -> CanonicalPageMapping | None:
258
+ """Certified mapping for one layer at one rank, or None (fail closed)."""
259
+ if not isinstance(spec, AttentionSpec):
260
+ return None
261
+ bs = spec.block_size
262
+ page = spec.real_page_size_bytes
263
+ if ctx.cp_size > 1 and (ctx.interleave > bs or bs % ctx.interleave):
264
+ return None
265
+
266
+ if isinstance(spec, MLAAttentionSpec):
267
+ # TP-replicated latent; CP shards its tokens across the DCP groups
268
+ if (
269
+ spec.compress_ratio != 1
270
+ or page % bs
271
+ or ctx.tp_size % ctx.dcp_size
272
+ or spec.kv_quant_mode.is_per_token_head
273
+ ):
274
+ return None
275
+ row = page // bs
276
+ return CanonicalPageMapping(
277
+ canonical_page_size_bytes=ctx.cp_size * page,
278
+ local_page_size_bytes=page,
279
+ runs=_interleave_cp_tokens([ByteRegion(0, 0, row, row)], bs, ctx),
280
+ num_writers=ctx.tp_size // ctx.dcp_size,
281
+ writer_index=ctx.tp_rank // ctx.dcp_size,
282
+ parallelism_agnostic=ctx.cp_size == 1,
283
+ )
284
+
285
+ if spec.kv_quant_mode.is_per_token_head or not isinstance(kv_cache, torch.Tensor):
286
+ return None
287
+ total, tp = ctx.total_kv_heads, ctx.tp_size
288
+ if spec.num_kv_heads != max(1, total // tp):
289
+ return None
290
+ if total >= tp:
291
+ if total % tp:
292
+ return None
293
+ num_head_shards, replication = tp, 1
294
+ else:
295
+ if tp % total:
296
+ return None
297
+ num_head_shards, replication = total, tp // total
298
+ # DCP shards tokens across ranks holding replicated KV
299
+ if replication % ctx.dcp_size:
300
+ return None
301
+
302
+ head_shard = ctx.tp_rank // replication
303
+ regions = _attention_byte_regions(
304
+ kv_cache, spec, num_blocks, head_shard, num_head_shards, ctx.cp_size
305
+ )
306
+ if regions is None:
307
+ return None
308
+ return CanonicalPageMapping(
309
+ canonical_page_size_bytes=ctx.cp_size * num_head_shards * page,
310
+ local_page_size_bytes=page,
311
+ runs=_interleave_cp_tokens(regions, bs, ctx),
312
+ num_writers=replication // ctx.dcp_size,
313
+ writer_index=(ctx.tp_rank % replication) // ctx.dcp_size,
314
+ parallelism_agnostic=ctx.cp_size == 1,
315
+ )
316
+
317
+
318
+def _opaque_fallback_mapping(
319
+ page_size_bytes: int, num_ranks: int, rank: int
320
+) -> CanonicalPageMapping:
321
+ """Fallback: place the worker's page whole at a worker-exclusive offset."""
322
+ run = CopyRun(
323
+ 0, rank * page_size_bytes, page_size_bytes, 1, page_size_bytes, page_size_bytes
324
+ )
325
+ return CanonicalPageMapping(
326
+ canonical_page_size_bytes=num_ranks * page_size_bytes,
327
+ local_page_size_bytes=page_size_bytes,
328
+ runs=(run,),
329
+ num_writers=1,
330
+ writer_index=0,
331
+ parallelism_agnostic=False,
332
+ )
333
+
334
+
335
+def _run_intervals(runs: tuple[CopyRun, ...], canonical: bool) -> list[tuple[int, int]]:
336
+ intervals = []
337
+ for run in runs:
338
+ offset = run.canonical_offset if canonical else run.local_offset
339
+ stride = run.canonical_stride if canonical else run.local_stride
340
+ for i in range(run.num_fragments):
341
+ start = offset + i * stride
342
+ intervals.append((start, start + run.fragment_size))
343
+ return sorted(intervals)
344
+
345
+
346
+def _is_exact_partition(intervals: list[tuple[int, int]], size: int) -> bool:
347
+ return (
348
+ bool(intervals)
349
+ and intervals[0][0] == 0
350
+ and intervals[-1][1] == size
351
+ and all(a[1] == b[0] for a, b in zip(intervals, intervals[1:]))
352
+ )
353
+
354
+
355
+def _verify_tiling(layer_name: str, per_rank: list[CanonicalPageMapping]) -> None:
356
+ """Whichever ranks a block elects as writers must tile the canonical page
357
+ exactly once, and each rank's runs must cover exactly its local page."""
358
+ size = per_rank[0].canonical_page_size_bytes
359
+ num_writers = per_rank[0].num_writers
360
+ for mapping in per_rank:
361
+ assert mapping.canonical_page_size_bytes == size
362
+ assert mapping.num_writers == num_writers
363
+ local = _run_intervals(mapping.runs, canonical=False)
364
+ assert _is_exact_partition(local, mapping.local_page_size_bytes), (
365
+ f"runs do not cover the local page of layer {layer_name}"
366
+ )
367
+ for block_id in range(num_writers):
368
+ stored: list[tuple[int, int]] = []
369
+ for mapping in per_rank:
370
+ if mapping.is_writer(block_id):
371
+ stored += _run_intervals(mapping.runs, canonical=True)
372
+ stored.sort()
373
+ assert _is_exact_partition(stored, size), (
374
+ f"writers of block {block_id} do not tile the canonical page "
375
+ f"of layer {layer_name}"
376
+ )
377
+
378
+
379
+def _unpadded_page_size(spec: KVCacheSpec) -> int | None:
380
+ if isinstance(spec, AttentionSpec):
381
+ return spec.unpadded_page_size_bytes
382
+ if isinstance(spec, MambaSpec):
383
+ return replace(spec, page_size_padded=None).page_size_bytes
384
+ return None
385
+
386
+
387
+def derive_canonical_mappings(
388
+ vllm_config: "VllmConfig",
389
+ kv_cache_config: KVCacheConfig,
390
+ kv_caches: dict[str, torch.Tensor | list[torch.Tensor]],
391
+) -> dict[str, CanonicalPageMapping]:
392
+ """Per-layer canonical page mappings for this worker.
393
+
394
+ Empty when the worker group is not exactly the TP x PCP grid; layers
395
+ absent from the result have no canonical representation.
396
+ """
397
+ parallel_config = vllm_config.parallel_config
398
+ tp_size = parallel_config.tensor_parallel_size
399
+ pcp_size = parallel_config.prefill_context_parallel_size
400
+ group_size = tp_size * pcp_size
401
+ if parallel_config.world_size != group_size:
402
+ return {}
403
+
404
+ def ctx(rank: int) -> _RankContext:
405
+ return _RankContext(
406
+ tp_size=tp_size,
407
+ dcp_size=parallel_config.decode_context_parallel_size,
408
+ pcp_size=pcp_size,
409
+ interleave=parallel_config.cp_kv_cache_interleave_size,
410
+ total_kv_heads=vllm_config.model_config.get_total_num_kv_heads(),
411
+ rank=rank,
412
+ )
413
+
414
+ my_rank = parallel_config.rank
415
+ num_blocks = kv_cache_config.num_blocks
416
+
417
+ mappings: dict[str, CanonicalPageMapping] = {}
418
+ for kv_cache_group in kv_cache_config.kv_cache_groups:
419
+ group_kv_cache_spec = kv_cache_group.kv_cache_spec
420
+ if isinstance(group_kv_cache_spec, UniformTypeKVCacheSpecs):
421
+ per_layer_specs = group_kv_cache_spec.kv_cache_specs
422
+ else:
423
+ per_layer_specs = {}
424
+ for layer_name in kv_cache_group.layer_names:
425
+ spec = per_layer_specs.get(layer_name, group_kv_cache_spec)
426
+ per_rank: list[CanonicalPageMapping] = []
427
+ for rank in range(group_size):
428
+ mapping = _layer_mapping(
429
+ spec, kv_caches.get(layer_name), num_blocks, ctx(rank)
430
+ )
431
+ if mapping is None:
432
+ break
433
+ per_rank.append(mapping)
434
+ if len(per_rank) != group_size:
435
+ page = _unpadded_page_size(spec)
436
+ if page is None:
437
+ continue
438
+ per_rank = [
439
+ _opaque_fallback_mapping(page, group_size, rank)
440
+ for rank in range(group_size)
441
+ ]
442
+ _verify_tiling(layer_name, per_rank)
443
+ mappings[layer_name] = per_rank[my_rank]
444
+ return mappings
vllm/distributed/kv_transfer/kv_connector/v1/offloading/worker.py
+20
@@ -5,6 +5,10 @@ from dataclasses import replace
5
6
import torch
7
8
+from vllm.config import VllmConfig
9
+from vllm.distributed.kv_transfer.kv_connector.v1.offloading.canonical_mapping import (
10
+ derive_canonical_mappings,
11
+)
12
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
13
OffloadingConnectorMetadata,
14
OffloadingWorkerMetadata,
@@ -40,9 +44,11 @@ class OffloadingConnectorWorker:
44
def __init__(
45
self,
46
spec: OffloadingSpec,
47
+ vllm_config: "VllmConfig",
48
kv_cache_config: KVCacheConfig,
49
):
50
self.spec = spec
51
+ self.vllm_config = vllm_config
52
self.kv_cache_config = kv_cache_config
53
self.worker: OffloadingWorker | None = None
54
# Non-writers still ack: pending_count waits for world_size per job.
@@ -63,6 +69,9 @@ class OffloadingConnectorWorker:
69
def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
70
kv_cache_config = self.kv_cache_config
71
num_blocks = kv_cache_config.num_blocks
72
+ mappings = derive_canonical_mappings(
73
+ self.vllm_config, kv_cache_config, kv_caches
74
+ )
75
76
# Packed layouts (e.g. DSv4) set block_stride > 0; their tensors use
77
# stride(0) as the manager-block stride (equals total_num_bytes_per_block).
@@ -201,10 +210,21 @@ class OffloadingConnectorWorker:
210
211
curr_tensor_idx = len(block_tensors) - 1
212
for layer_name in tensor_layer_names:
213
+ mapping = (
214
+ mappings.get(layer_name)
215
+ if len(tensors_per_block[first_layer_name]) == 1
216
+ else None
217
+ )
218
+ assert (
219
+ mapping is None
220
+ or mapping.local_page_size_bytes
221
+ == unpadded_page_size_bytes[layer_name]
222
+ )
223
block_data_refs[layer_name].append(
224
CanonicalKVCacheRef(
225
tensor_idx=curr_tensor_idx,
226
page_size_bytes=(unpadded_page_size_bytes[layer_name]),
227
+ mapping=mapping,
228
)
229
)
230
vllm/distributed/kv_transfer/kv_connector/v1/offloading_connector.py
+3
-1
@@ -75,7 +75,9 @@ class OffloadingConnector(KVConnectorBase_V1, SupportsHMA):
75
spec, vllm_config, kv_cache_config
76
)
77
elif role == KVConnectorRole.WORKER:
78
- self.connector_worker = OffloadingConnectorWorker(spec, kv_cache_config)
78
+ self.connector_worker = OffloadingConnectorWorker(
79
+ spec, vllm_config, kv_cache_config
80
+ )
81
82
def shutdown(self) -> None:
83
if self.connector_worker is not None:
vllm/v1/kv_offload/base.py
+44
@@ -457,6 +457,48 @@ class CanonicalKVCacheTensor:
457
page_size_bytes: int
458
459
460
+@dataclass(frozen=True)
461
+class CopyRun:
462
+ """A strided byte correspondence between this worker's physical page and
463
+ a canonical page: for i in range(num_fragments), fragment i spans
464
+ [local_offset + i * local_stride, +fragment_size) in the worker's page and
465
+ [canonical_offset + i * canonical_stride, +fragment_size) canonically."""
466
+
467
+ local_offset: int
468
+ canonical_offset: int
469
+ fragment_size: int
470
+ num_fragments: int
471
+ local_stride: int
472
+ canonical_stride: int
473
+
474
+
475
+@dataclass(frozen=True)
476
+class CanonicalPageMapping:
477
+ """How this worker's page maps into a canonical (parallelism-free) page.
478
+ In-process only, never serialized. Runs cover the full local page in both
479
+ directions; ranks holding identical bytes take turns writing them.
480
+ """
481
+
482
+ # Size of the canonical page in bytes
483
+ canonical_page_size_bytes: int
484
+ # Size of this worker's (un-padded) page in bytes
485
+ local_page_size_bytes: int
486
+ # Byte correspondences between this worker's page and a canonical page
487
+ runs: tuple[CopyRun, ...]
488
+ # Number of ranks holding these exact bytes
489
+ num_writers: int
490
+ # This worker's index among those ranks
491
+ writer_index: int
492
+ # Canonical bytes identical under any parallel config with this block span
493
+ parallelism_agnostic: bool
494
+
495
+ def is_writer(self, block_id: int) -> bool:
496
+ """Whether this worker stores the canonical page of the given block.
497
+ Rotating by block spreads writes across ranks holding identical bytes.
498
+ """
499
+ return block_id % self.num_writers == self.writer_index
500
+
501
+
502
@dataclass
503
class CanonicalKVCacheRef:
504
"""
@@ -468,6 +510,8 @@ class CanonicalKVCacheRef:
510
tensor_idx: int
511
# The un-padded page size per block in bytes
512
page_size_bytes: int
513
+ # How this worker's page maps into a canonical page; None = uncertified
514
+ mapping: CanonicalPageMapping | None = None
515
516
517
@dataclass