Skip to content

Add NeMo .nemo integration + FastConformer-RNNT streaming ASR model - #359

Merged
justinchuby merged 21 commits into
mainfrom
justinchu/nemo-fastconformer-rnnt
Jun 19, 2026
Merged

Add NeMo .nemo integration + FastConformer-RNNT streaming ASR model#359
justinchuby merged 21 commits into
mainfrom
justinchu/nemo-fastconformer-rnnt

Conversation

@justinchuby

Copy link
Copy Markdown
Member

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.yamlArchitectureConfig), 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

justinchuby and others added 2 commits June 17, 2026 22:31
Introduce mobius.integrations.nemo, mirroring the GGUF integration, to
import NVIDIA NeMo .nemo archives:

- NeMoArchive reads the tar bundle without depending on nemo_toolkit:
  parses model_config.yaml, loads the model_weights.ckpt state_dict
  (weights_only), extracts SentencePiece tokenizer artifacts, and resolves
  nemo:<file> URIs. Accepts a local path or HF Hub reference.
- nemo_to_config maps the FastConformer-RNNT (EncDecRNNTBPEModel) config to
  an ArchitectureConfig, adding a focused group of FastConformer/RNN-T
  fields to ArchitectureConfig.
- build_from_nemo wires reader → config → registry → build_from_module →
  weight loading (lazy imports; the registry model class lands in a
  follow-up commit).

Verified against the real nemotron-speech-streaming-en-0.6b.nemo: 653
parameters load and config dimensions map correctly. 17 offline unit tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Implement the FastConformer-RNNT transducer architecture used by
nvidia/nemotron-speech-streaming-en-0.6b, building on the new .nemo
integration. The model is emitted as three ONNX sub-models (encoder /
prediction decoder / joint) wired through a new RNNTTask.

What's added:
- models/nemo_rnnt.py: full EncDecRNNTModel — causal dw_striding conv
  subsampling (8x), relative-position multi-head attention with rel_shift,
  Conformer layers (FF/attn/conv/FF + norms), 2-layer LSTM prediction net
  with PyTorch->ONNX gate reordering, and a log-softmaxed joint network.
- tasks/_rnnt.py: 3-model RNNTTask (encoder/decoder/joint) with documented
  offline full-context, batch=1 runtime contract.
- integrations/nemo: config mapping now validates encoder architecture
  assumptions (dw_striding/rel_pos/layer_norm/no-xscaling) and the builder
  rejects non-fp32 export, failing loudly on unsupported variants.
- Registry, models/tasks exports, and ArchitectureConfig fields wired up.

Tests:
- L1 unit tests (graph build + I/O shapes) via random-weight fill.
- L4 parity vs a committed NeMo golden: encoder ~5e-7, decoder ~5e-7,
  joint ~2.7e-5.
- L5 greedy decode smoke test plus an incremental-vs-one-shot decoder
  state-consistency check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing af2a28caf93b97

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 60 60 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 68 68 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 107 107 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 54 54 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 62 62 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 58 58 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 98 98 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 60 60 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 56 56 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 62 62 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 58 58 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 275 275 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 129 129 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 413 413 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 166 166 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

@codecov

codecov Bot commented Jun 17, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing af2a28caf93b97

Model Sub-model Changes Status
bert (feature-extraction) model 0
falcon model 0
gemma2 model 0
gemma4 (gemma4) decoder 0
gemma4 (gemma4) embedding 0
gemma4 (gemma4) vision_encoder 0
gemma4_text model 0
gpt2 model 0
llama model 0
llama (static-cache) model 0
mamba (ssm-text-generation) model 0
phi3 model 0
phi3 (static-cache) model 0
qwen model 0
qwen (static-cache) model 0
qwen2 model 0
qwen2 (static-cache) model 0
qwen2_moe model 0
qwen2_moe (static-cache) model 0
qwen3 model 0
qwen3 (static-cache) model 0
qwen3_5_moe (hybrid-text-generation) model 0
qwen3_5_text (hybrid-text-generation) model 0
qwen3_5_vl (hybrid-qwen-vl) decoder 0
qwen3_5_vl (hybrid-qwen-vl) embedding 0
qwen3_5_vl (hybrid-qwen-vl) vision_encoder 0
qwen3_moe model 0
qwen3_moe (static-cache) model 0
qwen3_next (hybrid-text-generation) model 0
t5 (seq2seq) decoder 0
t5 (seq2seq) encoder 0
whisper (speech-to-text) decoder 0
whisper (speech-to-text) encoder 0

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds first-class support for importing NVIDIA NeMo .nemo archives into mobius and introduces a FastConformer-RNNT (RNN-T) speech recognition model/task that exports as three ONNX sub-models (encoder / prediction decoder / joint).

