Skip to content

Commit 6630bd3

Browse files
justinchubyCopilot
andauthored
Add NeMo .nemo integration + FastConformer-RNNT streaming ASR model (#359)
## Summary Adds support for **`nvidia/nemotron-speech-streaming-en-0.6b`** (a FastConformer-CacheAware-RNNT streaming ASR model distributed as a NeMo `.nemo` archive) in two layers: 1. **New `.nemo` integration** (`src/mobius/integrations/nemo/`) — analogous to the existing GGUF integration. An archive reader (`NeMoArchive`), a config mapping (`model_config.yaml` → `ArchitectureConfig`), and a `build_from_nemo` pipeline. Resolves local paths and HuggingFace Hub references. 2. **FastConformer-RNNT model** (`models/nemo_rnnt.py` + `tasks/_rnnt.py`) — emitted as three ONNX sub-models (encoder / prediction decoder / joint) via a new `RNNTTask`. ## Architecture - **Encoder:** 8× causal `dw_striding` conv subsampling → 24 Conformer layers (relative-position MHA with `rel_shift`, FF/attn/conv/FF macaron structure, layer-norm conv module). Positional encodings built dynamically in-graph. - **Prediction:** embedding + 2-layer LSTM with PyTorch→ONNX gate reordering; zero start-of-sequence embedding (NeMo `add_sos=True`). - **Joint:** enc/pred projections → ReLU → vocab logits → log-softmax. ## Validation | Level | Coverage | Result | |-------|----------|--------| | L1 | Graph build + I/O shapes (random weights) | 7 pass | | L4 | Parity vs committed NeMo golden | encoder ~5e-7, decoder ~5e-7, joint ~2.7e-5 | | L5 | Greedy decode + incremental-vs-one-shot decoder state | pass | Parity validated against `nemo_toolkit` 2.7.3 reference. ## Contract / limitations - **Offline full-context only** — the encoder consumes the full feature sequence (causal convs, no cache-aware streaming state); chunked streaming is out of scope for this export. - **batch=1 / equal-length** — no length input/output yet. - **fp32 only** — the builder rejects non-fp32 (graph emits fp32 constants); unsupported NeMo encoder variants (non-`dw_striding`/`rel_pos`/`layer_norm`, `xscaling`) are rejected with a clear error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com> Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com> Co-authored-by: Justin Chu <11205048+justinchuby@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent af2a28c commit 6630bd3

28 files changed

Lines changed: 3982 additions & 3 deletions

.github/copilot-instructions.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,8 +98,9 @@ and wrapper modules for nesting. See the `weight-name-alignment` skill.
9898
- Ruff for linting/formatting (line-length=95, Python 3.10+)
9999
- MyPy strict mode (excludes `*_test.py`)
100100
- Use `op.Shape(x, start=i, end=i+1)` for single dimension extraction
101-
- Use ONNX opset 23 `op.Attention` with `q_num_heads`/`kv_num_heads`
102-
attributes (not `num_heads`)
101+
- Use the ONNX opset-24 `op.Attention` (introduced in opset 23) with
102+
`q_num_heads`/`kv_num_heads` attributes (not `num_heads`). The codebase
103+
emits opset 24 graphs (`OPSET_VERSION` in `src/mobius/_constants.py`).
103104

104105
### Protobuf prohibition
105106

docs/design/gguf-support-proposal.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@ the fp16/fp32 activation, `B` is the packed uint8 weight blob.
779779
to fuse (e.g., unusual shape, unsupported config), performance
780780
degrades to "dequantize then fp matmul" — 2-4× slower.
781781

782-
4. **INT4 requires opset 21+**: Our codebase uses opset 23, so this
782+
4. **INT4 requires opset 21+**: Our codebase uses opset 24, so this
783783
isn't a blocker, but older runtimes (pre-2024) can't load int4 QDQ
784784
models.
785785

examples/nemotron_fastconformer_rnnt.py

Lines changed: 551 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Regenerate the FastConformer-RNNT golden reference used by the L4 parity test.
5+
6+
This produces ``testdata/golden/speech/nemotron_fastconformer_rnnt.npz`` by
7+
running the *real* NeMo model through the NeMo toolkit (the ground-truth
8+
reference implementation). It must be run inside an environment that has
9+
``nemo_toolkit`` installed (it is **not** a mobius runtime dependency)::
10+
11+
python -m venv /tmp/nemo_ref_venv
12+
source /tmp/nemo_ref_venv/bin/activate
13+
pip install "nemo_toolkit[asr]==2.7.3"
14+
python scripts/generate_nemo_rnnt_golden.py \
15+
--model nvidia/nemotron-speech-streaming-en-0.6b \
16+
--revision 7a9b763e6c5fb103da690219c049fac917aa50b1 \
17+
--out testdata/golden/speech/nemotron_fastconformer_rnnt.npz
18+
19+
The committed ``.npz`` stores only the arrays needed by the parity test plus a
20+
``meta`` JSON blob (model id, revision, NeMo version, dtype, seed, ids) so the
21+
reference is self-describing and auditable.
22+
"""
23+
24+
from __future__ import annotations
25+
26+
import argparse
27+
import json
28+
29+
import numpy as np
30+
import torch
31+
32+
# Deterministic input feature / token fixtures (also recorded in metadata).
33+
_SEED = 0
34+
_T = 131
35+
_FEAT_DIM = 128
36+
_TOKENS = [3, 5, 7, 9]
37+
_SOS_ID = 1025 # rnnt_num_classes (1024) + 1; zero start-of-sequence embedding
38+
_BLANK_ID = 1024
39+
40+
41+
def main() -> None:
42+
parser = argparse.ArgumentParser(description=__doc__)
43+
parser.add_argument("--model", default="nvidia/nemotron-speech-streaming-en-0.6b")
44+
parser.add_argument(
45+
"--revision",
46+
default="7a9b763e6c5fb103da690219c049fac917aa50b1",
47+
help="HuggingFace Hub commit SHA to pin the reference model.",
48+
)
49+
parser.add_argument(
50+
"--out",
51+
default="testdata/golden/speech/nemotron_fastconformer_rnnt.npz",
52+
)
53+
args = parser.parse_args()
54+
55+
import nemo # type: ignore[import-not-found]
56+
import nemo.collections.asr as nemo_asr # type: ignore[import-not-found]
57+
from huggingface_hub import hf_hub_download
58+
59+
torch.manual_seed(_SEED)
60+
61+
nemo_path = hf_hub_download(
62+
repo_id=args.model,
63+
filename="nemotron-speech-streaming-en-0.6b.nemo",
64+
revision=args.revision,
65+
)
66+
model = nemo_asr.models.ASRModel.restore_from(nemo_path, map_location="cpu")
67+
model.eval()
68+
69+
feats = torch.randn(1, _FEAT_DIM, _T)
70+
length = torch.tensor([_T])
71+
tokens = torch.tensor([_TOKENS])
72+
tlen = torch.tensor([len(_TOKENS)])
73+
74+
with torch.no_grad():
75+
enc_out, _ = model.encoder(audio_signal=feats, length=length)
76+
# decoder.predict(add_sos=True) prepends a zero SOS vector -> (B, H, U+1)
77+
pred_out = model.decoder(targets=tokens, target_length=tlen)[0]
78+
# Bypass fuse_loss_wer by calling the inner joint on (B, T, D) layout.
79+
joint_out = model.joint.joint(enc_out.transpose(1, 2), pred_out.transpose(1, 2))
80+
81+
meta = {
82+
"model_id": args.model,
83+
"revision": args.revision,
84+
"nemo_version": nemo.__version__,
85+
"dtype": "float32",
86+
"seed": _SEED,
87+
"feat_dim": _FEAT_DIM,
88+
"input_frames": _T,
89+
"tokens": _TOKENS,
90+
"sos_id": _SOS_ID,
91+
"blank_id": _BLANK_ID,
92+
}
93+
94+
np.savez_compressed(
95+
args.out,
96+
feats=feats.numpy().astype(np.float32),
97+
enc_out=enc_out.numpy().astype(np.float32),
98+
tokens=tokens.numpy().astype(np.int64),
99+
pred_out=pred_out.numpy().astype(np.float32),
100+
joint_out=joint_out.numpy().astype(np.float32),
101+
meta=np.array(json.dumps(meta)),
102+
)
103+
print(f"saved {args.out}\n{json.dumps(meta, indent=2)}")
104+
105+
106+
if __name__ == "__main__":
107+
main()
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Regenerate the cache-aware streaming golden for the FastConformer-RNNT test.
5+
6+
This produces ``testdata/golden/speech/nemotron_fastconformer_rnnt_streaming.npz``
7+
by driving the *real* NeMo encoder's streaming ``forward`` (with explicit
8+
``cache_last_channel`` / ``cache_last_time`` / ``cache_last_channel_len`` state)
9+
over two consecutive feature chunks. It must run inside an environment that has
10+
``nemo_toolkit`` installed (not a mobius runtime dependency)::
11+
12+
python -m venv /tmp/nemo_ref_venv
13+
source /tmp/nemo_ref_venv/bin/activate
14+
pip install "nemo_toolkit[asr]==2.7.3"
15+
python scripts/generate_nemo_rnnt_streaming_golden.py \
16+
--model nvidia/nemotron-speech-streaming-en-0.6b \
17+
--revision 7a9b763e6c5fb103da690219c049fac917aa50b1 \
18+
--out testdata/golden/speech/nemotron_fastconformer_rnnt_streaming.npz
19+
20+
To keep the committed reference small, only the per-chunk feature inputs and the
21+
encoder outputs / lengths / cache-length scalars are stored (not the full
22+
multi-megabyte cache tensors). The streaming parity test validates cache
23+
correctness implicitly by chaining chunk-0's ONNX output caches into chunk-1 and
24+
matching chunk-1's encoder output against this NeMo reference.
25+
"""
26+
27+
from __future__ import annotations
28+
29+
import argparse
30+
import json
31+
32+
import numpy as np
33+
import torch
34+
35+
_SEED = 0
36+
_FEAT_DIM = 128
37+
_CHUNK = 120 # feature frames per streaming chunk
38+
39+
40+
def main() -> None:
41+
parser = argparse.ArgumentParser(description=__doc__)
42+
parser.add_argument("--model", default="nvidia/nemotron-speech-streaming-en-0.6b")
43+
parser.add_argument(
44+
"--revision",
45+
default="7a9b763e6c5fb103da690219c049fac917aa50b1",
46+
help="HuggingFace Hub commit SHA to pin the reference model.",
47+
)
48+
parser.add_argument(
49+
"--out",
50+
default="testdata/golden/speech/nemotron_fastconformer_rnnt_streaming.npz",
51+
)
52+
args = parser.parse_args()
53+
54+
import nemo # type: ignore[import-not-found]
55+
import nemo.collections.asr as nemo_asr # type: ignore[import-not-found]
56+
from huggingface_hub import hf_hub_download
57+
58+
torch.manual_seed(_SEED)
59+
60+
nemo_path = hf_hub_download(
61+
repo_id=args.model,
62+
filename="nemotron-speech-streaming-en-0.6b.nemo",
63+
revision=args.revision,
64+
)
65+
model = nemo_asr.models.ASRModel.restore_from(nemo_path, map_location="cpu")
66+
model.eval()
67+
enc = model.encoder
68+
enc.setup_streaming_params()
69+
70+
def step(feats, ch, ct, cl):
71+
with torch.no_grad():
72+
return enc(
73+
audio_signal=feats,
74+
length=torch.tensor([_CHUNK]),
75+
cache_last_channel=ch,
76+
cache_last_time=ct,
77+
cache_last_channel_len=cl,
78+
)
79+
80+
ch0, ct0, cl0 = enc.get_initial_cache_state(batch_size=1)
81+
f0 = torch.randn(1, _FEAT_DIM, _CHUNK)
82+
out0, len0, ch1, ct1, cl1 = step(f0, ch0, ct0, cl0)
83+
f1 = torch.randn(1, _FEAT_DIM, _CHUNK)
84+
out1, len1, ch2, ct2, cl2 = step(f1, ch1, ct1, cl1)
85+
del ch2, ct2 # full out-caches of the second chunk are not part of the golden
86+
87+
meta = {
88+
"model_id": args.model,
89+
"revision": args.revision,
90+
"nemo_version": nemo.__version__,
91+
"dtype": "float32",
92+
"seed": _SEED,
93+
"feat_dim": _FEAT_DIM,
94+
"chunk_frames": _CHUNK,
95+
"last_channel_cache_size": int(enc.streaming_cfg.last_channel_cache_size),
96+
"drop_extra_pre_encoded": int(enc.streaming_cfg.drop_extra_pre_encoded),
97+
}
98+
99+
np.savez_compressed(
100+
args.out,
101+
f0=f0.numpy().astype(np.float32),
102+
f1=f1.numpy().astype(np.float32),
103+
out0=out0.numpy().astype(np.float32),
104+
out1=out1.numpy().astype(np.float32),
105+
len0=len0.numpy().astype(np.int64),
106+
len1=len1.numpy().astype(np.int64),
107+
cl1=cl1.numpy().astype(np.int64),
108+
cl2=cl2.numpy().astype(np.int64),
109+
meta=np.array(json.dumps(meta)),
110+
)
111+
print(f"saved {args.out}\n{json.dumps(meta, indent=2)}")
112+
113+
114+
if __name__ == "__main__":
115+
main()

src/mobius/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"build_diffusers_pipeline",
3737
"build_from_gguf",
3838
"build_from_module",
39+
"build_from_nemo",
3940
"components",
4041
"ep_capabilities",
4142
"ep_registry",
@@ -89,4 +90,5 @@
8990
)
9091
from mobius._weight_loading import apply_weights
9192
from mobius.integrations.gguf import build_from_gguf
93+
from mobius.integrations.nemo import build_from_nemo
9294
from mobius.tasks import CausalLMTask, ModelTask

