File size: 21,817 Bytes
11df544
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
"""VibeVoice family β†’ ONNX sub-parts β€” unified builder for ALL checkpoints.



One entry point for every VibeVoice model. No download: point --model at a locally

downloaded checkpoint dir (defaults below). Each model is decomposed into standalone

ONNX sub-parts (LLM decoder via onnxruntime-genai ModelBuilder; audio / diffusion /

connector blocks via Olive), all wired from the loaders in user_script.py.



Models & components (all parity-verified vs PyTorch, cos ~1.0):

  1.5b     (VibeVoice-1.5B, TTS)      llm acoustic_encoder acoustic_decoder

                                       semantic_encoder diffusion_head

                                       acoustic_connector semantic_connector

  asr      (VibeVoice-ASR, 7B)        llm acoustic_encoder acoustic_decoder

                                       semantic_encoder acoustic_connector semantic_connector

  asr-hf   (VibeVoice-ASR-HF, 7B)     llm acoustic_encoder semantic_encoder multi_modal_projector

  realtime (Realtime-0.5B, streaming) llm acoustic_decoder diffusion_head acoustic_connector

  acoustic (VibeVoice-AcousticTokenizer)  acoustic_encoder acoustic_decoder  (standalone; no LLM)



Usage (model is the FINAL positional arg β€” a known key OR a path to a checkpoint dir):

  uv run optimize.py 1.5b                                  # all components, cpu int4

  uv run optimize.py --device cuda --precision fp16 asr-hf

  uv run optimize.py --components llm acoustic_decoder realtime

  uv run optimize.py --device cuda --precision fp16 all

  uv run optimize.py ./asr-hf                              # a path β†’ type auto-detected from config.json

  uv run optimize.py --list



Args:

  model                                     FINAL positional: {1.5b,asr,asr-hf,realtime,all}

                                            or a filesystem path to a checkpoint dir (auto-detected)

  --device {cpu,cuda}                       target device (default cpu)

  --precision {int4,fp16,fp32}              LLM quantization / audio dtype (default int4)

  --components ...                          subset (default: all for the model)

  --output-dir PATH                         override output root (default <model-dir>/<device>_<precision>/models)



Precision note: --precision sets the LLM build (int4 / fp16 / fp32). Audio/diffusion/connector

blocks are VAE/DiT β€” int4 doesn't apply, so they export fp32 unless --precision fp16 (then fp16).

"""
import argparse
import json
import shutil
import sys
import tempfile
from pathlib import Path

HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))

# Force UTF-8 stdout/stderr so emoji in dependency logs (Olive/onnxscript) don't crash a
# cp1252 Windows console β€” lets `uv run optimize.py ...` work without PYTHONIOENCODING set.
for _s in (sys.stdout, sys.stderr):
    try: _s.reconfigure(encoding="utf-8", errors="replace")
    except Exception: pass

