From 580a20163138492ca155727f09685cbbd66e3258 Mon Sep 17 00:00:00 2001 From: tzhouam Date: Wed, 9 Sep 2026 15:02:19 +0800 Subject: [PATCH] [Edge][WP4] Windowed Code2Wav decoder export (torch.export / ExecuTorch) + client backend; results summarizer vllm_omni/edge/decoder_export.py exports a stateless windowed decoder (context 72 frames + chunk, one program per ramp chunk size) via torch.export (.pt2, verified here on CPU for chunks 2 and 25 with the real 0.6B decoder: exact match vs eager, 460 MB fp32 per program) or ExecuTorch XNNPACK (.pte; written against the executorch 1.x API, not executed: package not installed, install is permission gated; QNN partitioner export-only/unverified). The WP5 client gains --decoder-backend executorch --pte-manifest for phone/NPU targets. benchmarks/tts/edge_results/summarize.py renders all committed JSON as tables. Co-Authored-By: Claude Fable 5.1 --- ...der_export_manifest_0.6B_torch_export.json | 29 +++ benchmarks/tts/edge_results/summarize.py | 182 +++++++++++++++ .../qwen3_tts/edge_decoder_client.py | 61 ++++- tests/edge/test_decoder_export.py | 62 +++++ vllm_omni/edge/decoder_export.py | 216 ++++++++++++++++++ 5 files changed, 549 insertions(+), 1 deletion(-) create mode 100644 benchmarks/tts/edge_results/decoder_export_manifest_0.6B_torch_export.json create mode 100644 benchmarks/tts/edge_results/summarize.py create mode 100644 tests/edge/test_decoder_export.py create mode 100644 vllm_omni/edge/decoder_export.py diff --git a/benchmarks/tts/edge_results/decoder_export_manifest_0.6B_torch_export.json b/benchmarks/tts/edge_results/decoder_export_manifest_0.6B_torch_export.json new file mode 100644 index 000000000..e8b08714f --- /dev/null +++ b/benchmarks/tts/edge_results/decoder_export_manifest_0.6B_torch_export.json @@ -0,0 +1,29 @@ +{ + "model": "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice", + "backend": "torch_export", + "partitioner": null, + "context_frames": 72, + "num_quantizers": 16, + "hop": 1920, + "sample_rate": 24000, + "programs": { + "2": { + "chunk_frames": 2, + "window_frames": 74, + "max_abs_diff_vs_eager": 0.0, + "out_samples": 3840, + "path": "/tmp/claude-2005/-data-zhoutaichang-embedding-infer/e695f57b-0163-4287-b1be-1d0ec9330768/scratchpad/code2wav_export/code2wav_c2_ctx72.pt2", + "export_s": 8.396002520807087, + "bytes": 459787784 + }, + "25": { + "chunk_frames": 25, + "window_frames": 97, + "max_abs_diff_vs_eager": 0.0, + "out_samples": 48000, + "path": "/tmp/claude-2005/-data-zhoutaichang-embedding-infer/e695f57b-0163-4287-b1be-1d0ec9330768/scratchpad/code2wav_export/code2wav_c25_ctx72.pt2", + "export_s": 6.752497379668057, + "bytes": 459791131 + } + } +} \ No newline at end of file diff --git a/benchmarks/tts/edge_results/summarize.py b/benchmarks/tts/edge_results/summarize.py new file mode 100644 index 000000000..c45511435 --- /dev/null +++ b/benchmarks/tts/edge_results/summarize.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Render the committed edge results (stream_latency / init_profile / hw_emulation / cpu_sweep / +codec_stream) as markdown tables. + + python benchmarks/tts/edge_results/summarize.py [--root benchmarks/tts/edge_results] [--out summary.md] +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +HERE = Path(__file__).resolve().parent +GiB = 1 << 30 + + +def _load(p: Path) -> dict[str, Any] | None: + try: + return json.loads(p.read_text()) + except (OSError, ValueError): + return None + + +def _f(v: Any, nd: int = 1) -> str: + if v is None: + return "" + if isinstance(v, float): + return f"{v:.{nd}f}" + return str(v) + + +def stream_latency_rows(root: Path) -> list[dict[str, Any]]: + rows = [] + for f in sorted((root / "stream_latency").glob("*/*/*.json")): + d = _load(f) + if not d or "summary" not in d: + continue + s = d["summary"] + meta = d.get("meta", {}) + res = d.get("resources", {}) or {} + tag = f.stem.split("_", 1)[1] if "_" in f.stem else "" + st = d.get("step_stats") or {} + rows.append( + { + "model": f.parents[1].name.replace("Qwen3-TTS-12Hz-", "").replace("-CustomVoice", ""), + "deploy": f.parent.name, + "tag": tag, + "run": f.stem.split("_", 1)[0], + "n": s.get("n_requests"), + "init_s": d.get("init", {}).get("init_s"), + "ttfa_p50": s["ttfa_ms"]["p50"], + "ttfa_p90": s["ttfa_ms"]["p90"], + "playback_p50": s["playback_start_ms"]["p50"], + "stall_p50": s["stall_at_ttfa_ms"]["p50"], + "rtf_stream_p50": s["rtf_streamed"]["p50"], + "rtf_total_p50": s["rtf_total"]["p50"], + "rss_gib": (res.get("peak_rss_bytes") or 0) / GiB, + "gpu_mib": res.get("peak_gpu_mem_mib"), + "sha": (meta.get("git_sha") or "")[:8], + "step_stats": st.get("attribution", [])[:6] if st else None, + "path": str(f.relative_to(root)), + } + ) + return rows + + +def table(rows: list[dict[str, Any]], cols: list[str], nd: int = 1) -> str: + out = ["| " + " | ".join(cols) + " |", "|" + "---|" * len(cols)] + for r in rows: + out.append("| " + " | ".join(_f(r.get(c), nd) for c in cols) + " |") + return "\n".join(out) + + +def render(root: Path) -> str: + parts = ["# Edge results summary\n"] + rows = stream_latency_rows(root) + parts.append("## Streaming latency (stream_latency_bench.py)\n") + parts.append( + table( + rows, + [ + "model", + "deploy", + "tag", + "run", + "n", + "init_s", + "ttfa_p50", + "ttfa_p90", + "playback_p50", + "stall_p50", + "rtf_stream_p50", + "rtf_total_p50", + "rss_gib", + "gpu_mib", + "sha", + ], + ) + ) + for r in rows: + if r.get("step_stats"): + parts.append(f"\n### Step attribution: {r['model']} {r['deploy']} {r['tag']} ({r['run']})\n") + parts.append( + table(r["step_stats"], ["role", "counter", "count", "mean_ms", "p95_ms", "share_of_frame"], nd=3) + ) + for name, cols in ( + ("init_profile", ["config", "init_s", "ttfa_ms", "gpu_mib"]), + ( + "hw_emulation", + [ + "case", + "hardware_class", + "returncode", + "init_s", + "ttfa_p50", + "playback_start_p50", + "stall_p50", + "rtf_total_p50", + "peak_rss_gib", + "peak_gpu_mib", + ], + ), + ( + "cpu_sweep", + [ + "case", + "threads", + "inductor", + "returncode", + "init_s", + "ttfa_p50", + "playback_start_p50", + "rtf_total_p50", + "peak_rss_gib", + ], + ), + ): + for f in sorted((root / name).glob("*/summary.json")): + d = _load(f) + if not d: + continue + parts.append(f"\n## {name} {f.parent.name}\n") + rows_n = d.get("rows") or d.get("configs") or [] + if name == "init_profile" and isinstance(rows_n, dict): + rows_n = [{"config": k, **v} for k, v in rows_n.items()] + parts.append(table(rows_n, cols)) + if name == "cpu_sweep": + for r in rows_n: + if r.get("step_stats"): + parts.append(f"\n### Step attribution: {r['case']}\n") + parts.append( + table( + r["step_stats"], + ["role", "counter", "count", "mean_ms", "p95_ms", "share_of_frame"], + nd=3, + ) + ) + for f in sorted((root / "codec_stream").glob("**/*.json")): + d = _load(f) + if d and ("snr_db" in d or "parity" in d): + parts.append(f"\n## codec_stream {f.relative_to(root)}\n") + parts.append("```json\n" + json.dumps(d, indent=1)[:3000] + "\n```") + return "\n".join(parts) + "\n" + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--root", default=str(HERE)) + p.add_argument("--out", default=None) + args = p.parse_args(argv) + text = render(Path(args.root)) + if args.out: + Path(args.out).write_text(text) + else: + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/online_serving/text_to_speech/qwen3_tts/edge_decoder_client.py b/examples/online_serving/text_to_speech/qwen3_tts/edge_decoder_client.py index 0d7043e69..9699366bb 100644 --- a/examples/online_serving/text_to_speech/qwen3_tts/edge_decoder_client.py +++ b/examples/online_serving/text_to_speech/qwen3_tts/edge_decoder_client.py @@ -109,6 +109,63 @@ class LocalDecoder: return wav.float().cpu().numpy().reshape(-1) +class ExecutorchWindowedDecoder: + """Decoder backend for phone/NPU targets: exported windowed programs (see vllm_omni.edge.decoder_export). + + Keeps the last ``context_frames`` decoded frames as explicit state and runs + the ``.pte`` program matching the chunk size (falls back to the closest + larger program with zero-padded context when a size is missing). Untested + in this checkout (executorch not installed); mirrors ``LocalDecoder``'s API. + """ + + def __init__(self, manifest_path: str): + import json as _json + + from executorch.runtime import Runtime # type: ignore[import-not-found] + + self.manifest = _json.loads(Path(manifest_path).read_text()) + self.num_quantizers = int(self.manifest["num_quantizers"]) + self.sample_rate = int(self.manifest["sample_rate"]) + self.hop = int(self.manifest["hop"]) + self.context = int(self.manifest["context_frames"]) + rt = Runtime.get() + self.methods = {} + for chunk, entry in self.manifest["programs"].items(): + program = rt.load_program(entry["path"]) + self.methods[int(chunk)] = program.load_method("forward") + self.reset() + + def reset(self) -> None: + self.history = np.zeros((0, self.num_quantizers), dtype=np.int64) + self.frames_decoded = 0 + + def decode(self, codes: np.ndarray) -> np.ndarray: + if codes.shape[0] == 0: + return np.zeros(0, dtype=np.float32) + n = int(codes.shape[0]) + sizes = sorted(self.methods) + size = next((s for s in sizes if s >= n), sizes[-1]) + window = np.concatenate([self.history, codes], axis=0)[-(self.context + size) :] + pad = self.context + size - window.shape[0] + if pad > 0: + window = np.concatenate([np.zeros((pad, self.num_quantizers), dtype=np.int64), window], axis=0) + inp = torch.as_tensor(window.T[None], dtype=torch.long) # [1, Q, ctx+size] + out = self.methods[size].execute([inp])[0] + wav = out.reshape(-1).float().numpy()[-size * self.hop :] + self.history = np.concatenate([self.history, codes], axis=0)[-self.context :] + self.frames_decoded += n + return wav[-n * self.hop :] if n < size else wav + + +def build_decoder(args: argparse.Namespace, steady: int): + backend = getattr(args, "decoder_backend", "torch") + if backend == "executorch": + if not getattr(args, "pte_manifest", None): + raise SystemExit("--decoder-backend executorch needs --pte-manifest /manifest.json") + return ExecutorchWindowedDecoder(args.pte_manifest) + return LocalDecoder(args.model, device=args.device, left_context=args.left_context, chunk_frames=steady) + + # ------------------------------------------------------------------- client async def run(args: argparse.Namespace) -> dict[str, Any]: import websockets @@ -116,7 +173,7 @@ async def run(args: argparse.Namespace) -> dict[str, Any]: ramp = args.ramp or DEFAULT_RAMP schedule = parse_chunk_ramp({"codec_chunk_ramp": ramp}, steady=ramp[-1]) or ramp steady = schedule[-1] - decoder = LocalDecoder(args.model, device=args.device, left_context=args.left_context, chunk_frames=steady) + decoder = build_decoder(args, steady) url = f"ws://{args.host}:{args.port}/v1/audio/speech/stream" config: dict[str, Any] = { "type": "session.config", @@ -235,6 +292,8 @@ def main(argv: list[str] | None = None) -> int: p.add_argument("--max-new-tokens", dest="max_new_tokens", type=int, default=None) p.add_argument("--seed", type=int, default=None) p.add_argument("--device", default="cpu") + p.add_argument("--decoder-backend", dest="decoder_backend", choices=["torch", "executorch"], default="torch") + p.add_argument("--pte-manifest", dest="pte_manifest", default=None, help="manifest.json from decoder_export") p.add_argument("--left-context", type=int, default=72) p.add_argument("--ramp", type=int, nargs="*", default=None, help="chunk schedule in frames, default 2 4 8 16 25") p.add_argument("--out", default=None) diff --git a/tests/edge/test_decoder_export.py b/tests/edge/test_decoder_export.py new file mode 100644 index 000000000..c7114256e --- /dev/null +++ b/tests/edge/test_decoder_export.py @@ -0,0 +1,62 @@ +"""WP4 decoder export: windowed wrapper (CPU, fake decoder) and ExecuTorch lowering (skipped without executorch).""" + +from __future__ import annotations + +import importlib.util + +import pytest +import torch +from torch import nn + +from vllm_omni.edge import decoder_export as dx + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +HOP = 4 + + +class _FakeConfig: + num_quantizers = 3 + codebook_size = 8 + output_sample_rate = 24000 + + +class _FakeDecoder(nn.Module): + """Stands in for Qwen3TTSTokenizerV2Decoder: ``_forward_exact`` maps each frame to HOP samples.""" + + total_upsample = HOP + config = _FakeConfig() + + def __init__(self): + super().__init__() + self.scale = nn.Parameter(torch.tensor(0.5)) + + def _forward_exact(self, codes: torch.Tensor) -> torch.Tensor: + # [1, Q, T] -> [1, 1, T*HOP]; value depends on the frame's codes so slicing is testable + per_frame = codes.to(torch.float32).sum(dim=1, keepdim=True) * self.scale # [1, 1, T] + return per_frame.repeat_interleave(HOP, dim=-1).clamp(min=-1000, max=1000) + + +def test_windowed_wrapper_returns_only_the_new_chunk(): + module = dx.WindowedCode2Wav(_FakeDecoder(), chunk_frames=2, context_frames=3) + assert module.window_frames == 5 and module.hop == HOP and module.num_quantizers == 3 + codes = torch.arange(15, dtype=torch.long).reshape(1, 3, 5) + out = module(codes) + assert out.shape == (1, 2 * HOP) + full = _FakeDecoder()._forward_exact(codes).reshape(1, -1) + assert torch.equal(out, full[:, -2 * HOP :]) + + +def test_torch_export_of_windowed_wrapper_matches_eager(): + module = dx.WindowedCode2Wav(_FakeDecoder(), chunk_frames=2, context_frames=3) + ep = dx.export_program(module) + codes = torch.randint(0, 8, module.example_input().shape, dtype=torch.long) + with torch.inference_mode(): + assert torch.allclose(ep.module()(codes), module(codes)) + + +@pytest.mark.skipif(importlib.util.find_spec("executorch") is None, reason="executorch not installed") +def test_executorch_lowering_of_windowed_wrapper(): + module = dx.WindowedCode2Wav(_FakeDecoder(), chunk_frames=2, context_frames=3) + buf = dx.lower_executorch(dx.export_program(module), partitioner="xnnpack") + assert isinstance(buf, (bytes, bytearray)) and len(buf) > 0 diff --git a/vllm_omni/edge/decoder_export.py b/vllm_omni/edge/decoder_export.py new file mode 100644 index 000000000..91edb8fcd --- /dev/null +++ b/vllm_omni/edge/decoder_export.py @@ -0,0 +1,216 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Export the Qwen3-TTS Code2Wav decoder for on-device (NPU / phone) runtimes (WP4). + +vLLM-Omni cannot run on phone NPUs; the ``npu_phone`` hardware class is served +by the codec-token stream (WP5) plus an exported decoder artifact. This module +exports a *windowed, stateless* variant of ``Qwen3TTSTokenizerV2Decoder``: + +* input ``codes``: ``int64[1, num_quantizers, context_frames + chunk_frames]`` + (the last ``context_frames`` frames already decoded plus the new chunk); +* output ``wav``: ``float32[1, chunk_frames * hop]`` (only the new chunk's audio, + ``hop = total_upsample`` samples per frame, 1920 at 24 kHz / 12.5 Hz). + +Fixed shapes (one program per chunk size of the ramp schedule) keep the export +static, which is what ExecuTorch / QNN / CoreML partitioners need; the +72-frame window is explicit input state instead of the decoder's Python +caches, so the artifact carries no mutable state. Backends: + +* ``torch_export`` - ``torch.export`` + ``.pt2`` (always available); +* ``executorch`` - XNNPACK-lowered ``.pte`` (needs ``pip install executorch``; + the QNN partitioner is export-only and listed as unverified). + +Status: the ExecuTorch path is written against the executorch 1.x API but was +not executed in this checkout (package not installed; install is permission +gated). ``tests/edge/test_decoder_export.py`` skips the lowering without it and +still checks the windowed wrapper against the eager decoder. + +CLI: + python -m vllm_omni.edge.decoder_export --model Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice \ + --ramp 2 4 8 16 25 --context 72 --backend torch_export --out-dir ./code2wav_export +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path +from typing import Any + +import torch +from torch import nn + +DEFAULT_RAMP = (2, 4, 8, 16, 25) +DEFAULT_CONTEXT = 72 + + +class WindowedCode2Wav(nn.Module): + """Stateless windowed decode: full window in, trailing ``chunk_frames`` of audio out.""" + + def __init__(self, decoder: nn.Module, chunk_frames: int, context_frames: int = DEFAULT_CONTEXT): + super().__init__() + self.decoder = decoder + self.chunk_frames = int(chunk_frames) + self.context_frames = int(context_frames) + self.hop = int(getattr(decoder, "total_upsample", 1920)) + self.num_quantizers = int(getattr(getattr(decoder, "config", None), "num_quantizers", 16)) + + @property + def window_frames(self) -> int: + return self.context_frames + self.chunk_frames + + def example_input(self) -> torch.Tensor: + return torch.zeros(1, self.num_quantizers, self.window_frames, dtype=torch.long) + + def forward(self, codes: torch.Tensor) -> torch.Tensor: + wav = self.decoder._forward_exact(codes) # [1, 1, samples] or [1, samples] + wav = wav.reshape(wav.shape[0], -1) + return wav[:, -self.chunk_frames * self.hop :] + + +def resolve_model_dir(model: str) -> Path: + p = Path(model) + if p.is_dir(): + return p + from huggingface_hub import snapshot_download + + return Path(snapshot_download(model)) + + +def load_code2wav_decoder(model: str, dtype: torch.dtype = torch.float32) -> nn.Module: + """Load ``Qwen3TTSTokenizerV2Decoder`` from ``/speech_tokenizer`` (eager, CPU).""" + from safetensors.torch import load_file + + from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.configuration_qwen3_tts_tokenizer_v2 import ( + Qwen3TTSTokenizerV2Config, + ) + from vllm_omni.model_executor.models.qwen3_tts.tokenizer_12hz.modeling_qwen3_tts_tokenizer_v2 import ( + Qwen3TTSTokenizerV2Decoder, + ) + + model_dir = resolve_model_dir(model) + cfg = Qwen3TTSTokenizerV2Config.from_pretrained(str(model_dir), subfolder="speech_tokenizer") + decoder = Qwen3TTSTokenizerV2Decoder._from_config(cfg.decoder_config) + state = load_file(str(model_dir / "speech_tokenizer" / "model.safetensors")) + dec_state = {k[len("decoder.") :]: v for k, v in state.items() if k.startswith("decoder.")} + _, unexpected = decoder.load_state_dict(dec_state, strict=False) + if unexpected: + raise RuntimeError(f"unexpected decoder keys: {unexpected[:5]}") + decoder.eval().to(dtype=dtype) + if hasattr(decoder, "precompute_snake_caches"): + decoder.precompute_snake_caches() + return decoder + + +def export_program(module: WindowedCode2Wav) -> Any: + """``torch.export`` the windowed module with its fixed example input.""" + example = module.example_input() + with torch.inference_mode(): + return torch.export.export(module.eval(), (example,)) + + +def lower_executorch(exported_program: Any, partitioner: str = "xnnpack") -> bytes: + """Lower an exported program to an ExecuTorch ``.pte`` buffer (requires executorch).""" + from executorch.exir import to_edge_transform_and_lower # type: ignore[import-not-found] + + partitioners = [] + if partitioner == "xnnpack": + from executorch.backends.xnnpack.partition.xnnpack_partitioner import ( # type: ignore[import-not-found] + XnnpackPartitioner, + ) + + partitioners = [XnnpackPartitioner()] + elif partitioner == "qnn": # unverified: needs the Qualcomm SDK build of executorch + from executorch.backends.qualcomm.partition.qnn_partitioner import ( # type: ignore[import-not-found] + QnnPartitioner, + ) + + partitioners = [QnnPartitioner()] + elif partitioner not in ("none", ""): + raise ValueError(f"unknown partitioner {partitioner!r}") + edge = to_edge_transform_and_lower(exported_program, partitioner=partitioners or None) + return edge.to_executorch().buffer + + +def export_windowed_decoder( + model: str, + out_dir: str | Path, + *, + ramp: tuple[int, ...] = DEFAULT_RAMP, + context_frames: int = DEFAULT_CONTEXT, + backend: str = "torch_export", + partitioner: str = "xnnpack", + check: bool = True, +) -> dict[str, Any]: + """Export one program per chunk size; returns a manifest (also written as ``manifest.json``).""" + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + decoder = load_code2wav_decoder(model) + manifest: dict[str, Any] = { + "model": model, + "backend": backend, + "partitioner": partitioner if backend == "executorch" else None, + "context_frames": context_frames, + "num_quantizers": int(decoder.config.num_quantizers), + "hop": int(decoder.total_upsample), + "sample_rate": int(getattr(decoder.config, "output_sample_rate", 24000) or 24000), + "programs": {}, + } + for chunk in sorted(set(int(c) for c in ramp)): + module = WindowedCode2Wav(decoder, chunk, context_frames) + t0 = time.perf_counter() + ep = export_program(module) + entry: dict[str, Any] = {"chunk_frames": chunk, "window_frames": module.window_frames} + if check: + codes = torch.randint(0, int(decoder.config.codebook_size), module.example_input().shape, dtype=torch.long) + with torch.inference_mode(): + ref = module(codes) + got = ep.module()(codes) + entry["max_abs_diff_vs_eager"] = float((ref - got).abs().max()) + entry["out_samples"] = int(got.shape[-1]) + if backend == "torch_export": + path = out / f"code2wav_c{chunk}_ctx{context_frames}.pt2" + torch.export.save(ep, str(path)) + elif backend == "executorch": + path = out / f"code2wav_c{chunk}_ctx{context_frames}.pte" + path.write_bytes(lower_executorch(ep, partitioner)) + else: + raise ValueError(f"unknown backend {backend!r}") + entry["path"] = str(path) + entry["export_s"] = time.perf_counter() - t0 + entry["bytes"] = path.stat().st_size + manifest["programs"][str(chunk)] = entry + print( + f"[decoder_export] chunk={chunk} -> {path} ({entry['bytes'] / 1e6:.1f} MB, {entry['export_s']:.1f} s)", + flush=True, + ) + (out / "manifest.json").write_text(json.dumps(manifest, indent=1)) + return manifest + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--model", required=True) + p.add_argument("--out-dir", required=True) + p.add_argument("--ramp", type=int, nargs="*", default=list(DEFAULT_RAMP)) + p.add_argument("--context", type=int, default=DEFAULT_CONTEXT) + p.add_argument("--backend", choices=["torch_export", "executorch"], default="torch_export") + p.add_argument("--partitioner", choices=["xnnpack", "qnn", "none"], default="xnnpack") + p.add_argument("--no-check", action="store_true") + args = p.parse_args(argv) + export_windowed_decoder( + args.model, + args.out_dir, + ramp=tuple(args.ramp), + context_frames=args.context, + backend=args.backend, + partitioner=args.partitioner, + check=not args.no_check, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())