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

462 lines
14 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Standard
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Optional
import argparse
import asyncio
import itertools
import os
import threading
import time
import uuid
# Third Party
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse
import httpx
# First Party
from lmcache.logging import init_logger
logger = init_logger(__name__)
# Global dictionary to store asyncio.Events indexed by request ID.
# This is shared between the main proxy app and the telemetry app.
pending_requests: dict[str, asyncio.Event] = {}
pending_requests_lock = threading.Lock()
# Reference to the main event loop (set at startup)
main_event_loop: Optional[asyncio.AbstractEventLoop] = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Lifespan context manager to handle startup and shutdown events.
"""
global main_event_loop
main_event_loop = asyncio.get_running_loop()
# Startup: Initialize clients
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.
"""
if not isinstance(hosts, list):
hosts = [hosts]
if not isinstance(ports, list):
ports = [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)]
if len(hosts) == 1:
return [(hosts[0], p) for p in ports]
if len(ports) == 1:
return [(h, ports[0]) for h in hosts]
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
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)
for host, port in decoder_pairs:
decoder_base_url = f"http://{host}:{int(port)}"
decode_client = httpx.AsyncClient(timeout=None, base_url=decoder_base_url)
app.state.decode_clients.append(ClientInfo(decode_client))
app.state.total_clients = app.state.prefill_clients + app.state.decode_clients
yield
# Shutdown: Close clients
for client in app.state.prefill_clients:
await client.aclose()
for client in app.state.decode_clients:
await client.aclose()
# Main proxy FastAPI app
app = FastAPI(lifespan=lifespan)
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 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("--num-decoders", type=int, default=1)
parser.add_argument("--telemetry-port", type=int, default=5768)
args = parser.parse_args()
return args
@dataclass
class ClientInfo:
client: httpx.AsyncClient
host: Optional[str] = None
async def aclose(self):
await self.client.aclose()
# Initialize state
app.state.prefill_clients = []
app.state.decode_clients = []
app.state.total_clients = []
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]:
idx = next(round_robin_counter)
prefill_client = round_robin_pick_client(app.state.prefill_clients, idx)
decode_client = round_robin_pick_client(app.state.decode_clients, idx)
return prefill_client, decode_client
def _build_headers(**extra: str) -> dict[str, str]:
"""Build common HTTP headers, including auth if OPENAI_API_KEY is set."""
headers: dict[str, str] = {"Content-Type": "application/json"}
api_key = os.environ.get("OPENAI_API_KEY", "").strip()
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
headers.update(extra)
return headers
async def send_request_to_prefiller(
client: httpx.AsyncClient,
endpoint: str,
req_data: dict,
request_id: str,
):
"""
Send a request to prefiller with request-id header.
"""
headers = _build_headers(**{"X-Request-Id": request_id})
response = await client.post(endpoint, json=req_data, headers=headers)
response.raise_for_status()
return response
async def send_request_to_decoder(
client: httpx.AsyncClient,
endpoint: str,
req_data: dict,
):
"""
Send a request to decoder service.
"""
headers = _build_headers()
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.
"""
headers = _build_headers()
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 generate_request_id() -> str:
"""Generate a unique request ID."""
return str(uuid.uuid4())[:16]
def create_pending_request(request_id: str) -> asyncio.Event:
"""Create an asyncio.Event for a request and store it."""
event = asyncio.Event()
with pending_requests_lock:
pending_requests[request_id] = event
return event
def remove_pending_request(request_id: str):
"""Remove a pending request from the dictionary."""
with pending_requests_lock:
pending_requests.pop(request_id, None)
def notify_request(chatcmpl_request_id: str) -> bool:
"""
Notify a pending request that the KV store is complete.
Returns True if the request was found and notified.
"""
with pending_requests_lock:
# vLLM wraps the request ID as "chatcmpl-{uuid}-{suffix}",
# so strip the first and last segments to recover the original UUID.
request_id = "-".join(chatcmpl_request_id.split("-")[1:])
request_id = request_id[:16]
event = pending_requests.get(request_id, None)
if event:
# Schedule the event.set() on the main event loop
if main_event_loop is not None:
main_event_loop.call_soon_threadsafe(event.set)
return True
return False
async def _handle_disagg_request(request: Request, endpoint: str):
"""
Common handler for disaggregated prefill/decode requests.
Works for both /v1/completions and /v1/chat/completions — the only
difference is the *endpoint* path forwarded to the prefiller and decoder.
"""
try:
req_data = await request.json()
# Generate a random request ID
request_id = generate_request_id()
logger.info(f"Received {endpoint} request with generated ID: {request_id}")
# Pick prefill and decode clients
prefill_client, decode_client = round_robin_pick_clients()
# Create event for this request (signaled when KV store finishes)
event = create_pending_request(request_id)
try:
# Modify request for prefiller: set max_tokens=1
prefill_req_data = req_data.copy()
prefill_req_data["max_tokens"] = 1
if "max_completion_tokens" in prefill_req_data:
prefill_req_data["max_completion_tokens"] = 1
prefill_req_data["stream"] = False
prefill_req_data.pop("stream_options", None)
# Send to prefiller with X-Request-Id header (ignore output)
prefill_send_time = time.monotonic()
await send_request_to_prefiller(
prefill_client.client,
endpoint,
prefill_req_data,
request_id,
)
prefill_first_response_time = time.monotonic()
prefill_duration = prefill_first_response_time - prefill_send_time
logger.info(
f"Request {request_id}: prefill request"
f" duration = {prefill_duration:.4f}s"
)
# Wait for the event to be signaled (KV store finished)
await event.wait()
notify_time = time.monotonic()
notify_wait_duration = notify_time - prefill_first_response_time
logger.info(
f"Request {request_id}: finished saving KV caches after prefill"
f" response = {notify_wait_duration * 1000:.2f}ms"
)
logger.debug(f"Event signaled for {request_id}, forwarding to decoder")
finally:
# Clean up the pending request
remove_pending_request(request_id)
# Forward original request to decoder
is_stream = req_data.get("stream", False)
if is_stream:
async def generate_stream():
first_chunk = True
async for chunk in stream_service_response(
decode_client.client, endpoint, req_data
):
if first_chunk:
decode_first_response_time = time.monotonic()
latency = (
decode_first_response_time - prefill_first_response_time
)
logger.info(
f"Request {request_id}: latency between prefill first "
f"response and decode first response = "
f"{latency * 1000:.2f}ms"
)
first_chunk = False
yield chunk
return StreamingResponse(generate_stream(), media_type="application/json")
else:
response = await send_request_to_decoder(
decode_client.client, endpoint, req_data
)
return JSONResponse(content=response.json())
except Exception as e:
# Standard
import sys
import traceback
exc_info = sys.exc_info()
logger.error(f"Error in {endpoint} endpoint")
logger.error(str(e))
logger.error("".join(traceback.format_exception(*exc_info)))
raise
@app.post("/v1/completions")
async def handle_completions(request: Request):
"""Handle /v1/completions requests."""
return await _handle_disagg_request(request, "/v1/completions")
@app.post("/v1/chat/completions")
async def handle_chat_completions(request: Request):
"""Handle /v1/chat/completions requests."""
return await _handle_disagg_request(request, "/v1/chat/completions")
@app.get("/v1/models")
async def handle_models():
"""Handle /v1/models requests by forwarding to the first prefiller."""
try:
prefill_client = app.state.prefill_clients[0]
headers = _build_headers()
response = await prefill_client.client.get("/v1/models", headers=headers)
response.raise_for_status()
return JSONResponse(content=response.json())
except Exception as e:
logger.error(f"Error in /v1/models endpoint: {e}")
return JSONResponse(
content={"error": str(e)},
status_code=500,
)
# ============================================================================
# Telemetry FastAPI app (runs on separate port)
# ============================================================================
telemetry_app = FastAPI()
@telemetry_app.post("/api/v1/telemetry")
async def handle_telemetry(request: Request):
"""
Handle telemetry POST requests.
Expected payload format (from FastAPIRequestTelemetry):
{
"event": "request_store_finished",
"request_ids_set": ["id1", "id2", ...],
"model_name": "...",
"world_size": N,
"kv_rank": K,
}
For each request ID in request_ids_set, signal the corresponding
event if it exists.
"""
try:
payload = await request.json()
event_type = payload.get("event")
request_ids = payload.get("request_ids_set", [])
for request_id in request_ids:
logger.info(
f"Received telemetry event: {event_type} for request: {request_id}"
)
notified_count = 0
for request_id in request_ids:
if notify_request(request_id):
notified_count += 1
logger.info(f"Notified request: {request_id}")
return JSONResponse(
content={
"status": "ok",
"notified": notified_count,
"total": len(request_ids),
}
)
except Exception as e:
logger.error(f"Error processing telemetry: {e}")
return JSONResponse(
content={"status": "error", "message": str(e)},
status_code=500,
)
def run_telemetry_server(host: str, port: int):
"""Run the telemetry server in a separate thread."""
# Third Party
import uvicorn
uvicorn.run(telemetry_app, host=host, port=port, log_level="info")
if __name__ == "__main__":
global global_args
global_args = parse_args()
# Third Party
import uvicorn
# Start telemetry server in a background thread
telemetry_thread = threading.Thread(
target=run_telemetry_server,
args=(global_args.host, global_args.telemetry_port),
daemon=True,
)
telemetry_thread.start()
logger.info(
f"Telemetry server started on {global_args.host}:{global_args.telemetry_port}"
)
# Run main proxy server
uvicorn.run(app, host=global_args.host, port=global_args.port)