Automatic Speech Recognition
Transformers
ONNX
English
onnxruntime
speech
asr
granite
ibm
quantized
int8
fp16
non-autoregressive
nar
Instructions to use smcleod/ibm-granite-speech-4.1-2b-nar-onnx with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use smcleod/ibm-granite-speech-4.1-2b-nar-onnx with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("automatic-speech-recognition", model="smcleod/ibm-granite-speech-4.1-2b-nar-onnx")# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("smcleod/ibm-granite-speech-4.1-2b-nar-onnx", dtype="auto") - Notebooks
- Google Colab
- Kaggle
| # Copyright 2026 Sam McLeod | |
| # | |
| # Licensed 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. | |
| """Export the NAR editor (Granite 4.0 1B LLM run bidirectionally) as a single | |
| ONNX graph and verify parity against the captured PyTorch baseline. | |
| Wraps `model.llm.model` + `model.llm.lm_head` in an nn.Module whose forward | |
| takes (inputs_embeds, position_ids, attention_mask) where attention_mask is a | |
| 4-D additive mask (zeros = attention allowed everywhere). Exports with | |
| torch.onnx.export at opset 20, IR 10, single sidecar. | |
| End-to-end parity test: | |
| 1. Run the already-exported encoder.onnx on the reference clip. | |
| 2. Run CTC greedy decode + slot insertion + flat embedding assembly via the | |
| upstream model's bound methods (matches what Rust glue will do). | |
| 3. Run the resulting `inputs_embeds` through both the patched PyTorch editor | |
| and the exported editor.onnx, then compare logits. | |
| 4. Decode the ONNX logits at the text positions and verify the transcript | |
| matches the upstream NAR transcript exactly. | |
| Usage: | |
| HF_HOME=$TMPDIR/hf_home HF_MODULES_CACHE=$TMPDIR/hf_modules \ | |
| uv run python src/export_nar_editor.py | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import os | |
| import time | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| import soundfile as sf | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| # Resolve roots so the script works whether it lives at <repo>/src/<name>.py | |
| # (project layout) or <bundle>/<name>.py (HF bundle layout). Defaults exist for | |
| # the project layout; bundle users should pass explicit --audio / --baseline / | |
| # --model-dir / --out-dir. | |
| SCRIPT_DIR = Path(__file__).resolve().parent | |
| REPO_ROOT = SCRIPT_DIR.parent if SCRIPT_DIR.name == "src" else SCRIPT_DIR | |
| DEFAULT_AUDIO = REPO_ROOT / "test_data" / "10226_10111_000000.wav" | |
| DEFAULT_BASELINE = REPO_ROOT / "test_data" / "baselines" / "nar.json" | |
| DEFAULT_MODEL_DIR = REPO_ROOT / "models" / "granite-speech-4.1-2b-nar" | |
| DEFAULT_OUT_DIR = REPO_ROOT / "exports" / "granite-speech-4.1-2b-nar" | |
| def load_audio(path: Path) -> np.ndarray: | |
| waveform, sr = sf.read(str(path), dtype="float32") | |
| if waveform.ndim > 1: | |
| waveform = waveform.mean(axis=1) | |
| assert sr == 16000, f"expected 16 kHz, got {sr}" | |
| return waveform | |
| def tensor_stats(t: torch.Tensor | np.ndarray | None) -> dict[str, Any] | None: | |
| if t is None: | |
| return None | |
| if isinstance(t, torch.Tensor): | |
| x = t.detach().float().cpu().numpy() | |
| dtype_str = str(t.dtype).replace("torch.", "") | |
| else: | |
| x = np.asarray(t).astype(np.float32, copy=False) | |
| dtype_str = str(t.dtype) | |
| flat = x.flatten() | |
| return { | |
| "shape": list(x.shape), | |
| "dtype": dtype_str, | |
| "mean": float(flat.mean()) if flat.size else None, | |
| "std": float(flat.std()) if flat.size else None, | |
| "min": float(flat.min()) if flat.size else None, | |
| "max": float(flat.max()) if flat.size else None, | |
| "first10": [float(v) for v in flat[:10]], | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Wrapper module: LLM backbone + lm_head exposed as a single graph. | |
| # --------------------------------------------------------------------------- | |
| class NAREditor(nn.Module): | |
| """Wrap llm.model + llm.lm_head with a 4-D additive attention-mask input. | |
| Inputs: | |
| inputs_embeds: float32 [1, N_total, D_llm] pre-built flat sequence | |
| position_ids: int64 [1, N_total] cumulative per-sample positions | |
| attention_mask: float32 [1, 1, N_total, N_total] additive mask | |
| Zeros = attention allowed everywhere (bidirectional). | |
| Output: | |
| logits: float32 [1, N_total, V_llm] | |
| The Granite model in transformers 5.8 calls `create_causal_mask`, which | |
| in turn calls `_preprocess_mask_arguments`. That helper short-circuits and | |
| returns any 4-D attention mask as-is. So feeding zeros disables causality. | |
| """ | |
| def __init__(self, llm_model: nn.Module, lm_head: nn.Module) -> None: | |
| super().__init__() | |
| self.llm_model = llm_model | |
| self.lm_head = lm_head | |
| def forward( | |
| self, | |
| inputs_embeds: torch.Tensor, | |
| position_ids: torch.Tensor, | |
| attention_mask: torch.Tensor, | |
| ) -> torch.Tensor: | |
| out = self.llm_model( | |
| inputs_embeds=inputs_embeds, | |
| position_ids=position_ids, | |
| attention_mask=attention_mask, | |
| use_cache=False, | |
| ) | |
| return self.lm_head(out.last_hidden_state) | |
| # --------------------------------------------------------------------------- | |
| # Model loading (mirrors capture_baselines.py::capture_nar). | |
| # --------------------------------------------------------------------------- | |
| def load_nar_model(model_dir: Path) -> tuple[nn.Module, Any]: | |
| """Load NAR model with the same patches capture_baselines.py uses.""" | |
| from transformers import AutoConfig, AutoFeatureExtractor, AutoModel | |
| granite_local = REPO_ROOT / "models" / "granite-4.0-1b-base" | |
| if not granite_local.exists(): | |
| raise FileNotFoundError( | |
| f"Expected local Granite 4.0 base at {granite_local}; " | |
| "run `hf download ibm-granite/granite-4.0-1b-base " | |
| "--include '*.json' --include 'tokenizer*' --include '*.txt' " | |
| f"--local-dir {granite_local}` first." | |
| ) | |
| config = AutoConfig.from_pretrained(str(model_dir), trust_remote_code=True) | |
| config.llm_name = str(granite_local) | |
| config.attn_implementation = "eager" | |
| config._attn_implementation = "eager" | |
| for sub_attr in ("llm_config", "encoder_config", "projector_config"): | |
| sub = getattr(config, sub_attr, None) | |
| if sub is not None: | |
| for attr in ("attn_implementation", "_attn_implementation"): | |
| try: | |
| setattr(sub, attr, "eager") | |
| except Exception: | |
| pass | |
| print(f" loading model from {model_dir} (eager)") | |
| t0 = time.time() | |
| model = AutoModel.from_pretrained( | |
| str(model_dir), | |
| trust_remote_code=True, | |
| torch_dtype=torch.float32, | |
| attn_implementation="eager", | |
| config=config, | |
| ) | |
| model.eval() | |
| # The NAR config nests `llm_config.dtype = "bfloat16"`, which overrides | |
| # the top-level `torch_dtype=float32` request for the LLM submodule. | |
| # Force the whole model (including LLM and lm_head) to fp32. | |
| model = model.to(torch.float32) | |
| print(f" loaded in {time.time() - t0:.1f}s") | |
| fe = AutoFeatureExtractor.from_pretrained(str(model_dir), trust_remote_code=True) | |
| return model, fe | |
| # --------------------------------------------------------------------------- | |
| # Build inputs_embeds for parity from the already-exported encoder.onnx. | |
| # --------------------------------------------------------------------------- | |
| def build_editor_inputs( | |
| model: nn.Module, | |
| fe: Any, | |
| waveform: np.ndarray, | |
| encoder_onnx_path: Path, | |
| ) -> tuple[torch.Tensor, torch.Tensor, list[int], list[int], list[str]]: | |
| """Mirror what Rust glue does: encoder.onnx -> CTC greedy + slot inserts -> | |
| flat (audio, text_with_slots) sequence. | |
| Returns: | |
| flat_embeds: [1, N_total, 2048] | |
| flat_position_ids: [1, N_total] | |
| projected_lengths: per-sample audio-length list | |
| text_lengths: per-sample text-length list | |
| text_ctc_preds: per-sample CTC strings (for debug) | |
| """ | |
| import onnxruntime as ort | |
| waveform_t = torch.from_numpy(waveform.copy()) | |
| inputs = fe([waveform_t], device="cpu") | |
| input_features = inputs["input_features"].to(torch.float32) | |
| attention_mask_int = inputs["attention_mask"].to(torch.int64) | |
| print(f" running encoder.onnx for inputs_embeds construction") | |
| sess = ort.InferenceSession(str(encoder_onnx_path), providers=["CPUExecutionProvider"]) | |
| out_names = [o.name for o in sess.get_outputs()] | |
| ort_outputs = sess.run( | |
| out_names, | |
| { | |
| "input_features": input_features.numpy().astype(np.float32), | |
| "attention_mask": attention_mask_int.numpy().astype(np.int64), | |
| }, | |
| ) | |
| out_map = dict(zip(out_names, ort_outputs)) | |
| bpe_dense = torch.from_numpy(out_map["bpe_logits_dense"]) # [B, T_bpe, V_bpe] | |
| bpe_mask = torch.from_numpy(out_map["bpe_mask"]) # [B, T_bpe] bool | |
| audio_embeds = torch.from_numpy(out_map["audio_embeds"]) # [B, T_audio, 2048] | |
| audio_lengths = torch.from_numpy(out_map["audio_lengths"]) # [B] int64 | |
| # Reconstruct sparse BPE logits ([N_valid, V_bpe]) and per-sample lengths. | |
| bpe_lengths = bpe_mask.sum(dim=1).tolist() | |
| bpe_logits_flat = bpe_dense[bpe_mask] # [N_valid, V_bpe] | |
| # CTC greedy decode -> List[str] (one per sample). | |
| text_ctc_preds = model._decode_bpe_ctc_greedy(bpe_logits_flat, bpe_lengths) | |
| print(f" text_ctc_preds: {text_ctc_preds!r}") | |
| # Build flat LLM inputs. The upstream `_build_flat_llm_inputs` calls | |
| # `self.projector(encoder_embs)` to produce audio embeddings; we already | |
| # have those from the encoder.onnx. To avoid re-running the projector, | |
| # we replicate the slot-insertion + concat steps inline here. | |
| # This must match the upstream method byte-for-byte modulo the projector | |
| # source (encoder.onnx vs PyTorch projector). | |
| if model.config.scale_projected_embeddings and hasattr(model.llm.config, "embedding_multiplier"): | |
| audio_embeds_scaled = audio_embeds / model.llm.config.embedding_multiplier | |
| else: | |
| audio_embeds_scaled = audio_embeds | |
| audio_embeds_scaled = audio_embeds_scaled.to(model.llm.model.embed_tokens.weight.dtype) | |
| pred_text_llm_tokens = model.llm_tokenizer(text_ctc_preds) | |
| text_ids_with_slots = [ | |
| model.add_insertion_slots(torch.tensor(x)) | |
| for x in pred_text_llm_tokens.input_ids | |
| ] | |
| embed_tokens = model.llm.model.embed_tokens | |
| embeds_list = [] | |
| position_ids_list = [] | |
| text_lengths = [] | |
| projected_lengths = audio_lengths.tolist() | |
| for i, audio_len in enumerate(projected_lengths): | |
| audio = audio_embeds_scaled[i, :audio_len] | |
| text_emb = embed_tokens(text_ids_with_slots[i]) | |
| sample = torch.cat([audio, text_emb], dim=0) | |
| embeds_list.append(sample) | |
| position_ids_list.append(torch.arange(sample.shape[0])) | |
| text_lengths.append(text_ids_with_slots[i].shape[0]) | |
| flat_embeds = torch.cat(embeds_list, dim=0).unsqueeze(0).to(torch.float32) | |
| flat_position_ids = torch.cat(position_ids_list, dim=0).unsqueeze(0).to(torch.int64) | |
| print( | |
| f" flat_embeds={tuple(flat_embeds.shape)} " | |
| f"flat_position_ids={tuple(flat_position_ids.shape)} " | |
| f"projected_lengths={projected_lengths} text_lengths={text_lengths}" | |
| ) | |
| return flat_embeds, flat_position_ids, projected_lengths, text_lengths, text_ctc_preds | |
| # --------------------------------------------------------------------------- | |
| # Export. | |
| # --------------------------------------------------------------------------- | |
| def export_onnx( | |
| wrapper: NAREditor, | |
| sample_inputs_embeds: torch.Tensor, | |
| sample_position_ids: torch.Tensor, | |
| sample_attention_mask: torch.Tensor, | |
| out_path: Path, | |
| opset: int = 20, | |
| ir_version: int = 10, | |
| ) -> None: | |
| import tempfile | |
| import onnx | |
| out_path.parent.mkdir(parents=True, exist_ok=True) | |
| print(f" exporting to {out_path} (opset={opset}, ir_version={ir_version})") | |
| dynamic_axes = { | |
| "inputs_embeds": {1: "N"}, | |
| "position_ids": {1: "N"}, | |
| "attention_mask": {2: "N", 3: "N"}, | |
| "logits": {1: "N"}, | |
| } | |
| with tempfile.TemporaryDirectory(prefix="nar_editor_onnx_") as scratch_dir: | |
| scratch_path = Path(scratch_dir) / "editor.onnx" | |
| t0 = time.time() | |
| torch.onnx.export( | |
| wrapper, | |
| (sample_inputs_embeds, sample_position_ids, sample_attention_mask), | |
| str(scratch_path), | |
| input_names=["inputs_embeds", "position_ids", "attention_mask"], | |
| output_names=["logits"], | |
| dynamic_axes=dynamic_axes, | |
| opset_version=opset, | |
| do_constant_folding=True, | |
| export_params=True, | |
| dynamo=False, | |
| ) | |
| print(f" stage-1 torch.onnx.export done in {time.time() - t0:.1f}s") | |
| print(" stage-2: re-saving with single .onnx_data sidecar + ir bump") | |
| model_proto = onnx.load(str(scratch_path), load_external_data=True) | |
| if model_proto.ir_version < ir_version: | |
| model_proto.ir_version = ir_version | |
| for tensor in model_proto.graph.initializer: | |
| tensor.ClearField("data_location") | |
| tensor.ClearField("external_data") | |
| sidecar_name = out_path.name + "_data" | |
| if (out_path.parent / sidecar_name).exists(): | |
| (out_path.parent / sidecar_name).unlink() | |
| if out_path.exists(): | |
| out_path.unlink() | |
| onnx.save_model( | |
| model_proto, | |
| str(out_path), | |
| save_as_external_data=True, | |
| all_tensors_to_one_file=True, | |
| location=sidecar_name, | |
| size_threshold=1024, | |
| convert_attribute=False, | |
| ) | |
| onnx.checker.check_model(str(out_path), full_check=False) | |
| # Quick op-domain audit: the doc target says no `com.microsoft` ops. | |
| domains = sorted({n.domain for n in model_proto.graph.node}) | |
| print(f" saved {out_path} (+ {sidecar_name}) node-domains={domains}") | |
| # --------------------------------------------------------------------------- | |
| # Parity test. | |
| # --------------------------------------------------------------------------- | |
| def run_parity( | |
| model: nn.Module, | |
| flat_embeds: torch.Tensor, | |
| flat_position_ids: torch.Tensor, | |
| projected_lengths: list[int], | |
| text_lengths: list[int], | |
| text_ctc_preds: list[str], | |
| onnx_path: Path, | |
| parity_json: Path, | |
| baseline_json: Path, | |
| abs_tol: float = 1e-3, | |
| argmax_only: bool = False, | |
| ) -> bool: | |
| import onnxruntime as ort | |
| print("\n=== parity check ===") | |
| N = flat_embeds.shape[1] | |
| attention_mask = torch.zeros(1, 1, N, N, dtype=flat_embeds.dtype) | |
| print(" running PyTorch editor (llm.model + lm_head with 4-D zeros mask)") | |
| t0 = time.time() | |
| with torch.inference_mode(): | |
| out = model.llm.model( | |
| inputs_embeds=flat_embeds, | |
| position_ids=flat_position_ids, | |
| attention_mask=attention_mask, | |
| use_cache=False, | |
| ) | |
| logits_pt = model.llm.lm_head(out.last_hidden_state) | |
| print(f" pytorch forward: {time.time() - t0:.2f}s") | |
| print(f" logits_pt shape={tuple(logits_pt.shape)}") | |
| print(f" running ONNX inference: {onnx_path}") | |
| sess = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"]) | |
| t0 = time.time() | |
| ort_outputs = sess.run( | |
| ["logits"], | |
| { | |
| "inputs_embeds": flat_embeds.detach().numpy().astype(np.float32), | |
| "position_ids": flat_position_ids.detach().numpy().astype(np.int64), | |
| "attention_mask": attention_mask.detach().numpy().astype(np.float32), | |
| }, | |
| ) | |
| print(f" onnx forward: {time.time() - t0:.2f}s") | |
| logits_ort = ort_outputs[0] | |
| print(f" logits_ort shape={logits_ort.shape}") | |
| pt_np = logits_pt.detach().float().cpu().numpy() | |
| abs_err = np.abs(pt_np - logits_ort) | |
| max_err = float(abs_err.max()) | |
| mean_err = float(abs_err.mean()) | |
| p99 = float(np.percentile(abs_err, 99)) | |
| am_pt = pt_np.argmax(-1) | |
| am_ort = logits_ort.argmax(-1) | |
| argmax_mismatches = int((am_pt != am_ort).sum()) | |
| argmax_total = int(am_pt.size) | |
| # Per-segment argmax: only the text positions feed the transcript decode | |
| # (audio positions are read-only inputs to attention). Track separately so | |
| # INT8 mode can ship on transcript correctness even when audio-position | |
| # logits drift under weight quantisation. | |
| text_argmax_mismatches = 0 | |
| text_argmax_total = 0 | |
| audio_argmax_mismatches = 0 | |
| audio_argmax_total = 0 | |
| seg_offset = 0 | |
| for i in range(len(projected_lengths)): | |
| a_lo, a_hi = seg_offset, seg_offset + projected_lengths[i] | |
| t_lo, t_hi = a_hi, a_hi + text_lengths[i] | |
| seg_offset = t_hi | |
| audio_argmax_mismatches += int((am_pt[0, a_lo:a_hi] != am_ort[0, a_lo:a_hi]).sum()) | |
| audio_argmax_total += a_hi - a_lo | |
| text_argmax_mismatches += int((am_pt[0, t_lo:t_hi] != am_ort[0, t_lo:t_hi]).sum()) | |
| text_argmax_total += t_hi - t_lo | |
| # Top-5 stability check. | |
| topk_pt = np.argsort(-pt_np, axis=-1)[..., :5] | |
| topk_ort = np.argsort(-logits_ort, axis=-1)[..., :5] | |
| top1_match = int((topk_pt[..., 0] == topk_ort[..., 0]).sum()) | |
| top5_set_match = int( | |
| ( | |
| np.sort(topk_pt, axis=-1) == np.sort(topk_ort, axis=-1) | |
| ).all(axis=-1).sum() | |
| ) | |
| print("\n--- logits diff ---") | |
| print(f" shape pt={pt_np.shape} ort={logits_ort.shape}") | |
| print(f" max_abs_err={max_err:.3e} mean_abs_err={mean_err:.3e} p99={p99:.3e}") | |
| print(f" argmax mismatches: {argmax_mismatches}/{argmax_total}") | |
| print( | |
| f" text-segment argmax mismatches: {text_argmax_mismatches}/{text_argmax_total}; " | |
| f"audio-segment argmax mismatches: {audio_argmax_mismatches}/{audio_argmax_total}" | |
| ) | |
| print(f" top1 match: {top1_match}/{argmax_total} top5-set match: {top5_set_match}/{argmax_total}") | |
| # End-to-end transcript check: slice text positions from ONNX logits and run | |
| # the upstream argmax + unique_consecutive + EOS removal. | |
| eos_id = int(model.llm.config.eos_token_id) | |
| offset = 0 | |
| decoded_segments = [] | |
| for i in range(len(projected_lengths)): | |
| offset += projected_lengths[i] | |
| seg = logits_ort[0, offset:offset + text_lengths[i]] | |
| offset += text_lengths[i] | |
| pred = seg.argmax(-1) | |
| # Use torch.unique_consecutive to mirror upstream exactly. | |
| collapsed = torch.unique_consecutive(torch.from_numpy(pred)).tolist() | |
| collapsed = [t for t in collapsed if t != eos_id] | |
| text = model.llm_tokenizer.decode(collapsed, skip_special_tokens=True) | |
| decoded_segments.append(text) | |
| onnx_transcript = decoded_segments[0] if decoded_segments else "" | |
| print(f"\n ONNX transcript: {onnx_transcript!r}") | |
| baseline_transcript = None | |
| if baseline_json.exists(): | |
| baseline = json.loads(baseline_json.read_text()) | |
| baseline_transcript = baseline.get("transcript") | |
| print(f" baseline transcript: {baseline_transcript!r}") | |
| transcript_match = bool( | |
| baseline_transcript is not None and onnx_transcript == baseline_transcript | |
| ) | |
| # Pass criteria: zero argmax mismatches AND transcript matches baseline. | |
| # The max-abs threshold is informational; spec mandates argmax stability. | |
| # In INT8 mode, only text-position argmax matters - audio positions feed | |
| # attention but never get sliced for the transcript decode, so weight-quant | |
| # drift there is harmless. | |
| max_err_ok = max_err <= abs_tol | |
| argmax_ok = argmax_mismatches == 0 | |
| text_argmax_ok = text_argmax_mismatches == 0 | |
| if argmax_only: | |
| overall_ok = text_argmax_ok and transcript_match | |
| else: | |
| overall_ok = argmax_ok and transcript_match | |
| sidecar = onnx_path.with_name(onnx_path.name + "_data") | |
| int8_size = int(sidecar.stat().st_size) if sidecar.exists() else None | |
| payload = { | |
| "ok": overall_ok, | |
| "abs_tol": abs_tol, | |
| "argmax_only": argmax_only, | |
| "graph_path": str(onnx_path), | |
| "graph_size_bytes": int(onnx_path.stat().st_size), | |
| "int8_size_bytes": int8_size, | |
| "shape_pt": list(pt_np.shape), | |
| "shape_ort": list(logits_ort.shape), | |
| "text_argmax_mismatches": text_argmax_mismatches, | |
| "text_argmax_total": text_argmax_total, | |
| "audio_argmax_mismatches": audio_argmax_mismatches, | |
| "audio_argmax_total": audio_argmax_total, | |
| "max_abs_err": max_err, | |
| "mean_abs_err": mean_err, | |
| "p99_abs_err": p99, | |
| "max_abs_err_ok": max_err_ok, | |
| "argmax_mismatches": argmax_mismatches, | |
| "argmax_total": argmax_total, | |
| "argmax_ok": argmax_ok, | |
| "top1_match": top1_match, | |
| "top5_set_match": top5_set_match, | |
| "logits_stats_pt": tensor_stats(logits_pt), | |
| "logits_stats_ort": tensor_stats(logits_ort), | |
| "projected_lengths": projected_lengths, | |
| "text_lengths": text_lengths, | |
| "text_ctc_preds": text_ctc_preds, | |
| "onnx_transcript": onnx_transcript, | |
| "baseline_transcript": baseline_transcript, | |
| "transcript_match": transcript_match, | |
| } | |
| parity_json.parent.mkdir(parents=True, exist_ok=True) | |
| parity_json.write_text(json.dumps(payload, indent=2)) | |
| print(f"\n wrote parity report -> {parity_json}") | |
| print("\n--- parity summary ---") | |
| print(f" max_abs_err <= {abs_tol}: {'PASS' if max_err_ok else 'FAIL'} ({max_err:.3e})") | |
| print(f" argmax mismatches == 0: {'PASS' if argmax_ok else 'FAIL'} ({argmax_mismatches}/{argmax_total})") | |
| print(f" transcript matches baseline: {'PASS' if transcript_match else 'FAIL'}") | |
| print(f"\n{'PASS' if overall_ok else 'FAIL'}") | |
| return overall_ok | |
| # --------------------------------------------------------------------------- | |
| # Main. | |
| # --------------------------------------------------------------------------- | |
| def main() -> None: | |
| p = argparse.ArgumentParser() | |
| p.add_argument("--audio", default=str(DEFAULT_AUDIO)) | |
| p.add_argument("--baseline", default=str(DEFAULT_BASELINE)) | |
| p.add_argument("--model-dir", default=str(DEFAULT_MODEL_DIR)) | |
| p.add_argument("--out-dir", default=str(DEFAULT_OUT_DIR)) | |
| p.add_argument("--abs-tol", type=float, default=1e-3) | |
| p.add_argument("--skip-export", action="store_true", help="skip the export step (re-run parity only)") | |
| p.add_argument( | |
| "--graph-suffix", | |
| default="", | |
| help="suffix appended to the editor graph stem (e.g. '_int8') so parity runs " | |
| "against editor<suffix>.onnx. Parity output goes to editor_parity<suffix>.json. " | |
| "When set, --skip-export is implied.", | |
| ) | |
| p.add_argument( | |
| "--encoder-suffix", | |
| default=None, | |
| help="suffix for the encoder graph used to build editor inputs. Defaults to " | |
| "--graph-suffix; pass '' to force the FP32 encoder.", | |
| ) | |
| args = p.parse_args() | |
| out_dir = Path(args.out_dir) | |
| suffix = args.graph_suffix | |
| if suffix and not args.skip_export: | |
| print(f" --graph-suffix={suffix!r} set; implying --skip-export") | |
| args.skip_export = True | |
| encoder_suffix = args.encoder_suffix if args.encoder_suffix is not None else suffix | |
| onnx_path = out_dir / f"editor{suffix}.onnx" | |
| parity_json = out_dir / f"editor_parity{suffix}.json" | |
| encoder_onnx = out_dir / f"encoder{encoder_suffix}.onnx" | |
| if not encoder_onnx.exists(): | |
| raise FileNotFoundError( | |
| f"Expected exported encoder at {encoder_onnx}; " | |
| "run src/export_nar_encoder.py first." | |
| ) | |
| model_dir = Path(args.model_dir) | |
| print(f"audio: {args.audio}") | |
| print(f"out_dir: {out_dir}") | |
| waveform = load_audio(Path(args.audio)) | |
| print(f" duration={waveform.shape[0] / 16000:.2f}s") | |
| print("loading model...") | |
| model, fe = load_nar_model(model_dir) | |
| # Build editor inputs from the encoder.onnx output and the model's bound | |
| # methods. This is what the Rust glue will do in production. | |
| flat_embeds, flat_position_ids, projected_lengths, text_lengths, text_ctc_preds = ( | |
| build_editor_inputs(model, fe, waveform, encoder_onnx) | |
| ) | |
| wrapper = NAREditor(llm_model=model.llm.model, lm_head=model.llm.lm_head) | |
| wrapper.eval() | |
| if not args.skip_export: | |
| N = flat_embeds.shape[1] | |
| sample_attn = torch.zeros(1, 1, N, N, dtype=flat_embeds.dtype) | |
| with torch.inference_mode(): | |
| export_onnx( | |
| wrapper=wrapper, | |
| sample_inputs_embeds=flat_embeds, | |
| sample_position_ids=flat_position_ids, | |
| sample_attention_mask=sample_attn, | |
| out_path=onnx_path, | |
| opset=20, | |
| ir_version=10, | |
| ) | |
| ok = run_parity( | |
| model=model, | |
| flat_embeds=flat_embeds, | |
| flat_position_ids=flat_position_ids, | |
| projected_lengths=projected_lengths, | |
| text_lengths=text_lengths, | |
| text_ctc_preds=text_ctc_preds, | |
| onnx_path=onnx_path, | |
| parity_json=parity_json, | |
| baseline_json=Path(args.baseline), | |
| abs_tol=args.abs_tol, | |
| argmax_only=bool(suffix), | |
| ) | |
| if not ok: | |
| raise SystemExit(1) | |
| if __name__ == "__main__": | |
| main() | |