项目文件夹

文件
wehub-resource-sync 770d92cb1f
Build Docs / Deploy Docs (push) Has been cancelled
Lint / lint (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 行
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)