# LLM spec: (extract_fn, exclude_embeds, exclude_lm_head)
#   TTS backbones (1.5b, realtime): head is the diffusion head β†’ exclude embeds + lm_head
#     (decoder consumes inputs_embeds, emits hidden_states).
#   ASR (asr, asr-hf): generate text β†’ keep lm_head; audio+text fused upstream β†’ exclude embeds.
# Olive spec per component: (loader, io_config, dummy, env-overrides)
MODELS = {
    "1.5b": {
        "dir": "model",
        "llm": ("extract_qwen2_standalone", True, True),
        "olive": {
            "acoustic_encoder": ("get_acoustic_encoder_model", "get_acoustic_encoder_io_config", "get_acoustic_encoder_dummy_inputs", {}),
            "acoustic_decoder": ("get_acoustic_decoder_model", "get_acoustic_decoder_io_config", "get_acoustic_decoder_dummy_inputs", {}),
            "semantic_encoder": ("get_semantic_tokenizer_encoder_model", "get_semantic_tokenizer_encoder_io_config", "get_semantic_tokenizer_encoder_dummy_inputs", {}),
            "diffusion_head": ("get_diffusion_head_model", "get_diffusion_head_io_config", "get_diffusion_head_dummy_inputs", {"VV_HEAD_HIDDEN": "1536"}),
            "acoustic_connector": ("get_acoustic_connector_model", "get_acoustic_connector_io_config", "get_acoustic_connector_dummy_inputs", {}),
            "semantic_connector": ("get_semantic_connector_model", "get_semantic_connector_io_config", "get_semantic_connector_dummy_inputs", {}),
        },
    },
    "asr": {
        "dir": "asr",
        "llm": ("extract_qwen2_asr", True, False),
        "olive": {
            "acoustic_encoder": ("get_acoustic_encoder_model", "get_acoustic_encoder_io_config", "get_acoustic_encoder_dummy_inputs", {}),
            "acoustic_decoder": ("get_acoustic_decoder_model", "get_acoustic_decoder_io_config", "get_acoustic_decoder_dummy_inputs", {}),
            "semantic_encoder": ("get_semantic_tokenizer_encoder_model", "get_semantic_tokenizer_encoder_io_config", "get_semantic_tokenizer_encoder_dummy_inputs", {}),
            "acoustic_connector": ("get_acoustic_connector_model", "get_acoustic_connector_io_config", "get_acoustic_connector_dummy_inputs", {}),
            "semantic_connector": ("get_semantic_connector_model", "get_semantic_connector_io_config", "get_semantic_connector_dummy_inputs", {}),
        },
    },
    "asr-hf": {
        "dir": "asr-hf",
        "llm": ("extract_qwen2_asrhf", True, False),
        "olive": {
            "acoustic_encoder": ("get_asrhf_acoustic_encoder_model", "get_asrhf_acoustic_encoder_io_config", "get_asrhf_acoustic_encoder_dummy_inputs", {}),
            "semantic_encoder": ("get_asrhf_semantic_encoder_model", "get_asrhf_semantic_encoder_io_config", "get_asrhf_semantic_encoder_dummy_inputs", {}),
            "multi_modal_projector": ("get_asrhf_projector_model", "get_asrhf_projector_io_config", "get_asrhf_projector_dummy_inputs", {}),
        },
    },
    "realtime": {
        "dir": "realtime",
        "llm": ("extract_qwen2_realtime", True, True),
        "olive": {
            "acoustic_decoder": ("get_realtime_acoustic_decoder_model", "get_realtime_acoustic_decoder_io_config", "get_realtime_acoustic_decoder_dummy_inputs", {}),
            "diffusion_head": ("get_diffusion_head_model", "get_diffusion_head_io_config", "get_diffusion_head_dummy_inputs", {"VV_HEAD_HIDDEN": "896"}),
            "acoustic_connector": ("get_acoustic_connector_model", "get_acoustic_connector_io_config", "get_acoustic_connector_dummy_inputs", {}),
        },
    },
    # standalone Acoustic Tokenizer (encoder + decoder VAE) β€” no LLM. transformers-native loaders.
    "acoustic": {
        "dir": "acoustic",
        "llm": None,
        "olive": {
            "acoustic_encoder": ("get_acoustic_std_encoder_model", "get_acoustic_encoder_io_config", "get_acoustic_encoder_dummy_inputs", {}),
            "acoustic_decoder": ("get_acoustic_std_decoder_model", "get_acoustic_decoder_io_config", "get_acoustic_decoder_dummy_inputs", {}),
        },
    },
}

# HuggingFace repo per key β€” used to auto-download when the checkpoint dir is absent.
# Override any of these with --repo when the id differs.
HF_REPO = {
    "1.5b": "microsoft/VibeVoice-1.5B",
    "asr": "microsoft/VibeVoice-ASR",
    "asr-hf": "microsoft/VibeVoice-ASR-HF",
    "realtime": "microsoft/VibeVoice-Realtime-0.5B",
    "acoustic": "microsoft/VibeVoice-AcousticTokenizer",
}

# device β†’ (olive device string, execution provider, genai device string)
DEVICES = {"cpu": ("cpu", "CPUExecutionProvider", "cpu"),
           "gpu": ("gpu", "CUDAExecutionProvider", "cuda"),
           "cuda": ("gpu", "CUDAExecutionProvider", "cuda")}


def ensure_checkpoint(key, repo_override=None):
    """Return the local checkpoint dir for `key`, downloading from HF if it isn't present."""
    src = (HERE / MODELS[key]["dir"]).resolve()
    if (src / "config.json").exists():
        return src
    repo = repo_override or HF_REPO.get(key)
    if not repo:
        sys.exit(f"[{key}] no checkpoint at {src} and no HF repo known β€” pass --repo <org/name>")
    print(f"[{key}] checkpoint not found at {src}; downloading {repo} ...")
    from huggingface_hub import snapshot_download
    src.mkdir(parents=True, exist_ok=True)
    snapshot_download(repo, local_dir=str(src))
    print(f"[{key}] downloaded -> {src}")
    return src

VERBOSE = False    # set by --verbose: lowers Olive log severity + prints extra detail


