Files
wehub-resource-sync 770d92cb1f
Lint / lint (push) Waiting to run
Windows CI / Windows (push) Waiting to run
Build Docs / Deploy Docs (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:23:58 +08:00

403 lines
16 KiB
Python

"""Python entrypoint of package."""
import dataclasses
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, List, Literal # noqa: UP035
from mlc_llm.interface import jit
from mlc_llm.support import download_cache, logging, style
logging.enable_logging()
logger = logging.getLogger(__name__)
SUPPORTED_DEVICES = ["iphone", "macabi", "android"]
def build_model_library(
package_config: Dict[str, Any], # noqa: UP006
device: str,
bundle_dir: Path,
app_config_path: Path,
) -> Dict[str, str]: # noqa: UP006
"""Build model libraries. Return the dictionary of "library prefix to lib path"."""
# - Create the bundle directory.
os.makedirs(bundle_dir, exist_ok=True)
# Clean up all the directories in `output/bundle`.
logger.info('Clean up all directories under "%s"', str(bundle_dir))
for content_path in bundle_dir.iterdir():
if content_path.is_dir():
shutil.rmtree(content_path)
# - Process each model, and prepare the app config.
app_config_model_list = []
model_entries = package_config.get("model_list", [])
if not isinstance(model_entries, list):
raise ValueError('The "model_list" in "mlc-package-config.json" is expected to be a list.')
model_lib_path_for_prepare_libs = package_config.get("model_lib_path_for_prepare_libs", {})
if not isinstance(model_lib_path_for_prepare_libs, dict):
raise ValueError(
'The "model_lib_path_for_prepare_libs" in "mlc-package-config.json" is expected to be '
"a dict."
)
jit.log_jit_policy()
for model_entry in package_config.get("model_list", []):
# - Parse model entry.
if not isinstance(model_entry, dict):
raise ValueError('The element of "model_list" is expected to be a dict.')
model = model_entry["model"]
model_id = model_entry["model_id"]
bundle_weight = model_entry.get("bundle_weight", False)
overrides = model_entry.get("overrides", {})
model_lib = model_entry.get("model_lib", None)
estimated_vram_bytes = model_entry["estimated_vram_bytes"]
if not isinstance(model, str):
raise ValueError('The value of "model" in "model_list" is expected to be a string.')
if not isinstance(model_id, str):
raise ValueError('The value of "model_id" in "model_list" is expected to be a string.')
if not isinstance(bundle_weight, bool):
raise ValueError(
'The value of "bundle_weight" in "model_list" is expected to be a boolean.'
)
if not isinstance(overrides, dict):
raise ValueError('The value of "overrides" in "model_list" is expected to be a dict.')
if model_lib is not None and not isinstance(model_lib, str):
raise ValueError('The value of "model_lib" in "model_list" is expected to be string.')
# - Load model config. Download happens when needed.
model_path = download_cache.get_or_download_model(model)
# - Jit compile if the model lib path is not specified.
model_lib_path = (
model_lib_path_for_prepare_libs.get(model_lib, None) if model_lib is not None else None
)
if model_lib_path is None:
if model_lib is None:
logger.info(
'Model lib is not specified for model "%s". Now jit compile the model library.',
model_id,
)
else:
logger.info(
'Model lib path for "%s" is not specified in "model_lib_path_for_prepare_libs".'
"Now jit compile the model library.",
model_lib,
)
model_lib_path, model_lib = dataclasses.astuple(
jit.jit(
model_path=model_path,
overrides=overrides,
device=device,
system_lib_prefix=model_lib,
skip_log_jit_policy=True,
)
)
assert model_lib is not None
model_lib_path_for_prepare_libs[model_lib] = model_lib_path
# - Set "model_url"/"model_path" and "model_id"
app_config_model_entry = {}
is_local_model = not model.startswith("HF://") and not model.startswith("https://")
app_config_model_entry["model_id"] = model_id
app_config_model_entry["model_lib"] = model_lib
# - Bundle weight
if is_local_model and not bundle_weight:
raise ValueError(
f'Model "{model}" in "model_list" is a local path.'
f'Please set \'"bundle_weight": true\' in the entry of model "{model}".'
)
if bundle_weight:
if not os.path.isfile(model_path / "tensor-cache.json"):
raise ValueError(
f'Bundle weight is set for model "{model}". However, model weights are not'
f'found under the directory "{model}". '
+ (
"Please follow https://llm.mlc.ai/docs/compilation/convert_weights.html to "
"convert model weights."
if is_local_model
else "Please report this issue to https://github.com/mlc-ai/mlc-llm/issues."
)
)
# Overwrite the model weight directory in bundle.
bundle_model_weight_path = bundle_dir / model_id
logger.info(
"Bundle weight for %s, copy into %s",
style.bold(model_id),
style.bold(str(bundle_model_weight_path)),
)
if bundle_model_weight_path.exists():
shutil.rmtree(bundle_model_weight_path)
shutil.copytree(model_path, bundle_model_weight_path)
if bundle_weight and device in ["iphone", "macabi"]:
app_config_model_entry["model_path"] = model_id
else:
app_config_model_entry["model_url"] = model.replace("HF://", "https://huggingface.co/")
# - estimated_vram_bytes
app_config_model_entry["estimated_vram_bytes"] = estimated_vram_bytes
app_config_model_list.append(app_config_model_entry)
# - Dump "mlc-app-config.json".
app_config_json_str = json.dumps(
{"model_list": app_config_model_list},
indent=2,
)
with open(app_config_path, "w", encoding="utf-8") as file:
print(app_config_json_str, file=file)
logger.info(
'Dump the app config below to "%s":\n%s',
str(app_config_path),
style.green(app_config_json_str),
)
return model_lib_path_for_prepare_libs
def validate_model_lib(
app_config_path: Path,
package_config_path: Path,
model_lib_path_for_prepare_libs: dict,
device: Literal["iphone", "macabi", "android"],
output: Path,
) -> None:
"""Validate the model lib prefixes of model libraries."""
if device == "android":
from tvm.support import ndk as cc
else:
from tvm.support import cc
with open(app_config_path, encoding="utf-8") as file:
app_config = json.load(file)
tar_list = []
model_set = set()
for model, model_lib_path in model_lib_path_for_prepare_libs.items():
model_lib_path = os.path.join(model_lib_path)
lib_path_valid = os.path.isfile(model_lib_path)
if not lib_path_valid:
raise RuntimeError(f"Cannot find file {model_lib_path} as an {device} model library")
tar_list.append(model_lib_path)
model_set.add(model)
os.makedirs(output / "lib", exist_ok=True)
if device in ["iphone", "macabi"]:
lib_name = "libmodel_iphone.a"
else:
lib_name = "libmodel_android.a"
lib_path = output / "lib" / lib_name
def _get_model_libs(lib_path: Path) -> List[str]: # noqa: UP006
"""Get the model lib prefixes in the given static lib path."""
global_symbol_map = cc.get_global_symbol_section_map(lib_path)
libs = []
suffix = "___tvm_ffi__library_bin"
for name, _ in global_symbol_map.items():
if name.endswith(suffix):
model_lib = name[: -len(suffix)]
if model_lib.startswith("_"):
model_lib = model_lib[1:]
libs.append(model_lib)
return libs
cc.create_staticlib(lib_path, tar_list)
available_model_libs = _get_model_libs(lib_path)
logger.info("Creating lib from %s", str(tar_list))
logger.info("Validating the library %s", str(lib_path))
logger.info(
"List of available model libs packaged: %s,"
" if we have '-' in the model_lib string, it will be turned into '_'",
str(available_model_libs),
)
global_symbol_map = cc.get_global_symbol_section_map(lib_path)
error_happened = False
for item in app_config["model_list"]:
model_lib = item["model_lib"]
model_id = item["model_id"]
if model_lib not in model_set:
# NOTE: this cannot happen under new setting
# since if model_lib is not included, it will be jitted
raise RuntimeError(
f"ValidationError: model_lib={model_lib} specified for model_id={model_id} "
"is not included in model_lib_path_for_prepare_libs argument, "
"This will cause the specific model not being able to load, "
f"model_lib_path_for_prepare_libs={model_lib_path_for_prepare_libs}"
)
model_prefix_pattern = model_lib.replace("-", "_") + "___tvm_ffi__library_bin"
if (
model_prefix_pattern not in global_symbol_map
and "_" + model_prefix_pattern not in global_symbol_map
):
# NOTE: no lazy format is ok since this is a slow pass
model_lib_path = model_lib_path_for_prepare_libs[model_lib]
log_msg = (
"ValidationError:\n"
f"\tmodel_lib {model_lib} requested in {str(app_config_path)}"
f" is not found in {str(lib_path)}\n"
f"\tspecifically the model_lib for {model_lib_path}.\n"
f"\tcurrent available model_libs in {str(lib_path)}: {available_model_libs}\n"
f"\tThis can happen when we manually specified model_lib_path_for_prepare_libs"
f" in {str(package_config_path)}\n"
f"\tConsider remove model_lib_path_for_prepare_libs (so library can be jitted)"
"or check the compile command"
)
logger.info(log_msg)
error_happened = True
if not error_happened:
logger.info(style.green("Validation pass"))
else:
logger.info(style.red("Validation failed"))
sys.exit(255)
def build_android_binding(mlc_llm_source_dir: Path, output: Path) -> None:
"""Build android binding in MLC LLM"""
mlc4j_path = mlc_llm_source_dir / "android" / "mlc4j"
# Move the model libraries to "build/lib/" for linking
os.makedirs(Path("build") / "lib", exist_ok=True)
src_path = str(output / "lib" / "libmodel_android.a")
dst_path = str(Path("build") / "lib" / "libmodel_android.a")
logger.info('Moving "%s" to "%s"', src_path, dst_path)
shutil.move(src_path, dst_path)
# Build mlc4j
logger.info("Building mlc4j")
subprocess.run([sys.executable, mlc4j_path / "prepare_libs.py"], check=True, env=os.environ)
# Copy built files back to output directory.
lib_path = output / "lib" / "mlc4j"
os.makedirs(lib_path, exist_ok=True)
logger.info('Clean up all directories under "%s"', str(lib_path))
for content_path in lib_path.iterdir():
if content_path.is_dir():
shutil.rmtree(content_path)
src_path = str(mlc4j_path / "src")
dst_path = str(lib_path / "src")
logger.info('Copying "%s" to "%s"', src_path, dst_path)
shutil.copytree(src_path, dst_path)
src_path = str(mlc4j_path / "build.gradle")
dst_path = str(lib_path / "build.gradle")
logger.info('Copying "%s" to "%s"', src_path, dst_path)
shutil.copy(src_path, dst_path)
src_path = str(Path("build") / "output")
dst_path = str(lib_path / "output")
logger.info('Copying "%s" to "%s"', src_path, dst_path)
shutil.copytree(src_path, dst_path)
os.makedirs(lib_path / "src" / "main" / "assets")
src_path = str(output / "bundle" / "mlc-app-config.json")
dst_path = str(lib_path / "src" / "main" / "assets" / "mlc-app-config.json")
logger.info('Moving "%s" to "%s"', src_path, dst_path)
shutil.move(src_path, dst_path)
def build_iphone_binding(mlc_llm_source_dir: Path, output: Path) -> None:
"""Build iOS binding in MLC LLM"""
# Build iphone binding
logger.info("Build iphone binding")
subprocess.run(
["bash", mlc_llm_source_dir / "ios" / "prepare_libs.sh"],
check=True,
env=os.environ,
)
# Copy built libraries back to output directory.
for static_library in (Path("build") / "lib").iterdir():
dst_path = str(output / "lib" / static_library.name)
logger.info('Copying "%s" to "%s"', static_library, dst_path)
shutil.copy(static_library, dst_path)
def build_macabi_binding(mlc_llm_source_dir: Path, output: Path) -> None:
"""Build Mac Catalyst binding in MLC LLM"""
deployment_target = os.environ.get("MLC_MACABI_DEPLOYMENT_TARGET", "18.0")
macabi_arch = os.environ.get("MLC_MACABI_ARCH", "").strip() or "arm64"
logger.info("Build macabi binding (deployment target %s)", deployment_target)
cmd = [
"bash",
str(mlc_llm_source_dir / "ios" / "prepare_libs.sh"),
"--catalyst",
"--deployment-target",
deployment_target,
]
if macabi_arch:
cmd += ["--arch", macabi_arch]
subprocess.run(cmd, check=True, env=os.environ)
# Copy built libraries back to output directory.
build_dir = Path(f"build-maccatalyst-{macabi_arch}")
for static_library in (build_dir / "lib").iterdir():
dst_path = str(output / "lib" / static_library.name)
logger.info('Copying "%s" to "%s"', static_library, dst_path)
shutil.copy(static_library, dst_path)
def package(
package_config_path: Path,
mlc_llm_source_dir: Path,
output: Path,
) -> None:
"""Python entrypoint of package."""
logger.info('MLC LLM HOME: "%s"', mlc_llm_source_dir)
# - Read package config.
with open(package_config_path, encoding="utf-8") as file:
package_config = json.load(file)
if not isinstance(package_config, dict):
raise ValueError(
"The content of MLC package config is expected to be a dict with "
f'field "model_list". However, the content of "{package_config_path}" is not a dict.'
)
# - Read device.
if "device" not in package_config:
raise ValueError(f'JSON file "{package_config_path}" is required to have field "device".')
device = package_config["device"]
if device not in SUPPORTED_DEVICES:
raise ValueError(
f'The "device" field of JSON file {package_config_path} is expected to be one of '
f'{SUPPORTED_DEVICES}, while "{device}" is given in the JSON.'
)
bundle_dir = output / "bundle"
app_config_path = bundle_dir / "mlc-app-config.json"
# - Build model libraries.
model_lib_path_for_prepare_libs = build_model_library(
package_config, device, bundle_dir, app_config_path
)
# - Validate model libraries.
validate_model_lib(
app_config_path,
package_config_path,
model_lib_path_for_prepare_libs,
device,
output,
)
# - Copy model libraries
if device == "android":
build_android_binding(mlc_llm_source_dir, output)
elif device == "iphone":
build_iphone_binding(mlc_llm_source_dir, output)
elif device == "macabi":
build_macabi_binding(mlc_llm_source_dir, output)
else:
assert False, "Cannot reach here"
logger.info("All finished.")