chore: import upstream snapshot with attribution
Lint / lint (push) Has been cancelled
Build Docs / Deploy Docs (push) Has been cancelled
Windows CI / Windows (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:23:58 +08:00
commit 770d92cb1f
694 changed files with 114634 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
"""Subdirectory of serving."""
# Load MLC LLM library by importing base
from .. import base
from .config import EngineConfig
from .data import Data, ImageData, RequestStreamOutput, TextData, TokenData
from .embedding_engine import AsyncEmbeddingEngine
from .engine import AsyncMLCEngine, MLCEngine
from .radix_tree import PagedRadixTree
from .request import Request
from .server import PopenServer
+7
View File
@@ -0,0 +1,7 @@
"""FFI APIs for mlc_llm.serve"""
import tvm_ffi
# Exports functions registered via TVM_FFI_REGISTER_GLOBAL with the "mlc.serve" prefix.
# e.g. TVM_FFI_REGISTER_GLOBAL("mlc.serve.TextData")
tvm_ffi.init_ffi_api("mlc.serve", __name__)
+169
View File
@@ -0,0 +1,169 @@
"""Configuration dataclasses used in MLC LLM serving"""
import json
from dataclasses import asdict, dataclass, field
from typing import List, Literal, Optional, Tuple, Union # noqa: UP035
@dataclass
class EngineConfig:
"""The class of MLCEngine execution configuration.
Parameters
----------
model : str
The path to the model directory.
model_lib : str
The path to the model library.
additional_models : List[Union[str, Tuple[str, str]]]
The paths to the additional models' directories (and model libraries).
Each element is a single string (denoting the model directory)
or a tuple of two strings (denoting the model directory and model lib path).
mode : Literal["local", "interactive", "server"]
The engine mode in MLC LLM.
We provide three preset modes: "local", "interactive" and "server".
The default mode is "local".
The choice of mode decides the values of "max_num_sequence", "max_total_sequence_length"
and "prefill_chunk_size" when they are not explicitly specified.
1. Mode "local" refers to the local server deployment which has low
request concurrency. So the max batch size will be set to 4, and max
total sequence length and prefill chunk size are set to the context
window size (or sliding window size) of the model.
2. Mode "interactive" refers to the interactive use of server, which
has at most 1 concurrent request. So the max batch size will be set to 1,
and max total sequence length and prefill chunk size are set to the context
window size (or sliding window size) of the model.
3. Mode "server" refers to the large server use case which may handle
many concurrent request and want to use GPU memory as much as possible.
In this mode, we will automatically infer the largest possible max batch
size and max total sequence length.
You can manually specify arguments "max_num_sequence", "max_total_sequence_length" and
"prefill_chunk_size" to override the automatic inferred values.
tensor_parallel_shards : Optional[int]
Number of shards to split the model into in tensor parallelism multi-gpu inference.
When "model_lib" is given, this field will be ignored, and the tensor_parallel_shards
in the model_lib metadata will be used.
pipeline_parallel_stages : Optional[int]
Number of pipeline stages to split the model layers for pipeline parallelism.
When "model_lib" is given, this field will be ignored, and the pipeline_parallel_stages
in the model_lib metadata will be used.
opt : Optional[str]
The optimization flags for JIT compilation.
When "model_lib" is given, this field will be ignored.
MLC LLM maintains a predefined set of optimization flags,
denoted as O0, O1, O2, O3, where O0 means no optimization, O2 means majority of them,
and O3 represents extreme optimization that could potentially break the system.
Meanwhile, optimization flags could be explicitly specified via details knobs, e.g.
"cublas_gemm=1;cudagraph=0".
gpu_memory_utilization : Optional[float]
A number in (0, 1) denoting the fraction of GPU memory used by the server in total.
It is used to infer to maximum possible KV cache capacity.
When it is unspecified, it defaults to 0.85.
Under mode "local" or "interactive", the actual memory usage may be
significantly smaller than this number. Under mode "server", the actual
memory usage may be slightly larger than this number.
kv_cache_page_size : int
The number of consecutive tokens handled in each page in paged KV cache.
max_num_sequence : Optional[int]
The maximum number of sequences that are allowed to be
processed by the KV cache at any time.
max_total_sequence_length : Optional[int]
The maximum total number of tokens whose KV data are allowed
to exist in the KV cache at any time.
max_single_sequence_length : Optional[int]
The maximum length allowed for a single sequence in the engine.
prefill_chunk_size : Optional[int]
The maximum total sequence length in a prefill.
sliding_window_size : Optional[int]
The sliding window size in sliding window attention (SWA).
attention_sink_size : Optional[int]
The number of attention sinks when sliding window is enabled..
max_history_size: Optional[int]
The maximum history size for RNN state to roll back.
kv_state_kind: Optional[Literal["kv_cache", "rnn_state"]]
The kind of cache.
speculative_mode : Literal["disable", "small_draft", "eagle", "medusa"]
The speculative mode.
"disable" means speculative decoding is disabled.
"small_draft" means the normal speculative decoding (small draft) mode.
"eagle" means the eagle-style speculative decoding.
"medusa" means the medusa-style speculative decoding.
spec_draft_length : int
The number of tokens to generate in speculative proposal (draft).
Being 0 means to enable adaptive speculative mode, where the draft length
will be automatically adjusted based on engine state.
spec_tree_width : int
The width of the speculative decoding tree.
prefix_cache_mode : Literal["disable", "radix"]
The prefix cache mode.
"disable" means no prefix cache is disabled.
"radix" means the paged radix tree based prefix cache mode.
prefix_cache_max_num_recycling_seqs: Optional[int]
The maximum number of recycling sequences in prefix cache, default as max_num_sequence.
And set 0 to disable prefix cache, set -1 to have infinite capacity prefix cache.
prefill_mode : Literal["chunked", "hybrid"]
The prefill mode.
"chunked" means the basic prefill with chunked input enabled.
"hybrid" means the hybrid prefill or split-fuse,
so that decode step will be converted into prefill.
verbose : bool
A boolean indicating whether to print logging info in engine.
"""
model: Optional[str] = None
model_lib: Optional[str] = None
additional_models: List[Union[str, Tuple[str, str]]] = field(default_factory=list) # noqa: UP006
mode: Optional[Literal["local", "interactive", "server"]] = None
tensor_parallel_shards: Optional[int] = None
pipeline_parallel_stages: Optional[int] = None
opt: Optional[str] = None
gpu_memory_utilization: Optional[float] = None
kv_cache_page_size: int = 16
max_num_sequence: Optional[int] = None
max_total_sequence_length: Optional[int] = None
max_single_sequence_length: Optional[int] = None
prefill_chunk_size: Optional[int] = None
sliding_window_size: Optional[int] = None
attention_sink_size: Optional[int] = None
max_history_size: Optional[int] = None
kv_state_kind: Optional[Literal["kv_cache", "rnn_state"]] = None
speculative_mode: Literal["disable", "small_draft", "eagle", "medusa"] = "disable"
spec_draft_length: int = 0
spec_tree_width: int = 1
prefix_cache_mode: Literal["disable", "radix"] = "radix"
prefix_cache_max_num_recycling_seqs: Optional[int] = None
prefill_mode: Literal["chunked", "hybrid"] = "hybrid"
verbose: bool = True
def asjson(self) -> str:
"""Return the config in string of JSON format."""
return json.dumps(asdict(self))
@staticmethod
def from_json(json_str: str) -> "EngineConfig":
"""Construct a config from JSON string."""
return EngineConfig(**json.loads(json_str))
+214
View File
@@ -0,0 +1,214 @@
"""Classes denoting multi-modality data used in MLC LLM serving"""
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple # noqa: UP035
import tvm
import tvm_ffi
from tvm.runtime import Object, Tensor
from . import _ffi_api
@tvm_ffi.register_object("mlc.serve.Data")
class Data(Object):
"""The base class of multi-modality data (text, tokens, embedding, etc)."""
def __init__(self):
pass
@tvm_ffi.register_object("mlc.serve.TextData")
class TextData(Data):
"""The class of text data, containing a text string.
Parameters
----------
text : str
The text string.
"""
def __init__(self, text: str):
self.__init_handle_by_constructor__(_ffi_api.TextData, text)
@property
def text(self) -> str:
"""The text data in `str`."""
return str(_ffi_api.TextDataGetTextString(self))
def __str__(self) -> str:
return self.text
@tvm_ffi.register_object("mlc.serve.TokenData")
class TokenData(Data):
"""The class of token data, containing a list of token ids.
Parameters
----------
token_ids : List[int]
The list of token ids.
"""
def __init__(self, token_ids: List[int]): # noqa: UP006
self.__init_handle_by_constructor__(_ffi_api.TokenData, *token_ids)
@property
def token_ids(self) -> List[int]: # noqa: UP006
"""Return the token ids of the TokenData."""
return list(_ffi_api.TokenDataGetTokenIds(self))
# mypy: disable-error-code="attr-defined"
@tvm_ffi.register_object("mlc.serve.ImageData")
class ImageData(Data):
"""The class of image data, containing the image as Tensor.
Parameters
----------
image : tvm.runtime.Tensor
The image data.
"""
def __init__(self, image: Tensor, embed_size: int):
self.embed_size = embed_size
self.__init_handle_by_constructor__(_ffi_api.ImageData, image, embed_size)
@property
def image(self) -> Tensor:
"""Return the image data."""
return _ffi_api.ImageDataGetImage(self)
def __len__(self):
return self.embed_size
@staticmethod
def from_url(url: str, config: Dict) -> "ImageData": # noqa: UP006
"""Get the image from the given URL, process and return the image tensor as TVM Tensor."""
import base64
from io import BytesIO
import numpy as np
import requests
from PIL import Image
if url.startswith("data:image"):
# The image is encoded in base64 format
base64_image = url.split(",")[1]
image_data = base64.b64decode(base64_image)
image_tensor = Image.open(BytesIO(image_data)).convert("RGB")
elif url.startswith("http"):
response = requests.get(url, timeout=5)
image_tensor = Image.open(BytesIO(response.content)).convert("RGB")
else:
raise ValueError(f"Unsupported image URL format: {url}")
# image_embed_size = ImageData.get_embed_size(config)
# TODO: fix these hard-coded values for phi3.5-vision and llava
image_embed_size = 576
if config["model_type"] == "phi3_v":
image_embed_size = 1921
image_tensor = np.expand_dims(image_tensor, axis=0) # HWC -> NHWC
image_features = tvm.runtime.tensor(image_tensor)
image_data = ImageData(image_features, image_embed_size)
return image_data
@staticmethod
def get_embed_size(config: Dict) -> int: # noqa: UP006
"""Get the image embedding size from the model config file."""
image_size = config["model_config"]["vision_config"]["image_size"]
patch_size = config["model_config"]["vision_config"]["patch_size"]
embed_size = (image_size // patch_size) ** 2
return embed_size
@staticmethod
def get_input_size(config: Dict) -> int: # noqa: UP006
"""Get the image input size from the model config file."""
image_size = config["model_config"]["vision_config"]["image_size"]
return image_size
@dataclass
class SingleRequestStreamOutput:
"""The request stream output of a single request.
Attributes
----------
delta_token_ids : List[int]
The new generated tokens since the last callback invocation
for the input request.
delta_logprob_json_strs : Optional[List[str]]
The logprobs JSON strings of the new generated tokens
since last invocation.
finish_reason : Optional[str]
The finish reason of the request when it is finished,
of None if the request has not finished yet.
"""
delta_token_ids: List[int] # noqa: UP006
delta_logprob_json_strs: Optional[List[str]] # noqa: UP006
finish_reason: Optional[str]
request_final_usage_json_str: Optional[str]
extra_prefix_string: str
@tvm_ffi.register_object("mlc.serve.RequestStreamOutput")
class RequestStreamOutput(Object):
"""The generated delta request output that is streamed back
through callback stream function.
It contains four fields (in order):
request_id : str
The id of the request that the function is invoked for.
stream_outputs : List[SingleRequestStreamOutput]
The output instances, one for a request.
Note
----
We do not provide constructor, since in practice only C++ side
instantiates this class.
"""
def unpack(self) -> Tuple[str, List[SingleRequestStreamOutput]]: # noqa: UP006
"""Return the fields of the delta output in a tuple.
Returns
-------
request_id : str
The id of the request that the function is invoked for.
stream_outputs : List[SingleRequestStreamOutput]
The output instances, one for a request.
"""
fields = _ffi_api.RequestStreamOutputUnpack(self)
request_final_usage_json_str = fields[4]
request_id = str(fields[0])
if request_final_usage_json_str is not None:
return (
request_id,
[SingleRequestStreamOutput([], None, None, request_final_usage_json_str, "")],
)
stream_outputs = []
for i, (delta_token_ids, finish_reason, extra_prefix_string) in enumerate(
zip(fields[1], fields[3], fields[5])
):
delta_logprob_json_strs = (
[str(logprob_json_str) for logprob_json_str in fields[2][i]]
if fields[2] is not None
else None
)
stream_outputs.append(
SingleRequestStreamOutput(
delta_token_ids=list(delta_token_ids),
delta_logprob_json_strs=delta_logprob_json_strs,
finish_reason=str(finish_reason) if finish_reason is not None else None,
request_final_usage_json_str=None,
extra_prefix_string=str(extra_prefix_string),
)
)
return request_id, stream_outputs
+490
View File
@@ -0,0 +1,490 @@
"""Asynchronous embedding inference engine for encoder and decoder models."""
import asyncio
import concurrent.futures
import json
import os
from typing import List, Literal, Optional, Tuple, Union # noqa: UP035
import numpy as np
import tvm
from tvm import relax
from tvm.runtime import Device
from tvm_ffi import Shape
from mlc_llm.serve import engine_utils
from mlc_llm.support.auto_device import detect_device
from mlc_llm.tokenizers import Tokenizer
class AsyncEmbeddingEngine:
"""Asynchronous embedding inference engine.
Supports both encoder models (BERT-style) and decoder-only embedding models
(e.g. Qwen3-Embeddings). Uses a ThreadPoolExecutor for background inference
so that the asyncio event loop is not blocked.
Parameters
----------
model : str
Path to the model weight directory.
model_lib : str
Path to the compiled model library (.so/.dylib file).
device : Union[str, Device]
Device string, e.g. "auto", "cuda:0", "metal".
pooling_strategy : Optional[str]
Pooling strategy: "cls" (first token), "mean" (masked average),
or "last" (last token). If None, auto-detected based on model type:
encoder -> "cls", decoder -> "last".
"""
def __init__(
self,
model: str,
model_lib: str,
device: Union[str, Device] = "auto",
*,
pooling_strategy: Optional[str] = None,
) -> None:
# Reuse existing utility: device detection
self.device = detect_device(device) if isinstance(device, str) else device
# Reuse existing utility: tokenizer
self.tokenizer = Tokenizer(model)
# Load TVM module, metadata, and params via engine_utils helpers
ex = tvm.runtime.load_module(model_lib)
vm = relax.VirtualMachine(ex, device=self.device)
self._mod = vm.module
self._metadata = json.loads(self._mod["_metadata"]())
self._params = engine_utils.load_embedding_params(model, self.device, self._metadata)
# Detect model type and set pooling strategy
self.embedding_metadata = engine_utils.get_embedding_metadata(self._metadata)
if self.embedding_metadata:
self.model_type = self.embedding_metadata["model_type"]
self.pooling_strategy = self.embedding_metadata["pooling_strategy"]
self.normalize = self.embedding_metadata["normalize"]
else:
self.model_type = engine_utils.detect_embedding_model_type(self._mod)
self.pooling_strategy = "cls" if self.model_type == "encoder" else "last"
self.normalize = True
# Allow caller to override pooling strategy
if pooling_strategy:
self.pooling_strategy = pooling_strategy
# Initialize model-type-specific functions
if self.model_type == "encoder":
self._init_encoder(model)
else:
self._init_decoder(model)
# Background thread pool (1 worker = serialized GPU inference)
self._executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1, thread_name_prefix="embedding"
)
self._terminated = False
def _init_encoder(self, model: str) -> None:
"""Initialize encoder (BERT-style) model functions and special tokens."""
self._prefill_func = self._mod["prefill"]
self._cls_token_id: Optional[int] = None
self._sep_token_id: Optional[int] = None
tok_config_path = os.path.join(model, "tokenizer_config.json")
if os.path.exists(tok_config_path):
with open(tok_config_path, encoding="utf-8") as f:
tok_config = json.load(f)
# Try added_tokens_decoder first (newer HF format)
added = tok_config.get("added_tokens_decoder", {})
for tid, info in added.items():
if info.get("content") == tok_config.get("cls_token"):
self._cls_token_id = int(tid)
if info.get("content") == tok_config.get("sep_token"):
self._sep_token_id = int(tid)
# Fallback: encode the special token strings via tokenizer
if self._cls_token_id is None and tok_config.get("cls_token"):
ids = list(self.tokenizer.encode(tok_config["cls_token"]))
if len(ids) == 1:
self._cls_token_id = ids[0]
if self._sep_token_id is None and tok_config.get("sep_token"):
ids = list(self.tokenizer.encode(tok_config["sep_token"]))
if len(ids) == 1:
self._sep_token_id = ids[0]
def _init_decoder(self, model: str) -> None:
"""Initialize decoder (Qwen3-Embeddings style) model functions."""
# Prefer tokenizer post-processing (HF-style) for terminal/pooling token handling.
# Only fall back to manual EOS append when tokenizer does not define a post-processor
# that actually appends a token at the end of the sequence.
self._decoder_tokenizer_appends_eos = False
tokenizer_json_path = os.path.join(model, "tokenizer.json")
if os.path.exists(tokenizer_json_path):
with open(tokenizer_json_path, encoding="utf-8") as f:
tokenizer_json = json.load(f)
post_proc = tokenizer_json.get("post_processor")
if post_proc is not None:
# Check if the post-processor actually appends a special token at the end
# (e.g. TemplateProcessing with "$A <|endoftext|>"). We verify by encoding
# a test string and checking if the last token is a known special token.
test_tokens = list(self.tokenizer.encode("test"))
if len(test_tokens) > 0:
vocab = tokenizer_json.get("added_tokens", [])
special_ids = {t["id"] for t in vocab if t.get("special", False)}
if test_tokens[-1] in special_ids:
self._decoder_tokenizer_appends_eos = True
# Read EOS token from config — fallback only when tokenizer does not auto-append.
self._decoder_eos_token_id: Optional[int] = None
config_path = os.path.join(model, "mlc-chat-config.json")
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as f:
chat_config = json.load(f)
eos = chat_config.get("eos_token_id")
if isinstance(eos, list):
self._decoder_eos_token_id = eos[0]
elif isinstance(eos, int):
self._decoder_eos_token_id = eos
self._embed_func = self._mod["embed"]
self._prefill_to_hidden_func = self._mod["prefill_to_last_hidden_states"]
self._batch_prefill_to_hidden_func = self._mod["batch_prefill_to_last_hidden_states"]
if self._mod.implements_function("create_tir_paged_kv_cache"):
self._create_kv_cache_func = self._mod["create_tir_paged_kv_cache"]
elif self._mod.implements_function("create_flashinfer_paged_kv_cache"):
self._create_kv_cache_func = self._mod["create_flashinfer_paged_kv_cache"]
else:
raise RuntimeError("Cannot find KV cache creation function in model library.")
self._kv_state_add_sequence = tvm.get_global_func("vm.builtin.kv_state_add_sequence")
self._kv_state_remove_sequence = tvm.get_global_func("vm.builtin.kv_state_remove_sequence")
self._kv_state_begin_forward = tvm.get_global_func("vm.builtin.kv_state_begin_forward")
self._kv_state_end_forward = tvm.get_global_func("vm.builtin.kv_state_end_forward")
self._nd_reshape = tvm.get_global_func("vm.builtin.reshape")
def embed(self, inputs: List[str]) -> Tuple[List[List[float]], int]: # noqa: UP006
"""Compute embeddings for a list of input strings (synchronous).
Parameters
----------
inputs : List[str]
The input strings to embed.
Returns
-------
embeddings : List[List[float]]
The L2-normalized embedding vectors.
total_tokens : int
Total number of tokens processed.
"""
if self.model_type == "encoder":
return self._embed_encoder(inputs)
return self._embed_decoder(inputs)
async def async_embed(self, inputs: List[str]) -> Tuple[List[List[float]], int]: # noqa: UP006
"""Compute embeddings asynchronously in a background thread.
This method does not block the asyncio event loop.
Parameters
----------
inputs : List[str]
The input strings to embed.
Returns
-------
embeddings : List[List[float]]
The L2-normalized embedding vectors.
total_tokens : int
Total number of tokens processed.
"""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(self._executor, self.embed, inputs)
def _embed_encoder(
self,
inputs: List[str], # noqa: UP006
) -> Tuple[List[List[float]], int]: # noqa: UP006
"""Encoder model embedding (BERT-style).
Processes each input individually to avoid batch padding artifacts.
Encoder uses bidirectional attention, so chunked prefill is NOT possible
(each token must attend to all other tokens in the full sequence).
Inputs exceeding prefill_chunk_size are truncated.
(Additional Strategy)
TODO: For better long-text support, implement sliding window + mean pooling:
1. Split text into overlapping windows of prefill_chunk_size (stride=chunk/2)
2. Encode each window independently
3. Mean-pool all window embeddings → final embedding → L2 normalize
This preserves information from the full text at the cost of N× compute.
""" # noqa: RUF002
embeddings: List[List[float]] = [] # noqa: UP006
total_tokens = 0
prefill_chunk = self._metadata.get("prefill_chunk_size", 512)
for text in inputs:
tokens = list(self.tokenizer.encode(text))
# Add [CLS] and [SEP] if needed
if self._cls_token_id is not None and (
len(tokens) == 0 or tokens[0] != self._cls_token_id
):
tokens = [self._cls_token_id, *tokens]
if self._sep_token_id is not None and (
len(tokens) == 0 or tokens[-1] != self._sep_token_id
):
tokens = [*tokens, self._sep_token_id]
# Truncate to compiled buffer limit (keep [CLS] at start, [SEP] at end)
if len(tokens) > prefill_chunk:
tokens = tokens[:prefill_chunk]
if self._sep_token_id is not None:
tokens[-1] = self._sep_token_id
seq_len = len(tokens)
total_tokens += seq_len
token_ids = np.array([tokens], dtype=np.int32) # [1, seq_len]
attention_mask: np.ndarray = np.ones((1, seq_len), dtype=np.int32) # [1, seq_len]
tokens_tvm = tvm.runtime.tensor(token_ids, device=self.device)
mask_tvm = tvm.runtime.tensor(attention_mask, device=self.device)
output = self._prefill_func(tokens_tvm, mask_tvm, self._params)
# .numpy() copies to CPU, escaping TVM workspace buffer reuse across calls.
output_np = output.numpy() # [1, seq_len, hidden_size]
# Pooling
if self.pooling_strategy == "cls":
pooled = output_np[0, 0, :]
elif self.pooling_strategy == "mean":
pooled = output_np[0].mean(axis=0)
else: # "last"
pooled = output_np[0, -1, :]
# L2 normalize
pooled = pooled.astype(np.float32)
if self.normalize:
norm = np.linalg.norm(pooled)
if norm > 1e-12:
pooled = pooled / norm
embeddings.append(pooled.tolist())
return embeddings, total_tokens
def _embed_decoder(self, inputs: List[str]) -> Tuple[List[List[float]], int]: # noqa: UP006
"""Decoder model embedding with batch prefill optimization.
When total tokens fit within prefill_chunk_size, all inputs are processed
in a single batch forward pass using shared KV cache. Otherwise, falls back
to sequential chunked prefill per input.
"""
# Read KV cache config from metadata
prefill_chunk = self._metadata.get("prefill_chunk_size", 2048)
max_seq_len = self._metadata.get("context_window_size", 32768)
if max_seq_len == -1:
max_seq_len = self._metadata.get("sliding_window_size", -1)
assert max_seq_len > 0, f"max_seq_len must be positive, got {max_seq_len}"
support_sliding = int(self._metadata.get("sliding_window_size", -1) != -1)
# Tokenize all inputs. Prefer tokenizer post-processor output. If absent (older models),
# fall back to appending eos_token_id when missing.
token_lists: List[List[int]] = [] # noqa: UP006
for text in inputs:
tokens = list(self.tokenizer.encode(text))
if (
not self._decoder_tokenizer_appends_eos
and self._decoder_eos_token_id is not None
and (len(tokens) == 0 or tokens[-1] != self._decoder_eos_token_id)
):
tokens.append(self._decoder_eos_token_id)
if len(tokens) > max_seq_len:
tokens = tokens[:max_seq_len]
token_lists.append(tokens)
total_tokens = sum(len(t) for t in token_lists)
# Fast path: all tokens fit in one prefill chunk → batch forward
if total_tokens <= prefill_chunk and all(len(t) > 0 for t in token_lists):
return self._batch_embed_decoder(
token_lists, total_tokens, max_seq_len, prefill_chunk, support_sliding
)
# Greedy sub-batching: pack texts into sub-batches that fit within
# prefill_chunk, preserving input order. Oversize texts (single text
# exceeding prefill_chunk) fall back to sequential chunked prefill.
sub_batches = self._build_sub_batches(token_lists, prefill_chunk)
all_embeddings: List[List[float]] = [] # noqa: UP006
for batch_type, batch, batch_total in sub_batches:
if batch_type == "batch":
embs, _ = self._batch_embed_decoder(
batch, batch_total, max_seq_len, prefill_chunk, support_sliding
)
else:
embs, _ = self._sequential_embed_decoder(
batch, batch_total, max_seq_len, prefill_chunk, support_sliding
)
all_embeddings.extend(embs)
return all_embeddings, total_tokens
@staticmethod
def _build_sub_batches(
token_lists: List[List[int]], # noqa: UP006
prefill_chunk: int,
) -> List[Tuple[Literal["batch", "sequential"], List[List[int]], int]]: # noqa: UP006
"""Partition token lists into sub-batches that fit within prefill_chunk.
Each sub-batch is a tuple of (mode, token_lists, total_token_count).
Empty token lists are skipped to avoid invalid batch processing.
"""
sub_batches: List[Tuple[Literal["batch", "sequential"], List[List[int]], int]] = [] # noqa: UP006
current_batch: List[List[int]] = [] # noqa: UP006
current_tokens = 0
for tokens in token_lists:
if not tokens:
continue
token_len = len(tokens)
is_oversized = token_len > prefill_chunk
if current_batch and (is_oversized or current_tokens + token_len > prefill_chunk):
sub_batches.append(("batch", current_batch, current_tokens))
current_batch, current_tokens = [], 0
if is_oversized:
sub_batches.append(("sequential", [tokens], token_len))
else:
current_batch.append(tokens)
current_tokens += token_len
if current_batch:
sub_batches.append(("batch", current_batch, current_tokens))
return sub_batches
def _batch_embed_decoder(
self,
token_lists: List[List[int]], # noqa: UP006
total_tokens: int,
max_seq_len: int,
prefill_chunk: int,
support_sliding: int,
) -> Tuple[List[List[float]], int]: # noqa: UP006
"""Batch prefill: process all inputs in a single forward pass."""
batch_size = len(token_lists)
# Create KV cache for the entire batch
kv_cache = self._create_kv_cache_func(
Shape([batch_size]),
Shape([max_seq_len]),
Shape([prefill_chunk]),
Shape([16]),
Shape([support_sliding]),
)
# Register all sequences
seq_ids = list(range(batch_size))
seq_lens = [len(t) for t in token_lists]
for sid in seq_ids:
self._kv_state_add_sequence(kv_cache, sid)
# Begin forward with all sequences at once
self._kv_state_begin_forward(kv_cache, Shape(seq_ids), Shape(seq_lens))
# Concatenate all tokens → embed → batch prefill
all_tokens = []
for tokens in token_lists:
all_tokens.extend(tokens)
token_ids = tvm.runtime.tensor(np.array(all_tokens, dtype=np.int32), device=self.device)
all_embed = self._embed_func(token_ids, self._params)
all_embed = self._nd_reshape(all_embed, Shape([1, total_tokens, all_embed.shape[-1]]))
hidden_states, _ = self._batch_prefill_to_hidden_func(all_embed, kv_cache, self._params)
# .numpy() copies to CPU, escaping TVM workspace buffer reuse across calls.
# (torch.from_dlpack is zero-copy and hits aliasing bugs on 2nd+ invocation.)
hidden_np = hidden_states.numpy()
self._kv_state_end_forward(kv_cache)
for sid in seq_ids:
self._kv_state_remove_sequence(kv_cache, sid)
# Extract last token hidden state per sequence
embeddings: List[List[float]] = [] # noqa: UP006
offset = 0
for tokens in token_lists:
last_pos = offset + len(tokens) - 1
pooled = hidden_np[0, last_pos, :].astype(np.float32)
if self.normalize:
norm = np.linalg.norm(pooled)
if norm > 1e-12:
pooled = pooled / norm
embeddings.append(pooled.tolist())
offset += len(tokens)
return embeddings, total_tokens
def _sequential_embed_decoder(
self,
token_lists: List[List[int]], # noqa: UP006
total_tokens: int,
max_seq_len: int,
prefill_chunk: int,
support_sliding: int,
) -> Tuple[List[List[float]], int]: # noqa: UP006
"""Sequential chunked prefill: process each input independently."""
embeddings: List[List[float]] = [] # noqa: UP006
for tokens in token_lists:
if len(tokens) == 0:
continue
# Create KV cache for this single sequence
kv_cache = self._create_kv_cache_func(
Shape([1]),
Shape([max_seq_len]),
Shape([prefill_chunk]),
Shape([16]),
Shape([support_sliding]),
)
self._kv_state_add_sequence(kv_cache, 0)
# Process tokens in chunks
hidden = None
for chunk_start in range(0, len(tokens), prefill_chunk):
chunk_end = min(chunk_start + prefill_chunk, len(tokens))
chunk_tokens = tokens[chunk_start:chunk_end]
chunk_len = len(chunk_tokens)
token_ids = tvm.runtime.tensor(
np.array(chunk_tokens, dtype=np.int32), device=self.device
)
chunk_embed = self._embed_func(token_ids, self._params)
chunk_embed = self._nd_reshape(
chunk_embed, Shape([1, chunk_len, chunk_embed.shape[-1]])
)
self._kv_state_begin_forward(kv_cache, Shape([0]), Shape([chunk_len]))
hidden, kv_cache = self._prefill_to_hidden_func(chunk_embed, kv_cache, self._params)
# .numpy() copies to CPU, escaping TVM buffer aliasing.
hidden_np = hidden.numpy()
self._kv_state_end_forward(kv_cache)
self._kv_state_remove_sequence(kv_cache, 0)
pooled = hidden_np[0, -1, :] if hidden_np.ndim == 3 else hidden_np[-1, :]
pooled = pooled.astype(np.float32)
if self.normalize:
norm = np.linalg.norm(pooled)
if norm > 1e-12:
pooled = pooled / norm
embeddings.append(pooled.tolist())
return embeddings, total_tokens
def terminate(self) -> None:
"""Terminate the engine and clean up the thread pool."""
if getattr(self, "_terminated", True):
return
self._terminated = True
self._executor.shutdown(wait=False)
def __del__(self):
self.terminate()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+337
View File
@@ -0,0 +1,337 @@
"""Utility functions for MLC Serve engine"""
import uuid
from typing import Any, Callable, Dict, List, Literal, Optional, Union # noqa: UP035
from mlc_llm.protocol import error_protocol, openai_api_protocol
from mlc_llm.protocol.generation_config import GenerationConfig
from mlc_llm.serve import data
RequestProtocol = Union[
openai_api_protocol.CompletionRequest, openai_api_protocol.ChatCompletionRequest
]
def get_unsupported_fields(request: RequestProtocol) -> List[str]: # noqa: UP006
"""Get the unsupported fields of the request.
Return the list of unsupported field names.
"""
if isinstance(
request,
(
openai_api_protocol.CompletionRequest,
openai_api_protocol.ChatCompletionRequest,
),
):
return openai_api_protocol.openai_api_get_unsupported_fields(request)
raise RuntimeError("Cannot reach here")
def openai_api_get_generation_config(request: RequestProtocol) -> Dict[str, Any]: # noqa: UP006
"""Create the generation config from the given request."""
kwargs: Dict[str, Any] = {} # noqa: UP006
arg_names = [
"n",
"temperature",
"top_p",
"max_tokens",
"frequency_penalty",
"presence_penalty",
"logit_bias",
"seed",
"response_format",
"debug_config",
]
for arg_name in arg_names:
kwargs[arg_name] = getattr(request, arg_name)
if kwargs["max_tokens"] is None:
# Setting to -1 means the generation will not stop until
# exceeding model capability or hit any stop criteria.
kwargs["max_tokens"] = -1
if request.stop is not None:
kwargs["stop_strs"] = [request.stop] if isinstance(request.stop, str) else request.stop
if isinstance(request, openai_api_protocol.ChatCompletionRequest):
kwargs["logprobs"] = request.logprobs
kwargs["top_logprobs"] = request.top_logprobs
else:
logprobs = request.logprobs is not None
kwargs["logprobs"] = logprobs
kwargs["top_logprobs"] = request.logprobs if logprobs else 0
return kwargs
def get_generation_config(
request: RequestProtocol,
extra_stop_token_ids: Optional[List[int]] = None, # noqa: UP006
extra_stop_str: Optional[List[str]] = None, # noqa: UP006
) -> GenerationConfig:
"""Create the generation config in MLC LLM out from the input request protocol."""
kwargs: Dict[str, Any] # noqa: UP006
if isinstance(
request,
(
openai_api_protocol.CompletionRequest,
openai_api_protocol.ChatCompletionRequest,
),
):
kwargs = openai_api_get_generation_config(request)
else:
raise RuntimeError("Cannot reach here")
if extra_stop_token_ids is not None:
stop_token_ids = kwargs.get("stop_token_ids", [])
assert isinstance(stop_token_ids, list)
stop_token_ids += extra_stop_token_ids
kwargs["stop_token_ids"] = stop_token_ids
if extra_stop_str is not None:
stop_strs = kwargs.get("stop_strs", [])
assert isinstance(stop_strs, list)
stop_strs += extra_stop_str
kwargs["stop_strs"] = stop_strs
return GenerationConfig(**kwargs)
def random_uuid() -> str:
"""Generate a random id in hexadecimal string."""
return uuid.uuid4().hex
def check_unsupported_fields(request: RequestProtocol) -> None:
"""Check if the request has unsupported fields. Raise BadRequestError if so."""
unsupported_fields = get_unsupported_fields(request)
if len(unsupported_fields) != 0:
unsupported_fields = [f'"{field}"' for field in unsupported_fields]
raise error_protocol.BadRequestError(
f"Request fields {', '.join(unsupported_fields)} are not supported right now.",
)
def check_and_get_prompts_length(
prompts: List[Union[List[int], data.ImageData]], # noqa: UP006
max_input_sequence_length: int,
) -> int:
"""Check if the total prompt length exceeds the max single sequence
sequence length allowed by the served model. Raise BadRequestError if so.
Return the total prompt length.
"""
total_length: int = 0
for prompt in prompts:
total_length += len(prompt)
if total_length > max_input_sequence_length:
raise error_protocol.BadRequestError(
f"Request prompt has {total_length} tokens in total,"
f" larger than the model input length limit {max_input_sequence_length}.",
)
return total_length
def process_prompts(
input_prompts: Union[str, List[int], List[Union[str, List[int], data.ImageData]]], # noqa: UP006
ftokenize: Callable[[str], List[int]], # noqa: UP006
) -> List[Union[List[int], data.ImageData]]: # noqa: UP006
"""Convert all input tokens to list of token ids with regard to the
given tokenization function.
For each input prompt, return the list of token ids after tokenization.
"""
error_msg = f"Invalid request prompt {input_prompts}"
# Case 1. The prompt is a single string.
if isinstance(input_prompts, str):
return [ftokenize(input_prompts)]
assert isinstance(input_prompts, list)
if len(input_prompts) == 0:
raise error_protocol.BadRequestError(error_msg)
# Case 2. The prompt is a list of token ids.
if isinstance(input_prompts[0], int):
assert isinstance(input_prompts, list)
if not all(isinstance(token_id, int) for token_id in input_prompts):
raise error_protocol.BadRequestError(error_msg)
return [input_prompts]
# Case 3. A list of prompts.
output_prompts: List[Union[List[int], data.ImageData]] = [] # noqa: UP006
for input_prompt in input_prompts:
if isinstance(input_prompt, str):
output_prompts.append(ftokenize(input_prompt))
elif isinstance(input_prompt, list) and all(
isinstance(token_id, int) for token_id in input_prompt
):
output_prompts.append(input_prompt)
elif isinstance(input_prompt, data.ImageData):
output_prompts.append(input_prompt)
else:
raise error_protocol.BadRequestError(error_msg)
return output_prompts
def convert_prompts_to_data(
prompts: Union[str, List[int], List[Union[str, List[int], data.Data]]], # noqa: UP006
) -> List[data.Data]: # noqa: UP006
"""Convert the given prompts in the combination of token id lists
and/or data to all data."""
if isinstance(prompts, data.Data):
return [prompts]
if isinstance(prompts, str):
return [data.TextData(prompts)]
if isinstance(prompts[0], int):
assert isinstance(prompts, list) and all(isinstance(token_id, int) for token_id in prompts)
return [data.TokenData(prompts)]
return [convert_prompts_to_data(x)[0] for x in prompts]
class ErrorCleanupScope:
"""Scope to call cleanup when an error is thrown.
This class provides an important pattern properly cleanup
when async scope CancelledError or other exception happens.
Parameters
----------
cleanup : Callable
A callable function to trigger at scope exit during an exception.
Note
----
This helper is motivated by the need to properly
abort an async generator and trigger corresponding
cleanup functions. Naively use the try except
pattern will results in bug when we chain up
async generators.
.. code:: python
class EngineNotSafe:
async def _inner_gen(self, request):
request_id = self.get_request_id()
self.add_request(request)
try:
async for res in await producer_stream:
yield res
except asyncio.CancelledError:
self.abort(request_id)
async def generate(self, request):
async for res in await self._inner_gen(request):
# async error can he raised in here
# this will cause
res = await process(res)
yield res
The above except pattern is not safe.
This is because CancelledError may also be raised
outside _inner_gen during the process of generate
function in between iterations.
Instead, we use ErrorCleanupScope to safeguard the
generation process. The scope will always properly
cleanup in exit function when the exception is raised
.. code:: python
class EngineSafe:
async def _inner_gen(self, request):
request_id = self.get_request_id()
self.add_request(request)
with ErrorCleanupScope(lambda: self.abort(request_id))
async for res in await producer_stream:
yield res
async def generate(self, request):
async for res in await self._inner_gen(request):
# even if async error is raised here
# it will cleanup the ErrorCleanupScope
# properly during function exit
res = await process(res)
yield res
"""
cleanup: Callable
def __init__(self, cleanup: Callable):
self.cleanup = cleanup
def __enter__(self):
pass
def __exit__(self, exc_type, exc_value, traceback) -> None:
# only cleanup when exc type is not none
if exc_type is not None:
self.cleanup()
# ====== Embedding Engine Utilities ======
def load_embedding_params(model_weight_path, device, model_metadata) -> list:
"""Load embedding model parameters from weight directory.
Parameters
----------
model_weight_path : str
Path to the model weight directory.
device : tvm.runtime.Device
The target device.
model_metadata : dict
The model metadata dictionary containing param info.
Returns
-------
params : list
List of tvm.runtime.Tensor parameters in metadata order.
"""
from tvm.contrib import tvmjs
params, meta = tvmjs.load_tensor_cache(model_weight_path, device)
param_names = [param["name"] for param in model_metadata["params"]]
assert len(param_names) == meta["ParamSize"]
return [params[name] for name in param_names]
def get_embedding_metadata(config: Dict[str, Any]) -> Optional[Dict[str, Any]]: # noqa: UP006
"""Read emedding metadata from mlc-chat-config or model lib metadata.
Parameters
----------
config : Dict[str, Any]
The configuration dictionary containing model metadata.
Returns
-------
embedding_metadata : Optional[Dict[str, Any]] = None if it's not an embedding model.
The embedding metadata dictionary.
"""
if config.get("model_task") == "embedding":
return config.get("embedding_metadata")
return None
def detect_embedding_model_type(mod) -> Literal["encoder", "decoder"]:
"""Detect embedding model type from compiled TVM module functions.
Parameters
----------
mod : tvm.runtime.Module
The VM module with model functions.
Returns
-------
model_type : str
"encoder" for BERT-style models, "decoder" for Qwen3-Embeddings style.
"""
has_embed = mod.implements_function("embed")
has_prefill_to_hidden = mod.implements_function("prefill_to_last_hidden_states")
has_prefill = mod.implements_function("prefill")
if has_embed and has_prefill_to_hidden:
return "decoder"
if has_prefill:
return "encoder"
raise ValueError(
"Model does not support embedding inference. "
"Expected 'embed' + 'prefill_to_last_hidden_states' (decoder) "
"or 'prefill' (encoder)."
)
@@ -0,0 +1,8 @@
"""The entrypoints for MLC LLM server."""
from . import (
debug_entrypoints,
metrics_entrypoints,
microserving_entrypoints,
openai_entrypoints,
)
@@ -0,0 +1,128 @@
"""MLC LLM server debug entrypoints"""
import json
from http import HTTPStatus
import fastapi
from mlc_llm.protocol import error_protocol
from mlc_llm.serve.server import ServerContext
app = fastapi.APIRouter()
################ /debug/dump_event_trace ################
@app.post("/debug/dump_event_trace")
async def debug_dump_event_trace(request: fastapi.Request):
"""Return the recorded events in Chrome Trace Event Format in JSON string.
The input request payload should have only one field, specifying the
model to query. For example: `{"model": "Llama-2-7b-chat-hf-q0f16"}`.
"""
# Get the raw request body as bytes
request_raw_data = await request.body()
request_json_str = request_raw_data.decode("utf-8")
try:
# Parse the JSON string
request_dict = json.loads(request_json_str)
except json.JSONDecodeError:
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST, message=f"Invalid request {request_json_str}"
)
if "model" not in request_dict:
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST, message=f"Invalid request {request_json_str}"
)
# Check the requested model.
model = request_dict["model"]
server_context: ServerContext = ServerContext.current()
async_engine = server_context.get_engine(model)
if async_engine is None:
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST,
message=f'The requested model "{model}" is not served.',
)
if async_engine.state.trace_recorder is None:
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST,
message=f'The requested model "{model}" does not enable tracing',
)
return json.loads(async_engine.state.trace_recorder.dump_json())
################ /debug/cuda_profiler_start/end ################
@app.post("/debug/cuda_profiler_start")
async def debug_cuda_profiler_start(_request: fastapi.Request):
"""Start the cuda profiler for the engine. Only for debug purpose."""
server_context: ServerContext = ServerContext.current()
# Since the CUDA profiler is process-wise, call the function for one model is sufficient.
for model in server_context.get_model_list():
async_engine = server_context.get_engine(model)
async_engine._debug_call_func_on_all_worker("mlc.debug_cuda_profiler_start")
break
@app.post("/debug/cuda_profiler_stop")
async def debug_cuda_profiler_stop(_request: fastapi.Request):
"""Stop the cuda profiler for the engine. Only for debug purpose."""
server_context: ServerContext = ServerContext.current()
# Since the CUDA profiler is process-wise, call the function for one model is sufficient.
for model in server_context.get_model_list():
async_engine = server_context.get_engine(model)
async_engine._debug_call_func_on_all_worker("mlc.debug_cuda_profiler_stop")
break
@app.post("/debug/dump_engine_metrics")
async def debug_dump_engine_metrics(request: fastapi.Request):
"""Dump the engine metrics for the engine. Only for debug purpose."""
# Get the raw request body as bytes
request_raw_data = await request.body()
request_json_str = request_raw_data.decode("utf-8")
try:
# Parse the JSON string
request_dict = json.loads(request_json_str)
except json.JSONDecodeError:
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST, message=f"Invalid request {request_json_str}"
)
# Check the requested model.
model = request_dict.get("model", None)
server_context: ServerContext = ServerContext.current()
async_engine = server_context.get_engine(model)
res = await async_engine.metrics()
return res
@app.post("/debug/reset_engine")
async def debug_reset_engine_stats(request: fastapi.Request):
"""Reset the engine, clean up all running data and metrics."""
# Get the raw request body as bytes
request_raw_data = await request.body()
request_json_str = request_raw_data.decode("utf-8")
try:
# Parse the JSON string
request_dict = json.loads(request_json_str)
except json.JSONDecodeError:
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST, message=f"Invalid request {request_json_str}"
)
if "model" not in request_dict:
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST, message=f"Invalid request {request_json_str}"
)
# Check the requested model.
model = request_dict["model"]
server_context: ServerContext = ServerContext.current()
async_engine = server_context.get_engine(model)
async_engine.reset()
@@ -0,0 +1,23 @@
"""MLC LLM server metrics entrypoints"""
import fastapi
from fastapi.responses import PlainTextResponse
from mlc_llm.serve.server import ServerContext
app = fastapi.APIRouter()
################ /metrics ################
@app.get("/metrics", response_class=PlainTextResponse)
async def metrics(_request: fastapi.Request):
"""Start the cuda profiler for the engine. Only for debug purpose."""
server_context: ServerContext = ServerContext.current()
# Use the metrics from first engine for now
# TODO(mlc-team): consider refactor server context to
# single engine since multiple AsyncMLCEngine do not work well with each other
# We need to work within the internal engine instead.
for model in server_context.get_model_list():
async_engine = server_context.get_engine(model)
return (await async_engine.metrics()).prometheus_text()
@@ -0,0 +1,73 @@
"""MicroServing server entrypoints in MLC LLM"""
import fastapi
from mlc_llm.protocol.debug_protocol import DisaggConfig
from mlc_llm.protocol.microserving_protocol import (
PrepRecvRequest,
PrepRecvResponse,
RemoteSendRequest,
StartGenerateRequest,
)
from mlc_llm.protocol.openai_api_protocol import StreamOptions
from .openai_entrypoints import request_completion
app = fastapi.APIRouter()
################ MicroServing Endpoints ################
@app.post("/microserving/prep_recv")
async def prep_recv(request: PrepRecvRequest, raw_request: fastapi.Request) -> PrepRecvResponse:
"""Handle the microserving request for receive preparation.
Match the prompt in the prefix cache (when enabled),
allocate entries in the KV cache to prepare receiving the KV data of the prompt.
Return the matched prefix length and the allocated KV entry metadata.
"""
request.debug_config.disagg_config = DisaggConfig(
kind="prepare_receive",
kv_window_begin=0, # always zero for prepare_receive
kv_window_end=request.end,
)
request.stream_options = StreamOptions(include_usage=True)
request.stream = False
response = await request_completion(request=request, raw_request=raw_request)
assert response.usage is not None
assert response.usage.extra is not None
assert "prefix_matched_length" in response.usage.extra
assert "kv_append_metadata" in response.usage.extra
return PrepRecvResponse(
prefix_matched_length=response.usage.extra["prefix_matched_length"],
kv_append_metadata=response.usage.extra["kv_append_metadata"],
)
@app.post("/microserving/remote_send")
async def remote_send(request: RemoteSendRequest, raw_request: fastapi.Request):
"""Compute and generate the KV data of the prompt in the specified KV window.
Send the KV data to the destination server."""
request.debug_config.disagg_config = DisaggConfig(
kind="remote_send",
kv_window_begin=request.begin,
kv_window_end=request.end,
kv_append_metadata=request.kv_addr_info,
dst_group_offset=request.recv_rank,
)
request.stream_options = StreamOptions(include_usage=True)
request.stream = False
await request_completion(request=request, raw_request=raw_request)
return {}
@app.post("/microserving/start_generate")
async def start_generate(request: StartGenerateRequest, raw_request: fastapi.Request):
"""Prefill the prompt in the specified KV window, and start decode."""
request.debug_config.disagg_config = DisaggConfig(
kind="start_generation",
kv_window_begin=request.begin,
)
return await request_completion(request=request, raw_request=raw_request)
@@ -0,0 +1,340 @@
"""OpenAI API-compatible server entrypoints in MLC LLM"""
import base64
import struct
from collections.abc import AsyncGenerator
from http import HTTPStatus
from typing import List, Optional # noqa: UP035
import fastapi
import numpy as np
from mlc_llm.protocol import error_protocol
from mlc_llm.protocol.openai_api_protocol import (
ChatCompletionRequest,
CompletionLogProbs,
CompletionRequest,
EmbeddingObject,
EmbeddingRequest,
EmbeddingResponse,
EmbeddingUsage,
ListResponse,
LogProbsContent,
ModelResponse,
)
from mlc_llm.serve import engine_base, engine_utils
from mlc_llm.serve.server import ServerContext
def verify_api_key(request: fastapi.Request):
"""Function to verify API key"""
server_context = ServerContext.current()
# Only perform verification when API key is configured
if server_context is not None and server_context.api_key is not None:
provided_key = request.headers.get("Authorization", "").replace("Bearer ", "")
if provided_key != server_context.api_key:
raise fastapi.HTTPException(status_code=401, detail="Invalid API Key")
app = fastapi.APIRouter(dependencies=[fastapi.Depends(verify_api_key)])
################ v1/embeddings ################
@app.post("/v1/embeddings")
async def request_embedding(request: EmbeddingRequest):
"""OpenAI-compatible embedding API.
API reference: https://platform.openai.com/docs/api-reference/embeddings/create
"""
server_context: ServerContext = ServerContext.current()
embedding_engine = server_context.get_embedding_engine(request.model)
if embedding_engine is None:
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST,
message=f'The requested model "{request.model}" is not served as an embedding model.',
)
# Normalize input to List[str]
inputs: List[str] # noqa: UP006
if isinstance(request.input, str):
inputs = [request.input]
elif (
isinstance(request.input, list)
and len(request.input) > 0
and isinstance(request.input[0], str)
):
inputs = list(request.input)
else:
# Token ID inputs (List[int] or List[List[int]]) — decode back to strings
if isinstance(request.input[0], int):
inputs = [embedding_engine.tokenizer.decode(request.input)]
else:
inputs = [embedding_engine.tokenizer.decode(ids) for ids in request.input]
# Run embedding inference (async — does not block the event loop)
try:
embeddings, total_tokens = await embedding_engine.async_embed(inputs)
except Exception as exc:
return error_protocol.create_error_response(
HTTPStatus.INTERNAL_SERVER_ERROR,
message=f"Embedding inference failed: {exc}",
)
# Optional: truncate dimensions (Matryoshka-style).
# This is API-level renormalization after dimension truncation,
# independent of model metadata normalize. Always renormalize
# truncated vectors to maintain unit length per OpenAI API contract.
if request.dimensions is not None:
for i, emb in enumerate(embeddings):
vec = np.array(emb[: request.dimensions], dtype=np.float32)
norm = np.linalg.norm(vec)
if norm > 1e-12:
vec = vec / norm
embeddings[i] = vec.tolist()
# Build response data
resp_data = []
for i, emb in enumerate(embeddings):
if request.encoding_format == "base64":
binary = struct.pack(f"<{len(emb)}f", *emb)
resp_data.append(
EmbeddingObject(
embedding=base64.b64encode(binary).decode("utf-8"),
index=i,
)
)
else:
resp_data.append(EmbeddingObject(embedding=emb, index=i))
return EmbeddingResponse(
data=resp_data,
model=request.model,
usage=EmbeddingUsage(prompt_tokens=total_tokens, total_tokens=total_tokens),
)
################ v1/models ################
@app.get("/v1/models")
async def request_models() -> ListResponse:
"""OpenAI-compatible served model query API.
API reference: https://platform.openai.com/docs/api-reference/models
"""
server_context: ServerContext = ServerContext.current()
return ListResponse(data=[ModelResponse(id=model) for model in server_context.get_model_list()])
################ v1/completions ################
@app.post("/v1/completions")
async def request_completion(request: CompletionRequest, raw_request: fastapi.Request):
"""OpenAI-compatible completion API.
API reference: https://platform.openai.com/docs/api-reference/completions/create
"""
# - Check the requested model.
server_context: ServerContext = ServerContext.current()
request_final_usage_include_extra = server_context.enable_debug
request_include_debug_config = server_context.enable_debug
if not request_include_debug_config:
request.debug_config = None
async_engine = server_context.get_engine(request.model)
if async_engine is None:
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST,
message=f'The requested model "{request.model}" is not served.',
)
# FIXME: This is a temporary solution to make sure
# prep_recv, remote_send and start_generation process the same request
request_id = request.user if request.user is not None else f"cmpl-{engine_utils.random_uuid()}"
# Streaming response.
if request.stream:
# We manually get the first response from generator to
# capture potential exceptions in this scope, rather then
# the StreamingResponse scope.
stream_generator = async_engine._handle_completion(
request,
request_id,
request_final_usage_include_extra=request_final_usage_include_extra,
)
first_response = await anext( # noqa: F821
stream_generator
)
async def completion_stream_generator() -> AsyncGenerator[str, None]:
if isinstance(first_response, StopAsyncIteration):
yield "data: [DONE]\n\n"
return
yield f"data: {first_response.model_dump_json(by_alias=True)}\n\n"
async for response in stream_generator:
yield f"data: {response.model_dump_json(by_alias=True)}\n\n"
yield "data: [DONE]\n\n"
return fastapi.responses.StreamingResponse(
completion_stream_generator(), media_type="text/event-stream"
)
# Normal response.
request_final_usage = None
output_texts = [""] * request.n
finish_reasons: List[Optional[str]] = [None] * request.n # noqa: UP006
logprob_results: List[Optional[CompletionLogProbs]] = [None] * request.n # noqa: UP006
async for response in async_engine._handle_completion(
request,
request_id,
request_final_usage_include_extra=request_final_usage_include_extra,
):
if await raw_request.is_disconnected():
# In non-streaming cases, the engine will not be notified
# when the request is disconnected.
# Therefore, we check if it is disconnected each time,
# and explicitly return.
# Note that requesta abort is triggered when the async for and funciton scope ends.
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST, message="The request has disconnected"
)
# this is the final chunk
if response.usage is not None:
request_final_usage = response.usage
# remove extra information if debug is not enabled
if not server_context.enable_debug:
request_final_usage.extra = None
continue
for choice in response.choices:
output_texts[choice.index] += choice.text
if choice.finish_reason is not None and finish_reasons[choice.index] is None:
finish_reasons[choice.index] = choice.finish_reason
if choice.logprobs is not None:
if logprob_results[choice.index] is None:
logprob_results[choice.index] = choice.logprobs
else:
logprob_results[choice.index].token_logprobs.extend(
choice.logprobs.token_logprobs
)
logprob_results[choice.index].tokens.extend(choice.logprobs.tokens)
logprob_results[choice.index].top_logprobs.extend(choice.logprobs.top_logprobs)
return engine_base.wrap_completion_response(
request_id=request_id,
model=request.model,
output_texts=output_texts,
finish_reasons=finish_reasons,
logprob_results=logprob_results,
usage=request_final_usage,
)
################ v1/chat/completions ################
@app.post("/v1/chat/completions")
async def request_chat_completion(request: ChatCompletionRequest, raw_request: fastapi.Request):
"""OpenAI-compatible chat completion API.
API reference: https://platform.openai.com/docs/api-reference/chat
"""
# - Check the requested model.
server_context: ServerContext = ServerContext.current()
request_final_usage_include_extra = server_context.enable_debug
request_include_debug_config = server_context.enable_debug
if not request_include_debug_config:
request.debug_config = None
async_engine = server_context.get_engine(request.model)
if async_engine is None:
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST,
message=f'The requested model "{request.model}" is not served.',
)
# FIXME: This is a temporary solution to make sure
# prep_recv, remote_send and start_generation process the same request
request_id = (
request.user if request.user is not None else f"chatcmpl-{engine_utils.random_uuid()}"
)
# Streaming response.
if request.stream:
# We manually get the first response from generator to
# capture potential exceptions in this scope, rather then
# the StreamingResponse scope.
stream_generator = async_engine._handle_chat_completion(
request,
request_id,
request_final_usage_include_extra=request_final_usage_include_extra,
)
first_response = await anext( # noqa: F821
stream_generator
)
async def completion_stream_generator() -> AsyncGenerator[str, None]:
if isinstance(first_response, StopAsyncIteration):
yield "data: [DONE]\n\n"
return
yield f"data: {first_response.model_dump_json(by_alias=True)}\n\n"
async for response in stream_generator:
yield f"data: {response.model_dump_json(by_alias=True)}\n\n"
yield "data: [DONE]\n\n"
return fastapi.responses.StreamingResponse(
completion_stream_generator(), media_type="text/event-stream"
)
# Normal response.
request_final_usage = None
output_texts = ["" for _ in range(request.n)]
finish_reasons: List[Optional[str]] = [None for _ in range(request.n)] # noqa: UP006
logprob_results: Optional[List[List[LogProbsContent]]] = ( # noqa: UP006
[[] for _ in range(request.n)] if request.logprobs else None
)
async for response in async_engine._handle_chat_completion(
request,
request_id,
request_final_usage_include_extra=request_final_usage_include_extra,
):
if await raw_request.is_disconnected():
# In non-streaming cases, the engine will not be notified
# when the request is disconnected.
# Therefore, we check if it is disconnected each time,
# no need to explicitly abort, as the chat completion
# return will trigger abort call
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST, message="The request has disconnected"
)
# usage is always the last chunk
if response.usage is not None:
request_final_usage = response.usage
# remove extra information if debug is not enabled
if not server_context.enable_debug:
request_final_usage.extra = None
for choice in response.choices:
assert isinstance(choice.delta.content, str)
output_texts[choice.index] += choice.delta.content
if choice.finish_reason is not None and finish_reasons[choice.index] is None:
finish_reasons[choice.index] = choice.finish_reason
if choice.logprobs is not None:
assert logprob_results is not None
logprob_results[choice.index] += choice.logprobs.content
assert all(finish_reason is not None for finish_reason in finish_reasons)
use_function_calling, tool_calls_list = engine_base.process_function_call_output(
output_texts, finish_reasons
)
return engine_base.wrap_chat_completion_response(
request_id=request_id,
model=request.model,
output_texts=output_texts,
finish_reasons=finish_reasons,
tool_calls_list=tool_calls_list,
logprob_results=logprob_results,
use_function_calling=use_function_calling,
usage=request_final_usage,
)
@@ -0,0 +1,37 @@
"""The event trace recorder in MLC LLM serving"""
import tvm_ffi
from tvm.runtime import Object
from . import _ffi_api
@tvm_ffi.register_object("mlc.serve.EventTraceRecorder")
class EventTraceRecorder(Object):
"""The event trace recorder for requests."""
def __init__(self) -> None:
"""Initialize a trace recorder."""
self.__init_handle_by_constructor__(_ffi_api.EventTraceRecorder)
def add_event(self, request_id: str, event: str) -> None:
"""Record a event for the input request in the trace recorder.
Parameters
----------
request_id : str
The subject request of the event.
event : str
The event in a string name.
It can have one of the following patterns:
- "start xxx", which marks the start of event "xxx",
- "finish xxx", which marks the finish of event "xxx",
- "yyy", which marks the instant event "yyy".
The "starts" and "finishes" will be automatically paired in the trace recorder.
"""
return _ffi_api.EventTraceRecorderAddEvent(self, request_id, event)
def dump_json(self) -> str:
"""Dump the logged events in Chrome Trace Event Format in JSON string."""
return _ffi_api.EventTraceRecorderDumpJSON(self)
+154
View File
@@ -0,0 +1,154 @@
"""The Paged Radix Tree class."""
from typing import List, Tuple, Union # noqa: UP035
import tvm_ffi
from tvm.runtime import Object
from tvm_ffi import Shape
from . import _ffi_api
@tvm_ffi.register_object("mlc.serve.PagedRadixTree")
class PagedRadixTree(Object):
"""The paged radix tree to manage prefix and sequence."""
def __init__(self):
"""
Constructor of paged radix tree.
"""
self.__init_handle_by_constructor__(_ffi_api.PagedRadixTree)
def match(self, tokens: Union[Shape, List, Tuple]) -> Tuple[int, Shape]: # noqa: UP006
"""
Get all sequences with longest common prefix with given prefix tokens.
Parameters
----------
tokens : Union[Shape, List, Tuple]
The prefix tokens for reference.
Returns
------
matched_offset : int
The matched prefix length.
seq_ids : Shape
The array of matched sequence indice.
"""
if isinstance(tokens, (list, tuple)):
tokens = Shape(tokens)
output = _ffi_api.PagedRadixTreeMatchPrefix(self, tokens)
if len(output) == 1:
return output[0], []
return output[0], output[1:]
def add(self, seq_id: int) -> None:
"""
Add an empty sequence.
Parameters
----------
seq_id : int
The sequence ID for index.
"""
_ffi_api.PagedRadixTreeAddSequence(self, seq_id)
def remove(self, seq_id: int) -> None:
"""
Remove a sequence.
Parameters
----------
seq_id : int
The sequence ID to remove.
"""
_ffi_api.PagedRadixTreeRemoveSequence(self, seq_id)
def extend(self, seq_id: int, tokens: Union[Shape, List, Tuple]) -> None: # noqa: UP006
"""
Extend a sequence with given tokens.
Parameters
----------
seq_id : int
The sequence ID for index.
tokens : Union[Shape, List, Tuple]
The given tokens to extend.
"""
if isinstance(tokens, (list, tuple)):
tokens = Shape(tokens)
_ffi_api.PagedRadixTreeExtendSequence(self, seq_id, tokens)
def rollback(self, seq_id: int, num_tokens: int) -> None:
"""
Roll back a sequence by number of tokens.
Parameters
----------
seq_id : int
The sequence ID for index.
num_tokens : int
The number of tokens to be rolled back.
"""
_ffi_api.PagedRadixTreeRollBackSequence(self, seq_id, num_tokens)
def fork(self, seq_id: int, parent_seq_id: int, forked_offset: int) -> None:
"""
Fork a sequence from parent sequence at given position.
Parameters
----------
seq_id : int
The new sequence ID.
parent_seq_id : int
The parent sequence ID to fork from.
forked_offset : int
The position of parent sequence to fork at.
The valid value is [1, length of forked sequence].
If the position equals the length of forked sequence,
the new sequence will copy the entire forked sequence.
"""
_ffi_api.PagedRadixTreeForkSequence(self, seq_id, parent_seq_id, forked_offset)
def get(self, seq_id: int) -> Shape:
"""
Get a sequence's all tokens.
Parameters
----------
seq_id : int
The sequence ID for index.
Returns
------
tokens : Shape
The sequence tokens.
"""
return _ffi_api.PagedRadixTreeGetSequence(self, seq_id)
def get_length(self, seq_id: int) -> int:
"""
Get a sequence's length.
Parameters
----------
seq_id : int
The sequence ID for index.
Returns
------
length : int
The sequence length.
"""
return _ffi_api.PagedRadixTreeGetSequenceLength(self, seq_id)
def free_capacity(self) -> int:
"""
Get the remaining token capacity of the paged radix tree.
Returns
------
capacity : int
The remaining token capacity of the paged radix tree.
"""
return _ffi_api.PagedRadixTreeFreeCapacity(self)
+34
View File
@@ -0,0 +1,34 @@
"""The request class in MLC LLM serving"""
from typing import List # noqa: UP035
import tvm_ffi
from tvm.runtime import Object
from mlc_llm.protocol.generation_config import GenerationConfig
from . import _ffi_api
from .data import Data
@tvm_ffi.register_object("mlc.serve.Request")
class Request(Object):
"""The user submitted text-generation request, which contains
a unique request id, a list of multi-modal inputs, a set of
generation configuration parameters.
Note
----
Do not explicitly construct this class.
Construct this object via engine.create_request functions.
"""
@property
def inputs(self) -> List[Data]: # noqa: UP006
"""The inputs of the request."""
return _ffi_api.RequestGetInputs(self)
@property
def generation_config(self) -> GenerationConfig:
"""The generation config of the request."""
return GenerationConfig.model_validate_json(_ffi_api.RequestGetGenerationConfigJSON(self))
+4
View File
@@ -0,0 +1,4 @@
"""The server related data structure and tools in MLC LLM serve."""
from .popen_server import PopenServer
from .server_context import ServerContext
+200
View File
@@ -0,0 +1,200 @@
"""The MLC LLM server launched in a subprocess."""
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Literal, Optional, Union
import psutil
import requests
from tvm.runtime import Device
from mlc_llm.serve.config import EngineConfig
from mlc_llm.serve.engine_base import _check_engine_config
class PopenServer:
"""The wrapper of MLC LLM server, which runs the server in
a background subprocess.
This server can be used for debugging purposes.
"""
def __init__(
self,
model: str,
device: Union[str, Device] = "auto",
*,
model_lib: Optional[str] = None,
mode: Literal["local", "interactive", "server"] = "local",
engine_config: Optional[EngineConfig] = None,
enable_debug: bool = True,
enable_tracing: bool = False,
host: str = "127.0.0.1",
port: int = 8082,
) -> None:
"""Please check out `python/mlc_llm/cli/serve.py` for the server arguments."""
# - Check the fields fields of `engine_config`.
if engine_config is None:
engine_config = EngineConfig()
_check_engine_config(model, model_lib, mode, engine_config)
self.model = model
self.model_lib = model_lib
self.device = device
self.mode = mode
self.enable_debug = enable_debug
self.engine_config = engine_config
self.enable_tracing = enable_tracing
self.enable_debug = enable_debug
self.host = host
self.port = port
self._proc: Optional[subprocess.Popen] = None
self.base_url = ""
self.openai_v1_base_url = ""
def start(self, extra_env=None) -> None:
"""Launch the server in a popen subprocess.
Wait until the server becomes ready before return.
"""
extra_env = extra_env or {}
cmd = [sys.executable]
cmd += ["-m", "mlc_llm", "serve", self.model]
if self.model_lib is not None:
cmd += ["--model-lib", self.model_lib]
cmd += ["--device", self.device]
if self.enable_debug:
cmd += ["--enable-debug"]
if self.mode is not None:
cmd += ["--mode", self.mode]
if len(self.engine_config.additional_models) > 0:
args_additional_model = []
for additional_model in self.engine_config.additional_models:
if isinstance(additional_model, str):
args_additional_model.append(additional_model)
else:
args_additional_model.append(additional_model[0] + "," + additional_model[1])
cmd += ["--additional-models", *args_additional_model]
cmd += ["--speculative-mode", self.engine_config.speculative_mode]
cmd += ["--prefix-cache-mode", self.engine_config.prefix_cache_mode]
args_overrides = []
if self.engine_config.max_num_sequence is not None:
args_overrides.append(f"max_num_sequence={self.engine_config.max_num_sequence}")
if self.engine_config.max_total_sequence_length is not None:
args_overrides.append(
f"max_total_seq_length={self.engine_config.max_total_sequence_length}"
)
if self.engine_config.prefill_chunk_size is not None:
args_overrides.append(f"prefill_chunk_size={self.engine_config.prefill_chunk_size}")
if self.engine_config.max_history_size is not None:
args_overrides.append(f"max_history_size={self.engine_config.max_history_size}")
if self.engine_config.gpu_memory_utilization is not None:
args_overrides.append(
f"gpu_memory_utilization={self.engine_config.gpu_memory_utilization}"
)
if self.engine_config.spec_draft_length is not None:
args_overrides.append(f"spec_draft_length={self.engine_config.spec_draft_length}")
if self.engine_config.prefix_cache_max_num_recycling_seqs is not None:
args_overrides.append(
"prefix_cache_max_num_recycling_seqs="
+ str(self.engine_config.prefix_cache_max_num_recycling_seqs)
)
if len(args_overrides) > 0:
cmd += ["--overrides", ";".join(args_overrides)]
if self.enable_tracing:
cmd += ["--enable-tracing"]
if self.enable_debug:
cmd += ["--enable-debug"]
cmd += ["--host", self.host]
cmd += ["--port", str(self.port)]
process_path = str(Path(__file__).resolve().parents[4])
final_env = os.environ.copy()
for key, value in extra_env.items():
final_env[key] = value
self._proc = subprocess.Popen(cmd, cwd=process_path, env=final_env)
# NOTE: DO NOT USE `stdout=subprocess.PIPE, stderr=subprocess.PIPE`
# in subprocess.Popen here. PIPE has a fixed-size buffer with may block
# and hang forever.
# Try to query the server until it is ready.
self.base_url = f"http://{self.host}:{str(self.port)}"
self.openai_v1_base_url = f"http://{self.host}:{str(self.port)}/v1"
openai_v1_models_url = f"{self.base_url}/v1/models"
query_result = None
timeout = 120
attempts = 0.0
while query_result is None and attempts < timeout:
try:
query_result = requests.get(openai_v1_models_url, timeout=60)
if query_result.status_code != 200:
query_result = None
attempts += 0.1
time.sleep(0.1)
except Exception:
attempts += 0.1
time.sleep(0.1)
# Check if the subprocess terminates unexpectedly or
# the queries reach the timeout.
process_return_code = self._proc.poll()
if process_return_code is not None:
raise RuntimeError(
"The server fails to launch. "
f'Please check if "{self.model}" is a valid model compiled by MLC LLM.'
)
if attempts == timeout:
self.terminate()
raise RuntimeError(f"The server fails to launch in {timeout} seconds.")
def terminate(self) -> None:
"""Terminate the server subprocess."""
if self._proc is None:
return
# Kill all the child processes.
def kill_child_processes():
try:
parent = psutil.Process(self._proc.pid)
children = parent.children(recursive=True)
except psutil.NoSuchProcess:
return
for process in children:
try:
process.kill()
except psutil.NoSuchProcess:
pass
kill_child_processes()
# Kill the process.
try:
self._proc.kill()
except OSError:
pass
# Join the process to avoid zombies.
try:
self._proc.wait(timeout=10.0)
except subprocess.TimeoutExpired:
pass
self._proc = None
def __enter__(self):
"""Start the server."""
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Terminate the server."""
self.terminate()
@@ -0,0 +1,72 @@
"""Server context that shared by multiple entrypoint files."""
from typing import TYPE_CHECKING, Dict, List, Optional # noqa: UP035
from ..engine import AsyncMLCEngine
if TYPE_CHECKING:
from ..embedding_engine import AsyncEmbeddingEngine
class ServerContext:
"""The global server context, including the running models
and corresponding async engines.
"""
server_context: Optional["ServerContext"] = None
enable_debug: bool = False
def __init__(self) -> None:
self._models: Dict[str, AsyncMLCEngine] = {} # noqa: UP006
self._embedding_engines: Dict[str, AsyncEmbeddingEngine] = {} # noqa: UP006
self.api_key: Optional[str] = None
def __enter__(self):
if ServerContext.server_context is not None:
raise RuntimeError("Server context already exists.")
ServerContext.server_context = self
return self
def __exit__(self, exc_type, exc_value, traceback):
for model_engine in self._models.values():
model_engine.terminate()
for emb_engine in self._embedding_engines.values():
emb_engine.terminate()
self._models.clear()
self._embedding_engines.clear()
ServerContext.server_context = None
@staticmethod
def current():
"""Returns the current ServerContext."""
return ServerContext.server_context
def add_model(self, hosted_model: str, engine: AsyncMLCEngine) -> None:
"""Add a new model to the server context together with the engine."""
if hosted_model in self._models:
raise RuntimeError(f"Model {hosted_model} already running.")
self._models[hosted_model] = engine
def get_engine(self, model: Optional[str]) -> Optional[AsyncMLCEngine]:
"""Get the async engine of the requested model, or the unique async engine
if only one engine is served."""
if len(self._models) == 1:
return next(iter(self._models.values()))
return self._models.get(model, None)
def get_model_list(self) -> List[str]: # noqa: UP006
"""Get the list of all models on serve, including embedding models."""
return list(self._models.keys()) + list(self._embedding_engines.keys())
def add_embedding_engine(self, hosted_model: str, engine: "AsyncEmbeddingEngine") -> None:
"""Add a new embedding model to the server context."""
if hosted_model in self._embedding_engines:
raise RuntimeError(f"Embedding model {hosted_model} already running.")
self._embedding_engines[hosted_model] = engine
def get_embedding_engine(self, model: Optional[str]) -> Optional["AsyncEmbeddingEngine"]:
"""Get the embedding engine of the requested model, or the unique
embedding engine if only one is served."""
if len(self._embedding_engines) == 1:
return next(iter(self._embedding_engines.values()))
return self._embedding_engines.get(model, None)
+363
View File
@@ -0,0 +1,363 @@
"""The MLC LLM synchronized engine.
NOTE: This engine defined in this file directly wraps the underlying
Engine implementation in C++, is not optimized by multi-threading and
does not offer standard OpenAI API interface.
We do not expose it and use it by default. As of now it mainly serves
the test and debug purpose because of its simplicity.
"""
import json
from collections.abc import Sequence
from typing import Any, Callable, Dict, List, Literal, Optional, Tuple, Union # noqa: UP035
import tvm
from mlc_llm.protocol.generation_config import GenerationConfig
from mlc_llm.serve import data
from mlc_llm.serve.config import EngineConfig
from mlc_llm.serve.engine_base import (
EngineMetrics,
_check_engine_config,
_parse_models,
_print_engine_mode_logging_msg,
_process_model_args,
detect_device,
)
from mlc_llm.serve.event_trace_recorder import EventTraceRecorder
from mlc_llm.serve.request import Request
from mlc_llm.support import logging
from mlc_llm.tokenizers import TextStreamer, Tokenizer
logger = logging.getLogger(__name__)
def _create_tvm_module(
creator: str,
ffi_funcs: Sequence[str],
creator_args: Optional[List[Any]] = None, # noqa: UP006
) -> Dict[str, Callable]: # noqa: UP006
"""Internal method to create a module."""
if creator_args is None:
creator_args = []
module = tvm.get_global_func(creator, allow_missing=False)(*creator_args)
return {key: module[key] for key in ffi_funcs}
class SyncMLCEngine:
"""The Python interface of synchronize request serving engine for MLC LLM.
The engine receives requests from the "add_request" method. For
an given request, the engine will keep generating new tokens for
the request until finish (under certain criterion). After finish,
the engine will return the generation result through the callback
function provided by the request.
NOTE: This engine directly wraps the underlying Engine implementation
in C++, is not optimized by multi-threading and does not offer standard
OpenAI API interface. We do not expose it and use it by default.
As of now it mainly serves the test and debug purpose because of its
simplicity.
Parameters
----------
engine_config : Optional[EngineConfig]
Additional configurable arguments of MLC engine.
See class "EngineConfig" for more detail.
enable_tracing : bool
A boolean indicating if to enable event logging for requests.
request_stream_callback : Optional[Callable[[str, data.TokenData, Optional[str]], None]]
The provided callback function to handle the generation
output. It has the signature of `(str, data.TokenData, bool) -> None`,
where
- the first string is the request id,
- the TokenData contains the generated **delta** token ids since
the last invocation of the callback on the specific request,
- the optional string value denotes the finish reason if the
generation of the request is finished, or None if it has not finished.
The callback function is optional at construction, but it needs to
be set before the engine executing requests. This can be done via
the `set_request_stream_callback` method. Otherwise, the engine will raise
exception.
"""
def __init__(
self,
model: str,
device: Union[str, tvm.runtime.Device] = "auto",
*,
model_lib: Optional[str] = None,
mode: Literal["local", "interactive", "server"] = "local",
engine_config: Optional[EngineConfig] = None,
enable_tracing: bool = False,
request_stream_callback: Optional[Callable[[List[data.RequestStreamOutput]], None]] = None, # noqa: UP006
):
# - Check the fields fields of `engine_config`.
if engine_config is None:
engine_config = EngineConfig()
_check_engine_config(
model,
model_lib,
mode,
engine_config,
)
# - Initialize model loading info.
models = _parse_models(model, model_lib, engine_config.additional_models)
if isinstance(device, str):
device = detect_device(device)
assert isinstance(device, tvm.runtime.Device)
(
model_args,
model_config_paths,
self.conv_template,
) = _process_model_args(models, device, engine_config)
# - Load the raw model config into dict
self.model_config_dicts = []
for i, model_info in enumerate(models):
model_info.model_lib = model_args[i][1]
with open(model_config_paths[i], encoding="utf-8") as file:
self.model_config_dicts.append(json.load(file))
# - Print logging info for regarding the mode selection.
if engine_config.verbose:
_print_engine_mode_logging_msg(mode)
self._ffi = _create_tvm_module(
"mlc.serve.create_engine",
ffi_funcs=[
"init",
"add_request",
"abort_request",
"step",
"reset",
"json_metrics",
"get_request_stream_callback",
"set_request_stream_callback",
"create_request",
],
)
self.trace_recorder = EventTraceRecorder() if enable_tracing else None
engine_config.model = model_args[0][0]
engine_config.model_lib = model_args[0][1]
engine_config.additional_models = model_args[1:]
engine_config.mode = mode
self._ffi["init"](
engine_config.asjson(),
device,
request_stream_callback,
self.trace_recorder,
)
self.tokenizer = Tokenizer(model_args[0][0])
def generate(
self,
prompts: Union[str, List[str], List[int], List[List[int]], List[List[data.Data]]], # noqa: UP006
generation_config: Union[GenerationConfig, List[GenerationConfig]], # noqa: UP006
) -> Tuple[List[List[str]], List[Optional[List[List[str]]]]]: # noqa: UP006
"""Generate texts for a list of input prompts.
Each prompt can be a string or a list of token ids.
The generation for each prompt is independent.
Return the generation results, one for each prompt.
Parameters
----------
prompts : Union[str, List[str], List[int], List[List[int]]]
One or a list of input prompts for text generation.
Each prompt can be a string or a list of token ids.
generation_config : Union[GenerationConfig, List[GenerationConfig]]
The generation config for each requests.
If the it is a single GenerationConfig instance,
this config will be shared by all the prompts.
Otherwise, one generation config is required for every
prompt.
Returns
-------
output_text : List[List[str]]
The text generation results, one list of strings for each input prompt.
The length of each list is the parallel generation `n` in
generation config.
output_logprobs_str : List[Optional[List[List[str]]]]
The logprob strings of each token for each input prompt, or None
if an input prompt does not require logprobs.
"""
if isinstance(prompts, str):
# `prompts` is a single string.
prompts = [prompts]
else:
assert isinstance(prompts, list), (
"Input `prompts` is expected to be a string, a list of "
"str, a list of token ids or multiple lists of token ids. "
)
if len(prompts) == 0:
return [], []
if isinstance(prompts[0], int):
# `prompts` is a list of token ids
prompts = [prompts]
num_requests = len(prompts)
if not isinstance(generation_config, list):
generation_config = [generation_config] * num_requests
assert len(generation_config) == num_requests, (
"Number of generation config and number of prompts mismatch"
)
num_finished_generations = 0
output_texts: List[List[str]] = [] # noqa: UP006
output_logprobs_str: List[Optional[List[List[str]]]] = [] # noqa: UP006
text_streamers: List[List[TextStreamer]] = [] # noqa: UP006
for i in range(num_requests):
output_texts.append([])
output_logprobs_str.append([] if generation_config[i].logprobs else None)
text_streamers.append([])
for _ in range(generation_config[i].n):
output_texts[i].append("")
text_streamers[i].append(TextStreamer(self.tokenizer))
if output_logprobs_str[i] is not None:
output_logprobs_str[i].append([])
num_total_generations = sum(cfg.n for cfg in generation_config)
# Save a copy of the original function callback since `generate`
# overrides the callback function.
# The original callback will be set back later on.
original_callback = self._ffi["get_request_stream_callback"]()
# Define the callback function for request generation results
def request_stream_callback(delta_outputs: List[data.RequestStreamOutput]): # noqa: UP006
nonlocal num_finished_generations
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
rid = int(request_id)
assert len(stream_outputs) == generation_config[rid].n
for i, (stream_output, text_streamer) in enumerate(
zip(stream_outputs, text_streamers[rid])
):
if output_logprobs_str[rid] is not None:
assert stream_output.delta_logprob_json_strs is not None
output_logprobs_str[rid][i] += stream_output.delta_logprob_json_strs
delta_text = stream_output.extra_prefix_string + (
text_streamer.put(stream_output.delta_token_ids)
if len(stream_output.delta_token_ids) > 0
else ""
)
if stream_output.finish_reason is not None:
delta_text += text_streamer.finish()
output_texts[rid][i] += delta_text
if stream_output.finish_reason is not None:
num_finished_generations += 1
# Override the callback function in engine.
self._ffi["set_request_stream_callback"](request_stream_callback)
def convert_to_data(
prompt: Union[str, List[int], List[data.Data]], # noqa: UP006
) -> List[data.Data]: # noqa: UP006
if isinstance(prompt, str):
return [data.TextData(prompt)]
if isinstance(prompt[0], int):
return [data.TokenData(prompt)]
return prompt
# Add requests to engine.
for req_id, (prompt, generation_cfg) in enumerate(zip(prompts, generation_config)):
input_data = convert_to_data(prompt)
self.add_request(
self.create_request(
request_id=str(req_id),
inputs=input_data,
generation_config=generation_cfg,
)
)
while num_finished_generations != num_total_generations:
self.step()
# Restore the callback function in engine.
self._ffi["set_request_stream_callback"](original_callback)
return output_texts, output_logprobs_str
def create_request(
self,
request_id: str,
inputs: Union[data.Data, List[data.Data]], # noqa: UP006
generation_config: GenerationConfig,
):
"""Create a new request that can be added to engine.
Parameters
----------
request_id : str
The unique identifier of the request.
Different requests should have different ids.
inputs : List[Data]
The user inputs of a request. Input may have multi-modality.
generation_config : GenerationConfig
The generation configuration of the request.
Note
----
engine may fill in default generation config of the model.
"""
if not isinstance(inputs, list):
inputs = [inputs]
return self._ffi["create_request"](
request_id, inputs, generation_config.model_dump_json(by_alias=True)
)
def add_request(self, request: Request) -> None:
"""Add a new request to the engine.
Parameters
----------
request : Request
The request to add.
"""
self._ffi["add_request"](request)
def abort_request(self, request_id: str) -> None:
"""Abort the generation of the request corresponding to the input request id.
Parameters
----------
request_id : str
The unique id of the request to abort.
"""
self._ffi["abort_request"](request_id)
def step(self) -> None:
"""The main function that the engine takes a step of action.
At each step, the engine may decide to
- run prefill for one (or more) requests,
- run one-step decode for the all existing requests
...
In the end of certain actions (e.g., decode), the engine will
check if any request has finished, and will return the
generation results for those finished requests.
"""
self._ffi["step"]()
def reset(self) -> None:
"""Reset the engine, clean up all running data and metrics."""
self._ffi["reset"]()
def metrics(self) -> EngineMetrics:
"""Reset the engine, clean up all running data and metrics."""
return EngineMetrics(json.loads(self._ffi["json_metrics"]()))