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
+172
View File
@@ -0,0 +1,172 @@
"""Python entrypoint for calibration."""
import asyncio
import json
import random
from collections.abc import Mapping
from typing import List, Optional, Tuple # noqa: UP035
import numpy as np
import tqdm.asyncio
import tvm
from tvm.contrib import tvmjs
from mlc_llm.serve.engine import AsyncMLCEngine, EngineConfig
from mlc_llm.tokenizers import Tokenizer
class CalibrationObserver:
"""A singleton class to observe the calibration parameters.""" ""
instance: "CalibrationObserver" = None
params: Mapping[str, tvm.runtime.Tensor] = {}
@staticmethod
def get():
"""Get the singleton instance of the class.""" ""
if CalibrationObserver.instance is None:
CalibrationObserver.instance = CalibrationObserver()
return CalibrationObserver.instance
@tvm.register_global_func("mlc_llm.calibration_observer")
@staticmethod
def callback(
name: str,
mode: str,
value: "tvm.runtime.Tensor",
out_value: "tvm.runtime.Tensor",
):
"""The callback function to update the saved calibration parameters."""
instance = CalibrationObserver.get()
if mode == "max":
reducer = np.maximum
else:
raise NotImplementedError(f"Unsupported calibration mode: {mode}")
if name in instance.params:
instance.params[name] = reducer(instance.params[name], value.numpy())
else:
instance.params[name] = value.numpy()
out_value.copyfrom(instance.params[name])
def save_params(self, output: str):
"""Save the calibration parameters to the given output directory."""
tvmjs.dump_tensor_cache(
self.params,
output,
encode_format="f32-to-bf16",
meta_data=None,
show_progress=False,
update_if_exists=True,
)
def sample_requests(
dataset_path: str,
num_requests: int,
tokenizer: Tokenizer,
) -> List[Tuple[str, int, int]]: # noqa: UP006
"""Sample the requests from the given dataset."""
# Load the dataset.
with open(dataset_path, encoding="utf-8") as f:
dataset = json.load(f)
# Filter out the conversations with less than 2 turns.
dataset = [data for data in dataset if len(data["conversations"]) >= 2]
# Only keep the first two turns of each conversation.
dataset = [
(data["conversations"][0]["value"], data["conversations"][1]["value"]) for data in dataset
]
prompts = [prompt for prompt, _ in dataset]
prompt_token_ids = tokenizer.encode_batch(prompts)
completions = [completion for _, completion in dataset]
completion_token_ids = tokenizer.encode_batch(completions)
tokenized_dataset: List[Tuple[str, List[int], int]] = [] # noqa: UP006
for i in range(len(dataset)):
output_len = len(completion_token_ids[i])
tokenized_dataset.append((prompts[i], prompt_token_ids[i], output_len))
# Filter out too long sequences.
filtered_dataset: List[Tuple[str, int, int]] = [] # noqa: UP006
for prompt, token_ids, output_len in tokenized_dataset:
prompt_len = len(token_ids)
if prompt_len < 4 or output_len < 4:
# Prune too short sequences.
continue
if prompt_len > 1024 or prompt_len + output_len > 2048:
# Prune too long sequences.
continue
filtered_dataset.append((prompt, prompt_len, output_len))
# Sample the requests.
sampled_requests = random.sample(filtered_dataset, num_requests)
return sampled_requests
async def send_calibration_requests(
async_engine: AsyncMLCEngine,
sampled_requests: List[Tuple[str, int, int]], # noqa: UP006
max_concurrent_requests: int,
) -> None:
"""Send the calibration requests to the engine."""
tasks = []
semaphore = asyncio.Semaphore(max_concurrent_requests)
async def generate_task(request_idx):
async with semaphore:
prompt, _, output_len = sampled_requests[request_idx]
await async_engine.chat.completions.create(
messages=[{"role": "user", "content": prompt}],
max_tokens=output_len,
request_id=str(request_idx),
)
for i in range(len(sampled_requests)):
task = asyncio.create_task(generate_task(i))
tasks.append(task)
await tqdm.asyncio.tqdm.gather(*tasks)
def calibrate(
model: str,
device: str,
model_lib: Optional[str],
dataset: str,
output: str,
num_calibration_samples: int,
*,
seed: int,
max_num_sequence: Optional[int] = None,
max_total_sequence_length: Optional[int] = None,
prefill_chunk_size: Optional[int] = None,
max_history_size: Optional[int] = None,
gpu_memory_utilization: Optional[float] = None,
) -> None:
"""Calibrate the quantized model using the given dataset."""
random.seed(seed)
async_engine = AsyncMLCEngine(
model=model,
device=device,
model_lib=model_lib,
mode="server",
engine_config=EngineConfig(
max_num_sequence=max_history_size,
max_total_sequence_length=max_total_sequence_length,
prefill_chunk_size=prefill_chunk_size,
max_history_size=max_history_size,
gpu_memory_utilization=gpu_memory_utilization,
),
)
sampled_requests = sample_requests(dataset, num_calibration_samples, async_engine.tokenizer)
asyncio.run(
send_calibration_requests(
async_engine,
sampled_requests,
max_concurrent_requests=max_num_sequence or 32,
)
)
async_engine.terminate()
calibrator = CalibrationObserver.get()
calibrator.save_params(output)
+311
View File
@@ -0,0 +1,311 @@
"""Python entrypoint of chat."""
import dataclasses
from typing import Any, Dict, List, Optional, Union # noqa: UP035
from prompt_toolkit import prompt as get_prompt
from prompt_toolkit.key_binding import KeyBindings
from mlc_llm.json_ffi import JSONFFIEngine
from mlc_llm.protocol import openai_api_protocol
from mlc_llm.serve.config import EngineConfig
from mlc_llm.serve.engine import MLCEngine
from mlc_llm.serve.engine_base import _query_engine_metrics
from mlc_llm.support import argparse
from mlc_llm.support.config import ConfigOverrideBase
def _print_help_str():
help_str = """You can use the following special commands:
/help print the special commands
/exit quit the cli
/stats print out stats of last request (token/sec)
/metrics print out full engine metrics
/reset restart a fresh chat
/set [overrides] override settings in the generation config. For example,
`/set temperature=0.5;top_p=0.8;seed=23;max_tokens=100;stop=str1,str2`
Note: Separate stop words in the `stop` option with commas (,).
Multi-line input: Use escape+enter to start a new line.
"""
print(help_str)
def _set_up_key_bindings():
kb = KeyBindings()
@kb.add("escape", "enter")
def _(event):
event.current_buffer.insert_text("\n")
@kb.add("enter")
def _(event):
event.current_buffer.validate_and_handle()
return kb
@dataclasses.dataclass
class ChatCompletionOverride(ConfigOverrideBase):
"""Flags for overriding chat completions."""
temperature: Optional[float] = None
top_p: Optional[float] = None
frequency_penalty: Optional[float] = None
presence_penalty: Optional[float] = None
max_tokens: Optional[int] = None
seed: Optional[int] = None
stop: Optional[Union[str, List[str]]] = None # noqa: UP006
@staticmethod
def from_str(source: str) -> "ChatCompletionOverride":
"""Parse model config override values from a string."""
parser = argparse.ArgumentParser(description="chat completion override values")
parser.add_argument("--temperature", type=float, default=None)
parser.add_argument("--top_p", type=float, default=None)
parser.add_argument("--frequency_penalty", type=float, default=None)
parser.add_argument("--presence_penalty", type=float, default=None)
parser.add_argument("--max_tokens", type=int, default=None)
parser.add_argument("--seed", type=int, default=None)
parser.add_argument("--stop", type=str, default=None)
results = parser.parse_args([f"--{i}" for i in source.split(";") if i])
return ChatCompletionOverride(
temperature=results.temperature,
top_p=results.top_p,
frequency_penalty=results.frequency_penalty,
presence_penalty=results.presence_penalty,
max_tokens=results.max_tokens,
seed=results.seed,
stop=results.stop.split(",") if results.stop is not None else None,
)
@dataclasses.dataclass
class ModelConfigOverride(ConfigOverrideBase):
"""Flags for overriding model config."""
context_window_size: Optional[int] = None
sliding_window_size: Optional[int] = None
prefill_chunk_size: Optional[int] = None
attention_sink_size: Optional[int] = None
tensor_parallel_shards: Optional[int] = None
pipeline_parallel_stages: Optional[int] = None
opt: Optional[str] = None
@staticmethod
def from_str(source: str) -> "ModelConfigOverride":
"""Parse model config override values from a string."""
parser = argparse.ArgumentParser(description="model config override values")
parser.add_argument("--tensor_parallel_shards", type=int, default=None)
parser.add_argument("--pipeline_parallel_stages", type=int, default=None)
parser.add_argument("--opt", type=str, default=None)
parser.add_argument("--context_window_size", type=int, default=None)
parser.add_argument("--sliding_window_size", type=int, default=None)
parser.add_argument("--prefill_chunk_size", type=int, default=None)
parser.add_argument("--attention_sink_size", type=int, default=None)
results = parser.parse_args([f"--{i}" for i in source.split(";") if i])
return ModelConfigOverride(
tensor_parallel_shards=results.tensor_parallel_shards,
pipeline_parallel_stages=results.pipeline_parallel_stages,
opt=results.opt,
context_window_size=results.context_window_size,
sliding_window_size=results.sliding_window_size,
prefill_chunk_size=results.prefill_chunk_size,
attention_sink_size=results.attention_sink_size,
)
class ChatState:
"""Simple helper class to manage chat state.
Chat state wraps around a engine instance
and exposes the minimum set of tools to perform
interactive chat. It provides support for mlc_llm chat.
It also can be used to do interactive debugging
with different engine instance.
Examples
--------
.. code:: python
from openai import OpenAI
from mlc_llm import MLCEngine
from mlc_llm.serve import PopenServer
from mlc_llm.interface.chat import ChatState
def chat_with_engine(model):
# hookup with MLCEngine
ChatState(MLCEngine(model)).chat()
def chat_with_server(model):
# hookup with AsyncMLCEngine backed api server
with PopenServer(model) as server:
ChatState(
OpenAI(base_url=server.openai_v1_base_url, api_key="None")
).chat()
"""
history: List[Dict[str, Any]] # noqa: UP006
history_begin: int
# kwargs passed to completions
overrides: ChatCompletionOverride
# Underlying engine
engine: Union[JSONFFIEngine, MLCEngine]
last_finished_request_usage: Optional[openai_api_protocol.CompletionUsage]
def __init__(self, engine: Union[JSONFFIEngine, MLCEngine]):
self.engine = engine
self.history = []
self.history_window_begin = 0
self.overrides = ChatCompletionOverride()
# model is mainly used for compact reasons
self.model = "chat_model"
self.last_finished_request_usage = None
def slide_history(self):
"""Slide history to fit into context window"""
history_window_size = len(self.history) - self.history_window_begin
assert history_window_size % 2 == 0
self.history_window_begin += ((history_window_size + 3) // 4) * 2
def process_system_prompts(self):
"""Process system prompts"""
# TODO(mlc-team): possibly leverage debug option
# pass a simple prompt to warm up
for _ in self.engine.chat.completions.create(
messages=[{"role": "user", "content": ""}],
max_tokens=1,
model=self.model,
stream=True,
):
pass
def generate(self, prompt: str):
"""Run one generation with the prompt.
Parameters
----------
prompt: str
The input prompt
"""
self.history.append({"role": "user", "content": prompt})
output_text = ""
finish_reason_length = False
messages = self.history[self.history_window_begin :]
for response in self.engine.chat.completions.create(
messages=messages,
model=self.model,
stream=True,
stream_options={"include_usage": True},
**dataclasses.asdict(self.overrides),
):
if response.usage is not None:
self.last_finished_request_usage = response.usage
continue
for choice in response.choices:
assert choice.delta.role == "assistant"
if isinstance(choice.delta.content, str):
output_text += choice.delta.content
print(choice.delta.content, end="", flush=True)
if choice.finish_reason == "length":
finish_reason_length = True
if finish_reason_length:
print(" [output truncated due to context length limit...]")
# print additional \n when generation ends
print()
# record the history
self.history.append({"role": "assistant", "content": output_text})
if finish_reason_length:
self.slide_history()
def stats(self):
"""Print statistics of the prefill and decode speed."""
def get_stats_text():
"""Get text"""
if self.last_finished_request_usage is None:
return "N/A"
last_finished_request = self.last_finished_request_usage.extra
if last_finished_request is None:
return "N/A"
prefill_speed = last_finished_request.get("prefill_tokens_per_s", None)
decode_speed = last_finished_request.get("decode_tokens_per_s", None)
prefill_speed = f"{prefill_speed:.1f}" if prefill_speed is not None else "N/A"
decode_speed = f"{decode_speed:.1f}" if decode_speed is not None else "N/A"
return f"prefill: {prefill_speed} tok/s, decode: {decode_speed} tok/s"
print(get_stats_text(), flush=True)
def metrics(self):
"""Print metrics as prometheus text"""
print(_query_engine_metrics(self.engine).prometheus_text(), flush=True)
def reset(self):
"""Reset the chat history"""
self.history = []
self.history_window_begin = 0
def chat(self):
"""Start an interactive chat session."""
_print_help_str()
self.process_system_prompts()
# Multi-line input support: set escape+enter as start a new line
kb = _set_up_key_bindings()
while True:
try:
prompt = get_prompt(
">>> ",
key_bindings=kb,
multiline=True,
)
except (KeyboardInterrupt, EOFError):
break
if prompt[:4] == "/set":
overrides = ChatCompletionOverride.from_str(prompt.split()[1])
for key, value in dataclasses.asdict(overrides).items():
if value is not None:
setattr(self.overrides, key, value)
elif prompt[:6] == "/stats":
self.stats()
elif prompt[:8] == "/metrics":
self.metrics()
elif prompt[:6] == "/reset":
self.reset()
elif prompt[:5] == "/exit":
break
elif prompt[:5] == "/help":
_print_help_str()
else:
self.generate(prompt)
def chat(
model: str,
device: str,
model_lib: Optional[str],
overrides: ModelConfigOverride,
):
"""Chat cli entry"""
# By default we use JSONFFIEngine
engine = JSONFFIEngine(
model,
device,
model_lib=model_lib,
mode="interactive",
engine_config=EngineConfig(
max_single_sequence_length=overrides.context_window_size,
prefill_chunk_size=overrides.prefill_chunk_size,
sliding_window_size=overrides.sliding_window_size,
attention_sink_size=overrides.attention_sink_size,
tensor_parallel_shards=overrides.tensor_parallel_shards,
pipeline_parallel_stages=overrides.pipeline_parallel_stages,
opt=overrides.opt,
),
)
try:
ChatState(engine).chat()
finally:
engine.terminate()
+265
View File
@@ -0,0 +1,265 @@
"""Python entrypoint of compilation."""
import dataclasses
from io import StringIO
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple # noqa: UP035
from tvm import IRModule, relax, tirx
from tvm.ir.transform import Pass, PassContext
from tvm.relax.frontend import nn
from tvm.target import Target
from mlc_llm import compiler_pass as _ # noqa: F401
from mlc_llm import op as op_ext
from mlc_llm.cli.model_metadata import _report_memory_usage
from mlc_llm.model import Model
from mlc_llm.quantization import Quantization
from mlc_llm.support import logging
from mlc_llm.support.config import ConfigBase
from mlc_llm.support.style import bold
from .compiler_flags import ModelConfigOverride, OptimizationFlags
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class CompileArgs:
"""Arguments to MLC LLM's compiler."""
config: Path
quantization: Quantization
model: Model
target: Target
opt: OptimizationFlags
build_func: Callable[[IRModule, "CompileArgs", Pass], None]
system_lib_prefix: str
output: Path
overrides: ModelConfigOverride
debug_dump: Optional[Path]
def __post_init__(self) -> None:
self.opt.update(self.target, self.quantization)
def display(self) -> None:
"""Display the arguments to stdout."""
out = StringIO()
print(f"{bold('Compiling with arguments:')}", file=out)
print(f" {bold('--config'):<25} {self.config}", file=out)
print(f" {bold('--quantization'):<25} {self.quantization}", file=out)
print(f" {bold('--model-type'):<25} {self.model.name}", file=out)
print(f" {bold('--target'):<25} {self.target.export()}", file=out)
print(f" {bold('--opt'):<25} {self.opt}", file=out)
print(f' {bold("--system-lib-prefix"):<25} "{self.system_lib_prefix}"', file=out)
print(f" {bold('--output'):<25} {self.output}", file=out)
print(f" {bold('--overrides'):<25} {self.overrides}", file=out)
# As it's debug only, no need to display
# print(f" {bold('--debug-dump'):<25} {self.debug_dump}", file=out)
print(out.getvalue().rstrip())
def _apply_preproc_to_params_and_check_pipeline(
named_params: List[Tuple[str, nn.Parameter]], # noqa: UP006
model_config,
) -> Dict[str, tirx.PrimFunc]: # noqa: UP006
extra_tirs: Dict[str, tirx.PrimFunc] = {} # noqa: UP006
for name, param in named_params:
preprocs = param.attrs.get("preprocs", [])
shard_strategy = param.attrs.get("shard_strategy", None)
if shard_strategy is not None and model_config.tensor_parallel_shards > 1:
preprocs.append(
shard_strategy.gen_shard_info(
shards=model_config.tensor_parallel_shards,
weight=param,
)
)
if shard_strategy.name not in extra_tirs:
extra_tirs[shard_strategy.name] = shard_strategy.gen_tir(
shards=model_config.tensor_parallel_shards,
weight=param,
)
param.attrs["preprocs"] = preprocs
pipeline_parallel_stages = getattr(model_config, "pipeline_parallel_stages", 1)
if pipeline_parallel_stages != 1:
assert "pipeline_stages" in param.attrs, (
f'The pipeline stage is undefined for parameter "{name}" when the number '
f"of pipeline parallel stages is {pipeline_parallel_stages}"
)
param.attrs["pipeline_stages"] = (
[0]
if "pipeline_stages" not in param.attrs
else list(set(param.attrs["pipeline_stages"]))
)
return extra_tirs
def _infer_kv_state_kind(model_type) -> str:
if "rwkv" in model_type:
return "rnn_state"
if "medusa" in model_type:
return "none"
if "qwen3_5" in model_type:
return "hybrid"
return "kv_cache"
def _compile(args: CompileArgs, model_config: ConfigBase):
def _get_variable_bounds(model_config) -> Dict[str, int]: # noqa: UP006
if hasattr(model_config, "sliding_window_size"):
return {
"rolling_cache_len": model_config.sliding_window_size,
"kv_seq_len": model_config.sliding_window_size + model_config.prefill_chunk_size,
"seq_len": model_config.prefill_chunk_size,
"batch_size": getattr(model_config, "max_batch_size", 1),
}
return {
"total_seq_len": model_config.context_window_size,
"seq_len": model_config.prefill_chunk_size,
"batch_size": getattr(model_config, "max_batch_size", 1),
}
def _get_param_metadata(name: str, param: nn.Parameter) -> Dict[str, Any]: # noqa: UP006
return {
"name": name,
# Record dynamic shape as -1 (e.g. vocab_size)
"shape": [s if isinstance(s, int) else s.name for s in param.shape],
"dtype": str(param.dtype),
"preprocs": param.attrs["preprocs"],
"pipeline_stages": param.attrs.get("pipeline_stages", [0]),
}
logger.info("TOP LEVEL MODEL CONFIG BEFORE OVERRIDES: %s", str(model_config))
_kwargs = getattr(model_config, "kwargs", {})
model_config = args.overrides.apply(model_config)
with args.target:
op_ext.enable(
target=args.target,
flashinfer=args.opt.flashinfer,
faster_transformer=args.opt.faster_transformer,
cutlass=args.opt.cutlass,
)
# Step 1. Create the quantized model
logger.info("Creating model from: %s", model_config)
if (
args.quantization.kind == "ft-quant"
and hasattr(model_config, "tensor_parallel_shards")
and model_config.tensor_parallel_shards > 1
):
raise NotImplementedError
if (
hasattr(args.quantization, "linear_weight_layout")
and args.quantization.linear_weight_layout == "KN"
and hasattr(model_config, "tensor_parallel_shards")
and model_config.tensor_parallel_shards > 1
):
raise NotImplementedError(
"KN layout (q3f16_0 and q4f16_0) is not supported for tensor parallelism"
)
model, _ = args.model.quantize[args.quantization.kind](model_config, args.quantization)
# Step 2. Exporting the model to TVM
logger.info("Exporting the model to TVM compiler")
mod, named_params, ext_mods = model.export_tvm(
spec=model.get_default_spec(),
allow_extern=True,
)
# Step 3. Running relax compilation pipeline
logger.info("Running optimizations using TVM")
additional_tirs = _apply_preproc_to_params_and_check_pipeline(named_params, model_config)
variable_bounds = _get_variable_bounds(model_config)
cuda_graph_symbolic_capture_hints = {
"batch_decode": ["batch_size"],
"batch_decode_to_last_hidden_states": ["batch_size"],
"batch_verify": ["batch_size", "seq_len"],
"batch_verify_to_last_hidden_states": ["batch_size", "seq_len"],
}
avs = _kwargs.get("active_vocab_size", None)
if avs is not None and avs <= 0:
avs = None
metadata = {
"model_type": args.model.name,
"quantization": args.quantization.name,
"context_window_size": getattr(model_config, "context_window_size", -1),
"sliding_window_size": getattr(model_config, "sliding_window_size", -1),
"attention_sink_size": getattr(model_config, "attention_sink_size", -1),
"prefill_chunk_size": model_config.prefill_chunk_size,
"tensor_parallel_shards": model_config.tensor_parallel_shards,
"pipeline_parallel_stages": getattr(model_config, "pipeline_parallel_stages", 1),
"disaggregation": getattr(model_config, "disaggregation", False),
"kv_state_kind": _infer_kv_state_kind(args.model.name),
"max_batch_size": getattr(model_config, "max_batch_size", 1),
"active_vocab_size": avs,
"model_task": args.model.model_task,
}
if args.model.embedding_metadata:
metadata["embedding_metadata"] = dataclasses.asdict(args.model.embedding_metadata)
logger.info("Registering metadata: %s", metadata)
metadata["params"] = [_get_param_metadata(name, param) for name, param in named_params]
pass_config = {"relax.backend.use_cuda_graph": args.opt.cudagraph}
# TODO: Remove this workaround when the TVM CSE regression is fixed.
# Temporary workaround for TVM CSE regression that can produce
# dangling `cse_v*` vars during host codegen.
pass_config["tirx.disable_cse_tir"] = True
with PassContext(config=pass_config):
args.build_func(
mod,
args,
pipeline=relax.get_pipeline(
"mlc_llm",
target=args.target,
flashinfer=args.opt.flashinfer,
cublas_gemm=args.opt.cublas_gemm,
faster_transformer=args.opt.faster_transformer,
allreduce_strategy=args.opt.ipc_allreduce_strategy,
variable_bounds=variable_bounds,
cuda_graph_symbolic_capture_hints=cuda_graph_symbolic_capture_hints,
additional_tirs=additional_tirs,
ext_mods=ext_mods,
metadata=metadata,
debug_dump=args.debug_dump,
),
)
_report_memory_usage(metadata=metadata, config=model_config)
logger.info("Generated: %s", bold(str(args.output)))
def compile(
config: Dict[str, Any], # noqa: UP006
quantization: Quantization,
model_type: Model,
target: Target,
opt: OptimizationFlags,
build_func: Callable[[IRModule, CompileArgs, Pass], None],
system_lib_prefix: str,
output: Path,
overrides: ModelConfigOverride,
debug_dump: Optional[Path] = None,
):
"""Compile a model given its configuration and quantization format to a specific target."""
avs = None
if "active_vocab_size" in config:
avs = config.pop("active_vocab_size")
logger.info("Active vocab size from input config: %s", str(avs))
if "model_config" in config:
model_config = config.pop("model_config")
model_config.update(config)
model_config = model_type.config.from_dict(model_config)
else:
model_config = model_type.config.from_dict(config)
model_config.kwargs = {"active_vocab_size": avs} if avs is not None else {}
args = CompileArgs(
model_config,
quantization,
model_type,
target,
opt,
build_func,
system_lib_prefix,
output,
overrides,
debug_dump,
)
args.display()
_compile(args, model_config)
+227
View File
@@ -0,0 +1,227 @@
"""Flags for overriding model config."""
import dataclasses
import enum
from io import StringIO
from typing import Optional
from mlc_llm.support import argparse, logging
from mlc_llm.support.config import ConfigOverrideBase
logger = logging.getLogger(__name__)
class IPCAllReduceStrategyType(enum.IntEnum):
"""The all-reduce strategy."""
NONE = 0
ONESHOT = 1
TWOSHOT = 2
AUTO = 3
@dataclasses.dataclass
class OptimizationFlags:
"""Optimization flags"""
flashinfer: bool = False
cublas_gemm: bool = False
faster_transformer: bool = False
cudagraph: bool = False
cutlass: bool = False
ipc_allreduce_strategy: IPCAllReduceStrategyType = IPCAllReduceStrategyType.NONE
def __repr__(self) -> str:
out = StringIO()
print(f"flashinfer={int(self.flashinfer)}", file=out, end="")
print(f";cublas_gemm={int(self.cublas_gemm)}", file=out, end="")
print(f";faster_transformer={int(self.faster_transformer)}", file=out, end="")
print(f";cudagraph={int(self.cudagraph)}", file=out, end="")
print(f";cutlass={int(self.cutlass)}", file=out, end="")
print(
f";ipc_allreduce_strategy={self.ipc_allreduce_strategy.name}",
file=out,
end="",
)
return out.getvalue().rstrip()
@staticmethod
def from_str(source: str) -> "OptimizationFlags":
"""Parse optimization flags from a string."""
if source in OPT_FLAG_PRESET:
return OPT_FLAG_PRESET[source]
def boolean(value: str) -> bool:
if value == "0":
return False
if value == "1":
return True
raise ValueError(f"Invalid boolean value: {value}")
parser = argparse.ArgumentParser(description="optimization flags")
parser.add_argument("--flashinfer", type=boolean, default=True)
parser.add_argument("--cublas_gemm", type=boolean, default=False)
parser.add_argument("--faster_transformer", type=boolean, default=False)
parser.add_argument("--cudagraph", type=boolean, default=False)
parser.add_argument("--cutlass", type=boolean, default=False)
parser.add_argument(
"--ipc_allreduce_strategy",
type=str,
choices=["NONE", "ONESHOT", "TWOSHOT", "AUTO"],
default="NONE",
)
results = parser.parse_args([f"--{i}" for i in source.split(";") if i])
return OptimizationFlags(
flashinfer=results.flashinfer,
cublas_gemm=results.cublas_gemm,
faster_transformer=results.faster_transformer,
cudagraph=results.cudagraph,
cutlass=results.cutlass,
ipc_allreduce_strategy=IPCAllReduceStrategyType[results.ipc_allreduce_strategy],
)
def update(self, target, quantization) -> None:
"""Update optimization flags based on additional information."""
def _flashinfer(target) -> bool:
from mlc_llm.support.auto_target import (
detect_cuda_arch_list,
)
if not self.flashinfer:
return False
if target.kind.name != "cuda":
return False
arch_list = detect_cuda_arch_list(target)
for arch in arch_list:
if arch < 80:
logger.warning("flashinfer is not supported on CUDA arch < 80")
return False
return True
def _cublas_gemm(target, quantization) -> bool:
"""correct cublas_gemm flag"""
if target.kind.name not in ["cuda", "rocm"]:
return False
if not (
quantization.name in ["q0f16", "q0bf16", "q0f32"]
or "e4m3" in quantization.name
or "e5m2" in quantization.name
):
return False
return self.cublas_gemm
def _faster_transformer(target) -> bool:
"""correct faster_transformer flag"""
if not target.kind.name == "cuda":
return False
return self.faster_transformer
def _cutlass(target) -> bool:
"""correct cutlass flag"""
if not target.kind.name == "cuda":
return False
return self.cutlass
def _cudagraph(target) -> bool:
"""correct cudagraph flag"""
if not target.kind.name == "cuda":
return False
return self.cudagraph
self.flashinfer = _flashinfer(target)
self.cublas_gemm = _cublas_gemm(target, quantization)
self.faster_transformer = _faster_transformer(target)
self.cutlass = _cutlass(target)
self.cudagraph = _cudagraph(target)
@dataclasses.dataclass
class ModelConfigOverride(ConfigOverrideBase):
"""Flags for overriding model config."""
context_window_size: Optional[int] = None
sliding_window_size: Optional[int] = None
prefill_chunk_size: Optional[int] = None
attention_sink_size: Optional[int] = None
max_batch_size: Optional[int] = None
tensor_parallel_shards: Optional[int] = None
pipeline_parallel_stages: Optional[int] = None
disaggregation: Optional[bool] = None
def __repr__(self) -> str:
out = StringIO()
print(f"context_window_size={self.context_window_size}", file=out, end="")
print(f";sliding_window_size={self.sliding_window_size}", file=out, end="")
print(f";prefill_chunk_size={self.prefill_chunk_size}", file=out, end="")
print(f";attention_sink_size={self.attention_sink_size}", file=out, end="")
print(f";max_batch_size={self.max_batch_size}", file=out, end="")
print(f";tensor_parallel_shards={self.tensor_parallel_shards}", file=out, end="")
print(
f";pipeline_parallel_stages={self.pipeline_parallel_stages}",
file=out,
end="",
)
print(f";disaggregation={self.disaggregation}", file=out, end="")
return out.getvalue().rstrip()
@staticmethod
def from_str(source: str) -> "ModelConfigOverride":
"""Parse model config override values from a string."""
parser = argparse.ArgumentParser(description="model config override values")
parser.add_argument("--context_window_size", type=int, default=None)
parser.add_argument("--sliding_window_size", type=int, default=None)
parser.add_argument("--prefill_chunk_size", type=int, default=None)
parser.add_argument("--attention_sink_size", type=int, default=None)
parser.add_argument("--max_batch_size", type=int, default=None)
parser.add_argument("--tensor_parallel_shards", type=int, default=None)
parser.add_argument("--pipeline_parallel_stages", type=int, default=None)
parser.add_argument(
"--disaggregation",
type=lambda x: str(x).lower() in ["true", "1", "yes", "True"],
default=None,
)
results = parser.parse_args([f"--{i}" for i in source.split(";") if i])
return ModelConfigOverride(
context_window_size=results.context_window_size,
sliding_window_size=results.sliding_window_size,
prefill_chunk_size=results.prefill_chunk_size,
attention_sink_size=results.attention_sink_size,
max_batch_size=results.max_batch_size,
tensor_parallel_shards=results.tensor_parallel_shards,
pipeline_parallel_stages=results.pipeline_parallel_stages,
disaggregation=results.disaggregation,
)
OPT_FLAG_PRESET = {
"O0": OptimizationFlags(
flashinfer=False,
cublas_gemm=False,
cudagraph=False,
),
"O1": OptimizationFlags(
flashinfer=False,
cublas_gemm=True,
faster_transformer=True,
cudagraph=False,
cutlass=True,
),
"O2": OptimizationFlags(
flashinfer=True,
cublas_gemm=True,
faster_transformer=False,
cudagraph=True,
cutlass=True,
ipc_allreduce_strategy=IPCAllReduceStrategyType.NONE,
),
"O3": OptimizationFlags(
flashinfer=True,
cublas_gemm=True,
faster_transformer=True,
cudagraph=True,
cutlass=True,
ipc_allreduce_strategy=IPCAllReduceStrategyType.AUTO,
),
}
+250
View File
@@ -0,0 +1,250 @@
"""Python entrypoint of weight conversion."""
import contextlib
import dataclasses
import math
import os
import tempfile
from collections.abc import Iterator
from io import StringIO
from pathlib import Path
from typing import Any, Dict, Optional, Tuple # noqa: UP035
from tvm import tirx
from tvm.contrib import tvmjs
from tvm.runtime import DataType, Device, Tensor
from tvm.runtime import cpu as cpu_device
from tvm.target import Target
from mlc_llm.loader import LOADER
from mlc_llm.model import Model
from mlc_llm.quantization import Quantization
from mlc_llm.support import logging, tqdm
from mlc_llm.support.auto_weight import detect_weight
from mlc_llm.support.preshard import apply_preshard
from mlc_llm.support.style import bold, green
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class ConversionArgs:
"""Arguments to MLC LLM's weight conversation and quantization flow."""
config: Path
quantization: Quantization
model: Model
device: Device
source: Path
source_format: str
output: Path
lora_adapter: Optional[Path] = None
def display(self) -> None:
"""Display the arguments to stdout."""
def _device_to_str(device: Device) -> str:
return f"{Device._DEVICE_TYPE_TO_NAME[device.dlpack_device_type()]}:{device.index}"
out = StringIO()
print(f"{bold('Weight conversion with arguments:')}", file=out)
print(f" {bold('--config'):<25} {self.config}", file=out)
print(f" {bold('--quantization'):<25} {self.quantization}", file=out)
print(f" {bold('--model-type'):<25} {self.model.name}", file=out)
print(f" {bold('--device'):<25} {_device_to_str(self.device)}", file=out)
print(f" {bold('--source'):<25} {self.source}", file=out)
print(f" {bold('--source-format'):<25} {self.source_format}", file=out)
print(f" {bold('--output'):<25} {self.output}", file=out)
if self.lora_adapter is not None:
print(f" {bold('--lora-adapter'):<25} {self.lora_adapter}", file=out)
print(out.getvalue().rstrip())
def _resolve_base_model_dir(source: Path) -> Path:
return source if source.is_dir() else source.parent
@contextlib.contextmanager
def _merge_lora_adapter_with_base_model(base_source: Path, lora_adapter: Path) -> Iterator[Path]:
base_model_dir = _resolve_base_model_dir(base_source)
if not base_model_dir.exists():
raise ValueError(f"Base model directory does not exist: {base_model_dir}")
if not lora_adapter.exists() or not lora_adapter.is_dir():
raise ValueError(f"LoRA adapter directory does not exist: {lora_adapter}")
try:
from peft import PeftModel
from transformers import AutoModelForCausalLM
except ImportError as err:
raise ImportError(
"`--lora-adapter` requires `peft` and `transformers` to be installed."
) from err
with tempfile.TemporaryDirectory() as temp_dir:
merged_model_dir = Path(temp_dir) / "merged_model"
logger.info("Merging LoRA adapter %s into base model %s", lora_adapter, base_model_dir)
base_model = AutoModelForCausalLM.from_pretrained(
str(base_model_dir),
torch_dtype="auto",
trust_remote_code=False,
low_cpu_mem_usage=True,
)
merged_model = PeftModel.from_pretrained(
base_model, str(lora_adapter), is_trainable=False
).merge_and_unload()
merged_model.save_pretrained(str(merged_model_dir), safe_serialization=True)
yield merged_model_dir
def _convert_args(args: ConversionArgs) -> None:
pre_shards_num = os.getenv("MLC_INTERNAL_PRESHARD_NUM")
# model config & quantization config
model_config = args.model.config.from_file(args.config)
if (
args.quantization.kind == "ft-quant"
and hasattr(model_config, "tensor_parallel_shards")
and model_config.tensor_parallel_shards > 1
):
raise NotImplementedError
if pre_shards_num is not None:
model_config.tensor_parallel_shards = int(pre_shards_num)
model, quantize_map = args.model.quantize[args.quantization.kind](
model_config, args.quantization
)
_, _named_params, _ = model.export_tvm(
spec=model.get_default_spec(),
allow_extern=True,
)
named_params = dict(_named_params)
if pre_shards_num is not None:
named_params, preshard_funcs = apply_preshard(named_params, int(pre_shards_num), args)
else:
preshard_funcs = None
def _check_param(name: str, param: Tensor):
nonlocal named_params
if name not in named_params:
raise ValueError(f"Parameter not found in model: {name}")
if name in param_names:
raise ValueError(f"Duplication: Parameter {name} already computed")
# Check shape (possibly dynamic)
def _check_shape(actual: tuple, expect: tuple): # expect can have tirx.Var
if len(actual) != len(expect):
return False
for actual_i, expect_i in zip(actual, expect):
assert isinstance(expect_i, (int, tirx.Var))
if isinstance(expect_i, int) and actual_i != expect_i:
return False
return True
expect_shape = named_params[name].shape
actual_shape = param.shape
if not _check_shape(actual_shape, expect_shape):
raise ValueError(
f"Parameter {name} has shape {param.shape}, but expected {expect_shape}"
)
# Check dtype
actual_dtype = param.dtype
expect_dtype = named_params[name].dtype
if actual_dtype != expect_dtype:
raise ValueError(
f"Parameter {name} has dtype {param.dtype}, but expected {expect_dtype}"
)
del named_params[name]
# load and quantize
param_names = set()
total_bytes = 0.0
total_params: int = 0
def _param_generator() -> Iterator[Tuple[str, Tensor]]: # noqa: UP006
nonlocal total_params, total_bytes
with Target.from_device(args.device), tqdm.redirect():
loader = LOADER[args.source_format](
path=args.source,
extern_param_map=args.model.source[args.source_format](
model_config, args.quantization
),
quantize_param_map=quantize_map,
)
for name, param in loader.load(device=args.device, preshard_funcs=preshard_funcs):
_check_param(name, param)
param_names.add(name)
param = param.copyto(cpu_device())
total_bytes += math.prod(param.shape) * DataType(param.dtype).itemsize
yield name, param
total_params = loader.stats.total_param_num
def _metadata_callback() -> Dict[str, Any]: # noqa: UP006
return {
"ParamSize": len(param_names),
"ParamBytes": total_bytes,
"BitsPerParam": total_bytes * 8.0 / total_params,
}
# dump to output directory
tvmjs.dump_tensor_cache(
_param_generator(),
str(args.output),
meta_data=_metadata_callback,
encode_format="f32-to-bf16",
show_progress=False,
)
if named_params:
raise ValueError(f"Parameter not found in source: {', '.join(named_params.keys())}")
# Log necessary statistics
logger.info(
"%s after quantization: %.3f GB",
green("Parameter size"),
total_bytes / (1024**3),
)
logger.info(f"%s: {total_params:,}", green("Total parameters"))
logger.info(
"%s: %.3f",
green("Bits per parameter"),
total_bytes * 8.0 / total_params,
)
logger.info("Saved to directory: %s", bold(str(args.output)))
def convert_weight(
config: Path,
quantization: Quantization,
model: Model,
device: Device,
source: Path,
source_format: str,
output: Path,
lora_adapter: Optional[Path] = None,
):
"""MLC LLM's weight conversation and quantization flow."""
args = ConversionArgs(
config, quantization, model, device, source, source_format, output, lora_adapter
)
allowed_lora_source_formats = {"huggingface-safetensor", "huggingface-torch"}
if lora_adapter is not None and source_format not in allowed_lora_source_formats:
raise ValueError(
f"`--lora-adapter` only supports source formats: {sorted(allowed_lora_source_formats)}"
)
if lora_adapter is not None:
with _merge_lora_adapter_with_base_model(source, lora_adapter) as merged_model_dir:
merged_source, merged_source_format = detect_weight(
weight_path=merged_model_dir,
config_json_path=config,
weight_format="auto",
)
merged_args = dataclasses.replace(
args, source=merged_source, source_format=merged_source_format
)
merged_args.display()
_convert_args(merged_args)
return
args.display()
_convert_args(args)
+359
View File
@@ -0,0 +1,359 @@
"""Generator of mlc-chat-config.json and tokenizer configuration."""
import dataclasses
import json
import re
import shutil
from dataclasses import asdict
from pathlib import Path
from typing import Optional
from mlc_llm.conversation_template import ConvTemplateRegistry
from mlc_llm.model import Model
from mlc_llm.protocol.mlc_chat_config import MLCChatConfig
from mlc_llm.quantization import Quantization
from mlc_llm.support import convert_tiktoken, logging
from mlc_llm.support.style import bold, green, red
from mlc_llm.tokenizers import Tokenizer
from .compiler_flags import ModelConfigOverride
logger = logging.getLogger(__name__)
FOUND = green("Found")
NOT_FOUND = red("Not found")
FAILED = red("Failed")
def apply_system_defaults_for_missing_fields(mlc_chat_config: MLCChatConfig) -> None:
"""Apply system default value."""
for key, value in mlc_chat_config.get_system_defaults_for_missing_fields().items():
setattr(mlc_chat_config, key, value)
logger.info("[System default] Setting %s: %s", bold(key), value)
def check_string(s: str) -> bool:
"""Check whether it's a string."""
s = s[1:] if s[0] == "b" else s
delimit = s[0]
if s[-1] != delimit or delimit not in ["'", '"']:
return False
for i in range(1, len(s) - 1):
if s[i] == delimit and s[i - 1] != "\\":
return False
return True
def txt2rwkv_tokenizer(vocab: Path, out: Path) -> None:
"""Generate tokenizer_model from RWKV vocab file."""
idx2token = {}
with vocab.open("r", encoding="utf-8") as f:
lines = f.readlines()
for line in lines:
idx = int(line[: line.index(" ")])
raw = line[line.index(" ") : line.rindex(" ")].strip()
if check_string(raw):
x = eval(raw)
x = x.encode("utf-8") if isinstance(x, str) else x
assert isinstance(x, bytes)
assert len(x) == int(line[line.rindex(" ") :])
idx2token[idx] = x
else:
raise ValueError("Unsupported vocab dictionary")
with (out / "tokenizer_model").open("wb") as f:
import msgpack
msgpack.pack(idx2token, f)
def json2rwkv_tokenizer(vocab: Path, out: Path) -> None:
"""Generate tokenizer_model from RWKV vocab file."""
idx2token = {}
with vocab.open("r", encoding="utf-8") as f:
data = json.load(f)
for key, value in data.items():
x = key.encode("utf-8") if isinstance(key, str) else key
assert isinstance(x, bytes)
idx2token[int(value)] = x
with (out / "tokenizer_model").open("wb") as f:
import msgpack
msgpack.pack(idx2token, f)
def gen_config(
config: Path,
model: Model,
quantization: Quantization,
conv_template: str,
context_window_size: Optional[int],
sliding_window_size: Optional[int],
prefill_chunk_size: Optional[int],
attention_sink_size: Optional[int],
tensor_parallel_shards: Optional[int],
pipeline_parallel_stages: Optional[int],
disaggregation: Optional[bool],
max_batch_size: int,
output: Path,
):
"""Entrypoint of MLC Chat configuration generation."""
# Step 1. Initialize `mlc-chat-config.json` using `config.json`
conversation_reg = ConvTemplateRegistry.get_conv_template(conv_template)
if conversation_reg is None:
logger.warning(
"%s: Conversation template is not registered in ConvTemplateRegistry: %s",
red("Warning"),
conv_template,
)
conversation = conv_template
else:
conversation = conversation_reg.to_json_dict()
model_config = ModelConfigOverride(
context_window_size=context_window_size,
sliding_window_size=sliding_window_size,
prefill_chunk_size=prefill_chunk_size,
attention_sink_size=attention_sink_size,
max_batch_size=max_batch_size,
tensor_parallel_shards=tensor_parallel_shards,
pipeline_parallel_stages=pipeline_parallel_stages,
disaggregation=disaggregation,
).apply(model.config.from_file(config))
mlc_chat_config = MLCChatConfig(
model_type=model.name,
quantization=quantization.name,
model_config=model_config.asdict(),
vocab_size=model_config.vocab_size,
active_vocab_size=getattr(model_config, "active_vocab_size", model_config.vocab_size),
context_window_size=getattr(model_config, "context_window_size", -1),
sliding_window_size=getattr(model_config, "sliding_window_size", -1),
prefill_chunk_size=model_config.prefill_chunk_size,
attention_sink_size=getattr(model_config, "attention_sink_size", -1),
tensor_parallel_shards=model_config.tensor_parallel_shards,
pipeline_parallel_stages=getattr(model_config, "pipeline_parallel_stages", 1),
disaggregation=getattr(model_config, "disaggregation", False),
conv_template=conversation,
model_task=model.model_task,
embedding_metadata=(
dataclasses.asdict(model.embedding_metadata) if model.embedding_metadata else None
),
)
# Step 2. Load `generation_config.json` and `config.json` for text-generation related configs
for generation_config_filename in ["generation_config.json", "config.json"]:
generation_config = config.parent / generation_config_filename
if generation_config.exists():
with generation_config.open("r", encoding="utf-8") as in_file:
generation_config_json = json.load(in_file)
for key, value in generation_config_json.items():
if hasattr(mlc_chat_config, key) and getattr(mlc_chat_config, key) is None:
setattr(mlc_chat_config, key, value)
logger.info(
"[%s] Setting %s: %s",
generation_config_filename,
bold(key),
value,
)
else:
logger.info("%s %s: %s", NOT_FOUND, generation_config_filename, generation_config)
# Step 3. Copy tokenizer configuration
# 3.1. Copy over the files and populate mlc_chat_config
for filename in TOKENIZER_FILES:
file = config.parent / filename
if file.exists():
mlc_chat_config.tokenizer_files.append(filename)
dest = output / filename
shutil.copy(file, dest)
logger.info("%s tokenizer config: %s. Copying to %s", FOUND, file, bold(str(dest)))
else:
logger.info("%s tokenizer config: %s", NOT_FOUND, file)
# 3.2. Generate `tokenizer_model` for rwkv if `rwkv_vocab_.*` is found
pattern = re.compile(r"rwkv_vocab_v\d{8}\.(json|txt)")
for item in config.parent.iterdir():
if item.is_file() and pattern.match(item.name):
logger.info(
"%s RWKV vocab file: %s. Genetating %s",
FOUND,
item,
bold("tokenizer_model"),
)
if item.name.endswith(".txt"):
txt2rwkv_tokenizer(item, output)
else:
json2rwkv_tokenizer(item, output)
# 3.3. If we have `tokenizer.model` but not `tokenizer.json`, try convert it to
# `tokenizer.json` with `transformers`.
tokenizer_json_file = config.parent / "tokenizer.json"
tokenizer_model_file = config.parent / "tokenizer.model"
if tokenizer_model_file.exists() and (not tokenizer_json_file.exists()):
logger.info(
"The model has `tokenizer.model` but not `tokenizer.json`. "
"It is always recommended to prefer JSON instead. "
"Attempting to convert using HuggingFace transformers library"
)
try:
from transformers import (
AutoTokenizer,
)
tokenizer_json_save_dest = output / "tokenizer.json"
fast_tokenizer = AutoTokenizer.from_pretrained(str(config.parent), use_fast=True)
fast_tokenizer.backend_tokenizer.save(str(tokenizer_json_save_dest))
mlc_chat_config.tokenizer_files.append("tokenizer.json")
logger.info(
"Successfully converted `tokenizer.model` to: %s",
tokenizer_json_save_dest,
)
except Exception:
logger.warning(
"Converting to `tokenizer.json` %s with the exception below. "
"Skipping the conversion.",
FAILED,
exc_info=True,
)
# 3.3. If we still don't have "tokenizer.json" at this point, try looking for "*.tiktoken" files
if (not tokenizer_json_file.exists()) and list(config.parent.glob("*.tiktoken")):
try:
logger.info(
"The model has tiktoken files but not `tokenizer.json`. "
"Attempting to convert from tiktoken files"
)
convert_tiktoken.convert_tiktoken(
str(config.parent), str(output), mlc_chat_config.context_window_size
)
mlc_chat_config.tokenizer_files.append("tokenizer.json")
mlc_chat_config.tokenizer_files.append("vocab.json")
mlc_chat_config.tokenizer_files.append("merges.txt")
mlc_chat_config.tokenizer_files.append("special_tokens_map.json")
logger.info("Succesfully converted from tiktoken files to: %s", str(output))
except Exception:
logger.exception("%s with the exception below. Skipping", FAILED)
# 3.4. Detect tokenizer info
mlc_chat_config.tokenizer_info = asdict(Tokenizer.detect_tokenizer_info(str(output)))
logger.info("Detected tokenizer info: %s", mlc_chat_config.tokenizer_info)
# 3.5. Ensure added_tokens do not have duplicated added_tokens, a mistake from model releaser
# that affects correctness of huggingface tokenizer.
# See https://huggingface.co/NousResearch/Hermes-2-Pro-Llama-3-8B/discussions/15.
if tokenizer_json_file.exists():
with open(tokenizer_json_file, encoding="utf-8") as f:
tokenizer_json = json.load(f)
if "added_tokens" in tokenizer_json:
appeared_content = set()
for added_token in tokenizer_json["added_tokens"]:
content = added_token["content"]
if content in appeared_content:
logger.exception(
"%s with incorrect tokenizer.json which has duplicated token %s. "
"This affects correctness of huggingface tokenizer during runtime, "
"please check your tokenizer.json to remove duplication manually.",
FAILED,
content,
)
raise ValueError("Duplicated vocab in tokenizer.json")
appeared_content.add(content)
# Step 4. Load system default value
apply_system_defaults_for_missing_fields(mlc_chat_config)
# Step 5. Use HF tokenizer to detect active vocab size via len(tokenizer)
if tokenizer_json_file.exists():
try:
from transformers import (
AutoTokenizer,
)
hf_tokenizer = AutoTokenizer.from_pretrained(str(config.parent), use_fast=True)
active_vocab_size = len(hf_tokenizer)
if mlc_chat_config.active_vocab_size != active_vocab_size:
logger.info(
"Overriding active_vocab_size from %d to %d using HF tokenizer",
mlc_chat_config.active_vocab_size,
active_vocab_size,
)
mlc_chat_config.active_vocab_size = active_vocab_size
except Exception:
logger.warning(
"Detecting active_vocab_size %s with the exception below. Skipping.",
FAILED,
exc_info=True,
)
# Step 5. Dump the configuration file to output directory
with (output / "mlc-chat-config.json").open("w", encoding="utf-8") as out_file:
json.dump(mlc_chat_config.model_dump(by_alias=True), out_file, indent=2)
logger.info("Dumping configuration file to: %s", bold(out_file.name))
TOKENIZER_FILES = [
"tokenizer.model",
"tokenizer.json",
"vocab.json",
"merges.txt",
"added_tokens.json",
"tokenizer_config.json",
]
# FIXME: Copy RWKV tokenizer file
CONV_TEMPLATES = {
"llama-4",
"llama-3",
"llama-3_1",
"chatml",
"chatml_nosystem",
"qwen2",
"open_hermes_mistral",
"neural_hermes_mistral",
"llama_default",
"llama-2",
"mistral_default",
"ministral3",
"ministral3_reasoning",
"gpt2",
"codellama_completion",
"codellama_instruct",
"redpajama_chat",
"rwkv_world",
"gorilla",
"gorilla-openfunctions-v2",
"dolly",
"oasst",
"stablelm",
"LM",
"stablelm-3b",
"gpt_bigcode",
"wizardlm_7b",
"wizard_coder_or_math",
"glm",
"phi-2",
"phi-3",
"phi-3-vision",
"phi-4",
"stablelm-2",
"gemma_instruction",
"gemma3_instruction",
"orion",
"llava",
"hermes2_pro_llama3",
"hermes3_llama-3_1",
"tinyllama_v1_0",
"aya-23",
"deepseek",
"deepseek_v2",
"deepseek_v3",
"deepseek_r1_qwen",
"deepseek_r1_llama",
"olmo",
"olmo2",
"nemotron",
"llm-jp",
"qwen3",
"qwen3_5",
"qwen3_5_nothink",
}
+273
View File
@@ -0,0 +1,273 @@
"""Help message for CLI arguments."""
HELP = {
"config": (
"""
1) Path to a HuggingFace model directory that contains a `config.json` or
2) Path to `config.json` in HuggingFace format, or
3) The name of a pre-defined model architecture.
A `config.json` file in HuggingFace format defines the model architecture, including the vocabulary
size, the number of layers, the hidden size, number of attention heads, etc.
Example: https://huggingface.co/codellama/CodeLlama-7b-hf/blob/main/config.json.
A HuggingFace directory often contains a `config.json` which defines the model architecture,
the non-quantized model weights in PyTorch or SafeTensor format, tokenizer configurations,
as well as an optional `generation_config.json` provides additional default configuration for
text generation.
Example: https://huggingface.co/codellama/CodeLlama-7b-hf/tree/main.
"""
).strip(),
"quantization": """
The quantization mode we use to compile. If unprovided, will infer from `model`.
""".strip(),
"model": """
A path to ``mlc-chat-config.json``, or an MLC model directory that contains `mlc-chat-config.json`.
It can also be a link to a HF repository pointing to an MLC compiled model.
""".strip(),
"model_lib": """
The full path to the model library file to use (e.g. a ``.so`` file). If unspecified, we will use
the provided ``model`` to search over possible paths. It the model lib is not found, it will be
compiled in a JIT manner.
""".strip(),
"model_type": """
Model architecture such as "llama". If not set, it is inferred from `mlc-chat-config.json`.
""".strip(),
"device_compile": """
The GPU device to compile the model to. If not set, it is inferred from GPUs available locally.
""".strip(),
"enable_subgroups": """
Enable WebGPU subgroups in codegen. This only applies to WebGPU targets and will set
supports_subgroups accordingly.
""".strip(),
"device_quantize": """
The device used to do quantization such as "cuda" or "cuda:0". Will detect from local available GPUs
if not specified.
""".strip(),
"device_deploy": """
The device used to deploy the model such as "cuda" or "cuda:0". Will detect from local
available GPUs if not specified.
""".strip(),
"host": """
The host LLVM triple to compile the model to. If not set, it is inferred from the local CPU and OS.
Examples of the LLVM triple:
1) iPhones: arm64-apple-ios;
2) ARM64 Android phones: aarch64-linux-android;
3) WebAssembly: wasm32-unknown-unknown-wasm;
4) Windows: x86_64-pc-windows-msvc;
5) ARM macOS: arm64-apple-darwin.
""".strip(),
"opt": """
Optimization flags. 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.
--opt="cublas_gemm=1;cudagraph=0".
""".strip(),
"system_lib_prefix": """
Adding a prefix to all symbols exported. Similar to "objcopy --prefix-symbols".
This is useful when compiling multiple models into a single library to avoid symbol
conflicts. Different from objcopy, this takes no effect for shared library.
""".strip(),
"context_window_size": """
Option to provide the maximum sequence length supported by the model.
This is usually explicitly shown as context length or context window in the model card.
If this option is not set explicitly, by default,
it will be determined by `context_window_size` or `max_position_embeddings` in `config.json`,
and the latter is usually inaccurate for some models.
""".strip(),
"output_compile": """
The path to the output file. The suffix determines if the output file is a shared library or
objects. Available suffixes:
1) Linux: .so (shared), .tar (objects);
2) macOS: .dylib (shared), .tar (objects);
3) Windows: .dll (shared), .tar (objects);
4) Android, iOS: .tar (objects);
5) Web: .wasm (web assembly).
""".strip(),
"source": """
The path to original model weight, infer from `config` if missing.
""".strip(),
"source_format": """
The format of source model weight, infer from `config` if missing.
""".strip(),
"output_quantize": """
The output directory to save the quantized model weight. Will create `params_shard_*.bin` and
`tensor-cache.json` in this directory.
""".strip(),
"conv_template": """
Conversation template. It depends on how the model is tuned. Use "LM" for vanilla base model
""".strip(),
"output_gen_mlc_chat_config": """
The output directory for generated configurations, including `mlc-chat-config.json` and tokenizer
configuration.
""".strip(),
"sliding_window_size": """
(Experimental) The sliding window size in sliding window attention (SWA).
This optional field overrides the `sliding_window_size` in config.json for
those models that use SWA. Currently only useful when compiling Mistral.
This flag subjects to future refactoring.
""".strip(),
"prefill_chunk_size": """
(Experimental) The chunk size during prefilling. By default,
the chunk size is the same as sliding window or max sequence length.
This flag subjects to future refactoring.
""".strip(),
"attention_sink_size": """
(Experimental) The number of stored sinks. Only supported on Mistral yet. By default,
the number of sinks is 4. This flag subjects to future refactoring.
""".strip(),
"max_batch_size": """
The maximum allowed batch size set for the KV cache to concurrently support.
""".strip(),
"""tensor_parallel_shards""": """
Number of shards to split the model into in tensor parallelism multi-gpu inference.
""".strip(),
"""pipeline_parallel_stages""": """
Number of pipeline stages to split the model layers for pipeline parallelism.
""".strip(),
"""disaggregation""": """
Whether enable disaggregation when compiling the model.
""".strip(),
"overrides": """
Model configuration override. Configurations to override `mlc-chat-config.json`. Supports
`context_window_size`, `prefill_chunk_size`, `sliding_window_size`, `attention_sink_size`,
`max_batch_size` and `tensor_parallel_shards`. Meanwhile, model config could be explicitly
specified via details knobs, e.g. --overrides "context_window_size=1024;prefill_chunk_size=128".
""".strip(),
"modelconfig_overrides": """
Model configuration override. Supports overriding,
`context_window_size`, `prefill_chunk_size`, `sliding_window_size`, `attention_sink_size`,
`max_num_sequence` and `tensor_parallel_shards`. The overrides could be explicitly
specified via details knobs, e.g. --overrides "context_window_size=1024;prefill_chunk_size=128".
""".strip(),
"debug_dump": """
Specifies the directory where the compiler will store its IRs for debugging purposes
during various phases of compilation. By default, this is set to `None`, indicating
that debug dumping is disabled.
""".strip(),
"prompt": """
The prompt of the text generation.
""".strip(),
"generate_length": """
The target length of the text generation.
""".strip(),
"max_total_sequence_length_serve": """
The KV cache total token capacity, i.e., the maximum total number of tokens that
the KV cache support. This decides the GPU memory size that the KV cache consumes.
If not specified, system will automatically estimate the maximum capacity based
on the vRAM size on GPU.
""".strip(),
"prefill_chunk_size_serve": """
The maximum number of tokens the model passes for prefill each time.
It should not exceed the prefill chunk size in model config.
If not specified, this defaults to the prefill chunk size in model config.
""".strip(),
"max_history_size_serve": """
The maximum history length for rolling back the RNN state.
If unspecified, the default value is 1.
KV cache does not need this.
""".strip(),
"enable_tracing_serve": """
Enable Chrome Tracing for the server.
After enabling, you can send POST request to the "debug/dump_event_trace" entrypoint
to get the Chrome Trace. For example,
"curl -X POST http://127.0.0.1:8000/debug/dump_event_trace -H "Content-Type: application/json" -d '{"model": "dist/llama"}'"
""".strip(), # noqa: E501
"mode_serve": """
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_seq_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_seq_length" and
"prefill_chunk_size" via "--overrides" to override the automatic inferred values.
For example: --overrides "max_num_sequence=32;max_total_seq_length=4096"
""".strip(),
"additional_models_serve": """
The model paths and (optional) model library paths of additional models (other than the main model).
When engine is enabled with speculative decoding, additional models are needed.
The way of specifying additional models is:
"--additional-models model_path_1 model_path_2 ..." or
"--additional-models model_path_1,model_lib_1 model_path_2 ...".
When the model lib of a model is not given, JIT model compilation will be activated
to compile the model automatically.
""".strip(),
"gpu_memory_utilization_serve": """
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.
""".strip(),
"speculative_mode_serve": """
The speculative decoding mode. Right now four options are supported:
- "disable", where speculative decoding is not enabled,
- "small_draft", denoting the normal speculative decoding (small draft) style,
- "eagle", denoting the eagle-style speculative decoding.
- "medusa", denoting the medusa-style speculative decoding.
The default mode is "disable".
""".strip(),
"spec_draft_length_serve": """
The number of draft tokens to generate in speculative proposal.
Being 0 means to enable adaptive speculative mode, where the draft length will be
automatically adjusted based on engine state. The default values is 0.
""".strip(),
"prefix_cache_mode_serve": """
The prefix cache mode. Right now two options are supported:
- "disable", where prefix cache is not enabled,
- "radix", denoting the normal paged radix tree based prefix cache,
The default mode is "radix".
""".strip(),
"prefix_cache_max_num_recycling_seqs_serve": """
The maximum number of sequences in prefix cache, default as max_batch_size.
And set 0 to disable prefix cache, set -1 to have infinite capacity prefix cache.
""".strip(),
"prefill_mode": """
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.
""".strip(),
"overrides_serve": """
Overriding extra configurable fields of EngineConfig and model compilation config.
Supporting fields that can be be overridden: "tensor_parallel_shards", "max_num_sequence",
"max_total_seq_length", "prefill_chunk_size", "max_history_size", "gpu_memory_utilization",
"spec_draft_length", "prefix_cache_max_num_recycling_seqs", "context_window_size",
"sliding_window_size", "attention_sink_size".
Please check out the documentation of EngineConfig in mlc_llm/serve/config.py for detailed docstring
of each field.
Example: --overrides "max_num_sequence=32;max_total_seq_length=4096;tensor_parallel_shards=2"
""".strip(),
"config_package": """
The path to "mlc-package-config.json" which is used for package build.
See "https://github.com/mlc-ai/mlc-llm/blob/main/ios/MLCChat/mlc-package-config.json" as an example.
""".strip(),
"mlc_llm_source_dir": """
The source code path to MLC LLM.
""".strip(),
"output_package": """
The path of output directory for the package build outputs.
""".strip(),
"calibration_dataset": """
The path to the calibration dataset.
""".strip(),
"num_calibration_samples": """
The number of samples used for calibration.
""".strip(),
"output_calibration": """
The output directory to save the calibration params.
""".strip(),
"seed_calibrate": """
The seed to sample the calibration dataset.""",
"pd_balance_factor": """
How much prefill to move to decode engine. For example,
0.1 means the last 10 percent tokens are prefilled by decode engine.
""".strip(),
}
+181
View File
@@ -0,0 +1,181 @@
"""Just-in-time compilation of MLC-Chat models."""
import dataclasses
import hashlib
import json
import os
import shlex
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Dict, Optional, Union # noqa: UP035
from tvm.runtime import Device
from mlc_llm.model import MODELS
from mlc_llm.support import logging
from mlc_llm.support.auto_device import device2str
from mlc_llm.support.constants import (
MLC_DSO_SUFFIX,
MLC_JIT_POLICY,
MLC_LLM_HOME,
MLC_TEMP_DIR,
)
from mlc_llm.support.style import blue, bold
from .compiler_flags import ModelConfigOverride, OptimizationFlags
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class JITResult:
"""The jit compilation result class."""
model_lib_path: str
system_lib_prefix: Optional[str] = None
def log_jit_policy():
"""log current jit policy"""
logger.info(
"%s = %s. Can be one of: ON, OFF, REDO, READONLY",
bold("MLC_JIT_POLICY"),
MLC_JIT_POLICY,
)
def jit(
model_path: Path,
overrides: Dict[str, Any], # noqa: UP006
device: Union[Device, str],
system_lib_prefix: Optional[str] = None,
*,
skip_log_jit_policy=False,
) -> JITResult:
"""Just-in-time compile a MLC-Chat model."""
# skip logging jit policy since when outside can hint once
if not skip_log_jit_policy:
log_jit_policy()
if MLC_JIT_POLICY == "OFF":
raise RuntimeError("JIT is disabled by MLC_JIT_POLICY=OFF")
with open(model_path / "mlc-chat-config.json", encoding="utf-8") as in_file:
mlc_chat_config = json.load(in_file)
model_type = mlc_chat_config.pop("model_type")
quantization = mlc_chat_config.pop("quantization")
lib_suffix = MLC_DSO_SUFFIX if device not in ["iphone", "macabi", "android"] else "tar"
def _get_optimization_flags() -> str:
opt = overrides.pop("opt", None)
if opt is None:
opt = "O2"
return repr(OptimizationFlags.from_str(opt))
def _get_overrides() -> str:
forbid_list = [
"context_window_size",
"sliding_window_size",
"attention_sink_size",
]
result = []
for field in dataclasses.fields(ModelConfigOverride):
value = overrides.get(field.name, None)
if value is not None:
if field.name in forbid_list and value == -1:
continue
result.append(f"{field.name}={value}")
return ";".join(result)
def _get_model_config() -> Dict[str, Any]: # noqa: UP006
model_config = mlc_chat_config.pop("model_config")
model_config.update(mlc_chat_config)
for field in dataclasses.fields(ModelConfigOverride):
value = overrides.get(field.name, None)
if value is not None:
model_config[field.name] = value
return MODELS[model_type].config.from_dict(model_config).asdict()
def _run_jit(
opt: str,
overrides: str,
device: str,
system_lib_prefix: Optional[str],
dst: str,
):
with tempfile.TemporaryDirectory(dir=MLC_TEMP_DIR) as tmp_dir:
dso_path = os.path.join(tmp_dir, f"lib.{lib_suffix}")
cmd = [
sys.executable,
"-m",
"mlc_llm",
"compile",
str(model_path),
"--opt",
opt,
"--overrides",
overrides,
"--device",
device,
"--output",
dso_path,
]
if system_lib_prefix:
cmd += ["--system-lib-prefix", system_lib_prefix + "_"]
logger.info("Compiling using commands below:")
logger.info("%s", blue(shlex.join(cmd)))
subprocess.run(cmd, check=False, env=os.environ)
# note on windows: compilation can succeed but return code is still nonzero
# check whether file exists instead
if not os.path.isfile(dso_path):
raise RuntimeError("Cannot find compilation output, compilation failed")
shutil.move(dso_path, dst)
logger.info("Using compiled model lib: %s", bold(dst))
hash_key = {
"model_config": _get_model_config(),
"overrides": _get_overrides(),
"opt": _get_optimization_flags(),
"device": device2str(device) if isinstance(device, Device) else device,
"model_type": model_type,
"quantization": quantization,
}
if device in ["iphone", "macabi", "android"]:
if system_lib_prefix is None:
system_lib_hash_value = hashlib.md5(
json.dumps(
hash_key,
sort_keys=True,
indent=2,
).encode("utf-8")
).hexdigest()
system_lib_prefix = f"{model_type}_{quantization}_{system_lib_hash_value}".replace(
"-", "_"
)
hash_key["system_lib_prefix"] = system_lib_prefix
hash_value = hashlib.md5(
json.dumps(
hash_key,
sort_keys=True,
indent=2,
).encode("utf-8")
).hexdigest()
dst = MLC_LLM_HOME / "model_lib" / f"{hash_value}.{lib_suffix}"
if dst.is_file() and MLC_JIT_POLICY in ["ON", "READONLY"]:
logger.info("Using cached model lib: %s", bold(str(dst)))
return JITResult(str(dst), system_lib_prefix)
if MLC_JIT_POLICY == "READONLY":
raise RuntimeError(
"No cached model lib found, and JIT is disabled by MLC_JIT_POLICY=READONLY"
)
_run_jit(
opt=hash_key["opt"],
overrides=hash_key["overrides"],
device=hash_key["device"],
system_lib_prefix=system_lib_prefix,
dst=str(dst),
)
return JITResult(str(dst), system_lib_prefix)
+402
View File
@@ -0,0 +1,402 @@
"""Python entrypoint of package."""
import dataclasses
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Literal # noqa: UP035
from mlc_llm.interface import jit
from mlc_llm.support import download_cache, logging, style
logging.enable_logging()
logger = logging.getLogger(__name__)
SUPPORTED_DEVICES = ["iphone", "macabi", "android"]
def build_model_library(
package_config: Dict[str, Any], # noqa: UP006
device: str,
bundle_dir: Path,
app_config_path: Path,
) -> Dict[str, str]: # noqa: UP006
"""Build model libraries. Return the dictionary of "library prefix to lib path"."""
# - Create the bundle directory.
os.makedirs(bundle_dir, exist_ok=True)
# Clean up all the directories in `output/bundle`.
logger.info('Clean up all directories under "%s"', str(bundle_dir))
for content_path in bundle_dir.iterdir():
if content_path.is_dir():
shutil.rmtree(content_path)
# - Process each model, and prepare the app config.
app_config_model_list = []
model_entries = package_config.get("model_list", [])
if not isinstance(model_entries, list):
raise ValueError('The "model_list" in "mlc-package-config.json" is expected to be a list.')
model_lib_path_for_prepare_libs = package_config.get("model_lib_path_for_prepare_libs", {})
if not isinstance(model_lib_path_for_prepare_libs, dict):
raise ValueError(
'The "model_lib_path_for_prepare_libs" in "mlc-package-config.json" is expected to be '
"a dict."
)
jit.log_jit_policy()
for model_entry in package_config.get("model_list", []):
# - Parse model entry.
if not isinstance(model_entry, dict):
raise ValueError('The element of "model_list" is expected to be a dict.')
model = model_entry["model"]
model_id = model_entry["model_id"]
bundle_weight = model_entry.get("bundle_weight", False)
overrides = model_entry.get("overrides", {})
model_lib = model_entry.get("model_lib", None)
estimated_vram_bytes = model_entry["estimated_vram_bytes"]
if not isinstance(model, str):
raise ValueError('The value of "model" in "model_list" is expected to be a string.')
if not isinstance(model_id, str):
raise ValueError('The value of "model_id" in "model_list" is expected to be a string.')
if not isinstance(bundle_weight, bool):
raise ValueError(
'The value of "bundle_weight" in "model_list" is expected to be a boolean.'
)
if not isinstance(overrides, dict):
raise ValueError('The value of "overrides" in "model_list" is expected to be a dict.')
if model_lib is not None and not isinstance(model_lib, str):
raise ValueError('The value of "model_lib" in "model_list" is expected to be string.')
# - Load model config. Download happens when needed.
model_path = download_cache.get_or_download_model(model)
# - Jit compile if the model lib path is not specified.
model_lib_path = (
model_lib_path_for_prepare_libs.get(model_lib, None) if model_lib is not None else None
)
if model_lib_path is None:
if model_lib is None:
logger.info(
'Model lib is not specified for model "%s". Now jit compile the model library.',
model_id,
)
else:
logger.info(
'Model lib path for "%s" is not specified in "model_lib_path_for_prepare_libs".'
"Now jit compile the model library.",
model_lib,
)
model_lib_path, model_lib = dataclasses.astuple(
jit.jit(
model_path=model_path,
overrides=overrides,
device=device,
system_lib_prefix=model_lib,
skip_log_jit_policy=True,
)
)
assert model_lib is not None
model_lib_path_for_prepare_libs[model_lib] = model_lib_path
# - Set "model_url"/"model_path" and "model_id"
app_config_model_entry = {}
is_local_model = not model.startswith("HF://") and not model.startswith("https://")
app_config_model_entry["model_id"] = model_id
app_config_model_entry["model_lib"] = model_lib
# - Bundle weight
if is_local_model and not bundle_weight:
raise ValueError(
f'Model "{model}" in "model_list" is a local path.'
f'Please set \'"bundle_weight": true\' in the entry of model "{model}".'
)
if bundle_weight:
if not os.path.isfile(model_path / "tensor-cache.json"):
raise ValueError(
f'Bundle weight is set for model "{model}". However, model weights are not'
f'found under the directory "{model}". '
+ (
"Please follow https://llm.mlc.ai/docs/compilation/convert_weights.html to "
"convert model weights."
if is_local_model
else "Please report this issue to https://github.com/mlc-ai/mlc-llm/issues."
)
)
# Overwrite the model weight directory in bundle.
bundle_model_weight_path = bundle_dir / model_id
logger.info(
"Bundle weight for %s, copy into %s",
style.bold(model_id),
style.bold(str(bundle_model_weight_path)),
)
if bundle_model_weight_path.exists():
shutil.rmtree(bundle_model_weight_path)
shutil.copytree(model_path, bundle_model_weight_path)
if bundle_weight and device in ["iphone", "macabi"]:
app_config_model_entry["model_path"] = model_id
else:
app_config_model_entry["model_url"] = model.replace("HF://", "https://huggingface.co/")
# - estimated_vram_bytes
app_config_model_entry["estimated_vram_bytes"] = estimated_vram_bytes
app_config_model_list.append(app_config_model_entry)
# - Dump "mlc-app-config.json".
app_config_json_str = json.dumps(
{"model_list": app_config_model_list},
indent=2,
)
with open(app_config_path, "w", encoding="utf-8") as file:
print(app_config_json_str, file=file)
logger.info(
'Dump the app config below to "%s":\n%s',
str(app_config_path),
style.green(app_config_json_str),
)
return model_lib_path_for_prepare_libs
def validate_model_lib(
app_config_path: Path,
package_config_path: Path,
model_lib_path_for_prepare_libs: dict,
device: Literal["iphone", "macabi", "android"],
output: Path,
) -> None:
"""Validate the model lib prefixes of model libraries."""
if device == "android":
from tvm.support import ndk as cc
else:
from tvm.support import cc
with open(app_config_path, encoding="utf-8") as file:
app_config = json.load(file)
tar_list = []
model_set = set()
for model, model_lib_path in model_lib_path_for_prepare_libs.items():
model_lib_path = os.path.join(model_lib_path)
lib_path_valid = os.path.isfile(model_lib_path)
if not lib_path_valid:
raise RuntimeError(f"Cannot find file {model_lib_path} as an {device} model library")
tar_list.append(model_lib_path)
model_set.add(model)
os.makedirs(output / "lib", exist_ok=True)
if device in ["iphone", "macabi"]:
lib_name = "libmodel_iphone.a"
else:
lib_name = "libmodel_android.a"
lib_path = output / "lib" / lib_name
def _get_model_libs(lib_path: Path) -> List[str]: # noqa: UP006
"""Get the model lib prefixes in the given static lib path."""
global_symbol_map = cc.get_global_symbol_section_map(lib_path)
libs = []
suffix = "___tvm_ffi__library_bin"
for name, _ in global_symbol_map.items():
if name.endswith(suffix):
model_lib = name[: -len(suffix)]
if model_lib.startswith("_"):
model_lib = model_lib[1:]
libs.append(model_lib)
return libs
cc.create_staticlib(lib_path, tar_list)
available_model_libs = _get_model_libs(lib_path)
logger.info("Creating lib from %s", str(tar_list))
logger.info("Validating the library %s", str(lib_path))
logger.info(
"List of available model libs packaged: %s,"
" if we have '-' in the model_lib string, it will be turned into '_'",
str(available_model_libs),
)
global_symbol_map = cc.get_global_symbol_section_map(lib_path)
error_happened = False
for item in app_config["model_list"]:
model_lib = item["model_lib"]
model_id = item["model_id"]
if model_lib not in model_set:
# NOTE: this cannot happen under new setting
# since if model_lib is not included, it will be jitted
raise RuntimeError(
f"ValidationError: model_lib={model_lib} specified for model_id={model_id} "
"is not included in model_lib_path_for_prepare_libs argument, "
"This will cause the specific model not being able to load, "
f"model_lib_path_for_prepare_libs={model_lib_path_for_prepare_libs}"
)
model_prefix_pattern = model_lib.replace("-", "_") + "___tvm_ffi__library_bin"
if (
model_prefix_pattern not in global_symbol_map
and "_" + model_prefix_pattern not in global_symbol_map
):
# NOTE: no lazy format is ok since this is a slow pass
model_lib_path = model_lib_path_for_prepare_libs[model_lib]
log_msg = (
"ValidationError:\n"
f"\tmodel_lib {model_lib} requested in {str(app_config_path)}"
f" is not found in {str(lib_path)}\n"
f"\tspecifically the model_lib for {model_lib_path}.\n"
f"\tcurrent available model_libs in {str(lib_path)}: {available_model_libs}\n"
f"\tThis can happen when we manually specified model_lib_path_for_prepare_libs"
f" in {str(package_config_path)}\n"
f"\tConsider remove model_lib_path_for_prepare_libs (so library can be jitted)"
"or check the compile command"
)
logger.info(log_msg)
error_happened = True
if not error_happened:
logger.info(style.green("Validation pass"))
else:
logger.info(style.red("Validation failed"))
sys.exit(255)
def build_android_binding(mlc_llm_source_dir: Path, output: Path) -> None:
"""Build android binding in MLC LLM"""
mlc4j_path = mlc_llm_source_dir / "android" / "mlc4j"
# Move the model libraries to "build/lib/" for linking
os.makedirs(Path("build") / "lib", exist_ok=True)
src_path = str(output / "lib" / "libmodel_android.a")
dst_path = str(Path("build") / "lib" / "libmodel_android.a")
logger.info('Moving "%s" to "%s"', src_path, dst_path)
shutil.move(src_path, dst_path)
# Build mlc4j
logger.info("Building mlc4j")
subprocess.run([sys.executable, mlc4j_path / "prepare_libs.py"], check=True, env=os.environ)
# Copy built files back to output directory.
lib_path = output / "lib" / "mlc4j"
os.makedirs(lib_path, exist_ok=True)
logger.info('Clean up all directories under "%s"', str(lib_path))
for content_path in lib_path.iterdir():
if content_path.is_dir():
shutil.rmtree(content_path)
src_path = str(mlc4j_path / "src")
dst_path = str(lib_path / "src")
logger.info('Copying "%s" to "%s"', src_path, dst_path)
shutil.copytree(src_path, dst_path)
src_path = str(mlc4j_path / "build.gradle")
dst_path = str(lib_path / "build.gradle")
logger.info('Copying "%s" to "%s"', src_path, dst_path)
shutil.copy(src_path, dst_path)
src_path = str(Path("build") / "output")
dst_path = str(lib_path / "output")
logger.info('Copying "%s" to "%s"', src_path, dst_path)
shutil.copytree(src_path, dst_path)
os.makedirs(lib_path / "src" / "main" / "assets")
src_path = str(output / "bundle" / "mlc-app-config.json")
dst_path = str(lib_path / "src" / "main" / "assets" / "mlc-app-config.json")
logger.info('Moving "%s" to "%s"', src_path, dst_path)
shutil.move(src_path, dst_path)
def build_iphone_binding(mlc_llm_source_dir: Path, output: Path) -> None:
"""Build iOS binding in MLC LLM"""
# Build iphone binding
logger.info("Build iphone binding")
subprocess.run(
["bash", mlc_llm_source_dir / "ios" / "prepare_libs.sh"],
check=True,
env=os.environ,
)
# Copy built libraries back to output directory.
for static_library in (Path("build") / "lib").iterdir():
dst_path = str(output / "lib" / static_library.name)
logger.info('Copying "%s" to "%s"', static_library, dst_path)
shutil.copy(static_library, dst_path)
def build_macabi_binding(mlc_llm_source_dir: Path, output: Path) -> None:
"""Build Mac Catalyst binding in MLC LLM"""
deployment_target = os.environ.get("MLC_MACABI_DEPLOYMENT_TARGET", "18.0")
macabi_arch = os.environ.get("MLC_MACABI_ARCH", "").strip() or "arm64"
logger.info("Build macabi binding (deployment target %s)", deployment_target)
cmd = [
"bash",
str(mlc_llm_source_dir / "ios" / "prepare_libs.sh"),
"--catalyst",
"--deployment-target",
deployment_target,
]
if macabi_arch:
cmd += ["--arch", macabi_arch]
subprocess.run(cmd, check=True, env=os.environ)
# Copy built libraries back to output directory.
build_dir = Path(f"build-maccatalyst-{macabi_arch}")
for static_library in (build_dir / "lib").iterdir():
dst_path = str(output / "lib" / static_library.name)
logger.info('Copying "%s" to "%s"', static_library, dst_path)
shutil.copy(static_library, dst_path)
def package(
package_config_path: Path,
mlc_llm_source_dir: Path,
output: Path,
) -> None:
"""Python entrypoint of package."""
logger.info('MLC LLM HOME: "%s"', mlc_llm_source_dir)
# - Read package config.
with open(package_config_path, encoding="utf-8") as file:
package_config = json.load(file)
if not isinstance(package_config, dict):
raise ValueError(
"The content of MLC package config is expected to be a dict with "
f'field "model_list". However, the content of "{package_config_path}" is not a dict.'
)
# - Read device.
if "device" not in package_config:
raise ValueError(f'JSON file "{package_config_path}" is required to have field "device".')
device = package_config["device"]
if device not in SUPPORTED_DEVICES:
raise ValueError(
f'The "device" field of JSON file {package_config_path} is expected to be one of '
f'{SUPPORTED_DEVICES}, while "{device}" is given in the JSON.'
)
bundle_dir = output / "bundle"
app_config_path = bundle_dir / "mlc-app-config.json"
# - Build model libraries.
model_lib_path_for_prepare_libs = build_model_library(
package_config, device, bundle_dir, app_config_path
)
# - Validate model libraries.
validate_model_lib(
app_config_path,
package_config_path,
model_lib_path_for_prepare_libs,
device,
output,
)
# - Copy model libraries
if device == "android":
build_android_binding(mlc_llm_source_dir, output)
elif device == "iphone":
build_iphone_binding(mlc_llm_source_dir, output)
elif device == "macabi":
build_macabi_binding(mlc_llm_source_dir, output)
else:
assert False, "Cannot reach here"
logger.info("All finished.")
+125
View File
@@ -0,0 +1,125 @@
"""Python entrypoint of router."""
from collections.abc import AsyncGenerator
from http import HTTPStatus
from typing import List, Literal, Optional, Type # noqa: UP035
import fastapi
import uvicorn
from fastapi.middleware.cors import CORSMiddleware
from mlc_llm.protocol import error_protocol
from mlc_llm.protocol.openai_api_protocol import CompletionLogProbs, CompletionRequest
from mlc_llm.router import Router
from mlc_llm.serve import engine_base, engine_utils
def serve(
model: str,
model_lib: Optional[str],
router_host: str,
router_port: int,
endpoint_hosts: List[str], # noqa: UP006
endpoint_ports: List[int], # noqa: UP006
endpoint_num_gpus: List[int], # noqa: UP006
enable_prefix_cache: bool,
router_mode: Literal["disagg", "round-robin"] = "round-robin",
pd_balance_factor: float = 0.0,
router_type: Type[Router] = Router, # noqa: UP006
):
"""Start the router with the specified configuration."""
# 1. Instantiate router
router = router_type(
model=model,
model_lib=model_lib,
hosts=endpoint_hosts,
ports=endpoint_ports,
num_gpus=endpoint_num_gpus,
enable_prefix_cache=enable_prefix_cache,
router_mode=router_mode,
pd_balance_factor=pd_balance_factor,
)
router_app = fastapi.APIRouter()
@router_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
"""
if router is None:
return error_protocol.create_error_response(
HTTPStatus.BAD_REQUEST, message="Router is not initialized."
)
request_id = 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 = router.handle_completion(request, request_id)
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"
)
# FIXME: Non-streaming response not fully implemented
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 router.handle_completion(request, request_id):
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"
)
# TODO(Charlie): This is copied from engine.py --
# why is it here? Non-streaming only has a single chunk right?
# this is the final chunk
# if response.usage is not None:
# request_final_usage = response.usage
# 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:
logprob_results[choice.index] = choice.logprobs
assert all(finish_reason is not None for finish_reason in finish_reasons)
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,
)
# 2. Set up app
app = fastapi.FastAPI()
app.add_middleware(CORSMiddleware)
app.include_router(router_app)
app.exception_handler(error_protocol.BadRequestError)(error_protocol.bad_request_error_handler)
# 3. Run
uvicorn.run(app, host=router_host, port=router_port, log_level="info")
+131
View File
@@ -0,0 +1,131 @@
"""Python entrypoint of serve."""
from typing import Any, List, Literal, Optional, Tuple, Union # noqa: UP035
import fastapi
import uvicorn
from fastapi.middleware.cors import CORSMiddleware
from mlc_llm.protocol import error_protocol
from mlc_llm.serve import engine
from mlc_llm.serve.embedding_engine import AsyncEmbeddingEngine
from mlc_llm.serve.entrypoints import (
debug_entrypoints,
metrics_entrypoints,
microserving_entrypoints,
openai_entrypoints,
)
from mlc_llm.serve.server import ServerContext
from mlc_llm.support import logging
logger = logging.getLogger(__name__)
def serve(
model: str,
device: str,
model_lib: Optional[str],
mode: Literal["local", "interactive", "server"],
enable_debug: bool,
additional_models: List[Union[str, Tuple[str, str]]], # noqa: UP006
embedding_model: Optional[str],
embedding_model_lib: Optional[str],
tensor_parallel_shards: Optional[int],
pipeline_parallel_stages: Optional[int],
opt: Optional[str],
max_num_sequence: Optional[int],
max_total_sequence_length: Optional[int],
max_single_sequence_length: Optional[int],
prefill_chunk_size: Optional[int],
sliding_window_size: Optional[int],
attention_sink_size: Optional[int],
max_history_size: Optional[int],
gpu_memory_utilization: Optional[float],
speculative_mode: Literal["disable", "small_draft", "eagle", "medusa"],
spec_draft_length: Optional[int],
spec_tree_width: Optional[int],
prefix_cache_mode: Literal["disable", "radix"],
prefix_cache_max_num_recycling_seqs: Optional[int],
prefill_mode: Literal["hybrid", "chunked"],
enable_tracing: bool,
host: str,
port: int,
allow_credentials: bool,
allow_origins: Any,
allow_methods: Any,
allow_headers: Any,
api_key: Optional[str] = None,
):
"""Serve the model with the specified configuration."""
# Create engine and start the background loop
async_engine = engine.AsyncMLCEngine(
model=model,
device=device,
model_lib=model_lib,
mode=mode,
engine_config=engine.EngineConfig(
additional_models=additional_models,
tensor_parallel_shards=tensor_parallel_shards,
pipeline_parallel_stages=pipeline_parallel_stages,
opt=opt,
max_num_sequence=max_num_sequence,
max_total_sequence_length=max_total_sequence_length,
max_single_sequence_length=max_single_sequence_length,
prefill_chunk_size=prefill_chunk_size,
sliding_window_size=sliding_window_size,
attention_sink_size=attention_sink_size,
max_history_size=max_history_size,
gpu_memory_utilization=gpu_memory_utilization,
speculative_mode=speculative_mode,
spec_draft_length=spec_draft_length,
spec_tree_width=spec_tree_width,
prefix_cache_mode=prefix_cache_mode,
prefix_cache_max_num_recycling_seqs=prefix_cache_max_num_recycling_seqs,
prefill_mode=prefill_mode,
),
enable_tracing=enable_tracing,
)
# Set up embedding model if specified
emb_engine = None
if embedding_model is not None:
if embedding_model_lib is None:
raise ValueError(
"--embedding-model-lib is required when --embedding-model is specified."
)
emb_engine = AsyncEmbeddingEngine(
model=embedding_model,
model_lib=embedding_model_lib,
device=device,
)
logger.info("Embedding model %s loaded successfully.", embedding_model)
with ServerContext() as server_context:
server_context.add_model(model, async_engine)
if emb_engine is not None:
server_context.add_embedding_engine(embedding_model, emb_engine)
server_context.api_key = api_key
app = fastapi.FastAPI()
app.add_middleware(
CORSMiddleware,
allow_credentials=allow_credentials,
allow_origins=allow_origins,
allow_methods=allow_methods,
allow_headers=allow_headers,
)
app.include_router(openai_entrypoints.app)
app.include_router(metrics_entrypoints.app)
app.include_router(microserving_entrypoints.app)
server_context.enable_debug = enable_debug
if enable_debug:
app.include_router(debug_entrypoints.app)
logger.info("Enable debug endpoint and debug_config in requests...")
app.exception_handler(error_protocol.BadRequestError)(
error_protocol.bad_request_error_handler
)
uvicorn.run(app, host=host, port=port, log_level="info")