Changes:

  • Added a NeMo .nemo integration layer (archive reader, config→ArchitectureConfig mapping, and build_from_nemo build pipeline).
  • Added FastConformer-RNNT model implementation and a new RNNTTask that emits encoder/decoder/joint ONNX graphs.
  • Added unit + integration tests for graph validity and parity/greedy decoding against a NeMo golden reference.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/nemo_rnnt_integration_test.py Integration parity + greedy decode smoke tests using real .nemo + golden NPZ.
tests/build_graph_test.py Registers fastconformer_rnnt as a specialized model type for build coverage.
src/mobius/tasks/_rnnt.py New RNNTTask exporting encoder/decoder/joint graphs and defining I/O contracts.
src/mobius/tasks/init.py Exposes RNNTTask and registers the "fastconformer-rnnt" task name.
src/mobius/models/nemo_rnnt.py Implements FastConformer encoder + RNN-T prediction/joint modules + NeMo weight mapping.
src/mobius/models/nemo_rnnt_test.py Unit tests that build/run the three graphs with random weights to validate shapes/structure.
src/mobius/models/init.py Exports EncDecRNNTModel.
src/mobius/integrations/nemo/_reader.py Implements NeMoArchive for config/weights/tokenizer extraction from .nemo.
src/mobius/integrations/nemo/_reader_test.py Offline tests for NeMoArchive using a synthetic .nemo tar.
src/mobius/integrations/nemo/_config_mapping.py Maps NeMo model_config.yaml into ArchitectureConfig with validation guards.
src/mobius/integrations/nemo/_config_mapping_test.py Tests for config mapping + unsupported-config rejection.
src/mobius/integrations/nemo/_builder.py Adds build_from_nemo pipeline: read → map → registry resolve → build → apply weights.
src/mobius/integrations/nemo/init.py Public entrypoint export for build_from_nemo.
src/mobius/_registry.py Registers fastconformer_rnntEncDecRNNTModel with task "fastconformer-rnnt".
src/mobius/_configs/_base.py Adds FastConformer-RNNT-related fields to ArchitectureConfig.

Comment thread src/mobius/integrations/nemo/_config_mapping.py Outdated
Comment thread src/mobius/integrations/nemo/_reader.py
Comment thread src/mobius/models/nemo_rnnt_test.py
Address review gaps on the FastConformer-RNNT PR:

- Expose the `.nemo` build path like GGUF: export `build_from_nemo` from
  the top-level `mobius` package and add a `mobius build-nemo` CLI
  subcommand (local path or HF repo, dtype/EP/output options).
- Add `revision=` support threaded through `build_from_nemo` ->
  `NeMoArchive` -> `_resolve_nemo_path` so HF downloads can be pinned.
- Pin the integration test to the model's HF commit SHA and store
  self-describing metadata (model id, revision, NeMo version, dtype,
  seed, token/SOS/blank ids) in the committed golden npz.
- Commit a reproducible golden generator (scripts/generate_nemo_rnnt_golden.py).
- Import shared components from the public `mobius.components` API and
  drop a dead TYPE_CHECKING block in models/nemo_rnnt.py.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Member Author

Follow-up: review-gap fixes (commit e7c35d4)

  • Public API + CLI parity with GGUF: build_from_nemo is now exported from top-level mobius, and a mobius build-nemo <path-or-hf-repo> subcommand was added (dtype/EP/output options).
  • Reproducible & pinned golden: integration test pins the HF commit SHA; the committed .npz now carries a meta blob (model id, revision, NeMo version, dtype, seed, token/SOS/blank ids); added scripts/generate_nemo_rnnt_golden.py to regenerate it.
  • Cleanups: import shared components from the public mobius.components API; removed a dead TYPE_CHECKING block.

Quality-checklist scope (explicit N/A waivers)

These standard items don't apply to this model and are intentionally skipped:

  • L2 YAML case / L3 synthetic parity: the source artifact is a NeMo .nemo archive, not a Transformers HF config/model, so the HF-config-driven YAML/synthetic-parity harness can't load it. Coverage is provided by specialized L1 unit tests + custom L4 (NeMo golden parity) and L5 (RNN-T greedy decode + incremental-vs-one-shot decoder state) tests.
  • ORT GenAI / Foundry Local: RNN-T isn't part of the ORT GenAI decoder pipeline (it needs a custom transducer decode loop), so genai_config / Foundry smoke tests are N/A. The greedy decode contract is documented in tasks/_rnnt.py.
  • Multi-dtype (fp16/bf16): the graph emits fp32 constants (positional encodings, masks, scales); the builder explicitly rejects non-fp32 export rather than silently mismatching. fp16/bf16 support is a documented follow-up.

justinchuby and others added 2 commits June 17, 2026 23:45
…-RNNT

Two follow-ups requested after the initial PR:

fp16/bf16 export:
- Cast dtype-sensitive scalar constants (attention scale, mask fill, macaron
  factor) to the compute dtype via CastLike, and cast the float32 Sin/Cos
  positional encoding to the compute dtype before projection.