def _log(*a):
    if VERBOSE:
        print(*a)


def all_components(model: str):
    llm = ["llm"] if MODELS[model].get("llm") else []   # some checkpoints (acoustic) have no LLM
    return llm + list(MODELS[model]["olive"].keys())


def detect_model_type(model_src: Path) -> str:
    """Infer the registry key from a checkpoint's config.json (model_type / architectures)."""
    cfg = json.loads((model_src / "config.json").read_text())
    mt = cfg.get("model_type"); arch = (cfg.get("architectures") or [""])[0]
    if mt == "vibevoice_streaming":
        return "realtime"
    if mt == "vibevoice_asr":
        return "asr-hf"
    if arch == "VibeVoiceForASRTraining":
        return "asr"
    if mt == "vibevoice_acoustic_tokenizer":
        return "acoustic"
    if mt == "vibevoice":
        return "1.5b"
    raise SystemExit(f"cannot auto-detect VibeVoice type at {model_src} "
                     f"(model_type={mt!r}, arch={arch!r})")


def resolve_target(arg: str):
    """Return (model_key, model_src) from a positional that is a registry key OR a path."""
    if arg in MODELS:
        return arg, (HERE / MODELS[arg]["dir"]).resolve()
    src = Path(arg).expanduser().resolve()
    if not (src / "config.json").exists():
        raise SystemExit(f"'{arg}' is not a known model key {list(MODELS)+['all']} "
                         f"nor a checkpoint dir with config.json")
    return detect_model_type(src), src


# Input token-embedding weight per model (the [vocab, hidden] lookup table). The genai decoders are
# built exclude_embeds=True (input = inputs_embeds), so this CONSTANT weight is shipped next to the
# ONNX to make the package self-contained β€” the driver turns token ids into vectors from this file
# (else it must read the multi-GB source checkpoint). Mirrors common.EMBED_KEY.
EMBED_WEIGHT_KEY = {
    "1.5b": "model.language_model.embed_tokens.weight",
    "asr": "model.language_model.embed_tokens.weight",
    "asr-hf": "language_model.model.embed_tokens.weight",
    "realtime": "model.tts_language_model.embed_tokens.weight",
}


def extract_embed_matrix(model: str, model_src: Path, target: Path, precision: str):
    """Export {target}/embeddings.onnx : input_ids[int64, BΓ—S] -> inputs_embeds[float, BΓ—SΓ—H] via a

    Gather on the input token-embedding table. The genai decoder is built exclude_embeds=True, so this

    small companion graph provides the missing embed step and makes the package self-contained (no

    source checkpoint at inference β€” the genai-native embeddings.onnx pattern). fp16 for fp16 builds,

    else fp32; weights go to embeddings.onnx.data (external). Constant weight β€” a one-time snapshot."""
    import glob
    import numpy as np
    import onnx
    from onnx import helper, numpy_helper, TensorProto
    from safetensors import safe_open
    key = EMBED_WEIGHT_KEY.get(model)
    if key is None:
        print(f"  [embed] no embed key for '{model}' β€” skip"); return
    # Always fp16: a fp32 [vocab,hidden] table is >2 GB and trips the protobuf serialize limit, and the
    # decoder's OnnxLLM driver upcasts inputs_embeds to the graph dtype anyway (so fp32/int4 decoders
    # still receive fp32 input). Embedding values sit well within fp16 range β€” negligible loss.
    np_dt, tp = np.float16, TensorProto.FLOAT16
    W = None
    for sf in glob.glob(str(Path(model_src) / "*.safetensors")):
        with safe_open(sf, "pt") as f:
            if key in f.keys():
                W = f.get_tensor(key).float().numpy().astype(np_dt); break
    if W is None:
        print(f"  [embed][warn] {key} not found in {model_src} β€” package will need the source checkpoint"); return
    V, H = W.shape
    g = helper.make_graph(
        [helper.make_node("Gather", ["embed_weight", "input_ids"], ["inputs_embeds"], axis=0)],
        "embeddings",
        inputs=[helper.make_tensor_value_info("input_ids", TensorProto.INT64, ["batch", "seq"])],
        outputs=[helper.make_tensor_value_info("inputs_embeds", tp, ["batch", "seq", H])],
        initializer=[numpy_helper.from_array(W, name="embed_weight")])
    m = helper.make_model(g, opset_imports=[helper.make_opsetid("", 17)])
    out = Path(target) / "embeddings.onnx"
    onnx.save(m, str(out), save_as_external_data=True, location="embeddings.onnx.data",
              size_threshold=1024, all_tensors_to_one_file=True)
    print(f"  [embed] embeddings.onnx {tuple(W.shape)} {np_dt.__name__} -> {target}")