src/mobius/__main__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,21 @@ def _cmd_build(args: argparse.Namespace) -> None:
161161
_save_package(pkg, output_dir, args, optimize, component_filter)
162162
return
163163

164+
# Auto-detect NeMo .nemo archives (local file or HF ref like
165+
# 'owner/repo:model.nemo'). Routes to the NeMo import path; reuses the
166+
# standard build args (--dtype, --ep, --external-data) and save logic.
167+
if args.model and args.model.endswith(".nemo"):
168+
from mobius.integrations.nemo import build_from_nemo
169+
170+
print(f"Detected NeMo archive: {args.model}")
171+
pkg = build_from_nemo(
172+
args.model,
173+
dtype=dtype_override,
174+
execution_provider=execution_provider,
175+
)
176+
_save_package(pkg, output_dir, args, optimize, component_filter)
177+
return
178+
164179
# Build from HuggingFace model ID or local config
165180
if args.config:
166181
import transformers

src/mobius/_configs/_base.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,30 @@ class ArchitectureConfig(BaseModelConfig):
432432
audio_t5_bias_max_distance: int | None = None
433433
audio_token_id: int | None = None
434434

435+
# FastConformer-RNNT (NeMo) config — see models/nemo_rnnt.py.
436+
# Encoder
437+
fastconformer_subsampling_factor: int = 8
438+
fastconformer_subsampling_conv_channels: int = 256
439+
fastconformer_conv_kernel_size: int = 9
440+
fastconformer_pos_emb_max_len: int = 5000
441+
fastconformer_xscaling: bool = False
442+
# Number of input mel features to the encoder.
443+
fastconformer_feat_in: int = 128
444+
# Chunked-limited attention context: [left_context, right_context] in frames.
445+
fastconformer_att_context_size: tuple[int, int] = (70, 13)
446+
# Cache-aware streaming: per-layer last-channel (attention) cache length in
447+
# subsampled frames (NeMo ``last_channel_cache_size`` = att_context left) and
448+
# the number of leading subsampled frames dropped per chunk (NeMo
449+
# ``drop_extra_pre_encoded``).
450+
fastconformer_streaming_cache_size: int = 70
451+
fastconformer_streaming_drop_extra: int = 2
452+
# RNN-T prediction network + joint
453+
rnnt_pred_hidden: int | None = None
454+
rnnt_pred_rnn_layers: int = 1
455+
rnnt_joint_hidden: int | None = None
456+
# Number of acoustic classes excluding the blank symbol (vocab without blank).
457+
rnnt_num_classes: int | None = None
458+
435459
# LoRA config (for multimodal models like Phi4-MM)
436460
speech_lora: dict | None = None
437461

