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
View File
@@ -0,0 +1,341 @@
import tvm
from tvm.ir import assert_structural_equal
from tvm.script import ir as I
from tvm.script import relax as R
from mlc_llm.compiler_pass.fuse_ft_dequantize_matmul_epilogue import (
FuseFTDequantizeEpilogue,
)
def test_fuse_bias():
@I.ir_module
class Before:
@R.function
def main(
x: R.Tensor((1, 1, 4096), "float16"),
weight: R.Tensor((4096, 512), "int8"),
scale: R.Tensor((1, 1024), "float16"),
bias: R.Tensor((1, 1, 1024), "float16"),
):
with R.dataflow():
lv1 = R.call_dps_packed(
"fastertransformer.gemm_fp16_int",
(
x,
weight,
scale,
"identity",
R.prim_value(1),
R.prim_value(1024),
R.prim_value(4096),
R.prim_value(4096),
),
out_sinfo=R.Tensor((1, 1, 1024), "float16"),
)
lv2 = R.add(lv1, bias)
R.output(lv2)
return lv2
@I.ir_module
class After:
@R.function
def main(
x: R.Tensor((1, 1, 4096), "float16"),
weight: R.Tensor((4096, 512), "int8"),
scale: R.Tensor((1, 1024), "float16"),
bias: R.Tensor((1, 1, 1024), "float16"),
) -> R.Tensor((1, 1, 1024), "float16"):
with R.dataflow():
lv2 = R.call_dps_packed(
"fastertransformer.gemm_fp16_int_bias",
(
x,
weight,
scale,
bias,
R.str("identity"),
R.prim_value(1),
R.prim_value(1024),
R.prim_value(4096),
R.prim_value(4096),
R.prim_value(0),
),
out_sinfo=R.Tensor((1, 1, 1024), "float16"),
)
R.output(lv2)
return lv2
seq = tvm.transform.Sequential([FuseFTDequantizeEpilogue()])
mod = seq(Before)
assert_structural_equal(mod, After)
def test_fuse_activation():
@I.ir_module
class Before:
@R.function
def main(
x: R.Tensor((1, 1, 4096), "float16"),
weight: R.Tensor((4096, 512), "int8"),
scale: R.Tensor((1, 1024), "float16"),
):
with R.dataflow():
lv1 = R.call_dps_packed(
"fastertransformer.gemm_fp16_int",
(
x,
weight,
scale,
"identity",
R.prim_value(1),
R.prim_value(1024),
R.prim_value(4096),
R.prim_value(4096),
),
out_sinfo=R.Tensor((1, 1, 1024), "float16"),
)
lv2 = R.nn.silu(lv1)
R.output(lv2)
return lv2
@I.ir_module
class After:
@R.function
def main(
x: R.Tensor((1, 1, 4096), "float16"),
weight: R.Tensor((4096, 512), "int8"),
scale: R.Tensor((1, 1024), "float16"),
) -> R.Tensor((1, 1, 1024), "float16"):
with R.dataflow():
lv2 = R.call_dps_packed(
"fastertransformer.gemm_fp16_int",
(
x,
weight,
scale,
R.str("silu"),
R.prim_value(1),
R.prim_value(1024),
R.prim_value(4096),
R.prim_value(4096),
),
out_sinfo=R.Tensor((1, 1, 1024), "float16"),
)
R.output(lv2)
return lv2
seq = tvm.transform.Sequential([FuseFTDequantizeEpilogue()])
mod = seq(Before)
assert_structural_equal(mod, After)
def test_fuse_bias_activation():
@I.ir_module
class Before:
@R.function
def main(
x: R.Tensor((1, 1, 4096), "float16"),
weight: R.Tensor((4096, 512), "int8"),
scale: R.Tensor((1, 1024), "float16"),
bias: R.Tensor((1, 1, 1024), "float16"),
):
with R.dataflow():
lv1 = R.call_dps_packed(
"fastertransformer.gemm_fp16_int",
(
x,
weight,
scale,
"identity",
R.prim_value(1),
R.prim_value(1024),
R.prim_value(4096),
R.prim_value(4096),
),
out_sinfo=R.Tensor((1, 1, 1024), "float16"),
)
lv2 = R.add(lv1, bias)
lv3 = R.nn.relu(lv2)
R.output(lv3)
return lv3
@I.ir_module
class After:
@R.function
def main(
x: R.Tensor((1, 1, 4096), "float16"),
weight: R.Tensor((4096, 512), "int8"),
scale: R.Tensor((1, 1024), "float16"),
bias: R.Tensor((1, 1, 1024), "float16"),
) -> R.Tensor((1, 1, 1024), "float16"):
with R.dataflow():
lv2 = R.call_dps_packed(
"fastertransformer.gemm_fp16_int_bias",
(
x,
weight,
scale,
bias,
R.str("relu"),
R.prim_value(1),
R.prim_value(1024),
R.prim_value(4096),
R.prim_value(4096),
R.prim_value(0),
),
out_sinfo=R.Tensor((1, 1, 1024), "float16"),
)
R.output(lv2)
return lv2
seq = tvm.transform.Sequential([FuseFTDequantizeEpilogue()])
mod = seq(Before)
assert_structural_equal(mod, After)
def test_fuse_residual_binary():
@I.ir_module
class Before:
@R.function
def main(
x: R.Tensor((1, 1, 4096), "float16"),
weight: R.Tensor((4096, 512), "int8"),
scale: R.Tensor((1, 1024), "float16"),
bias: R.Tensor((1, 1, 1024), "float16"),
residual: R.Tensor((1, 1, 1024), "float16"),
):
with R.dataflow():
lv1 = R.call_dps_packed(
"fastertransformer.gemm_fp16_int",
(
x,
weight,
scale,
"identity",
R.prim_value(1),
R.prim_value(1024),
R.prim_value(4096),
R.prim_value(4096),
),
out_sinfo=R.Tensor((1, 1, 1024), "float16"),
)
lv2 = R.add(lv1, bias)
lv3 = R.nn.relu(lv2)
lv4 = R.multiply(lv3, residual)
R.output(lv4)
return lv4
@I.ir_module
class After:
@R.function
def main(
x: R.Tensor((1, 1, 4096), "float16"),
weight: R.Tensor((4096, 512), "int8"),
scale: R.Tensor((1, 1024), "float16"),
bias: R.Tensor((1, 1, 1024), "float16"),
residual: R.Tensor((1, 1, 1024), "float16"),
) -> R.Tensor((1, 1, 1024), "float16"):
with R.dataflow():
lv2 = R.call_dps_packed(
"fastertransformer.gemm_fp16_int_bias_residual",
(
x,
weight,
scale,
bias,
residual,
R.str("relu"),
R.str("multiply"),
R.str("identity"),
R.prim_value(1),
R.prim_value(1024),
R.prim_value(4096),
R.prim_value(4096),
),
out_sinfo=R.Tensor((1, 1, 1024), "float16"),
)
R.output(lv2)
return lv2
seq = tvm.transform.Sequential([FuseFTDequantizeEpilogue()])
mod = seq(Before)
assert_structural_equal(mod, After)
def test_fuse_residual_unary():
@I.ir_module
class Before:
@R.function
def main(
x: R.Tensor((1, 1, 4096), "float16"),
weight: R.Tensor((4096, 512), "int8"),
scale: R.Tensor((1, 1024), "float16"),
bias: R.Tensor((1, 1, 1024), "float16"),
residual: R.Tensor((1, 1, 1024), "float16"),
):
with R.dataflow():
lv1 = R.call_dps_packed(
"fastertransformer.gemm_fp16_int",
(
x,
weight,
scale,
"identity",
R.prim_value(1),
R.prim_value(1024),
R.prim_value(4096),
R.prim_value(4096),
),
out_sinfo=R.Tensor((1, 1, 1024), "float16"),
)
lv2 = R.add(lv1, bias)
lv3 = R.nn.relu(lv2)
lv4 = R.add(lv3, residual)
lv5 = R.nn.gelu(lv4)
R.output(lv5)
return lv5
@I.ir_module
class After:
@R.function
def main(
x: R.Tensor((1, 1, 4096), "float16"),
weight: R.Tensor((4096, 512), "int8"),
scale: R.Tensor((1, 1024), "float16"),
bias: R.Tensor((1, 1, 1024), "float16"),
residual: R.Tensor((1, 1, 1024), "float16"),
) -> R.Tensor((1, 1, 1024), "float16"):
with R.dataflow():
lv2 = R.call_dps_packed(
"fastertransformer.gemm_fp16_int_bias_residual",
(
x,
weight,
scale,
bias,
residual,
R.str("relu"),
R.str("plus"),
R.str("gelu"),
R.prim_value(1),
R.prim_value(1024),
R.prim_value(4096),
R.prim_value(4096),
),
out_sinfo=R.Tensor((1, 1, 1024), "float16"),
)
R.output(lv2)
return lv2
seq = tvm.transform.Sequential([FuseFTDequantizeEpilogue()])
mod = seq(Before)
assert_structural_equal(mod, After)
if __name__ == "__main__":
test_fuse_bias()
test_fuse_activation()
test_fuse_bias_activation()
test_fuse_residual_binary()
test_fuse_residual_unary()
+41
View File
@@ -0,0 +1,41 @@
# 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.
def pytest_configure(config):
"""Register markers"""
config.addinivalue_line(
"markers",
"unittest: unittests for modules, do not require GPU, usually run fast",
)
config.addinivalue_line("markers", "op_correctness: unittest for op corectness, requires GPU")
config.addinivalue_line(
"markers",
(
"engine: testing engine feature functionalities, requires model and GPU, "
"note: for most request related tests, use endpoint test instead."
),
)
config.addinivalue_line(
"markers",
(
"endpoint: sending requests to a global endpoint fixture(can be an rest or API), "
"tests compatibilities of API behaviors"
),
)
config.addinivalue_line(
"markers",
"uncategorized: this test is not yet categorized, team should work to categorize it",
)
@@ -0,0 +1,83 @@
import pytest
from mlc_llm.conversation_template import ConvTemplateRegistry
from mlc_llm.protocol.conversation_protocol import Conversation, MessagePlaceholders
def get_conv_templates():
return [
"llama-3",
"llama-2",
"mistral_default",
"gorilla",
"gorilla-openfunctions-v2",
"chatml",
"phi-2",
"codellama_completion",
"codellama_instruct",
"rwkv_world",
]
@pytest.mark.parametrize("conv_template_name", get_conv_templates())
def test_json(conv_template_name):
template = ConvTemplateRegistry.get_conv_template(conv_template_name)
j = template.to_json_dict()
template_parsed = Conversation.from_json_dict(j)
assert template == template_parsed
@pytest.mark.parametrize("conv_template_name", get_conv_templates())
def test_prompt(conv_template_name):
conversation = ConvTemplateRegistry.get_conv_template(conv_template_name)
user_msg = "test1"
assistant_msg = "test2"
prompt = "test3"
expected_user_msg = (
conversation.role_templates["user"]
.replace(MessagePlaceholders.USER.value, user_msg)
.replace(MessagePlaceholders.FUNCTION.value, "")
)
expected_prompt = (
conversation.role_templates["user"]
.replace(MessagePlaceholders.USER.value, prompt)
.replace(MessagePlaceholders.FUNCTION.value, "")
)
conversation.messages.append(("user", user_msg))
conversation.messages.append(("assistant", assistant_msg))
conversation.messages.append(("user", prompt))
conversation.messages.append(("assistant", None))
res = conversation.as_prompt()
system_msg = conversation.system_template.replace(
MessagePlaceholders.SYSTEM.value, conversation.system_message
)
expected_final_prompt = (
system_msg
+ (conversation.seps[0] if system_msg != "" else "")
+ (
conversation.roles["user"] + conversation.role_content_sep
if conversation.add_role_after_system_message
else ""
)
+ expected_user_msg
+ conversation.seps[0 % len(conversation.seps)]
+ conversation.roles["assistant"]
+ conversation.role_content_sep
+ assistant_msg
+ conversation.seps[1 % len(conversation.seps)]
+ conversation.roles["user"]
+ conversation.role_content_sep
+ expected_prompt
+ conversation.seps[0 % len(conversation.seps)]
+ conversation.roles["assistant"]
+ conversation.role_empty_sep
)
assert res == expected_final_prompt
if __name__ == "__main__":
test_json("llama-3")
@@ -0,0 +1,43 @@
import pytest
from mlc_llm.conversation_template import ConvTemplateRegistry
pytestmark = [pytest.mark.runtime_unittest]
# From the official Llama-3 example:
# https://llama.meta.com/docs/model-cards-and-prompt-formats/meta-llama-3/
def test_llama3_prompt():
conversation = ConvTemplateRegistry.get_conv_template("llama-3")
system_msg = "You are a helpful AI assistant for travel tips and recommendations"
user_msg1 = "What is France's capital?"
assistant_msg1 = "Bonjour! The capital of France is Paris!"
user_msg2 = "What can I do there?"
assistant_msg2 = "Paris, the City of Light, offers a romantic getaway with must-see attractions like the Eiffel Tower and Louvre Museum, romantic experiences like river cruises and charming neighborhoods, and delicious food and drink options, with helpful tips for making the most of your trip." # noqa: E501
prompt = "Give me a detailed list of the attractions I should visit, and time it takes in each one, to plan my trip accordingly." # noqa: E501
conversation.system_message = system_msg
conversation.messages.append(("user", user_msg1))
conversation.messages.append(("assistant", assistant_msg1))
conversation.messages.append(("user", user_msg2))
conversation.messages.append(("assistant", assistant_msg2))
conversation.messages.append(("user", prompt))
conversation.messages.append(("assistant", None))
res = conversation.as_prompt()
expected = (
"<|start_header_id|>system<|end_header_id|>\n\n"
"You are a helpful AI assistant for travel tips and recommendations<|eot_id|>\n"
"<|start_header_id|>user<|end_header_id|>\n\n"
"What is France's capital?<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n"
"Bonjour! The capital of France is Paris!<|eot_id|><|start_header_id|>user<|end_header_id|>\n\n" # noqa: E501
"What can I do there?<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n"
"Paris, the City of Light, offers a romantic getaway with must-see attractions like the Eiffel Tower and Louvre Museum, romantic experiences like river cruises and charming neighborhoods, and delicious food and drink options, with helpful tips for making the most of your trip.<|eot_id|><|start_header_id|>user<|end_header_id|>\n\n" # noqa: E501
"Give me a detailed list of the attractions I should visit, and time it takes in each one, to plan my trip accordingly.<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n" # noqa: E501
)
assert res[0] == expected
if __name__ == "__main__":
test_llama3_prompt()
@@ -0,0 +1,169 @@
import concurrent.futures as cf
import os
import shlex
import subprocess
import sys
import tempfile
from itertools import product
import tvm
from mlc_llm.model import MODEL_PRESETS
from mlc_llm.model import MODELS as SUPPORTED_MODELS
from mlc_llm.quantization import QUANTIZATION as SUPPORTED_QUANTS
from mlc_llm.support.constants import MLC_TEMP_DIR
OPT_LEVEL = "O2"
DEVICE2TARGET = {
"cuda": {
"kind": "cuda",
"arch": "sm_86",
"max_threads_per_block": 1024,
"max_num_threads": 1024,
"max_shared_memory_per_block": 49152,
"thread_warp_size": 32,
},
"rocm": {
"kind": "rocm",
"mtriple": "amdgcn-amd-amdhsa-hcc",
"mcpu": "gfx1100",
"thread_warp_size": 32,
"max_threads_per_block": 1024,
"max_num_threads": 256,
"max_shared_memory_per_block": 65536,
},
"vulkan": {
"kind": "vulkan",
"max_threads_per_block": 1024,
"max_num_threads": 256,
"max_shared_memory_per_block": 32768,
"thread_warp_size": 1,
"supports_float32": 1,
"supports_float16": 1,
"supports_int64": 1,
"supports_int32": 1,
"supports_int16": 1,
"supports_int8": 1,
"supports_16bit_buffer": 1,
},
"metal": "metal",
"wasm": "webgpu",
"android": "android",
"ios": "iphone",
}
DEVICE2SUFFIX = {
"cuda": "so",
"rocm": "so",
"vulkan": "so",
"metal": "dylib",
"wasm": "wasm",
"android": "tar",
"ios": "tar",
}
MODELS = list(MODEL_PRESETS.keys())
QUANTS = [ # TODO(@junrushao): use `list(mlc_llm.quantization.QUANTIZATION.keys())`
"q0f16",
"q0f32",
"q3f16_1",
"q4f16_1",
"q4f32_1",
"q4f16_ft",
]
TENSOR_PARALLEL_SHARDS = [
1,
]
def run_command(log_file, cmd):
with open(log_file, "w", encoding="utf-8") as file:
subprocess.check_call(
cmd,
stdout=file,
stderr=subprocess.STDOUT,
)
def test_model_compile():
device = sys.argv[1]
num_workers = int(sys.argv[2])
target = DEVICE2TARGET[device]
if not isinstance(target, str):
target = str(tvm.target.Target(target))
suffix = DEVICE2SUFFIX[device]
passed_cmds = []
failed_cmds = []
with tempfile.TemporaryDirectory(dir=MLC_TEMP_DIR) as tmp_dir:
with cf.ProcessPoolExecutor(max_workers=num_workers) as executor:
log_files = []
cmds = []
futures = []
for idx, (model, quant, tp_shard) in enumerate(
product(
MODELS,
QUANTS,
TENSOR_PARALLEL_SHARDS,
)
):
if (
SUPPORTED_QUANTS[quant].kind
not in SUPPORTED_MODELS[MODEL_PRESETS[model]["model_type"]].quantize
):
continue
if not target.startswith("cuda") and quant == "q4f16_ft":
# FasterTransformer only works with cuda
continue
if "deepseek_v2" in model and "32" in quant:
# Skip f32 for deepseek v2 model for now.
continue
log_file = os.path.join(tmp_dir, f"lib{idx}.log")
cmd = [
sys.executable,
"-m",
"mlc_llm",
"compile",
model,
"--quantization",
quant,
"--overrides",
f"tensor_parallel_shards={tp_shard}",
"--device",
target,
"--opt",
OPT_LEVEL,
"-o",
os.path.join(tmp_dir, f"lib{idx}.{suffix}"),
]
future = executor.submit(run_command, log_file, cmd)
log_files.append(log_file)
cmds.append(cmd)
futures.append(future)
for log_file, cmd, future in zip(log_files, cmds, futures):
cmd = shlex.join(cmd)
try:
future.result()
passed_cmds.append(cmd)
print(f"[PASS] {cmd}")
except Exception:
failed_cmds.append(cmd)
print("-------------------------------")
print(f"[FAIL] {cmd}")
with open(log_file, encoding="utf-8") as file:
print(file.read())
print("-------------------------------")
print("-------------------------------")
print(f"Total {len(passed_cmds)} passed, {len(failed_cmds)} failed.")
print("-------------------------------")
print("Passed commands:")
for cmd in passed_cmds:
print(cmd)
if failed_cmds:
print("-------------------------------")
print("Failed commands:")
for cmd in failed_cmds:
print(cmd)
sys.exit(1)
if __name__ == "__main__":
test_model_compile()
@@ -0,0 +1,197 @@
import json
from typing import Dict, List, Optional # noqa: UP035
import pytest
from pydantic import BaseModel
from mlc_llm.json_ffi import JSONFFIEngine
from mlc_llm.testing import require_test_model
# test category "engine_feature"
pytestmark = [pytest.mark.engine_feature]
chat_completion_prompts = [
"What is the meaning of life?",
"Introduce the history of Pittsburgh to me. Please elaborate in detail.",
"Write a three-day Seattle travel plan. Please elaborate in detail.",
"What is Alaska famous of? Please elaborate in detail.",
"What is the difference between Lambda calculus and Turing machine? Please elaborate in detail.", # noqa: E501
"What are the necessary components to assemble a desktop computer? Please elaborate in detail.",
"Why is Vitamin D important to human beings? Please elaborate in detail.",
"Where is milk tea originated from? Please elaborate in detail.",
"Where is the southernmost place in United States? Please elaborate in detail.",
"Do you know AlphaGo? What capabilities does it have, and what achievements has it got? Please elaborate in detail.", # noqa: E501
]
function_calling_prompts = [
"What is the temperature in Pittsburgh, PA?",
"What is the temperature in Tokyo, JP?",
"What is the temperature in Pittsburgh, PA and Tokyo, JP?",
]
tools = [
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
},
},
}
]
def run_chat_completion(
engine: JSONFFIEngine,
model: str,
prompts: List[str] = chat_completion_prompts, # noqa: UP006
tools: Optional[List[Dict]] = None, # noqa: UP006
):
num_requests = 2
max_tokens = 64
n = 1
output_texts: List[List[str]] = [["" for _ in range(n)] for _ in range(num_requests)] # noqa: UP006
for rid in range(num_requests):
print(f"chat completion for request {rid}")
for response in engine.chat.completions.create(
messages=[{"role": "user", "content": [{"type": "text", "text": prompts[rid]}]}],
model=model,
max_tokens=max_tokens,
n=n,
request_id=str(rid),
tools=tools,
):
for choice in response.choices:
assert choice.delta.role == "assistant"
assert isinstance(choice.delta.content, str)
output_texts[rid][choice.index] += choice.delta.content
# Print output.
print("Chat completion all finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
def run_json_schema_function_calling(
engine: JSONFFIEngine,
model: str,
prompts: List[str] = function_calling_prompts, # noqa: UP006
tools: Optional[List[Dict]] = None, # noqa: UP006
):
num_requests = 2
max_tokens = 64
n = 1
output_texts: List[List[str]] = [["" for _ in range(n)] for _ in range(num_requests)] # noqa: UP006
class ToolCall(BaseModel):
name: str
arguments: Dict[str, str] # noqa: UP006
class Schema(BaseModel):
tool_calls: List[ToolCall] # noqa: UP006
schema_str = json.dumps(Schema.model_json_schema())
print("Schema str", schema_str)
for rid in range(num_requests):
print(f"chat completion for request {rid}")
for response in engine.chat.completions.create(
messages=[
{
"role": "system",
"content": "You are a function calling AI model. You are provided with function signatures within " # noqa: E501
"<tools></tools> XML tags. You may call one or more functions to assist with the user query. Don't make " # noqa: E501
f"assumptions about what values to plug into functions. Here are the available tools: <tools> {json.dumps(tools)} </tools> " # noqa: E501
"Do not stop calling functions until the task has been accomplished or you've reached max iteration of 10. " # noqa: E501
"Calling multiple functions at once can overload the system and increase cost so call one function at a time please. " # noqa: E501
"If you plan to continue with analysis, always call another function. Return a valid json object (using double " # noqa: E501
f"quotes) in the following schema: {schema_str}",
},
{"role": "user", "content": [{"type": "text", "text": prompts[rid]}]},
],
model=model,
max_tokens=max_tokens,
n=n,
request_id=str(rid),
response_format={"type": "json_object", "schema": schema_str},
):
for choice in response.choices:
assert choice.delta.role == "assistant"
assert isinstance(choice.delta.content, str)
output_texts[rid][choice.index] += choice.delta.content
# Print output.
print("Chat completion all finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
@require_test_model("Llama-2-7b-chat-hf-q4f16_1-MLC")
def test_chat_completion(model):
# Create engine.
engine = JSONFFIEngine(model)
run_chat_completion(engine, model)
# Test malformed requests.
for response in engine._raw_chat_completion(
"malformed_string", include_usage=False, request_id="123"
):
assert len(response.choices) == 1
assert response.choices[0].finish_reason == "error"
engine.terminate()
@require_test_model("Llama-2-7b-chat-hf-q4f16_1-MLC")
def test_reload_reset_unload(model):
# Create engine.
engine = JSONFFIEngine(model)
# Run chat completion before and after reload/reset.
run_chat_completion(engine, model)
engine._test_reload()
run_chat_completion(engine, model)
engine._test_reset()
run_chat_completion(engine, model)
engine._test_unload()
engine.terminate()
@require_test_model("Hermes-2-Pro-Mistral-7B-q4f16_1-MLC")
def test_json_schema_with_system_prompt(model):
engine = JSONFFIEngine(model)
# run function calling
run_json_schema_function_calling(engine, model, function_calling_prompts, tools)
engine.terminate()
if __name__ == "__main__":
test_chat_completion()
test_reload_reset_unload()
test_json_schema_with_system_prompt()
@@ -0,0 +1,92 @@
import base64
from typing import Dict, List, Optional # noqa: UP035
import requests
from mlc_llm.json_ffi import JSONFFIEngine
from mlc_llm.testing import require_test_model
def base64_encode_image(url: str) -> str:
response = requests.get(url)
response.raise_for_status() # Ensure we got a successful response
image_data = base64.b64encode(response.content)
image_data_str = image_data.decode("utf-8")
data_url = f"data:image/jpeg;base64,{image_data_str}"
return data_url
image_prompts = [
[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": f"{base64_encode_image('https://llava-vl.github.io/static/images/view.jpg')}",
},
{"type": "text", "text": "What does the image represent?"},
],
}
]
]
def run_chat_completion(
engine: JSONFFIEngine,
model: str,
prompts: List[List[Dict]] = image_prompts, # noqa: UP006
tools: Optional[List[Dict]] = None, # noqa: UP006
):
num_requests = 1
max_tokens = 64
n = 1
output_texts: List[List[str]] = [["" for _ in range(n)] for _ in range(num_requests)] # noqa: UP006
for rid in range(num_requests):
print(f"chat completion for request {rid}")
for response in engine.chat.completions.create(
messages=prompts[rid],
model=model,
max_tokens=max_tokens,
n=n,
request_id=str(rid),
tools=tools,
):
for choice in response.choices:
assert choice.delta.role == "assistant"
assert isinstance(choice.delta.content[0], Dict) # noqa: UP006
assert choice.delta.content[0]["type"] == "text"
output_texts[rid][choice.index] += choice.delta.content[0]["text"]
# Print output.
print("Chat completion all finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
@require_test_model("llava-1.5-7b-hf-q4f16_1-MLC")
def test_chat_completion():
# Create engine.
engine = JSONFFIEngine(
model, # noqa: F821
max_total_sequence_length=1024,
)
run_chat_completion(engine, model) # noqa: F821
# Test malformed requests.
for response in engine._raw_chat_completion("malformed_string", n=1, request_id="123"):
assert len(response.choices) == 1
assert response.choices[0].finish_reason == "error"
engine.terminate()
if __name__ == "__main__":
test_chat_completion()
@@ -0,0 +1,105 @@
import json
import pytest
import tvm
from mlc_llm.json_ffi import JSONFFIEngine
from mlc_llm.testing import require_test_model
# test category "unittest"
pytestmark = [pytest.mark.unittest]
def check_error_handling(engine, expect_str, **params):
"""Check error handling in raw completion API"""
body = {
"messages": [{"role": "user", "content": "hello"}],
"stream_options": {"include_usage": True},
}
body.update(params)
for response in engine._raw_chat_completion(
json.dumps(body), include_usage=False, request_id="123"
):
if response.choices[0].finish_reason is not None:
break
if response.choices[0].finish_reason != "error":
raise RuntimeError(f"expect the request {params} to hit an error")
if expect_str not in response.choices[0].delta.content:
raise RuntimeError(
f"expect '{expect_str}' in error msg, but get '{response.choices[0].delta.content}'"
)
# NOTE: we only need tokenizers in folder
# launch time of mock test is fast so we can put it in unittest
@require_test_model("Llama-3-8B-Instruct-q4f16_1-MLC")
def test_chat_completion_misuse(model: str):
engine = JSONFFIEngine(model, tvm.cpu(), model_lib="mock://echo")
# Test malformed requests.
for response in engine._raw_chat_completion(
"malformed_string", include_usage=False, request_id="123"
):
assert len(response.choices) == 1
assert response.choices[0].finish_reason == "error"
# check parameters
check_error_handling(engine, "should be non-negative", temperature=-1)
check_error_handling(engine, "in range [0, 1]", top_p=100)
check_error_handling(engine, "frequency_penalty", frequency_penalty=100)
def check_normal_param_passing(engine):
json_schema = """
{"properties": {"result": {"items": {"type": "Integer"}, "title": "Result", "type": "array"}},
"required": ["result"], "title": "Output", "type": "object"}
"""
param_dict = {
"top_p": 0.6,
"temperature": 0.8,
"frequency_penalty": 0.1,
"presence_penalty": 0.1,
}
usage = None
for response in engine.chat.completions.create(
messages=[{"role": "user", "content": "hello"}],
stream=True,
stream_options={"include_usage": True},
response_format={"type": "json_object", "schema": json_schema},
**param_dict,
):
if response.usage is not None:
usage = response.usage
# echo mock will echo back the generation config
for k, v in param_dict.items():
assert usage.extra[k] == v, f"{k} mismatch"
assert "response_format" in usage.extra
assert usage.extra["response_format"]["type"] == "json_object"
assert "schema" in usage.extra["response_format"]
def check_n_generation(engine):
hit_set = set()
for response in engine.chat.completions.create(
messages=[{"role": "user", "content": "hello"}],
stream=True,
stream_options={"include_usage": True},
n=3,
):
for choice in response.choices:
hit_set.add(choice.index)
for i in range(3):
assert i in hit_set, f"{i} not in n generation"
@require_test_model("Llama-3-8B-Instruct-q4f16_1-MLC")
def test_chat_completion_api(model: str):
engine = JSONFFIEngine(model, tvm.cpu(), model_lib="mock://echo")
check_normal_param_passing(engine)
check_n_generation(engine)
if __name__ == "__main__":
test_chat_completion_api()
test_chat_completion_misuse()
+39
View File
@@ -0,0 +1,39 @@
from pathlib import Path
from typing import Union
import pytest
import tvm
from mlc_llm.loader import HuggingFaceLoader
from mlc_llm.model import MODEL_PRESETS, MODELS
from mlc_llm.quantization import QUANTIZATION
from mlc_llm.support import logging, tqdm
logging.enable_logging()
@pytest.mark.parametrize(
"param_path",
[
"./dist/models/llama-2-7b-w4-g128-awq.pt",
"./dist/models/Llama-2-7B-AWQ/model.safetensors",
],
)
def test_load_llama(param_path: Union[str, Path]):
path_params = Path(param_path)
model = MODELS["llama"]
quantization = QUANTIZATION["q4f16_awq"]
config = model.config.from_dict(MODEL_PRESETS["llama2_7b"])
loader = HuggingFaceLoader(
path=path_params,
extern_param_map=model.source["awq"](config, quantization),
)
with tqdm.redirect():
for _name, _param in loader.load(tvm.device("cpu")):
...
if __name__ == "__main__":
test_load_llama(param_path="./dist/models/llama-2-7b-w4-g128-awq.pt")
test_load_llama(param_path="./dist/models/Llama-2-7B-AWQ/model.safetensors")
+68
View File
@@ -0,0 +1,68 @@
from pathlib import Path
from typing import Union
import pytest
import tvm
from mlc_llm.loader import HuggingFaceLoader
from mlc_llm.model import MODELS
from mlc_llm.support import logging, tqdm
logging.enable_logging()
@pytest.mark.parametrize(
"base_path",
[
"./dist/models/Llama-2-7b-hf",
"./dist/models/Llama-2-13b-hf",
"./dist/models/Llama-2-70b-hf",
],
)
def test_load_torch_llama(base_path: Union[str, Path]):
base_path = Path(base_path)
path_config = base_path / "config.json"
path_params = base_path / "pytorch_model.bin.index.json"
model = MODELS["llama"]
config = model.config.from_file(path_config)
loader = HuggingFaceLoader(
path=path_params,
extern_param_map=model.source["huggingface-torch"](config, None),
)
with tqdm.redirect():
for _name, _param in loader.load(device=tvm.device("cpu")):
return # To reduce the time of the test
@pytest.mark.parametrize(
"base_path",
[
"./dist/models/Llama-2-7b-hf",
"./dist/models/Llama-2-13b-hf",
"./dist/models/Llama-2-70b-hf",
],
)
def test_load_safetensor_llama(base_path: Union[str, Path]):
base_path = Path(base_path)
path_config = base_path / "config.json"
path_params = base_path / "model.safetensors.index.json"
model = MODELS["llama"]
config = model.config.from_file(path_config)
loader = HuggingFaceLoader(
path=path_params,
extern_param_map=model.source["huggingface-safetensor"](config, None),
)
with tqdm.redirect():
for _name, _param in loader.load(device=tvm.device("cpu")):
return # To reduce the time of the test
if __name__ == "__main__":
test_load_torch_llama(base_path="./dist/models/Llama-2-7b-hf")
test_load_torch_llama(base_path="./dist/models/Llama-2-13b-hf")
test_load_torch_llama(base_path="./dist/models/Llama-2-70b-hf")
test_load_safetensor_llama(base_path="./dist/models/Llama-2-7b-hf")
test_load_safetensor_llama(base_path="./dist/models/Llama-2-13b-hf")
test_load_safetensor_llama(base_path="./dist/models/Llama-2-70b-hf")
+70
View File
@@ -0,0 +1,70 @@
"""Unit tests for Gemma3 model architecture."""
import pytest
from mlc_llm.model import MODEL_PRESETS, MODELS
def test_gemma3_model_registered():
"""Verify Gemma3 model is in the registry."""
assert "gemma3" in MODELS, "gemma3 should be registered in MODELS"
@pytest.mark.parametrize(
"model_name",
[
"gemma3_2b",
"gemma3_9b",
],
)
def test_gemma3_creation(model_name: str):
"""Test Gemma3 model creation and export to TVM IR.
Verifies:
- Config can be loaded from preset
- Model instance can be created
- Model exports to TVM IR successfully
- Named parameters are extracted
"""
model_info = MODELS["gemma3"]
config = model_info.config.from_dict(MODEL_PRESETS[model_name])
model = model_info.model(config)
mod, named_params = model.export_tvm(
spec=model.get_default_spec(),
)
# Verify export succeeded
assert mod is not None
assert len(named_params) > 0
# Optional: show module structure
mod.show(black_format=False)
# Print parameters for debugging
for name, param in named_params:
print(name, param.shape, param.dtype)
def test_gemma3_config_validation():
"""Test Gemma3 configuration has required fields."""
model_info = MODELS["gemma3"]
config = model_info.config.from_dict(MODEL_PRESETS["gemma3_2b"])
# Check required config parameters
assert hasattr(config, "hidden_size") and config.hidden_size > 0
assert hasattr(config, "num_hidden_layers") and config.num_hidden_layers > 0
assert hasattr(config, "num_attention_heads") and config.num_attention_heads > 0
assert hasattr(config, "vocab_size") and config.vocab_size > 0
print(
f"Gemma3 Config: hidden_size={config.hidden_size}, "
f"layers={config.num_hidden_layers}, "
f"heads={config.num_attention_heads}, "
f"vocab={config.vocab_size}"
)
if __name__ == "__main__":
# Allow running tests directly
test_gemma3_creation("gemma3_2b")
test_gemma3_creation("gemma3_9b")
+20
View File
@@ -0,0 +1,20 @@
import pytest
from mlc_llm.model import MODEL_PRESETS, MODELS
@pytest.mark.parametrize("model_name", ["gpt2"])
def test_gpt2_creation(model_name: str):
model_info = MODELS["gpt2"]
config = model_info.config.from_dict(MODEL_PRESETS[model_name])
model = model_info.model(config)
mod, named_params = model.export_tvm(
spec=model.get_default_spec(),
)
mod.show(black_format=False)
for name, param in named_params:
print(name, param.shape, param.dtype)
if __name__ == "__main__":
test_gpt2_creation("gpt2")
+20
View File
@@ -0,0 +1,20 @@
import pytest
from mlc_llm.model import MODEL_PRESETS, MODELS
@pytest.mark.parametrize("model_name", ["redpajama_3b_v1"])
def test_mistral_creation(model_name: str):
model_info = MODELS["gpt_neox"]
config = model_info.config.from_dict(MODEL_PRESETS[model_name])
model = model_info.model(config)
mod, named_params = model.export_tvm(
spec=model.get_default_spec(),
)
mod.show(black_format=False)
for name, param in named_params:
print(name, param.shape, param.dtype)
if __name__ == "__main__":
test_mistral_creation("redpajama_3b_v1")
+114
View File
@@ -0,0 +1,114 @@
import tvm
from tvm import tirx
from tvm.relax.frontend.nn import core, modules, spec
from tvm.script import ir as I
from tvm.script import relax as R
from tvm.script import tirx as T
from mlc_llm.nn.kv_cache import PagedKVCache, RopeMode
# mypy: disable-error-code="attr-defined"
def test_nn_module_paged_kv_cache():
# fmt: off
@I.ir_module
class Module:
@R.function
def create_paged_kv_cache(
max_batch_size: R.Shape(["max_batch_size_1"]),
max_total_seq_len: R.Shape(["max_total_seq_len_1"]),
prefill_chunk_size: R.Shape(["prefill_chunk_size_1"]),
page_size: R.Shape(["page_size_1"]),
support_sliding_window: R.Shape(["support_sliding_window_1"]),
) -> R.Object:
max_batch_size_1 = T.int64()
max_total_seq_len_1 = T.int64()
prefill_chunk_size_1 = T.int64()
page_size_1 = T.int64()
support_sliding_window_1 = T.int64()
R.func_attr({"num_input": 5})
with R.dataflow():
paged_kv_cache: R.Object = R.call_pure_packed("mlc.create_paged_kv_cache_generic", R.shape([max_batch_size_1, max_total_seq_len_1, prefill_chunk_size_1, page_size_1, support_sliding_window_1]), R.prim_value(32), R.prim_value(32), R.prim_value(32), R.prim_value(128), R.prim_value(1), R.prim_value(1), R.prim_value(10000), R.prim_value(128), R.dtype("float16"), sinfo_args=(R.Object,)) # noqa: E501
gv1: R.Object = paged_kv_cache
R.output(gv1)
return gv1
@R.function
def forward(
cache: R.Object, qkv: R.Tensor((1, 100, 96, 128), dtype="float16")
) -> R.Tensor((1, 100, 32, 128), dtype="float16"):
R.func_attr({"num_input": 2})
with R.dataflow():
reshape: R.Tensor((100, 96, 128), dtype="float16") = R.reshape(
qkv, R.shape([100, 96, 128])
)
lv = R.call_dps_packed(
"vm.builtin.attention_kv_cache_attention_with_fused_qkv",
(cache, R.prim_value(0), R.prim_value(T.float32(1)), reshape),
out_sinfo=R.Tensor((100, 32, 128), dtype="float16"),
)
reshape1: R.Tensor((1, 100, 32, 128), dtype="float16") = R.reshape(
lv, R.shape([1, 100, 32, 128])
)
gv: R.Tensor((1, 100, 32, 128), dtype="float16") = reshape1
R.output(gv)
return gv
# fmt: on
class PagedKVCacheTest(modules.Module):
def forward(
self,
cache: PagedKVCache,
qkv: core.Tensor,
) -> core.Tensor:
return cache.attention_with_fused_qkv(0, qkv, num_qo_heads=32, sm_scale=128**-0.5)
def create_paged_kv_cache(
self,
max_batch_size: tirx.Var,
max_total_seq_len: tirx.Var,
prefill_chunk_size: tirx.Var,
page_size: tirx.Var,
support_sliding_window: tirx.Var,
) -> PagedKVCache:
return PagedKVCache.create_generic(
attn_kind="mha",
max_batch_size=max_batch_size,
max_total_seq_len=max_total_seq_len,
prefill_chunk_size=prefill_chunk_size,
page_size=page_size,
support_sliding_window=support_sliding_window,
num_hidden_layers=32,
num_attention_heads=32,
num_key_value_heads=32,
qk_head_dim=128,
v_head_dim=128,
rope_mode=RopeMode.NORMAL,
rope_scale=1,
rope_theta=10000,
rotary_dim=128,
dtype="float16",
)
export_results = PagedKVCacheTest().export_tvm(
spec={
"forward": {
"cache": spec.Object(object_type=PagedKVCache),
"qkv": spec.Tensor((1, 100, 96, 128), "float16"),
},
"create_paged_kv_cache": {
"max_batch_size": int,
"max_total_seq_len": int,
"prefill_chunk_size": int,
"page_size": int,
"support_sliding_window": int,
},
},
)
tvm_mod = export_results[0]
tvm.ir.assert_structural_equal(tvm_mod, Module, True)
if __name__ == "__main__":
test_nn_module_paged_kv_cache()
+25
View File
@@ -0,0 +1,25 @@
import pytest
from mlc_llm.model import MODEL_PRESETS, MODELS
@pytest.mark.parametrize(
"model_name", ["llama2_7b", "llama2_13b", "llama2_70b", "tinyllama_1b_chat_v1.0"]
)
def test_llama2_creation(model_name: str):
model_info = MODELS["llama"]
config = model_info.config.from_dict(MODEL_PRESETS[model_name])
model = model_info.model(config)
mod, named_params = model.export_tvm(
spec=model.get_default_spec(),
)
mod.show(black_format=False)
for name, param in named_params:
print(name, param.shape, param.dtype)
if __name__ == "__main__":
test_llama2_creation("llama2_7b")
test_llama2_creation("llama2_13b")
test_llama2_creation("llama2_70b")
test_llama2_creation("tinyllama_1b_chat_v1")
@@ -0,0 +1,72 @@
import pytest
from mlc_llm.model import MODEL_PRESETS, MODELS
from mlc_llm.quantization import QUANTIZATION
from mlc_llm.quantization.group_quantization import (
GroupQuantizeEmbedding,
GroupQuantizeLinear,
)
@pytest.mark.parametrize(
"model_name",
["llama2_7b", "llama2_13b", "llama2_70b"],
)
@pytest.mark.parametrize(
"quant_name",
["q3f16_1", "q4f16_1", "q4f32_1"],
)
def test_llama2_group_quantization(model_name: str, quant_name: str):
model_info = MODELS["llama"]
config = model_info.config.from_dict(MODEL_PRESETS[model_name])
model, quant_map = model_info.quantize["group-quant"](config, QUANTIZATION[quant_name])
assert "model.embed_tokens.weight" in quant_map.param_map
assert isinstance(
model.model.embed_tokens,
GroupQuantizeEmbedding,
)
assert "lm_head.weight" in quant_map.param_map
assert isinstance(model.lm_head, GroupQuantizeLinear)
for i in range(config.num_hidden_layers):
assert f"model.layers.{i}.self_attn.qkv_proj.weight" in quant_map.param_map
assert isinstance(
model.model.layers[i].self_attn.qkv_proj,
GroupQuantizeLinear,
)
assert f"model.layers.{i}.self_attn.o_proj.weight" in quant_map.param_map
assert isinstance(
model.model.layers[i].self_attn.o_proj,
GroupQuantizeLinear,
)
assert f"model.layers.{i}.mlp.gate_up_proj.weight" in quant_map.param_map
assert isinstance(
model.model.layers[i].mlp.gate_up_proj,
GroupQuantizeLinear,
)
assert f"model.layers.{i}.mlp.down_proj.weight" in quant_map.param_map
assert isinstance(
model.model.layers[i].mlp.down_proj,
GroupQuantizeLinear,
)
@pytest.mark.parametrize(
"model_name",
["llama2_7b", "llama2_13b", "llama2_70b"],
)
@pytest.mark.parametrize(
"quant_name",
["q0f16", "q0f32"],
)
def test_llama2_no_quantization(model_name: str, quant_name: str):
model_info = MODELS["llama"]
config = model_info.config.from_dict(MODEL_PRESETS[model_name])
_, quant_map = model_info.quantize["no-quant"](config, QUANTIZATION[quant_name])
assert len(quant_map.param_map) == 0
assert len(quant_map.map_func) == 0
if __name__ == "__main__":
test_llama2_group_quantization("llama2_7b", "q4f16_1")
test_llama2_group_quantization("llama2_13b", "q4f16_1")
test_llama2_group_quantization("llama2_70b", "q4f16_1")
+20
View File
@@ -0,0 +1,20 @@
import pytest
from mlc_llm.model import MODEL_PRESETS, MODELS
@pytest.mark.parametrize("model_name", ["mistral_7b"])
def test_mistral_creation(model_name: str):
model_info = MODELS["mistral"]
config = model_info.config.from_dict(MODEL_PRESETS[model_name])
model = model_info.model(config)
mod, named_params = model.export_tvm(
spec=model.get_default_spec(),
)
mod.show(black_format=False)
for name, param in named_params:
print(name, param.shape, param.dtype)
if __name__ == "__main__":
test_mistral_creation("mistral_7b")
+21
View File
@@ -0,0 +1,21 @@
import pytest
from mlc_llm.model import MODEL_PRESETS, MODELS
@pytest.mark.parametrize("model_name", ["phi-1_5", "phi-2"])
def test_phi_creation(model_name: str):
model_info = MODELS["phi-msft"]
config = model_info.config.from_dict(MODEL_PRESETS[model_name])
model = model_info.model(config)
mod, named_params = model.export_tvm(
spec=model.get_default_spec(),
)
mod.show(black_format=False)
for name, param in named_params:
print(name, param.shape, param.dtype)
if __name__ == "__main__":
test_phi_creation("phi-1_5")
test_phi_creation("phi-2")
+147
View File
@@ -0,0 +1,147 @@
import json
import os
import numpy as np
import pytest
import torch
import tvm
from safetensors import safe_open
from transformers import AutoModel, AutoTokenizer
from tvm import relax
from tvm.contrib import tvmjs
from tvm.runtime import ShapeTuple
from tvm.runtime.vm import VirtualMachine
MLC_QWEN3_EMB_HF_DIR = os.environ.get("MLC_QWEN3_EMB_HF_DIR")
MLC_QWEN3_EMB_MODEL_DIR = os.environ.get("MLC_QWEN3_EMB_MODEL_DIR")
MLC_QWEN3_EMB_MODEL_LIB = os.environ.get("MLC_QWEN3_EMB_MODEL_LIB")
MLC_QWEN3_EMB_DEVICE = os.environ.get("MLC_QWEN3_EMB_DEVICE", "cuda")
_skip = not all([MLC_QWEN3_EMB_HF_DIR, MLC_QWEN3_EMB_MODEL_DIR, MLC_QWEN3_EMB_MODEL_LIB])
_skip_reason = (
"Set MLC_QWEN3_EMB_HF_DIR, MLC_QWEN3_EMB_MODEL_DIR, MLC_QWEN3_EMB_MODEL_LIB to run this test"
)
TEST_TEXTS = [
"What is machine learning?",
"CMU is Carnegie Mellon University",
"机器学习是人工智能的一个分支",
"量子コンピュータの基本原理を説明してください",
"머신러닝은 인공지능의 한 분야입니다.",
(
"Instruct: Given a web search query, retrieve relevant passages "
"that answer the query\nQuery: What is the capital of China?"
),
(
"The Transformer architecture, introduced in the paper Attention Is All You Need, "
"revolutionized natural language processing by replacing recurrent layers with "
"self-attention mechanisms. This allows the model to process all positions in a "
"sequence simultaneously rather than sequentially, leading to significant improvements "
"in both training efficiency and the ability to capture long-range dependencies. "
"The key innovation is the multi-head attention mechanism, which allows the model "
"to jointly attend to information from different representation subspaces at "
"different positions."
),
"Hello",
"def fibonacci(n): return n if n <= 1 else fibonacci(n-1) + fibonacci(n-2)",
]
def _load_embed_weight(hf_dir):
safetensor_files = [f for f in os.listdir(hf_dir) if f.endswith(".safetensors")]
for sf in safetensor_files:
with safe_open(os.path.join(hf_dir, sf), framework="pt", device="cpu") as f:
if "embed_tokens.weight" in f.keys():
return f.get_tensor("embed_tokens.weight")
raise FileNotFoundError(f"embed_tokens.weight not found in {hf_dir}")
def _hf_logits(text, tokenizer, hf_model, embed_weight):
inputs = tokenizer(text, return_tensors="pt")
with torch.no_grad():
hidden = hf_model(**inputs).last_hidden_state.float()
logits = hidden @ embed_weight.float().T
return logits[0, -1, :].numpy()
def _mlc_logits(text, tokenizer, mlc_module, params, metadata, dev, embed_weight):
input_ids = tokenizer(text, return_tensors="pt")["input_ids"][0].numpy().astype(np.int32)
seq_len = len(input_ids)
embed_func = mlc_module["embed"]
prefill_func = mlc_module["prefill_to_last_hidden_states"]
if mlc_module.implements_function("create_flashinfer_paged_kv_cache"):
create_kv = mlc_module["create_flashinfer_paged_kv_cache"]
elif mlc_module.implements_function("create_tir_paged_kv_cache"):
create_kv = mlc_module["create_tir_paged_kv_cache"]
else:
raise RuntimeError("Cannot find KV cache creation function")
sliding_window = metadata.get("sliding_window_size", -1)
context_window = metadata.get("context_window_size", 32768)
prefill_chunk = metadata.get("prefill_chunk_size", 2048)
max_seq_len = sliding_window if context_window == -1 else context_window
kv_cache = create_kv(
ShapeTuple([1]),
ShapeTuple([max_seq_len]),
ShapeTuple([prefill_chunk]),
ShapeTuple([16]),
ShapeTuple([int(sliding_window != -1)]),
)
nd_view = tvm.get_global_func("vm.builtin.reshape")
add_sequence = tvm.get_global_func("vm.builtin.kv_state_add_sequence")
begin_forward = tvm.get_global_func("vm.builtin.kv_state_begin_forward")
end_forward = tvm.get_global_func("vm.builtin.kv_state_end_forward")
tokens_tvm = tvm.runtime.tensor(input_ids, device=dev)
embedding = embed_func(tokens_tvm, params)
embedding = nd_view(embedding, ShapeTuple([1, seq_len, embedding.shape[-1]]))
add_sequence(kv_cache, 0)
begin_forward(kv_cache, ShapeTuple([0]), ShapeTuple([seq_len]))
hidden_states, _ = prefill_func(embedding, kv_cache, params)
end_forward(kv_cache)
# Compute logits from hidden states using embed_tokens weight (tie_word_embeddings)
hidden = hidden_states.numpy().astype(np.float32)
logits = hidden @ embed_weight.float().numpy().T
return logits[0, -1, :]
@pytest.mark.skipif(_skip, reason=_skip_reason)
def test_mlc_hf_logit_match():
tokenizer = AutoTokenizer.from_pretrained(MLC_QWEN3_EMB_HF_DIR, padding_side="left")
hf_model = AutoModel.from_pretrained(MLC_QWEN3_EMB_HF_DIR)
embed_weight = _load_embed_weight(MLC_QWEN3_EMB_HF_DIR)
dev = tvm.runtime.device(MLC_QWEN3_EMB_DEVICE, 0)
ex = tvm.runtime.load_module(MLC_QWEN3_EMB_MODEL_LIB)
vm = relax.VirtualMachine(ex, dev)
mlc_module = vm.module
metadata = json.loads(VirtualMachine(ex, tvm.runtime.device("cpu"))["_metadata"]())
params_dict, _ = tvmjs.load_tensor_cache(MLC_QWEN3_EMB_MODEL_DIR, dev)
param_names = [p["name"] for p in metadata["params"]]
params = [params_dict[name] for name in param_names]
for text in TEST_TEXTS:
hf = _hf_logits(text, tokenizer, hf_model, embed_weight)
mlc = _mlc_logits(text, tokenizer, mlc_module, params, metadata, dev, embed_weight)
cos_sim = np.dot(hf, mlc) / (np.linalg.norm(hf) * np.linalg.norm(mlc))
assert cos_sim > 0.99, f"[{text[:30]}] Cosine similarity {cos_sim:.6f} below 0.99"
max_diff = np.max(np.abs(hf - mlc))
assert max_diff < 1.0, f"[{text[:30]}] Max absolute diff {max_diff:.6e} exceeds 1.0"
hf_top10 = set(np.argsort(hf)[-10:])
mlc_top10 = set(np.argsort(mlc)[-10:])
overlap = len(hf_top10 & mlc_top10)
assert overlap >= 7, f"[{text[:30]}] Top-10 overlap {overlap}/10 below 7"
if __name__ == "__main__":
test_mlc_hf_logit_match()
+163
View File
@@ -0,0 +1,163 @@
import numpy as np
import pytest
import tvm
import tvm.testing
from mlc_llm.op.batch_spec_verify import batch_spec_verify
# test category "op_correctness"
pytestmark = [pytest.mark.op_correctness]
@pytest.mark.parametrize("nbatch", [32, 64])
@pytest.mark.parametrize("vocab", [3, 32, 64, 32000, 33, 65, 32001, 128000])
@pytest.mark.parametrize("plist", [[0.5, 0.5], [1, 0], [0, 1]])
def test_batch_spec_verify(nbatch, vocab, plist):
def numpy_reference(
draft_probs,
draft_tokens,
model_probs,
token_tree_first_child,
token_tree_next_sibling,
uniform_samples,
token_tree_parent_ptr,
):
nbatch = token_tree_parent_ptr.shape[0]
for b in range(nbatch):
parent_ptr = token_tree_parent_ptr[b]
child_ptr = token_tree_first_child[parent_ptr]
while child_ptr != -1:
child_token = draft_tokens[child_ptr]
p_child = model_probs[parent_ptr, child_token]
q_child = draft_probs[child_ptr, child_token]
uniform_sample = uniform_samples[child_ptr]
if p_child / q_child >= uniform_sample:
parent_ptr = child_ptr
child_ptr = token_tree_first_child[child_ptr]
else:
model_probs[parent_ptr, :] = np.maximum(
model_probs[parent_ptr, :] - draft_probs[child_ptr, :], 0.0
)
psum = np.sum(model_probs[parent_ptr, :])
model_probs[parent_ptr, :] /= psum
child_ptr = token_tree_next_sibling[child_ptr]
token_tree_parent_ptr[b] = parent_ptr
np.random.seed(0)
def gen_chain(num_nodes, base):
token_tree_first_child = list()
token_tree_next_sibling = list()
for i in range(num_nodes):
token_tree_first_child.append(base + i + 1 if i + 1 < num_nodes else -1)
token_tree_next_sibling.append(-1)
return token_tree_first_child, token_tree_next_sibling, base, base + 1
def gen_full_binary_tree(height, base):
token_tree_first_child = list()
token_tree_next_sibling = list()
num_nodes = 2**height - 1
for i in range(num_nodes):
token_tree_first_child.append(base + i * 2 + 1 if i * 2 + 1 < num_nodes else -1)
token_tree_next_sibling.append(base + i * 2 + 2 if i * 2 + 2 < num_nodes else -1)
return token_tree_first_child, token_tree_next_sibling, base, base + 1
### Inputs
num_nodes = 0
token_tree_first_child = list()
token_tree_next_sibling = list()
token_tree_parent_ptr = list()
for _ in range(nbatch):
choice = np.random.choice(2, 1, p=plist)
if choice == 0:
nodes_batch = np.random.randint(3, 32)
res = gen_chain(nodes_batch, num_nodes)
num_nodes += nodes_batch
else:
height = np.random.randint(3, 5)
res = gen_full_binary_tree(height, num_nodes)
num_nodes += 2**height - 1
token_tree_first_child.extend(res[0])
token_tree_next_sibling.extend(res[1])
token_tree_parent_ptr.append(res[2])
token_tree_first_child = np.array(token_tree_first_child).astype("int32")
token_tree_next_sibling = np.array(token_tree_next_sibling).astype("int32")
token_tree_parent_ptr = np.array(token_tree_parent_ptr).astype("int32")
draft_probs = np.random.rand(num_nodes, vocab).astype("float32")
draft_probs /= np.sum(draft_probs, axis=1, keepdims=True)
draft_tokens = np.random.randint(0, vocab, num_nodes).astype("int32")
model_probs = np.random.rand(num_nodes, vocab).astype("float32")
model_probs /= np.sum(model_probs, axis=1, keepdims=True)
uniform_samples = np.random.rand(num_nodes).astype("float32")
### TVM Inputs
dev = tvm.cuda(0)
draft_probs_tvm = tvm.runtime.tensor(draft_probs, dev)
draft_tokens_tvm = tvm.runtime.tensor(draft_tokens, dev)
model_probs_tvm = tvm.runtime.tensor(model_probs, dev)
token_tree_first_child_tvm = tvm.runtime.tensor(token_tree_first_child, dev)
token_tree_next_sibling_tvm = tvm.runtime.tensor(token_tree_next_sibling, dev)
uniform_samples_tvm = tvm.runtime.tensor(uniform_samples, dev)
token_tree_parent_ptr_tvm = tvm.runtime.tensor(token_tree_parent_ptr, dev)
# print("draft_probs", draft_probs)
# print("draft_tokens", draft_tokens)
# print("model_probs", model_probs)
# print("token_tree_first_child", token_tree_first_child)
# print("token_tree_next_sibling", token_tree_next_sibling)
# print("uniform_samples", uniform_samples)
# print("token_tree_parent_ptr", token_tree_parent_ptr)
### Numpy reference
numpy_reference(
draft_probs,
draft_tokens,
model_probs,
token_tree_first_child,
token_tree_next_sibling,
uniform_samples,
token_tree_parent_ptr,
)
# print("model_probs", model_probs)
# print("token_tree_parent_ptr", token_tree_parent_ptr)
### TVM
kernel = batch_spec_verify(vocab)
mod = tvm.build(kernel, target="cuda")
mod(
draft_probs_tvm,
draft_tokens_tvm,
model_probs_tvm,
token_tree_first_child_tvm,
token_tree_next_sibling_tvm,
uniform_samples_tvm,
token_tree_parent_ptr_tvm,
)
# print("model_probs", model_probs_tvm.asnumpy())
# print("token_tree_parent_ptr", token_tree_parent_ptr_tvm.asnumpy())
tvm.testing.assert_allclose(model_probs, model_probs_tvm.asnumpy())
tvm.testing.assert_allclose(
token_tree_parent_ptr, token_tree_parent_ptr_tvm.asnumpy(), rtol=0, atol=0
)
time_evaluator = mod.time_evaluator(mod.entry_name, dev, number=10, repeat=3)
print(f"batch_size: {nbatch}, vocab_size: {vocab}, tree_structure: {plist}")
print(
time_evaluator(
draft_probs_tvm,
draft_tokens_tvm,
model_probs_tvm,
token_tree_first_child_tvm,
token_tree_next_sibling_tvm,
uniform_samples_tvm,
token_tree_parent_ptr_tvm,
)
)
if __name__ == "__main__":
tvm.testing.main()
File diff suppressed because it is too large Load Diff
+251
View File
@@ -0,0 +1,251 @@
import numpy as np
import pytest
tvm = pytest.importorskip("tvm")
from tvm import relax # noqa: E402
from tvm.relax.frontend import nn # noqa: E402
from tvm.relax.frontend.nn import spec # noqa: E402
from tvm.runtime import tensor as tvm_tensor # noqa: E402
from mlc_llm.op import ( # noqa: E402
MultimodalRotaryEmbedding,
VisionPositionMetadata,
apply_multimodal_rotary_pos_emb,
get_mrope_position_ids,
)
def _numpy_rotate_half(x: np.ndarray) -> np.ndarray:
x1, x2 = np.split(x, 2, axis=-1)
return np.concatenate([-x2, x1], axis=-1)
def _numpy_apply_mrope(
q: np.ndarray,
k: np.ndarray,
position_ids: np.ndarray,
theta: float,
mrope_section: tuple[int, ...],
) -> tuple[np.ndarray, np.ndarray]:
if position_ids.ndim != 3:
raise ValueError(f"position_ids must be rank-3, got shape {position_ids.shape}")
if position_ids.shape[0] == 3:
position_ids = np.transpose(position_ids, (1, 2, 0))
elif position_ids.shape[-1] != 3:
raise ValueError(
"position_ids must have shape (batch, seq, 3) or (3, batch, seq), "
f"got {position_ids.shape}"
)
head_dim = q.shape[-1]
inv_freq = 1.0 / (theta ** (np.arange(0, head_dim, 2, dtype=np.float32) / float(head_dim)))
pos = np.transpose(position_ids, (2, 0, 1))
inv = inv_freq.reshape(1, 1, -1, 1).astype(np.float32)
inv = np.broadcast_to(inv, (3, pos.shape[1], inv_freq.size, 1))
pos = pos.reshape(3, pos.shape[1], 1, pos.shape[2]).astype(np.float32)
freqs = np.matmul(inv, pos)
freqs = np.transpose(freqs, (0, 1, 3, 2))
emb = np.concatenate([freqs, freqs], axis=-1)
cos = np.cos(emb)
sin = np.sin(emb)
split_sizes = list(mrope_section) * 2
split_points = np.cumsum(split_sizes)[:-1]
cos_chunks = np.split(cos, split_points, axis=-1)
sin_chunks = np.split(sin, split_points, axis=-1)
cos = np.concatenate([chunk[idx % 3] for idx, chunk in enumerate(cos_chunks)], axis=-1)
sin = np.concatenate([chunk[idx % 3] for idx, chunk in enumerate(sin_chunks)], axis=-1)
cos = np.expand_dims(cos, axis=2)
sin = np.expand_dims(sin, axis=2)
q_out = q * cos + _numpy_rotate_half(q) * sin
k_out = k * cos + _numpy_rotate_half(k) * sin
return q_out, k_out
def _evaluate_tensor(expr):
mod = tvm.IRModule.from_expr(expr)
target = tvm.target.Target("llvm")
ex = tvm.relax.build(mod, target)
vm = tvm.relax.VirtualMachine(ex, tvm.cpu())
return vm["main"]().numpy()
def _run_mlc_mrope(
q_np: np.ndarray,
k_np: np.ndarray,
position_ids_np: np.ndarray,
theta: float,
mrope_section: tuple[int, ...],
) -> tuple[np.ndarray, np.ndarray]:
class RopeModule(nn.Module):
def __init__(self):
super().__init__()
self.rotary = MultimodalRotaryEmbedding(q_np.shape[-1], theta, mrope_section)
def forward(
self,
q: nn.Tensor,
k: nn.Tensor,
pos: nn.Tensor,
):
"""Run MRoPE on test tensors and return rotated query/key outputs."""
cos, sin = self.rotary(q, pos)
return apply_multimodal_rotary_pos_emb(q, k, cos, sin, mrope_section)
module = RopeModule()
mod, _, _ = module.export_tvm(
spec={
"forward": {
"q": spec.Tensor(q_np.shape, "float32"),
"k": spec.Tensor(k_np.shape, "float32"),
"pos": spec.Tensor(position_ids_np.shape, "int64"),
}
},
allow_extern=True,
)
target = tvm.target.Target("llvm")
exec_mod = relax.build(mod, target=target)
vm = relax.VirtualMachine(exec_mod, tvm.cpu())
device = tvm.cpu()
q_nd = tvm_tensor(q_np.astype("float32"), device=device)
k_nd = tvm_tensor(k_np.astype("float32"), device=device)
pos_nd = tvm_tensor(position_ids_np.astype("int64"), device=device)
out_q, out_k = vm["forward"](q_nd, k_nd, pos_nd)
return out_q.numpy(), out_k.numpy()
def test_apply_mrope_matches_numpy_reference():
theta = 10000.0
mrope_section = (2, 2, 2)
batch, seq_len, heads, head_dim = 1, 4, 2, 12
rng = np.random.default_rng(0)
q_np = rng.standard_normal((batch, seq_len, heads, head_dim), dtype=np.float32)
k_np = rng.standard_normal((batch, seq_len, heads, head_dim), dtype=np.float32)
position_ids = np.zeros((batch, seq_len, 3), dtype=np.int64)
position_ids[0, :, 0] = np.arange(seq_len)
position_ids[0, :, 1] = np.arange(seq_len) * 2
position_ids[0, :, 2] = np.arange(seq_len) * 3
mlc_q, mlc_k = _run_mlc_mrope(q_np, k_np, position_ids, theta, mrope_section)
ref_q, ref_k = _numpy_apply_mrope(q_np, k_np, position_ids, theta, mrope_section)
np.testing.assert_allclose(mlc_q, ref_q, rtol=1e-5, atol=1e-5)
np.testing.assert_allclose(mlc_k, ref_k, rtol=1e-5, atol=1e-5)
def test_get_mrope_position_ids_text_only():
input_ids = np.array([[1, 2, 3, 0, 0]], dtype=np.int64)
attention_mask = np.array([[1, 1, 1, 0, 0]], dtype=np.int64)
meta = VisionPositionMetadata(
vision_start_token_id=1000,
image_token_id=1001,
video_token_id=1002,
spatial_merge_size=2,
tokens_per_second=4.0,
)
position_ids, deltas = get_mrope_position_ids(
input_ids,
meta,
attention_mask=attention_mask,
image_grid_thw=None,
video_grid_thw=None,
second_per_grid_ts=None,
)
expected = attention_mask.cumsum(axis=-1) - 1
expected = np.where(attention_mask == 0, 1, expected)
expected = np.expand_dims(expected, axis=0).repeat(3, axis=0)
np.testing.assert_array_equal(position_ids, expected)
np.testing.assert_array_equal(deltas, np.array([[-2]], dtype=np.int64))
def test_get_mrope_position_ids_single_image_block():
meta = VisionPositionMetadata(
vision_start_token_id=5000,
image_token_id=5001,
video_token_id=6000,
spatial_merge_size=2,
tokens_per_second=4.0,
)
input_ids = np.array(
[[11, 12, 5000, 5001, 21, 22, 23, 24, 31, 32]],
dtype=np.int64,
)
attention_mask = np.ones_like(input_ids, dtype=np.int64)
image_grid_thw = np.array([[1, 4, 4]], dtype=np.int64)
position_ids, deltas = get_mrope_position_ids(
input_ids,
meta,
attention_mask=attention_mask,
image_grid_thw=image_grid_thw,
video_grid_thw=None,
second_per_grid_ts=None,
)
expected = np.array(
[
[0, 1, 2, 3, 3, 3, 3, 5, 6, 7],
[0, 1, 2, 3, 3, 4, 4, 5, 6, 7],
[0, 1, 2, 3, 4, 3, 4, 5, 6, 7],
],
dtype=np.int64,
).reshape(3, 1, -1)
np.testing.assert_array_equal(position_ids, expected)
np.testing.assert_array_equal(deltas, np.array([[-2]], dtype=np.int64))
def test_apply_mrope_accepts_3_batch_seq_layout():
theta = 10000.0
mrope_section = (2, 2, 2)
batch, seq_len, heads, head_dim = 1, 4, 2, 12
rng = np.random.default_rng(1)
q_np = rng.standard_normal((batch, seq_len, heads, head_dim), dtype=np.float32)
k_np = rng.standard_normal((batch, seq_len, heads, head_dim), dtype=np.float32)
position_ids_bsc = np.zeros((batch, seq_len, 3), dtype=np.int64)
position_ids_bsc[0, :, 0] = np.arange(seq_len)
position_ids_bsc[0, :, 1] = np.arange(seq_len) * 2
position_ids_bsc[0, :, 2] = np.arange(seq_len) * 3
position_ids_3bs = np.transpose(position_ids_bsc, (2, 0, 1))
mlc_q_bsc, mlc_k_bsc = _run_mlc_mrope(q_np, k_np, position_ids_bsc, theta, mrope_section)
mlc_q_3bs, mlc_k_3bs = _run_mlc_mrope(q_np, k_np, position_ids_3bs, theta, mrope_section)
ref_q, ref_k = _numpy_apply_mrope(q_np, k_np, position_ids_bsc, theta, mrope_section)
np.testing.assert_allclose(mlc_q_bsc, ref_q, rtol=1e-5, atol=1e-5)
np.testing.assert_allclose(mlc_k_bsc, ref_k, rtol=1e-5, atol=1e-5)
np.testing.assert_allclose(mlc_q_3bs, ref_q, rtol=1e-5, atol=1e-5)
np.testing.assert_allclose(mlc_k_3bs, ref_k, rtol=1e-5, atol=1e-5)
def test_get_mrope_position_ids_output_is_directly_usable():
theta = 10000.0
mrope_section = (2, 2, 2)
meta = VisionPositionMetadata(
vision_start_token_id=7000,
image_token_id=7001,
video_token_id=7002,
spatial_merge_size=2,
tokens_per_second=4.0,
)
input_ids = np.array([[11, 12, 7000, 7001, 21, 22, 23, 24, 31, 32]], dtype=np.int64)
attention_mask = np.ones_like(input_ids, dtype=np.int64)
image_grid_thw = np.array([[1, 4, 4]], dtype=np.int64)
position_ids_3bs, _ = get_mrope_position_ids(
input_ids,
meta,
attention_mask=attention_mask,
image_grid_thw=image_grid_thw,
video_grid_thw=None,
second_per_grid_ts=None,
)
position_ids_bsc = np.transpose(position_ids_3bs, (1, 2, 0))
batch, seq_len = input_ids.shape
heads, head_dim = 2, 12
rng = np.random.default_rng(2)
q_np = rng.standard_normal((batch, seq_len, heads, head_dim), dtype=np.float32)
k_np = rng.standard_normal((batch, seq_len, heads, head_dim), dtype=np.float32)
mlc_q_3bs, mlc_k_3bs = _run_mlc_mrope(q_np, k_np, position_ids_3bs, theta, mrope_section)
mlc_q_bsc, mlc_k_bsc = _run_mlc_mrope(q_np, k_np, position_ids_bsc, theta, mrope_section)
np.testing.assert_allclose(mlc_q_3bs, mlc_q_bsc, rtol=1e-5, atol=1e-5)
np.testing.assert_allclose(mlc_k_3bs, mlc_k_bsc, rtol=1e-5, atol=1e-5)
+86
View File
@@ -0,0 +1,86 @@
import numpy as np
import pytest
import tvm
import tvm.testing
from mlc_llm.op.top_p_pivot import top_p_pivot, top_p_renorm
# mypy: disable-error-code="var-annotated"
# test category "op_correctness"
pytestmark = [pytest.mark.op_correctness]
@pytest.mark.parametrize("batch_size", [32, 64])
@pytest.mark.parametrize("vocab", [3, 32, 64, 128])
def test_top_p_renorm(batch_size, vocab):
top_p = 0.95
init_pivots_np = np.array([1 - top_p, 0.02, 0.01]).astype(np.float32)
top_p_np = np.array([top_p]).astype(np.float32)
p_np = np.random.exponential(3, size=(batch_size, vocab)).astype(np.float32)
p_np /= np.sum(p_np, axis=-1, keepdims=True)
final_pivot_np = np.zeros(batch_size).astype(np.float32)
final_lsum_np = np.zeros(batch_size).astype(np.float32)
dev = tvm.cuda(0)
var_prob = tvm.runtime.tensor(p_np, dev)
var_init_pivots = tvm.runtime.tensor(init_pivots_np, dev)
top_p_global = tvm.runtime.tensor(top_p_np, dev)
var_final_pivot = tvm.runtime.tensor(final_pivot_np, dev)
var_final_lsum = tvm.runtime.tensor(final_lsum_np, dev)
kernel = top_p_pivot(init_pivots_np.shape[0])
mod = tvm.build(kernel, target="cuda")
mod(var_prob, top_p_global, var_init_pivots, var_final_pivot, var_final_lsum)
final_pivot = var_final_pivot.asnumpy()
final_lsum = var_final_lsum.asnumpy()
renorm_np = p_np.copy()
var_renorm = tvm.runtime.tensor(renorm_np, dev)
kernel_renorm = top_p_renorm()
mod_renorm = tvm.build(kernel_renorm, target="cuda")
mod_renorm(var_prob, var_final_pivot, var_final_lsum, var_renorm)
renorm = var_renorm.asnumpy()
def verify_pivot(probs: np.ndarray, pivot: float, lsum: float, renorm: np.ndarray):
sorted_probs = np.sort(probs, axis=-1)[::-1]
num_larger_than_pivot = np.sum(sorted_probs >= pivot)
filtered_sorted_probs = sorted_probs[:num_larger_than_pivot]
min_larger_than_pivot = min(filtered_sorted_probs)
sum_larger_than_pivot = np.sum(np.where(sorted_probs >= pivot, sorted_probs, 0))
sum_larger_than_pivot_exclude_min = np.sum(
np.where(filtered_sorted_probs != min_larger_than_pivot, filtered_sorted_probs, 0)
)
probs[probs < pivot] = 0
renorm_prob = probs / np.sum(probs, axis=-1, keepdims=True)
try:
assert sum_larger_than_pivot >= top_p
assert sum_larger_than_pivot_exclude_min < top_p
assert abs(lsum - sum_larger_than_pivot) < 1e-6
assert np.allclose(renorm, renorm_prob, atol=1e-6, rtol=1e-6)
except AssertionError:
print("Failed")
print("probs:", repr(probs))
print("pivot:", pivot)
print("sorted_probs:", sorted_probs)
print("num_larger_than_pivot:", num_larger_than_pivot)
print("filtered_sorted_probs:", filtered_sorted_probs)
print("min_larger_than_pivot:", min_larger_than_pivot)
print("sum_larger_than_pivot:", sum_larger_than_pivot)
print("sum_larger_than_pivot_exclude_min:", sum_larger_than_pivot_exclude_min)
print("renom_prob:", renorm_prob)
print("renorm:", renorm)
raise
for i in range(batch_size):
verify_pivot(p_np[i], final_pivot[i], final_lsum[i], renorm[i])
if __name__ == "__main__":
tvm.testing.main()
+243
View File
@@ -0,0 +1,243 @@
import math
import numpy as np
import pytest
import tvm
import tvm.testing
from tvm.relax.frontend.nn.llm import tree_attn
# test category "op_correctness"
pytestmark = [pytest.mark.op_correctness]
@pytest.mark.parametrize("nbatch", [1, 4, 32])
@pytest.mark.parametrize("h_q", [8, 16])
@pytest.mark.parametrize("h_kv", [4, 8])
@pytest.mark.parametrize("d", [128])
@pytest.mark.parametrize("rotary_mode", [0, 1])
def test_tree_attn(nbatch, h_q, h_kv, d, rotary_mode):
np.random.seed(0)
np.set_printoptions(linewidth=10000)
def gen_chain(num_nodes):
mask = np.tril(np.ones((num_nodes, num_nodes)))
return num_nodes, list(mask.flatten()), np.arange(num_nodes)
def gen_full_binary_tree(height):
mask = list()
pos = list()
num_nodes = 2**height - 1
for i in range(num_nodes):
if i == 0:
mask_0 = [0] * num_nodes
mask_0[0] = 1
mask.append(mask_0)
pos.append(0)
else:
mask_i = mask[(i + 1) // 2 - 1].copy()
mask_i[i] = 1
mask.append(mask_i)
pos.append(pos[(i + 1) // 2 - 1] + 1)
return num_nodes, list(np.array(mask).flatten()), pos
### Inputs
num_nodes = 0
m_list = list()
mn_list = list()
mask_list = list()
q_pos_list = list()
mn_list.append(0)
for _ in range(nbatch):
choice = np.random.choice(2, 1, p=[1, 0])
if choice == 0:
nodes_batch = np.random.randint(3, 32)
res = gen_chain(nodes_batch)
num_nodes += nodes_batch
else:
height = np.random.randint(2, 6)
res = gen_full_binary_tree(height)
num_nodes += 2**height - 1
m_list.append(res[0])
mn_list.append(res[0] ** 2)
mask_list.extend(res[1])
q_pos_list.extend(res[2])
qkv_indptr = np.array(np.cumsum([0, *m_list])).astype(np.int32)
m_list = np.array(m_list).astype(np.int32)
mn_list = np.array(mn_list).astype(np.int32)
mn_list = np.cumsum(mn_list).astype(np.int32)
mask_list = np.array(mask_list).astype(np.int32)
q_pos_list = np.array(q_pos_list).astype(np.int32)
# print("qkv_indptr:", qkv_indptr)
# print("m_list:", m_list)
# print("mn_list:", mn_list)
# for num_nodes, base in zip(m_list, mn_list):
# print("num_nodes:", num_nodes)
# print("indptr:", base)
# print(
# "mask:",
# mask_list[base : base + num_nodes * num_nodes].reshape(num_nodes, num_nodes),
# )
# print("q_pos:", q_pos_list[base : base + num_nodes])
q = np.random.rand(num_nodes, h_q, d).astype(np.float16)
q_indptr = qkv_indptr
k = np.random.rand(num_nodes, h_kv, d).astype(np.float16)
v = np.random.rand(num_nodes, h_kv, d).astype(np.float16)
kv_indptr = qkv_indptr
q_rope_position = q_pos_list
m_arr = m_list
mn_indptr = mn_list
mask = mask_list
output = np.zeros((num_nodes, h_q, d), dtype=np.float16)
lse = np.zeros((num_nodes, h_q), dtype=np.float32)
rotary_scale = 1.0
rotary_theta = 10000.0
attn_score_scaling_factor = 1.0
### TVM Inputs
dev = tvm.cuda(0)
q_tvm = tvm.runtime.tensor(q, dev)
q_indptr_tvm = tvm.runtime.tensor(q_indptr, dev)
k_tvm = tvm.runtime.tensor(k, dev)
v_tvm = tvm.runtime.tensor(v, dev)
kv_indptr_tvm = tvm.runtime.tensor(kv_indptr, dev)
q_rope_position_tvm = tvm.runtime.tensor(q_rope_position, dev)
# m_arr_tvm = tvm.runtime.tensor(m_arr, dev)
mn_indptr_tvm = tvm.runtime.tensor(mn_indptr, dev)
mask_tvm = tvm.runtime.tensor(mask, dev)
output_tvm = tvm.runtime.tensor(output, dev)
lse_tvm = tvm.runtime.tensor(lse, dev)
target = tvm.target.Target("cuda")
kernel = tree_attn(h_kv=h_kv, h_q=h_q, d=d, dtype="float16", rope_scaling={}, target=target)
mod = tvm.build(kernel, target=target)
mod(
q_tvm,
q_indptr_tvm,
k_tvm,
v_tvm,
kv_indptr_tvm,
q_rope_position_tvm,
# m_arr_tvm,
mn_indptr_tvm,
mask_tvm,
output_tvm,
lse_tvm,
rotary_mode,
rotary_scale,
rotary_theta,
attn_score_scaling_factor,
nbatch,
)
### Numpy reference
def numpy_reference(
q,
q_indptr,
k,
v,
kv_indptr,
q_rope_position,
m_arr,
mn_indptr,
mask,
rotary_mode,
rotary_scale,
rotary_theta,
attn_score_scaling_factor,
output_tvm,
):
def rope_freq(s, d, d_range, theta, dtype):
freq = s / math.pow(theta, (d * 2 % d_range) / float(d_range))
cos_freq = np.cos(freq).astype(dtype)
sin_freq = np.sin(freq).astype(dtype)
return cos_freq, sin_freq
def rope(buffer, offset, rotary_dim, theta, scale, dtype):
result = buffer.copy()
for pos, h, d in np.ndindex(buffer.shape):
cos_freq, sin_freq = rope_freq(offset[pos] * scale, d, rotary_dim, theta, dtype)
cos = cos_freq * buffer[pos, h, d]
sin = sin_freq * (
-buffer[pos, h, d + rotary_dim // 2]
if d < rotary_dim // 2
else buffer[pos, h, d - rotary_dim // 2]
)
result[pos, h, d] = cos + sin
return result
for i in range(len(m_arr)):
num_nodes = m_arr[i]
base = mn_indptr[i]
q_base = q_indptr[i]
kv_base = kv_indptr[i]
q_pos = q_rope_position[q_base : q_base + num_nodes] # (num_nodes,)
q_i = q[q_base : q_base + num_nodes] # (num_nodes, h_q, d)
k_i = k[kv_base : kv_base + num_nodes] # (num_nodes, h_kv, d)
v_i = v[kv_base : kv_base + num_nodes] # (num_nodes, h_kv, d)
mask_i = mask[base : base + num_nodes * num_nodes].reshape(num_nodes, num_nodes)
if rotary_mode == 1:
q_i = rope(q_i, q_pos, d, rotary_theta, rotary_scale, q_i.dtype)
k_i = rope(k_i, q_pos, d, rotary_theta, rotary_scale, k_i.dtype)
# group attention
# q: (num_nodes, h_q, d)
# k: (num_nodes, h_kv, d)
# v: (num_nodes, h_kv, d)
group_size = h_q // h_kv
q_reshape = q_i.transpose(1, 0, 2) # (h_q, num_nodes, d)
k_reshape = k_i.transpose(1, 2, 0) # (h_kv, d, num_nodes)
v_reshape = v_i.transpose(1, 0, 2) # (h_kv, num_nodes, d)
# expand k_reshape
k_reshape = k_reshape.reshape(h_kv, 1, d, num_nodes)
k_reshape = np.repeat(k_reshape, group_size, axis=1)
k_reshape = k_reshape.reshape(h_q, d, num_nodes)
# expand v_reshape
v_reshape = v_reshape.reshape(h_kv, 1, num_nodes, d)
v_reshape = np.repeat(v_reshape, group_size, axis=1)
v_reshape = v_reshape.reshape(h_q, num_nodes, d)
# print("q_reshape:", q_reshape.shape)
# print("k_reshape:", k_reshape.shape)
# print("v_reshape:", v_reshape.shape)
# qk: (h_q, num_nodes, num_nodes)
qk = np.matmul(q_reshape, k_reshape) * attn_score_scaling_factor / math.sqrt(float(d))
# softmax(qk, axis=-1), numerical stability
qk[:, mask_i == 0] = -np.inf
qk_max = np.max(qk, axis=-1, keepdims=True)
qk = np.exp(qk - qk_max)
qk = qk / np.sum(qk, axis=-1, keepdims=True)
# attention
output_i = np.matmul(qk, v_reshape).transpose(1, 0, 2) # (num_nodes, h_q, d)
# print(output_i)
tvm.testing.assert_allclose(
output_i, output_tvm[q_base : q_base + num_nodes], rtol=1e-3, atol=1e-3
)
numpy_reference(
q,
q_indptr,
k,
v,
kv_indptr,
q_rope_position,
m_arr,
mn_indptr,
mask,
rotary_mode,
rotary_scale,
rotary_theta,
attn_score_scaling_factor,
output_tvm.numpy(),
)
if __name__ == "__main__":
tvm.testing.main()
+51
View File
@@ -0,0 +1,51 @@
import numpy as np
import pytest
import scipy.special
import tvm
from tvm.s_tir import dlight
# test category "op_correctness"
pytestmark = [pytest.mark.op_correctness]
def test_two_stage_softmax():
from mlc_llm.compiler_pass.rewrite_softmax import _get_lse_and_softmax_func
chunk_size = 4096
target = tvm.target.Target("cuda")
f_chunk_lse, f_softmax_with_lse = _get_lse_and_softmax_func(target, chunk_size)
mod = tvm.IRModule({"chunk_lse": f_chunk_lse, "softmax_with_chunked_lse": f_softmax_with_lse})
with target:
mod = dlight.ApplyDefaultSchedule(dlight.gpu.GeneralReduction())(mod)
runtime_mod = tvm.build(mod, target=target)
device = tvm.cuda()
num_runs = 5
vocab_size = 128256
for batch_size in [1, 2, 4, 8, 16, 32, 64, 128]:
for _ in range(num_runs):
x_np = np.random.uniform(low=-10, high=10, size=(batch_size, vocab_size)).astype(
"float32"
)
y_np = scipy.special.softmax(x_np, axis=-1)
x_nd = tvm.runtime.tensor(x_np, device=device)
r_nd = tvm.runtime.empty(
(batch_size, (vocab_size + chunk_size - 1) // chunk_size),
x_np.dtype,
device=device,
)
y_nd = tvm.runtime.empty(x_np.shape, x_np.dtype, device=device)
runtime_mod["chunk_lse"](x_nd, r_nd)
runtime_mod["softmax_with_chunked_lse"](x_nd, r_nd, y_nd)
y_nd_arr = y_nd.numpy()
np.testing.assert_allclose(y_nd_arr, y_np, atol=1e-6, rtol=1e-6)
print(f"pass batch size {batch_size}")
if __name__ == "__main__":
test_two_stage_softmax()
@@ -0,0 +1,86 @@
from typing import List # noqa: UP035
import numpy as np
import pytest
import torch
import tvm
import tvm.testing
from tvm import DataType
from tvm.relax.frontend import nn
from mlc_llm.loader import QuantizeMapping
from mlc_llm.quantization import QUANTIZATION, AWQQuantize
def dequantize_np(
config: AWQQuantize,
weight: np.ndarray,
zeros: np.ndarray,
scale: np.ndarray,
) -> np.ndarray:
def decode_int_arr(int_arr: np.ndarray, num_elem_per_storage: int, bits: int):
bin_mask = (1 << bits) - 1
int_arr_repeated = np.repeat(int_arr, num_elem_per_storage, axis=-1)
indice_j = np.indices(int_arr_repeated.shape)[1]
arr_bin = np.bitwise_and(
np.right_shift(
int_arr_repeated,
(indice_j % num_elem_per_storage) * bits,
),
bin_mask,
)
return arr_bin
weight_bin = decode_int_arr(
weight, config.num_elem_per_storage, DataType(config.quantize_dtype).bits
)
zero_bin = decode_int_arr(
zeros, config.num_elem_per_storage, DataType(config.quantize_dtype).bits
)
scale_repeated = np.repeat(scale, config.group_size, axis=-1)
zero_bin_repeated = np.repeat(zero_bin, config.group_size, axis=-1)
return (weight_bin - zero_bin_repeated) * scale_repeated
@pytest.mark.parametrize(
"quant_name, shape, dtype",
[
("q4f16_awq", [2, 4096], "float16"),
],
)
def test_dequantize_weight(quant_name: str, shape: List[int], dtype: str): # noqa: UP006
class Test(nn.Module):
def __init__(self) -> None:
super().__init__()
self.linear = nn.Linear(shape[1], shape[0], bias=False, dtype=dtype)
def forward(self, x: nn.Tensor):
return self.linear(x)
config = QUANTIZATION[quant_name]
assert isinstance(config, AWQQuantize)
weight_np = np.random.randint(
np.iinfo(config.storage_dtype).min,
np.iinfo(config.storage_dtype).max,
(shape[0], shape[1] // config.num_elem_per_storage),
).astype(config.storage_dtype)
zeros_np = np.random.randint(
np.iinfo(config.storage_dtype).min,
np.iinfo(config.storage_dtype).max,
(shape[0], shape[1] // config.num_elem_per_storage // config.group_size),
).astype(config.storage_dtype)
scale_np = np.random.random((shape[0], shape[1] // config.group_size)).astype(
config.model_dtype
)
mod = config.quantize_model(Test(), QuantizeMapping({}, {}), "")
mod.linear.qweight.data = weight_np
mod.linear.qzeros.data = zeros_np
mod.linear.scales.data = scale_np
model = mod.jit(spec={"forward": {"x": nn.spec.Tensor((shape[1], shape[1]), dtype)}})
out = model["forward"](torch.from_numpy(np.diag(np.ones(shape[1]).astype(dtype))))
ref = dequantize_np(config, weight_np, zeros_np, scale_np).T
tvm.testing.assert_allclose(out, ref, rtol=1e-3, atol=1e-3)
if __name__ == "__main__":
test_dequantize_weight("q4f16_awq", [2, 4096], "float16")
@@ -0,0 +1,188 @@
from typing import List, Optional # noqa: UP035
import numpy as np
import pytest
import torch
import tvm
import tvm.testing
from tvm import DataType
from tvm.relax.frontend import nn
from mlc_llm.loader import QuantizeMapping
from mlc_llm.quantization import QUANTIZATION
from mlc_llm.quantization.group_quantization import (
GroupQuantize,
GroupQuantizeEmbedding,
GroupQuantizeLinear,
)
def quantize_np(config: GroupQuantize, weight: np.ndarray):
n, k = weight.shape
weight_padded = np.pad(
weight,
((0, 0), (0, (config.group_size - k % config.group_size) % config.group_size)),
)
n, k = weight_padded.shape
weight_reshaped = np.reshape(weight_padded, (n, k // config.group_size, config.group_size))
max_abs = np.maximum(np.max(np.abs(weight_reshaped), axis=-1), 1e-4)
scale = np.divide(max_abs, config.max_int_value)
scale_reshaped = np.reshape(scale, (*scale.shape, 1))
weight_scaled_reshaped = np.clip(
np.add(
np.round(np.divide(weight_reshaped, scale_reshaped)),
config.max_int_value,
),
0,
config.max_int_value * 2,
).astype(config.storage_dtype)
weight_filtered = np.reshape(weight_scaled_reshaped, (n, k))
weight_filtered[..., weight.shape[1] :] = 0
weight_scaled = np.reshape(
weight_filtered,
(n, k // config.num_elem_per_storage, config.num_elem_per_storage),
)
indice_k = np.indices(weight_scaled.shape, dtype=config.storage_dtype)[-1]
quantized_weight = np.sum(
np.left_shift(weight_scaled, indice_k * DataType(config.quantize_dtype).bits),
axis=-1,
dtype=config.storage_dtype,
)
return quantized_weight, scale
def dequantize_np(
config: GroupQuantize,
weight: np.ndarray,
scale: np.ndarray,
out_shape: Optional[List[int]] = None, # noqa: UP006
):
assert weight.shape[0] == scale.shape[0]
bin_mask = (1 << DataType(config.quantize_dtype).bits) - 1
max_int = config.max_int_value
out_shape = (
[weight.shape[0], weight.shape[1] * config.num_elem_per_storage]
if out_shape is None
else out_shape
)
weight_repeated = np.repeat(weight, config.num_elem_per_storage, axis=-1)
scale_repeated = np.repeat(scale, config.group_size, axis=-1)
indice_j = np.indices(weight_repeated.shape)[1]
weight_bin = np.bitwise_and(
np.right_shift(
weight_repeated,
(indice_j % config.num_elem_per_storage) * DataType(config.quantize_dtype).bits,
),
bin_mask,
)
assert weight_bin.shape[1] <= scale_repeated.shape[1]
return ((weight_bin - max_int) * scale_repeated[..., : weight_bin.shape[1]])[
: out_shape[0], : out_shape[1]
]
@pytest.mark.parametrize(
"quant_name, shape, dtype, device",
[
("q3f16_1", [2, 13], "float16", "cpu"),
("q3f16_1", [16, 120], "float16", "cpu"),
("q4f16_1", [2, 13], "float16", "cpu"),
("q4f16_1", [16, 128], "float16", "cpu"),
("q4f32_1", [2, 13], "float32", "cpu"),
("q4f32_1", [16, 128], "float32", "cpu"),
],
)
def test_quantize_weight(quant_name: str, shape: List[int], dtype: str, device: str): # noqa: UP006
config = QUANTIZATION[quant_name]
assert isinstance(config, GroupQuantize)
weight_np = np.random.random(shape).astype(dtype)
output = config.quantize_weight(tvm.runtime.tensor(weight_np, device=tvm.device(device)))
quantized_weight, scale = output[0].numpy(), output[1].numpy()
quantized_weight_ref, scale_ref = quantize_np(config, weight_np)
tvm.testing.assert_allclose(scale, scale_ref, rtol=1e-3, atol=1e-3)
tvm.testing.assert_allclose(
dequantize_np(config, quantized_weight, scale, shape),
dequantize_np(config, quantized_weight_ref, scale_ref, shape),
rtol=1e-2 if quant_name.startswith("q3") else 1e-3,
atol=0.4 if quant_name.startswith("q3") else 0.2,
)
@pytest.mark.parametrize(
"quant_name, shape, dtype",
[
("q3f16_1", [2, 13], "float16"),
("q3f16_1", [16, 120], "float16"),
("q4f16_1", [2, 13], "float16"),
("q4f16_1", [16, 128], "float16"),
("q4f32_1", [2, 13], "float32"),
("q4f32_1", [16, 128], "float32"),
],
)
def test_dequantize_weight(quant_name: str, shape: List[int], dtype: str): # noqa: UP006
class Test(nn.Module):
def __init__(self) -> None:
super().__init__()
self.linear = nn.Linear(shape[1], shape[0], bias=False, dtype=dtype)
def forward(self, x: nn.Tensor):
return self.linear(x)
config = QUANTIZATION[quant_name]
assert isinstance(config, GroupQuantize)
num_group = -(shape[1] // -config.group_size)
weight_np = np.random.randint(
np.iinfo(config.storage_dtype).min,
np.iinfo(config.storage_dtype).max,
(shape[0], config.num_storage_per_group * num_group),
).astype(config.storage_dtype)
scale_np = np.random.random((shape[0], num_group)).astype(config.model_dtype)
mod = config.quantize_model(Test(), QuantizeMapping({}, {}), "")
mod.linear.q_weight.data = weight_np
mod.linear.q_scale.data = scale_np
model = mod.jit(spec={"forward": {"x": nn.spec.Tensor((shape[1], shape[1]), dtype)}})
out = model["forward"](torch.from_numpy(np.diag(np.ones(shape[1]).astype(dtype))))
ref = dequantize_np(config, weight_np, scale_np, shape).T
tvm.testing.assert_allclose(out, ref, rtol=1e-3, atol=1e-3)
@pytest.mark.parametrize(
"quant_name, shape, dtype",
[
("q3f16_1", [16, 128], "float16"),
("q4f16_1", [16, 128], "float16"),
("q4f32_1", [16, 128], "float32"),
],
)
def test_quantize_model(quant_name: str, shape: List[int], dtype: str): # noqa: UP006
class Test(nn.Module):
def __init__(self) -> None:
super().__init__()
self.linear = nn.Linear(shape[0], shape[1], dtype=dtype)
self.embedding = nn.Embedding(shape[0], shape[1], dtype=dtype)
def forward(self, x: nn.Tensor):
return self.linear(x)
config = QUANTIZATION[quant_name]
assert isinstance(config, GroupQuantize)
quant_map = QuantizeMapping({}, {})
mod = config.quantize_model(Test(), quant_map, "model")
assert quant_map.param_map["model.linear.weight"] == [
"model.linear.q_weight",
"model.linear.q_scale",
]
assert quant_map.map_func["model.linear.weight"] == config.quantize_weight
assert isinstance(mod.linear, GroupQuantizeLinear)
assert quant_map.param_map["model.embedding.weight"] == [
"model.embedding.q_weight",
"model.embedding.q_scale",
]
assert quant_map.map_func["model.embedding.weight"] == config.quantize_weight
assert isinstance(mod.embedding, GroupQuantizeEmbedding)
if __name__ == "__main__":
test_quantize_weight("q4f16_1", [16, 128], "float16", "llvm")
test_quantize_model("q4f16_1", [16, 128], "float16")
test_dequantize_weight("q4f16_1", [16, 128], "float16")
+101
View File
@@ -0,0 +1,101 @@
import asyncio
from mlc_llm.protocol import openai_api_protocol
from mlc_llm.router import Router
model_tp1 = "./dist/Llama-3.2-1B-Instruct-q0f16-MLC/"
model_lib_tp1 = "./dist/lib/Llama-3.2-1B-q0f16-cuda.so"
# model_lib_tp1 = None
model_tp2 = "./dist/Llama-3.2-1B-Instruct-q0f16-MLC-tp2/"
model_lib_tp2 = "./dist/lib/Llama-3.2-1B-q0f16-cuda-tp2.so"
# model_lib_tp2 = None
def get_router_1tp1():
return (
Router(
model_tp1,
model_lib=model_lib_tp1,
hosts=["127.0.0.1"],
ports=[8080],
),
model_tp1,
)
def get_router_2tp1():
return (
Router(
model_tp1,
model_lib=model_lib_tp1,
hosts=["127.0.0.1", "127.0.0.1"],
ports=[8080, 8081],
device_id_starts=[0, 1],
npes=2,
),
model_tp1,
)
def get_router_1tp2():
return (
Router(
model_tp2,
model_lib=model_lib_tp2,
hosts=["127.0.0.1"],
ports=[8080],
npes=2,
),
model_tp2,
)
def get_router_2tp2():
return (
Router(
model_tp2,
model_lib=model_lib_tp2,
hosts=["127.0.0.1", "127.0.0.1"],
ports=[8080, 8081],
device_id_starts=[0, 2],
npes=4,
),
model_tp2,
)
CONFIG_TO_ROUTER = {
"1tp1": get_router_1tp1,
"2tp1": get_router_2tp1,
"1tp2": get_router_1tp2,
"2tp2": get_router_2tp2,
}
async def test_router(schedule: str = "round_robin", endpoints_config: str = "1tp1"):
router, model_id = CONFIG_TO_ROUTER[endpoints_config]()
request = openai_api_protocol.CompletionRequest(
prompt="The meaning of life ",
model=model_id,
stream=True,
max_tokens=64,
stream_options=openai_api_protocol.StreamOptions(include_usage=True),
)
if schedule == "round_robin":
async for chunk in router._handle_completion_round_robin(request, "1"):
print(chunk)
elif schedule == "disagg":
async for chunk in router._handle_completion_disagg(request, "1"):
print(chunk)
else:
raise ValueError(f"Unknown scheduling method: {schedule}")
router.terminate()
if __name__ == "__main__":
# asyncio.run(test_router("round_robin", endpoints_config="1tp1"))
# asyncio.run(test_router("round_robin", endpoints_config="1tp2"))
# asyncio.run(test_router("round_robin", endpoints_config="2tp1"))
asyncio.run(test_router("round_robin", endpoints_config="2tp2"))
+73
View File
@@ -0,0 +1,73 @@
import argparse
import os
import random
from typing import List, Tuple # noqa: UP035
from mlc_llm.protocol.generation_config import GenerationConfig
from mlc_llm.serve.sync_engine import EngineConfig, SyncMLCEngine
def _parse_args():
args = argparse.ArgumentParser()
args.add_argument("--model-lib", type=str)
args.add_argument("--device", type=str, default="auto")
args.add_argument("--batch-size", type=int, default=80)
args.add_argument("--max-total-seq-length", type=int)
args.add_argument("--seed", type=int, default=0)
parsed = args.parse_args()
parsed.model = os.path.dirname(parsed.model_lib)
assert parsed.batch_size % 16 == 0
return parsed
def generate_requests(
num_requests: int, input_length: int, output_length: int
) -> Tuple[List[List[int]], List[GenerationConfig]]: # noqa: UP006
prompt_ids = []
for _ in range(num_requests):
token_ids = []
for _ in range(input_length):
token_ids.append(random.randint(0, 30000))
prompt_ids.append(token_ids)
generation_config_list = [
GenerationConfig(temperature=1.0, top_p=1.0, max_tokens=output_length)
] * num_requests
return prompt_ids, generation_config_list
def benchmark(args: argparse.Namespace):
random.seed(args.seed)
# Create engine
engine = SyncMLCEngine(
model=args.model,
device=args.device,
model_lib=args.model_lib,
mode="server",
engine_config=EngineConfig(
max_num_sequence=args.batch_size,
max_total_sequence_length=args.max_total_seq_length,
),
)
print(args)
for num_requests in [1, 2, 4, 8, 16, 32, 64]:
if num_requests > args.batch_size:
continue
for input_length in [64, 128, 256, 512, 1024]:
if num_requests * input_length >= 16384:
continue
for output_length in [4]:
print(f"nreq={num_requests}\tin={input_length}\tout={output_length}")
prompt_ids, generation_config = generate_requests(
num_requests, input_length, output_length
)
engine.reset()
engine.generate(prompt_ids, generation_config)
print()
if __name__ == "__main__":
ARGS = _parse_args()
benchmark(ARGS)
+34
View File
@@ -0,0 +1,34 @@
import os
from typing import Tuple # noqa: UP035
import pytest
from mlc_llm.serve import PopenServer
@pytest.fixture(scope="session")
def served_model() -> Tuple[str, str]: # noqa: UP006
model_lib = os.environ.get("MLC_SERVE_MODEL_LIB")
if model_lib is None:
raise ValueError(
'Environment variable "MLC_SERVE_MODEL_LIB" not found. '
"Please set it to model lib compiled by MLC LLM "
"(e.g., `dist/Llama-2-7b-chat-hf-q0f16-MLC/Llama-2-7b-chat-hf-q0f16-MLC-cuda.so`)."
)
model = os.path.dirname(model_lib)
return model, model_lib
@pytest.fixture(scope="session")
def launch_server(served_model):
"""A pytest session-level fixture which launches the server in a subprocess."""
server = PopenServer(
model=served_model[0],
model_lib=served_model[1],
enable_tracing=True,
enable_debug=True,
port=8000,
)
with server:
yield
@@ -0,0 +1,369 @@
"""Embedding server endpoint tests in MLC LLM.
Tests the /v1/embeddings endpoint via HTTP using the OpenAI client,
following the same patterns as test_server.py.
Reuses MLC LLM test infrastructure:
- Pytest markers (endpoint)
- expect_error() response validation pattern from test_server.py
- OpenAI client usage pattern from test_server.py
- Session-scoped server fixture pattern from conftest.py
Run (launches its own embedding-only server):
MLC_SERVE_EMBEDDING_MODEL_LIB="path/to/model.dylib" \
pytest -m endpoint tests/python/serve/server/test_embedding_server.py -v
Environment variables:
MLC_SERVE_EMBEDDING_MODEL_LIB Path to compiled embedding model library (required)
MLC_SERVE_EMBEDDING_MODEL Path to embedding model weight directory
(optional, defaults to dirname of model lib)
"""
import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Dict, Optional # noqa: UP035
import numpy as np
import pytest
import requests
from openai import OpenAI
# Reuse MLC LLM marker system
pytestmark = [pytest.mark.endpoint]
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
EMBEDDING_MODEL_LIB = os.environ.get("MLC_SERVE_EMBEDDING_MODEL_LIB")
EMBEDDING_MODEL_DIR = os.environ.get(
"MLC_SERVE_EMBEDDING_MODEL",
os.path.dirname(EMBEDDING_MODEL_LIB) if EMBEDDING_MODEL_LIB else None,
)
EMBEDDING_SERVER_HOST = "127.0.0.1"
EMBEDDING_SERVER_PORT = 8321
EMBEDDING_BASE_URL = f"http://{EMBEDDING_SERVER_HOST}:{EMBEDDING_SERVER_PORT}/v1"
EMBEDDING_MODEL_NAME = "embedding"
def _skip_if_no_model():
if EMBEDDING_MODEL_LIB is None:
pytest.skip(
'Environment variable "MLC_SERVE_EMBEDDING_MODEL_LIB" not found. '
"Set it to a compiled embedding model library."
)
if not os.path.isfile(EMBEDDING_MODEL_LIB):
pytest.skip(f"Embedding model library not found at: {EMBEDDING_MODEL_LIB}")
if EMBEDDING_MODEL_DIR is None or not os.path.isdir(EMBEDDING_MODEL_DIR):
pytest.skip(f"Embedding model directory not found at: {EMBEDDING_MODEL_DIR}")
# ---------------------------------------------------------------------------
# Response validation helpers — adapted from test_server.py patterns
# ---------------------------------------------------------------------------
def check_embedding_response(
response: Dict, # noqa: UP006
*,
model: str,
num_embeddings: int,
expected_dim: Optional[int] = None,
check_unit_norm: bool = True,
):
"""Validate an OpenAI-compatible embedding response.
Adapted from check_openai_nonstream_response() in test_server.py,
specialized for embedding responses.
"""
assert response["object"] == "list"
assert response["model"] == model
data = response["data"]
assert isinstance(data, list)
assert len(data) == num_embeddings
for item in data:
assert item["object"] == "embedding"
assert isinstance(item["index"], int)
emb = item["embedding"]
assert isinstance(emb, list)
assert len(emb) > 0
if expected_dim is not None:
assert len(emb) == expected_dim, f"Expected dim={expected_dim}, got {len(emb)}"
if check_unit_norm:
norm = float(np.linalg.norm(emb))
assert abs(norm - 1.0) < 1e-3, f"Expected unit norm, got {norm}"
# Usage validation — same pattern as test_server.py
usage = response["usage"]
assert isinstance(usage, dict)
assert usage["prompt_tokens"] > 0
assert usage["total_tokens"] == usage["prompt_tokens"]
def expect_error(response_str: str, msg_prefix: Optional[str] = None):
"""Validate error response — reused directly from test_server.py."""
response = json.loads(response_str)
assert response["object"] == "error"
assert isinstance(response["message"], str)
if msg_prefix is not None:
assert response["message"].startswith(msg_prefix)
# ---------------------------------------------------------------------------
# Server fixture — follows PopenServer/launch_server pattern from conftest.py
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def launch_embedding_server():
"""Launch an embedding-only server as a subprocess.
Follows the same lifecycle pattern as the launch_server fixture
in serve/server/conftest.py, but uses a lightweight embedding-only
server since PopenServer doesn't support --embedding-model yet.
"""
_skip_if_no_model()
mlc_llm_path = str(Path(__file__).resolve().parents[4] / "python")
server_code = f"""
import sys
sys.path.insert(0, "{mlc_llm_path}")
import fastapi
import uvicorn
from mlc_llm.serve.embedding_engine import AsyncEmbeddingEngine
from mlc_llm.serve.server import ServerContext
from mlc_llm.serve.entrypoints import openai_entrypoints
app = fastapi.FastAPI()
app.include_router(openai_entrypoints.app)
engine = AsyncEmbeddingEngine(
model="{EMBEDDING_MODEL_DIR}",
model_lib="{EMBEDDING_MODEL_LIB}",
device="auto",
)
ctx = ServerContext()
ServerContext.server_context = ctx
ctx.add_embedding_engine("{EMBEDDING_MODEL_NAME}", engine)
uvicorn.run(app, host="{EMBEDDING_SERVER_HOST}", port={EMBEDDING_SERVER_PORT}, log_level="info")
"""
with subprocess.Popen(
[sys.executable, "-c", server_code],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
) as proc:
# Wait for server readiness — same polling pattern as PopenServer.start()
timeout = 120
attempts = 0.0
ready = False
while attempts < timeout:
try:
response = requests.get(f"{EMBEDDING_BASE_URL}/models", timeout=2)
if response.status_code == 200:
ready = True
break
except requests.RequestException:
pass
attempts += 0.5
time.sleep(0.5)
if not ready:
stderr = proc.stderr.read().decode() if proc.stderr else ""
proc.kill()
raise RuntimeError(f"Embedding server failed to start in {timeout}s.\nStderr: {stderr}")
yield proc
# Cleanup — same pattern as PopenServer.terminate()
proc.send_signal(signal.SIGINT)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def client(launch_embedding_server):
"""OpenAI client connected to the embedding server."""
assert launch_embedding_server is not None
return OpenAI(base_url=EMBEDDING_BASE_URL, api_key="none")
# ===================================================================
# /v1/models
# ===================================================================
@pytest.mark.usefixtures("client")
def test_models_endpoint():
"""The /v1/models endpoint lists the embedding model."""
resp = requests.get(f"{EMBEDDING_BASE_URL}/models", timeout=5)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data["data"], list)
# ===================================================================
# Single input
# ===================================================================
def test_single_string_input(client):
"""Single string input returns one embedding."""
resp = client.embeddings.create(input="What is machine learning?", model=EMBEDDING_MODEL_NAME)
raw = resp.model_dump()
check_embedding_response(raw, model=EMBEDDING_MODEL_NAME, num_embeddings=1)
# ===================================================================
# Batch input
# ===================================================================
BATCH_INPUTS = [
"What is machine learning?",
"How to brew coffee?",
"ML is a subset of AI.",
]
def test_batch_string_input(client):
"""List of strings returns one embedding per input."""
resp = client.embeddings.create(input=BATCH_INPUTS, model=EMBEDDING_MODEL_NAME)
raw = resp.model_dump()
check_embedding_response(raw, model=EMBEDDING_MODEL_NAME, num_embeddings=len(BATCH_INPUTS))
def test_batch_index_ordering(client):
"""Embedding indices are sequential."""
resp = client.embeddings.create(input=BATCH_INPUTS, model=EMBEDDING_MODEL_NAME)
indices = [d.index for d in resp.data]
assert indices == list(range(len(BATCH_INPUTS)))
# ===================================================================
# Cosine similarity — semantic quality via endpoint
# ===================================================================
def test_cosine_similarity_via_endpoint(client):
"""Related texts have higher similarity than unrelated (end-to-end)."""
resp = client.embeddings.create(
input=[
"What is machine learning?",
"Explain deep learning",
"Order a pizza",
],
model=EMBEDDING_MODEL_NAME,
)
e0, e1, e2 = [np.array(d.embedding) for d in resp.data]
sim_related = float(np.dot(e0, e1))
sim_unrelated = float(np.dot(e0, e2))
assert sim_related > sim_unrelated, (
f"Related ({sim_related:.4f}) should > unrelated ({sim_unrelated:.4f})"
)
# ===================================================================
# Dimension truncation (Matryoshka)
# ===================================================================
def test_dimension_truncation(client):
"""dimensions parameter truncates and re-normalizes output."""
target_dim = 256
resp = client.embeddings.create(
input="Hello world", model=EMBEDDING_MODEL_NAME, dimensions=target_dim
)
raw = resp.model_dump()
check_embedding_response(
raw,
model=EMBEDDING_MODEL_NAME,
num_embeddings=1,
expected_dim=target_dim,
)
# ===================================================================
# Encoding format
# ===================================================================
@pytest.mark.usefixtures("launch_embedding_server")
def test_base64_encoding():
"""base64 encoding format returns base64-encoded embeddings."""
resp = requests.post(
f"{EMBEDDING_BASE_URL}/embeddings",
json={
"input": "Hello world",
"model": EMBEDDING_MODEL_NAME,
"encoding_format": "base64",
},
timeout=5,
)
assert resp.status_code == 200
data = resp.json()
assert data["data"][0]["object"] == "embedding"
# base64 string should be a non-empty string (not a list)
emb = data["data"][0]["embedding"]
assert isinstance(emb, str) and len(emb) > 0
# ===================================================================
# Error handling — reuses expect_error() pattern from test_server.py
# ===================================================================
@pytest.mark.usefixtures("launch_embedding_server")
def test_any_model_name_works_with_single_engine():
"""When only one embedding engine is served, any model name works.
This mirrors ServerContext.get_engine() behavior: a single served
model is returned regardless of the requested model name.
"""
resp = requests.post(
f"{EMBEDDING_BASE_URL}/embeddings",
json={"input": "test", "model": "any-name-works"},
timeout=5,
)
assert resp.status_code == 200
data = resp.json()
assert len(data["data"]) == 1
# ===================================================================
# Standalone runner (same pattern as test_server.py __main__)
# ===================================================================
if __name__ == "__main__":
_skip_if_no_model()
print(f"Using model: {EMBEDDING_MODEL_DIR}")
print(f"Using model lib: {EMBEDDING_MODEL_LIB}")
print(f"Server URL: {EMBEDDING_BASE_URL}")
print(
"\nMake sure the embedding server is running, or set env vars "
"and use pytest to auto-launch."
)
# Allow running against an already-running server
c = OpenAI(base_url=EMBEDDING_BASE_URL, api_key="none")
test_models_endpoint()
test_single_string_input(c)
test_batch_string_input(c)
test_batch_index_ordering(c)
test_cosine_similarity_via_endpoint(c)
test_dimension_truncation(c)
test_base64_encoding()
test_any_model_name_works_with_single_engine()
print("\nAll embedding server tests passed!")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,208 @@
"""
Test script for function call in chat completion. To run this script, use the following command:
MLC_SERVE_MODEL_LIB=dist/gorilla-openfunctions-v1-q4f16_1_MLC/gorilla-openfunctions-v1-q4f16_1-cuda.so
MLC_SERVE_MODEL_LIB=${MLC_SERVE_MODEL_LIB} python -m pytest -x tests/python/serve/server/test_server_function_call.py
""" # noqa: E501
import json
import os
from typing import Dict, List, Optional, Tuple # noqa: UP035
import pytest
import requests
OPENAI_V1_CHAT_COMPLETION_URL = "http://127.0.0.1:8000/v1/chat/completions"
def check_openai_nonstream_response(
response: Dict, # noqa: UP006
*,
model: str,
object_str: str,
num_choices: int,
finish_reason: List[str], # noqa: UP006
completion_tokens: Optional[int] = None,
):
print(response)
assert response["model"] == model
assert response["object"] == object_str
choices = response["choices"]
assert isinstance(choices, list)
assert len(choices) == num_choices
for idx, choice in enumerate(choices):
assert choice["index"] == idx
assert choice["finish_reason"] in finish_reason
# text: str
message = choice["message"]
assert message["role"] == "assistant"
if choice["finish_reason"] == "tool_calls":
assert message["content"] is None
assert isinstance(message["tool_calls"], list)
else:
assert message["tool_calls"] is None
assert message["content"] is not None
usage = response["usage"]
assert isinstance(usage, dict)
assert usage["total_tokens"] == usage["prompt_tokens"] + usage["completion_tokens"]
assert usage["prompt_tokens"] > 0
if completion_tokens is not None:
assert usage["completion_tokens"] == completion_tokens
def check_openai_stream_response(
responses: List[Dict], # noqa: UP006
*,
model: str,
object_str: str,
num_choices: int,
finish_reason: str,
echo_prompt: Optional[str] = None,
suffix: Optional[str] = None,
stop: Optional[List[str]] = None, # noqa: UP006
require_substr: Optional[List[str]] = None, # noqa: UP006
):
assert len(responses) > 0
finished = [False for _ in range(num_choices)]
outputs = ["" for _ in range(num_choices)]
for response in responses:
assert response["model"] == model
assert response["object"] == object_str
choices = response["choices"]
assert isinstance(choices, list)
assert len(choices) == num_choices
for idx, choice in enumerate(choices):
assert choice["index"] == idx
delta = choice["delta"]
assert delta["role"] == "assistant"
assert isinstance(delta["content"], str)
outputs[idx] += delta["content"]
if finished[idx]:
assert choice["finish_reason"] == finish_reason
elif choice["finish_reason"] is not None:
assert choice["finish_reason"] == finish_reason
finished[idx] = True
for output in outputs:
if echo_prompt is not None:
assert output.startswith(echo_prompt)
if suffix is not None:
assert output.endswith(suffix)
if stop is not None:
for stop_str in stop:
assert stop_str not in output
if require_substr is not None:
for substr in require_substr:
assert substr in output
tools = [
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
},
},
}
]
CHAT_COMPLETION_MESSAGES = [
# messages #0
[
{
"role": "user",
"content": "What is the current weather in Pittsburgh, PA?",
}
],
# messages #1
[
{
"role": "user",
"content": "What is the current weather in Pittsburgh, PA and Tokyo, JP?",
}
],
# messages #2
[
{
"role": "user",
"content": "What is the current weather in Pittsburgh, PA in fahrenheit?",
}
],
]
@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.parametrize("messages", CHAT_COMPLETION_MESSAGES)
def test_openai_v1_chat_completion_function_call(
served_model: Tuple[str, str], # noqa: UP006
launch_server,
stream: bool,
messages: List[Dict[str, str]], # noqa: UP006
):
# `served_model` and `launch_server` are pytest fixtures
# defined in conftest.py.
payload = {
"model": served_model[0],
"messages": messages,
"stream": stream,
"tools": tools,
}
response = requests.post(OPENAI_V1_CHAT_COMPLETION_URL, json=payload, timeout=60)
if not stream:
check_openai_nonstream_response(
response.json(),
model=served_model[0],
object_str="chat.completion",
num_choices=1,
finish_reason=["tool_calls", "error"],
)
else:
responses = []
for chunk in response.iter_lines(chunk_size=512):
if not chunk or chunk == b"data: [DONE]":
continue
responses.append(json.loads(chunk.decode("utf-8")[6:]))
check_openai_stream_response(
responses,
model=served_model[0],
object_str="chat.completion.chunk",
num_choices=1,
finish_reason="tool_calls",
)
if __name__ == "__main__":
model_lib = os.environ.get("MLC_SERVE_MODEL_LIB")
if model_lib is None:
raise ValueError(
'Environment variable "MLC_SERVE_MODEL_LIB" not found. '
"Please set it to model lib compiled by MLC LLM "
"(e.g., `./dist/gorilla-openfunctions-v1-q4f16_1_MLC/gorilla-openfunctions-v1-q4f16_1-cuda.so`) " # noqa: E501
"which supports function calls."
)
MODEL = (os.path.dirname(model_lib), model_lib)
for msg in CHAT_COMPLETION_MESSAGES:
test_openai_v1_chat_completion_function_call(MODEL, None, stream=False, messages=msg)
test_openai_v1_chat_completion_function_call(MODEL, None, stream=True, messages=msg)
@@ -0,0 +1,257 @@
import json
import os
from typing import Dict, List, Optional, Tuple # noqa: UP035
import pytest
import regex
import requests
OPENAI_V1_CHAT_COMPLETION_URL = "http://127.0.0.1:8001/v1/chat/completions"
JSON_TOKEN_PATTERN = (
r"((-?(?:0|[1-9]\d*))(\.\d+)?([eE][-+]?\d+)?)|null|true|false|"
r'("((\\["\\\/bfnrt])|(\\u[0-9a-fA-F]{4})|[^"\\\x00-\x1f])*")'
)
JSON_TOKEN_RE = regex.compile(JSON_TOKEN_PATTERN)
def is_json_or_json_prefix(s: str) -> bool:
try:
json.loads(s)
return True
except json.JSONDecodeError as e:
# If the JSON decoder reaches the end of s, it is a prefix of a JSON string.
if e.pos == len(s):
return True
# Since json.loads is token-based instead of char-based, there may remain half a token after
# the matching position.
# If the left part is a prefix of a valid JSON token, the output is also valid
regex_match = JSON_TOKEN_RE.fullmatch(s[e.pos :], partial=True)
return regex_match is not None
def check_openai_nonstream_response(
response: Dict, # noqa: UP006
*,
is_chat_completion: bool,
model: str,
object_str: str,
num_choices: int,
finish_reasons: List[str], # noqa: UP006
completion_tokens: Optional[int] = None,
echo_prompt: Optional[str] = None,
suffix: Optional[str] = None,
stop: Optional[List[str]] = None, # noqa: UP006
require_substr: Optional[List[str]] = None, # noqa: UP006
json_mode: bool = False,
):
assert response["model"] == model
assert response["object"] == object_str
choices = response["choices"]
assert isinstance(choices, list)
assert len(choices) <= num_choices
texts: List[str] = ["" for _ in range(num_choices)] # noqa: UP006
for choice in choices:
idx = choice["index"]
assert choice["finish_reason"] in finish_reasons
if not is_chat_completion:
assert isinstance(choice["text"], str)
texts[idx] = choice["text"]
if echo_prompt is not None:
assert texts[idx]
if suffix is not None:
assert texts[idx]
else:
message = choice["message"]
assert message["role"] == "assistant"
assert isinstance(message["content"], str)
texts[idx] = message["content"]
if stop is not None:
for stop_str in stop:
assert stop_str not in texts[idx]
if require_substr is not None:
for substr in require_substr:
assert substr in texts[idx]
if json_mode:
assert is_json_or_json_prefix(texts[idx])
usage = response["usage"]
assert isinstance(usage, dict)
assert usage["total_tokens"] == usage["prompt_tokens"] + usage["completion_tokens"]
assert usage["prompt_tokens"] > 0
if completion_tokens is not None:
assert usage["completion_tokens"] == completion_tokens
def check_openai_stream_response(
responses: List[Dict], # noqa: UP006
*,
is_chat_completion: bool,
model: str,
object_str: str,
num_choices: int,
finish_reasons: List[str], # noqa: UP006
completion_tokens: Optional[int] = None,
echo_prompt: Optional[str] = None,
suffix: Optional[str] = None,
stop: Optional[List[str]] = None, # noqa: UP006
require_substr: Optional[List[str]] = None, # noqa: UP006
json_mode: bool = False,
):
assert len(responses) > 0
finished = [False for _ in range(num_choices)]
outputs = ["" for _ in range(num_choices)]
for response in responses:
assert response["model"] == model
assert response["object"] == object_str
choices = response["choices"]
assert isinstance(choices, list)
assert len(choices) <= num_choices
for choice in choices:
idx = choice["index"]
if not is_chat_completion:
assert isinstance(choice["text"], str)
outputs[idx] += choice["text"]
else:
delta = choice["delta"]
assert delta["role"] == "assistant"
assert isinstance(delta["content"], str)
outputs[idx] += delta["content"]
if finished[idx]:
assert choice["finish_reason"] in finish_reasons
elif choice["finish_reason"] is not None:
assert choice["finish_reason"] in finish_reasons
finished[idx] = True
if not is_chat_completion:
usage = response["usage"]
assert isinstance(usage, dict)
assert usage["total_tokens"] == usage["prompt_tokens"] + usage["completion_tokens"]
assert usage["prompt_tokens"] > 0
if completion_tokens is not None:
assert usage["completion_tokens"] <= completion_tokens
if not is_chat_completion:
if completion_tokens is not None:
assert responses[-1]["usage"]["completion_tokens"] == completion_tokens
for i, output in enumerate(outputs):
if echo_prompt is not None:
assert output.startswith(echo_prompt)
if suffix is not None:
assert output.endswith(suffix)
if stop is not None:
for stop_str in stop:
assert stop_str not in output
if require_substr is not None:
for substr in require_substr:
assert substr in output
if json_mode:
assert is_json_or_json_prefix(output)
CHAT_COMPLETION_MESSAGES = [
# messages #0
[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": "https://llava-vl.github.io/static/images/view.jpg",
},
{"type": "text", "text": "What does this image represent?"},
],
},
],
# messages #1
[
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": "https://llava-vl.github.io/static/images/view.jpg",
},
{"type": "text", "text": "What does this image represent?"},
],
},
{
"role": "assistant",
"content": "The image represents a serene and peaceful scene of a pier extending over a body of water, such as a lake or a river.er. The pier is made of wood and has a bench on it, providing a place for people to sit and enjoy the view. The pier is situated in a natural environment, surrounded by trees and mountains in the background. This setting creates a tranquil atmosphere, inviting visitors to relax and appreciate the beauty of the landscape.", # noqa: E501
},
{
"role": "user",
"content": "What country is the image set in? Give me 10 ranked guesses and reasons why.", # noqa: E501
},
],
]
@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.parametrize("messages", CHAT_COMPLETION_MESSAGES)
def test_openai_v1_chat_completions(
served_model: Tuple[str, str], # noqa: UP006
launch_server,
stream: bool,
messages: List[Dict[str, str]], # noqa: UP006
):
# `served_model` and `launch_server` are pytest fixtures
# defined in conftest.py.
payload = {
"model": served_model[0],
"messages": messages,
"stream": stream,
}
response = requests.post(OPENAI_V1_CHAT_COMPLETION_URL, json=payload, timeout=180)
if not stream:
check_openai_nonstream_response(
response.json(),
is_chat_completion=True,
model=served_model[0],
object_str="chat.completion",
num_choices=1,
finish_reasons=["stop"],
)
else:
responses = []
for chunk in response.iter_lines(chunk_size=512):
if not chunk or chunk == b"data: [DONE]":
continue
responses.append(json.loads(chunk.decode("utf-8")[6:]))
check_openai_stream_response(
responses,
is_chat_completion=True,
model=served_model[0],
object_str="chat.completion.chunk",
num_choices=1,
finish_reasons=["stop"],
)
if __name__ == "__main__":
model_lib = os.environ.get("MLC_SERVE_MODEL_LIB")
if model_lib is None:
raise ValueError(
'Environment variable "MLC_SERVE_MODEL_LIB" not found. '
"Please set it to model lib compiled by MLC LLM "
"(e.g., `dist/Llama-2-7b-chat-hf-q0f16-MLC/Llama-2-7b-chat-hf-q0f16-MLC-cuda.so`)."
)
model = os.environ.get("MLC_SERVE_MODEL")
if model is None:
MODEL = (os.path.dirname(model_lib), model_lib)
else:
MODEL = (model, model_lib)
for msg in CHAT_COMPLETION_MESSAGES:
test_openai_v1_chat_completions(MODEL, None, stream=False, messages=msg)
test_openai_v1_chat_completions(MODEL, None, stream=True, messages=msg)
+365
View File
@@ -0,0 +1,365 @@
"""Embedding engine tests in MLC LLM.
Tests AsyncEmbeddingEngine for both direct (sync) and async embedding inference.
Reuses MLC LLM test infrastructure: markers, require_test_model pattern,
and conventions from test_serve_engine.py.
Run with real model (requires GPU + compiled embedding model):
MLC_SERVE_EMBEDDING_MODEL_LIB="path/to/model.dylib" \
pytest -m engine tests/python/serve/test_embedding_engine.py -v
Environment variables:
MLC_SERVE_EMBEDDING_MODEL_LIB Path to compiled embedding model library (required)
MLC_SERVE_EMBEDDING_MODEL Path to embedding model weight directory
(optional, defaults to dirname of model lib)
"""
import asyncio
import os
import numpy as np
import pytest
# Reuse MLC LLM marker system (registered in tests/python/conftest.py)
pytestmark = [pytest.mark.engine]
# ---------------------------------------------------------------------------
# Fixtures — follows pattern from serve/server/conftest.py (served_model)
# ---------------------------------------------------------------------------
EMBEDDING_MODEL_LIB = os.environ.get("MLC_SERVE_EMBEDDING_MODEL_LIB")
EMBEDDING_MODEL_DIR = os.environ.get(
"MLC_SERVE_EMBEDDING_MODEL",
os.path.dirname(EMBEDDING_MODEL_LIB) if EMBEDDING_MODEL_LIB else None,
)
def _skip_if_no_model():
if EMBEDDING_MODEL_LIB is None:
pytest.skip(
'Environment variable "MLC_SERVE_EMBEDDING_MODEL_LIB" not found. '
"Set it to a compiled embedding model library "
"(e.g., Qwen3-Embedding-0.6B-q0f32-MLC.dylib)."
)
if not os.path.isfile(EMBEDDING_MODEL_LIB):
pytest.skip(f"Embedding model library not found at: {EMBEDDING_MODEL_LIB}")
if EMBEDDING_MODEL_DIR is None or not os.path.isdir(EMBEDDING_MODEL_DIR):
pytest.skip(f"Embedding model directory not found at: {EMBEDDING_MODEL_DIR}")
@pytest.fixture(scope="module")
def embedding_engine():
"""Module-scoped AsyncEmbeddingEngine — loaded once, shared across tests."""
_skip_if_no_model()
from mlc_llm.serve.embedding_engine import AsyncEmbeddingEngine
engine = AsyncEmbeddingEngine(
model=EMBEDDING_MODEL_DIR,
model_lib=EMBEDDING_MODEL_LIB,
device="auto",
)
yield engine
engine.terminate()
# ---------------------------------------------------------------------------
# Helpers — reuse cosine_similarity pattern from test_serve_engine.py
# ---------------------------------------------------------------------------
def cosine_similarity(a, b):
"""Return cosine similarity between two vectors."""
a, b = np.array(a), np.array(b)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
# ===================================================================
# Engine initialization tests
# ===================================================================
def test_engine_model_type(embedding_engine):
"""Engine reports a valid model type."""
assert embedding_engine.model_type in ("encoder", "decoder")
def test_engine_pooling_strategy(embedding_engine):
"""Engine selects appropriate default pooling strategy."""
if embedding_engine.model_type == "encoder":
assert embedding_engine.pooling_strategy == "cls"
else:
assert embedding_engine.pooling_strategy == "last"
# ===================================================================
# Single-text embedding
# ===================================================================
def test_single_text_shape(embedding_engine):
"""Single text returns exactly one embedding vector."""
embeddings, tokens = embedding_engine.embed(["Hello world"])
assert len(embeddings) == 1
assert len(embeddings[0]) > 0
assert tokens > 0
def test_single_text_unit_norm(embedding_engine):
"""Embedding output is L2-normalized."""
embeddings, _ = embedding_engine.embed(["Hello world"])
norm = float(np.linalg.norm(embeddings[0]))
assert abs(norm - 1.0) < 1e-4, f"Expected unit norm, got {norm}"
# ===================================================================
# Batch embedding
# ===================================================================
BATCH_TEXTS = [
"Machine learning is fascinating",
"I love pizza",
"Deep learning uses neural networks",
]
def test_batch_count(embedding_engine):
"""Batch embedding returns one vector per input."""
embeddings, tokens = embedding_engine.embed(BATCH_TEXTS)
assert len(embeddings) == len(BATCH_TEXTS)
assert tokens > 0
def test_batch_all_normalized(embedding_engine):
"""Every vector in a batch is L2-normalized."""
embeddings, _ = embedding_engine.embed(BATCH_TEXTS)
for i, emb in enumerate(embeddings):
norm = float(np.linalg.norm(emb))
assert abs(norm - 1.0) < 1e-4, f"Embedding [{i}] norm={norm}"
def test_batch_consistent_dimension(embedding_engine):
"""All embeddings in a batch have the same dimension."""
embeddings, _ = embedding_engine.embed(BATCH_TEXTS)
dims = {len(emb) for emb in embeddings}
assert len(dims) == 1, f"Inconsistent dimensions: {dims}"
# ===================================================================
# Semantic quality — cosine similarity ranking
# ===================================================================
SIMILARITY_TEXTS = [
"What is machine learning?",
"Explain deep learning algorithms",
"I want to order pizza",
]
def test_cosine_similarity_ranking(embedding_engine):
"""Related texts have higher cosine similarity than unrelated texts."""
embeddings, _ = embedding_engine.embed(SIMILARITY_TEXTS)
e_ml, e_dl, e_pizza = [np.array(e) for e in embeddings]
sim_related = float(np.dot(e_ml, e_dl))
sim_unrelated = float(np.dot(e_ml, e_pizza))
assert sim_related > sim_unrelated, (
f"Related sim ({sim_related:.4f}) should > unrelated sim ({sim_unrelated:.4f})"
)
# ===================================================================
# Determinism
# ===================================================================
def test_deterministic_output(embedding_engine):
"""Same input produces identical output across calls."""
text = ["Deterministic test"]
emb1, _ = embedding_engine.embed(text)
emb2, _ = embedding_engine.embed(text)
cos = cosine_similarity(emb1[0], emb2[0])
assert cos > 0.9999, f"Expected deterministic output, cosine={cos}"
# ===================================================================
# Async embedding
# ===================================================================
def test_async_embed(embedding_engine):
"""async_embed produces same result as sync embed."""
text = ["Async test"]
sync_emb, sync_tokens = embedding_engine.embed(text)
loop = asyncio.new_event_loop()
try:
async_emb, async_tokens = loop.run_until_complete(embedding_engine.async_embed(text))
finally:
loop.close()
assert sync_tokens == async_tokens
cos = cosine_similarity(sync_emb[0], async_emb[0])
assert cos > 0.9999, f"Async vs sync mismatch, cosine={cos}"
# ===================================================================
# Edge cases
# ===================================================================
def test_empty_string(embedding_engine):
"""Empty string should still produce a valid embedding for supported models."""
embeddings, tokens = embedding_engine.embed([""])
if embedding_engine.model_type == "encoder":
assert len(embeddings) == 1
assert len(embeddings[0]) > 0
assert tokens > 0
else:
assert len(embeddings) == 1
assert len(embeddings[0]) > 0
assert tokens > 0
# ===================================================================
# Long text handling (model-type dependent)
# ===================================================================
def test_long_text_decoder_chunked_prefill(embedding_engine):
"""[Decoder only] Text >prefill_chunk_size triggers chunked prefill.
~5000 tokens processed in 3 chunks. Result is unit-norm embedding."""
if embedding_engine.model_type != "decoder":
pytest.skip("Chunked prefill is decoder-only")
long_text = "word " * 5000
embeddings, tokens = embedding_engine.embed([long_text])
assert tokens > 2048, f"Expected >2048 tokens to trigger chunking, got {tokens}"
norm = float(np.linalg.norm(embeddings[0]))
assert abs(norm - 1.0) < 1e-3
def _get_encoder_tokens(embedding_engine, text):
"""Replicate encoder preprocessing: tokenize and add [CLS]/[SEP]."""
tokens = list(embedding_engine.tokenizer.encode(text))
if embedding_engine._cls_token_id is not None and (
len(tokens) == 0 or tokens[0] != embedding_engine._cls_token_id
):
tokens = [embedding_engine._cls_token_id, *tokens]
if embedding_engine._sep_token_id is not None and (
len(tokens) == 0 or tokens[-1] != embedding_engine._sep_token_id
):
tokens = [*tokens, embedding_engine._sep_token_id]
return tokens
def test_long_text_encoder_truncation(embedding_engine):
"""[Encoder only] Text exceeding prefill_chunk_size is truncated.
Two texts with the same shared prefix but different suffixes beyond the
limit should produce identical embeddings, since the suffix is truncated
and the retained token prefixes are verified to be identical."""
if embedding_engine.model_type != "encoder":
pytest.skip("Truncation test is encoder-only")
prefill_chunk = embedding_engine._metadata.get("prefill_chunk_size", 512)
# Dynamically construct input that exceeds prefill_chunk_size.
unit = "machine learning is great "
suffix_a = " alpha beta gamma " * 200
suffix_b = " totally different ending " * 200
unit_tokens = len(list(embedding_engine.tokenizer.encode(unit)))
repeats = max(1, prefill_chunk // max(unit_tokens, 1) + 64)
# Increase prefix length until both inputs exceed prefill_chunk_size
# and their truncated token prefixes are identical.
while True:
shared_prefix = unit * repeats
full_tokens_a = _get_encoder_tokens(embedding_engine, shared_prefix + suffix_a)
full_tokens_b = _get_encoder_tokens(embedding_engine, shared_prefix + suffix_b)
if (
len(full_tokens_a) > prefill_chunk
and len(full_tokens_b) > prefill_chunk
and full_tokens_a[:prefill_chunk] == full_tokens_b[:prefill_chunk]
):
break
repeats += 64
assert repeats < 200000, "Failed to construct truncation test inputs"
text_a = shared_prefix + suffix_a
text_b = shared_prefix + suffix_b
emb_a, tokens_a = embedding_engine.embed([text_a])
emb_b, tokens_b = embedding_engine.embed([text_b])
# Verify truncation happened
assert tokens_a <= prefill_chunk, (
f"Encoder should truncate to {prefill_chunk}, got {tokens_a} tokens"
)
assert tokens_b <= prefill_chunk
# Both should be valid unit-norm embeddings
assert abs(float(np.linalg.norm(emb_a[0])) - 1.0) < 1e-3
assert abs(float(np.linalg.norm(emb_b[0])) - 1.0) < 1e-3
# Both truncated to identical token sequences → embeddings must match
cos = cosine_similarity(emb_a[0], emb_b[0])
assert cos > 0.999, f"Same truncated tokens should match, cosine={cos:.6f}"
def test_long_vs_short_semantic_quality(embedding_engine):
"""Long text should still capture semantic meaning correctly.
Decoder: chunked prefill preserves full context.
Encoder: truncation keeps most relevant prefix."""
short_ml = "Machine learning enables systems to learn from data"
long_ml = (
"Machine learning is a fascinating field of study. " * 200
+ "It enables systems to learn from data."
)
pizza = "I want to order a pepperoni pizza for dinner"
embs, _ = embedding_engine.embed([short_ml, long_ml, pizza])
e_short, e_long, e_pizza = [np.array(e) for e in embs]
sim_same_topic = float(np.dot(e_short, e_long))
sim_different = float(np.dot(e_short, e_pizza))
assert sim_same_topic > sim_different, (
f"Same topic ({sim_same_topic:.4f}) should > different ({sim_different:.4f})"
)
def test_unicode_text(embedding_engine):
"""Unicode input is handled correctly."""
texts = ["Привет мир", "你好世界", "こんにちは世界"]
embeddings, _ = embedding_engine.embed(texts)
assert len(embeddings) == 3
for emb in embeddings:
assert abs(float(np.linalg.norm(emb)) - 1.0) < 1e-4
# ===================================================================
# Standalone runner (like test_serve_engine.py)
# ===================================================================
if __name__ == "__main__":
_skip_if_no_model()
from mlc_llm.serve.embedding_engine import AsyncEmbeddingEngine
engine = AsyncEmbeddingEngine(
model=EMBEDDING_MODEL_DIR,
model_lib=EMBEDDING_MODEL_LIB,
device="auto",
)
try:
test_engine_model_type(engine)
test_engine_pooling_strategy(engine)
test_single_text_shape(engine)
test_single_text_unit_norm(engine)
test_batch_count(engine)
test_batch_all_normalized(engine)
test_batch_consistent_dimension(engine)
test_cosine_similarity_ranking(engine)
test_deterministic_output(engine)
test_async_embed(engine)
test_empty_string(engine)
test_long_text_decoder_chunked_prefill(engine)
test_long_text_encoder_truncation(engine)
test_long_vs_short_semantic_quality(engine)
test_unicode_text(engine)
print("\nAll embedding engine tests passed!")
finally:
engine.terminate()
@@ -0,0 +1,48 @@
import json
import pytest
from mlc_llm.serve.event_trace_recorder import EventTraceRecorder
# test category "unittest"
pytestmark = [pytest.mark.unittest]
def test_event_trace_recorder():
trace_recorder = EventTraceRecorder()
request_ids = ["x", "y"]
num_decode = 5
for request_id in request_ids:
trace_recorder.add_event(request_id, event="start tokenization")
trace_recorder.add_event(request_id, event="finish tokenization")
trace_recorder.add_event(request_id, event="add request")
trace_recorder.add_event(request_id, event="start embed")
trace_recorder.add_event(request_id, event="finish embed")
trace_recorder.add_event(request_id, event="start prefill")
trace_recorder.add_event(request_id, event="finish prefill")
for _ in range(num_decode):
for request_id in request_ids:
trace_recorder.add_event(request_id, event="start decode")
trace_recorder.add_event(request_id, event="finish decode")
for request_id in request_ids:
trace_recorder.add_event(request_id, event="start detokenization")
trace_recorder.add_event(request_id, event="finish detokenization")
events = json.loads(trace_recorder.dump_json())
decode_count = {}
for event in events:
request_id = event["tid"]
if event["name"].startswith("decode"):
if request_id not in decode_count:
decode_count[request_id] = 1
else:
decode_count[request_id] += 1
for _, decode_cnt in decode_count.items():
assert decode_cnt == num_decode * 2, decode_cnt
if __name__ == "__main__":
test_event_trace_recorder()
+138
View File
@@ -0,0 +1,138 @@
import pytest
from mlc_llm.serve import PagedRadixTree
# category "runtime_module"
pytestmark = [pytest.mark.unittest]
def test_add():
prt = PagedRadixTree()
prt.add(0)
assert list(prt.get(0)) == []
prt.add(1)
assert list(prt.get(1)) == []
def test_remove():
prt = PagedRadixTree()
capacity = prt.free_capacity()
prt.add(0)
prt.remove(0)
prt.add(0)
prt.extend(0, [1 for _ in range(200)])
prt.remove(0)
assert prt.free_capacity() == capacity
prt.add(1)
prt.extend(1, [1 for _ in range(200)])
capacity = prt.free_capacity()
prt.add(2)
prt.extend(2, [1 for _ in range(100)] + [2 for _ in range(100)])
prt.remove(2)
assert prt.free_capacity() == capacity
prt.add(3)
prt.extend(3, [1 for _ in range(200)])
prt.remove(3)
assert prt.free_capacity() == capacity
prt.add(4)
prt.add(5)
prt.add(6)
assert prt.free_capacity() == capacity
prt.remove(4)
assert prt.free_capacity() == capacity
prt.remove(5)
assert prt.free_capacity() == capacity
prt.remove(6)
assert prt.free_capacity() == capacity
def test_extend():
prt = PagedRadixTree()
L = prt.free_capacity() // 64
H = L // 2
Q = L // 4
seq_id = 0
for start_pos in [0, H, L, L + H]:
for length in [Q, L - H, L, 2 * L - H, 2 * L]:
prt.add(seq_id)
if start_pos:
tokens_1 = [seq_id for _ in range(start_pos)]
prt.extend(seq_id, tokens_1)
assert list(prt.get(seq_id)) == tokens_1
else:
tokens_1 = []
tokens_2 = [seq_id for _ in range(length)]
prt.extend(seq_id, tokens_2)
assert list(prt.get(seq_id)) == tokens_1 + tokens_2
seq_id += 1
def test_fork():
prt = PagedRadixTree()
L = prt.free_capacity() // 64
H = L // 2
Q = L // 4
seq_id = 0
length_list = [Q, H, L, L + Q, L + H, L * 2]
for p_idx in range(1, len(length_list)):
for c_idx in range(0, p_idx + 1):
prt.add(seq_id)
tokens = [seq_id for _ in range(length_list[p_idx])]
prt.extend(seq_id, tokens)
prt.fork(seq_id + 1, seq_id, length_list[c_idx])
assert list(prt.get(seq_id + 1)) == tokens[: length_list[c_idx]]
seq_id += 2
def test_fork_2():
prt = PagedRadixTree()
prt.add(0)
prt.extend(0, [0, 1, 2, 3])
prt.fork(1, 0, 3)
prt.extend(1, [4])
prt.fork(2, 0, 3)
prt.extend(2, [5])
assert prt.match([0, 1, 2, 4]) == (4, (1,))
assert prt.match([0, 1, 2, 5]) == (4, (2,))
def test_rollback():
prt = PagedRadixTree()
L = prt.free_capacity() // 64
H = L // 2
Q = L // 4
seq_id = 0
for start_pos in [H, L, L + H, 2 * L, 3 * L + H]:
for length in [Q, H, L + Q, 2 * L, 2 * L + Q]:
if length > start_pos:
continue
prt.add(seq_id)
tokens = [seq_id for _ in range(start_pos)]
prt.extend(seq_id, tokens)
prt.rollback(seq_id, length)
assert list(prt.get(seq_id)) == tokens[:-length]
seq_id += 1
for start_pos in [H, L, L + H, 2 * L, 3 * L + H]:
for length in [Q, H, L + Q, 2 * L, 2 * L + Q]:
if length > start_pos:
continue
prt.add(seq_id)
tokens = [seq_id for _ in range(start_pos)]
prt.extend(seq_id, tokens)
prt.fork(seq_id + 1, seq_id, start_pos)
prt.rollback(seq_id + 1, length)
assert list(prt.get(seq_id + 1)) == tokens[:-length]
seq_id += 2
if __name__ == "__main__":
test_add()
test_remove()
test_extend()
test_fork()
test_fork_2()
test_rollback()
@@ -0,0 +1,285 @@
import asyncio
from typing import List # noqa: UP035
from mlc_llm.protocol.generation_config import GenerationConfig
from mlc_llm.serve import AsyncMLCEngine, EngineConfig
from mlc_llm.testing import require_test_model
prompts = [
"What is the meaning of life?",
"Introduce the history of Pittsburgh to me. Please elaborate in detail.",
"Write a three-day Seattle travel plan. Please elaborate in detail.",
"What is Alaska famous of? Please elaborate in detail.",
"What is the difference between Lambda calculus and Turing machine? Please elaborate in detail.", # noqa: E501
"What are the necessary components to assemble a desktop computer? Please elaborate in detail.",
"Why is Vitamin D important to human beings? Please elaborate in detail.",
"Where is milk tea originated from? Please elaborate in detail.",
"Where is the southernmost place in United States? Please elaborate in detail.",
"Do you know AlphaGo? What capabilities does it have, and what achievements has it got? Please elaborate in detail.", # noqa: E501
]
@require_test_model("Llama-2-7b-chat-hf-q4f16_1-MLC")
async def test_engine_generate(model: str):
# Create engine
async_engine = AsyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(max_total_sequence_length=4096),
)
num_requests = 10
max_tokens = 256
generation_cfg = GenerationConfig(max_tokens=max_tokens, n=7)
output_texts: List[List[str]] = [ # noqa: UP006
["" for _ in range(generation_cfg.n)] for _ in range(num_requests)
]
async def generate_task(
async_engine: AsyncMLCEngine,
prompt: str,
generation_cfg: GenerationConfig,
request_id: str,
):
print(f"generate task for request {request_id}")
rid = int(request_id)
async for delta_outputs in async_engine._generate(
prompt, generation_cfg, request_id=request_id
):
if len(delta_outputs) == generation_cfg.n:
for i, delta_output in enumerate(delta_outputs):
output_texts[rid][i] += delta_output.delta_text
else:
assert len(delta_outputs) == 1
assert len(delta_outputs[0].request_final_usage_json_str) != 0
tasks = [
asyncio.create_task(
generate_task(async_engine, prompts[i], generation_cfg, request_id=str(i))
)
for i in range(num_requests)
]
await asyncio.gather(*tasks)
# Print output.
print("All finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
async_engine.terminate()
del async_engine
@require_test_model("Llama-2-7b-chat-hf-q4f16_1-MLC")
async def test_chat_completion(model: str):
# Create engine
async_engine = AsyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(max_total_sequence_length=4096),
)
num_requests = 2
max_tokens = 32
n = 1
output_texts: List[List[str]] = [["" for _ in range(n)] for _ in range(num_requests)] # noqa: UP006
async def generate_task(prompt: str, request_id: str):
print(f"generate chat completion task for request {request_id}")
rid = int(request_id)
async for response in await async_engine.chat.completions.create( # noqa: F821
messages=[{"role": "user", "content": prompt}],
model=model,
max_tokens=max_tokens,
n=n,
request_id=request_id,
stream=True,
):
for choice in response.choices:
assert choice.delta.role == "assistant"
assert isinstance(choice.delta.content, str)
output_texts[rid][choice.index] += choice.delta.content
tasks = [
asyncio.create_task(generate_task(prompts[i], request_id=str(i)))
for i in range(num_requests)
]
await asyncio.gather(*tasks)
# Print output.
print("Chat completion all finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
async_engine.terminate()
del async_engine
@require_test_model("Llama-2-7b-chat-hf-q4f16_1-MLC")
async def test_chat_completion_non_stream(model: str):
# Create engine
async_engine = AsyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(max_total_sequence_length=4096),
)
num_requests = 2
max_tokens = 32
n = 1
output_texts: List[List[str]] = [["" for _ in range(n)] for _ in range(num_requests)] # noqa: UP006
async def generate_task(prompt: str, request_id: str):
print(f"generate chat completion task for request {request_id}")
rid = int(request_id)
response = await async_engine.chat.completions.create( # noqa: F821
messages=[{"role": "user", "content": prompt}],
model=model,
max_tokens=max_tokens,
n=n,
request_id=request_id,
)
for choice in response.choices:
assert choice.message.role == "assistant"
assert isinstance(choice.message.content, str)
output_texts[rid][choice.index] += choice.message.content
tasks = [
asyncio.create_task(generate_task(prompts[i], request_id=str(i)))
for i in range(num_requests)
]
await asyncio.gather(*tasks)
# Print output.
print("Chat completion all finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
async_engine.terminate()
del async_engine
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
async def test_completion(model: str):
# Create engine
async_engine = AsyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(max_total_sequence_length=4096),
)
num_requests = 2
max_tokens = 128
n = 1
output_texts: List[List[str]] = [["" for _ in range(n)] for _ in range(num_requests)] # noqa: UP006
async def generate_task(prompt: str, request_id: str):
print(f"generate completion task for request {request_id}")
rid = int(request_id)
async for response in await async_engine.completions.create( # noqa: F821
prompt=prompt,
model=model,
max_tokens=max_tokens,
n=n,
request_id=request_id,
stream=True,
extra_body={"debug_config": {"ignore_eos": True}},
):
for choice in response.choices:
output_texts[rid][choice.index] += choice.text
tasks = [
asyncio.create_task(generate_task(prompts[i], request_id=str(i)))
for i in range(num_requests)
]
await asyncio.gather(*tasks)
# Print output.
print("Completion all finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
async_engine.terminate()
del async_engine
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
async def test_completion_non_stream(model: str):
# Create engine
async_engine = AsyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(max_total_sequence_length=4096),
)
num_requests = 2
max_tokens = 128
n = 1
output_texts: List[List[str]] = [["" for _ in range(n)] for _ in range(num_requests)] # noqa: UP006
async def generate_task(prompt: str, request_id: str):
print(f"generate completion task for request {request_id}")
rid = int(request_id)
response = await async_engine.completions.create( # noqa: F821
prompt=prompt,
model=model,
max_tokens=max_tokens,
n=n,
request_id=request_id,
extra_body={"debug_config": {"ignore_eos": True}},
)
for choice in response.choices:
output_texts[rid][choice.index] += choice.text
tasks = [
asyncio.create_task(generate_task(prompts[i], request_id=str(i)))
for i in range(num_requests)
]
await asyncio.gather(*tasks)
# Print output.
print("Completion all finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
async_engine.terminate()
del async_engine
if __name__ == "__main__":
asyncio.run(test_engine_generate())
asyncio.run(test_chat_completion())
asyncio.run(test_chat_completion_non_stream())
asyncio.run(test_completion())
asyncio.run(test_completion_non_stream())
@@ -0,0 +1,84 @@
import asyncio
from typing import List # noqa: UP035
from mlc_llm.protocol.generation_config import GenerationConfig
from mlc_llm.serve import AsyncMLCEngine, EngineConfig
from mlc_llm.testing import require_test_model
prompts = [
"What is the meaning of life?",
"Introduce the history of Pittsburgh to me. Please elaborate in detail.",
"Write a three-day Seattle travel plan. Please elaborate in detail.",
"What is Alaska famous of? Please elaborate in detail.",
"What is the difference between Lambda calculus and Turing machine? Please elaborate in detail.", # noqa: E501
"What are the necessary components to assemble a desktop computer? Please elaborate in detail.",
"Why is Vitamin D important to human beings? Please elaborate in detail.",
"Where is milk tea originated from? Please elaborate in detail.",
"Where is the southernmost place in United States? Please elaborate in detail.",
"Do you know AlphaGo? What capabilities does it have, and what achievements has it got? Please elaborate in detail.", # noqa: E501
]
@require_test_model(
"Llama-2-7b-chat-hf-q0f16-MLC",
"Llama-2-7b-chat-hf-q4f16_1-MLC",
)
async def test_engine_generate(model: str, small_model: str):
# Create engine
async_engine = AsyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
additional_models=[small_model],
speculative_mode="small_draft",
),
)
num_requests = 10
max_tokens = 256
generation_cfg = GenerationConfig(max_tokens=max_tokens)
output_texts: List[List[str]] = [ # noqa: UP006
["" for _ in range(generation_cfg.n)] for _ in range(num_requests)
]
async def generate_task(
async_engine: AsyncMLCEngine,
prompt: str,
generation_cfg: GenerationConfig,
request_id: str,
):
print(f"generate task for request {request_id}")
rid = int(request_id)
async for delta_outputs in async_engine._generate(
prompt, generation_cfg, request_id=request_id
):
assert len(delta_outputs) == generation_cfg.n
for i, delta_output in enumerate(delta_outputs):
output_texts[rid][i] += delta_output.delta_text
tasks = [
asyncio.create_task(
generate_task(async_engine, prompts[i], generation_cfg, request_id=str(i))
)
for i in range(num_requests)
]
await asyncio.gather(*tasks)
# Print output.
print("All finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
async_engine.terminate()
del async_engine
if __name__ == "__main__":
asyncio.run(test_engine_generate())
+241
View File
@@ -0,0 +1,241 @@
from typing import List # noqa: UP035
from mlc_llm.protocol.generation_config import GenerationConfig
from mlc_llm.serve import EngineConfig, MLCEngine
from mlc_llm.testing import require_test_model
prompts = [
"What is the meaning of life?",
"Introduce the history of Pittsburgh to me. Please elaborate in detail.",
"Write a three-day Seattle travel plan. Please elaborate in detail.",
"What is Alaska famous of? Please elaborate in detail.",
"What is the difference between Lambda calculus and Turing machine? Please elaborate in detail.", # noqa: E501
"What are the necessary components to assemble a desktop computer? Please elaborate in detail.",
"Why is Vitamin D important to human beings? Please elaborate in detail.",
"Where is milk tea originated from? Please elaborate in detail.",
"Where is the southernmost place in United States? Please elaborate in detail.",
"Do you know AlphaGo? What capabilities does it have, and what achievements has it got? Please elaborate in detail.", # noqa: E501
]
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_engine_generate(model: str):
# Create engine
engine = MLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
),
)
num_requests = 10
max_tokens = 256
generation_cfg = GenerationConfig(max_tokens=max_tokens, n=7)
output_texts: List[List[str]] = [ # noqa: UP006
["" for _ in range(generation_cfg.n)] for _ in range(num_requests)
]
for rid in range(num_requests):
print(f"generating for request {rid}")
for delta_outputs in engine._generate(prompts[rid], generation_cfg, request_id=str(rid)):
assert len(delta_outputs) == generation_cfg.n
for i, delta_output in enumerate(delta_outputs):
output_texts[rid][i] += delta_output.delta_text
# Print output.
print("All finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
engine.terminate()
del engine
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_chat_completion(model: str):
# Create engine
engine = MLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
),
)
num_requests = 2
max_tokens = 64
n = 2
output_texts: List[List[str]] = [["" for _ in range(n)] for _ in range(num_requests)] # noqa: UP006
for rid in range(num_requests):
print(f"chat completion for request {rid}")
for response in engine.chat.completions.create(
messages=[{"role": "user", "content": prompts[rid]}],
model=model,
max_tokens=max_tokens,
n=n,
request_id=str(rid),
stream=True,
):
for choice in response.choices:
assert choice.delta.role == "assistant"
assert isinstance(choice.delta.content, str)
output_texts[rid][choice.index] += choice.delta.content
# Print output.
print("Chat completion all finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
engine.terminate()
del engine
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_chat_completion_non_stream(model: str):
# Create engine
engine = MLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
),
)
num_requests = 2
max_tokens = 64
n = 2
output_texts: List[List[str]] = [["" for _ in range(n)] for _ in range(num_requests)] # noqa: UP006
for rid in range(num_requests):
print(f"chat completion for request {rid}")
response = engine.chat.completions.create(
messages=[{"role": "user", "content": prompts[rid]}],
model=model,
max_tokens=max_tokens,
n=n,
request_id=str(rid),
)
for choice in response.choices:
assert choice.message.role == "assistant"
assert isinstance(choice.message.content, str)
output_texts[rid][choice.index] += choice.message.content
# Print output.
print("Chat completion all finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
engine.terminate()
del engine
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_completion(model: str):
# Create engine
engine = MLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
),
)
num_requests = 2
max_tokens = 128
n = 1
output_texts: List[List[str]] = [["" for _ in range(n)] for _ in range(num_requests)] # noqa: UP006
for rid in range(num_requests):
print(f"completion for request {rid}")
for response in engine.completions.create(
prompt=prompts[rid],
model=model,
max_tokens=max_tokens,
n=n,
request_id=str(rid),
stream=True,
extra_body={"debug_config": {"ignore_eos": True}},
):
for choice in response.choices:
output_texts[rid][choice.index] += choice.text
# Print output.
print("Completion all finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
engine.terminate()
del engine
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_completion_non_stream(model: str):
# Create engine
engine = MLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
),
)
num_requests = 2
max_tokens = 128
n = 1
output_texts: List[List[str]] = [["" for _ in range(n)] for _ in range(num_requests)] # noqa: UP006
for rid in range(num_requests):
print(f"completion for request {rid}")
response = engine.completions.create(
prompt=prompts[rid],
model=model,
max_tokens=max_tokens,
n=n,
request_id=str(rid),
extra_body={"debug_config": {"ignore_eos": True}},
)
for choice in response.choices:
output_texts[rid][choice.index] += choice.text
# Print output.
print("Completion all finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
engine.terminate()
del engine
if __name__ == "__main__":
test_engine_generate()
test_chat_completion()
test_chat_completion_non_stream()
test_completion()
test_completion_non_stream()
@@ -0,0 +1,356 @@
import asyncio
import json
import random
from typing import Dict, List, Literal # noqa: UP035
from pydantic import BaseModel
from mlc_llm.protocol.debug_protocol import DebugConfig
from mlc_llm.protocol.openai_api_protocol import ChatCompletionResponse
from mlc_llm.serve import AsyncMLCEngine, MLCEngine
from mlc_llm.testing import require_test_model
LLAMA_2_MODEL = "Llama-2-7b-chat-hf-q4f16_1-MLC"
LLAMA_3_MODEL = "Meta-Llama-3-8B-Instruct-q4f16_1-MLC"
@require_test_model(LLAMA_3_MODEL)
def test_batch_generation_with_grammar(model: str):
# Engine
engine = MLCEngine(model=model, mode="server")
# Inputs
system_prompt = "You are a helpful assistant. Always respond only with json."
prompts_list = [
"Generate a JSON string containing 20 objects:",
"Generate a JSON containing a non-empty list:",
"Generate a JSON with 5 elements:",
"Generate a JSON with a number list, counting from 1 to 20:",
]
repeat = 3
top_p = 0.9
temperature = 0.6
max_tokens = 4096
# non-json output
responses_text: List[ChatCompletionResponse] = [] # noqa: UP006
for _ in range(repeat):
for p in prompts_list:
print(f"Start generation task for request {len(responses_text)}")
responses_text.append(
engine.chat.completions.create(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": p},
],
response_format={"type": "text"},
top_p=top_p,
temperature=temperature,
max_tokens=max_tokens,
seed=random.randint(0, 1 << 30),
extra_body={"debug_config": DebugConfig(grammar_execution_mode="constraint")},
)
)
print("Text output")
for req_id, response in enumerate(responses_text):
prompt = prompts_list[req_id % len(prompts_list)]
output = response.choices[0].message.content
print(f"Prompt {req_id}: {prompt}")
print(f"Output {req_id}: {output}\n")
# json output
responses_json: List[ChatCompletionResponse] = [] # noqa: UP006
for _ in range(repeat):
for p in prompts_list:
print(f"Start generation task for request {len(responses_json)}")
responses_json.append(
engine.chat.completions.create(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": p},
],
response_format={"type": "json_object"},
top_p=top_p,
temperature=temperature,
seed=random.randint(0, 1 << 30),
)
)
print("JSON output")
for req_id, response in enumerate(responses_json):
prompt = prompts_list[req_id % len(prompts_list)]
output = str(response.choices[0].message.content)
print(f"Prompt {req_id}: {prompt}")
print(f"Output {req_id}: {output}\n")
json.loads(output)
print("Engine metrics:", engine.metrics())
engine.terminate()
@require_test_model(LLAMA_3_MODEL)
def test_batch_generation_with_schema(model: str):
# Create engine
engine = MLCEngine(model=model, mode="server")
class Product(BaseModel):
product_id: int
is_available: bool
price: float
is_featured: Literal[True]
category: Literal["Electronics", "Clothing", "Food"]
tags: List[str] # noqa: UP006
stock: Dict[str, int] # noqa: UP006
schema_str = json.dumps(Product.model_json_schema())
system_prompt = (
"You are a helpful assistant. Always respond only with JSON based on the "
f"following JSON schema: {schema_str}."
)
prompt = "Generate a JSON that describes the product according to the given JSON schema."
repeat = 8
top_p = 0.9
temperature = 0.6
max_tokens = 4096
# non-json output
responses_text: List[ChatCompletionResponse] = [] # noqa: UP006
for i in range(repeat):
print(f"Start generation task for request {i}")
responses_text.append(
engine.chat.completions.create(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
response_format={"type": "text"},
top_p=top_p,
temperature=temperature,
max_tokens=max_tokens,
seed=random.randint(0, 1 << 30),
extra_body={"debug_config": DebugConfig(grammar_execution_mode="constraint")},
)
)
print("Text output")
for req_id, response in enumerate(responses_text):
output = response.choices[0].message.content
print(f"Prompt {req_id}: {prompt}")
print(f"Output {req_id}: {output}\n")
# json output without schema
responses_json: List[ChatCompletionResponse] = [] # noqa: UP006
for i in range(repeat):
print(f"Start generation task for request {i}")
responses_json.append(
engine.chat.completions.create(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
top_p=top_p,
temperature=temperature,
max_tokens=max_tokens,
seed=random.randint(0, 1 << 30),
extra_body={"debug_config": DebugConfig(grammar_execution_mode="constraint")},
)
)
print("JSON output")
for req_id, response in enumerate(responses_json):
output = response.choices[0].message.content
print(f"Prompt {req_id}: {prompt}")
print(f"Output {req_id}: {output}\n")
# json output with schema
responses_schema: List[ChatCompletionResponse] = [] # noqa: UP006
for i in range(repeat):
print(f"Start generation task for request {i}")
responses_schema.append(
engine.chat.completions.create(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object", "schema": schema_str},
top_p=top_p,
temperature=temperature,
max_tokens=max_tokens,
seed=random.randint(0, 1 << 30),
extra_body={"debug_config": DebugConfig(grammar_execution_mode="constraint")},
)
)
print("JSON Schema output")
for req_id, response in enumerate(responses_schema):
output = response.choices[0].message.content
print(f"Prompt {req_id}: {prompt}")
print(f"Output {req_id}: {output}\n")
print("Engine metrics:", engine.metrics())
engine.terminate()
@require_test_model(LLAMA_3_MODEL)
def test_batch_generation_jump_forward(model: str, jump_forward: bool = True, repeat: int = 1):
# Create engine
engine = MLCEngine(model=model, mode="server")
class Product(BaseModel):
product_id: int
is_available: bool
price: float
is_featured: Literal[True]
category: Literal["Electronics", "Clothing", "Food"]
tags: List[str] # noqa: UP006
stock: Dict[str, int] # noqa: UP006
schema_str = json.dumps(Product.model_json_schema())
system_prompt = (
"You are a helpful assistant. Always respond only with JSON based on the "
f"following JSON schema: {schema_str}."
)
prompt = "Generate a JSON that describes the product according to the given JSON schema."
top_p = 0.9
temperature = 0.6
max_tokens = 4096
grammar_execution_mode = "jump_forward" if jump_forward else "constraint"
# json output with schema
responses: List[ChatCompletionResponse] = [] # noqa: UP006
for i in range(repeat):
print(f"Start generation task for request {i}")
responses.append(
engine.chat.completions.create(
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object", "schema": schema_str},
top_p=top_p,
temperature=temperature,
max_tokens=max_tokens,
seed=random.randint(0, 1 << 30),
extra_body={
"debug_config": DebugConfig(grammar_execution_mode=grammar_execution_mode)
},
)
)
print(f"Jump forward: {jump_forward}, Repeat: {repeat}")
for req_id, response in enumerate(responses):
output = response.choices[0].message.content
print(f"Prompt {req_id}: {prompt}")
print(f"Output {req_id}: {output}\n")
print("Engine metrics:", engine.metrics())
engine.terminate()
@require_test_model(LLAMA_3_MODEL)
async def run_async_engine(
model: str,
mode: Literal["text", "json", "schema"] = "schema",
jump_forward: bool = True,
num_requests: int = 8,
):
# Create engine
async_engine = AsyncMLCEngine(model=model, mode="server")
class Product(BaseModel):
product_id: int
is_available: bool
price: float
is_featured: Literal[True]
category: Literal["Electronics", "Clothing", "Food"]
tags: List[str] # noqa: UP006
stock: Dict[str, int] # noqa: UP006
schema_str = json.dumps(Product.model_json_schema())
if mode == "text":
response_format = {"type": "text"}
elif mode == "json":
response_format = {"type": "json_object"}
elif mode == "schema":
response_format = {"type": "json_object", "schema": schema_str}
system_prompt = (
"You are a helpful assistant. Always respond only with JSON based on the "
f"following JSON schema: {schema_str}."
)
prompt = "Generate a JSON that describes the product according to the given JSON schema."
top_p = 0.9
temperature = 0.6
max_tokens = 4096
grammar_execution_mode = "jump_forward" if jump_forward else "constraint"
responses = ["" for _ in range(num_requests)]
async def generate_task(prompt: str, request_id: str):
print(f"Start generation task for request {request_id}")
rid = int(request_id)
async for response in await async_engine.chat.completions.create( # noqa: F821
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt},
],
response_format=response_format,
top_p=top_p,
temperature=temperature,
max_tokens=max_tokens,
seed=random.randint(0, 1 << 30),
stream=True,
extra_body={"debug_config": DebugConfig(grammar_execution_mode=grammar_execution_mode)},
):
assert len(response.choices) == 1
choice = response.choices[0]
assert choice.delta.role == "assistant"
assert isinstance(choice.delta.content, str)
responses[rid] += choice.delta.content
tasks = [
asyncio.create_task(generate_task(prompt, request_id=str(i))) for i in range(num_requests)
]
await asyncio.gather(*tasks)
print(f"Mode: {mode}, Jump forward: {jump_forward}, Num requests: {num_requests}")
for req_id, output in enumerate(responses):
print(f"Prompt {req_id}: {prompt}")
print(f"Output {req_id}: {output}\n")
print("Engine metrics:", await async_engine.metrics())
async_engine.terminate()
del async_engine
def test_async_engine(
mode: Literal["text", "json", "schema"] = "schema",
jump_forward: bool = True,
num_requests: int = 8,
):
asyncio.run(run_async_engine(mode, jump_forward, num_requests))
if __name__ == "__main__":
test_batch_generation_with_grammar()
test_batch_generation_with_schema()
test_batch_generation_jump_forward(False)
test_batch_generation_jump_forward(True)
test_async_engine("schema", False, 1)
test_async_engine("schema", True, 1)
test_async_engine("schema", False, 8)
test_async_engine("schema", True, 8)
@@ -0,0 +1,56 @@
import json
from pathlib import Path
from mlc_llm.protocol.generation_config import GenerationConfig
from mlc_llm.serve import data
from mlc_llm.serve.sync_engine import EngineConfig, SyncMLCEngine
def get_test_image(config) -> data.ImageData:
return data.ImageData.from_url("https://llava-vl.github.io/static/images/view.jpg", config)
def test_engine_generate():
# Create engine
model = "dist/llava-1.5-7b-hf-q4f16_1-MLC/params"
model_lib = "dist/llava-1.5-7b-hf-q4f16_1-MLC/llava-1.5-7b-hf-q4f16_1-MLC.so"
engine = SyncMLCEngine(
model=model,
model_lib=model_lib,
mode="server",
engine_config=EngineConfig(max_total_sequence_length=4096),
)
max_tokens = 256
with open(Path(model) / "mlc-chat-config.json", encoding="utf-8") as file:
model_config = json.load(file)
prompts = [
[
data.TextData("USER: "),
get_test_image(model_config),
data.TextData("\nWhat does this image represent? ASSISTANT:"),
],
[
data.TextData("USER: "),
get_test_image(model_config),
data.TextData("\nIs there a dog in this image? ASSISTANT:"),
],
[data.TextData("USER: What is the meaning of life? ASSISTANT:")],
]
output_texts, _ = engine.generate(
prompts, GenerationConfig(max_tokens=max_tokens, stop_token_ids=[2])
)
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
if __name__ == "__main__":
test_engine_generate()
@@ -0,0 +1,39 @@
"""Mock testing engine I/O conventions
Mock test only can help checking the overall input
output processing options are passed correctly
"""
import pytest
import tvm
from mlc_llm.serve import MLCEngine
from mlc_llm.testing import require_test_model
# test category "unittest"
pytestmark = [pytest.mark.unittest]
# NOTE: we only need tokenizers in folder
# launch time of mock test is fast so we can put it in unittest
@require_test_model("Llama-3-8B-Instruct-q4f16_1-MLC")
def test_completion_api(model: str):
engine = MLCEngine(model, tvm.cpu(), model_lib="mock://echo")
param_dict = {
"top_p": 0.6,
"temperature": 0.9,
"frequency_penalty": 0.1,
"presence_penalty": 0.1,
"n": 2,
}
response = engine.chat.completions.create(
messages=[{"role": "user", "content": "hello"}],
**param_dict,
)
# echo mock will echo back the generation config
for k, v in param_dict.items():
assert response.usage.extra[k] == v
if __name__ == "__main__":
test_completion_api()
@@ -0,0 +1,141 @@
from mlc_llm.protocol.debug_protocol import DebugConfig
from mlc_llm.protocol.generation_config import GenerationConfig
from mlc_llm.serve.sync_engine import EngineConfig, SyncMLCEngine
from mlc_llm.testing import require_test_model
prompts = [
"The meaning of life is",
"According to the history of Pittsburgh,",
"I have a three-day Seattle travel plan. On the first day,",
"Undoubtedly, Alaska is one of the most beautiful places on Earth,",
"Explain difference between Lambda calculus and Turing machine is",
"To assemble a desktop computer, we need the necessary components of",
"Vitamin D is important to human beings, because",
"Refer to history, the milk tea is originated from",
"In the southernmost place in United States,",
"AlphaGo has the capabilities of",
]
def test_engine_system_prompt(engine):
system_prompt = "This is a system prompt"
system_prompt_tokens = len(engine.tokenizer.encode(system_prompt))
max_tokens = 8
_, _ = engine.generate(
system_prompt,
GenerationConfig(
temperature=0,
max_tokens=max_tokens,
debug_config=DebugConfig(pinned_system_prompt=True),
),
)
metrics = engine.metrics()
assert metrics["prefill_tokens_sum"] == system_prompt_tokens
sum_prefill_tokens = system_prompt_tokens
input_token_lens = [len(engine.tokenizer.encode(prompt)) for prompt in prompts]
generation_config = GenerationConfig(temperature=0, max_tokens=max_tokens)
_, _ = engine.generate(prompts, generation_config)
metrics = engine.metrics()
assert metrics["prefill_tokens_sum"] == sum_prefill_tokens + sum(input_token_lens)
sum_prefill_tokens = metrics["prefill_tokens_sum"]
_, _ = engine.generate(system_prompt + " and why ?", generation_config)
metrics = engine.metrics()
# system prompt is reused entirely
assert metrics["prefill_tokens_sum"] == sum_prefill_tokens + 3
sum_prefill_tokens = metrics["prefill_tokens_sum"]
_, _ = engine.generate(prompts[:4], generation_config)
metrics = engine.metrics()
# first 4 prompts are removed and need to prefill again
assert metrics["prefill_tokens_sum"] == sum_prefill_tokens + sum(input_token_lens[:4])
def test_engine_multi_round(engine):
num_requests = 10
max_tokens = 8
generation_config = GenerationConfig(temperature=0, max_tokens=max_tokens)
input_token_lens = [len(engine.tokenizer.encode(prompt)) for prompt in prompts[:num_requests]]
output_texts, _ = engine.generate(prompts[:num_requests], generation_config)
metrics = engine.metrics()
assert metrics["prefill_tokens_sum"] == sum(input_token_lens)
sum_prefill_tokens = metrics["prefill_tokens_sum"]
concat_prompt = []
for i, output in enumerate(output_texts):
concat_prompt.append(prompts[i] + " " + output[0] + " ?")
output_texts, _ = engine.generate(concat_prompt[:num_requests], generation_config)
metrics = engine.metrics()
assert metrics["prefill_tokens_sum"] == sum_prefill_tokens + 2 * num_requests
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_basic_engine_system_prompt(model: str):
# Create engine
engine = SyncMLCEngine(
model=model,
mode="local",
engine_config=EngineConfig(
max_total_sequence_length=4096,
prefix_cache_max_num_recycling_seqs=5,
),
)
test_engine_system_prompt(engine)
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_basic_engine_multi_round(model: str):
# Create engine
engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(max_total_sequence_length=4096),
)
test_engine_multi_round(engine)
@require_test_model(
"Llama-2-7b-chat-hf-q0f16-MLC",
"Llama-2-7b-chat-hf-q4f16_1-MLC",
)
def test_engine_spec_multi_round(model: str, small_model: str):
# Create engine
engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
additional_models=[small_model],
speculative_mode="small_draft",
),
)
test_engine_multi_round(engine)
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_engine_eagle_multi_round(model: str):
# Create engine
small_model = "dist/Eagle-llama2-7b-chat-q0f16-MLC"
small_model_lib = "dist/Eagle-llama2-7b-chat-q0f16-MLC/Eagle-llama2-7b-chat-q0f16-MLC-cuda.so"
engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
additional_models=[(small_model, small_model_lib)],
speculative_mode="eagle",
max_num_sequence=80,
),
)
test_engine_multi_round(engine)
if __name__ == "__main__":
test_basic_engine_system_prompt()
test_basic_engine_multi_round()
test_engine_spec_multi_round()
test_engine_eagle_multi_round()
@@ -0,0 +1,60 @@
from typing import List # noqa: UP035
from mlc_llm.protocol.generation_config import GenerationConfig
from mlc_llm.serve import EngineConfig, MLCEngine
prompts = [
"What is the meaning of life?",
"Introduce the history of Pittsburgh to me. Please elaborate in detail.",
"Write a three-day Seattle travel plan. Please elaborate in detail.",
"What is Alaska famous of? Please elaborate in detail.",
"What is the difference between Lambda calculus and Turing machine? Please elaborate in detail.", # noqa: E501
"What are the necessary components to assemble a desktop computer? Please elaborate in detail.",
"Why is Vitamin D important to human beings? Please elaborate in detail.",
"Where is milk tea originated from? Please elaborate in detail.",
"Where is the southernmost place in United States? Please elaborate in detail.",
"Do you know AlphaGo? What capabilities does it have, and what achievements has it got? Please elaborate in detail.", # noqa: E501
]
def test_engine_generate() -> None:
engine = MLCEngine(
model="dist/rwkv-6-world-1b6-q0f16-MLC",
model_lib="dist/rwkv-6-world-1b6-q0f16-MLC/rwkv-6-world-1b6-q0f16-MLC-cuda.so",
mode="server",
engine_config=EngineConfig(
max_num_sequence=8,
max_history_size=1,
),
)
num_requests = 10
max_tokens = 256
generation_cfg = GenerationConfig(max_tokens=max_tokens, n=7)
output_texts: List[List[str]] = [ # noqa: UP006
["" for _ in range(generation_cfg.n)] for _ in range(num_requests)
]
for rid in range(num_requests):
print(f"generating for request {rid}")
for delta_outputs in engine._generate(prompts[rid], generation_cfg, request_id=str(rid)):
assert len(delta_outputs) == generation_cfg.n
for i, delta_output in enumerate(delta_outputs):
output_texts[rid][i] += delta_output.delta_text
# Print output.
print("All finished")
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
engine.terminate()
del engine
if __name__ == "__main__":
test_engine_generate()
@@ -0,0 +1,660 @@
from typing import Callable, List, Optional # noqa: UP035
import numpy as np
from mlc_llm.protocol.generation_config import GenerationConfig
from mlc_llm.serve import Request, RequestStreamOutput, data
from mlc_llm.serve.sync_engine import EngineConfig, SyncMLCEngine
from mlc_llm.testing import require_test_model
prompts = [
"What is the meaning of life?",
"Introduce the history of Pittsburgh to me. Please elaborate in detail.",
"Write a three-day Seattle travel plan. Please elaborate in detail.",
"What is Alaska famous of? Please elaborate in detail.",
"What is the difference between Lambda calculus and Turing machine? Please elaborate in detail.", # noqa: E501
"What are the necessary components to assemble a desktop computer? Please elaborate in detail.",
"Why is Vitamin D important to human beings? Please elaborate in detail.",
"Where is milk tea originated from? Please elaborate in detail.",
"Where is the southernmost place in United States? Please elaborate in detail.",
"Do you know AlphaGo? What capabilities does it have, and what achievements has it got? Please elaborate in detail.", # noqa: E501
]
def create_requests(
num_requests: int,
stop_token_id: Optional[int] = None,
temperature: float = 0.8,
repetition_penalty: float = 1.0,
max_tokens_low: int = 256,
max_tokens_high: int = 257,
) -> List[Request]: # noqa: UP006
assert num_requests >= 0 and num_requests <= len(prompts)
stop_token_ids = [stop_token_id] if stop_token_id is not None else []
requests = []
for req_id, prompt in zip(range(num_requests), prompts):
max_tokens = np.random.randint(max_tokens_low, max_tokens_high)
requests.append(
Request(
request_id=str(req_id),
inputs=data.TextData(prompt),
generation_config=GenerationConfig(
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens=max_tokens,
stop_token_ids=stop_token_ids,
),
)
)
return requests
@require_test_model(
"Llama-2-7b-chat-hf-q0f16-MLC",
"Llama-2-7b-chat-hf-q4f16_1-MLC",
)
def test_engine_basic(model: str, small_model: str):
"""Test engine **without continuous batching**.
- Add all requests to the engine altogether in the beginning.
- All requests have the same max_tokens. This means all requests
will end together.
- Engine keeps running `step` for estimated number of steps (number of
requests + max_tokens - 1). Then check the output of each request.
"""
# Hyperparameters for tests (you can try different combinations).
num_requests = len(prompts) # [4, 8, 10]
temperature = 0.9 # [0, 0.8, 0.9, 1.0, 1.1]
repetition_penalty = 1.0 # [1.0, 1.01]
max_tokens: int = 256 # [32, 128, 256]
np.random.seed(0)
# Output list
outputs: List[List[int]] = [[] for _ in range(num_requests)] # noqa: UP006
# Define the callback function for request generation results
def fcallback(delta_outputs: List[RequestStreamOutput]): # noqa: UP006
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
assert len(stream_outputs) == 1
outputs[int(request_id)] += stream_outputs[0].delta_token_ids
# Create engine
engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
additional_models=[small_model],
speculative_mode="small_draft",
),
request_stream_callback=fcallback,
)
# Create requests
requests = create_requests(
num_requests,
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens_low=max_tokens,
max_tokens_high=max_tokens + 1,
)
# Add all requests to engine
for request in requests:
engine.add_request(request)
num_steps = num_requests + max_tokens - 1
# Run steps
for step in range(num_steps):
engine.step()
for req_id, output in enumerate(outputs):
print(f"Prompt {req_id}: {requests[req_id].inputs[0]}")
print(f"Output {req_id}:{engine.tokenizer.decode(output)}\n")
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_engine_eagle_basic(model: str):
"""Test engine **without continuous batching**.
- Add all requests to the engine altogether in the beginning.
- All requests have the same max_tokens. This means all requests
will end together.
- Engine keeps running `step` for estimated number of steps (number of
requests + max_tokens - 1). Then check the output of each request.
- Use Eagle model as speculative model
"""
# Hyperparameters for tests (you can try different combinations).
num_requests = len(prompts) # [4, 8, 10]
temperature = 0.9 # [0, 0.8, 0.9, 1.0, 1.1]
repetition_penalty = 1.0 # [1.0, 1.01]
max_tokens: int = 256 # [32, 128, 256]
np.random.seed(0)
# Output list
outputs: List[List[int]] = [[] for _ in range(num_requests)] # noqa: UP006
# Define the callback function for request generation results
def fcallback(delta_outputs: List[RequestStreamOutput]): # noqa: UP006
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
assert len(stream_outputs) == 1
outputs[int(request_id)] += stream_outputs[0].delta_token_ids
# Create engine
small_model = "dist/Eagle-llama2-7b-chat-q0f16-MLC"
small_model_lib = "dist/Eagle-llama2-7b-chat-q0f16-MLC/Eagle-llama2-7b-chat-q0f16-MLC-cuda.so"
engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
additional_models=[(small_model, small_model_lib)],
speculative_mode="eagle",
spec_draft_length=2,
),
request_stream_callback=fcallback,
)
# Create requests
requests = create_requests(
num_requests,
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens_low=max_tokens,
max_tokens_high=max_tokens + 1,
)
# Add all requests to engine
for request in requests:
engine.add_request(request)
num_steps = num_requests + max_tokens - 1
# Run steps
for step in range(num_steps):
engine.step()
for req_id, output in enumerate(outputs):
print(f"Prompt {req_id}: {requests[req_id].inputs[0]}")
print(f"Output {req_id}:{engine.tokenizer.decode(output)}\n")
@require_test_model(
"Llama-2-7b-chat-hf-q0f16-MLC",
"Llama-2-7b-chat-hf-q4f16_1-MLC",
)
def test_engine_continuous_batching_1(model: str, small_model: str):
"""Test engine **with continuous batching**.
- Add all requests to the engine altogether in the beginning.
- All requests have a random maximum generation length. So each
request keeps generating until reaching the maximum length.
- Engine keeps running `step` for estimated number of steps (number of
requests + the maximum max_tokens - 1). Then check the output
of each request.
"""
# Hyperparameters for tests (you can try different combinations)
num_requests = len(prompts) # [4, 8, 10]
temperature = 0.9 # [0.8, 0.9, 1.0, 1.1]
repetition_penalty = 1.00 # [1.0, 1.01]
max_tokens_low = 128
max_tokens_high = 384
np.random.seed(0)
# Output list
outputs: List[List[int]] = [[] for _ in range(num_requests)] # noqa: UP006
finish_time: List[Optional[int]] = [None] * num_requests # noqa: UP006
# Define the callback class for request generation results
class CallbackTimer:
timer: int = -1
def callback_getter(self) -> Callable[[List[RequestStreamOutput]], None]: # noqa: UP006
def fcallback(delta_outputs: List[RequestStreamOutput]): # noqa: UP006
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
assert len(stream_outputs) == 1
if stream_outputs[0].finish_reason is not None:
print(f"Request {request_id} finished at step {self.timer}.")
outputs[int(request_id)] += stream_outputs[0].delta_token_ids
finish_time[int(request_id)] = self.timer
return fcallback
def step(self) -> None:
self.timer += 1
# Create engine
timer = CallbackTimer()
engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
additional_models=[small_model],
speculative_mode="small_draft",
),
request_stream_callback=timer.callback_getter(),
)
# Create requests
requests = create_requests(
num_requests,
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens_low=max_tokens_low,
max_tokens_high=max_tokens_high,
)
# Add all requests to engine
for request in requests:
engine.add_request(request)
num_steps = num_requests + max(request.generation_config.max_tokens for request in requests) - 1
# Run steps
for step in range(num_steps):
timer.step()
assert timer.timer == step
engine.step()
for req_id, (request, output, fin_time) in enumerate(zip(requests, outputs, finish_time)):
print(f"Prompt {req_id}: {request.inputs[0]}")
print(f"Output {req_id}:{engine.tokenizer.decode(output)}\n")
# assert fin_time == request.generation_config.max_tokens - 1
@require_test_model("Llama-2-7b-chat-hf-q4f16_1-MLC")
def test_engine_eagle_continuous_batching_1(model: str):
"""Test engine **with continuous batching**.
- Add all requests to the engine altogether in the beginning.
- All requests have a random maximum generation length. So each
request keeps generating until reaching the maximum length.
- Engine keeps running `step` for estimated number of steps (number of
requests + the maximum max_tokens - 1). Then check the output
of each request.
"""
# Hyperparameters for tests (you can try different combinations)
num_requests = len(prompts) # [4, 8, 10]
temperature = 0.9 # [0.8, 0.9, 1.0, 1.1]
repetition_penalty = 1.00 # [1.0, 1.01]
max_tokens_low = 128
max_tokens_high = 384
np.random.seed(0)
# Output list
outputs: List[List[int]] = [[] for _ in range(num_requests)] # noqa: UP006
finish_time: List[Optional[int]] = [None] * num_requests # noqa: UP006
# Define the callback class for request generation results
class CallbackTimer:
timer: int = -1
def callback_getter(self) -> Callable[[List[RequestStreamOutput]], None]: # noqa: UP006
def fcallback(delta_outputs: List[RequestStreamOutput]): # noqa: UP006
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
assert len(stream_outputs) == 1
if stream_outputs[0].finish_reason is not None:
print(f"Request {request_id} finished at step {self.timer}.")
outputs[int(request_id)] += stream_outputs[0].delta_token_ids
finish_time[int(request_id)] = self.timer
return fcallback
def step(self) -> None:
self.timer += 1
# Create engine
small_model = "dist/Eagle-llama2-7b-chat-q4f16_1-MLC"
small_model_lib = (
"dist/Eagle-llama2-7b-chat-q4f16_1-MLC/Eagle-llama2-7b-chat-q4f16_1-MLC-cuda.so"
)
timer = CallbackTimer()
engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
additional_models=[(small_model, small_model_lib)],
speculative_mode="eagle",
),
request_stream_callback=timer.callback_getter(),
)
# Create requests
requests = create_requests(
num_requests,
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens_low=max_tokens_low,
max_tokens_high=max_tokens_high,
)
# Add all requests to engine
for request in requests:
engine.add_request(request)
num_steps = num_requests + max(request.generation_config.max_tokens for request in requests) - 1
# Run steps
for step in range(num_steps):
timer.step()
assert timer.timer == step
engine.step()
for req_id, (request, output, fin_time) in enumerate(zip(requests, outputs, finish_time)):
print(f"Prompt {req_id}: {request.inputs[0]}")
print(f"Output {req_id}:{engine.tokenizer.decode(output)}\n")
# assert fin_time == request.generation_config.max_tokens - 1
def compare_output_text(output_text1, output_text2):
if isinstance(output_text1, list) and isinstance(output_text2, list):
for item1, item2 in zip(output_text1, output_text2):
if not compare_output_text(item1, item2):
return False
elif output_text1 != output_text2:
print(output_text1)
print(output_text2)
return False
return True
@require_test_model(
"Llama-2-7b-chat-hf-q0f16-MLC",
"Llama-2-7b-chat-hf-q4f16_1-MLC",
)
def test_engine_generate(model: str, small_model: str, compare_precision=False):
# Create engine
engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
additional_models=[small_model],
speculative_mode="small_draft",
),
)
num_requests = 10
max_tokens = 256
# Generate output.
if compare_precision:
print("compare precision")
generation_config = GenerationConfig(
temperature=0.0, top_p=0, max_tokens=1024, stop_token_ids=[2], n=1
)
engine_single_model = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
),
)
output_texts_single_model, _ = engine_single_model.generate(
prompts[:num_requests], generation_config
)
for req_id, outputs in enumerate(output_texts_single_model):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
# TODO: Add pytorch precision
else:
generation_config = GenerationConfig(max_tokens=max_tokens, n=3)
output_texts, _ = engine.generate(prompts[:num_requests], generation_config)
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
if compare_precision:
precision_flag = compare_output_text(output_texts, output_texts_single_model)
if precision_flag:
print("Accuracy verification succeed\n")
else:
print("Accuracy verification failed\n")
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_engine_eagle_generate(model: str):
# Create engine
small_model = "dist/Eagle-llama2-7b-chat-q4f16_1-MLC"
small_model_lib = (
"dist/Eagle-llama2-7b-chat-q4f16_1-MLC/Eagle-llama2-7b-chat-q4f16_1-MLC-cuda.so"
)
engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
additional_models=[(small_model, small_model_lib)],
speculative_mode="eagle",
),
)
num_requests = 10
max_tokens = 256
# Generate output.
output_texts, _ = engine.generate(
prompts[:num_requests], GenerationConfig(max_tokens=max_tokens, n=3)
)
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
@require_test_model("Llama-2-13b-chat-hf-q4f16_1-MLC")
def test_engine_efficiency(model: str):
"""Test engine speculative decoding efficiency."""
# Hyperparameters for tests (you can try different combinations).
num_requests = 1 # [4, 8, 10]
temperature = 0.9 # [0, 0.8, 0.9, 1.0, 1.1]
repetition_penalty = 1.0 # [1.0, 1.01]
max_tokens: int = 512
np.random.seed(0)
# Output list
outputs: List[List[int]] = [[] for _ in range(num_requests)] # noqa: UP006
# Define the callback function for request generation results
def fcallback(delta_outputs: List[RequestStreamOutput]): # noqa: UP006
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
assert len(stream_outputs) == 1
outputs[int(request_id)] += stream_outputs[0].delta_token_ids
# Create engine
engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(max_total_sequence_length=4096),
request_stream_callback=fcallback,
)
# Create requests
requests = create_requests(
num_requests,
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens_low=max_tokens,
max_tokens_high=max_tokens + 1,
)
# Add all requests to engine
for request in requests:
engine.add_request(request)
num_steps = num_requests + max_tokens - 1
# Run steps
for step in range(num_steps):
engine.step()
for eg, name in zip([engine], ["Normal Deconding"]):
metrics = eg.metrics()
print("engine name:", name)
if name == "Speculative Decoding":
print("spec decode metrics:", metrics["spec_decode"])
print("engine total decode time:", metrics["engine_decode_time_sum"])
print()
@require_test_model(
"Llama-2-13b-chat-hf-q4f16_1-MLC",
"Llama-2-7b-chat-hf-q4f16_1-MLC",
)
def test_engine_spec_efficiency(model: str, small_model: str):
"""Test engine speculative decoding efficiency."""
# Hyperparameters for tests (you can try different combinations).
num_requests = 1 # [4, 8, 10]
temperature = 0.9 # [0, 0.8, 0.9, 1.0, 1.1]
repetition_penalty = 1.0 # [1.0, 1.01]
max_tokens: int = 512
np.random.seed(0)
# Output list
outputs: List[List[int]] = [[] for _ in range(num_requests)] # noqa: UP006
# Define the callback function for request generation results
def fcallback(delta_outputs: List[RequestStreamOutput]): # noqa: UP006
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
assert len(stream_outputs) == 1
outputs[int(request_id)] += stream_outputs[0].delta_token_ids
# Create engine
spec_engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
additional_models=[small_model],
spec_draft_length=6,
speculative_mode="small_draft",
),
request_stream_callback=fcallback,
)
# Create requests
requests = create_requests(
num_requests,
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens_low=max_tokens,
max_tokens_high=max_tokens + 1,
)
# Add all requests to engine
for request in requests:
spec_engine.add_request(request)
num_steps = num_requests + max_tokens - 1
# Run steps
for step in range(num_steps):
spec_engine.step()
for eg, name in zip([spec_engine], ["Speculative Decoding"]):
metrics = eg.metrics()
print("engine name:", name)
if name == "Speculative Decoding":
print("total draft tokens:", metrics["sum_num_draft_tokens"])
print("total accepted tokens:", metrics["sum_num_accepted_tokens"])
print(
"Accept rate:",
metrics["sum_num_accepted_tokens"] / (1e-10 + metrics["sum_num_draft_tokens"]),
)
print("engine total decode time:", metrics["engine_decode_time_sum"])
print()
@require_test_model("Llama-2-7b-chat-hf-q4f16_1-MLC")
def test_engine_eagle_spec_efficiency(model: str):
"""Test engine speculative decoding efficiency."""
# Hyperparameters for tests (you can try different combinations).
num_requests = 1 # [4, 8, 10]
temperature = 0.9 # [0, 0.8, 0.9, 1.0, 1.1]
repetition_penalty = 1.0 # [1.0, 1.01]
max_tokens: int = 512
np.random.seed(0)
# Output list
outputs: List[List[int]] = [[] for _ in range(num_requests)] # noqa: UP006
# Define the callback function for request generation results
def fcallback(delta_outputs: List[RequestStreamOutput]): # noqa: UP006
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
assert len(stream_outputs) == 1
outputs[int(request_id)] += stream_outputs[0].delta_token_ids
# Create engine
small_model = "dist/Eagle-llama2-7b-chat-q0f16-MLC"
small_model_lib = "dist/Eagle-llama2-7b-chat-q0f16-MLC/Eagle-llama2-7b-chat-q0f16-MLC-cuda.so"
spec_engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(
max_total_sequence_length=4096,
additional_models=[(small_model, small_model_lib)],
spec_draft_length=6,
speculative_mode="eagle",
),
request_stream_callback=fcallback,
)
# Create requests
requests = create_requests(
num_requests,
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens_low=max_tokens,
max_tokens_high=max_tokens + 1,
)
# Add all requests to engine
for request in requests:
spec_engine.add_request(request)
num_steps = num_requests + max_tokens - 1
# Run steps
for step in range(num_steps):
spec_engine.step()
for eg, name in zip([spec_engine], ["Speculative Decoding"]):
metrics = eg.metrics()
print("engine name:", name)
if name == "Speculative Decoding":
print("spec decode:", metrics["spec_decode"])
print("engine total decode time:", metrics["engine_decode_time_sum"])
print()
if __name__ == "__main__":
test_engine_basic()
test_engine_eagle_basic()
test_engine_continuous_batching_1()
test_engine_eagle_continuous_batching_1()
test_engine_generate(compare_precision=True)
test_engine_eagle_generate()
test_engine_efficiency()
test_engine_spec_efficiency()
test_engine_eagle_spec_efficiency()
@@ -0,0 +1,474 @@
from typing import Callable, List, Optional # noqa: UP035
import numpy as np
from mlc_llm.protocol.generation_config import GenerationConfig
from mlc_llm.serve import Request, RequestStreamOutput, data
from mlc_llm.serve.sync_engine import EngineConfig, SyncMLCEngine
from mlc_llm.testing import require_test_model
prompts = [
"What is the meaning of life?",
"Introduce the history of Pittsburgh to me. Please elaborate in detail.",
"Write a three-day Seattle travel plan. Please elaborate in detail.",
"What is Alaska famous of? Please elaborate in detail.",
"What is the difference between Lambda calculus and Turing machine? Please elaborate in detail.", # noqa: E501
"What are the necessary components to assemble a desktop computer? Please elaborate in detail.",
"Why is Vitamin D important to human beings? Please elaborate in detail.",
"Where is milk tea originated from? Please elaborate in detail.",
"Where is the southernmost place in United States? Please elaborate in detail.",
"Do you know AlphaGo? What capabilities does it have, and what achievements has it got? Please elaborate in detail.", # noqa: E501
]
def create_requests(
engine: SyncMLCEngine,
num_requests: int,
stop_token_id: Optional[int] = None,
temperature: float = 0.8,
repetition_penalty: float = 1.0,
max_tokens_low: int = 256,
max_tokens_high: int = 257,
) -> List[Request]: # noqa: UP006
assert num_requests >= 0 and num_requests <= len(prompts)
stop_token_ids = [stop_token_id] if stop_token_id is not None else []
requests = []
for req_id, prompt in zip(range(num_requests), prompts):
max_tokens = np.random.randint(max_tokens_low, max_tokens_high)
requests.append(
engine.create_request(
request_id=str(req_id),
inputs=data.TextData(prompt),
generation_config=GenerationConfig(
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens=max_tokens,
stop_token_ids=stop_token_ids,
),
)
)
return requests
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_engine_basic(model: str):
"""Test engine **without continuous batching**.
- Add all requests to the engine altogether in the beginning.
- All requests have the same max_tokens. This means all requests
will end together.
- Engine keeps running `step` for estimated number of steps (number of
requests + max_tokens - 1). Then check the output of each request.
"""
# Hyperparameters for tests (you can try different combinations).
num_requests = 10 # [4, 8, 10]
temperature = 0.9 # [0, 0.8, 0.9, 1.0, 1.1]
repetition_penalty = 1.0 # [1.0, 1.01]
max_tokens: int = 256 # [32, 128, 256]
np.random.seed(0)
# Output list
outputs: List[List[int]] = [[] for _ in range(num_requests)] # noqa: UP006
# Define the callback function for request generation results
def fcallback(delta_outputs: List[RequestStreamOutput]): # noqa: UP006
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
assert len(stream_outputs) == 1
outputs[int(request_id)] += stream_outputs[0].delta_token_ids
# Create engine
engine = SyncMLCEngine(
model=model,
mode="server",
request_stream_callback=fcallback,
)
# Create requests
requests = create_requests(
engine,
num_requests,
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens_low=max_tokens,
max_tokens_high=max_tokens + 1,
)
# Add all requests to engine
for request in requests:
engine.add_request(request)
num_steps = num_requests + max_tokens - 1
# Run steps
for step in range(num_steps):
engine.step()
for req_id, output in enumerate(outputs):
print(f"Prompt {req_id}: {requests[req_id].inputs[0]}")
print(f"Output {req_id}:{engine.tokenizer.decode(output)}\n")
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_engine_continuous_batching_1(model: str):
"""Test engine **with continuous batching**.
- Add all requests to the engine altogether in the beginning.
- All requests have a random maximum generation length. So each
request keeps generating until reaching the maximum length.
- Engine keeps running `step` for estimated number of steps (number of
requests + the maximum max_tokens - 1). Then check the output
of each request.
"""
# Hyperparameters for tests (you can try different combinations)
num_requests = 10 # [4, 8, 10]
temperature = 0.9 # [0.8, 0.9, 1.0, 1.1]
repetition_penalty = 1.00 # [1.0, 1.01]
max_tokens_low = 128
max_tokens_high = 384
np.random.seed(0)
# Output list
outputs: List[List[int]] = [[] for _ in range(num_requests)] # noqa: UP006
finish_time: List[Optional[int]] = [None] * num_requests # noqa: UP006
# Define the callback class for request generation results
class CallbackTimer:
timer: int = -1
def callback_getter(self) -> Callable[[List[RequestStreamOutput]], None]: # noqa: UP006
def fcallback(delta_outputs: List[RequestStreamOutput]): # noqa: UP006
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
assert len(stream_outputs) == 1
if stream_outputs[0].finish_reason is not None:
print(f"Request {request_id} finished at step {self.timer}.")
outputs[int(request_id)] += stream_outputs[0].delta_token_ids
finish_time[int(request_id)] = self.timer
return fcallback
def step(self) -> None:
self.timer += 1
# Create engine
timer = CallbackTimer()
engine = SyncMLCEngine(
model=model,
mode="server",
request_stream_callback=timer.callback_getter(),
)
# Create requests
requests = create_requests(
engine,
num_requests,
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens_low=max_tokens_low,
max_tokens_high=max_tokens_high,
)
# Add all requests to engine
for request in requests:
engine.add_request(request)
num_steps = num_requests + max(request.generation_config.max_tokens for request in requests) - 1
# Run steps
for step in range(num_steps):
timer.step()
assert timer.timer == step
engine.step()
for req_id, (request, output, fin_time) in enumerate(zip(requests, outputs, finish_time)):
print(f"Prompt {req_id}: {request.inputs[0]}")
print(f"Output {req_id}:{engine.tokenizer.decode(output)}\n")
assert fin_time == request.generation_config.max_tokens - 1, (
f"finish time = {fin_time}, max tokens = {request.generation_config.max_tokens - 1}"
)
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_engine_continuous_batching_2(model: str):
"""Test engine **with continuous batching**.
- Add all requests to the engine altogether in the beginning.
- All requests have the stop token. So each request keeps generating
until having the stop token or reaching the maximum length.
- Engine keeps running `step` for estimated number of steps (number of
requests + the maximum max_tokens - 1). Then check the output
of each request.
"""
# Hyperparameters for tests (you can try different combinations)
num_requests = 10 # [4, 8, 10]
temperature = 0.9 # [0.8, 0.9, 1.0, 1.1]
repetition_penalty = 1.00 # [1.0, 1.01]
stop_token_id = 2
max_tokens = 512
np.random.seed(0)
# Output list
outputs: List[List[int]] = [[] for _ in range(num_requests)] # noqa: UP006
finish_time: List[Optional[int]] = [None] * num_requests # noqa: UP006
# Define the callback class for request generation results
class CallbackTimer:
timer: int = -1
def callback_getter(self) -> Callable[[List[RequestStreamOutput]], None]: # noqa: UP006
def fcallback(delta_outputs: List[RequestStreamOutput]): # noqa: UP006
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
assert len(stream_outputs) == 1
if stream_outputs[0].finish_reason is not None:
print(f"Request {request_id} finished at step {self.timer}.")
outputs[int(request_id)] += stream_outputs[0].delta_token_ids
finish_time[int(request_id)] = self.timer
return fcallback
def step(self) -> None:
self.timer += 1
# Create engine
timer = CallbackTimer()
engine = SyncMLCEngine(
model=model,
mode="server",
request_stream_callback=timer.callback_getter(),
)
# Create requests
requests = create_requests(
engine,
num_requests,
stop_token_id=stop_token_id,
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens_low=max_tokens,
max_tokens_high=max_tokens + 1,
)
# Add all requests to engine
for request in requests:
engine.add_request(request)
num_steps = num_requests + max_tokens - 1
# Run steps
for step in range(num_steps):
timer.step()
assert timer.timer == step
engine.step()
for req_id, (request, output, fin_time) in enumerate(zip(requests, outputs, finish_time)):
print(f"Prompt {req_id}: {request.inputs[0]}")
if fin_time < num_requests + max_tokens - 2:
print(f"Request {req_id} ends early on the stop token")
print(f"Output {req_id}:{engine.tokenizer.decode(output)}\n")
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_engine_continuous_batching_3(model: str):
"""Test engine **with continuous batching**.
- Add requests randomly between time [0, 200).
- All requests have a random maximum generation length. So each
request keeps generating until reaching the maximum length.
- Engine keeps running `step` until all requests finish.
Then check the output of each request.
"""
# Hyperparameters for tests (you can try different combinations)
num_requests = 10 # [4, 8, 10]
temperature = 0.9 # [0.8, 0.9, 1.0, 1.1]
repetition_penalty = 1.00 # [1.0, 1.01]
stop_token_id = 2
max_tokens_low = 64
max_tokens_high = 192
np.random.seed(0)
# Output list
outputs: List[List[int]] = [[] for _ in range(num_requests)] # noqa: UP006
finish_time: List[Optional[int]] = [None] * num_requests # noqa: UP006
# Define the callback class for request generation results
class CallbackTimer:
timer: int = -1
finished_requests: int = 0
def callback_getter(self) -> Callable[[List[RequestStreamOutput]], None]: # noqa: UP006
def fcallback(delta_outputs: List[RequestStreamOutput]): # noqa: UP006
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
assert len(stream_outputs) == 1
if stream_outputs[0].finish_reason is not None:
print(f"Request {request_id} finished at step {self.timer}.")
self.finished_requests += 1
outputs[int(request_id)] += stream_outputs[0].delta_token_ids
finish_time[int(request_id)] = self.timer
return fcallback
def step(self) -> None:
self.timer += 1
def all_finished(self) -> bool:
return self.finished_requests == num_requests
# Create engine
timer = CallbackTimer()
engine = SyncMLCEngine(
model=model,
mode="server",
request_stream_callback=timer.callback_getter(),
)
# Create requests
requests = create_requests(
engine,
num_requests,
stop_token_id=stop_token_id,
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens_low=max_tokens_low,
max_tokens_high=max_tokens_high,
)
# Assign the time to add requests to engine
request_add_time = [np.random.randint(0, 200) for _ in range(num_requests)]
# Run steps
while not timer.all_finished():
timer.step()
# Add requests to engine
for req_id, add_time in enumerate(request_add_time):
if add_time == timer.timer:
print(f"add request {req_id} at step {timer.timer}")
engine.add_request(requests[req_id])
engine.step()
for req_id, (request, output, fin_time) in enumerate(zip(requests, outputs, finish_time)):
print(f"Prompt {req_id}: {request.inputs[0]}")
print(f"Finish time: {fin_time}")
print(f"Output {req_id}:{engine.tokenizer.decode(output)}\n")
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_engine_generate(model: str):
# Create engine
engine = SyncMLCEngine(
model=model,
mode="server",
engine_config=EngineConfig(max_total_sequence_length=4096),
)
num_requests = 10
max_tokens = 256
# Generate output.
output_texts, _ = engine.generate(
prompts[:num_requests], GenerationConfig(max_tokens=max_tokens, n=7)
)
for req_id, outputs in enumerate(output_texts):
print(f"Prompt {req_id}: {prompts[req_id]}")
if len(outputs) == 1:
print(f"Output {req_id}:{outputs[0]}\n")
else:
for i, output in enumerate(outputs):
print(f"Output {req_id}({i}):{output}\n")
@require_test_model("Llama-2-7b-chat-hf-q0f16-MLC")
def test_engine_hybrid_prefill(model: str):
"""Test engine **with hybrid prefill**.
- Add each single request step by step.
- All requests have the same generation length. But due to hybrid prefill,
the earlier request will decode with later request prefill, in single step.
So each request lasts the same steps, and stops generation step by step as well.
- Engine keeps running `step` for the generation length, to finish the last request.
Then check the output of each request.
"""
# Hyperparameters for tests (you can try different combinations)
num_requests = 10 # [4, 8, 10]
temperature = 0.9 # [0.8, 0.9, 1.0, 1.1]
repetition_penalty = 1.00 # [1.0, 1.01]
max_tokens = 15
np.random.seed(0)
# Output list
outputs: List[List[int]] = [[] for _ in range(num_requests)] # noqa: UP006
finish_time: List[Optional[int]] = [None] * num_requests # noqa: UP006
# Define the callback class for request generation results
class CallbackTimer:
timer: int = -1
def callback_getter(self) -> Callable[[List[RequestStreamOutput]], None]: # noqa: UP006
def fcallback(delta_outputs: List[RequestStreamOutput]): # noqa: UP006
for delta_output in delta_outputs:
request_id, stream_outputs = delta_output.unpack()
assert len(stream_outputs) == 1
if stream_outputs[0].finish_reason is not None:
print(f"Request {request_id} finished at step {self.timer}.")
outputs[int(request_id)] += stream_outputs[0].delta_token_ids
finish_time[int(request_id)] = self.timer
return fcallback
def step(self) -> None:
self.timer += 1
# Create engine
timer = CallbackTimer()
engine = SyncMLCEngine(
model=model,
mode="server",
request_stream_callback=timer.callback_getter(),
engine_config=EngineConfig(prefill_mode="hybrid"),
)
# Create requests
requests = create_requests(
engine,
num_requests,
temperature=temperature,
repetition_penalty=repetition_penalty,
max_tokens_low=max_tokens,
max_tokens_high=max_tokens + 1,
)
# Add all requests to engine step by step
for step, request in enumerate(requests):
engine.add_request(request)
timer.step()
assert timer.timer == step
engine.step()
# Run steps
for step in range(max_tokens):
timer.step()
assert timer.timer == step + num_requests
engine.step()
for req_id, (request, output, fin_time) in enumerate(zip(requests, outputs, finish_time)):
print(f"Prompt {req_id}: {request.inputs[0]}")
print(f"Output {req_id}:{engine.tokenizer.decode(output)}\n")
assert fin_time == req_id + request.generation_config.max_tokens - 1, (
f"finish time = {fin_time}, max tokens = {req_id + request.generation_config.max_tokens - 1}" # noqa: E501
)
if __name__ == "__main__":
test_engine_basic()
test_engine_continuous_batching_1()
test_engine_continuous_batching_2()
test_engine_continuous_batching_3()
test_engine_generate()
test_engine_hybrid_prefill()
+43
View File
@@ -0,0 +1,43 @@
import json
import tempfile
from pathlib import Path
import pytest
from mlc_llm.support import logging
from mlc_llm.support.auto_config import detect_config
logging.enable_logging()
# test category "unittest"
pytestmark = [pytest.mark.unittest]
def _create_json_file(json_path, data):
with open(json_path, "w", encoding="utf-8") as i_f:
json.dump(data, i_f)
def test_detect_config():
with tempfile.TemporaryDirectory() as tmpdir:
base_path = Path(tmpdir)
config_json_path = base_path / "config.json"
_create_json_file(config_json_path, {})
assert detect_config(base_path) == config_json_path
assert detect_config(config_json_path) == config_json_path
def test_detect_config_fail():
with pytest.raises(ValueError):
detect_config(Path("do/not/exist"))
with tempfile.TemporaryDirectory() as tmpdir:
base_path = Path(tmpdir)
with pytest.raises(ValueError):
assert detect_config(base_path)
if __name__ == "__main__":
test_detect_config()
test_detect_config_fail()
+37
View File
@@ -0,0 +1,37 @@
import pytest
from tvm.target import Target
from mlc_llm.support.auto_target import _apply_webgpu_subgroups
# test category "unittest"
pytestmark = [pytest.mark.unittest]
def test_apply_webgpu_subgroups_enables_webgpu_target():
target = Target("webgpu")
updated = _apply_webgpu_subgroups(target, True)
assert updated is not target
assert dict(target.export())["supports_subgroups"] is False
assert dict(updated.export())["supports_subgroups"] is True
def test_apply_webgpu_subgroups_non_webgpu_target_is_unchanged():
target = Target("llvm")
updated = _apply_webgpu_subgroups(target, True)
assert updated is target
assert dict(updated.export()) == dict(target.export())
@pytest.mark.parametrize("target_kind", ["webgpu", "llvm"])
@pytest.mark.parametrize("enable_subgroups", [False, None])
def test_apply_webgpu_subgroups_disabled_is_unchanged(target_kind, enable_subgroups):
target = Target(target_kind)
updated = _apply_webgpu_subgroups(target, enable_subgroups)
assert updated is target
assert dict(updated.export()) == dict(target.export())
+150
View File
@@ -0,0 +1,150 @@
import json
import os
import tempfile
from pathlib import Path
import pytest
from mlc_llm.support import logging
from mlc_llm.support.auto_weight import detect_weight
logging.enable_logging()
# test category "unittest"
pytestmark = [pytest.mark.unittest]
def _create_json_file(json_path, data):
with open(json_path, "w", encoding="utf-8") as i_f:
json.dump(data, i_f)
@pytest.mark.parametrize(
"weight_format, index_filename, result",
[
("huggingface-torch", "pytorch_model.bin.index.json", "huggingface-torch"),
(
"huggingface-safetensor",
"model.safetensors.index.json",
"huggingface-safetensor",
),
("auto", "pytorch_model.bin.index.json", "huggingface-torch"),
("auto", "model.safetensors.index.json", "huggingface-safetensor"),
],
)
def test_detect_weight(weight_format, index_filename, result):
with tempfile.TemporaryDirectory() as tmpdir:
base_path = Path(tmpdir)
if index_filename is not None:
weight_index_file = base_path / index_filename
_create_json_file(weight_index_file, {})
assert detect_weight(base_path, None, weight_format) == (
weight_index_file,
result,
)
@pytest.mark.parametrize(
"weight_format, index_filename, result",
[
("huggingface-torch", "pytorch_model.bin.index.json", "huggingface-torch"),
(
"huggingface-safetensor",
"model.safetensors.index.json",
"huggingface-safetensor",
),
("auto", "pytorch_model.bin.index.json", "huggingface-torch"),
("auto", "model.safetensors.index.json", "huggingface-safetensor"),
],
)
def test_detect_weight_in_config_json(weight_format, index_filename, result):
with (
tempfile.TemporaryDirectory() as config_dir,
tempfile.TemporaryDirectory() as weight_dir,
):
config_path = Path(config_dir)
weight_path = Path(weight_dir)
config_json_path = config_path / "config.json"
_create_json_file(config_json_path, {"weight_path": weight_dir})
if index_filename is not None:
weight_index_file = weight_path / index_filename
_create_json_file(weight_index_file, {})
assert detect_weight(None, config_json_path, weight_format) == (
weight_index_file,
result,
)
@pytest.mark.parametrize(
"weight_format, index_filename, result",
[
("huggingface-torch", "pytorch_model.bin.index.json", "huggingface-torch"),
(
"huggingface-safetensor",
"model.safetensors.index.json",
"huggingface-safetensor",
),
("auto", "pytorch_model.bin.index.json", "huggingface-torch"),
("auto", "model.safetensors.index.json", "huggingface-safetensor"),
],
)
def test_detect_weight_same_dir_config_json(weight_format, index_filename, result):
with tempfile.TemporaryDirectory() as tmpdir:
base_path = Path(tmpdir)
config_json_path = base_path / "config.json"
_create_json_file(config_json_path, {})
if index_filename is not None:
weight_index_file = Path(os.path.join(tmpdir, index_filename))
_create_json_file(weight_index_file, {})
assert detect_weight(None, config_json_path, weight_format) == (
weight_index_file,
result,
)
def test_find_weight_fail():
with tempfile.TemporaryDirectory() as tmpdir:
base_path = Path(tmpdir)
with pytest.raises(ValueError):
detect_weight(Path("do/not/exist"), base_path, "awq")
with pytest.raises(AssertionError):
detect_weight(None, Path("do/not/exist"), "awq")
if __name__ == "__main__":
test_detect_weight("huggingface-torch", "pytorch_model.bin.index.json", "huggingface-torch")
test_detect_weight(
"huggingface-safetensor",
"model.safetensors.index.json",
"huggingface-safetensor",
)
test_detect_weight("auto", "pytorch_model.bin.index.json", "huggingface-torch")
test_detect_weight("auto", "model.safetensors.index.json", "huggingface-safetensor")
test_detect_weight_in_config_json(
"huggingface-torch", "pytorch_model.bin.index.json", "huggingface-torch"
)
test_detect_weight_in_config_json(
"huggingface-safetensor",
"model.safetensors.index.json",
"huggingface-safetensor",
)
test_detect_weight_in_config_json("auto", "pytorch_model.bin.index.json", "huggingface-torch")
test_detect_weight_in_config_json(
"auto", "model.safetensors.index.json", "huggingface-safetensor"
)
test_detect_weight_same_dir_config_json(
"huggingface-torch", "pytorch_model.bin.index.json", "huggingface-torch"
)
test_detect_weight_same_dir_config_json(
"huggingface-safetensor",
"model.safetensors.index.json",
"huggingface-safetensor",
)
test_detect_weight_same_dir_config_json(
"auto", "pytorch_model.bin.index.json", "huggingface-torch"
)
test_detect_weight_same_dir_config_json(
"auto", "model.safetensors.index.json", "huggingface-safetensor"
)
test_find_weight_fail()
@@ -0,0 +1,69 @@
import json
import tempfile
from pathlib import Path
import pytest
from mlc_llm.cli import convert_weight as convert_weight_cli
pytestmark = [pytest.mark.unittest]
def test_convert_weight_cli_passes_lora_adapter(monkeypatch):
with tempfile.TemporaryDirectory() as tmp_dir:
temp_path = Path(tmp_dir)
config_path = temp_path / "config.json"
source_dir = temp_path / "source"
source_dir.mkdir(parents=True, exist_ok=True)
source_index = source_dir / "pytorch_model.bin.index.json"
adapter_dir = temp_path / "adapter"
adapter_dir.mkdir(parents=True, exist_ok=True)
output_dir = temp_path / "output"
config_path.write_text(json.dumps({}), encoding="utf-8")
source_index.write_text(json.dumps({"weight_map": {}}), encoding="utf-8")
def _fake_detect_device(device):
return device
def _fake_detect_weight(_weight_path, _config_json_path, _weight_format):
return source_index, "huggingface-torch"
def _fake_detect_model_type(_model_type, _config):
return "dummy"
monkeypatch.setattr(convert_weight_cli, "detect_config", Path)
monkeypatch.setattr(convert_weight_cli, "detect_device", _fake_detect_device)
monkeypatch.setattr(convert_weight_cli, "detect_weight", _fake_detect_weight)
monkeypatch.setattr(convert_weight_cli, "detect_model_type", _fake_detect_model_type)
monkeypatch.setattr(convert_weight_cli, "MODELS", {"dummy": object()})
monkeypatch.setattr(convert_weight_cli, "QUANTIZATION", {"q0f16": object()})
call_args = {}
def _fake_convert_weight(**kwargs):
call_args.update(kwargs)
monkeypatch.setattr(convert_weight_cli, "convert_weight", _fake_convert_weight)
convert_weight_cli.main(
[
str(config_path),
"--quantization",
"q0f16",
"--model-type",
"dummy",
"--source",
str(source_dir),
"--source-format",
"auto",
"--output",
str(output_dir),
"--lora-adapter",
str(adapter_dir),
]
)
assert call_args["lora_adapter"] == adapter_dir
assert call_args["source"] == source_index
assert call_args["source_format"] == "huggingface-torch"
@@ -0,0 +1,108 @@
import contextlib
import json
import tempfile
from pathlib import Path
import pytest
from mlc_llm.interface import convert_weight as convert_weight_interface
pytestmark = [pytest.mark.unittest]
def test_resolve_base_model_dir():
with tempfile.TemporaryDirectory() as tmp_dir:
temp_path = Path(tmp_dir)
model_dir = temp_path / "model"
model_dir.mkdir(parents=True, exist_ok=True)
source_file = model_dir / "pytorch_model.bin.index.json"
source_file.write_text(json.dumps({"weight_map": {}}), encoding="utf-8")
assert convert_weight_interface._resolve_base_model_dir(model_dir) == model_dir
assert convert_weight_interface._resolve_base_model_dir(source_file) == model_dir
def test_convert_weight_with_lora_uses_merged_source(monkeypatch):
with tempfile.TemporaryDirectory() as tmp_dir:
temp_path = Path(tmp_dir)
config_path = temp_path / "config.json"
config_path.write_text(json.dumps({}), encoding="utf-8")
source_dir = temp_path / "source"
source_dir.mkdir(parents=True, exist_ok=True)
source_file = source_dir / "pytorch_model.bin.index.json"
source_file.write_text(json.dumps({"weight_map": {}}), encoding="utf-8")
adapter_dir = temp_path / "adapter"
adapter_dir.mkdir(parents=True, exist_ok=True)
merged_dir = temp_path / "merged"
merged_dir.mkdir(parents=True, exist_ok=True)
merged_file = merged_dir / "pytorch_model.bin"
merged_file.write_bytes(b"")
captured = {}
@contextlib.contextmanager
def _fake_merge(base_source: Path, lora_adapter: Path):
captured["merge_base_source"] = base_source
captured["merge_lora_adapter"] = lora_adapter
yield merged_dir
def _fake_detect_weight(weight_path: Path, config_json_path: Path, weight_format: str):
captured["detect_weight_path"] = weight_path
captured["detect_weight_config"] = config_json_path
captured["detect_weight_format"] = weight_format
return merged_file, "huggingface-torch"
def _fake_convert_args(args):
captured["converted_args"] = args
monkeypatch.setattr(
convert_weight_interface, "_merge_lora_adapter_with_base_model", _fake_merge
)
monkeypatch.setattr(convert_weight_interface, "detect_weight", _fake_detect_weight)
monkeypatch.setattr(convert_weight_interface, "_convert_args", _fake_convert_args)
monkeypatch.setattr(convert_weight_interface.ConversionArgs, "display", lambda self: None)
convert_weight_interface.convert_weight(
config=config_path,
quantization=object(),
model=type("DummyModel", (), {"name": "dummy"})(),
device=object(),
source=source_file,
source_format="huggingface-safetensor",
output=temp_path / "output",
lora_adapter=adapter_dir,
)
converted_args = captured["converted_args"]
assert captured["merge_base_source"] == source_file
assert captured["merge_lora_adapter"] == adapter_dir
assert captured["detect_weight_path"] == merged_dir
assert captured["detect_weight_config"] == config_path
assert captured["detect_weight_format"] == "auto"
assert converted_args.source == merged_file
assert converted_args.source_format == "huggingface-torch"
assert converted_args.lora_adapter == adapter_dir
def test_convert_weight_with_lora_rejects_awq():
with tempfile.TemporaryDirectory() as tmp_dir:
temp_path = Path(tmp_dir)
config_path = temp_path / "config.json"
config_path.write_text(json.dumps({}), encoding="utf-8")
adapter_dir = temp_path / "adapter"
adapter_dir.mkdir(parents=True, exist_ok=True)
with pytest.raises(ValueError, match="only supports source formats"):
convert_weight_interface.convert_weight(
config=config_path,
quantization=object(),
model=type("DummyModel", (), {"name": "dummy"})(),
device=object(),
source=temp_path / "source",
source_format="awq",
output=temp_path / "output",
lora_adapter=adapter_dir,
)
+192
View File
@@ -0,0 +1,192 @@
"""Streamer tests in MLC LLM.
Please specify the local path to llama2 tokenizer via environment
variable before running this test.
The recommended way to run the tests is to use the following command:
MLC_LLAMA_TOKENIZER_PATH="path/to/llama/tokenizer" \
pytest -vv tests/python/support/test_text_streamer_stop_handler.py
Here "MLC_LLAMA_TOKENIZER_PATH" can be chosen from
- a llama2 weight directory (e.g., "path/to/Llama-2-7b-chat-hf"),
- a sentencepiece llama2 tokenizer path
(e.g., "path/to/Llama-2-7b-chat-hf/tokenizer.model").
To directly run the Python file (a.k.a., not using pytest), you also need to
specify the tokenizer path via environment variable.
"""
import time
from typing import List, Tuple # noqa: UP035
import pytest
from mlc_llm.testing import require_test_tokenizers
from mlc_llm.tokenizers import StopStrHandler, TextStreamer, Tokenizer
# test category "unittest"
pytestmark = [pytest.mark.unittest]
# fmt: off
para_input_tokens = [18585, 29892, 1244, 29915, 29879, 263, 3273, 14880, 1048, 953, 29877, 2397,
29892, 988, 1269, 1734, 338, 5643, 491, 385, 953, 29877, 2397, 29901, 13, 13,
29950, 1032, 727, 29991, 29871, 243, 162, 148, 142, 306, 29915, 29885, 1244, 304,
1371, 1234, 738, 5155, 366, 505, 1048, 953, 29877, 2397, 29871, 243, 162, 167, 151,
29889, 7440, 366, 1073, 393, 953, 29877, 2397, 508, 367, 1304, 304, 27769, 23023,
1080, 322, 21737, 297, 263, 2090, 322, 1708, 1319, 982, 29973, 29871, 243, 162, 155,
135, 2688, 508, 884, 367, 1304, 304, 788, 263, 6023, 310, 2022, 2877, 304, 596, 7191,
322, 11803, 29889, 29871, 243, 162, 149, 152, 1126, 29892, 1258, 366, 1073, 393, 727,
526, 1584, 953, 29877, 2397, 8090, 322, 14188, 366, 508, 1708, 29973, 29871, 243, 162,
145, 177, 243, 162, 148, 131, 1105, 29892, 748, 14432, 322, 679, 907, 1230, 411, 953,
29877, 2397, 29991, 29871, 243, 162, 149, 168, 243, 162, 145, 171]
DECODED_PARAGRAPH = (
"Sure, here's a short paragraph about emoji, "
"where each word is followed by an emoji:\n\n"
"Hey there! 👋 I'm here to help answer any questions you have about emoji 🤔. "
"Did you know that emoji can be used to convey emotions and feelings in a "
"fun and playful way? 😄 "
"They can also be used to add a touch of personality to your messages and posts. 💕 "
"And, did you know that there are even emoji games and activities you can play? 🎮👀 "
"So, go ahead and get creative with emoji! 💥🎨"
)
# fmt: on
@require_test_tokenizers("Llama-2-7b-chat-hf-q4f16_1-MLC")
def test_text_streamer(llama_tokenizer_path: str):
text_streamer = TextStreamer(Tokenizer(llama_tokenizer_path))
total_text = ""
for token in para_input_tokens:
total_text += text_streamer.put([token])
total_text += text_streamer.finish()
assert total_text == DECODED_PARAGRAPH
def stop_handler_process_tokens(
stop_handler: StopStrHandler,
tokens: List[int], # noqa: UP006
tokenizer: Tokenizer,
) -> str:
returned_tokens = []
for token in tokens:
returned_tokens += stop_handler.put(token)
if stop_handler.stop_triggered:
break
if not stop_handler.stop_triggered:
returned_tokens += stop_handler.finish()
return tokenizer.decode(returned_tokens)
@require_test_tokenizers("Llama-2-7b-chat-hf-q4f16_1-MLC")
def test_stop_str_handler_stop(llama_tokenizer_path: str):
stop_strs = [" 🤔"]
tokenizer = Tokenizer(llama_tokenizer_path)
stop_handler = StopStrHandler(stop_strs, tokenizer)
total_text = stop_handler_process_tokens(stop_handler, para_input_tokens, tokenizer)
expected_text = (
"Sure, here's a short paragraph about emoji, "
"where each word is followed by an emoji:\n\n"
"Hey there! 👋 I'm here to help answer any questions you have about emoji"
)
assert total_text == expected_text
@require_test_tokenizers("Llama-2-7b-chat-hf-q4f16_1-MLC")
def test_stop_str_handler_not_stop(
llama_tokenizer_path: str,
):
stop_strs = ["^^"]
tokenizer = Tokenizer(llama_tokenizer_path)
stop_handler = StopStrHandler(stop_strs, tokenizer)
total_text = stop_handler_process_tokens(stop_handler, para_input_tokens, tokenizer)
assert total_text == DECODED_PARAGRAPH
@require_test_tokenizers("Llama-2-7b-chat-hf-q4f16_1-MLC")
def test_stop_str_handler_return_cached_tokens(
llama_tokenizer_path: str,
):
tokens = para_input_tokens[:26] # until "\n\n"
stop_strs = ["\n\n\n"]
tokenizer = Tokenizer(llama_tokenizer_path)
stop_handler = StopStrHandler(stop_strs, tokenizer)
total_text = stop_handler_process_tokens(stop_handler, tokens, tokenizer)
expected_text = (
"Sure, here's a short paragraph about emoji, where each word is followed by an emoji:\n\n"
)
assert total_text == expected_text
@require_test_tokenizers("Llama-2-7b-chat-hf-q4f16_1-MLC")
def test_stop_str_handler_throughput(
llama_tokenizer_path: str,
):
stop_strs = ["[INST]"]
tokenizer = Tokenizer(llama_tokenizer_path)
stop_handler = StopStrHandler(stop_strs, tokenizer)
tokens = para_input_tokens * 20
returned_tokens = []
tbegin = time.perf_counter()
for token in tokens:
returned_tokens += stop_handler.put(token)
assert not stop_handler.stop_triggered
tend = time.perf_counter()
throughput = len(tokens) / (tend - tbegin)
print(
f"num tokens = {len(tokens)}, "
f"time elapsed = {tend - tbegin:.5f} sec, "
f"throughput = {throughput}"
)
assert throughput >= 100000
emoji_tokens_expected_result = [
# HF: "", SentencePiece: "👀"
([177, 243, 162, 148, 131], ("", "👀")),
# Both: "👀👀"
([243, 162, 148, 131, 243, 162, 148, 131], ("👀👀",)),
# Both: "👀👀👀"
([243, 162, 148, 131, 243, 162, 148, 131, 243, 162, 148, 131], ("👀👀👀",)),
# HF: "👀", SentencePiece: "👀👀"
([243, 162, 148, 131, 162, 148, 131, 243, 162, 148, 131], ("👀", "👀👀")),
# Both: "👀 have👀"
([243, 162, 148, 131, 162, 148, 131, 505, 243, 162, 148, 131], ("👀 have👀",)),
]
@pytest.mark.parametrize("tokens_and_results", emoji_tokens_expected_result)
@require_test_tokenizers("Llama-2-7b-chat-hf-q4f16_1-MLC")
def test_text_streamer_emojis(
llama_tokenizer_path: str,
tokens_and_results: Tuple[List[int], Tuple[str]], # noqa: UP006
):
text_streamer = TextStreamer(Tokenizer(llama_tokenizer_path))
total_text = ""
tokens, expected_results = tokens_and_results
for token in tokens:
total_text += text_streamer.put([token])
total_text += text_streamer.finish()
assert total_text in expected_results
if __name__ == "__main__":
test_text_streamer()
test_stop_str_handler_stop()
test_stop_str_handler_not_stop()
test_stop_str_handler_return_cached_tokens()
test_stop_str_handler_throughput()
for tokens_and_res in emoji_tokens_expected_result:
test_text_streamer_emojis(tokens_and_res)