- Builder no longer rejects non-fp32; f16/bf16 now build end-to-end. Validated
  the f16 encoder against the f32 golden on CUDA (maxdiff ~1e-3).

Ragged-batch support:
- Encoder gains a `length` (B,) input and an `encoder_length` (B,) output.
- Subsampled lengths follow NeMo's calc_length (n -> floor(n/2)+1 x3); a
  per-frame validity mask gates attention (queries never attend to padded
  keys) and zeroes padded output frames.
- length=full reduces exactly to the previous full-context path, so NeMo
  golden parity is preserved.

Tests: half-precision build/type-check (f16/bf16), padding-mask consistency
(a sample's valid region is identical alone vs padded in a batch), and a
CUDA-gated f16 encoder parity test. Updated golden/greedy tests to pass length.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Add a NeMo cache-aware streaming export for the FastConformer-RNNT
encoder, alongside the existing offline full-context encoder. The new
"encoder_streaming" model in the RNNT package consumes one feature chunk
plus NeMo's per-layer running state (cache_last_channel, cache_last_time,
cache_last_channel_len) and returns the chunk encoding together with the
updated caches, enabling chunk-by-chunk inference.

What:
- Streaming paths threaded through the encoder components via optional
  cache kwargs on forward() (so all calls still go through nn.Module
  __call__ for correct initializer naming):
  - _Conv1d: causal conv using a left-context cache instead of zero pad;
    returns the updated cache (last k-1 frames of concat(cache, x)).
  - RelPositionMultiHeadAttention: non-square q/kv attention (queries from
    the current chunk, keys/values from concat(cache, chunk)) with a
    relative-position embedding spanning the full window.
  - ConformerConvolution / ConformerLayer: cache_time / cache_channel
    threading; the cached attention state is the normed attention input.
  - FastConformerEncoder._forward_streaming: drop_extra_pre_encoded,
    cache-aware mask with chunk-aligned offset, per-layer cache stacking,
    and cache_last_channel_len growth capped at cache_size.
- RNNTTask emits "encoder_streaming" (5 cache I/O tensors). The streaming
  graph is built from a deep copy of the module so its parameters realize
  as independent initializers (same names) in a separate graph.
- Config: derive fastconformer_streaming_cache_size (= att_context left)
  and fastconformer_streaming_drop_extra from the NeMo encoder config.

Validation:
- New streaming reference generator
  (scripts/generate_nemo_rnnt_streaming_golden.py) + compact golden.
- L1 unit test (I/O shapes + cache growth) and L4 chunk-chained parity
  test vs NeMo: enc_out matches to ~1e-6 over two chunks; cache lengths
  exact. Full non-integration suite (2908) and lint pass.

Streaming targets a single stream of equal-length chunks (batch=1 /
homogeneous, no intra-chunk padding), matching NeMo's cache-aware design;
ragged batches should use the offline "encoder".

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Member Author

Cache-aware streaming encoder added (commit c07dda4)

The third follow-up is done. The RNNT package now also exports an
encoder_streaming model for chunk-by-chunk inference, alongside the offline
full-context encoder.

Contract: inputs audio_signal (B,feat,T_chunk), length, and NeMo's
per-layer caches cache_last_channel (L,B,70,d), cache_last_time (L,B,d,8),
cache_last_channel_len (B,); outputs the chunk encoding + the three updated
caches. Targets a single stream of equal-length chunks (batch=1 / homogeneous),
matching NeMo's cache-aware streaming design; ragged batches use the offline
encoder.

Implementation: streaming threaded through the components via optional cache
kwargs on forward() (non-square rel-pos attention with K/V = concat(cache,
chunk); causal conv left-context from cache_last_time; drop_extra_pre_encoded
handling; chunk-aligned offset mask; per-layer cache stacking). The streaming
graph is built from a deep copy of the module so its parameters realize as
independent initializers with identical names.

Validation: new reproducible streaming golden generator + compact golden.
L1 unit test (I/O + cache growth) and an L4 chunk-chained parity test vs NeMo:
enc_out matches to ~1e-6 over two chained chunks and cache lengths are exact.
Full non-integration suite (2908) + the 4 nemo integration tests + lint all pass.

All three documented follow-ups (fp16/bf16, ragged-batch length masking,
streaming) are now complete.

justinchuby and others added 2 commits June 18, 2026 00:39
Add examples/nemotron_fastconformer_rnnt.py demonstrating both inference
modes for the NeMo FastConformer-CacheAware-RNNT model:

* Offline (from file): full-context encoder + RNN-T greedy decode for a
  complete transcript.
* Real-time (streaming): chunked, cache-aware encoder_streaming model
  carrying attention/conv caches across chunks, printing partial text as
  each chunk is decoded; the same loop drives microphone input.

The example is self-contained: the log-mel frontend (matching NeMo's
AudioToMelSpectrogramPreprocessor, normalize="NA") and the SentencePiece
BPE tokenizer are reconstructed from the .nemo archive, so nemo_toolkit
is not a runtime dependency.

