Files
wehub-resource-sync 770d92cb1f
Lint / lint (push) Has been cancelled
Build Docs / Deploy Docs (push) Has been cancelled
Windows CI / Windows (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:23:58 +08:00

391 lines
16 KiB
Python

"""Programmable router for dispatching OpenAI API to Microserving API"""
import json
import math
import threading
from collections.abc import AsyncGenerator, Iterable
from typing import Any, List, Literal, Optional, Tuple # noqa: UP035
import aiohttp
import tvm
from mlc_llm.protocol import openai_api_protocol
from mlc_llm.serve import EngineConfig, PopenServer
from mlc_llm.serve.entrypoints import microserving_entrypoints
from mlc_llm.tokenizers import Tokenizer
class Router:
"""Programmable Router Implementation"""
def __init__(
self,
model: str,
model_lib: Optional[str] = None,
hosts: Optional[List[str]] = None, # noqa: UP006
ports: Optional[List[int]] = None, # noqa: UP006
num_gpus: Optional[List[int]] = None, # noqa: UP006
enable_prefix_cache: bool = False,
router_mode: Literal["disagg", "round-robin"] = "disagg",
pd_balance_factor: float = 0.0,
):
"""
Spawn len(host_list) server endpoints with Popen.
"""
if hosts is None:
hosts = ["127.0.0.1"]
if ports is None:
ports = [8080]
if num_gpus is None:
num_gpus = [1]
self.router_mode = router_mode
self.pd_balance_factor = pd_balance_factor
# Get endpoint urls
self.num_servers = len(hosts)
assert self.num_servers == len(ports) == len(num_gpus)
self.hosts = hosts
self.ports = ports
self.server_urls = []
for i in range(self.num_servers):
self.server_urls.append(f"http://{hosts[i]}:{ports[i]}")
# Misc
self.headers = {"Content-Type": "application/json"}
self.num_running_requests = [0] * self.num_servers
# Call nvshmem_init here to get uid, then pass to env variables to server.start() below
f_init_nvshmem_uid = tvm.get_global_func("runtime.disco.nvshmem.init_nvshmem_uid")
uid = list(f_init_nvshmem_uid())
# Start underlying servers concurrently. Otherwise 1 server cannot start on its own
# since initializing nvhsmem world requires all GPUs.
self.servers: List[PopenServer] = [] # noqa: UP006
self.device_id_starts = [0]
for num_gpus_val in num_gpus:
self.device_id_starts.append(self.device_id_starts[-1] + num_gpus_val)
# device_id_starts[-1] is the total number of GPUs.
def start_server(i: int):
nvshmem_config = {
"uid": uid,
"npes": self.device_id_starts[-1], # total number of workers in the nvshmem world
"pe_start": self.device_id_starts[i], # start of PE for this endpoint's workers
}
server = PopenServer(
model=model,
model_lib=model_lib,
host=hosts[i],
port=ports[i],
enable_debug=True,
device=f"cuda:{self.device_id_starts[i]}",
mode="server",
engine_config=EngineConfig(
prefix_cache_mode="radix" if enable_prefix_cache else "disable",
gpu_memory_utilization=0.8,
),
)
self.servers.append(server)
server.start(extra_env={"MLC_NVSHMEM_INIT_CONFIG_JSON_STR": json.dumps(nvshmem_config)})
threads = []
num_used_gpus = 0
for i in range(self.num_servers):
thread = threading.Thread(
target=start_server,
args=[i],
)
num_used_gpus += num_gpus[i]
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
self.tokenizer = Tokenizer(model)
def terminate(self):
"""Terminate the underlying servers"""
for server in self.servers:
server.terminate()
async def handle_completion(
self,
request: openai_api_protocol.CompletionRequest,
request_id: str,
) -> AsyncGenerator[openai_api_protocol.CompletionResponse, Any]:
"""
Handle a completion request from API with a schedule.
"""
if isinstance(request.prompt, str):
request.prompt = self.tokenizer.encode(request.prompt)
# Add a debugConfig if not present
if request.debug_config is None:
request.debug_config = openai_api_protocol.DebugConfig()
completed = False
while not completed:
completed = True
async for response in self.translate_request(request, request_id):
if response is None:
completed = False
break
yield response
async def translate_request(
self, request: openai_api_protocol.CompletionRequest, request_id: str
) -> AsyncGenerator[openai_api_protocol.CompletionResponse, Any]:
"""
Translate OpenAI API request to microserving API calls.
"""
if self.router_mode == "disagg":
async for response in self._handle_completion_disagg(
request, request_id, pd_balance_factor=self.pd_balance_factor
):
yield response
elif self.router_mode == "round-robin":
async for response in self._handle_completion_round_robin(request):
yield response
else:
raise ValueError("Cannot reach here")
def _pick_endpoint(self, endpoint_ids: Iterable[int]) -> int:
# Pick the least congested endpoint.
endpoint_id = -1
min_running_req = int(1e9)
for candidate_id in endpoint_ids:
if self.num_running_requests[candidate_id] < min_running_req:
min_running_req = self.num_running_requests[candidate_id]
endpoint_id = candidate_id
assert endpoint_id != -1
return endpoint_id
async def _handle_completion_round_robin(
self,
request: openai_api_protocol.CompletionRequest,
) -> AsyncGenerator[openai_api_protocol.CompletionResponse, Any]:
"""
Handle a completion request from API. Given a streaming request, yields multiple response
chunks. Given a non-streaming request, yield a single response. Dispatch request to
endpoints with round-robin scheduling at a request level.
"""
# Round robin
cur_endpoint = self._pick_endpoint(range(self.num_servers))
self.num_running_requests[cur_endpoint] += 1
payload = request.model_dump()
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=3 * 3600), trust_env=True
) as session:
# todo: replace this with start_generate
async with session.post(
self.server_urls[cur_endpoint] + "/v1/completions",
json=payload,
headers=self.headers,
) as response:
assert response.status == 200, await response.text()
if payload["stream"]:
async for chunk in response.content:
# Convert raw bytes to CompletionResponse
chunk = chunk.strip()
if not chunk or chunk == b"\n":
continue
# Get rid of the prefix "data: " and suffix "\n"
raw_data = chunk[6:].strip()
if raw_data == b"[DONE]":
continue
data = json.loads(raw_data)
# Commented because we still want usage chunk to be passed back
# if not data["choices"]:
# continue
response = openai_api_protocol.CompletionResponse.model_validate(data)
if response.choices:
reason = response.choices[0].finish_reason
if reason == "preempt":
yield None
yield response
else:
data = await response.json()
response = openai_api_protocol.CompletionResponse.model_validate(data)
if response.choices:
reason = response.choices[0].finish_reason
if reason == "preempt":
yield None
yield response
self.num_running_requests[cur_endpoint] -= 1
# Below methods are for disaggregated serving
# Note that only _handle_completion_disagg() has scheduling logics. The other three
# helper methods only reflect our flow.
async def _handle_completion_disagg(
self,
original_request: openai_api_protocol.CompletionRequest,
request_id: str,
pd_balance_factor=0,
) -> AsyncGenerator[openai_api_protocol.CompletionResponse, Any]:
"""
Handle a completion request from API with disaggregated scheduling. Given two servers
P (prefill) and D (decode), the router does the following:
1. Ask D to prepare metadata, receive D's metadata
(prefix cache, KV append positions, etc.)
2. Send P the prefill request and D's metadata, receive ack
3. Ask D to start decoding, receive response as a normal streaming
"""
original_request.user = request_id
# Arbitrarily determine server 0 is P, other servers are D
prefill_server_id = 0
decode_server_id = self._pick_endpoint(range(1, self.num_servers))
# Tell D to prepare metadata for prompt[0:kv_window_end].
# P does not need to sample. Ask D to treat the last
# token like the first sampled token.
kv_window_end = (
-1
if math.fabs(pd_balance_factor) < 1e-5
else int((1 - pd_balance_factor) * len(original_request.prompt))
)
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=3 * 3600), trust_env=True
) as session:
self.num_running_requests[decode_server_id] += 1
try:
# 1. Ask D to prepare metadata
prep_recv_request = microserving_entrypoints.PrepRecvRequest(
**original_request.model_dump(), end=kv_window_end
)
(
kv_append_metadata_base64,
prefix_matched_length,
) = await self.send_prepare_receive(
session=session,
request=prep_recv_request,
server_url=self.server_urls[decode_server_id],
)
kv_window_end = (
len(original_request.prompt) + kv_window_end
if kv_window_end < 0
else kv_window_end
)
assert prefix_matched_length <= kv_window_end
# 2. Send P the prefill request and D's metadata. When it returns, it means that
# KV transfer has finished prefilling and transferring the KV of
# prompt[prefix_matched_length:kv_window_end]. So D is ready to decode.
if prefix_matched_length < kv_window_end:
remote_send_request = microserving_entrypoints.RemoteSendRequest(
**original_request.model_dump(),
begin=prefix_matched_length,
end=kv_window_end,
kv_addr_info=kv_append_metadata_base64,
recv_rank=self.device_id_starts[decode_server_id],
)
await self.send_remote_send(
session=session,
request=remote_send_request,
server_url=self.server_urls[prefill_server_id],
)
# 3. Start decoding, receive and yield back response as a normal request
# The kv window passed through denotes the range to prefill on the
# decode server, which should be [-1:] here.
start_generate_request = microserving_entrypoints.StartGenerateRequest(
**original_request.model_dump(),
begin=kv_window_end,
)
async for response in self.send_start_generate(
session=session,
request=start_generate_request,
server_url=self.server_urls[decode_server_id],
):
if len(response.choices) > 0:
finish_reason = response.choices[0].finish_reason
if finish_reason == "preempt":
yield None
yield response
except Exception as e:
self.num_running_requests[decode_server_id] -= 1
raise e
self.num_running_requests[decode_server_id] -= 1
async def send_prepare_receive(
self,
session: aiohttp.ClientSession,
request: openai_api_protocol.CompletionRequest,
server_url: str,
) -> Tuple[str, int]: # noqa: UP006
"""
Performs step 1 of disaggregated serving: ask D to prepare metadata.
Returns:
The metadata received from D, which is a tuple of 2 elements:
- kv_append_metadata_base64: str, info about KV append encoded in base64 string
- prefix_matched_length: int, length of the matched prefix.
i.e. prompt[0:prefix_matched_length] is the matched prefix
"""
# Send request to the decode server for receive preparation.
# Get the prompt length, matched prefix length and the KV metadata.
async with session.post(
server_url + "/microserving/prep_recv",
json=request.model_dump(),
headers=self.headers,
) as response:
assert response.status == 200, await response.text()
data = await response.json()
return (
data["kv_append_metadata"],
data["prefix_matched_length"],
)
async def send_remote_send(
self,
session: aiohttp.ClientSession,
request: openai_api_protocol.CompletionRequest,
server_url: str,
) -> None:
"""
Performs step 2 of disaggregated serving: ask P to prefill and transfer KV to D.
P returns an empty chunk to acknowledge completion.
"""
# Send request to P and get ack
async with session.post(
server_url + "/microserving/remote_send",
json=request.model_dump(),
headers=self.headers,
) as response:
assert response.status == 200, await response.text()
await response.json()
async def send_start_generate(
self,
session: aiohttp.ClientSession,
request: openai_api_protocol.CompletionRequest,
server_url: str,
) -> AsyncGenerator[openai_api_protocol.CompletionResponse, Any]:
"""
Performs step 3 of disaggregated serving: ask D to decode and return normal response.
"""
# Todo: return string directly to reduce str->json->str roundtrip overhead
async with session.post(
server_url + "/microserving/start_generate",
json=request.model_dump(),
headers=self.headers,
) as response:
assert response.status == 200, await response.text()
if request.stream:
async for chunk in response.content:
# Convert raw bytes to CompletionResponse
chunk = chunk.strip()
if not chunk or chunk == b"\n":
continue
# Get rid of the prefix "data: " and suffix "\n"
raw_data = chunk[6:].strip()
if raw_data == b"[DONE]":
continue
data = json.loads(raw_data)
# Commented because we still want usage chunk to be passed back
# if not data["choices"]:
# continue
yield openai_api_protocol.CompletionResponse.model_validate(data)
else:
data = await response.json()
yield openai_api_protocol.CompletionResponse.model_validate(data)