#!/usr/bin/env python3 """Export mistralai/Voxtral-Mini-3B-2507 to the onnx-asr "speech-llm" three-graph contract. Graphs ------ encoder.onnx input_features (1,128,3000*N) -> audio_embeds (1,375*N,3072) embed_tokens.onnx input_ids (1,S) -> inputs_embeds (1,S,3072) decoder.onnx inputs_embeds (1,S,H) + attn_bias (1,1,S,P+S) + position_ids (1,S) + past_key_values.{i}.{key,value} (1,KV,P,D) -> logits (1,S,V) + present.{i}.{key,value} (1,KV,P+S,D) The Voxtral audio encoder is a Whisper encoder: full attention over a fixed 3000-frame (30 s) mel window, so the graph needs neither packing indices nor a block-diagonal bias and the runtime feeds the features unchanged. """ from __future__ import annotations import argparse import base64 import json import shutil from pathlib import Path import torch import torch.nn.functional as F from torch import nn REPO = "mistralai/Voxtral-Mini-3B-2507" # The eight languages Voxtral-Mini-3B-2507 is documented to transcribe. LANGUAGES = { "en": "English", "fr": "French", "de": "German", "es": "Spanish", "it": "Italian", "pt": "Portuguese", "nl": "Dutch", "hi": "Hindi", } MEL_FRAMES = 3000 class EncoderExport(nn.Module): """Whisper-style audio encoder + Voxtral multi-modal projector. The mel features arrive as one flat window of `3000 * N` frames. They are split into the 30 s chunks the encoder expects, encoded, and the resulting 1500 frames per chunk are grouped four at a time (the projector input width is four times the encoder width) before the projection into the language model embedding space. """ def __init__(self, encoder: nn.Module, projector: nn.Module, num_mel_bins: int, intermediate_size: int): super().__init__() self.encoder = encoder self.projector = projector self.num_mel_bins = num_mel_bins self.intermediate_size = intermediate_size def forward(self, input_features: torch.Tensor) -> torch.Tensor: enc = self.encoder chunks = ( input_features.reshape(1, self.num_mel_bins, -1, MEL_FRAMES) .permute(0, 2, 1, 3) .reshape(-1, self.num_mel_bins, MEL_FRAMES) ) hidden = F.gelu(enc.conv1(chunks)) hidden = F.gelu(enc.conv2(hidden)) hidden = hidden.permute(0, 2, 1) hidden = hidden + enc.embed_positions.weight for layer in enc.layers: hidden = layer(hidden, attention_mask=None) hidden = enc.layer_norm(hidden) hidden = hidden.reshape(-1, self.intermediate_size) return self.projector(hidden).unsqueeze(0) class EmbedExport(nn.Module): def __init__(self, embed_tokens: nn.Module): super().__init__() self.embed_tokens = embed_tokens def forward(self, input_ids: torch.Tensor) -> torch.Tensor: return self.embed_tokens(input_ids) class DecoderExport(nn.Module): """Llama causal LM over inputs_embeds with a flat past/present KV cache.""" def __init__(self, language_model: nn.Module, lm_head: nn.Module, num_layers: int): super().__init__() self.language_model = language_model self.lm_head = lm_head self.num_layers = num_layers def forward(self, inputs_embeds, attn_bias, position_ids, *past): from transformers.cache_utils import DynamicCache cache = DynamicCache(config=self.language_model.config) for i in range(self.num_layers): cache.update(past[2 * i], past[2 * i + 1], i) out = self.language_model( inputs_embeds=inputs_embeds, attention_mask=attn_bias, position_ids=position_ids, past_key_values=cache, use_cache=True, ) logits = self.lm_head(out.last_hidden_state) present: list[torch.Tensor] = [] for i in range(self.num_layers): present.append(cache.layers[i].keys) present.append(cache.layers[i].values) return (logits, *present) MAX_PROTOBUF = 1_900_000_000 def consolidate(src: Path, dst: Path) -> None: """Move a graph to `dst`, spilling weights to `.onnx_data` when it is too big for protobuf.""" import onnx raw_size = sum(path.stat().st_size for path in src.parent.rglob("*") if path.is_file()) model = onnx.load(str(src), load_external_data=True) dst.parent.mkdir(parents=True, exist_ok=True) data_path = dst.with_suffix(".onnx_data") if data_path.exists(): data_path.unlink() if raw_size > MAX_PROTOBUF: onnx.save( model, str(dst), save_as_external_data=True, all_tensors_to_one_file=True, location=data_path.name, size_threshold=1024, convert_attribute=False, ) else: onnx.save(model, str(dst)) def export_graph(module, inputs, name: str, out: Path, tmp: Path, **kwargs) -> None: stage = tmp / name stage.mkdir(parents=True, exist_ok=True) torch.onnx.export(module, inputs, str(stage / f"{name}.onnx"), do_constant_folding=True, dynamo=False, **kwargs) consolidate(stage / f"{name}.onnx", out / f"{name}.onnx") shutil.rmtree(stage, ignore_errors=True) def quantize(name: str, out: Path, tmp: Path) -> None: from onnxruntime.quantization import QuantType, quantize_dynamic stage = tmp / f"{name}_int8" stage.mkdir(parents=True, exist_ok=True) # Graphs whose fp32 weights already live in a side file stay over the 2 GB # protobuf limit after quantization of the non-MatMul tensors, so onnxruntime # has to write external data too. quantize_dynamic( out / f"{name}.onnx", stage / f"{name}_int8.onnx", weight_type=QuantType.QInt8, use_external_data_format=(out / f"{name}.onnx_data").exists(), extra_options={"MatMulConstBOnly": True}, ) consolidate(stage / f"{name}_int8.onnx", out / f"{name}_int8.onnx") shutil.rmtree(stage, ignore_errors=True) def bytes_to_unicode() -> dict[int, str]: """GPT-2 byte -> printable unicode table (same one onnx-asr uses to decode).""" bs = list(range(ord("!"), ord("~") + 1)) + list(range(ord("\xa1"), ord("\xac") + 1)) + list( range(ord("\xae"), ord("\xff") + 1) ) cs = bs[:] n = 0 for b in range(2**8): if b not in bs: bs.append(b) cs.append(2**8 + n) n += 1 return dict(zip(bs, [chr(c) for c in cs])) def build_vocab(repo: str, vocab_size: int) -> dict[str, int]: """Build a {token: id} map straight from tekken.json. `MistralCommonBackend.get_vocab()` decodes every piece as UTF-8, which is lossy for the byte-level pieces, so the raw token bytes are read from tekken.json and written in the GPT-2 byte-to-unicode encoding that the onnx-asr byte-level decoder reverses exactly. """ from huggingface_hub import hf_hub_download with Path(hf_hub_download(repo, "tekken.json")).open("rt", encoding="utf-8") as f: tekken = json.load(f) num_special = tekken["config"]["default_num_special_tokens"] byte_encoder = bytes_to_unicode() vocab: dict[str, int] = {} for special in tekken["special_tokens"][:num_special]: vocab[special["token_str"]] = special["rank"] for entry in tekken["vocab"][: vocab_size - num_special]: token = "".join(byte_encoder[b] for b in base64.b64decode(entry["token_bytes"])) vocab[token] = entry["rank"] + num_special assert len(vocab) == vocab_size, (len(vocab), vocab_size) return vocab def build_prompts(processor, repo: str, audio_token_id: int, clip: Path) -> tuple[list[int], list[int], dict]: """Read the transcription-request token ids straight out of mistral-common. Voxtral encodes a transcription request as ` [INST]