forked from Karylab-cklius/vllm
[KV Connector] Support NIXL heterogeneous P/D block sizes for hybrid models (#49612)
Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9b9fc4039c
commit
601fa9a74e
@@ -479,8 +479,9 @@ class FakeNixlConnectorWorker(NixlConnectorWorker):
|
||||
super().__init__(*args, kv_cache_config=kv_cache_config, **kwargs)
|
||||
self._hand_shake_latency = hand_shake_latency
|
||||
self.kv_cache_layout = kv_cache_layout
|
||||
# Mock register_kv_caches attribute needed for tests that do not call it.
|
||||
# Mock register_kv_caches attributes needed for tests that do not call it.
|
||||
self.src_xfer_handles_by_block_size = {self.block_size: 1}
|
||||
self.src_blocks_data = np.empty((0, 3), dtype=np.uint64)
|
||||
test_shape = self.attn_backends[0].get_kv_cache_shape(
|
||||
num_blocks=1, block_size=16, num_kv_heads=1, head_size=1
|
||||
)
|
||||
@@ -765,8 +766,9 @@ class TestNixlHandshake:
|
||||
assert remote_info.remote_tp_size == remote_tp_size
|
||||
assert -tp_ratio == worker.transfer_topo.tp_ratio(remote_tp_size)
|
||||
# ensure src_xfer_handles_by_tp_ratio is populated with tpratio chunks
|
||||
assert -tp_ratio in worker.src_xfer_handles_by_tp_ratio
|
||||
assert len(worker.src_xfer_handles_by_tp_ratio[-tp_ratio]) == tp_ratio
|
||||
split_key = (-tp_ratio, worker.block_size)
|
||||
assert split_key in worker.src_xfer_handles_by_tp_ratio
|
||||
assert len(worker.src_xfer_handles_by_tp_ratio[split_key]) == tp_ratio
|
||||
assert remote_engine_id in worker.dst_xfer_side_handles
|
||||
assert set(worker.dst_xfer_side_handles[remote_engine_id].keys()) == set(
|
||||
range(tp_ratio)
|
||||
@@ -2091,7 +2093,7 @@ def test_shutdown_cleans_up_resources(default_vllm_config, dist_init):
|
||||
# Mock register_kv_cache which registers local handle
|
||||
worker.src_xfer_handles_by_block_size = {worker.block_size: 455}
|
||||
# P TP = 2 * D TP case, we should register 2 local handles
|
||||
worker.src_xfer_handles_by_tp_ratio = {-2: [456, 457]}
|
||||
worker.src_xfer_handles_by_tp_ratio = {(-2, 16): [456, 457]}
|
||||
worker.dst_xfer_side_handles = {"engine1": {0: 789}}
|
||||
worker._remote_agents = {"engine1": {(0, 0): "agent1"}}
|
||||
# _cleanup_remote_engine (called by shutdown) also clears these:
|
||||
|
||||
@@ -708,6 +708,120 @@ def test_get_block_descs_ids_kernel_block_mismatch():
|
||||
assert list(result) == expected, f"Expected {expected}, got {list(result)}"
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
def test_get_block_descs_ids_hetero_block_size_hybrid():
|
||||
"""With a block-size ratio, FA desc ids are ratio-expanded while SSM
|
||||
desc ids keep the unexpanded logical stride (state blocks are never
|
||||
sub-split)."""
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec, MambaSpec
|
||||
|
||||
worker = _make_mock_worker_for_desc_ids(
|
||||
num_regions=2,
|
||||
has_mamba=True,
|
||||
group_spec_types=(FullAttentionSpec, MambaSpec),
|
||||
block_len_per_layer=[100],
|
||||
)
|
||||
|
||||
ratio = 4
|
||||
# FA ids are already remote-granularity (expanded) sub-block ids.
|
||||
fa_sub_blocks = [3, 5]
|
||||
ssm_blocks = [1]
|
||||
result = worker._compute_desc_ids(
|
||||
block_ids=(fa_sub_blocks, ssm_blocks),
|
||||
dst_num_blocks=100,
|
||||
block_size_ratio=ratio,
|
||||
physical_blocks_per_logical=1,
|
||||
)
|
||||
|
||||
# FA regions have 100*4 entries each; SSM regions (4 per layer) start at
|
||||
# 2*400 and stride by the unexpanded 100 logical blocks.
|
||||
expected = [3, 5, 403, 405, 801, 901, 1001, 1101]
|
||||
assert list(result) == expected, f"Expected {expected}, got {list(result)}"
|
||||
|
||||
|
||||
def _bind_worker_method(worker, name):
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
|
||||
NixlConnectorWorker,
|
||||
)
|
||||
|
||||
method = getattr(NixlConnectorWorker, name)
|
||||
setattr(worker, name, method.__get__(worker, NixlConnectorWorker))
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
def test_map_block_ids_for_block_size_ratio_hybrid():
|
||||
"""Attention groups expand to remote granularity and clip to the remote
|
||||
coverage; mamba state blocks pass through 1:1."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
|
||||
NixlConnectorWorker,
|
||||
)
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec, MambaSpec
|
||||
|
||||
worker = MagicMock(spec=NixlConnectorWorker)
|
||||
worker._group_spec_types = (FullAttentionSpec, MambaSpec)
|
||||
_bind_worker_method(worker, "get_mapped_blocks")
|
||||
_bind_worker_method(worker, "_map_block_ids_for_block_size_ratio")
|
||||
|
||||
local, remote = worker._map_block_ids_for_block_size_ratio(
|
||||
[[1, 2, 3], [7]],
|
||||
[list(range(30, 40)), [42]],
|
||||
4,
|
||||
)
|
||||
# [1, 2, 3] expand to sub-blocks [4..15], clipped to the 10 remote blocks.
|
||||
assert local == [list(range(4, 14)), [7]]
|
||||
assert remote == [list(range(30, 40)), [42]]
|
||||
|
||||
# Attention-only full prefix hit: empty local list is preserved.
|
||||
worker._group_spec_types = (FullAttentionSpec,)
|
||||
local, remote = worker._map_block_ids_for_block_size_ratio([[]], [[30, 31]], 4)
|
||||
assert local == []
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
def test_post_process_zeroes_untransferred_tail():
|
||||
"""The untransferred sub-blocks of the last local block are zeroed on
|
||||
receive; mamba state caches are untouched by the attention permute."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
|
||||
NixlConnectorWorker,
|
||||
)
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec, MambaSpec
|
||||
|
||||
ratio = 4
|
||||
block_tokens = 8 # 2 tokens per remote sub-block
|
||||
|
||||
worker = MagicMock(spec=NixlConnectorWorker)
|
||||
worker._group_spec_types = (FullAttentionSpec, MambaSpec)
|
||||
worker.transfer_topo = MagicMock()
|
||||
worker.device_type = "cpu"
|
||||
worker.enable_permute_local_kv = False
|
||||
attn_cache = torch.ones(6, block_tokens, 2, 4)
|
||||
mamba_cache = torch.ones(6, 16)
|
||||
worker.device_kv_caches = {"attn.0": attn_cache, "mamba.0": mamba_cache}
|
||||
fa_group = MagicMock(layer_names=["attn.0"])
|
||||
ssm_group = MagicMock(layer_names=["mamba.0"])
|
||||
worker.kv_cache_config = MagicMock(kv_cache_groups=[fa_group, ssm_group])
|
||||
# The cached property filters mamba layers out of the permuted caches.
|
||||
attn_caches = NixlConnectorWorker._attention_kv_caches.func(worker)
|
||||
assert len(attn_caches) == 1 and attn_caches[0] is attn_cache
|
||||
worker._attention_kv_caches = attn_caches
|
||||
_bind_worker_method(worker, "post_process_device_kv_on_receive")
|
||||
|
||||
# Request occupies blocks [2, 3]; only 6 of 8 sub-blocks were received.
|
||||
worker.post_process_device_kv_on_receive(ratio, [([2, 3], 6)])
|
||||
|
||||
# Block 2 fully covered; block 3 covered for 2 sub-blocks (4 tokens).
|
||||
assert torch.all(attn_cache[2] == 1)
|
||||
assert torch.all(attn_cache[3, :4] == 1)
|
||||
assert torch.all(attn_cache[3, 4:] == 0)
|
||||
# Untouched blocks and the mamba cache keep their content.
|
||||
assert torch.all(attn_cache[4] == 1)
|
||||
assert torch.all(mamba_cache == 1)
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
def test_nixl_metadata_hybrid_ssm_block_ids():
|
||||
"""Test NixlConnectorMetadata correctly stores block IDs for FA + SSM
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""End-to-end NIXL descriptor geometry invariants for hybrid MLA+SSM models
|
||||
under heterogeneous P/D block geometry (TP-sharded KDA-style state, so
|
||||
the mamba-aligned logical block size differs between P and D while the
|
||||
kernel-granularity pages stay equal).
|
||||
|
||||
The invariant under test: every LOCAL byte range a request's READ transfers
|
||||
into must lie within that request's own blocks. A violation means an
|
||||
incoming transfer can overwrite a co-resident request's KV or mamba state
|
||||
mid-decode (silent corruption of an unrelated request).
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from .utils import create_vllm_config
|
||||
|
||||
|
||||
class _RecordingNixl:
|
||||
"""Minimal NIXL wrapper stand-in that records descriptor lists and
|
||||
prepared transfers so tests can resolve desc ids to byte ranges."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.dlists: dict[int, np.ndarray] = {}
|
||||
self.xfers: list[tuple] = []
|
||||
self._next_handle = 1
|
||||
|
||||
def get_reg_descs(self, caches_data, mem_type):
|
||||
return caches_data
|
||||
|
||||
def register_memory(self, descs, backends=None):
|
||||
pass
|
||||
|
||||
def deregister_memory(self, descs):
|
||||
pass
|
||||
|
||||
def get_agent_metadata(self):
|
||||
return b"agent-meta"
|
||||
|
||||
def get_xfer_descs(self, blocks_data, mem_type):
|
||||
return blocks_data
|
||||
|
||||
def prep_xfer_dlist(self, agent, descs):
|
||||
handle = self._next_handle
|
||||
self._next_handle += 1
|
||||
self.dlists[handle] = np.asarray(descs, dtype=np.uint64).reshape(-1, 3)
|
||||
return handle
|
||||
|
||||
def add_remote_agent(self, metadata):
|
||||
return "remote-agent"
|
||||
|
||||
def make_prepped_xfer(
|
||||
self, op, local_handle, local_ids, remote_handle, remote_ids, notif_msg=None
|
||||
):
|
||||
handle = self._next_handle
|
||||
self._next_handle += 1
|
||||
self.xfers.append(
|
||||
(
|
||||
op,
|
||||
local_handle,
|
||||
np.asarray(local_ids),
|
||||
remote_handle,
|
||||
np.asarray(remote_ids),
|
||||
)
|
||||
)
|
||||
return handle
|
||||
|
||||
def transfer(self, handle):
|
||||
pass
|
||||
|
||||
def check_xfer_state(self, handle):
|
||||
return "DONE"
|
||||
|
||||
def get_xfer_telemetry(self, handle):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(
|
||||
xferDuration=1.0, postDuration=1.0, totalBytes=1, descCount=1
|
||||
)
|
||||
|
||||
def release_xfer_handle(self, handle):
|
||||
pass
|
||||
|
||||
def release_dlist_handle(self, handle):
|
||||
pass
|
||||
|
||||
def send_notif(self, agent, notif_msg=None):
|
||||
pass
|
||||
|
||||
def get_new_notifs(self):
|
||||
return {}
|
||||
|
||||
def remove_remote_agent(self, agent):
|
||||
pass
|
||||
|
||||
|
||||
def _make_mla_hybrid_worker(local_block_size, kernel_block_size, num_logical_blocks):
|
||||
"""Build a real pull worker with a hybrid MLA + 2xKDA HMA layout."""
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl import (
|
||||
base_worker as bw,
|
||||
)
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker import (
|
||||
NixlConnectorWorker,
|
||||
)
|
||||
from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
KVCacheConfig,
|
||||
KVCacheGroupSpec,
|
||||
KVCacheTensor,
|
||||
MambaSpec,
|
||||
MLAAttentionSpec,
|
||||
)
|
||||
|
||||
mla_spec = MLAAttentionSpec(
|
||||
block_size=local_block_size,
|
||||
num_kv_heads=1,
|
||||
head_size=6,
|
||||
dtype=torch.float16,
|
||||
)
|
||||
unified_page = mla_spec.page_size_bytes
|
||||
kda_spec = MambaSpec(
|
||||
block_size=local_block_size,
|
||||
shapes=((8, 3), (1, 4, 4)),
|
||||
dtypes=(torch.float16, torch.float32),
|
||||
page_size_padded=unified_page,
|
||||
mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
|
||||
)
|
||||
kv_cache_config = KVCacheConfig(
|
||||
num_blocks=num_logical_blocks,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(
|
||||
size=num_logical_blocks * unified_page,
|
||||
shared_by=[f"mla.{i}", f"kda_a.{i}", f"kda_b.{i}"],
|
||||
)
|
||||
for i in range(2)
|
||||
],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(["mla.0", "mla.1"], mla_spec),
|
||||
KVCacheGroupSpec(["kda_a.0", "kda_a.1"], kda_spec),
|
||||
KVCacheGroupSpec(["kda_b.0", "kda_b.1"], kda_spec),
|
||||
],
|
||||
)
|
||||
|
||||
vllm_config = create_vllm_config(block_size=local_block_size)
|
||||
vllm_config.cache_config.enable_prefix_caching = False
|
||||
# kv_buffer_device defaults to the *real* platform's device type, which on
|
||||
# a CPU-only test host would make this a host-buffer worker: host xfer
|
||||
# buffers are per-layer, so the HMA shared-tensor regions this test builds
|
||||
# would not be deduplicated. Pin it to the faked device type.
|
||||
vllm_config.kv_transfer_config.kv_buffer_device = "cuda"
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
fake_backend = MagicMock()
|
||||
fake_backend.get_supported_kernel_block_sizes.return_value = [kernel_block_size]
|
||||
fake_backend.get_name.return_value = "FLASHMLA"
|
||||
fake_backend.full_cls_name.return_value = "fake.FLASHMLA"
|
||||
fake_platform = MagicMock()
|
||||
fake_platform.device_type = "cuda"
|
||||
fake_platform.get_nixl_memory_type.return_value = "VRAM"
|
||||
|
||||
from vllm.config import set_current_vllm_config
|
||||
|
||||
with (
|
||||
patch.object(bw, "NixlWrapper", _RecordingNixl),
|
||||
patch.object(bw, "get_tensor_model_parallel_rank", return_value=0),
|
||||
patch.object(bw, "get_tensor_model_parallel_world_size", return_value=1),
|
||||
patch.object(bw, "get_current_attn_backends", return_value=[fake_backend]),
|
||||
patch.object(bw, "current_platform", fake_platform),
|
||||
patch(
|
||||
"vllm.model_executor.layers.mamba.mamba_utils.get_conv_state_layout",
|
||||
return_value="DS",
|
||||
),
|
||||
set_current_vllm_config(vllm_config),
|
||||
):
|
||||
worker = NixlConnectorWorker(vllm_config, "local-engine", kv_cache_config)
|
||||
worker.use_mla = True
|
||||
|
||||
# Attention caches are kernel-block granular on dim 0, as the
|
||||
# receive post-process assumes.
|
||||
ppl = local_block_size // kernel_block_size
|
||||
tensors = [
|
||||
torch.zeros(
|
||||
num_logical_blocks * ppl, unified_page // ppl, dtype=torch.uint8
|
||||
)
|
||||
for _ in range(2)
|
||||
]
|
||||
worker.register_kv_caches(
|
||||
{
|
||||
"kda_a.0": tensors[0],
|
||||
"mla.0": tensors[0],
|
||||
"kda_b.0": tensors[0],
|
||||
"kda_a.1": tensors[1],
|
||||
"mla.1": tensors[1],
|
||||
"kda_b.1": tensors[1],
|
||||
}
|
||||
)
|
||||
# Keep tensors alive alongside the worker; flat views for byte checks.
|
||||
worker._test_tensors = [t.view(-1) for t in tensors]
|
||||
worker._test_tensors_2d = tensors
|
||||
worker._test_unified_page = unified_page
|
||||
return worker
|
||||
|
||||
|
||||
def _make_remote_meta(
|
||||
worker,
|
||||
remote_block_size,
|
||||
remote_kernel_block_size,
|
||||
remote_num_logical,
|
||||
remote_ssm_sizes,
|
||||
):
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
|
||||
NixlAgentMetadata,
|
||||
)
|
||||
|
||||
remote_ppl = remote_block_size // remote_kernel_block_size
|
||||
# Kernel-granularity pages are TP-independent for MLA hybrids and must
|
||||
# match the local ones for the handshake to pass, scaled down by the
|
||||
# block-size ratio when the remote's kernel block is smaller.
|
||||
block_size_ratio = worker.block_size // remote_kernel_block_size
|
||||
kernel_page = worker.block_len_per_layer[0] // block_size_ratio
|
||||
return NixlAgentMetadata(
|
||||
engine_id="remote-engine",
|
||||
agent_metadata=b"remote-agent-meta",
|
||||
device_id=0,
|
||||
kv_caches_base_addr=[0x10_000_000, 0x20_000_000],
|
||||
num_blocks=remote_num_logical * remote_ppl,
|
||||
block_lens=[kernel_page, kernel_page],
|
||||
kv_cache_layout=worker.kv_cache_layout,
|
||||
block_size=remote_kernel_block_size,
|
||||
ssm_sizes=remote_ssm_sizes,
|
||||
attn_backend_name=worker.backend_name,
|
||||
physical_blocks_per_logical_kv_block=remote_ppl,
|
||||
)
|
||||
|
||||
|
||||
def _owned_byte_ranges(worker, group_logical_ids):
|
||||
"""Byte ranges owned by a request: for each HMA region tensor, every
|
||||
logical block id of every group maps to one unified page."""
|
||||
unified_page = worker._test_unified_page
|
||||
bases = [t.data_ptr() for t in worker._test_tensors]
|
||||
owned = []
|
||||
for base in bases:
|
||||
for ids in group_logical_ids:
|
||||
for b in ids:
|
||||
owned.append((base + b * unified_page, base + (b + 1) * unified_page))
|
||||
return owned
|
||||
|
||||
|
||||
def _assert_local_writes_within(worker, owned_ranges):
|
||||
nixl = worker.nixl_wrapper
|
||||
assert nixl.xfers, "no transfers were posted"
|
||||
violations = []
|
||||
total_descs = 0
|
||||
for op, local_handle, local_ids, _, remote_ids in nixl.xfers:
|
||||
assert len(local_ids) == len(remote_ids)
|
||||
desc_arr = nixl.dlists[local_handle]
|
||||
for i in local_ids:
|
||||
addr, length, _dev = desc_arr[int(i)]
|
||||
addr, length = int(addr), int(length)
|
||||
total_descs += 1
|
||||
if not any(lo <= addr and addr + length <= hi for lo, hi in owned_ranges):
|
||||
violations.append((int(i), hex(addr), length))
|
||||
assert not violations, (
|
||||
f"{len(violations)}/{total_descs} local descriptors write outside "
|
||||
f"the request's own blocks: {violations[:10]}"
|
||||
)
|
||||
return total_descs
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
def test_hetero_ppl_multi_read_writes_stay_within_request_blocks():
|
||||
"""MLA-hybrid hetero geometry: local (D, TP1) logical blocks of 12 tokens
|
||||
(kernel 4, ppl=3) vs remote (P, TP2) logical blocks of 8 tokens (ppl=2),
|
||||
equal kernel pages, tp_ratio=-2 multi-read with replicated MLA and
|
||||
TP-sharded KDA state. Every local descriptor of the request's reads must
|
||||
stay within its own blocks."""
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
|
||||
NixlConnectorMetadata,
|
||||
)
|
||||
|
||||
worker = _make_mla_hybrid_worker(
|
||||
local_block_size=12, kernel_block_size=4, num_logical_blocks=8
|
||||
)
|
||||
assert worker._physical_blocks_per_logical_kv_block == 3
|
||||
|
||||
meta_r = _make_remote_meta(
|
||||
worker,
|
||||
remote_block_size=8,
|
||||
remote_kernel_block_size=4,
|
||||
remote_num_logical=12,
|
||||
remote_ssm_sizes=(24, 32),
|
||||
)
|
||||
for rank in (0, 1):
|
||||
worker.add_remote_agent(meta_r, remote_tp_rank=rank, remote_tp_size=2)
|
||||
|
||||
# Request B: 17 matched tokens. Local: 2 logical blocks (24 tok
|
||||
# capacity); remote: 16 prefilled tokens -> 2 remote logical blocks.
|
||||
# Sparse, non-contiguous ids so neighbor blocks exist on all sides.
|
||||
local_ids = ([2, 5], [1], [7])
|
||||
remote_ids = [[1, 4], [5], [2]]
|
||||
|
||||
metadata = NixlConnectorMetadata()
|
||||
metadata.add_new_req_to_recv(
|
||||
request_id="req-b",
|
||||
local_block_ids=local_ids,
|
||||
kv_transfer_params={
|
||||
"remote_block_ids": remote_ids,
|
||||
"remote_engine_id": "remote-engine",
|
||||
"remote_request_id": "prefill-req-b",
|
||||
"remote_host": "localhost",
|
||||
"remote_port": 1234,
|
||||
"tp_size": 2,
|
||||
},
|
||||
)
|
||||
meta = metadata.reqs_to_recv["req-b"]
|
||||
meta.local_physical_block_ids = worker._logical_to_kernel_block_ids(
|
||||
meta.local_block_ids, worker._physical_blocks_per_logical_kv_block
|
||||
)
|
||||
worker._recving_metadata["req-b"] = meta
|
||||
|
||||
worker._read_blocks_for_req("req-b", meta)
|
||||
|
||||
owned = _owned_byte_ranges(worker, local_ids)
|
||||
total = _assert_local_writes_within(worker, owned)
|
||||
# Multi-read: rank 0 carries the replicated MLA + its SSM shard,
|
||||
# rank 1 carries only its SSM shard.
|
||||
assert len(worker.nixl_wrapper.xfers) == 2
|
||||
assert total > 0
|
||||
|
||||
|
||||
def _resolve(
|
||||
desc_arr,
|
||||
idx,
|
||||
bases,
|
||||
region_size,
|
||||
unified_page,
|
||||
desc_page,
|
||||
logical_ids_attn,
|
||||
block_tokens,
|
||||
):
|
||||
"""Resolve a desc id to (region, kind, token_start) where kind is 'attn'
|
||||
(desc-page sized, sub-block-aligned, in the request's attention blocks)
|
||||
or 'mamba'. token_start is the request-relative token offset, so local
|
||||
and remote are comparable even when their kernel blocks differ in size."""
|
||||
addr, length, _ = (int(x) for x in desc_arr[int(idx)])
|
||||
for region, base in enumerate(bases):
|
||||
off = addr - base
|
||||
if 0 <= off < region_size:
|
||||
b = off // unified_page
|
||||
rem = off % unified_page
|
||||
if length == desc_page and rem % desc_page == 0 and b in logical_ids_attn:
|
||||
pos = logical_ids_attn.index(b)
|
||||
tokens_per_desc = block_tokens * desc_page // unified_page
|
||||
sub = rem // desc_page
|
||||
return (region, "attn", pos * block_tokens + sub * tokens_per_desc)
|
||||
return (region, "mamba", None)
|
||||
raise AssertionError(f"desc {idx} addr {addr:#x} not in any region")
|
||||
|
||||
|
||||
def _run_hetero_case(
|
||||
local_block, kernel, remote_block, num_tokens, tp_size=2, remote_kernel=None
|
||||
):
|
||||
"""Full pull-path run for one geometry; returns pairing records.
|
||||
|
||||
``remote_kernel`` defaults to the local kernel block size; a smaller
|
||||
value additionally exercises block_size_ratio > 1.
|
||||
"""
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
|
||||
NixlConnectorMetadata,
|
||||
)
|
||||
|
||||
remote_kernel = remote_kernel or kernel
|
||||
block_size_ratio = kernel // remote_kernel
|
||||
remote_ppl = remote_block // remote_kernel
|
||||
matched = num_tokens - 1 # mamba N-1 rule
|
||||
n_local = -(-num_tokens // local_block)
|
||||
n_remote = -(-matched // remote_block)
|
||||
|
||||
worker = _make_mla_hybrid_worker(
|
||||
local_block_size=local_block,
|
||||
kernel_block_size=kernel,
|
||||
num_logical_blocks=max(2 * n_local + 4, 8),
|
||||
)
|
||||
# Local KDA state pages are (48, 64) bytes; the remote holds 1/tp_size
|
||||
# shards of each.
|
||||
meta_r = _make_remote_meta(
|
||||
worker,
|
||||
remote_block_size=remote_block,
|
||||
remote_kernel_block_size=remote_kernel,
|
||||
remote_num_logical=max(2 * n_remote + 4, 8),
|
||||
remote_ssm_sizes=(48 // tp_size, 64 // tp_size),
|
||||
)
|
||||
for rank in range(tp_size):
|
||||
worker.add_remote_agent(meta_r, remote_tp_rank=rank, remote_tp_size=tp_size)
|
||||
|
||||
# Sparse ids so neighbors exist between the request's blocks.
|
||||
local_attn = [2 * i + 1 for i in range(n_local)]
|
||||
remote_attn = [2 * i + 2 for i in range(n_remote)]
|
||||
local_ids = (local_attn, [0], [2 * n_local + 2])
|
||||
remote_ids = [remote_attn, [1], [0]]
|
||||
|
||||
metadata = NixlConnectorMetadata()
|
||||
metadata.add_new_req_to_recv(
|
||||
request_id="req-b",
|
||||
local_block_ids=local_ids,
|
||||
kv_transfer_params={
|
||||
"remote_block_ids": remote_ids,
|
||||
"remote_engine_id": "remote-engine",
|
||||
"remote_request_id": "prefill-req-b",
|
||||
"remote_host": "localhost",
|
||||
"remote_port": 1234,
|
||||
"tp_size": tp_size,
|
||||
},
|
||||
)
|
||||
meta = metadata.reqs_to_recv["req-b"]
|
||||
meta.local_physical_block_ids = worker._logical_to_kernel_block_ids(
|
||||
meta.local_block_ids, worker._physical_blocks_per_logical_kv_block
|
||||
)
|
||||
worker._recving_metadata["req-b"] = meta
|
||||
|
||||
# Sentinel-fill the local KV so untouched bytes are detectable.
|
||||
for t in worker._test_tensors:
|
||||
t.fill_(0xAA)
|
||||
|
||||
worker._read_blocks_for_req("req-b", meta)
|
||||
|
||||
# Invariant 1: all local writes within the request's own blocks.
|
||||
owned = _owned_byte_ranges(worker, local_ids)
|
||||
_assert_local_writes_within(worker, owned)
|
||||
|
||||
# Invariant 2: local<->remote attention pairs are token-aligned.
|
||||
nixl = worker.nixl_wrapper
|
||||
local_bases = [t.data_ptr() for t in worker._test_tensors]
|
||||
remote_bases = [0x10_000_000, 0x20_000_000]
|
||||
local_unified = worker._test_unified_page
|
||||
remote_unified = (local_unified // local_block) * remote_block
|
||||
# With block_size_ratio > 1 the local page is split into ratio sub-descs,
|
||||
# each the size of a whole remote kernel page.
|
||||
desc_page = worker.block_len_per_layer[0] // block_size_ratio
|
||||
meta_r_num_blocks_bytes = (meta_r.num_blocks // remote_ppl) * remote_unified
|
||||
covered_tokens = set()
|
||||
for op, lh, lids, rh, rids in nixl.xfers:
|
||||
larr, rarr = nixl.dlists[lh], nixl.dlists[rh]
|
||||
for li, ri in zip(lids, rids):
|
||||
lreg, lkind, ltok = _resolve(
|
||||
larr,
|
||||
li,
|
||||
local_bases,
|
||||
len(worker._test_tensors[0]),
|
||||
local_unified,
|
||||
desc_page,
|
||||
local_attn,
|
||||
local_block,
|
||||
)
|
||||
rreg, rkind, rtok = _resolve(
|
||||
rarr,
|
||||
ri,
|
||||
remote_bases,
|
||||
meta_r_num_blocks_bytes,
|
||||
remote_unified,
|
||||
desc_page,
|
||||
remote_attn,
|
||||
remote_block,
|
||||
)
|
||||
assert lkind == rkind, (
|
||||
f"pair kind mismatch: local {lkind} vs remote {rkind} "
|
||||
f"(local desc {li}, remote desc {ri})"
|
||||
)
|
||||
assert lreg == rreg, (
|
||||
f"region mismatch: local {lreg} vs remote {rreg} for "
|
||||
f"tokens {ltok} vs {rtok}"
|
||||
)
|
||||
if lkind == "attn":
|
||||
assert ltok == rtok, (
|
||||
f"TOKEN MISALIGNMENT: local sub-block holds tokens "
|
||||
f"[{ltok}..) but receives remote tokens [{rtok}..) "
|
||||
f"(geometry local_block={local_block}, "
|
||||
f"remote_block={remote_block}, N={num_tokens})"
|
||||
)
|
||||
covered_tokens.add(ltok)
|
||||
|
||||
# Invariant 3: full coverage of the matched tokens, at the finest
|
||||
# transfer granularity (the remote kernel block).
|
||||
needed = {t for t in range(0, matched - matched % remote_kernel, remote_kernel)}
|
||||
missing = needed - covered_tokens
|
||||
assert not missing, (
|
||||
f"tokens never transferred: {sorted(missing)[:8]} "
|
||||
f"(geometry local_block={local_block}, remote_block={remote_block}, "
|
||||
f"N={num_tokens}, matched={matched})"
|
||||
)
|
||||
|
||||
# Invariant 4: no stale bytes after receive completion. The scheduler
|
||||
# excludes the blocks covering the matched tokens from alloc-time KV
|
||||
# zeroing (the zeroing would race the RDMA write), so every byte of
|
||||
# those blocks must be either written by the transfer or zeroed by the
|
||||
# receive post-process. Stale bytes surface as mid-response garbage
|
||||
# once decode grows into the untransferred tail.
|
||||
for op, lh, lids, rh, rids in nixl.xfers:
|
||||
larr = nixl.dlists[lh]
|
||||
for li in lids:
|
||||
addr, length, _ = (int(x) for x in larr[int(li)])
|
||||
for t in worker._test_tensors:
|
||||
off = addr - t.data_ptr()
|
||||
if 0 <= off < t.numel():
|
||||
t[off : off + length] = 0 # simulate the RDMA write
|
||||
break
|
||||
done_sending, done_recving = worker.get_finished()
|
||||
assert "req-b" in done_recving
|
||||
n_excluded = -(-matched // local_block)
|
||||
stale = []
|
||||
for b in local_attn[:n_excluded]:
|
||||
for region, t in enumerate(worker._test_tensors):
|
||||
page = t[b * local_unified : (b + 1) * local_unified]
|
||||
n_stale = int((page == 0xAA).sum())
|
||||
if n_stale:
|
||||
stale.append((region, b, n_stale))
|
||||
assert not stale, (
|
||||
f"stale (unzeroed, untransferred) bytes in matched-range attention "
|
||||
f"blocks (region, block, bytes): {stale} "
|
||||
f"(geometry local_block={local_block}, remote_block={remote_block}, "
|
||||
f"N={num_tokens}, matched={matched})"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
@pytest.mark.parametrize(
|
||||
"local_block,remote_block",
|
||||
[
|
||||
(12, 8), # ppl 3 vs 2
|
||||
(36, 8), # ppl 9 vs 2 (large ppl asymmetry, scaled)
|
||||
(24, 4), # ppl 6 vs 1
|
||||
(16, 24), # remote larger than local (D_TP > P_TP direction)
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("num_tokens", list(range(2, 40)))
|
||||
def test_hetero_ppl_token_alignment_sweep(local_block, remote_block, num_tokens):
|
||||
"""Sweep prompt lengths across block-boundary residues for several
|
||||
hetero-ppl geometries; assert neighbor-safety, token alignment, and
|
||||
coverage of every transferred kernel block."""
|
||||
_run_hetero_case(
|
||||
local_block, kernel=4, remote_block=remote_block, num_tokens=num_tokens
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
@pytest.mark.parametrize(
|
||||
"num_tokens",
|
||||
# Residues around the remote kernel block (4), the local kernel block
|
||||
# (8), the remote logical block (8) and the local logical block (24).
|
||||
[2, 5, 8, 9, 13, 16, 17, 21, 24, 25, 29, 32, 33, 41, 48, 49],
|
||||
)
|
||||
def test_hetero_ppl_with_block_size_ratio(num_tokens):
|
||||
"""Both hetero regimes at once: kernel blocks differ (local 8 / remote
|
||||
4, block_size_ratio=2) *and* physical_blocks_per_logical differs (3 vs
|
||||
2). The transfer is clipped at remote sub-block granularity by the
|
||||
pairing and front-trimmed by _apply_prefix_caching, so the
|
||||
untransferred tail can span both a partial block and whole blocks —
|
||||
the case each of the two former zeroing paths handled only half of."""
|
||||
_run_hetero_case(
|
||||
local_block=24,
|
||||
kernel=8,
|
||||
remote_block=8,
|
||||
remote_kernel=4,
|
||||
num_tokens=num_tokens,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cpu_test
|
||||
@pytest.mark.parametrize(
|
||||
"num_tokens",
|
||||
# Residues around every geometric boundary: kernel block (64), remote
|
||||
# logical block (768), local logical block (5760), plus odd offsets.
|
||||
[
|
||||
2,
|
||||
63,
|
||||
64,
|
||||
65,
|
||||
127,
|
||||
128,
|
||||
300,
|
||||
640,
|
||||
767,
|
||||
768,
|
||||
769,
|
||||
831,
|
||||
832,
|
||||
1000,
|
||||
1535,
|
||||
1536,
|
||||
1537,
|
||||
2303,
|
||||
2304,
|
||||
2305,
|
||||
3001,
|
||||
5759,
|
||||
5760,
|
||||
5761,
|
||||
5824,
|
||||
6528,
|
||||
6529,
|
||||
],
|
||||
)
|
||||
def test_mla_hybrid_large_ppl_geometry(num_tokens):
|
||||
"""KimiLinear-scale MLA-hybrid geometry (TP8 prefill -> TP1 decode):
|
||||
decode (local) logical block 5760 / kernel 64 (ppl=90), prefill
|
||||
(remote) logical block 768 (ppl=12), tp_ratio=-8 multi-read with
|
||||
replicated MLA and 8-way TP-sharded KDA state."""
|
||||
_run_hetero_case(
|
||||
local_block=5760,
|
||||
kernel=64,
|
||||
remote_block=768,
|
||||
num_tokens=num_tokens,
|
||||
tp_size=8,
|
||||
)
|
||||
@@ -161,3 +161,57 @@ class TestMambaPlanSplitHandles:
|
||||
# FA: chunk=200//1=200, slot=0 (skip_fa) → (1000, 200, 0), (2000, 200, 0)
|
||||
# SSM: chunk=400//2=200, idx=1 → (3200, 200, 0)
|
||||
assert splits[1] == [(1000, 200, 0), (2000, 200, 0), (3200, 200, 0)]
|
||||
|
||||
def test_hetero_block_size_splits(self):
|
||||
"""With a block-size ratio, single-source FA sub-block descs pass
|
||||
through whole; SSM descs are unexpanded and split per source."""
|
||||
plan = TPMapping(
|
||||
source_ranks_per_group=((0,), (0, 1)),
|
||||
all_source_ranks=(0, 1),
|
||||
rank_to_attention_slot={0: 0, 1: 0},
|
||||
rank_offset_factor=0,
|
||||
)
|
||||
|
||||
worker = _make_mock_worker_for_splits((FullAttentionSpec, MambaSpec))
|
||||
# 2 FA blocks x ratio 2 sub-blocks + 1 SSM desc (never expanded).
|
||||
src_blocks_data = np.array(
|
||||
[
|
||||
(1000, 100, 0),
|
||||
(1100, 100, 0),
|
||||
(2000, 100, 0),
|
||||
(2100, 100, 0),
|
||||
(3000, 400, 0),
|
||||
],
|
||||
dtype=np.uint64,
|
||||
)
|
||||
|
||||
splits = list(worker._build_local_splits_from_plan(plan, src_blocks_data, 4, 2))
|
||||
|
||||
assert len(splits) == 2
|
||||
fa_passthrough = [
|
||||
(1000, 100, 0),
|
||||
(1100, 100, 0),
|
||||
(2000, 100, 0),
|
||||
(2100, 100, 0),
|
||||
]
|
||||
assert splits[0] == fa_passthrough + [(3000, 200, 0)]
|
||||
assert splits[1] == fa_passthrough + [(3200, 200, 0)]
|
||||
|
||||
def test_hetero_block_size_head_sharded_asserts(self):
|
||||
"""Head-sharded FA reads (multiple FA sources) are incompatible with
|
||||
a block-size mismatch and must fail loudly."""
|
||||
plan = TPMapping(
|
||||
source_ranks_per_group=((0, 1), (0, 1)),
|
||||
all_source_ranks=(0, 1),
|
||||
rank_to_attention_slot={0: 0, 1: 1},
|
||||
rank_offset_factor=0,
|
||||
)
|
||||
|
||||
worker = _make_mock_worker_for_splits((FullAttentionSpec, MambaSpec))
|
||||
src_blocks_data = np.array(
|
||||
[(1000, 100, 0), (1100, 100, 0), (3000, 400, 0)],
|
||||
dtype=np.uint64,
|
||||
)
|
||||
|
||||
with pytest.raises(AssertionError, match="Head-sharded"):
|
||||
list(worker._build_local_splits_from_plan(plan, src_blocks_data, 2, 2))
|
||||
|
||||
@@ -12,6 +12,7 @@ import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from functools import cached_property
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import msgspec
|
||||
@@ -67,6 +68,7 @@ from vllm.distributed.parallel_state import (
|
||||
from vllm.logger import init_logger
|
||||
from vllm.platforms import current_platform
|
||||
from vllm.utils.network_utils import make_zmq_path
|
||||
from vllm.utils.torch_utils import async_tensor_h2d
|
||||
from vllm.v1.attention.backends.utils import get_kv_cache_layout
|
||||
from vllm.v1.kv_cache_interface import (
|
||||
FullAttentionSpec,
|
||||
@@ -122,9 +124,11 @@ class NixlBaseConnectorWorker:
|
||||
return (region_ids * num_blocks + block_arr).flatten()
|
||||
|
||||
# Compute desc ids per group using the right stride: FA descs have
|
||||
# num_blocks entries per region (kernel granularity), SSM descs have
|
||||
# logical_blocks entries per region (no kernel splitting).
|
||||
logical_blocks = num_blocks // physical_blocks_per_logical
|
||||
# num_blocks entries per region (kernel granularity, expanded by
|
||||
# block_size_ratio for heterogeneous block sizes), SSM descs have
|
||||
# logical_blocks entries per region (no kernel splitting, and never
|
||||
# ratio-expanded since state blocks are indivisible).
|
||||
logical_blocks = dst_num_blocks // physical_blocks_per_logical
|
||||
all_descs: list[np.ndarray] = []
|
||||
for i, group in enumerate(block_ids):
|
||||
group_arr = np.asarray(group)
|
||||
@@ -161,6 +165,7 @@ class NixlBaseConnectorWorker:
|
||||
plan: TPMapping,
|
||||
src_blocks_data: np.ndarray,
|
||||
num_fa_descs: int,
|
||||
block_size_ratio: int = 1,
|
||||
) -> Iterator[list[tuple[int, int, int]]]:
|
||||
"""Build split handle data for P_TP > D_TP scenario.
|
||||
|
||||
@@ -187,6 +192,11 @@ class NixlBaseConnectorWorker:
|
||||
|
||||
# Per-FA-descriptor replicate flag, in _build_fa_local emission order.
|
||||
fa_desc_replicated = self._fa_desc_replicated(num_fa_descs)
|
||||
|
||||
assert block_size_ratio == 1 or fa_num_splits == 1 or all(fa_desc_replicated), (
|
||||
"Head-sharded attention reads with P_TP > D_TP and heterogeneous "
|
||||
"block sizes are not supported"
|
||||
)
|
||||
src_blocks_list = src_blocks_data.tolist()
|
||||
|
||||
for p_idx, p_rank in enumerate(plan.all_source_ranks):
|
||||
@@ -442,9 +452,12 @@ class NixlBaseConnectorWorker:
|
||||
|
||||
# nixl_prepped_dlist_handle.
|
||||
self.src_xfer_handles_by_block_size: dict[int, int] = {}
|
||||
# Local descriptor arrays per remote block size (block_size_ratio>1),
|
||||
# kept for building per-tp-ratio splits at the same granularity.
|
||||
self.src_blocks_data_by_block_size: dict[int, np.ndarray] = {}
|
||||
# Populated dynamically during handshake based on remote configuration.
|
||||
# Keep track of regions at different tp_ratio values. tp_ratio->handles
|
||||
self.src_xfer_handles_by_tp_ratio: dict[int, list[int]] = {}
|
||||
# Per-source split handles, keyed by (tp_ratio, remote_block_size).
|
||||
self.src_xfer_handles_by_tp_ratio: dict[tuple[int, int], list[int]] = {}
|
||||
# Map of engine_id -> {tp_rank: nixl_prepped_dlist_handle (int)}.
|
||||
self.dst_xfer_side_handles = defaultdict[EngineId, dict[int, int]](dict)
|
||||
|
||||
@@ -1258,11 +1271,7 @@ class NixlBaseConnectorWorker:
|
||||
agent_metadata_bytes=encoder.encode(agent_metadata),
|
||||
)
|
||||
|
||||
def _build_mamba_local(
|
||||
self,
|
||||
base_addresses: list[int],
|
||||
block_size_ratio: int,
|
||||
) -> np.ndarray:
|
||||
def _build_mamba_local(self, base_addresses: list[int]) -> np.ndarray:
|
||||
"""Build desc regions (conv sub-projections + ssm) per layer for
|
||||
local mamba blocks with DS conv layout, as an Nx3 uint64 array.
|
||||
|
||||
@@ -1289,16 +1298,17 @@ class NixlBaseConnectorWorker:
|
||||
| Key N-1 | Val N-1 | |Conv N-1| SSM N-1 |
|
||||
+-------------------+ +--------------------+
|
||||
|1st_split-2nd_split| |1st_split-2nd_split |
|
||||
|
||||
Mamba state blocks are indivisible (not token-extent data), so the
|
||||
descriptors always use the local page geometry regardless of any
|
||||
attention block-size ratio; their desc ids are likewise never
|
||||
ratio-expanded (see _compute_desc_ids).
|
||||
"""
|
||||
assert block_size_ratio == 1, (
|
||||
"Mamba 3-read transfer with block_size_ratio != 1 is not tested. "
|
||||
f"Got block_size_ratio={block_size_ratio}."
|
||||
)
|
||||
assert base_addresses, "Local KV cache base addresses must not be empty."
|
||||
assert self._conv_decomp is not None
|
||||
conv_offsets = self._conv_decomp.local_conv_offsets
|
||||
conv_size, ssm_size = self._mamba_ssm_size
|
||||
num_blocks = self._logical_num_blocks * block_size_ratio
|
||||
num_blocks = self._logical_num_blocks
|
||||
physical_per_logical = self._physical_blocks_per_logical_kv_block
|
||||
device_id = self.device_id
|
||||
block_arange = np.arange(num_blocks, dtype=np.uint64)
|
||||
@@ -1307,9 +1317,7 @@ class NixlBaseConnectorWorker:
|
||||
for i, base_addr in enumerate(base_addresses):
|
||||
# Jump one page_size, but ssm page_size may be bigger when kernel
|
||||
# locks block size to a specific value (physical_per_logical scale).
|
||||
page_stride = (
|
||||
self.block_len_per_layer[i] // block_size_ratio * physical_per_logical
|
||||
)
|
||||
page_stride = self.block_len_per_layer[i] * physical_per_logical
|
||||
blk_addrs = base_addr + block_arange * page_stride
|
||||
for off, sz in conv_offsets:
|
||||
parts.append(self._stack_descs(blk_addrs + off, sz, device_id))
|
||||
@@ -1459,7 +1467,7 @@ class NixlBaseConnectorWorker:
|
||||
self.device_id,
|
||||
)
|
||||
if self._has_mamba:
|
||||
assert self.num_descs == len(blocks_data)
|
||||
assert self.num_descs * block_size_ratio == len(blocks_data)
|
||||
# TODO (ZhanqiuHu): For homogeneous TP (tp_ratio == 1), the 3-descs split
|
||||
# is unnecessary — a single conv desc per block suffices. Consider
|
||||
# adding a fast path that falls back to the standard 2-region
|
||||
@@ -1467,7 +1475,7 @@ class NixlBaseConnectorWorker:
|
||||
# remote has been seen. Currently we always register 4 regions
|
||||
# because local descs are created before knowing the remote TP.
|
||||
logger.debug("Registering local Mamba descriptors (4 regions/layer)")
|
||||
mamba = self._build_mamba_local(local_base_addresses, block_size_ratio)
|
||||
mamba = self._build_mamba_local(local_base_addresses)
|
||||
blocks_data = np.concatenate([blocks_data, mamba])
|
||||
|
||||
descs = self.nixl_wrapper.get_xfer_descs(blocks_data, self.nixl_memory_type)
|
||||
@@ -1607,29 +1615,44 @@ class NixlBaseConnectorWorker:
|
||||
|
||||
plan = self.tp_mappings[engine_id]
|
||||
|
||||
### (Optional) Register a local handler at the remote engine's block
|
||||
### granularity (remote/prefill blocks smaller than local).
|
||||
remote_block_size = nixl_agent_meta.block_size
|
||||
src_blocks_data = self.src_blocks_data
|
||||
if block_size_ratio > 1:
|
||||
if remote_block_size not in self.src_xfer_handles_by_block_size:
|
||||
handle, blocks_data = self.register_local_xfer_handler(
|
||||
remote_block_size
|
||||
)
|
||||
self.src_xfer_handles_by_block_size[remote_block_size] = handle
|
||||
self.src_blocks_data_by_block_size[remote_block_size] = blocks_data
|
||||
src_blocks_data = self.src_blocks_data_by_block_size[remote_block_size]
|
||||
|
||||
### (Optional) Register local agent memory regions. MLA is not split.
|
||||
split_key = (tp_ratio, remote_block_size)
|
||||
if (
|
||||
tp_ratio < 0
|
||||
and (not self.use_mla or len(plan.all_source_ranks) > 1)
|
||||
and tp_ratio not in self.src_xfer_handles_by_tp_ratio
|
||||
and split_key not in self.src_xfer_handles_by_tp_ratio
|
||||
):
|
||||
# Remote tp_size > local tp_size: read from multiple remote ranks.
|
||||
# Logically "split" own regions into per-source chunks. Hybrid
|
||||
# MLA+SSM also needs this path: MLA is replicated and read once,
|
||||
# while the SSM state is sharded across every remote TP rank.
|
||||
# We only do this once per remote tp_size (replica-friendly).
|
||||
self.src_xfer_handles_by_tp_ratio[tp_ratio] = []
|
||||
# We only do this once per remote (tp_size, block_size).
|
||||
self.src_xfer_handles_by_tp_ratio[split_key] = []
|
||||
|
||||
for handle_data in self._build_local_splits_from_plan(
|
||||
plan,
|
||||
self.src_blocks_data,
|
||||
self.num_descs,
|
||||
src_blocks_data,
|
||||
self.num_descs * block_size_ratio,
|
||||
block_size_ratio,
|
||||
):
|
||||
descs = self.nixl_wrapper.get_xfer_descs(
|
||||
handle_data, self.nixl_memory_type
|
||||
)
|
||||
handle = self.nixl_wrapper.prep_xfer_dlist("NIXL_INIT_AGENT", descs)
|
||||
self.src_xfer_handles_by_tp_ratio[tp_ratio].append(handle)
|
||||
self.src_xfer_handles_by_tp_ratio[split_key].append(handle)
|
||||
|
||||
### Register remote agent memory regions
|
||||
# With homogeneous TP, D pulls the whole kv cache from corresponding rank. With
|
||||
@@ -1665,13 +1688,6 @@ class NixlBaseConnectorWorker:
|
||||
self.nixl_wrapper.prep_xfer_dlist(remote_agent_name, descs)
|
||||
)
|
||||
|
||||
if block_size_ratio > 1:
|
||||
# when prefill with smaller block_size, we need to init a
|
||||
# new handler with same block_len to match
|
||||
self.src_xfer_handles_by_block_size[nixl_agent_meta.block_size] = (
|
||||
self.register_local_xfer_handler(nixl_agent_meta.block_size)[0]
|
||||
)
|
||||
|
||||
return remote_agent_name
|
||||
|
||||
def _validate_remote_agent_handshake(
|
||||
@@ -1716,9 +1732,13 @@ class NixlBaseConnectorWorker:
|
||||
"Disable prefix caching with --no-enable-prefix-caching."
|
||||
)
|
||||
|
||||
if self._is_hma_required:
|
||||
assert block_size_ratio == 1, (
|
||||
"HMA does not support different remote block size yet"
|
||||
if block_size_ratio != 1:
|
||||
# Heterogeneous block sizes transfer at remote-block granularity;
|
||||
# the untransferred tail of the last local attention block is
|
||||
# zeroed in the receive post-process, and mamba state pages
|
||||
# transfer 1:1 (never sub-split).
|
||||
assert not self.use_host_buffer, (
|
||||
"Heterogeneous block sizes are not supported with host buffer"
|
||||
)
|
||||
kv_cache_layout = (
|
||||
self.kv_cache_layout
|
||||
@@ -1875,27 +1895,57 @@ class NixlBaseConnectorWorker:
|
||||
"d2h",
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def _attention_kv_caches(self) -> list[torch.Tensor]:
|
||||
"""Device KV caches of attention layers (mamba states excluded),
|
||||
as consumed by the receive post-process."""
|
||||
assert self.device_kv_caches, (
|
||||
"_attention_kv_caches accessed before register_kv_caches"
|
||||
)
|
||||
mamba_layers = {
|
||||
name
|
||||
for g, group in enumerate(self.kv_cache_config.kv_cache_groups)
|
||||
if _is_ssm_spec(self._group_spec_types[g])
|
||||
for name in group.layer_names
|
||||
}
|
||||
kv_caches = self.device_kv_caches
|
||||
return [cache for name, cache in kv_caches.items() if name not in mamba_layers]
|
||||
|
||||
def post_process_device_kv_on_receive(
|
||||
self,
|
||||
block_size_ratio: int,
|
||||
block_ids_list: list[list[int]],
|
||||
block_ids_list: list[tuple[list[int], int]],
|
||||
convert: bool = True,
|
||||
):
|
||||
"""
|
||||
Post process device kv cache after receiving from remote.
|
||||
|
||||
3 types of post processing supported:
|
||||
3 types of conversion supported (``convert``):
|
||||
* kv_cache_postprocess_layout => convert from HND to NHD
|
||||
* kv_cache_postprocess_blksize => convert from small block size
|
||||
to large block size
|
||||
* kv_cache_postprocess_blksize_and_layout => convert from small
|
||||
block size to large block size and convert from HND to NHD
|
||||
|
||||
The transfer only covers ``covered_sub_blocks`` remote-sized
|
||||
sub-blocks of each request's local attention blocks; the rest was
|
||||
clipped, either by remote-block pairing (block-size ratio) or by the
|
||||
hetero-ppl front trim in ``_apply_prefix_caching``. Those blocks were
|
||||
excluded from the scheduler's alloc-time KV zeroing (which would race
|
||||
the RDMA write), so everything past the covered range is zeroed here.
|
||||
Stale bytes would otherwise surface as garbage or NaNs once decode
|
||||
grows into the untransferred tail.
|
||||
"""
|
||||
if len(self.device_kv_caches) == 0:
|
||||
return
|
||||
assert block_size_ratio >= 1, "Only nP < nD supported currently."
|
||||
assert self.transfer_topo is not None
|
||||
if self.enable_permute_local_kv and block_size_ratio > 1:
|
||||
if not convert:
|
||||
logger.debug(
|
||||
"Post-processing device kv cache on receive by zeroing "
|
||||
"untransferred blocks."
|
||||
)
|
||||
elif self.enable_permute_local_kv and block_size_ratio > 1:
|
||||
logger.debug(
|
||||
"Post-processing device kv cache on receive by converting "
|
||||
"block_size with %sx bigger and permuting layout from HND"
|
||||
@@ -1914,18 +1964,45 @@ class NixlBaseConnectorWorker:
|
||||
block_size_ratio,
|
||||
)
|
||||
|
||||
for block_ids in block_ids_list:
|
||||
indices = torch.tensor(block_ids, device=self.device_type, dtype=torch.long)
|
||||
attn_caches = self._attention_kv_caches
|
||||
device = attn_caches[0].device
|
||||
for block_ids, covered_sub_blocks in block_ids_list:
|
||||
# Blocks the transfer didn't write: the token tail of the last
|
||||
# partially covered block, then everything beyond it.
|
||||
covered_blocks, sub_blocks_in_last = divmod(
|
||||
covered_sub_blocks, block_size_ratio
|
||||
)
|
||||
first_stale = covered_blocks + (1 if sub_blocks_in_last else 0)
|
||||
has_stale = first_stale < len(block_ids)
|
||||
indices = None
|
||||
if convert or has_stale:
|
||||
indices = async_tensor_h2d(block_ids, device, torch.long)
|
||||
|
||||
for cache in self.device_kv_caches.values():
|
||||
if self.enable_permute_local_kv and block_size_ratio > 1:
|
||||
kv_postprocess_blksize_and_layout_on_receive(
|
||||
cache, indices, block_size_ratio
|
||||
)
|
||||
elif self.enable_permute_local_kv:
|
||||
kv_postprocess_layout_on_receive(cache, indices)
|
||||
else:
|
||||
kv_postprocess_blksize_on_receive(cache, indices, block_size_ratio)
|
||||
if convert:
|
||||
for cache in attn_caches:
|
||||
if self.enable_permute_local_kv and block_size_ratio > 1:
|
||||
kv_postprocess_blksize_and_layout_on_receive(
|
||||
cache, indices, block_size_ratio
|
||||
)
|
||||
elif self.enable_permute_local_kv:
|
||||
kv_postprocess_layout_on_receive(cache, indices)
|
||||
else:
|
||||
kv_postprocess_blksize_on_receive(
|
||||
cache, indices, block_size_ratio
|
||||
)
|
||||
|
||||
if sub_blocks_in_last:
|
||||
last_block_id = block_ids[covered_blocks]
|
||||
for cache in attn_caches:
|
||||
# Both post-processed layouts leave tokens on dim 1.
|
||||
sub_block_tokens = cache.shape[1] // block_size_ratio
|
||||
zero_from = sub_blocks_in_last * sub_block_tokens
|
||||
cache[last_block_id, zero_from:].zero_()
|
||||
if has_stale:
|
||||
assert indices is not None
|
||||
stale_ids = indices[first_stale:]
|
||||
for cache in attn_caches:
|
||||
cache.index_fill_(0, stale_ids, 0)
|
||||
|
||||
def post_process_device_kv_on_receive_heterogeneous_attn(
|
||||
self, block_ids: list[int]
|
||||
@@ -1995,18 +2072,33 @@ class NixlBaseConnectorWorker:
|
||||
if self.use_host_buffer:
|
||||
self.sync_recved_kv_to_device(req_id, meta)
|
||||
|
||||
# post processing for heteroblocksize
|
||||
# Post processing for heteroblocksize/layout, and for blocks the
|
||||
# transfer clipped. The latter happens either at remote-block
|
||||
# granularity (block_size_ratio > 1) or at kernel-block
|
||||
# granularity, when equal kernel pages meet differing logical
|
||||
# block sizes and _apply_prefix_caching front-trims to the
|
||||
# minimum count (hybrid heterogeneous TP).
|
||||
remote_info = self.transfer_topo.get_engine_info(meta.remote.engine_id)
|
||||
block_size_ratio = self.transfer_topo.block_size_ratio(
|
||||
remote_info.remote_block_size
|
||||
)
|
||||
if not self.use_mla and (
|
||||
block_size_ratio > 1 or self.enable_permute_local_kv
|
||||
):
|
||||
assert not self._is_hma_required
|
||||
block_ids_for_blocksize_post_process[block_size_ratio].append(
|
||||
meta.local_physical_block_ids[0]
|
||||
)
|
||||
hetero_ppl = (
|
||||
remote_info.remote_physical_blocks_per_logical
|
||||
!= self._physical_blocks_per_logical_kv_block
|
||||
)
|
||||
if block_size_ratio > 1 or self.enable_permute_local_kv or hetero_ppl:
|
||||
for g, local_group in enumerate(meta.local_physical_block_ids):
|
||||
if not local_group or _is_ssm_spec(self._group_spec_types[g]):
|
||||
continue
|
||||
# Number of remote-sized sub-blocks the transfer covered;
|
||||
# everything past this was clipped and must be zeroed.
|
||||
covered_sub_blocks = min(
|
||||
len(local_group) * block_size_ratio,
|
||||
len(meta.remote.block_ids[g]),
|
||||
)
|
||||
block_ids_for_blocksize_post_process[block_size_ratio].append(
|
||||
(local_group, covered_sub_blocks)
|
||||
)
|
||||
# post processing for heterogeneous attention
|
||||
if self.enable_heterogeneous_attn_post_process:
|
||||
block_ids_for_heterogeneous_attn_post_process.append(
|
||||
@@ -2016,7 +2108,14 @@ class NixlBaseConnectorWorker:
|
||||
block_size_ratio,
|
||||
block_ids_list,
|
||||
) in block_ids_for_blocksize_post_process.items():
|
||||
self.post_process_device_kv_on_receive(block_size_ratio, block_ids_list)
|
||||
# MLA never needs the block-size/layout conversion, but its
|
||||
# clipped blocks still need zeroing.
|
||||
convert = not self.use_mla and (
|
||||
block_size_ratio > 1 or self.enable_permute_local_kv
|
||||
)
|
||||
self.post_process_device_kv_on_receive(
|
||||
block_size_ratio, block_ids_list, convert
|
||||
)
|
||||
|
||||
for block_ids in block_ids_for_heterogeneous_attn_post_process:
|
||||
self.post_process_device_kv_on_receive_heterogeneous_attn(block_ids)
|
||||
@@ -2206,6 +2305,45 @@ class NixlBaseConnectorWorker:
|
||||
|
||||
return mapped_2d.flatten().astype(np.int64)
|
||||
|
||||
def _map_block_ids_for_block_size_ratio(
|
||||
self,
|
||||
local_block_ids: BlockIds,
|
||||
remote_block_ids: BlockIds,
|
||||
block_size_ratio: int,
|
||||
) -> tuple[BlockIds, BlockIds]:
|
||||
"""Map attention-group block ids to remote-block granularity.
|
||||
|
||||
Each local attention block is split into ``block_size_ratio``
|
||||
sub-blocks paired 1:1 with remote blocks. Sub-blocks beyond the
|
||||
remote list — the untransferred tail of the last local block — are
|
||||
clipped here and zeroed in the receive post-process. Mamba state
|
||||
blocks are indivisible and transfer 1:1, unexpanded.
|
||||
|
||||
ex: remote (prefill) block ids with block_size 4:
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
Local (decode) block ids with block_size 16: [1, 2, 3] expand to
|
||||
[4, 5, ..., 15], then clip to the first 10 to pair 1:1 with remote.
|
||||
"""
|
||||
mapped_local: list[list[int]] = []
|
||||
mapped_remote: list[list[int]] = []
|
||||
for i, remote_group in enumerate(remote_block_ids):
|
||||
local_group = local_block_ids[i] if local_block_ids else []
|
||||
if _is_ssm_spec(self._group_spec_types[i]):
|
||||
mapped_local.append(list(local_group))
|
||||
mapped_remote.append(list(remote_group))
|
||||
continue
|
||||
mapped = self.get_mapped_blocks(
|
||||
np.asarray(local_group), block_size_ratio
|
||||
).tolist()
|
||||
if len(mapped) > len(remote_group):
|
||||
mapped = mapped[: len(remote_group)]
|
||||
mapped_local.append(mapped)
|
||||
mapped_remote.append(list(remote_group))
|
||||
if not any(mapped_local):
|
||||
# Full prefix cache hit is indicated with an empty list.
|
||||
return [], mapped_remote
|
||||
return mapped_local, mapped_remote
|
||||
|
||||
def _logical_to_kernel_block_ids(self, block_ids: BlockIds, ratio: int) -> BlockIds:
|
||||
"""
|
||||
Convert block ids to kernel physical block ids.
|
||||
@@ -2300,13 +2438,18 @@ class NixlBaseConnectorWorker:
|
||||
remote_block_ids[i] = remote_group[-num_local_blocks:]
|
||||
else:
|
||||
# TODO Handle prefix caching with different block_sizes
|
||||
max_padding = max(
|
||||
self._physical_blocks_per_logical_kv_block,
|
||||
remote_physical_per_logical,
|
||||
# Allocation rounding legitimately leaves up to
|
||||
# ppl - 1 trailing dead kernel blocks per side (plus one
|
||||
# extra local block for the recomputed final token), so
|
||||
# the counts may differ by up to the sum of the two
|
||||
# ratios; anything larger indicates mismatched lists.
|
||||
max_padding = (
|
||||
self._physical_blocks_per_logical_kv_block
|
||||
+ remote_physical_per_logical
|
||||
)
|
||||
assert abs(num_local_blocks - num_remote_blocks) < max_padding, (
|
||||
assert abs(num_local_blocks - num_remote_blocks) <= max_padding, (
|
||||
f"Group {i}: |{num_local_blocks} - "
|
||||
f"{num_remote_blocks}| >= {max_padding}"
|
||||
f"{num_remote_blocks}| > {max_padding}"
|
||||
)
|
||||
num_blocks = min(num_local_blocks, num_remote_blocks)
|
||||
local_block_ids[i] = local_block_ids[i][:num_blocks]
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker import (
|
||||
NixlBaseConnectorWorker,
|
||||
)
|
||||
@@ -178,10 +176,10 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
|
||||
)
|
||||
# Get side handles.
|
||||
if tp_ratio < 0 and (not self.use_mla or len(read_specs) > 1):
|
||||
assert remote_block_size == self.block_size
|
||||
# Remote tp_size > local tp_size: we must perform multiple
|
||||
# reads. Get the memory chunk onto which we will write to.
|
||||
local_xfer_side_handle = self.src_xfer_handles_by_tp_ratio[tp_ratio][i]
|
||||
split_key = (tp_ratio, remote_block_size)
|
||||
local_xfer_side_handle = self.src_xfer_handles_by_tp_ratio[split_key][i]
|
||||
else:
|
||||
# Single read from remote, we write to the whole memory region.
|
||||
# Also handle remote block size different from local block size.
|
||||
@@ -235,30 +233,11 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
|
||||
remote_info.remote_block_size
|
||||
)
|
||||
if block_size_ratio > 1:
|
||||
# TODO (NickLucche) assume HMA is off. Change to handle multiple KV groups.
|
||||
assert not self._is_hma_required
|
||||
local_block_ids0 = local_block_ids[0] if local_block_ids else []
|
||||
remote_block_ids0 = remote_block_ids[0]
|
||||
local_block_ids_mapped = self.get_mapped_blocks(
|
||||
np.asarray(local_block_ids0), block_size_ratio
|
||||
).tolist()
|
||||
if len(local_block_ids_mapped) > len(remote_block_ids0):
|
||||
# NOTE:
|
||||
# get_mapped_blocks will always expand block_ids for n times.
|
||||
# ex:
|
||||
# prefill block_ids with block_size as 4:
|
||||
# [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
# Local decode block_ids with block_size as 16: [1, 2, 3]
|
||||
# expanded decode block_ids with get_mapped_blocks from [1, 2, 3] to
|
||||
# [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
||||
# Then we clip local to align with prefill
|
||||
# [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] to
|
||||
# [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
||||
local_block_ids_mapped = local_block_ids_mapped[
|
||||
: len(remote_block_ids0)
|
||||
]
|
||||
local_block_ids = [local_block_ids_mapped] if local_block_ids_mapped else []
|
||||
remote_block_ids = [remote_block_ids0]
|
||||
local_block_ids, remote_block_ids = (
|
||||
self._map_block_ids_for_block_size_ratio(
|
||||
local_block_ids, remote_block_ids, block_size_ratio
|
||||
)
|
||||
)
|
||||
# NOTE(rob): having the staging blocks be on the READER side is
|
||||
# not going to work well (since we will have to call rearrange tensors).
|
||||
# after we detect the txn is complete (which means we cannot make the
|
||||
|
||||
@@ -39,7 +39,6 @@ from concurrent.futures import Future
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import msgspec
|
||||
import numpy as np
|
||||
|
||||
from vllm.distributed.kv_transfer.kv_connector.utils import BlockIds
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker import (
|
||||
@@ -553,8 +552,8 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
|
||||
req_id,
|
||||
)
|
||||
if tp_ratio < 0 and not self.use_mla:
|
||||
assert remote_block_size == self.block_size
|
||||
local_xfer_side_handle = self.src_xfer_handles_by_tp_ratio[tp_ratio][i]
|
||||
split_key = (tp_ratio, remote_block_size)
|
||||
local_xfer_side_handle = self.src_xfer_handles_by_tp_ratio[split_key][i]
|
||||
else:
|
||||
local_xfer_side_handle = self.src_xfer_handles_by_block_size[
|
||||
remote_block_size
|
||||
@@ -606,18 +605,11 @@ class NixlPushConnectorWorker(NixlBaseConnectorWorker):
|
||||
remote_info.remote_block_size
|
||||
)
|
||||
if block_size_ratio > 1:
|
||||
assert not self._is_hma_required
|
||||
local_block_ids0 = local_block_ids[0] if local_block_ids else []
|
||||
remote_block_ids0 = remote_block_ids[0]
|
||||
local_block_ids_mapped = self.get_mapped_blocks(
|
||||
np.asarray(local_block_ids0), block_size_ratio
|
||||
).tolist()
|
||||
if len(local_block_ids_mapped) > len(remote_block_ids0):
|
||||
local_block_ids_mapped = local_block_ids_mapped[
|
||||
: len(remote_block_ids0)
|
||||
]
|
||||
local_block_ids = [local_block_ids_mapped] if local_block_ids_mapped else []
|
||||
remote_block_ids = [remote_block_ids0]
|
||||
local_block_ids, remote_block_ids = (
|
||||
self._map_block_ids_for_block_size_ratio(
|
||||
local_block_ids, remote_block_ids, block_size_ratio
|
||||
)
|
||||
)
|
||||
|
||||
notif_id = f"{remote_request_id}:{self.world_size}".encode()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user