Files
StarPilot/scripts/model_compiler.py
T
firestar5683 0315fe483a Uptown Funk
2026-08-10 10:52:01 -05:00

734 lines
27 KiB
Python

#!/usr/bin/env python3
import argparse
import codecs
import ctypes
import glob
import hashlib
import json
import os
import pickle
import shutil
import subprocess
import sys
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
DEFAULT_INPUT_ROOT = Path("/data/openpilot/uncompiledmodels")
DEFAULT_OUTPUT_ROOT = Path("/data/openpilot/compiledmodels")
COMPILE_SCRIPT = REPO_ROOT / "tinygrad_repo/examples/openpilot/compile3.py"
DRIVING_COMPILE_SCRIPT = REPO_ROOT / "selfdrive/modeld/compile_modeld.py"
DM_WARP_COMPILE_SCRIPT = REPO_ROOT / "selfdrive/modeld/compile_dm_warp.py"
MODEL_VERSIONS_CACHE = Path("/data/models/.model_versions.json")
MODELS_PATH = MODEL_VERSIONS_CACHE.parent # runtime dir modeld loads from: /data/models
DM_MODEL_KEY = "dm"
DM_MODEL_NAME = "dmonitoring_model"
DM_TARGET_ALIASES = {DM_MODEL_KEY, "dmonitoring", DM_MODEL_NAME}
DM_INPUT_CANDIDATES = ("dmonitoring_model.onnx", "dmonitoring.onnx", "dm.onnx")
COMPONENT_ALIASES = {
"driving_supercombo": ("driving_supercombo", "supercombo"),
"driving_off_policy": ("driving_off_policy", "off_policy", "offpolicy"),
"driving_on_policy": ("driving_on_policy", "on_policy", "onpolicy"),
"driving_policy": ("driving_policy", "policy"),
"driving_vision": ("driving_vision", "vision"),
}
DEFAULT_CAMERA_RESOLUTIONS = ((1928, 1208), (1344, 760))
MEDMODEL_INPUT_SIZE = (512, 256)
DM_INPUT_SIZE = (1440, 960)
MODEL_RUN_FREQ = 20
MODEL_CONTEXT_FREQ = 5
REPOSITORY_FILE_LIMIT = 100 * 1024 * 1024
DEFAULT_MULTIPART_SIZE = 95 * 1024 * 1024
USBGPU_PROBE_ATTEMPTS = 10
USBGPU_PROBE_TIMEOUT = 2
USBDEVFS_CONTROL = 0xC0185500
USBGPU_VID_PIDS = (("add1", "0001"), ("3801", "0001"))
class _UsbdevfsControl(ctypes.Structure):
_fields_ = [("request_type", ctypes.c_uint8), ("request", ctypes.c_uint8),
("value", ctypes.c_uint16), ("index", ctypes.c_uint16),
("length", ctypes.c_uint16), ("timeout", ctypes.c_uint32),
("data", ctypes.c_void_p)]
def build_compile_env(*, supercombo: bool = False) -> dict[str, str]:
env = os.environ.copy()
existing_pythonpath = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = f"{REPO_ROOT}{os.pathsep}{existing_pythonpath}" if existing_pythonpath else str(REPO_ROOT)
defaults = {} if supercombo else {
"DEBUG": "0",
"FLOAT16": "1",
"IMAGE": "2",
"JIT_BATCH_SIZE": "0",
"NOLOCALS": "1",
"OPENPILOT_HACKS": "1",
}
for key, default in defaults.items():
try:
int(str(env.get(key)), 0)
except (TypeError, ValueError):
env[key] = default
if supercombo:
# Unified supercombo artifacts must use upstream compile defaults. The
# legacy QCOM tuning causes a reproducible HCQ timeline failure here.
env.pop("QCOM_PRIORITY", None)
return env
def _probe_external_gpu_link_once() -> tuple[bool, str]:
"""Probe the bridge without initializing tinygrad or resetting the USB device."""
import fcntl
diagnostics: list[str] = []
for path in glob.glob("/sys/bus/usb/devices/*"):
try:
vendor = Path(path, "idVendor").read_text().strip().lower()
product = Path(path, "idProduct").read_text().strip().lower()
if (vendor, product) not in USBGPU_VID_PIDS:
continue
bus = int(Path(path, "busnum").read_text())
device = int(Path(path, "devnum").read_text())
location = f"usb:{bus}-{device}"
fd = os.open(f"/dev/bus/usb/{bus:03d}/{device:03d}", os.O_RDWR)
except (OSError, ValueError) as exc:
diagnostics.append(f"{path}: open failed ({exc})")
continue
try:
fcntl.ioctl(fd, USBDEVFS_CONTROL, _UsbdevfsControl(0x40, 0xF3, 1, 0, 0, USBGPU_PROBE_TIMEOUT * 1000, None))
state = (ctypes.c_ubyte * 1)()
fcntl.ioctl(fd, USBDEVFS_CONTROL, _UsbdevfsControl(0xC0, 0xE4, 0xB450, 0, 1, 1000, ctypes.cast(state, ctypes.c_void_p)))
if state[0] == 0x78:
return True, f"{location}: LTSSM=0x78"
diagnostics.append(f"{location}: LTSSM=0x{state[0]:02X}")
except OSError as exc:
diagnostics.append(f"{location}: control probe failed ({exc})")
finally:
os.close(fd)
return False, diagnostics[-1] if diagnostics else "no ASM2464PD device found"
def wait_for_external_gpu(compile_env: dict[str, str]) -> bool:
"""Wait for the USB GPU's PCIe link before starting the large model build.
The dock can enumerate on USB before its PCIe link has finished training.
Probe the bridge's control endpoint directly, like upstream openpilot. Do
not instantiate tinygrad here: opening the GPU resets/claims the USB
interface, and doing that in a probe process can leave the bridge in a state
where the authoritative compiler cannot train the link.
"""
del compile_env # retained in the public helper signature for callers/tests
diagnostics: list[str] = []
for attempt in range(USBGPU_PROBE_ATTEMPTS):
if attempt:
time.sleep(1)
try:
ready, detail = _probe_external_gpu_link_once()
except Exception as exc: # probe is advisory; compile_modeld remains authoritative
ready, detail = False, str(exc)
if ready:
return True
diagnostics.append(detail)
detail = diagnostics[-1] if diagnostics else "unknown error"
print(
f"Warning: external GPU link did not become ready after {USBGPU_PROBE_ATTEMPTS} probes: {detail}\n"
" Continuing; compile_modeld will perform the authoritative link wait and initialization."
)
return False
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Compile staged ONNX models into StarPilot's unified tinygrad artifact format.",
)
parser.add_argument("--model", help="Output model ID, for example sc2.")
parser.add_argument("--dm", action="store_true", help="Build DM model, metadata, and both camera warps.")
parser.add_argument("--input-dir", type=Path, default=DEFAULT_INPUT_ROOT)
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_ROOT)
parser.add_argument(
"--input-format",
choices=("auto", "supercombo", "split"),
default="auto",
help="Source ONNX layout. Auto prefers supercombo when present.",
)
parser.add_argument(
"--version",
help="Behavioral model version stored in the artifact. It does not control artifact layout.",
)
parser.add_argument("--list", action="store_true", help="List staged models and exit.")
parser.add_argument("--force", action="store_true", help="Accepted for compatibility; selected outputs are always replaced.")
parser.add_argument("--gpu", "--external-gpu", dest="external_gpu", action="store_true",
help="Compile the driving artifact for the USB AMD GPU.")
parser.add_argument("--split-artifact", type=Path, help="Split an existing oversized PKL without compiling.")
parser.add_argument("--chunk-size-mib", type=int, default=95, help="Multipart size in MiB; must be below 100.")
parser.add_argument("--no-split", action="store_true",
help="Keep a single .pkl even if >100 MiB (for local installs, which need one "
"file). Auto-enabled for local- model IDs.")
parser.add_argument("--no-install", action="store_true",
help="Do not auto-copy a local- model into /data/models after compiling.")
parser.add_argument(
"--image-history-pipeline",
choices=("policy", "warp"),
default="policy",
help="Driving artifact ABI. 'policy' is the newer faster path; 'warp' reproduces legacy v22 artifacts.",
)
args, unknown = parser.parse_known_args()
dynamic_flags = [value[2:] for value in unknown if value.startswith("--")]
invalid = [value for value in unknown if not value.startswith("--")]
if invalid:
parser.error(f"Unexpected arguments: {' '.join(invalid)}")
if len(dynamic_flags) > 1:
parser.error("Pass only one dynamic model flag, for example ./models --sc2")
if args.model and dynamic_flags and args.model != dynamic_flags[0]:
parser.error("Use either --model sc2 or --sc2, not both.")
args.model = args.model or (dynamic_flags[0] if dynamic_flags else None)
if args.model and args.model.strip().lower() in DM_TARGET_ALIASES:
args.dm = True
args.model = None
if args.dm and args.model:
parser.error("Use either --dm or a driving model ID.")
if args.split_artifact and (args.dm or args.model):
parser.error("--split-artifact cannot be combined with --dm or a model ID.")
if not 1 <= args.chunk_size_mib < 100:
parser.error("--chunk-size-mib must be between 1 and 99.")
return args
def detect_component(path: Path) -> str | None:
stem = path.stem.lower()
for component, aliases in COMPONENT_ALIASES.items():
if any(alias in stem for alias in aliases):
return component
return None
def _model_key_from_flat_file(path: Path, component: str) -> str | None:
lowered = path.stem.lower()
for alias in COMPONENT_ALIASES[component]:
if lowered == alias:
return None
suffix = f"_{alias}"
if lowered.endswith(suffix):
key = path.stem[:-len(suffix)]
return None if key in ("", "driving") else key
return None
def find_staged_models(input_root: Path) -> dict[str, dict[str, Path]]:
found: dict[str, dict[str, Path]] = {}
if not input_root.is_dir():
return found
for child in sorted(input_root.iterdir()):
if not child.is_dir():
continue
files = {
component: path
for path in sorted(child.glob("*.onnx"))
if (component := detect_component(path)) is not None
}
if files:
found[child.name] = files
root_files: dict[str, Path] = {}
for path in sorted(input_root.glob("*.onnx")):
component = detect_component(path)
if component is None:
continue
model_key = _model_key_from_flat_file(path, component)
if model_key:
found.setdefault(model_key, {})[component] = path
else:
root_files[component] = path
if root_files:
found["_root"] = root_files
return found
def resolve_model_files(input_root: Path, model_key: str) -> dict[str, Path]:
staged = find_staged_models(input_root)
if model_key in staged:
return staged[model_key]
root_files = staged.get("_root")
if root_files:
return root_files
matching_files = {
component: path
for path in sorted(input_root.glob(f"{model_key}_*.onnx"))
if (component := detect_component(path)) is not None
}
if matching_files:
return matching_files
named_sources = [files for key, files in staged.items() if key != "_root"]
return named_sources[0] if len(named_sources) == 1 else {}
def find_staged_dm(input_root: Path) -> Path | None:
if not input_root.is_dir():
return None
for candidate in DM_INPUT_CANDIDATES:
path = input_root / candidate
if path.is_file():
return path
for child in sorted(input_root.iterdir()):
if child.is_dir():
for candidate in DM_INPUT_CANDIDATES:
path = child / candidate
if path.is_file():
return path
return None
def get_metadata_value_by_name(model, name: str):
for prop in model.metadata_props:
if prop.key == name:
return prop.value
return None
def write_metadata(onnx_path: Path, output_path: Path) -> dict:
import onnx
model = onnx.load(str(onnx_path))
output_slices = get_metadata_value_by_name(model, "output_slices")
if output_slices is None:
raise ValueError(f"output_slices not found in metadata for {onnx_path.name}")
def get_name_and_shape(value_info) -> tuple[str, tuple[int, ...]]:
shape = tuple(int(dim.dim_value) for dim in value_info.type.tensor_type.shape.dim)
return value_info.name, shape
metadata = {
"model_checkpoint": get_metadata_value_by_name(model, "model_checkpoint"),
"output_slices": pickle.loads(codecs.decode(output_slices.encode(), "base64")),
"input_shapes": dict(get_name_and_shape(value) for value in model.graph.input),
"output_shapes": dict(get_name_and_shape(value) for value in model.graph.output),
}
with open(output_path, "wb") as metadata_file:
pickle.dump(metadata, metadata_file)
return metadata
def infer_model_version(model_key: str, explicit_version: str | None) -> str:
if explicit_version:
return explicit_version.strip()
if MODEL_VERSIONS_CACHE.is_file():
try:
version = json.loads(MODEL_VERSIONS_CACHE.read_text()).get(model_key)
if isinstance(version, str):
return version.strip()
except Exception:
pass
return ""
def select_input_format(requested: str, files: dict[str, Path]) -> str:
if requested == "supercombo":
if "driving_supercombo" not in files:
raise SystemExit("--input-format supercombo requires driving_supercombo.onnx")
return requested
if requested == "split":
return requested
return "supercombo" if "driving_supercombo" in files else "split"
def driving_compile_args(files: dict[str, Path], input_format: str) -> tuple[str, list[str]]:
if input_format == "supercombo":
return "supercombo", ["--supercombo-onnx", str(files["driving_supercombo"])]
vision = files.get("driving_vision")
primary = files.get("driving_on_policy") or files.get("driving_policy")
off_policy = files.get("driving_off_policy")
if vision is None or primary is None:
missing = [
name for name, present in (
("driving_vision", vision),
("driving_policy or driving_on_policy", primary),
) if present is None
]
raise SystemExit(f"Missing required split ONNX files: {', '.join(missing)}")
args = ["--vision-onnx", str(vision)]
if off_policy is None:
args += ["--policy-onnx", str(primary)]
return "vision_policy", args
args += ["--on-policy-onnx", str(primary), "--off-policy-onnx", str(off_policy)]
return "vision_multi_policy", args
def remove_paths(paths: list[Path]) -> int:
count = 0
for path in paths:
if path.is_file() or path.is_symlink():
path.unlink()
count += 1
return count
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as artifact_file:
for chunk in iter(lambda: artifact_file.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _read_protobuf_varint(source) -> int:
value = 0
for shift in range(0, 70, 7):
byte = source.read(1)
if not byte:
raise ValueError("unexpected end of file while reading protobuf varint")
value |= (byte[0] & 0x7F) << shift
if not byte[0] & 0x80:
return value
raise ValueError("protobuf varint is too long")
def validate_onnx_source(path: Path) -> None:
"""Validate the top-level ONNX protobuf without materializing model weights."""
size = path.stat().st_size
if size == 0:
raise ValueError(f"ONNX source is empty: {path}")
with open(path, "rb") as source:
if source.read(128).startswith(b"version https://git-lfs.github.com/spec/v1"):
raise ValueError(f"ONNX source is a Git LFS pointer, not model data: {path}")
source.seek(0)
while source.tell() < size:
tag = _read_protobuf_varint(source)
field, wire_type = tag >> 3, tag & 0x07
if field == 7: # ModelProto.graph
if wire_type != 2:
raise ValueError(f"ONNX graph has invalid protobuf wire type {wire_type}: {path}")
graph_size = _read_protobuf_varint(source)
remaining = size - source.tell()
if graph_size <= 0:
raise ValueError(f"ONNX graph is empty: {path}")
if graph_size > remaining:
raise ValueError(f"ONNX source is truncated: graph needs {graph_size} bytes but only {remaining} remain: {path}")
return
if wire_type == 0:
_read_protobuf_varint(source)
elif wire_type == 1:
source.seek(8, os.SEEK_CUR)
elif wire_type == 2:
source.seek(_read_protobuf_varint(source), os.SEEK_CUR)
elif wire_type == 5:
source.seek(4, os.SEEK_CUR)
else:
raise ValueError(f"ONNX source has invalid protobuf wire type {wire_type}: {path}")
raise ValueError(f"ONNX ModelProto has no graph: {path}")
def multipart_output_paths(artifact: Path, output_dir: Path | None = None) -> list[Path]:
output_dir = output_dir or artifact.parent
return [
*sorted(output_dir.glob(f"{artifact.name}.p[0-9][0-9]")),
output_dir / f"{artifact.name}.sha256",
]
def install_local_artifact(artifact: Path, model_key: str, version: str) -> None:
"""Copy a freshly compiled local- model into the runtime dir modeld loads from,
and ensure its <id>.json sidecar carries the correct version.
The sidecar version is NOT cosmetic: without it _discover_local_models() records
an empty version, which downstream parses on the wrong contract (a v15 model then
drives like v11). Since we know the build version here, we write it so the local
install is correct by default. Local models must be a single is_file() in
/data/models to show in the picker. No-ops off-device (no /data/models).
"""
if not MODELS_PATH.is_dir():
print(f" skipped auto-install: {MODELS_PATH} not present (not on device?)")
return
dest = MODELS_PATH / artifact.name
shutil.copy2(artifact, dest)
print(f" installed -> {dest}")
sidecar = MODELS_PATH / f"{model_key}.json"
info: dict = {}
if sidecar.is_file():
try:
loaded = json.loads(sidecar.read_text())
if isinstance(loaded, dict):
info = loaded
except Exception as error:
print(f" WARN: existing sidecar {sidecar.name} is malformed, rewriting: {error}")
if not version:
if not str(info.get("version") or "").strip():
print(f" WARN: could not determine version -- set it by hand in {sidecar.name} "
"or the model may drive on the wrong version contract")
return
# keep any user-set name/series; only guarantee a correct, non-empty version
if str(info.get("version") or "").strip() == version and sidecar.is_file():
print(f" sidecar ok: {sidecar.name} (version {version})")
return
info.setdefault("name", model_key[len("local-"):].replace("_", " ").replace("-", " ").strip())
info.setdefault("series", "Local")
info["version"] = version
sidecar.write_text(json.dumps(info, indent=2) + "\n")
print(f" wrote sidecar {sidecar.name} (version {version})")
def split_oversized_artifact(
artifact: Path,
output_dir: Path | None = None,
chunk_size: int = DEFAULT_MULTIPART_SIZE,
force: bool = False,
) -> list[Path]:
artifact = artifact.resolve()
output_dir = (output_dir or artifact.parent).resolve()
if not artifact.is_file():
raise FileNotFoundError(artifact)
if chunk_size <= 0 or chunk_size >= REPOSITORY_FILE_LIMIT:
raise ValueError("Multipart chunk size must be between 1 byte and 100 MiB.")
remove_paths(multipart_output_paths(artifact, output_dir))
if artifact.stat().st_size <= REPOSITORY_FILE_LIMIT and not force:
return []
output_dir.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha256()
part_paths: list[Path] = []
with open(artifact, "rb") as source:
for index in range(100):
part_path = output_dir / f"{artifact.name}.p{index:02d}"
part_size = 0
with open(part_path, "wb") as part_file:
while part_size < chunk_size:
chunk = source.read(min(1024 * 1024, chunk_size - part_size))
if not chunk:
break
part_file.write(chunk)
digest.update(chunk)
part_size += len(chunk)
if part_size == 0:
part_path.unlink()
break
part_paths.append(part_path)
if not part_paths:
raise ValueError(f"Artifact is empty: {artifact}")
checksum_path = output_dir / f"{artifact.name}.sha256"
checksum_path.write_text(f"{digest.hexdigest()} {artifact.name}\n")
verify_digest = hashlib.sha256()
for part_path in part_paths:
with open(part_path, "rb") as part_file:
for chunk in iter(lambda: part_file.read(1024 * 1024), b""):
verify_digest.update(chunk)
if verify_digest.hexdigest() != digest.hexdigest():
remove_paths([*part_paths, checksum_path])
raise RuntimeError("Split artifact failed checksum verification.")
return [*part_paths, checksum_path]
def compile_driving(
model_key: str,
files: dict[str, Path],
input_format: str,
version: str,
output_dir: Path,
image_history_pipeline: str,
external_gpu: bool = False,
) -> Path:
model_type, source_args = driving_compile_args(files, input_format)
output_path = output_dir / f"{model_key}_driving_tinygrad.pkl"
removed = remove_paths(sorted({
output_path,
*multipart_output_paths(output_path, output_dir),
*output_dir.glob("*_driving_tinygrad.pkl"),
*output_dir.glob("*_driving_tinygrad.pkl.p[0-9][0-9]"),
*output_dir.glob("*_driving_tinygrad.pkl.sha256"),
*output_dir.glob("*_driving_*_tinygrad.pkl"),
*output_dir.glob("*_driving_*_metadata.pkl"),
}))
if removed:
print(f" cleared {removed} existing driving output entries")
frame_skip = MODEL_RUN_FREQ // MODEL_CONTEXT_FREQ
command = [
sys.executable,
str(DRIVING_COMPILE_SCRIPT),
"--model-type",
model_type,
"--model-size",
f"{MEDMODEL_INPUT_SIZE[0]}x{MEDMODEL_INPUT_SIZE[1]}",
"--camera-resolutions",
*(f"{width}x{height}" for width, height in DEFAULT_CAMERA_RESOLUTIONS),
"--output",
str(output_path),
"--frame-skip",
str(frame_skip),
"--image-history-pipeline",
image_history_pipeline,
*source_args,
]
if version:
command += ["--behavior-version", version]
compile_env = build_compile_env(supercombo=input_format == "supercombo")
if external_gpu:
for qcom_only_flag in ("IMAGE", "NOLOCALS", "OPENPILOT_HACKS"):
compile_env.pop(qcom_only_flag, None)
compile_env.update({
"DEBUG": "2",
"DEV": "USB+AMD:LLVM",
"WARP_DEV": "QCOM",
"FLOAT16": "1",
"JIT_BATCH_SIZE": "0",
"GMMU": "0",
"TC_OPT": "2",
})
command.append("--out-of-band")
wait_for_external_gpu(compile_env)
subprocess.run(command, cwd=REPO_ROOT, env=compile_env, check=True)
return output_path
def compile_dm(onnx_path: Path, output_dir: Path) -> list[Path]:
outputs = [
output_dir / f"{DM_MODEL_NAME}_tinygrad.pkl",
output_dir / f"{DM_MODEL_NAME}_metadata.pkl",
*(output_dir / f"dm_warp_{width}x{height}_tinygrad.pkl" for width, height in DEFAULT_CAMERA_RESOLUTIONS),
]
removed = remove_paths(outputs)
if removed:
print(f" cleared {removed} existing DM output entries")
subprocess.run(
[sys.executable, str(COMPILE_SCRIPT), str(onnx_path), str(outputs[0])],
cwd=REPO_ROOT,
env=build_compile_env(),
check=True,
)
write_metadata(onnx_path, outputs[1])
dm_w, dm_h = DM_INPUT_SIZE
for (cam_w, cam_h), output_path in zip(DEFAULT_CAMERA_RESOLUTIONS, outputs[2:], strict=True):
subprocess.run(
[
sys.executable,
str(DM_WARP_COMPILE_SCRIPT),
"--camera-resolution",
f"{cam_w}x{cam_h}",
"--warp-to",
f"{dm_w}x{dm_h}",
"--output",
str(output_path),
],
cwd=REPO_ROOT,
env=build_compile_env(),
check=True,
)
return outputs
def list_models(staged: dict[str, dict[str, Path]], input_root: Path) -> int:
for model_key, files in sorted(staged.items()):
print(model_key)
for component, path in sorted(files.items()):
print(f" {component}: {path}")
if (dm_path := find_staged_dm(input_root)) is not None:
print(DM_MODEL_KEY)
print(f" {DM_MODEL_NAME}: {dm_path}")
if not staged and dm_path is None:
print(f"No staged models found in {input_root}")
return 0
def main() -> int:
args = parse_args()
if args.split_artifact:
outputs = split_oversized_artifact(
args.split_artifact,
args.output_dir,
args.chunk_size_mib * 1024 * 1024,
force=True,
)
for output in outputs:
print(f" saved {output.name} ({output.stat().st_size} bytes)")
return 0
staged = find_staged_models(args.input_dir)
if args.list:
return list_models(staged, args.input_dir)
args.output_dir.mkdir(parents=True, exist_ok=True)
if args.dm:
onnx_path = find_staged_dm(args.input_dir)
if onnx_path is None:
raise SystemExit(f"No staged DM ONNX found in {args.input_dir}")
print(f"Compiling DM artifacts from {onnx_path} -> {args.output_dir}")
for output in compile_dm(onnx_path, args.output_dir):
print(f" saved {output.name}")
print("Done.")
return 0
if not args.model:
available = ", ".join(sorted(key for key in staged if key != "_root"))
raise SystemExit(f"Choose a model ID, for example ./models --sc2. Available: {available or 'none'}")
model_key = args.model.strip()
files = resolve_model_files(args.input_dir, model_key)
if not files:
raise SystemExit(f"No staged ONNX files found for {model_key} in {args.input_dir}")
input_format = select_input_format(args.input_format, files)
_, source_args = driving_compile_args(files, input_format)
for option, source in zip(source_args[::2], source_args[1::2], strict=True):
source_path = Path(source)
validate_onnx_source(source_path)
print(f" source {option.removeprefix('--')}: {source_path} ({source_path.stat().st_size} bytes)")
version = infer_model_version(model_key, args.version)
if not version and input_format == "supercombo":
version = "v15"
version_label = version or "unspecified behavior"
will_install = model_key.startswith("local-") and not args.no_install
target = f"{args.output_dir}" + (f" -> {MODELS_PATH} (auto-install)" if will_install else "")
print(f"Compiling {model_key} ({input_format}, {version_label}) from {args.input_dir} -> {target}")
output = compile_driving(model_key, files, input_format, version, args.output_dir,
args.image_history_pipeline, args.external_gpu)
print(f" saved {output.name}")
# Local models install as a single is_file() and never go to GitHub, so the >100 MiB
# repo split is pointless for them (you'd only have to reassemble it). Keep one .pkl.
keep_single = args.no_split or model_key.startswith("local-")
if keep_single:
is_local = model_key.startswith("local-")
if output.stat().st_size > REPOSITORY_FILE_LIMIT:
size_mb = output.stat().st_size / 1e6
if is_local:
print(f" local model: kept as one {size_mb:.1f} MB file (repo split not needed)")
else:
print(f" --no-split: kept one {size_mb:.1f} MB file; over 100 MB, so split it "
"before committing to a repo (re-run without --no-split, or --split-artifact)")
if is_local and not args.no_install:
install_local_artifact(output, model_key, version)
else:
multipart_outputs = split_oversized_artifact(output)
if multipart_outputs:
print(" artifact exceeds 100 MiB; created repository-safe multipart files:")
for multipart_output in multipart_outputs:
print(f" {multipart_output.name} ({multipart_output.stat().st_size} bytes)")
output.unlink()
print(f" removed oversized source artifact {output.name}")
print("Done.")
return 0
if __name__ == "__main__":
raise SystemExit(main())