Validated end-to-end on a LibriSpeech sample: offline transcript is
exact; streaming converges to the same text. The mel frontend matches
NeMo's preprocessor on interior frames (mean abs diff ~2e-4). Streaming
chunk length defaults to the model's native chunk (att_context [70,13]
-> 1.12 s) and is snapped to an 8-frame subsampling multiple.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Address rubber-duck and code-review feedback on the FastConformer-RNNT
example:

* RnntGreedyDecoder.reset now preserves the prediction-network LSTM state
  produced by the SOS step (previously discarded), so the first emitted
  token is conditioned on the post-SOS state, matching NeMo add_sos=True.
* Hold the tokenizer's TemporaryDirectory on the pipeline instance instead
  of leaking a mkdtemp directory on every run.
* Microphone streaming now starts with empty left-context (consistent with
  the first file-streaming chunk) and carries the overlap from the running
  audio buffer, robust to short blocks.
* Reject non-positive --chunk-seconds.

Re-validated: offline transcript still exact and streaming still converges
on a LibriSpeech sample; 31 nemo unit tests pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
* Add fastconformer_rnnt to _COVERAGE_SKIP in model_coverage_test.py: it is
  a NeMo .nemo RNN-T ASR model loaded from a .nemo archive (no standard HF
  config / test_model_id), and is covered by tests/nemo_rnnt_integration_test.py.
  Fixes the four failing TestL1L3GraphBuildCoverage / TestL2ConfigValidation
  cases.
* Escape the regex metacharacter in the pytest.raises match pattern in
  _reader_test.py (RUF043), which CI lint (check mode) treats as a failure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 23 changed files in this pull request and generated 2 comments.

Comment thread tests/build_graph_test.py
Comment thread src/mobius/__main__.py Outdated
Adapt the FastConformer-RNNT ONNX export so the ONNX Runtime GenAI
`nemotron_speech` C++ pipeline can consume it directly, and add a
deployment-bundle generator.

Graph layout (tasks/_rnnt.py): the `encoder_streaming` and `joint`
graphs now follow the GenAI contract at the I/O boundary via transposes,
while the model `forward()` methods keep their native NeMo-parity layout:
  - encoder_streaming: time-major audio `(B, T, mel)` and output
    `(B, T, d)`; batch-first caches `(B, L, 70, d)` / `(B, L, d, 8)`.
  - joint: single-frame time-major inputs `(B, 1, d)` / `(B, 1, d_pred)`.
  - decoder: already compatible (start token = blank_id 1024 maps to the
    zero SOS embedding row); unchanged.
The offline `encoder` graph intentionally keeps the native feature-major
layout (it is not part of the GenAI streaming pipeline). The unit test,
integration test and example decode loops are updated to the new layouts.

Bundle (integrations/nemo/_genai_config.py): `write_genai_bundle` writes
flat encoder/decoder/joint ONNX plus genai_config.json,
audio_processor_config.json, an HF Unigram tokenizer.json derived from the
.nemo SentencePiece model (Metaspace decoder so ▁ marks decode to spaces),
and an optional Silero VAD download. Wired into `mobius build-nemo --genai`.
The GenAI runtime only supports float32 encoder I/O, so the generator
asserts the package was built in float32.

Validated: integration parity/streaming/greedy/fp16 + bundle load via
onnxruntime_genai; example transcribes the LibriSpeech sample exactly
(offline) and converges (streaming); full fast unit suite (2911) + lint.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Comment thread tests/nemo_rnnt_integration_test.py Fixed
Make the FastConformer-RNNT encoder and joint graphs natively time-major /
batch-first — the layout already used internally by the model and required
by the ORT GenAI nemotron_speech pipeline — so the exported graphs carry no
layout transposes at all.

Previously the offline encoder emitted feature-major (B, d, T) while the
streaming encoder adapted to the GenAI time-major layout via boundary
transposes (commit 966dadf), leaving two divergent encoder layouts and
redundant transposes. The model's ConvSubsampling already transposed the
feature-major input to time-major on entry and the encoder transposed back on
exit purely to match NeMo's I/O convention; the joint likewise transposed its
inputs internally. Adopting time-major as the native layout removes every one
of those transposes and unifies the offline and streaming encoders on a single
layout.

