[KV Connector] Support heterogeneous TP sharing in Mooncake Store Connector (#53129)
Signed-off-by: Guanyi Chen <939416532@qq.com> Signed-off-by: z-zanez <zhouzh93@mail2.sysu.edu.cn> Signed-off-by: Zheng Zhou <zhouzh93@mail2.sysu.edu.cn> Signed-off-by: zane <zhouzh93@mail2.sysu.edu.cn> Co-authored-by: Guanyi Chen <939416532@qq.com>
周峥 committed
Sep 2, 2026 at 01:18 UTC
c866ba9d11984fd621af5837f91cd763312f4dd3
8 files changed
+1766
-361
docs/features/mooncake_store_connector_usage.md
+55
-9
@@ -137,15 +137,12 @@ vllm serve meta-llama/Llama-3.1-8B-Instruct \
137
```
138
139
To also offload newly completed decode KV blocks, add the following extra
140
-configuration to the decoder's `MooncakeStoreConnector` entry. This currently
141
-requires the prefill and decode instances to use the same tensor-parallel size
142
-and compatible KV-cache topology.
140
+configuration to the decoder's `MooncakeStoreConnector` entry.
141
142
When decode processing starts, the consumer checks the block-aligned prompt
143
prefix and fills any blocks missing from the Store. Subsequent saves append
144
newly completed decode blocks. This keeps a complete, reusable prefix in the
147
-Store and also covers deployments where prompt KV is delivered directly by
148
-`MooncakeConnector` instead of through the Store.
145
+Store. It also covers prompt KV delivered directly by `MooncakeConnector`.
146
147
```json
148
{
@@ -155,9 +152,41 @@ Store and also covers deployments where prompt KV is delivered directly by
152
}
153
```
154
155
+#### Sharing one Store across multiple Prefill TP sizes
156
+
157
+Heterogeneous-TP sharing normally uses a fixed `store_tp_size`. When several
158
+prefillers use different TP sizes, opt in to a common Store TP derived from
159
+their least common multiple:
160
+
161
+```json
162
+{
163
+ "kv_connector_extra_config": {
164
+ "enable_store_tp_lcm": true,
165
+ "prefill_tp_sizes": [4, 2]
166
+ }
167
+}
168
+```
169
+
170
+Every prefiller and decoder that shares these entries must use the same list.
171
+The example selects Store TP 4: a TP4 endpoint maps each rank to one Store
172
+shard, while TP2 endpoints map each rank to two Store shards. Runtime TP sizes
173
+remain unchanged. A decoder configured with `"save_decode_cache": true` uses
174
+the same Store TP for decode KV from every prefiller.
175
+
176
+The list may contain positive integer TP sizes. Sharing requires a Store TP that
177
+is at least the local TP and divisible by it, an LBHNC or LBNHC local KV cache,
178
+and the existing topology and KV-head constraints. The Store namespace includes
179
+the attention backend's selected layout. Different layouts use separate Store
180
+entries. Malformed lists and unsupported endpoints use an isolated rank-local
181
+key layout. When
182
+`enable_store_tp_lcm` is absent or false, `prefill_tp_sizes` has no effect and
183
+the existing `store_tp_size` behavior is unchanged.
184
+
185
**Proxy:**
186
160
-A disaggregation proxy is required to route requests between prefiller and decoder nodes. The proxy assigns `do_remote_prefill=True` / `do_remote_decode=True` to coordinate P2P transfer via `MooncakeConnector`. Refer to the [MooncakeConnector usage guide](mooncake_connector_usage.md) for proxy setup details.
187
+A disaggregation proxy routes requests between prefiller and decoder nodes.
188
+When `MooncakeConnector` is also used for direct P2P transfer, refer to its
189
+[usage guide](mooncake_connector_usage.md) for proxy setup details.
190
191
### Disk Offloading
192
@@ -249,9 +278,26 @@ Strict isolation requires a Mooncake master started with `--enable_multi_tenants
278
- `lookup_rpc_port` (int): Custom port for the ZMQ lookup RPC socket. Default: `0`.
279
- `cache_prefix` (str): Namespace prepended to every store key. Lets separate deployments share one Mooncake master without polluting each other — instances configured with different prefixes never see each other's cached blocks, even for identical prompts. All instances that should share a prefix cache must use the same value. Default: `""` (no prefix; keys are byte-identical to the unprefixed format).
280
- `save_decode_cache` (bool): Enable offloading decode tokens' KV cache. A `kv_consumer` does not save during prefill; when decode starts, it fills any missing block-aligned prompt prefix before appending completed decode blocks. Default: `false`.
252
-
253
-Decode offloading uses the existing TP-rank key namespace. Cross-TP sharing is
254
-not yet supported.
281
+- `store_tp_size` (int): Common Store TP for endpoints with different local TP sizes. It supports LBHNC and LBNHC local KV caches, with `store_tp_size >= local_tp_size` and `store_tp_size % local_tp_size == 0`. The current topology is one full-attention cache group, PCP/DCP disabled, and cross-layer blocks disabled. For GQA and MHA, the total KV-head count must be divisible by `store_tp_size`. Store shards contain fixed global KV-head ranges in the local layout. Shared endpoints use the same KV cache layout, pipeline-parallel size, and Store TP. The Store namespace includes the layout and PP size. Unsupported configurations use a topology-specific rank-local namespace.
282
+
283
+LBHNC/HND is strongly recommended for TP-sharded Store when supported. LBNHC/NHD
284
+creates many transfer segments and may significantly reduce PUT/GET performance.
285
+
286
+For example, with prefill TP 4, decode TP 2, and eight KV heads, set
287
+`store_tp_size` to 4 on both instances. Each decode rank reads and writes two
288
+of the four Store shards.
289
+
290
+MQA with one total KV head uses a replicated-head layout. For the supported
291
+prefill TP 4 to decode TP 2 case, every rank uses the
292
+same rank-0 key namespace. The four prefill replicas stripe block PUTs so each
293
+object is stored once, while both decode ranks GET every block into their local
294
+KV replica. `store_tp_size` does not appear in MQA keys, so identical MQA
295
+objects written at different store TP sizes share the same pool entry when PP
296
+sizes match.
297
+
298
+Tensor-parallel collectives and low-precision arithmetic are not bitwise
299
+invariant across TP sizes, so heterogeneous-TP reuse does not guarantee the
300
+same greedy output as recomputing the prefix at the decode TP size.
301
302
## Notes
303
tests/v1/kv_connector/unit/test_mooncake_store_connector.py
+170
-4
@@ -48,6 +48,31 @@ def _make_vllm_config():
48
)
49
50
51
+@pytest.mark.parametrize(
52
+ "extra_config",
53
+ [
54
+ {"store_tp_size": 4},
55
+ {"enable_store_tp_lcm": True, "prefill_tp_sizes": [6, 4]},
56
+ {"prefill_tp_sizes": [4, 2]},
57
+ {"enable_store_tp_lcm": True, "prefill_tp_sizes": [4, 0]},
58
+ None,
59
+ ],
60
+)
61
+def test_store_tp_does_not_override_backend_kv_cache_layout(extra_config):
62
+ vllm_config = create_vllm_config(
63
+ kv_connector="MooncakeStoreConnector",
64
+ kv_role="kv_both",
65
+ kv_connector_extra_config=extra_config,
66
+ )
67
+
68
+ assert (
69
+ mooncake_store_connector.MooncakeStoreConnector.get_required_kvcache_layout(
70
+ vllm_config
71
+ )
72
+ is None
73
+ )
74
+
75
+
76
def _make_kv_cache_config() -> KVCacheConfig:
77
"""Single-group full-attention KVCacheConfig — enough for the connector
78
constructor's validate() pass."""
@@ -90,15 +115,18 @@ def test_scheduler_requires_align_mode_for_mamba():
115
scheduler.MooncakeStoreScheduler(vllm_config, kv_cache_config)
116
117
93
-def _make_block_stored() -> BlockStored:
118
+def _make_block_stored(
119
+ block_hash: bytes = b"hash", group_idx: int | None = None
120
+) -> BlockStored:
121
return BlockStored(
95
- block_hashes=[b"hash"],
122
+ block_hashes=[block_hash],
123
parent_block_hash=None,
124
token_ids=[1, 2, 3],
125
block_size=16,
126
lora_id=None,
127
medium="cpu",
128
lora_name=None,
129
+ group_idx=group_idx,
130
)
131
132
@@ -169,7 +197,7 @@ def test_worker_methods_delegate_to_store_worker():
197
assert invalid_block_ids == {3, 4}
198
199
172
-def test_get_kv_connector_kv_cache_events_returns_none_when_empty():
200
+def test_get_kv_connector_kv_cache_events_returns_none_when_disabled():
201
vllm_config = _make_vllm_config()
202
kv_cache_config = _make_kv_cache_config()
203
@@ -184,7 +212,7 @@ def test_get_kv_connector_kv_cache_events_returns_none_when_empty():
212
vllm_config, KVConnectorRole.WORKER, kv_cache_config
213
)
214
187
- mock_worker_cls.return_value.get_kv_events.return_value = []
215
+ mock_worker_cls.return_value.enable_kv_events = False
216
assert connector.get_kv_connector_kv_cache_events() is None
217
218
@@ -248,6 +276,9 @@ def test_get_kv_connector_kv_cache_events_wraps_worker_events():
276
)
277
278
mock_worker_cls.return_value.get_kv_events.return_value = [event]
279
+ mock_worker_cls.return_value.enable_kv_events = True
280
+ mock_worker_cls.return_value.kv_send_thread = MagicMock()
281
+ mock_worker_cls.return_value.group_tp_replication_factors = (1,)
282
kv_events = connector.get_kv_connector_kv_cache_events()
283
284
assert isinstance(kv_events, mooncake_store_connector.MooncakeStoreKVEvents)
@@ -255,6 +286,119 @@ def test_get_kv_connector_kv_cache_events_wraps_worker_events():
286
assert kv_events.get_all_events() == [event]
287
288
289
+def test_get_kv_connector_kv_cache_events_keeps_empty_worker_contribution():
290
+ vllm_config = _make_vllm_config()
291
+ kv_cache_config = _make_kv_cache_config()
292
+
293
+ with (
294
+ set_current_vllm_config(vllm_config),
295
+ patch(
296
+ "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
297
+ "connector.MooncakeStoreWorker"
298
+ ) as mock_worker_cls,
299
+ ):
300
+ connector = mooncake_store_connector.MooncakeStoreConnector(
301
+ vllm_config, KVConnectorRole.WORKER, kv_cache_config
302
+ )
303
+
304
+ worker = mock_worker_cls.return_value
305
+ worker.enable_kv_events = True
306
+ worker.kv_send_thread = MagicMock()
307
+ worker.group_tp_replication_factors = (2,)
308
+ worker.get_kv_events.return_value = []
309
+
310
+ kv_events = connector.get_kv_connector_kv_cache_events()
311
+
312
+ assert isinstance(kv_events, mooncake_store_connector.MooncakeStoreKVEvents)
313
+ assert kv_events.get_number_of_workers() == 1
314
+ assert kv_events.get_all_events() == []
315
+
316
+
317
+def _aggregate_store_events(
318
+ worker_events: list[list[BlockStored]],
319
+ replication_factors: tuple[int, ...],
320
+) -> list[BlockStored]:
321
+ combined = mooncake_store_connector.MooncakeStoreKVEvents(
322
+ num_workers=1,
323
+ group_tp_replication_factors=replication_factors,
324
+ )
325
+ combined.add_events(worker_events[0])
326
+ for events in worker_events[1:]:
327
+ combined.add_events(events)
328
+ combined.increment_workers()
329
+ combined.aggregate()
330
+ return combined.get_all_events()
331
+
332
+
333
+def test_gqa_store_events_follow_tp_replica_striping():
334
+ block_a = _make_block_stored(b"a", group_idx=0)
335
+ block_b = _make_block_stored(b"b", group_idx=0)
336
+
337
+ events = _aggregate_store_events(
338
+ [[block_a], [block_b], [block_a], [block_b]],
339
+ replication_factors=(2,),
340
+ )
341
+
342
+ assert events == [block_a, block_b]
343
+
344
+
345
+def test_mqa_store_events_require_one_replica():
346
+ blocks = [_make_block_stored(bytes([index]), group_idx=0) for index in range(4)]
347
+
348
+ events = _aggregate_store_events(
349
+ [[block] for block in blocks],
350
+ replication_factors=(4,),
351
+ )
352
+
353
+ assert events == blocks
354
+
355
+
356
+def test_store_events_require_every_non_replicated_worker():
357
+ complete = _make_block_stored(b"complete", group_idx=0)
358
+ incomplete = _make_block_stored(b"incomplete", group_idx=0)
359
+
360
+ events = _aggregate_store_events(
361
+ [
362
+ [complete, incomplete],
363
+ [complete],
364
+ [complete],
365
+ [complete],
366
+ ],
367
+ replication_factors=(1,),
368
+ )
369
+
370
+ assert events == [complete]
371
+
372
+
373
+def test_store_events_reject_incomplete_replica_coverage():
374
+ complete = _make_block_stored(b"complete", group_idx=0)
375
+ incomplete = _make_block_stored(b"incomplete", group_idx=0)
376
+
377
+ events = _aggregate_store_events(
378
+ [[complete, incomplete], [complete], [], []],
379
+ replication_factors=(2,),
380
+ )
381
+
382
+ assert events == [complete]
383
+
384
+
385
+def test_store_events_use_each_groups_replication_factor():
386
+ full_attention = _make_block_stored(b"full", group_idx=0)
387
+ replicated_attention = _make_block_stored(b"replicated", group_idx=1)
388
+
389
+ events = _aggregate_store_events(
390
+ [
391
+ [full_attention, replicated_attention],
392
+ [full_attention],
393
+ [full_attention, replicated_attention],
394
+ [full_attention],
395
+ ],
396
+ replication_factors=(1, 2),
397
+ )
398
+
399
+ assert events == [full_attention, replicated_attention]
400
+
401
+
402
def test_update_connector_output_and_take_events():
403
vllm_config = _make_vllm_config()
404
kv_cache_config = _make_kv_cache_config()
@@ -284,6 +428,28 @@ def test_update_connector_output_and_take_events():
428
assert connector._kv_cache_events is None
429
430
431
+def test_store_events_persist_across_polls():
432
+ connector = object.__new__(mooncake_store_connector.MooncakeStoreConnector)
433
+ connector.connector_scheduler = MagicMock()
434
+ connector._kv_cache_events = None
435
+ complete = _make_block_stored(b"complete", group_idx=0)
436
+ pending = _make_block_stored(b"pending", group_idx=0)
437
+
438
+ def poll(worker_events: list[BlockStored]) -> list[BlockStored]:
439
+ events = mooncake_store_connector.MooncakeStoreKVEvents(
440
+ num_workers=4,
441
+ group_tp_replication_factors=(2,),
442
+ )
443
+ events.add_events(worker_events)
444
+ connector.update_connector_output(KVConnectorOutput(kv_cache_events=events))
445
+ return list(connector.take_events())
446
+
447
+ assert poll([complete, pending]) == []
448
+ assert poll([complete]) == [complete]
449
+ assert poll([pending, pending]) == [pending]
450
+ assert connector._kv_cache_events is None
451
+
452
+
453
# ============================================================
454
# reset_cache() — RL hard-reset path via typed LookupKey protocol
455
# ============================================================
tests/v1/kv_connector/unit/test_mooncake_store_layout.py
new
+180
@@ -0,0 +1,180 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Tests for Mooncake Store payload layouts."""
4
+
5
+import ctypes
6
+import random
7
+
8
+import pytest
9
+import torch
10
+
11
+from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
12
+ ChunkedTokenDatabase,
13
+ KeyMetadata,
14
+ LBHNCStoreLayout,
15
+ LBNHCStoreLayout,
16
+ RankLocalStoreLayout,
17
+)
18
+from vllm.utils.math_utils import cdiv
19
+
20
+BLOCK_SIZE = 128
21
+
22
+
23
+def _rank_local_layout(num_regions: int, num_block_lens: int) -> RankLocalStoreLayout:
24
+ metadata = KeyMetadata("test-model", 1, 0, 0, 0)
25
+ layout = RankLocalStoreLayout(metadata, BLOCK_SIZE, BLOCK_SIZE)
26
+ layout.set_kv_caches_base_addr(
27
+ [0x7F00_0000_0000 + i * (1 << 30) for i in range(num_regions)]
28
+ )
29
+ layout.set_block_len([30_208 + 512 * i for i in range(num_block_lens)])
30
+ return layout
31
+
32
+
33
+def _rank_local_reference(
34
+ layout: RankLocalStoreLayout, start: int, end: int, block_ids: list[int]
35
+) -> tuple[list[int], list[int], int]:
36
+ block_id = block_ids[start // layout.block_size]
37
+ length = len(layout.block_len)
38
+ addrs = [
39
+ base_addr + block_id * layout.block_len[index % length]
40
+ for index, base_addr in enumerate(layout.kv_caches_base_addr)
41
+ ]
42
+ sizes = [
43
+ layout.block_len[index % length] * cdiv(end - start, layout.block_size)
44
+ for index in range(len(layout.kv_caches_base_addr))
45
+ ]
46
+ return addrs, sizes, block_id
47
+
48
+
49
+@pytest.mark.parametrize("num_regions,num_block_lens", [(96, 96), (96, 2), (1, 1)])
50
+def test_rank_local_descriptors_match_reference(num_regions: int, num_block_lens: int):
51
+ layout = _rank_local_layout(num_regions, num_block_lens)
52
+ rng = random.Random(0)
53
+ block_ids = [rng.randrange(0, 1 << 20) for _ in range(300)]
54
+ chunks = []
55
+ block = 0
56
+ while block < len(block_ids) - 4:
57
+ span = rng.choice([1, 1, 1, 2, 4])
58
+ chunks.append((block * BLOCK_SIZE, (block + span) * BLOCK_SIZE))
59
+ block += span + rng.choice([0, 1])
60
+
61
+ addrs, sizes, selected_blocks = layout.prepare_values(
62
+ chunks, block_ids, [0] * len(chunks)
63
+ )
64
+
65
+ for chunk, chunk_addrs, chunk_sizes, block_id in zip(
66
+ chunks, addrs, sizes, selected_blocks, strict=True
67
+ ):
68
+ assert (chunk_addrs, chunk_sizes, block_id) == _rank_local_reference(
69
+ layout, *chunk, block_ids
70
+ )
71
+ assert all(type(addr) is int for addr in chunk_addrs)
72
+ assert type(block_id) is int
73
+
74
+
75
+def test_rank_local_descriptors_handle_empty_and_invalid_chunks():
76
+ layout = _rank_local_layout(4, 4)
77
+ assert layout.prepare_values([], [1, 2, 3], []) == ([], [], [])
78
+ with pytest.raises(AssertionError):
79
+ layout.prepare_values([(0, BLOCK_SIZE + 1)], [0, 1], [0])
80
+
81
+
82
+@pytest.mark.parametrize(
83
+ ("layout_cls", "store_format"),
84
+ [
85
+ (LBHNCStoreLayout, "tp_shared_lbhnc"),
86
+ (LBNHCStoreLayout, "tp_shared_lbnhc"),
87
+ ],
88
+)
89
+def test_tp_shared_layout_owns_store_namespace(layout_cls, store_format):
90
+ assert (
91
+ layout_cls.shared_namespace(4, 2)
92
+ == f"@store_tp:4@store_pp:2@store_format:{store_format}"
93
+ )
94
+
95
+
96
+def test_rank_local_database_api_rejects_tp_shared_layout():
97
+ metadata = KeyMetadata("test-model", 0, 0, 0, 0)
98
+ layout = LBHNCStoreLayout(
99
+ metadata,
100
+ block_size=16,
101
+ hash_block_size=16,
102
+ local_tp_size=2,
103
+ store_tp_size=4,
104
+ tp_rank=0,
105
+ num_kv_heads=8,
106
+ )
107
+ database = ChunkedTokenDatabase(metadata, 16, store_layout=layout)
108
+
109
+ with pytest.raises(RuntimeError, match="rank-local"):
110
+ database.prepare_value_for_block(0)
111
+
112
+
113
+@pytest.mark.parametrize(
114
+ ("layout_cls", "producer_strides", "consumer_strides"),
115
+ [
116
+ (LBHNCStoreLayout, (128, 64, 4, 1), (256, 64, 4, 1)),
117
+ (LBNHCStoreLayout, (128, 4, 8, 1), (256, 4, 16, 1)),
118
+ ],
119
+)
120
+def test_tp_shared_layout_round_trip_across_tp_sizes(
121
+ layout_cls, producer_strides, consumer_strides
122
+):
123
+ block_size = 16
124
+ shape = (1, 2, block_size, 4)
125
+ stored: dict[int, bytes] = {}
126
+ producer_tensors = []
127
+
128
+ for tp_rank in range(4):
129
+ tensor = torch.empty_strided(shape, producer_strides, dtype=torch.float16)
130
+ tensor.copy_(
131
+ torch.arange(128, dtype=torch.float16).view(shape) + tp_rank * 1000
132
+ )
133
+ producer_tensors.append(tensor)
134
+ metadata = KeyMetadata("test-model", tp_rank, 0, 0, 0)
135
+ layout = layout_cls(
136
+ metadata,
137
+ block_size,
138
+ block_size,
139
+ local_tp_size=4,
140
+ store_tp_size=4,
141
+ tp_rank=tp_rank,
142
+ num_kv_heads=8,
143
+ )
144
+ layout.register_kv_caches([tensor], 1)
145
+ addrs, sizes, _ = layout.prepare_values([(0, block_size)], [0], [tp_rank])
146
+ stored[tp_rank] = b"".join(
147
+ ctypes.string_at(addr, size)
148
+ for addr, size in zip(addrs[0], sizes[0], strict=True)
149
+ )
150
+
151
+ for tp_rank in range(2):
152
+ tensor = torch.empty_strided(
153
+ (1, 4, block_size, 4), consumer_strides, dtype=torch.float16
154
+ )
155
+ tensor.zero_()
156
+ metadata = KeyMetadata("test-model", tp_rank, 0, 0, 0)
157
+ layout = layout_cls(
158
+ metadata,
159
+ block_size,
160
+ block_size,
161
+ local_tp_size=2,
162
+ store_tp_size=4,
163
+ tp_rank=tp_rank,
164
+ num_kv_heads=8,
165
+ )
166
+ layout.register_kv_caches([tensor], 1)
167
+ shard_ids = layout.local_shard_ids
168
+ addrs, sizes, _ = layout.prepare_values(
169
+ [(0, block_size)] * len(shard_ids), [0], shard_ids
170
+ )
171
+ for shard_id, shard_addrs, shard_sizes in zip(
172
+ shard_ids, addrs, sizes, strict=True
173
+ ):
174
+ offset = 0
175
+ for addr, size in zip(shard_addrs, shard_sizes, strict=True):
176
+ ctypes.memmove(addr, stored[shard_id][offset : offset + size], size)
177
+ offset += size
178
+
179
+ expected = torch.cat(producer_tensors[tp_rank * 2 : tp_rank * 2 + 2], dim=1)
180
+ torch.testing.assert_close(tensor, expected)
tests/v1/kv_connector/unit/test_mooncake_store_prepare_values.py
deleted
-90
@@ -1,90 +0,0 @@
1
-# SPDX-License-Identifier: Apache-2.0
2
-# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""Tests for ChunkedTokenDatabase.prepare_values."""
4
-
5
-import random
6
-
7
-import pytest
8
-
9
-from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
10
- ChunkedTokenDatabase,
11
- KeyMetadata,
12
-)
13
-from vllm.utils.math_utils import cdiv
14
-
15
-BLOCK_SIZE = 128
16
-
17
-
18
-def _reference_prepare_value(
19
- db: ChunkedTokenDatabase, start: int, end: int, block_ids: list[int]
20
-) -> tuple[list[int], list[int], int]:
21
- """Compute a token range with the original scalar implementation."""
22
- addr_list = []
23
- size_list = []
24
- block_id = block_ids[start // db.block_size]
25
- length = len(db.block_len)
26
- for index, base_addr in enumerate(db.kv_caches_base_addr):
27
- addr = base_addr + block_id * db.block_len[index % length]
28
- assert (end - start) % db.block_size == 0
29
- size = db.block_len[index % length] * cdiv(end - start, db.block_size)
30
- addr_list.append(addr)
31
- size_list.append(size)
32
- return addr_list, size_list, block_id
33
-
34
-
35
-def _make_db(num_regions: int, num_block_lens: int) -> ChunkedTokenDatabase:
36
- md = KeyMetadata(model_name="t", tp_rank=1, pcp_rank=0, dcp_rank=0, pp_rank=0)
37
- db = ChunkedTokenDatabase(md, BLOCK_SIZE)
38
- db.set_kv_caches_base_addr(
39
- [0x7F00_0000_0000 + i * (1 << 30) for i in range(num_regions)]
40
- )
41
- # Exercise repeated block lengths when there are more cache regions.
42
- db.set_block_len([30_208 + 512 * i for i in range(num_block_lens)])
43
- return db
44
-
45
-
46
-@pytest.mark.parametrize("num_regions,num_block_lens", [(96, 96), (96, 2), (1, 1)])
47
-def test_prepare_values_matches_reference(num_regions: int, num_block_lens: int):
48
- db = _make_db(num_regions, num_block_lens)
49
- rng = random.Random(0)
50
- n_blocks = 300
51
- block_ids = [rng.randrange(0, 1 << 20) for _ in range(n_blocks)]
52
- chunks = []
53
- b = 0
54
- while b < n_blocks - 4:
55
- span = rng.choice([1, 1, 1, 2, 4])
56
- chunks.append((b * BLOCK_SIZE, (b + span) * BLOCK_SIZE))
57
- b += span + rng.choice([0, 1])
58
-
59
- addrs, sizes, bids = db.prepare_values(chunks, block_ids)
60
- assert len(addrs) == len(sizes) == len(bids) == len(chunks)
61
- for (start, end), addr, size, bid in zip(chunks, addrs, sizes, bids):
62
- ref_addr, ref_size, ref_bid = _reference_prepare_value(
63
- db, start, end, block_ids
64
- )
65
- assert addr == ref_addr
66
- assert size == ref_size
67
- assert bid == ref_bid
68
- # Native bindings require Python ints rather than numpy scalars.
69
- assert all(type(a) is int for a in addr)
70
- assert type(bid) is int
71
-
72
-
73
-def test_prepare_value_single_matches_reference():
74
- db = _make_db(8, 8)
75
- block_ids = list(range(64))
76
- got = db.prepare_value(5 * BLOCK_SIZE, 7 * BLOCK_SIZE, block_ids)
77
- assert got == _reference_prepare_value(
78
- db, 5 * BLOCK_SIZE, 7 * BLOCK_SIZE, block_ids
79
- )
80
-
81
-
82
-def test_prepare_values_empty():
83
- db = _make_db(4, 4)
84
- assert db.prepare_values([], [1, 2, 3]) == ([], [], [])
85
-
86
-
87
-def test_prepare_values_rejects_unaligned_chunk():
88
- db = _make_db(4, 4)
89
- with pytest.raises(AssertionError):
90
- db.prepare_values([(0, BLOCK_SIZE + 1)], [0, 1])
tests/v1/kv_connector/unit/test_mooncake_store_worker.py
+586
-16
@@ -17,6 +17,7 @@ import pytest
17
import torch
18
19
from tests.v1.attention.utils import dense_kv_cache_views
20
+from vllm.distributed.kv_events import KVEventAggregator
21
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake import (
22
rdma_utils,
23
)
@@ -30,9 +31,12 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
31
BlobBlockHashes,
32
ChunkedTokenDatabase,
33
KeyMetadata,
34
+ LBHNCStoreLayout,
35
+ LBNHCStoreLayout,
36
LoadSpec,
37
MooncakeLookupResult,
38
PoolKey,
39
+ RankLocalStoreLayout,
40
ReqMeta,
41
TailKeyBoundary,
42
)
@@ -44,8 +48,19 @@ from vllm.v1.core.kv_cache_utils import BlockHash, maybe_convert_block_hash
48
from vllm.v1.kv_cache_interface import (
49
FullAttentionSpec,
50
KVCacheGroupSpec,
47
- KVCacheLayout,
51
+ RSWASpec,
52
)
53
+from vllm.v1.kv_cache_layout import KVCacheLayout
54
+
55
+_TP_SHARED_NAMESPACE = "@store_tp:4@store_pp:1@store_format:tp_shared_lbhnc"
56
+_TP_SHARED_LBNHC_NAMESPACE = "@store_tp:4@store_pp:1@store_format:tp_shared_lbnhc"
57
+
58
+
59
+def _tp_shared_prefix(tp_rank: int) -> str:
60
+ return (
61
+ f"test-model{_TP_SHARED_NAMESPACE}@tp_rank:{tp_rank}"
62
+ "@pcp0@dcp0@pp_rank:0@group:0"
63
+ )
64
65
66
class _RecordingBlockHashes:
@@ -78,6 +93,46 @@ def _default_send_coord() -> mooncake_store_worker.MooncakeStoreCoordinator:
93
)
94
95
96
+def _make_tp_shared_db(
97
+ tp_rank: int = 0,
98
+) -> tuple[ChunkedTokenDatabase, torch.Tensor]:
99
+ num_blocks = 2
100
+ local_heads = 4
101
+ block_size = 16
102
+ content_size = 4
103
+ strides = (
104
+ local_heads * block_size * content_size,
105
+ block_size * content_size,
106
+ content_size,
107
+ 1,
108
+ )
109
+ tensor = torch.empty_strided(
110
+ (num_blocks, local_heads, block_size, content_size),
111
+ strides,
112
+ dtype=torch.float16,
113
+ )
114
+ metadata = KeyMetadata(
115
+ "test-model",
116
+ tp_rank,
117
+ 0,
118
+ 0,
119
+ 0,
120
+ store_namespace=_TP_SHARED_NAMESPACE,
121
+ )
122
+ layout = LBHNCStoreLayout(
123
+ metadata,
124
+ block_size,
125
+ block_size,
126
+ local_tp_size=2,
127
+ store_tp_size=4,
128
+ tp_rank=tp_rank,
129
+ num_kv_heads=8,
130
+ )
131
+ db = ChunkedTokenDatabase(metadata, block_size, store_layout=layout)
132
+ layout.register_kv_caches([tensor], num_blocks)
133
+ return db, tensor
134
+
135
+
136
def _make_store_sending_thread(
137
store: MagicMock,
138
*,
@@ -243,11 +298,15 @@ def _make_vllm_config(
298
rank: int = 0,
299
decode_context_parallel_size: int = 1,
300
kv_role: str = "kv_both",
301
+ pipeline_parallel_size: int = 1,
302
+ kv_cache_layout: KVCacheLayout = KVCacheLayout.LBHNC,
303
) -> SimpleNamespace:
304
+ cache_config = SimpleNamespace(block_size=16, num_gpu_blocks=10)
305
+ cache_config.get_resolved_kv_cache_layout = lambda: kv_cache_layout
306
return SimpleNamespace(
307
model_config=_FakeModelConfig(),
308
parallel_config=SimpleNamespace(
250
- pipeline_parallel_size=1,
309
+ pipeline_parallel_size=pipeline_parallel_size,
310
rank=rank,
311
data_parallel_index=0,
312
decode_context_parallel_size=decode_context_parallel_size,
@@ -256,7 +315,7 @@ def _make_vllm_config(
315
kv_transfer_config=_FakeKVTransferConfig(
316
kv_role=kv_role, extra_config=extra_config
317
),
259
- cache_config=SimpleNamespace(block_size=16, num_gpu_blocks=10),
318
+ cache_config=cache_config,
319
kv_events_config=SimpleNamespace(enable_kv_cache_events=False),
320
speculative_config=None,
321
)
@@ -334,6 +393,138 @@ def test_pool_key_to_string_without_prefix_is_unchanged():
393
)
394
395
396
+def test_pool_key_store_tp_namespace():
397
+ key = PoolKey(
398
+ KeyMetadata(
399
+ "test-model",
400
+ 2,
401
+ 0,
402
+ 0,
403
+ 0,
404
+ store_namespace=_TP_SHARED_NAMESPACE,
405
+ ),
406
+ "deadbeef",
407
+ )
408
+
409
+ assert key.to_string() == f"{_tp_shared_prefix(2)}@deadbeef"
410
+
411
+
412
+def test_tp_shared_sending_writes_each_store_shard():
413
+ store = MagicMock()
414
+ store.batch_is_exist.return_value = [0, 0, 0, 0]
415
+ store.batch_put_from_multi_buffers.return_value = [1, 1, 1, 1]
416
+ db, _ = _make_tp_shared_db()
417
+ thread = _make_store_sending_thread(
418
+ store,
419
+ token_databases=[db],
420
+ replicate_config=SimpleNamespace(),
421
+ )
422
+ req = _make_store_req("req", [b"h0", b"h1"])
423
+ _run_store_req(thread, req)
424
+
425
+ keys, addrs, sizes, _ = store.batch_put_from_multi_buffers.call_args.args
426
+ assert keys == [
427
+ f"{_tp_shared_prefix(0)}@6830",
428
+ f"{_tp_shared_prefix(1)}@6830",
429
+ f"{_tp_shared_prefix(0)}@6831",
430
+ f"{_tp_shared_prefix(1)}@6831",
431
+ ]
432
+ assert len(addrs) == len(sizes) == 4
433
+
434
+
435
+def test_tp_shared_kv_events_aggregate_once_per_logical_block():
436
+ aggregator = KVEventAggregator(num_workers=2)
437
+
438
+ for tp_rank in range(2):
439
+ store = MagicMock()
440
+ store.batch_is_exist.return_value = [0, 0]
441
+ store.batch_put_from_multi_buffers.return_value = [256, 256]
442
+ db, _ = _make_tp_shared_db(tp_rank)
443
+ thread = _make_store_sending_thread(
444
+ store,
445
+ token_databases=[db],
446
+ tp_rank=tp_rank,
447
+ replicate_config=SimpleNamespace(),
448
+ )
449
+ thread.enable_kv_event = True
450
+
451
+ _run_store_req(thread, _make_event_store_req(16))
452
+
453
+ events = thread.get_kv_events()
454
+ assert len(events) == 1
455
+ aggregator.add_events(events)
456
+
457
+ assert len(aggregator.get_common_events()) == 1
458
+
459
+
460
+def test_tp_shared_kv_event_waits_for_all_missing_shards():
461
+ store = MagicMock()
462
+ store.batch_is_exist.return_value = [0, 0]
463
+ store.batch_put_from_multi_buffers.return_value = [256, -1]
464
+ db, _ = _make_tp_shared_db()
465
+ thread = _make_store_sending_thread(
466
+ store,
467
+ token_databases=[db],
468
+ replicate_config=SimpleNamespace(),
469
+ )
470
+ thread.enable_kv_event = True
471
+
472
+ _run_store_req(thread, _make_event_store_req(16))
473
+
474
+ assert thread.get_kv_events() == []
475
+
476
+
477
+def test_tp_shared_kv_event_counts_existing_shards_as_satisfied():
478
+ store = MagicMock()
479
+ store.batch_is_exist.return_value = [1, 0]
480
+ store.batch_put_from_multi_buffers.return_value = [256]
481
+ db, _ = _make_tp_shared_db()
482
+ thread = _make_store_sending_thread(
483
+ store,
484
+ token_databases=[db],
485
+ replicate_config=SimpleNamespace(),
486
+ )
487
+ thread.enable_kv_event = True
488
+
489
+ _run_store_req(thread, _make_event_store_req(16))
490
+
491
+ assert len(thread.get_kv_events()) == 1
492
+
493
+
494
+def test_tp_shared_receiving_reads_each_local_store_shard():
495
+ from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheGroupSpec
496
+
497
+ store = MagicMock()
498
+ store.batch_get_into_multi_buffers.return_value = [1, 1, 1, 1]
499
+ db, _ = _make_tp_shared_db()
500
+ spec = FullAttentionSpec(block_size=16, num_kv_heads=8, head_size=64, dtype=None)
501
+ coord = mooncake_store_worker.MooncakeStoreCoordinator(
502
+ [KVCacheGroupSpec(["layer0"], spec)],
503
+ scheduler_block_size=16,
504
+ hash_block_size=16,
505
+ )
506
+ thread = mooncake_store_worker.KVCacheStoreRecvingThread(
507
+ store=store,
508
+ token_databases=[db],
509
+ block_size=16,
510
+ tp_rank=0,
511
+ ready_event=threading.Event(),
512
+ coord=coord,
513
+ )
514
+ thread.request_queue.task_done = MagicMock()
515
+
516
+ thread._handle_request(_make_load_req("req", [b"h0", b"h1"], token_len=32))
517
+
518
+ keys, addrs, sizes = store.batch_get_into_multi_buffers.call_args.args
519
+ assert keys == [
520
+ f"{_tp_shared_prefix(0)}@6830",
521
+ f"{_tp_shared_prefix(1)}@6830",
522
+ f"{_tp_shared_prefix(0)}@6831",
523
+ f"{_tp_shared_prefix(1)}@6831",
524
+ ]
525
+ assert len(addrs) == len(sizes) == 4
526
+
527
+
528
def test_pool_key_cache_prefix_namespaces_and_disambiguates():
529
"""A non-empty cache_prefix is prepended, and two instances with
530
different prefixes never collide on identical block hashes."""
@@ -1155,8 +1346,7 @@ def test_store_sending_thread_prepares_missing_chunks_once_per_group():
1346
)
1347
db0.set_kv_caches_base_addr([0x1000])
1348
db0.set_block_len([256])
1158
- db0.prepare_values = MagicMock(wraps=db0.prepare_values)
1159
- db0.prepare_value = MagicMock(side_effect=AssertionError("scalar path called"))
1349
+ db0.store_layout.prepare_values = MagicMock(wraps=db0.store_layout.prepare_values)
1350
1351
db1 = ChunkedTokenDatabase(
1352
KeyMetadata("test-model", 0, 0, 0, 0, group_id=1),
@@ -1164,8 +1354,7 @@ def test_store_sending_thread_prepares_missing_chunks_once_per_group():
1354
)
1355
db1.set_kv_caches_base_addr([0x2000])
1356
db1.set_block_len([512])
1167
- db1.prepare_values = MagicMock(wraps=db1.prepare_values)
1168
- db1.prepare_value = MagicMock(side_effect=AssertionError("scalar path called"))
1357
+ db1.store_layout.prepare_values = MagicMock(wraps=db1.store_layout.prepare_values)
1358
1359
thread = _make_store_sending_thread(
1360
store,
@@ -1183,10 +1372,12 @@ def test_store_sending_thread_prepares_missing_chunks_once_per_group():
1372
),
1373
)
1374
1186
- db0.prepare_value.assert_not_called()
1187
- db1.prepare_value.assert_not_called()
1188
- db0.prepare_values.assert_called_once_with([(0, 16), (32, 48)], [1, 2, 3])
1189
- db1.prepare_values.assert_called_once_with([(16, 32), (32, 48)], [3, 2, 1])
1375
+ db0.store_layout.prepare_values.assert_called_once_with(
1376
+ [(0, 16), (32, 48)], [1, 2, 3], [0, 0]
1377
+ )
1378
+ db1.store_layout.prepare_values.assert_called_once_with(
1379
+ [(16, 32), (32, 48)], [3, 2, 1], [0, 0]
1380
+ )
1381
1382
keys, addrs, sizes, _ = store.batch_put_from_multi_buffers.call_args.args
1383
assert [key.rsplit("@", 1)[-1] for key in keys] == [
@@ -1836,8 +2027,10 @@ def test_recv_thread_splits_disk_offload_loads_by_budget():
2027
assert second_keys == [
2028
"test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@6132",
2029
]
1839
- base_addr = thread.token_databases[0].kv_caches_base_addr[0]
1840
- block_len = thread.token_databases[0].block_len[0]
2030
+ layout = thread.token_databases[0].store_layout
2031
+ assert isinstance(layout, RankLocalStoreLayout)
2032
+ base_addr = layout.kv_caches_base_addr[0]
2033
+ block_len = layout.block_len[0]
2034
assert first_addrs == [[base_addr], [base_addr + block_len]]
2035
assert second_addrs == [[base_addr + 2 * block_len]]
2036
expected_size = block_len
@@ -2004,6 +2197,294 @@ def test_requester_worker_init_skips_disk_budget_when_offload_disabled(
2197
assert w.disk_offload_buffer_budget_bytes is None
2198
2199
2200
+def test_worker_enables_lbhnc_store_tp_layout(tmp_path, monkeypatch):
2201
+ store = MagicMock()
2202
+ store.setup.return_value = 0
2203
+ _install_fake_mooncake(monkeypatch, store)
2204
+ _patch_worker_runtime(monkeypatch, tp_size=2)
2205
+ monkeypatch.setattr(_FakeModelConfig, "get_total_num_kv_heads", lambda _self: 8)
2206
+ monkeypatch.setenv(
2207
+ "MOONCAKE_CONFIG_PATH",
2208
+ _write_mooncake_config(
2209
+ tmp_path,
2210
+ {
2211
+ "metadata_server": "http://metadata/endpoint",
2212
+ "protocol": "tcp",
2213
+ "master_server_address": "10.0.0.7:50051",
2214
+ },
2215
+ ),
2216
+ )
2217
+
2218
+ w = worker.MooncakeStoreWorker(
2219
+ _make_vllm_config(extra_config={"store_tp_size": 4}),
2220
+ _make_kv_cache_config(),
2221
+ )
2222
+
2223
+ assert w.store_tp_size == 4
2224
+ assert w.token_dbs[0].metadata.store_namespace == _TP_SHARED_NAMESPACE
2225
+ assert isinstance(w.token_dbs[0].store_layout, LBHNCStoreLayout)
2226
+ assert w.token_dbs[0].store_layout.local_shard_ids == (0, 1)
2227
+ assert len(w._lookup_key_prefixes[0]) == 4
2228
+
2229
+
2230
+def test_worker_enables_lbnhc_store_tp_layout(tmp_path, monkeypatch):
2231
+ store = MagicMock()
2232
+ store.setup.return_value = 0
2233
+ _install_fake_mooncake(monkeypatch, store)
2234
+ _patch_worker_runtime(monkeypatch, tp_size=2)
2235
+ monkeypatch.setattr(_FakeModelConfig, "get_total_num_kv_heads", lambda _self: 8)
2236
+ monkeypatch.setenv(
2237
+ "MOONCAKE_CONFIG_PATH",
2238
+ _write_mooncake_config(
2239
+ tmp_path,
2240
+ {
2241
+ "metadata_server": "http://metadata/endpoint",
2242
+ "protocol": "tcp",
2243
+ "master_server_address": "10.0.0.7:50051",
2244
+ },
2245
+ ),
2246
+ )
2247
+
2248
+ with patch.object(worker.logger, "warning_once") as warning_once:
2249
+ w = worker.MooncakeStoreWorker(
2250
+ _make_vllm_config(
2251
+ extra_config={"store_tp_size": 4},
2252
+ kv_cache_layout=KVCacheLayout.LBNHC,
2253
+ ),
2254
+ _make_kv_cache_config(),
2255
+ )
2256
+
2257
+ warning_once.assert_called_once()
2258
+ assert w.store_tp_size == 4
2259
+ assert w.token_dbs[0].metadata.store_namespace == _TP_SHARED_LBNHC_NAMESPACE
2260
+ assert isinstance(w.token_dbs[0].store_layout, LBNHCStoreLayout)
2261
+ assert w.token_dbs[0].store_layout.local_shard_ids == (0, 1)
2262
+ assert len(w._lookup_key_prefixes[0]) == 4
2263
+
2264
+
2265
+def test_tp_sharded_layout_rejects_full_attention_subclasses():
2266
+ spec = RSWASpec(
2267
+ block_size=16,
2268
+ num_kv_heads=8,
2269
+ head_size=64,
2270
+ dtype=None,
2271
+ rswa_window=32,
2272
+ )
2273
+ store_worker = object.__new__(worker.MooncakeStoreWorker)
2274
+ store_worker.pcp_size = 1
2275
+ store_worker.dcp_size = 1
2276
+ store_worker._kv_cache_groups = [SimpleNamespace(kv_cache_spec=spec)]
2277
+
2278
+ assert not store_worker._supports_tp_sharded_store_layout(LBHNCStoreLayout, {})
2279
+
2280
+
2281
+@pytest.mark.parametrize(
2282
+ ("extra_config", "expected"),
2283
+ [
2284
+ ({"store_tp_size": 4}, 4),
2285
+ (
2286
+ {
2287
+ "enable_store_tp_lcm": True,
2288
+ "prefill_tp_sizes": [3, 4, 6],
2289
+ },
2290
+ 12,
2291
+ ),
2292
+ (
2293
+ {
2294
+ "enable_store_tp_lcm": False,
2295
+ "prefill_tp_sizes": [4, 2],
2296
+ },
2297
+ None,
2298
+ ),
2299
+ ({"enable_store_tp_lcm": True, "prefill_tp_sizes": []}, None),
2300
+ ({"enable_store_tp_lcm": True, "prefill_tp_sizes": [4, 0]}, None),
2301
+ ({"enable_store_tp_lcm": True, "prefill_tp_sizes": [4, 2.0]}, None),
2302
+ ({"store_tp_size": 0}, None),
2303
+ ({"store_tp_size": -1}, None),
2304
+ ({"store_tp_size": True}, None),
2305
+ ({"store_tp_size": 4.0}, None),
2306
+ ],
2307
+)
2308
+def test_resolve_store_tp_size(extra_config: dict[str, object], expected: int | None):
2309
+ assert worker.resolve_store_tp_size(extra_config) == expected
2310
+
2311
+
2312
+def test_lcm_store_tp_gives_prefill_and_decode_common_namespace(tmp_path, monkeypatch):
2313
+ store = MagicMock()
2314
+ store.setup.return_value = 0
2315
+ _install_fake_mooncake(monkeypatch, store)
2316
+ monkeypatch.setattr(_FakeModelConfig, "get_total_num_kv_heads", lambda _self: 8)
2317
+ monkeypatch.setenv(
2318
+ "MOONCAKE_CONFIG_PATH",
2319
+ _write_mooncake_config(
2320
+ tmp_path,
2321
+ {
2322
+ "metadata_server": "http://metadata/endpoint",
2323
+ "protocol": "tcp",
2324
+ "master_server_address": "10.0.0.7:50051",
2325
+ },
2326
+ ),
2327
+ )
2328
+ lcm_config = {
2329
+ "enable_store_tp_lcm": True,
2330
+ "prefill_tp_sizes": [4, 2],
2331
+ }
2332
+
2333
+ workers = []
2334
+ topologies = [
2335
+ (4, 2, "kv_both", (2,)),
2336
+ (2, 1, "kv_both", (2, 3)),
2337
+ (2, 1, "kv_consumer", (2, 3)),
2338
+ ]
2339
+ for tp_size, tp_rank, kv_role, expected_shards in topologies:
2340
+ _patch_worker_runtime(
2341
+ monkeypatch,
2342
+ tp_size=tp_size,
2343
+ tp_rank=tp_rank,
2344
+ )
2345
+ extra_config = dict(lcm_config)
2346
+ if kv_role == "kv_consumer":
2347
+ extra_config["save_decode_cache"] = True
2348
+ store_worker = worker.MooncakeStoreWorker(
2349
+ _make_vllm_config(extra_config=extra_config, kv_role=kv_role),
2350
+ _make_kv_cache_config(),
2351
+ )
2352
+ assert store_worker.store_tp_size == 4
2353
+ assert isinstance(store_worker.token_dbs[0].store_layout, LBHNCStoreLayout)
2354
+ assert store_worker.token_dbs[0].store_layout.local_shard_ids == expected_shards
2355
+ workers.append(store_worker)
2356
+
2357
+ shard_keys = [
2358
+ store_worker.token_dbs[0].store_layout.key_for(2, BlockHash(b"h"))
2359
+ for store_worker in workers
2360
+ ]
2361
+ assert len(set(shard_keys)) == 1
2362
+ assert workers[-1].can_put
2363
+
2364
+
2365
+@pytest.mark.parametrize(
2366
+ ("tp_size", "prefill_tp_sizes"),
2367
+ [
2368
+ (3, [4, 2]),
2369
+ (2, [4, 3]),
2370
+ (2, []),
2371
+ (2, [4, 0]),
2372
+ ],
2373
+)
2374
+def test_lcm_store_tp_falls_back_when_topology_is_incompatible(
2375
+ tmp_path,
2376
+ monkeypatch,
2377
+ tp_size: int,
2378
+ prefill_tp_sizes: list[int],
2379
+):
2380
+ store = MagicMock()
2381
+ store.setup.return_value = 0
2382
+ _install_fake_mooncake(monkeypatch, store)
2383
+ _patch_worker_runtime(monkeypatch, tp_size=tp_size)
2384
+ monkeypatch.setattr(_FakeModelConfig, "get_total_num_kv_heads", lambda _self: 8)
2385
+ monkeypatch.setenv(
2386
+ "MOONCAKE_CONFIG_PATH",
2387
+ _write_mooncake_config(
2388
+ tmp_path,
2389
+ {
2390
+ "metadata_server": "http://metadata/endpoint",
2391
+ "protocol": "tcp",
2392
+ "master_server_address": "10.0.0.7:50051",
2393
+ },
2394
+ ),
2395
+ )
2396
+
2397
+ store_worker = worker.MooncakeStoreWorker(
2398
+ _make_vllm_config(
2399
+ extra_config={
2400
+ "enable_store_tp_lcm": True,
2401
+ "prefill_tp_sizes": prefill_tp_sizes,
2402
+ }
2403
+ ),
2404
+ _make_kv_cache_config(),
2405
+ )
2406
+
2407
+ assert store_worker.store_tp_size is None
2408
+ assert type(store_worker.token_dbs[0]) is ChunkedTokenDatabase
2409
+ assert "@store_format:rank_local_tp" in store_worker.token_dbs[0].key_for(
2410
+ BlockHash(b"h")
2411
+ )
2412
+
2413
+
2414
+@pytest.mark.parametrize("store_tp_size", [1, 3, 6])
2415
+def test_worker_keeps_rank_local_layout_for_unsupported_store_tp(
2416
+ tmp_path,
2417
+ monkeypatch,
2418
+ store_tp_size: int,
2419
+):
2420
+ store = MagicMock()
2421
+ store.setup.return_value = 0
2422
+ _install_fake_mooncake(monkeypatch, store)
2423
+ _patch_worker_runtime(monkeypatch, tp_size=2)
2424
+ monkeypatch.setattr(_FakeModelConfig, "get_total_num_kv_heads", lambda _self: 8)
2425
+ monkeypatch.setenv(
2426
+ "MOONCAKE_CONFIG_PATH",
2427
+ _write_mooncake_config(
2428
+ tmp_path,
2429
+ {
2430
+ "metadata_server": "http://metadata/endpoint",
2431
+ "protocol": "tcp",
2432
+ "master_server_address": "10.0.0.7:50051",
2433
+ },
2434
+ ),
2435
+ )
2436
+
2437
+ w = worker.MooncakeStoreWorker(
2438
+ _make_vllm_config(
2439
+ extra_config={"store_tp_size": store_tp_size},
2440
+ kv_cache_layout=KVCacheLayout.LBNHC,
2441
+ ),
2442
+ _make_kv_cache_config(),
2443
+ )
2444
+
2445
+ assert w.store_tp_size is None
2446
+ assert type(w.token_dbs[0]) is ChunkedTokenDatabase
2447
+ key = w.token_dbs[0].key_for(BlockHash(b"h"))
2448
+ assert "@store_tp:" not in key
2449
+ assert "@store_pp:1" in key
2450
+ assert "@store_format:rank_local_tp2_layout_LBNHC" in key
2451
+
2452
+
2453
+def test_store_tp_opt_in_isolates_different_pp_sizes(tmp_path, monkeypatch):
2454
+ store = MagicMock()
2455
+ store.setup.return_value = 0
2456
+ _install_fake_mooncake(monkeypatch, store)
2457
+ _patch_worker_runtime(monkeypatch, tp_size=2)
2458
+ monkeypatch.setattr(_FakeModelConfig, "get_total_num_kv_heads", lambda _self: 8)
2459
+ monkeypatch.setenv(
2460
+ "MOONCAKE_CONFIG_PATH",
2461
+ _write_mooncake_config(
2462
+ tmp_path,
2463
+ {
2464
+ "metadata_server": "http://metadata/endpoint",
2465
+ "protocol": "tcp",
2466
+ "master_server_address": "10.0.0.7:50051",
2467
+ },
2468
+ ),
2469
+ )
2470
+
2471
+ keys = []
2472
+ for pp_size in (1, 2):
2473
+ w = worker.MooncakeStoreWorker(
2474
+ _make_vllm_config(
2475
+ extra_config={"store_tp_size": 4},
2476
+ pipeline_parallel_size=pp_size,
2477
+ ),
2478
+ _make_kv_cache_config(),
2479
+ )
2480
+ layout = w.token_dbs[0].store_layout
2481
+ keys.append(layout.key_for(layout.local_shard_ids[0], BlockHash(b"h")))
2482
+
2483
+ assert "@store_pp:1" in keys[0]
2484
+ assert "@store_pp:2" in keys[1]
2485
+ assert keys[0] != keys[1]
2486
+
2487
+
2488
def test_save_decode_cache_keeps_transfer_path_enabled(tmp_path, monkeypatch):
2489
store = MagicMock()
2490
store.setup.return_value = 0
@@ -2185,6 +2666,58 @@ def test_worker_put_striding_covers_every_rank_get_namespace(
2666
)
2667
2668
2669
+def test_mqa_p4_to_d2_uses_shared_rank_zero_namespace(tmp_path, monkeypatch):
2670
+ store = MagicMock()
2671
+ store.setup.return_value = 0
2672
+ _install_fake_mooncake(monkeypatch, store)
2673
+ monkeypatch.setenv(
2674
+ "MOONCAKE_CONFIG_PATH",
2675
+ _write_mooncake_config(
2676
+ tmp_path,
2677
+ {
2678
+ "metadata_server": "http://metadata/endpoint",
2679
+ "protocol": "tcp",
2680
+ "master_server_address": "10.0.0.7:50051",
2681
+ },
2682
+ ),
2683
+ )
2684
+
2685
+ block_hash = BlockHash(b"hash")
2686
+ put_keys: set[str] = set()
2687
+ get_keys: set[str] = set()
2688
+ for tp_size, target in ((4, put_keys), (2, get_keys)):
2689
+ for tp_rank in range(tp_size):
2690
+ _patch_worker_runtime(monkeypatch, tp_rank=tp_rank, tp_size=tp_size)
2691
+ w = worker.MooncakeStoreWorker(
2692
+ _make_vllm_config(
2693
+ rank=tp_rank,
2694
+ extra_config={"store_tp_size": 4},
2695
+ ),
2696
+ _make_kv_cache_config(),
2697
+ )
2698
+
2699
+ assert w.store_tp_size is None
2700
+ assert w.token_dbs[0].metadata.tp_rank == 0
2701
+ key = w.token_dbs[0].key_for(block_hash)
2702
+ if tp_size == 4:
2703
+ put_step = w._group_tp_replication_factors[0]
2704
+ if tp_rank % put_step == 0:
2705
+ target.add(key)
2706
+ else:
2707
+ target.add(key)
2708
+
2709
+ assert (
2710
+ put_keys
2711
+ == get_keys
2712
+ == {
2713
+ (
2714
+ "test-model@store_pp:1@store_format:tp_shared_mqa"
2715
+ "@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@68617368"
2716
+ )
2717
+ }
2718
+ )
2719
+
2720
+
2721
def test_requester_worker_group_semantics_falls_back_without_group_ids(
2722
tmp_path,
2723
monkeypatch,
@@ -2396,9 +2929,11 @@ def test_store_sending_thread_only_stores_swa_blocks_in_window():
2929
2930
store = MagicMock()
2931
store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
2399
- store.batch_put_from_multi_buffers.side_effect = (
2400
- lambda keys, addrs, sizes, *_args: ([256] * len(keys))
2401
- )
2932
+
2933
+ def successful_put(keys, addrs, sizes, *_args):
2934
+ return [256] * len(keys)
2935
+
2936
+ store.batch_put_from_multi_buffers.side_effect = successful_put
2937
2938
full_spec = FullAttentionSpec(
2939
block_size=32, num_kv_heads=8, head_size=64, dtype=None
@@ -2754,6 +3289,7 @@ def _make_bare_worker(
3289
worker.recv_request_queue = queue.Queue()
3290
worker.finished_store_req = set()
3291
worker.tp_size = 1
3292
+ worker.store_tp_size = None
3293
worker.num_kv_head = 1
3294
worker.pp_size = 1
3295
# Minimal single-full-attention-group config so the coordinator-based
@@ -2812,6 +3348,40 @@ def test_lookup_key_prefixes_cover_dcp_rank_namespaces():
3348
)
3349
3350
3351
+def test_lookup_key_prefixes_cover_store_tp_shards():
3352
+ worker = _make_bare_worker()
3353
+ worker.store_tp_size = 4
3354
+ metadata = KeyMetadata(
3355
+ "test-model",
3356
+ 0,
3357
+ 0,
3358
+ 0,
3359
+ 0,
3360
+ group_id=0,
3361
+ store_namespace=_TP_SHARED_NAMESPACE,
3362
+ )
3363
+ worker.token_dbs = [
3364
+ ChunkedTokenDatabase(
3365
+ metadata,
3366
+ block_size=16,
3367
+ store_layout=LBHNCStoreLayout(
3368
+ metadata,
3369
+ block_size=16,
3370
+ hash_block_size=16,
3371
+ local_tp_size=2,
3372
+ store_tp_size=4,
3373
+ tp_rank=0,
3374
+ num_kv_heads=8,
3375
+ ),
3376
+ )
3377
+ ]
3378
+ worker._init_lookup_key_prefixes()
3379
+
3380
+ assert worker._lookup_key_prefixes[0] == (
3381
+ *(_tp_shared_prefix(tp_rank) for tp_rank in range(4)),
3382
+ )
3383
+
3384
+
3385
def test_lookup_key_prefixes_cover_pcp_rank_namespaces():
3386
worker = _make_bare_worker()
3387
worker.tp_size = 4
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/connector.py
+66
-25
@@ -11,16 +11,17 @@ and consumer instances read/write KV to/from the store independently,
11
enabling prefix caching via hash-based deduplication.
12
"""
13
14
-from collections.abc import Iterable
14
+from collections import Counter
15
+from collections.abc import Iterable, Sequence
16
from typing import Any
17
18
import torch
19
20
from vllm.config import VllmConfig
21
from vllm.distributed.kv_events import (
22
+ BlockStored,
23
KVCacheEvent,
24
KVConnectorKVEvents,
23
- KVEventAggregator,
25
)
26
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
27
KVConnectorBase_V1,
@@ -56,31 +57,66 @@ logger = init_logger(__name__)
57
class MooncakeStoreKVEvents(KVConnectorKVEvents):
58
"""KV event aggregation for MooncakeStoreConnector."""
59
59
- def __init__(self, num_workers: int) -> None:
60
- self._aggregator = KVEventAggregator(num_workers)
60
+ def __init__(
61
+ self,
62
+ num_workers: int,
63
+ group_tp_replication_factors: Sequence[int] = (1,),
64
+ ) -> None:
65
+ if num_workers <= 0:
66
+ raise ValueError("num_workers must be greater than zero.")
67
+ if any(factor <= 0 for factor in group_tp_replication_factors):
68
+ raise ValueError("TP replication factors must be greater than zero.")
69
+ self._event_counter: Counter[KVCacheEvent] = Counter()
70
+ self._num_workers = num_workers
71
+ self._group_tp_replication_factors = tuple(group_tp_replication_factors)
72
73
def add_events(self, events: list[KVCacheEvent]) -> None:
63
- self._aggregator.add_events(events)
74
+ if not isinstance(events, list):
75
+ raise TypeError("events must be a list of KVCacheEvent.")
76
+ self._event_counter.update(events)
77
+
78
+ def _replication_factor(self, event: KVCacheEvent) -> int:
79
+ if not isinstance(event, BlockStored) or event.group_idx is None:
80
+ return 1
81
+ return self._group_tp_replication_factors[event.group_idx]
82
+
83
+ def _is_common_event(self, event: KVCacheEvent, count: int) -> bool:
84
+ return count * self._replication_factor(event) >= self._num_workers
85
86
def aggregate(self) -> "MooncakeStoreKVEvents":
66
- common_events = self._aggregator.get_common_events()
67
- self._aggregator.clear_events()
68
- self._aggregator.add_events(common_events)
69
- self._aggregator.reset_workers()
87
+ common_events = self.pop_common_events()
88
+ self._event_counter.clear()
89
+ self._event_counter.update(common_events)
90
+ self._num_workers = 1
91
return self
92
93
+ def pop_common_events(self) -> list[KVCacheEvent]:
94
+ common_events = [
95
+ event
96
+ for event, count in self._event_counter.items()
97
+ if self._is_common_event(event, count)
98
+ ]
99
+ for event in common_events:
100
+ del self._event_counter[event]
101
+ return common_events
102
+
103
+ def has_events(self) -> bool:
104
+ return bool(self._event_counter)
105
+
106
def increment_workers(self, count: int = 1) -> None:
73
- self._aggregator.increment_workers(count)
107
+ if count <= 0:
108
+ raise ValueError("count must be positive.")
109
+ self._num_workers += count
110
111
def get_all_events(self) -> list[KVCacheEvent]:
76
- return self._aggregator.get_all_events()
112
+ return list(self._event_counter.elements())
113
114
def get_number_of_workers(self) -> int:
79
- return self._aggregator.get_number_of_workers()
115
+ return self._num_workers
116
117
def clear_events(self) -> None:
82
- self._aggregator.clear_events()
83
- self._aggregator.reset_workers()
118
+ self._event_counter.clear()
119
+ self._num_workers = 1
120
121
def __repr__(self) -> str:
122
return f"<MooncakeStoreKVEvents events={self.get_all_events()}>"
@@ -260,16 +296,13 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
296
self._kv_cache_events = kv_cache_events
297
else:
298
self._kv_cache_events.add_events(kv_cache_events.get_all_events())
263
- self._kv_cache_events.increment_workers(
264
- kv_cache_events.get_number_of_workers()
265
- )
299
300
def take_events(self) -> Iterable[KVCacheEvent]:
301
if self._kv_cache_events is not None:
269
- self._kv_cache_events.aggregate()
270
- yield from self._kv_cache_events.get_all_events()
271
- self._kv_cache_events.clear_events()
272
- self._kv_cache_events = None
302
+ events = self._kv_cache_events.pop_common_events()
303
+ if not self._kv_cache_events.has_events():
304
+ self._kv_cache_events = None
305
+ yield from events
306
307
# ============================================================
308
# Worker-side methods
@@ -321,11 +354,19 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
354
self,
355
) -> MooncakeStoreKVEvents | None:
356
assert self.connector_worker is not None
324
- events = self.connector_worker.get_kv_events()
325
- if not events:
357
+ if (
358
+ not self.connector_worker.enable_kv_events
359
+ or self.connector_worker.kv_send_thread is None
360
+ ):
361
return None
327
-
328
- kv_events = MooncakeStoreKVEvents(num_workers=1)
362
+ events = self.connector_worker.get_kv_events()
363
+ # Empty containers still count this worker toward the poll's quorum.
364
+ kv_events = MooncakeStoreKVEvents(
365
+ num_workers=1,
366
+ group_tp_replication_factors=(
367
+ self.connector_worker.group_tp_replication_factors
368
+ ),
369
+ )
370
kv_events.add_events(events)
371
return kv_events
372
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/data.py
+392
-41
@@ -18,6 +18,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import (
18
)
19
from vllm.logger import init_logger
20
from vllm.utils.math_utils import cdiv
21
+from vllm.utils.torch_utils import is_non_overlapping_and_dense
22
from vllm.v1.core.kv_cache_utils import (
23
BlockHash,
24
BlockHashListWithBlockSize,
@@ -111,6 +112,9 @@ class KeyMetadata:
112
# share one Mooncake master without colliding on identical block hashes.
113
# Empty (the default) keeps keys byte-identical to the unprefixed format.
114
cache_prefix: str = ""
115
+ # Complete namespace for an opt-in Store payload format. Empty keeps
116
+ # historical keys byte-identical.
117
+ store_namespace: str = ""
118
119
120
@dataclass(order=True)
@@ -125,6 +129,7 @@ class PoolKey:
129
(
130
self.key_metadata.cache_prefix,
131
self.key_metadata.model_name,
132
+ self.key_metadata.store_namespace,
133
self.key_metadata.tp_rank,
134
self.key_metadata.pcp_rank,
135
self.key_metadata.dcp_rank,
@@ -148,6 +153,7 @@ class PoolKey:
153
return (
154
f"{prefix}"
155
f"{key_metadata.model_name}"
156
+ f"{key_metadata.store_namespace}"
157
f"@tp_rank:{key_metadata.tp_rank if tp_rank is None else tp_rank}"
158
f"@pcp{key_metadata.pcp_rank if pcp_rank is None else pcp_rank}"
159
f"@dcp{key_metadata.dcp_rank if dcp_rank is None else dcp_rank}"
@@ -165,61 +171,149 @@ class PoolKey:
171
)
172
173
168
-class ChunkedTokenDatabase:
169
- """Maps token positions to store keys and GPU memory addresses."""
174
+StoreShardId = int
175
+# Physical ranks encoded in a rank-local key: (TP, PCP, DCP, PP).
176
+RankNamespace = tuple[int, int, int, int]
177
+
178
+
179
+class StoreLayout:
180
+ """Store payload layout for one KV-cache group on one local TP rank."""
181
182
def __init__(
183
self,
184
metadata: KeyMetadata,
185
block_size: int,
175
- hash_block_size: int | None = None,
176
- ):
186
+ hash_block_size: int,
187
+ ) -> None:
188
self.metadata = metadata
189
self.block_size = block_size
179
- self.hash_block_size = hash_block_size or block_size
180
- if self.block_size % self.hash_block_size != 0:
181
- raise ValueError(
182
- f"block_size ({self.block_size}) must be a multiple of "
183
- f"hash_block_size ({self.hash_block_size})"
184
- )
190
+ self.hash_block_size = hash_block_size
191
+
192
+ @property
193
+ def local_shard_ids(self) -> tuple[StoreShardId, ...]:
194
+ """Store objects contributed or consumed by this local rank."""
195
+ raise NotImplementedError
196
+
197
+ def key_for(self, shard_id: StoreShardId, chunk_hash: BlockHash) -> str:
198
+ raise NotImplementedError
199
+
200
+ def lookup_key_prefixes(
201
+ self,
202
+ rank_namespaces: Sequence[RankNamespace],
203
+ ) -> tuple[str, ...]:
204
+ """Prefixes that must exist for a logical block to be reusable."""
205
+ raise NotImplementedError
206
+
207
+ def register_kv_caches(
208
+ self,
209
+ kv_caches: Sequence[torch.Tensor],
210
+ num_blocks: int,
211
+ ) -> None:
212
+ """Build descriptors from the local KV cache tensors."""
213
+ raise NotImplementedError
214
+
215
+ def prepare_values(
216
+ self,
217
+ chunks: Sequence[tuple[int, int]],
218
+ block_ids: list[int],
219
+ shard_ids: Sequence[StoreShardId],
220
+ ) -> tuple[list[list[int]], list[list[int]], list[int]]:
221
+ """Build Store multi-buffer descriptors for logical chunks."""
222
+ raise NotImplementedError
223
+
224
+
225
+class RankLocalStoreLayout(StoreLayout):
226
+ """Historical rank-local Store payload layout."""
227
+
228
+ def __init__(
229
+ self,
230
+ metadata: KeyMetadata,
231
+ block_size: int,
232
+ hash_block_size: int,
233
+ ) -> None:
234
+ super().__init__(metadata, block_size, hash_block_size)
235
+ self._key_prefix = PoolKey.build_prefix(metadata)
236
self.kv_caches_base_addr: list[int] = []
237
self.block_len: list[int] = []
187
- self._key_prefix = PoolKey.build_prefix(metadata)
238
189
- def key_for(self, chunk_hash: BlockHash) -> str:
239
+ @property
240
+ def local_shard_ids(self) -> tuple[StoreShardId, ...]:
241
+ return (0,)
242
+
243
+ def key_for(self, shard_id: StoreShardId, chunk_hash: BlockHash) -> str:
244
+ assert shard_id == 0
245
return PoolKey.build_key_string(self._key_prefix, chunk_hash.hex())
246
192
- def set_kv_caches_base_addr(self, kv_caches_base_addr: list[int]):
193
- self.kv_caches_base_addr = kv_caches_base_addr
247
+ def lookup_key_prefixes(
248
+ self,
249
+ rank_namespaces: Sequence[RankNamespace],
250
+ ) -> tuple[str, ...]:
251
+ return tuple(
252
+ PoolKey.build_prefix(
253
+ self.metadata,
254
+ tp_rank=tp_rank,
255
+ pcp_rank=pcp_rank,
256
+ dcp_rank=dcp_rank,
257
+ pp_rank=pp_rank,
258
+ )
259
+ for tp_rank, pcp_rank, dcp_rank, pp_rank in rank_namespaces
260
+ )
261
195
- def set_block_len(self, block_len: list[int]):
196
- self.block_len = block_len
262
+ def set_kv_caches_base_addr(self, base_addrs: list[int]) -> None:
263
+ self.kv_caches_base_addr = base_addrs
264
198
- def prepare_value(
199
- self, start: int, end: int, block_ids: list[int]
200
- ) -> tuple[list[int], list[int], int]:
201
- """Compute memory addresses and sizes for a single token range.
265
+ def set_block_len(self, block_lens: list[int]) -> None:
266
+ self.block_len = block_lens
267
203
- Returns:
204
- (addr_list, size_list, block_id)
205
- """
206
- addr_lists, size_lists, chunk_block_ids = self.prepare_values(
207
- ((start, end),), block_ids
208
- )
209
- return addr_lists[0], size_lists[0], chunk_block_ids[0]
268
+ def register_kv_caches(
269
+ self,
270
+ kv_caches: Sequence[torch.Tensor],
271
+ num_blocks: int,
272
+ ) -> None:
273
+ seen_region_ptrs: set[int] = set()
274
+ base_addrs: list[int] = []
275
+ block_lens: list[int] = []
276
+ for cache in kv_caches:
277
+ storage = cache.untyped_storage()
278
+ storage_addr = storage.data_ptr()
279
+ storage_len = storage.nbytes()
280
+ if not is_non_overlapping_and_dense(cache[0]):
281
+ for head_idx in range(cache.shape[1]):
282
+ head_cache = cache[:, head_idx]
283
+ assert is_non_overlapping_and_dense(head_cache[0])
284
+ region_addr = head_cache.data_ptr()
285
+ if region_addr in seen_region_ptrs:
286
+ continue
287
+ seen_region_ptrs.add(region_addr)
288
+ base_addrs.append(region_addr)
289
+ block_lens.append(head_cache.stride(0) * head_cache.element_size())
290
+ elif cache.stride(0) * cache.element_size() * num_blocks == storage_len:
291
+ if storage_addr in seen_region_ptrs:
292
+ continue
293
+ seen_region_ptrs.add(storage_addr)
294
+ base_addrs.append(storage_addr)
295
+ block_lens.append(storage_len // num_blocks)
296
+ else:
297
+ region_addr = cache.data_ptr()
298
+ if region_addr in seen_region_ptrs:
299
+ continue
300
+ seen_region_ptrs.add(region_addr)
301
+ base_addrs.append(region_addr)
302
+ block_lens.append(cache.stride(0) * cache.element_size())
303
+ self.kv_caches_base_addr = base_addrs
304
+ self.block_len = block_lens
305
306
def prepare_values(
307
self,
308
chunks: Sequence[tuple[int, int]],
309
block_ids: list[int],
310
+ shard_ids: Sequence[StoreShardId],
311
) -> tuple[list[list[int]], list[list[int]], list[int]]:
216
- """Compute memory addresses and sizes for multiple token ranges.
217
-
218
- Returns:
219
- (addr_lists, size_lists, chunk_block_ids), one entry per chunk.
220
- """
312
if not chunks:
313
return [], [], []
314
+ assert len(chunks) == len(shard_ids) and all(
315
+ shard_id == 0 for shard_id in shard_ids
316
+ )
317
base = np.asarray(self.kv_caches_base_addr, dtype=np.int64)
318
length = len(self.block_len)
319
blen = np.asarray(
@@ -229,7 +323,7 @@ class ChunkedTokenDatabase:
323
n = len(chunks)
324
starts = np.fromiter((c[0] for c in chunks), dtype=np.int64, count=n)
325
spans = np.fromiter((c[1] for c in chunks), dtype=np.int64, count=n) - starts
232
- assert not (spans % self.hash_block_size).any()
326
+ assert np.all(spans % self.hash_block_size == 0)
327
bids = np.fromiter(
328
(block_ids[i] for i in (starts // self.block_size).tolist()),
329
dtype=np.int64,
@@ -241,15 +335,272 @@ class ChunkedTokenDatabase:
335
return addrs.tolist(), sizes.tolist(), bids.tolist()
336
337
def prepare_value_for_block(self, block_id: int) -> tuple[list[int], list[int]]:
244
- """Return addresses and sizes for one physical block slot."""
245
- addr_list = []
246
- size_list = []
338
length = len(self.block_len)
248
- for index, base_addr in enumerate(self.kv_caches_base_addr):
249
- addr = base_addr + block_id * self.block_len[index % length]
250
- addr_list.append(addr)
251
- size_list.append(self.block_len[index % length])
252
- return addr_list, size_list
339
+ return (
340
+ [
341
+ base_addr + block_id * self.block_len[index % length]
342
+ for index, base_addr in enumerate(self.kv_caches_base_addr)
343
+ ],
344
+ [
345
+ self.block_len[index % length]
346
+ for index in range(len(self.kv_caches_base_addr))
347
+ ],
348
+ )
349
+
350
+
351
+class TPShardedStoreLayout(StoreLayout):
352
+ """Store layout shared by divisible TP sizes."""
353
+
354
+ store_format: str
355
+
356
+ @classmethod
357
+ def shared_namespace(cls, store_tp_size: int, pp_size: int) -> str:
358
+ return (
359
+ f"@store_tp:{store_tp_size}@store_pp:{pp_size}"
360
+ f"@store_format:{cls.store_format}"
361
+ )
362
+
363
+ def __init__(
364
+ self,
365
+ metadata: KeyMetadata,
366
+ block_size: int,
367
+ hash_block_size: int,
368
+ local_tp_size: int,
369
+ store_tp_size: int,
370
+ tp_rank: int,
371
+ num_kv_heads: int,
372
+ ) -> None:
373
+ super().__init__(metadata, block_size, hash_block_size)
374
+ self.shards_per_rank = store_tp_size // local_tp_size
375
+ first_shard = tp_rank * self.shards_per_rank
376
+ self.store_shard_ids = tuple(
377
+ range(first_shard, first_shard + self.shards_per_rank)
378
+ )
379
+ self.store_tp_size = store_tp_size
380
+ self.heads_per_store_shard = num_kv_heads // store_tp_size
381
+ self.local_num_kv_heads = num_kv_heads // local_tp_size
382
+ self._shard_key_prefixes = {
383
+ shard_id: PoolKey.build_prefix(metadata, tp_rank=shard_id)
384
+ for shard_id in self.store_shard_ids
385
+ }
386
+ self._shard_addr_bases = np.empty((self.shards_per_rank, 0), dtype=np.uint64)
387
+ self._shard_block_strides = np.empty((self.shards_per_rank, 0), dtype=np.uint64)
388
+ self._shard_sizes = np.empty((self.shards_per_rank, 0), dtype=np.uint64)
389
+
390
+ @property
391
+ def local_shard_ids(self) -> tuple[StoreShardId, ...]:
392
+ return self.store_shard_ids
393
+
394
+ def key_for(self, shard_id: StoreShardId, chunk_hash: BlockHash) -> str:
395
+ return PoolKey.build_key_string(
396
+ self._shard_key_prefixes[shard_id], chunk_hash.hex()
397
+ )
398
+
399
+ def lookup_key_prefixes(
400
+ self,
401
+ rank_namespaces: Sequence[RankNamespace],
402
+ ) -> tuple[str, ...]:
403
+ pp_ranks = sorted({namespace[3] for namespace in rank_namespaces})
404
+ return tuple(
405
+ PoolKey.build_prefix(self.metadata, tp_rank=shard_id, pp_rank=pp_rank)
406
+ for shard_id in range(self.store_tp_size)
407
+ for pp_rank in pp_ranks
408
+ )
409
+
410
+ def _set_segment_templates(
411
+ self, templates: Sequence[tuple[list[int], list[int], list[int]]]
412
+ ) -> None:
413
+ self._shard_addr_bases = np.asarray(
414
+ [template[0] for template in templates], dtype=np.uint64
415
+ )
416
+ self._shard_block_strides = np.asarray(
417
+ [template[1] for template in templates], dtype=np.uint64
418
+ )
419
+ self._shard_sizes = np.asarray(
420
+ [template[2] for template in templates], dtype=np.uint64
421
+ )
422
+
423
+ def _has_packed_strides(
424
+ self, head_stride: int, token_stride: int, content_bytes: int
425
+ ) -> bool:
426
+ raise NotImplementedError
427
+
428
+ def _shard_segments(
429
+ self, head_start: int, head_stride: int, token_stride: int, content_bytes: int
430
+ ) -> Iterable[tuple[int, int]]:
431
+ raise NotImplementedError
432
+
433
+ def register_kv_caches(
434
+ self,
435
+ kv_caches: Sequence[torch.Tensor],
436
+ num_blocks: int,
437
+ ) -> None:
438
+ templates: list[tuple[list[int], list[int], list[int]]] = [
439
+ ([], [], []) for _ in range(self.shards_per_rank)
440
+ ]
441
+ for cache in kv_caches:
442
+ if cache.ndim != 4 or tuple(cache.shape[:3]) != (
443
+ num_blocks,
444
+ self.local_num_kv_heads,
445
+ self.block_size,
446
+ ):
447
+ raise ValueError(
448
+ "TP-shared Mooncake store requires packed KV caches with "
449
+ "logical shape (num_blocks, local_kv_heads, block_size, content)"
450
+ )
451
+
452
+ element_size = cache.element_size()
453
+ block_stride, head_stride, token_stride, content_stride = (
454
+ stride * element_size for stride in cache.stride()
455
+ )
456
+ content_bytes = cache.shape[3] * element_size
457
+ if content_stride != element_size or not self._has_packed_strides(
458
+ head_stride, token_stride, content_bytes
459
+ ):
460
+ raise ValueError(
461
+ "TP-shared Mooncake store requires packed "
462
+ f"{self.store_format} KV layout"
463
+ )
464
+
465
+ for local_shard, (addr_bases, block_strides, sizes) in enumerate(templates):
466
+ head_start = local_shard * self.heads_per_store_shard
467
+ for offset, size in self._shard_segments(
468
+ head_start, head_stride, token_stride, content_bytes
469
+ ):
470
+ addr_bases.append(cache.data_ptr() + offset)
471
+ block_strides.append(block_stride)
472
+ sizes.append(size)
473
+
474
+ self._set_segment_templates(templates)
475
+
476
+ def prepare_values(
477
+ self,
478
+ chunks: Sequence[tuple[int, int]],
479
+ block_ids: list[int],
480
+ shard_ids: Sequence[StoreShardId],
481
+ ) -> tuple[list[list[int]], list[list[int]], list[int]]:
482
+ if not chunks:
483
+ return [], [], []
484
+ if len(chunks) != len(shard_ids):
485
+ raise ValueError("Each Store chunk must have one shard ID")
486
+
487
+ count = len(chunks)
488
+ starts = np.fromiter(
489
+ (chunk[0] for chunk in chunks), dtype=np.int64, count=count
490
+ )
491
+ ends = np.fromiter((chunk[1] for chunk in chunks), dtype=np.int64, count=count)
492
+ if np.any(ends - starts != self.block_size):
493
+ raise ValueError("TP-shared Mooncake store requires full KV blocks")
494
+
495
+ first_shard = self.store_shard_ids[0]
496
+ local_shards = np.fromiter(
497
+ (shard_id - first_shard for shard_id in shard_ids),
498
+ dtype=np.int64,
499
+ count=count,
500
+ )
501
+ if np.any(local_shards < 0) or np.any(local_shards >= self.shards_per_rank):
502
+ raise ValueError("Store shard is not owned by this TP rank")
503
+ chunk_block_ids = np.fromiter(
504
+ (block_ids[index] for index in (starts // self.block_size).tolist()),
505
+ dtype=np.uint64,
506
+ count=count,
507
+ )
508
+ addr_bases = self._shard_addr_bases[local_shards]
509
+ block_strides = self._shard_block_strides[local_shards]
510
+ addrs = addr_bases + chunk_block_ids[:, None] * block_strides
511
+ sizes = self._shard_sizes[local_shards]
512
+ return addrs.tolist(), sizes.tolist(), chunk_block_ids.tolist()
513
+
514
+
515
+class LBHNCStoreLayout(TPShardedStoreLayout):
516
+ """Native head-major layout shared by divisible TP sizes."""
517
+
518
+ store_format = "tp_shared_lbhnc"
519
+
520
+ def _has_packed_strides(
521
+ self, head_stride: int, token_stride: int, content_bytes: int
522
+ ) -> bool:
523
+ return (
524
+ token_stride == content_bytes
525
+ and head_stride == self.block_size * content_bytes
526
+ )
527
+
528
+ def _shard_segments(
529
+ self, head_start: int, head_stride: int, token_stride: int, content_bytes: int
530
+ ) -> Iterable[tuple[int, int]]:
531
+ yield head_start * head_stride, self.heads_per_store_shard * head_stride
532
+
533
+
534
+class LBNHCStoreLayout(TPShardedStoreLayout):
535
+ """Native token-major layout shared by divisible TP sizes."""
536
+
537
+ store_format = "tp_shared_lbnhc"
538
+
539
+ def _has_packed_strides(
540
+ self, head_stride: int, token_stride: int, content_bytes: int
541
+ ) -> bool:
542
+ return (
543
+ head_stride == content_bytes
544
+ and token_stride == self.local_num_kv_heads * content_bytes
545
+ )
546
+
547
+ def _shard_segments(
548
+ self, head_start: int, head_stride: int, token_stride: int, content_bytes: int
549
+ ) -> Iterable[tuple[int, int]]:
550
+ for token_idx in range(self.block_size):
551
+ yield (
552
+ token_idx * token_stride + head_start * head_stride,
553
+ self.heads_per_store_shard * content_bytes,
554
+ )
555
+
556
+
557
+class ChunkedTokenDatabase:
558
+ """Enumerates logical token chunks and their hashes."""
559
+
560
+ def __init__(
561
+ self,
562
+ metadata: KeyMetadata,
563
+ block_size: int,
564
+ hash_block_size: int | None = None,
565
+ store_layout: StoreLayout | None = None,
566
+ ):
567
+ self.metadata = metadata
568
+ self.block_size = block_size
569
+ self.hash_block_size = hash_block_size or block_size
570
+ if self.block_size % self.hash_block_size != 0:
571
+ raise ValueError(
572
+ f"block_size ({self.block_size}) must be a multiple of "
573
+ f"hash_block_size ({self.hash_block_size})"
574
+ )
575
+ self.store_layout = store_layout or RankLocalStoreLayout(
576
+ metadata, block_size, self.hash_block_size
577
+ )
578
+
579
+ @property
580
+ def kv_caches_base_addr(self) -> list[int]:
581
+ return self._rank_local_layout().kv_caches_base_addr
582
+
583
+ @property
584
+ def block_len(self) -> list[int]:
585
+ return self._rank_local_layout().block_len
586
+
587
+ def _rank_local_layout(self) -> RankLocalStoreLayout:
588
+ if not isinstance(self.store_layout, RankLocalStoreLayout):
589
+ raise RuntimeError("This operation requires a rank-local Store layout")
590
+ return self.store_layout
591
+
592
+ def key_for(self, chunk_hash: BlockHash) -> str:
593
+ return self._rank_local_layout().key_for(0, chunk_hash)
594
+
595
+ def set_kv_caches_base_addr(self, kv_caches_base_addr: list[int]):
596
+ self._rank_local_layout().set_kv_caches_base_addr(kv_caches_base_addr)
597
+
598
+ def set_block_len(self, block_len: list[int]):
599
+ self._rank_local_layout().set_block_len(block_len)
600
+
601
+ def prepare_value_for_block(self, block_id: int) -> tuple[list[int], list[int]]:
602
+ """Return addresses and sizes for one physical block slot."""
603
+ return self._rank_local_layout().prepare_value_for_block(block_id)
604
605
def process_tokens(
606
self,
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+317
-176
@@ -12,6 +12,7 @@ and MooncakeDistributedStore integration.
12
13
import dataclasses
14
import json
15
+import math
16
import os
17
import queue
18
import socket
@@ -44,12 +45,16 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
45
BlobBlockHashes,
46
ChunkedTokenDatabase,
47
KeyMetadata,
48
+ LBHNCStoreLayout,
49
+ LBNHCStoreLayout,
50
MooncakeLookupResult,
51
MooncakeStoreConnectorMetadata,
52
MooncakeStoreWorkerMetadata,
53
PoolKey,
54
ReqMeta,
55
+ StoreShardId,
56
TailKeyBoundary,
57
+ TPShardedStoreLayout,
58
)
59
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.protocol import ( # noqa: E501
60
LOOKUP_MSG,
@@ -62,7 +67,6 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.protocol import
67
from vllm.logger import init_logger
68
from vllm.utils.math_utils import cdiv
69
from vllm.utils.network_utils import get_ip, make_zmq_socket
65
-from vllm.utils.torch_utils import is_non_overlapping_and_dense
70
from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
71
from vllm.v1.core.kv_cache_utils import (
72
BlockHash,
@@ -71,6 +75,7 @@ from vllm.v1.core.kv_cache_utils import (
75
resolve_kv_cache_block_sizes,
76
)
77
from vllm.v1.kv_cache_interface import (
78
+ FullAttentionSpec,
79
KVCacheConfig,
80
KVCacheSpec,
81
MambaSpec,
@@ -79,6 +84,7 @@ from vllm.v1.kv_cache_interface import (
84
UniformTypeKVCacheSpecs,
85
group_kernel_blocks,
86
)
87
+from vllm.v1.kv_cache_layout import KVCacheLayout
88
89
from .metrics import MooncakeStoreConnectorStats
90
@@ -92,6 +98,22 @@ MOONCAKE_NO_AVAILABLE_HANDLE = -200
98
_T = TypeVar("_T")
99
100
101
+def resolve_store_tp_size(extra_config: dict[str, Any]) -> int | None:
102
+ """Resolve the common Store TP requested by connector config."""
103
+ if extra_config.get("enable_store_tp_lcm") is True:
104
+ prefill_tp_sizes = extra_config.get("prefill_tp_sizes")
105
+ if not isinstance(prefill_tp_sizes, list) or not prefill_tp_sizes:
106
+ return None
107
+ if any(
108
+ type(tp_size) is not int or tp_size <= 0 for tp_size in prefill_tp_sizes
109
+ ):
110
+ return None
111
+ return math.lcm(*prefill_tp_sizes)
112
+
113
+ store_tp_size = extra_config.get("store_tp_size")
114
+ return store_tp_size if type(store_tp_size) is int and store_tp_size > 0 else None
115
+
116
+
117
def _rotate_list(values: list[_T], offset: int) -> list[_T]:
118
return values[offset:] + values[:offset]
119
@@ -109,7 +131,10 @@ def _make_mooncake_group_id(metadata: KeyMetadata, chunk_hash: str) -> str:
131
# Mooncake group ids describe the lifecycle unit. For vLLM, that unit is
132
# a prefix chunk, so shard dimensions stay only in the object key.
133
prefix = f"{metadata.cache_prefix}@" if metadata.cache_prefix else ""
112
- return f"vllm-mooncake-store:{prefix}{metadata.model_name}@{chunk_hash}"
134
+ return (
135
+ f"vllm-mooncake-store:{prefix}{metadata.model_name}"
136
+ f"{metadata.store_namespace}@{chunk_hash}"
137
+ )
138
139
140
# Mirrors FileStorageConfig::local_buffer_size in Mooncake C++.
@@ -952,8 +977,11 @@ class KVCacheStoreSendingThread(KVTransferThread):
977
starts: list[int] = []
978
ends: list[int] = []
979
keys: list[str] = []
955
- kv_event_block_hashes: list[BlockHash] = []
980
+ event_specs: list[tuple[int, int, int, BlockHash]] | None = (
981
+ [] if self.enable_kv_event else None
982
+ )
983
group_indices: list[int] = []
984
+ store_shard_ids: list[StoreShardId] = []
985
for g_idx, db in enumerate(self.token_databases):
986
# Rotate the stride phase per group to balance load across ranks.
987
put_step = self.group_put_steps[g_idx]
@@ -980,12 +1008,14 @@ class KVCacheStoreSendingThread(KVTransferThread):
1008
block_idx,
1009
)
1010
continue
983
- starts.append(start)
984
- ends.append(end)
985
- keys.append(db.key_for(block_hash))
986
- if self.enable_kv_event:
987
- kv_event_block_hashes.append(block_hash)
988
- group_indices.append(g_idx)
1011
+ for store_shard_id in db.store_layout.local_shard_ids:
1012
+ starts.append(start)
1013
+ ends.append(end)
1014
+ keys.append(db.store_layout.key_for(store_shard_id, block_hash))
1015
+ group_indices.append(g_idx)
1016
+ store_shard_ids.append(store_shard_id)
1017
+ if event_specs is not None:
1018
+ event_specs.append((start, end, g_idx, block_hash))
1019
1020
if not keys:
1021
self._record_saved(req_meta, token_len)
@@ -1023,11 +1053,10 @@ class KVCacheStoreSendingThread(KVTransferThread):
1053
starts = [starts[i] for i in missing_indices]
1054
ends = [ends[i] for i in missing_indices]
1055
keys = [keys[i] for i in missing_indices]
1026
- if self.enable_kv_event:
1027
- kv_event_block_hashes = [
1028
- kv_event_block_hashes[i] for i in missing_indices
1029
- ]
1056
+ if event_specs is not None:
1057
+ event_specs = [event_specs[i] for i in missing_indices]
1058
group_indices = [group_indices[i] for i in missing_indices]
1059
+ store_shard_ids = [store_shard_ids[i] for i in missing_indices]
1060
1061
group_ids = (
1062
[
@@ -1050,61 +1079,33 @@ class KVCacheStoreSendingThread(KVTransferThread):
1079
1080
addrs: list[list[int]] = []
1081
sizes: list[list[int]] = []
1053
- stored_events: list[BlockStored] = []
1082
chunks_per_group: list[list[tuple[int, int]]] = [
1083
[] for _ in self.token_databases
1084
]
1057
- for start, end, g_idx in zip(starts, ends, group_indices, strict=True):
1085
+ shards_per_group: list[list[StoreShardId]] = [
1086
+ [] for _ in self.token_databases
1087
+ ]
1088
+ for start, end, g_idx, store_shard_id in zip(
1089
+ starts,
1090
+ ends,
1091
+ group_indices,
1092
+ store_shard_ids,
1093
+ strict=True,
1094
+ ):
1095
chunks_per_group[g_idx].append((start, end))
1096
+ shards_per_group[g_idx].append(store_shard_id)
1097
for g_idx, chunks in enumerate(chunks_per_group):
1098
if not chunks:
1099
continue
1100
db = self.token_databases[g_idx]
1063
- group_addrs, group_sizes, _ = db.prepare_values(
1064
- chunks, block_ids_per_group[g_idx]
1101
+ group_addrs, group_sizes, _ = db.store_layout.prepare_values(
1102
+ chunks,
1103
+ block_ids_per_group[g_idx],
1104
+ shards_per_group[g_idx],
1105
)
1106
addrs.extend(group_addrs)
1107
sizes.extend(group_sizes)
1108
1069
- if self.enable_kv_event:
1070
- new_block_hashes = [
1071
- maybe_convert_block_hash(bh) for bh in kv_event_block_hashes
1072
- ]
1073
- token_ids_end = token_ids_start + len(event_token_ids or ())
1074
-
1075
- for idx, (s, e, g_idx) in enumerate(
1076
- zip(starts, ends, group_indices, strict=True)
1077
- ):
1078
- db = self.token_databases[g_idx]
1079
- if self.enable_kv_event:
1080
- token_ids = (
1081
- event_token_ids[s - token_ids_start : e - token_ids_start]
1082
- if event_token_ids is not None
1083
- and token_ids_start <= s
1084
- and e <= token_ids_end
1085
- else []
1086
- )
1087
- stored_event = BlockStored(
1088
- block_hashes=[new_block_hashes[idx]],
1089
- # Derive the direct predecessor from the unfiltered
1090
- # request chain. Adjacent PUTs need not be adjacent in
1091
- # that chain after Store dedup, masks, or TP striding.
1092
- parent_block_hash=(
1093
- maybe_convert_block_hash(
1094
- req_meta.block_hashes[s // db.hash_block_size - 1]
1095
- )
1096
- if s > 0
1097
- else None
1098
- ),
1099
- token_ids=token_ids,
1100
- block_size=db.block_size,
1101
- lora_id=None,
1102
- medium="cpu",
1103
- lora_name=None,
1104
- group_idx=g_idx,
1105
- )
1106
- stored_events.append(stored_event)
1107
-
1109
if current_event is not None:
1110
current_event.synchronize()
1111
@@ -1112,6 +1113,8 @@ class KVCacheStoreSendingThread(KVTransferThread):
1113
assert len(group_ids) == len(keys)
1114
self.replicate_config.group_ids = group_ids
1115
1116
+ failed_indices: set[int] = set()
1117
+ put_had_exception = False
1118
batch_bytes = _sum_batch_bytes(sizes)
1119
put_start = time.perf_counter()
1120
try:
@@ -1121,65 +1124,106 @@ class KVCacheStoreSendingThread(KVTransferThread):
1124
sizes,
1125
self.replicate_config,
1126
)
1124
- failed = [i for i, v in enumerate(res) if v < 0]
1127
+ except Exception as e:
1128
self._record_operation(
1129
"save_put",
1130
put_start,
1131
len(keys),
1132
num_bytes=batch_bytes,
1130
- status="partial_failure" if failed else "ok",
1131
- num_failed_keys=len(failed),
1133
+ status="error",
1134
+ num_failed_keys=len(keys),
1135
)
1133
- if failed:
1134
- failed_codes = set(res[i] for i in failed)
1135
- if self.enable_kv_event:
1136
- failed_indices = set(failed)
1137
- stored_events = [
1138
- event
1139
- for i, event in enumerate(stored_events)
1140
- if i not in failed_indices
1141
- ]
1136
+ logger.error("Failed to put key %s, error: %s", keys, e)
1137
+ put_had_exception = True
1138
+ else:
1139
+ failed_indices = {i for i, value in enumerate(res) if value < 0}
1140
+ self._record_operation(
1141
+ "save_put",
1142
+ put_start,
1143
+ len(keys),
1144
+ num_bytes=batch_bytes,
1145
+ status="partial_failure" if failed_indices else "ok",
1146
+ num_failed_keys=len(failed_indices),
1147
+ )
1148
+ failed_codes = {res[i] for i in failed_indices}
1149
+ if failed_indices:
1150
logger.warning(
1151
"batch_put failed: %d/%d keys failed "
1144
- "(codes=%s, batch_bytes=%d, num_keys=%d), "
1145
- "first_key=%s",
1146
- len(failed),
1152
+ "(codes=%s, batch_bytes=%d), first_key=%s",
1153
+ len(failed_indices),
1154
len(keys),
1155
failed_codes,
1156
batch_bytes,
1150
- len(keys),
1151
- keys[0] if keys else "N/A",
1157
+ keys[0],
1158
)
1153
- if (
1154
- MOONCAKE_NO_AVAILABLE_HANDLE in failed_codes
1155
- and not self._mark_request_skipped_for_pressure(req_meta)
1156
- ):
1157
- logger.warning(
1158
- "Detected Mooncake CPU/disk offloading pressure "
1159
- "(NO_AVAILABLE_HANDLE); skipping future store "
1160
- "batches for request %s until a later store "
1161
- "batch succeeds",
1162
- req_id,
1163
- )
1164
- else:
1165
- self._record_saved(req_meta, token_len)
1166
- save_completed = True
1167
- if self._clear_store_pressure():
1168
- logger.info(
1169
- "Mooncake CPU/disk offloading pressure cleared "
1170
- "after a successful store batch"
1159
+ if (
1160
+ MOONCAKE_NO_AVAILABLE_HANDLE in failed_codes
1161
+ and not self._mark_request_skipped_for_pressure(req_meta)
1162
+ ):
1163
+ logger.warning(
1164
+ "Detected Mooncake CPU/disk offloading pressure "
1165
+ "(NO_AVAILABLE_HANDLE); skipping future store "
1166
+ "batches for request %s until a later store batch succeeds",
1167
+ req_id,
1168
+ )
1169
+
1170
+ if not put_had_exception and not failed_indices:
1171
+ self._record_saved(req_meta, token_len)
1172
+ save_completed = True
1173
+ if self._clear_store_pressure():
1174
+ logger.info(
1175
+ "Mooncake CPU/disk offloading pressure cleared "
1176
+ "after a successful store batch"
1177
+ )
1178
+
1179
+ stored_events: list[BlockStored] = []
1180
+ if self.enable_kv_event and not put_had_exception:
1181
+ assert event_specs is not None
1182
+ # BlockStored is a logical-block event, while one block may map
1183
+ # to several Store shards. Emit once only after every missing
1184
+ # shard for that block succeeded. Shards that already existed
1185
+ # were removed before this mapping and are already satisfied.
1186
+ indices_by_event: dict[tuple[int, int, int, BlockHash], list[int]] = {}
1187
+ for index, event_spec in enumerate(event_specs):
1188
+ indices_by_event.setdefault(event_spec, []).append(index)
1189
+
1190
+ token_ids_end = token_ids_start + len(event_token_ids or ())
1191
+ for (
1192
+ s,
1193
+ end,
1194
+ g_idx,
1195
+ block_hash,
1196
+ ), event_indices in indices_by_event.items():
1197
+ if any(index in failed_indices for index in event_indices):
1198
+ continue
1199
+ db = self.token_databases[g_idx]
1200
+ token_ids = (
1201
+ event_token_ids[s - token_ids_start : end - token_ids_start]
1202
+ if event_token_ids is not None
1203
+ and token_ids_start <= s
1204
+ and end <= token_ids_end
1205
+ else []
1206
+ )
1207
+ stored_events.append(
1208
+ BlockStored(
1209
+ block_hashes=[maybe_convert_block_hash(block_hash)],
1210
+ # Store filtering can separate adjacent request
1211
+ # blocks, so derive the predecessor from the request.
1212
+ parent_block_hash=(
1213
+ maybe_convert_block_hash(
1214
+ req_meta.block_hashes[s // db.hash_block_size - 1]
1215
+ )
1216
+ if s > 0
1217
+ else None
1218
+ ),
1219
+ token_ids=token_ids,
1220
+ block_size=db.block_size,
1221
+ lora_id=None,
1222
+ medium="cpu",
1223
+ lora_name=None,
1224
+ group_idx=g_idx,
1225
)
1172
- except Exception as e:
1173
- self._record_operation(
1174
- "save_put",
1175
- put_start,
1176
- len(keys),
1177
- num_bytes=batch_bytes,
1178
- status="error",
1179
- num_failed_keys=len(keys),
1180
- )
1181
- logger.error("Failed to put key %s, error: %s", keys, e)
1182
- stored_events.clear()
1226
+ )
1227
1228
if self.enable_kv_event and stored_events:
1229
self.update_kv_event(stored_events)
@@ -1269,6 +1313,7 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1313
for g_idx, db in enumerate(self.token_databases):
1314
mask = load_mask_per_group[g_idx]
1315
chunks: list[tuple[int, int]] = []
1316
+ store_shard_ids: list[StoreShardId] = []
1317
for start, end, block_hash in db.process_tokens(
1318
token_len, req_meta.block_hashes, mask_num
1319
):
@@ -1282,10 +1327,14 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1327
block_hash = req_meta.block_hashes[
1328
boundary_tokens // db.hash_block_size - 1
1329
]
1285
- key_list.append(db.key_for(block_hash))
1286
- chunks.append((start, end))
1287
- g_addrs, g_sizes, g_block_ids = db.prepare_values(
1288
- chunks, req_meta.block_ids[g_idx]
1330
+ for store_shard_id in db.store_layout.local_shard_ids:
1331
+ key_list.append(db.store_layout.key_for(store_shard_id, block_hash))
1332
+ chunks.append((start, end))
1333
+ store_shard_ids.append(store_shard_id)
1334
+ g_addrs, g_sizes, g_block_ids = db.store_layout.prepare_values(
1335
+ chunks,
1336
+ req_meta.block_ids[g_idx],
1337
+ store_shard_ids,
1338
)
1339
addr_list.extend(g_addrs)
1340
size_list.extend(g_sizes)
@@ -1298,7 +1347,14 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1347
size_list_c = _rotate_list(size_list, rotation)
1348
block_id_list_c = _rotate_list(block_id_list, rotation)
1349
1301
- load_batches = [(key_list_c, addr_list_c, size_list_c, block_id_list_c)]
1350
+ load_batches = [
1351
+ (
1352
+ key_list_c,
1353
+ addr_list_c,
1354
+ size_list_c,
1355
+ block_id_list_c,
1356
+ )
1357
+ ]
1358
if self.usable_disk_offload_buffer_budget_bytes is not None:
1359
total_staging_bytes = sum(
1360
_estimate_disk_offload_staging_bytes(size) for size in size_list_c
@@ -1340,7 +1396,12 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1396
block_id_offset:next_block_id_offset
1397
]
1398
load_batches.append(
1343
- (batch_keys, batch_addrs, batch_sizes, batch_block_ids)
1399
+ (
1400
+ batch_keys,
1401
+ batch_addrs,
1402
+ batch_sizes,
1403
+ batch_block_ids,
1404
+ )
1405
)
1406
block_id_offset = next_block_id_offset
1407
@@ -1348,7 +1409,12 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1409
current_batch_block_ids: list[int] = block_id_list_c
1410
batch_bytes = 0
1411
try:
1351
- for batch_keys, batch_addrs, batch_sizes, batch_block_ids in load_batches:
1412
+ for (
1413
+ batch_keys,
1414
+ batch_addrs,
1415
+ batch_sizes,
1416
+ batch_block_ids,
1417
+ ) in load_batches:
1418
current_batch_keys = batch_keys
1419
current_batch_block_ids = batch_block_ids
1420
batch_bytes = _sum_batch_bytes(batch_sizes)
@@ -1607,11 +1673,9 @@ class MooncakeStoreWorker:
1673
retention_interval=kv_cache_config.prefix_cache_retention_interval,
1674
dcp_world_size=self.dcp_size,
1675
)
1610
- # One ChunkedTokenDatabase per group; addresses populated in
1611
- # register_kv_caches once the kv-cache layout is known. Each group's
1612
- # key namespace is its TP shard id: ranks holding identical bytes
1613
- # (MLA / shared GQA KV heads) share a namespace, TP-sharded Mamba
1614
- # state gets one namespace per rank.
1676
+ self.store_tp_size, store_namespace, store_layout_cls = (
1677
+ self._select_store_layout(extra_config)
1678
+ )
1679
metadata = KeyMetadata(
1680
model_name=model_config.model.rstrip("/").split("/")[-1],
1681
tp_rank=self.tp_rank,
@@ -1623,24 +1687,140 @@ class MooncakeStoreWorker:
1687
"cache_prefix", ""
1688
)
1689
),
1690
+ store_namespace=store_namespace,
1691
)
1692
self._group_tp_replication_factors: tuple[int, ...] = (
1693
self._compute_group_tp_replication_factors()
1694
)
1630
- self.token_dbs = [
1631
- ChunkedTokenDatabase(
1632
- dataclasses.replace(
1633
- metadata,
1634
- group_id=g_idx,
1635
- tp_rank=self.tp_rank // self._group_tp_replication_factors[g_idx],
1636
- ),
1637
- g.kv_cache_spec.block_size,
1638
- hash_block_size=self.hash_block_size,
1639
- )
1640
- for g_idx, g in enumerate(self._kv_cache_groups)
1641
- ]
1695
+ self.token_dbs = self._build_token_databases(metadata, store_layout_cls)
1696
self._init_lookup_key_prefixes()
1697
1698
+ def _supports_tp_sharded_store_layout(
1699
+ self,
1700
+ layout_cls: type[TPShardedStoreLayout] | None,
1701
+ extra_config: dict[str, Any],
1702
+ ) -> bool:
1703
+ if (
1704
+ layout_cls is None
1705
+ or self.pcp_size != 1
1706
+ or self.dcp_size != 1
1707
+ or len(self._kv_cache_groups) != 1
1708
+ ):
1709
+ return False
1710
+
1711
+ # Subclasses may use different sharding or cache-lifetime semantics.
1712
+ return (
1713
+ type(self._kv_cache_groups[0].kv_cache_spec) is FullAttentionSpec
1714
+ and str(extra_config.get("enable_cross_layers_blocks", "False")).lower()
1715
+ != "true"
1716
+ )
1717
+
1718
+ def _select_store_layout(
1719
+ self, extra_config: dict[str, Any]
1720
+ ) -> tuple[int | None, str, type[TPShardedStoreLayout] | None]:
1721
+ """Select the opt-in TP layout and its Store namespace."""
1722
+ lcm_store_tp_enabled = extra_config.get("enable_store_tp_lcm") is True
1723
+ store_tp_requested = (
1724
+ lcm_store_tp_enabled or extra_config.get("store_tp_size") is not None
1725
+ )
1726
+ if not store_tp_requested:
1727
+ return None, "", None
1728
+
1729
+ requested_store_tp_size = resolve_store_tp_size(extra_config)
1730
+ cache_layout = self.cache_config.get_resolved_kv_cache_layout()
1731
+ layout_cls: type[TPShardedStoreLayout] | None = {
1732
+ KVCacheLayout.LBHNC: LBHNCStoreLayout,
1733
+ KVCacheLayout.LBNHC: LBNHCStoreLayout,
1734
+ }.get(cache_layout)
1735
+
1736
+ if (
1737
+ requested_store_tp_size is not None
1738
+ and requested_store_tp_size >= self.tp_size
1739
+ and requested_store_tp_size % self.tp_size == 0
1740
+ and self._supports_tp_sharded_store_layout(layout_cls, extra_config)
1741
+ ):
1742
+ assert layout_cls is not None
1743
+ if self.num_kv_head % requested_store_tp_size == 0:
1744
+ if layout_cls is LBNHCStoreLayout:
1745
+ logger.warning_once(
1746
+ "Mooncake Store TP sharding is using the LBNHC (NHC) "
1747
+ "KV cache layout, which creates many transfer segments "
1748
+ "and may significantly reduce PUT/GET performance. Use "
1749
+ "LBHNC (HNC) when supported by the attention backend."
1750
+ )
1751
+ return (
1752
+ requested_store_tp_size,
1753
+ layout_cls.shared_namespace(requested_store_tp_size, self.pp_size),
1754
+ layout_cls,
1755
+ )
1756
+ if self.num_kv_head == 1:
1757
+ logger.info(
1758
+ "Mooncake heterogeneous-TP store sharing uses the replicated "
1759
+ "MQA layout for store_tp_size=%d",
1760
+ requested_store_tp_size,
1761
+ )
1762
+ return (
1763
+ None,
1764
+ f"@store_pp:{self.pp_size}@store_format:tp_shared_mqa",
1765
+ None,
1766
+ )
1767
+
1768
+ store_namespace = (
1769
+ f"@store_pp:{self.pp_size}@store_format:"
1770
+ f"rank_local_tp{self.tp_size}_layout_{cache_layout.name}"
1771
+ )
1772
+ requested_topology = (
1773
+ extra_config.get("prefill_tp_sizes")
1774
+ if lcm_store_tp_enabled
1775
+ else extra_config.get("store_tp_size")
1776
+ )
1777
+ logger.warning(
1778
+ "Mooncake heterogeneous-TP store sharing is disabled for "
1779
+ "Store TP configuration %r with KV layout %s; using a "
1780
+ "compatibility-namespaced rank-local store layout",
1781
+ requested_topology,
1782
+ cache_layout,
1783
+ )
1784
+ return None, store_namespace, None
1785
+
1786
+ def _build_token_databases(
1787
+ self,
1788
+ metadata: KeyMetadata,
1789
+ layout_cls: type[TPShardedStoreLayout] | None,
1790
+ ) -> list[ChunkedTokenDatabase]:
1791
+ """Construct token databases and their Store layouts."""
1792
+ token_dbs: list[ChunkedTokenDatabase] = []
1793
+ for group_idx, group in enumerate(self._kv_cache_groups):
1794
+ group_tp_rank = self.tp_rank
1795
+ if layout_cls is None:
1796
+ group_tp_rank //= self._group_tp_replication_factors[group_idx]
1797
+ group_metadata = dataclasses.replace(
1798
+ metadata,
1799
+ group_id=group_idx,
1800
+ tp_rank=group_tp_rank,
1801
+ )
1802
+ store_layout: TPShardedStoreLayout | None = None
1803
+ if layout_cls is not None:
1804
+ assert self.store_tp_size is not None
1805
+ store_layout = layout_cls(
1806
+ group_metadata,
1807
+ group.kv_cache_spec.block_size,
1808
+ self.hash_block_size,
1809
+ local_tp_size=self.tp_size,
1810
+ store_tp_size=self.store_tp_size,
1811
+ tp_rank=self.tp_rank,
1812
+ num_kv_heads=self.num_kv_head,
1813
+ )
1814
+ token_dbs.append(
1815
+ ChunkedTokenDatabase(
1816
+ group_metadata,
1817
+ group.kv_cache_spec.block_size,
1818
+ hash_block_size=self.hash_block_size,
1819
+ store_layout=store_layout,
1820
+ )
1821
+ )
1822
+ return token_dbs
1823
+
1824
def _spec_tp_replication_factor(self, spec: KVCacheSpec) -> int:
1825
if self.dcp_size > 1:
1826
return 1
@@ -1689,17 +1869,8 @@ class MooncakeStoreWorker:
1869
)
1870
1871
self._lookup_key_prefixes = tuple(
1692
- tuple(
1693
- PoolKey.build_prefix(
1694
- db.metadata,
1695
- tp_rank=tp_rank,
1696
- pcp_rank=pcp_rank,
1697
- dcp_rank=dcp_rank,
1698
- pp_rank=pp_rank,
1699
- )
1700
- for tp_rank, pcp_rank, dcp_rank, pp_rank in rank_namespaces(
1701
- self._group_tp_replication_factors[g_idx]
1702
- )
1872
+ db.store_layout.lookup_key_prefixes(
1873
+ rank_namespaces(self._group_tp_replication_factors[g_idx])
1874
)
1875
for g_idx, db in enumerate(self.token_dbs)
1876
)
@@ -1719,12 +1890,11 @@ class MooncakeStoreWorker:
1890
self.num_blocks = self.cache_config.num_gpu_blocks
1891
1892
seen_storage_ptrs: set[int] = set()
1722
- seen_region_ptrs: set[int] = set()
1723
- addrs: list[int] = []
1724
- block_lens: list[int] = []
1893
+ cache_tensors: list[torch.Tensor] = []
1894
1895
for cache in kv_caches.values():
1896
cache = group_kernel_blocks(cache, self.num_blocks)
1897
+ cache_tensors.append(cache)
1898
cache_storage = cache.untyped_storage()
1899
base_addr = cache_storage.data_ptr()
1900
region_len = cache_storage.nbytes()
@@ -1740,48 +1910,15 @@ class MooncakeStoreWorker:
1910
ret,
1911
)
1912
1743
- if not is_non_overlapping_and_dense(cache[0]):
1744
- # A block is scattered across per-head regions; each region's
1745
- # blocks are contiguous.
1746
- for head_idx in range(cache.shape[1]):
1747
- head_cache = cache[:, head_idx]
1748
- assert is_non_overlapping_and_dense(head_cache[0])
1749
- region_addr = head_cache.data_ptr()
1750
- if region_addr in seen_region_ptrs:
1751
- continue
1752
- seen_region_ptrs.add(region_addr)
1753
- addrs.append(region_addr)
1754
- block_lens.append(head_cache.stride(0) * head_cache.element_size())
1755
- elif cache.stride(0) * cache.element_size() * self.num_blocks == region_len:
1756
- # The block stride spans the whole per-block window
1757
- # (block-outermost and packed layouts, and single-layer
1758
- # tensors), which may hold other layers' pages at higher
1759
- # offsets. Register the storage once as one whole-window
1760
- # region; per-layer regions would copy overlapping windows and
1761
- # run past the storage for offset layers.
1762
- if base_addr in seen_region_ptrs:
1763
- continue
1764
- seen_region_ptrs.add(base_addr)
1765
- addrs.append(base_addr)
1766
- block_lens.append(region_len // self.num_blocks)
1767
- else:
1768
- region_addr = cache.data_ptr()
1769
- if region_addr in seen_region_ptrs:
1770
- continue
1771
- seen_region_ptrs.add(region_addr)
1772
- addrs.append(region_addr)
1773
- block_lens.append(cache.stride(0) * cache.element_size())
1774
-
1913
logger.info(
1776
- "Registered KV caches: num_groups=%d, num_segments=%d, num_blocks=%d",
1914
+ "Registered KV caches: num_groups=%d, num_tensors=%d, num_blocks=%d",
1915
len(self.token_dbs),
1778
- len(addrs),
1916
+ len(cache_tensors),
1917
self.num_blocks,
1918
)
1919
1920
for db in self.token_dbs:
1783
- db.set_kv_caches_base_addr(addrs)
1784
- db.set_block_len(block_lens)
1921
+ db.store_layout.register_kv_caches(cache_tensors, self.num_blocks)
1922
1923
# Start transfer threads
1924
if self.can_put:
@@ -2128,6 +2265,10 @@ class MooncakeStoreWorker:
2265
return self.kv_send_thread.get_kv_events()
2266
return []
2267
2268
+ @property
2269
+ def group_tp_replication_factors(self) -> tuple[int, ...]:
2270
+ return self._group_tp_replication_factors
2271
+
2272
def close(self) -> None:
2273
"""Release the MooncakeDistributedStore handle on teardown.
2274