Signed-off-by: chickeyton <ngton2014@gmail.com> Signed-off-by: herotai214 <herotai214@gmail.com> Co-authored-by: herotai214 <herotai214@gmail.com>
473 lines
17 KiB
Python
473 lines
17 KiB
Python
# 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
|
||
|
||
import torch
|
||
from vllm.distributed.parallel_state import (
|
||
get_tensor_model_parallel_rank,
|
||
get_tensor_model_parallel_world_size,
|
||
)
|
||
from vllm.logger import init_logger
|
||
|
||
from .initialization import KV_RANK_PORT_STRIDE, KV_REPLICA_PORT_STRIDE, KV_TRANSFER_PORT_OFFSET
|
||
|
||
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,
|
||
}
|
||
|
||
|
||
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
|