Changes:
- models/nemo_rnnt.py: ConvSubsampling consumes (B, T, feat_in) directly;
  FastConformerEncoder offline + streaming return time-major (B, T', d) with
  batch-first caches (B, L, ...); RNNTJoint projects time-major inputs
  directly. Decoder stays feature-major (B, d_pred, U) per the C++ contract.
- tasks/_rnnt.py: declare native time-major audio/output + batch-first caches
  in encoder/encoder_streaming/joint builders; drop all boundary transposes;
  update module docstring (encoder now unified time-major).
- tests + example: feed/compare in the native time-major layout; the runtime
  greedy loop keeps the single decoder-frame transpose for the joint.
- _genai_config.py: ruff RUF046/TRY300 cleanups.

Validated: nemo unit tests, integration parity/streaming/greedy/fp16/genai
bundle (5 pass), example offline (exact) + streaming (converges) on
LibriSpeech, lint clean, full fast suite green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 25 changed files in this pull request and generated 9 comments.

Comment thread src/mobius/integrations/nemo/_reader.py Outdated
Comment thread src/mobius/__main__.py Outdated
Comment thread src/mobius/models/nemo_rnnt.py
Comment thread src/mobius/integrations/nemo/_genai_config.py
Comment thread src/mobius/__main__.py Outdated
Comment thread src/mobius/__main__.py Outdated
Comment thread src/mobius/__main__.py Outdated
Comment thread src/mobius/models/nemo_rnnt.py Outdated
Comment thread src/mobius/models/nemo_rnnt_test.py
justinchuby and others added 4 commits June 18, 2026 14:08
Resolve actionable review comments on PR #359:

- models/nemo_rnnt.py: update the module docstring and ConvSubsampling
  docstring to the native time-major layout and NeMo's per-stage causal
  length rule (the previous text described the old feature-major layout and
  an inaccurate ceil(T/8) length).
- _reader.py: expand `~` in `_resolve_nemo_path` so `build_from_nemo('~/m.nemo')`
  resolves the local file; stream `model_weights.ckpt` straight from the tar
  member (seekable fast path, buffered fallback) to avoid buffering the
  multi-GB checkpoint in memory.
- __main__.py: add `build-nemo --revision` to pin HF Hub downloads and thread
  it through `build_from_nemo` and the GenAI `NeMoArchive`; correct the
  `--dtype` help (f16/bf16 are supported; only the GenAI bundle is f32-only).
- _config_mapping.py: drop the unused `logging` import / `logger`.
- tests: use `ir.serde.serialize_model` instead of `ir.to_proto`; replace an
  empty `except ImportError: pass` with `pytest.skip`; restore the sorted
  order of `_SPECIALIZED_TEST_MODEL_TYPES`.

Note: the GenAI bundle's `log_eps` (2**-24) and `audio_processor`
`log_zero_guard_value` (1e-10) intentionally differ — both match the official
olive nemotron_speech recipe exactly.

Validated: nemo unit + coverage tests, integration parity + genai bundle
(real .nemo archive, streamed state_dict), lint clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Replace the manual matrix_ac MatMul + Softmax + context MatMul in
RelPositionMultiHeadAttention with the opset-23 fused `op.Attention` op, so
ORT can dispatch the scaled-dot-product attention to its memory-efficient /
fused kernels.

NeMo's Transformer-XL relative-position attention has two score terms:
matrix_ac = (q + pos_bias_u) @ k^T and the data-dependent matrix_bd =
rel_shift((q + pos_bias_v) @ p^T). The content term matrix_ac is evaluated by
op.Attention itself by folding pos_bias_u into the query and letting the op
apply the 1/sqrt(d_k) scale; matrix_bd (pre-scaled to the same domain) plus
the boolean keep-mask (converted to a -INF_VAL additive bias) are combined
into the single additive attention bias the op accepts. Net effect is
identical scores with fewer, fused graph nodes.

Notes on the two questions this addresses:
- GQA does not apply: this is standard MHA with equal Q/KV heads, and the
  relative-position bias is not expressible by the fused GroupQueryAttention
  op (which assumes rotary, not Transformer-XL rel_pos).
- The existing components/_audio.py ConformerEncoder/ConformerAttention cannot
  be reused as-is: they implement a T5-relative-bias conformer with
  symmetric-pad subsampling (Phi4MM family), whereas NeMo FastConformer uses
  Transformer-XL rel_pos with causal subsampling. This change adopts their
  op.Attention pattern without taking the incompatible mechanism.

Validated: nemo unit tests; integration offline parity (atol 1e-4), fp16
parity (CUDA), streaming parity, greedy decode; example offline exact +
streaming converges on LibriSpeech; lint clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Replace the manual `x * sigmoid(x)` SiLU in the conformer feed-forward and
convolution modules with the native opset-24 `op.Swish` op (alpha=1). The
model graphs already target OPSET_VERSION=24, and ORT 1.27 implements Swish
for float32 (CPU/CUDA), float16 (CPU/CUDA) and bfloat16 (CUDA) — covering
every dtype/EP path this model executes — so this emits one fused node instead
of a Mul+Sigmoid pair with identical numerics.

The conv module's GLU gate stays as `Mul(a, Sigmoid(b))`: ONNX has no GLU/Glu
operator (the two operands are different split halves, not a self-gate).

Validated: nemo unit tests; integration offline parity (atol 1e-4), fp16
parity (CUDA), streaming parity, greedy decode; example offline exact on
LibriSpeech; lint clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
The codebase emits opset 24 graphs (OPSET_VERSION=24 in
src/mobius/_constants.py), but two docs implied opset 23:

- .github/copilot-instructions.md said "Use ONNX opset 23 op.Attention".
  op.Attention was introduced in opset 23 but our graphs target opset 24;
  clarify both facts.
- docs/design/gguf-support-proposal.md said "Our codebase uses opset 23";
  corrected to opset 24.

Other "opset 23" mentions in the skills correctly refer to op-introduction
versions (Attention/RMSNormalization) or the opset-24->23 lowering fallback
for older ORT CUDA EPs, and are left unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
@titaiwangms

Copy link
Copy Markdown
Contributor

Review — multi-model team review (readability, correctness, adversarial, spec-adherence, integration)

Overall this is a high-quality PR. The FastConformer-RNNT math was independently verified against the NeMo reference source (rel-pos decomposition via fused op.Attention, rel_shift incl. the rectangular streaming case, positional encoding, Macaron/conv ordering, subsampling length rule, LSTM gate reorder, joint + blank bookkeeping all check out). The clever op.Attention decomposition (folding pos_bias_u into Q, pre-scaling matrix_bd into the post-scale bias domain) is mathematically equivalent to NeMo. The .nemo reader is safe (tar.extractfile avoids path traversal, yaml.safe_load, torch.load(weights_only=True)). No Critical issues.

Major

1. Streaming cache length grows by physical chunk frames (tq), not valid frames (length_sub)src/mobius/models/nemo_rnnt.py:830-833 (flagged independently by two reviewers)

new_len = op.Min(op.Add(cache_last_channel_len, op.Squeeze(tq)), ...)

length_sub (the per-sample valid post-drop length) is computed but unused for the cache-length update. For a final/short chunk or length=0, this over-increments cache_last_channel_len, so the next step's _att_bias_streaming treats invalid cached frames as valid keys → cross-chunk state corruption. Since length is an exposed input, callers reasonably expect it honored. Suggest op.Add(cache_last_channel_len, length_sub), and add a streaming test with length < chunk_T (the current test uses only full-length chunks and won't catch this).

