Files
vllm-omni/tests/distributed/omni_connectors/test_kv_flow.py
T
cbab1d4d8f [Rebase] Rebase to vllm 0.26.0 (#5443)
Signed-off-by: tzhouam <tzhouam@connect.ust.hk>
Signed-off-by: tzhouam <tzhouam@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: tzhouam <tzhouam@gmail.com>
2026-07-27 23:35:49 +08:00

609 lines
22 KiB
Python

import json
import struct
import pytest
import torch
import vllm_omni.distributed.omni_connectors.kv_transfer_manager as kv_transfer_manager_module
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import (
KVCacheTransferData,
OmniKVCacheConfig,
OmniKVTransferManager,
)
from vllm_omni.distributed.omni_connectors.utils.kv_utils import normalize_layer_kv
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.cache]
class MockConnector:
def __init__(self):
self.store = {}
def put(self, from_stage, to_stage, put_key, data):
# The manager now passes full key as put_key
key = f"{from_stage}->{to_stage}:{put_key}"
self.store[key] = data
return True, len(str(data)), None # (success, size, metadata)
def get(self, from_stage, to_stage, get_key, metadata=None):
# The manager now passes full key as get_key
key = f"{from_stage}->{to_stage}:{get_key}"
if key in self.store:
return self.store[key], len(str(self.store[key]))
return None
@pytest.fixture
def mock_connector():
return MockConnector()
@pytest.fixture
def kv_config():
return OmniKVCacheConfig(
connector_config={"type": "mock"},
from_stage="stage1",
to_stage="stage2",
stage_id="stage2", # Acting as receiver for some tests
need_recv_cache=True,
need_send_cache=True,
recv_timeout=1.0, # Short timeout for tests
)
@pytest.fixture
def common_constants():
return {
"num_layers": 2,
"num_heads": 4,
"head_dim": 16,
"block_size": 8,
"seq_len": 20,
"req_id": "req_test_1",
}
def _decode_stored_payload(data):
if isinstance(data, torch.Tensor) and data.dtype == torch.uint8 and data.dim() == 1:
return KVCacheTransferData.from_bytes(data.cpu().numpy().tobytes())
if isinstance(data, (bytes, bytearray, memoryview)):
return KVCacheTransferData.from_bytes(data)
return data
def _make_serialized_payload() -> tuple[bytes, torch.Tensor]:
key_tensor = torch.arange(12, dtype=torch.float32).reshape(3, 4)
payload = KVCacheTransferData(
request_id="req-payload",
layer_blocks={"key_cache": [key_tensor], "value_cache": [None]},
block_ids=[1],
metadata={"seq_len": 3},
).to_bytes()
return payload, key_tensor
def _rewrite_serialized_header(payload: bytes, mutate_header) -> bytes:
header_len = struct.unpack(">I", payload[:4])[0]
header = json.loads(payload[4 : 4 + header_len])
mutate_header(header)
new_header = json.dumps(header, separators=(",", ":")).encode("utf-8")
return struct.pack(">I", len(new_header)) + new_header + payload[4 + header_len :]
def test_manager_extraction(kv_config, mock_connector, common_constants):
"""Test extraction and sending logic in OmniKVTransferManager."""
num_layers = common_constants["num_layers"]
block_size = common_constants["block_size"]
num_heads = common_constants["num_heads"]
head_dim = common_constants["head_dim"]
seq_len = common_constants["seq_len"]
req_id = common_constants["req_id"]
num_blocks = 10
kv_caches = []
for _ in range(num_layers):
k_cache = torch.randn(num_blocks, block_size, num_heads, head_dim)
v_cache = torch.randn(num_blocks, block_size, num_heads, head_dim)
# Stack K and V to create [2, num_blocks, block_size, n_heads, head_dim]
layer_cache = torch.stack([k_cache, v_cache], dim=0)
kv_caches.append(layer_cache)
block_ids = [1, 3, 5]
finished_reqs = {req_id: {"block_ids": block_ids, "seq_len": seq_len}}
manager = OmniKVTransferManager(kv_config)
# Mock the connector factory or injection
manager._connector = mock_connector
processed = manager.handle_finished_requests_kv_transfer(finished_reqs, kv_caches, block_size, "float32")
assert req_id in processed
# Check if data was put into connector
# Manager builds full key: omni_{from}_to_{to}_kv_cache_{req_id}
full_request_id = f"omni_stage1_to_stage2_kv_cache_{req_id}"
expected_key = f"stage1->stage2:{full_request_id}"
assert expected_key in mock_connector.store
data = _decode_stored_payload(mock_connector.store[expected_key])
assert data["request_id"] == req_id
assert "layer_blocks" in data
assert len(data["layer_blocks"]["key_cache"]) == num_layers
# Verify shape of extracted tensor: [seq_len, heads, dim]
# Note: Manager detaches and moves to CPU
expected_shape = (seq_len, num_heads, head_dim)
assert data["layer_blocks"]["key_cache"][0].shape == expected_shape
def test_from_bytes_rejects_out_of_bounds_header_len():
payload, _ = _make_serialized_payload()
bad_payload = struct.pack(">I", len(payload)) + payload[4:]
with pytest.raises(ValueError, match="header_len"):
KVCacheTransferData.from_bytes(bad_payload)
with pytest.raises(ValueError, match="header_len"):
KVCacheTransferData.from_bytes_gpu(torch.tensor(list(bad_payload), dtype=torch.uint8))
def test_from_bytes_rejects_out_of_bounds_tensor_span():
payload, _ = _make_serialized_payload()
bad_payload = _rewrite_serialized_header(payload, lambda header: header["td"][0].update({"o": 4096}))
with pytest.raises(ValueError, match="tensor span"):
KVCacheTransferData.from_bytes(bad_payload)
with pytest.raises(ValueError, match="tensor span"):
KVCacheTransferData.from_bytes_gpu(torch.tensor(list(bad_payload), dtype=torch.uint8))
def test_from_bytes_rejects_unsupported_dtype():
payload, _ = _make_serialized_payload()
bad_payload = _rewrite_serialized_header(payload, lambda header: header["td"][0].update({"d": "cuda"}))
with pytest.raises(ValueError, match="Unsupported dtype"):
KVCacheTransferData.from_bytes(bad_payload)
with pytest.raises(ValueError, match="Unsupported dtype"):
KVCacheTransferData.from_bytes_gpu(torch.tensor(list(bad_payload), dtype=torch.uint8))
def test_from_bytes_uses_explicit_layer_index_descriptor():
payload, key_tensor = _make_serialized_payload()
payload_with_explicit_index = _rewrite_serialized_header(
payload,
lambda header: header["td"][0].update({"n": "key_cache_extra_suffix", "i": 0}),
)
data = KVCacheTransferData.from_bytes(payload_with_explicit_index)
assert torch.equal(data["layer_blocks"]["key_cache"][0], key_tensor)
def test_update_sender_info_uses_configured_source_stage():
config = OmniKVCacheConfig(
connector_config={"type": "mock"},
stage_id=2,
engine_input_source=[1],
need_recv_cache=True,
)
manager = OmniKVTransferManager(config)
manager.update_sender_info(
{
0: {"host": "10.0.0.1", "zmq_port": 50151},
1: {"host": "10.0.0.2", "zmq_port": 50152},
}
)
assert manager.config.connector_config["sender_host"] == "10.0.0.2"
assert manager.config.connector_config["sender_zmq_port"] == 50152
def test_receive_kv_cache_uses_exponential_backoff(monkeypatch):
config = OmniKVCacheConfig(
connector_config={"type": "mock"},
from_stage="sender",
stage_id="receiver",
need_recv_cache=True,
recv_timeout=0.3,
)
manager = OmniKVTransferManager(config)
class _NeverReadyConnector:
def get(self, **kwargs):
del kwargs
return None
manager._connector = _NeverReadyConnector()
now = {"value": 0.0}
sleep_intervals = []
monkeypatch.setattr(kv_transfer_manager_module.time, "time", lambda: now["value"])
def _fake_sleep(interval: float) -> None:
sleep_intervals.append(interval)
now["value"] += interval
monkeypatch.setattr(kv_transfer_manager_module.time, "sleep", _fake_sleep)
data, size = manager.receive_kv_cache_for_request("req-backoff")
assert (data, size) == (None, 0)
assert sleep_intervals == pytest.approx([0.01, 0.02, 0.04, 0.08, 0.16])
def test_manager_extraction_tuple_layout(kv_config, mock_connector, common_constants):
"""Test extraction with tuple layout."""
num_layers = common_constants["num_layers"]
block_size = common_constants["block_size"]
num_heads = common_constants["num_heads"]
head_dim = common_constants["head_dim"]
seq_len = common_constants["seq_len"]
req_id = common_constants["req_id"]
num_blocks = 10
kv_caches = []
for _ in range(num_layers):
k_cache = torch.randn(num_blocks, block_size, num_heads, head_dim)
v_cache = torch.randn(num_blocks, block_size, num_heads, head_dim)
kv_caches.append((k_cache, v_cache))
block_ids = [1, 3, 5]
finished_reqs = {req_id: {"block_ids": block_ids, "seq_len": seq_len}}
manager = OmniKVTransferManager(kv_config)
manager._connector = mock_connector
processed = manager.handle_finished_requests_kv_transfer(finished_reqs, kv_caches, block_size, "float32")
assert req_id in processed
full_request_id = f"omni_stage1_to_stage2_kv_cache_{req_id}"
expected_key = f"stage1->stage2:{full_request_id}"
assert expected_key in mock_connector.store
data = _decode_stored_payload(mock_connector.store[expected_key])
expected_shape = (seq_len, num_heads, head_dim)
for idx in range(len(kv_caches)):
assert data["layer_blocks"]["key_cache"][idx].shape == expected_shape
assert data["layer_blocks"]["value_cache"][idx].shape == expected_shape
def test_manager_extraction_mismatched_kv_block_counts(kv_config, mock_connector, common_constants):
"""Mismatched key/value block counts should not crash extraction."""
block_size = common_constants["block_size"]
num_heads = common_constants["num_heads"]
head_dim = common_constants["head_dim"]
req_id = common_constants["req_id"]
key_blocks = torch.randn(3, block_size, num_heads, head_dim)
value_blocks = torch.randn(2, block_size, num_heads, head_dim)
kv_caches = [(key_blocks, value_blocks)]
finished_reqs = {req_id: {"block_ids": [0, 1, 2], "seq_len": 32}}
manager = OmniKVTransferManager(kv_config)
manager._connector = mock_connector
processed = manager.handle_finished_requests_kv_transfer(finished_reqs, kv_caches, block_size, "float32")
assert req_id in processed
full_request_id = f"omni_stage1_to_stage2_kv_cache_{req_id}"
expected_key = f"stage1->stage2:{full_request_id}"
assert expected_key in mock_connector.store
data = _decode_stored_payload(mock_connector.store[expected_key])
expected_shape = (2 * block_size, num_heads, head_dim)
assert data["layer_blocks"]["key_cache"][0].shape == expected_shape
assert data["layer_blocks"]["value_cache"][0].shape == expected_shape
@pytest.mark.parametrize(
"invalid_case",
["invalid_stacked_shape", "invalid_tuple_length", "non_tensor_entries"],
)
def test_normalize_layer_kv_rejects_invalid_inputs(kv_config, common_constants, invalid_case):
"""_normalize_layer_kv should reject malformed KV representations."""
block_size = common_constants["block_size"]
num_heads = common_constants["num_heads"]
head_dim = common_constants["head_dim"]
req_id = common_constants["req_id"]
if invalid_case == "invalid_stacked_shape":
layer_kv = torch.randn(3, block_size, num_heads, head_dim)
elif invalid_case == "invalid_tuple_length":
layer_kv = (
torch.randn(2, block_size, num_heads, head_dim),
torch.randn(2, block_size, num_heads, head_dim),
torch.randn(2, block_size, num_heads, head_dim),
)
else:
layer_kv = (torch.randn(2, block_size, num_heads, head_dim), "not-a-tensor")
normalized = normalize_layer_kv(layer_kv, req_id=req_id, layer_idx=0)
assert normalized is None
def test_normalize_layer_kv_unpacks_packed_layout(common_constants):
"""The vLLM >= 0.23 packed layout (num_blocks, n_heads, block_size,
2*head_dim) is unpacked into (num_blocks, block_size, n_heads, head_dim)
key/value blocks when block_size is provided."""
block_size = common_constants["block_size"]
num_heads = common_constants["num_heads"]
head_dim = common_constants["head_dim"]
req_id = common_constants["req_id"]
num_blocks = 10
layer_kv = torch.randn(num_blocks, num_heads, block_size, 2 * head_dim)
# Without block_size the 4-D packed layout is not recognized.
assert normalize_layer_kv(layer_kv, req_id=req_id, layer_idx=0) is None
normalized = normalize_layer_kv(layer_kv, req_id=req_id, layer_idx=0, block_size=block_size)
assert normalized is not None
key_blocks, value_blocks = normalized
assert key_blocks.shape == (num_blocks, block_size, num_heads, head_dim)
assert value_blocks.shape == (num_blocks, block_size, num_heads, head_dim)
reference = layer_kv.transpose(1, 2)
assert torch.equal(key_blocks, reference[..., :head_dim])
assert torch.equal(value_blocks, reference[..., head_dim:])
def test_manager_reception(kv_config, mock_connector, common_constants):
"""Test reception and injection logic in OmniKVTransferManager."""
num_layers = common_constants["num_layers"]
block_size = common_constants["block_size"]
num_heads = common_constants["num_heads"]
head_dim = common_constants["head_dim"]
seq_len = common_constants["seq_len"]
req_id = common_constants["req_id"]
expected_shape = (seq_len, num_heads, head_dim)
key_cache = [torch.randn(expected_shape) for _ in range(num_layers)]
value_cache = [torch.randn(expected_shape) for _ in range(num_layers)]
layer_blocks = {"key_cache": key_cache, "value_cache": value_cache}
metadata = {
"block_size": block_size,
"num_layers": num_layers,
"dtype": "float32",
"seq_len": seq_len,
}
data_to_receive = {
"request_id": req_id,
"layer_blocks": layer_blocks,
"metadata": metadata,
"block_ids": [],
}
# In setUp, from_stage="stage1", stage_id="stage2". recv_stages=("stage1", "stage2")
manager = OmniKVTransferManager(kv_config)
manager._connector = mock_connector
# Pre-populate connector with data
# Manager builds full key: omni_{from}_to_{to}_kv_cache_{req_id}
full_request_id = f"omni_stage1_to_stage2_kv_cache_{req_id}"
store_key = f"stage1->stage2:{full_request_id}"
mock_connector.store[store_key] = data_to_receive
req = OmniDiffusionRequest(
prompt="test_recv",
sampling_params=OmniDiffusionSamplingParams(),
request_id=req_id,
)
# req.need_kv_receive = True # Implicitly handled by receive_kv_cache check? No, manager doesn't check it, runner does.
# But receive_kv_cache in manager checks request_id. Which we need to fix in manager next.
success = manager.receive_kv_cache(req, target_device=torch.device("cpu"))
assert success
assert hasattr(req, "past_key_values")
assert hasattr(req, "kv_metadata")
assert len(req.past_key_values.key_cache) == num_layers
assert torch.allclose(req.past_key_values.key_cache[0], key_cache[0])
assert req.kv_metadata["seq_len"] == seq_len
def test_manager_reception_prefers_parent_request_id_for_batched_request(kv_config, mock_connector, common_constants):
"""Batched diffusion requests must fetch KV using the parent/global request ID."""
num_layers = common_constants["num_layers"]
num_heads = common_constants["num_heads"]
head_dim = common_constants["head_dim"]
seq_len = common_constants["seq_len"]
parent_req_id = common_constants["req_id"]
expected_shape = (seq_len, num_heads, head_dim)
key_cache = [torch.randn(expected_shape) for _ in range(num_layers)]
value_cache = [torch.randn(expected_shape) for _ in range(num_layers)]
data_to_receive = {
"request_id": parent_req_id,
"layer_blocks": {"key_cache": key_cache, "value_cache": value_cache},
"metadata": {"seq_len": seq_len},
"block_ids": [],
}
manager = OmniKVTransferManager(kv_config)
manager._connector = mock_connector
full_request_id = f"omni_stage1_to_stage2_kv_cache_{parent_req_id}"
store_key = f"stage1->stage2:{full_request_id}"
mock_connector.store[store_key] = data_to_receive
req = OmniDiffusionRequest(
prompt="prompt-a",
sampling_params=OmniDiffusionSamplingParams(),
request_id=parent_req_id,
)
success = manager.receive_kv_cache(req, target_device=torch.device("cpu"))
assert success
assert req.kv_metadata["seq_len"] == seq_len
assert torch.allclose(req.past_key_values.key_cache[0], key_cache[0])
def test_receive_multi_kv_cache_uses_parent_request_id_for_cfg_collection(kv_config):
manager = OmniKVTransferManager(kv_config)
seen = {}
def collect_cfg(request_id, cfg_request_ids, kv_transfer_manager, target_device):
seen["request_id"] = request_id
seen["cfg_request_ids"] = cfg_request_ids
seen["kv_transfer_manager"] = kv_transfer_manager
seen["target_device"] = target_device
return {"cfg_text_kv_metadata": {"ok": True}}
req = OmniDiffusionRequest(
prompt="prompt-a",
sampling_params=OmniDiffusionSamplingParams(),
request_id="req-parent",
)
req.sampling_params.cfg_kv_request_ids = {"cfg_text": "req-parent__cfg_text"}
manager.receive_kv_cache = lambda request, target_device=None: request is req
success = manager.receive_multi_kv_cache(
req,
cfg_kv_collect_func=collect_cfg,
target_device=torch.device("cpu"),
)
assert success
assert seen["request_id"] == "req-parent"
assert seen["cfg_request_ids"] == {"cfg_text": "req-parent__cfg_text"}
assert seen["kv_transfer_manager"] is manager
assert seen["target_device"] == torch.device("cpu")
assert req.sampling_params.cfg_text_kv_metadata == {"ok": True}
def test_integration_flow(common_constants):
"""Simulate extraction -> connector -> reception."""
num_layers = common_constants["num_layers"]
block_size = common_constants["block_size"]
num_heads = common_constants["num_heads"]
head_dim = common_constants["head_dim"]
req_id = common_constants["req_id"]
sender_config = OmniKVCacheConfig(
connector_config={"type": "mock"}, from_stage="sender", to_stage="receiver", need_send_cache=True
)
sender_manager = OmniKVTransferManager(sender_config)
connector = MockConnector()
sender_manager._connector = connector # Shared connector
# Create Data
num_blocks = 5
kv_caches = []
for _ in range(num_layers):
layer = torch.randn(2, num_blocks, block_size, num_heads, head_dim)
kv_caches.append(layer)
finished_reqs = {req_id: {"block_ids": [0, 1], "seq_len": 10}}
# Send
sender_manager.handle_finished_requests_kv_transfer(finished_reqs, kv_caches, block_size, "float32")
receiver_config = OmniKVCacheConfig(
connector_config={"type": "mock"},
from_stage="sender",
stage_id="receiver",
need_recv_cache=True,
recv_timeout=1.0,
)
receiver_manager = OmniKVTransferManager(receiver_config)
# Share the same mock connector instance
receiver_manager._connector = connector
req = OmniDiffusionRequest(
prompt="test_integ",
sampling_params=OmniDiffusionSamplingParams(),
request_id=req_id,
)
# Receive
success = receiver_manager.receive_kv_cache(req)
# Verify
assert success
assert req.past_key_values is not None
assert req.kv_metadata["seq_len"] == 10
def test_deferred_release_isolates_data_from_pool_buffer():
payload, key_tensor = _make_serialized_payload()
# ManagedBuffer-like object whose release() zeros storage (simulates reuse).
raw_array = bytearray(payload)
buf_tensor = torch.frombuffer(raw_array, dtype=torch.uint8)
class _CorruptingManagedBuffer:
def __init__(self, storage, tensor):
self._storage = storage
self._tensor = tensor
@property
def tensor(self):
return self._tensor
def release(self):
for i in range(len(self._storage)):
self._storage[i] = 0
managed_buf = _CorruptingManagedBuffer(raw_array, buf_tensor)
class _ManagedBufConnector:
def __init__(self, buf, from_stage, to_stage, req_id):
self._buf = buf
self._key = f"{from_stage}->{to_stage}:omni_{from_stage}_to_{to_stage}_kv_cache_{req_id}"
def get(self, from_stage, to_stage, get_key, metadata=None):
if f"{from_stage}->{to_stage}:{get_key}" == self._key:
return self._buf, len(self._buf._storage)
return None
config = OmniKVCacheConfig(
connector_config={"type": "mock"},
from_stage="sender",
stage_id="receiver",
need_recv_cache=True,
recv_timeout=1.0,
)
manager = OmniKVTransferManager(config)
req_id = "req-payload"
manager._connector = _ManagedBufConnector(managed_buf, "sender", "receiver", req_id)
data, size = manager.receive_kv_cache_for_request(req_id, target_device=torch.device("cpu"))
assert data is not None
assert size > 0
# Returned data must survive the pool buffer being zeroed by release().
assert torch.equal(data["layer_blocks"]["key_cache"][0], key_tensor)
def test_manager_extraction_no_connector(kv_config, common_constants):
"""Test extraction when connector is unavailable (should still return IDs)."""
block_size = common_constants["block_size"]
req_id = common_constants["req_id"]
manager = OmniKVTransferManager(kv_config)
# Force connector to be None
manager._connector = None
manager.config.connector_config = None
finished_reqs = {req_id: {"block_ids": [1, 2], "seq_len": 10}}
processed = manager.handle_finished_requests_kv_transfer(
finished_reqs, kv_caches=[], block_size=block_size, cache_dtype="float32"
)
assert req_id in processed