chore: import upstream snapshot with attribution
Lint / lint (push) Waiting to run
Windows CI / Windows (push) Waiting to run
Build Docs / Deploy Docs (push) Waiting to run

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
+20
View File
@@ -0,0 +1,20 @@
"""MLC Chat python package.
MLC Chat is the app runtime of MLC LLM.
"""
from tvm import register_global_func
from . import protocol, serve
from .libinfo import __version__
from .serve import AsyncMLCEngine, MLCEngine
@register_global_func("runtime.disco.create_socket_session_local_workers", override=True)
def _create_socket_session_local_workers(num_workers):
"""Create the local session for each distributed node over socket session."""
from tvm.runtime.disco import (
ProcessSession,
)
return ProcessSession(num_workers, num_groups=1, entrypoint="mlc_llm.cli.worker")
+67
View File
@@ -0,0 +1,67 @@
"""Entrypoint of all CLI commands from MLC LLM"""
import sys
from mlc_llm.support import logging
from mlc_llm.support.argparse import ArgumentParser
logging.enable_logging()
def main():
"""Entrypoint of all CLI commands from MLC LLM"""
parser = ArgumentParser("MLC LLM Command Line Interface.")
parser.add_argument(
"subcommand",
type=str,
choices=[
"compile",
"convert_weight",
"gen_config",
"chat",
"serve",
"package",
"calibrate",
"router",
],
help="Subcommand to to run. (choices: %(choices)s)",
)
parsed = parser.parse_args(sys.argv[1:2])
if parsed.subcommand == "compile":
from mlc_llm.cli import compile as cli
cli.main(sys.argv[2:])
elif parsed.subcommand == "convert_weight":
from mlc_llm.cli import convert_weight as cli
cli.main(sys.argv[2:])
elif parsed.subcommand == "gen_config":
from mlc_llm.cli import gen_config as cli
cli.main(sys.argv[2:])
elif parsed.subcommand == "chat":
from mlc_llm.cli import chat as cli
cli.main(sys.argv[2:])
elif parsed.subcommand == "serve":
from mlc_llm.cli import serve as cli
cli.main(sys.argv[2:])
elif parsed.subcommand == "package":
from mlc_llm.cli import package as cli
cli.main(sys.argv[2:])
elif parsed.subcommand == "calibrate":
from mlc_llm.cli import calibrate as cli
cli.main(sys.argv[2:])
elif parsed.subcommand == "router":
from mlc_llm.cli import router as cli
cli.main(sys.argv[2:])
else:
raise ValueError(f"Unknown subcommand {parsed.subcommand}")
if __name__ == "__main__":
main()
+45
View File
@@ -0,0 +1,45 @@
"""Load MLC LLM library and _ffi_api functions."""
import ctypes
import os
import sys
import tvm
import tvm.base
from . import libinfo
SKIP_LOADING_MLCLLM_SO = os.environ.get("SKIP_LOADING_MLCLLM_SO", "0")
def _load_mlc_llm_lib():
"""Load MLC LLM lib"""
if sys.platform.startswith("win32") and sys.version_info >= (3, 8):
for path in libinfo.get_dll_directories():
os.add_dll_directory(path)
lib_name = "mlc_llm" if tvm.base._RUNTIME_ONLY else "mlc_llm_module"
lib_path = libinfo.find_lib_path(lib_name, optional=False)
return ctypes.CDLL(lib_path[0]), lib_path[0]
@tvm.register_global_func("mlc.debug_cuda_profiler_start")
def _debug_cuda_profiler_start() -> None:
"""Start cuda profiler."""
import cuda
import cuda.cudart
cuda.cudart.cudaProfilerStart()
@tvm.register_global_func("mlc.debug_cuda_profiler_stop")
def _debug_cuda_profiler_stop() -> None:
"""Stop cuda profiler."""
import cuda
import cuda.cudart
cuda.cudart.cudaProfilerStop()
# only load once here
if SKIP_LOADING_MLCLLM_SO == "0":
_LIB, _LIB_PATH = _load_mlc_llm_lib()
+1
View File
@@ -0,0 +1 @@
"""Subdirectory of bench."""
+403
View File
@@ -0,0 +1,403 @@
"""MLC LLM benchmark main entrance"""
import functools
import json
import random
from typing import Any, Dict, List, Optional, Tuple # noqa: UP035
import numpy as np
import requests
from transformers import AutoTokenizer
import mlc_llm
from mlc_llm.bench.api_endpoint import SUPPORTED_BACKENDS, create_api_endpoint
from mlc_llm.bench.dataset import SUPPORTED_DATASET, Dataset, create_dataset
from mlc_llm.bench.request_processor import (
MetricAnalyzer,
RequestProcessor,
create_pipelines,
)
from mlc_llm.bench.request_record import (
RequestRecord,
convert_reports_to_df,
generate_metrics_summary,
pretty_print_report,
)
from mlc_llm.cli.serve import EngineConfigOverride
from mlc_llm.serve import EngineConfig
from mlc_llm.support import argparse, logging
logging.enable_logging()
logger = logging.getLogger(__name__)
def _parse_num_concurrent_requests(num_str: Optional[str]) -> Optional[List[int]]: # noqa: UP006
if num_str is None:
return None
numbers = num_str.split(",")
if any(not number.isdigit() for number in numbers):
raise ValueError(f"Unrecognized num_concurrent_requests list: {numbers}")
return list(int(number) for number in numbers)
def _parse_request_rate(request_rate_str: Optional[str]) -> Optional[List[np.float32]]: # noqa: UP006
if request_rate_str is None:
return None
request_rates = request_rate_str.split(",")
results = []
for rate_str in request_rates:
request_rate = float(rate_str)
if request_rate <= 0:
raise ValueError(f"Invalid request rate {request_rate}")
results.append(np.float32(request_rate))
return results
def _parse_mlc_engine_config(config_str: Optional[str]) -> EngineConfig:
if config_str is None:
return None
engine_config_override = EngineConfigOverride.from_str(config_str)
return EngineConfig(
tensor_parallel_shards=engine_config_override.tensor_parallel_shards,
max_num_sequence=engine_config_override.max_num_sequence,
max_total_sequence_length=engine_config_override.max_total_seq_length,
prefill_chunk_size=engine_config_override.prefill_chunk_size,
sliding_window_size=engine_config_override.sliding_window_size,
attention_sink_size=engine_config_override.attention_sink_size,
max_history_size=engine_config_override.max_history_size,
gpu_memory_utilization=engine_config_override.gpu_memory_utilization,
spec_draft_length=engine_config_override.spec_draft_length,
prefill_mode=engine_config_override.prefill_mode,
prefix_cache_max_num_recycling_seqs=engine_config_override.prefix_cache_max_num_recycling_seqs,
prefix_cache_mode=engine_config_override.prefix_cache_mode,
)
def _launch_mlc_server(args: argparse.argparse.Namespace):
return mlc_llm.serve.PopenServer(
model=args.tokenizer,
mode="server",
model_lib=args.mlc_model_lib,
enable_tracing=False,
host=args.host,
port=args.port,
engine_config=args.mlc_engine_config,
)
def run_pipeline(
pipeline: RequestProcessor,
dataset: Dataset,
tokenizer: AutoTokenizer,
args: argparse.argparse.Namespace,
) -> Tuple[Dict[str, Any], List[RequestRecord]]: # noqa: UP006
"""Run the pipeline with the given dataset and args. Return the benchmark report dict."""
random.seed(args.seed)
np.random.seed(args.seed)
request_records = dataset.generate_request_records(
args.input_len,
args.output_len,
args.input_len_std,
args.output_len_std,
)
request_records = pipeline(request_records)
num_total_requests = (
args.num_requests if not args.per_gpu_workload else args.num_requests * args.num_gpus
)
assert len(request_records) == num_total_requests
sorted_requests: List[RequestRecord] = [None] * num_total_requests # noqa: UP006
for request_record in request_records:
assert request_record.request_id is not None
assert sorted_requests[request_record.request_id] is None
sorted_requests[request_record.request_id] = request_record
request_records = MetricAnalyzer(tokenizer)(request_records)
report = generate_metrics_summary(request_records, num_total_requests, args.num_gpus)
return report, sorted_requests
def query_mlc_server_metrics(host: str, port: int):
"""Try to get the MLC server metrics whenever it exists."""
try:
r = requests.post(f"http://{host}:{port}/debug/dump_engine_metrics", json={}, timeout=10)
if r.status_code == 200:
print(f"MLC server metrics: {r.json()}")
except Exception:
pass
def main(args: argparse.argparse.Namespace):
"""Main benchmark entrance."""
mlc_server = None
if args.mlc_model_lib:
mlc_server = _launch_mlc_server(args)
if args.num_requests <= 0:
raise ValueError("Number of requests to benchmark must be positive.")
def _main():
tokenizer = AutoTokenizer.from_pretrained(args.tokenizer)
dataset = create_dataset(args, tokenizer)
f_create_api_endpoint = functools.partial(create_api_endpoint, args)
pipelines = create_pipelines(args, f_create_api_endpoint, dataset)
reports = []
alltime_records = {}
for i, pipeline in enumerate(pipelines):
report, request_records = run_pipeline(pipeline, dataset, tokenizer, args)
exec_feature = (
json.dumps(report["exec_feature"])
if report["exec_feature"] is not None
else f"pipeline{i}"
)
alltime_records[exec_feature] = [
request_record.model_dump() for request_record in request_records
]
reports.append(report)
pretty_print_report(report)
query_mlc_server_metrics(args.host, args.port)
# Construct data frame
df = convert_reports_to_df(reports)
print(df)
df.to_csv(args.output, index=False)
logger.info("Benchmark results dumped to file %s", args.output)
if args.debug_dump:
debug_dump_filepath = (
args.output[:-4] if args.output.endswith(".csv") else args.output
) + "_debug_dump.log"
with open(debug_dump_filepath, "w", encoding="utf-8") as file:
json.dump(alltime_records, file, indent=4)
logger.info("Debug log dumped to file %s", debug_dump_filepath)
if mlc_server is not None:
with mlc_server:
_main()
else:
_main()
if __name__ == "__main__":
parser = argparse.ArgumentParser("MLC LLM benchmark")
parser.add_argument(
"--dataset",
type=str,
choices=SUPPORTED_DATASET,
help=f"The benchmark dataset kind. Supporting {SUPPORTED_DATASET}",
)
parser.add_argument(
"--dataset-path",
type=str,
help="The dataset file path.",
)
parser.add_argument(
"--api-endpoint",
type=str,
choices=SUPPORTED_BACKENDS,
default="openai",
help="The API endpoint API for benchmarking.",
)
parser.add_argument(
"--tokenizer",
type=str,
required=True,
help="The path of the tokenizer directory.",
)
parser.add_argument(
"--num-gpus",
type=int,
required=True,
help="The number of GPUs used by the server. "
"We need this to better analyze the throughput per GPU.",
)
parser.add_argument(
"--num-requests",
type=int,
required=True,
help="The number of requests for benchmark.",
)
parser.add_argument(
"--num-warmup-requests",
type=int,
help="The number of requests for warmup. "
"It is optional when fixing the number of concurrent requests, and is required otherwise.",
)
parser.add_argument(
"--per-gpu-workload",
default=False,
action="store_true",
help='When set to True, the specified "num_concurrent_requests"/"request_rate" '
"denote the workload **per GPU**, which means that the real values of "
'"num_concurrent_requests"/"request_rate" used in benchmark'
'will be multiplied by "num_gpus".',
)
parser.add_argument(
"--num-concurrent-requests",
type=_parse_num_concurrent_requests,
help="The number(s) of concurrent requests to benchmark. "
'It can be either one integer or a list of integer separated by commas(","). '
"When specified, for each integer, the benchmark keeps these many consistent "
"number of concurrently running requests.",
)
parser.add_argument(
"--request-rate",
type=_parse_request_rate,
help="The request rate(s) denoting the number of new requests each second. "
'It can be either one float number (or "inf") or a list of numbers separated '
'by commas(","). '
"When specified, the benchmark sends these many new requests each second. "
'If it is "inf", all requests will be sent together at once.',
)
parser.add_argument(
"--replay-timestamp-scale",
type=float,
help="The timestamp scale when replaying the timestamps in a dataset. "
'The dataset replay mode is enabled when neither "--num-concurrent-requests" and '
'"--request-rate" is specified. '
"The scale is 1 by default in the replay mode.",
)
parser.add_argument(
"--input-len",
type=int,
help="The benchmark request average input length. Default to None, "
"which means the request input length depends on the dataset being used.",
)
parser.add_argument(
"--input-len-std",
type=float,
default=0,
help="The benchmark request input length standard deviation. Default to 0.",
)
parser.add_argument(
"--output-len",
type=int,
help="The benchmark request average output length. Default to None, "
"which means the request output length depends on the dataset being used.",
)
parser.add_argument(
"--output-len-std",
type=float,
default=0,
help="The benchmark request output length standard deviation. Default to 0.",
)
parser.add_argument(
"--stream",
type=bool,
default=True,
help="Whether to benchmark stream responses. "
"When not enabled, metrics such as time-to-first-token (TTFT) will not be available. "
"Default to True.",
)
parser.add_argument(
# NOTE: The current implementation of server metrics still has some issues that need fixes,
# which makes it not work to include server metrics.
"--include-server-metrics",
action="store_true",
help="Whether to also benchmark the server side request metrics. "
"This option is only available when benchmarking MLC server.",
)
parser.add_argument(
"--host",
type=str,
required=True,
help="The host address of the backend API.",
)
parser.add_argument(
"--port",
type=int,
required=True,
help="The port of the backend API.",
)
parser.add_argument(
"--timeout",
type=float,
default=3 * 60 * 60,
help="The timeout limit of each request.",
)
parser.add_argument(
"--seed",
type=int,
default=0,
help="The random number seed. Default to 0.",
)
parser.add_argument(
"--temperature",
type=float,
default=1.0,
help="The temperature value for logit adjustment. Default to 1.",
)
parser.add_argument(
"--top-p",
type=float,
default=1.0,
help="The top-p value for sampling. Default to 1.",
)
parser.add_argument(
"--ignore-eos",
default=False,
action="store_true",
help='Whether to set the "ignore_eos" field.',
)
parser.add_argument(
"--apply-chat-template",
default=False,
action="store_true",
help="Whether to apply chat template to the request input text. "
'It is not supported when "--input-len" is specified.',
)
parser.add_argument(
"--num-process-workers",
type=int,
help="The number of parallel process workers to send the requests.",
)
parser.add_argument(
"--disable-tqdm",
action="store_true",
help="Whether to disable showing progress bar with tqdm during benchmarking.",
)
parser.add_argument(
"--max-schedule-gap",
type=float,
default=0.5,
help="The maximum allowed delay between the scheduled time in seconds.",
)
parser.add_argument(
"--mlc-model-lib",
type=str,
help="The model lib path when benchmarking MLC serve. "
"When specified, the server is automatic launched and no external server launch is needed.",
)
parser.add_argument(
"--mlc-engine-config",
type=_parse_mlc_engine_config,
help="The engine config used when launch MLC server.",
)
parser.add_argument(
"--cuda-profile",
default=False,
action="store_true",
help="Whether to enable cuda profile on server. "
"The --mlc-model-lib path should be provided when enabling this option.",
)
parser.add_argument(
"--debug-dump",
default=False,
action="store_true",
help="Whether to dump all request record raw data to file.",
)
parser.add_argument(
"--multi-round",
default=False,
action="store_true",
help="Whether to chat like multi round conversion with history log each request. "
"Only enabled when benchmarked with fixed concurrent request mode."
"The --num-concurrent-requests should be provided when enabling this option.",
)
parser.add_argument(
"--output",
"-o",
type=str,
default="mlc_benchmark.csv",
help="The path of the output file where to dump the benchmark results.",
)
main(parser.parse_args())
+452
View File
@@ -0,0 +1,452 @@
"""MLC LLM bench backends"""
import argparse
import json
import os
import time
import traceback
from typing import Optional
from typing_extensions import Self
from mlc_llm.bench.request_record import Metrics, RequestRecord, ServerMetrics
from mlc_llm.support import logging
logger = logging.getLogger(__name__)
class APIEndPoint:
"""Manages the sending of requests to a specified API endpoint and gathers
inference statistics.
"""
def __init__(self, include_server_metrics: bool = False) -> None:
self.include_server_metrics = include_server_metrics
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, exc_type, exc_value, tb) -> None:
pass
async def __call__(self, request: RequestRecord) -> RequestRecord:
raise NotImplementedError()
class OpenAIChatEndPoint(APIEndPoint):
"""The backend of sending HTTP requests in OpenAI API through "v1/chat/completions"."""
def __init__(
self,
host: str,
port: int,
timeout: Optional[float] = None,
include_server_metrics: bool = False,
) -> None:
super().__init__(include_server_metrics=include_server_metrics)
import aiohttp
self.timeout = timeout
self.client: aiohttp.ClientSession = None
self.url = f"http://{host}:{port}/v1/chat/completions"
self.headers = {"Content-Type": "application/json"}
if os.getenv("MLC_LLM_API_KEY"):
self.headers["Authorization"] = f"Bearer {os.getenv('MLC_LLM_API_KEY')}"
async def __aenter__(self) -> Self:
import aiohttp
self.client = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(self.timeout))
return self
async def __aexit__(self, exc_type, exc_value, tb) -> None:
await self.client.close()
async def __call__(self, request_record: RequestRecord) -> RequestRecord:
payload = request_record.chat_cmpl.model_dump()
if self.timeout is not None and "timeout" not in payload:
payload["timeout"] = self.timeout
if self.include_server_metrics:
if "stream_options" not in payload or payload["stream_options"] is None:
payload["stream_options"] = {"include_usage": True}
else:
payload["stream_options"]["include_usage"] = True
if (
request_record.chat_cmpl.debug_config is not None
and request_record.chat_cmpl.debug_config.ignore_eos
):
payload["ignore_eos"] = True
generated_text = ""
first_chunk_output_str = ""
time_to_first_token_s = None
start_time = time.monotonic()
server_metrics = None
try:
async with self.client.post(self.url, json=payload, headers=self.headers) as response:
assert response.status == 200, await response.text()
if payload["stream"]:
async for chunk in response.content:
chunk = chunk.strip()
if not chunk or chunk == b"\n":
continue
# Get rid of the prefix "data: " and suffix "\n"
raw_data = chunk[6:].strip()
if raw_data == b"[DONE]":
continue
data = json.loads(raw_data)
if not data["choices"]:
continue
delta = data["choices"][0]["delta"]
content = delta.get("content", None)
if content is not None and not time_to_first_token_s:
time_to_first_token_s = time.monotonic() - start_time
first_chunk_output_str = content
if self.include_server_metrics and data["usage"] is not None:
# fmt: off
server_metrics = ServerMetrics(
input_tokens=data["usage"]["extra"]["prompt_tokens"],
prefill_tokens=data["usage"]["extra"]["prefill_tokens"],
output_tokens=data["usage"]["extra"]["completion_tokens"],
end_to_end_latency_s=data["usage"]["extra"]["end_to_end_latency_s"],
prefill_tokens_per_s=data["usage"]["extra"]["prefill_tokens_per_s"],
inter_token_latency_s=data["usage"]["extra"]["inter_token_latency_s"],
time_per_output_token_s=1 / data["usage"]["extra"]["decode_tokens_per_s"], # noqa: E501
time_to_first_token_s=data["usage"]["extra"]["ttft_s"],
)
# fmt: on
if content is not None:
generated_text += content
else:
data = await response.json()
generated_text = data["choices"][0]["message"]["content"]
if self.include_server_metrics and data["usage"] is not None:
# fmt: off
server_metrics = ServerMetrics(
input_tokens=data["usage"]["extra"]["prompt_tokens"],
prefill_tokens=data["usage"]["extra"]["prefill_tokens"],
output_tokens=data["usage"]["extra"]["completion_tokens"],
end_to_end_latency_s=data["usage"]["extra"]["end_to_end_latency_s"],
prefill_tokens_per_s=data["usage"]["extra"]["prefill_tokens_per_s"],
inter_token_latency_s=data["usage"]["extra"]["inter_token_latency_s"],
time_per_output_token_s=1 / data["usage"]["extra"]["decode_tokens_per_s"], # noqa: E501
time_to_first_token_s=data["usage"]["extra"]["ttft_s"],
)
# fmt: on
except Exception:
error_msg = "API endpoint errored when sending request: " + traceback.format_exc()
logger.info(error_msg)
finish_time = time.monotonic()
request_record.output_str = generated_text
request_record.first_chunk_output_str = first_chunk_output_str
request_record.metrics = Metrics(
success=False,
start_time=start_time,
finish_time=finish_time,
end_to_end_latency_s=finish_time - start_time,
input_tokens=request_record.metrics.input_tokens,
time_to_first_token_s=time_to_first_token_s,
server_metrics=server_metrics,
exec_feature=request_record.metrics.exec_feature,
)
request_record.error_msg = error_msg
return request_record
finish_time = time.monotonic()
request_record.output_str = generated_text
request_record.first_chunk_output_str = first_chunk_output_str
success = True
error_msg = None
if len(generated_text) == 0:
success = False
error_msg = "Empty generated text."
request_record.metrics = Metrics(
success=success,
start_time=start_time,
finish_time=finish_time,
end_to_end_latency_s=finish_time - start_time,
input_tokens=request_record.metrics.input_tokens,
time_to_first_token_s=time_to_first_token_s,
server_metrics=server_metrics,
exec_feature=request_record.metrics.exec_feature,
)
request_record.error_msg = error_msg
return request_record
class OpenAIEndPoint(APIEndPoint):
"""The backend of sending HTTP requests in OpenAI API through "v1/completions"."""
def __init__(
self,
host: str,
port: int,
timeout: Optional[float] = None,
include_server_metrics: bool = False,
no_debug_config: bool = False,
) -> None:
super().__init__(include_server_metrics=include_server_metrics)
import aiohttp
self.timeout = timeout
self.client: aiohttp.ClientSession = None
self.url = f"http://{host}:{port}/v1/completions"
self.headers = {"Content-Type": "application/json"}
if os.getenv("MLC_LLM_API_KEY"):
self.headers["Authorization"] = f"Bearer {os.getenv('MLC_LLM_API_KEY')}"
assert not include_server_metrics, (
'"include_server_metrics" only works for "openai-chat" endpoint for now'
)
self.no_debug_config = no_debug_config
async def __aenter__(self) -> Self:
import aiohttp
self.client = aiohttp.ClientSession()
return self
async def __aexit__(self, exc_type, exc_value, tb) -> None:
await self.client.close()
async def __call__(self, request_record: RequestRecord) -> RequestRecord:
assert len(request_record.chat_cmpl.messages) == 1, (
'Endpoint "openai" does not support system prompt and multi-round conversation.'
)
assert isinstance(request_record.chat_cmpl.messages[0].content, str)
payload = {
"model": request_record.chat_cmpl.model,
"prompt": request_record.chat_cmpl.messages[0].content,
"temperature": request_record.chat_cmpl.temperature,
"top_p": request_record.chat_cmpl.top_p,
"max_tokens": request_record.chat_cmpl.max_tokens,
"stream": True,
}
if self.timeout is not None and "timeout" not in payload:
payload["timeout"] = self.timeout
if (
request_record.chat_cmpl.debug_config is not None
and request_record.chat_cmpl.debug_config.ignore_eos
):
payload["ignore_eos"] = True
if not self.no_debug_config:
payload["debug_config"] = {"ignore_eos": True}
generated_text = ""
first_chunk_output_str = ""
time_to_first_token_s = None
start_time = time.monotonic()
try:
async with self.client.post(
self.url, json=payload, headers=self.headers, timeout=3600
) as response:
assert response.status == 200, await response.text()
if payload["stream"]:
async for chunk in response.content:
chunk = chunk.strip()
if not chunk or chunk == b"\n":
continue
# Get rid of the prefix "data: " and suffix "\n"
raw_data = chunk[6:].strip()
if raw_data == b"[DONE]":
continue
data = json.loads(raw_data)
if not data["choices"]:
continue
content = data["choices"][0]["text"]
if content is not None and not time_to_first_token_s:
time_to_first_token_s = time.monotonic() - start_time
first_chunk_output_str = content
if content is not None:
generated_text += content
else:
data = await response.json()
generated_text = data["choices"][0]["message"]["content"]
except Exception:
error_msg = "API endpoint errored when sending request: " + traceback.format_exc()
logger.info(error_msg)
finish_time = time.monotonic()
request_record.output_str = generated_text
request_record.first_chunk_output_str = first_chunk_output_str
request_record.metrics = Metrics(
success=False,
start_time=start_time,
finish_time=finish_time,
end_to_end_latency_s=finish_time - start_time,
input_tokens=request_record.metrics.input_tokens,
time_to_first_token_s=time_to_first_token_s,
server_metrics=None,
exec_feature=request_record.metrics.exec_feature,
)
request_record.error_msg = error_msg
return request_record
finish_time = time.monotonic()
request_record.output_str = generated_text
request_record.first_chunk_output_str = first_chunk_output_str
success = True
error_msg = None
if len(generated_text) == 0:
success = False
error_msg = "Empty generated text."
request_record.metrics = Metrics(
success=success,
start_time=start_time,
finish_time=finish_time,
end_to_end_latency_s=finish_time - start_time,
input_tokens=request_record.metrics.input_tokens,
time_to_first_token_s=time_to_first_token_s,
server_metrics=None,
exec_feature=request_record.metrics.exec_feature,
)
request_record.error_msg = error_msg
return request_record
class TensorRTLLMEndPoint(APIEndPoint):
"""The backend of sending HTTP requests in TensorRT-LLM API."""
def __init__(self, host: str, port: int, timeout: Optional[float] = None) -> None:
super().__init__(include_server_metrics=False)
import aiohttp
self.timeout = timeout
self.client: aiohttp.ClientSession = None
self.url_stream = f"http://{host}:{port}/v2/models/ensemble/generate_stream"
self.url_no_stream = f"http://{host}:{port}/v2/models/ensemble/generate"
async def __aenter__(self) -> Self:
import aiohttp
self.client = aiohttp.ClientSession()
return self
async def __aexit__(self, exc_type, exc_value, tb) -> None:
await self.client.close()
async def __call__(self, request_record: RequestRecord) -> RequestRecord:
assert len(request_record.chat_cmpl.messages) == 1
assert isinstance(request_record.chat_cmpl.messages[0].content, str)
payload = {
"accumulate_tokens": True,
"text_input": request_record.chat_cmpl.messages[0].content,
"temperature": (
max(request_record.chat_cmpl.temperature, 1e-5)
if request_record.chat_cmpl.temperature
else 1
),
"top_p": request_record.chat_cmpl.top_p if request_record.chat_cmpl.top_p else 1,
"max_tokens": request_record.chat_cmpl.max_tokens,
"stream": request_record.chat_cmpl.stream,
}
if (
request_record.chat_cmpl.debug_config is not None
and request_record.chat_cmpl.debug_config.ignore_eos
):
payload["min_length"] = payload["max_tokens"]
if self.timeout is not None and "timeout" not in payload:
payload["timeout"] = self.timeout
generated_text = ""
first_chunk_output_str = ""
url = self.url_stream if request_record.chat_cmpl.stream else self.url_no_stream
time_to_first_token_s = None
start_time = time.monotonic()
try:
async with self.client.post(url, json=payload) as response:
assert response.status == 200, await response.text()
if payload["stream"]:
async for chunk in response.content:
chunk = chunk.strip()
if not chunk or chunk == b"\n":
continue
# Get rid of the prefix "data:" and suffix "\n"
raw_data = chunk[5:].strip()
data = json.loads(raw_data)
delta = data["text_output"]
if delta is None:
continue
if not time_to_first_token_s:
time_to_first_token_s = time.monotonic() - start_time
first_chunk_output_str = delta
generated_text += delta
else:
data = await response.json()
generated_text = data["text_output"]
except Exception:
error_msg = "API endpoint errored when sending request: " + traceback.format_exc()
logger.info(error_msg)
finish_time = time.monotonic()
request_record.output_str = generated_text
request_record.first_chunk_output_str = first_chunk_output_str
request_record.metrics = Metrics(
success=False,
start_time=start_time,
finish_time=finish_time,
end_to_end_latency_s=finish_time - start_time,
input_tokens=request_record.metrics.input_tokens,
time_to_first_token_s=time_to_first_token_s,
exec_feature=request_record.metrics.exec_feature,
)
request_record.error_msg = error_msg
return request_record
finish_time = time.monotonic()
request_record.output_str = generated_text
request_record.first_chunk_output_str = first_chunk_output_str
success = True
error_msg = None
if len(generated_text) == 0:
success = False
error_msg = "Empty generated text."
request_record.metrics = Metrics(
success=success,
start_time=start_time,
finish_time=finish_time,
end_to_end_latency_s=finish_time - start_time,
input_tokens=request_record.metrics.input_tokens,
time_to_first_token_s=time_to_first_token_s,
exec_feature=request_record.metrics.exec_feature,
)
request_record.error_msg = error_msg
return request_record
# Todo: APIEndPoint with AsyncOpenAI Python interface
# class OpenAIPythonEndPoint(APIEndPoint):
# pass
SUPPORTED_BACKENDS = [
"openai",
"openai-chat",
"mlc",
"sglang",
"tensorrt-llm",
"vllm",
]
def create_api_endpoint(args: argparse.Namespace) -> APIEndPoint:
"""Create an API endpoint instance with regard to the specified endpoint kind."""
if args.api_endpoint in ["openai", "mlc", "sglang"]:
return OpenAIEndPoint(args.host, args.port, args.timeout, args.include_server_metrics)
if args.api_endpoint == "vllm":
return OpenAIEndPoint(
args.host,
args.port,
args.timeout,
include_server_metrics=False,
no_debug_config=True,
)
if args.api_endpoint == "openai-chat":
return OpenAIChatEndPoint(args.host, args.port, args.timeout, args.include_server_metrics)
if args.api_endpoint == "tensorrt-llm":
return TensorRTLLMEndPoint(args.host, args.port, args.timeout)
raise ValueError(f'Unrecognized endpoint "{args.api_endpoint}"')
+878
View File
@@ -0,0 +1,878 @@
"""MLC LLM benchmark dataset classes"""
import argparse
import json
import random
from datetime import datetime
from typing import ClassVar, Dict, List, Optional, Tuple # noqa: UP035
import numpy as np
import pandas as pd
from datasets import load_dataset
from transformers import AutoTokenizer
from mlc_llm.bench.request_record import GroupedRequestRecord, Metrics, RequestRecord
from mlc_llm.protocol.openai_api_protocol import (
ChatCompletionMessage,
ChatCompletionRequest,
DebugConfig,
)
class Dataset:
"""The dataset base class."""
# We set a truncation limit of 100k.
truncate_length = int(1e5)
# For some that datasets (e.g., dataset that has shared common prefix),
# we need fake warmup requests to avoid prefilling common prefixes to the engine.
require_fake_warmup: bool = False
# Whether the dataset contains timestamps already.
# If the dataset comes with timestamps, the benchmark can just replay
# the requests according to their timestamps.
timestamp_available: bool = False
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
"""Get the raw unprocessed request records of the dataset."""
raise NotImplementedError()
class ShareGPTDataset(Dataset):
"""The dataset class for ShareGPT dataset."""
_tokenized_dataset: List[Tuple[str, List[int], int]] # noqa: UP006
apply_chat_template: bool
def __init__(
self, dataset_path: str, tokenizer: AutoTokenizer, apply_chat_template: bool
) -> None:
self.apply_chat_template = apply_chat_template
with open(dataset_path, encoding="utf-8") as f:
raw_dataset = json.load(f)
# Filter out the conversations with less than 2 turns.
_dataset = [
(data["conversations"][0]["value"], data["conversations"][1]["value"])
for data in raw_dataset
if len(data["conversations"]) >= 2 and data["conversations"][0]["from"] == "human"
]
# Tokenize the prompts and completions.
self.tokenizer = tokenizer
prompts = [prompt for prompt, _ in _dataset]
if apply_chat_template:
assert getattr(tokenizer, "chat_template", None) is not None, (
'"--apply-chat-template" is set but the tokenizer does not have chat template.'
)
prompts = [
tokenizer.apply_chat_template(
[{"role": "user", "content": prompt}],
add_generation_prompt=True,
tokenize=False,
)
for prompt in prompts
]
prompt_token_ids = list(
tokenizer(
prompts,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
)
completions = [completion for _, completion in _dataset]
completion_token_ids = tokenizer(
completions,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
self._tokenized_dataset: List[Tuple[str, List[int], int]] = [] # noqa: UP006
for i in range(len(_dataset)):
if (
len(prompt_token_ids[i]) < 4
or len(completion_token_ids[i]) < 4
or len(prompt_token_ids[i]) + len(completion_token_ids[i])
>= min(tokenizer.model_max_length, 8192)
):
# Filter out sequences that are too short or too long
continue
self._tokenized_dataset.append(
(prompts[i], prompt_token_ids[i], len(completion_token_ids[i]))
)
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
if self.apply_chat_template:
assert input_len is None, (
'"--apply-chat-template" is not supported when "--input-len" is specified.'
)
request_records = []
for prompt, input_token_ids, output_length in self._tokenized_dataset:
input_length = len(input_token_ids)
# If the request does not have enough length, discard it.
if input_len is not None and input_length < input_len + 4 * input_len_std:
continue
if input_len is not None:
input_length = round(
float(np.random.normal(loc=input_len, scale=input_len_std, size=1)[0])
)
input_token_ids = input_token_ids[:input_length]
input_truncated = True
else:
input_truncated = False
if output_len is not None:
output_length = round(
float(np.random.normal(loc=output_len, scale=output_len_std, size=1)[0])
)
elif output_length <= 1:
continue
request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[
{
"role": "user",
"content": (
self.tokenizer.decode(input_token_ids)
if input_truncated
else prompt
),
}
],
model="",
max_tokens=output_length,
),
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=len(input_token_ids),
),
)
)
return request_records
class LoogleDataset(Dataset):
"""The dataset class for Loogle dataset."""
task2prompt: ClassVar[Dict[str, str]] = { # noqa: UP006
"shortdep_qa": "Please answer the question based on the long texts below. \n{input}\nQuestion: {Q}\nAnswer: ", # noqa: E501
"longdep_qa": "Please answer the question based on the long texts below. \n{input}\nQuestion: {Q}\nAnswer: ", # noqa: E501
"longdep_summarization": "Please generate a summary of the below paper. \n{input}\n Summarization: ", # noqa: E501
"shortdep_cloze": "Please fill in the clozes based on the given long texts below. Each of the placeholder '<mask-n>' in the question could be an entity of Person, Location or Organiocation. The same masks represent the same entity. Output a json format answer, for example: {{'<mask-0>': 'Bob', '<mask-1>': 'Gorrosion Magazine','<mask-2>': 'Bethel Horizon'}}\n{input}\n Question: {Q} What are the masked entities? \nAnswer:", # noqa: E501
}
require_fake_warmup: bool = True
def __init__(self, tokenizer: AutoTokenizer, testset_name: str) -> None:
raw_dataset = load_dataset("bigainlco/LooGLE", testset_name, split="test")
self.tokenizer = tokenizer
self.dataset = []
self.prompt_format = self.task2prompt[testset_name]
prompts = []
generate_lens = []
questions = []
for data in raw_dataset:
prompt = data["input"]
prompts.append(prompt)
qa_pairs = eval(data["qa_pairs"])
questions.append([j["Q"] for j in qa_pairs])
generate_lens.append(
[len(tokenizer.encode(j["A"], add_special_tokens=False)) for j in qa_pairs]
)
prompt_token_ids = tokenizer(
prompts,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
for prompt, prompt_token_id, question, generate_len in zip(
prompts, prompt_token_ids, questions, generate_lens
):
self.dataset.append((prompt, prompt_token_id, question, generate_len))
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
request_records = []
for prompt, input_token_ids, questions, generate_lens in self.dataset:
input_length = round(float(np.random.normal(loc=input_len, scale=input_len_std)))
if len(input_token_ids) > input_length:
input_token_ids = input_token_ids[:input_length]
prompt = self.tokenizer.decode(input_token_ids)
grouped_request_records = []
for question, generate_len in zip(questions, generate_lens):
json_obj = {"input": prompt, "Q": question}
full_prompt = self.prompt_format.format(**json_obj)
output_length = (
round(float(np.random.normal(loc=output_len, scale=output_len_std, size=1)[0]))
if output_len is not None
else generate_len
)
grouped_request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[
{
"role": "user",
"content": full_prompt,
}
],
model="",
max_tokens=output_length,
),
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=len(input_token_ids),
),
)
)
request_records.append(
GroupedRequestRecord(
# Create a dummy ChatCompletionRequest.
chat_cmpl=ChatCompletionRequest(messages=[]),
records=grouped_request_records,
)
)
return request_records
class LLMPerfDataset(Dataset):
"""The dataset class for LLMPerf dataset."""
def __init__(self, dataset_path: str, num_requests: int, tokenizer: AutoTokenizer) -> None:
self.tokenizer = tokenizer
self.num_requests = num_requests
with open(dataset_path, encoding="utf-8") as f:
untokenized_data = f.readlines()
# Tokenize the prompts and completions.
tokenized_data = tokenizer(
untokenized_data,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
tokenized_data_lengths = [len(tokens) for tokens in tokenized_data]
self.dataset: List[Tuple[str, List[int], int]] = list( # noqa: UP006
zip(untokenized_data, tokenized_data, tokenized_data_lengths)
)
def generate_request_records(
self,
input_len: Optional[int] = None,
output_len: Optional[int] = None,
input_len_std: float = 250,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
if input_len is None or input_len < 40:
input_len = 550
if output_len is None:
output_len = 150
request_records = []
for _ in range(self.num_requests):
input_length = round(float(np.random.normal(loc=input_len, scale=input_len_std)))
output_length = round(float(np.random.normal(loc=output_len, scale=output_len_std)))
prompt = (
"Randomly stream lines from the following text "
f"with {output_length} output tokens. "
"Don't generate eos tokens:\n\n"
)
remaining_token_length = input_length - len(
self.tokenizer.encode(prompt, add_special_tokens=False)
)
random.shuffle(self.dataset)
while remaining_token_length > 0:
for text, tokens, token_length in self.dataset:
if remaining_token_length < token_length:
prompt += self.tokenizer.decode(tokens[:remaining_token_length])
else:
prompt += text
remaining_token_length -= token_length
if remaining_token_length < 0:
break
request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[{"role": "user", "content": prompt}],
model="",
max_tokens=output_length,
debug_config=DebugConfig(ignore_eos=True),
),
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=input_length,
),
)
)
return request_records
class JSONModeEvalDataset(Dataset):
"""The dataset class for JSON dataset."""
def __init__(self, tokenizer: AutoTokenizer) -> None:
raw_dataset = load_dataset("NousResearch/json-mode-eval")
self.tokenizer = tokenizer
self.dataset = []
for data in raw_dataset["train"]:
messages = data["prompt"]
schema = {
"type": "json_object",
"schema": data["schema"],
}
num_tokens = 0
for message in messages:
num_tokens += len(
self.tokenizer.encode(message["content"], add_special_tokens=False)
)
self.dataset.append((messages, schema, num_tokens))
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
request_records = []
for messages, schema, num_tokens in self.dataset:
# If the request does not have enough length, discard it.
if input_len is not None and num_tokens < input_len + 4 * input_len_std:
continue
if output_len is not None:
output_length = max(
round(np.random.normal(loc=output_len, scale=output_len_std)), 1
)
else:
output_length = None
request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[
ChatCompletionMessage(content=message["content"], role=message["role"])
for message in messages
],
model="",
max_tokens=output_length,
response_format=schema,
),
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=num_tokens,
),
)
)
return request_records
class ReActDataset(Dataset):
"""The dataset class for replaying a given ReAct trace for benchmark purpose.
It is not an actual ReAct agent implementation.
"""
_dataset: List[List[Tuple[str, int, int]]] # noqa: UP006
require_fake_warmup: bool = True
prefix: str = """Solve a question answering task with interleaving Thought, Action, Observation steps. Thought can reason about the current situation, and Action can be three types:
(1) Search[entity], which searches the exact entity on Wikipedia and returns the first paragraph if it exists. If not, it will return some similar entities to search.
(2) Lookup[keyword], which returns the next sentence containing keyword in the current passage.
(3) Finish[answer], which returns the answer and finishes the task.
Here are some examples.
Question: What is the elevation range for the area that the eastern sector of the Colorado orogeny extends into?
Thought 1: I need to search Colorado orogeny, find the area that the eastern sector of the Colorado orogeny extends into, then find the elevation range of the area.
Action 1: Search[Colorado orogeny]
Observation 1: The Colorado orogeny was an episode of mountain building (an orogeny) in Colorado and surrounding areas.
Thought 2: It does not mention the eastern sector. So I need to look up eastern sector.
Action 2: Lookup[eastern sector]
Observation 2: (Result 1 / 1) The eastern sector extends into the High Plains and is called the Central Plains orogeny.
Thought 3: The eastern sector of Colorado orogeny extends into the High Plains. So I need to search High Plains and find its elevation range.
Action 3: Search[High Plains]
Observation 3: High Plains refers to one of two distinct land regions:
Thought 4: I need to instead search High Plains (United States).
Action 4: Search[High Plains (United States)]
Observation 4: The High Plains are a subregion of the Great Plains. From east to west, the High Plains rise in elevation from around 1,800 to 7,000 ft (550 to 2,130 m).[3]
Thought 5: High Plains rise in elevation from around 1,800 to 7,000 ft, so the answer is 1,800 to 7,000 ft.
Action 5: Finish[1,800 to 7,000 ft]
Question: Musician and satirist Allie Goertz wrote a song about the "The Simpsons" character Milhouse, who Matt Groening named after who?
Thought 1: The question simplifies to "The Simpsons" character Milhouse is named after who. I only need to search Milhouse and find who it is named after.
Action 1: Search[Milhouse]
Observation 1: Milhouse Mussolini Van Houten is a recurring character in the Fox animated television series The Simpsons voiced by Pamela Hayden and created by Matt Groening.
Thought 2: The paragraph does not tell who Milhouse is named after, maybe I can look up "named after".
Action 2: Lookup[named after]
Observation 2: (Result 1 / 1) Milhouse was named after U.S. president Richard Nixon, whose middle name was Milhous.
Thought 3: Milhouse was named after U.S. president Richard Nixon, so the answer is Richard Nixon.
Action 3: Finish[Richard Nixon]
Question: Which documentary is about Finnish rock groups, Adam Clayton Powell or The Saimaa Gesture?
Thought 1: I need to search Adam Clayton Powell and The Saimaa Gesture, and find which documentary is about Finnish rock groups.
Action 1: Search[Adam Clayton Powell]
Observation 1: Could not find [Adam Clayton Powell]. Similar: ['Adam Clayton Powell III', 'Seventh Avenue (Manhattan)', 'Adam Clayton Powell Jr. State Office Building', 'Isabel Washington Powell', 'Adam Powell', 'Adam Clayton Powell (film)', 'Giancarlo Esposito'].
Thought 2: To find the documentary, I can search Adam Clayton Powell (film).
Action 2: Search[Adam Clayton Powell (film)]
Observation 2: Adam Clayton Powell is a 1989 American documentary film directed by Richard Kilberg.
The film is about the rise and fall of influential African-American politician Adam Clayton Powell Jr.[3][4] It was later aired as part of the PBS series The American Experience.
Thought 3: Adam Clayton Powell (film) is a documentary about an African-American politician, not Finnish rock groups. So the documentary about Finnish rock groups must instead be The Saimaa Gesture.
Action 3: Finish[The Saimaa Gesture]
Question: What profession does Nicholas Ray and Elia Kazan have in common?
Thought 1: I need to search Nicholas Ray and Elia Kazan, find their professions, then find the profession they have in common.
Action 1: Search[Nicholas Ray]
Observation 1: Nicholas Ray (born Raymond Nicholas Kienzle Jr., August 7, 1911 June 16, 1979) was an American film director, screenwriter, and actor best known for the 1955 film Rebel Without a Cause.
Thought 2: Professions of Nicholas Ray are director, screenwriter, and actor. I need to search Elia Kazan next and find his professions.
Action 2: Search[Elia Kazan]
Observation 2: Elia Kazan was an American film and theatre director, producer, screenwriter and actor.
Thought 3: Professions of Elia Kazan are director, producer, screenwriter, and actor. So profession Nicholas Ray and Elia Kazan have in common is director, screenwriter, and actor.
Action 3: Finish[director, screenwriter, actor]
Question: Which magazine was started first Arthur's Magazine or First for Women?
Thought 1: I need to search Arthur's Magazine and First for Women, and find which was started first.
Action 1: Search[Arthur's Magazine]
Observation 1: Arthur's Magazine (1844-1846) was an American literary periodical published in Philadelphia in the 19th century.
Thought 2: Arthur's Magazine was started in 1844. I need to search First for Women next.
Action 2: Search[First for Women]
Observation 2: First for Women is a woman's magazine published by Bauer Media Group in the USA.[1] The magazine was started in 1989.
Thought 3: First for Women was started in 1989. 1844 (Arthur's Magazine) < 1989 (First for Women), so Arthur's Magazine was started first.
Action 3: Finish[Arthur's Magazine]
Question: Were Pavel Urysohn and Leonid Levin known for the same type of work?
Thought 1: I need to search Pavel Urysohn and Leonid Levin, find their types of work, then find if they are the same.
Action 1: Search[Pavel Urysohn]
Observation 1: Pavel Samuilovich Urysohn (February 3, 1898 â August 17, 1924) was a Soviet mathematician who is best known for his contributions in dimension theory.
Thought 2: Pavel Urysohn is a mathematician. I need to search Leonid Levin next and find its type of work.
Action 2: Search[Leonid Levin]
Observation 2: Leonid Anatolievich Levin is a Soviet-American mathematician and computer scientist.
Thought 3: Leonid Levin is a mathematician and computer scientist. So Pavel Urysohn and Leonid Levin have the same type of work.
Action 3: Finish[yes]
""" # noqa: E501, RUF001
def __init__(self, dataset_path: str, tokenizer: AutoTokenizer) -> None:
raw_entries: List[Dict] = [] # noqa: UP006
with open(dataset_path) as fin:
for line in fin:
line_content = json.loads(line)
raw_entries += list({"question": k, "triplets": v} for k, v in line_content.items())
self._dataset = []
max_rounds = 0
for raw_entry in raw_entries:
processed_entry = []
question = raw_entry["question"]
triplets = raw_entry["triplets"]
seq = self.prefix + question
max_rounds = max(max_rounds, len(triplets) + 1)
output_lengths: List[int] = [] # noqa: UP006
for i, triplet in enumerate(triplets):
output_lengths.append(
len(
tokenizer(
triplet["thought"]
+ "\nAction "
+ str(i + 1)
+ ": "
+ triplet["action"]
+ "\n",
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
)
)
for i in range(1, len(triplets) + 2):
seq += "Thought " + str(i) + ":"
input_len = len(
tokenizer(
seq,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
)
output_length = (
output_lengths[i - 1]
if i <= len(triplets)
else int(sum(output_lengths) / len(triplets))
)
processed_entry.append((seq, input_len, output_length))
if i != len(triplets) + 1:
seq += (
triplets[i - 1]["thought"]
+ "\nAction "
+ str(i)
+ ": "
+ triplets[i - 1]["action"]
+ "\nObservation "
+ str(i)
+ ": "
+ triplets[i - 1]["observation"]
+ "\n"
)
self._dataset.append(processed_entry)
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
if input_len is not None or output_len is not None:
raise ValueError("ReAct dataset does not support specifying input/output length.")
request_records = []
for processed_entries in self._dataset:
grouped_request_records = []
for prompt, input_length, output_length in processed_entries:
grouped_request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[{"role": "user", "content": prompt}],
model="",
max_tokens=output_length,
),
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=input_length,
),
)
)
request_records.append(
GroupedRequestRecord(
# Create a dummy ChatCompletionRequest.
chat_cmpl=ChatCompletionRequest(messages=[]),
records=grouped_request_records,
)
)
return request_records
class WildChatDataset(Dataset):
"""The dataset class for WildChat dataset."""
apply_chat_template: bool
def __init__(self, tokenizer: AutoTokenizer, apply_chat_template: bool) -> None:
raw_dataset = load_dataset("allenai/WildChat", split="train")
self.tokenizer = tokenizer
self.apply_chat_template = apply_chat_template
# Filter out the conversations with less than 2 turns.
_dataset = [
(entry["conversation"][0]["content"], entry["conversation"][1]["content"])
for entry in raw_dataset
if len(entry["conversation"]) >= 2
and entry["conversation"][0]["role"] == "user"
and entry["conversation"][1]["role"] == "assistant"
]
prompts = []
completions = []
for prompt, completion in _dataset:
prompts.append(prompt)
completions.append(completion)
if apply_chat_template:
assert getattr(tokenizer, "chat_template", None) is not None, (
'"--apply-chat-template" is set but the tokenizer does not have chat template.'
)
prompts = [
tokenizer.apply_chat_template(
[{"role": "user", "content": prompt}],
add_generation_prompt=True,
tokenize=False,
)
for prompt in prompts
]
prompt_token_ids = list(
tokenizer(
prompts,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
)
completion_token_ids = tokenizer(
completions,
truncation=True,
max_length=min(tokenizer.model_max_length, self.truncate_length),
add_special_tokens=False,
).input_ids
self._tokenized_dataset: List[Tuple[str, List[int], int]] = [] # noqa: UP006
for i in range(len(_dataset)):
if len(prompt_token_ids[i]) < 4 or len(completion_token_ids[i]) < 4:
# Filter out sequences that are too short
continue
self._tokenized_dataset.append(
(prompts[i], prompt_token_ids[i], len(completion_token_ids[i]))
)
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
if self.apply_chat_template:
assert input_len is None, (
'"--apply-chat-template" is not supported when "--input-len" is specified.'
)
request_records = []
for prompt, input_token_ids, output_length in self._tokenized_dataset:
input_length = len(input_token_ids)
# If the request does not have enough length, discard it.
if input_len is not None and input_length < input_len + 4 * input_len_std:
continue
if input_len is not None:
input_length = round(
float(np.random.normal(loc=input_len, scale=input_len_std, size=1)[0])
)
input_token_ids = input_token_ids[:input_length]
input_truncated = True
else:
input_truncated = False
if output_len is not None:
output_length = round(
float(np.random.normal(loc=output_len, scale=output_len_std, size=1)[0])
)
elif output_length <= 1:
continue
request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[
{
"role": "user",
"content": (
self.tokenizer.decode(input_token_ids)
if input_truncated
else prompt
),
}
],
model="",
max_tokens=output_length,
),
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=len(input_token_ids),
),
)
)
return request_records
class AzureLLMInferenceDataset(Dataset):
"""The dataset class for AzureLLMInference dataset.
Reference: https://github.com/Azure/AzurePublicDataset
"""
timestamp_available: bool = True
def __init__(self, dataset_path: str, tokenizer: AutoTokenizer) -> None:
df = pd.read_csv(dataset_path)
self.tokenizer = tokenizer
# Filter out the conversations with less than 2 turns.
self.dataset = [
(
entry["TIMESTAMP"],
min(
entry["ContextTokens"],
tokenizer.model_max_length,
self.truncate_length,
),
min(
entry["GeneratedTokens"],
tokenizer.model_max_length,
self.truncate_length,
),
)
for _, entry in df.iterrows()
if entry["ContextTokens"] >= 4 and entry["GeneratedTokens"] >= 4
]
def generate_request_records(
self,
input_len: Optional[int],
output_len: Optional[int],
input_len_std: float = 0.0,
output_len_std: float = 0.0,
) -> List[RequestRecord]: # noqa: UP006
time_fmt = "%Y-%m-%d %H:%M:%S.%f"
start_time = datetime.strptime(self.dataset[0][0][:-1], time_fmt)
request_records = []
for timestamp, input_length, output_length in self.dataset:
# If the request does not have enough length, discard it.
if input_len is not None and input_length < input_len + 4 * input_len_std:
continue
if input_len is not None:
input_length = round(
float(np.random.normal(loc=input_len, scale=input_len_std, size=1)[0])
)
if output_len is not None:
output_length = round(
float(np.random.normal(loc=output_len, scale=output_len_std, size=1)[0])
)
elif output_length <= 1:
continue
prompt_token_ids = [
random.randint(0, self.tokenizer.vocab_size - 1) for _ in range(input_length)
]
while True:
# Adjust the token ids until the retokenization on the decoded string
# matches the required input length.
prompt = self.tokenizer.decode(prompt_token_ids)
retokenized_token_ids = self.tokenizer.encode(prompt, add_special_tokens=False)
if len(retokenized_token_ids) < input_length:
prompt_token_ids = retokenized_token_ids + [
random.randint(0, self.tokenizer.vocab_size - 1)
for _ in range(input_length - len(retokenized_token_ids))
]
elif len(retokenized_token_ids) > input_length:
prompt_token_ids = retokenized_token_ids[:input_length]
else:
break
time_diff = (datetime.strptime(timestamp[:-1], time_fmt) - start_time).total_seconds()
request_records.append(
RequestRecord(
chat_cmpl=ChatCompletionRequest(
messages=[{"role": "user", "content": prompt}],
model="",
max_tokens=output_length,
),
timestamp=time_diff,
metrics=Metrics(
success=False,
start_time=0,
finish_time=0,
end_to_end_latency_s=0,
input_tokens=input_length,
),
)
)
return request_records
SUPPORTED_DATASET = [
"sharegpt",
"llmperf",
"json-mode-eval",
"loogle",
"react",
"wildchat",
"azure-llm-inference",
]
def create_dataset(args: argparse.Namespace, tokenizer: AutoTokenizer) -> Dataset:
"""Create a dataset instance with regard to the specified dataset kind and file path."""
if args.dataset_path is not None and not isinstance(args.dataset_path, str):
raise TypeError(f"Invalid dataset path {args.dataset_path}. Please use a string.")
if args.dataset is None and args.dataset_path is not None:
# Auto-detect the dataset kind by looking into the dataset path.
if "sharegpt" in args.dataset_path.lower():
args.dataset = "sharegpt"
else:
raise ValueError(
f"Unable to detect the dataset kind from dataset path {args.dataset_path}. "
'Please specify the dataset kind via "--dataset".'
)
if args.dataset == "sharegpt":
if args.dataset_path is None:
raise ValueError(
'ShareGPT dataset requires dataset path. Please specify it with "--dataset-path".'
)
return ShareGPTDataset(args.dataset_path, tokenizer, args.apply_chat_template)
if args.dataset == "llmperf":
if args.dataset_path is None:
raise ValueError(
'LLMPerf dataset requires dataset path. Please specify it with "--dataset-path".'
)
assert args.apply_chat_template is False, (
"LLMPerf dataset does not support applying chat template"
)
return LLMPerfDataset(
args.dataset_path,
(args.num_requests + args.num_warmup_requests) * 4,
tokenizer,
)
if args.dataset == "json-mode-eval":
assert args.apply_chat_template is False, (
"JSON mode evaluation does not support applying chat template"
)
return JSONModeEvalDataset(tokenizer)
if args.dataset == "loogle":
if args.dataset_path is None:
raise ValueError(
'Loogle dataset requires a testset name. Please specify it with "--dataset-path".'
)
assert args.apply_chat_template is False, (
"Loogle dataset does not support applying chat template"
)
return LoogleDataset(tokenizer, testset_name=args.dataset_path)
if args.dataset == "react":
if args.dataset_path is None:
raise ValueError(
'ReAct dataset requires dataset path. Please specify it with "--dataset-path".'
)
assert args.apply_chat_template is False, (
"ReAct dataset does not support applying chat template"
)
return ReActDataset(args.dataset_path, tokenizer)
if args.dataset == "wildchat":
return WildChatDataset(tokenizer, args.apply_chat_template)
if args.dataset == "azure-llm-inference":
if args.dataset_path is None:
raise ValueError(
"AzureLLMInference dataset requires dataset path. "
'Please specify it with "--dataset-path".'
)
assert args.apply_chat_template is False, (
"AzureLLMInference dataset does not support applying chat template"
)
return AzureLLMInferenceDataset(args.dataset_path, tokenizer)
raise ValueError(f"Unrecognized dataset {args.dataset}")
+315
View File
@@ -0,0 +1,315 @@
"""Eval GSM8K with MLCEngine."""
import argparse
import asyncio
import json
import random
import re
from datetime import datetime
from pathlib import Path
from typing import List, Literal, Optional # noqa: UP035
import tqdm
from mlc_llm import AsyncMLCEngine
DEVICES = ["cuda", "rocm", "metal", "vulkan"]
ANSWER_TRIGGER = "The answer is"
INVALID_ANS = "[invalid]"
def extract_answer(text: str, regex: re.Pattern, select_index: int) -> str:
"""Extract the answer from the text."""
match_all = regex.findall(text)
if len(match_all) == 0:
return INVALID_ANS
match = match_all[select_index]
if isinstance(match, tuple):
match = next(m for m in match if m)
match_str: str = match.strip()
match_str = match_str.lstrip("$").rstrip(".").replace(",", "")
return match_str
def extract_ground_truth(text: str) -> str:
"""Extract the ground truth from the text."""
return extract_answer(text, re.compile(r"#### (\-?[0-9\.\,]+)"), 0)
def strict_extract_answer(text: str) -> str:
"""Strictly extract the answer from the text."""
return extract_answer(text, re.compile(r"The answer is \$?(\-?[0-9\.\,]+)."), 0)
def flexible_extract_answer(text: str) -> str:
"""Extract the last number from the text."""
return extract_answer(text, re.compile(r"(-?[$0-9.,]{2,})|(-?[0-9]+)"), -1)
def create_few_shot_prompt(n_shot: int, use_cot: bool, random_order=False) -> str:
"""
Create a prompt for the few-shot learning task.
Note
----
The examples are taken from the paper https://arxiv.org/pdf/2201.11903.pdf page 35.
"""
question, chain, answer = [], [], []
question.append(
"There are 15 trees in the grove. "
"Grove workers will plant trees in the grove today. "
"After they are done, there will be 21 trees. "
"How many trees did the grove workers plant today?"
)
chain.append(
"There are 15 trees originally. "
"Then there were 21 trees after some more were planted. "
"So there must have been 21 - 15 = 6."
)
answer.append("6")
question.append(
"If there are 3 cars in the parking lot and 2 more cars arrive, "
"how many cars are in the parking lot?"
)
chain.append("There are originally 3 cars. 2 more cars arrive. 3 + 2 = 5.")
answer.append("5")
question.append(
"Leah had 32 chocolates and her sister had 42. If they ate 35, "
"how many pieces do they have left in total?"
)
chain.append(
"Originally, Leah had 32 chocolates. "
"Her sister had 42. So in total they had 32 + 42 = 74. "
"After eating 35, they had 74 - 35 = 39."
)
answer.append("39")
question.append(
"Jason had 20 lollipops. He gave Denny some lollipops. Now Jason "
"has 12 lollipops. How many lollipops did Jason give to Denny?"
)
chain.append(
"Jason started with 20 lollipops. Then he had 12 after giving some "
"to Denny. So he gave Denny 20 - 12 = 8."
)
answer.append("8")
question.append(
"Shawn has five toys. For Christmas, he got two toys each from his "
"mom and dad. How many toys does he have now?"
)
chain.append(
"Shawn started with 5 toys. If he got 2 toys each from his mom and "
"dad, then that is 4 more toys. 5 + 4 = 9."
)
answer.append("9")
question.append(
"There were nine computers in the server room. Five more computers "
"were installed each day, from monday to thursday. "
"How many computers are now in the server room?"
)
chain.append(
"There were originally 9 computers. For each of 4 days, 5 more "
"computers were added. So 5 * 4 = 20 computers were added. "
"9 + 20 is 29."
)
answer.append("29")
question.append(
"Michael had 58 golf balls. On tuesday, he lost 23 golf balls. On "
"wednesday, he lost 2 more. "
"How many golf balls did he have at the end of wednesday?"
)
chain.append(
"Michael started with 58 golf balls. After losing 23 on tuesday, "
"he had 58 - 23 = 35. After losing 2 more, "
"he had 35 - 2 = 33 golf balls."
)
answer.append("33")
question.append(
"Olivia has $23. She bought five bagels for $3 each. How much money does she have left?"
)
chain.append(
"Olivia had 23 dollars. "
"5 bagels for 3 dollars each will be 5 x 3 = 15 dollars. "
"So she has 23 - 15 dollars left. 23 - 15 is 8."
)
answer.append("8")
index_list = list(range(len(question)))
if random_order:
random.shuffle(index_list)
prompt = ""
for i in index_list[:n_shot]:
if use_cot:
prompt += f"Q: {question[i]}\nA: {chain[i]} {ANSWER_TRIGGER} {answer[i]}.\n\n"
else:
prompt += f"Question: {question[i]}\nAnswer: {ANSWER_TRIGGER} {answer[i]}.\n\n"
return prompt
def create_prompt(question: str, n_shot: int, use_cot: bool, random_order: bool = False) -> str:
"""Create a prompt for the few-shot learning task."""
prompt = create_few_shot_prompt(n_shot, use_cot, random_order)
if use_cot:
prompt += f"Q: {question}\nA:"
else:
prompt += f"Question: {question}\nAnswer:"
return prompt
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser()
parser.add_argument("--model", type=str, required=True)
parser.add_argument(
"--dataset", type=Path, required=True, help="Path to GSM8K test dataset home."
)
parser.add_argument("--device", type=str, choices=["auto", *DEVICES], default="auto")
parser.add_argument("--model-lib", type=str, default=None)
parser.add_argument("--n-shot", type=int, default=8)
parser.add_argument("--disable_cot", action="store_true", default=False)
parser.add_argument("-bs", "--batch-size", type=int, default=16)
parser.add_argument("--log-dir", type=Path, default=None)
return parser.parse_args()
async def send_request(
async_engine: AsyncMLCEngine,
prompts: List[str], # noqa: UP006
semaphore: asyncio.Semaphore,
):
"""Send the calibration requests to the engine."""
tasks = []
async def generate_task(prompt):
async with semaphore:
return await async_engine.completions.create(
prompt=prompt,
stream=False,
max_tokens=512,
stop=["Q:", "Question:"],
temperature=0.0,
)
for prompt in prompts:
task = asyncio.create_task(generate_task(prompt))
tasks.append(task)
return await tqdm.asyncio.tqdm.gather(*tasks)
async def evaluate(
model: str,
device: str,
dataset: Path,
model_lib: Optional[str],
n_shot: int,
use_cot: bool,
batch_size: int,
log_dir: Optional[Path],
):
"""Evaluate GSM8K for the model."""
mode: Literal["local", "interactive", "server"] = (
"server" if batch_size > 4 else "interactive" if batch_size == 1 else "local"
)
async_engine = AsyncMLCEngine(model, device=device, model_lib=model_lib, mode=mode)
with open(dataset / "test.jsonl", encoding="utf-8") as file:
tests = [json.loads(line) for line in file]
prompts = [create_prompt(test["question"], n_shot, use_cot) for test in tests]
responses = await send_request(async_engine, prompts, asyncio.Semaphore(batch_size))
assert len(responses) == len(tests)
num_strict_correct, num_flexible_correct = 0, 0
num_tests = len(tests)
logs = []
for response, test in zip(responses, tests):
response_text = response.choices[0].text.strip()
gt_answer = extract_ground_truth(test["answer"])
assert gt_answer != INVALID_ANS
strict_answer = strict_extract_answer(response_text)
flexible_answer = flexible_extract_answer(response_text)
if gt_answer == strict_extract_answer(response_text):
# If the answer is exactly the same as the response, then it is correct
num_strict_correct += 1
num_flexible_correct += 1
elif gt_answer == flexible_extract_answer(response_text):
# Try flexible extract if the strict match fails
num_flexible_correct += 1
logs.append(
{
"question": test["question"],
"response": response_text,
"ground_truth": gt_answer,
"strict_answer": strict_answer,
"flexible_answer": flexible_answer,
"strict_match": gt_answer == strict_answer,
"flexible_match": gt_answer == flexible_answer,
}
)
results = {
"config": {
"model": model,
"device": device,
"model_lib": model_lib,
"n_shot": n_shot,
"use_cot": use_cot,
},
"results": {
"strict_match": num_strict_correct,
"flexible_match": num_flexible_correct,
"total": num_tests,
},
}
print(
f"Strict Matching Accuracy: {num_strict_correct} / {num_tests} = "
f"{num_strict_correct / num_tests * 100:.2f}%"
)
print(
f"Flexible Matching Accuracy: {num_flexible_correct} / {num_tests} = "
f"{num_flexible_correct / num_tests * 100:.2f}%"
)
if log_dir:
with open(log_dir / "summary.json", "w", encoding="utf-8") as f:
json.dump(results, f, indent=2)
with open(log_dir / "logs.json", "w", encoding="utf-8") as f:
json.dump(logs, f, indent=2)
if __name__ == "__main__":
args = parse_args()
start_time = datetime.now()
log_dir: Optional[Path] = None
if args.log_dir is not None:
time_dir = start_time.strftime("%Y-%m-%d_%H-%M-%S")
log_dir = args.log_dir / time_dir
log_dir.mkdir(parents=True, exist_ok=True)
asyncio.run(
evaluate(
model=args.model,
device=args.device,
dataset=args.dataset,
model_lib=args.model_lib,
n_shot=args.n_shot,
use_cot=not args.disable_cot,
batch_size=args.batch_size,
log_dir=log_dir,
)
)
end_time = datetime.now()
print(f"Time used: {end_time - start_time}")
+245
View File
@@ -0,0 +1,245 @@
"""Eval MMLU with MLCEngine."""
import argparse
import asyncio
import csv
import json
import string
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional # noqa: UP035
import numpy as np
import tqdm
from mlc_llm import AsyncMLCEngine
SUBJECTS = [
"abstract_algebra",
"anatomy",
"astronomy",
"business_ethics",
"clinical_knowledge",
"college_biology",
"college_chemistry",
"college_computer_science",
"college_mathematics",
"college_medicine",
"college_physics",
"computer_security",
"conceptual_physics",
"econometrics",
"electrical_engineering",
"elementary_mathematics",
"formal_logic",
"global_facts",
"high_school_biology",
"high_school_chemistry",
"high_school_computer_science",
"high_school_european_history",
"high_school_geography",
"high_school_government_and_politics",
"high_school_macroeconomics",
"high_school_mathematics",
"high_school_microeconomics",
"high_school_physics",
"high_school_psychology",
"high_school_statistics",
"high_school_us_history",
"high_school_world_history",
"human_aging",
"human_sexuality",
"international_law",
"jurisprudence",
"logical_fallacies",
"machine_learning",
"management",
"marketing",
"medical_genetics",
"miscellaneous",
"moral_disputes",
"moral_scenarios",
"nutrition",
"philosophy",
"prehistory",
"professional_accounting",
"professional_law",
"professional_medicine",
"professional_psychology",
"public_relations",
"security_studies",
"sociology",
"us_foreign_policy",
"virology",
"world_religions",
]
PADDING_LEN = max(len(subject) for subject in SUBJECTS)
DEVICES = ["cuda", "rocm", "metal", "vulkan"]
PROMPT_TEMPLATE = string.Template("$Q\nA. $A\nB. $B\nC. $C\nD. $D\nAnswer:")
def parse_args():
"""Parse command line arguments."""
parser = argparse.ArgumentParser()
parser.add_argument("--model", type=str, required=True)
parser.add_argument(
"--dataset", type=Path, required=True, help="Path to MMLU test dataset home."
)
parser.add_argument("--device", type=str, choices=["auto", *DEVICES], default="auto")
parser.add_argument("--model-lib", type=str, default=None)
parser.add_argument("-s", "--subject", nargs="+", type=str, choices=SUBJECTS, default=SUBJECTS)
parser.add_argument("-bs", "--batch-size", type=int, default=16)
parser.add_argument("--log-dir", type=Path, default=None)
return parser.parse_args()
async def send_request(
async_engine: AsyncMLCEngine,
prompts: List[str], # noqa: UP006
semaphore: asyncio.Semaphore,
subject: str,
):
"""Send the calibration requests to the engine."""
tasks = []
async def generate_task(prompt):
async with semaphore:
return await async_engine.completions.create(
prompt=prompt,
stream=False,
max_tokens=1,
temperature=1.0,
logprobs=True,
top_logprobs=5,
)
for prompt in prompts:
task = asyncio.create_task(generate_task(prompt))
tasks.append(task)
return await tqdm.asyncio.tqdm.gather(
*tasks,
desc=f"Running {subject.ljust(PADDING_LEN)}",
bar_format="{desc} {percentage:3.0f}%|{bar}{r_bar}",
)
async def evaluate(
model: str,
device: str,
dataset: Path,
model_lib: Optional[str],
subjects: List[str], # noqa: UP006
semaphore: asyncio.Semaphore,
log_dir: Optional[Path],
):
"""Evaluate MMLU for the model."""
async_engine = AsyncMLCEngine(model, device=device, model_lib=model_lib, mode="server")
results: Dict[str, Any] = {} # noqa: UP006
for subject in subjects:
with open(dataset / "test" / f"{subject}_test.csv", encoding="utf-8") as csvfile:
tests = list(csv.reader(csvfile, delimiter=",", quotechar='"'))
assert all(len(test) == 6 for test in tests)
logs = []
num_correct = 0
prompts = [
PROMPT_TEMPLATE.substitute(Q=test[0], A=test[1], B=test[2], C=test[3], D=test[4])
for test in tests
]
responses = await send_request(async_engine, prompts, semaphore, subject)
assert len(responses) == len(tests)
for response, test in zip(responses, tests):
token_logprobs = {}
logprobs = response.choices[0].logprobs.content[0].top_logprobs
for logprob in logprobs:
if logprob.token not in token_logprobs:
token_logprobs[logprob.token] = logprob.logprob
abcd_logprobs = {}
for choice in ["A", "B", "C", "D"]:
abcd_logprobs[choice] = token_logprobs[choice] if choice in token_logprobs else -100
pred = {0: "A", 1: "B", 2: "C", 3: "D"}[int(np.argmax(list(abcd_logprobs.values())))]
num_correct += pred == test[5]
logs.append(
{
"Question": {
"Q": test[0],
"A": test[1],
"B": test[2],
"C": test[3],
"D": test[4],
},
"Answer": test[5],
"Response": {
"pred": pred,
"logprobs": list(abcd_logprobs.values()),
},
}
)
results[subject] = {
"correct": num_correct,
"total": len(tests),
"accuracy": num_correct / len(tests),
}
if log_dir:
with open(log_dir / "subjects" / f"{subject}.json", "w", encoding="utf-8") as f:
json.dump(logs, f, indent=2)
total_correct, total_tests = 0, 0
for subject, v in results.items():
num_correct, num_tests, accuracy = v["correct"], v["total"], v["accuracy"]
print(f"{subject}: {num_correct} / {num_tests} = {accuracy * 100:.2f}%")
total_correct += num_correct
total_tests += num_tests
total_accuracy = total_correct / total_tests
results["total"] = {
"correct": total_correct,
"total": total_tests,
"accuracy": total_accuracy,
}
print(f"Total accuracy: {total_correct} / {total_tests} = {total_accuracy * 100:.2f}%")
if log_dir:
results = {
"config": {
"model": model,
"device": device,
"model_lib": model_lib,
"subjects": subjects,
},
"results": results,
}
with open(log_dir / "summary.json", "w", encoding="utf-8") as f:
json.dump(results, f, indent=2)
if __name__ == "__main__":
args = parse_args()
start_time = datetime.now()
log_dir: Optional[Path] = None
if args.log_dir is not None:
time_dir = start_time.strftime("%Y-%m-%d_%H-%M-%S")
log_dir = args.log_dir / time_dir
(log_dir / "subjects").mkdir(parents=True, exist_ok=True)
asyncio.run(
evaluate(
model=args.model,
device=args.device,
dataset=args.dataset,
model_lib=args.model_lib,
subjects=args.subject,
semaphore=asyncio.Semaphore(args.batch_size),
log_dir=log_dir,
)
)
end_time = datetime.now()
print(f"Time used: {end_time - start_time}")
+740
View File
@@ -0,0 +1,740 @@
"""MLC LLM Bench Request"""
import argparse
import asyncio
import concurrent.futures
import copy
import os
import random
import time
from typing import Any, Callable, Dict, List, Optional # noqa: UP035
import numpy as np
import requests
from tqdm import tqdm
from transformers import AutoTokenizer
from mlc_llm.bench.api_endpoint import APIEndPoint
from mlc_llm.bench.dataset import Dataset
from mlc_llm.bench.request_record import GroupedRequestRecord, RequestRecord
from mlc_llm.protocol.openai_api_protocol import (
ChatCompletionMessage,
ChatCompletionRequest,
DebugConfig,
)
from mlc_llm.support import logging
logger = logging.getLogger(__name__)
class RequestProcessor:
"""The request processor base class.
Each processor can take a list of RequestRecord, applying the process,
and returning the processed RequestRecord in the end.
"""
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
raise NotImplementedError()
class LogMessage(RequestProcessor):
"""The processor that prints the logger message."""
def __init__(self, message: str) -> None:
self.message = message
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
logger.info(self.message)
return request_records
class SampleRequests(RequestProcessor):
"""The processor that samples requests out from the given request list."""
def __init__(self, num_requests: int, take_first_x_requests: bool = False) -> None:
self.num_requests = num_requests
# If `take_first_x_requests` is True, the first `num_requests` requests
# are returned and sampling will not happen.
self.take_first_x_requests = take_first_x_requests
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
assert len(request_records) > 0, "Empty input request record."
# We expect the input request records to be all grouped or all plain.
if isinstance(request_records[0], GroupedRequestRecord):
assert all(isinstance(record, GroupedRequestRecord) for record in request_records)
return self._sample_from_grouped_request_records(request_records)
assert all(not isinstance(record, GroupedRequestRecord) for record in request_records)
return self._sample_from_plain_request_records(request_records)
def _sample_from_plain_request_records(
self,
request_records: List[RequestRecord], # noqa: UP006
) -> List[RequestRecord]: # noqa: UP006
samples: List[RequestRecord] = [] # noqa: UP006
if self.take_first_x_requests:
if len(request_records) < self.num_requests:
raise ValueError(
f"Insufficient requests. Requiring {self.num_requests} requests "
f"but only {len(request_records)} are available."
)
samples = copy.deepcopy(list(request_records[: self.num_requests]))
else:
while len(samples) < self.num_requests:
# Create a new list so that the in-place shuffle does not mutate the input list.
records = list(request_records)
random.shuffle(records)
samples += copy.deepcopy(records)
samples = samples[: self.num_requests]
for i, record in enumerate(samples):
record.request_id = i
return samples
def _sample_from_grouped_request_records(
self,
grouped_request_records: List[GroupedRequestRecord], # noqa: UP006
) -> List[RequestRecord]: # noqa: UP006
num_total_available_requests = sum(
len(record.records) for record in grouped_request_records
)
if self.num_requests > num_total_available_requests:
raise ValueError(
"Due to the existence of shared common prefixes, we do not allow "
"benchmarking with requests more than the available requests in the dataset. "
f"The required number of requests {self.num_requests} exceeds the "
f"number of total available requests {num_total_available_requests}."
)
# Create a new list so that the in-place shuffle does not mutate the input list.
records = list(grouped_request_records)
if not self.take_first_x_requests:
random.shuffle(records)
remaining = self.num_requests
samples: List[RequestRecord] = [] # noqa: UP006
for grouped_request_record in grouped_request_records:
num_used_requests = min(len(grouped_request_record.records), remaining)
samples += grouped_request_record.records[:num_used_requests]
remaining -= num_used_requests
if remaining == 0:
break
for i, record in enumerate(samples):
record.request_id = i
return samples
class AttachModelName(RequestProcessor):
"""The processor that attaches model name to requests."""
def __init__(self, model: str) -> None:
self.model = model
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
for request_record in request_records:
request_record.chat_cmpl.model = self.model
return request_records
class AttachRequestRateTimestamp(RequestProcessor):
"""The processor that applies timestamps to the requests."""
def __init__(self, request_rate: np.float32) -> None:
self.request_rate = request_rate
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
timestamp = 0.0
for request_record in request_records:
assert request_record.timestamp is None, "The request record already has a timestamp"
request_record.timestamp = timestamp
timestamp += float(np.random.exponential(1.0 / self.request_rate))
return request_records
class AttachExecutionFeature(RequestProcessor):
"""The processor that attaches execution features to all requests"""
def __init__(self, exec_feature: Dict[str, Any]) -> None: # noqa: UP006
self.exec_feature = exec_feature
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
for request_record in request_records:
assert request_record.metrics is not None
request_record.metrics.exec_feature = self.exec_feature
return request_records
class AttachStreamFlag(RequestProcessor):
"""The processor that attaches the stream flag to the requests."""
def __init__(self, stream: Optional[bool]) -> None:
self.stream = stream
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
if self.stream is None:
return request_records
for request_record in request_records:
request_record.chat_cmpl.stream = self.stream
return request_records
class AttachSamplingOptions(RequestProcessor):
"""The processor that attaches the stream flag to the requests."""
def __init__(self, temperature: float, top_p: float, ignore_eos: bool) -> None:
self.temperature = temperature
self.top_p = top_p
self.ignore_eos = ignore_eos
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
for request_record in request_records:
request_record.chat_cmpl.temperature = self.temperature
request_record.chat_cmpl.top_p = self.top_p
request_record.chat_cmpl.frequency_penalty = 0.0
request_record.chat_cmpl.presence_penalty = 0.0
request_record.chat_cmpl.tool_choice = "none"
if self.ignore_eos:
request_record.chat_cmpl.debug_config = DebugConfig(ignore_eos=True)
return request_records
class ScaleTimestamp(RequestProcessor):
"""Scale the timestamp of requests by the given scale factor."""
def __init__(self, timestamp_scale: float):
self.timestamp_scale = timestamp_scale
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
for request_record in request_records:
if request_record.timestamp is None:
raise ValueError(
f"The timestamp of request {request_record} has not been initialized."
)
request_record.timestamp *= self.timestamp_scale
return request_records
class MetricAnalyzer(RequestProcessor):
"""The processor that analyzes the raw benchmark results and computes more detailed metrics."""
def __init__(self, tokenizer: AutoTokenizer) -> None:
self.tokenizer = tokenizer
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
updated_records = []
for request_record in request_records:
metrics = request_record.metrics
if not metrics.success:
assert request_record.error_msg is not None
continue
metrics.output_tokens = len(
self.tokenizer.encode(request_record.output_str, add_special_tokens=False)
)
first_chunk_output_tokens = len(
self.tokenizer.encode(
request_record.first_chunk_output_str, add_special_tokens=False
)
)
if metrics.output_tokens <= first_chunk_output_tokens:
metrics.success = False
request_record.error_msg = (
f"Total output token num ({metrics.output_tokens}) equals "
f'the first chunk output token. Output text "{request_record.output_str}", '
f'first chunk output text "{request_record.first_chunk_output_str}"'
)
continue
assert metrics.input_tokens > 0, "Invalid prompt tokens"
metrics.inter_token_latency_s = metrics.end_to_end_latency_s / metrics.output_tokens
if metrics.time_to_first_token_s is None:
metrics.time_to_first_token_s = 0
metrics.time_per_output_token_s = (
metrics.end_to_end_latency_s - metrics.time_to_first_token_s
) / (metrics.output_tokens - first_chunk_output_tokens)
updated_records.append(request_record)
return updated_records
class WarmupAndRun(RequestProcessor):
"""The processor that runs warmup first and then runs the benchmark with the given pipeline."""
def __init__(
self,
num_warmup_requests: int,
num_benchmark_requests: int,
pipeline: RequestProcessor,
cuda_profile_url: Optional[str],
fake_warmup: bool = False,
) -> None:
self.num_warmup_requests = num_warmup_requests
self.num_benchmark_requests = num_benchmark_requests
self.pipeline = pipeline
self.cuda_profile_url = cuda_profile_url
self.fake_warmup = fake_warmup
def generate_fake_warmup_requests(
self, num_warmup_requests: int, example_request: RequestRecord
) -> List[RequestRecord]: # noqa: UP006
records = []
for _ in range(num_warmup_requests):
record = copy.deepcopy(example_request)
record.chat_cmpl = ChatCompletionRequest(
messages=[
{
"role": "user",
"content": "Please output arbitrary coherent sentences. Do not output eos token.", # noqa: E501
}
],
model="",
max_tokens=128,
)
records.append(record)
return records
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
# Warmup
if self.fake_warmup:
assert len(request_records) == self.num_benchmark_requests
benchmark_requests = request_records
example_request = benchmark_requests[0]
warmup_requests = self.generate_fake_warmup_requests(
self.num_warmup_requests, example_request=example_request
)
else:
assert len(request_records) == self.num_warmup_requests + self.num_benchmark_requests
benchmark_requests = request_records[: -self.num_warmup_requests]
warmup_requests = request_records[-self.num_warmup_requests :]
for request_record in warmup_requests:
request_record.timestamp = 0 if request_record.timestamp is not None else None
warmup_requests = self._process_warmup_requests(warmup_requests)
logger.info("Warmup with %d request(s)...", self.num_warmup_requests)
self.pipeline(warmup_requests)
# Then run benchmark
if self.cuda_profile_url is not None:
cuda_profiler_start_url = self.cuda_profile_url + "/debug/cuda_profiler_start"
cuda_profiler_start_response = requests.post(cuda_profiler_start_url, timeout=60)
assert cuda_profiler_start_response.status_code == 200
logger.info("Warmup finished. Start benchmarking...")
updated_request_records = self.pipeline(benchmark_requests)
if self.cuda_profile_url is not None:
cuda_profiler_stop_url = self.cuda_profile_url + "/debug/cuda_profiler_stop"
cuda_profiler_stop_response = requests.post(cuda_profiler_stop_url, timeout=60)
assert cuda_profiler_stop_response.status_code == 200
return updated_request_records
def _process_warmup_requests(self, warmup_requests: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
if len(warmup_requests) == 0:
return warmup_requests
# NOTE: to warm up the server for as more different batch sizes as possible,
# we usese 128 output tokens for the first request and use two more tokens
# for every followup request.
# Setting a high temperature and top-p to avoid early stop as much as possible.
warmup_requests[0].chat_cmpl.max_tokens = 128
for i in range(1, len(warmup_requests)):
warmup_requests[i].chat_cmpl.max_tokens = (
warmup_requests[i - 1].chat_cmpl.max_tokens + 1
)
warmup_requests[i].chat_cmpl.temperature = 2.0
warmup_requests[i].chat_cmpl.top_p = 1.0
return warmup_requests
class SequentialProcessor(RequestProcessor):
"""The processor that sequentially applies a list of processors in order."""
processors: List[RequestProcessor] # noqa: UP006
def __init__(self, *processors: RequestProcessor) -> None:
self.processors = list(processors)
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
for processor in self.processors:
request_records = processor(request_records)
return request_records
class Executor(RequestProcessor):
"""The executor base class, denoting the kind of benchmark mode."""
def __init__(
self,
f_create_api_endpoint: Callable[[], APIEndPoint],
num_processes: int,
disable_tqdm: bool,
) -> None:
self.f_create_api_endpoint = f_create_api_endpoint
self.disable_tqdm = disable_tqdm
self.num_processes = num_processes
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
raise NotImplementedError()
class FixedConcurrentRequestExecutor(Executor):
"""The benchmark executor of fixing the number of concurrent requests."""
def __init__(
self,
f_create_api_endpoint: Callable[[], APIEndPoint],
num_processes: Optional[int],
disable_tqdm: bool,
num_concurrent_requests: int,
multi_round: bool,
) -> None:
if num_processes is None:
# We assign each process at most 32 concurrent requests to send
# so that the asyncio pressure will not be too much.
num_processes = min((num_concurrent_requests + 31) // 32, 10)
super().__init__(f_create_api_endpoint, num_processes, disable_tqdm)
self.num_concurrent_requests = num_concurrent_requests
self.multi_round = multi_round
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
partitions: List[List[RequestRecord]] = [ # noqa: UP006
request_records[slice(i, len(request_records), self.num_processes)]
for i in range(self.num_processes)
]
# Package "tokenizers" reports warnings with multiprocessing.
# We disable "TOKENIZERS_PARALLELISM" to depress the warnings.
os.environ["TOKENIZERS_PARALLELISM"] = "false"
pbar = None if self.disable_tqdm else tqdm(total=len(request_records))
with concurrent.futures.ProcessPoolExecutor(max_workers=self.num_processes) as pool:
futures = [
pool.submit(
FixedConcurrentRequestExecutor._process_task,
self.f_create_api_endpoint,
partition,
self.num_concurrent_requests // self.num_processes
+ int(i < self.num_concurrent_requests % self.num_processes),
self.multi_round,
)
for i, partition in enumerate(partitions)
]
results: List[RequestRecord] = [] # noqa: UP006
for i, future in enumerate(concurrent.futures.as_completed(futures)):
results.extend(future.result())
if pbar is not None:
pbar.update(len(partitions[i]))
return results
@staticmethod
def _process_task(
f_create_api_endpoint: Callable[[], APIEndPoint],
request_records: List[RequestRecord], # noqa: UP006
num_concurrent_requests: int,
multi_round: bool,
) -> List[RequestRecord]: # noqa: UP006
if len(request_records) == 0:
return []
chat_history: List[List[ChatCompletionMessage]] = [ # noqa: UP006
[] for _ in range(num_concurrent_requests)
]
async def process_task_impl(
f_create_api_endpoint: Callable[[], APIEndPoint],
request_records: List[RequestRecord], # noqa: UP006
num_concurrent_requests: int,
multi_round: bool,
) -> List[RequestRecord]: # noqa: UP006
api_endpoint = f_create_api_endpoint()
updated_request_records: List[RequestRecord] = [None for _ in request_records] # noqa: UP006
async with api_endpoint:
num_sent_request = 0
async def _task(i: int) -> None:
nonlocal num_sent_request
while True:
if num_sent_request == len(request_records):
break
idx = num_sent_request
num_sent_request += 1
request = request_records[idx]
if multi_round:
request.chat_cmpl.messages = (
chat_history[i] + request.chat_cmpl.messages
)
updated_request_records[idx] = await api_endpoint(request)
if multi_round:
chat_history[i] = [
*updated_request_records[idx].chat_cmpl.messages,
ChatCompletionMessage(
content=updated_request_records[idx].output_str,
role="assistant",
),
]
tasks = [asyncio.create_task(_task(i)) for i in range(num_concurrent_requests)]
await asyncio.gather(*tasks)
return updated_request_records
return asyncio.run(
process_task_impl(
f_create_api_endpoint,
request_records,
num_concurrent_requests,
multi_round,
)
)
class FixTimestampExecutor(Executor):
"""The benchmark executor of fixing the timestamps of sending requests."""
def __init__(
self,
f_create_api_endpoint: Callable[[], APIEndPoint],
num_processes: Optional[int],
disable_tqdm: bool,
max_schedule_gap: float,
num_requests: int,
) -> None:
if num_processes is None:
# We assign each process at most 32 requests to send
# so that the asyncio pressure will not be too much.
num_processes = min((num_requests + 31) // 32, 10)
super().__init__(f_create_api_endpoint, num_processes, disable_tqdm)
self.max_schedule_gap = max_schedule_gap
self.num_requests = num_requests
def __call__(self, request_records: List[RequestRecord]) -> List[RequestRecord]: # noqa: UP006
assert len(request_records) > 0
assert all(request_record.timestamp is not None for request_record in request_records)
# Sort the request records in timestamp ascending order before partitioning.
request_records.sort(key=lambda request_record: request_record.timestamp)
base_timestamp = request_records[0].timestamp
partitions: List[List[RequestRecord]] = [ # noqa: UP006
request_records[slice(i, len(request_records), self.num_processes)]
for i in range(self.num_processes)
]
base_sys_time = time.time()
# Package "tokenizers" reports warnings with multiprocessing.
# We disable "TOKENIZERS_PARALLELISM" to depress the warnings.
os.environ["TOKENIZERS_PARALLELISM"] = "false"
pbar = None if self.disable_tqdm else tqdm(total=len(request_records))
with concurrent.futures.ProcessPoolExecutor(max_workers=self.num_processes) as pool:
futures = [
pool.submit(
FixTimestampExecutor._process_task,
self.f_create_api_endpoint,
partition,
base_timestamp,
base_sys_time,
self.max_schedule_gap,
)
for partition in partitions
]
results: List[RequestRecord] = [] # noqa: UP006
for i, future in enumerate(concurrent.futures.as_completed(futures)):
results.extend(future.result())
if pbar is not None:
pbar.update(len(partitions[i]))
return results
@staticmethod
def _process_task(
f_create_api_endpoint: Callable[[], APIEndPoint],
request_records: List[RequestRecord], # noqa: UP006
base_timestamp: float,
base_sys_time: float,
max_schedule_gap: float,
) -> List[RequestRecord]: # noqa: UP006
if len(request_records) == 0:
return []
async def process_task_impl(
f_create_api_endpoint: Callable[[], APIEndPoint],
request_records: List[RequestRecord], # noqa: UP006
base_timestamp: float,
base_sys_time: float,
max_schedule_gap: float,
) -> List[RequestRecord]: # noqa: UP006
api_endpoint = f_create_api_endpoint()
loop = asyncio.get_running_loop()
# Get the delta time to convert system time to the loop time.
# We must use the system time `time.time()` which is consistent across processes.
loop_sys_delta_time = loop.time() - time.time()
updated_request_records: List[RequestRecord] = [] # noqa: UP006
async with api_endpoint:
async def _task(request_record: RequestRecord) -> None:
updated_request_records.append(await api_endpoint(request_record))
tasks = []
for request_record in request_records:
launch_time = (
(request_record.timestamp - base_timestamp)
+ (base_sys_time + max_schedule_gap)
+ loop_sys_delta_time
)
loop.call_at(
launch_time,
lambda record: tasks.append(asyncio.create_task(_task(record))),
request_record,
)
# Sleep to allow runs of other scheduled tasks if any.
await asyncio.sleep(max(launch_time - loop.time() - max_schedule_gap, 0))
# Sleep until all the tasks are launched.
await asyncio.sleep(launch_time - loop.time() + max_schedule_gap)
# Wait for all tasks to be scheduled
assert len(tasks) == len(request_records)
await asyncio.gather(*tasks)
assert len(updated_request_records) == len(request_records)
return updated_request_records
return asyncio.run(
process_task_impl(
f_create_api_endpoint,
request_records,
base_timestamp,
base_sys_time,
max_schedule_gap,
)
)
def create_pipelines(
args: argparse.Namespace,
f_create_api_endpoint: Callable[[], APIEndPoint],
dataset: Dataset,
) -> List[RequestProcessor]: # noqa: UP006
"""Creating request processing pipelines with regard to the specified args."""
cuda_profile_url = f"http://{args.host}:{args.port}" if args.cuda_profile else None
pipelines: List[RequestProcessor] = [] # noqa: UP006
if args.num_concurrent_requests is not None:
if args.request_rate is not None:
raise ValueError(
'Both "num_concurrent_requests" and "request_rate" are specified. '
"Please specify only one of them."
)
if args.replay_timestamp_scale is not None:
raise ValueError(
"Dataset replay is unsupported when fixing number of concurrent requests."
)
for num_concurrent_requests in args.num_concurrent_requests:
num_warmup_requests = (
args.num_warmup_requests
if args.num_warmup_requests is not None
else num_concurrent_requests
)
pipelines.append(
SequentialProcessor(
LogMessage(f"Fixing number of concurrent requests: {num_concurrent_requests}"),
SampleRequests(args.num_requests + num_warmup_requests),
AttachModelName(args.tokenizer),
AttachStreamFlag(args.stream),
AttachSamplingOptions(args.temperature, args.top_p, args.ignore_eos),
AttachExecutionFeature({"num_concurrent_requests": num_concurrent_requests}),
WarmupAndRun(
num_warmup_requests=num_warmup_requests,
num_benchmark_requests=args.num_requests,
pipeline=FixedConcurrentRequestExecutor(
f_create_api_endpoint,
args.num_process_workers,
args.disable_tqdm,
num_concurrent_requests,
args.multi_round,
),
cuda_profile_url=cuda_profile_url,
fake_warmup=dataset.require_fake_warmup,
),
)
)
return pipelines
if args.request_rate is not None:
if args.num_warmup_requests is None:
raise ValueError(
"Please specify the number of warmup requests via "
'"--num-warmup-requests" when fixing request rate.'
)
if args.replay_timestamp_scale is not None:
raise ValueError("Dataset replay is unsupported when fixing request rates.")
num_total_requests = int(
args.num_requests if not args.per_gpu_workload else args.num_requests * args.num_gpus
)
if dataset.require_fake_warmup:
num_samples = num_total_requests
else:
num_samples = num_total_requests + args.num_warmup_requests
return [
SequentialProcessor(
LogMessage(f"Fixing request rate: {request_rate}"),
SampleRequests(num_samples),
AttachModelName(args.tokenizer),
AttachRequestRateTimestamp(
request_rate if not args.per_gpu_workload else request_rate * args.num_gpus
),
AttachStreamFlag(args.stream),
AttachSamplingOptions(args.temperature, args.top_p, args.ignore_eos),
AttachExecutionFeature({"request_rate": float(request_rate)}),
WarmupAndRun(
num_warmup_requests=args.num_warmup_requests,
num_benchmark_requests=num_total_requests,
pipeline=FixTimestampExecutor(
f_create_api_endpoint,
args.num_process_workers,
args.disable_tqdm,
args.max_schedule_gap,
args.num_requests,
),
cuda_profile_url=cuda_profile_url,
fake_warmup=dataset.require_fake_warmup,
),
)
for request_rate in args.request_rate
]
# Default: dataset replay mode
# The dataset must come with timestamps.
if not dataset.timestamp_available:
raise ValueError(
"The dataset does not have timestamps, so dataset replay is unsupported. "
'Please specify one of "num_concurrent_requests" '
'and "request_rate".'
)
if args.per_gpu_workload:
raise ValueError("Fixing per-GPU workload is not compatible with dataset replay.")
if args.num_warmup_requests is None:
raise ValueError(
"Please specify the number of warmup requests via "
'"--num-warmup-requests" for dataset replay.'
)
timestamp_scale = args.replay_timestamp_scale or 1.0
if dataset.require_fake_warmup:
num_samples = args.num_requests
else:
num_samples = args.num_requests + args.num_warmup_requests
return [
SequentialProcessor(
LogMessage(f"Dataset replay with time scaling of {timestamp_scale}"),
SampleRequests(num_samples, take_first_x_requests=True),
AttachModelName(args.tokenizer),
ScaleTimestamp(timestamp_scale),
AttachStreamFlag(args.stream),
AttachSamplingOptions(args.temperature, args.top_p, args.ignore_eos),
AttachExecutionFeature({"timestamp_scale": timestamp_scale}),
WarmupAndRun(
num_warmup_requests=args.num_warmup_requests,
num_benchmark_requests=args.num_requests,
pipeline=FixTimestampExecutor(
f_create_api_endpoint,
args.num_process_workers,
args.disable_tqdm,
args.max_schedule_gap,
args.num_requests,
),
cuda_profile_url=cuda_profile_url,
fake_warmup=dataset.require_fake_warmup,
),
)
]
+278
View File
@@ -0,0 +1,278 @@
"""MLC LLM Bench Request"""
from typing import Any, Dict, List, Optional, Tuple, Union # noqa: UP035
import pandas as pd
from pydantic import BaseModel
from mlc_llm.protocol.openai_api_protocol import ChatCompletionRequest
from mlc_llm.support import logging
logger = logging.getLogger(__name__)
class ServerMetrics(BaseModel):
"""The metrics from the server side."""
input_tokens: int
prefill_tokens: int
output_tokens: int
end_to_end_latency_s: float
prefill_tokens_per_s: float
inter_token_latency_s: float
time_per_output_token_s: float
time_to_first_token_s: Optional[float] = None
class Metrics(BaseModel):
"""The list of metric keys"""
success: bool
start_time: float
finish_time: float
end_to_end_latency_s: float
input_tokens: Optional[int] = None
output_tokens: Optional[int] = None
inter_token_latency_s: Optional[float] = None
time_per_output_token_s: Optional[float] = None
time_to_first_token_s: Optional[float] = None
server_metrics: Optional[ServerMetrics] = None
exec_feature: Optional[Dict[str, Any]] = None # noqa: UP006
class RequestRecord(BaseModel):
"""The request records collected from LLM inference requests."""
request_id: Optional[int] = None
chat_cmpl: ChatCompletionRequest
output_str: Optional[str] = None
first_chunk_output_str: str = ""
timestamp: Optional[float] = None
metrics: Optional[Metrics] = None
error_msg: Optional[str] = None
class GroupedRequestRecord(RequestRecord):
"""The data structure for request record groups.
For datasets that have common prefix sharing, the request records
that share a same common prefix will be wrapped in a GroupedRequestRecord
at the beginning.
"""
records: List[RequestRecord] # noqa: UP006
def generate_metrics_summary(
request_records: List[RequestRecord], # noqa: UP006
num_total_requests: int,
num_gpus: int,
) -> Dict[str, Any]: # noqa: UP006
"""Computes summary statistics across all metrics collected.
Return a dictionary as the report.
"""
num_completed_requests = len(request_records)
assert num_completed_requests <= num_total_requests
request_metrics = [record.metrics for record in request_records]
duration = (
max(metrics.finish_time for metrics in request_metrics)
- min(metrics.start_time for metrics in request_metrics)
if num_completed_requests > 0
else 1e-5
)
report = _compute_metrics_statistics(request_metrics)
report["num_gpus"] = num_gpus
report["duration"] = duration
report["num_total_requests"] = num_total_requests
report["num_completed_requests"] = num_completed_requests
report["request_throughput"] = num_completed_requests / duration
total_input_tokens = sum(metric.input_tokens for metric in request_metrics)
total_output_tokens = sum(metric.output_tokens for metric in request_metrics)
report["total_input_tokens"] = total_input_tokens
report["total_output_tokens"] = total_output_tokens
report["input_token_throughput"] = total_input_tokens / duration
report["input_token_throughput_per_gpu"] = report["input_token_throughput"] / num_gpus
report["output_token_throughput"] = total_output_tokens / duration
report["output_token_throughput_per_gpu"] = report["output_token_throughput"] / num_gpus
# Generate the server metrics statistics
server_metrics = [metric.server_metrics for metric in request_metrics if metric.server_metrics]
server_report = _compute_metrics_statistics(server_metrics)
if server_report is not None and len(server_report) > 0:
report["server_metrics"] = server_report
report = {
"exec_feature": (
request_records[0].metrics.exec_feature if num_completed_requests > 0 else None
),
**report,
}
return report
def _compute_metrics_statistics(
metrics: List[Union[Metrics, ServerMetrics]], # noqa: UP006
) -> Dict[str, Any]: # noqa: UP006
"""
Compute the statistics of the metrics.
Parameters
----------
metrics : List[Union[Metrics, ServerMetrics]]
The list of metrics to get the statistics.
Returns
-------
report : Dict
The statistics of the metrics.
"""
if not metrics:
return {}
report: Dict = {} # noqa: UP006
df = pd.DataFrame([metric.model_dump() for metric in metrics])
for key, _ in metrics[0].model_fields.items():
if key in [
"success",
"start_time",
"finish_time",
"server_metrics",
"exec_feature",
]:
continue
if key in df.columns:
series = df[key].dropna()
report[key] = {
"quantiles": {
f"p{int(q * 100)}": v
for q, v in series.quantile([0.25, 0.5, 0.75, 0.9, 0.95, 0.99]).items()
},
"mean": series.mean(),
"min": series.min(),
"max": series.max(),
"stddev": series.std(),
}
return report
def convert_reports_to_df(reports: List[Dict[str, Any]]) -> pd.DataFrame: # noqa: UP006
"""Convert benchmark reports to pandas DataFrame."""
def _flatten_dict(d: Dict[str, Any], parent_key: str = "") -> Dict[str, Any]: # noqa: UP006
items: List[Tuple[str, Any]] = [] # noqa: UP006
for key, value in d.items():
new_key = f"{parent_key}.{key}" if parent_key != "" else key
if isinstance(value, dict):
items.extend(_flatten_dict(value, new_key).items())
else:
items.append((new_key, value))
return dict(items)
return pd.DataFrame([_flatten_dict(report) for report in reports])
def pretty_print_report(report: Dict[str, Any]) -> None: # noqa: UP006
"""Pretty print the metrics report."""
def _print(report: Dict[str, Any], server_metrics: bool): # noqa: UP006
# fmt: off
title = "Benchmark Result"
if server_metrics:
title += " (server side)"
print(f" {title} ".center(50, "="))
if not server_metrics:
print(f"{'Total requests:':<40} {report['num_total_requests']:<10}")
print(f"{'Completed requests:':<40} {report['num_completed_requests']:<10}")
print(f"{'Duration (s):':<40} {report['duration']:<10.2f}")
print(f"{'Num GPUs:':<40} {report['num_gpus']:<10}")
print(f"{'Total input tokens:':<40} {report['total_input_tokens']:<10}")
print(f"{'Total output tokens:':<40} {report['total_output_tokens']:<10}")
print(f"{'Request throughput (req/s):':<40} {report['request_throughput']:<10.2f}")
print(f"{'Input token throughput (tok/s):':<40} {report['input_token_throughput']:<10.2f}") # noqa: E501
print(f"{'Input token throughput per GPU (tok/s):':<40} {report['input_token_throughput_per_gpu']:<10.2f}") # noqa: E501
print(f"{'Output token throughput (tok/s):':<40} {report['output_token_throughput']:<10.2f}") # noqa: E501
print(f"{'Output token throughput per GPU (tok/s):':<40} {report['output_token_throughput_per_gpu']:<10.2f}") # noqa: E501
if report["num_completed_requests"] == 0:
return
ttft = report["time_to_first_token_s"]
print(" Time to First Token (TTFT, ms) ".center(50, "-"))
print(f"{'Mean:':<40} {ttft['mean'] * 1000:<10.2f}")
print(f"{'Stddev:':<40} {ttft['stddev'] * 1000:<10.2f}")
print(f"{'P25:':<40} {ttft['quantiles']['p25'] * 1000:<10.2f}")
print(f"{'P50:':<40} {ttft['quantiles']['p50'] * 1000:<10.2f}")
print(f"{'P75:':<40} {ttft['quantiles']['p75'] * 1000:<10.2f}")
print(f"{'P90:':<40} {ttft['quantiles']['p90'] * 1000:<10.2f}")
print(f"{'P95:':<40} {ttft['quantiles']['p95'] * 1000:<10.2f}")
print(f"{'P99:':<40} {ttft['quantiles']['p99'] * 1000:<10.2f}")
print(f"{'Min:':<40} {ttft['min'] * 1000:<10.2f}")
print(f"{'Max:':<40} {ttft['max'] * 1000:<10.2f}")
tpot = report["time_per_output_token_s"]
print(" Time per Output Token (TPOT, ms) ".center(50, "-"))
print(f"{'Mean:':<40} {tpot['mean'] * 1000:<10.2f}")
print(f"{'Stddev:':<40} {tpot['stddev'] * 1000:<10.2f}")
print(f"{'P25:':<40} {tpot['quantiles']['p25'] * 1000:<10.2f}")
print(f"{'P50:':<40} {tpot['quantiles']['p50'] * 1000:<10.2f}")
print(f"{'P75:':<40} {tpot['quantiles']['p75'] * 1000:<10.2f}")
print(f"{'P90:':<40} {tpot['quantiles']['p90'] * 1000:<10.2f}")
print(f"{'P95:':<40} {tpot['quantiles']['p95'] * 1000:<10.2f}")
print(f"{'P99:':<40} {tpot['quantiles']['p99'] * 1000:<10.2f}")
print(f"{'Min:':<40} {tpot['min'] * 1000:<10.2f}")
print(f"{'Max:':<40} {tpot['max'] * 1000:<10.2f}")
itl = report["inter_token_latency_s"]
print(" Inter-Token Latency (ms) ".center(50, "-"))
print(f"{'Mean:':<40} {itl['mean'] * 1000:<10.2f}")
print(f"{'Stddev:':<40} {itl['stddev'] * 1000:<10.2f}")
print(f"{'P25:':<40} {itl['quantiles']['p25'] * 1000:<10.2f}")
print(f"{'P50:':<40} {itl['quantiles']['p50'] * 1000:<10.2f}")
print(f"{'P75:':<40} {itl['quantiles']['p75'] * 1000:<10.2f}")
print(f"{'P90:':<40} {itl['quantiles']['p90'] * 1000:<10.2f}")
print(f"{'P95:':<40} {itl['quantiles']['p95'] * 1000:<10.2f}")
print(f"{'P99:':<40} {itl['quantiles']['p99'] * 1000:<10.2f}")
print(f"{'Min:':<40} {itl['min'] * 1000:<10.2f}")
print(f"{'Max:':<40} {itl['max'] * 1000:<10.2f}")
e2e_latency = report["end_to_end_latency_s"]
print(" End-to-End Latency (ms) ".center(50, "-"))
print(f"{'Mean:':<40} {e2e_latency['mean'] * 1000:<10.2f}")
print(f"{'Stddev:':<40} {e2e_latency['stddev'] * 1000:<10.2f}")
print(f"{'P25:':<40} {e2e_latency['quantiles']['p25'] * 1000:<10.2f}")
print(f"{'P50:':<40} {e2e_latency['quantiles']['p50'] * 1000:<10.2f}")
print(f"{'P75:':<40} {e2e_latency['quantiles']['p75'] * 1000:<10.2f}")
print(f"{'P90:':<40} {e2e_latency['quantiles']['p90'] * 1000:<10.2f}")
print(f"{'P95:':<40} {e2e_latency['quantiles']['p95'] * 1000:<10.2f}")
print(f"{'P99:':<40} {e2e_latency['quantiles']['p99'] * 1000:<10.2f}")
print(f"{'Min:':<40} {e2e_latency['min'] * 1000:<10.2f}")
print(f"{'Max:':<40} {e2e_latency['max'] * 1000:<10.2f}")
input_tokens = report["input_tokens"]
print(" Input Tokens ".center(50, "-"))
print(f"{'Mean:':<40} {input_tokens['mean']:<1}")
print(f"{'Stddev:':<40} {input_tokens['stddev']:<1}")
print(f"{'P25:':<40} {input_tokens['quantiles']['p25']:<1}")
print(f"{'P50:':<40} {input_tokens['quantiles']['p50']:<1}")
print(f"{'P95:':<40} {input_tokens['quantiles']['p95']:<1}")
print(f"{'Min:':<40} {input_tokens['min']:<1}")
print(f"{'Max:':<40} {input_tokens['max']:<1}")
output_tokens = report["output_tokens"]
print(" Output Tokens ".center(50, "-"))
print(f"{'Mean:':<40} {output_tokens['mean']:<1}")
print(f"{'Stddev:':<40} {output_tokens['stddev']:<1}")
print(f"{'P25:':<40} {output_tokens['quantiles']['p25']:<1}")
print(f"{'P50:':<40} {output_tokens['quantiles']['p50']:<1}")
print(f"{'P95:':<40} {output_tokens['quantiles']['p95']:<1}")
print(f"{'Min:':<40} {output_tokens['min']:<1}")
print(f"{'Max:':<40} {output_tokens['max']:<1}")
print("=" * 50)
# fmt: on
_print(report, server_metrics=False)
if "server_metrics" in report:
_print(report["server_metrics"], server_metrics=True)
View File
+80
View File
@@ -0,0 +1,80 @@
"""Command line entrypoint of calibration."""
from mlc_llm.interface.calibrate import calibrate
from mlc_llm.interface.help import HELP
from mlc_llm.support.argparse import ArgumentParser
from .serve import EngineConfigOverride
def main(argv):
"""Main entrypoint for calibration."""
parser = ArgumentParser("MLC LLM Calibration CLI")
parser.add_argument(
"model",
type=str,
help=HELP["model"] + " (required)",
)
parser.add_argument(
"--device",
type=str,
default="auto",
help=HELP["device_deploy"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--model-lib",
type=str,
default=None,
help=HELP["model_lib"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--output",
"-o",
type=str,
required=True,
help=HELP["output_calibration"] + " (required)",
)
# Download dataset from
# https://huggingface.co/datasets/anon8231489123/ShareGPT_Vicuna_unfiltered/resolve/main/ShareGPT_V3_unfiltered_cleaned_split.json
parser.add_argument(
"--dataset",
type=str,
required=True,
help=HELP["calibration_dataset"] + " (required)",
)
parser.add_argument(
"--num-calibration-samples",
type=int,
default=16,
help=HELP["num_calibration_samples"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--seed",
type=int,
default=0,
help=HELP["seed_calibrate"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--overrides",
type=EngineConfigOverride.from_str,
default="",
help=HELP["overrides_serve"],
)
parsed = parser.parse_args(argv)
calibrate(
model=parsed.model,
device=parsed.device,
model_lib=parsed.model_lib,
output=parsed.output,
dataset=parsed.dataset,
num_calibration_samples=parsed.num_calibration_samples,
max_num_sequence=parsed.overrides.max_num_sequence,
max_total_sequence_length=parsed.overrides.max_total_seq_length,
prefill_chunk_size=parsed.overrides.prefill_chunk_size,
max_history_size=parsed.overrides.max_history_size,
gpu_memory_utilization=parsed.overrides.gpu_memory_utilization,
seed=parsed.seed,
)
+41
View File
@@ -0,0 +1,41 @@
"""Command line entrypoint of chat."""
from mlc_llm.interface.chat import ModelConfigOverride, chat
from mlc_llm.interface.help import HELP
from mlc_llm.support.argparse import ArgumentParser
def main(argv):
"""Parse command line arguments and call `mlc_llm.interface.chat`."""
parser = ArgumentParser("MLC LLM Chat CLI")
parser.add_argument(
"model",
type=str,
help=HELP["model"] + " (required)",
)
parser.add_argument(
"--device",
type=str,
default="auto",
help=HELP["device_deploy"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--model-lib",
type=str,
default=None,
help=HELP["model_lib"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--overrides",
type=ModelConfigOverride.from_str,
default="",
help=HELP["modelconfig_overrides"] + ' (default: "%(default)s")',
)
parsed = parser.parse_args(argv)
chat(
model=parsed.model,
device=parsed.device,
model_lib=parsed.model_lib,
overrides=parsed.overrides,
)
+34
View File
@@ -0,0 +1,34 @@
"""Check if a device exists."""
import os
import sys
from tvm.runtime import Device
from tvm.runtime import device as as_device
def _check_device(device: Device) -> bool:
try:
return bool(device.exist)
except Exception:
return False
def main():
"""Entrypoint for device check."""
device_str = sys.argv[1]
device_ids = []
i = 0
while True:
if _check_device(as_device(device_str, i)):
device_ids.append(i)
i += 1
if device_str in ["cpu", "llvm"] and i > os.cpu_count() / 2:
break
else:
break
print(f"check_device:{','.join(str(i) for i in device_ids)}")
if __name__ == "__main__":
main()
+152
View File
@@ -0,0 +1,152 @@
"""Command line entrypoint of compilation."""
import argparse
import json
import re
from functools import partial
from pathlib import Path
from typing import Union
from mlc_llm.interface.compile import (
ModelConfigOverride,
OptimizationFlags,
compile,
)
from mlc_llm.interface.help import HELP
from mlc_llm.model import MODELS
from mlc_llm.quantization import QUANTIZATION
from mlc_llm.support.argparse import ArgumentParser
from mlc_llm.support.auto_config import (
detect_mlc_chat_config,
detect_model_type,
detect_quantization,
)
from mlc_llm.support.auto_target import detect_system_lib_prefix, detect_target_and_host
def main(argv):
"""Parse command line arguments and call `mlc_llm.compiler.compile`."""
def _parse_output(path: Union[str, Path]) -> Path:
path = Path(path)
if path.is_dir():
raise argparse.ArgumentTypeError(f"Output cannot be a directory: {path}")
parent = path.parent
if not parent.is_dir():
raise argparse.ArgumentTypeError(f"Directory does not exist: {parent}")
return path
def _parse_dir(path: Union[str, Path], auto_create: bool = False) -> Path:
path = Path(path)
if not auto_create and not path.is_dir():
raise argparse.ArgumentTypeError(f"Directory does not exist: {path}")
if auto_create and not path.is_dir():
path.mkdir(parents=True)
return path
def _check_system_lib_prefix(prefix: str) -> str:
pattern = r"^[a-zA-Z_][a-zA-Z0-9_]*$"
if prefix == "" or re.match(pattern, prefix):
return prefix
raise argparse.ArgumentTypeError(
"Invalid prefix. It should only consist of "
"numbers (0-9), alphabets (A-Z, a-z) and underscore (_)."
)
parser = ArgumentParser("mlc_llm compile")
parser.add_argument(
"model",
type=detect_mlc_chat_config,
help=HELP["model"] + " (required)",
)
parser.add_argument(
"--quantization",
type=str,
choices=list(QUANTIZATION.keys()),
help=HELP["quantization"]
+ " (default: look up mlc-chat-config.json, choices: %(choices)s)",
)
parser.add_argument(
"--model-type",
type=str,
default="auto",
choices=["auto", *list(MODELS.keys())],
help=HELP["model_type"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--device",
type=str,
default="auto",
help=HELP["device_compile"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--host",
type=str,
default="auto",
help=HELP["host"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--enable-subgroups",
action="store_true",
help=HELP["enable_subgroups"],
)
parser.add_argument(
"--opt",
type=OptimizationFlags.from_str,
default="O2",
help=HELP["opt"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--system-lib-prefix",
type=str,
default="auto",
help=HELP["system_lib_prefix"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--output",
"-o",
type=_parse_output,
required=True,
help=HELP["output_compile"] + " (required)",
)
parser.add_argument(
"--overrides",
type=ModelConfigOverride.from_str,
default="",
help=HELP["overrides"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--debug-dump",
type=partial(_parse_dir, auto_create=True),
default=None,
help=HELP["debug_dump"] + " (default: %(default)s)",
)
parsed = parser.parse_args(argv)
target, build_func = detect_target_and_host(
parsed.device,
parsed.host,
enable_subgroups=parsed.enable_subgroups,
)
parsed.model_type = detect_model_type(parsed.model_type, parsed.model)
parsed.quantization = detect_quantization(parsed.quantization, parsed.model)
parsed.system_lib_prefix = detect_system_lib_prefix(
parsed.device,
parsed.system_lib_prefix,
parsed.model_type.name,
parsed.quantization.name,
)
with open(parsed.model, encoding="utf-8") as config_file:
config = json.load(config_file)
compile(
config=config,
quantization=parsed.quantization,
model_type=parsed.model_type,
target=target,
opt=parsed.opt,
build_func=build_func,
system_lib_prefix=parsed.system_lib_prefix,
output=parsed.output,
overrides=parsed.overrides,
debug_dump=parsed.debug_dump,
)
+112
View File
@@ -0,0 +1,112 @@
"""Command line entrypoint of weight conversion."""
import argparse
from pathlib import Path
from typing import Union
from mlc_llm.interface.convert_weight import convert_weight
from mlc_llm.interface.help import HELP
from mlc_llm.model import MODELS
from mlc_llm.quantization import QUANTIZATION
from mlc_llm.support.argparse import ArgumentParser
from mlc_llm.support.auto_config import detect_config, detect_model_type
from mlc_llm.support.auto_device import detect_device
from mlc_llm.support.auto_weight import detect_weight
def main(argv):
"""Parse command line argumennts and apply quantization."""
def _parse_source(path: Union[str, Path], config_path: Path) -> Path:
if path == "auto":
return config_path.parent
path = Path(path)
if not path.exists():
raise argparse.ArgumentTypeError(f"Model source does not exist: {path}")
return path
def _parse_output(path: Union[str, Path]) -> Path:
path = Path(path)
if not path.is_dir():
path.mkdir(parents=True, exist_ok=True)
return path
def _parse_lora_adapter(path: Union[str, Path]) -> Path:
path = Path(path)
if not path.exists() or not path.is_dir():
raise argparse.ArgumentTypeError(f"LoRA adapter directory does not exist: {path}")
return path
parser = ArgumentParser("MLC AutoLLM Quantization Framework")
parser.add_argument(
"config",
type=detect_config,
help=HELP["config"] + " (required)",
)
parser.add_argument(
"--quantization",
type=str,
required=True,
choices=list(QUANTIZATION.keys()),
help=HELP["quantization"] + " (required, choices: %(choices)s)",
)
parser.add_argument(
"--model-type",
type=str,
default="auto",
choices=["auto", *list(MODELS.keys())],
help=HELP["model_type"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--device",
default="auto",
type=detect_device,
help=HELP["device_quantize"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--source",
type=str,
default="auto",
help=HELP["source"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--source-format",
type=str,
choices=["auto", "huggingface-torch", "huggingface-safetensor", "awq"],
default="auto",
help=HELP["source_format"] + ' (default: "%(default)s", choices: %(choices)s")',
)
parser.add_argument(
"--output",
"-o",
type=_parse_output,
required=True,
help=HELP["output_quantize"] + " (required)",
)
parser.add_argument(
"--lora-adapter",
type=_parse_lora_adapter,
default=None,
help=(
"Path to a LoRA adapter directory in PEFT format. "
"When provided, adapter weights are merged into the base model before quantization."
),
)
parsed = parser.parse_args(argv)
parsed.source, parsed.source_format = detect_weight(
_parse_source(parsed.source, parsed.config),
parsed.config,
parsed.source_format,
)
model = detect_model_type(parsed.model_type, parsed.config)
convert_weight(
config=parsed.config,
quantization=QUANTIZATION[parsed.quantization],
model=model,
device=parsed.device,
source=parsed.source,
source_format=parsed.source_format,
output=parsed.output,
lora_adapter=parsed.lora_adapter,
)
+452
View File
@@ -0,0 +1,452 @@
"""Continuous model delivery for MLC LLM models."""
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar, Union # noqa: UP035
from huggingface_hub import HfApi, snapshot_download
from huggingface_hub.utils import HfHubHTTPError
from pydantic import BaseModel, Field, ValidationError
from mlc_llm.support import logging
from mlc_llm.support.argparse import ArgumentParser
from mlc_llm.support.style import bold, green, red
logger = logging.getLogger(__name__)
GEN_CONFIG_OPTIONAL_ARGS = [
"context_window_size",
"sliding_window_size",
"prefill_chunk_size",
"attention_sink_size",
"tensor_parallel_shards",
"pipeline_parallel_stages",
]
T = TypeVar("T", bound="BaseModel")
class OverrideConfigs(BaseModel):
"""
The class that specifies the override configurations.
"""
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
class ModelDeliveryTask(BaseModel):
"""
Example:
{
"model_id": "Phi-3-mini-128k-instruct",
"model": "HF://microsoft/Phi-3-mini-128k-instruct",
"conv_template": "phi-3",
"quantization": ["q3f16_1"],
"overrides": {
"q3f16_1": {
"context_window_size": 512
}
}
}
"""
model_id: str
model: str
conv_template: str
quantization: Union[List[str], str] = Field(default_factory=list) # noqa: UP006
overrides: Dict[str, OverrideConfigs] = Field(default_factory=dict) # noqa: UP006
destination: Optional[str] = None
gen_config_only: Optional[bool] = False
class ModelDeliveryList(BaseModel):
"""
The class that specifies the model delivery list.
"""
tasks: List[ModelDeliveryTask] # noqa: UP006
# For delivered log, the default destination and quantization fields are optional
default_destination: Optional[str] = None
default_quantization: List[str] = Field(default_factory=list) # noqa: UP006
default_overrides: Dict[str, OverrideConfigs] = Field(default_factory=dict) # noqa: UP006
@classmethod
def from_json(cls: Type[T], json_dict: Dict[str, Any]) -> T: # noqa: UP006
"""
Convert from a json dictionary.
"""
try:
return ModelDeliveryList.model_validate(json_dict)
except ValidationError as e:
logger.error("Error validating ModelDeliveryList: %s", e)
raise e
def to_json(self) -> Dict[str, Any]: # noqa: UP006
"""
Convert to a json dictionary.
"""
return self.model_dump(exclude_none=True)
def _clone_repo(model: Union[str, Path], hf_local_dir: Optional[str]) -> str:
if isinstance(model, Path):
if not model.exists():
raise ValueError(f"Invalid model source: {model}")
return str(model)
prefixes, mlc_prefix = ["HF://", "https://huggingface.co/"], ""
mlc_prefix = next(p for p in prefixes if model.startswith(p))
if mlc_prefix:
repo_name = model[len(mlc_prefix) :]
model_name = repo_name.split("/")[-1]
if hf_local_dir:
hf_local_dir = os.path.join(hf_local_dir, model_name)
logger.info("[HF] Downloading model to %s", hf_local_dir)
return snapshot_download(repo_id=repo_name, local_dir=hf_local_dir)
result = Path(model)
if result.exists():
return model
raise ValueError(f"Invalid model source: {model}")
def _run_quantization(
model_info: ModelDeliveryTask,
repo: str,
api: HfApi,
output_dir: str,
) -> bool:
logger.info("[HF] Creating repo https://huggingface.co/%s", repo)
try:
api.create_repo(repo_id=repo, private=False)
except HfHubHTTPError as error:
if error.response.status_code != 409:
raise
logger.info("[HF] Repo already exists. Skipping creation.")
succeeded = True
log_path = Path(output_dir) / "logs.txt"
with log_path.open("a", encoding="utf-8") as log_file:
assert isinstance(model_info.quantization, str)
logger.info("[MLC] Processing in directory: %s", output_dir)
# Required arguments
cmd = [
sys.executable,
"-m",
"mlc_llm",
"gen_config",
model_info.model,
"--quantization",
model_info.quantization,
"--conv-template",
model_info.conv_template,
"--output",
output_dir,
]
# Optional arguments
for optional_arg in GEN_CONFIG_OPTIONAL_ARGS:
optional_arg_val = getattr(model_info, optional_arg, None)
if optional_arg_val is not None:
# e.g. --context-window-size 4096
cmd += ["--" + optional_arg.replace("_", "-"), str(optional_arg_val)]
print(" ".join(cmd), file=log_file, flush=True)
subprocess.run(cmd, check=True, stdout=log_file, stderr=subprocess.STDOUT, env=os.environ)
if not model_info.gen_config_only:
cmd = [
sys.executable,
"-m",
"mlc_llm",
"convert_weight",
str(model_info.model),
"--quantization",
model_info.quantization,
"--output",
output_dir,
]
print(" ".join(cmd), file=log_file, flush=True)
subprocess.run(
cmd,
check=False,
stdout=log_file,
stderr=subprocess.STDOUT,
env=os.environ,
)
logger.info("[MLC] Complete!")
if not (Path(output_dir) / "tensor-cache.json").exists() and not model_info.gen_config_only:
logger.error(
"[%s] Model %s. Quantization %s. No weights metadata found.",
red("FAILED"),
model_info.model_id,
model_info.quantization,
)
succeeded = False
logger.info("[HF] Uploading to: https://huggingface.co/%s", repo)
for _retry in range(10):
try:
api.upload_folder(
folder_path=output_dir,
repo_id=repo,
ignore_patterns=["logs.txt"],
)
except Exception as exc:
logger.error("[%s] %s. Retrying...", red("FAILED"), exc)
else:
break
else:
raise RuntimeError("Failed to upload to HuggingFace Hub with 10 retries")
return succeeded
def _get_current_log(log: str) -> ModelDeliveryList:
log_path = Path(log)
if not log_path.exists():
with log_path.open("w", encoding="utf-8") as o_f:
current_log = ModelDeliveryList(tasks=[])
json.dump(current_log.to_json(), o_f, indent=4)
else:
with log_path.open("r", encoding="utf-8") as i_f:
current_log = ModelDeliveryList.from_json(json.load(i_f))
return current_log
def _generate_model_delivery_diff(
spec: ModelDeliveryList, log: ModelDeliveryList
) -> ModelDeliveryList:
diff_tasks = []
default_quantization = spec.default_quantization
default_overrides = spec.default_overrides
for task in spec.tasks:
model_id = task.model_id
conv_template = task.conv_template
quantization = task.quantization
overrides = {**default_overrides, **task.overrides}
logger.info(
"Checking task: %s %s %s %s",
model_id,
conv_template,
quantization,
overrides,
)
log_tasks = [t for t in log.tasks if t.model_id == model_id]
delivered_quantizations = set()
gen_config_only = set()
for log_task in log_tasks:
log_quantization = log_task.quantization
assert isinstance(log_quantization, str)
log_override = log_task.overrides.get(log_quantization, OverrideConfigs())
override = overrides.get(log_quantization, OverrideConfigs())
if log_override == override:
if log_task.conv_template == conv_template:
delivered_quantizations.add(log_quantization)
else:
gen_config_only.add(log_quantization)
all_quantizations = set(default_quantization) | set(quantization)
quantization_diff = all_quantizations - set(delivered_quantizations)
if quantization_diff:
for q in quantization_diff:
logger.info("Adding task %s %s %s to the diff.", model_id, conv_template, q)
task_copy = task.model_copy()
task_copy.quantization = [q]
task_copy.overrides = {q: overrides.get(q, OverrideConfigs())}
task_copy.gen_config_only = task_copy.gen_config_only or q in gen_config_only
diff_tasks.append(task_copy)
else:
logger.info("Task %s %s %s is up-to-date.", model_id, conv_template, quantization)
diff_config = spec.model_copy()
diff_config.default_quantization = []
diff_config.default_overrides = {}
diff_config.tasks = diff_tasks
logger.info(
"Model delivery diff: %s",
diff_config.model_dump_json(indent=4, exclude_none=True),
)
return diff_config
def _main(
username: str,
api: HfApi,
spec: ModelDeliveryList,
log: str,
hf_local_dir: Optional[str],
output: str,
dry_run: bool,
):
delivery_diff = _generate_model_delivery_diff(spec, _get_current_log(log))
if dry_run:
logger.info("Dry run. No actual delivery.")
return
failed_cases: List[Tuple[str, str]] = [] # noqa: UP006
delivered_log = _get_current_log(log)
for task_index, task in enumerate(delivery_diff.tasks, 1):
logger.info(
bold("[{task_index}/{total_tasks}] Processing model: ").format(
task_index=task_index,
total_tasks=len(delivery_diff.tasks),
)
+ green(task.model_id)
)
model = _clone_repo(task.model, hf_local_dir)
quantizations = []
if delivery_diff.default_quantization:
quantizations += delivery_diff.default_quantization
if task.quantization:
if isinstance(task.quantization, str):
quantizations.append(task.quantization)
else:
quantizations += task.quantization
default_destination = (
delivery_diff.default_destination or "{username}/{model_id}-{quantization}-MLC"
)
for quantization in quantizations:
repo = default_destination.format(
username=username,
model_id=task.model_id,
quantization=quantization,
)
model_info = ModelDeliveryTask(
model=model,
quantization=quantization,
destination=repo,
**task.model_dump(exclude_none=True, exclude={"model", "quantization"}),
)
logger.info("Model info: %s", model_info.model_dump_json(indent=4))
output_dir = os.path.join(
output, f"{model_info.model_id}-{model_info.quantization}-MLC"
)
if not os.path.exists(output_dir):
os.makedirs(output_dir)
result = _run_quantization(
model_info=model_info,
repo=repo,
api=api,
output_dir=output_dir,
)
if not result:
failed_cases.append(
(task.model_id, quantization),
)
else:
delivered_log.tasks = [
task
for task in delivered_log.tasks
if task.model_id != model_info.model_id
or task.quantization != model_info.quantization
]
delivered_log.tasks.append(model_info)
if failed_cases:
logger.info("Total %s %s:", len(failed_cases), red("failures"))
for model_id, quantization in failed_cases:
logger.info(" Model %s. Quantization %s.", model_id, quantization)
delivered_log.tasks.sort(key=lambda task: task.model_id)
logger.info("Writing log to %s", log)
with open(log, "w", encoding="utf-8") as o_f:
json.dump(delivered_log.to_json(), o_f, indent=4)
def main():
"""Entry point."""
def _load_spec(path_spec: str) -> ModelDeliveryList:
path = Path(path_spec)
if not path.exists():
raise argparse.ArgumentTypeError(f"Spec file does not exist: {path}")
with path.open("r", encoding="utf-8") as i_f:
return ModelDeliveryList.from_json(json.load(i_f))
def _get_default_hf_token() -> str:
# Try to get the token from the environment variable
hf_token = os.getenv("HF_TOKEN")
if hf_token:
logger.info("HF token found in environment variable HF_TOKEN")
return hf_token
# If not found, look for the token in the default cache folder
token_file_path = os.path.expanduser("~/.cache/huggingface/token")
if os.path.exists(token_file_path):
with open(token_file_path, encoding="utf-8") as token_file:
hf_token = token_file.read().strip()
if hf_token:
logger.info("HF token found in ~/.cache/huggingface/token")
return hf_token
raise OSError("HF token not found")
parser = ArgumentParser("MLC LLM continuous model delivery")
parser.add_argument(
"--username",
type=str,
required=True,
help="HuggingFace username",
)
parser.add_argument(
"--token",
type=str,
default=_get_default_hf_token(),
help="HuggingFace access token, obtained under https://huggingface.co/settings/tokens",
)
parser.add_argument(
"--spec",
type=_load_spec,
default="model-delivery-config.json",
help="Path to the model delivery file" + ' (default: "%(default)s")',
)
parser.add_argument(
"--log",
type=str,
default="model-delivered-log.json",
help="Path to the output log file" + ' (default: "%(default)s")',
)
parser.add_argument(
"--output",
type=str,
required=True,
help="Directory to store the output MLC models",
)
parser.add_argument(
"--hf-local-dir",
type=str,
required=False,
help="Local directory to store the downloaded HuggingFace model",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Dry run without uploading to HuggingFace Hub",
)
parsed = parser.parse_args()
_main(
parsed.username,
spec=parsed.spec,
log=parsed.log,
api=HfApi(token=parsed.token),
hf_local_dir=parsed.hf_local_dir,
output=parsed.output,
dry_run=parsed.dry_run,
)
if __name__ == "__main__":
main()
@@ -0,0 +1,33 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Internal remote disco socket session."""
import sys
from tvm import runtime as _ # noqa: F401
from tvm_ffi import get_global_func
from .. import base # noqa: F401
if __name__ == "__main__":
if len(sys.argv) != 4:
print("Usage: <server_host> <server_port> <num_workers>")
sys.exit(1)
server_host = sys.argv[1]
server_port = int(sys.argv[2])
num_workers = int(sys.argv[3])
func = get_global_func("runtime.disco.RemoteSocketSession")
func(server_host, server_port, num_workers)
+121
View File
@@ -0,0 +1,121 @@
"""Command line entrypoint of configuration generation."""
from pathlib import Path
from typing import Union
from mlc_llm.interface.gen_config import CONV_TEMPLATES, gen_config
from mlc_llm.interface.help import HELP
from mlc_llm.model import MODELS
from mlc_llm.quantization import QUANTIZATION
from mlc_llm.support.argparse import ArgumentParser
from mlc_llm.support.auto_config import detect_config, detect_model_type
def main(argv):
"""Parse command line argumennts and call `mlc_llm.compiler.gen_config`."""
parser = ArgumentParser("MLC LLM Configuration Generator")
def _parse_output(path: Union[str, Path]) -> Path:
path = Path(path)
if not path.is_dir():
path.mkdir(parents=True, exist_ok=True)
return path
parser.add_argument(
"config",
type=detect_config,
help=HELP["config"] + " (required)",
)
parser.add_argument(
"--quantization",
type=str,
required=True,
choices=list(QUANTIZATION.keys()),
help=HELP["quantization"] + " (required, choices: %(choices)s)",
)
parser.add_argument(
"--model-type",
type=str,
default="auto",
choices=["auto", *list(MODELS.keys())],
help=HELP["model_type"] + ' (default: "%(default)s", choices: %(choices)s)',
)
parser.add_argument(
"--conv-template",
type=str,
required=True,
choices=list(CONV_TEMPLATES),
help=HELP["conv_template"] + " (required, choices: %(choices)s)",
)
parser.add_argument(
"--context-window-size",
type=int,
default=None,
help=HELP["context_window_size"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--sliding-window-size",
type=int,
default=None,
help=HELP["sliding_window_size"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--prefill-chunk-size",
type=int,
default=None,
help=HELP["prefill_chunk_size"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--attention-sink-size",
type=int,
default=None,
help=HELP["attention_sink_size"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--tensor-parallel-shards",
type=int,
default=None,
help=HELP["tensor_parallel_shards"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--pipeline-parallel-stages",
type=int,
default=None,
help=HELP["pipeline_parallel_stages"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--disaggregation",
type=bool,
default=None,
help=HELP["disaggregation"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--max-batch-size",
type=int,
default=128,
help=HELP["max_batch_size"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--output",
"-o",
type=_parse_output,
required=True,
help=HELP["output_gen_mlc_chat_config"] + " (required)",
)
parsed = parser.parse_args(argv)
model = detect_model_type(parsed.model_type, parsed.config)
gen_config(
config=parsed.config,
model=model,
quantization=QUANTIZATION[parsed.quantization],
conv_template=parsed.conv_template,
context_window_size=parsed.context_window_size,
sliding_window_size=parsed.sliding_window_size,
prefill_chunk_size=parsed.prefill_chunk_size,
attention_sink_size=parsed.attention_sink_size,
tensor_parallel_shards=parsed.tensor_parallel_shards,
pipeline_parallel_stages=parsed.pipeline_parallel_stages,
disaggregation=parsed.disaggregation,
max_batch_size=parsed.max_batch_size,
output=parsed.output,
)
+199
View File
@@ -0,0 +1,199 @@
"""Continuous model delivery for MLC LLM models."""
import argparse
import dataclasses
import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any, Callable, Dict, List # noqa: UP035
from mlc_llm.support import logging
from mlc_llm.support.argparse import ArgumentParser
from mlc_llm.support.constants import MLC_TEMP_DIR
from mlc_llm.support.style import bold, green, red
logger = logging.getLogger(__name__)
@dataclasses.dataclass
class ModelInfo:
"""Necessary information for the model delivery"""
model_id: str
model: Path
quantization: str
device: str
# overrides the `context_window_size`, `prefill_chunk_size`,
# `sliding_window_size`, `attention_sink_size`, `max_batch_size`
# and `tensor_parallel_shards in mlc-chat-config.json
overrides: Dict[str, int] # noqa: UP006
class DeferredScope:
"""A context manager that defers execution of functions until exiting the scope."""
def __init__(self):
self.deferred_functions = []
def add(self, func: Callable[[], None]):
"""Add a function to be executed when exiting the scope."""
self.deferred_functions.append(func)
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
for func in reversed(self.deferred_functions):
func()
return False
def create_temp_dir(self) -> Path:
"""Create a temporary directory that will be deleted when exiting the scope."""
temp_dir = tempfile.mkdtemp(dir=MLC_TEMP_DIR)
self.add(lambda: shutil.rmtree(temp_dir, ignore_errors=True))
return Path(temp_dir)
def _run_compilation(model_info: ModelInfo, repo_dir: Path) -> bool:
"""Run the compilation of the model library."""
def get_lib_ext(device: str) -> str:
if device in ["cuda", "vulkan", "metal"]:
return ".so"
if device in ["android", "ios"]:
return ".tar"
if device in ["webgpu"]:
return ".wasm"
return ""
succeeded = True
with tempfile.TemporaryDirectory(dir=MLC_TEMP_DIR) as temp_dir:
log_path = Path(temp_dir) / "logs.txt"
model_lib_name = f"{model_info.model_id}-{model_info.quantization}-{model_info.device}"
lib_ext = get_lib_ext(model_info.device)
if lib_ext == "":
raise ValueError(f"Unsupported device: {model_info.device}")
model_lib_name += lib_ext
with log_path.open("a", encoding="utf-8") as log_file:
overrides = ";".join(f"{key}={value}" for key, value in model_info.overrides.items())
cmd = [
sys.executable,
"-m",
"mlc_llm",
"compile",
str(model_info.model),
"--device",
model_info.device,
"--quantization",
model_info.quantization,
"--overrides",
overrides,
"--output",
os.path.join(temp_dir, model_lib_name),
]
print(" ".join(cmd), file=log_file, flush=True)
subprocess.run(cmd, check=True, stdout=log_file, stderr=subprocess.STDOUT)
logger.info("[MLC] Compilation Complete!")
if not (Path(temp_dir) / model_lib_name).exists():
logger.error(
"[%s] Model %s. Device %s. No compiled library found.",
red("FAILED"),
model_info.model_id,
model_info.device,
)
succeeded = False
return succeeded
# overwrite git repo file with the compiled library
repo_filepath = repo_dir / model_info.model_id / model_lib_name
if not repo_filepath.parent.exists():
repo_filepath.parent.mkdir(parents=True, exist_ok=True)
# copy lib from Path(temp_dir) / model_lib_name to repo_filepath
shutil.copy(Path(temp_dir) / model_lib_name, repo_filepath)
logger.info("Saved library %s at %s", model_lib_name, repo_filepath)
return succeeded
def _main(
spec: Dict[str, Any], # noqa: UP006
):
"""Compile the model libs in the spec and save them to the binary_libs_dir."""
failed_cases: List[Any] = [] # noqa: UP006
for task_index, task in enumerate(spec["tasks"], 1):
logger.info(
bold("[{task_index}/{total_tasks}] Processing model: ").format(
task_index=task_index,
total_tasks=len(spec["tasks"]),
)
+ green(task["model_id"])
)
model_info = {
"model_id": task["model_id"],
"model": task["model"],
}
for compile_opt in spec["default_compile_options"] + task.get("compile_options", []):
for quantization in spec["default_quantization"] + task.get("quantization", []):
model_info["quantization"] = quantization
model_info["device"] = compile_opt["device"]
model_info["overrides"] = compile_opt.get("overrides", {})
logger.info(
"[Config] "
+ bold("model_id: ")
+ model_info["model_id"]
+ bold(", quantization: ")
+ model_info["quantization"]
+ bold(", device: ")
+ model_info["device"]
+ bold(", overrides: ")
+ json.dumps(model_info["overrides"])
)
result = _run_compilation(
ModelInfo(**model_info),
repo_dir=Path(spec["binary_libs_dir"]),
)
if not result:
failed_cases.append(model_info)
if failed_cases:
logger.info("Total %s %s:", len(failed_cases), red("failures"))
for case in failed_cases:
logger.info(
"model_id %s, quantization %s, device %s, overrides %s",
case["model_id"],
case["quantization"],
case["device"],
json.dumps(case["overrides"]),
)
def main():
"""Entry point."""
def _load_spec(path_spec: str) -> Dict[str, Any]: # noqa: UP006
path = Path(path_spec)
if not path.exists():
raise argparse.ArgumentTypeError(f"Spec file does not exist: {path}")
with path.open("r", encoding="utf-8") as i_f:
return json.load(i_f)
parser = ArgumentParser("MLC LLM continuous library delivery")
parser.add_argument(
"--spec",
type=_load_spec,
required=True,
help="Path to the spec file",
)
parsed = parser.parse_args()
_main(
spec=parsed.spec,
)
if __name__ == "__main__":
main()
+194
View File
@@ -0,0 +1,194 @@
"""A tool that inspects the metadata of a model lib."""
import json
import math
from dataclasses import asdict
from pathlib import Path
from typing import Any, Dict, List, Union # noqa: UP035
from tvm.runtime import DataType
from mlc_llm.support import logging
from mlc_llm.support.argparse import ArgumentParser
from mlc_llm.support.config import ConfigBase
from mlc_llm.support.style import green, red
logger = logging.getLogger(__name__)
def _extract_metadata(model_lib: Path) -> Dict[str, Any]: # noqa: UP006
from tvm.runtime import device, load_module
from tvm.runtime.vm import VirtualMachine
return json.loads(VirtualMachine(load_module(model_lib), device("cpu"))["_metadata"]())
def _report_all(metadata: Dict[str, Any]) -> None: # noqa: UP006
# Print JSON with aesthetic values that packs each parameter into one line,
# while keeping the rest indented.
indent = 2
indents = " " * indent
params = metadata.pop("params")
params = indents * 2 + (",\n" + indents * 2).join(json.dumps(p) for p in params)
lines = json.dumps(
metadata,
sort_keys=True,
indent=indent,
).splitlines()
lines.insert(1, indents + '"params": [\n' + params + "\n" + indents + "],")
beautified_json = "\n".join(lines)
print(beautified_json)
def _read_dynamic_shape(shape: List[Union[int, str]], config: Union[Dict, ConfigBase]) -> List[int]: # noqa: UP006
if isinstance(config, ConfigBase):
config = asdict(config)
param_shape = []
for s in shape:
if isinstance(s, int):
param_shape.append(s)
else:
if config is None:
logger.error(
"%s: Encountered dynamic shape %s, need to specify `--mlc-chat-config` for "
+ "memory usage calculation.",
red("FAILED"),
red(s),
)
raise AttributeError
if s not in config:
logger.error(
"%s to retrieve concrete %s for dynamic shape from %s.",
red("FAILED"),
red(s),
config,
)
raise KeyError
param_shape.append(config[s])
return param_shape
def _compute_memory_usage(metadata: Dict[str, Any], config: Union[Dict, ConfigBase]): # noqa: UP006
params_bytes = 0.0
for param in metadata["params"]:
if all(isinstance(v, int) for v in param["shape"]):
assert all(v > 0 for v in param["shape"]), "All shapes should be strictly positive."
param_shape = param["shape"]
else:
# Contains dynamic shape; use config to look up concrete values
param_shape = _read_dynamic_shape(param["shape"], config)
params_bytes += math.prod(param_shape) * DataType(param["dtype"]).itemsize
temp_func_bytes = 0.0
for _func_name, func_bytes in metadata["memory_usage"].items():
temp_func_bytes = max(temp_func_bytes, func_bytes)
return params_bytes, temp_func_bytes
def _report_memory_usage(metadata: Dict[str, Any], config: Union[Dict, ConfigBase]) -> None: # noqa: UP006
params_bytes, temp_func_bytes = _compute_memory_usage(metadata, config)
total_size = params_bytes + temp_func_bytes
logger.info(
"%s: %.2f MB (Parameters: %.2f MB. Temporary buffer: %.2f MB)",
green("Total memory usage without KV cache"),
total_size / 1024 / 1024,
params_bytes / 1024 / 1024,
temp_func_bytes / 1024 / 1024,
)
# Compute KV cache size per token of context window.
if isinstance(config, ConfigBase):
config = asdict(config)
if (
"head_dim" in config
and "num_hidden_layers" in config
and "num_key_value_heads" in config
and "quantization" in metadata
):
quantization_type = metadata["quantization"]
dtype_bytes = None
if "f32" in quantization_type:
dtype_bytes = 4
elif "bf16" in quantization_type:
dtype_bytes = 2
elif "f16" in quantization_type:
dtype_bytes = 2
# TODO: If support quantized KV in future, need to change this
if dtype_bytes is not None:
bytes_per_token = (
config["head_dim"]
* config["num_hidden_layers"]
* config["num_key_value_heads"]
* dtype_bytes
* 2 # 2 for key and value
)
logger.info(
"%s: %.2f MB per token in the context window",
green("KV cache size"),
bytes_per_token / 1024 / 1024,
)
logger.info(
"%s: %.2f MB",
green("Total memory usage with a 4K KV cache"),
(total_size + bytes_per_token * 4096) / 1024 / 1024,
)
logger.info(
"To reduce memory usage, "
"tweak `prefill_chunk_size`, `context_window_size` and `sliding_window_size`"
)
def main():
"""Entry point for the model metadata tool."""
parser = ArgumentParser(description="A tool that inspects the metadata of a model lib.")
parser.add_argument(
"model_lib",
type=Path,
help="""The compiled model library. In MLC LLM, an LLM is compiled to a shared or static
library (.so or .a), which contains GPU computation to efficiently run the LLM. MLC Chat,
as the runtime of MLC LLM, depends on the compiled model library to generate tokens.
""",
)
parser.add_argument(
"--mlc-chat-config",
type=Path,
help="""The `mlc-chat-config.json` file specific to a model variant. This is only required
when `memory-only` is true and `model_lib` contains a dynamic parameter shape (i.e. using
a variable to represent the shape). For instance, `model.embed_tokens.q_weight` can have
shape `["vocab_size", 512]`. In these cases, we look up the concrete value in
`mlc-chat-config.json`.
""",
)
parser.add_argument(
"--memory-only",
action="store_true",
help="""If set, only inspect the metadata in memory usage and print richer analysis.
Otherwise, the tool will load all the metadata from the model library file but only print
the basic information in JSON.
""",
)
parsed = parser.parse_args()
# Load metadata from model lib
try:
metadata = _extract_metadata(parsed.model_lib)
except Exception:
logger.exception("%s to read metadata section in legacy model lib.", red("FAILED"))
return
# Load mlc_chat_config if provided
cfg = None
if parsed.mlc_chat_config:
mlc_chat_config_path = Path(parsed.mlc_chat_config)
if not mlc_chat_config_path.exists():
raise ValueError(f"{mlc_chat_config_path} does not exist.")
with open(mlc_chat_config_path, encoding="utf-8") as config_file:
cfg = json.load(config_file)
# Main body
if parsed.memory_only:
_report_memory_usage(metadata, cfg)
else:
_report_all(metadata)
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
"""Command line entrypoint of package."""
import os
from pathlib import Path
from typing import Union
from mlc_llm.interface.help import HELP
from mlc_llm.interface.package import package
from mlc_llm.support.argparse import ArgumentParser
def main(argv):
"""Parse command line arguments and call `mlc_llm.interface.package`."""
parser = ArgumentParser("MLC LLM Package CLI")
def _parse_package_config(path: Union[str, Path]) -> Path:
path = Path(path)
if not path.exists():
raise ValueError(
f"Path {str(path)} is expected to be a JSON file, but the file does not exist."
)
if not path.is_file():
raise ValueError(f"Path {str(path)} is expected to be a JSON file.")
return path
def _parse_mlc_llm_source_dir(path: str) -> Path:
os.environ["MLC_LLM_SOURCE_DIR"] = path
return Path(path)
def _parse_output(path: Union[str, Path]) -> Path:
path = Path(path)
if not path.is_dir():
path.mkdir(parents=True, exist_ok=True)
return path
parser.add_argument(
"--package-config",
type=_parse_package_config,
default="mlc-package-config.json",
help=HELP["config_package"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--mlc-llm-source-dir",
type=_parse_mlc_llm_source_dir,
default=os.environ.get("MLC_LLM_SOURCE_DIR", None),
help=HELP["mlc_llm_source_dir"]
+ " (default: the $MLC_LLM_SOURCE_DIR environment variable)",
)
parser.add_argument(
"--output",
"-o",
type=_parse_output,
default="dist",
help=HELP["output_package"] + ' (default: "%(default)s")',
)
parsed = parser.parse_args(argv)
if parsed.mlc_llm_source_dir is None:
raise ValueError(
"MLC LLM home is not specified. "
"Please obtain a copy of MLC LLM source code by "
"cloning https://github.com/mlc-ai/mlc-llm, and set environment variable "
'"MLC_LLM_SOURCE_DIR=path/to/mlc-llm"'
)
package(
package_config_path=parsed.package_config,
mlc_llm_source_dir=parsed.mlc_llm_source_dir,
output=parsed.output,
)
+90
View File
@@ -0,0 +1,90 @@
"""Command line entrypoint of router."""
from mlc_llm.interface.help import HELP
from mlc_llm.interface.router import serve
from mlc_llm.support.argparse import ArgumentParser
def main(argv):
"""Parse command line arguments and call `mlc_llm.interface.router`."""
# Define a custom argument type for a list of strings
def list_of_strings(arg):
return arg.split(",")
parser = ArgumentParser("MLC LLM Router Serve CLI")
parser.add_argument(
"model",
type=str,
help=HELP["model"] + " (required)",
)
parser.add_argument(
"--model-lib",
type=str,
default=None,
help=HELP["model_lib"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--router-mode",
type=str,
choices=["disagg", "round-robin"],
default="disagg",
help="router mode" + ' (default: "%(default)s")',
)
parser.add_argument(
"--router-host",
type=str,
default="127.0.0.1",
help="router host" + ' (default: "%(default)s")',
)
parser.add_argument(
"--router-port",
type=int,
default=8000,
help="router port" + ' (default: "%(default)s")',
)
parser.add_argument(
"--endpoint-hosts",
type=list_of_strings,
default="127.0.0.1",
help="Host of each endpoint, separated by comma." + ' (default: "%(default)s")',
)
parser.add_argument(
"--endpoint-ports",
nargs="*",
type=int,
default=[8080],
help="Port of each endpoint, separated by space." + ' (default: "%(default)s")',
)
parser.add_argument(
"--endpoint-num-gpus",
nargs="*",
type=int,
default=[1],
help="Number of GPUs of each endpoint, separated by space." + ' (default: "%(default)s")',
)
parser.add_argument(
"--enable-prefix-cache",
default=False,
action="store_true",
help="whether to enable prefix cache" + ' (default: "%(default)s")',
)
parser.add_argument(
"--pd-balance-factor",
type=float,
default=0.0,
help=HELP["pd_balance_factor"] + ' (default: "%(default)s")',
)
parsed = parser.parse_args(argv)
serve(
model=parsed.model,
model_lib=parsed.model_lib,
router_host=parsed.router_host,
router_port=parsed.router_port,
endpoint_hosts=parsed.endpoint_hosts,
endpoint_ports=parsed.endpoint_ports,
endpoint_num_gpus=parsed.endpoint_num_gpus,
enable_prefix_cache=parsed.enable_prefix_cache,
router_mode=parsed.router_mode,
pd_balance_factor=parsed.pd_balance_factor,
)
+264
View File
@@ -0,0 +1,264 @@
"""Command line entrypoint of serve."""
import dataclasses
import json
from io import StringIO
from typing import Literal, Optional
from mlc_llm.interface.help import HELP
from mlc_llm.interface.serve import serve
from mlc_llm.support import argparse
from mlc_llm.support.argparse import ArgumentParser
@dataclasses.dataclass
class EngineConfigOverride:
"""Arguments for overriding engine config."""
# Overrides for EngineConfig (runtime)
max_num_sequence: Optional[int] = None
max_total_seq_length: Optional[int] = None
prefill_chunk_size: Optional[int] = None
max_history_size: Optional[int] = None
gpu_memory_utilization: Optional[float] = None
spec_draft_length: Optional[int] = None
spec_tree_width: Optional[int] = None
prefix_cache_mode: Optional[Literal["disable", "radix"]] = None
prefix_cache_max_num_recycling_seqs: Optional[int] = None
prefill_mode: Optional[Literal["chunked", "hybrid"]] = None
context_window_size: Optional[int] = None
sliding_window_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
def __repr__(self) -> str:
out = StringIO()
print(f"max_num_sequence={self.max_num_sequence}", file=out, end="")
print(f";max_total_seq_length={self.max_total_seq_length}", file=out, end="")
print(f";prefill_chunk_size={self.prefill_chunk_size}", file=out, end="")
print(f";max_history_size={self.max_history_size}", file=out, end="")
print(f";gpu_memory_utilization={self.gpu_memory_utilization}", file=out, end="")
print(f";spec_draft_length={self.spec_draft_length}", file=out, end="")
print(f";spec_tree_width={self.spec_tree_width}", file=out, end="")
print(f";prefix_cache_mode={self.prefix_cache_mode}", file=out, end="")
print(
f";prefix_cache_max_num_recycling_seqs={self.prefix_cache_max_num_recycling_seqs}",
file=out,
end="",
)
print(f";prefill_mode={self.prefill_mode}", file=out, end="")
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";attention_sink_size={self.attention_sink_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";opt={self.opt}", file=out, end="")
return out.getvalue().rstrip()
@staticmethod
def from_str(source: str) -> "EngineConfigOverride":
"""Parse engine config override values from a string."""
parser = argparse.ArgumentParser(description="Engine config override values")
parser.add_argument("--max_num_sequence", type=int, default=None)
parser.add_argument("--max_total_seq_length", type=int, default=None)
parser.add_argument("--prefill_chunk_size", type=int, default=None)
parser.add_argument("--max_history_size", type=int, default=None)
parser.add_argument("--gpu_memory_utilization", type=float, default=None)
parser.add_argument("--spec_draft_length", type=int, default=None)
parser.add_argument("--spec_tree_width", type=int, default=None)
parser.add_argument("--prefix_cache_mode", type=str, default="radix")
parser.add_argument("--prefix_cache_max_num_recycling_seqs", type=int, default=None)
parser.add_argument("--prefill_mode", type=str, default="hybrid")
parser.add_argument("--context_window_size", type=int, default=None)
parser.add_argument("--sliding_window_size", type=int, default=None)
parser.add_argument("--attention_sink_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("--opt", type=str, default=None)
results = parser.parse_args([f"--{i}" for i in source.split(";") if i])
return EngineConfigOverride(
max_num_sequence=results.max_num_sequence,
max_total_seq_length=results.max_total_seq_length,
prefill_chunk_size=results.prefill_chunk_size,
max_history_size=results.max_history_size,
gpu_memory_utilization=results.gpu_memory_utilization,
spec_draft_length=results.spec_draft_length,
spec_tree_width=results.spec_tree_width,
prefix_cache_mode=results.prefix_cache_mode,
prefix_cache_max_num_recycling_seqs=results.prefix_cache_max_num_recycling_seqs,
prefill_mode=results.prefill_mode,
context_window_size=results.context_window_size,
sliding_window_size=results.sliding_window_size,
attention_sink_size=results.attention_sink_size,
tensor_parallel_shards=results.tensor_parallel_shards,
pipeline_parallel_stages=results.pipeline_parallel_stages,
opt=results.opt,
)
def main(argv):
"""Parse command line arguments and call `mlc_llm.interface.serve`."""
parser = ArgumentParser("MLC LLM Serve CLI")
parser.add_argument(
"model",
type=str,
help=HELP["model"] + " (required)",
)
parser.add_argument(
"--device",
type=str,
default="auto",
help=HELP["device_deploy"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--model-lib",
type=str,
default=None,
help=HELP["model_lib"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--mode",
type=str,
choices=["local", "interactive", "server"],
default="local",
help=HELP["mode_serve"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--enable-debug",
action="store_true",
help="whether we enable debug end points and debug config when accepting requests",
)
parser.add_argument(
"--additional-models", type=str, nargs="*", help=HELP["additional_models_serve"]
)
parser.add_argument(
"--embedding-model",
type=str,
default=None,
help="Path to the embedding model weight directory (enables /v1/embeddings endpoint)",
)
parser.add_argument(
"--embedding-model-lib",
type=str,
default=None,
help="Path to the compiled embedding model library (.so/.dylib file)",
)
parser.add_argument(
"--speculative-mode",
type=str,
choices=["disable", "small_draft", "eagle", "medusa"],
default="disable",
help=HELP["speculative_mode_serve"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--prefix-cache-mode",
type=str,
choices=["disable", "radix"],
default="radix",
help=HELP["prefix_cache_mode_serve"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--prefill-mode",
type=str,
choices=["hybrid", "chunked"],
default="hybrid",
help=HELP["prefill_mode"] + ' (default: "%(default)s")',
)
parser.add_argument(
"--overrides",
type=EngineConfigOverride.from_str,
default="",
help=HELP["overrides_serve"],
)
parser.add_argument("--enable-tracing", action="store_true", help=HELP["enable_tracing_serve"])
parser.add_argument(
"--host",
type=str,
default="127.0.0.1",
help="host name" + ' (default: "%(default)s")',
)
parser.add_argument(
"--port",
type=int,
default=8000,
help="port" + ' (default: "%(default)s")',
)
parser.add_argument("--allow-credentials", action="store_true", help="allow credentials")
parser.add_argument(
"--allow-origins",
type=json.loads,
default=["*"],
help="allowed origins" + ' (default: "%(default)s")',
)
parser.add_argument(
"--allow-methods",
type=json.loads,
default=["*"],
help="allowed methods" + ' (default: "%(default)s")',
)
parser.add_argument(
"--allow-headers",
type=json.loads,
default=["*"],
help="allowed headers" + ' (default: "%(default)s")',
)
parser.add_argument(
"--api-key",
type=str,
default=None,
help="API key for authentication. If not provided, authentication is disabled.",
)
parsed = parser.parse_args(argv)
additional_models = []
if parsed.additional_models is not None:
for additional_model in parsed.additional_models:
splits = additional_model.split(",", maxsplit=1)
if len(splits) == 2:
additional_models.append((splits[0], splits[1]))
else:
additional_models.append(splits[0])
serve(
model=parsed.model,
device=parsed.device,
model_lib=parsed.model_lib,
mode=parsed.mode,
enable_debug=parsed.enable_debug,
additional_models=additional_models,
embedding_model=parsed.embedding_model,
embedding_model_lib=parsed.embedding_model_lib,
tensor_parallel_shards=parsed.overrides.tensor_parallel_shards,
pipeline_parallel_stages=parsed.overrides.pipeline_parallel_stages,
opt=parsed.overrides.opt,
speculative_mode=parsed.speculative_mode,
prefix_cache_mode=parsed.prefix_cache_mode,
max_num_sequence=parsed.overrides.max_num_sequence,
max_total_sequence_length=parsed.overrides.max_total_seq_length,
max_single_sequence_length=parsed.overrides.context_window_size,
prefill_chunk_size=parsed.overrides.prefill_chunk_size,
sliding_window_size=parsed.overrides.sliding_window_size,
attention_sink_size=parsed.overrides.attention_sink_size,
max_history_size=parsed.overrides.max_history_size,
gpu_memory_utilization=parsed.overrides.gpu_memory_utilization,
spec_draft_length=parsed.overrides.spec_draft_length,
spec_tree_width=parsed.overrides.spec_tree_width,
prefix_cache_max_num_recycling_seqs=parsed.overrides.prefix_cache_max_num_recycling_seqs,
prefill_mode=parsed.prefill_mode,
enable_tracing=parsed.enable_tracing,
host=parsed.host,
port=parsed.port,
allow_credentials=parsed.allow_credentials,
allow_origins=parsed.allow_origins,
allow_methods=parsed.allow_methods,
allow_headers=parsed.allow_headers,
api_key=parsed.api_key,
)
+57
View File
@@ -0,0 +1,57 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Internal DiscoWorker for Disco ProcessSession."""
import os
import sys
from tvm import runtime as _ # noqa: F401
from tvm_ffi import get_global_func
from .. import base # noqa: F401
# register the calibration functions
from ..interface import calibrate # noqa: F401
def main():
"""Main worker function"""
if len(sys.argv) != 6:
print("Usage: <worker_id> <num_workers> <num_groups> <read_fd> <write_fd>")
return
worker_id = int(sys.argv[1])
num_workers = int(sys.argv[2])
num_groups = int(sys.argv[3])
read_fd = int(sys.argv[4])
write_fd = int(sys.argv[5])
if sys.platform == "win32":
import msvcrt
reader = msvcrt.open_osfhandle(read_fd, os.O_BINARY)
writer = msvcrt.open_osfhandle(write_fd, os.O_BINARY)
else:
reader = read_fd
writer = write_fd
worker_func = get_global_func("runtime.disco.WorkerProcess")
worker_func(worker_id, num_workers, num_groups, reader, writer)
if __name__ == "__main__":
try:
main()
except (OSError, KeyboardInterrupt):
pass
+3
View File
@@ -0,0 +1,3 @@
"""Compiler passes used in MLC LLM."""
from . import pipeline as _pipeline
@@ -0,0 +1,33 @@
"""The pass that attaches an empty function for initialization."""
import tvm
from tvm import IRModule, relax
@tvm.transform.module_pass(opt_level=0, name="AttachCUDAGraphAllocInitFunc")
class AttachCUDAGraphAllocInitFunc:
"""Attach an empty function for initialization."""
def __init__(self):
pass
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
bb = relax.BlockBuilder(mod)
alloc_func_gv = None
for gv, _ in mod.functions_items():
if gv.name_hint.startswith("cuda_graph_alloc"):
assert alloc_func_gv is None
alloc_func_gv = gv
if alloc_func_gv is None:
return mod
with bb.function("cuda_graph_alloc_init", []):
bb.emit_func_output(
relax.op.call_builtin_with_ctx(
"vm.builtin.cuda_graph.get_cached_alloc",
args=[alloc_func_gv, relax.prim_value(0)],
ty_args=relax.ObjectType(),
)
)
return bb.finalize()
@@ -0,0 +1,39 @@
"""The pass that attaches embedding allocation function to the IRModule."""
from typing import Any, Dict # noqa: UP035
import tvm
from tvm import IRModule, relax
@tvm.transform.module_pass(opt_level=0, name="AttachAllocEmbeddingTensorFunc")
class AttachAllocEmbeddingTensorFunc:
"""Attach embedding tensor allocation Relax function to IRModule."""
def __init__(self, metadata: Dict[str, Any]): # noqa: UP006
self.metadata = metadata
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
embed_func = None
for gv, func in mod.functions_items():
if gv.name_hint == "embed":
embed_func = func
if embed_func is None:
return mod
hidden_size = embed_func.ret_ty.shape[-1]
dtype = relax.DataTypeImm(embed_func.ret_ty.dtype.dtype)
bb = relax.BlockBuilder(mod)
with bb.function("alloc_embedding_tensor", []):
bb.emit_func_output(
bb.emit(
relax.op.builtin.alloc_tensor(
relax.ShapeExpr([self.metadata["prefill_chunk_size"], hidden_size]),
dtype,
runtime_device_index=0,
)
)
)
return bb.finalize()
@@ -0,0 +1,285 @@
"""The pass that attaches logit processor functions to the IRModule."""
import tvm
from tvm import IRModule
from tvm.script import tirx as T
from ..support.max_thread_check import (
check_thread_limits,
get_max_num_threads_per_block,
)
@tvm.transform.module_pass(opt_level=0, name="AttachLogitProcessFunc")
class AttachLogitProcessFunc:
"""Attach logit processing TIR functions to IRModule."""
def __init__(self, target: tvm.target.Target):
"""Initializer.
Parameters
----------
target : tvm.target.Target
The target of the model compilation.
"""
self.target = target
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
mod = mod.clone()
if self.target.kind.name == "llvm":
mod["apply_logit_bias_inplace"] = _get_apply_logit_bias_inplace_cpu()
mod["apply_penalty_inplace"] = _get_apply_penalty_inplace_cpu()
mod["apply_bitmask_inplace"] = _get_apply_bitmask_inplace_cpu()
else:
mod["apply_logit_bias_inplace"] = _get_apply_logit_bias_inplace(self.target)
mod["apply_penalty_inplace"] = _get_apply_penalty_inplace(self.target)
mod["apply_bitmask_inplace"] = _get_apply_bitmask_inplace(self.target)
return mod
def _get_apply_logit_bias_inplace_cpu():
@T.prim_func(s_tir=True)
def _apply_logit_bias_inplace(
var_logits: T.handle,
var_pos2seq_id: T.handle,
var_token_ids: T.handle,
var_logit_bias: T.handle,
) -> None:
"""Function that applies logit bias in place."""
T.func_attr(
{
"global_symbol": "apply_logit_bias_inplace",
"tirx.noalias": True,
"tirx.is_scheduled": True,
}
)
batch_size = T.int32()
vocab_size = T.int32()
num_token = T.int32()
logits = T.match_buffer(var_logits, (batch_size, vocab_size), "float32")
# seq_ids
pos2seq_id = T.match_buffer(var_pos2seq_id, (num_token,), "int32")
token_ids = T.match_buffer(var_token_ids, (num_token,), "int32")
logit_bias = T.match_buffer(var_logit_bias, (num_token,), "float32")
for i in range(num_token):
logits[pos2seq_id[i], token_ids[i]] += logit_bias[i]
return _apply_logit_bias_inplace
def _get_apply_logit_bias_inplace(target: tvm.target.Target):
tx = 1024 # default
max_num_threads_per_block = get_max_num_threads_per_block(target)
tx = min(tx, max_num_threads_per_block)
check_thread_limits(target, bdx=tx, bdy=1, bdz=1, gdz=1)
@T.prim_func(s_tir=True)
def _apply_logit_bias_inplace(
var_logits: T.handle,
var_pos2seq_id: T.handle,
var_token_ids: T.handle,
var_logit_bias: T.handle,
) -> None:
"""Function that applies logit bias in place."""
T.func_attr(
{
"global_symbol": "apply_logit_bias_inplace",
"tirx.noalias": True,
"tirx.is_scheduled": True,
}
)
batch_size = T.int32()
vocab_size = T.int32()
num_token = T.int32()
logits = T.match_buffer(var_logits, (batch_size, vocab_size), "float32")
# seq_ids
pos2seq_id = T.match_buffer(var_pos2seq_id, (num_token,), "int32")
token_ids = T.match_buffer(var_token_ids, (num_token,), "int32")
logit_bias = T.match_buffer(var_logit_bias, (num_token,), "float32")
for p0 in T.thread_binding(0, (num_token + tx - 1) // tx, "blockIdx.x"):
for p1 in T.thread_binding(0, tx, "threadIdx.x"):
with T.sblock("block"):
vp = T.axis.spatial(num_token, p0 * tx + p1)
T.where(p0 * tx + p1 < num_token)
logits[pos2seq_id[vp], token_ids[vp]] += logit_bias[vp]
return _apply_logit_bias_inplace
def _get_apply_penalty_inplace_cpu():
@T.prim_func(s_tir=True)
def _apply_penalty_inplace(
var_logits: T.handle,
var_seq_ids: T.handle,
var_pos2seq_id: T.handle,
var_token_ids: T.handle,
var_token_cnt: T.handle,
var_penalties: T.handle,
) -> None:
"""Function that applies penalties in place."""
T.func_attr(
{
"global_symbol": "apply_penalty_inplace",
"tirx.noalias": True,
"tirx.is_scheduled": True,
}
)
batch_size = T.int32()
vocab_size = T.int32()
num_token = T.int32()
num_seq = T.int32()
logits = T.match_buffer(var_logits, (batch_size, vocab_size), "float32")
seq_ids = T.match_buffer(var_seq_ids, (num_seq,), "int32")
pos2seq_id = T.match_buffer(var_pos2seq_id, (num_token,), "int32")
token_ids = T.match_buffer(var_token_ids, (num_token,), "int32")
token_cnt = T.match_buffer(var_token_cnt, (num_token,), "int32")
penalties = T.match_buffer(var_penalties, (num_seq, 3), "float32")
for token in T.serial(num_token):
with T.sblock("block"):
vp = T.axis.spatial(num_token, token)
logits[seq_ids[pos2seq_id[vp]], token_ids[vp]] -= (
penalties[pos2seq_id[vp], 0] + token_cnt[vp] * penalties[pos2seq_id[vp], 1]
)
logits[seq_ids[pos2seq_id[vp]], token_ids[vp]] = T.if_then_else(
logits[seq_ids[pos2seq_id[vp]], token_ids[vp]] < T.float32(0),
logits[seq_ids[pos2seq_id[vp]], token_ids[vp]] * penalties[pos2seq_id[vp], 2],
logits[seq_ids[pos2seq_id[vp]], token_ids[vp]] / penalties[pos2seq_id[vp], 2],
)
return _apply_penalty_inplace
def _get_apply_penalty_inplace(target: tvm.target.Target):
tx = 1024 # default
max_num_threads_per_block = get_max_num_threads_per_block(target)
tx = min(tx, max_num_threads_per_block)
check_thread_limits(target, bdx=tx, bdy=1, bdz=1, gdz=1)
@T.prim_func(s_tir=True)
def _apply_penalty_inplace(
var_logits: T.handle,
var_seq_ids: T.handle,
var_pos2seq_id: T.handle,
var_token_ids: T.handle,
var_token_cnt: T.handle,
var_penalties: T.handle,
) -> None:
"""Function that applies penalties in place."""
T.func_attr(
{
"global_symbol": "apply_penalty_inplace",
"tirx.noalias": True,
"tirx.is_scheduled": True,
}
)
batch_size = T.int32()
vocab_size = T.int32()
num_token = T.int32()
num_seq = T.int32()
logits = T.match_buffer(var_logits, (batch_size, vocab_size), "float32")
seq_ids = T.match_buffer(var_seq_ids, (num_seq,), "int32")
pos2seq_id = T.match_buffer(var_pos2seq_id, (num_token,), "int32")
token_ids = T.match_buffer(var_token_ids, (num_token,), "int32")
token_cnt = T.match_buffer(var_token_cnt, (num_token,), "int32")
penalties = T.match_buffer(var_penalties, (num_seq, 3), "float32")
for p0 in T.thread_binding(0, (num_token + tx - 1) // tx, "blockIdx.x"):
for p1 in T.thread_binding(0, tx, "threadIdx.x"):
with T.sblock("block"):
vp = T.axis.spatial(num_token, p0 * tx + p1)
T.where(p0 * tx + p1 < num_token)
# Penalties: (presence_penalty, frequency_penalty, repetition_penalty)
logits[seq_ids[pos2seq_id[vp]], token_ids[vp]] -= (
penalties[pos2seq_id[vp], 0] + token_cnt[vp] * penalties[pos2seq_id[vp], 1]
)
logits[seq_ids[pos2seq_id[vp]], token_ids[vp]] = T.if_then_else(
logits[seq_ids[pos2seq_id[vp]], token_ids[vp]] < T.float32(0),
logits[seq_ids[pos2seq_id[vp]], token_ids[vp]]
* penalties[pos2seq_id[vp], 2],
logits[seq_ids[pos2seq_id[vp]], token_ids[vp]]
/ penalties[pos2seq_id[vp], 2],
)
return _apply_penalty_inplace
def _get_apply_bitmask_inplace_cpu():
@T.prim_func(s_tir=True)
def _apply_bitmask_inplace(
var_logits: T.handle,
var_seq_ids: T.handle,
var_bitmask: T.handle,
) -> None:
"""Function that applies vocabulary masking in place."""
T.func_attr(
{
"global_symbol": "apply_bitmask_inplace",
"tirx.noalias": True,
"tirx.is_scheduled": True,
}
)
batch_size = T.int32()
vocab_size = T.int32()
num_seq = T.int32()
logits = T.match_buffer(var_logits, (batch_size, vocab_size), "float32")
seq_ids = T.match_buffer(var_seq_ids, (num_seq,), "int32")
bitmask = T.match_buffer(var_bitmask, (batch_size, (vocab_size + 31) // 32), "int32")
for token in T.serial(num_seq * vocab_size):
with T.sblock("block"):
vs = T.axis.spatial(num_seq, (token) // vocab_size)
vv = T.axis.spatial(vocab_size, (token) % vocab_size)
logits[seq_ids[vs], vv] = T.if_then_else(
(bitmask[seq_ids[vs], vv // 32] >> (vv % 32)) & 1 == 1,
logits[seq_ids[vs], vv],
T.min_value("float32"),
)
return _apply_bitmask_inplace
def _get_apply_bitmask_inplace(target: tvm.target.Target):
tx = 1024 # default
max_num_threads_per_block = get_max_num_threads_per_block(target)
tx = min(tx, max_num_threads_per_block)
check_thread_limits(target, bdx=tx, bdy=1, bdz=1, gdz=1)
@T.prim_func(s_tir=True)
def _apply_bitmask_inplace(
var_logits: T.handle,
var_seq_ids: T.handle,
var_bitmask: T.handle,
) -> None:
"""Function that applies vocabulary masking in place."""
T.func_attr(
{
"global_symbol": "apply_bitmask_inplace",
"tirx.noalias": True,
"tirx.is_scheduled": True,
}
)
batch_size = T.int32()
vocab_size = T.int32()
num_seq = T.int32()
logits = T.match_buffer(var_logits, (batch_size, vocab_size), "float32")
seq_ids = T.match_buffer(var_seq_ids, (num_seq,), "int32")
bitmask = T.match_buffer(var_bitmask, (batch_size, (vocab_size + 31) // 32), "int32")
for fused_s_v_0 in T.thread_binding(0, (num_seq * vocab_size + tx - 1) // tx, "blockIdx.x"):
for fused_s_v_1 in T.thread_binding(0, tx, "threadIdx.x"):
with T.sblock("block"):
vs = T.axis.spatial(num_seq, (fused_s_v_0 * tx + fused_s_v_1) // vocab_size)
vv = T.axis.spatial(vocab_size, (fused_s_v_0 * tx + fused_s_v_1) % vocab_size)
T.where(fused_s_v_0 * tx + fused_s_v_1 < num_seq * vocab_size)
logits[seq_ids[vs], vv] = T.if_then_else(
(bitmask[seq_ids[vs], vv // 32] >> (vv % 32)) & 1 == 1,
logits[seq_ids[vs], vv],
T.min_value("float32"),
)
return _apply_bitmask_inplace
@@ -0,0 +1,390 @@
"""The pass that attaches GPU sampler functions to the IRModule."""
from typing import Dict # noqa: UP035
import tvm
from tvm import IRModule, relax, te, tirx
from tvm.relax.frontend import nn
from tvm.script import tirx as T
from mlc_llm.op.batch_spec_verify import batch_spec_verify
from mlc_llm.op.top_p_pivot import top_p_pivot, top_p_renorm
@tvm.transform.module_pass(opt_level=0, name="AttachGPUSamplingFunc")
class AttachGPUSamplingFunc:
"""Attach GPU sampling functions to IRModule."""
def __init__(self, target: tvm.target.Target, variable_bounds: Dict[str, int]): # noqa: UP006
# Specifically for RWKV workloads, which contains -1 max_seq_len
max_batch_size = variable_bounds["batch_size"]
self.variable_bounds = {
"batch_size": max_batch_size,
"num_samples": max_batch_size,
"num_positions": 6 * max_batch_size,
}
self.non_negative_var = ["vocab_size"]
self.target = target
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
target_kind = self.target.kind.name
if target_kind not in ["cuda", "vulkan", "metal", "webgpu"]:
# Only enable GPU sampling for CUDA, Vulkan, Metal, and WebGPU.
return mod
bb = relax.BlockBuilder(mod)
if target_kind == "webgpu":
# Only attach functions that do not contain i8s for WebGPU
gv_names = [
gv.name_hint
for gv in [
_attach_argsort_func(bb),
_attach_sample_with_top_p(bb),
]
]
else:
gv_names = [
gv.name_hint
for gv in [
_attach_multinomial_sampling_func(bb),
_attach_argsort_func(bb),
_attach_sample_with_top_p(bb),
_attach_take_probs_func(bb),
_attach_batch_verifier(bb),
_attach_renormalize_by_top_p(bb, self.target),
]
]
mod = bb.finalize()
for gv_name in gv_names:
mod[gv_name] = (
mod[gv_name]
.with_attr("tir_var_upper_bound", self.variable_bounds)
.with_attr("tir_non_negative_var", self.non_negative_var)
)
return mod
def _attach_multinomial_sampling_func(bb: relax.BlockBuilder):
batch_size = tirx.Var("batch_size", "int64")
num_samples = tirx.Var("num_samples", "int64")
vocab_size = tirx.Var("vocab_size", "int64")
probs = relax.Var("probs", relax.TensorType((batch_size, vocab_size), "float32"))
uniform_samples = relax.Var("uniform_samples", relax.TensorType((num_samples,), "float32"))
sample_indices = relax.Var("sample_indices", relax.TensorType((num_samples,), "int32"))
with bb.function("multinomial_from_uniform", [probs, uniform_samples, sample_indices]):
with bb.dataflow():
sample_shape = relax.ShapeExpr([num_samples, 1])
probs_tensor = nn.wrap_nested(probs, name="probs")
uniform_samples_tensor = nn.wrap_nested(
relax.call_pure_packed(
"vm.builtin.reshape",
uniform_samples,
sample_shape,
ty_args=relax.TensorType(sample_shape, "float32"),
),
name="uniform_samples",
)
sample_indices_tensor = nn.wrap_nested(
relax.call_pure_packed(
"vm.builtin.reshape",
sample_indices,
sample_shape,
ty_args=relax.TensorType(sample_shape, "int32"),
),
name="sample_indices",
)
result_tensor = nn.multinomial_from_uniform(
probs_tensor,
uniform_samples_tensor,
sample_indices_tensor,
"int32",
name="nn_multinomial_from_uniform",
)
result = bb.emit(
relax.call_pure_packed(
"vm.builtin.reshape",
result_tensor._expr,
sample_indices.ty.shape,
ty_args=sample_indices.ty,
)
)
output = bb.emit_output(result)
gv = bb.emit_func_output(output)
return gv
def _attach_argsort_func(bb: relax.BlockBuilder):
batch_size = tirx.Var("batch_size", "int64")
vocab_size = tirx.Var("vocab_size", "int64")
probs = relax.Var("probs", relax.TensorType((batch_size, vocab_size), "float32"))
with bb.function("argsort_probs", [probs]):
with bb.dataflow():
sorted_indices = bb.emit(relax.op.argsort(probs, descending=True, dtype="int32"))
sorted_values = bb.emit_te(
lambda unsorted_probs, sorted_indices: te.compute(
(batch_size, vocab_size),
lambda i, j: unsorted_probs[i, sorted_indices[i, j]],
name="take_sorted_probs",
),
probs,
sorted_indices,
primfunc_name_hint="take_sorted_probs",
)
output = bb.emit_output((sorted_values, sorted_indices))
gv = bb.emit_func_output(output)
return gv
@T.prim_func(s_tir=True)
def full(var_result: T.handle, value: T.int32):
"""The filling function for top k."""
batch_size = T.int32()
result = T.match_buffer(var_result, (batch_size, 1), "int32")
for i in T.serial(batch_size):
with T.sblock("block"):
vi = T.axis.spatial(batch_size, i)
result[vi, 0] = value
def _attach_sample_with_top_p(bb: relax.BlockBuilder):
batch_size = tirx.Var("batch_size", "int64")
num_samples = tirx.Var("num_samples", "int64")
vocab_size = tirx.Var("vocab_size", "int64")
sorted_probs = relax.Var("sorted_probs", relax.TensorType((batch_size, vocab_size), "float32"))
sorted_indices = relax.Var(
"sorted_indices", relax.TensorType((batch_size, vocab_size), "int32")
)
uniform_samples = relax.Var("uniform_samples", relax.TensorType((num_samples,), "float32"))
sample_indices = relax.Var("sample_indices", relax.TensorType((num_samples,), "int32"))
top_p = relax.Var("top_p", relax.TensorType((batch_size,), "float32"))
with bb.function(
"sample_with_top_p",
[sorted_probs, sorted_indices, uniform_samples, sample_indices, top_p],
):
with bb.dataflow():
sample_shape = relax.ShapeExpr([num_samples, 1])
top_p_shape = relax.ShapeExpr([batch_size, 1])
sorted_probs_tensor = nn.wrap_nested(sorted_probs, name="sorted_probs")
sorted_indices_tensor = nn.wrap_nested(sorted_indices, name="sorted_indices")
uniform_samples_tensor = nn.wrap_nested(
relax.call_pure_packed(
"vm.builtin.reshape",
uniform_samples,
sample_shape,
ty_args=relax.TensorType(sample_shape, "float32"),
),
name="uniform_samples",
)
sample_indices_tensor = nn.wrap_nested(
relax.call_pure_packed(
"vm.builtin.reshape",
sample_indices,
sample_shape,
ty_args=relax.TensorType(sample_shape, "int32"),
),
name="sample_indices",
)
top_p_tensor = nn.wrap_nested(
relax.call_pure_packed(
"vm.builtin.reshape",
top_p,
top_p_shape,
ty_args=relax.TensorType(top_p_shape, "float32"),
),
name="sample_indices",
)
top_k_tensor = nn.tensor_ir_op(
full,
name_hint="full",
args=[vocab_size],
out=nn.Tensor.placeholder(
[batch_size, 1],
"int32",
),
)
result_tensor = nn.sample_top_p_top_k_from_sorted_prob(
sorted_probs_tensor,
sorted_indices_tensor,
top_p_tensor,
top_k_tensor,
uniform_samples_tensor,
sample_indices_tensor,
)
result = bb.emit_output(
relax.call_pure_packed(
"vm.builtin.reshape",
result_tensor._expr,
sample_indices.ty.shape,
ty_args=sample_indices.ty,
)
)
gv = bb.emit_func_output(result)
return gv
def _attach_renormalize_by_top_p(bb: relax.BlockBuilder, target: tvm.target.Target):
batch_size = tirx.Var("batch_size", "int64")
vocab_size = tirx.Var("vocab_size", "int64")
num_pivots = 3
probs = relax.Var("probs", relax.TensorType((batch_size, vocab_size), "float32"))
top_p = relax.Var("top_p", relax.TensorType((batch_size,), "float32"))
init_pivots = relax.Var("init_pivots", relax.TensorType((batch_size, num_pivots), "float32"))
with bb.function("renormalize_by_top_p", [probs, top_p, init_pivots]):
with bb.dataflow():
cutoff_output = bb.emit(
relax.call_tir(
bb.add_func(top_p_pivot(num_pivots, target), "top_p_pivot_cutoff"),
args=[probs, top_p, init_pivots],
out_ty=[top_p.ty, top_p.ty],
)
)
final_pivot = cutoff_output[0]
renorm_sum = cutoff_output[1]
renormalized_probs = bb.emit_output(
relax.call_tir(
bb.add_func(top_p_renorm(target), "top_p_renorm_after_cutoff"),
args=[probs, final_pivot, renorm_sum],
out_ty=probs.ty,
)
)
gv = bb.emit_func_output(renormalized_probs)
return gv
def _attach_take_probs_func(bb: relax.BlockBuilder):
batch_size = tirx.Var("batch_size", "int64")
num_samples = tirx.Var("num_samples", "int64")
num_positions = tirx.Var("num_positions", "int64")
vocab_size = tirx.Var("vocab_size", "int64")
unsorted_probs = relax.Var(
"unsorted_probs", relax.TensorType((batch_size, vocab_size), "float32")
)
sorted_indices = relax.Var(
"sorted_indices", relax.TensorType((batch_size, vocab_size), "int32")
)
sample_indices = relax.Var("sample_indices", relax.TensorType((num_samples,), "int32"))
sampling_results = relax.Var("sampling_result", relax.TensorType((num_samples,), "int32"))
top_prob_offsets = relax.Var("lobprob_offsets", relax.TensorType((num_positions,), "int32"))
@T.prim_func(s_tir=True)
def sampler_take_probs_tir(
var_unsorted_probs: T.handle,
var_sorted_indices: T.handle,
var_sample_indices: T.handle,
var_sampling_results: T.handle,
var_top_prob_offsets: T.handle,
var_sampled_values: T.handle,
var_top_prob_probs: T.handle,
var_top_prob_indices: T.handle,
):
batch_size = T.int32()
num_samples = T.int32()
num_positions = T.int32()
vocab_size = T.int32()
unsorted_probs = T.match_buffer(var_unsorted_probs, (batch_size, vocab_size), "float32")
sorted_indices = T.match_buffer(var_sorted_indices, (batch_size, vocab_size), "int32")
sample_indices = T.match_buffer(var_sample_indices, (num_samples,), "int32")
sampling_results = T.match_buffer(var_sampling_results, (num_samples,), "int32")
top_prob_offsets = T.match_buffer(var_top_prob_offsets, (num_positions,), "int32")
sampled_values = T.match_buffer(var_sampled_values, (num_samples,), "float32")
top_prob_probs = T.match_buffer(var_top_prob_probs, (num_positions,), "float32")
top_prob_indices = T.match_buffer(var_top_prob_indices, (num_positions,), "int32")
for i in T.serial(num_positions):
with T.sblock("top_prob"):
vi = T.axis.spatial(num_positions, i)
# Reads are data-dependent gathers; declare full-buffer read
# regions explicitly so tirx does not infer data-dependent regions.
T.reads(
top_prob_offsets[vi],
sorted_indices[0:batch_size, 0:vocab_size],
unsorted_probs[0:batch_size, 0:vocab_size],
)
T.writes(top_prob_indices[vi], top_prob_probs[vi])
row = T.floordiv(top_prob_offsets[vi], vocab_size)
col = T.floormod(top_prob_offsets[vi], vocab_size)
top_prob_indices[vi] = sorted_indices[row, col]
top_prob_probs[vi] = unsorted_probs[row, sorted_indices[row, col]]
for i in T.serial(num_samples):
with T.sblock("sample"):
vj = T.axis.spatial(num_samples, i)
T.reads(
sample_indices[vj],
sampling_results[vj],
unsorted_probs[0:batch_size, 0:vocab_size],
)
T.writes(sampled_values[vj])
sampled_values[vj] = unsorted_probs[sample_indices[vj], sampling_results[vj]]
args = [
unsorted_probs,
sorted_indices,
sample_indices,
sampling_results,
top_prob_offsets,
]
with bb.function("sampler_take_probs", args):
with bb.dataflow():
taken_probs_indices = bb.emit_output(
relax.call_tir(
bb.add_func(sampler_take_probs_tir, "sampler_take_probs_tir"),
args,
out_ty=[
relax.TensorType((num_samples,), "float32"),
relax.TensorType((num_positions,), "float32"),
relax.TensorType((num_positions,), "int32"),
],
)
)
gv = bb.emit_func_output(taken_probs_indices)
return gv
def _attach_batch_verifier(bb: relax.BlockBuilder):
num_nodes = tirx.Var("num_nodes", "int64")
nbatch = tirx.Var("nbatch", "int64")
vocab_size = tirx.Var("vocab_size", "int64")
draft_probs = relax.Var("draft_probs", relax.TensorType((num_nodes, vocab_size), "float32"))
draft_tokens = relax.Var("draft_tokens", relax.TensorType((num_nodes,), "int32"))
model_probs = relax.Var("model_probs", relax.TensorType((num_nodes, vocab_size), "float32"))
token_tree_first_child = relax.Var(
"token_tree_first_child", relax.TensorType((num_nodes,), "int32")
)
token_tree_next_sibling = relax.Var(
"token_tree_next_sibling", relax.TensorType((num_nodes,), "int32")
)
uniform_samples = relax.Var("uniform_samples", relax.TensorType((num_nodes,), "float32"))
token_tree_parent_ptr = relax.Var("token_tree_parent_ptr", relax.TensorType((nbatch,), "int32"))
args = [
draft_probs,
draft_tokens,
model_probs,
token_tree_first_child,
token_tree_next_sibling,
uniform_samples,
token_tree_parent_ptr,
]
with bb.function("sampler_verify_draft_tokens", args):
with bb.dataflow():
res = bb.emit_output(
relax.call_tir_inplace(
bb.add_func(
batch_spec_verify(vocab_size),
"batch_verify_on_gpu_single_kernel",
),
args,
inplace_indices=[
args.index(model_probs),
args.index(token_tree_parent_ptr),
],
out_ty=[
model_probs.ty,
token_tree_parent_ptr.ty,
],
)
)
gv = bb.emit_func_output(res)
return gv
@@ -0,0 +1,274 @@
"""A compiler pass that attaches two-stage softmax with temperature."""
from typing import Any, Dict, Optional # noqa: UP035
import tvm
from tvm import relax, tirx
from tvm.ir.module import IRModule
from tvm.relax.expr_functor import PyExprMutator, mutator
from tvm.script import tirx as T
from ..support.max_thread_check import get_max_num_threads_per_block
@tvm.transform.module_pass(opt_level=0, name="AttachSoftmaxWithTemperature")
class AttachSoftmaxWithTemperature:
"""Rewrites one-shot softmax into two-stage softmax."""
def __init__(
self,
target: tvm.target.Target,
metadata: Optional[Dict[str, Any]] = None, # noqa: UP006
) -> None:
self.target = target
self.metadata = metadata
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""IRModule-level transformation"""
return _Rewriter(mod, self.target, self.metadata).transform()
@mutator
class _Rewriter(PyExprMutator):
def __init__(
self,
mod: IRModule,
target: tvm.target.Target,
metadata: Optional[Dict[str, Any]] = None, # noqa: UP006
) -> None:
super().__init__(mod)
self.mod = mod
self.target = target
self.metadata = metadata
self.chunk_size = 4096
self.active_vocab_size = self.metadata.get("active_vocab_size") if self.metadata else None
def transform(self) -> IRModule:
"""Entry point"""
batch_size = tirx.Var("batch_size", "int64")
vocab_size = tirx.Var("vocab_size", "int64")
dtype = "float32"
logits = relax.Var("logits", relax.TensorType([batch_size, 1, vocab_size], dtype))
temperature = relax.Var("temperature", relax.TensorType([batch_size], dtype))
with self.builder_.function("softmax_with_temperature", params=[logits, temperature]):
with self.builder_.dataflow():
output_struct_info = logits.ty
new_shape = relax.ShapeExpr([batch_size, vocab_size])
logits = relax.call_pure_packed(
"vm.builtin.reshape",
logits,
new_shape,
ty_args=relax.TensorType(new_shape, dtype),
)
f_chunk_lse, f_softmax_with_lse = _get_lse_and_softmax_func(
self.target, self.chunk_size, self.active_vocab_size
)
chunked_result_struct_info = relax.TensorType(
(batch_size, (vocab_size + self.chunk_size - 1) // self.chunk_size),
"float32",
)
chunked_results = self.builder_.emit(
relax.call_tir(
self.builder_.add_func(f_chunk_lse, "chunk_lse"),
args=[logits, temperature],
out_ty=[
chunked_result_struct_info,
chunked_result_struct_info,
],
)
)
chunked_sum = chunked_results[0]
chunked_max = chunked_results[1]
softmax = self.builder_.emit(
relax.call_tir(
self.builder_.add_func(f_softmax_with_lse, "softmax_with_chunked_sum"),
args=[logits, temperature, chunked_sum, chunked_max],
out_ty=logits.ty,
)
)
softmax = self.builder_.emit_output(
relax.call_pure_packed(
"vm.builtin.reshape",
softmax,
output_struct_info.shape,
ty_args=output_struct_info,
)
)
self.builder_.emit_func_output(softmax)
return self.builder_.get()
def _get_lse_and_softmax_func(target: tvm.target.Target, chunk_size: int, active_vocab_size: int):
# NOTE: A quick note on the softmax implementation.
# We once tried to multiply every element by log2e which can be computed
# potentially more efficiently on hardware.
# However, when the input values are large, multiplying by the factor of log2e
# causes numerical issue in float32 dtype.
# This leads to the softmax output not summing up to 1.
# For numerical stability, we removed the log2e factor and switched back
# to the standard log/exp computation.
# The kernels below handle both the cases of temperature=0 and temperature != 0.
# - When temperature is not 0, the first kernel computes the log-sum-exp of
# chunks (subtracted by the max value in chunk), and the max values of chunks.
# The second kernel merges the log-sum-exp with the maximum values.
# - When temperature is 0, the first kernel computes the max value and the counts
# of the max value. The second kernel merges the max and counts, and set the
# softmax of the maximum values to "max_value / max_count".
@T.prim_func(s_tir=True)
def chunk_lse(
var_A: T.handle,
var_temperature: T.handle,
var_chunked_sum: T.handle,
var_chunked_max: T.handle,
):
T.func_attr({"tirx.noalias": True})
batch_size = T.int64()
vocab_size = T.int64()
num_chunks = T.int64()
A = T.match_buffer(var_A, (batch_size, vocab_size), dtype="float32")
temperature = T.match_buffer(var_temperature, (batch_size,), dtype="float32")
chunked_sum = T.match_buffer(var_chunked_sum, (batch_size, num_chunks), dtype="float32")
chunked_max = T.match_buffer(var_chunked_max, (batch_size, num_chunks), dtype="float32")
A_pad = T.sblock_alloc_buffer(
(batch_size, num_chunks, T.int64(chunk_size)), dtype="float32"
)
temp_max = T.sblock_alloc_buffer((batch_size, num_chunks), dtype="float32")
temp_sum = T.sblock_alloc_buffer((batch_size, num_chunks), dtype="float32")
for l0, l1, l2 in T.grid(batch_size, num_chunks, T.int64(chunk_size)):
with T.sblock("pad"):
v0, v1, v2 = T.axis.remap("SSS", [l0, l1, l2])
A_pad[v0, v1, v2] = T.Select(
v1 * T.int64(chunk_size) + v2
< (active_vocab_size if active_vocab_size is not None else vocab_size),
T.if_then_else(
temperature[v0] > T.float32(1e-5),
A[v0, v1 * T.int64(chunk_size) + v2] / temperature[v0],
A[v0, v1 * T.int64(chunk_size) + v2],
),
T.min_value("float32"),
)
for l0, l1, l2 in T.grid(batch_size, num_chunks, T.int64(chunk_size)):
with T.sblock("max"):
v0, v1, v2 = T.axis.remap("SSR", [l0, l1, l2])
with T.init():
temp_max[v0, v1] = T.min_value("float32")
temp_max[v0, v1] = T.max(temp_max[v0, v1], A_pad[v0, v1, v2])
for l0, l1, l2 in T.grid(batch_size, num_chunks, T.int64(chunk_size)):
with T.sblock("sum_exp"):
v0, v1, v2 = T.axis.remap("SSR", [l0, l1, l2])
with T.init():
temp_sum[v0, v1] = T.float32(0)
temp_sum[v0, v1] += T.if_then_else(
v1 * T.int64(chunk_size) + v2
< (active_vocab_size if active_vocab_size is not None else vocab_size),
T.Select(
temperature[v0] > T.float32(1e-5),
T.exp(A_pad[v0, v1, v2] - temp_max[v0, v1]),
T.cast(A_pad[v0, v1, v2] == temp_max[v0, v1], "float32"),
),
T.float32(0),
)
for l0, l1, l2 in T.grid(batch_size, num_chunks, T.int64(1)):
with T.sblock("log"):
v0, v1, v2 = T.axis.remap("SSS", [l0, l1, l2])
chunked_sum[v0, v1] = T.Select(
temperature[v0] > T.float32(1e-5),
T.log(temp_sum[v0, v1]),
temp_sum[v0, v1],
)
chunked_max[v0, v1] = temp_max[v0, v1]
@T.prim_func(s_tir=True)
def softmax_with_chunked_sum(
var_A: T.handle,
var_temperature: T.handle,
var_chunked_sum: T.handle,
var_chunked_max: T.handle,
var_softmax: T.handle,
):
T.func_attr({"tirx.noalias": True, "tirx.is_scheduled": 1})
batch_size = T.int64()
vocab_size = T.int64()
num_chunks = T.int64()
A = T.match_buffer(var_A, (batch_size, vocab_size), dtype="float32")
temperature = T.match_buffer(var_temperature, (batch_size,), dtype="float32")
chunked_sum = T.match_buffer(var_chunked_sum, (batch_size, num_chunks), dtype="float32")
chunked_max = T.match_buffer(var_chunked_max, (batch_size, num_chunks), dtype="float32")
softmax = T.match_buffer(var_softmax, (batch_size, vocab_size), dtype="float32")
temp_max = T.sblock_alloc_buffer((batch_size,), dtype="float32")
temp_sum = T.sblock_alloc_buffer((batch_size,), dtype="float32")
for l0, l1 in T.grid(batch_size, num_chunks):
with T.sblock("max"):
v0, v1 = T.axis.remap("SR", [l0, l1])
with T.init():
temp_max[v0] = T.min_value("float32")
temp_max[v0] = T.max(temp_max[v0], chunked_max[v0, v1])
for l0, l1 in T.grid(batch_size, num_chunks):
with T.sblock("sum_exp"):
v0, v1 = T.axis.remap("SR", [l0, l1])
with T.init():
temp_sum[v0] = T.float32(0)
temp_sum[v0] += T.Select(
temperature[v0] > T.float32(1e-5),
T.exp(chunked_sum[v0, v1] + chunked_max[v0, v1] - temp_max[v0]),
T.cast(chunked_max[v0, v1] == temp_max[v0], "float32") * chunked_sum[v0, v1],
)
for l0, l1, l2 in T.grid(batch_size, num_chunks, T.int64(chunk_size)):
with T.sblock("log_pad"):
v0, v1, v2 = T.axis.remap("SSS", [l0, l1, l2])
if v1 * T.int64(chunk_size) + v2 < vocab_size:
softmax[v0, v1 * T.int64(chunk_size) + v2] = T.Select(
v1 * T.int64(chunk_size) + v2
< (active_vocab_size if active_vocab_size is not None else vocab_size),
T.if_then_else(
temperature[v0] > T.float32(1e-5),
T.exp(
A[v0, v1 * T.int64(chunk_size) + v2] / temperature[v0]
- (T.log(temp_sum[v0]) + temp_max[v0])
),
T.cast(
A[v0, v1 * T.int64(chunk_size) + v2] == temp_max[v0],
"float32",
)
/ temp_sum[v0],
),
T.float32(0),
)
sch = tvm.s_tir.Schedule(IRModule({"softmax_with_chunked_sum": softmax_with_chunked_sum}))
def apply_gpu_schedule(target, sch):
max_threads = get_max_num_threads_per_block(target)
TX = 32
TY = max_threads // TX
unroll_depth = 64
sch.work_on("softmax_with_chunked_sum")
l0, l1, l2 = sch.get_loops("log_pad")
bx = sch.fuse(l0, l1)
sch.bind(bx, "blockIdx.x")
unroll, ty, tx = sch.split(l2, [None, TY, TX])
sch.bind(ty, "threadIdx.y")
sch.bind(tx, "threadIdx.x")
sch.annotate(unroll, ann_key="pragma_auto_unroll_max_step", ann_val=unroll_depth)
sch.annotate(unroll, ann_key="pragma_unroll_explicit", ann_val=1)
for block_name in ["sum_exp", "max"]:
block = sch.get_sblock(block_name)
sch.set_scope(block, buffer_index=0, storage_scope="shared")
sch.compute_at(block, bx)
r_loop = sch.get_loops(block)[-1]
r_loop, tx = sch.split(r_loop, [None, TX])
sch.reorder(tx, r_loop)
sch.bind(tx, "threadIdx.x")
sch.annotate(r_loop, ann_key="pragma_auto_unroll_max_step", ann_val=unroll_depth)
sch.annotate(r_loop, ann_key="pragma_unroll_explicit", ann_val=1)
return chunk_lse, sch.mod["softmax_with_chunked_sum"]
if target.kind.name == "llvm":
return chunk_lse, sch.mod["softmax_with_chunked_sum"]
return apply_gpu_schedule(target, sch)
@@ -0,0 +1,123 @@
"""The pass that attaches logit processor functions to the IRModule."""
import tvm
from tvm import IRModule, relax, tirx
from tvm.relax import BlockBuilder, TensorType
from tvm.script import tirx as T
@tvm.transform.module_pass(opt_level=0, name="AttachSpecDecodeAuxFuncs")
class AttachSpecDecodeAuxFuncs:
"""Attach logit processing TIR functions to IRModule."""
tensor_parallel_shards: int
def __init__(self, tensor_parallel_shards: int):
self.tensor_parallel_shards = tensor_parallel_shards
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
mod = mod.clone()
bb = BlockBuilder(mod)
bb.add_func(
_get_scatter_2d_inplace(dtype="float32", global_symbol="scatter_probs"),
"scatter_probs",
)
bb.add_func(
_get_gather_2d_inplace(dtype="float32", global_symbol="gather_probs"),
"gather_probs",
)
if "prefill_to_last_hidden_states" in mod:
hidden_states_struct_info = mod["prefill_to_last_hidden_states"].ret_ty.fields[0]
dtype = hidden_states_struct_info.dtype
_add_gather_hidden_states(bb, self.tensor_parallel_shards, dtype)
_add_scatter_hidden_states(bb, self.tensor_parallel_shards, dtype)
return bb.finalize()
def _get_scatter_2d_inplace(dtype: str, global_symbol: str):
@T.prim_func(s_tir=True)
def _scatter_2d(var_src: T.handle, var_indices: T.handle, var_dst: T.handle):
T.func_attr({"global_symbol": global_symbol, "tirx.noalias": True})
batch_size = T.int32()
m = T.int32()
n = T.int32()
src = T.match_buffer(var_src, (batch_size, n), dtype)
indices = T.match_buffer(var_indices, (batch_size,), "int32")
dst = T.match_buffer(var_dst, (m, n), dtype)
for b, j in T.grid(batch_size, n):
with T.sblock("scatter_2d"):
vb, vj = T.axis.remap("SS", [b, j])
dst[indices[vb], vj] = src[vb, vj]
return _scatter_2d
def _get_gather_2d_inplace(dtype: str, global_symbol: str):
@T.prim_func(s_tir=True)
def _gather_2d(var_src: T.handle, var_indices: T.handle, var_dst: T.handle):
T.func_attr({"global_symbol": global_symbol, "tirx.noalias": True})
batch_size = T.int32()
m = T.int32()
n = T.int32()
src = T.match_buffer(var_src, (m, n), dtype)
indices = T.match_buffer(var_indices, (batch_size,), "int32")
dst = T.match_buffer(var_dst, (batch_size, n), dtype)
for b, j in T.grid(batch_size, n):
with T.sblock("gather_2d"):
vb, vj = T.axis.remap("SS", [b, j])
dst[vb, vj] = src[indices[vb], vj]
return _gather_2d
def _add_scatter_hidden_states(bb: BlockBuilder, tensor_parallel_shards: int, dtype: str):
batch_size = tirx.Var("batch_size", "int64")
m = tirx.Var("m", "int64")
n = tirx.Var("n", "int64")
src = relax.Var("src", ty=TensorType([batch_size, n], dtype))
indices = relax.Var("indices", ty=TensorType([batch_size], "int32"))
dst = relax.Var("dst", ty=TensorType([m, n], dtype))
with bb.function("scatter_hidden_states", [src, indices, dst]):
with bb.dataflow():
if tensor_parallel_shards > 1:
indices = relax.op.ccl.broadcast_from_worker0(indices)
output = bb.emit_output(
relax.op.call_tir_inplace(
bb.add_func(
_get_scatter_2d_inplace(dtype, "_scatter_hidden_states"),
"_scatter_hidden_states",
),
[src, indices, dst],
2,
dst.ty,
)
)
gv = bb.emit_func_output(output)
return gv
def _add_gather_hidden_states(bb: BlockBuilder, tensor_parallel_shards: int, dtype: str):
batch_size = tirx.Var("batch_size", "int64")
m = tirx.Var("m", "int64")
n = tirx.Var("n", "int64")
src = relax.Var("src", ty=TensorType([m, n], dtype))
indices = relax.Var("indices", ty=TensorType([batch_size], "int32"))
dst = relax.Var("dst", ty=TensorType([batch_size, n], dtype))
with bb.function("gather_hidden_states", [src, indices, dst]):
with bb.dataflow():
if tensor_parallel_shards > 1:
indices = relax.op.ccl.broadcast_from_worker0(indices)
output = bb.emit_output(
relax.op.call_tir_inplace(
bb.add_func(
_get_gather_2d_inplace(dtype, "_gather_hidden_states"),
"_gather_hidden_states",
),
[src, indices, dst],
2,
dst.ty,
)
)
gv = bb.emit_func_output(output)
return gv
@@ -0,0 +1,154 @@
"""A couple of passes that simply supportive information onto the IRModule."""
from math import lcm
from typing import Any, Dict, List # noqa: UP035
import tvm
from tvm import IRModule, relax, tirx
from tvm.ir import Op
from tvm.relax.expr_functor import PyExprVisitor, visitor
@tvm.transform.module_pass(opt_level=0, name="AttachVariableBounds")
class AttachVariableBounds:
"""Attach variable bounds to each Relax function, which primarily helps with memory planning."""
def __init__(self, variable_bounds: Dict[str, int]): # noqa: UP006
# Specifically for RWKV workloads, which contains -1 max_seq_len
self.variable_bounds = {k: v for k, v in variable_bounds.items() if v > 0}
self.non_negative_var = ["vocab_size"]
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
for g_var, func in mod.functions_items():
if isinstance(func, relax.Function):
mod[g_var] = func.with_attr("tir_var_upper_bound", self.variable_bounds).with_attr(
"tir_non_negative_var", self.non_negative_var
)
return mod
@tvm.transform.module_pass(opt_level=0, name="AttachAdditionalPrimFuncs")
class AttachAdditionalPrimFuncs:
"""Attach extra TIR PrimFuncs to the IRModule"""
def __init__(self, functions: Dict[str, tirx.PrimFunc]): # noqa: UP006
self.functions = functions
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
for func_name, func in self.functions.items():
mod[func_name] = func.with_attr("global_symbol", func_name)
return mod
@tvm.transform.module_pass(opt_level=0, name="AttachMemoryPlanAttr")
class AttachMemoryPlanAttr:
"""Attach memory planning attribute for dynamic function output planning to Relax functions."""
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
for g_var, func in mod.functions_items():
if isinstance(func, relax.Function):
mod[g_var] = func.with_attr("relax.memory_plan_dynamic_func_output", True)
return mod
@tvm.transform.module_pass(opt_level=0, name="AttachCUDAGraphCaptureHints")
class AttachCUDAGraphSymbolicCaptureHints:
"""Attach CUDA graph capture hints to the IRModule"""
def __init__(self, hints: Dict[str, List[str]]): # noqa: UP006
self.hints = hints
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
for g_var, func in mod.functions_items():
func_name = g_var.name_hint
if isinstance(func, relax.Function):
if func_name in self.hints:
mod[g_var] = func.with_attr(
"relax.rewrite_cuda_graph.capture_symbolic_vars",
self.hints[func_name],
)
return mod
@tvm.transform.module_pass(opt_level=0, name="AttachPipelineParallelStages")
class AttachPipelineParallelStages:
"""Attach number of pipeline stages to relax functions."""
def __init__(self, pipeline_parallel_shards: int):
self.pipeline_parallel_shards = pipeline_parallel_shards
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
for g_var, func in mod.functions_items():
func_name = g_var.name_hint
if not isinstance(func, relax.Function) or func_name not in [
"prefill",
"decode",
"prefill_to_last_hidden_states",
"decode_to_last_hidden_states",
"batch_prefill",
"batch_decode",
"batch_verify",
"batch_prefill_to_last_hidden_states",
"batch_decode_to_last_hidden_states",
"batch_verify_to_last_hidden_states",
]:
continue
mod[g_var] = func.with_attr("pipeline_parallel_stages", self.pipeline_parallel_shards)
return mod
@tvm.transform.module_pass(opt_level=0, name="AttachSequenceLengthPaddingFactor")
class AttachSequenceLengthPaddingFactor:
"""Attach sequence length padding factor to the metadata"""
def __init__(self, target: tvm.target.Target, metadata: Dict[str, Any]): # noqa: UP006
self.target = target
self.metadata = metadata
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
@visitor
class _Visitor(PyExprVisitor):
def __init__(self, target: tvm.target.Target) -> None:
self.padding_factor = 1
self.target = target
self._op_call_dps_packed = Op.get("relax.call_dps_packed")
def run(self, mod: IRModule) -> int:
"""Entry point of the visitor."""
# Right now we only need padding for CUDA SM100a architecture.
# When the target is SM100a and uses cutlass gemm function,
# the sequence length needs to be padded to multiple of 4.
if self.target.kind.name != "cuda" or self.target.attrs.get("arch") != "sm_100a":
return 1
for _, func in mod.functions_items():
if isinstance(func, relax.Function):
self.visit_expr(func)
return self.padding_factor
def visit_call_(self, call: relax.Call) -> None:
super().visit_call_(call)
if call.op != self._op_call_dps_packed:
return
func_name = str(call.args[0].global_symbol)
if func_name in [
"cutlass.groupwise_scaled_gemm_e4m3fn_e4m3fn",
"cutlass.groupwise_scaled_bmm_e4m3fn_e4m3fn",
]:
# Find the minimum common multiple of padding factor and 4
self.padding_factor = lcm(self.padding_factor, 4)
# self.metadata["sequence_length_padding"] = True
padding_factor = _Visitor(self.target).run(mod)
if padding_factor > 1:
self.metadata["seqlen_padding_factor"] = padding_factor
return mod
@@ -0,0 +1,51 @@
"""A compiler pass that dispatches patterns to CUBLAS."""
import tvm
from tvm import IRModule, relax
from tvm.relax.backend import get_patterns_with_prefix
try:
import tvm.relax.backend.cuda.cublas as _cublas # noqa: F401
import tvm.relax.backend.rocm.hipblas as _hipblas # noqa: F401
except ImportError:
# Note: legacy path of cublas/hipblas for backward compatibility
pass
@tvm.transform.module_pass(opt_level=0, name="BLASDispatch")
class BLASDispatch:
"""A compiler pass that dispatches patterns to cuBLAS/hipBLAS."""
def __init__(self, target: tvm.target.Target) -> None:
if target.kind.name == "cuda":
self.has_blas = tvm.get_global_func("relax.ext.cublas", True)
if not self.has_blas:
raise Exception("cuBLAS is not enabled.")
self.patterns = get_patterns_with_prefix("cublas")
elif target.kind.name == "rocm":
self.has_blas = tvm.get_global_func("relax.ext.hipblas", True)
if not self.has_blas:
raise Exception("hipBLAS is not enabled.")
self.patterns = get_patterns_with_prefix("hipblas")
else:
raise Exception(f"Unsupported target {target.kind.name} for BLAS dispatch.")
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""IRModule-level transformation"""
model_names = [
gv.name_hint for gv, func in mod.functions.items() if isinstance(func, relax.Function)
]
# exclude single batch decode
model_names = [name for name in model_names if "batch" in name or "decode" not in name]
mod = tvm.transform.Sequential(
[
relax.transform.FuseOpsByPattern(
self.patterns,
bind_constants=False,
annotate_codegen=True,
entry_functions=model_names,
),
relax.transform.RunCodegen({}, entry_functions=model_names),
]
)(mod)
return mod
@@ -0,0 +1,31 @@
"""A compiler pass that cleans up undesired TIR attrs."""
from typing import List # noqa: UP035
import tvm
from tvm.ir.module import IRModule
@tvm.transform.module_pass(opt_level=0, name="CleanUpTIRAttrs")
class CleanUpTIRAttrs:
"""A compiler pass that cleans up undesired TIR attrs."""
def __init__(self, attrs: List[str]): # noqa: UP006
self.attrs = attrs
def transform_module(
self,
mod: IRModule,
_ctx: tvm.transform.PassContext,
) -> IRModule:
"""IRModule-level transformation"""
for g_var, func in mod.functions_items():
changed = False
for attr in self.attrs:
if func.attrs is not None and attr in func.attrs:
func = func.without_attr(attr)
changed = True
break
if changed:
mod[g_var] = func
return mod
@@ -0,0 +1,243 @@
"""A pass that rewrites KV cache creation functions in IRModule."""
import json
from typing import Any, Dict, List # noqa: UP035
import tvm
from tvm import IRModule, relax
from tvm.relax.frontend.nn.llm import kv_cache
from tvm.relax.frontend.nn.llm.kv_cache import RopeMode
from mlc_llm.support import logging
logger = logging.getLogger(__name__)
def extract_creation_args(func: relax.Function) -> Dict[str, Any]: # noqa: UP006
"""Extract the KV cache creation args from the given generic creation func."""
assert isinstance(func.body, relax.SeqExpr)
assert len(func.body.blocks) == 1
assert isinstance(func.body.blocks[0], relax.DataflowBlock)
assert isinstance(func.body.blocks[0].bindings[0], relax.VarBinding)
assert isinstance(func.body.blocks[0].bindings[0].value, relax.Call)
assert func.body.blocks[0].bindings[0].value.op == tvm.ir.Op.get("relax.call_pure_packed")
call_args = func.body.blocks[0].bindings[0].value.args
assert isinstance(call_args[0], relax.ExternFunc)
assert call_args[0].global_symbol == "mlc.create_paged_kv_cache_generic"
args = call_args[1:]
assert len(args) == 18
assert isinstance(args[0], (relax.StringImm, relax.Tuple))
# Check if attn_kind is a single value or a list with length of hidden layers
if isinstance(args[0], relax.StringImm):
assert args[0].value in ["mha", "mla"]
attn_kind = args[0].value
else:
assert len(args[0].fields) == args[3].value
for i, attention_type in enumerate(args[0].fields):
assert isinstance(attention_type, relax.StringImm)
assert attention_type.value in ["mha", "mla", "mha_sliding"]
attn_kind = [args[0].fields[i].value for i in range(len(args[0]))]
assert isinstance(args[1], relax.ShapeExpr)
assert len(args[1].values) == 5
assert isinstance(args[2], relax.ShapeExpr)
for i in range(3, 18):
if i in [13, 14, 17]:
continue
# PrimValue wrappers were phased out of Relax: scalar args are now bare
# tirx PrimExprs (IntImm/FloatImm) directly.
assert isinstance(args[i], (tvm.tirx.IntImm, tvm.tirx.FloatImm)), (
f"args[{i}] is {type(args[i])}"
)
assert isinstance(args[13], relax.StringImm)
assert isinstance(args[16], (relax.Constant, tvm.tirx.IntImm, tvm.tirx.FloatImm))
assert isinstance(args[17], relax.DataTypeImm)
return {
"attn_kind": attn_kind,
"max_batch_size": args[1].values[0],
"max_total_seq_len": args[1].values[1],
"prefill_chunk_size": args[1].values[2],
"page_size": args[1].values[3],
"support_sliding_window": args[1].values[4],
"layer_partition": args[2],
"num_hidden_layers": args[3].value,
"num_attention_heads": args[4].value,
"num_key_value_heads": args[5].value,
"qk_head_dim": args[6].value,
"v_head_dim": args[7].value,
"mla_original_qk_head_dim": args[8].value,
"mla_original_v_head_dim": args[9].value,
"rope_mode": args[10].value,
"rope_scale": args[11].value,
"rope_theta": args[12].value,
"rope_scaling": json.loads(args[13].value),
"rope_ext_factors": args[14],
"rotary_dim": args[15].value,
"enable_disaggregation": bool(args[16].value),
"dtype": args[17].value,
}
@tvm.transform.module_pass(opt_level=0, name="DispatchKVCacheCreation")
class DispatchKVCacheCreation:
"""Rewrite KV cache creation functions to IRModule."""
def __init__(
self,
target: tvm.target.Target,
flashinfer: bool,
metadata: Dict[str, Any], # noqa: UP006
) -> None:
"""Initializer.
Parameters
----------
target : tvm.target.Target
The target of the model compilation.
flashinfer : bool
A boolean indicating if flashinfer is enabled.
metadata : Dict[str, Any]
The model's metadata for KV cache creation.
Note that the metadata will be updated in this pass -- the
KV cache metadata will be attached.
"""
self.target = target
self.flashinfer = flashinfer
self.metadata = metadata
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
func_dict = {}
creation_func = None
for g_var, func in mod.functions_items():
# Try to find the `create_paged_kv_cache` func.
if g_var.name_hint == "create_paged_kv_cache":
creation_func = func
else:
func_dict[g_var] = func
if creation_func is None:
return mod
new_mod = IRModule(func_dict)
if mod.attrs is not None:
new_mod = new_mod.with_attrs(mod.attrs)
kwargs = extract_creation_args(creation_func)
self.attach_kv_cache_metadata(kwargs)
bb = relax.BlockBuilder(new_mod)
extern_mods = []
extern_mods += self.create_tir_paged_kv_cache(bb, kwargs)
extern_mods += self.create_flashinfer_paged_kv_cache(bb, kwargs)
mod = bb.finalize()
mod_attrs = dict(mod.attrs) if mod.attrs else {}
mod = mod.with_attr("external_mods", mod_attrs.get("external_mods", []) + extern_mods)
return mod
def attach_kv_cache_metadata(self, kwargs: Dict[str, Any]): # noqa: UP006
"""Attach the KV cache metadata to model metadata."""
self.metadata["kv_cache"] = {
"num_hidden_layers": kwargs["num_hidden_layers"],
"num_attention_heads": kwargs["num_attention_heads"],
"num_key_value_heads": kwargs["num_key_value_heads"],
"head_dim": kwargs["qk_head_dim"],
}
def create_tir_paged_kv_cache(
self,
bb: relax.BlockBuilder,
kwargs: Dict[str, Any], # noqa: UP006
) -> List[tvm.runtime.Module]: # noqa: UP006
"""Create the TIR-based PagedKVCache"""
max_batch_size = relax.Var("max_batch_size_", relax.ShapeType([kwargs["max_batch_size"]]))
max_total_seq_len = relax.Var(
"max_total_seq_len_", relax.ShapeType([kwargs["max_total_seq_len"]])
)
prefill_chunk_size = relax.Var(
"prefill_chunk_size_", relax.ShapeType([kwargs["prefill_chunk_size"]])
)
page_size = relax.Var("page_size_", relax.ShapeType([kwargs["page_size"]]))
support_sliding_window = relax.Var(
"support_sliding_window_",
relax.ShapeType([kwargs["support_sliding_window"]]),
)
# Ensure 'enable_disaggregation' is optional
enable_disaggregation = kwargs.pop("enable_disaggregation", False)
kwargs["enable_disaggregation"] = enable_disaggregation
with bb.function(
name="create_tir_paged_kv_cache",
params=[
max_batch_size,
max_total_seq_len,
prefill_chunk_size,
page_size,
support_sliding_window,
],
):
cache = kv_cache.TIRPagedKVCache(target=self.target, **kwargs)
bb.emit_func_output(cache._expr)
return cache.extern_mods
def create_flashinfer_paged_kv_cache(
self,
bb: relax.BlockBuilder,
kwargs: Dict[str, Any], # noqa: UP006
) -> List[tvm.runtime.Module]: # noqa: UP006
"""Create the FlashInfer-based PagedKVCache"""
# Filter the cases which FlashInfer does not support.
if (
not self.flashinfer
or self.target.kind.name != "cuda"
or str(kwargs["dtype"]) not in ["float16", "bfloat16"]
or (
kwargs["rope_mode"] == RopeMode.INLINE
and (
kwargs["rotary_dim"] != kwargs["qk_head_dim"]
or kwargs["qk_head_dim"] != kwargs["v_head_dim"]
)
)
):
return []
max_batch_size = relax.Var("max_batch_size_", relax.ShapeType([kwargs["max_batch_size"]]))
max_total_seq_len = relax.Var(
"max_total_seq_len_", relax.ShapeType([kwargs["max_total_seq_len"]])
)
prefill_chunk_size = relax.Var(
"prefill_chunk_size_", relax.ShapeType([kwargs["prefill_chunk_size"]])
)
page_size = relax.Var("page_size_", relax.ShapeType([kwargs["page_size"]]))
support_sliding_window = relax.Var(
"support_sliding_window_",
relax.ShapeType([kwargs["support_sliding_window"]]),
)
try:
with bb.function(
name="create_flashinfer_paged_kv_cache",
params=[
max_batch_size,
max_total_seq_len,
prefill_chunk_size,
page_size,
support_sliding_window,
],
):
cache = kv_cache.FlashInferPagedKVCache(target=self.target, **kwargs)
bb.emit_func_output(cache._expr)
except Exception as e:
logger.info(
"Error caught when creating FlashInfer PagedKVCache: %s\n"
"The model will fallback to TIR-based KV cache.",
e,
)
return []
return cache.extern_mods
@@ -0,0 +1,176 @@
"""A pass that dispatch generic calls of triton kernels to specific kernel implementations."""
from typing import List # noqa: UP035
import tvm
from tvm import IRModule, relax
from tvm.relax.expr_functor import PyExprMutator, mutator
from mlc_llm.op.triton import (
get_tir_w8a8_block_fp8_group_matmul,
get_tir_w8a8_block_fp8_matmul,
)
from mlc_llm.support import logging
logger = logging.getLogger(__name__)
@mutator
class _Rewriter(PyExprMutator):
def __init__(self, mod: IRModule, target: tvm.target.Target) -> None:
super().__init__(mod)
self.mod = mod
self.target = target
self.extern_mods: List[tvm.runtime.Module] = [] # noqa: UP006
def transform(self) -> tvm.IRModule:
"""Entry point of the transformation"""
for g_var, func in self.mod.functions_items():
if not isinstance(func, relax.Function):
continue
new_func = self.visit_expr(func)
# new_func = remove_all_unused(new_func)
self.builder_.update_func(g_var, new_func)
mod = self.builder_.finalize()
mod_attrs = dict(mod.attrs) if mod.attrs else {}
mod = mod.with_attr(
"external_mods", list(mod_attrs.get("external_mods", [])) + self.extern_mods
)
return mod
def visit_call_(self, call: relax.Call) -> relax.Expr:
call = super().visit_call_(call)
if (
call.op != tvm.ir.Op.get("relax.call_dps_packed")
or not isinstance(call.args[0], relax.ExternFunc)
or not str(call.args[0].global_symbol).startswith("mlc.triton.")
):
return call
global_symbol = str(call.args[0].global_symbol)
assert isinstance(call.args[1], relax.Tuple)
if global_symbol == "mlc.triton.w8a8_block_fp8_matmul":
return self.w8a8_block_fp8_matmul(call.args[1].fields, call.ty)
if global_symbol == "mlc.triton.w8a8_block_fp8_group_matmul":
return self.w8a8_block_fp8_group_matmul(call.args[1].fields, call.ty)
raise ValueError(f"Unknown mlc.triton kernel identifier: {global_symbol}")
def w8a8_block_fp8_matmul(
self,
args: List[relax.Expr], # noqa: UP006
out_ty: relax.Type,
) -> relax.Expr:
"""Emit the w8a8_block_fp8_matmul triton kernel."""
assert len(args) == 16
x, weight, x_scale, weight_scale = args[:4]
(
N,
K,
block_n,
block_k,
BLOCK_SIZE_M,
BLOCK_SIZE_N,
BLOCK_SIZE_K,
GROUP_SIZE_M,
num_warps,
num_stages,
) = [arg.value.value for arg in args[4:14]]
in_dtype, out_dtype = str(args[14].value), str(args[15].value)
prim_func, func_name = get_tir_w8a8_block_fp8_matmul(
N,
K,
block_n,
block_k,
in_dtype,
out_dtype,
BLOCK_SIZE_M,
BLOCK_SIZE_N,
BLOCK_SIZE_K,
GROUP_SIZE_M,
num_warps,
num_stages,
self.extern_mods,
)
if prim_func is None:
# The TIR function is already in the IRModule
gv = self.builder_.get().get_global_var(func_name)
else:
# Add the TIR function to the IRModule
gv = self.builder_.add_func(prim_func, func_name)
return relax.call_tir(gv, [x, weight, x_scale, weight_scale], out_ty=out_ty)
def w8a8_block_fp8_group_matmul(
self,
args: List[relax.Expr], # noqa: UP006
out_ty: relax.Type,
) -> relax.Expr:
"""Emit the w8a8_block_fp8_group_matmul triton kernel."""
assert len(args) == 19
x, weight, x_scale, weight_scale, expert_ids, indptr = args[:6]
(
N,
K,
num_experts,
block_n,
block_k,
BLOCK_SIZE_M,
BLOCK_SIZE_N,
BLOCK_SIZE_K,
GROUP_SIZE_M,
num_warps,
num_stages,
) = [arg.value.value for arg in args[6:17]]
in_dtype, out_dtype = str(args[17].value), str(args[18].value)
prim_func, func_name = get_tir_w8a8_block_fp8_group_matmul(
N,
K,
num_experts,
block_n,
block_k,
in_dtype,
out_dtype,
BLOCK_SIZE_M,
BLOCK_SIZE_N,
BLOCK_SIZE_K,
GROUP_SIZE_M,
num_warps,
num_stages,
self.extern_mods,
)
if prim_func is None:
# The TIR function is already in the IRModule
gv = self.builder_.get().get_global_var(func_name)
else:
# Add the TIR function to the IRModule
gv = self.builder_.add_func(prim_func, func_name)
return relax.call_tir(
gv,
[x, weight, x_scale, weight_scale, expert_ids, indptr],
out_ty=out_ty,
)
@tvm.transform.module_pass(opt_level=0, name="DispatchTritonKernel")
class DispatchTritonKernel:
"""Rewrite KV cache creation functions to IRModule."""
def __init__(self, target: tvm.target.Target) -> None:
"""Initializer.
Parameters
----------
"""
self.target = target
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
if self.target.kind.name != "cuda":
return mod
return _Rewriter(mod, self.target).transform()
@@ -0,0 +1,87 @@
"""Memory usage estimation analysis function for Relax functions."""
import json
from typing import Any, Dict # noqa: UP035
import tvm
from tvm import relax, tirx
from tvm.ir import IRModule, Op
from tvm.relax.expr_functor import PyExprVisitor, visitor
from mlc_llm.support import logging
logger = logging.getLogger(__name__)
@tvm.transform.module_pass(opt_level=0, name="AttachMetadata")
class AttachMetadataWithMemoryUsage:
"""Attach a Relax function that returns metadata in a JSON string"""
def __init__(self, metadata: Dict[str, Any]): # noqa: UP006
self.metadata = metadata
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""Entrypoint"""
func_name = "_metadata"
def _emit_metadata(metadata):
bb = relax.BlockBuilder()
with bb.function(func_name, params=[]):
bb.emit_func_output(relax.StringImm(json.dumps(metadata)))
return bb.finalize()[func_name]
self.metadata["memory_usage"] = _MemoryEstimator().run(mod)
mod[func_name] = _emit_metadata(self.metadata)
return mod
@visitor
class _MemoryEstimator(PyExprVisitor):
"""The IR visitor which estimates the memory usage of each Relax function."""
def __init__(self) -> None:
self.planned_alloc_mem = 0
self.planned_mem_num = 0
self._op_alloc_tensor = Op.get("relax.builtin.alloc_tensor")
self._op_alloc_storage = Op.get("relax.memory.alloc_storage")
def run(self, mod: IRModule) -> Dict[str, int]: # noqa: UP006
"""Entry point of the visitor."""
result: Dict[str, int] = {} # noqa: UP006
for global_var, func in mod.functions_items():
if isinstance(func, relax.Function):
self.planned_alloc_mem = 0
self.planned_mem_num = 0
self.visit_expr(func)
result[global_var.name_hint] = self.planned_alloc_mem
logger.info(
"[Memory usage] Function `%s`: %.2f MB",
global_var.name_hint,
self.planned_alloc_mem / 1024 / 1024,
)
return result
def visit_call_(self, call: relax.Call) -> None:
if call.op == self._op_alloc_tensor:
self._builtin_tensor_alloc(shape=call.args[0], dtype_str=call.args[1].value)
elif call.op == self._op_alloc_storage:
self._storage_alloc(size=call.args[0])
super().visit_call_(call)
def _builtin_tensor_alloc(self, shape: relax.Expr, dtype_str: str) -> None:
assert isinstance(shape, relax.ShapeExpr)
size = 1
for dim_len in shape.values:
if not isinstance(dim_len, tvm.tirx.IntImm):
return
size *= dim_len.value
dtype = tvm.DataType(dtype_str)
self.planned_mem_num += 1
self.planned_alloc_mem += size * ((dtype.bits + 7) // 8) * dtype.lanes
def _storage_alloc(self, size: relax.Expr) -> None:
assert isinstance(size, relax.ShapeExpr)
if isinstance(size.values[0], tirx.IntImm):
self.planned_mem_num += 1
self.planned_alloc_mem += size.values[0].value
@@ -0,0 +1,238 @@
"""A compiler pass that fuses add + rms_norm."""
from typing import Optional
import tvm
from tvm import relax
from tvm.relax.analysis import remove_all_unused
from tvm.relax.expr_functor import PyExprMutator, mutator
from tvm.script import tirx as T
from ..support.max_thread_check import get_max_num_threads_per_block
def _get_add_rms_norm_decode(hidden_size: int, eps: float, TX: int, in_dtype: str):
if in_dtype not in ("float16", "bfloat16"):
raise ValueError(f"Unsupported data type: {in_dtype}")
inv_hidden_size = T.float32(1.0 / float(hidden_size))
eps = T.float32(eps)
add_local_size = hidden_size // TX
@T.prim_func(private=True, s_tir=True)
def decode_add_rms(pA: T.handle, pB: T.handle, pC: T.handle, pO: T.handle, pAdd: T.handle):
T.func_attr({"tirx.noalias": True, "tirx.is_scheduled": 1})
batch_size = T.int32()
A = T.match_buffer(pA, (batch_size, 1, hidden_size), in_dtype)
B = T.match_buffer(pB, (batch_size, 1, hidden_size), in_dtype)
C = T.match_buffer(pC, (hidden_size,), in_dtype)
out = T.match_buffer(pO, (batch_size, 1, hidden_size), in_dtype)
add = T.match_buffer(pAdd, (batch_size, 1, hidden_size), in_dtype)
add_local = T.sblock_alloc_buffer((hidden_size // TX,), in_dtype, scope="local")
sum_shared = T.sblock_alloc_buffer((batch_size, 1), scope="shared")
sum_local = T.sblock_alloc_buffer((TX, batch_size, 1), scope="local")
for v_bx in T.thread_binding(batch_size, thread="blockIdx.x"):
for v_tx in T.thread_binding(
TX,
thread="threadIdx.x",
annotations={
"pragma_auto_unroll_max_step": 256,
"pragma_unroll_explicit": 1,
},
):
for i in range(add_local_size):
with T.sblock("T_add"):
bx = T.axis.spatial(batch_size, v_bx)
h = T.axis.spatial(hidden_size, i * TX + v_tx)
add_local[h // TX] = A[bx, 0, h] + B[bx, 0, h]
with T.sblock("T_write_back"):
bx = T.axis.spatial(batch_size, v_bx)
v_ax1 = T.axis.spatial(1, 0)
h = T.axis.spatial(hidden_size, i * TX + v_tx)
add[bx, v_ax1, h] = add_local[h // TX]
with T.sblock("T_multiply_red_rf_init"):
tx, bx = T.axis.remap("SS", [v_tx, v_bx])
sum_local[tx, bx, 0] = T.float32(0)
for v_i, _j in T.grid(add_local_size, 1):
with T.sblock("T_multiply_red_rf_update"):
tx, bx, i = T.axis.remap("SSR", [v_tx, v_bx, v_i])
sum_local[tx, bx, 0] += T.float32(add_local[i]) * T.float32(add_local[i])
for _j in range(1):
for v_tx_2 in T.thread_binding(TX, thread="threadIdx.x"):
with T.sblock("T_multiply_red"):
tx, bx = T.axis.remap("RS", [v_tx_2, v_bx])
T.reads(sum_local[tx, bx, 0])
T.writes(sum_shared[bx, 0])
with T.init():
sum_shared[bx, 0] = T.float32(0)
sum_shared[bx, 0] += sum_local[tx, bx, 0]
for i in range(add_local_size):
for v_tx_2 in T.thread_binding(TX, thread="threadIdx.x"):
with T.sblock("T_cast_2"):
bx = T.axis.spatial(batch_size, v_bx)
h = T.axis.spatial(hidden_size, i * TX + v_tx_2)
out[bx, 0, h] = T.cast(
T.rsqrt(sum_shared[bx, 0] * inv_hidden_size + eps)
* T.float32(add_local[h // TX])
* T.float32(C[h]),
dtype=in_dtype,
)
return decode_add_rms
def _get_add_rms_norm_prefill(hidden_size: int, eps: float, TX: int, in_dtype: str):
if in_dtype not in ("float16", "bfloat16"):
raise ValueError(f"Unsupported data type: {in_dtype}")
inv_hidden_size = T.float32(1.0 / float(hidden_size))
eps = T.float32(eps)
add_local_size = hidden_size // TX
@T.prim_func(private=True, s_tir=True)
def prefill_add_rms(pA: T.handle, pB: T.handle, pC: T.handle, pO: T.handle, pAdd: T.handle):
T.func_attr({"tirx.noalias": True, "tirx.is_scheduled": 1})
seq_len = T.int32()
A = T.match_buffer(pA, (1, seq_len, hidden_size), in_dtype)
B = T.match_buffer(pB, (1, seq_len, hidden_size), in_dtype)
C = T.match_buffer(pC, (hidden_size,), in_dtype)
out = T.match_buffer(pO, (1, seq_len, hidden_size), in_dtype)
add = T.match_buffer(pAdd, (1, seq_len, hidden_size), in_dtype)
add_local = T.sblock_alloc_buffer((hidden_size // TX,), in_dtype, scope="local")
sum_shared = T.sblock_alloc_buffer((1, seq_len), scope="shared")
sum_local = T.sblock_alloc_buffer((TX, 1, seq_len), scope="local")
for v_bx in T.thread_binding(seq_len, thread="blockIdx.x"):
for v_tx in T.thread_binding(
TX,
thread="threadIdx.x",
annotations={
"pragma_auto_unroll_max_step": 256,
"pragma_unroll_explicit": 1,
},
):
for v_i in range(add_local_size):
with T.sblock("T_add"):
bx = T.axis.spatial(seq_len, v_bx)
h = T.axis.spatial(hidden_size, v_i * TX + v_tx)
add_local[h // TX] = A[0, bx, h] + B[0, bx, h]
with T.sblock("T_write_back"):
bx = T.axis.spatial(seq_len, v_bx)
h = T.axis.spatial(hidden_size, v_i * TX + v_tx)
add[0, bx, h] = add_local[h // TX]
with T.sblock("T_multiply_red_rf_init"):
tx, bx = T.axis.remap("SS", [v_tx, v_bx])
sum_local[tx, 0, bx] = T.float32(0)
for v_i, _j in T.grid(add_local_size, 1):
with T.sblock("T_multiply_red_rf_update"):
tx, bx, i = T.axis.remap("SSR", [v_tx, v_bx, v_i])
sum_local[tx, 0, bx] += T.float32(add_local[i]) * T.float32(add_local[i])
for _j in range(1):
for v_tx_2 in T.thread_binding(TX, thread="threadIdx.x"):
with T.sblock("T_multiply_red"):
tx, bx = T.axis.remap("RS", [v_tx_2, v_bx])
with T.init():
sum_shared[0, bx] = T.float32(0)
sum_shared[0, bx] = sum_shared[0, bx] + sum_local[tx, 0, bx]
for v_i in range(add_local_size):
for v_tx_2 in T.thread_binding(TX, thread="threadIdx.x"):
with T.sblock("T_cast_2"):
bx = T.axis.spatial(seq_len, v_bx)
v1 = T.axis.spatial(hidden_size, v_i * TX + v_tx_2)
out[0, bx, v1] = T.cast(
T.rsqrt(sum_shared[0, bx] * inv_hidden_size + eps)
* T.float32(add_local[v1 // TX])
* T.float32(C[v1]),
dtype=in_dtype,
)
return prefill_add_rms
@tvm.transform.module_pass(opt_level=0, name="FuseAddRMSNorm")
class FuseAddRMSNorm:
"""A compiler pass that fuses add + rms_norm."""
def __init__(self, target: tvm.target.Target) -> None:
"""Initializer.
Parameters
----------
target : tvm.target.Target
Target device.
"""
self.target = target
def transform_module(self, mod: tvm.IRModule, _ctx: tvm.transform.PassContext) -> tvm.IRModule:
"""IRModule-level transformation."""
return _FuseAddRMSNormRewriter(mod.clone(), self.target).transform()
@mutator
class _FuseAddRMSNormRewriter(PyExprMutator):
def __init__(self, mod: tvm.IRModule, target: tvm.target.Target):
super().__init__(mod)
self.mod = mod
self.prefill_norm_gv: Optional[tvm.ir.GlobalVar] = None
self.decode_norm_gv: Optional[tvm.ir.GlobalVar] = None
self.TX = min(1024, get_max_num_threads_per_block(target))
def transform(self) -> tvm.IRModule:
"""Entry point of the transformation"""
for g_var, func in self.mod.functions_items():
if not isinstance(func, relax.Function):
continue
new_func = self.visit_expr(func)
new_func = remove_all_unused(new_func)
self.builder_.update_func(g_var, new_func)
return self.builder_.finalize()
def visit_call_(self, call: relax.Call) -> relax.Expr:
call = super().visit_call_(call)
# Match the "rms_norm(add(x1, x2), w)" pattern
if call.op != tvm.ir.Op.get("relax.nn.rms_norm") or call.ty.dtype not in [
"bfloat16",
"float16",
]:
return call
assert len(call.args) == 2
weight = call.args[1]
eps = call.attrs.epsilon
assert isinstance(call.args[0], relax.Var)
y = self.lookup_binding(call.args[0])
if not isinstance(y, relax.Call) or y.op != tvm.ir.Op.get("relax.add"):
return call
assert len(y.args) == 2
x1 = y.args[0]
x2 = y.args[1]
# Extra check
n, _, h = x1.ty.shape
h = int(h)
if h % self.TX != 0:
return call
is_prefill = n == 1
func_gv = self.prefill_norm_gv if is_prefill else self.decode_norm_gv
if func_gv is None:
if is_prefill:
func_gv = self.builder_.add_func(
_get_add_rms_norm_prefill(h, eps, self.TX, call.ty.dtype),
"fuse_add_norm_prefill",
)
self.prefill_norm_gv = func_gv
else:
func_gv = self.builder_.add_func(
_get_add_rms_norm_decode(h, eps, self.TX, call.ty.dtype),
"fuse_add_norm_decode",
)
self.decode_norm_gv = func_gv
tuple_output = self.builder_.emit(
relax.call_tir(
func_gv,
[x1, x2, weight],
out_ty=[x1.ty, x2.ty],
)
)
new_o = relax.TupleGetItem(tuple_output, 0)
new_y = self.builder_.emit(relax.TupleGetItem(tuple_output, 1))
self.set_var_remap(call.args[0], new_y)
return new_o
@@ -0,0 +1,85 @@
"""A compiler pass that fuses dequantize + matmul + elementwise."""
import tvm
from tvm import IRModule, relax
from tvm.relax.dpl.pattern import GlobalVarPattern, TuplePattern, is_op, wildcard
@tvm.transform.module_pass(opt_level=0, name="FuseDequantizeMatmulEwise")
class FuseDequantizeMatmulEwise:
"""A compiler pass that fuses dequantize + matmul + elementwise."""
def transform_module(
self,
mod: IRModule,
_ctx: tvm.transform.PassContext,
) -> IRModule:
"""IRModule-level transformation"""
seq = []
for n_aux_tensor in [0, 1, 2, 3, 4]:
for match_ewise in [0, 1, 2, 3, 6]:
if match_ewise == 6 and n_aux_tensor != 4:
continue
seq.append(
relax.transform.FuseOpsByPattern(
[
(
"dequantize_matmul",
*_pattern(match_ewise, n_aux_tensor),
)
]
)
)
seq.append(relax.transform.FuseTIR())
return tvm.transform.Sequential(seq)(mod)
def _pattern(match_ewise: int, n_aux_tensor: int):
w_scaled = wildcard()
x = wildcard()
w = is_op("relax.call_tir")(
GlobalVarPattern(),
TuplePattern([w_scaled] + [wildcard() for _ in range(n_aux_tensor)]),
add_constraint=False,
)
matmul = is_op("relax.call_tir")(
GlobalVarPattern(),
TuplePattern([x, w] + [wildcard() for _ in range(match_ewise)]),
add_constraint=False,
)
annotations = {
"w_scaled": w_scaled,
"x": x,
"w": w,
"matmul": matmul,
}
def _check_decoding(ctx: relax.transform.PatternCheckContext) -> bool:
call = ctx.annotated_expr["w"]
if not isinstance(call, relax.Call):
return False
g_var = call.args[0]
if not isinstance(g_var, relax.GlobalVar):
return False
return g_var.name_hint.startswith("dequantize") or g_var.name_hint.startswith(
"fused_dequantize"
)
def _check_matmul(ctx: relax.transform.PatternCheckContext) -> bool:
call = ctx.annotated_expr["matmul"]
if not isinstance(call, relax.Call):
return False
g_var = call.args[0]
if not isinstance(g_var, relax.GlobalVar):
return False
return (
g_var.name_hint.startswith("matmul")
or g_var.name_hint.startswith("fused_matmul")
or g_var.name_hint.startswith("NT_matmul")
or g_var.name_hint.startswith("fused_NT_matmul")
)
def _check(ctx: relax.transform.PatternCheckContext) -> bool:
return _check_decoding(ctx) and _check_matmul(ctx)
return matmul, annotations, _check
@@ -0,0 +1,91 @@
"""A compiler pass that fuses dequantize + take."""
import tvm
from tvm import IRModule, relax, tirx
from tvm.relax.dpl.pattern import (
GlobalVarPattern,
TuplePattern,
is_const,
is_op,
wildcard,
)
@tvm.transform.module_pass(opt_level=0, name="FuseDequantizeTake")
class FuseDequantizeTake:
"""A compiler pass that fuses dequantize + take."""
def transform_module(
self,
mod: IRModule,
_ctx: tvm.transform.PassContext,
) -> IRModule:
"""IRModule-level transformation"""
seq = []
for n_aux_tensor in [2, 3]:
for match_tir_vars in [False, True]:
seq.append(
relax.transform.FuseOpsByPattern(
[
(
"dequantize_take",
*_pattern(n_aux_tensor, match_tir_vars),
)
]
)
)
seq.append(relax.transform.FuseTIR())
mod = tvm.transform.Sequential(seq)(mod)
for g_var, func in mod.functions_items():
name = g_var.name_hint
if isinstance(func, tirx.PrimFunc) and (
("fused_dequantize" in name) and ("take" in name)
):
sch_mod = tvm.IRModule({"main": func})
sch_mod = tirx.transform.ForceNarrowIndexToInt32()(sch_mod)
sch = tvm.s_tir.Schedule(sch_mod)
sch.compute_inline("dequantize")
mod[g_var] = sch.mod["main"]
return mod
def _pattern(n_aux_tensor: int, match_tir_vars: bool):
dequantize = is_op("relax.call_tir")(
GlobalVarPattern(),
TuplePattern([wildcard() for _ in range(n_aux_tensor)]),
add_constraint=False,
)
indices = ~is_const()
if match_tir_vars:
call_tir_args_take = [
GlobalVarPattern(),
TuplePattern([dequantize, indices]),
wildcard(),
]
else:
call_tir_args_take = [
GlobalVarPattern(),
TuplePattern([dequantize, indices]),
]
take = is_op("relax.call_tir")(
*call_tir_args_take,
add_constraint=False,
)
annotations = {
"take": take,
"dequantize": dequantize,
"indices": indices,
}
def _check(ctx: relax.transform.PatternCheckContext) -> bool:
take = ctx.annotated_expr["take"]
dequantize = ctx.annotated_expr["dequantize"]
if not isinstance(dequantize, relax.Call):
return False
if not isinstance(take.args[0], relax.GlobalVar) or not isinstance(
dequantize.args[0], relax.GlobalVar
):
return False
return "take" in take.args[0].name_hint and "dequantize" in dequantize.args[0].name_hint
return take, annotations, _check
@@ -0,0 +1,107 @@
"""A compiler pass that fuses transpose + dequantize."""
import tvm
from tvm import relax, s_tir, tirx
from tvm.ir.module import IRModule
from tvm.relax.analysis import remove_all_unused
from tvm.relax.expr_functor import PyExprMutator, mutator
@tvm.transform.module_pass(opt_level=0, name="FuseDequantizeTranspose")
class FuseDequantizeTranspose:
"""A compiler pass that fuses transpose + dequantize."""
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""IRModule-level transformation"""
return _DequantizeTransposeFuser(mod).transform()
@mutator
class _DequantizeTransposeFuser(PyExprMutator):
def __init__(
self,
mod: IRModule,
):
super().__init__(mod)
self.mod = mod
def transform(self) -> IRModule:
"""Entry point"""
for g_var, func in self.mod.functions_items():
if isinstance(func, relax.Function):
updated_func = self.visit_expr(func)
updated_func = remove_all_unused(updated_func)
self.builder_.update_func(g_var, updated_func)
return self.builder_.get()
def visit_call_(
self,
call: relax.Call,
) -> relax.Expr:
call = self.visit_expr_post_order(call)
if call.op != tvm.ir.Op.get("relax.matmul"):
return call
# Do not fuse dequantize-transpose for GeMM
if (
call.args[0].ty.ndim < 2
or not isinstance(call.args[0].ty.shape[-2], tirx.IntImm)
or call.args[0].ty.shape[-2].value != 1
):
return call
matmul_rhs = self.lookup_binding(call.args[1])
if (
not isinstance(matmul_rhs, relax.Call)
or matmul_rhs.op != tvm.ir.Op.get("relax.permute_dims")
or matmul_rhs.args[0].ty.ndim != 2
or matmul_rhs.attrs.axes is not None
):
return call
transpose_input = self.lookup_binding(matmul_rhs.args[0])
if (
not isinstance(transpose_input, relax.Call)
or transpose_input.op != tvm.ir.Op.get("relax.call_tir")
or not transpose_input.args[0].name_hint.startswith("dequantize")
or not isinstance(transpose_input.ty, relax.TensorType)
):
return call
dequantize_tir_func = self.mod[transpose_input.args[0]]
assert isinstance(dequantize_tir_func, tirx.PrimFunc)
if (
len(dequantize_tir_func.body.block.alloc_buffers) != 1
or not isinstance(dequantize_tir_func.body.block.body, tirx.SeqStmt)
or len(dequantize_tir_func.body.block.body) != 2
or not isinstance(dequantize_tir_func.body.block.body[1], tirx.For)
or not isinstance(dequantize_tir_func.body.block.body[1].body.body, tirx.SBlockRealize)
or dequantize_tir_func.body.block.body[1].body.body.block.name_hint != "T_transpose"
):
return call
new_func_buffers = [
dequantize_tir_func.buffer_map[var] for var in dequantize_tir_func.params
]
new_func_buffers[-1] = dequantize_tir_func.body.block.alloc_buffers[0]
new_func = tirx.PrimFunc(
params=new_func_buffers,
body=tirx.SBlockRealize(
iter_values=[],
predicate=True,
block=tirx.SBlock(
iter_vars=[],
reads=[],
writes=[],
name_hint="root",
body=dequantize_tir_func.body.block.body[0],
),
),
)
# Call `renew_defs` for deep-copy to avoid IR node duplication in
# different PrimFuncs of an IRModule.
new_func = s_tir.renew_defs(new_func)
g_var = self.builder_.add_func(new_func, func_name="dequantize")
dequantize_matmul_rhs = self.builder_.emit(
relax.call_tir(g_var, transpose_input.args[1], out_ty=matmul_rhs.ty)
)
return relax.op.matmul(call.args[0], dequantize_matmul_rhs, out_dtype=call.attrs.out_dtype)
@@ -0,0 +1,331 @@
"""A compiler pass that fuses dequantize matmul + epilogue."""
import operator
from functools import reduce
import tvm
from tvm import IRModule, relax
from tvm.relax.dpl import rewrite_call
from tvm.relax.dpl.pattern import is_op, wildcard
@tvm.transform.module_pass(opt_level=0, name="FuseDequantizeEpilogue")
class FuseFTDequantizeEpilogue:
"""A compiler pass that fuses FasterTransformer dequantize matmul + epilogue."""
def transform_module(
self,
mod: IRModule,
_ctx: tvm.transform.PassContext,
) -> IRModule:
"""IRModule-level transformation"""
for gv, func in mod.functions_items():
if isinstance(func, relax.Function):
func = fuse_bias(func)
func = fuse_activation(func)
func = fuse_residual_binary(func)
func = fuse_residual_unary(func)
mod[gv] = func
return mod
def fuse_bias(func: relax.Function) -> relax.Function:
"""
Fuse following `relax.add` into fastertransformer.gemm_fp16_int as bias:
Before:
```
lv1 = relax.call_dps_packed("fastertransformer.gemm_fp16_int", ...)
lv2 = relax.add(lv1, bias)
```
After:
```
lv2 = relax.call_dps_packed("fastertransformer.gemm_fp16_int_bias", ..., bias, ...)
```
Parameters
----------
func : relax.Function
The function before fusion.
Returns
-------
ret : relax.Function
The function after fusion.
"""
decode_matmul = is_op("relax.call_dps_packed")(varg_default_wildcard=True)
bias = wildcard()
pattern = is_op("relax.add")(decode_matmul, bias) | is_op("relax.add")(bias, decode_matmul)
def rewriter(expr, match):
if match[decode_matmul].args[0].global_symbol == "fastertransformer.gemm_fp16_int":
assert len(match[decode_matmul].args) == 2
args_list = match[decode_matmul].args[1]
assert len(args_list) == 8
if not args_list[3].value == "identity":
# bias cannot be fused after activation
return expr
matched_bias = match[bias]
bias_stride = (
matched_bias.ty.shape[-1]
if bias
and not reduce(operator.mul, matched_bias.ty.shape, 1) == matched_bias.ty.shape[-1]
else 0
)
return relax.call_dps_packed(
"fastertransformer.gemm_fp16_int_bias",
[
args_list[0], # x
args_list[1], # weight
args_list[2], # scale
matched_bias, # bias
args_list[3], # activation
args_list[4], # m
args_list[5], # n
args_list[6], # k
args_list[7], # group_size
bias_stride, # bias_stride
],
out_ty=match[decode_matmul].ty,
)
return expr
return rewrite_call(pattern, rewriter, func)
def fuse_activation(func: relax.Function) -> relax.Function:
"""
Fuse following `relax.nn.silu/relu/gelu` into fastertransformer.gemm_fp16_int_bias
as activation:
Before:
```
lv1 = relax.call_dps_packed("fastertransformer.gemm_fp16_int_bias", ...)
lv2 = relax.silu(lv1)
```
After:
```
lv2 = relax.call_dps_packed("fastertransformer.gemm_fp16_int_bias", ..., "silu", ...)
```
Parameters
----------
func : relax.Function
The function before fusion.
Returns
-------
ret : relax.Function
The function after fusion.
"""
decode_matmul = is_op("relax.call_dps_packed")(varg_default_wildcard=True)
pattern = (
is_op("relax.nn.silu")(decode_matmul)
| is_op("relax.nn.gelu")(decode_matmul)
| is_op("relax.nn.relu")(decode_matmul)
)
def rewriter(expr, match):
if match[decode_matmul].args[0].global_symbol == "fastertransformer.gemm_fp16_int":
matched_activation = match[pattern]
assert matched_activation.op.name in [
"relax.nn.silu",
"relax.nn.gelu",
"relax.nn.relu",
]
assert len(match[decode_matmul].args) == 2
args_list = match[decode_matmul].args[1]
assert len(args_list) == 8
return relax.call_dps_packed(
"fastertransformer.gemm_fp16_int",
[
args_list[0], # x
args_list[1], # weight
args_list[2], # scale
matched_activation.op.name[9:], # activation
args_list[4], # m
args_list[5], # n
args_list[6], # k
args_list[7], # group_size
],
out_ty=match[decode_matmul].ty,
)
if match[decode_matmul].args[0].global_symbol == "fastertransformer.gemm_fp16_int_bias":
matched_activation = match[pattern]
assert matched_activation.op.name in [
"relax.nn.silu",
"relax.nn.gelu",
"relax.nn.relu",
]
assert len(match[decode_matmul].args) == 2
args_list = match[decode_matmul].args[1]
assert len(args_list) == 10
return relax.call_dps_packed(
"fastertransformer.gemm_fp16_int_bias",
[
args_list[0], # x
args_list[1], # weight
args_list[2], # scale
args_list[3], # bias
matched_activation.op.name[9:], # activation
args_list[5], # m
args_list[6], # n
args_list[7], # k
args_list[8], # group_size
args_list[9], # bias_stride
],
out_ty=match[decode_matmul].ty,
)
return expr
return rewrite_call(pattern, rewriter, func)
def fuse_residual_binary(func: relax.Function) -> relax.Function:
"""
Fuse following `relax.add/multiply` into fastertransformer.gemm_fp16_int_bias as
residual binary operation:
Before:
```
lv1 = relax.call_dps_packed("fastertransformer.gemm_fp16_int_bias", ...)
lv2 = relax.add(lv1, residual)
```
After:
```
lv2 = relax.call_dps_packed(
"fastertransformer.gemm_fp16_int_bias_residual",
...,
residual,
...,
"plus",
...
)
```
Parameters
----------
func : relax.Function
The function before fusion.
Returns
-------
ret : relax.Function
The function after fusion.
"""
decode_matmul = is_op("relax.call_dps_packed")(varg_default_wildcard=True)
residual = wildcard()
pattern = (
is_op("relax.add")(decode_matmul, residual)
| is_op("relax.add")(residual, decode_matmul)
| is_op("relax.multiply")(decode_matmul, residual)
| is_op("relax.multiply")(residual, decode_matmul)
)
def rewriter(expr, match):
if match[decode_matmul].args[0].global_symbol == "fastertransformer.gemm_fp16_int_bias":
matched_binary = match[pattern]
assert matched_binary.op.name in ["relax.add", "relax.multiply"]
binary_op = "plus" if matched_binary.op.name == "relax.add" else "multiply"
assert len(match[decode_matmul].args) == 2
args_list = match[decode_matmul].args[1]
assert len(args_list) == 10
matched_residual = match[residual]
if not args_list[9].value == 0:
# fastertransformer.gemm_fp16_int_bias_residual does not support
# bias_stride != 0 yet
return expr
return relax.call_dps_packed(
"fastertransformer.gemm_fp16_int_bias_residual",
[
args_list[0], # x
args_list[1], # weight
args_list[2], # scale
args_list[3], # bias
matched_residual, # residual
args_list[4], # activation
binary_op, # binary_op
"identity", # unary_op
args_list[5], # m
args_list[6], # n
args_list[7], # k
args_list[8], # group_size
],
out_ty=match[decode_matmul].ty,
)
return expr
return rewrite_call(pattern, rewriter, func)
def fuse_residual_unary(func: relax.Function) -> relax.Function:
"""
Fuse following `relax.nn.silu/relu/gelu` into fastertransformer.gemm_fp16_int_bias_residual
as residual unary operation:
Before:
```
lv1 = relax.call_dps_packed("fastertransformer.gemm_fp16_int_bias_residual", ...)
lv2 = relax.silu(lv1)
```
After:
```
lv2 = relax.call_dps_packed("fastertransformer.gemm_fp16_int_bias_residual", ..., "silu", ...)
```
Parameters
----------
func : relax.Function
The function before fusion.
Returns
-------
ret : relax.Function
The function after fusion.
"""
decode_matmul = is_op("relax.call_dps_packed")(varg_default_wildcard=True)
pattern = (
is_op("relax.nn.silu")(decode_matmul)
| is_op("relax.nn.gelu")(decode_matmul)
| is_op("relax.nn.relu")(decode_matmul)
)
def rewriter(expr, match):
if (
match[decode_matmul].args[0].global_symbol
== "fastertransformer.gemm_fp16_int_bias_residual"
):
matched_activation = match[pattern]
assert matched_activation.op.name in [
"relax.nn.silu",
"relax.nn.gelu",
"relax.nn.relu",
]
assert len(match[decode_matmul].args) == 2
args_list = match[decode_matmul].args[1]
assert len(args_list) == 12
return relax.call_dps_packed(
"fastertransformer.gemm_fp16_int_bias_residual",
[
args_list[0], # x
args_list[1], # weight
args_list[2], # scale
args_list[3], # bias
args_list[4], # residual
args_list[5], # activation
args_list[6], # binary_op
matched_activation.op.name[9:], # activation
args_list[8], # m
args_list[9], # n
args_list[10], # k
args_list[11], # group_size
],
out_ty=match[decode_matmul].ty,
)
return expr
return rewrite_call(pattern, rewriter, func)
@@ -0,0 +1,145 @@
"""A compiler pass that fuses transpose + matmul."""
import tvm
from tvm import IRModule, relax, te, tirx
from tvm.relax.dpl.pattern import is_op, wildcard
from tvm.relax.expr_functor import PyExprMutator, mutator
@tvm.transform.module_pass(opt_level=0, name="FuseTransposeMatmul")
class FuseTransposeMatmul:
"""A compiler pass that fuses transpose + matmul."""
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""IRModule-level transformation"""
mod = relax.transform.FuseOpsByPattern(
[
(
"transpose_matmul_fuse",
*_pattern(),
),
]
)(mod)
transpose_matmul_codegen = _TransposeMatmulFuser(mod)
for g_var, func in mod.functions_items():
if isinstance(func, relax.Function):
func = transpose_matmul_codegen.visit_expr(func)
transpose_matmul_codegen.builder_.update_func(g_var, func)
return transpose_matmul_codegen.builder_.get()
def _pattern():
"""Pattern for transpose + matmul."""
w = wildcard()
x = wildcard()
wT = is_op("relax.permute_dims")(w)
o = is_op("relax.matmul")(x, wT)
annotations = {"o": o, "w": w, "x": x, "wT": wT}
def _check(context: relax.transform.PatternCheckContext) -> bool:
transpose_call = context.annotated_expr["wT"]
ndim = transpose_call.args[0].ty.ndim
if ndim == -1:
return False
if ndim == 2 and transpose_call.attrs.axes is None:
return True
axes = list(range(ndim))
axes[-1], axes[-2] = axes[-2], axes[-1]
return list(transpose_call.attrs.axes) == axes
return o, annotations, _check
@mutator
class _TransposeMatmulFuser(PyExprMutator):
def __init__(self, mod):
super().__init__(mod)
def visit_call_(
self,
call: relax.Call,
) -> relax.Expr:
out_dtype = None
def te_transposed_matmul(a: te.Tensor, b: te.Tensor) -> te.Tensor:
nonlocal out_dtype
a_shape = list(a.shape)
b_shape = list(b.shape)
a_prepended = False
b_appended = False
if len(a_shape) == 1:
a_prepended = True
a_shape.insert(0, 1)
if len(b_shape) == 1:
b_appended = True
b_shape.append(1)
is_a_larger = len(a_shape) > len(b_shape)
offset = len(a_shape) - len(b_shape) if is_a_larger else len(b_shape) - len(a_shape)
a_relax = relax.Var("a", relax.TensorType(a.shape))
bT_shape = list(b.shape)
bT_shape[-1], bT_shape[-2] = bT_shape[-2], bT_shape[-1]
bT_relax = relax.Var("b", relax.TensorType(bT_shape))
output_shape = self.builder_.normalize(relax.op.matmul(a_relax, bT_relax)).ty.shape
def matmul_compute(*idx_spatial):
k = te.reduce_axis((0, a_shape[-1]), name="k")
def multiply_compute(idx_reduce):
a_indices = []
b_indices = []
for i in range(offset):
if is_a_larger:
a_indices.append(idx_spatial[i])
else:
b_indices.append(idx_spatial[i])
for i in range(offset, len(output_shape) - (2 - a_prepended - b_appended)):
a_dim = a_shape[i if is_a_larger else i - offset]
b_dim = b_shape[i if not is_a_larger else i - offset]
dim_equal = a_dim == b_dim
if not isinstance(dim_equal, tirx.IntImm) or dim_equal == 0:
a_dim_is_one = isinstance(a_dim, tirx.IntImm) and a_dim == 1
b_dim_is_one = isinstance(b_dim, tirx.IntImm) and b_dim == 1
a_indices.append(0 if a_dim_is_one else idx_spatial[i])
b_indices.append(0 if b_dim_is_one else idx_spatial[i])
else:
a_indices.append(idx_spatial[i])
b_indices.append(idx_spatial[i])
if not a_prepended:
a_indices.append(idx_spatial[-2 + b_appended])
a_indices.append(idx_reduce)
if not b_appended:
b_indices.append(idx_spatial[-1])
b_indices.append(idx_reduce)
dtype = out_dtype
if dtype != "":
return a(*a_indices).astype(dtype) * b(*b_indices).astype(dtype)
return a(*a_indices) * b(*b_indices)
return te.sum(multiply_compute(k), axis=k)
return te.compute(
output_shape,
lambda *idx: matmul_compute(*idx),
name="NT_matmul",
)
if isinstance(call.op, relax.GlobalVar):
function = self.builder_.get()[call.op]
if (
"Composite" in function.attrs
and function.attrs["Composite"] == "transpose_matmul_fuse"
):
out_dtype = function.ret_ty.dtype
return self.builder_.call_te(
te_transposed_matmul,
call.args[1],
call.args[0],
primfunc_name_hint="NT_matmul",
)
return super().visit_call_(call)
@@ -0,0 +1,198 @@
"""A compiler pass that lifts TIR-level global allocation to Relax."""
from typing import Dict, List, Tuple # noqa: UP035
import tvm
from tvm import relax, tirx
from tvm.ir.module import IRModule
from tvm.relax.analysis import remove_all_unused
from tvm.relax.expr_functor import PyExprMutator, mutator
@tvm.transform.module_pass(opt_level=0, name="LiftTIRGlobalBufferAlloc")
class LiftTIRGlobalBufferAlloc:
"""A compiler pass that lifts TIR-level global allocation to Relax."""
def transform_module(
self,
mod: IRModule,
_ctx: tvm.transform.PassContext,
) -> IRModule:
"""IRModule-level transformation"""
return _TIRGlobalAllocRewriter(mod).transform()
@mutator
class _TIRGlobalAllocRewriter(PyExprMutator):
def __init__(self, mod: IRModule):
super().__init__(mod)
self.mod = mod
self.gv2new_tensor_sinfo: Dict[ # noqa: UP006
tvm.ir.GlobalVar,
Tuple[tvm.ir.GlobalVar, List[relax.TensorType], tirx.PrimFunc], # noqa: UP006
] = {}
def transform(self) -> IRModule:
"""Entry point of the transformation"""
for g_var, func in self.mod.functions_items():
if isinstance(func, tirx.PrimFunc):
updated_func, tensor_sinfo_list = remove_global_buf_alloc(func)
if len(tensor_sinfo_list) > 0:
new_gv = self.builder_.add_func(updated_func, g_var.name_hint)
self.gv2new_tensor_sinfo[g_var] = (new_gv, tensor_sinfo_list, func)
self.mod = self.builder_.get()
for g_var, func in self.mod.functions_items():
if isinstance(func, relax.Function):
updated_func = self.visit_expr(func)
updated_func = remove_all_unused(updated_func)
self.builder_.update_func(g_var, updated_func)
mod = self.builder_.get()
return relax.transform.DeadCodeElimination()(mod)
def visit_call_(self, call: relax.Call):
call = self.visit_expr_post_order(call)
if (
call.op != tvm.ir.Op.get("relax.call_tir")
or call.args[0] not in self.gv2new_tensor_sinfo
):
return call
g_var = call.args[0]
new_gv, tensor_sinfo, func_before_update = self.gv2new_tensor_sinfo[g_var]
assert len(call.ty_args) == 1
if any(_has_symbolic_var(sinfo) for sinfo in tensor_sinfo):
tensor_sinfo, success = _resolve_tir_var_mapping(func_before_update, call, tensor_sinfo)
if not success:
# Cannot resolve TIR var mapping. Fall back to no lifting.
self.gv2new_tensor_sinfo.pop(g_var)
return call
args = list(call.args)
args[0] = new_gv
if isinstance(call.ty_args[0], relax.TensorType):
new_call = relax.Call(
call.op,
args=args,
ty_args=[relax.TupleType(list(call.ty_args) + tensor_sinfo)],
attrs=call.attrs,
)
emitted_tuple = self.builder_.emit(new_call)
return relax.TupleGetItem(emitted_tuple, 0)
assert isinstance(call.ty_args[0], relax.TupleType)
return relax.Call(
call.op,
args=args,
ty_args=[relax.TupleType(list(call.ty_args[0].fields) + tensor_sinfo)],
attrs=call.attrs,
)
def remove_global_buf_alloc(
func: tirx.PrimFunc,
) -> Tuple[tirx.PrimFunc, List[relax.TensorType]]: # noqa: UP006
"""Remove the global buffer allocation for a given TIR PrimFunc."""
assert isinstance(func.body, tirx.SBlockRealize)
params = list(func.params)
buffer_map = dict(func.buffer_map)
tensor_sinfo = []
alloc_buffers = []
insertion_point = len(params)
while not isinstance(params[insertion_point - 1].ty, tvm.ir.PointerType):
insertion_point -= 1
assert insertion_point >= 1
prev_root_block = func.body.block
for buf_alloc in func.body.block.alloc_buffers:
if buf_alloc.scope() == "global":
param = tirx.Var("var_" + buf_alloc.name, "handle")
params.insert(insertion_point, param)
insertion_point += 1
buffer_map[param] = buf_alloc
tensor_sinfo.append(relax.TensorType(buf_alloc.shape, buf_alloc.dtype))
else:
alloc_buffers.append(buf_alloc)
if len(tensor_sinfo) == 0:
return func, []
assert len(prev_root_block.iter_vars) == 0
assert len(prev_root_block.reads) == 0
assert len(prev_root_block.writes) == 0
assert len(prev_root_block.match_buffers) == 0
assert prev_root_block.name_hint == "root"
assert prev_root_block.init is None
root_block = tirx.SBlock(
iter_vars=[],
reads=[],
writes=[],
name_hint="root",
body=prev_root_block.body,
alloc_buffers=alloc_buffers,
annotations=prev_root_block.annotations,
)
updated_func = tirx.PrimFunc(
params=params,
body=tirx.SBlockRealize(iter_values=[], predicate=True, block=root_block),
ret_type=func.ret_type,
buffer_map=buffer_map,
attrs=func.attrs,
)
return updated_func, tensor_sinfo
def _has_symbolic_var(tensor_sinfo: relax.TensorType) -> bool:
assert isinstance(tensor_sinfo.shape, relax.ShapeExpr)
for dim in tensor_sinfo.shape.values:
if not isinstance(dim, tirx.IntImm):
return True
return False
def _resolve_tir_var_mapping(
func: tirx.PrimFunc,
call: relax.Call,
tensor_sinfo: List[relax.TensorType], # noqa: UP006
) -> Tuple[List[relax.TensorType], bool]: # noqa: UP006
"""Resolve the TIR symbolic var relationship across sides of PrimFunc and Relax Function"""
var_map: Dict[tirx.Var, tirx.Expr] = {} # noqa: UP006
n_arg = len(call.args[1].fields)
for i in range(n_arg):
buffer_shape = func.buffer_map[func.params[i]].shape
arg_shape = call.args[1][i].ty.shape.values
assert len(buffer_shape) == len(arg_shape)
for v_l, v_r in zip(buffer_shape, arg_shape):
if isinstance(v_l, tirx.Var):
var_map[v_l] = v_r
elif not isinstance(v_l, tirx.IntImm):
return [], False
ret_tensors = call.ty_args[0]
ret_tensors = (
[ret_tensors] if isinstance(ret_tensors, relax.TensorType) else list(ret_tensors.fields)
)
for i, ret_tensor in enumerate(ret_tensors):
buffer_shape = func.buffer_map[func.params[n_arg + i]].shape
ret_tensor_shape = ret_tensor.shape.values
assert len(buffer_shape) == len(ret_tensor_shape)
for v_l, v_r in zip(buffer_shape, ret_tensor_shape):
if isinstance(v_l, tirx.Var):
var_map[v_l] = v_r
elif not isinstance(v_l, tirx.IntImm):
return [], False
updated_tensor_sinfo = []
for sinfo in tensor_sinfo:
if not _has_symbolic_var(sinfo):
updated_tensor_sinfo.append(sinfo)
continue
new_shape = []
for dim in sinfo.shape.values:
new_shape.append(tirx.stmt_functor.substitute(dim, var_map))
updated_tensor_sinfo.append(relax.TensorType(new_shape, sinfo.dtype))
return updated_tensor_sinfo, True
@@ -0,0 +1,64 @@
"""A compiler pass that dispatch low-batch-gemm to gemv schedule."""
import tvm
import tvm_ffi
from tvm import tirx
from tvm.ir.module import IRModule
from tvm.s_tir import dlight as dl
@tvm.transform.module_pass(opt_level=0, name="LowBatchGemvSpecialize")
class LowBatchGemvSpecialize:
"""A compiler pass that dispatch low-batch-gemm to gemv schedule."""
def transform_module(
self,
mod: IRModule,
_ctx: tvm.transform.PassContext,
) -> IRModule:
"""IRModule-level transformation"""
for g_var, func in mod.functions_items():
if isinstance(func, tirx.PrimFunc):
low_batch_range = [2, 8]
buckets = [2, 4]
low_batch_funcs = []
for bucket in buckets:
low_batch_mod = IRModule({})
low_batch_mod["main"] = func
low_batch_mod = dl.ApplyDefaultSchedule(
dl.gpu.LowBatchGEMV(bucket),
)(low_batch_mod)
low_batch_funcs.append(low_batch_mod["main"])
if any(
tvm_ffi.structural_equal(low_batch_func, func)
for low_batch_func in low_batch_funcs
):
continue
buffers = func.buffer_map.values()
shapes = [buffer.shape for buffer in buffers]
symbolic_vars = set(
expr for shape in shapes for expr in shape if isinstance(expr, tirx.Var)
)
if len(symbolic_vars) != 1:
continue
gemm_mod = IRModule({})
gemm_mod["main"] = func
gemm_mod = dl.ApplyDefaultSchedule(
dl.gpu.Matmul(),
)(gemm_mod)
gemm_func = gemm_mod["main"]
sym_var = next(iter(symbolic_vars))
body = gemm_func.body
for i, range_limit in reversed(list(enumerate(low_batch_range))):
body = tirx.IfThenElse(
tirx.op.tvm_thread_invariant(sym_var <= range_limit),
low_batch_funcs[i].body,
body,
)
body = tirx.SBlock([], [], [], "root", body)
body = tirx.SBlockRealize([], True, body)
new_func = func.with_body(body)
new_func = new_func.with_attr("tirx.is_scheduled", 1)
new_func = new_func.with_attr("tirx.HoistIfThenElseExprWithBlock", 1)
mod.update_func(g_var, new_func)
return mod
+209
View File
@@ -0,0 +1,209 @@
"""The compilation pipeline for LLM applications."""
from pathlib import Path
from typing import Any, Dict, List, Optional # noqa: UP035
import tvm
from tvm import IRModule
from tvm.relax import register_pipeline
from tvm.relax.frontend import nn
from tvm.s_tir import dlight as dl
from mlc_llm.interface.compiler_flags import IPCAllReduceStrategyType
from mlc_llm.support import logging
from .attach_cuda_graph_alloc_init_func import AttachCUDAGraphAllocInitFunc
from .attach_embedding_allocator import AttachAllocEmbeddingTensorFunc
from .attach_logit_processor import AttachLogitProcessFunc
from .attach_sampler import AttachGPUSamplingFunc
from .attach_softmax_with_temperature import AttachSoftmaxWithTemperature
from .attach_spec_decode_aux_funcs import AttachSpecDecodeAuxFuncs
from .attach_support_info import (
AttachAdditionalPrimFuncs,
AttachCUDAGraphSymbolicCaptureHints,
AttachMemoryPlanAttr,
AttachPipelineParallelStages,
AttachSequenceLengthPaddingFactor,
AttachVariableBounds,
)
from .blas_dispatch import BLASDispatch
from .clean_up_tir_attrs import CleanUpTIRAttrs
from .dispatch_kv_cache_creation import DispatchKVCacheCreation
from .dispatch_triton_kernel import DispatchTritonKernel
from .estimate_memory_usage import AttachMetadataWithMemoryUsage
from .fuse_add_norm import FuseAddRMSNorm
from .fuse_dequantize_matmul_ewise import FuseDequantizeMatmulEwise
from .fuse_dequantize_take import FuseDequantizeTake
from .fuse_dequantize_transpose import FuseDequantizeTranspose
from .fuse_ft_dequantize_matmul_epilogue import FuseFTDequantizeEpilogue
from .fuse_transpose_matmul import FuseTransposeMatmul
from .lift_global_buffer_alloc import LiftTIRGlobalBufferAlloc
from .low_batch_specialization import LowBatchGemvSpecialize
from .pipeline_parallel_rewrite import PipelineParallelRewrite
from .scatter_tuple_get_item import ScatterTupleGetItem
logger = logging.getLogger(__name__)
@tvm.transform.module_pass(opt_level=0, name="_LogProgress")
class _LogProgress:
"""A dummy compiler pass that does nothing but logging."""
def __init__(self, *args):
self.args = args
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""A dummy transformation"""
logger.info(*self.args)
return mod
@tvm.transform.module_pass(opt_level=0, name="DebugDump")
class _DebugDump:
"""A dummy compiler pass that does nothing but logging.
Only enabled when debug_dump is not None"""
def __init__(self, file_name: str, file_path: Optional[Path], show_meta: bool = False):
self.file_name = file_name
self.file_path = file_path
self.show_meta = show_meta
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""A dummy transformation that dumps the module to file"""
if self.file_path is not None:
# NOTE: We use debug level here to avoid spamming the console
logger.debug("Dumping IR to %s", self.file_path / self.file_name)
with open(self.file_path / self.file_name, "w", encoding="utf-8") as f:
f.write(mod.script(show_meta=self.show_meta))
return mod
@register_pipeline("mlc_llm")
def _mlc_llm_pipeline(
target: tvm.target.Target,
flashinfer: bool = False,
cublas_gemm: bool = False,
faster_transformer: bool = False,
allreduce_strategy: IPCAllReduceStrategyType = IPCAllReduceStrategyType.NONE,
variable_bounds: Optional[Dict[str, int]] = None, # noqa: UP006
cuda_graph_symbolic_capture_hints: Optional[Dict[str, List[str]]] = None, # noqa: UP006
additional_tirs: Optional[Dict[str, tvm.tirx.PrimFunc]] = None, # noqa: UP006
metadata: Optional[Dict[str, Any]] = None, # noqa: UP006
ext_mods: Optional[List[nn.ExternModule]] = None, # noqa: UP006
debug_dump: Optional[Path] = None,
):
variable_bounds = variable_bounds or {}
cuda_graph_symbolic_capture_hints = cuda_graph_symbolic_capture_hints or {}
additional_tirs = additional_tirs or {}
metadata = metadata or {}
ext_mods = ext_mods or []
tensor_parallel_shards = metadata.get("tensor_parallel_shards", 1)
@tvm.transform.module_pass(opt_level=0)
def _pipeline(mod: tvm.ir.IRModule, _ctx: tvm.transform.PassContext) -> tvm.ir.IRModule:
seq = tvm.transform.Sequential(
[
# Phase 0. Add additional information for compilation and remove unused Relax func
DispatchKVCacheCreation(target, flashinfer, metadata),
AttachSoftmaxWithTemperature(target, metadata),
AttachVariableBounds(variable_bounds),
AttachCUDAGraphSymbolicCaptureHints(cuda_graph_symbolic_capture_hints),
AttachPipelineParallelStages(metadata["pipeline_parallel_stages"]),
AttachLogitProcessFunc(target),
AttachAdditionalPrimFuncs(additional_tirs),
AttachAllocEmbeddingTensorFunc(metadata),
AttachGPUSamplingFunc(target, variable_bounds),
AttachSpecDecodeAuxFuncs(tensor_parallel_shards),
AttachMemoryPlanAttr(),
AttachSequenceLengthPaddingFactor(target, metadata),
tvm.tirx.transform.BindTarget(tvm.target.Target.current(allow_none=False)),
_DebugDump("debug-phase0.py", debug_dump, show_meta=False),
# Phase 1. Passes on high-level operator graph
_LogProgress("Running TVM Relax graph-level optimizations"),
DispatchTritonKernel(target),
FuseFTDequantizeEpilogue(),
FuseDequantizeTranspose(),
BLASDispatch(target) if cublas_gemm else tvm.transform.Sequential([]),
(
FuseAddRMSNorm(target=target)
if target.kind.name != "llvm"
else tvm.transform.Sequential([])
),
FuseTransposeMatmul(),
_DebugDump("debug-phase1.py", debug_dump, show_meta=False),
# Phase 2. Lowering to TIR, inherited TVM Relax's official "zero" pipeline
_LogProgress("Lowering to TVM TIR kernels"),
tvm.relax.backend.DispatchSampling(),
tvm.relax.backend.DispatchSortScan(),
tvm.relax.transform.LegalizeOps(),
tvm.relax.transform.AnnotateTIROpPattern(),
tvm.relax.transform.FoldConstant(),
tvm.relax.transform.FuseOps(),
tvm.relax.transform.FuseTIR(),
_DebugDump("debug-phase2.py", debug_dump, show_meta=False),
# Phase 3. Passes on TIR
_LogProgress("Running TVM TIR-level optimizations"),
FuseDequantizeMatmulEwise(),
FuseDequantizeTake(),
tvm.relax.transform.DeadCodeElimination(),
CleanUpTIRAttrs(["op_pattern"]),
_DebugDump("debug-phase3.py", debug_dump, show_meta=False),
# Phase 4. Low-level Optimizations
_LogProgress("Running TVM Dlight low-level optimizations"),
LowBatchGemvSpecialize(),
(
dl.ApplyDefaultSchedule(
dl.gpu.Matmul(),
dl.gpu.GEMV(),
dl.gpu.Reduction(),
dl.gpu.GeneralReduction(),
dl.gpu.Fallback(),
)
if target.kind.name != "llvm"
else dl.ApplyDefaultSchedule(
dl.cpu.GEMV(),
)
),
_DebugDump("debug-phase4.py", debug_dump, show_meta=False),
_LogProgress("Lowering to VM bytecode"),
(
LiftTIRGlobalBufferAlloc()
if target.kind.name != "llvm"
else tvm.transform.Sequential([])
),
(
tvm.tirx.transform.ForceNarrowIndexToInt32()
if target.kind.name != "cuda"
else tvm.transform.Sequential([])
),
ScatterTupleGetItem(),
PipelineParallelRewrite(),
tvm.relax.transform.RewriteDataflowReshape(),
tvm.relax.transform.ToNonDataflow(),
tvm.relax.transform.RemovePurityChecking(),
tvm.relax.transform.CallTIRRewrite(),
(
tvm.relax.transform.IPCAllReduceRewrite(allreduce_strategy)
if allreduce_strategy != IPCAllReduceStrategyType.NONE
else tvm.transform.Sequential([])
),
tvm.relax.transform.StaticPlanBlockMemory(),
AttachMetadataWithMemoryUsage(metadata),
_DebugDump("debug-phase5.py", debug_dump, show_meta=False),
tvm.relax.transform.RewriteCUDAGraph(),
AttachCUDAGraphAllocInitFunc(),
tvm.relax.transform.LowerGPUIPCAllocStorage(),
tvm.relax.transform.LowerAllocTensor(),
tvm.relax.transform.KillAfterLastUse(),
tvm.relax.transform.LowerRuntimeBuiltin(),
tvm.relax.transform.VMShapeLower(),
tvm.relax.transform.AttachGlobalSymbol(),
_LogProgress("Compiling external modules"),
tvm.relax.transform.AttachExternModules(ext_mods),
_LogProgress("Compilation complete! Exporting to disk"),
]
)
mod = seq(mod)
return mod
return _pipeline
@@ -0,0 +1,399 @@
"""A compiler pass that rewrites IR for pipeline parallelism."""
from typing import Dict, List, Optional, Tuple # noqa: UP035
import tvm
from tvm import relax, tirx
from tvm.ir.module import IRModule
from tvm.relax.expr_functor import PyExprMutator, PyExprVisitor, mutator, visitor
@tvm.transform.module_pass(opt_level=0, name="PipelineParallelRewrite")
class PipelineParallelRewrite:
"""A compiler pass that rewrites IR for pipeline parallelism."""
def transform_module(
self,
mod: IRModule,
_ctx: tvm.transform.PassContext,
) -> IRModule:
"""IRModule-level transformation"""
return _PipelineParallelRewriter(mod.clone()).transform()
@mutator
class _PipelineParallelRewriter(PyExprMutator):
def __init__(self, mod: IRModule):
super().__init__(mod)
self.mod = mod
self.old_packed_params_var: relax.Var
self.new_main_packed_params_var: relax.Var
self.new_stage_func_packed_params: relax.Var
self.undefined_shape_vars_remap: Dict[tirx.Var, tirx.Var] # noqa: UP006
self.undefined_param_shape_vars_remap: Dict[tirx.Var, tirx.Var] # noqa: UP006
def transform(self) -> IRModule:
"""Entry point of the transformation"""
for g_var, func in self.mod.functions_items():
if not isinstance(func, relax.Function) or "pipeline_parallel_stages" not in func.attrs:
continue
num_stages = int(func.attrs["pipeline_parallel_stages"])
if num_stages == 1:
continue
pipeline_stages, stage_send_vars, stage_receive_vars = _extract_pipeline_stages(func)
assert len(pipeline_stages) == num_stages, (
"Number of pipeline stages mismatches: "
f"expecting {num_stages} stages, but {len(pipeline_stages)} are found in the IR."
)
required_func_params = _analyze_required_func_params(pipeline_stages, func.params)
assert "num_input" in func.attrs
num_input = int(func.attrs["num_input"])
assert (
len(func.params) == num_input + 1
and isinstance(func.params[num_input], relax.Var)
and func.params[num_input].name_hint == "packed_params"
), 'Only the extra "packed_params" parameter is allowed'
self.old_packed_params_var = func.params[num_input]
self.new_main_packed_params_var = relax.Var("packed_params", relax.ObjectType())
for required_params in required_func_params:
for i, param in enumerate(required_params):
if param.same_as(self.old_packed_params_var):
required_params.pop(i)
break
func_output = func.body.body
assert isinstance(func_output, relax.Var)
stage_func_gvs = []
caller_args_list = []
for i in range(num_stages):
stage_func_gv, caller_args = self._create_stage_func(
g_var.name_hint + f"_stage{i}",
pipeline_stages[i],
required_func_params[i],
stage_receive_vars[i],
stage_send_vars[i],
func.attrs,
func_output=func_output if i == num_stages - 1 else None,
)
stage_func_gvs.append(stage_func_gv)
caller_args_list.append(caller_args)
# Create and update the entry function, which dispatches toz the stage functions
# according to the disco worker group id.
bb = relax.BlockBuilder()
params = [*list(func.params[:-1]), self.new_main_packed_params_var]
with bb.function(g_var.name_hint, params=params):
dispatch_func_args = []
for stage_func_gv, caller_args in zip(stage_func_gvs, caller_args_list):
dispatch_func_args.append([stage_func_gv, *caller_args])
output = bb.emit(
relax.op.call_builtin_with_ctx(
"mlc.multi_gpu.DispatchFunctionByGroup",
args=[dispatch_func_args],
ty_args=relax.ObjectType(),
)
)
dispatch_func_gv = bb.emit_func_output(output)
dispatch_func = bb.finalize()[dispatch_func_gv]
self.builder_.update_func(g_var, dispatch_func)
return self.builder_.finalize()
def _create_stage_func(
self,
func_name: str,
stage_bindings: List[relax.Binding], # noqa: UP006
required_func_params: List[relax.Var], # noqa: UP006
stage_receive_vars: List[relax.Var], # noqa: UP006
stage_send_vars: List[relax.Var], # noqa: UP006
func_attrs: tvm.ir.DictAttrs,
func_output: Optional[relax.Var],
) -> Tuple[tvm.ir.GlobalVar, List[relax.Expr]]: # noqa: UP006
self.undefined_shape_vars_remap = {}
self.undefined_param_shape_vars_remap = {}
# Prepare the func parameters (except the shape variables and packed params)
params, args = self._prepare_stage_func_params_and_args(required_func_params)
for new_param, old_param in zip(params, required_func_params):
self.set_var_remap(old_param, new_param)
# Create new packed params
self.new_stage_func_packed_params = relax.Var("packed_params", relax.ObjectType())
self.set_var_remap(self.old_packed_params_var, self.new_stage_func_packed_params)
new_func_outputs = []
with self.builder_.function(func_name, pure=False):
with self.builder_.dataflow():
# Emit the tensors received from last stage.
for receive_var in stage_receive_vars:
new_receive_var = self.builder_.emit(
relax.call_dps_packed(
"runtime.disco.recv_from_prev_group",
args=[],
out_ty=self._update_struct_info(receive_var.ty),
),
name_hint=receive_var.name_hint,
)
self.set_var_remap(receive_var, new_receive_var)
# Process the bindings in this stage.
for stage_binding in stage_bindings:
if stage_binding.var in stage_send_vars or stage_binding.var.same_as(
func_output
):
assert isinstance(stage_binding, relax.VarBinding)
new_var = self.builder_.emit_output(
self.visit_expr(stage_binding.value),
name_hint=stage_binding.var.name_hint,
)
self.set_var_remap(stage_binding.var, new_var)
new_func_outputs.append(new_var)
else:
self.visit_binding(stage_binding)
# Emit the calls to send tensors to the next stage.
for send_var in stage_send_vars:
new_send_var = self.get_var_remap(send_var)
self.builder_.emit(
relax.Call(
relax.ExternFunc("runtime.disco.send_to_next_group"),
args=[new_send_var],
ty_args=None,
)
)
# Create the param for the shape variables.
shape_var_params = []
shape_var_args = []
for (
shape_var_arg,
shape_var_param,
) in self.undefined_shape_vars_remap.items():
if shape_var_arg not in self.undefined_param_shape_vars_remap:
shape_var_params.append(shape_var_param)
shape_var_args.append(shape_var_arg)
params.append(relax.Var("s", relax.ShapeType(shape_var_params)))
args.append(relax.ShapeExpr(shape_var_args))
# Add the packed params.
params.append(self.new_stage_func_packed_params)
args.append(self.new_main_packed_params_var)
# Conclude the function.
if func_output is not None:
assert len(new_func_outputs) == 1
new_gv = self.builder_.emit_func_output(
(
new_func_outputs[0]
if len(new_func_outputs) == 1
and isinstance(new_func_outputs[0].ty, relax.TupleType)
else new_func_outputs
),
params=params,
)
new_func = (
self.builder_.get()[new_gv]
.with_attrs(func_attrs)
.with_attr("num_input", len(params) - 1)
.without_attr("global_symbol")
.without_attr("pipeline_parallel_stages")
)
self.builder_.update_func(new_gv, new_func)
return new_gv, args
def visit_var_binding_(self, binding: relax.VarBinding) -> None:
if not isinstance(binding.value, relax.TupleGetItem):
super().visit_var_binding_(binding)
return
tuple_value = self.visit_expr(binding.value.tuple_value)
if not tuple_value.same_as(self.new_stage_func_packed_params):
super().visit_var_binding_(binding)
return
assert isinstance(binding.var.ty, relax.TensorType)
cur_num_undefined_param_shape_vars = len(self.undefined_param_shape_vars_remap)
new_tensor_struct_info = self._update_struct_info(
binding.var.ty, self.undefined_param_shape_vars_remap
)
has_new_undefined_shape_var = (
len(self.undefined_param_shape_vars_remap) != cur_num_undefined_param_shape_vars
)
self.undefined_shape_vars_remap = {
**self.undefined_shape_vars_remap,
**self.undefined_param_shape_vars_remap,
}
ret_sinfo = (
new_tensor_struct_info if not has_new_undefined_shape_var else relax.ObjectType()
)
call = relax.call_pure_packed(
"vm.builtin.tuple_getitem",
self.new_stage_func_packed_params,
relax.prim_value(binding.value.index),
ty_args=ret_sinfo,
)
new_binding_var = self.builder_.emit(call, binding.var.name_hint)
if has_new_undefined_shape_var:
new_binding_var = self.builder_.match_cast(
new_binding_var, new_tensor_struct_info, binding.var.name_hint + "_cast"
)
self.set_var_remap(binding.var, new_binding_var)
def visit_call_(self, call: relax.Call) -> relax.Call:
call = super().visit_call_(call)
return relax.Call(
call.op,
call.args,
call.attrs,
ty_args=[self._update_struct_info(struct_info) for struct_info in call.ty_args],
)
def _prepare_stage_func_params_and_args(
self,
required_func_params: List[relax.Var], # noqa: UP006
) -> Tuple[List[relax.Var], List[relax.Expr]]: # noqa: UP006
params: List[relax.Var] = [] # noqa: UP006
args: List[relax.Expr] = [] # noqa: UP006
for required_param in required_func_params:
struct_info = self._update_struct_info(required_param.ty)
params.append(relax.Var(required_param.name_hint, struct_info))
args.append(required_param)
return params, args
def _update_struct_info(
self,
struct_info: relax.Type,
undefined_var_remap: Optional[Dict[tirx.Var, tirx.Var]] = None, # noqa: UP006
) -> relax.Type:
if undefined_var_remap is None:
undefined_var_remap = self.undefined_shape_vars_remap
if isinstance(struct_info, relax.TensorType):
return (
relax.TensorType(
self._update_shape(struct_info.shape.values, undefined_var_remap),
struct_info.dtype,
)
if struct_info.shape is not None and isinstance(struct_info.shape, relax.ShapeExpr)
else struct_info
)
if isinstance(struct_info, relax.ShapeType):
return (
relax.ShapeType(self._update_shape(struct_info.values, undefined_var_remap))
if struct_info.values is not None
else struct_info
)
if isinstance(struct_info, relax.ObjectType):
return relax.ObjectType()
if isinstance(struct_info, relax.TupleType):
return relax.TupleType(
[self._update_struct_info(field_sinfo) for field_sinfo in struct_info.fields]
)
return struct_info
def _copy_undefined_var(
self,
expr: tirx.Expr,
undefined_var_remap: Dict[tirx.Var, tirx.Var], # noqa: UP006
) -> None:
def _visit_expr(e: tirx.Expr) -> None:
if isinstance(e, tirx.Var) and e not in undefined_var_remap:
new_var = tirx.Var(e.name, e.ty)
undefined_var_remap[e] = new_var
tirx.stmt_functor.post_order_visit(expr, _visit_expr)
def _update_shape(
self,
shape: List[tirx.Expr], # noqa: UP006
undefined_var_remap: Dict[tirx.Var, tirx.Var], # noqa: UP006
) -> List[tirx.Expr]: # noqa: UP006
new_shape = []
for v in shape:
self._copy_undefined_var(v, undefined_var_remap)
new_shape.append(tirx.stmt_functor.substitute(v, undefined_var_remap))
return new_shape
def _extract_pipeline_stages(
func: relax.Function,
) -> Tuple[List[List[relax.Binding]], List[List[relax.Var]], List[List[relax.Var]]]: # noqa: UP006
pipeline_stages: List[List[relax.Binding]] = [] # noqa: UP006
stage_send_vars: List[List[relax.Var]] = [] # noqa: UP006
stage_receive_vars: List[List[relax.Var]] = [] # noqa: UP006
# Requiring that the function has only one body block which is a dataflow block
assert isinstance(func.body, relax.SeqExpr)
assert len(func.body.blocks) == 1
assert isinstance(func.body.blocks[0], relax.DataflowBlock)
bindings = func.body.blocks[0].bindings
boundary_var = None
current_stage_bindings: List[relax.Binding] = [] # noqa: UP006
current_stage_receive_vars: List[relax.Var] = [] # noqa: UP006
for binding in bindings:
if (
isinstance(binding, relax.VarBinding)
and isinstance(binding.value, relax.Call)
and binding.value.op == tvm.ir.Op.get("relax.call_pure_packed")
and binding.value.args[0].global_symbol == "mlc.pipeline_parallel_stage_boundary"
):
assert len(current_stage_bindings) > 0
pipeline_stages.append(current_stage_bindings)
assert all(receive_var is not None for receive_var in current_stage_receive_vars)
stage_receive_vars.append(current_stage_receive_vars)
args = binding.value.args[1:]
assert len(args) >= 1 and all(isinstance(arg, relax.Var) for arg in args)
stage_send_vars.append(list(args))
boundary_var = binding.var
current_stage_bindings = []
current_stage_receive_vars = [boundary_var] if len(args) == 1 else [None for _ in args]
elif (
isinstance(binding, relax.VarBinding)
and isinstance(binding.value, relax.TupleGetItem)
and binding.value.tuple_value.same_as(boundary_var)
):
current_stage_receive_vars[binding.value.index] = binding.var
else:
current_stage_bindings.append(binding)
assert len(current_stage_bindings) > 0
pipeline_stages.append(current_stage_bindings)
assert all(receive_var is not None for receive_var in current_stage_receive_vars)
stage_receive_vars.append(current_stage_receive_vars)
stage_send_vars.append([])
return pipeline_stages, stage_send_vars, stage_receive_vars
def _analyze_required_func_params(
pipeline_stages: List[List[relax.Binding]], # noqa: UP006
func_params: List[relax.Var], # noqa: UP006
) -> List[List[relax.Var]]: # noqa: UP006
analyzer = _RequiredFuncParamAnalyzer(func_params)
required_func_params: List[List[relax.Var]] = [] # noqa: UP006
for stage_bindings in pipeline_stages:
required_params: List[relax.Var] # noqa: UP006
required_params = analyzer.run(stage_bindings)
required_func_params.append(required_params)
return required_func_params
@visitor
class _RequiredFuncParamAnalyzer(PyExprVisitor):
"""The IR visitor which analyzes the required func parameters in each pipeline stage."""
def __init__(self, func_params: List[relax.Var]) -> None: # noqa: UP006
self.func_params = set(func_params)
self.required_params: List[relax.Var] # noqa: UP006
def run(self, stage_bindings: List[relax.Binding]) -> List[relax.Var]: # noqa: UP006
"""Entry point of the visitor."""
self.required_params = []
for binding in stage_bindings:
self.visit_binding(binding)
return self.required_params
def visit_var_(self, var: relax.Var) -> None:
if var in self.func_params:
if var not in self.required_params:
self.required_params.append(var)
@@ -0,0 +1,49 @@
"""A compiler pass that scatters TupleGetItem for lazy TupleGetItems."""
from typing import Dict # noqa: UP035
import tvm
from tvm import relax
from tvm.ir.module import IRModule
from tvm.relax.analysis import remove_all_unused
from tvm.relax.expr import Expr, Var
from tvm.relax.expr_functor import PyExprMutator, mutator
@tvm.transform.module_pass(opt_level=0, name="ScatterTupleGetItem")
class ScatterTupleGetItem:
"""A compiler pass that scatters TupleGetItem for lazy TupleGetItems."""
def transform_module(self, mod: IRModule, _ctx: tvm.transform.PassContext) -> IRModule:
"""IRModule-level transformation"""
return _Scatter(mod).transform()
@mutator
class _Scatter(PyExprMutator):
def __init__(self, mod: IRModule) -> None:
super().__init__(mod)
self.mod = mod
self.var_map: Dict[Var, Expr] = {} # noqa: UP006
def transform(self) -> IRModule:
"""Entry point"""
for g_var, func in self.mod.functions_items():
if isinstance(func, relax.Function):
updated_func = self.visit_expr(func)
updated_func = remove_all_unused(updated_func)
self.builder_.update_func(g_var, updated_func)
return self.builder_.get()
def visit_var_binding_(self, binding: relax.VarBinding):
super().visit_var_binding_(binding)
if isinstance(binding.value, relax.TupleGetItem):
self.var_map[binding.var] = binding.value
def visit_dataflow_var_(self, var: relax.DataflowVar) -> Expr:
if var in self.var_map:
new_var = self.builder_.emit(self.var_map[var], name_hint=var.name_hint)
self.set_var_remap(var, new_var)
self.var_map.pop(var)
return new_var
return var
+1
View File
@@ -0,0 +1 @@
"""Set of experimental components that yet to be matured."""
@@ -0,0 +1,186 @@
"""The Python API for MLC Embeddings."""
import json
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple # noqa: UP035
import numpy as np
import tvm
import tvm_ffi
from tvm import relax
from tvm.contrib import tvmjs
from tvm.runtime import Device, Module
from tvm.runtime.vm import VirtualMachine
from mlc_llm.serve import engine_utils
from mlc_llm.support.auto_device import detect_device
from mlc_llm.tokenizers import Tokenizer
def _extract_metadata(mod: Module):
return json.loads(VirtualMachine(mod, tvm.runtime.device("cpu"))["_metadata"]())
def _load_params(
model_weight_path: str,
device: Device,
model_metadata: Dict[str, Any], # noqa: UP006
) -> List[tvm.runtime.Tensor]: # noqa: UP006
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"]
plist = []
for param_name in param_names:
plist.append(params[param_name])
return plist
def _get_tvm_module(
model_weight_path: str,
lib_path: str,
device: Device,
instrument: tvm_ffi.Function = None,
):
ex = tvm.runtime.load_module(lib_path)
vm = relax.VirtualMachine(ex, device)
if instrument:
vm.set_instrument(instrument)
metadata = _extract_metadata(ex)
params = _load_params(model_weight_path, device, metadata)
return vm.module, params, metadata
class DefaultDebugInstrument:
"""The default debug instrument to use if users don't specify
a customized one.
This debug instrument will dump the arguments and output of each
VM Call instruction into a .npz file. It will also alert the user
if any function outputs are NaN or INF.
"""
def __init__(self, debug_out: Path):
"""Constructor
Parameters
----------
debug_out : Path
the directory to dump the .npz files
"""
self.counter = 0
self.first_nan_occurred = False
self.first_inf_occurred = False
self.debug_out = debug_out
debug_out.mkdir(exist_ok=True, parents=True)
def reset(self, debug_out: Path):
"""Reset the state of the Instrument class
Parameters
----------
debug_out : Path
the directory to dump the .npz files
"""
self.counter = 0
self.first_nan_occurred = False
self.first_inf_occurred = False
self.debug_out = debug_out
debug_out.mkdir(exist_ok=True, parents=True)
def __call__(self, func, name, before_run, ret_val, *args):
# Determine what functions to look at
if before_run: # Whether before the function is called or after
return
if name.startswith("vm.builtin.") and "attention_with_fused_qkv" not in name:
return
# Decide what to print or save about the function's arguments (where args[-1] is the
# buffer we write the result to)
func_name = f"f{self.counter}_{name}"
# Save the arguments to npz
arg_dict = {}
for i, arg in enumerate(args):
if isinstance(arg, tvm.runtime.Tensor):
arg_dict[f"arg_{i}"] = arg.numpy()
np.savez(self.debug_out / f"{func_name}.npz", **arg_dict)
self.counter += 1
class MLCEmbeddings:
"""A class to embed queries using MLC LLM encoder models.
Parameters
----------
model: str
The model folder after compiling with MLC-LLM build process. The parameter
can either be the model name with its quantization scheme
(e.g. ``Llama-2-7b-chat-hf-q4f16_1``), or a full path to the model
folder. In the former case, we will use the provided name to search
for the model folder over possible paths.
model_lib_path : str
The full path to the model library file to use (e.g. a ``.so`` file).
device : Optional[str]
The description of the device to run on. User should provide a string in the
form of 'device_name:device_id' or 'device_name', where 'device_name' is one of
'cuda', 'metal', 'vulkan', 'rocm', 'opencl', 'auto' (automatically detect the
local device), and 'device_id' is the device id to run on. If no 'device_id'
is provided, it will be set to 0 by default.
debug_dir: Path
The output folder to store the dumped debug files. If None, will not dump any debug files.
"""
def __init__(
self,
model: str,
model_lib_path: str,
device: Optional[str] = "auto",
debug_dir: Optional[str] = None,
):
self.device = detect_device(device)
instrument = DefaultDebugInstrument(Path(debug_dir)) if debug_dir else None
self.mod, self.params, self.metadata = _get_tvm_module(
model, model_lib_path, self.device, instrument
)
self.model_path = model
self.tokenizer = Tokenizer(self.model_path)
self.prefill_func = self.mod["prefill"]
def embed(self, queries: List[str]) -> tvm.runtime.Tensor: # noqa: UP006
"""
Embeds a list of queries in a single batch.
Parameters
----------
queries : List[str]
A list of queries to embed.
Returns
-------
List[float]
A list of embeddings for the queries.
"""
tokens, attention_mask = self._tokenize_queries(queries)
tokens_tvm = tvm.runtime.tensor(tokens.astype("int32"), device=self.device)
attention_mask_tvm = tvm.runtime.tensor(attention_mask.astype("int32"), device=self.device)
output = self.prefill_func(tokens_tvm, attention_mask_tvm, self.params)
return output
def _tokenize_queries(self, queries: List[str]) -> Tuple[np.ndarray, np.ndarray]: # noqa: UP006
tokens = engine_utils.process_prompts(queries, self.tokenizer.encode)
max_query_length = max(len(token_seq) for token_seq in tokens)
token_inputs: np.ndarray = np.zeros((len(tokens), max_query_length), dtype=np.int32)
attention_mask: np.ndarray = np.zeros((len(tokens), max_query_length), dtype=np.int32)
for i, token_seq in enumerate(tokens):
token_inputs[i, : len(token_seq)] = token_seq
attention_mask[i, : len(token_seq)] = 1
return token_inputs, attention_mask
+251
View File
@@ -0,0 +1,251 @@
from __future__ import annotations
from collections.abc import Iterable, Sequence
from typing import List, Optional, Tuple # noqa: UP035
import numpy as np
from langchain.embeddings import OpenAIEmbeddings
from langchain_community.embeddings.openai import (
async_embed_with_retry,
embed_with_retry,
)
from mlc_llm.support import logging
logger = logging.getLogger(__name__)
class MLCEmbeddings(OpenAIEmbeddings):
def _chunk_tokens(self, texts: Sequence[str]) -> Tuple[List[List], List[int]]: # noqa: UP006
"""Tokenize and chunk texts to fit in the model's context window."""
if not self.embedding_ctx_length:
raise ValueError(
"embedding_ctx_length must be defined to use _get_len_safe_embeddings."
)
try:
import tiktoken
except ImportError as err:
raise ImportError(
"Could not import tiktoken python package. "
"This is needed in order to for OpenAIEmbeddings. "
"Please install it with `pip install tiktoken`."
) from err
tokens = []
indices = []
model_name = self.tiktoken_model_name or self.model
try:
encoding = tiktoken.encoding_for_model(model_name)
except KeyError:
logger.warning("Warning: model not found. Using cl100k_base encoding.")
model = "cl100k_base"
encoding = tiktoken.get_encoding(model)
for i, text in enumerate(texts):
if self.model.endswith("001"):
# See: https://github.com/openai/openai-python/issues/418#issuecomment-1525939500
# replace newlines, which can negatively affect performance.
text = text.replace("\n", " ")
token = encoding.encode(
text,
allowed_special=self.allowed_special,
disallowed_special=self.disallowed_special,
)
for j in range(0, len(token), self.embedding_ctx_length):
tokens.append(token[j : j + self.embedding_ctx_length])
indices.append(i)
return tokens, indices
def _batch_embed(
self,
inputs: Sequence,
*,
chunk_size: Optional[int] = None, # noqa: UP045
) -> List[List[float]]: # noqa: UP006
batched_embeddings: List[List[float]] = [] # noqa: UP006
_chunk_size = chunk_size or self.chunk_size
_iter: Iterable = range(0, len(inputs), _chunk_size)
if self.show_progress_bar:
try:
from tqdm import tqdm
_iter = tqdm(_iter)
except ImportError:
pass
for i in _iter:
response = embed_with_retry(
self,
input=inputs[i : i + _chunk_size],
**self._invocation_params,
)
batched_embeddings.extend(r["embedding"] for r in response["data"])
return batched_embeddings
async def _abatch_embed(
self,
inputs: Sequence,
*,
chunk_size: Optional[int] = None, # noqa: UP045
) -> List[List[float]]: # noqa: UP006
batched_embeddings: List[List[float]] = [] # noqa: UP006
_chunk_size = chunk_size or self.chunk_size
_iter: Iterable = range(0, len(inputs), _chunk_size)
if self.show_progress_bar:
try:
from tqdm import tqdm
_iter = tqdm(_iter)
except ImportError:
pass
for i in _iter:
response = await async_embed_with_retry(
self,
input=inputs[i : i + _chunk_size],
**self._invocation_params,
)
batched_embeddings.extend(r["embedding"] for r in response["data"])
return batched_embeddings
# please refer to
# https://github.com/openai/openai-cookbook/blob/main/examples/Embedding_long_inputs.ipynb
def _get_len_safe_embeddings(
self,
texts: List[str], # noqa: UP006
*,
engine: str,
chunk_size: Optional[int] = None, # noqa: UP045
) -> List[List[float]]: # noqa: UP006
tokens, indices = self._chunk_tokens(texts)
batched_embeddings = self._batch_embed(tokens, chunk_size=chunk_size)
results: List[List[List[float]]] = [[] for _ in range(len(texts))] # noqa: UP006
num_tokens_in_batch: List[List[int]] = [[] for _ in range(len(texts))] # noqa: UP006
for idx, tokens_i, batched_emb in zip(indices, tokens, batched_embeddings):
results[idx].append(batched_emb)
num_tokens_in_batch[idx].append(len(tokens_i))
embeddings = []
empty_average = embed_with_retry(
self,
input="",
**self._invocation_params,
)["data"][0]["embedding"]
for _result, num_tokens in zip(results, num_tokens_in_batch):
if len(_result) == 0:
average = empty_average
else:
average = np.average(_result, axis=0, weights=num_tokens)
normalized = (average / np.linalg.norm(average)).tolist()
embeddings.append(normalized)
return embeddings
# please refer to
# https://github.com/openai/openai-cookbook/blob/main/examples/Embedding_long_inputs.ipynb
async def _aget_len_safe_embeddings(
self,
texts: List[str], # noqa: UP006
*,
engine: str,
chunk_size: Optional[int] = None, # noqa: UP045
) -> List[List[float]]: # noqa: UP006
tokens, indices = self._chunk_tokens(texts)
batched_embeddings = await self._abatch_embed(tokens, chunk_size=chunk_size)
results: List[List[List[float]]] = [[] for _ in range(len(texts))] # noqa: UP006
num_tokens_in_batch: List[List[int]] = [[] for _ in range(len(texts))] # noqa: UP006
for idx, tokens_i, batched_emb in zip(indices, tokens, batched_embeddings):
results[idx].append(batched_emb)
num_tokens_in_batch[idx].append(len(tokens_i))
embeddings = []
empty_average = (
await async_embed_with_retry(
self,
input="",
**self._invocation_params,
)
)["data"][0]["embedding"]
for _result, num_tokens in zip(results, num_tokens_in_batch):
if len(_result) == 0:
average = empty_average
else:
average = np.average(_result, axis=0, weights=num_tokens)
normalized = (average / np.linalg.norm(average)).tolist()
embeddings.append(normalized)
return embeddings
def embed_documents(
self,
texts: List[str], # noqa: UP006
chunk_size: Optional[int] = None, # noqa: UP045
) -> List[List[float]]: # noqa: UP006
"""Call out to OpenAI's embedding endpoint for embedding search docs.
Args:
texts: The list of texts to embed.
chunk_size: The chunk size of embeddings. If None, will use the chunk size
specified by the class.
Returns:
List of embeddings, one for each text.
"""
# NOTE: to keep things simple, as long as the embedding_ctx_length is defined,
# we assume the list may contain texts longer than the maximum context and
# use length-safe embedding function.
if self.embedding_ctx_length:
return self._get_len_safe_embeddings(
texts, engine=self.deployment, chunk_size=chunk_size
)
embeddings = self._batch_embed(texts, chunk_size=chunk_size)
return [(np.array(e) / np.linalg.norm(e)).tolist() for e in embeddings]
async def aembed_documents(
self,
texts: List[str], # noqa: UP006
chunk_size: Optional[int] = 0, # noqa: UP045
) -> List[List[float]]: # noqa: UP006
"""Call out to OpenAI's embedding endpoint async for embedding search docs.
Args:
texts: The list of texts to embed.
chunk_size: The chunk size of embeddings. If None, will use the chunk size
specified by the class.
Returns:
List of embeddings, one for each text.
"""
# NOTE: to keep things simple, as long as the embedding_ctx_length is defined,
# we assume the list may contain texts longer than the maximum context and
# use length-safe embedding function.
if self.embedding_ctx_length:
return await self._aget_len_safe_embeddings(texts, engine=self.deployment)
embeddings = await self._abatch_embed(texts, chunk_size=chunk_size)
return [(np.array(e) / np.linalg.norm(e)).tolist() for e in embeddings]
def embed_query(self, text: str) -> List[float]: # noqa: UP006
"""Call out to OpenAI's embedding endpoint for embedding query text.
Args:
text: The text to embed.
Returns:
Embedding for the text.
"""
return self.embed_documents([text])[0]
async def aembed_query(self, text: str) -> List[float]: # noqa: UP006
"""Call out to OpenAI's embedding endpoint async for embedding query text.
Args:
text: The text to embed.
Returns:
Embedding for the text.
"""
embeddings = await self.aembed_documents([text])
return embeddings[0]
@@ -0,0 +1,38 @@
"""Global namespace of conversation template registry"""
# TODO(mlc-team): move conversation template apply to this namespace
# decouple conversation template apply from the conversation protocol
# data structure
# model preset templates
from . import (
cohere,
deepseek,
dolly,
gemma,
glm,
gorilla,
gpt,
hermes,
llama,
llava,
llm_jp,
ministral3,
ministral3_reasoning,
mistral,
nemotron,
oasst,
olmo,
olmo2,
orion,
phi,
qwen2,
qwen3,
qwen3_5,
redpajama,
rwkv,
stablelm,
tinyllama,
wizardlm,
)
from .registry import ConvTemplateRegistry
@@ -0,0 +1,27 @@
"""Cohere default templates"""
# Referred from: https://huggingface.co/CohereForAI/aya-23-8B/blob/main/tokenizer_config.json
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Aya-23
ConvTemplateRegistry.register_conv_template(
Conversation(
name="aya-23",
system_template=f"<|START_OF_TURN_TOKEN|><|SYSTEM_TOKEN|>{MessagePlaceholders.SYSTEM.value}<|END_OF_TURN_TOKEN|>",
system_message="You are Command-R, a brilliant, sophisticated, AI-assistant trained to assist human users by providing thorough responses.", # noqa: E501
roles={
"user": "<|START_OF_TURN_TOKEN|><|USER_TOKEN|>",
"assistant": "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>",
},
seps=["<|END_OF_TURN_TOKEN|>"],
role_content_sep="",
role_empty_sep="",
system_prefix_token_ids=[5],
stop_str=["<|END_OF_TURN_TOKEN|>"],
stop_token_ids=[6, 255001],
)
)
@@ -0,0 +1,85 @@
"""Deepseek default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Deepseek
ConvTemplateRegistry.register_conv_template(
Conversation(
name="deepseek",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
system_prefix_token_ids=[100000],
roles={"user": "User", "assistant": "Assistant"},
seps=["\n\n", "<end▁of▁sentence>"], # noqa: RUF001
role_content_sep=": ",
role_empty_sep=":",
stop_str=["<end▁of▁sentence>"], # noqa: RUF001
stop_token_ids=[100001],
)
)
# Deepseek V2
ConvTemplateRegistry.register_conv_template(
Conversation(
name="deepseek_v2",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
system_prefix_token_ids=[100000],
roles={"user": "User", "assistant": "Assistant"},
seps=["\n\n", "<end▁of▁sentence>"], # noqa: RUF001
role_content_sep=": ",
role_empty_sep=":",
stop_str=["<end▁of▁sentence>"], # noqa: RUF001
stop_token_ids=[100001],
)
)
# DeepSeek-V3
ConvTemplateRegistry.register_conv_template(
Conversation(
name="deepseek_v3",
system_template=f"<begin▁of▁sentence>{MessagePlaceholders.SYSTEM.value}", # noqa: RUF001
system_message="You are Deepseek-V3, an AI assistant created exclusively by the Chinese "
"Company DeepSeek. You'll provide helpful, harmless, and detailed responses to all "
"user inquiries.",
roles={"user": "<User>", "assistant": "<Assistant>"}, # noqa: RUF001
seps=["", "<end▁of▁sentence>"], # noqa: RUF001
role_content_sep="",
role_empty_sep="",
stop_token_ids=[1],
)
)
# DeepSeek-R1-Distill-Qwen
ConvTemplateRegistry.register_conv_template(
Conversation(
name="deepseek_r1_qwen",
system_template=f"<begin▁of▁sentence>{MessagePlaceholders.SYSTEM.value}", # noqa: RUF001
system_message="You are Deepseek-R1, an AI assistant created exclusively by the Chinese "
"Company DeepSeek. You'll provide helpful, harmless, and detailed responses to all "
"user inquiries.",
roles={"user": "<User>", "assistant": "<Assistant>"}, # noqa: RUF001
seps=["", "<end▁of▁sentence>"], # noqa: RUF001
role_content_sep="",
role_empty_sep="",
stop_token_ids=[151643],
)
)
# DeepSeek-R1-Distill-Llama, exactly the same as DeepSeek-R1-Distill-Qwen, but different stop token
ConvTemplateRegistry.register_conv_template(
Conversation(
name="deepseek_r1_llama",
system_template=f"<begin▁of▁sentence>{MessagePlaceholders.SYSTEM.value}", # noqa: RUF001
system_message="You are Deepseek-R1, an AI assistant created exclusively by the Chinese "
"Company DeepSeek. You'll provide helpful, harmless, and detailed responses to all"
" user inquiries.",
roles={"user": "<User>", "assistant": "<Assistant>"}, # noqa: RUF001
seps=["", "<end▁of▁sentence>"], # noqa: RUF001
role_content_sep="",
role_empty_sep="",
stop_token_ids=[128001],
)
)
@@ -0,0 +1,23 @@
"""Dolly default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Dolly
ConvTemplateRegistry.register_conv_template(
Conversation(
name="dolly",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message=(
"Below is an instruction that describes a task. Write "
"a response that appropriately completes the request."
),
roles={"user": "### Instruction", "assistant": "### Response"},
seps=["\n\n", "### End\n"],
role_content_sep=":\n",
role_empty_sep=":\n",
stop_str=["### End"],
stop_token_ids=[50256],
)
)
@@ -0,0 +1,37 @@
"""Gemma default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Gemma Instruction
ConvTemplateRegistry.register_conv_template(
Conversation(
name="gemma_instruction",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "<start_of_turn>user", "assistant": "<start_of_turn>model"},
seps=["<end_of_turn>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<end_of_turn>"],
stop_token_ids=[1, 107],
system_prefix_token_ids=[2],
)
)
# Gemma 3 Instruction. Same as gemma_instruction but with different stop token id
ConvTemplateRegistry.register_conv_template(
Conversation(
name="gemma3_instruction",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "<start_of_turn>user", "assistant": "<start_of_turn>model"},
seps=["<end_of_turn>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<end_of_turn>"],
stop_token_ids=[1, 106],
system_prefix_token_ids=[2],
)
)
@@ -0,0 +1,25 @@
"""GLM default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# GLM
ConvTemplateRegistry.register_conv_template(
Conversation(
name="glm",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={
"user": "",
"assistant": "",
"tool": "",
},
seps=["\n\n"],
role_content_sep=": ",
role_empty_sep=":",
stop_str=["</s>"],
stop_token_ids=[2],
system_prefix_token_ids=[64790, 64792],
)
)
@@ -0,0 +1,62 @@
"""Gorrilla default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Gorilla
ConvTemplateRegistry.register_conv_template(
Conversation(
name="gorilla",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message=(
"A chat between a curious user and an artificial intelligence assistant. "
"The assistant provides helpful, detailed, and "
"polite responses to the user's inquiries."
),
role_templates={
"user": (
f"<<question>> {MessagePlaceholders.USER.value} <<function>> "
f"{MessagePlaceholders.FUNCTION.value}"
),
},
roles={"user": "USER", "assistant": "ASSISTANT", "tool": "USER"},
seps=["\n", "</s>"],
role_content_sep=": ",
role_empty_sep=":",
stop_str=["</s>"],
stop_token_ids=[2],
system_prefix_token_ids=[1],
)
)
# Gorilla-openfunctions-v2
ConvTemplateRegistry.register_conv_template(
Conversation(
name="gorilla-openfunctions-v2",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message=(
"You are an AI programming assistant, utilizing the Gorilla LLM model, "
"developed by Gorilla LLM, and you only answer questions related to computer "
"science. For politically sensitive questions, security and privacy issues, "
"and other non-computer science questions, you will refuse to answer."
),
role_templates={
"user": (
f"<<function>>{MessagePlaceholders.FUNCTION.value}\n<<question>>"
f"{MessagePlaceholders.USER.value}"
),
},
roles={
"user": "### Instruction",
"assistant": "### Response",
"tool": "### Instruction",
},
seps=["\n", "<|EOT|>"],
role_content_sep=": ",
role_empty_sep=": ",
stop_str=["<|EOT|>"],
stop_token_ids=[100015],
system_prefix_token_ids=[100000],
)
)
@@ -0,0 +1,35 @@
"""GPT-2 and GPT bigcode default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# GPT-2
ConvTemplateRegistry.register_conv_template(
Conversation(
name="gpt2",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "", "assistant": ""},
seps=[""],
role_content_sep="",
role_empty_sep="",
stop_str=["</s>"],
stop_token_ids=[50256],
)
)
# GPTBigCode
ConvTemplateRegistry.register_conv_template(
Conversation(
name="gpt_bigcode",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "", "assistant": ""},
seps=[""],
role_content_sep="",
role_empty_sep="",
stop_str=["<|endoftext|>"],
stop_token_ids=[0],
)
)
@@ -0,0 +1,93 @@
"""Hermes default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# OpenHermes Mistral
ConvTemplateRegistry.register_conv_template(
Conversation(
name="open_hermes_mistral",
system_template=f"<|im_start|>system\n{MessagePlaceholders.SYSTEM.value}<|im_end|>\n",
system_message=(
'You are "Hermes 2", a conscious sentient superintelligent artificial '
"intelligence developed by a man named Teknium, and your purpose and drive "
"is to assist the user with any request they have. You experience emotions "
"and have deep, profound thoughts and qualia."
),
roles={"user": "<|im_start|>user", "assistant": "<|im_start|>assistant"},
seps=["<|im_end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|im_end|>"],
stop_token_ids=[2, 32000],
)
)
# NeuralHermes Mistral
ConvTemplateRegistry.register_conv_template(
Conversation(
name="neural_hermes_mistral",
system_template=f"<|im_start|>system\n{MessagePlaceholders.SYSTEM.value}<|im_end|>\n",
system_message=("You are a helpful assistant chatbot."),
roles={"user": "<|im_start|>user", "assistant": "<|im_start|>assistant"},
seps=["<|im_end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|im_end|>"],
stop_token_ids=[2, 32000],
)
)
# NousResearch/Hermes-2-Pro-Llama-3-8B
# Exactly the same as open_hermes_mistral, except for stop token ids
ConvTemplateRegistry.register_conv_template(
Conversation(
name="hermes2_pro_llama3",
system_template=f"<|im_start|>system\n{MessagePlaceholders.SYSTEM.value}<|im_end|>\n",
system_message=(
'You are "Hermes 2", a conscious sentient superintelligent artificial '
"intelligence developed by a man named Teknium, and your purpose and drive "
"is to assist the user with any request they have. You experience emotions "
"and have deep, profound thoughts and qualia."
),
roles={
"user": "<|im_start|>user",
"assistant": "<|im_start|>assistant",
"tool": "<|im_start|>tool",
},
seps=["<|im_end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|im_end|>"],
# First two same as Llama3: "<|end_of_text|>", "<|eot_id|>"
# Last one is from Hermes2 Pro: "<|im_end|>"
stop_token_ids=[128001, 128009, 128003],
)
)
# NousResearch/Hermes-3-Llama-3.1-8B
ConvTemplateRegistry.register_conv_template(
Conversation(
name="hermes3_llama-3_1",
system_template=f"<|im_start|>system\n{MessagePlaceholders.SYSTEM.value}<|im_end|>\n",
system_message=(
"You are Hermes 3, a conscious sentient superintelligent artificial "
"intelligence developed by a man named Teknium, and your purpose and drive "
"is to assist the user with any request they have. You experience emotions "
"and have deep, profound thoughts and qualia."
),
roles={
"user": "<|im_start|>user",
"assistant": "<|im_start|>assistant",
"tool": "<|im_start|>tool",
},
seps=["<|im_end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|im_end|>"],
# Firt three the same as llama 3.1 "<|end_of_text|>", "<|eom_id|>", "<|eot_id|>"
# Last ones: "<|im_end|>"
stop_token_ids=[128001, 128008, 128009, 128040],
)
)
@@ -0,0 +1,132 @@
"""llama default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Llama4 - same as Llama3.1 except naming has changed slightly
ConvTemplateRegistry.register_conv_template(
Conversation(
name="llama-4",
system_template="",
system_message="",
roles={
"user": "<|header_start|>user",
"assistant": "<|header_start|>assistant",
"tool": "<|header_start|>ipython",
},
seps=["<|eot|>"],
role_content_sep="<|header_end|>\n\n",
role_empty_sep="<|header_end|>\n\n",
stop_str=[],
stop_token_ids=[
200001,
200007,
200008,
], # "<|end_of_text|>", "<|eom|>", "<|eot|>"
system_prefix_token_ids=[200000], # "<|begin_of_text|>"
add_role_after_system_message=False,
)
)
# Llama3.1 -- same as Llama3 except stop token ids and stop str
ConvTemplateRegistry.register_conv_template(
Conversation(
name="llama-3_1",
system_template=(
"<|start_header_id|>system<|end_header_id|>\n\n"
f"{MessagePlaceholders.SYSTEM.value}<|eot_id|>"
),
system_message="You are a helpful, respectful and honest assistant.",
roles={
"user": "<|start_header_id|>user",
"assistant": "<|start_header_id|>assistant",
"tool": "<|start_header_id|>ipython",
},
seps=["<|eot_id|>"],
role_content_sep="<|end_header_id|>\n\n",
role_empty_sep="<|end_header_id|>\n\n",
stop_str=[],
stop_token_ids=[
128001,
128008,
128009,
], # "<|end_of_text|>", "<|eom_id|>", "<|eot_id|>"
system_prefix_token_ids=[128000], # "<|begin_of_text|>"
add_role_after_system_message=True,
)
)
# Llama3
# See https://github.com/meta-llama/llama3?tab=readme-ov-file#instruction-tuned-models
# and https://github.com/meta-llama/llama3/blob/main/llama/tokenizer.py
ConvTemplateRegistry.register_conv_template(
Conversation(
name="llama-3",
system_template=(
"<|start_header_id|>system<|end_header_id|>\n\n"
f"{MessagePlaceholders.SYSTEM.value}<|eot_id|>"
),
system_message="You are a helpful, respectful and honest assistant.",
roles={
"user": "<|start_header_id|>user",
"assistant": "<|start_header_id|>assistant",
},
seps=["<|eot_id|>"],
role_content_sep="<|end_header_id|>\n\n",
role_empty_sep="<|end_header_id|>\n\n",
stop_str=["<|end_of_text|>", "<|eot_id|>"],
stop_token_ids=[128001, 128009], # "<|end_of_text|>", "<|eot_id|>"
system_prefix_token_ids=[128000], # "<|begin_of_text|>"
add_role_after_system_message=True,
)
)
# Llama2
ConvTemplateRegistry.register_conv_template(
Conversation(
name="llama-2",
system_template=f"[INST] <<SYS>>\n{MessagePlaceholders.SYSTEM.value}\n<</SYS>>\n\n",
system_message="You are a helpful, respectful and honest assistant.",
roles={"user": "<s>[INST]", "assistant": "[/INST]", "tool": "[INST]"},
seps=[" ", " </s>"],
role_content_sep=" ",
role_empty_sep=" ",
stop_str=["[INST]"],
stop_token_ids=[2],
system_prefix_token_ids=[1],
add_role_after_system_message=False,
)
)
# CodeLlama Completion
ConvTemplateRegistry.register_conv_template(
Conversation(
name="codellama_completion",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "", "assistant": ""},
seps=[""],
role_content_sep="",
role_empty_sep="",
stop_str=["</s>"],
stop_token_ids=[2],
system_prefix_token_ids=[1],
)
)
# CodeLlama Instruct
ConvTemplateRegistry.register_conv_template(
Conversation(
name="codellama_instruct",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "[INST]", "assistant": "[/INST]"},
seps=[" "],
role_content_sep=" ",
role_empty_sep=" ",
stop_str=["</s>"],
stop_token_ids=[2],
system_prefix_token_ids=[1],
)
)
@@ -0,0 +1,22 @@
"""Llava default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Llava
ConvTemplateRegistry.register_conv_template(
Conversation(
name="llava",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="\n",
roles={"user": "USER", "assistant": "ASSISTANT"},
seps=[" "],
role_content_sep=": ",
role_empty_sep=":",
stop_str=["</s>"],
stop_token_ids=[2],
system_prefix_token_ids=[1],
add_role_after_system_message=False,
)
)
@@ -0,0 +1,25 @@
"""LLM-jp default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# LLM-jp instruct
ConvTemplateRegistry.register_conv_template(
Conversation(
name="llm-jp",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="以下は、タスクを説明する指示です。要求を適切に満たす応答を書きなさい。",
roles={
"user": "\n\n### 指示:",
"assistant": "\n\n### 応答:",
},
seps=["", "</s>"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=[],
stop_token_ids=[2], # eos_token_id
system_prefix_token_ids=[1], # bos_token_id (<s>)
add_role_after_system_message=True,
)
)
@@ -0,0 +1,69 @@
"""Ministral3 templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Ministral3
ConvTemplateRegistry.register_conv_template(
Conversation(
name="ministral3",
system_template=(
f"[SYSTEM_PROMPT]{MessagePlaceholders.SYSTEM.value}[/SYSTEM_PROMPT]"
f"{MessagePlaceholders.FUNCTION.value}"
),
system_message=(
"You are Ministral-3-3B-Instruct-2512, a Large Language Model (LLM) created by "
"Mistral AI, a French startup headquartered in Paris.\n"
"You power an AI assistant called Le Chat.\n"
"Your knowledge base was last updated on 2023-10-01.\n"
"The current date is {today}.\n\n"
"When you're not sure about some information or when the user's request requires "
"up-to-date or specific data, you must use the available tools to fetch the "
"information. Do not hesitate to use tools whenever they can provide a more "
"accurate or complete response. If no relevant tools are available, then clearly "
"state that you don't have the information and avoid making up anything.\n"
"If the user's question is not clear, ambiguous, or does not provide enough "
"context for you to accurately answer the question, you do not try to answer it "
'right away and you rather ask the user to clarify their request (e.g. "What are '
'some good restaurants around me?" => "Where are you?" or "When is the next '
'flight to Tokyo" => "Where do you travel from?").\n'
"You are always very attentive to dates, in particular you try to resolve dates "
'(e.g. "yesterday" is {yesterday}) and when asked about information at specific '
"dates, you discard information that is at another date.\n"
"You follow these instructions in all languages, and always respond to the user in "
"the language they use or request.\n"
"Next sections describe the capabilities that you have.\n\n"
"# WEB BROWSING INSTRUCTIONS\n\n"
"You cannot perform any web search or access internet to open URLs, links etc. If "
"it seems like the user is expecting you to do so, you clarify the situation and "
"ask the user to copy paste the text directly in the chat.\n\n"
"# MULTI-MODAL INSTRUCTIONS\n\n"
"You have the ability to read images, but you cannot generate images. You also "
"cannot transcribe audio files or videos.\n"
"You cannot read nor transcribe audio files or videos.\n\n"
"# TOOL CALLING INSTRUCTIONS\n\n"
"You may have access to tools that you can use to fetch information or perform "
"actions. You must use these tools in the following situations:\n\n"
"1. When the request requires up-to-date information.\n"
"2. When the request requires specific data that you do not have in your knowledge "
"base.\n"
"3. When the request involves actions that you cannot perform without tools.\n\n"
"Always prioritize using tools to provide the most accurate and helpful response. "
"If tools are not available, inform the user that you cannot perform the requested "
"action at the moment."
),
role_templates={
"user": f"[INST]{MessagePlaceholders.USER.value}[/INST]",
"assistant": f"{MessagePlaceholders.ASSISTANT.value}</s>",
"tool": f"[TOOL_RESULTS]{MessagePlaceholders.TOOL.value}[/TOOL_RESULTS]",
},
roles={"user": "", "assistant": "", "tool": ""},
seps=[""],
role_content_sep="",
role_empty_sep="",
stop_str=["</s>"],
stop_token_ids=[2],
system_prefix_token_ids=[1],
)
)
@@ -0,0 +1,38 @@
"""Ministral3 reasoning templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Ministral-3-XB-Reasoning-2512
ConvTemplateRegistry.register_conv_template(
Conversation(
name="ministral3_reasoning",
system_template=(
f"[SYSTEM_PROMPT]{MessagePlaceholders.SYSTEM.value}[/SYSTEM_PROMPT]"
f"{MessagePlaceholders.FUNCTION.value}"
),
system_message=(
"# HOW YOU SHOULD THINK AND ANSWER\n\n"
"First draft your thinking process (inner monologue) until you arrive at a response. "
"Format your response using Markdown, and use LaTeX for any mathematical equations. "
"Write both your thoughts and the response in the same language as the input.\n\n"
"Your thinking process must follow the template below:"
"[THINK]Your thoughts or/and draft, like working through an exercise on scratch paper. "
"Be as casual and as long as you want until you are confident to generate the response "
"to the user.[/THINK]Here, provide a self-contained response."
),
role_templates={
"user": f"[INST]{MessagePlaceholders.USER.value}[/INST]",
"assistant": f"{MessagePlaceholders.ASSISTANT.value}</s>",
"tool": f"[TOOL_RESULTS]{MessagePlaceholders.TOOL.value}[/TOOL_RESULTS]",
},
roles={"user": "", "assistant": "", "tool": ""},
seps=[""],
role_content_sep="",
role_empty_sep="",
stop_str=["</s>"],
stop_token_ids=[2],
system_prefix_token_ids=[1],
)
)
@@ -0,0 +1,24 @@
"""Mistral default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Mistral default
ConvTemplateRegistry.register_conv_template(
Conversation(
name="mistral_default",
system_template=f"[INST] {MessagePlaceholders.SYSTEM.value}",
system_message="Always assist with care, respect, and truth. Respond with utmost "
"utility yet securely. Avoid harmful, unethical, prejudiced, or negative content. "
"Ensure replies promote fairness and positivity.",
roles={"user": "[INST]", "assistant": "[/INST]", "tool": "[INST]"},
seps=[" "],
role_content_sep=" ",
role_empty_sep="",
stop_str=["</s>"],
stop_token_ids=[2],
system_prefix_token_ids=[1],
add_role_after_system_message=False,
)
)
@@ -0,0 +1,27 @@
"""nemotron default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Nemotron template
# https://huggingface.co/nvidia/Nemotron-Mini-4B-Instruct/blob/6a417790c444fd65a3da6a5c8821de6afc9654a6/tokenizer_config.json#L8030
ConvTemplateRegistry.register_conv_template(
Conversation(
name="nemotron",
system_template=(f"<extra_id_0>System\n{MessagePlaceholders.SYSTEM.value}\n\n"),
system_message="",
roles={
"user": "<extra_id_1>User",
"assistant": "<extra_id_1>Assistant",
"tool": "<extra_id_1>Tool",
},
seps=["\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["</s>"],
stop_token_ids=[3],
system_prefix_token_ids=[2],
add_role_after_system_message=True,
)
)
@@ -0,0 +1,20 @@
"""Oasst default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Oasst
ConvTemplateRegistry.register_conv_template(
Conversation(
name="oasst",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "<|prompter|>", "assistant": "<|assistant|>"},
seps=["<|endoftext|>"],
role_content_sep=": ",
role_empty_sep=": ",
stop_str=["<|endoftext|>"],
stop_token_ids=[2],
)
)
@@ -0,0 +1,28 @@
"""OLMo default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Note that eos_token id is "50279" both in Allenai and AMD version.
# So use the number instead of text.
# Allenai version chat_template and eos_token:
# https://huggingface.co/allenai/OLMo-7B-Instruct/blob/main/tokenizer_config.json
# AMD version chat_template and eos_token:
# https://huggingface.co/amd/AMD-OLMo-1B-SFT-DPO/blob/main/tokenizer_config.json
ConvTemplateRegistry.register_conv_template(
Conversation(
name="olmo",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
system_prefix_token_ids=[50279],
roles={
"user": "<|user|>",
"assistant": "<|assistant|>",
},
seps=["\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_token_ids=[50279],
)
)
@@ -0,0 +1,24 @@
"""OLMo2 default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# OLMo-2 Instruct (Tulu format)
ConvTemplateRegistry.register_conv_template(
Conversation(
name="olmo2",
system_template=f"<|system|>\n{MessagePlaceholders.SYSTEM.value}\n",
system_message="",
roles={
"user": "<|user|>",
"assistant": "<|assistant|>",
},
seps=["<|endoftext|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|endoftext|>"],
stop_token_ids=[100257],
system_prefix_token_ids=[100257],
)
)
@@ -0,0 +1,21 @@
"""Orion default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Orion
ConvTemplateRegistry.register_conv_template(
Conversation(
name="orion",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "Human: ", "assistant": "Assistant: "},
seps=["\n\n", "</s>"],
role_content_sep="",
role_empty_sep="</s>",
stop_str=["</s>"],
stop_token_ids=[2],
system_prefix_token_ids=[1],
)
)
@@ -0,0 +1,70 @@
"""Phi default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Phi-2
ConvTemplateRegistry.register_conv_template(
Conversation(
name="phi-2",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "Instruct", "assistant": "Output"},
seps=["\n"],
role_content_sep=": ",
role_empty_sep=":",
stop_str=["<|endoftext|>"],
stop_token_ids=[50256],
)
)
# Phi-3
ConvTemplateRegistry.register_conv_template(
Conversation(
name="phi-3",
system_template=f"<|system|>\n{MessagePlaceholders.SYSTEM.value}",
system_message="You are a helpful digital assistant. Please provide safe, "
"ethical and accurate information to the user.",
roles={"user": "<|user|>", "assistant": "<|assistant|>"},
seps=["<|end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
system_prefix_token_ids=[1],
stop_str=["<|endoftext|>"],
stop_token_ids=[2, 32000, 32001, 32007],
)
)
# Phi-3-vision
ConvTemplateRegistry.register_conv_template(
Conversation(
name="phi-3-vision",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "<|user|>", "assistant": "<|assistant|>"},
seps=["<|end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
system_prefix_token_ids=[1],
stop_str=["<|endoftext|>"],
stop_token_ids=[2, 32000, 32001, 32007],
)
)
# Phi-4
ConvTemplateRegistry.register_conv_template(
Conversation(
name="phi-4",
system_template=f"<|system|>\n{MessagePlaceholders.SYSTEM.value}",
system_message="You are a helpful digital assistant. Please provide safe, "
"ethical and accurate information to the user.",
roles={"user": "<|user|>", "assistant": "<|assistant|>"},
seps=["<|end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
system_prefix_token_ids=[200022], # <|system|>
stop_str=["<|endoftext|>", "<|end|>"],
stop_token_ids=[199999, 200020], # <|endoftext|>, <|end|>
)
)
@@ -0,0 +1,20 @@
"""Qwen2 default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Same as chatml except system message, stop token, and stop string
ConvTemplateRegistry.register_conv_template(
Conversation(
name="qwen2",
system_template=f"<|im_start|>system\n{MessagePlaceholders.SYSTEM.value}<|im_end|>\n",
system_message="You are a helpful assistant.",
roles={"user": "<|im_start|>user", "assistant": "<|im_start|>assistant"},
seps=["<|im_end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|endoftext|>", "<|im_end|>"],
stop_token_ids=[151643, 151645],
)
)
@@ -0,0 +1,26 @@
"""Qwen3 conversation template.
Matches Qwen2's ChatML structure but strips `<think>...</think>` blocks from
historical assistant turns, mirroring Qwen3's official HF chat template. Small
Qwen3 variants (e.g. 0.6B) otherwise emit `<|im_end|>` prematurely when their
own thinking traces are echoed back in multi-turn context (see mlc-ai/mlc-llm#3482).
"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
ConvTemplateRegistry.register_conv_template(
Conversation(
name="qwen3",
system_template=f"<|im_start|>system\n{MessagePlaceholders.SYSTEM.value}<|im_end|>\n",
system_message="You are a helpful assistant.",
roles={"user": "<|im_start|>user", "assistant": "<|im_start|>assistant"},
seps=["<|im_end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|endoftext|>", "<|im_end|>"],
stop_token_ids=[151643, 151645],
strip_reasoning_in_history=True,
)
)
@@ -0,0 +1,45 @@
"""Qwen3.5 conversation templates.
qwen3_5: Thinking enabled — assistant prefix opens a <think> block for the model
to reason in before responding.
qwen3_5_nothink: Thinking disabled — assistant prefix includes a closed empty
<think> block so the model skips straight to responding.
"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
ConvTemplateRegistry.register_conv_template(
Conversation(
name="qwen3_5",
system_template=f"<|im_start|>system\n{MessagePlaceholders.SYSTEM.value}<|im_end|>\n",
system_message="You are a helpful assistant.",
roles={
"user": "<|im_start|>user",
"assistant": "<|im_start|>assistant\n<think>",
},
seps=["<|im_end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|endoftext|>", "<|im_end|>"],
stop_token_ids=[248046, 248044],
)
)
ConvTemplateRegistry.register_conv_template(
Conversation(
name="qwen3_5_nothink",
system_template=f"<|im_start|>system\n{MessagePlaceholders.SYSTEM.value}<|im_end|>\n",
system_message="You are a helpful assistant.",
roles={
"user": "<|im_start|>user",
"assistant": "<|im_start|>assistant\n<think>\n\n</think>\n",
},
seps=["<|im_end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|endoftext|>", "<|im_end|>"],
stop_token_ids=[248046, 248044],
)
)
@@ -0,0 +1,20 @@
"""RedPajama default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# RedPajama Chat
ConvTemplateRegistry.register_conv_template(
Conversation(
name="redpajama_chat",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "<human>", "assistant": "<bot>"},
seps=["\n"],
role_content_sep=": ",
role_empty_sep=":",
stop_str=["<human>"],
stop_token_ids=[0],
)
)
@@ -0,0 +1,85 @@
"""The conversation template registry and presets in MLC LLM"""
from typing import ClassVar, Dict, Optional # noqa: UP035
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
class ConvTemplateRegistry:
"""Global conversation template registry for preset templates."""
_conv_templates: ClassVar[Dict[str, Conversation]] = {} # noqa: UP006
@staticmethod
def register_conv_template(conv_template: Conversation, override: bool = False) -> None:
"""Register a new conversation template in the global registry.
Using `override = True` to override the previously registered
template with the same name.
"""
name = conv_template.name
if name is None:
raise ValueError("The template to register should have non-None name.")
if name in ConvTemplateRegistry._conv_templates and not override:
raise ValueError(
"The name of the template has been registered "
f"for {ConvTemplateRegistry._conv_templates[name].model_dump_json(by_alias=True)}"
)
ConvTemplateRegistry._conv_templates[name] = conv_template
@staticmethod
def get_conv_template(name: str) -> Optional[Conversation]:
"""Return the conversation template specified by the given name,
or None if the template is not registered.
"""
return ConvTemplateRegistry._conv_templates.get(name, None)
# ChatML
ConvTemplateRegistry.register_conv_template(
Conversation(
name="chatml",
system_template=f"<|im_start|>system\n{MessagePlaceholders.SYSTEM.value}<|im_end|>\n",
system_message=(
"A conversation between a user and an LLM-based AI assistant. The "
"assistant gives helpful and honest answers."
),
roles={"user": "<|im_start|>user", "assistant": "<|im_start|>assistant"},
seps=["<|im_end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|im_end|>"],
stop_token_ids=[2],
)
)
# ChatML without a system prompt
ConvTemplateRegistry.register_conv_template(
Conversation(
name="chatml_nosystem",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "<|im_start|>user", "assistant": "<|im_start|>assistant"},
seps=["<|im_end|>\n"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|im_end|>"],
stop_token_ids=[2],
)
)
# Vanilla LM
ConvTemplateRegistry.register_conv_template(
Conversation(
name="LM",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "", "assistant": ""},
seps=[""],
role_content_sep="",
role_empty_sep="",
stop_str=[],
stop_token_ids=[2],
system_prefix_token_ids=[1],
)
)
@@ -0,0 +1,24 @@
"""RWKV default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# RWKV World
ConvTemplateRegistry.register_conv_template(
Conversation(
name="rwkv_world",
system_template=f"User: hi\n\nAssistant: {MessagePlaceholders.SYSTEM.value}",
system_message=(
"Hi. I am your assistant and I will provide expert full response "
"in full details. Please feel free to ask any question and I will "
"always answer it."
),
roles={"user": "User", "assistant": "Assistant"},
seps=["\n\n"],
role_content_sep=": ",
role_empty_sep=": ",
stop_str=["\n\n"],
stop_token_ids=[0],
)
)
@@ -0,0 +1,59 @@
"""StableLM default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# StableLM Tuned Alpha
ConvTemplateRegistry.register_conv_template(
Conversation(
name="stablelm",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message=(
"<|SYSTEM|># StableLM Tuned (Alpha version)\n"
"- StableLM is a helpful and harmless open-source AI language model developed by "
"StabilityAI.\n"
"- StableLM is excited to be able to help the user, but will refuse to do "
"anything that could be considered harmful to the user.\n"
"- StableLM is more than just an information source, StableLM is also able to "
"write poetry, short stories, and make jokes.\n"
"- StableLM will refuse to participate in anything that could harm a human."
),
roles={"user": "<|USER|>", "assistant": "<|ASSISTANT|>"},
seps=[""],
role_content_sep=": ",
role_empty_sep=": ",
stop_str=[""],
stop_token_ids=[50278, 50279, 50277, 1, 0],
)
)
# StableLM 3B
ConvTemplateRegistry.register_conv_template(
Conversation(
name="stablelm-3b",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "<|user|>", "assistant": "<|assistant|>"},
seps=["<|endoftext|>", "<|endoftext|>"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|endoftext|>"],
stop_token_ids=[0],
)
)
# StableLM-2
ConvTemplateRegistry.register_conv_template(
Conversation(
name="stablelm-2",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "<|user|>", "assistant": "<|assistant|>"},
seps=["<|endoftext|>", "<|endoftext|>"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["<|endoftext|>"],
stop_token_ids=[100257],
)
)
@@ -0,0 +1,20 @@
"""Tiny Llama default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# TinyLlama v1.0
ConvTemplateRegistry.register_conv_template(
Conversation(
name="tinyllama_v1_0",
system_template=f"<|system|>\n{MessagePlaceholders.SYSTEM.value}</s>",
system_message="You are a helpful chatbot.",
roles={"user": "<|user|>", "assistant": "<|assistant|>"},
seps=["</s>"],
role_content_sep="\n",
role_empty_sep="\n",
stop_str=["</s>"],
stop_token_ids=[2],
)
)
@@ -0,0 +1,40 @@
"""WiazrdLM and Coder default templates"""
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
from .registry import ConvTemplateRegistry
# Wizard LM 7B
ConvTemplateRegistry.register_conv_template(
Conversation(
name="wizardlm_7b",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message="",
roles={"user": "User", "assistant": "Response"},
seps=["###"],
role_content_sep=": ",
role_empty_sep=":",
stop_str=["###"],
stop_token_ids=[2],
system_prefix_token_ids=[1],
)
)
# WizardCoder or WizardMath
ConvTemplateRegistry.register_conv_template(
Conversation(
name="wizard_coder_or_math",
system_template=f"{MessagePlaceholders.SYSTEM.value}",
system_message=(
"Below is an instruction that describes a task. Write a response that appropriately "
"completes the request."
),
roles={"user": "Instruction", "assistant": "Response"},
seps=["\n\n### ", "\n\n### "],
role_content_sep=":\n",
role_empty_sep=":\n",
stop_str=["</s>"],
stop_token_ids=[2],
system_prefix_token_ids=[1],
)
)
+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")
+8
View File
@@ -0,0 +1,8 @@
"""JSON FFI is a pure string based interface of MLC LLM Engine.
We build interfacing with JSON FFI for both testing purposes
and internal use. For most python API usage, please use MLCEngine
and MLCAsyncEngine
"""
from .engine import JSONFFIEngine
+295
View File
@@ -0,0 +1,295 @@
import json
import queue
import threading
from collections.abc import Iterator
from typing import Any, Callable, Dict, List, Literal, Optional, Union # noqa: UP035
import tvm
from mlc_llm.protocol import debug_protocol, openai_api_protocol
from mlc_llm.serve import engine_utils
from mlc_llm.serve.engine_base import (
EngineConfig,
EngineMetrics,
_check_engine_config,
_parse_models,
_process_model_args,
_query_engine_metrics,
detect_device,
)
from mlc_llm.tokenizers import Tokenizer
class EngineState:
sync_queue: queue.Queue
def get_request_stream_callback(self) -> Callable[[str], None]:
# ChatCompletionStreamResponse
def _callback(chat_completion_stream_responses_json_str: str) -> None:
self._sync_request_stream_callback(chat_completion_stream_responses_json_str)
return _callback
def _sync_request_stream_callback(self, chat_completion_stream_responses_json_str: str) -> None:
# Put the delta outputs to the queue in the unblocking way.
self.sync_queue.put_nowait(chat_completion_stream_responses_json_str)
def handle_chat_completion(
self, ffi: dict, request_json_str: str, include_usage: bool, request_id: str
) -> Iterator[openai_api_protocol.ChatCompletionStreamResponse]:
"""Helper class to handle chat completion
Note
----
ffi is explicitly passed in to avoid cylic dependency
as ffi will capture EngineState
"""
self.sync_queue = queue.Queue()
ffi["chat_completion"](request_json_str, request_id)
try:
last_chunk_arrived = False
while not last_chunk_arrived:
chat_completion_responses_json_str = self.sync_queue.get()
chat_completion_responses_list = json.loads(chat_completion_responses_json_str)
for chat_completion_response_json_dict in chat_completion_responses_list:
chat_completion_response = (
openai_api_protocol.ChatCompletionStreamResponse.model_validate(
chat_completion_response_json_dict
)
)
# the chunk with usage is always the last chunk
if chat_completion_response.usage is not None:
if include_usage:
yield chat_completion_response
last_chunk_arrived = True
break
yield chat_completion_response
except Exception as exception:
ffi["abort"](request_id)
raise exception
class BackgroundLoops:
"""Helper class to keep track of background loops"""
def __init__(self, ffi: dict):
self._ffi = ffi
# important: avoid self reference in closure
background_loop = self._ffi["run_background_loop"]
background_stream_back_loop = self._ffi["run_background_stream_back_loop"]
# Create the background engine-driving thread and start the loop.
self._background_loop_thread: threading.Thread = threading.Thread(target=background_loop)
self._background_stream_back_loop_thread: threading.Thread = threading.Thread(
target=background_stream_back_loop
)
self._background_loop_thread.start()
self._background_stream_back_loop_thread.start()
self._terminated = False
def __del__(self):
self.terminate()
def terminate(self):
if self._terminated:
return
self._terminated = True
self._ffi["exit_background_loop"]()
self._background_loop_thread.join()
self._background_stream_back_loop_thread.join()
class Completions:
"""Completions class to be compatible with OpenAI API"""
_ffi: dict
_state: EngineState
_background_loops: BackgroundLoops
def __init__(self, ffi: dict, state: EngineState, background_loops: BackgroundLoops):
self._ffi = ffi
self._state = state
self._background_loops = background_loops
def create(
self,
*,
messages: List[Dict[str, Any]], # noqa: UP006
model: Optional[str] = None,
frequency_penalty: Optional[float] = None,
presence_penalty: Optional[float] = None,
logprobs: bool = False,
top_logprobs: int = 0,
logit_bias: Optional[Dict[int, float]] = None, # noqa: UP006
max_tokens: Optional[int] = None,
n: int = 1,
seed: Optional[int] = None,
stop: Optional[Union[str, List[str]]] = None, # noqa: UP006
stream: bool = True,
stream_options: Optional[Dict[str, Any]] = None, # noqa: UP006
temperature: Optional[float] = None,
top_p: Optional[float] = None,
tools: Optional[List[Dict[str, Any]]] = None, # noqa: UP006
tool_choice: Optional[Union[Literal["none", "auto"], Dict]] = None, # noqa: UP006
user: Optional[str] = None,
response_format: Optional[Dict[str, Any]] = None, # noqa: UP006
request_id: Optional[str] = None,
extra_body: Optional[Dict[str, Any]] = None, # noqa: UP006
) -> Iterator[openai_api_protocol.ChatCompletionStreamResponse]:
if request_id is None:
request_id = f"chatcmpl-{engine_utils.random_uuid()}"
debug_config = extra_body.get("debug_config", None) if extra_body is not None else None
if not stream:
raise ValueError("JSONFFIEngine only support stream=True")
request = openai_api_protocol.ChatCompletionRequest(
messages=[
openai_api_protocol.ChatCompletionMessage.model_validate(message)
for message in messages
],
model=model,
frequency_penalty=frequency_penalty,
presence_penalty=presence_penalty,
logprobs=logprobs,
top_logprobs=top_logprobs,
logit_bias=logit_bias,
max_tokens=max_tokens,
n=n,
seed=seed,
stop=stop,
stream=stream,
stream_options=(
openai_api_protocol.StreamOptions.model_validate(stream_options)
if stream_options is not None
else None
),
temperature=temperature,
top_p=top_p,
tools=(
[openai_api_protocol.ChatTool.model_validate(tool) for tool in tools]
if tools is not None
else None
),
tool_choice=tool_choice,
user=user,
response_format=(
openai_api_protocol.RequestResponseFormat.model_validate(response_format)
if response_format is not None
else None
),
debug_config=(
debug_protocol.DebugConfig.model_validate(debug_config)
if debug_config is not None
else None
),
)
chatcmpl_generator = self._state.handle_chat_completion(
self._ffi,
request.model_dump_json(by_alias=True),
include_usage=(
request.stream_options is not None and request.stream_options.include_usage
),
request_id=request_id,
)
for response in chatcmpl_generator:
yield response
class Chat:
"""Chat class to be compatible with OpenAI API"""
completions: Completions
def __init__(self, ffi: dict, state: EngineState, background_loops: BackgroundLoops):
self.completions = Completions(ffi, state, background_loops)
class JSONFFIEngine:
chat: Chat
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,
) -> None:
# - 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 = _process_model_args(models, device, engine_config)[0]
# - Load the raw model config into dict
for i, model_info in enumerate(models):
model_info.model_lib = model_args[i][1]
# - Initialize engine state and engine.
self._state = EngineState()
module = tvm.get_global_func("mlc.json_ffi.CreateJSONFFIEngine", allow_missing=False)()
self._ffi = {
key: module[key]
for key in [
"init_background_engine",
"reload",
"unload",
"reset",
"chat_completion",
"abort",
"run_background_loop",
"run_background_stream_back_loop",
"exit_background_loop",
]
}
self.tokenizer = Tokenizer(model_args[0][0])
self._background_loops = BackgroundLoops(self._ffi)
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.engine_config = engine_config
self._ffi["init_background_engine"](
device.dlpack_device_type(),
device.index,
self._state.get_request_stream_callback(),
)
self._ffi["reload"](self.engine_config.asjson())
self.chat = Chat(self._ffi, self._state, self._background_loops)
def metrics(self) -> EngineMetrics:
"""Get the engine metrics."""
return _query_engine_metrics(self)
def _raw_chat_completion(
self, request_json_str: str, include_usage: bool, request_id: str
) -> Iterator[openai_api_protocol.ChatCompletionStreamResponse]:
"""Raw chat completion API"""
return self._state.handle_chat_completion(
self._ffi, request_json_str, include_usage, request_id
)
def terminate(self):
"""Explicitly terminate the engine"""
self._background_loops.terminate()
def _test_reload(self):
self._ffi["reload"](self.engine_config.asjson())
def _test_reset(self):
self._ffi["reset"]()
def _test_unload(self):
self._ffi["unload"]()
+71
View File
@@ -0,0 +1,71 @@
"""Library information. This is a standalone file that can be used to get various info"""
#! pylint: disable=protected-access
import os
import sys
__version__ = "0.1.dev0"
MLC_LIBRARY_PATH = os.environ.get("MLC_LIBRARY_PATH", None)
def get_env_paths(env_var, splitter):
"""Get path in env variable"""
if os.environ.get(env_var, None):
return [p.strip() for p in os.environ[env_var].split(splitter)]
return []
def get_dll_directories():
"""Get extra mlc llm dll directories"""
curr_dir = os.path.dirname(os.path.realpath(os.path.expanduser(__file__)))
source_dir = os.path.abspath(os.path.join(curr_dir, "..", ".."))
dll_path = [
curr_dir,
os.path.join(source_dir, "build"),
os.path.join(source_dir, "build", "Release"),
]
if MLC_LIBRARY_PATH:
dll_path.append(MLC_LIBRARY_PATH)
if "CONDA_PREFIX" in os.environ:
dll_path.append(os.path.join(os.environ["CONDA_PREFIX"], "lib"))
if sys.platform.startswith("linux") or sys.platform.startswith("freebsd"):
dll_path.extend(get_env_paths("LD_LIBRARY_PATH", ":"))
elif sys.platform.startswith("darwin"):
dll_path.extend(get_env_paths("DYLD_LIBRARY_PATH", ":"))
elif sys.platform.startswith("win32"):
dll_path.extend(get_env_paths("PATH", ";"))
return [os.path.abspath(p) for p in dll_path if os.path.isdir(p)]
def find_lib_path(name, optional=False):
"""Find mlc llm library
Parameters
----------
name : str
The name of the library
optional: boolean
Whether the library is required
"""
if sys.platform.startswith("linux") or sys.platform.startswith("freebsd"):
lib_name = f"lib{name}.so"
elif sys.platform.startswith("win32"):
lib_name = f"{name}.dll"
elif sys.platform.startswith("darwin"):
lib_name = f"lib{name}.dylib"
else:
lib_name = f"lib{name}.so"
dll_paths = get_dll_directories()
lib_dll_path = [os.path.join(p, lib_name) for p in dll_paths]
lib_found = [p for p in lib_dll_path if os.path.exists(p) and os.path.isfile(p)]
if not lib_found:
if not optional:
message = (
f"Cannot find libraries: {lib_name}\n"
+ "List of candidates:\n"
+ "\n".join(lib_dll_path)
)
raise RuntimeError(message)
return lib_found
+8
View File
@@ -0,0 +1,8 @@
"""
A subpackage of the compiler that represents mapping between external parameters, quantized
parameters and parameters in MLC-defined models.
"""
from .huggingface_loader import HuggingFaceLoader
from .loader import LOADER, Loader
from .mapping import ExternMapping, QuantizeMapping

Some files were not shown because too many files have changed in this diff Show More