def build_llm(model: str, model_src: Path, target: Path, precision: str, device: str):
    """Extract a standalone Qwen2 dir, then ModelBuilder β†’ llm_decoder.onnx at --precision."""
    import user_script
    from onnxruntime_genai.models.builder import create_model

    extract_name, excl_embeds, excl_head = MODELS[model]["llm"]
    extract = getattr(user_script, extract_name)
    _log(f"    [llm] extract={extract_name} -> standalone Qwen2 dir")
    qwen2 = extract(str(model_src), str(HERE))
    target.mkdir(parents=True, exist_ok=True)
    genai_device = DEVICES[device][2]
    # Unique cache dir PER INVOCATION (not a shared `.mb_cache_{model}`). onnxruntime-genai's
    # save_model() ends with `if not os.listdir(cache): os.rmdir(cache)` β€” so a shared cache races
    # when two optimize.py runs build the same model concurrently (e.g. int4 + fp16, or cpu + cuda):
    # the first run rmdir's it, the second run's os.listdir(cache) then raises FileNotFoundError.
    # tempfile.mkdtemp is atomic+unique, so concurrent builds never collide; we clean up defensively
    # afterwards (genai already removes it when left empty).
    import tempfile, shutil
    cache = Path(tempfile.mkdtemp(prefix=f".mb_cache_{model}_{device}_{precision}_", dir=str(HERE)))
    print(f"  ModelBuilder: llm_decoder {precision} on {genai_device} "
          f"(exclude_embeds={excl_embeds}, exclude_lm_head={excl_head}) -> {target}/llm_decoder.onnx")
    try:
        create_model("", qwen2, str(target), precision, genai_device, cache_dir=str(cache),
                     filename="llm_decoder.onnx", exclude_embeds=excl_embeds, exclude_lm_head=excl_head)
    finally:
        shutil.rmtree(cache, ignore_errors=True)
    print("  llm_decoder done.")
    extract_embed_matrix(model, model_src, target, precision)   # ship the embedding table alongside


def build_olive(model: str, component: str, model_src: Path, target: Path, precision: str, device: str):
    """Olive export (OnnxConversion dynamo; +OnnxFloatToFloat16 when precision==fp16)."""
    import os
    from olive import run

    loader, io_cfg, dummy, env = MODELS[model]["olive"][component]
    os.environ.update(env)
    olive_dev, ep, _ = DEVICES[device]
    tmp = (target / f"_{component}_tmp").resolve()
    passes = {"c": {"type": "OnnxConversion", "use_dynamo_exporter": True}}
    if precision == "fp16":
        # keep shape/resample ops in fp32 β€” converting them yields invalid graphs (e.g. the acoustic
        # tokenizer's ConstantOfShape emitted fp16 where a float32 consumer expects it) and they gain
        # nothing from fp16. Same class of block-list the Higgs encoders needed.
        passes["f16"] = {"type": "OnnxFloatToFloat16",
                         "op_block_list": ["ConstantOfShape", "ConvTranspose", "Resize", "Range"]}
    cfg = {
        "input_model": {"type": "PyTorchModel", "model_path": str(model_src),
                        "model_loader": loader, "model_script": str(HERE / "user_script.py"),
                        "io_config": io_cfg, "dummy_inputs_func": dummy},
        "systems": {"local": {"type": "LocalSystem",
                    "accelerators": [{"device": olive_dev, "execution_providers": [ep]}]}},
        "passes": passes, "target": "local", "no_artifacts": True,
        "log_severity_level": 1 if VERBOSE else 3,
        "output_dir": str(tmp), "cache_dir": str((target / f"_ocache_{component}").resolve()),
    }
    with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, dir=str(HERE)) as f:
        json.dump(cfg, f, indent=2); cfgp = f.name
    dtype = "fp16" if precision == "fp16" else "fp32"
    print(f"  Olive export {component} ({dtype}, {ep}) -> {target}/{component}.onnx")
    _log(f"    [olive] loader={loader} passes={list(passes)} env={env or '{}'}")
    try:
        run(cfgp)
    finally:
        Path(cfgp).unlink(missing_ok=True)
    for src in tmp.glob("model.onnx*"):
        dst = target / src.name.replace("model.onnx", f"{component}.onnx")
        if dst.exists(): dst.unlink()
        shutil.move(str(src), str(dst))
    shutil.rmtree(tmp, ignore_errors=True)
    shutil.rmtree(target / f"_ocache_{component}", ignore_errors=True)
    print(f"  {component} done.")