src/mobius/_registry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
DeepSeekV3CausalLMModel,
3838
DiffLlamaCausalLMModel,
3939
DogeCausalLMModel,
40+
EncDecRNNTModel,
4041
Ernie45MoECausalLMModel,
4142
ErnieCausalLMModel,
4243
ExaOne4CausalLMModel,
@@ -669,6 +670,7 @@ def _detect_fallback_registration(hf_config) -> ModelRegistration | None:
669670
"wav2vec2-conformer": ModelRegistration(Wav2Vec2Model, task="audio-feature-extraction"),
670671
"wavlm": ModelRegistration(Wav2Vec2Model, task="audio-feature-extraction"),
671672
"mms": ModelRegistration(Wav2Vec2ForCTCModel, task="ctc-asr", config_class=MMSConfig),
673+
"fastconformer_rnnt": ModelRegistration(EncDecRNNTModel, task="fastconformer-rnnt"),
672674
}
673675

674676

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""NeMo ``.nemo`` model import support for mobius.
5+
6+
This package loads NVIDIA NeMo ``.nemo`` archives and converts them to ONNX
7+
models using the standard graph construction pipeline.
8+
9+
Usage::
10+
11+
from mobius.integrations.nemo import build_from_nemo
12+
13+
pkg = build_from_nemo("nvidia/nemotron-speech-streaming-en-0.6b")
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from mobius.integrations.nemo._builder import build_from_nemo
19+
from mobius.integrations.nemo._genai_config import write_genai_bundle
20+
21+
__all__ = ["build_from_nemo", "write_genai_bundle"]

0 commit comments

Comments
 (0)