[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>
This commit is contained in:
周峥
2026-09-01 10:18:06 -07:00
committed by GitHub
co-authored by Guanyi Chen
parent 2fe5cef35e
commit c866ba9d11
8 changed files with 1771 additions and 366 deletions
@@ -137,15 +137,12 @@ vllm serve meta-llama/Llama-3.1-8B-Instruct \
```
To also offload newly completed decode KV blocks, add the following extra
configuration to the decoder's `MooncakeStoreConnector` entry. This currently
requires the prefill and decode instances to use the same tensor-parallel size
and compatible KV-cache topology.
configuration to the decoder's `MooncakeStoreConnector` entry.
When decode processing starts, the consumer checks the block-aligned prompt
prefix and fills any blocks missing from the Store. Subsequent saves append
newly completed decode blocks. This keeps a complete, reusable prefix in the
Store and also covers deployments where prompt KV is delivered directly by
`MooncakeConnector` instead of through the Store.
Store. It also covers prompt KV delivered directly by `MooncakeConnector`.
```json
{
@@ -155,9 +152,41 @@ Store and also covers deployments where prompt KV is delivered directly by
}
```
#### Sharing one Store across multiple Prefill TP sizes
Heterogeneous-TP sharing normally uses a fixed `store_tp_size`. When several
prefillers use different TP sizes, opt in to a common Store TP derived from
their least common multiple:
```json
{
"kv_connector_extra_config": {
"enable_store_tp_lcm": true,
"prefill_tp_sizes": [4, 2]
}
}
```
Every prefiller and decoder that shares these entries must use the same list.
The example selects Store TP 4: a TP4 endpoint maps each rank to one Store
shard, while TP2 endpoints map each rank to two Store shards. Runtime TP sizes
remain unchanged. A decoder configured with `"save_decode_cache": true` uses
the same Store TP for decode KV from every prefiller.
The list may contain positive integer TP sizes. Sharing requires a Store TP that
is at least the local TP and divisible by it, an LBHNC or LBNHC local KV cache,
and the existing topology and KV-head constraints. The Store namespace includes
the attention backend's selected layout. Different layouts use separate Store
entries. Malformed lists and unsupported endpoints use an isolated rank-local
key layout. When
`enable_store_tp_lcm` is absent or false, `prefill_tp_sizes` has no effect and
the existing `store_tp_size` behavior is unchanged.
**Proxy:**
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.
A disaggregation proxy routes requests between prefiller and decoder nodes.
When `MooncakeConnector` is also used for direct P2P transfer, refer to its
[usage guide](mooncake_connector_usage.md) for proxy setup details.
### Disk Offloading
@@ -249,9 +278,26 @@ Strict isolation requires a Mooncake master started with `--enable_multi_tenants
- `lookup_rpc_port` (int): Custom port for the ZMQ lookup RPC socket. Default: `0`.
- `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).
- `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`.
- `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.
Decode offloading uses the existing TP-rank key namespace. Cross-TP sharing is
not yet supported.
LBHNC/HND is strongly recommended for TP-sharded Store when supported. LBNHC/NHD
creates many transfer segments and may significantly reduce PUT/GET performance.
For example, with prefill TP 4, decode TP 2, and eight KV heads, set
`store_tp_size` to 4 on both instances. Each decode rank reads and writes two
of the four Store shards.
MQA with one total KV head uses a replicated-head layout. For the supported
prefill TP 4 to decode TP 2 case, every rank uses the
same rank-0 key namespace. The four prefill replicas stripe block PUTs so each
object is stored once, while both decode ranks GET every block into their local
KV replica. `store_tp_size` does not appear in MQA keys, so identical MQA
objects written at different store TP sizes share the same pool entry when PP
sizes match.
Tensor-parallel collectives and low-precision arithmetic are not bitwise
invariant across TP sizes, so heterogeneous-TP reuse does not guarantee the
same greedy output as recomputing the prefix at the decode TP size.
## Notes
@@ -48,6 +48,31 @@ def _make_vllm_config():
)
@pytest.mark.parametrize(
"extra_config",
[
{"store_tp_size": 4},
{"enable_store_tp_lcm": True, "prefill_tp_sizes": [6, 4]},
{"prefill_tp_sizes": [4, 2]},
{"enable_store_tp_lcm": True, "prefill_tp_sizes": [4, 0]},
None,
],
)
def test_store_tp_does_not_override_backend_kv_cache_layout(extra_config):
vllm_config = create_vllm_config(
kv_connector="MooncakeStoreConnector",
kv_role="kv_both",
kv_connector_extra_config=extra_config,
)
assert (
mooncake_store_connector.MooncakeStoreConnector.get_required_kvcache_layout(
vllm_config
)
is None
)
def _make_kv_cache_config() -> KVCacheConfig:
"""Single-group full-attention KVCacheConfig — enough for the connector
constructor's validate() pass."""
@@ -90,15 +115,18 @@ def test_scheduler_requires_align_mode_for_mamba():
scheduler.MooncakeStoreScheduler(vllm_config, kv_cache_config)
def _make_block_stored() -> BlockStored:
def _make_block_stored(
block_hash: bytes = b"hash", group_idx: int | None = None
) -> BlockStored:
return BlockStored(
block_hashes=[b"hash"],
block_hashes=[block_hash],
parent_block_hash=None,
token_ids=[1, 2, 3],
block_size=16,
lora_id=None,
medium="cpu",
lora_name=None,
group_idx=group_idx,
)
@@ -169,7 +197,7 @@ def test_worker_methods_delegate_to_store_worker():
assert invalid_block_ids == {3, 4}
def test_get_kv_connector_kv_cache_events_returns_none_when_empty():
def test_get_kv_connector_kv_cache_events_returns_none_when_disabled():
vllm_config = _make_vllm_config()
kv_cache_config = _make_kv_cache_config()
@@ -184,7 +212,7 @@ def test_get_kv_connector_kv_cache_events_returns_none_when_empty():
vllm_config, KVConnectorRole.WORKER, kv_cache_config
)
mock_worker_cls.return_value.get_kv_events.return_value = []
mock_worker_cls.return_value.enable_kv_events = False
assert connector.get_kv_connector_kv_cache_events() is None
@@ -248,6 +276,9 @@ def test_get_kv_connector_kv_cache_events_wraps_worker_events():
)
mock_worker_cls.return_value.get_kv_events.return_value = [event]
mock_worker_cls.return_value.enable_kv_events = True
mock_worker_cls.return_value.kv_send_thread = MagicMock()
mock_worker_cls.return_value.group_tp_replication_factors = (1,)
kv_events = connector.get_kv_connector_kv_cache_events()
assert isinstance(kv_events, mooncake_store_connector.MooncakeStoreKVEvents)
@@ -255,6 +286,119 @@ def test_get_kv_connector_kv_cache_events_wraps_worker_events():
assert kv_events.get_all_events() == [event]
def test_get_kv_connector_kv_cache_events_keeps_empty_worker_contribution():
vllm_config = _make_vllm_config()
kv_cache_config = _make_kv_cache_config()
with (
set_current_vllm_config(vllm_config),
patch(
"vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
"connector.MooncakeStoreWorker"
) as mock_worker_cls,
):
connector = mooncake_store_connector.MooncakeStoreConnector(
vllm_config, KVConnectorRole.WORKER, kv_cache_config
)
worker = mock_worker_cls.return_value
worker.enable_kv_events = True
worker.kv_send_thread = MagicMock()
worker.group_tp_replication_factors = (2,)
worker.get_kv_events.return_value = []
kv_events = connector.get_kv_connector_kv_cache_events()
assert isinstance(kv_events, mooncake_store_connector.MooncakeStoreKVEvents)
assert kv_events.get_number_of_workers() == 1
assert kv_events.get_all_events() == []
def _aggregate_store_events(
worker_events: list[list[BlockStored]],
replication_factors: tuple[int, ...],
) -> list[BlockStored]:
combined = mooncake_store_connector.MooncakeStoreKVEvents(
num_workers=1,
group_tp_replication_factors=replication_factors,
)
combined.add_events(worker_events[0])
for events in worker_events[1:]:
combined.add_events(events)
combined.increment_workers()
combined.aggregate()
return combined.get_all_events()
def test_gqa_store_events_follow_tp_replica_striping():
block_a = _make_block_stored(b"a", group_idx=0)
block_b = _make_block_stored(b"b", group_idx=0)
events = _aggregate_store_events(
[[block_a], [block_b], [block_a], [block_b]],
replication_factors=(2,),
)
assert events == [block_a, block_b]
def test_mqa_store_events_require_one_replica():
blocks = [_make_block_stored(bytes([index]), group_idx=0) for index in range(4)]
events = _aggregate_store_events(
[[block] for block in blocks],
replication_factors=(4,),
)
assert events == blocks
def test_store_events_require_every_non_replicated_worker():
complete = _make_block_stored(b"complete", group_idx=0)
incomplete = _make_block_stored(b"incomplete", group_idx=0)
events = _aggregate_store_events(
[
[complete, incomplete],
[complete],
[complete],
[complete],
],
replication_factors=(1,),
)
assert events == [complete]
def test_store_events_reject_incomplete_replica_coverage():
complete = _make_block_stored(b"complete", group_idx=0)
incomplete = _make_block_stored(b"incomplete", group_idx=0)
events = _aggregate_store_events(
[[complete, incomplete], [complete], [], []],
replication_factors=(2,),
)
assert events == [complete]
def test_store_events_use_each_groups_replication_factor():
full_attention = _make_block_stored(b"full", group_idx=0)
replicated_attention = _make_block_stored(b"replicated", group_idx=1)
events = _aggregate_store_events(
[
[full_attention, replicated_attention],
[full_attention],
[full_attention, replicated_attention],
[full_attention],
],
replication_factors=(1, 2),
)
assert events == [full_attention, replicated_attention]
def test_update_connector_output_and_take_events():
vllm_config = _make_vllm_config()
kv_cache_config = _make_kv_cache_config()
@@ -284,6 +428,28 @@ def test_update_connector_output_and_take_events():
assert connector._kv_cache_events is None
def test_store_events_persist_across_polls():
connector = object.__new__(mooncake_store_connector.MooncakeStoreConnector)
connector.connector_scheduler = MagicMock()
connector._kv_cache_events = None
complete = _make_block_stored(b"complete", group_idx=0)
pending = _make_block_stored(b"pending", group_idx=0)
def poll(worker_events: list[BlockStored]) -> list[BlockStored]:
events = mooncake_store_connector.MooncakeStoreKVEvents(
num_workers=4,
group_tp_replication_factors=(2,),
)
events.add_events(worker_events)
connector.update_connector_output(KVConnectorOutput(kv_cache_events=events))
return list(connector.take_events())
assert poll([complete, pending]) == []
assert poll([complete]) == [complete]
assert poll([pending, pending]) == [pending]
assert connector._kv_cache_events is None
# ============================================================
# reset_cache() — RL hard-reset path via typed LookupKey protocol
# ============================================================
@@ -0,0 +1,180 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for Mooncake Store payload layouts."""
import ctypes
import random
import pytest
import torch
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
ChunkedTokenDatabase,
KeyMetadata,
LBHNCStoreLayout,
LBNHCStoreLayout,
RankLocalStoreLayout,
)
from vllm.utils.math_utils import cdiv
BLOCK_SIZE = 128
def _rank_local_layout(num_regions: int, num_block_lens: int) -> RankLocalStoreLayout:
metadata = KeyMetadata("test-model", 1, 0, 0, 0)
layout = RankLocalStoreLayout(metadata, BLOCK_SIZE, BLOCK_SIZE)
layout.set_kv_caches_base_addr(
[0x7F00_0000_0000 + i * (1 << 30) for i in range(num_regions)]
)
layout.set_block_len([30_208 + 512 * i for i in range(num_block_lens)])
return layout
def _rank_local_reference(
layout: RankLocalStoreLayout, start: int, end: int, block_ids: list[int]
) -> tuple[list[int], list[int], int]:
block_id = block_ids[start // layout.block_size]
length = len(layout.block_len)
addrs = [
base_addr + block_id * layout.block_len[index % length]
for index, base_addr in enumerate(layout.kv_caches_base_addr)
]
sizes = [
layout.block_len[index % length] * cdiv(end - start, layout.block_size)
for index in range(len(layout.kv_caches_base_addr))
]
return addrs, sizes, block_id
@pytest.mark.parametrize("num_regions,num_block_lens", [(96, 96), (96, 2), (1, 1)])
def test_rank_local_descriptors_match_reference(num_regions: int, num_block_lens: int):
layout = _rank_local_layout(num_regions, num_block_lens)
rng = random.Random(0)
block_ids = [rng.randrange(0, 1 << 20) for _ in range(300)]
chunks = []
block = 0
while block < len(block_ids) - 4:
span = rng.choice([1, 1, 1, 2, 4])
chunks.append((block * BLOCK_SIZE, (block + span) * BLOCK_SIZE))
block += span + rng.choice([0, 1])
addrs, sizes, selected_blocks = layout.prepare_values(
chunks, block_ids, [0] * len(chunks)
)
for chunk, chunk_addrs, chunk_sizes, block_id in zip(
chunks, addrs, sizes, selected_blocks, strict=True
):
assert (chunk_addrs, chunk_sizes, block_id) == _rank_local_reference(
layout, *chunk, block_ids
)
assert all(type(addr) is int for addr in chunk_addrs)
assert type(block_id) is int
def test_rank_local_descriptors_handle_empty_and_invalid_chunks():
layout = _rank_local_layout(4, 4)
assert layout.prepare_values([], [1, 2, 3], []) == ([], [], [])
with pytest.raises(AssertionError):
layout.prepare_values([(0, BLOCK_SIZE + 1)], [0, 1], [0])
@pytest.mark.parametrize(
("layout_cls", "store_format"),
[
(LBHNCStoreLayout, "tp_shared_lbhnc"),
(LBNHCStoreLayout, "tp_shared_lbnhc"),
],
)
def test_tp_shared_layout_owns_store_namespace(layout_cls, store_format):
assert (
layout_cls.shared_namespace(4, 2)
== f"@store_tp:4@store_pp:2@store_format:{store_format}"
)
def test_rank_local_database_api_rejects_tp_shared_layout():
metadata = KeyMetadata("test-model", 0, 0, 0, 0)
layout = LBHNCStoreLayout(
metadata,
block_size=16,
hash_block_size=16,
local_tp_size=2,
store_tp_size=4,
tp_rank=0,
num_kv_heads=8,
)
database = ChunkedTokenDatabase(metadata, 16, store_layout=layout)
with pytest.raises(RuntimeError, match="rank-local"):
database.prepare_value_for_block(0)
@pytest.mark.parametrize(
("layout_cls", "producer_strides", "consumer_strides"),
[
(LBHNCStoreLayout, (128, 64, 4, 1), (256, 64, 4, 1)),
(LBNHCStoreLayout, (128, 4, 8, 1), (256, 4, 16, 1)),
],
)
def test_tp_shared_layout_round_trip_across_tp_sizes(
layout_cls, producer_strides, consumer_strides
):
block_size = 16
shape = (1, 2, block_size, 4)
stored: dict[int, bytes] = {}
producer_tensors = []
for tp_rank in range(4):
tensor = torch.empty_strided(shape, producer_strides, dtype=torch.float16)
tensor.copy_(
torch.arange(128, dtype=torch.float16).view(shape) + tp_rank * 1000
)
producer_tensors.append(tensor)
metadata = KeyMetadata("test-model", tp_rank, 0, 0, 0)
layout = layout_cls(
metadata,
block_size,
block_size,
local_tp_size=4,
store_tp_size=4,
tp_rank=tp_rank,
num_kv_heads=8,
)
layout.register_kv_caches([tensor], 1)
addrs, sizes, _ = layout.prepare_values([(0, block_size)], [0], [tp_rank])
stored[tp_rank] = b"".join(
ctypes.string_at(addr, size)
for addr, size in zip(addrs[0], sizes[0], strict=True)
)
for tp_rank in range(2):
tensor = torch.empty_strided(
(1, 4, block_size, 4), consumer_strides, dtype=torch.float16
)
tensor.zero_()
metadata = KeyMetadata("test-model", tp_rank, 0, 0, 0)
layout = layout_cls(
metadata,
block_size,
block_size,
local_tp_size=2,
store_tp_size=4,
tp_rank=tp_rank,
num_kv_heads=8,
)
layout.register_kv_caches([tensor], 1)
shard_ids = layout.local_shard_ids
addrs, sizes, _ = layout.prepare_values(
[(0, block_size)] * len(shard_ids), [0], shard_ids
)
for shard_id, shard_addrs, shard_sizes in zip(
shard_ids, addrs, sizes, strict=True
):
offset = 0
for addr, size in zip(shard_addrs, shard_sizes, strict=True):
ctypes.memmove(addr, stored[shard_id][offset : offset + size], size)
offset += size
expected = torch.cat(producer_tensors[tp_rank * 2 : tp_rank * 2 + 2], dim=1)
torch.testing.assert_close(tensor, expected)
@@ -1,90 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for ChunkedTokenDatabase.prepare_values."""
import random
import pytest
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
ChunkedTokenDatabase,
KeyMetadata,
)
from vllm.utils.math_utils import cdiv
BLOCK_SIZE = 128
def _reference_prepare_value(
db: ChunkedTokenDatabase, start: int, end: int, block_ids: list[int]
) -> tuple[list[int], list[int], int]:
"""Compute a token range with the original scalar implementation."""
addr_list = []
size_list = []
block_id = block_ids[start // db.block_size]
length = len(db.block_len)
for index, base_addr in enumerate(db.kv_caches_base_addr):
addr = base_addr + block_id * db.block_len[index % length]
assert (end - start) % db.block_size == 0
size = db.block_len[index % length] * cdiv(end - start, db.block_size)
addr_list.append(addr)
size_list.append(size)
return addr_list, size_list, block_id
def _make_db(num_regions: int, num_block_lens: int) -> ChunkedTokenDatabase:
md = KeyMetadata(model_name="t", tp_rank=1, pcp_rank=0, dcp_rank=0, pp_rank=0)
db = ChunkedTokenDatabase(md, BLOCK_SIZE)
db.set_kv_caches_base_addr(
[0x7F00_0000_0000 + i * (1 << 30) for i in range(num_regions)]
)
# Exercise repeated block lengths when there are more cache regions.
db.set_block_len([30_208 + 512 * i for i in range(num_block_lens)])
return db
@pytest.mark.parametrize("num_regions,num_block_lens", [(96, 96), (96, 2), (1, 1)])
def test_prepare_values_matches_reference(num_regions: int, num_block_lens: int):
db = _make_db(num_regions, num_block_lens)
rng = random.Random(0)
n_blocks = 300
block_ids = [rng.randrange(0, 1 << 20) for _ in range(n_blocks)]
chunks = []
b = 0
while b < n_blocks - 4:
span = rng.choice([1, 1, 1, 2, 4])
chunks.append((b * BLOCK_SIZE, (b + span) * BLOCK_SIZE))
b += span + rng.choice([0, 1])
addrs, sizes, bids = db.prepare_values(chunks, block_ids)
assert len(addrs) == len(sizes) == len(bids) == len(chunks)
for (start, end), addr, size, bid in zip(chunks, addrs, sizes, bids):
ref_addr, ref_size, ref_bid = _reference_prepare_value(
db, start, end, block_ids
)
assert addr == ref_addr
assert size == ref_size
assert bid == ref_bid
# Native bindings require Python ints rather than numpy scalars.
assert all(type(a) is int for a in addr)
assert type(bid) is int
def test_prepare_value_single_matches_reference():
db = _make_db(8, 8)
block_ids = list(range(64))
got = db.prepare_value(5 * BLOCK_SIZE, 7 * BLOCK_SIZE, block_ids)
assert got == _reference_prepare_value(
db, 5 * BLOCK_SIZE, 7 * BLOCK_SIZE, block_ids
)
def test_prepare_values_empty():
db = _make_db(4, 4)
assert db.prepare_values([], [1, 2, 3]) == ([], [], [])
def test_prepare_values_rejects_unaligned_chunk():
db = _make_db(4, 4)
with pytest.raises(AssertionError):
db.prepare_values([(0, BLOCK_SIZE + 1)], [0, 1])
@@ -17,6 +17,7 @@ import pytest
import torch
from tests.v1.attention.utils import dense_kv_cache_views
from vllm.distributed.kv_events import KVEventAggregator
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake import (
rdma_utils,
)
@@ -30,9 +31,12 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
BlobBlockHashes,
ChunkedTokenDatabase,
KeyMetadata,
LBHNCStoreLayout,
LBNHCStoreLayout,
LoadSpec,
MooncakeLookupResult,
PoolKey,
RankLocalStoreLayout,
ReqMeta,
TailKeyBoundary,
)
@@ -44,8 +48,19 @@ from vllm.v1.core.kv_cache_utils import BlockHash, maybe_convert_block_hash
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheGroupSpec,
KVCacheLayout,
RSWASpec,
)
from vllm.v1.kv_cache_layout import KVCacheLayout
_TP_SHARED_NAMESPACE = "@store_tp:4@store_pp:1@store_format:tp_shared_lbhnc"
_TP_SHARED_LBNHC_NAMESPACE = "@store_tp:4@store_pp:1@store_format:tp_shared_lbnhc"
def _tp_shared_prefix(tp_rank: int) -> str:
return (
f"test-model{_TP_SHARED_NAMESPACE}@tp_rank:{tp_rank}"
"@pcp0@dcp0@pp_rank:0@group:0"
)
class _RecordingBlockHashes:
@@ -78,6 +93,46 @@ def _default_send_coord() -> mooncake_store_worker.MooncakeStoreCoordinator:
)
def _make_tp_shared_db(
tp_rank: int = 0,
) -> tuple[ChunkedTokenDatabase, torch.Tensor]:
num_blocks = 2
local_heads = 4
block_size = 16
content_size = 4
strides = (
local_heads * block_size * content_size,
block_size * content_size,
content_size,
1,
)
tensor = torch.empty_strided(
(num_blocks, local_heads, block_size, content_size),
strides,
dtype=torch.float16,
)
metadata = KeyMetadata(
"test-model",
tp_rank,
0,
0,
0,
store_namespace=_TP_SHARED_NAMESPACE,
)
layout = LBHNCStoreLayout(
metadata,
block_size,
block_size,
local_tp_size=2,
store_tp_size=4,
tp_rank=tp_rank,
num_kv_heads=8,
)
db = ChunkedTokenDatabase(metadata, block_size, store_layout=layout)
layout.register_kv_caches([tensor], num_blocks)
return db, tensor
def _make_store_sending_thread(
store: MagicMock,
*,
@@ -243,11 +298,15 @@ def _make_vllm_config(
rank: int = 0,
decode_context_parallel_size: int = 1,
kv_role: str = "kv_both",
pipeline_parallel_size: int = 1,
kv_cache_layout: KVCacheLayout = KVCacheLayout.LBHNC,
) -> SimpleNamespace:
cache_config = SimpleNamespace(block_size=16, num_gpu_blocks=10)
cache_config.get_resolved_kv_cache_layout = lambda: kv_cache_layout
return SimpleNamespace(
model_config=_FakeModelConfig(),
parallel_config=SimpleNamespace(
pipeline_parallel_size=1,
pipeline_parallel_size=pipeline_parallel_size,
rank=rank,
data_parallel_index=0,
decode_context_parallel_size=decode_context_parallel_size,
@@ -256,7 +315,7 @@ def _make_vllm_config(
kv_transfer_config=_FakeKVTransferConfig(
kv_role=kv_role, extra_config=extra_config
),
cache_config=SimpleNamespace(block_size=16, num_gpu_blocks=10),
cache_config=cache_config,
kv_events_config=SimpleNamespace(enable_kv_cache_events=False),
speculative_config=None,
)
@@ -334,6 +393,138 @@ def test_pool_key_to_string_without_prefix_is_unchanged():
)
def test_pool_key_store_tp_namespace():
key = PoolKey(
KeyMetadata(
"test-model",
2,
0,
0,
0,
store_namespace=_TP_SHARED_NAMESPACE,
),
"deadbeef",
)
assert key.to_string() == f"{_tp_shared_prefix(2)}@deadbeef"
def test_tp_shared_sending_writes_each_store_shard():
store = MagicMock()
store.batch_is_exist.return_value = [0, 0, 0, 0]
store.batch_put_from_multi_buffers.return_value = [1, 1, 1, 1]
db, _ = _make_tp_shared_db()
thread = _make_store_sending_thread(
store,
token_databases=[db],
replicate_config=SimpleNamespace(),
)
req = _make_store_req("req", [b"h0", b"h1"])
_run_store_req(thread, req)
keys, addrs, sizes, _ = store.batch_put_from_multi_buffers.call_args.args
assert keys == [
f"{_tp_shared_prefix(0)}@6830",
f"{_tp_shared_prefix(1)}@6830",
f"{_tp_shared_prefix(0)}@6831",
f"{_tp_shared_prefix(1)}@6831",
]
assert len(addrs) == len(sizes) == 4
def test_tp_shared_kv_events_aggregate_once_per_logical_block():
aggregator = KVEventAggregator(num_workers=2)
for tp_rank in range(2):
store = MagicMock()
store.batch_is_exist.return_value = [0, 0]
store.batch_put_from_multi_buffers.return_value = [256, 256]
db, _ = _make_tp_shared_db(tp_rank)
thread = _make_store_sending_thread(
store,
token_databases=[db],
tp_rank=tp_rank,
replicate_config=SimpleNamespace(),
)
thread.enable_kv_event = True
_run_store_req(thread, _make_event_store_req(16))
events = thread.get_kv_events()
assert len(events) == 1
aggregator.add_events(events)
assert len(aggregator.get_common_events()) == 1
def test_tp_shared_kv_event_waits_for_all_missing_shards():
store = MagicMock()
store.batch_is_exist.return_value = [0, 0]
store.batch_put_from_multi_buffers.return_value = [256, -1]
db, _ = _make_tp_shared_db()
thread = _make_store_sending_thread(
store,
token_databases=[db],
replicate_config=SimpleNamespace(),
)
thread.enable_kv_event = True
_run_store_req(thread, _make_event_store_req(16))
assert thread.get_kv_events() == []
def test_tp_shared_kv_event_counts_existing_shards_as_satisfied():
store = MagicMock()
store.batch_is_exist.return_value = [1, 0]
store.batch_put_from_multi_buffers.return_value = [256]
db, _ = _make_tp_shared_db()
thread = _make_store_sending_thread(
store,
token_databases=[db],
replicate_config=SimpleNamespace(),
)
thread.enable_kv_event = True
_run_store_req(thread, _make_event_store_req(16))
assert len(thread.get_kv_events()) == 1
def test_tp_shared_receiving_reads_each_local_store_shard():
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheGroupSpec
store = MagicMock()
store.batch_get_into_multi_buffers.return_value = [1, 1, 1, 1]
db, _ = _make_tp_shared_db()
spec = FullAttentionSpec(block_size=16, num_kv_heads=8, head_size=64, dtype=None)
coord = mooncake_store_worker.MooncakeStoreCoordinator(
[KVCacheGroupSpec(["layer0"], spec)],
scheduler_block_size=16,
hash_block_size=16,
)
thread = mooncake_store_worker.KVCacheStoreRecvingThread(
store=store,
token_databases=[db],
block_size=16,
tp_rank=0,
ready_event=threading.Event(),
coord=coord,
)
thread.request_queue.task_done = MagicMock()
thread._handle_request(_make_load_req("req", [b"h0", b"h1"], token_len=32))
keys, addrs, sizes = store.batch_get_into_multi_buffers.call_args.args
assert keys == [
f"{_tp_shared_prefix(0)}@6830",
f"{_tp_shared_prefix(1)}@6830",
f"{_tp_shared_prefix(0)}@6831",
f"{_tp_shared_prefix(1)}@6831",
]
assert len(addrs) == len(sizes) == 4
def test_pool_key_cache_prefix_namespaces_and_disambiguates():
"""A non-empty cache_prefix is prepended, and two instances with
different prefixes never collide on identical block hashes."""
@@ -1155,8 +1346,7 @@ def test_store_sending_thread_prepares_missing_chunks_once_per_group():
)
db0.set_kv_caches_base_addr([0x1000])
db0.set_block_len([256])
db0.prepare_values = MagicMock(wraps=db0.prepare_values)
db0.prepare_value = MagicMock(side_effect=AssertionError("scalar path called"))
db0.store_layout.prepare_values = MagicMock(wraps=db0.store_layout.prepare_values)
db1 = ChunkedTokenDatabase(
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():
)
db1.set_kv_caches_base_addr([0x2000])
db1.set_block_len([512])
db1.prepare_values = MagicMock(wraps=db1.prepare_values)
db1.prepare_value = MagicMock(side_effect=AssertionError("scalar path called"))
db1.store_layout.prepare_values = MagicMock(wraps=db1.store_layout.prepare_values)
thread = _make_store_sending_thread(
store,
@@ -1183,10 +1372,12 @@ def test_store_sending_thread_prepares_missing_chunks_once_per_group():
),
)
db0.prepare_value.assert_not_called()
db1.prepare_value.assert_not_called()
db0.prepare_values.assert_called_once_with([(0, 16), (32, 48)], [1, 2, 3])
db1.prepare_values.assert_called_once_with([(16, 32), (32, 48)], [3, 2, 1])
db0.store_layout.prepare_values.assert_called_once_with(
[(0, 16), (32, 48)], [1, 2, 3], [0, 0]
)
db1.store_layout.prepare_values.assert_called_once_with(
[(16, 32), (32, 48)], [3, 2, 1], [0, 0]
)
keys, addrs, sizes, _ = store.batch_put_from_multi_buffers.call_args.args
assert [key.rsplit("@", 1)[-1] for key in keys] == [
@@ -1836,8 +2027,10 @@ def test_recv_thread_splits_disk_offload_loads_by_budget():
assert second_keys == [
"test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@6132",
]
base_addr = thread.token_databases[0].kv_caches_base_addr[0]
block_len = thread.token_databases[0].block_len[0]
layout = thread.token_databases[0].store_layout
assert isinstance(layout, RankLocalStoreLayout)
base_addr = layout.kv_caches_base_addr[0]
block_len = layout.block_len[0]
assert first_addrs == [[base_addr], [base_addr + block_len]]
assert second_addrs == [[base_addr + 2 * block_len]]
expected_size = block_len
@@ -2004,6 +2197,294 @@ def test_requester_worker_init_skips_disk_budget_when_offload_disabled(
assert w.disk_offload_buffer_budget_bytes is None
def test_worker_enables_lbhnc_store_tp_layout(tmp_path, monkeypatch):
store = MagicMock()
store.setup.return_value = 0
_install_fake_mooncake(monkeypatch, store)
_patch_worker_runtime(monkeypatch, tp_size=2)
monkeypatch.setattr(_FakeModelConfig, "get_total_num_kv_heads", lambda _self: 8)
monkeypatch.setenv(
"MOONCAKE_CONFIG_PATH",
_write_mooncake_config(
tmp_path,
{
"metadata_server": "http://metadata/endpoint",
"protocol": "tcp",
"master_server_address": "10.0.0.7:50051",
},
),
)
w = worker.MooncakeStoreWorker(
_make_vllm_config(extra_config={"store_tp_size": 4}),
_make_kv_cache_config(),
)
assert w.store_tp_size == 4
assert w.token_dbs[0].metadata.store_namespace == _TP_SHARED_NAMESPACE
assert isinstance(w.token_dbs[0].store_layout, LBHNCStoreLayout)
assert w.token_dbs[0].store_layout.local_shard_ids == (0, 1)
assert len(w._lookup_key_prefixes[0]) == 4
def test_worker_enables_lbnhc_store_tp_layout(tmp_path, monkeypatch):
store = MagicMock()
store.setup.return_value = 0
_install_fake_mooncake(monkeypatch, store)
_patch_worker_runtime(monkeypatch, tp_size=2)
monkeypatch.setattr(_FakeModelConfig, "get_total_num_kv_heads", lambda _self: 8)
monkeypatch.setenv(
"MOONCAKE_CONFIG_PATH",
_write_mooncake_config(
tmp_path,
{
"metadata_server": "http://metadata/endpoint",
"protocol": "tcp",
"master_server_address": "10.0.0.7:50051",
},
),
)
with patch.object(worker.logger, "warning_once") as warning_once:
w = worker.MooncakeStoreWorker(
_make_vllm_config(
extra_config={"store_tp_size": 4},
kv_cache_layout=KVCacheLayout.LBNHC,
),
_make_kv_cache_config(),
)
warning_once.assert_called_once()
assert w.store_tp_size == 4
assert w.token_dbs[0].metadata.store_namespace == _TP_SHARED_LBNHC_NAMESPACE
assert isinstance(w.token_dbs[0].store_layout, LBNHCStoreLayout)
assert w.token_dbs[0].store_layout.local_shard_ids == (0, 1)
assert len(w._lookup_key_prefixes[0]) == 4
def test_tp_sharded_layout_rejects_full_attention_subclasses():
spec = RSWASpec(
block_size=16,
num_kv_heads=8,
head_size=64,
dtype=None,
rswa_window=32,
)
store_worker = object.__new__(worker.MooncakeStoreWorker)
store_worker.pcp_size = 1
store_worker.dcp_size = 1
store_worker._kv_cache_groups = [SimpleNamespace(kv_cache_spec=spec)]
assert not store_worker._supports_tp_sharded_store_layout(LBHNCStoreLayout, {})
@pytest.mark.parametrize(
("extra_config", "expected"),
[
({"store_tp_size": 4}, 4),
(
{
"enable_store_tp_lcm": True,
"prefill_tp_sizes": [3, 4, 6],
},
12,
),
(
{
"enable_store_tp_lcm": False,
"prefill_tp_sizes": [4, 2],
},
None,
),
({"enable_store_tp_lcm": True, "prefill_tp_sizes": []}, None),
({"enable_store_tp_lcm": True, "prefill_tp_sizes": [4, 0]}, None),
({"enable_store_tp_lcm": True, "prefill_tp_sizes": [4, 2.0]}, None),
({"store_tp_size": 0}, None),
({"store_tp_size": -1}, None),
({"store_tp_size": True}, None),
({"store_tp_size": 4.0}, None),
],
)
def test_resolve_store_tp_size(extra_config: dict[str, object], expected: int | None):
assert worker.resolve_store_tp_size(extra_config) == expected
def test_lcm_store_tp_gives_prefill_and_decode_common_namespace(tmp_path, monkeypatch):
store = MagicMock()
store.setup.return_value = 0
_install_fake_mooncake(monkeypatch, store)
monkeypatch.setattr(_FakeModelConfig, "get_total_num_kv_heads", lambda _self: 8)
monkeypatch.setenv(
"MOONCAKE_CONFIG_PATH",
_write_mooncake_config(
tmp_path,
{
"metadata_server": "http://metadata/endpoint",
"protocol": "tcp",
"master_server_address": "10.0.0.7:50051",
},
),
)
lcm_config = {
"enable_store_tp_lcm": True,
"prefill_tp_sizes": [4, 2],
}
workers = []
topologies = [
(4, 2, "kv_both", (2,)),
(2, 1, "kv_both", (2, 3)),
(2, 1, "kv_consumer", (2, 3)),
]
for tp_size, tp_rank, kv_role, expected_shards in topologies:
_patch_worker_runtime(
monkeypatch,
tp_size=tp_size,
tp_rank=tp_rank,
)
extra_config = dict(lcm_config)
if kv_role == "kv_consumer":
extra_config["save_decode_cache"] = True
store_worker = worker.MooncakeStoreWorker(
_make_vllm_config(extra_config=extra_config, kv_role=kv_role),
_make_kv_cache_config(),
)
assert store_worker.store_tp_size == 4
assert isinstance(store_worker.token_dbs[0].store_layout, LBHNCStoreLayout)
assert store_worker.token_dbs[0].store_layout.local_shard_ids == expected_shards
workers.append(store_worker)
shard_keys = [
store_worker.token_dbs[0].store_layout.key_for(2, BlockHash(b"h"))
for store_worker in workers
]
assert len(set(shard_keys)) == 1
assert workers[-1].can_put
@pytest.mark.parametrize(
("tp_size", "prefill_tp_sizes"),
[
(3, [4, 2]),
(2, [4, 3]),
(2, []),
(2, [4, 0]),
],
)
def test_lcm_store_tp_falls_back_when_topology_is_incompatible(
tmp_path,
monkeypatch,
tp_size: int,
prefill_tp_sizes: list[int],
):
store = MagicMock()
store.setup.return_value = 0
_install_fake_mooncake(monkeypatch, store)
_patch_worker_runtime(monkeypatch, tp_size=tp_size)
monkeypatch.setattr(_FakeModelConfig, "get_total_num_kv_heads", lambda _self: 8)
monkeypatch.setenv(
"MOONCAKE_CONFIG_PATH",
_write_mooncake_config(
tmp_path,
{
"metadata_server": "http://metadata/endpoint",
"protocol": "tcp",
"master_server_address": "10.0.0.7:50051",
},
),
)
store_worker = worker.MooncakeStoreWorker(
_make_vllm_config(
extra_config={
"enable_store_tp_lcm": True,
"prefill_tp_sizes": prefill_tp_sizes,
}
),
_make_kv_cache_config(),
)
assert store_worker.store_tp_size is None
assert type(store_worker.token_dbs[0]) is ChunkedTokenDatabase
assert "@store_format:rank_local_tp" in store_worker.token_dbs[0].key_for(
BlockHash(b"h")
)
@pytest.mark.parametrize("store_tp_size", [1, 3, 6])
def test_worker_keeps_rank_local_layout_for_unsupported_store_tp(
tmp_path,
monkeypatch,
store_tp_size: int,
):
store = MagicMock()
store.setup.return_value = 0
_install_fake_mooncake(monkeypatch, store)
_patch_worker_runtime(monkeypatch, tp_size=2)
monkeypatch.setattr(_FakeModelConfig, "get_total_num_kv_heads", lambda _self: 8)
monkeypatch.setenv(
"MOONCAKE_CONFIG_PATH",
_write_mooncake_config(
tmp_path,
{
"metadata_server": "http://metadata/endpoint",
"protocol": "tcp",
"master_server_address": "10.0.0.7:50051",
},
),
)
w = worker.MooncakeStoreWorker(
_make_vllm_config(
extra_config={"store_tp_size": store_tp_size},
kv_cache_layout=KVCacheLayout.LBNHC,
),
_make_kv_cache_config(),
)
assert w.store_tp_size is None
assert type(w.token_dbs[0]) is ChunkedTokenDatabase
key = w.token_dbs[0].key_for(BlockHash(b"h"))
assert "@store_tp:" not in key
assert "@store_pp:1" in key
assert "@store_format:rank_local_tp2_layout_LBNHC" in key
def test_store_tp_opt_in_isolates_different_pp_sizes(tmp_path, monkeypatch):
store = MagicMock()
store.setup.return_value = 0
_install_fake_mooncake(monkeypatch, store)
_patch_worker_runtime(monkeypatch, tp_size=2)
monkeypatch.setattr(_FakeModelConfig, "get_total_num_kv_heads", lambda _self: 8)
monkeypatch.setenv(
"MOONCAKE_CONFIG_PATH",
_write_mooncake_config(
tmp_path,
{
"metadata_server": "http://metadata/endpoint",
"protocol": "tcp",
"master_server_address": "10.0.0.7:50051",
},
),
)
keys = []
for pp_size in (1, 2):
w = worker.MooncakeStoreWorker(
_make_vllm_config(
extra_config={"store_tp_size": 4},
pipeline_parallel_size=pp_size,
),
_make_kv_cache_config(),
)
layout = w.token_dbs[0].store_layout
keys.append(layout.key_for(layout.local_shard_ids[0], BlockHash(b"h")))
assert "@store_pp:1" in keys[0]
assert "@store_pp:2" in keys[1]
assert keys[0] != keys[1]
def test_save_decode_cache_keeps_transfer_path_enabled(tmp_path, monkeypatch):
store = MagicMock()
store.setup.return_value = 0
@@ -2185,6 +2666,58 @@ def test_worker_put_striding_covers_every_rank_get_namespace(
)
def test_mqa_p4_to_d2_uses_shared_rank_zero_namespace(tmp_path, monkeypatch):
store = MagicMock()
store.setup.return_value = 0
_install_fake_mooncake(monkeypatch, store)
monkeypatch.setenv(
"MOONCAKE_CONFIG_PATH",
_write_mooncake_config(
tmp_path,
{
"metadata_server": "http://metadata/endpoint",
"protocol": "tcp",
"master_server_address": "10.0.0.7:50051",
},
),
)
block_hash = BlockHash(b"hash")
put_keys: set[str] = set()
get_keys: set[str] = set()
for tp_size, target in ((4, put_keys), (2, get_keys)):
for tp_rank in range(tp_size):
_patch_worker_runtime(monkeypatch, tp_rank=tp_rank, tp_size=tp_size)
w = worker.MooncakeStoreWorker(
_make_vllm_config(
rank=tp_rank,
extra_config={"store_tp_size": 4},
),
_make_kv_cache_config(),
)
assert w.store_tp_size is None
assert w.token_dbs[0].metadata.tp_rank == 0
key = w.token_dbs[0].key_for(block_hash)
if tp_size == 4:
put_step = w._group_tp_replication_factors[0]
if tp_rank % put_step == 0:
target.add(key)
else:
target.add(key)
assert (
put_keys
== get_keys
== {
(
"test-model@store_pp:1@store_format:tp_shared_mqa"
"@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@68617368"
)
}
)
def test_requester_worker_group_semantics_falls_back_without_group_ids(
tmp_path,
monkeypatch,
@@ -2396,9 +2929,11 @@ def test_store_sending_thread_only_stores_swa_blocks_in_window():
store = MagicMock()
store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
store.batch_put_from_multi_buffers.side_effect = (
lambda keys, addrs, sizes, *_args: ([256] * len(keys))
)
def successful_put(keys, addrs, sizes, *_args):
return [256] * len(keys)
store.batch_put_from_multi_buffers.side_effect = successful_put
full_spec = FullAttentionSpec(
block_size=32, num_kv_heads=8, head_size=64, dtype=None
@@ -2754,6 +3289,7 @@ def _make_bare_worker(
worker.recv_request_queue = queue.Queue()
worker.finished_store_req = set()
worker.tp_size = 1
worker.store_tp_size = None
worker.num_kv_head = 1
worker.pp_size = 1
# Minimal single-full-attention-group config so the coordinator-based
@@ -2812,6 +3348,40 @@ def test_lookup_key_prefixes_cover_dcp_rank_namespaces():
)
def test_lookup_key_prefixes_cover_store_tp_shards():
worker = _make_bare_worker()
worker.store_tp_size = 4
metadata = KeyMetadata(
"test-model",
0,
0,
0,
0,
group_id=0,
store_namespace=_TP_SHARED_NAMESPACE,
)
worker.token_dbs = [
ChunkedTokenDatabase(
metadata,
block_size=16,
store_layout=LBHNCStoreLayout(
metadata,
block_size=16,
hash_block_size=16,
local_tp_size=2,
store_tp_size=4,
tp_rank=0,
num_kv_heads=8,
),
)
]
worker._init_lookup_key_prefixes()
assert worker._lookup_key_prefixes[0] == (
*(_tp_shared_prefix(tp_rank) for tp_rank in range(4)),
)
def test_lookup_key_prefixes_cover_pcp_rank_namespaces():
worker = _make_bare_worker()
worker.tp_size = 4
@@ -11,16 +11,17 @@ and consumer instances read/write KV to/from the store independently,
enabling prefix caching via hash-based deduplication.
"""
from collections.abc import Iterable
from collections import Counter
from collections.abc import Iterable, Sequence
from typing import Any
import torch
from vllm.config import VllmConfig
from vllm.distributed.kv_events import (
BlockStored,
KVCacheEvent,
KVConnectorKVEvents,
KVEventAggregator,
)
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
KVConnectorBase_V1,
@@ -56,31 +57,66 @@ logger = init_logger(__name__)
class MooncakeStoreKVEvents(KVConnectorKVEvents):
"""KV event aggregation for MooncakeStoreConnector."""
def __init__(self, num_workers: int) -> None:
self._aggregator = KVEventAggregator(num_workers)
def __init__(
self,
num_workers: int,
group_tp_replication_factors: Sequence[int] = (1,),
) -> None:
if num_workers <= 0:
raise ValueError("num_workers must be greater than zero.")
if any(factor <= 0 for factor in group_tp_replication_factors):
raise ValueError("TP replication factors must be greater than zero.")
self._event_counter: Counter[KVCacheEvent] = Counter()
self._num_workers = num_workers
self._group_tp_replication_factors = tuple(group_tp_replication_factors)
def add_events(self, events: list[KVCacheEvent]) -> None:
self._aggregator.add_events(events)
if not isinstance(events, list):
raise TypeError("events must be a list of KVCacheEvent.")
self._event_counter.update(events)
def _replication_factor(self, event: KVCacheEvent) -> int:
if not isinstance(event, BlockStored) or event.group_idx is None:
return 1
return self._group_tp_replication_factors[event.group_idx]
def _is_common_event(self, event: KVCacheEvent, count: int) -> bool:
return count * self._replication_factor(event) >= self._num_workers
def aggregate(self) -> "MooncakeStoreKVEvents":
common_events = self._aggregator.get_common_events()
self._aggregator.clear_events()
self._aggregator.add_events(common_events)
self._aggregator.reset_workers()
common_events = self.pop_common_events()
self._event_counter.clear()
self._event_counter.update(common_events)
self._num_workers = 1
return self
def pop_common_events(self) -> list[KVCacheEvent]:
common_events = [
event
for event, count in self._event_counter.items()
if self._is_common_event(event, count)
]
for event in common_events:
del self._event_counter[event]
return common_events
def has_events(self) -> bool:
return bool(self._event_counter)
def increment_workers(self, count: int = 1) -> None:
self._aggregator.increment_workers(count)
if count <= 0:
raise ValueError("count must be positive.")
self._num_workers += count
def get_all_events(self) -> list[KVCacheEvent]:
return self._aggregator.get_all_events()
return list(self._event_counter.elements())
def get_number_of_workers(self) -> int:
return self._aggregator.get_number_of_workers()
return self._num_workers
def clear_events(self) -> None:
self._aggregator.clear_events()
self._aggregator.reset_workers()
self._event_counter.clear()
self._num_workers = 1
def __repr__(self) -> str:
return f"<MooncakeStoreKVEvents events={self.get_all_events()}>"
@@ -260,16 +296,13 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
self._kv_cache_events = kv_cache_events
else:
self._kv_cache_events.add_events(kv_cache_events.get_all_events())
self._kv_cache_events.increment_workers(
kv_cache_events.get_number_of_workers()
)
def take_events(self) -> Iterable[KVCacheEvent]:
if self._kv_cache_events is not None:
self._kv_cache_events.aggregate()
yield from self._kv_cache_events.get_all_events()
self._kv_cache_events.clear_events()
self._kv_cache_events = None
events = self._kv_cache_events.pop_common_events()
if not self._kv_cache_events.has_events():
self._kv_cache_events = None
yield from events
# ============================================================
# Worker-side methods
@@ -321,11 +354,19 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
self,
) -> MooncakeStoreKVEvents | None:
assert self.connector_worker is not None
events = self.connector_worker.get_kv_events()
if not events:
if (
not self.connector_worker.enable_kv_events
or self.connector_worker.kv_send_thread is None
):
return None
kv_events = MooncakeStoreKVEvents(num_workers=1)
events = self.connector_worker.get_kv_events()
# Empty containers still count this worker toward the poll's quorum.
kv_events = MooncakeStoreKVEvents(
num_workers=1,
group_tp_replication_factors=(
self.connector_worker.group_tp_replication_factors
),
)
kv_events.add_events(events)
return kv_events
@@ -18,6 +18,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import (
)
from vllm.logger import init_logger
from vllm.utils.math_utils import cdiv
from vllm.utils.torch_utils import is_non_overlapping_and_dense
from vllm.v1.core.kv_cache_utils import (
BlockHash,
BlockHashListWithBlockSize,
@@ -111,6 +112,9 @@ class KeyMetadata:
# share one Mooncake master without colliding on identical block hashes.
# Empty (the default) keeps keys byte-identical to the unprefixed format.
cache_prefix: str = ""
# Complete namespace for an opt-in Store payload format. Empty keeps
# historical keys byte-identical.
store_namespace: str = ""
@dataclass(order=True)
@@ -125,6 +129,7 @@ class PoolKey:
(
self.key_metadata.cache_prefix,
self.key_metadata.model_name,
self.key_metadata.store_namespace,
self.key_metadata.tp_rank,
self.key_metadata.pcp_rank,
self.key_metadata.dcp_rank,
@@ -148,6 +153,7 @@ class PoolKey:
return (
f"{prefix}"
f"{key_metadata.model_name}"
f"{key_metadata.store_namespace}"
f"@tp_rank:{key_metadata.tp_rank if tp_rank is None else tp_rank}"
f"@pcp{key_metadata.pcp_rank if pcp_rank is None else pcp_rank}"
f"@dcp{key_metadata.dcp_rank if dcp_rank is None else dcp_rank}"
@@ -165,61 +171,149 @@ class PoolKey:
)
class ChunkedTokenDatabase:
"""Maps token positions to store keys and GPU memory addresses."""
StoreShardId = int
# Physical ranks encoded in a rank-local key: (TP, PCP, DCP, PP).
RankNamespace = tuple[int, int, int, int]
class StoreLayout:
"""Store payload layout for one KV-cache group on one local TP rank."""
def __init__(
self,
metadata: KeyMetadata,
block_size: int,
hash_block_size: int | None = None,
):
hash_block_size: int,
) -> None:
self.metadata = metadata
self.block_size = block_size
self.hash_block_size = hash_block_size or block_size
if self.block_size % self.hash_block_size != 0:
raise ValueError(
f"block_size ({self.block_size}) must be a multiple of "
f"hash_block_size ({self.hash_block_size})"
)
self.kv_caches_base_addr: list[int] = []
self.block_len: list[int] = []
self._key_prefix = PoolKey.build_prefix(metadata)
self.hash_block_size = hash_block_size
def key_for(self, chunk_hash: BlockHash) -> str:
return PoolKey.build_key_string(self._key_prefix, chunk_hash.hex())
@property
def local_shard_ids(self) -> tuple[StoreShardId, ...]:
"""Store objects contributed or consumed by this local rank."""
raise NotImplementedError
def set_kv_caches_base_addr(self, kv_caches_base_addr: list[int]):
self.kv_caches_base_addr = kv_caches_base_addr
def key_for(self, shard_id: StoreShardId, chunk_hash: BlockHash) -> str:
raise NotImplementedError
def set_block_len(self, block_len: list[int]):
self.block_len = block_len
def lookup_key_prefixes(
self,
rank_namespaces: Sequence[RankNamespace],
) -> tuple[str, ...]:
"""Prefixes that must exist for a logical block to be reusable."""
raise NotImplementedError
def prepare_value(
self, start: int, end: int, block_ids: list[int]
) -> tuple[list[int], list[int], int]:
"""Compute memory addresses and sizes for a single token range.
Returns:
(addr_list, size_list, block_id)
"""
addr_lists, size_lists, chunk_block_ids = self.prepare_values(
((start, end),), block_ids
)
return addr_lists[0], size_lists[0], chunk_block_ids[0]
def register_kv_caches(
self,
kv_caches: Sequence[torch.Tensor],
num_blocks: int,
) -> None:
"""Build descriptors from the local KV cache tensors."""
raise NotImplementedError
def prepare_values(
self,
chunks: Sequence[tuple[int, int]],
block_ids: list[int],
shard_ids: Sequence[StoreShardId],
) -> tuple[list[list[int]], list[list[int]], list[int]]:
"""Compute memory addresses and sizes for multiple token ranges.
"""Build Store multi-buffer descriptors for logical chunks."""
raise NotImplementedError
Returns:
(addr_lists, size_lists, chunk_block_ids), one entry per chunk.
"""
class RankLocalStoreLayout(StoreLayout):
"""Historical rank-local Store payload layout."""
def __init__(
self,
metadata: KeyMetadata,
block_size: int,
hash_block_size: int,
) -> None:
super().__init__(metadata, block_size, hash_block_size)
self._key_prefix = PoolKey.build_prefix(metadata)
self.kv_caches_base_addr: list[int] = []
self.block_len: list[int] = []
@property
def local_shard_ids(self) -> tuple[StoreShardId, ...]:
return (0,)
def key_for(self, shard_id: StoreShardId, chunk_hash: BlockHash) -> str:
assert shard_id == 0
return PoolKey.build_key_string(self._key_prefix, chunk_hash.hex())
def lookup_key_prefixes(
self,
rank_namespaces: Sequence[RankNamespace],
) -> tuple[str, ...]:
return tuple(
PoolKey.build_prefix(
self.metadata,
tp_rank=tp_rank,
pcp_rank=pcp_rank,
dcp_rank=dcp_rank,
pp_rank=pp_rank,
)
for tp_rank, pcp_rank, dcp_rank, pp_rank in rank_namespaces
)
def set_kv_caches_base_addr(self, base_addrs: list[int]) -> None:
self.kv_caches_base_addr = base_addrs
def set_block_len(self, block_lens: list[int]) -> None:
self.block_len = block_lens
def register_kv_caches(
self,
kv_caches: Sequence[torch.Tensor],
num_blocks: int,
) -> None:
seen_region_ptrs: set[int] = set()
base_addrs: list[int] = []
block_lens: list[int] = []
for cache in kv_caches:
storage = cache.untyped_storage()
storage_addr = storage.data_ptr()
storage_len = storage.nbytes()
if not is_non_overlapping_and_dense(cache[0]):
for head_idx in range(cache.shape[1]):
head_cache = cache[:, head_idx]
assert is_non_overlapping_and_dense(head_cache[0])
region_addr = head_cache.data_ptr()
if region_addr in seen_region_ptrs:
continue
seen_region_ptrs.add(region_addr)
base_addrs.append(region_addr)
block_lens.append(head_cache.stride(0) * head_cache.element_size())
elif cache.stride(0) * cache.element_size() * num_blocks == storage_len:
if storage_addr in seen_region_ptrs:
continue
seen_region_ptrs.add(storage_addr)
base_addrs.append(storage_addr)
block_lens.append(storage_len // num_blocks)
else:
region_addr = cache.data_ptr()
if region_addr in seen_region_ptrs:
continue
seen_region_ptrs.add(region_addr)
base_addrs.append(region_addr)
block_lens.append(cache.stride(0) * cache.element_size())
self.kv_caches_base_addr = base_addrs
self.block_len = block_lens
def prepare_values(
self,
chunks: Sequence[tuple[int, int]],
block_ids: list[int],
shard_ids: Sequence[StoreShardId],
) -> tuple[list[list[int]], list[list[int]], list[int]]:
if not chunks:
return [], [], []
assert len(chunks) == len(shard_ids) and all(
shard_id == 0 for shard_id in shard_ids
)
base = np.asarray(self.kv_caches_base_addr, dtype=np.int64)
length = len(self.block_len)
blen = np.asarray(
@@ -229,7 +323,7 @@ class ChunkedTokenDatabase:
n = len(chunks)
starts = np.fromiter((c[0] for c in chunks), dtype=np.int64, count=n)
spans = np.fromiter((c[1] for c in chunks), dtype=np.int64, count=n) - starts
assert not (spans % self.hash_block_size).any()
assert np.all(spans % self.hash_block_size == 0)
bids = np.fromiter(
(block_ids[i] for i in (starts // self.block_size).tolist()),
dtype=np.int64,
@@ -241,15 +335,272 @@ class ChunkedTokenDatabase:
return addrs.tolist(), sizes.tolist(), bids.tolist()
def prepare_value_for_block(self, block_id: int) -> tuple[list[int], list[int]]:
"""Return addresses and sizes for one physical block slot."""
addr_list = []
size_list = []
length = len(self.block_len)
for index, base_addr in enumerate(self.kv_caches_base_addr):
addr = base_addr + block_id * self.block_len[index % length]
addr_list.append(addr)
size_list.append(self.block_len[index % length])
return addr_list, size_list
return (
[
base_addr + block_id * self.block_len[index % length]
for index, base_addr in enumerate(self.kv_caches_base_addr)
],
[
self.block_len[index % length]
for index in range(len(self.kv_caches_base_addr))
],
)
class TPShardedStoreLayout(StoreLayout):
"""Store layout shared by divisible TP sizes."""
store_format: str
@classmethod
def shared_namespace(cls, store_tp_size: int, pp_size: int) -> str:
return (
f"@store_tp:{store_tp_size}@store_pp:{pp_size}"
f"@store_format:{cls.store_format}"
)
def __init__(
self,
metadata: KeyMetadata,
block_size: int,
hash_block_size: int,
local_tp_size: int,
store_tp_size: int,
tp_rank: int,
num_kv_heads: int,
) -> None:
super().__init__(metadata, block_size, hash_block_size)
self.shards_per_rank = store_tp_size // local_tp_size
first_shard = tp_rank * self.shards_per_rank
self.store_shard_ids = tuple(
range(first_shard, first_shard + self.shards_per_rank)
)
self.store_tp_size = store_tp_size
self.heads_per_store_shard = num_kv_heads // store_tp_size
self.local_num_kv_heads = num_kv_heads // local_tp_size
self._shard_key_prefixes = {
shard_id: PoolKey.build_prefix(metadata, tp_rank=shard_id)
for shard_id in self.store_shard_ids
}
self._shard_addr_bases = np.empty((self.shards_per_rank, 0), dtype=np.uint64)
self._shard_block_strides = np.empty((self.shards_per_rank, 0), dtype=np.uint64)
self._shard_sizes = np.empty((self.shards_per_rank, 0), dtype=np.uint64)
@property
def local_shard_ids(self) -> tuple[StoreShardId, ...]:
return self.store_shard_ids
def key_for(self, shard_id: StoreShardId, chunk_hash: BlockHash) -> str:
return PoolKey.build_key_string(
self._shard_key_prefixes[shard_id], chunk_hash.hex()
)
def lookup_key_prefixes(
self,
rank_namespaces: Sequence[RankNamespace],
) -> tuple[str, ...]:
pp_ranks = sorted({namespace[3] for namespace in rank_namespaces})
return tuple(
PoolKey.build_prefix(self.metadata, tp_rank=shard_id, pp_rank=pp_rank)
for shard_id in range(self.store_tp_size)
for pp_rank in pp_ranks
)
def _set_segment_templates(
self, templates: Sequence[tuple[list[int], list[int], list[int]]]
) -> None:
self._shard_addr_bases = np.asarray(
[template[0] for template in templates], dtype=np.uint64
)
self._shard_block_strides = np.asarray(
[template[1] for template in templates], dtype=np.uint64
)
self._shard_sizes = np.asarray(
[template[2] for template in templates], dtype=np.uint64
)
def _has_packed_strides(
self, head_stride: int, token_stride: int, content_bytes: int
) -> bool:
raise NotImplementedError
def _shard_segments(
self, head_start: int, head_stride: int, token_stride: int, content_bytes: int
) -> Iterable[tuple[int, int]]:
raise NotImplementedError
def register_kv_caches(
self,
kv_caches: Sequence[torch.Tensor],
num_blocks: int,
) -> None:
templates: list[tuple[list[int], list[int], list[int]]] = [
([], [], []) for _ in range(self.shards_per_rank)
]
for cache in kv_caches:
if cache.ndim != 4 or tuple(cache.shape[:3]) != (
num_blocks,
self.local_num_kv_heads,
self.block_size,
):
raise ValueError(
"TP-shared Mooncake store requires packed KV caches with "
"logical shape (num_blocks, local_kv_heads, block_size, content)"
)
element_size = cache.element_size()
block_stride, head_stride, token_stride, content_stride = (
stride * element_size for stride in cache.stride()
)
content_bytes = cache.shape[3] * element_size
if content_stride != element_size or not self._has_packed_strides(
head_stride, token_stride, content_bytes
):
raise ValueError(
"TP-shared Mooncake store requires packed "
f"{self.store_format} KV layout"
)
for local_shard, (addr_bases, block_strides, sizes) in enumerate(templates):
head_start = local_shard * self.heads_per_store_shard
for offset, size in self._shard_segments(
head_start, head_stride, token_stride, content_bytes
):
addr_bases.append(cache.data_ptr() + offset)
block_strides.append(block_stride)
sizes.append(size)
self._set_segment_templates(templates)
def prepare_values(
self,
chunks: Sequence[tuple[int, int]],
block_ids: list[int],
shard_ids: Sequence[StoreShardId],
) -> tuple[list[list[int]], list[list[int]], list[int]]:
if not chunks:
return [], [], []
if len(chunks) != len(shard_ids):
raise ValueError("Each Store chunk must have one shard ID")
count = len(chunks)
starts = np.fromiter(
(chunk[0] for chunk in chunks), dtype=np.int64, count=count
)
ends = np.fromiter((chunk[1] for chunk in chunks), dtype=np.int64, count=count)
if np.any(ends - starts != self.block_size):
raise ValueError("TP-shared Mooncake store requires full KV blocks")
first_shard = self.store_shard_ids[0]
local_shards = np.fromiter(
(shard_id - first_shard for shard_id in shard_ids),
dtype=np.int64,
count=count,
)
if np.any(local_shards < 0) or np.any(local_shards >= self.shards_per_rank):
raise ValueError("Store shard is not owned by this TP rank")
chunk_block_ids = np.fromiter(
(block_ids[index] for index in (starts // self.block_size).tolist()),
dtype=np.uint64,
count=count,
)
addr_bases = self._shard_addr_bases[local_shards]
block_strides = self._shard_block_strides[local_shards]
addrs = addr_bases + chunk_block_ids[:, None] * block_strides
sizes = self._shard_sizes[local_shards]
return addrs.tolist(), sizes.tolist(), chunk_block_ids.tolist()
class LBHNCStoreLayout(TPShardedStoreLayout):
"""Native head-major layout shared by divisible TP sizes."""
store_format = "tp_shared_lbhnc"
def _has_packed_strides(
self, head_stride: int, token_stride: int, content_bytes: int
) -> bool:
return (
token_stride == content_bytes
and head_stride == self.block_size * content_bytes
)
def _shard_segments(
self, head_start: int, head_stride: int, token_stride: int, content_bytes: int
) -> Iterable[tuple[int, int]]:
yield head_start * head_stride, self.heads_per_store_shard * head_stride
class LBNHCStoreLayout(TPShardedStoreLayout):
"""Native token-major layout shared by divisible TP sizes."""
store_format = "tp_shared_lbnhc"
def _has_packed_strides(
self, head_stride: int, token_stride: int, content_bytes: int
) -> bool:
return (
head_stride == content_bytes
and token_stride == self.local_num_kv_heads * content_bytes
)
def _shard_segments(
self, head_start: int, head_stride: int, token_stride: int, content_bytes: int
) -> Iterable[tuple[int, int]]:
for token_idx in range(self.block_size):
yield (
token_idx * token_stride + head_start * head_stride,
self.heads_per_store_shard * content_bytes,
)
class ChunkedTokenDatabase:
"""Enumerates logical token chunks and their hashes."""
def __init__(
self,
metadata: KeyMetadata,
block_size: int,
hash_block_size: int | None = None,
store_layout: StoreLayout | None = None,
):
self.metadata = metadata
self.block_size = block_size
self.hash_block_size = hash_block_size or block_size
if self.block_size % self.hash_block_size != 0:
raise ValueError(
f"block_size ({self.block_size}) must be a multiple of "
f"hash_block_size ({self.hash_block_size})"
)
self.store_layout = store_layout or RankLocalStoreLayout(
metadata, block_size, self.hash_block_size
)
@property
def kv_caches_base_addr(self) -> list[int]:
return self._rank_local_layout().kv_caches_base_addr
@property
def block_len(self) -> list[int]:
return self._rank_local_layout().block_len
def _rank_local_layout(self) -> RankLocalStoreLayout:
if not isinstance(self.store_layout, RankLocalStoreLayout):
raise RuntimeError("This operation requires a rank-local Store layout")
return self.store_layout
def key_for(self, chunk_hash: BlockHash) -> str:
return self._rank_local_layout().key_for(0, chunk_hash)
def set_kv_caches_base_addr(self, kv_caches_base_addr: list[int]):
self._rank_local_layout().set_kv_caches_base_addr(kv_caches_base_addr)
def set_block_len(self, block_len: list[int]):
self._rank_local_layout().set_block_len(block_len)
def prepare_value_for_block(self, block_id: int) -> tuple[list[int], list[int]]:
"""Return addresses and sizes for one physical block slot."""
return self._rank_local_layout().prepare_value_for_block(block_id)
def process_tokens(
self,
@@ -12,6 +12,7 @@ and MooncakeDistributedStore integration.
import dataclasses
import json
import math
import os
import queue
import socket
@@ -44,12 +45,16 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
BlobBlockHashes,
ChunkedTokenDatabase,
KeyMetadata,
LBHNCStoreLayout,
LBNHCStoreLayout,
MooncakeLookupResult,
MooncakeStoreConnectorMetadata,
MooncakeStoreWorkerMetadata,
PoolKey,
ReqMeta,
StoreShardId,
TailKeyBoundary,
TPShardedStoreLayout,
)
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.protocol import ( # noqa: E501
LOOKUP_MSG,
@@ -62,7 +67,6 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.protocol import
from vllm.logger import init_logger
from vllm.utils.math_utils import cdiv
from vllm.utils.network_utils import get_ip, make_zmq_socket
from vllm.utils.torch_utils import is_non_overlapping_and_dense
from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
from vllm.v1.core.kv_cache_utils import (
BlockHash,
@@ -71,6 +75,7 @@ from vllm.v1.core.kv_cache_utils import (
resolve_kv_cache_block_sizes,
)
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheConfig,
KVCacheSpec,
MambaSpec,
@@ -79,6 +84,7 @@ from vllm.v1.kv_cache_interface import (
UniformTypeKVCacheSpecs,
group_kernel_blocks,
)
from vllm.v1.kv_cache_layout import KVCacheLayout
from .metrics import MooncakeStoreConnectorStats
@@ -92,6 +98,22 @@ MOONCAKE_NO_AVAILABLE_HANDLE = -200
_T = TypeVar("_T")
def resolve_store_tp_size(extra_config: dict[str, Any]) -> int | None:
"""Resolve the common Store TP requested by connector config."""
if extra_config.get("enable_store_tp_lcm") is True:
prefill_tp_sizes = extra_config.get("prefill_tp_sizes")
if not isinstance(prefill_tp_sizes, list) or not prefill_tp_sizes:
return None
if any(
type(tp_size) is not int or tp_size <= 0 for tp_size in prefill_tp_sizes
):
return None
return math.lcm(*prefill_tp_sizes)
store_tp_size = extra_config.get("store_tp_size")
return store_tp_size if type(store_tp_size) is int and store_tp_size > 0 else None
def _rotate_list(values: list[_T], offset: int) -> list[_T]:
return values[offset:] + values[:offset]
@@ -109,7 +131,10 @@ def _make_mooncake_group_id(metadata: KeyMetadata, chunk_hash: str) -> str:
# Mooncake group ids describe the lifecycle unit. For vLLM, that unit is
# a prefix chunk, so shard dimensions stay only in the object key.
prefix = f"{metadata.cache_prefix}@" if metadata.cache_prefix else ""
return f"vllm-mooncake-store:{prefix}{metadata.model_name}@{chunk_hash}"
return (
f"vllm-mooncake-store:{prefix}{metadata.model_name}"
f"{metadata.store_namespace}@{chunk_hash}"
)
# Mirrors FileStorageConfig::local_buffer_size in Mooncake C++.
@@ -952,8 +977,11 @@ class KVCacheStoreSendingThread(KVTransferThread):
starts: list[int] = []
ends: list[int] = []
keys: list[str] = []
kv_event_block_hashes: list[BlockHash] = []
event_specs: list[tuple[int, int, int, BlockHash]] | None = (
[] if self.enable_kv_event else None
)
group_indices: list[int] = []
store_shard_ids: list[StoreShardId] = []
for g_idx, db in enumerate(self.token_databases):
# Rotate the stride phase per group to balance load across ranks.
put_step = self.group_put_steps[g_idx]
@@ -980,12 +1008,14 @@ class KVCacheStoreSendingThread(KVTransferThread):
block_idx,
)
continue
starts.append(start)
ends.append(end)
keys.append(db.key_for(block_hash))
if self.enable_kv_event:
kv_event_block_hashes.append(block_hash)
group_indices.append(g_idx)
for store_shard_id in db.store_layout.local_shard_ids:
starts.append(start)
ends.append(end)
keys.append(db.store_layout.key_for(store_shard_id, block_hash))
group_indices.append(g_idx)
store_shard_ids.append(store_shard_id)
if event_specs is not None:
event_specs.append((start, end, g_idx, block_hash))
if not keys:
self._record_saved(req_meta, token_len)
@@ -1023,11 +1053,10 @@ class KVCacheStoreSendingThread(KVTransferThread):
starts = [starts[i] for i in missing_indices]
ends = [ends[i] for i in missing_indices]
keys = [keys[i] for i in missing_indices]
if self.enable_kv_event:
kv_event_block_hashes = [
kv_event_block_hashes[i] for i in missing_indices
]
if event_specs is not None:
event_specs = [event_specs[i] for i in missing_indices]
group_indices = [group_indices[i] for i in missing_indices]
store_shard_ids = [store_shard_ids[i] for i in missing_indices]
group_ids = (
[
@@ -1050,61 +1079,33 @@ class KVCacheStoreSendingThread(KVTransferThread):
addrs: list[list[int]] = []
sizes: list[list[int]] = []
stored_events: list[BlockStored] = []
chunks_per_group: list[list[tuple[int, int]]] = [
[] for _ in self.token_databases
]
for start, end, g_idx in zip(starts, ends, group_indices, strict=True):
shards_per_group: list[list[StoreShardId]] = [
[] for _ in self.token_databases
]
for start, end, g_idx, store_shard_id in zip(
starts,
ends,
group_indices,
store_shard_ids,
strict=True,
):
chunks_per_group[g_idx].append((start, end))
shards_per_group[g_idx].append(store_shard_id)
for g_idx, chunks in enumerate(chunks_per_group):
if not chunks:
continue
db = self.token_databases[g_idx]
group_addrs, group_sizes, _ = db.prepare_values(
chunks, block_ids_per_group[g_idx]
group_addrs, group_sizes, _ = db.store_layout.prepare_values(
chunks,
block_ids_per_group[g_idx],
shards_per_group[g_idx],
)
addrs.extend(group_addrs)
sizes.extend(group_sizes)
if self.enable_kv_event:
new_block_hashes = [
maybe_convert_block_hash(bh) for bh in kv_event_block_hashes
]
token_ids_end = token_ids_start + len(event_token_ids or ())
for idx, (s, e, g_idx) in enumerate(
zip(starts, ends, group_indices, strict=True)
):
db = self.token_databases[g_idx]
if self.enable_kv_event:
token_ids = (
event_token_ids[s - token_ids_start : e - token_ids_start]
if event_token_ids is not None
and token_ids_start <= s
and e <= token_ids_end
else []
)
stored_event = BlockStored(
block_hashes=[new_block_hashes[idx]],
# Derive the direct predecessor from the unfiltered
# request chain. Adjacent PUTs need not be adjacent in
# that chain after Store dedup, masks, or TP striding.
parent_block_hash=(
maybe_convert_block_hash(
req_meta.block_hashes[s // db.hash_block_size - 1]
)
if s > 0
else None
),
token_ids=token_ids,
block_size=db.block_size,
lora_id=None,
medium="cpu",
lora_name=None,
group_idx=g_idx,
)
stored_events.append(stored_event)
if current_event is not None:
current_event.synchronize()
@@ -1112,6 +1113,8 @@ class KVCacheStoreSendingThread(KVTransferThread):
assert len(group_ids) == len(keys)
self.replicate_config.group_ids = group_ids
failed_indices: set[int] = set()
put_had_exception = False
batch_bytes = _sum_batch_bytes(sizes)
put_start = time.perf_counter()
try:
@@ -1121,54 +1124,6 @@ class KVCacheStoreSendingThread(KVTransferThread):
sizes,
self.replicate_config,
)
failed = [i for i, v in enumerate(res) if v < 0]
self._record_operation(
"save_put",
put_start,
len(keys),
num_bytes=batch_bytes,
status="partial_failure" if failed else "ok",
num_failed_keys=len(failed),
)
if failed:
failed_codes = set(res[i] for i in failed)
if self.enable_kv_event:
failed_indices = set(failed)
stored_events = [
event
for i, event in enumerate(stored_events)
if i not in failed_indices
]
logger.warning(
"batch_put failed: %d/%d keys failed "
"(codes=%s, batch_bytes=%d, num_keys=%d), "
"first_key=%s",
len(failed),
len(keys),
failed_codes,
batch_bytes,
len(keys),
keys[0] if keys else "N/A",
)
if (
MOONCAKE_NO_AVAILABLE_HANDLE in failed_codes
and not self._mark_request_skipped_for_pressure(req_meta)
):
logger.warning(
"Detected Mooncake CPU/disk offloading pressure "
"(NO_AVAILABLE_HANDLE); skipping future store "
"batches for request %s until a later store "
"batch succeeds",
req_id,
)
else:
self._record_saved(req_meta, token_len)
save_completed = True
if self._clear_store_pressure():
logger.info(
"Mooncake CPU/disk offloading pressure cleared "
"after a successful store batch"
)
except Exception as e:
self._record_operation(
"save_put",
@@ -1179,7 +1134,96 @@ class KVCacheStoreSendingThread(KVTransferThread):
num_failed_keys=len(keys),
)
logger.error("Failed to put key %s, error: %s", keys, e)
stored_events.clear()
put_had_exception = True
else:
failed_indices = {i for i, value in enumerate(res) if value < 0}
self._record_operation(
"save_put",
put_start,
len(keys),
num_bytes=batch_bytes,
status="partial_failure" if failed_indices else "ok",
num_failed_keys=len(failed_indices),
)
failed_codes = {res[i] for i in failed_indices}
if failed_indices:
logger.warning(
"batch_put failed: %d/%d keys failed "
"(codes=%s, batch_bytes=%d), first_key=%s",
len(failed_indices),
len(keys),
failed_codes,
batch_bytes,
keys[0],
)
if (
MOONCAKE_NO_AVAILABLE_HANDLE in failed_codes
and not self._mark_request_skipped_for_pressure(req_meta)
):
logger.warning(
"Detected Mooncake CPU/disk offloading pressure "
"(NO_AVAILABLE_HANDLE); skipping future store "
"batches for request %s until a later store batch succeeds",
req_id,
)
if not put_had_exception and not failed_indices:
self._record_saved(req_meta, token_len)
save_completed = True
if self._clear_store_pressure():
logger.info(
"Mooncake CPU/disk offloading pressure cleared "
"after a successful store batch"
)
stored_events: list[BlockStored] = []
if self.enable_kv_event and not put_had_exception:
assert event_specs is not None
# BlockStored is a logical-block event, while one block may map
# to several Store shards. Emit once only after every missing
# shard for that block succeeded. Shards that already existed
# were removed before this mapping and are already satisfied.
indices_by_event: dict[tuple[int, int, int, BlockHash], list[int]] = {}
for index, event_spec in enumerate(event_specs):
indices_by_event.setdefault(event_spec, []).append(index)
token_ids_end = token_ids_start + len(event_token_ids or ())
for (
s,
end,
g_idx,
block_hash,
), event_indices in indices_by_event.items():
if any(index in failed_indices for index in event_indices):
continue
db = self.token_databases[g_idx]
token_ids = (
event_token_ids[s - token_ids_start : end - token_ids_start]
if event_token_ids is not None
and token_ids_start <= s
and end <= token_ids_end
else []
)
stored_events.append(
BlockStored(
block_hashes=[maybe_convert_block_hash(block_hash)],
# Store filtering can separate adjacent request
# blocks, so derive the predecessor from the request.
parent_block_hash=(
maybe_convert_block_hash(
req_meta.block_hashes[s // db.hash_block_size - 1]
)
if s > 0
else None
),
token_ids=token_ids,
block_size=db.block_size,
lora_id=None,
medium="cpu",
lora_name=None,
group_idx=g_idx,
)
)
if self.enable_kv_event and stored_events:
self.update_kv_event(stored_events)
@@ -1269,6 +1313,7 @@ class KVCacheStoreRecvingThread(KVTransferThread):
for g_idx, db in enumerate(self.token_databases):
mask = load_mask_per_group[g_idx]
chunks: list[tuple[int, int]] = []
store_shard_ids: list[StoreShardId] = []
for start, end, block_hash in db.process_tokens(
token_len, req_meta.block_hashes, mask_num
):
@@ -1282,10 +1327,14 @@ class KVCacheStoreRecvingThread(KVTransferThread):
block_hash = req_meta.block_hashes[
boundary_tokens // db.hash_block_size - 1
]
key_list.append(db.key_for(block_hash))
chunks.append((start, end))
g_addrs, g_sizes, g_block_ids = db.prepare_values(
chunks, req_meta.block_ids[g_idx]
for store_shard_id in db.store_layout.local_shard_ids:
key_list.append(db.store_layout.key_for(store_shard_id, block_hash))
chunks.append((start, end))
store_shard_ids.append(store_shard_id)
g_addrs, g_sizes, g_block_ids = db.store_layout.prepare_values(
chunks,
req_meta.block_ids[g_idx],
store_shard_ids,
)
addr_list.extend(g_addrs)
size_list.extend(g_sizes)
@@ -1298,7 +1347,14 @@ class KVCacheStoreRecvingThread(KVTransferThread):
size_list_c = _rotate_list(size_list, rotation)
block_id_list_c = _rotate_list(block_id_list, rotation)
load_batches = [(key_list_c, addr_list_c, size_list_c, block_id_list_c)]
load_batches = [
(
key_list_c,
addr_list_c,
size_list_c,
block_id_list_c,
)
]
if self.usable_disk_offload_buffer_budget_bytes is not None:
total_staging_bytes = sum(
_estimate_disk_offload_staging_bytes(size) for size in size_list_c
@@ -1340,7 +1396,12 @@ class KVCacheStoreRecvingThread(KVTransferThread):
block_id_offset:next_block_id_offset
]
load_batches.append(
(batch_keys, batch_addrs, batch_sizes, batch_block_ids)
(
batch_keys,
batch_addrs,
batch_sizes,
batch_block_ids,
)
)
block_id_offset = next_block_id_offset
@@ -1348,7 +1409,12 @@ class KVCacheStoreRecvingThread(KVTransferThread):
current_batch_block_ids: list[int] = block_id_list_c
batch_bytes = 0
try:
for batch_keys, batch_addrs, batch_sizes, batch_block_ids in load_batches:
for (
batch_keys,
batch_addrs,
batch_sizes,
batch_block_ids,
) in load_batches:
current_batch_keys = batch_keys
current_batch_block_ids = batch_block_ids
batch_bytes = _sum_batch_bytes(batch_sizes)
@@ -1607,11 +1673,9 @@ class MooncakeStoreWorker:
retention_interval=kv_cache_config.prefix_cache_retention_interval,
dcp_world_size=self.dcp_size,
)
# One ChunkedTokenDatabase per group; addresses populated in
# register_kv_caches once the kv-cache layout is known. Each group's
# key namespace is its TP shard id: ranks holding identical bytes
# (MLA / shared GQA KV heads) share a namespace, TP-sharded Mamba
# state gets one namespace per rank.
self.store_tp_size, store_namespace, store_layout_cls = (
self._select_store_layout(extra_config)
)
metadata = KeyMetadata(
model_name=model_config.model.rstrip("/").split("/")[-1],
tp_rank=self.tp_rank,
@@ -1623,24 +1687,140 @@ class MooncakeStoreWorker:
"cache_prefix", ""
)
),
store_namespace=store_namespace,
)
self._group_tp_replication_factors: tuple[int, ...] = (
self._compute_group_tp_replication_factors()
)
self.token_dbs = [
ChunkedTokenDatabase(
dataclasses.replace(
metadata,
group_id=g_idx,
tp_rank=self.tp_rank // self._group_tp_replication_factors[g_idx],
),
g.kv_cache_spec.block_size,
hash_block_size=self.hash_block_size,
)
for g_idx, g in enumerate(self._kv_cache_groups)
]
self.token_dbs = self._build_token_databases(metadata, store_layout_cls)
self._init_lookup_key_prefixes()
def _supports_tp_sharded_store_layout(
self,
layout_cls: type[TPShardedStoreLayout] | None,
extra_config: dict[str, Any],
) -> bool:
if (
layout_cls is None
or self.pcp_size != 1
or self.dcp_size != 1
or len(self._kv_cache_groups) != 1
):
return False
# Subclasses may use different sharding or cache-lifetime semantics.
return (
type(self._kv_cache_groups[0].kv_cache_spec) is FullAttentionSpec
and str(extra_config.get("enable_cross_layers_blocks", "False")).lower()
!= "true"
)
def _select_store_layout(
self, extra_config: dict[str, Any]
) -> tuple[int | None, str, type[TPShardedStoreLayout] | None]:
"""Select the opt-in TP layout and its Store namespace."""
lcm_store_tp_enabled = extra_config.get("enable_store_tp_lcm") is True
store_tp_requested = (
lcm_store_tp_enabled or extra_config.get("store_tp_size") is not None
)
if not store_tp_requested:
return None, "", None
requested_store_tp_size = resolve_store_tp_size(extra_config)
cache_layout = self.cache_config.get_resolved_kv_cache_layout()
layout_cls: type[TPShardedStoreLayout] | None = {
KVCacheLayout.LBHNC: LBHNCStoreLayout,
KVCacheLayout.LBNHC: LBNHCStoreLayout,
}.get(cache_layout)
if (
requested_store_tp_size is not None
and requested_store_tp_size >= self.tp_size
and requested_store_tp_size % self.tp_size == 0
and self._supports_tp_sharded_store_layout(layout_cls, extra_config)
):
assert layout_cls is not None
if self.num_kv_head % requested_store_tp_size == 0:
if layout_cls is LBNHCStoreLayout:
logger.warning_once(
"Mooncake Store TP sharding is using the LBNHC (NHC) "
"KV cache layout, which creates many transfer segments "
"and may significantly reduce PUT/GET performance. Use "
"LBHNC (HNC) when supported by the attention backend."
)
return (
requested_store_tp_size,
layout_cls.shared_namespace(requested_store_tp_size, self.pp_size),
layout_cls,
)
if self.num_kv_head == 1:
logger.info(
"Mooncake heterogeneous-TP store sharing uses the replicated "
"MQA layout for store_tp_size=%d",
requested_store_tp_size,
)
return (
None,
f"@store_pp:{self.pp_size}@store_format:tp_shared_mqa",
None,
)
store_namespace = (
f"@store_pp:{self.pp_size}@store_format:"
f"rank_local_tp{self.tp_size}_layout_{cache_layout.name}"
)
requested_topology = (
extra_config.get("prefill_tp_sizes")
if lcm_store_tp_enabled
else extra_config.get("store_tp_size")
)
logger.warning(
"Mooncake heterogeneous-TP store sharing is disabled for "
"Store TP configuration %r with KV layout %s; using a "
"compatibility-namespaced rank-local store layout",
requested_topology,
cache_layout,
)
return None, store_namespace, None
def _build_token_databases(
self,
metadata: KeyMetadata,
layout_cls: type[TPShardedStoreLayout] | None,
) -> list[ChunkedTokenDatabase]:
"""Construct token databases and their Store layouts."""
token_dbs: list[ChunkedTokenDatabase] = []
for group_idx, group in enumerate(self._kv_cache_groups):
group_tp_rank = self.tp_rank
if layout_cls is None:
group_tp_rank //= self._group_tp_replication_factors[group_idx]
group_metadata = dataclasses.replace(
metadata,
group_id=group_idx,
tp_rank=group_tp_rank,
)
store_layout: TPShardedStoreLayout | None = None
if layout_cls is not None:
assert self.store_tp_size is not None
store_layout = layout_cls(
group_metadata,
group.kv_cache_spec.block_size,
self.hash_block_size,
local_tp_size=self.tp_size,
store_tp_size=self.store_tp_size,
tp_rank=self.tp_rank,
num_kv_heads=self.num_kv_head,
)
token_dbs.append(
ChunkedTokenDatabase(
group_metadata,
group.kv_cache_spec.block_size,
hash_block_size=self.hash_block_size,
store_layout=store_layout,
)
)
return token_dbs
def _spec_tp_replication_factor(self, spec: KVCacheSpec) -> int:
if self.dcp_size > 1:
return 1
@@ -1689,17 +1869,8 @@ class MooncakeStoreWorker:
)
self._lookup_key_prefixes = tuple(
tuple(
PoolKey.build_prefix(
db.metadata,
tp_rank=tp_rank,
pcp_rank=pcp_rank,
dcp_rank=dcp_rank,
pp_rank=pp_rank,
)
for tp_rank, pcp_rank, dcp_rank, pp_rank in rank_namespaces(
self._group_tp_replication_factors[g_idx]
)
db.store_layout.lookup_key_prefixes(
rank_namespaces(self._group_tp_replication_factors[g_idx])
)
for g_idx, db in enumerate(self.token_dbs)
)
@@ -1719,12 +1890,11 @@ class MooncakeStoreWorker:
self.num_blocks = self.cache_config.num_gpu_blocks
seen_storage_ptrs: set[int] = set()
seen_region_ptrs: set[int] = set()
addrs: list[int] = []
block_lens: list[int] = []
cache_tensors: list[torch.Tensor] = []
for cache in kv_caches.values():
cache = group_kernel_blocks(cache, self.num_blocks)
cache_tensors.append(cache)
cache_storage = cache.untyped_storage()
base_addr = cache_storage.data_ptr()
region_len = cache_storage.nbytes()
@@ -1740,48 +1910,15 @@ class MooncakeStoreWorker:
ret,
)
if not is_non_overlapping_and_dense(cache[0]):
# A block is scattered across per-head regions; each region's
# blocks are contiguous.
for head_idx in range(cache.shape[1]):
head_cache = cache[:, head_idx]
assert is_non_overlapping_and_dense(head_cache[0])
region_addr = head_cache.data_ptr()
if region_addr in seen_region_ptrs:
continue
seen_region_ptrs.add(region_addr)
addrs.append(region_addr)
block_lens.append(head_cache.stride(0) * head_cache.element_size())
elif cache.stride(0) * cache.element_size() * self.num_blocks == region_len:
# The block stride spans the whole per-block window
# (block-outermost and packed layouts, and single-layer
# tensors), which may hold other layers' pages at higher
# offsets. Register the storage once as one whole-window
# region; per-layer regions would copy overlapping windows and
# run past the storage for offset layers.
if base_addr in seen_region_ptrs:
continue
seen_region_ptrs.add(base_addr)
addrs.append(base_addr)
block_lens.append(region_len // self.num_blocks)
else:
region_addr = cache.data_ptr()
if region_addr in seen_region_ptrs:
continue
seen_region_ptrs.add(region_addr)
addrs.append(region_addr)
block_lens.append(cache.stride(0) * cache.element_size())
logger.info(
"Registered KV caches: num_groups=%d, num_segments=%d, num_blocks=%d",
"Registered KV caches: num_groups=%d, num_tensors=%d, num_blocks=%d",
len(self.token_dbs),
len(addrs),
len(cache_tensors),
self.num_blocks,
)
for db in self.token_dbs:
db.set_kv_caches_base_addr(addrs)
db.set_block_len(block_lens)
db.store_layout.register_kv_caches(cache_tensors, self.num_blocks)
# Start transfer threads
if self.can_put:
@@ -2128,6 +2265,10 @@ class MooncakeStoreWorker:
return self.kv_send_thread.get_kv_events()
return []
@property
def group_tp_replication_factors(self) -> tuple[int, ...]:
return self._group_tp_replication_factors
def close(self) -> None:
"""Release the MooncakeDistributedStore handle on teardown.