2. _nemo_model_type is monkey-patched onto a dataclass instead of using the native model_type fieldsrc/mobius/integrations/nemo/_config_mapping.py:141, _builder.py:70-79 (flagged independently by two reviewers)
ArchitectureConfig already declares model_type: str | None = None (_configs/_base.py:497). Stashing config._nemo_model_type creates a dynamic attribute invisible to type checkers / asdict / serialization; worse, dataclasses.replace(config, dtype=...) in _builder.py silently drops it, which is exactly why it must be re-stashed on the next line — a latent bug for the next replace() caller. Suggest passing model_type=... natively into ArchitectureConfig(...) and reading config.model_type.

3. _validate_encoder does not guard use_bias; a NeMo-default config builds silently-wrongsrc/mobius/integrations/nemo/_config_mapping.py (_validate_encoder)
The conformer stack hardcodes bias=False everywhere (FF, q/k/v/out, conv modules), but NeMo defaults use_bias=True for all of these (conformer_modules.py:79,255,277-317, multi_head_attention.py:72,96-99; linear_pos is correctly always bias-free). Because apply_weights is non-strict (unmatched keys → warning only, _weight_loading.py:116-121), a .nemo trained with the default use_bias=True builds successfully while silently dropping every bias → wrong output. This is the exact "loads weights but silently wrong" failure the validator's own docstring exists to prevent. The supported nemotron-speech-streaming-en-0.6b happens to use use_bias=False (hence the clean ~5e-7 parity), so this is latent. Suggest adding "use_bias": (False, bool(enc.get("use_bias", True))) to the checks dict.

4. No att_context_style guard; att_context_size[1] == -1 divides by zerosrc/mobius/models/nemo_rnnt.py (FastConformerEncoder.__init__, _att_bias)
The masks always implement NeMo's chunked_limited rule (verified correct for that style), but att_context_style == "regular"/chunked_limited_with_rc models pass validation yet get the wrong mask. Also self._chunk_size = right + 1right == -1 (NeMo's "unlimited right" sentinel, special-cased at conformer_encoder.py:826-828) yields chunk_size = 0op.Div(..., 0) in _att_bias. (left == -1 is handled correctly.) Suggest validating att_context_style == "chunked_limited" and att_context[1] >= 0. Related: cache_size == 0 (from att_context_size[0] == 0) makes op.Slice(kv, starts=[-cache_len], ...) with -0 return the whole sequence instead of an empty cache — another reason to guard these config edges.

