Files
vllm-omni/vllm_omni/distributed/omni_connectors/utils/kv_utils.py
T

472 lines
17 KiB
Python
Raw Normal View History

2026-03-09 22:23:50 +08:00
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Utility helpers for KV cache manipulation, TP routing, and merge/slice."""
from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
2026-03-09 22:23:50 +08:00
import torch
from vllm.distributed.parallel_state import (
get_tensor_model_parallel_rank,
get_tensor_model_parallel_world_size,
)
2026-03-09 22:23:50 +08:00
from vllm.logger import init_logger
from .initialization import KV_RANK_PORT_STRIDE, KV_REPLICA_PORT_STRIDE, KV_TRANSFER_PORT_OFFSET
2026-03-09 22:23:50 +08:00
logger = init_logger(__name__)
LayerKV = torch.Tensor | tuple[torch.Tensor, torch.Tensor]
# ------------------------------------------------------------------ #
# TP Topology
# ------------------------------------------------------------------ #
@dataclass(frozen=True)
class KVTPTopology:
"""Immutable descriptor for a KV-transfer parallel mapping.
Captures sender/receiver parallel sizes and the local rank within
that parallel dimension. Works for any divisible parallel dimension
(TP, SP, Ring Attention).
"""
source_tp_size: int
target_tp_size: int
local_rank: int
def __post_init__(self) -> None:
if self.source_tp_size <= 0 or self.target_tp_size <= 0:
raise ValueError(
f"Parallel sizes must be positive: "
f"source_tp_size={self.source_tp_size}, target_tp_size={self.target_tp_size}"
)
if self.local_rank < 0:
raise ValueError(f"local_rank must be non-negative, got {self.local_rank}")
@property
def is_heterogeneous(self) -> bool:
return self.source_tp_size != self.target_tp_size
@property
def ratio(self) -> int:
"""Larger parallel size divided by smaller. Always >= 1."""
return max(self.source_tp_size, self.target_tp_size) // min(self.source_tp_size, self.target_tp_size)
# ------------------------------------------------------------------ #
# Runtime TP detection
# ------------------------------------------------------------------ #
def get_local_tp_rank() -> int:
"""Return the TP-local rank of this worker process.
Uses ``get_tensor_model_parallel_rank()`` which returns the rank
within the TP group only, not the stage-global rank.
"""
try:
return get_tensor_model_parallel_rank()
except Exception:
logger.debug("TP parallel state not initialized, falling back to LOCAL_RANK env", exc_info=True)
try:
return int(os.environ.get("LOCAL_RANK", "0"))
except (ValueError, TypeError):
return 0
def get_tp_world_size() -> int:
"""Return the TP world size (tensor-parallel dimension only).
Uses ``get_tensor_model_parallel_world_size()`` so that
cfg_parallel, SP, PP etc. are not included in the count.
"""
try:
return get_tensor_model_parallel_world_size()
except Exception:
logger.debug("TP parallel state not initialized, defaulting world_size=1", exc_info=True)
return 1
def get_omni_replica_id() -> int:
"""Return the Omni replica id for this worker process."""
try:
replica_id = int(os.environ.get("VLLM_OMNI_REPLICA_ID", "0"))
except (ValueError, TypeError):
return 0
return max(replica_id, 0)
# ------------------------------------------------------------------ #
# ZMQ port computation
# ------------------------------------------------------------------ #
def kv_zmq_port(
base_port: int,
from_stage: int,
local_rank: int = 0,
replica_id: int | None = None,
) -> int:
"""Compute the ZMQ port for a KV-transfer connector.
Each Omni replica and TP rank gets its own port so multi-replica or
TP > 1 deployments do not cause ``EADDRINUSE`` when multiple sender
workers bind on the same host. The formula is backward-compatible:
replica 0 / rank 0 produces the previous ``base + OFFSET + stage`` port.
"""
replica = get_omni_replica_id() if replica_id is None else max(int(replica_id), 0)
return (
base_port
+ KV_TRANSFER_PORT_OFFSET
+ replica * KV_REPLICA_PORT_STRIDE
+ local_rank * KV_RANK_PORT_STRIDE
+ from_stage
)
# ------------------------------------------------------------------ #
# TP topology validation and rank routing
# ------------------------------------------------------------------ #
def validate_kv_tp_topology(topo: KVTPTopology) -> None:
"""Reject heterogeneous TP mappings that cannot be routed losslessly."""
larger = max(topo.source_tp_size, topo.target_tp_size)
smaller = min(topo.source_tp_size, topo.target_tp_size)
if larger % smaller != 0:
raise ValueError(
f"KV TP mapping must be divisible: "
f"source_tp_size={topo.source_tp_size}, "
f"target_tp_size={topo.target_tp_size}"
)
def get_kv_target_ranks(topo: KVTPTopology) -> list[int]:
"""Which remote ranks this local rank sends KV shards to (send side)."""
validate_kv_tp_topology(topo)
if topo.source_tp_size == topo.target_tp_size:
return [topo.local_rank]
if topo.source_tp_size > topo.target_tp_size:
return [topo.local_rank // (topo.source_tp_size // topo.target_tp_size)]
ratio = topo.target_tp_size // topo.source_tp_size
return [topo.local_rank * ratio + i for i in range(ratio)]
def get_kv_source_ranks(topo: KVTPTopology) -> list[int]:
"""Which remote ranks this local rank receives KV shards from (recv side)."""
validate_kv_tp_topology(topo)
if topo.source_tp_size == topo.target_tp_size:
return [topo.local_rank]
if topo.source_tp_size > topo.target_tp_size:
ratio = topo.source_tp_size // topo.target_tp_size
return [topo.local_rank * ratio + i for i in range(ratio)]
return [topo.local_rank // (topo.target_tp_size // topo.source_tp_size)]
# ------------------------------------------------------------------ #
# Rank-aware connector key building
# ------------------------------------------------------------------ #
def get_kv_connector_key(
req_id: str,
from_stage: int | str,
chunk_id: int,
from_rank: int,
to_rank: int,
) -> str:
"""Build connector key that includes rank info for KV transfers.
Format matches PR #2677: ``{req_id}_{from_stage}_{chunk_id}_{from_rank}_{to_rank}``
"""
return f"{req_id}_{from_stage}_{chunk_id}_{from_rank}_{to_rank}"
def build_rank_aware_send_keys(
request_id: str,
from_stage: str,
to_stage: str,
topo: KVTPTopology,
hook: Callable | None = None,
) -> list[str]:
"""Build send-side connector keys, checking injectable hook first."""
if callable(hook):
keys = list(hook(request_id, from_stage, to_stage))
if keys:
return keys
if topo.source_tp_size <= 1 and topo.target_tp_size <= 1:
return [f"omni_{from_stage}_to_{to_stage}_kv_cache_{request_id}"]
target_ranks = get_kv_target_ranks(topo)
return [get_kv_connector_key(request_id, from_stage, 0, topo.local_rank, r) for r in target_ranks]
def build_rank_aware_recv_keys(
request_id: str,
from_stage: str,
to_stage: str,
topo: KVTPTopology,
hook: Callable | None = None,
) -> list[tuple[str, int | None]]:
"""Build recv-side connector keys with sender rank info.
Returns a list of ``(key, from_rank)`` tuples. ``from_rank`` is
``None`` when TP <= 1 (single sender, no per-rank routing needed).
For TP > 1, ``from_rank`` identifies which sender rank owns the
key so that the connector can route metadata queries to the
correct endpoint.
"""
if callable(hook):
raw = list(hook(request_id, from_stage, to_stage))
if raw:
if isinstance(raw[0], tuple):
return raw
# Hook returned plain strings (e.g. OmniConnectorModelRunnerMixin.
# get_rank_aware_kv_keys). Reconstruct from_rank from topology so
# Mooncake connector can route metadata queries to the correct
# sender endpoint in heterogeneous TP.
# TODO: have the mixin return (key, from_rank) tuples directly
# to avoid this indirect reconstruction.
source_ranks = get_kv_source_ranks(topo)
if len(raw) == len(source_ranks):
return list(zip(raw, source_ranks))
return [(k, None) for k in raw]
if topo.source_tp_size <= 1 and topo.target_tp_size <= 1:
return [(f"omni_{from_stage}_to_{to_stage}_kv_cache_{request_id}", None)]
source_ranks = get_kv_source_ranks(topo)
return [(get_kv_connector_key(request_id, from_stage, 0, r, topo.local_rank), r) for r in source_ranks]
# ------------------------------------------------------------------ #
# KV tensor head slicing (heterogeneous TP)
# ------------------------------------------------------------------ #
def slice_kv_tensor_heads(
tensor: torch.Tensor | None,
offset_in_shard: int,
num_slices: int,
) -> torch.Tensor | None:
"""Slice one KV tensor along its head dimension (dim 1)."""
if tensor is None:
return None
if not isinstance(tensor, torch.Tensor):
return tensor
if tensor.dim() < 2:
raise ValueError(f"Expected KV tensor with a head dimension, got shape={tuple(tensor.shape)}")
if num_slices <= 0:
raise ValueError(f"num_slices must be > 0, got {num_slices}")
if not (0 <= offset_in_shard < num_slices):
raise ValueError(f"offset_in_shard must be in [0, {num_slices}), got {offset_in_shard}")
heads_in_shard = tensor.shape[1]
if heads_in_shard % num_slices != 0:
raise ValueError(
"KV head count must be divisible for heterogeneous TP slicing: "
f"heads_in_shard={heads_in_shard}, num_slices={num_slices}"
)
heads_per_slice = heads_in_shard // num_slices
start = offset_in_shard * heads_per_slice
end = start + heads_per_slice
return tensor[:, start:end, ...].contiguous()
def slice_layer_blocks(
layer_blocks: dict[str, Any],
offset_in_shard: int,
num_slices: int,
) -> dict[str, list[torch.Tensor | None]]:
"""Slice all KV layers for one logical receiver rank."""
sliced_blocks: dict[str, list[torch.Tensor | None]] = {}
for cache_name in ("key_cache", "value_cache"):
cache_list = layer_blocks.get(cache_name, [])
sliced_blocks[cache_name] = [
slice_kv_tensor_heads(tensor, offset_in_shard, num_slices) for tensor in cache_list
]
return sliced_blocks
# ------------------------------------------------------------------ #
# Multi-rank merge and receiver-side slice
# ------------------------------------------------------------------ #
def merge_received_rank_shards(
payloads: list[dict[str, Any]],
merger: Callable | None = None,
) -> dict[str, Any] | None:
"""Merge multiple source-rank KV shards for one target rank.
When *merger* is provided (injectable hook), it is called directly.
Otherwise the default merges along the head dimension (dim 1).
"""
if callable(merger):
return merger(payloads)
if not payloads:
return None
if len(payloads) == 1:
return payloads[0]
base_payload = payloads[0]
if not isinstance(base_payload, dict) or "layer_blocks" not in base_payload:
return base_payload
merged: dict[str, Any] = {
"request_id": base_payload.get("request_id"),
"block_ids": list(base_payload.get("block_ids", [])),
"metadata": dict(base_payload.get("metadata", {})),
}
merged_layer_blocks: dict[str, list[torch.Tensor | None]] = {}
for cache_name in ("key_cache", "value_cache"):
cache_lists = [payload.get("layer_blocks", {}).get(cache_name, []) for payload in payloads]
num_layers = max((len(cache_list) for cache_list in cache_lists), default=0)
merged_cache: list[torch.Tensor | None] = []
for layer_idx in range(num_layers):
layer_tensors = [
cache_list[layer_idx]
for cache_list in cache_lists
if layer_idx < len(cache_list) and cache_list[layer_idx] is not None
]
if not layer_tensors:
merged_cache.append(None)
elif len(layer_tensors) == 1 or not isinstance(layer_tensors[0], torch.Tensor):
merged_cache.append(layer_tensors[0])
else:
merged_cache.append(torch.cat(layer_tensors, dim=1).contiguous())
merged_layer_blocks[cache_name] = merged_cache
merged["layer_blocks"] = merged_layer_blocks
return merged
def slice_received_rank_shard(
payload: dict[str, Any] | None,
topo: KVTPTopology,
slicer: Callable | None = None,
) -> dict[str, Any] | None:
"""Optionally slice a received payload to extract this rank's portion.
Used when ``to_tp > from_tp``: the sender sent full heads and each
receiver rank slices out its own subset.
"""
if callable(slicer):
return slicer(payload)
if not payload or topo.target_tp_size <= topo.source_tp_size or "layer_blocks" not in payload:
return payload
metadata = payload.get("metadata", {})
slice_metadata = metadata.get("tp_head_slice") if isinstance(metadata, dict) else None
if isinstance(slice_metadata, dict) and slice_metadata.get("applied"):
tagged_rank = slice_metadata.get("target_rank")
if tagged_rank is not None and tagged_rank != topo.local_rank:
logger.warning(
"Received pre-sliced KV payload for unexpected target rank: expected=%s got=%s",
topo.local_rank,
tagged_rank,
)
return payload
ratio = topo.target_tp_size // topo.source_tp_size
offset_in_sender = topo.local_rank % ratio
updated_metadata = dict(metadata) if isinstance(metadata, dict) else {}
updated_metadata["tp_head_slice"] = {
"applied": True,
"side": "receiver",
"target_rank": topo.local_rank,
"from_tp": topo.source_tp_size,
"to_tp": topo.target_tp_size,
"offset_in_shard": offset_in_sender,
"num_slices": ratio,
}
return {
"request_id": payload.get("request_id"),
"layer_blocks": slice_layer_blocks(payload["layer_blocks"], offset_in_sender, ratio),
"block_ids": list(payload.get("block_ids", [])),
"metadata": updated_metadata,
}
2026-03-09 22:23:50 +08:00
def normalize_layer_kv(
layer_kv: LayerKV,
*,
req_id: str = "",
layer_idx: int = -1,
) -> tuple[torch.Tensor, torch.Tensor] | None:
"""Normalize one layer KV cache to a ``(key_blocks, value_blocks)`` tuple.
In vLLM, different attention backends return paged-attention KV blocks
with different layouts. For example:
* **FlashAttention** (``vllm/v1/attention/backends/flash_attn.py``)
returns shape ``(2, num_blocks, block_size, num_kv_heads, head_size)``
– the key/value dimension is at **dim 0**.
* **FlashInfer** (``vllm/v1/attention/backends/flashinfer.py``)
returns shape ``(num_blocks, 2, block_size, num_kv_heads, head_size)``
– the key/value dimension is at **dim 1**.
This utility handles both layouts (and the tuple case) so that
downstream code can work with any backend.
Supported layouts:
* **Stacked tensor** ``[2, num_blocks, block_size, n_heads, head_dim]`` –
dim-0 selects key / value.
* **Stacked tensor** ``[num_blocks, 2, block_size, n_heads, head_dim]`` –
dim-1 selects key / value.
* **Tuple** ``(key_tensor, value_tensor)`` – returned as-is after
validation.
Args:
layer_kv: The raw KV cache (tensor or tuple) for the layer.
req_id: Request ID used only for diagnostic log messages.
layer_idx: Layer index used only for diagnostic log messages.
Returns:
``(key_blocks, value_blocks)`` if *layer_kv* is valid, ``None``
otherwise.
"""
if isinstance(layer_kv, torch.Tensor):
if layer_kv.ndim >= 3 and layer_kv.shape[0] == 2:
key_blocks = layer_kv[0]
value_blocks = layer_kv[1]
elif layer_kv.ndim >= 3 and layer_kv.shape[1] == 2:
key_blocks = layer_kv[:, 0]
value_blocks = layer_kv[:, 1]
else:
logger.warning(
f"Layer {layer_idx} for request {req_id} has invalid stacked KV shape: "
f"expected [2, ...] or [..., 2, ...] at dim 0/1, got {tuple(layer_kv.shape)}"
)
return None
elif isinstance(layer_kv, tuple):
if len(layer_kv) != 2:
logger.warning(f"Layer {layer_idx} for request {req_id} has KV pair length {len(layer_kv)} (expected 2)")
return None
key_blocks, value_blocks = layer_kv
if not isinstance(key_blocks, torch.Tensor) or not isinstance(value_blocks, torch.Tensor):
logger.warning(f"Layer {layer_idx} for request {req_id} has non-tensor KV pair entries")
return None
else:
logger.warning(f"Layer {layer_idx} for request {req_id} has unsupported KV type {type(layer_kv).__name__}")
return None
# ensure key/value blocks are at least 2D for block indexing
if key_blocks.ndim < 2 or value_blocks.ndim < 2:
logger.warning(
f"Layer {layer_idx} for request {req_id} has invalid KV block shape: "
f"got key={tuple(key_blocks.shape)} value={tuple(value_blocks.shape)}"
)
return None
return key_blocks, value_blocks