def build_model(model: str, model_src: Path, device: str, precision: str, components, output_dir,

                exclude_llm=False):
    spec = MODELS[model]
    if not (model_src / "config.json").exists():
        sys.exit(f"[{model}] no checkpoint at {model_src} (download it first)")
    # Output layout: onnx/{model}/{device}_{precision}
    target = Path(output_dir) if output_dir else (HERE / "onnx" / model / f"{device}_{precision}")
    target.mkdir(parents=True, exist_ok=True)

    comps = [c.strip() for c in components.split(",")] if components else all_components(model)
    unknown = [c for c in comps if c != "llm" and c not in spec["olive"]]
    if unknown:
        sys.exit(f"[{model}] unknown components {unknown}; valid: {all_components(model)}")
    if exclude_llm and "llm" in comps:
        comps = [c for c in comps if c != "llm"]
        print(f"[{model}] --exclude-llm: skipping the LLM decoder build")

    # int4 only quantizes the LLM (MatMulNBits). Keys with no LLM (e.g. acoustic β€” a conv VAE) have
    # no quantizable MatMul weights, so int4 is a no-op that just re-emits fp32. Warn + downgrade so
    # the output isn't a misleadingly-named fp32 copy.
    if precision == "int4" and "llm" not in comps:
        print(f"[{model}] WARNING: int4 is a no-op here (no LLM / conv-VAE components have no "
              f"quantizable MatMul weights) β€” building fp32 instead.")
        precision = "fp32"
        if not output_dir:
            target = HERE / "onnx" / model / f"{device}_fp32"
            target.mkdir(parents=True, exist_ok=True)

    print(f"\n=== {model}  src={model_src}  device={device}  precision={precision} ===")
    print(f"    target={target}  components={comps}")
    for comp in comps:
        if comp == "llm":
            build_llm(model, model_src, target, precision, device)
        else:
            build_olive(model, comp, model_src, target, precision, device)
    print(f"[{model}] done -> {target}")


def main():
    ap = argparse.ArgumentParser(description="VibeVoice family -> ONNX (unified)",
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--device", choices=["cpu", "cuda", "gpu"], default="cpu")
    ap.add_argument("--precision", choices=["int4", "fp16", "fp32"], default="int4")
    ap.add_argument("--components", help="comma-separated subset, e.g. llm,acoustic_decoder "
                                         "(default: all for the model)")
    ap.add_argument("--exclude-llm", action="store_true",
                    help="skip the LLM decoder build (e.g. the RAM-bound 7B ASR decoders)")
    ap.add_argument("--repo", help="override the HF repo id to download the checkpoint from")
    ap.add_argument("--output-dir", help="override output dir (default onnx/{model}/{device}_{precision})")
    ap.add_argument("--verbose", "-v", action="store_true", help="verbose logs (Olive + extra detail)")
    ap.add_argument("--list", action="store_true", help="list models + components and exit")
    ap.add_argument("model", nargs="?",
                    help="FINAL positional: a keyword {1.5b,asr,asr-hf,realtime,all}. "
                         "The checkpoint is used from ./<dir> if present, else downloaded from HF.")
    args = ap.parse_args()
    global VERBOSE
    VERBOSE = args.verbose
    device = "cuda" if args.device == "gpu" else args.device

    if args.list or not args.model:
        print("VibeVoice models & components:")
        for m in MODELS:
            print(f"  {m:9s} (dir '{MODELS[m]['dir']}', repo '{HF_REPO.get(m, 'local-only')}'): {' '.join(all_components(m))}")
        print("\nDevices: cpu cuda   Precisions: int4 fp16 fp32")
        print("Positional 'model': a keyword above or 'all'. Output -> onnx/{model}/{device}_{precision}")
        return

    if args.model == "all":
        if args.components:
            ap.error("--components cannot be combined with 'all'")
        if args.repo:
            ap.error("--repo cannot be combined with 'all'")
        keys = list(MODELS)
    elif args.model in MODELS:
        keys = [args.model]
    else:
        ap.error(f"unknown model '{args.model}'; choose from {list(MODELS) + ['all']}")

    for m in keys:
        src = ensure_checkpoint(m, args.repo)      # download if the local dir is missing
        build_model(m, src, device, args.precision, args.components, args.output_dir,
                    exclude_llm=args.exclude_llm)


if __name__ == "__main__":
    main()