Minor

  • genai_config log-eps inconsistency_genai_config.py:37,370: _LOG_EPS = 2**-24 (NeMo-compatible) vs log_zero_guard_value = 1e-10 in the written audio_processor_config. For low-energy mel bins these differ materially; use a single source of truth and add a test asserting the written value equals _LOG_EPS.
  • Unpinned VAD download_genai_config.py (_download_silero_vad): hf_hub_download(_SILERO_REPO, _SILERO_FILE) has no revision/hash pin, so include_vad=True bundles ship a mutable external model. Pin a revision (and/or default include_vad=False).
  • CLI --task not wiredbuild_from_nemo exposes task: str | None, but nemo_parser in __main__.py has no --task arg, unlike the standard build parser. Either wire it or drop the unused kwarg.
  • Brittle implicit weight sharing — no weight_prefix_map on the model, so apply_weights(prefix_map=None) broadcasts every key against every sub-graph; this is what lets the deepcopied encoder/encoder_streaming share encoder.* initializers. Works, but document it on RNNTTask.build or declare an explicit map.
  • Untrusted-archive resource limits_reader.py: no caps on tar member count / config size / checkpoint size. Code-exec is mitigated (weights_only=True), but document that .nemo inputs must be trusted for resource consumption, or add sane limits.
  • Dead config fieldsfastconformer_pos_emb_max_len, fastconformer_subsampling_factor, fastconformer_xscaling are mapped/stored/asserted but never read (PE is dynamic; subsampling hardcodes 3 stages; xscaling forced False). Add a "stored for round-tripping, not applied" comment or drop them.
  • Streaming output-contract note_forward_streaming returns all T_out frames; NeMo trims to valid_out_len (conformer_encoder.py:567-569). Presumably delegated to the GenAI runtime — worth a one-line docstring note so a non-GenAI consumer doesn't double-count overlapped frames.
  • batch_norm attribute holds a LayerNorm — confusing without a comment; add one noting it matches NeMo's checkpoint key ...conv.batch_norm.* and is always a LayerNorm under conv_norm_type="layer_norm".

Question

  • Fully-masked query row: NeMo does masked_fill(-INF) then post-softmax masked_fill(0); this PR adds -10000 and does no post-softmax zeroing, so a fully-masked row yields a value-average (note: with -10000, not -inf, this does not NaN). Benign today — offline padded rows are re-zeroed via op.Where(valid, …) and the streaming contract forbids intra-chunk padding — but please confirm no batched-streaming path can produce an all-masked query row that escapes the output zeroing.

Nits

  • Spurious parens making a non-tuple look like a tuple — _config_mapping.py:1151 (("fastconformer_rnnt")).
  • Two-char names hurt the dense kernels: ll reused with two meanings, de, ch/ct/nch/nct in _forward_streaming.
  • model_roles["decoder"] = "encoder" (tasks/_rnnt.py) looks like a copy-paste error; add a comment that none of the four sub-models use decoder/GQA fusion.

Praise

  • The rectangular streaming rel_shift (query window offset by cache_size against a full Tk key window) is correctly derived, not copy-pasted from the square case — the easiest place to get Transformer-XL streaming wrong; verified exact via numpy simulation.
  • Clean NeMoArchive reader; loud, actionable _validate_encoder failures; excellent shape-contract comments and ORT-GenAI layout docstrings.
  • The looser joint parity (2.7e-5 vs 5e-7) is expected, not a red flag: an extra LogSoftmax over vocab+1 atop a deeper accumulation chain, and it's argmax-invariant for greedy decode.

🤖 Synthesized from a 5-model review team (Claude readability + Opus deep/spec + GPT correctness & adversarial + Gemini integration). Spec claims grounded against NeMo source; the two top Majors verified directly against the diff.

justinchuby and others added 3 commits June 18, 2026 18:15
Cache-aware multi-context NeMo archives may store pre_encode_cache_size as
a list (e.g. [4, 9]). nemo_to_config() called int() on it directly, which
raised TypeError on those archives. Mirror the genai-config path (and the
reference olive recipe) by taking the last entry before int().

Add unit tests for list-valued pre_encode_cache_size and att_context_size.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
After migrating SiLU to the native opset-24 op.Swish, the _swish helper was
just a one-line pass-through (return op.Swish(x)). Inline it at the two call
sites in the macaron FFN and conv module and remove the wrapper.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
Act on the high-value findings from the multi-model PR #359 review:

Correctness:
- Streaming cache length now grows by the chunk's *valid* frame count
  (length_sub), not the physical post-drop T_out. A short/final chunk
  previously over-incremented cache_last_channel_len, letting the next
  step's mask treat padded cache frames as valid keys. Identical for
  full chunks (the validated path).

Robustness / fail-loud:
- _validate_encoder now rejects use_bias=True (the stack hardcodes
  bias=False; non-strict weight loading would otherwise silently drop
  every bias) and att_context_style != "chunked_limited" (the only mask
  rule implemented).
- nemo_to_config rejects an unlimited right context (att_context[1] == -1),
  which would make chunk_size 0 → div-by-zero in the streaming mask.
- Pin the Silero VAD download to a fixed revision so include_vad bundles
  are reproducible.

Design:
- Store the resolved registry model_type on the native ArchitectureConfig
  .model_type field instead of monkey-patching config._nemo_model_type,
  which dataclasses.replace() silently dropped (forcing a re-stash).

