Files
2026-07-13 12:24:33 +08:00

786 lines
27 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Standard
from collections import defaultdict
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Optional
import argparse
import asyncio
import itertools
import json
import math
import os
import time
# Third Party
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx
import msgspec
import numpy as np
import zmq
import zmq.asyncio
# First Party
from lmcache.logging import init_logger
from lmcache.v1.storage_backend.pd_backend import (
PDMsg,
ProxyNotif,
)
logger = init_logger(__name__)
class WeightedSemaphore:
"""Async semaphore with variable-weight acquire.
Limits in-flight PD token usage: each request holds ceil(L/chunk_size)
slots until decoding starts, preventing decoder buffer exhaustion deadlocks.
"""
def __init__(self, capacity: int) -> None:
self._capacity = capacity
self._available = capacity
self._lock = asyncio.Condition()
async def acquire(self, slots: int) -> None:
"""Acquire *slots* from the semaphore, blocking until available.
Args:
slots: Number of slots to acquire (must be <= capacity).
Raises:
ValueError: If slots exceeds total capacity (would block forever).
"""
if slots > self._capacity:
raise ValueError(
f"Requested {slots} slots exceeds total capacity {self._capacity}"
)
async with self._lock:
await self._lock.wait_for(lambda: self._available >= slots)
self._available -= slots
async def release(self, slots: int) -> None:
"""Return *slots* to the semaphore and wake waiting acquirers.
Args:
slots: Number of slots to release. No-op if <= 0.
"""
if slots <= 0:
return
async with self._lock:
self._available += slots
self._lock.notify_all()
@property
def available(self) -> int:
"""Number of slots currently available."""
return self._available
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Lifespan context manager to handle startup and shutdown events.
"""
# Startup: Initialize clients
# Build prefill clients with CSV-based broadcast pairing
pref_hosts = global_args.prefiller_host
pref_ports = global_args.prefiller_port
def pair_hosts_and_ports(hosts, ports, count=None):
"""
Flexible host-port pairing with expansion strategies.
Multiple pairing strategies:
1. Single host + single port + count: Generate incremental ports on same host
2. Single host + multiple ports: Pair the host with each port
3. Multiple hosts + single port: Pair each host with the same port
4. Multiple hosts + multiple ports: Strict one-to-one pairing
(must have same length)
"""
# Ensure lists
if not isinstance(hosts, list):
hosts = [hosts]
if not isinstance(ports, list):
ports = [ports]
# Single host/port with count -> incremental ports
if len(hosts) == 1 and len(ports) == 1:
if count is None or count <= 1:
return [(hosts[0], ports[0])]
else:
return [(hosts[0], ports[0] + i) for i in range(count)]
# Expand single host to multiple ports
if len(hosts) == 1:
return [(hosts[0], p) for p in ports]
# Expand single port to multiple hosts
if len(ports) == 1:
return [(h, ports[0]) for h in hosts]
# Strict one-to-one pairing when both lists are provided
if len(hosts) != len(ports):
raise ValueError(
"Length mismatch between hosts and ports lists for pairing"
)
return list(zip(hosts, ports, strict=False))
prefill_pairs = pair_hosts_and_ports(
pref_hosts, pref_ports, global_args.num_prefillers
)
for host, port in prefill_pairs:
prefiller_base_url = f"http://{host}:{int(port)}"
prefill_client = httpx.AsyncClient(timeout=None, base_url=prefiller_base_url)
app.state.prefill_clients.append(
ClientInfo(
prefill_client,
)
)
# Build decoder clients with CSV-based broadcast pairing
dec_hosts = global_args.decoder_host
dec_ports = global_args.decoder_port
decoder_pairs = pair_hosts_and_ports(dec_hosts, dec_ports, global_args.num_decoders)
# Whether the ports increase per instances
# (only when using single host/port with num_decoders > 1)
incremental_mode = (
len(dec_hosts) == 1 and len(dec_ports) == 1 and global_args.num_decoders > 1
)
for i, (host, port) in enumerate(decoder_pairs):
decoder_base_url = f"http://{host}:{int(port)}"
decode_client = httpx.AsyncClient(timeout=None, base_url=decoder_base_url)
if incremental_mode:
init_ports = [p + i for p in global_args.decoder_init_port]
alloc_ports = [p + i for p in global_args.decoder_alloc_port]
else:
# Use the provided ports as-is
# (suitable when different hosts can reuse same port numbers)
init_ports = list(global_args.decoder_init_port)
alloc_ports = list(global_args.decoder_alloc_port)
app.state.decode_clients.append(
ClientInfo(
decode_client,
host,
init_ports,
alloc_ports,
)
)
app.state.total_clients = app.state.prefill_clients + app.state.decode_clients
app.state.zmq_task = asyncio.create_task(zmq_pull_server())
global pd_buffer_semaphore
kv_bytes_per_token = compute_kv_bytes_per_token(global_args.model)
capacity_slots = global_args.pd_buffer_size // (
kv_bytes_per_token * global_args.chunk_size
)
pd_buffer_semaphore = WeightedSemaphore(capacity_slots)
logger.info(
"PD buffer semaphore: capacity=%d slots"
" (%d bytes / (%d bytes/tok * %d chunk_size)) for model %s.",
capacity_slots,
global_args.pd_buffer_size,
kv_bytes_per_token,
global_args.chunk_size,
global_args.model,
)
yield
# Shutdown: Close clients
for client in app.state.prefill_clients:
await client.aclose()
for client in app.state.decode_clients:
await client.aclose()
global run_proxy
run_proxy = False
await app.state.zmq_task # Wait for background task to finish
# Update FastAPI app initialization to use lifespan
app = FastAPI(lifespan=lifespan)
class StatsCalculator:
def __init__(self):
self._stats = []
self._last_log_time = time.time()
def add(self, value):
self._stats.append(value)
if time.time() - self._last_log_time > 5:
self._log_stats()
self._last_log_time = time.time()
def _log_stats(self):
# Print average, median, and 99th percentile
np_arr = np.array(self._stats) * 1000
output_str = (
f"\nNum requests: {len(self._stats)}"
+ "\nPrefill node TTFT stats:"
+ f"\n - Average (ms): {np.mean(np_arr)}"
+ f"\n - Median (ms): {np.median(np_arr)}"
+ f"\n - 99th Percentile (ms): {np.percentile(np_arr, 99)}\n"
)
print(
"===============================",
output_str,
"===============================",
)
stats_calculator = StatsCalculator()
counter = 0
def csv_ints(s):
return [int(x) for x in s.split(",")]
def csv_strs(s):
return [x.strip() for x in s.split(",")]
def compute_kv_bytes_per_token(model_name: str) -> int:
"""Return the number of KV cache bytes per token for *model_name*.
Reads num_hidden_layers, num_key_value_heads, head_dim, and torch_dtype
from the HuggingFace config without downloading model weights.
Args:
model_name: HuggingFace model id or local path.
Returns:
Bytes per token across all layers and both K/V tensors.
"""
# Third Party
from transformers import AutoConfig
cfg = AutoConfig.from_pretrained(model_name)
num_layers: int = cfg.num_hidden_layers
num_kv_heads: int = getattr(cfg, "num_key_value_heads", cfg.num_attention_heads)
head_dim: int = getattr(cfg, "head_dim", cfg.hidden_size // cfg.num_attention_heads)
# 4 bytes for float32, 2 bytes for float16/bfloat16 (the common default)
torch_dtype = str(getattr(cfg, "torch_dtype", "bfloat16"))
dtype_bytes = 4 if "float32" in torch_dtype else 2
return 2 * num_layers * num_kv_heads * head_dim * dtype_bytes
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--host", type=str, default="localhost")
parser.add_argument("--prefiller-host", type=csv_strs, default=["localhost"])
parser.add_argument("--prefiller-port", type=csv_ints, default=[8100])
parser.add_argument("--num-prefillers", type=int, default=1)
parser.add_argument("--decoder-host", type=csv_strs, default=["localhost"])
parser.add_argument("--decoder-port", type=csv_ints, default=[8200])
parser.add_argument("--decoder-init-port", type=csv_ints, default=[8300])
parser.add_argument("--decoder-alloc-port", type=csv_ints, default=[8400])
parser.add_argument("--num-decoders", type=int, default=1)
parser.add_argument("--proxy-host", type=str, default="localhost")
parser.add_argument("--proxy-port", type=int, default=8500)
# PD buffer concurrency limiting. A weighted semaphore caps in-flight
# chunk slots to prevent decoder buffer exhaustion deadlocks.
# capacity_slots = pd_buffer_size // (kv_bytes_per_token * chunk_size)
# kv_bytes_per_token is derived from the model config automatically.
parser.add_argument(
"--model",
type=str,
default="meta-llama/Llama-3.1-8B-Instruct",
help=(
"HuggingFace model name or local path. Used to derive"
" kv_bytes_per_token for the PD buffer semaphore capacity."
),
)
parser.add_argument(
"--pd-buffer-size",
type=int,
default=2 * 1024 * 1024 * 1024, # 2 GB
help=(
"PD transfer buffer size in bytes (must match the decoder's"
" LMCache config). Used to derive the in-flight slot capacity."
" Default: 2 GB."
),
)
parser.add_argument(
"--chunk-size",
type=int,
default=256,
help="LMCache chunk size in tokens (must match the LMCache config).",
)
args = parser.parse_args()
return args
@dataclass
class ClientInfo:
client: httpx.AsyncClient
host: Optional[str] = None
init_port: Optional[list[int]] = None
alloc_port: Optional[list[int]] = None
# Initialize variables to hold the persistent clients
app.state.prefill_clients = []
app.state.decode_clients = []
app.state.total_clients = []
"""
client_request and prefill/decode map
key: str - unique id for requests across same conversation
value: tuple - (tokenization_client, prefiller_client, decoder_client)
"""
app.state.bound_clients = {}
# Keep finished reqs
app.state.finished_reqs = defaultdict(int)
pd_buffer_semaphore: Optional[WeightedSemaphore] = None
zmq_ctx = zmq.asyncio.Context()
run_proxy = True # Shutdown flag
async def zmq_pull_server():
socket = zmq_ctx.socket(zmq.PULL)
proxy_url = f"{global_args.proxy_host}:{global_args.proxy_port}"
try:
socket.bind(f"tcp://{proxy_url}")
except zmq.ZMQError:
logger.exception("ZMQ proxy server failed to bind on %s", proxy_url)
return
logger.info("ZMQ proxy server started on %s", proxy_url)
while run_proxy:
try:
msg_bytes = await socket.recv()
except zmq.Again:
await asyncio.sleep(0.01) # Avoid busy loop
continue
except zmq.ZMQError as exc:
if exc.errno in (zmq.ETERM, zmq.ENOTSOCK):
break
logger.warning("ZMQ recv error: %s", exc)
await asyncio.sleep(0.05)
continue
try:
msg = msgspec.msgpack.decode(msg_bytes, type=PDMsg)
except msgspec.DecodeError as exc:
logger.warning("ZMQ received non-PD message: %s", exc)
continue
except Exception as exc:
logger.exception("ZMQ message decode failed: %s", exc)
continue
if not isinstance(msg, ProxyNotif):
logger.debug("ZMQ ignored message type: %s", type(msg).__name__)
continue
req_id = msg.req_id
app.state.finished_reqs[req_id] += 1
logger.debug("Prefill of req %s done.", req_id)
socket.close()
logger.info("ZMQ PULL server stopped.")
async def send_request_to_service(
client: httpx.AsyncClient, endpoint: str, req_data: dict
):
"""
Send a request to a service using a persistent client.
"""
headers = {"Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}"}
response = await client.post(endpoint, json=req_data, headers=headers)
response.raise_for_status()
return response
async def stream_service_response(
client: httpx.AsyncClient, endpoint: str, req_data: dict
):
"""
Asynchronously stream the response from a service using a persistent client.
"""
headers = {"Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}"}
async with client.stream(
"POST", endpoint, json=req_data, headers=headers
) as response:
response.raise_for_status()
async for chunk in response.aiter_bytes():
yield chunk
def round_robin_pick_client(clients, idx):
return clients[idx % len(clients)]
round_robin_counter = itertools.count()
def round_robin_pick_clients() -> tuple[ClientInfo, ClientInfo, ClientInfo]:
idx = next(round_robin_counter)
tokenization_client = round_robin_pick_client(app.state.total_clients, idx)
prefill_client = round_robin_pick_client(app.state.prefill_clients, idx)
decode_client = round_robin_pick_client(app.state.decode_clients, idx)
return tokenization_client, prefill_client, decode_client
async def wait_decode_kv_ready(req_id: str, num_tp_rank: int):
while app.state.finished_reqs[req_id] < num_tp_rank:
await asyncio.sleep(0.0001) # sleep for 0.1 ms
logger.debug(f"Prefill node signaled kv ready for req {req_id}")
app.state.finished_reqs.pop(req_id)
BOUND_CLIENTS_MAX_NUM = 1024 * 1024
def pick_up_bound_clients(client_id: str) -> tuple[ClientInfo, ClientInfo, ClientInfo]:
if client_id not in app.state.bound_clients:
if len(app.state.bound_clients) >= BOUND_CLIENTS_MAX_NUM:
# Here simply clear the bound_clients if full
app.state.bound_clients.clear()
app.state.bound_clients[client_id] = round_robin_pick_clients()
return app.state.bound_clients[client_id]
BOUND_CLIENT = os.getenv("CLIENT_BOUND", "false").lower() == "true"
# CLIENT_BOUND_KEY, the field name of the client uid in http request
CLIENT_BOUND_KEY = os.getenv("CLIENT_BOUND_KEY", "session-id")
def pick_up_clients(request: Request) -> tuple[ClientInfo, ClientInfo, ClientInfo]:
bound_client_id = request.headers.get(CLIENT_BOUND_KEY) if BOUND_CLIENT else None
if bound_client_id:
# Use or create a persistent set of clients for the session.
return pick_up_bound_clients(bound_client_id)
return round_robin_pick_clients()
@app.post("/v1/completions")
async def handle_completions(request: Request):
global counter, stats_calculator
counter += 1
req_id = str(counter) # we use counter as req_id
st = time.time()
slots = 0 # slots to release on error; set after successful acquire only
acquired = False
try:
req_data = await request.json()
# Pick tokenization, prefill and decode client
tokenization_client, prefill_client, decode_client = pick_up_clients(request)
tokenize_output = await send_request_to_service(
tokenization_client.client, "/tokenize", {"prompt": req_data["prompt"]}
)
tokenize_output = tokenize_output.json()
org_max_tokens = req_data["max_tokens"]
req_data["prompt"] = tokenize_output["tokens"]
req_data["max_tokens"] = 1
# Acquire ceil(L/chunk_size) PD buffer slots before prefill.
slots = math.ceil(len(tokenize_output["tokens"]) / global_args.chunk_size)
if pd_buffer_semaphore is not None:
await pd_buffer_semaphore.acquire(slots)
acquired = True
disagg_spec = {
"req_id": req_id,
"receiver_host": decode_client.host,
"receiver_init_port": decode_client.init_port,
"receiver_alloc_port": decode_client.alloc_port,
}
num_tp_rank = len(decode_client.init_port or [])
req_data["kv_transfer_params"] = {
"ret_first_tok": True,
"disagg_spec": disagg_spec,
}
req_data["stream"] = False
stream_options = req_data.pop("stream_options", None)
# Send request to prefill service, ignore the response
prefill_output = await send_request_to_service(
prefill_client.client, "/v1/completions", req_data
)
prefill_output = prefill_output.json()
et = time.time()
stats_calculator.add(et - st)
req_data["max_tokens"] = org_max_tokens - 1
req_data["prompt"].append(prefill_output["kv_transfer_params"]["first_tok"])
req_data.pop("kv_transfer_params")
req_data["stream"] = True
if stream_options is not None:
req_data["stream_options"] = stream_options
# Stream response from decode service
async def generate_stream():
head_chunk = {
"id": prefill_output["id"],
"object": "text_completion",
"created": prefill_output["created"],
"model": prefill_output["model"],
"choices": [
{
"index": 0,
"text": prefill_output["choices"][0]["text"],
"logprobs": None,
"finish_reason": None,
"stop_reason": None,
}
],
"usage": None,
}
yield (
"data: " + json.dumps(head_chunk, separators=(",", ":")) + "\n\n"
).encode()
try:
await wait_decode_kv_ready(req_id, num_tp_rank)
finally:
if pd_buffer_semaphore is not None:
await pd_buffer_semaphore.release(slots)
async for chunk in stream_service_response(
decode_client.client, "/v1/completions", req_data
):
yield chunk
return StreamingResponse(generate_stream(), media_type="application/json")
except Exception as e:
if pd_buffer_semaphore is not None and acquired:
await pd_buffer_semaphore.release(slots)
# Standard
import sys
import traceback
exc_info = sys.exc_info()
print("Error occurred in disagg prefill proxy server - completions endpoint")
print(e)
print("".join(traceback.format_exception(*exc_info)))
raise
@app.post("/v1/chat/completions")
async def handle_chat_completions(request: Request):
global counter, stats_calculator
counter += 1
req_id = str(counter)
st = time.time()
slots = 0 # slots to release on error; set after successful acquire only
acquired = False
try:
req_data = await request.json()
# Pick tokenization, prefill and decode client
tokenization_client, prefill_client, decode_client = pick_up_clients(request)
# For chat completions, we need to tokenize the messages
tokenize_output = await send_request_to_service(
tokenization_client.client, "/tokenize", {"messages": req_data["messages"]}
)
tokenize_output = tokenize_output.json()
org_max_tokens = req_data["max_tokens"]
req_data["prompt"] = tokenize_output["tokens"]
req_data["max_tokens"] = 1
org_max_completion_tokens = None
if "max_completion_tokens" in req_data:
org_max_completion_tokens = req_data["max_completion_tokens"]
req_data["max_completion_tokens"] = 1
# Acquire ceil(L/chunk_size) PD buffer slots before prefill.
slots = math.ceil(len(tokenize_output["tokens"]) / global_args.chunk_size)
if pd_buffer_semaphore is not None:
await pd_buffer_semaphore.acquire(slots)
acquired = True
disagg_spec = {
"req_id": req_id,
"receiver_host": decode_client.host,
"receiver_init_port": decode_client.init_port,
"receiver_alloc_port": decode_client.alloc_port,
}
num_tp_rank = len(decode_client.init_port or [])
req_data["kv_transfer_params"] = {
"ret_first_tok": True,
"disagg_spec": disagg_spec,
}
req_data["stream"] = False
stream_options = req_data.pop("stream_options", None)
# Send request to prefill service, get the response
prefill_output = await send_request_to_service(
prefill_client.client, "/v1/completions", req_data
)
prefill_output = prefill_output.json()
et = time.time()
stats_calculator.add(et - st)
req_data["max_tokens"] = org_max_tokens - 1
if org_max_completion_tokens is not None:
req_data["max_completion_tokens"] = org_max_completion_tokens - 1
# Add the first token from prefill to the tokenized messages for decode
req_data["prompt"].append(prefill_output["kv_transfer_params"]["first_tok"])
req_data.pop("kv_transfer_params")
req_data["stream"] = True
if stream_options is not None:
req_data["stream_options"] = stream_options
# Stream response from decode service
async def generate_stream():
initial_chunk = {
"id": prefill_output["id"],
"object": "chat.completion.chunk",
"created": prefill_output["created"],
"model": prefill_output["model"],
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": ""},
"logprobs": None,
"finish_reason": None,
}
],
}
yield (
"data: " + json.dumps(initial_chunk, separators=(",", ":")) + "\n\n"
).encode()
head_chunk = {
"id": prefill_output["id"],
"object": "chat.completion.chunk",
"created": prefill_output["created"],
"model": prefill_output["model"],
"choices": [
{
"index": 0,
"delta": {"content": prefill_output["choices"][0]["text"]},
"logprobs": None,
"finish_reason": None,
}
],
}
yield (
"data: " + json.dumps(head_chunk, separators=(",", ":")) + "\n\n"
).encode()
try:
await wait_decode_kv_ready(req_id, num_tp_rank)
finally:
if pd_buffer_semaphore is not None:
await pd_buffer_semaphore.release(slots)
# Stream and convert completion format chunks to chat completion format
async for chunk in stream_service_response(
decode_client.client, "/v1/completions", req_data
):
chunk_str = chunk.decode("utf-8")
if chunk_str.startswith("data: ") and not chunk_str.startswith(
"data: [DONE]"
):
try:
json_str = chunk_str[6:].strip() # Remove 'data: ' prefix
if json_str:
completion_data = json.loads(json_str)
# Decoder can emit non-token chunks (usage, final
# metadata, keepalives) with an empty choices list.
# Those aren't chat-completion deltas, so pass the
# original chunk through unchanged instead of
# indexing into an empty list.
if not completion_data.get("choices"):
yield chunk
continue
chat_completion_data = {
"id": completion_data["id"],
"object": "chat.completion.chunk",
"created": completion_data["created"],
"model": completion_data["model"],
"choices": [
{
"index": 0,
"delta": {
"content": completion_data["choices"][0][
"text"
]
},
"logprobs": completion_data["choices"][0].get(
"logprobs"
),
"finish_reason": completion_data["choices"][
0
].get("finish_reason"),
}
],
}
converted_chunk = (
"data: "
+ json.dumps(
chat_completion_data, separators=(",", ":")
)
+ "\n\n"
).encode()
yield converted_chunk
except (json.JSONDecodeError, KeyError):
yield chunk
else:
yield chunk
return StreamingResponse(generate_stream(), media_type="application/json")
except Exception as e:
if pd_buffer_semaphore is not None and acquired:
await pd_buffer_semaphore.release(slots)
# Standard
import sys
import traceback
exc_info = sys.exc_info()
print(
"Error occurred in disagg prefill proxy server - chat completions endpoint"
)
print(e)
print("".join(traceback.format_exception(*exc_info)))
raise
if __name__ == "__main__":
global global_args
global_args = parse_args()
# Third Party
import uvicorn
uvicorn.run(app, host=global_args.host, port=global_args.port)