[Core]: KV Cache Transfer Encapsulation (#979)

Signed-off-by: princepride <wangzhipeng628@gmail.com>
Signed-off-by: 汪志鹏 <wangzhipeng628@gmail.com>
This commit is contained in:
汪志鹏 authored and GitHub committed 2026-01-28 23:12:45 +08:00
1 parent c9392dff01
commit 741f7e2826
5 files changed
+714 -504

No files matched your search

+240 -183
View File
@@ -1,194 +1,251 @@
import unittest
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import torch
from vllm_omni.diffusion.models.bagel.pipeline_bagel import BagelPipeline
from tests.utils import hardware_test
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import (
OmniKVCacheConfig,
OmniKVTransferManager,
)
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.worker.gpu_ar_model_runner import GPUARModelRunner
class MockGPUARModelRunner(GPUARModelRunner):
"""Subclass to bypass heavy initialization."""
def __init__(self, kv_caches, input_batch):
self.kv_caches = kv_caches
self.input_batch = input_batch
self.device = "cpu"
self.cache_config = MagicMock()
self.cache_config.block_size = 16
self.cache_config.cache_dtype = "auto"
self.logger = MagicMock()
class MockBagelPipeline(BagelPipeline):
"""Subclass to bypass heavy initialization."""
class MockConnector:
def __init__(self):
self.device = "cpu"
self.od_config = MagicMock()
self.bagel = MagicMock()
self.tokenizer = MagicMock()
self.language_model = MagicMock()
self.new_token_ids = {}
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
class TestKVFlow(unittest.TestCase):
def setUp(self):
# Common constants
self.num_layers = 2
self.num_heads = 4
self.head_dim = 16
self.block_size = 8
self.x = 8 # PagedAttention factor
self.seq_len = 20
self.req_id = "req_test_1"
def test_sender_extraction_logic(self):
"""Test extraction logic in GPUARModelRunner."""
if GPUARModelRunner is object:
self.skipTest("vLLM not installed")
# 1. Setup KV Cache (List of tuples (K, V))
# Shape: [num_blocks, block_size, num_heads, head_dim] matching 4D expectation
num_blocks = 10
kv_caches = []
for _ in range(self.num_layers):
k_cache = torch.randn(num_blocks, self.block_size, self.num_heads, self.head_dim)
v_cache = torch.randn(num_blocks, self.block_size, self.num_heads, self.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)
# 2. Setup Input Batch Mock
block_ids = [1, 3, 5]
mock_input_batch = MagicMock()
mock_input_batch.req_id_to_index = {self.req_id: 0}
mock_input_batch.block_table.get_row.return_value = block_ids
# 3. Instantiate Runner
runner = MockGPUARModelRunner(kv_caches, mock_input_batch)
# 4. Run Extraction
# calling _extract_kv_cache directly: (req_id, block_ids, seq_len)
result = runner._extract_kv_cache(self.req_id, block_ids, self.seq_len)
# 5. Verify Result
self.assertIsNotNone(result)
self.assertEqual(result.request_id, self.req_id)
# Check keys "key_cache" and "value_cache" exist (length 2)
self.assertEqual(len(result.layer_blocks["key_cache"]), 2)
self.assertEqual(len(result.layer_blocks["value_cache"]), 2)
# Check Tensor Shape: [seq_len, num_heads, head_dim]
# result.layer_blocks["key_cache"] is a list of tensors
expected_shape = (self.seq_len, self.num_heads, self.head_dim)
self.assertEqual(result.layer_blocks["key_cache"][0].shape, expected_shape)
return result # Return for use in next test
def test_receiver_injection_logic(self):
"""Test injection logic in BagelPipeline."""
if BagelPipeline is object:
self.skipTest("vLLM not installed")
# 1. Get Data from Sender Test (simulate transfer)
# Re-create data manually to be independent
key_cache = []
value_cache = []
expected_shape = (self.seq_len, self.num_heads, self.head_dim)
for i in range(self.num_layers):
key_cache.append(torch.randn(expected_shape))
value_cache.append(torch.randn(expected_shape))
layer_blocks = {"key_cache": key_cache, "value_cache": value_cache}
transfer_data = MagicMock() # Mock KVCacheTransferData
transfer_data.layer_blocks = layer_blocks
transfer_data.metadata = {"kv_lens": [self.seq_len], "ropes": [0]}
# 2. Setup Request with Injected Data
sp = OmniDiffusionSamplingParams(
past_key_values=SimpleNamespace(**layer_blocks),
kv_metadata=transfer_data.metadata,
)
req = OmniDiffusionRequest(["test"], sp)
# 3. Setup Pipeline
pipeline = MockBagelPipeline()
# Mock Bagel's NaiveCache
class RealNaiveCache: # Minimal impl
def __init__(self, n):
self.key_cache = {i: None for i in range(n)}
self.value_cache = {i: None for i in range(n)}
pipeline.bagel.config.llm_config.num_hidden_layers = self.num_layers
captured_context = {}
def mock_prepare_prompts(curr_kvlens, curr_rope, **kwargs):
# Capture the state passed to prepare_prompts
captured_context["kv_lens"] = curr_kvlens
captured_context["ropes"] = curr_rope
return {}, [0], [0]
pipeline.bagel.prepare_prompts = MagicMock(side_effect=mock_prepare_prompts)
with patch("vllm_omni.diffusion.models.bagel.pipeline_bagel.NaiveCache") as MockNaiveCacheCls:
# Setup the instance returned by the constructor
mock_cache_instance = RealNaiveCache(self.num_layers)
MockNaiveCacheCls.return_value = mock_cache_instance
# Verification of Injection Logic (Simulation)
current_cache = RealNaiveCache(self.num_layers)
# --- Logic from Source Code ---
injected_kv = req.sampling_params.past_key_values
if isinstance(current_cache, RealNaiveCache) and hasattr(injected_kv, "key_cache"):
# Assuming injected_kv is SimpleNamespace or object with list attrs
for layer_idx in range(len(injected_kv.key_cache)):
if injected_kv.key_cache[layer_idx] is not None:
k_tensor = injected_kv.key_cache[layer_idx]
v_tensor = injected_kv.value_cache[layer_idx]
if k_tensor.device != pipeline.device:
k_tensor = k_tensor.to(pipeline.device)
if v_tensor.device != pipeline.device:
v_tensor = v_tensor.to(pipeline.device)
current_cache.key_cache[layer_idx] = k_tensor
current_cache.value_cache[layer_idx] = v_tensor
self.assertTrue(torch.allclose(current_cache.key_cache[0], layer_blocks["key_cache"][0]))
self.assertTrue(torch.allclose(current_cache.value_cache[1], layer_blocks["value_cache"][1]))
def test_integration(self):
"""Simulate the flow from Sender -> Connector (Dict) -> Receiver."""
if GPUARModelRunner is object or BagelPipeline is object:
self.skipTest("vLLM not installed")
# 1. Sender (Extraction)
runner_test_result = self.test_sender_extraction_logic() # Get KVCacheTransferData
# 2. Connector (Serialize/Deserialize Simulation)
# KVCacheTransferData has to_dict
data_dict = runner_test_result.to_dict()
# 3. Receiver (Request Setup)
sp = OmniDiffusionSamplingParams(
past_key_values=data_dict["layer_blocks"],
kv_metadata=data_dict["metadata"],
)
req = OmniDiffusionRequest(["integration_test"], sp) # noqa: F841
# 4. Receiver (Injection Simulation)
# Use the logic verification again
pass
@pytest.fixture
def mock_connector():
return MockConnector()
if __name__ == "__main__":
unittest.main()
@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",
}
@pytest.mark.cache
@hardware_test(
res={"cuda": "L4"},
num_cards=2,
)
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 = 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
@pytest.mark.cache
@hardware_test(
res={"cuda": "L4"},
num_cards=2,
)
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(
prompts=["test_recv"],
sampling_params=OmniDiffusionSamplingParams(),
request_ids=[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
@pytest.mark.cache
@hardware_test(
res={"cuda": "L4"},
num_cards=2,
)
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)
receiver_manager._connector = connector # Share the same mock connector instance
req = OmniDiffusionRequest(
prompts=["test_integ"],
sampling_params=OmniDiffusionSamplingParams(),
request_ids=[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
@pytest.mark.cache
@hardware_test(
res={"cuda": "L4"},
num_cards=2,
)
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
@@ -28,8 +28,7 @@ from vllm_omni.diffusion.forward_context import set_forward_context
from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader
from vllm_omni.diffusion.offload import apply_offload_hooks
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.distributed.omni_connectors.factory import OmniConnectorFactory
from vllm_omni.distributed.omni_connectors.utils.config import ConnectorSpec
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import OmniKVTransferManager
logger = init_logger(__name__)
@@ -62,10 +61,9 @@ class DiffusionModelRunner:
self.device = device
self.pipeline = None
self.cache_backend = None
self.connector = None
# Initialize OmniConnector after vllm_config is available (via init_device_and_model)
self._init_omni_connector()
# Initialize KV cache manager for connector management
self.kv_transfer_manager = OmniKVTransferManager.from_od_config(od_config)
def load_model(
self,
@@ -138,126 +136,6 @@ class DiffusionModelRunner:
logger.info("Model runner: Initialization complete.")
def _init_omni_connector(self) -> None:
# TODO(wzliu)! get real connector from yaml file instead of hardcode
"""Initialize OmniConnector for KV cache transfer."""
try:
connector_config = None
# 1. Try to get from omni_kv_config (injected from YAML)
# Use self.od_config because self.vllm_config is a dummy VllmConfig without model_config
if self.od_config.omni_kv_config:
connector_config = self.od_config.omni_kv_config.get("connector_config")
if not connector_config:
logger.warning("No OmniConnector config found, skipping initialization")
return
logger.info(f"Initializing OmniConnector with config: {connector_config}")
c_type = connector_config.get("type")
if not c_type:
logger.error("Connector config missing 'type'")
return
c_extra = {k: v for k, v in connector_config.items() if k != "type"}
connector_spec = ConnectorSpec(name=c_type, extra=c_extra)
self.connector = OmniConnectorFactory.create_connector(connector_spec)
except Exception as e:
logger.error(f"Failed to initialize OmniConnector: {e}")
import traceback
traceback.print_exc()
def _receive_kv_cache_for_request(self, req: OmniDiffusionRequest) -> None:
"""Receive KV cache for a request via OmniConnector."""
# TODO(wzliu)! must get control info from stage queue instead of hardcode
if not req.request_ids:
logger.warning("Request has no ID, cannot receive KV cache")
return
request_id = req.request_ids[0]
try:
logger.info(f"Attempting to receive KV cache for request {request_id}")
# TODO: Key used for transfer (must match sender side)
# key = f"kv_cache_{req.request_id}"
# Get data from connector
# Determine from_stage and to_stage dynamically
omni_kv_config = self.od_config.omni_kv_config
stage_id = omni_kv_config.get("stage_id")
engine_input_source = omni_kv_config.get("engine_input_source", [])
to_stage = stage_id
# Default to stage_id - 1 if input source is not explicit
if engine_input_source:
from_stage = engine_input_source[0]
elif isinstance(stage_id, int):
from_stage = stage_id - 1
else:
raise ValueError("Invalid stage id")
logger.info(f"Wait for KV cache for request {request_id} from stage {from_stage} to {to_stage}...")
# Check if we should receive KV cache based on config
need_recv_cache = omni_kv_config.get("need_recv_cache", False)
if need_recv_cache:
# Default timeout 30 seconds to prevent infinite hanging
timeout = omni_kv_config.get("recv_timeout", 30.0)
start_time = time.time()
while True:
get_key = f"omni_{from_stage}_to_{to_stage}_kv_cache_{request_id}"
result = self.connector.get(
from_stage=from_stage,
to_stage=to_stage,
get_key=get_key,
)
if result:
break
if time.time() - start_time > timeout:
logger.error(f"Timeout waiting for KV cache for request {request_id} after {timeout}s")
result = None
break
time.sleep(0.5)
else:
logger.info(f"Skip receiving KV cache for {request_id} (need_recv_cache=False)")
result = None
if result:
data, size = result
logger.info(f"Successfully received KV cache for {request_id}")
# Assume data structure matches KVCacheTransferData.to_dict()
if isinstance(data, dict) and "layer_blocks" in data:
# Get layer blocks and ensure they are on the correct device
layer_blocks = data["layer_blocks"]
# Move tensors to GPU if needed (OmniSerializer should handle tensor reconstruction)
for cache_list in [layer_blocks["key_cache"], layer_blocks["value_cache"]]:
for i, tensor in enumerate(cache_list):
if isinstance(tensor, torch.Tensor) and tensor.device != self.pipeline.device:
cache_list[i] = tensor.to(self.pipeline.device).contiguous()
from types import SimpleNamespace
req.sampling_params.past_key_values = SimpleNamespace(**layer_blocks)
if "metadata" in data:
req.sampling_params.kv_metadata = data["metadata"]
else:
logger.warning(f"No KV cache received for {request_id} (timeout or empty)")
except Exception as e:
logger.error(f"Error receiving KV cache for {request_id}: {e}")
import traceback
traceback.print_exc()
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
"""Load weights into the pipeline."""
return self.pipeline.load_weights(weights)
@@ -277,9 +155,8 @@ class DiffusionModelRunner:
if len(req.prompts) == 0:
raise ValueError("Cannot execute model with empty request list")
# [Omni] KV Cache Receiving Logic
if req.sampling_params.need_kv_receive and self.connector is not None:
self._receive_kv_cache_for_request(req)
# The manager handles the check for need_recv_cache internally
self.kv_transfer_manager.receive_kv_cache(req, target_device=getattr(self.pipeline, "device", None))
if req.sampling_params.generator is None and req.sampling_params.seed is not None:
req.sampling_params.generator = torch.Generator(device=self.device).manual_seed(req.sampling_params.seed)
@@ -0,0 +1,459 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unified OmniConnector and KV cache transfer management."""
import time
from collections.abc import Callable
from dataclasses import asdict, dataclass
from typing import Any
import torch
from vllm.logger import init_logger
from .factory import OmniConnectorFactory
from .utils.config import ConnectorSpec
logger = init_logger(__name__)
@dataclass
class OmniKVCacheConfig:
"""Configuration for OmniKVTransferManager."""
connector_config: dict[str, Any] | None = None
from_stage: str | None = None
to_stage: str | None = None
stage_id: str | int | None = None
engine_input_source: list[str | int] | None = None
need_recv_cache: bool = False
need_send_cache: bool = False
recv_timeout: float = 30.0
@dataclass
class KVCacheTransferData:
"""Container for KV cache transfer data."""
request_id: str
layer_blocks: dict[str, Any]
block_ids: list[int]
metadata: dict[str, Any]
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary for serialization."""
return asdict(self)
class OmniKVTransferManager:
"""Unified management for OmniConnector and KV cache transfer.
This class encapsulates all KV cache related operations:
- Connector initialization and lazy creation
- KV cache extraction from GPU blocks
- KV cache transfer with retry logic
- KV cache receiving with timeout
"""
def __init__(self, config: OmniKVCacheConfig):
self.config = config
self._connector = None
# Pre-calculate send stages (from_stage, to_stage)
self.send_stages = (
(str(config.from_stage), str(config.to_stage)) if config.from_stage and config.to_stage else (None, None)
)
# Pre-calculate receive stages (from_stage, to_stage)
recv_from = config.from_stage
if config.engine_input_source:
recv_from = config.engine_input_source[0]
elif isinstance(config.stage_id, int):
recv_from = config.stage_id - 1
self.recv_stages = (
(str(recv_from), str(config.stage_id))
if recv_from is not None and config.stage_id is not None
else (None, None)
)
@classmethod
def _create(cls, cfg: dict | None) -> "OmniKVTransferManager":
"""Create manager from raw config dict."""
if not cfg or not isinstance(cfg, dict):
return cls(OmniKVCacheConfig())
return cls(
OmniKVCacheConfig(
connector_config=cfg.get("connector_config"),
from_stage=cfg.get("omni_from_stage"),
to_stage=cfg.get("omni_to_stage"),
stage_id=cfg.get("stage_id"),
engine_input_source=cfg.get("engine_input_source", []),
need_recv_cache=cfg.get("need_recv_cache", False),
need_send_cache=cfg.get("need_send_cache", False),
recv_timeout=cfg.get("recv_timeout", 30.0),
)
)
@classmethod
def from_model_config(cls, config: Any) -> "OmniKVTransferManager":
"""Create from model config (for AR model runner)."""
return cls._create(getattr(config, "omni_kv_config", None))
@classmethod
def from_od_config(cls, config: Any) -> "OmniKVTransferManager":
"""Create from OmniDiffusion config (for diffusion runner)."""
return cls._create(getattr(config, "omni_kv_config", None))
@classmethod
def from_vllm_config(cls, vllm_config: Any, model_config: Any) -> "OmniKVTransferManager":
"""Create from vllm config with fallback to kv_transfer_config."""
# Primary: omni_kv_config from model_config
omni_kv = getattr(model_config, "omni_kv_config", None)
if isinstance(omni_kv, dict):
return cls._create(omni_kv)
# Fallback: check kv_transfer_config
kv_cfg = getattr(vllm_config, "kv_transfer_config", None)
if kv_cfg:
direct = getattr(kv_cfg, "omni_connector_config", None)
if isinstance(direct, dict) and direct:
return cls._create({"connector_config": direct})
extra = getattr(kv_cfg, "kv_connector_extra_config", None)
if isinstance(extra, dict):
omni = extra.get("omni_connector_config")
if isinstance(omni, dict) and omni:
return cls._create({"connector_config": omni})
return cls(OmniKVCacheConfig())
@property
def connector(self):
"""Lazy initialization of connector."""
# If a previous initialization attempt failed, don't retry on every access.
if self._connector is False:
return None
if self._connector is None:
cfg = self.config.connector_config
if cfg and (c_type := cfg.get("type")):
try:
logger.info(f"Initializing OmniConnector with config: {cfg}")
c_extra = {k: v for k, v in cfg.items() if k != "type"}
self._connector = OmniConnectorFactory.create_connector(ConnectorSpec(name=c_type, extra=c_extra))
except Exception as e:
logger.error(f"Failed to initialize OmniConnector: {e}")
import traceback
traceback.print_exc()
# Cache failure sentinel to avoid repeated initialization attempts in hot paths.
self._connector = False
return self._connector if self._connector else None
def get_connector(self):
"""Get connector (compatibility wrapper for existing code)."""
return self.connector
def handle_finished_requests_kv_transfer(
self,
finished_reqs: dict[str, dict[str, Any]],
kv_caches: list[torch.Tensor],
block_size: int,
cache_dtype: str,
request_id_resolver: Callable[[str], str] | None = None,
) -> list[str]:
"""Handle KV cache transfer for finished requests.
This method extracts KV cache from GPU blocks and transfers them
to the downstream stage via the connector.
Args:
finished_reqs: Dict mapping request_id to {block_ids, seq_len}
kv_caches: List of KV cache tensors per layer
block_size: Size of each cache block
cache_dtype: Data type of the cache
request_id_resolver: Optional function to resolve global request ID
Returns:
List of request IDs that were processed
"""
if not finished_reqs:
return []
if not self.config.need_send_cache:
return list(finished_reqs.keys())
if not self.connector:
logger.warning("No connector available, skipping KV transfer but freeing resources")
return list(finished_reqs.keys())
logger.debug(f"Processing KV transfer for {len(finished_reqs)} requests")
extracted_ids = []
for req_id, data in finished_reqs.items():
try:
seq_len = data.get("seq_len", 0)
block_ids = data.get("block_ids", [])
if not block_ids:
logger.warning(f"Request {req_id} has no block IDs, skipping")
continue
# Extract KV cache from GPU blocks -> CPU tensors
kv_data = self._extract_kv_cache(req_id, block_ids, seq_len, kv_caches, block_size, cache_dtype)
if kv_data:
# Resolve global request ID if available
transfer_req_id = request_id_resolver(req_id) if request_id_resolver else req_id
# Transfer to downstream stage via connector
self._transfer_kv_cache(kv_data, transfer_req_id)
except Exception as e:
logger.error(f"Failed KV transfer for {req_id}: {e}")
finally:
extracted_ids.append(req_id)
return extracted_ids
def _extract_kv_cache(
self,
req_id: str,
block_ids: list[int],
seq_len: int,
kv_caches: list[torch.Tensor],
block_size: int,
cache_dtype: str,
) -> KVCacheTransferData | None:
"""Extract KV cache from GPU blocks for a single request.
Args:
req_id: Request identifier
block_ids: List of block IDs to extract
seq_len: Sequence length
kv_caches: List of KV cache tensors per layer
block_size: Size of each cache block
cache_dtype: Data type of the cache
Returns:
KVCacheTransferData if extraction successful, None otherwise
"""
num_layers = len(kv_caches)
key_cache: list[torch.Tensor | None] = [None] * num_layers
value_cache: list[torch.Tensor | None] = [None] * num_layers
for layer_idx, kv_tensor in enumerate(kv_caches):
# Validate block IDs - shape: [2, num_blocks, block_size, n_heads, head_dim]
max_block = kv_tensor.shape[1] - 1
valid_ids = [bid for bid in block_ids if 0 <= bid <= max_block]
if not valid_ids:
continue
# Extract and reshape: [2, n_blocks, block_size, n_heads, head_dim]
# -> [2, seq_len, n_heads, head_dim]
selected = kv_tensor[:, valid_ids] # [2, n_valid, block_size, n_heads, head_dim]
n_kv, n_blks, blk_sz, n_heads, d_head = selected.shape
flat = selected.reshape(n_kv, n_blks * blk_sz, n_heads, d_head)
if seq_len < flat.shape[1]:
flat = flat[:, :seq_len]
# Move to CPU
flat_cpu = flat.detach().cpu().contiguous()
key_cache[layer_idx] = flat_cpu[0]
value_cache[layer_idx] = flat_cpu[1]
if not any(k is not None for k in key_cache):
return None
return KVCacheTransferData(
request_id=req_id,
layer_blocks={"key_cache": key_cache, "value_cache": value_cache},
block_ids=block_ids,
metadata={
"block_size": block_size,
"num_layers": num_layers,
"dtype": str(cache_dtype),
"seq_len": seq_len,
},
)
def _transfer_kv_cache(self, kv_data: KVCacheTransferData, transfer_req_id: str) -> None:
"""Transfer KV cache data to downstream stage via OmniConnector.
Args:
kv_data: The extracted KV cache data
transfer_req_id: The request ID to use for transfer
"""
from_stage, to_stage = self.send_stages
if not from_stage or not to_stage:
raise ValueError("Transfer stages (omni_from_stage, omni_to_stage) not configured")
# Prepare data and transfer with retry
data_dict = kv_data.to_dict()
data_dict["request_id"] = transfer_req_id
success, size, _ = self._transfer_with_retry(from_stage, to_stage, f"kv_cache_{transfer_req_id}", data_dict)
if success:
logger.info(f"KV transfer OK: {transfer_req_id}, {size} bytes")
else:
logger.error(f"KV transfer FAILED: {transfer_req_id}")
def _transfer_with_retry(
self,
from_stage: str,
to_stage: str,
request_id: str,
data: dict[str, Any],
max_retries: int = 3,
) -> tuple[bool, int, dict[str, Any] | None]:
"""Transfer data with retry and exponential backoff.
Args:
from_stage: Source stage identifier
to_stage: Target stage identifier
request_id: Request identifier for the key
data: Data to transfer
max_retries: Maximum number of retry attempts
Returns:
Tuple of (success, size, metadata)
"""
for attempt in range(max_retries):
try:
# Build the full key for connector
full_request_id = f"omni_{from_stage}_to_{to_stage}_{request_id}"
success, size, metadata = self.connector.put(
from_stage=from_stage, to_stage=to_stage, put_key=full_request_id, data=data
)
if success:
return success, size, metadata
logger.warning(f"Transfer attempt {attempt + 1} failed for {request_id}")
except Exception as e:
logger.warning(f"Transfer attempt {attempt + 1} exception: {e}")
if attempt < max_retries - 1:
time.sleep(0.1 * (2**attempt))
return False, 0, None
@torch.inference_mode()
def receive_kv_cache_for_request(
self,
request_id: str,
target_device: torch.device | None = None,
) -> tuple[dict[str, Any] | None, int]:
"""Receive KV cache for a specific request.
This implements the receiving logic from gpu_diffusion_model_runner.py.
Args:
request_id: The request ID to receive KV cache for
target_device: Optional device to move tensors to
Returns:
Tuple of (data dict, size) if successful, (None, 0) otherwise
"""
if not self.connector:
logger.warning("No connector available for receiving KV cache")
return None, 0
from_stage, to_stage = self.recv_stages
if not from_stage or not to_stage:
logger.warning("Receive stages not configured")
return None, 0
# Check if we should receive KV cache based on config
if not self.config.need_recv_cache:
logger.info(f"Skip receiving KV cache for {request_id} (need_recv_cache=False)")
return None, 0
timeout = self.config.recv_timeout
start_time = time.time()
logger.info(f"Wait for KV cache for request {request_id} from stage {from_stage} to {to_stage}...")
try:
while True:
# Build the full key for connector
full_request_id = f"omni_{from_stage}_to_{to_stage}_kv_cache_{request_id}"
result = self.connector.get(
from_stage=from_stage,
to_stage=to_stage,
get_key=full_request_id,
)
if result:
data, size = result
logger.info(f"Successfully received KV cache for {request_id}, {size} bytes")
# Move tensors to target device if specified
if target_device is not None and isinstance(data, dict) and "layer_blocks" in data:
layer_blocks = data["layer_blocks"]
for cache_list in [
layer_blocks.get("key_cache", []),
layer_blocks.get("value_cache", []),
]:
for i, tensor in enumerate(cache_list):
if isinstance(tensor, torch.Tensor) and tensor.device != target_device:
cache_list[i] = tensor.to(target_device).contiguous()
return data, size
if time.time() - start_time > timeout:
logger.error(f"Timeout waiting for KV cache for request {request_id} after {timeout}s")
return None, 0
time.sleep(0.5)
except Exception as e:
logger.error(f"Error receiving KV cache for {request_id}: {e}")
import traceback
traceback.print_exc()
return None, 0
def apply_kv_cache_to_request(self, req: Any, data: dict[str, Any]) -> None:
"""Apply received KV cache data to a request object.
Args:
req: The request object to apply KV cache to
data: The received KV cache data dictionary
"""
if isinstance(data, dict) and "layer_blocks" in data:
layer_blocks = data["layer_blocks"]
from types import SimpleNamespace
kv_obj = SimpleNamespace(**layer_blocks)
req.past_key_values = kv_obj
# [Omni] Also attach to sampling_params for BagelPipeline compatibility
# BagelPipeline checks req.sampling_params.past_key_values
if hasattr(req, "sampling_params") and req.sampling_params is not None:
req.sampling_params.past_key_values = kv_obj
if "metadata" in data:
req.kv_metadata = data["metadata"]
# Legacy compatibility method
def receive_kv_cache(self, req: Any, target_device: torch.device | None = None) -> bool:
"""Receive KV cache and populate request object (legacy interface).
Args:
req: Request object with request_id attribute
target_device: Optional device to move tensors to
Returns:
True if successful, False otherwise
"""
request_id = getattr(req, "request_id", None)
if not request_id and hasattr(req, "request_ids") and req.request_ids:
# Adaptation for new OmniDiffusionRequest which has list of prompts/ids
request_id = req.request_ids[0]
if not request_id:
logger.warning("Request has no ID, cannot receive KV cache")
return False
data, size = self.receive_kv_cache_for_request(request_id, target_device)
if data:
self.apply_kv_cache_to_request(req, data)
return True
return False
@@ -61,13 +61,7 @@ stage_args:
final_output_type: image
is_comprehension: false
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 2048
seed: 52
detokenize: True
repetition_penalty: 1.0
input_connectors:
from_stage_0: mooncake_connector
+10 -187
View File
@@ -35,7 +35,7 @@ from vllm.v1.worker.gpu_model_runner import (
from vllm.v1.worker.ubatch_utils import maybe_create_ubatch_slices
from vllm.v1.worker.utils import is_residual_scattered_for_sp
from vllm_omni.core.sched.omni_ar_scheduler import KVCacheTransferData
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import OmniKVTransferManager
from vllm_omni.outputs import OmniModelRunnerOutput
from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner
@@ -70,7 +70,8 @@ class GPUARModelRunner(OmniGPUModelRunner):
# each model stage has their own hidden size
self.hidden_size = self.model_config.hf_text_config.hidden_size
self.inputs_embeds = self._make_buffer(self.max_num_tokens, self.hidden_size, dtype=self.dtype, numpy=False)
self.omni_connector = None
# Initialize KV cache manager (preserve vllm_config fallback behavior)
self.kv_transfer_manager = OmniKVTransferManager.from_vllm_config(self.vllm_config, self.model_config)
def _make_buffer(self, *size, dtype, numpy=True):
# Prevent ray from pinning the buffer due to large size
@@ -95,7 +96,13 @@ class GPUARModelRunner(OmniGPUModelRunner):
raise RuntimeError("State error: sample_tokens() must be called after execute_model() returns None.")
# [Omni] Handle KV transfer BEFORE updating states (which removes finished requests)
self.kv_extracted_req_ids = self._handle_finished_requests_kv_transfer(scheduler_output)
self.kv_extracted_req_ids = self.kv_transfer_manager.handle_finished_requests_kv_transfer(
finished_reqs=getattr(scheduler_output, "finished_requests_needing_kv_transfer", {}),
kv_caches=self.kv_caches,
block_size=self.cache_config.block_size,
cache_dtype=str(self.cache_config.cache_dtype),
request_id_resolver=self._resolve_global_request_id,
)
if self.vllm_config.model_config.enable_return_routed_experts:
capturer = RoutedExpertsCapturer.get_instance()
@@ -577,161 +584,6 @@ class GPUARModelRunner(OmniGPUModelRunner):
return async_output
def _handle_finished_requests_kv_transfer(self, scheduler_output: SchedulerOutput) -> list[str]:
"""Handle KV cache transfer for finished requests.
Returns list of request IDs that were processed (for Scheduler to free blocks).
"""
finished_reqs = getattr(scheduler_output, "finished_requests_needing_kv_transfer", {})
if not finished_reqs:
return []
logger.debug(f"Processing KV transfer for {len(finished_reqs)} requests")
extracted_ids = []
for req_id, data in finished_reqs.items():
try:
seq_len = data.get("seq_len", 0)
block_ids = data.get("block_ids", [])
if not block_ids:
logger.warning(f"Request {req_id} has no block IDs, skipping")
continue
# Extract KV cache from GPU blocks -> CPU tensors
kv_data = self._extract_kv_cache(req_id, block_ids, seq_len)
if kv_data:
# Transfer to downstream stage via connector
self._transfer_kv_cache(kv_data)
except Exception as e:
logger.error(f"Failed KV transfer for {req_id}: {e}")
finally:
extracted_ids.append(req_id)
return extracted_ids
def _extract_kv_cache(self, req_id: str, block_ids: list[int], seq_len: int) -> KVCacheTransferData | None:
"""Extract KV cache from GPU blocks for a single request."""
num_layers = len(self.kv_caches)
key_cache = [None] * num_layers
value_cache = [None] * num_layers
for layer_idx, kv_tensor in enumerate(self.kv_caches):
# Validate block IDs
max_block = kv_tensor.shape[1] - 1
valid_ids = [bid for bid in block_ids if 0 <= bid <= max_block]
if not valid_ids:
continue
# Extract and reshape: [2, n_blocks, block_size, n_heads, head_dim]
# -> [2, seq_len, n_heads, head_dim]
selected = kv_tensor[:, valid_ids] # [2, n_valid, block_size, n_heads, head_dim]
n_kv, n_blks, blk_sz, n_heads, d_head = selected.shape
flat = selected.reshape(n_kv, n_blks * blk_sz, n_heads, d_head)
if seq_len < flat.shape[1]:
flat = flat[:, :seq_len]
# Move to CPU
flat_cpu = flat.detach().cpu().contiguous()
key_cache[layer_idx] = flat_cpu[0]
value_cache[layer_idx] = flat_cpu[1]
if not any(k is not None for k in key_cache):
return None
return KVCacheTransferData(
request_id=req_id,
layer_blocks={"key_cache": key_cache, "value_cache": value_cache},
block_ids=block_ids,
metadata={
"block_size": self.cache_config.block_size,
"num_layers": num_layers,
"dtype": str(self.cache_config.cache_dtype),
"seq_len": seq_len,
},
)
def _transfer_kv_cache(self, kv_data: KVCacheTransferData) -> None:
"""Transfer KV cache data to downstream stage via OmniConnector."""
connector = self._get_or_create_connector()
if not connector:
return
# Resolve global request ID if available
transfer_req_id = self._resolve_global_request_id(kv_data.request_id)
from_stage, to_stage = self._detect_transfer_stages()
# Prepare data and transfer with retry
data_dict = kv_data.to_dict()
data_dict["request_id"] = transfer_req_id
success, size, _ = self._transfer_with_retry(
connector, from_stage, to_stage, f"kv_cache_{transfer_req_id}", data_dict
)
if success:
logger.info(f"KV transfer OK: {transfer_req_id}, {size} bytes")
else:
logger.error(f"KV transfer FAILED: {transfer_req_id}")
def _get_or_create_connector(self) -> Any | None:
"""Get existing connector or create one from config."""
if self.omni_connector:
return self.omni_connector
from vllm_omni.distributed.omni_connectors.factory import OmniConnectorFactory
from vllm_omni.distributed.omni_connectors.utils.config import ConnectorSpec
config = self._get_omni_connector_config()
if not config or not isinstance(config, dict):
logger.warning("No valid OmniConnector config found")
return None
c_type = config.get("type")
if not c_type:
logger.error("OmniConnector config missing 'type' field")
return None
c_extra = {k: v for k, v in config.items() if k != "type"}
self.omni_connector = OmniConnectorFactory.create_connector(ConnectorSpec(name=c_type, extra=c_extra))
return self.omni_connector
def _get_omni_connector_config(self) -> dict[str, Any] | None:
"""Get OmniConnector configuration from model config."""
# Primary: omni_kv_config from YAML
omni_kv = getattr(self.model_config, "omni_kv_config", None)
if isinstance(omni_kv, dict):
cfg = omni_kv.get("connector_config")
if isinstance(cfg, dict) and cfg:
return cfg
# Fallback: kv_transfer_config
kv_cfg = getattr(self.vllm_config, "kv_transfer_config", None)
if kv_cfg:
direct = getattr(kv_cfg, "omni_connector_config", None)
if isinstance(direct, dict) and direct:
return direct
extra = getattr(kv_cfg, "kv_connector_extra_config", None)
if isinstance(extra, dict):
omni = extra.get("omni_connector_config")
if isinstance(omni, dict) and omni:
return omni
return None
def _detect_transfer_stages(self) -> tuple[str, str]:
"""Detect source and target stages for KV transfer."""
omni_kv = getattr(self.model_config, "omni_kv_config", None)
if isinstance(omni_kv, dict):
from_s = omni_kv.get("omni_from_stage")
to_s = omni_kv.get("omni_to_stage")
if from_s and to_s:
return str(from_s), str(to_s)
raise ValueError(
"KV transfer stages not configured. Please set 'omni_from_stage' and 'omni_to_stage' in omni_kv_config."
)
def _resolve_global_request_id(self, req_id: str) -> str:
"""Resolve global request ID from request state."""
req_state = self.requests.get(req_id)
@@ -747,32 +599,3 @@ class GPUARModelRunner(OmniGPUModelRunner):
return global_id.decode("utf-8")
return str(global_id)
return req_id
def _transfer_with_retry(
self,
connector: Any,
from_stage: str,
to_stage: str,
request_id: str,
data: dict[str, Any],
max_retries: int = 3,
) -> tuple[bool, int, dict[str, Any] | None]:
"""Transfer data with retry and exponential backoff."""
import time
for attempt in range(max_retries):
try:
put_key = f"omni_{from_stage}_to_{to_stage}_{request_id}"
success, size, metadata = connector.put(
from_stage=from_stage, to_stage=to_stage, put_key=put_key, data=data
)
if success:
return success, size, metadata
logger.warning(f"Transfer attempt {attempt + 1} failed for {request_id}")
except Exception as e:
logger.warning(f"Transfer attempt {attempt + 1} exception: {e}")
if attempt < max_retries - 1:
time.sleep(0.1 * (2**attempt))
return False, 0, None