PD disagg with Mooncake Connector: GDN support (Qwen3.5) and MLA support (Deepseek-V4-Flash) (#46807)

This commit is contained in:
Dakai An
2026-06-29 23:29:04 -07:00
committed by GitHub
parent 97b5ce5c39
commit 0feca7ffa8
7 changed files with 735 additions and 103 deletions
@@ -33,14 +33,37 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_utils import
)
from vllm.utils.network_utils import get_open_port
from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
from vllm.v1.kv_cache_interface import KVCacheConfig
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheConfig,
KVCacheGroupSpec,
)
from vllm.v1.request import RequestStatus
from .utils import create_request, create_scheduler, create_vllm_config
def _make_test_kv_cache_config() -> KVCacheConfig:
return KVCacheConfig(num_blocks=0, kv_cache_tensors=[], kv_cache_groups=[])
return KVCacheConfig(
num_blocks=0,
kv_cache_tensors=[],
kv_cache_groups=[
KVCacheGroupSpec(
[
"model.layers.0.self_attn",
"model.layers.1.self_attn",
"model.layers.0.mla_attn",
"model.layers.1.eagle_attn",
],
FullAttentionSpec(
block_size=16,
num_kv_heads=4,
head_size=64,
dtype=torch.float16,
),
)
],
)
class FakeMooncakeWrapper:
@@ -126,6 +149,8 @@ async def test_build_transfer_params_separates_prefill_pp_layers():
worker.is_kv_producer = True
worker.tp_rank = 0
worker.tp_size = 1
worker.kv_cache_config = _make_test_kv_cache_config()
worker._physical_blocks_per_logical_kv_block = 1
worker.transfer_topo = SimpleNamespace(local_replicates_kv_cache=False)
block_len = 256
@@ -206,6 +231,7 @@ async def test_build_transfer_params_separates_prefill_pp_layers():
req_blocks={"d-req-pp": (transfer_id, [[20, 21]])},
kv_caches_base_addr=[region.base_addr for region in remote_regions],
block_lens=[region.block_len for region in remote_regions],
kv_block_lens=[region.kv_block_len for region in remote_regions],
registered_layer_names=[region.layer_name for region in remote_regions],
registered_layer_indices=[region.layer_index for region in remote_regions],
)
@@ -266,6 +292,7 @@ async def test_send_kv_to_decode_aligns_consumer_regions_by_layer_metadata(
kv_half = block_len // 2
prefill_worker.kv_caches_base_addr = [0x1000]
prefill_worker.block_len_per_layer = [block_len]
prefill_worker.kv_block_len_per_layer = [kv_half]
prefill_worker.registered_layer_names = ["model.layers.1.self_attn"]
prefill_worker.registered_layer_indices = [1]
@@ -294,6 +321,7 @@ async def test_send_kv_to_decode_aligns_consumer_regions_by_layer_metadata(
req_blocks={"d-req-layer-align": (transfer_id, [[20]])},
kv_caches_base_addr=[0xA000, 0xB000],
block_lens=[block_len, block_len],
kv_block_lens=[kv_half, kv_half],
registered_layer_names=[
"model.layers.0.self_attn",
"model.layers.1.self_attn",
@@ -804,7 +832,9 @@ async def test_kv_producer(monkeypatch):
prefill_worker = prefill_connector.connector_worker
prefill_worker.kv_caches_base_addr = [0x1000]
block_len = 4096
kv_half = block_len // 2
prefill_worker.block_len_per_layer = [block_len]
prefill_worker.kv_block_len_per_layer = [kv_half]
prefill_worker.registered_layer_names = ["model.layers.0.self_attn"]
prefill_worker.registered_layer_indices = [0]
@@ -832,6 +862,7 @@ async def test_kv_producer(monkeypatch):
req_blocks={"d-req-1": (transfer_id, [[20, 21]])},
kv_caches_base_addr=[0x2000],
block_lens=[block_len],
kv_block_lens=[kv_half],
registered_layer_names=["model.layers.0.self_attn"],
registered_layer_indices=[0],
)
@@ -845,8 +876,6 @@ async def test_kv_producer(monkeypatch):
) as mock_send_blocks:
# With blocks-first layout, each block is virtually split
# into K and V halves, producing non-coalesced transfers.
kv_half = block_len // 2
def expected_split_transfers(src_base, dst_base, src_blocks, dst_blocks):
"""Build expected (src_ptrs, dst_ptrs, lengths) for
virtual-split K/V transfers."""
@@ -981,6 +1010,7 @@ async def test_kv_consumuer(monkeypatch):
decode_worker = decode_connector.connector_worker
decode_worker.kv_caches_base_addr = [0x1000]
decode_worker.block_len_per_layer = [4096]
decode_worker.kv_block_len_per_layer = [4096]
decode_worker.registered_layer_names = ["model.layers.0.self_attn"]
decode_worker.registered_layer_indices = [0]
decode_worker.rpc_port = 54321
@@ -1236,6 +1266,7 @@ async def test_kv_producer_heterogeneous_tp(monkeypatch, d_tp_size):
prefill_worker.kv_caches_base_addr = [0x1000]
prefill_worker.block_len_per_layer = [local_block_len]
prefill_worker.kv_block_len_per_layer = [local_block_len // 2]
prefill_worker.registered_layer_names = ["model.layers.0.self_attn"]
prefill_worker.registered_layer_indices = [0]
@@ -1283,6 +1314,7 @@ async def test_kv_producer_heterogeneous_tp(monkeypatch, d_tp_size):
},
kv_caches_base_addr=[0x2000],
block_lens=[remote_block_len],
kv_block_lens=[remote_block_len // 2],
registered_layer_names=["model.layers.0.self_attn"],
registered_layer_indices=[0],
)
@@ -257,6 +257,7 @@ async def test_build_transfer_params_multi_group_trimming(monkeypatch):
},
kv_caches_base_addr=[0x2000],
block_lens=[block_len],
kv_block_lens=[block_len],
)
local_regions = [
@@ -348,6 +349,7 @@ async def test_build_transfer_params_group_count_mismatch(monkeypatch):
},
kv_caches_base_addr=[0x2000],
block_lens=[block_len],
kv_block_lens=[block_len],
)
local_regions = [
@@ -0,0 +1,386 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for MooncakeConnector hybrid FA + GDN support.
GDN is represented as a MambaSpec in vLLM, so these tests exercise the
Mooncake MambaSpec path with mamba_type=GDN_ATTN. Mamba2 is intentionally not
validated by this test module.
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import torch
from vllm.config import set_current_vllm_config
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_connector import (
KVConnectorRole,
MooncakeConnector,
MooncakeConnectorScheduler,
MooncakeConnectorWorker,
MooncakeXferMetadata,
SendBlockMeta,
TransferRegion,
)
from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheConfig,
KVCacheGroupSpec,
MambaSpec,
)
from .test_mooncake_connector import patch_worker_dependencies
from .utils import create_request, create_vllm_config
def noop_shutdown():
pass
def make_hybrid_gdn_kv_cache_config(block_size: int) -> KVCacheConfig:
gdn_spec = MambaSpec(
block_size=block_size,
shapes=((6, 3), (1, 2, 2)),
dtypes=(torch.float16, torch.float16),
mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
)
assert gdn_spec.mamba_type == MambaAttentionBackendEnum.GDN_ATTN
return KVCacheConfig(
num_blocks=16,
kv_cache_tensors=[],
kv_cache_groups=[
KVCacheGroupSpec(
["model.layers.0.self_attn"],
FullAttentionSpec(
block_size=block_size,
num_kv_heads=1,
head_size=1,
dtype=torch.float16,
),
),
KVCacheGroupSpec(
["model.layers.1.linear_attn"],
gdn_spec,
),
],
)
def make_hybrid_gdn_scheduler(kv_role: str) -> MooncakeConnectorScheduler:
vllm_config = create_vllm_config(
kv_connector="MooncakeConnector",
kv_role=kv_role,
)
vllm_config.scheduler_config.disable_hybrid_kv_cache_manager = False
return MooncakeConnectorScheduler(
vllm_config=vllm_config,
engine_id="test-engine",
kv_cache_config=make_hybrid_gdn_kv_cache_config(
vllm_config.cache_config.block_size
),
)
@pytest.mark.cpu_test
def test_hybrid_gdn_remote_prefill_uses_mamba_n_minus_one():
scheduler = make_hybrid_gdn_scheduler(kv_role="kv_consumer")
request = create_request(num_tokens=10, do_remote_prefill=True)
num_new_tokens, is_async = scheduler.get_num_new_matched_tokens(
request, num_computed_tokens=0
)
assert num_new_tokens == request.num_prompt_tokens - 1
assert is_async is True
@pytest.mark.cpu_test
def test_hybrid_gdn_remote_decode_truncates_prefill_once():
scheduler = make_hybrid_gdn_scheduler(kv_role="kv_producer")
request = create_request(num_tokens=10, do_remote_decode=True)
original_tokens = list(request.prompt_token_ids)
num_new_tokens, is_async = scheduler.get_num_new_matched_tokens(
request, num_computed_tokens=0
)
assert num_new_tokens == 0
assert is_async is False
assert request.prompt_token_ids == original_tokens[:-1]
assert request._all_token_ids == original_tokens[:-1]
assert request.num_prompt_tokens == len(original_tokens) - 1
assert request.max_tokens == 1
assert request.kv_transfer_params["_p_side_truncated"] is True
scheduler.get_num_new_matched_tokens(request, num_computed_tokens=0)
assert request.prompt_token_ids == original_tokens[:-1]
def test_register_kv_caches_emits_fa_and_gdn_regions(monkeypatch):
monkeypatch.setenv("VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT", "5")
vllm_config = create_vllm_config(
kv_connector="MooncakeConnector",
kv_role="kv_consumer",
)
kv_cache_config = make_hybrid_gdn_kv_cache_config(
vllm_config.cache_config.block_size
)
with set_current_vllm_config(vllm_config), patch_worker_dependencies():
connector = MooncakeConnector(
vllm_config,
KVConnectorRole.WORKER,
kv_cache_config,
)
worker = connector.connector_worker
fa_cache = torch.empty((2, 2, 11), dtype=torch.float16)
gdn_conv_state = torch.empty((2, 22), dtype=torch.float16)
gdn_ssm_state = torch.empty((2, 4), dtype=torch.float16)
worker.register_kv_caches(
{
"model.layers.0.self_attn": fa_cache,
"model.layers.1.linear_attn": (gdn_conv_state, gdn_ssm_state),
}
)
assert worker.transfer_topo.is_mamba is True
assert worker.registered_layer_names == [
"model.layers.0.self_attn",
"model.layers.1.linear_attn",
]
assert worker.registered_group_indices == [0, 1]
assert worker.kv_caches_base_addr == [
fa_cache.data_ptr(),
gdn_conv_state.data_ptr(),
]
worker.shutdown()
worker.shutdown = noop_shutdown
connector.connector_worker = None
def test_register_kv_caches_deduplicates_shared_backing_memory(monkeypatch):
monkeypatch.setenv("VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT", "5")
vllm_config = create_vllm_config(
kv_connector="MooncakeConnector",
kv_role="kv_consumer",
)
kv_cache_config = make_hybrid_gdn_kv_cache_config(
vllm_config.cache_config.block_size
)
with set_current_vllm_config(vllm_config), patch_worker_dependencies():
connector = MooncakeConnector(
vllm_config,
KVConnectorRole.WORKER,
kv_cache_config,
)
worker = connector.connector_worker
backing = torch.empty((4, 64), dtype=torch.float16)
fa_cache = backing[:2, :16]
gdn_conv_state = backing[:3]
gdn_ssm_state = torch.empty((3, 4), dtype=torch.float16)
with patch.object(
worker.engine, "batch_register_memory", return_value=0
) as batch_register_memory:
worker.register_kv_caches(
{
"model.layers.0.self_attn": fa_cache,
"model.layers.1.linear_attn": (gdn_conv_state, gdn_ssm_state),
}
)
assert worker.kv_caches_base_addr == [
fa_cache.data_ptr(),
gdn_conv_state.data_ptr(),
]
batch_register_memory.assert_called_once()
registered_ptrs, registered_lens = batch_register_memory.call_args[0]
assert registered_ptrs == [backing.data_ptr()]
assert registered_lens == [backing.untyped_storage().nbytes()]
worker.shutdown()
worker.shutdown = noop_shutdown
connector.connector_worker = None
def test_hybrid_gdn_transfer_params_preserve_group_identity(monkeypatch):
monkeypatch.setenv("VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT", "5")
vllm_config = create_vllm_config(
kv_connector="MooncakeConnector",
kv_role="kv_producer",
)
kv_cache_config = make_hybrid_gdn_kv_cache_config(
vllm_config.cache_config.block_size
)
with set_current_vllm_config(vllm_config), patch_worker_dependencies():
connector = MooncakeConnector(
vllm_config,
KVConnectorRole.WORKER,
kv_cache_config,
)
worker = connector.connector_worker
block_len = 0x100
transfer_id = "xfer-hybrid-gdn"
async def build_transfer_params():
send_meta = SendBlockMeta(
p_req_id="p-hybrid-gdn",
transfer_id=transfer_id,
local_block_ids=[
[10, 11],
[NULL_BLOCK_ID, 4],
],
ready=asyncio.Event(),
)
return await worker._build_transfer_params(
[("d-hybrid-gdn", send_meta)],
xfer_meta,
local_regions,
remote_regions,
)
xfer_meta = MooncakeXferMetadata(
remote_hostname="consumer-host",
remote_port=54321,
remote_tp_size=1,
remote_tp_rank=0,
req_blocks={
"d-hybrid-gdn": (
transfer_id,
[
[30, 31],
[NULL_BLOCK_ID, 7],
],
)
},
kv_caches_base_addr=[],
block_lens=[],
kv_block_lens=[],
)
local_regions = [
TransferRegion(
layer_name="model.layers.1.linear_attn",
layer_index=1,
base_addr=0x5000,
block_len=block_len,
kv_block_len=block_len,
group_index=1,
),
TransferRegion(
layer_name="model.layers.0.self_attn",
layer_index=0,
base_addr=0x1000,
block_len=block_len,
kv_block_len=block_len,
group_index=0,
),
]
remote_regions = [
TransferRegion(
layer_name="model.layers.1.linear_attn",
layer_index=1,
base_addr=0x6000,
block_len=block_len,
kv_block_len=block_len,
group_index=1,
),
TransferRegion(
layer_name="model.layers.0.self_attn",
layer_index=0,
base_addr=0x2000,
block_len=block_len,
kv_block_len=block_len,
group_index=0,
),
]
src_ptrs, dst_ptrs, lengths, err_reqs, err_msg = asyncio.run(
build_transfer_params()
)
assert err_reqs == []
assert err_msg is None
assert src_ptrs == [
0x5000 + 4 * block_len,
0x1000 + 10 * block_len,
]
assert dst_ptrs == [
0x6000 + 7 * block_len,
0x2000 + 30 * block_len,
]
assert lengths == [block_len, 2 * block_len]
worker.shutdown()
worker.shutdown = noop_shutdown
connector.connector_worker = None
def test_logical_to_kernel_block_ids_expands_fa_not_gdn():
worker = object.__new__(MooncakeConnectorWorker)
worker.shutdown = noop_shutdown
worker._physical_blocks_per_logical_kv_block = 17
worker.kv_cache_config = make_hybrid_gdn_kv_cache_config(block_size=544)
block_ids = [[2], [2]]
kernel_block_ids = worker._logical_to_kernel_block_ids(block_ids)
assert kernel_block_ids == [list(range(34, 51)), [2]]
def test_hybrid_gdn_splits_fa_regions_but_keeps_gdn_state_whole(
monkeypatch,
):
monkeypatch.setenv("VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT", "5")
vllm_config = create_vllm_config(
kv_connector="MooncakeConnector",
kv_role="kv_producer",
)
kv_cache_config = make_hybrid_gdn_kv_cache_config(
vllm_config.cache_config.block_size
)
with set_current_vllm_config(vllm_config), patch_worker_dependencies():
connector = MooncakeConnector(
vllm_config,
KVConnectorRole.WORKER,
kv_cache_config,
)
worker = connector.connector_worker
worker.transfer_topo = SimpleNamespace(virtually_split_kv_in_blocks=True)
regions = worker._get_transfer_regions(
base_addrs=[0x1000, 0x2000],
block_lens=[0x100, 0x100],
kv_block_lens=[0x40, 0x100],
layer_names=[
"model.layers.0.self_attn",
"model.layers.1.linear_attn",
],
layer_indices=[0, 1],
group_indices=[0, 1],
)
assert [
(region.group_index, region.base_addr, region.kv_block_len)
for region in regions
] == [
(0, 0x1000, 0x40),
(0, 0x1040, 0x40),
(1, 0x2000, 0x100),
]
worker.shutdown()
worker.shutdown = noop_shutdown
connector.connector_worker = None
@@ -22,7 +22,6 @@ from vllm.config import VllmConfig
from vllm.distributed.kv_transfer.kv_connector.utils import (
EngineId,
TransferTopology,
get_current_attn_backend,
get_current_attn_backends,
)
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
@@ -51,10 +50,18 @@ from vllm.platforms import current_platform
from vllm.utils.math_utils import cdiv
from vllm.utils.network_utils import get_ip, make_zmq_path, make_zmq_socket
from vllm.v1.attention.backend import AttentionMetadata
from vllm.v1.attention.backends.utils import get_kv_cache_layout
from vllm.v1.attention.backends.utils import NULL_BLOCK_ID, get_kv_cache_layout
from vllm.v1.core.sched.output import SchedulerOutput
from vllm.v1.kv_cache_interface import FullAttentionSpec, SlidingWindowSpec
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheSpec,
MambaSpec,
MLAAttentionSpec,
SlidingWindowMLASpec,
SlidingWindowSpec,
)
from vllm.v1.request import RequestStatus
from vllm.v1.worker.block_table import BlockTable
from vllm.v1.worker.utils import select_common_block_size
logger = init_logger(__name__)
@@ -85,6 +92,7 @@ class TransferRegion:
base_addr: int
block_len: int
kv_block_len: int
group_index: int = 0
def _get_tp_ratio(local_tp_size: int, remote_tp_size: int) -> int:
@@ -111,24 +119,58 @@ def _get_tp_ratio(local_tp_size: int, remote_tp_size: int) -> int:
def _expand_transfer_regions(
base_addrs: list[int],
block_lens: list[int],
kv_block_lens: list[int],
layer_names: list[str],
layer_indices: list[int],
is_kv_layout_blocks_first: bool,
group_indices: list[int] | None = None,
split_kv_regions: list[bool] | None = None,
) -> list[TransferRegion]:
"""Expand registered KV tensors into the regions transferred by Mooncake."""
assert (
len(base_addrs) == len(block_lens) == len(layer_names) == len(layer_indices)
len(base_addrs)
== len(block_lens)
== len(kv_block_lens)
== len(layer_names)
== len(layer_indices)
), (
"Mooncake transfer regions require matching metadata lengths, got "
f"base_addrs={len(base_addrs)}, block_lens={len(block_lens)}, "
f"kv_block_lens={len(kv_block_lens)}, "
f"layer_names={len(layer_names)}, "
f"layer_indices={len(layer_indices)}."
)
if group_indices is None:
group_indices = [0] * len(layer_names)
assert len(group_indices) == len(layer_names), (
"Mooncake transfer regions require matching group metadata lengths, "
f"got group_indices={len(group_indices)}, layer_names={len(layer_names)}."
)
if split_kv_regions is None:
split_kv_regions = [is_kv_layout_blocks_first] * len(layer_names)
assert len(split_kv_regions) == len(layer_names), (
"Mooncake transfer regions require matching split metadata, "
f"got split_kv_regions={len(split_kv_regions)}, "
f"layer_names={len(layer_names)}."
)
regions: list[TransferRegion] = []
for base_addr, block_len, layer_name, layer_index in zip(
base_addrs, block_lens, layer_names, layer_indices
for (
base_addr,
block_len,
kv_block_len,
layer_name,
layer_index,
group_index,
split_kv_region,
) in zip(
base_addrs,
block_lens,
kv_block_lens,
layer_names,
layer_indices,
group_indices,
split_kv_regions,
):
kv_block_len = block_len // 2 if is_kv_layout_blocks_first else block_len
regions.append(
TransferRegion(
layer_name=layer_name,
@@ -136,9 +178,10 @@ def _expand_transfer_regions(
base_addr=base_addr,
block_len=block_len,
kv_block_len=kv_block_len,
group_index=group_index,
)
)
if is_kv_layout_blocks_first:
if split_kv_region:
regions.append(
TransferRegion(
layer_name=layer_name,
@@ -146,6 +189,7 @@ def _expand_transfer_regions(
base_addr=base_addr + kv_block_len,
block_len=block_len,
kv_block_len=kv_block_len,
group_index=group_index,
)
)
return regions
@@ -308,6 +352,17 @@ def _align_transfer_regions(
f"{remote_region.layer_index}."
),
)
if local_region.group_index != remote_region.group_index:
return (
[],
[],
(
"Mooncake registered group index mismatch for "
f"{local_region.layer_name}: producer="
f"{local_region.group_index}, consumer="
f"{remote_region.group_index}."
),
)
aligned_local.append(local_region)
aligned_remote.append(remote_region)
@@ -332,8 +387,10 @@ class MooncakeXferMetadata(
req_blocks: dict[ReqId, tuple[TransferId, list[list[int]]]]
kv_caches_base_addr: list[int]
block_lens: list[int]
kv_block_lens: list[int]
registered_layer_names: list[str] = msgspec.field(default_factory=list)
registered_layer_indices: list[int] = msgspec.field(default_factory=list)
registered_group_indices: list[int] = msgspec.field(default_factory=list)
class MooncakeXferResponseStatus(IntEnum):
@@ -581,6 +638,9 @@ class MooncakeConnectorScheduler:
for g in kv_cache_config.kv_cache_groups
)
)
# GDN is represented as a MambaSpec in vLLM. This Mooncake MambaSpec
# path is currently tested with GDN; Mamba2 is not validated yet.
self._has_mamba = kv_cache_config.has_mamba_layers
# Requests that need to start recv/send.
# New requests are added by update_state_after_alloc in
@@ -617,6 +677,38 @@ class MooncakeConnectorScheduler:
for i, blocks in enumerate(block_ids)
]
def _get_remote_prefill_token_count(self, num_prompt_tokens: int) -> int:
"""D-side only. Returns N-1 for Mamba models since the decoder
always recomputes the last token and must start from h(N-1)."""
if self._has_mamba and num_prompt_tokens > 1:
return num_prompt_tokens - 1
return num_prompt_tokens
def _truncate_mamba_request_for_prefill(self, request: "Request") -> None:
"""P-side only: drop the last prompt token so the prefiller computes
h(N-1) instead of h(N). The decoder recomputes the last token to
derive h(N) correctly.
Guarded by ``_p_side_truncated`` to avoid repeated truncation if the
request is preempted and rescheduled."""
params = request.kv_transfer_params
if (
params is not None
and not params.get("_p_side_truncated")
and request.num_prompt_tokens > 1
):
if request.prompt_token_ids is not None:
request.prompt_token_ids.pop()
elif request.prompt_embeds is not None:
request.prompt_embeds = request.prompt_embeds[:-1]
else:
return
request._all_token_ids.pop()
request.num_prompt_tokens -= 1
request.max_tokens = 1
params["_p_side_truncated"] = True
def get_num_new_matched_tokens(
self, request: "Request", num_computed_tokens: int
) -> tuple[int, bool]:
@@ -650,10 +742,15 @@ class MooncakeConnectorScheduler:
# Remote prefill: get all prompt blocks from remote.
assert not self.is_kv_producer
token_ids = request.prompt_token_ids or []
count = len(token_ids) - num_computed_tokens
count = self._get_remote_prefill_token_count(len(token_ids)) - (
num_computed_tokens
)
if count > 0:
return count, True
if params.get("do_remote_decode") and self._has_mamba:
self._truncate_mamba_request_for_prefill(request)
# No remote prefill for this request.
return 0, False
@@ -802,7 +899,7 @@ class MooncakeConnectorWorker:
self,
vllm_config: VllmConfig,
engine_id: str,
kv_cache_config: "KVCacheConfig | None" = None,
kv_cache_config: "KVCacheConfig",
):
if TransferEngine is None:
logger.error("Mooncake is not available")
@@ -831,10 +928,15 @@ class MooncakeConnectorWorker:
protocol = kv_transfer_config.kv_connector_extra_config.get( # type: ignore[union-attr]
"mooncake_protocol", "rdma"
)
device_name = kv_transfer_config.kv_connector_extra_config.get( # type: ignore[union-attr]
"device_name", ""
)
logger.info(
"The Mooncake Transfer Engine is using %s as its protocol.", protocol
)
ret_value = self.engine.initialize(self.hostname, "P2PHANDSHAKE", protocol, "")
ret_value = self.engine.initialize(
self.hostname, "P2PHANDSHAKE", protocol, device_name
)
if ret_value != 0:
raise RuntimeError("Mooncake Transfer Engine initialization failed.")
@@ -852,10 +954,11 @@ class MooncakeConnectorWorker:
self.engine_id: EngineId = engine_id
self.tp_rank = get_tensor_model_parallel_rank()
self.tp_size = get_tensor_model_parallel_world_size()
self.num_blocks = 0
self.block_len_per_layer: list[int] = []
self.kv_block_len_per_layer: list[int] = []
self.registered_layer_names: list[str] = []
self.registered_layer_indices: list[int] = []
self.registered_group_indices: list[int] = []
self.seen_base_addresses: list[int] = []
assert (parallel_config := vllm_config.parallel_config)
@@ -916,26 +1019,40 @@ class MooncakeConnectorWorker:
self.cache_config = vllm_config.cache_config
self.kv_cache_config = kv_cache_config
self.use_mla = self.model_config.use_mla
self._physical_blocks_per_logical_kv_block = 1
self._sync_block_size_with_kernel()
# Get the attention backend from the first layer
# NOTE (NickLucche) models with multiple backends are not supported yet
backend = get_current_attn_backend(vllm_config)
self.backend_name = backend.get_name()
self.attn_backends = get_current_attn_backends(vllm_config)
self.kv_cache_layout = get_kv_cache_layout()
logger.debug("Detected attention backend %s", self.backend_name)
logger.debug(
"Detected attention backends %s",
[backend.get_name() for backend in self.attn_backends],
)
logger.debug("Detected kv cache layout %s", self.kv_cache_layout)
self._tp_size: dict[EngineId, int] = {self.engine_id: self.tp_size}
self._layer_specs: dict[str, KVCacheSpec] = {}
for group in kv_cache_config.kv_cache_groups:
group_spec = group.kv_cache_spec
specs_by_layer = getattr(group_spec, "kv_cache_specs", {})
for layer_name in group.layer_names:
self._layer_specs[layer_name] = specs_by_layer.get(
layer_name, group_spec
)
self._layer_group_indices: dict[str, int] = {
layer: group_index
for group_index, group in enumerate(kv_cache_config.kv_cache_groups)
for layer in group.layer_names
}
self.transfer_topo = TransferTopology(
tp_rank=self.tp_rank,
tp_size=self.tp_size,
block_size=self.block_size,
engine_id=self.engine_id,
is_mla=self.use_mla,
is_mamba=False,
is_mamba=kv_cache_config.has_mamba_layers,
total_num_kv_heads=self.model_config.get_total_num_kv_heads(),
attn_backends=[backend],
attn_backends=self.attn_backends,
)
self.async_zmq_ctx = zmq.asyncio.Context()
@@ -958,6 +1075,9 @@ class MooncakeConnectorWorker:
kernel_block_size,
)
assert self.block_size > kernel_block_size
self._physical_blocks_per_logical_kv_block = (
self.block_size // kernel_block_size
)
self.block_size = kernel_block_size
def __del__(self):
@@ -1092,14 +1212,18 @@ class MooncakeConnectorWorker:
local_regions = self._get_transfer_regions(
self.kv_caches_base_addr,
self.block_len_per_layer,
self.kv_block_len_per_layer,
self.registered_layer_names,
self.registered_layer_indices,
self.registered_group_indices,
)
remote_regions = self._get_transfer_regions(
meta.kv_caches_base_addr,
meta.block_lens,
meta.kv_block_lens,
meta.registered_layer_names,
meta.registered_layer_indices,
meta.registered_group_indices,
)
local_regions, remote_regions, align_err = _align_transfer_regions(
local_regions, remote_regions
@@ -1271,6 +1395,32 @@ class MooncakeConnectorWorker:
remote_tp_ranks,
)
def _logical_to_kernel_block_ids(
self, block_ids: list[list[int]]
) -> list[list[int]]:
# For example, if a 544-token logical block is served by 32-token
# FA kernel blocks, FA block id k expands to [17k, ..., 17k + 16],
# while the matching Mamba/GDN state block remains k. Only attention
# groups need logical block ids expanded to kernel block ids; Mamba/GDN
# state block ids stay in the logical/page-id space.
if self._physical_blocks_per_logical_kv_block == 1:
return block_ids
block_arange = np.arange(self._physical_blocks_per_logical_kv_block).reshape(
1, -1
)
group_specs = self.kv_cache_config.kv_cache_groups
return [
BlockTable.map_to_kernel_blocks(
np.array(group),
self._physical_blocks_per_logical_kv_block,
block_arange,
).tolist()
if not isinstance(group_specs[i].kv_cache_spec, MambaSpec)
else group
for i, group in enumerate(block_ids)
]
async def _build_transfer_params(
self,
ready_reqs: list[tuple[ReqId, SendBlockMeta]],
@@ -1293,14 +1443,6 @@ class MooncakeConnectorWorker:
):
continue
# Per-group partial hit trimming, then flatten.
# With HMA, groups share the same KV tensor but use different
# block ranges. We trim and concatenate so the coalescer and
# address math see one flat block list — same as non-HMA, but
# now including blocks from every group.
local_block_ids: list[int] = []
remote_block_ids: list[int] = []
has_block_error = False
if len(send_meta.local_block_ids) != len(remote_block_ids_per_group):
logger.error(
"req %s: KV group count mismatch: local=%d, remote=%d",
@@ -1312,26 +1454,55 @@ class MooncakeConnectorWorker:
if err_msg is None:
err_msg = "KV group count mismatch"
continue
for local_group, remote_group in zip(
send_meta.local_block_ids, remote_block_ids_per_group
# Keep KV-cache group identity. Hybrid/HMA groups can carry
# different semantics (e.g. full-attention KV pages vs GDN/Mamba
# inner-state slots), so their block IDs must not be flattened and
# reused for every registered region.
local_block_ids_by_group: list[list[int]] = []
remote_block_ids_by_group: list[list[int]] = []
has_block_error = False
group_specs = self.kv_cache_config.kv_cache_groups
for group_index, (local_group, remote_group) in enumerate(
zip(send_meta.local_block_ids, remote_block_ids_per_group)
):
is_mamba_group = isinstance(
group_specs[group_index].kv_cache_spec,
MambaSpec,
)
if is_mamba_group:
# Mamba/GDN prefix caching can use null blocks only as
# align-mode placeholders. They do not carry transferable
# state, so skip them on both producer and consumer sides.
local_group = [
block_id
for block_id in local_group
if block_id != NULL_BLOCK_ID
]
remote_group = [
block_id
for block_id in remote_group
if block_id != NULL_BLOCK_ID
]
n_local = len(local_group)
n_remote = len(remote_group)
if n_local < n_remote:
logger.error(
"req %s: local blocks(%d) < remote blocks(%d) "
"in a KV cache group",
"in a KV cache group (is_mamba_group=%s)",
d_req_id,
n_local,
n_remote,
is_mamba_group,
)
has_block_error = True
break
if n_local > n_remote:
elif n_local > n_remote:
# Partial prefix cache hit: just read uncomputed blocks.
local_group = local_group[-n_remote:]
local_block_ids.extend(local_group)
remote_block_ids.extend(remote_group)
local_group = local_group[-n_remote:] if n_remote > 0 else []
local_block_ids_by_group.append(local_group)
remote_block_ids_by_group.append(remote_group)
if has_block_error:
err_reqs.append(d_req_id)
@@ -1339,22 +1510,44 @@ class MooncakeConnectorWorker:
err_msg = "P num blocks less than D"
continue
if not local_block_ids:
if not any(local_block_ids_by_group):
continue
# Group by indices
group_local_block_ids, group_remote_block_ids = group_concurrent_contiguous(
local_block_ids, remote_block_ids
local_block_ids_by_group = self._logical_to_kernel_block_ids(
local_block_ids_by_group
)
remote_block_ids_by_group = self._logical_to_kernel_block_ids(
remote_block_ids_by_group
)
for local_region, remote_region in zip(local_regions, remote_regions):
should_transfer, src_region_offset, dst_region_offset, transfer_len = (
self._get_sender_transfer_plan(
local_kv_block_len=local_region.kv_block_len,
remote_kv_block_len=remote_region.kv_block_len,
remote_tp_rank=agent_meta.remote_tp_rank,
remote_tp_size=agent_meta.remote_tp_size,
)
assert local_region.group_index == remote_region.group_index, (
"Aligned Mooncake transfer regions must belong to the same "
"KV group."
)
group_index = local_region.group_index
assert group_index < len(local_block_ids_by_group), (
"Transfer region references a missing KV group."
)
local_block_ids = local_block_ids_by_group[group_index]
remote_block_ids = remote_block_ids_by_group[group_index]
if not local_block_ids:
continue
# Group by indices within this region's KV-cache group only.
group_local_block_ids, group_remote_block_ids = (
group_concurrent_contiguous(local_block_ids, remote_block_ids)
)
(
should_transfer,
src_region_offset,
dst_region_offset,
transfer_len,
) = self._get_sender_transfer_plan(
local_kv_block_len=local_region.kv_block_len,
remote_kv_block_len=remote_region.kv_block_len,
remote_tp_rank=agent_meta.remote_tp_rank,
remote_tp_size=agent_meta.remote_tp_size,
)
if not should_transfer:
# Replicated KV cache: only one producer rank in the TP group
@@ -1368,7 +1561,7 @@ class MooncakeConnectorWorker:
"Computed source transfer region exceeds local KV block size."
)
assert dst_region_offset + transfer_len <= remote_region.kv_block_len, (
"Computed destination transfer region exceeds remote KV block size."
"Destination transfer region exceeds remote KV block size."
)
# Collapse one contiguous block group into a single larger
# transfer descriptor when the per-block copy is identical.
@@ -1411,28 +1604,10 @@ class MooncakeConnectorWorker:
)
lengths.append(transfer_len)
if local_region is local_regions[0]:
logger.debug(
"Mooncake transfer plan for request %s: local_tp=%d "
"remote_tp=%d remote_tp_rank=%d local_block_len=%d "
"remote_block_len=%d src_offset=%d dst_offset=%d "
"transfer_len=%d coalesce=%s",
d_req_id,
self.tp_size,
agent_meta.remote_tp_size,
agent_meta.remote_tp_rank,
local_region.block_len,
remote_region.block_len,
src_region_offset,
dst_region_offset,
transfer_len,
can_coalesce,
)
logger.debug(
"Sending kv_caches for request %s (%d blocks) to %s",
d_req_id,
len(local_block_ids),
sum(len(group) for group in local_block_ids_by_group),
remote_session,
)
@@ -1480,18 +1655,33 @@ class MooncakeConnectorWorker:
logger.info("Registering KV_Caches. use_mla: %s", self.use_mla)
kv_data_ptrs = []
kv_data_lens = []
seen_base_addresses = []
kv_data_ptrs: list[int] = []
kv_data_lens: list[int] = []
region_base_addresses: list[int] = []
seen_storage_ptrs: set[int] = set()
self.block_len_per_layer = []
self.kv_block_len_per_layer = []
self.registered_layer_names = []
self.registered_layer_indices = []
self.registered_group_indices = []
split_k_and_v = self.transfer_topo.split_k_and_v
tensor_size_bytes = None
for layer_name, cache_or_caches in kv_caches.items():
layer_index = extract_layer_index(layer_name)
cache_list = cache_or_caches if split_k_and_v else [cache_or_caches]
layer_spec = self._layer_specs.get(layer_name)
if layer_spec is None:
logger.debug(
"Skipping layer %s because no KV cache spec is present.",
layer_name,
)
continue
if isinstance(layer_spec, MambaSpec):
conv, _ = cache_or_caches
cache_list = [conv]
else:
cache_list = self.transfer_topo.get_transfer_cache_regions(
cache_or_caches, layer_spec
)
logger.debug(
"registering layer %s with %d cache tensor(s)",
layer_name,
@@ -1501,45 +1691,46 @@ class MooncakeConnectorWorker:
for cache in cache_list:
self._log_debug_cache_registration(layer_name, cache)
base_addr = cache.data_ptr()
if base_addr in seen_base_addresses:
continue
seen_base_addresses.append(base_addr)
if tensor_size_bytes is None:
tensor_size_bytes = cache.nbytes
self.num_blocks = cache.shape[0]
assert cache.shape[0] == self.num_blocks, (
"All kv cache tensors must have the same number of blocks"
)
# Use stride-based block length so RDMA reaches the last
# block's padding (e.g. DeepseekV4 MLA alignment). stride(0)
# reflects the actual byte distance between consecutive
# blocks in GPU memory, which matches or exceeds the
# shape-based size.
block_len = cache.stride(0) * cache.element_size()
region_base_addresses.append(base_addr)
if isinstance(layer_spec, (MLAAttentionSpec, SlidingWindowMLASpec)):
kv_block_len = layer_spec.page_size_bytes
elif self.transfer_topo.virtually_split_kv_in_blocks and not isinstance(
layer_spec, MambaSpec
):
kv_block_len = block_len // 2
else:
kv_block_len = block_len
self.block_len_per_layer.append(block_len)
self.kv_block_len_per_layer.append(kv_block_len)
self.registered_layer_names.append(layer_name)
self.registered_layer_indices.append(layer_index)
kv_data_ptrs.append(base_addr)
kv_data_lens.append(self.num_blocks * block_len)
self.registered_group_indices.append(
self._layer_group_indices[layer_name]
)
storage = cache.untyped_storage()
storage_addr = storage.data_ptr()
if storage_addr not in seen_storage_ptrs:
seen_storage_ptrs.add(storage_addr)
kv_data_ptrs.append(storage_addr)
kv_data_lens.append(storage.nbytes())
self.kv_caches_base_addr = seen_base_addresses
self.seen_base_addresses = seen_base_addresses
self.kv_caches_base_addr = region_base_addresses
self.seen_base_addresses = kv_data_ptrs
if not kv_data_ptrs:
raise RuntimeError("No KV cache tensors were registered with Mooncake.")
ret_value = self.engine.batch_register_memory(kv_data_ptrs, kv_data_lens)
if ret_value != 0:
raise RuntimeError("Mooncake batch memory registration failed.")
assert tensor_size_bytes is not None
assert self.num_blocks != 0
self.device_kv_caches = kv_caches
logger.debug(
"registered num_blocks=%d block_lens=%s",
self.num_blocks,
"registered block_lens=%s kv_block_lens=%s",
self.block_len_per_layer,
self.kv_block_len_per_layer,
)
# No need to launch server for D node.
@@ -1642,8 +1833,10 @@ class MooncakeConnectorWorker:
},
kv_caches_base_addr=self.kv_caches_base_addr,
block_lens=self.block_len_per_layer,
kv_block_lens=self.kv_block_len_per_layer,
registered_layer_names=self.registered_layer_names,
registered_layer_indices=self.registered_layer_indices,
registered_group_indices=self.registered_group_indices,
)
encoded_data = self._encoder.encode(metadata)
@@ -1852,15 +2045,34 @@ class MooncakeConnectorWorker:
self,
base_addrs: list[int],
block_lens: list[int],
kv_block_lens: list[int],
layer_names: list[str],
layer_indices: list[int],
group_indices: list[int] | None = None,
) -> list[TransferRegion]:
if not group_indices:
group_indices = [
self._layer_group_indices.get(layer_name, 0)
for layer_name in layer_names
]
split_kv_regions = None
if self.transfer_topo.virtually_split_kv_in_blocks:
split_kv_regions = [
not isinstance(
self._layer_specs[layer_name],
(MambaSpec, MLAAttentionSpec, SlidingWindowMLASpec),
)
for layer_name in layer_names
]
return _expand_transfer_regions(
base_addrs=base_addrs,
block_lens=block_lens,
kv_block_lens=kv_block_lens,
layer_names=layer_names,
layer_indices=layer_indices,
is_kv_layout_blocks_first=self.transfer_topo.virtually_split_kv_in_blocks,
group_indices=group_indices,
split_kv_regions=split_kv_regions,
)
def _get_sender_transfer_plan(
@@ -320,7 +320,7 @@ class NixlBaseConnectorScheduler:
logger.warning("Connection listener got unexpected message %s", msg)
sock.send_multipart((identity, b"", encoded_data[target_tp_rank]))
def _mamba_prefill_token_count(self, num_prompt_tokens: int) -> int:
def _get_remote_prefill_token_count(self, num_prompt_tokens: int) -> int:
"""D-side only. Returns N-1 for Mamba models since the decoder
always recomputes the last token and must start from h(N-1)."""
if self._has_mamba and num_prompt_tokens > 1:
@@ -60,7 +60,7 @@ class NixlPullConnectorScheduler(NixlBaseConnectorScheduler):
if params is not None and params.get("do_remote_prefill"):
# Remote prefill: get all prompt blocks from remote.
token_ids = request.prompt_token_ids or []
actual = self._mamba_prefill_token_count(len(token_ids))
actual = self._get_remote_prefill_token_count(len(token_ids))
count = actual - num_computed_tokens
if count > 0:
return count, True
@@ -116,7 +116,7 @@ class NixlPushConnectorScheduler(NixlBaseConnectorScheduler):
if params is not None and params.get("do_remote_prefill"):
token_ids = request.prompt_token_ids or []
actual = self._mamba_prefill_token_count(len(token_ids))
actual = self._get_remote_prefill_token_count(len(token_ids))
count = actual - num_computed_tokens
if count > 0:
return count, True