Nits:
- Fix spurious parens around the "fastconformer_rnnt" registry value.
- Document why all four RNN-T sub-models map to the "encoder" role.

Add unit tests for use_bias, att_context_style, and unlimited-right-context
guards. All nemo unit tests (39) and integration tests (5, real model) pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
@justinchuby

Copy link
Copy Markdown
Member Author

Thanks for the exceptionally thorough multi-model review — the spec-grounding against the NeMo source was especially valuable. Addressed in 2be9ce1 (+ earlier f17b9db, dfdf05f):

Fixed

Major 1 — streaming cache length ✅ Now grows by the chunk's valid frame count (length_sub) instead of the physical post-drop T_out. Identical for full chunks (the validated path), correct for short/final chunks where padding previously inflated cache_last_channel_len.

Major 2 — _nemo_model_type monkey-patch ✅ Switched to the native ArchitectureConfig.model_type field; removed the dataclasses.replace() re-stash latent bug.

Major 3 — use_bias guard_validate_encoder now rejects use_bias=True (the stack hardcodes bias=False; non-strict loading would silently drop every bias).

Major 4 — att_context_style / unlimited-right guards ✅ Reject att_context_style != "chunked_limited" and att_context_size[1] == -1 (the chunk_size = right + 1 = 0 → div-by-zero case). left == -1 stays handled (clamps to cache 70).

Minor — list-valued pre_encode_cache_size ✅ Already inconsistent between paths; nemo_to_config now mirrors the genai-config path (and the olive recipe) by taking the last entry.

Minor — unpinned VAD download ✅ Pinned _SILERO_REVISION to a fixed commit SHA.

Nits ✅ Fixed the spurious ("fastconformer_rnnt") parens; documented the "decoder": "encoder" role mapping. Also inlined a now-trivial _swish wrapper into native op.Swish.

Added 4 unit tests (use_bias / att_context_style / unlimited-right / list configs). All 39 nemo unit + 5 integration tests (real model) pass, lint clean.

Respectfully pushing back

Minor — genai_config log-eps "inconsistency". _LOG_EPS = 2⁻²⁴ (genai_config log_eps) and log_zero_guard_value = 1e-10 (audio_processor_config) are different fields consumed by different stages, and both match the upstream reference recipe (nvidia-nemotron-speech-streaming-en-0.6b/cpu/optimize.py lines 163 & 259) byte-for-byte. Collapsing them to one value would diverge from the reference, so I've left them as-is.

Question — fully-masked query row. Confirmed benign and no batched-streaming path can escape it: offline padded rows are re-zeroed via op.Where(valid, …), and the streaming contract forbids intra-chunk padding (a chunk is either fully valid or length=0, and the cache-length fix above keeps masked keys out). With -10000 (not -inf) there's also no NaN risk.

Deferred (doc-only, low-risk)

Dead-field round-trip comments, untrusted-archive resource caps, the unused --task kwarg on build_from_nemo, and the explicit weight_prefix_map documentation — all noted; happy to follow up if you'd like them in this PR rather than a doc pass.

justinchuby and others added 4 commits June 18, 2026 21:01
The example runs ONNX via mobius._testing.ort_inference.OnnxModelSession,
which imports onnxruntime and onnxruntime-easy. These are not pulled in by
the mobius-ai core package, so the documented `pip install mobius-ai
librosa soundfile sentencepiece` line failed at import with
ModuleNotFoundError: onnxruntime_easy. Add the missing packages and note
that torch is a core dependency. Also document that only Linux/x86-64
(CPU/CUDA) is verified; the CPU path should work elsewhere but is untested.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
onnxruntime-easy supports device="webgpu"; expose it as a --device choice so
it can be tried where an onnxruntime WebGPU EP build is available. Mark it
EXPERIMENTAL/unverified: the encoder emits opset-24 Swish + Attention and the
decoder uses LSTM, which may lack WebGPU kernels (CPU fallback or errors).

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>
Drop the dedicated `mobius build-nemo` subcommand (~130 lines: handler +
8-arg subparser) in favor of auto-detecting `.nemo` inputs in the existing
`build` command, mirroring how diffusers pipelines are auto-detected. When
`--model` ends with `.nemo` (local file or `owner/repo:model.nemo` HF ref),
`build` routes to `build_from_nemo` and reuses the standard `--dtype`/`--ep`/
`--external-data` args and `_save_package` save logic.

The GenAI `nemotron_speech` bundle path (formerly `--genai`/`--chunk-seconds`/
`--no-vad`) is not part of the core CLI; it remains available via the Python
API (`write_genai_bundle`) and the example script.

Add test_build_dot_nemo_model_routes_to_nemo.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com>
@justinchuby
justinchuby merged commit 6630bd3 into main Jun 19, 2026
17 of 20 checks passed
@justinchuby
justinchuby deleted the justinchu/nemo-fastconformer-rnnt branch June 19, 2026 16:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants