Views
No views yet
ibm-granite/granite-speech-4.1-2b-nar produced by smcleod. Three precision tiers (fp32/, int8/, fp16w/) ship in this repo - see Files below for sizes and trade-offs. The graphs target opset 20 / IR 10 / ai.onnx-only, so they load under the ort 2.0-rc.x Rust crate and onnxruntime 1.17 - 1.25.encoder.onnx runs the conformer + CTC heads + BPE-collapsing projector and emits bpe_logits_dense plus pooled audio embeddings. embed_tokens.onnx looks up text-token embeddings for the CTC draft (with insertion slots). editor.onnx runs the bidirectional NLE editor over the concatenation of audio embeddings and text-with-slots embeddings and emits per-position vocab logits. Decoding is a single argmax pass; no KV cache, no autoregression. See How to use for the slot-insertion algorithm.fp32/, int8/, fp16w/). Inside, files use the clean stem (no precision suffix) - the directory name carries the tier. Download a single subdirectory if you only need one precision; the tokeniser, processor, scripts, and metadata at the bundle root are shared across all tiers.fp32/ - FP32 (reference, full precision) - 9.8 GB totalfp32/encoder.onnx + fp32/encoder.onnx_datafp32/editor.onnx + fp32/editor.onnx_datafp32/embed_tokens.onnx + fp32/embed_tokens.onnx_dataint8/ - INT8 (smallest) - 2.5 GB totalMatMulInteger + ConvInteger, all ai.onnx). Mild quality drop on case/punctuation but transcripts remain semantically accurate. Choose when disk or memory is tight.int8/encoder.onnx + int8/encoder.onnx_dataint8/editor.onnx + int8/editor.onnx_dataint8/embed_tokens.onnx + int8/embed_tokens.onnx_datafp16w/ - FP16w (recommended for highest quality at smaller-than-FP32 size) - 4.9 GB totalCast(FP16->FP32) inserted before each consumer; arithmetic and IO stay FP32. Quality is essentially identical to FP32 (mean norm WER 0.04% vs 0.72% for INT8) at 50% of FP32 storage. Choose when you have the disk and want FP32-grade transcripts.fp16w/encoder.onnx + fp16w/encoder.onnx_datafp16w/editor.onnx + fp16w/editor.onnx_datafp16w/embed_tokens.onnx + fp16w/embed_tokens.onnx_datatokenizer.json, tokenizer_config.json, special_tokens_map.json, preprocessor_config.jsonexport_nar_encoder.py, export_nar_editor.py, export_embed_tokens.py, quantise.py, convert_fp.pygranite_export_metadata.json (graph IO, parity numbers, toolchain)LICENSE (Apache 2.0)test_fixtures/ - golden inputs/outputs for integration testing. See test_fixtures/README.md.preprocessor_config.json). NAR's encoder additionally wants an
attention_mask [B, T] int64 (1 = valid, 0 = padding). The included
test_fixtures/expected_attention_mask.npy and
test_fixtures/expected_input_features.npy are reference outputs from the
upstream AutoFeatureExtractor for verifying your frontend.11. encoder.onnx (input_features, attention_mask)
2 -> bpe_logits_dense, bpe_mask, audio_embeds, audio_lengths, char_logits
32. CTC decode (host) (bpe_logits_dense + bpe_mask)
4 -> draft text token IDs (greedy + collapse blanks/dupes)
53. embed_tokens.onnx (text token IDs with insertion-slot tokens) -> text_embeds
64. splice (host) concat(audio_embeds[:audio_len], text_embeds) -> inputs_embeds [1, N, 2048]
75. editor.onnx (inputs_embeds, position_ids, 4-D zero attention_mask)
8 -> logits [1, N, 100352]
96. argmax + slice (host) over the text segment of `logits` -> final token IDs -> tokenizer.decodebpe_logits_dense (shape [B, T_bpe, V_bpe]) is the head used downstream.
char_logits is exposed for diagnostics but not part of the inference path.add_insertion_slots interleaves the LLM's eos_token_id between every CTC
draft token (and at the boundaries), giving the editor a fixed insertion slot
to rewrite or expand each span. Reproduce it directly without the upstream
class:1def add_insertion_slots(t, eos_id):
2 # t: list/tensor of CTC draft token ids (after greedy argmax + blank-collapse).
3 # eos_id: tokenizer.eos_token_id (read from tokenizer_config.json).
4 n = len(t)
5 out_len = max(2 * n + 1, 8)
6 out = [eos_id] * out_len
7 for i in range(n):
8 out[2 * i + 1] = t[i]
9 return out1slots = add_insertion_slots(t, eos_id) # length 2n+1 (>=8)
2text_emb = embed_tokens.onnx(slots) # [1, len(slots), 2048]
3audio = audio_embeds[:audio_len] # [audio_len, 2048]
4flat = concat([audio, text_emb], dim=0) # [audio_len + len(slots), 2048]
5position = arange(audio_len + len(slots))
6attn = zeros([1, 1, N, N], float32) # bidirectional, no masking
7
8logits = editor.onnx(flat.unsqueeze(0), position.unsqueeze(0), attn)logits is logits[:, audio_len:, :]; argmax over that
segment yields the final token IDs, decode via the LLM tokeniser. The 4-D
attention_mask is identically zero (additive-mask convention: 0 = unmasked,
-inf = masked); the graph expects an explicit input.config.scale_projected_embeddings is set on the upstream config,
divide audio_embeds by config.embedding_multiplier before splicing.
The shipped encoder graph does not bake in that division; do it host-side.tokenizer.json + tokenizer_config.json from
ibm-granite/granite-speech-4.1-2b-nar. NAR has no chat
template (no chat_template.jinja); the tokeniser is used directly on the
draft text and on the editor output.ai.onnx-only at opset 20; no com.microsoft.* ops. Load under
ort 2.0-rc.x or onnxruntime 1.17 - 1.25.QInt8 over MatMul + Conv ops. The quantiser emits MatMulInteger + ConvInteger and leaves activations in FP32. The unquantised ~22% of MatMul nodes in the LLM body graphs are activation x activation (attention QK^T and attention_weights x V); dynamic weight-only INT8 cannot quantise those, so this is the expected ceiling, not a coverage gap.Cast(FP16->FP32) inserted before each consumer, so arithmetic and IO stay FP32. Quality matches FP32 within numeric tolerance at ~50% of FP32 storage.embed_tokens is shipped as its own graph in all three tiers. INT8 uses per-row symmetric quantisation rather than the dynamic MatMul/Conv quantiser (Gather is not in that op set), giving the embedding table its own ~4x storage win at INT8.com.microsoft.* ops are used. Re-validate the op-domain set with assert_pure_ai_onnx in quantise.py / convert_fp.py after any change.10226_10111_000000.wav, 8.43 seconds, 844 mel frames). FP32 graphs
match the reference within numeric tolerance; INT8 graphs are validated in
argmax-only mode (logit values shift but token argmax is preserved, so the
decoded transcript is unchanged).| graph | precision | max-abs-err | argmax mismatches | transcript match |
|---|---|---|---|---|
| encoder (bpe_logits_dense) | FP32 | 0.00204 | 0/211 | n/a |
| encoder (bpe_logits_dense) | INT8 | 1.84 | 0/211 | n/a |
| editor | FP32 | 0.00147 | 0/257 | Y |
| editor | INT8 | 94.5 | 15/257 | Y |
bpe_logits_dense (used downstream) holds argmax-stable through quantisation; char_logits (unused downstream) drifts noticeably and is not part of the inference path. The editor INT8 graph reproduces the reference transcript despite logit max-abs delta, because argmax decoding is invariant to the residual quant error.WER is the strict word-error rate against the PyTorch reference (case + punctuation sensitive). norm WER lower-cases both transcripts and strips punctuation before comparing - the dominant driver of strict WER on this model at INT8 is capitalisation and trailing punctuation drift, not actual word substitution. Pick whichever metric matches your downstream task. FP16w is essentially FP32 quality at 50% of FP32 storage; INT8 is the smallest tier with a mild quality drop.| Clip | Duration | FP32 byte-exact | INT8 byte-exact | INT8 WER | INT8 norm WER | FP16w byte-exact | FP16w WER | FP16w norm WER |
|---|---|---|---|---|---|---|---|---|
| is-it-more-wood | 46.9 s | Y | N | 4.3% | 2.05% | Y | 0.0% | 0.00% |
| two-speakers-1 | 93.8 s | N | N | 3.5% | 1.72% | N | 0.4% | 0.34% |
| two-speakers-2 | 38.8 s | Y | N | 5.1% | 0.96% | Y | 0.0% | 0.00% |
granite_export_metadata.json multi_clip_parity block.after his nap timothy lazily stretched first one gray velvet foot then another strolled indolently to his plate turning over the food carefully selecting choice bits nosing out that which he scorned upon the clean hearth
ai.onnx only)<stem>.onnx_data sidecar per graphort 2.0-rc.x Rust crate.
Compatible with onnxruntime Python 1.17 through 1.25. No com.microsoft
ops are used. Graphs were emitted via the TorchScript path
(torch.onnx.export(..., dynamo=False)); the dynamo exporter was deliberately
avoided because it injects aten::* ops ort does not understand. See the
Runtime / EP notes above for CoreML / CUDA / CPU
specifics including which precision tier to pick per backend.quantise.py regenerate every artefact in this
bundle. The export pipeline writes flat-layout files into exports/<variant>/;
the per-tier subdirectory layout you see in this repo is produced by
scripts/stage_bundles.py (in the source tree at
https://github.com/sammcj/granite-speech-4.1-onnx). From a checkout:1python export_nar_encoder.py \
2 --model-dir <path-to-ibm-granite/granite-speech-4.1-2b-nar> \
3 --out-dir exports/granite-speech-4.1-2b-nar
4python export_nar_editor.py \
5 --model-dir <path-to-ibm-granite/granite-speech-4.1-2b-nar> \
6 --out-dir exports/granite-speech-4.1-2b-nar
7python export_embed_tokens.py --variant nar
8
9# INT8 (NAR variant: no exclusion - both AR-style exclusions regressed NAR norm WER).
10# embed_tokens uses a hand-rolled per-row INT8 path baked into export_embed_tokens.py.
11python quantise.py --input exports/granite-speech-4.1-2b-nar/encoder.onnx --output exports/granite-speech-4.1-2b-nar/encoder_int8.onnx
12python quantise.py --input exports/granite-speech-4.1-2b-nar/editor.onnx --output exports/granite-speech-4.1-2b-nar/editor_int8.onnx
13
14# FP16w (weights-FP16, FP32 compute - no exclusions needed):
15python convert_fp.py --precision fp16w --input exports/granite-speech-4.1-2b-nar/encoder.onnx --output exports/granite-speech-4.1-2b-nar/encoder_fp16w.onnx
16python convert_fp.py --precision fp16w --input exports/granite-speech-4.1-2b-nar/editor.onnx --output exports/granite-speech-4.1-2b-nar/editor_fp16w.onnxLICENSE for the full text.