Skip to content

Adding Nemotron 3 Diarization ONNX Model Support - #748

Merged
themason2011 merged 10 commits into
mainfrom
users/masoncorey/AddingNemotron3DiarizationModelSupport
Sep 30, 2026
Merged

themason2011 merged 10 commits into
mainfrom
users/masoncorey/AddingNemotron3DiarizationModelSupport

Conversation

@themason2011

@themason2011 themason2011 commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Add ONNX graph support for NVIDIA Nemotron 3 Diarization, including both an offline full-sequence model and a stateful streaming model for per-frame speaker-activity probabilities.

Changes

  • Add Nemotron3DiarizationConfig to map the Hugging Face audio, head, and streaming settings, and register nemotron3_diarization with the existing offline diarization task.
  • Implement the Sortformer-style audio path: feature stacking/subsampling, bidirectional RoPE transformer, projection, sub-pixel upsampling, and sigmoid speaker-classification head.
  • Add the diarization-streaming task. Each graph invocation processes one mel-feature chunk with configurable look-ahead and returns that chunk’s speaker probabilities plus updated fixed-capacity Arrival-Order Speaker Cache (AOSC), FIFO buffers, occupancy counts, and compression state. The cache update includes score-based top-k compression.
  • Export the model and task from their public modules and add a tiny model configuration for graph-build coverage.

Validation

  • Add streaming graph tests for package creation, input/output names, task lookup, and three consecutive ONNX Runtime calls that feed each step’s cache state into the next and check probability ranges and cache capacities.
  • The existing parameterized speech graph-build suite includes the new tiny model configuration for offline graph construction.
  • The three-step execution test uses random weights; this PR does not add a numerical comparison against Hugging Face with real model weights.

Scope / limitations

  • The graph consumes mel-spectrogram features, not raw audio. Streaming callers provide the chunk window, look-ahead length, and cache state on each invocation.
  • The streaming export assumes a fully valid input window; it does not expose Hugging Face’s padding attention_mask for a potentially padded final chunk.
  • Outputs are speaker-activity probabilities, not post-processed speaker turns or timestamps.

@themason2011
themason2011 requested review from a team and a lite review from Copilot September 28, 2026 16:48
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Performance Comparison

Comparing 88fd6a1f → 8503dcce

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0% ⚪
bert (feature-extraction) num_nodes 68 68 +0.0% ⚪
falcon model_size_bytes 364 KB 364 KB +0.0% ⚪
falcon num_nodes 66 66 +0.0% ⚪
gemma2 model_size_bytes 428 KB 428 KB +0.0% ⚪
gemma2 num_nodes 105 105 +0.0% ⚪
gpt2 model_size_bytes 324 KB 324 KB +0.0% ⚪
gpt2 num_nodes 54 54 +0.0% ⚪
llama model_size_bytes 425 KB 425 KB +0.0% ⚪
llama num_nodes 60 60 +0.0% ⚪
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0% ⚪
llama (static-cache) num_nodes 56 56 +0.0% ⚪
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0% ⚪
mamba (ssm-text-generation) num_nodes 94 94 +0.0% ⚪
phi3 model_size_bytes 421 KB 421 KB +0.0% ⚪
phi3 num_nodes 58 58 +0.0% ⚪
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0% ⚪
phi3 (static-cache) num_nodes 54 54 +0.0% ⚪
qwen2 model_size_bytes 425 KB 425 KB +0.0% ⚪
qwen2 num_nodes 60 60 +0.0% ⚪
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0% ⚪
qwen2 (static-cache) num_nodes 56 56 +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 265 265 +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 127 127 +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 450 450 +0.0% ⚪
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0% ⚪
t5 (seq2seq) num_nodes 176 176 +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.

@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing 88fd6a1f → 8503dcce

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.

Copilot review overview

🟡 Changes recommended

Critical and moderate issues remain in model compatibility, streaming behavior, configuration extraction, test coverage, and registry coverage.

Review effort: Lite
Findings: 4 High severity · 1 Medium severity

Open (5)
What changed in this PR

Adds Nemotron 3 Diarization ONNX support, including configuration, model registration, offline/stateful streaming graphs, cache handling, and tests.

Changes:

  • Adds Nemotron model architecture and configuration extraction.
  • Adds streaming task wiring with cache/FIFO handling.
  • Adds registry/exports and graph execution tests.
File Summary / findings
tests/​build_graph/​speech_test.py Adds graph and runtime tests; needs HF numerical parity coverage for normal, final-chunk, and compressed-cache steps.
tests/​_test_configs.py Adds a tiny Nemotron test configuration.
src/​mobius/​tasks/​_diarization_streaming.py Adds streaming graph I/O; fixed dimensions and lookahead handling do not support shorter final chunks or processor streaming modes.
src/​mobius/​tasks/​__init__.py Exports the streaming task.
src/​mobius/​models/​nemotron3_diarization.py Implements the model and cache logic; contains MHA/KV shape incompatibility and dtype-invalid padding.
src/​mobius/​models/​__init__.py Exports the Nemotron model.
src/​mobius/​_registry.py Registers the model; missing coverage YAML or documented skip will fail the coverage suite.
src/​mobius/​_configs/​_base.py Adds configuration extraction; nested configs must be normalized before field access.
src/​mobius/​_configs/​__init__.py Exports the configuration.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/mobius/_registry.py
Comment thread src/mobius/models/nemotron3_diarization.py
Comment thread src/mobius/models/nemotron3_diarization.py Outdated
Comment thread src/mobius/tasks/_diarization_streaming.py Outdated
Comment thread tests/build_graph/speech_test.py Outdated
@titaiwangms
titaiwangms self-requested a review September 28, 2026 17:59
themason2011 and others added 3 commits September 28, 2026 18:22
Nemotron3DiarizationModel's diarization/diarization-streaming task
strings are not part of the generic testdata/cases/ + generate_golden.py
YAML pipeline (same limitation as the NeMo-based sortformer model), so
tests/model_coverage_test.py's L4/L5 check was failing.

Add:
- scripts/generate_nemotron3_diarization_golden.py: regenerates
  testdata/golden/speech/nemotron3_diarization.npz from the real
  HuggingFace Nemotron3DiarizationForAudioFrameClassification reference
  (offline single-pass output, plus a 3-chunk streaming session that
  exercises FIFO overflow and AOSC top-k compression).
- tests/nemotron3_diarization_integration_test.py: L4 offline parity and
  L5 multi-chunk streaming-session parity (speaker probabilities and full
  cache state) against that golden, building the real ONNX graphs via
  mobius.build().
- _COVERAGE_SKIP entry in tests/model_coverage_test.py pointing at the new
  integration test, mirroring the existing sortformer entry.

Signed-off-by: themason2011 <masoncorey@microsoft.com>
Previously, nvidia/Nemotron-3-Diarization and sortformer had bespoke,
one-off integration test scripts and golden generators that duplicated
the shared L4/L5 golden-test framework (testdata/cases/,
scripts/generate_golden.py, tests/e2e_golden_test.py,
mobius._testing.golden/parity). This meant diarization models were not
subject to the same coverage checks, schema validation, or discovery
machinery as every other architecture, and required a
model_coverage_test.py _COVERAGE_SKIP escape hatch to pass CI.

This change extends the existing framework to natively support
diarization's per-frame speaker-probability output, instead of
building a separate one-off harness:

- Add "diarization" / "diarization-streaming" task types and a "nemo"
  reference_loader to testdata/cases/schema.json.
- Add save_diarization_golden()/load_diarization_golden() to
  mobius._testing.golden, storing full-precision probability arrays in
  a companion .npz (mirroring the existing drafter-inputs pattern) with
  lightweight metadata in the standard golden .json.
- Add compare_diarization_golden() to mobius._testing.parity: an
  elementwise allclose gate over continuous frame probabilities, with
  dominant-speaker argmax agreement and active-speaker-set Jaccard as
  diagnostics, returning the same ParityReport shape used everywhere
  else in the framework.
- Add _generate_diarization_offline()/_generate_diarization_streaming()
  generators to scripts/generate_golden.py, dispatching to either
  AutoModelForAudioFrameClassification (HF) or a NeMo .nemo checkpoint
  depending on case.model_type.
- Wire diarization/diarization-streaming dispatch into
  tests/e2e_golden_test.py: a nemo-loader branch in
  _build_model_package(), new helpers for offline and streaming
  diarization runs, an early-exit branch in
  TestL4CheckpointVerified, and a new TestL5DiarizationSession class
  for full cache-threaded streaming verification.
- Add 3 new YAML cases under testdata/cases/diarization/ covering
  nemotron3 offline (L4), nemotron3 streaming (L5), and sortformer
  offline (L4+L5, migrated from NeMo).
- Migrate existing committed golden data for both models into the new
  storage format; nemotron3's HF-sourced mel (channel-last) is
  pre-transposed to the channel-first layout the ONNX graph expects,
  matching NeMo's native convention used by sortformer.
- Remove the now-redundant bespoke test/generator scripts and their
  ad-hoc golden .npz files.
- Remove the nemotron3_diarization _COVERAGE_SKIP entry entirely (now
  covered generically). Narrow the sortformer _COVERAGE_SKIP entry to
  only its pre-existing, unrelated L1-L3 gap (no test_model_id/tiny
  config, matching fastconformer_rnnt), since its L4/L5 coverage is now
  handled by this framework too.

Verified: tests/yaml_schema_test.py, tests/model_coverage_test.py, and
tests/e2e_golden_test.py -k diarization all pass; full baseline suite
unaffected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: themason2011 <masoncorey@microsoft.com>
Fixes 3 of the 5 outstanding review-bot comments on this PR (the other
two -- missing L2/L4 coverage, and insufficient streaming test coverage
-- were already resolved by the prior 'Integrate diarization L4/L5
testing' commit).

- tests/_test_configs.py: set num_key_value_heads=TINY_HEADS on the
  nemotron3_diarization tiny config. HuggingFace's checkpoint is always
  MHA (audio_config sets num_key_value_heads == num_attention_heads);
  the shared tiny-config default is GQA (TINY_KV_HEADS < TINY_HEADS),
  which built a V-projection shape no real checkpoint could populate.

- src/mobius/models/nemotron3_diarization.py: cast every FLOAT32
  literal in the AOSC scoring/compression path (_get_frame_scores's
  threshold/complement/log-half/neg-inf/positivity constants, the
  tail-boost Where branches, the score-cache Pad fill value, and the
  top-k -inf sentinel comparison) to match the surrounding tensor's
  config.dtype via CastLike. ONNX elementwise ops require matching
  operand dtypes, so these bare FLOAT32 constants made fp16/bf16
  streaming exports invalid.

- src/mobius/tasks/_diarization_streaming.py: make the streaming
  input_features time axis symbolic instead of a fixed
  chunk_length+chunk_right_context window. forward_streaming already
  derives every frame count from the input's actual shape, so only the
  declared graph input shape was blocking HuggingFace's supported
  shorter final chunk.

- tests/build_graph/speech_test.py: exercise a shorter final chunk
  (no lookahead) in the streaming execution test to prove the symbolic
  time axis actually accepts variable-length windows, and add a
  docstring note pointing to tests/e2e_golden_test.py::
  TestL5DiarizationSession for real-weights numeric parity (including
  the lookahead trim and cache-compression transitions), since this
  test intentionally stays a fast random-weight structural smoke test.

Verified: tests/build_graph/speech_test.py -k Nemotron3Diarization,
tests/model_coverage_test.py, and
tests/e2e_golden_test.py -k nemotron3_diarization -m integration
(against the real nvidia/Nemotron-3-Diarization checkpoint) all pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: themason2011 <masoncorey@microsoft.com>
@titaiwangms

Copy link
Copy Markdown
Contributor

Full review of head 90435051 (read-only; no changes made). The config/model/task/registry split follows Mobius conventions, the offline path reuses DiarizationTask, and the new streaming task has an explicit cache-state I/O contract. The earlier Copilot comments about missing YAML, MHA tiny-config dimensions, dtype constants, fixed-length final chunks, and missing real-weight goldens have been addressed in subsequent commits; the findings below concern the current head.

Findings

  1. Major — L4/L5 can pass with incorrect diarization probabilities. In src/mobius/_testing/parity.py:424-438, a failed elementwise allclose becomes AMBIGUOUS whenever the dominant speaker per frame is unchanged. Both diarization consumers in tests/e2e_golden_test.py:2243,2308 assert only result != FAIL, accepting that result. For example, golden [0.9, 0.6] versus ONNX [0.9, 0.1] passes the argmax check even though the second speaker changes from active to inactive and the numeric error far exceeds the stated tolerance. This also weakens the migrated Sortformer L4 check. Please require PASS/allclose for continuous-probability parity and add a regression test with a secondary speaker crossing the 0.5 threshold.

  2. Major — the default offline graph silently changes semantics for long recordings. src/mobius/models/nemotron3_diarization.py:22-26,582-598 describes a single full-sequence forward with no chunking or speaker cache, while the Hugging Face offline forward chunks a whole recording. The PR itself limits its parity claim to one chunk (chunk_length * subsampling_factor, approximately 2,720 mel frames at the checkpoint defaults); the committed offline golden deliberately stays below this limit. Inputs beyond it still execute but no longer follow the reference inference path. Please decide on and enforce the public contract: reject/clearly constrain long offline inputs and route them to streaming, or implement the matching chunked offline path.

Smaller follow-ups

  • src/mobius/tasks/_diarization.py:20-24 says speaker_probs has time / subsampling_factor frames, but Nemotron upsamples and returns approximately one output per mel frame. Make the shared task's output-frame rule model-specific. Also correct the streaming task's output-length formula for windows whose raw-frame count is not divisible by the subsampling factor (src/mobius/tasks/_diarization_streaming.py:49-55).
  • The migrated testdata/golden/diarization/sortformer.json retains input_frames/num_spks provenance, while the new scripts/generate_golden.py emits num_frames/num_speakers; regenerate it or document that it was carried over. The Nemotron golden records a development Transformers version but no immutable Transformers source revision, making the reference implementation harder to reproduce.

Verification boundary: This was a static review, not an independent real-weight run. L1, L3, and lint passed on this head, but the L4 and L5 CI jobs each hit their one-hour timeout and were cancelled; that is not evidence that the model-specific goldens failed or passed. A targeted completed L4/L5 run is still needed. Short, non-subsampling-aligned final chunks and reduced-precision cache compression have not been independently compared with the HF reference here; these are open verification questions, not additional confirmed defects.

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Fixes the 6 findings confirmed with the user from titaiwangms's latest
review pass:

- src/mobius/_testing/parity.py, parity_test.py,
  tests/e2e_golden_test.py: remove compare_diarization_golden()'s
  AMBIGUOUS downgrade. Dominant-speaker argmax agreement is not a valid
  substitute for full elementwise tolerance on a multi-label sigmoid
  output -- a secondary (non-dominant) speaker's probability can cross
  the 0.5 activation threshold (a real diarization error) while the
  argmax stays unchanged. Every allclose failure is now a hard FAIL;
  dominant-speaker match and active-speaker-set Jaccard are reported as
  diagnostics only. Updated L4/L5 assertions to require
  ParityResult.PASS.

- src/mobius/tasks/_diarization.py: made DiarizationTask's docstring
  model-agnostic -- "frames = time / subsampling_factor" is true for
  Sortformer but wrong for Nemotron3 (which upsamples back to
  per-mel-frame resolution, frames == time).

- src/mobius/models/nemotron3_diarization.py (forward_streaming): fixed
  a real pad-leak bug -- the raw-frame trim bound included the
  lookahead window's frame count instead of just the current chunk's,
  so a shorter final chunk without lookahead retained trailing padding
  in its output. Added a regression test in
  tests/build_graph/speech_test.py.

- testdata/golden/diarization/sortformer*: regenerated via real NeMo
  inference so field names match the current schema.

- scripts/generate_golden.py: pinned an exact transformers commit
  (c8b81b63232be35ab1774dd3cabbf499d8b9808f) for reproducible
  Nemotron3Diarization golden generation (the model isn't in any
  released transformers version yet); added
  _best_effort_package_commit() to record the resolved git commit in
  golden provenance for git-installed dependencies (transformers,
  nemo_toolkit). Regenerated the Nemotron3 golden JSON/NPZ files
  against the pinned commit.

- src/mobius/models/nemotron3_diarization.py (forward, the offline/
  non-streaming graph -- the largest finding): replaced the single
  flat non-causal pass with an ONNX Loop-based chunked implementation
  matching HuggingFace's real offline forward, which embeds the whole
  input once then loops in fixed chunk_length-sized chunks (with
  chunk_right_context lookahead), reusing the AOSC + FIFO cache
  mechanism with offline-specific cache sizes (config.fifo_length,
  config.speaker_cache_update_period -- distinct from streaming_config's
  sizes). Extracted a shared _run_chunk_and_update_cache() helper out
  of forward_streaming so both callers share the same encode/classify/
  cache-update logic parameterized by cache size. The loop accumulates
  logits into a fixed-size buffer (zero-padded per-iteration regions
  added in, rather than growing via Concat) to avoid an optimizer
  silently folding Concat(compile-time-empty, x) -> x and discarding
  all but the last iteration. Added _prerealize_parameters() to
  register every backbone/classifier parameter as a root-graph
  initializer with its final qualified name before the Loop body is
  built, and pushed distinguishing module scopes around each Loop/If
  subgraph -- both fixes for an ONNX SSA violation caused by nested
  subgraphs' independent per-graph node counters coincidentally
  generating identical auto-generated names.

- src/mobius/_configs/_base.py: added offline_fifo_length (default 40)
  and offline_speaker_cache_update_period (default 300) to
  Nemotron3DiarizationConfig, extracted from HuggingFace's top-level
  config.fifo_length/config.speaker_cache_update_period.

- src/mobius/_passes/_fold_transpose.py: fixed
  FoldTransposedInitializerPass removing a Transpose node via
  model.graph.remove(...) instead of node.graph.remove(...) -- this
  crashed whenever the node lived inside a subgraph (e.g. the new
  offline diarization Loop body), since ir.Graph.all_nodes() recurses
  into subgraphs but a node only belongs to its own (sub)graph. A
  general-purpose optimizer bug, not diarization-specific.

- tests/build_graph/speech_test.py: added
  TestBuildGraphNemotron3DiarizationOffline (package_builds, Loop
  presence, single/multi-chunk shape and cache-compression coverage)
  since no offline-forward ORT-execution tests existed before.

- testdata/cases/diarization/nemotron3_diarization_multichunk.yaml
  (new) + golden data: a 6000-mel-frame recording (750 encoder embeds,
  well above chunk_length=340) that forces HuggingFace's real offline
  forward to internally re-chunk into 3 iterations and exercise cache
  compression -- verifying full numeric parity of the new Loop-based
  offline graph against real HuggingFace chunked inference, not just
  structural ORT execution.

Verified: tests/build_graph/speech_test.py (214 passed),
tests/e2e_golden_test.py -k "diarization or sortformer" -m integration
(25 passed, against real checkpoints), and the broader
tests/build_graph + src/ suite (no new regressions; two pre-existing
gemma4/gemma3n failures confirmed unrelated via git stash).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: themason2011 <masoncorey@microsoft.com>
@themason2011

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough re-review — addressed all findings in b574814. Summary of changes and verification for each:

1. Major — L4/L5 can pass with incorrect diarization probabilities.
Removed compare_diarization_golden()'s AMBIGUOUS downgrade entirely (src/mobius/_testing/parity.py). Any elementwise allclose failure is now a hard FAIL; dominant-speaker argmax and active-speaker-set Jaccard are diagnostics only, never a substitute for the tolerance gate. Both L4/L5 consumers in tests/e2e_golden_test.py now assert result == ParityResult.PASS explicitly. Added a regression test reproducing your exact example (src/mobius/_testing/parity_test.py::test_secondary_speaker_threshold_crossing_fails, golden [0.9, 0.6] vs ONNX [0.9, 0.1]) confirming it now fails.

2. Major — the default offline graph silently changes semantics for long recordings.
Rewrote the offline forward() (src/mobius/models/nemotron3_diarization.py) as a chunked ONNX Loop that matches HuggingFace's real offline forward: the whole input is embedded once, then processed in chunk_length-sized windows (with chunk_right_context lookahead), reusing the AOSC + FIFO speaker-cache mechanism across chunks with offline-specific cache sizes (config.fifo_length / config.speaker_cache_update_period, exposed as offline_fifo_length / offline_speaker_cache_update_period on Nemotron3DiarizationConfig). This is no longer a "stay under the limit" contract — arbitrary-length recordings now follow the real chunked reference path. Verified with new golden data for a 6000-mel-frame recording (750 encoder embeds, well above chunk_length=340) that forces 3 real chunking iterations and triggers cache compression in HuggingFace's own reference implementation (testdata/cases/diarization/nemotron3_diarization_multichunk.yaml); tests/e2e_golden_test.py::TestL4CheckpointVerified::test_prefill_argmax_matches_golden[diarization/nemotron3_diarization_multichunk] passes against it. Also added tests/build_graph/speech_test.py::TestBuildGraphNemotron3DiarizationOffline (Loop presence, single/multi-chunk shapes, cache-compression trigger) since no offline ORT-execution tests existed before.

Smaller follow-ups:

  • DiarizationTask's docstring (src/mobius/tasks/_diarization.py) is now model-agnostic — no longer asserts frames = time / subsampling_factor universally; points to the specific model class for its exact formula. (The streaming task's output-length formula for non-subsampling-aligned windows was already corrected in a prior commit — confirmed still accurate.)
  • Regenerated testdata/golden/diarization/sortformer.json via real NeMo inference so its field names match the current schema (no more carried-over input_frames/num_spks).
  • The Nemotron golden now records an immutable transformers_commit (resolved via a new _best_effort_package_commit() helper in scripts/generate_golden.py) alongside the dev version string, for reproducibility.

Verification for this pass: tests/build_graph/speech_test.py (214 passed), tests/e2e_golden_test.py -k "diarization or sortformer" -m integration (25 passed, against the real nvidia/Nemotron-3-Diarization and Sortformer checkpoints — a completed, non-timed-out run), and the broader tests/build_graph + src/ suite (no new regressions). Along the way also fixed an unrelated general-purpose optimizer bug found while building the offline Loop graph: FoldTransposedInitializerPass was removing nodes via the wrong graph object, crashing for any Transpose living inside a subgraph.

Full details in the commit message: b574814.

themason2011 and others added 2 commits September 29, 2026 16:17
The nemotron3_diarization_multichunk.yaml case added in b574814 sets
generation.num_frames to force multi-chunk offline processing when
regenerating golden data. testdata/cases/schema.json's generation
object had additionalProperties: false and only allowed HuggingFace
GenerationConfig-style keys, so this failed JSON-Schema validation in
tests/yaml_schema_test.py on every CI matrix job (all 6 test
(os, python-version) jobs failed identically on Linux and Windows).

scripts/generate_golden.py already reads num_frames, num_chunks, seed,
and nemo_filename from case.generation_params (the same dict populated
from this YAML key) to configure synthetic audio/diarization fixture
generation -- a pre-existing, intentional reuse of the 'generation' key
for a second, non-GenerationConfig purpose. No prior YAML file
exercised these fields, so the schema gap went undetected until now.

Add all four fields to the schema's generation object allowlist and
update its description to document the dual purpose, rather than
introducing a new top-level key (least invasive fix, matches existing
code behavior).

Verified: tests/yaml_schema_test.py (282 passed, including the
previously-failing nemotron3_diarization_multichunk.yaml case) and
tests/build_graph/speech_test.py (496 passed) both pass locally.

Note: the separate L4/L5 GPU golden-parity workflow failures on this
PR are unrelated pre-existing CI flakiness -- that scheduled workflow
has been cancelled for timing out every day for the past two weeks on
main itself, failing on unrelated models (gemma, granite, bamba, etc.)
well before alphabetically reaching any diarization test case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: themason2011 <masoncorey@microsoft.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.

Copilot review overview

🟡 Changes recommended

Golden generation ignores the selected device, and streaming lookahead lacks required bounds validation.

Review effort: Balanced
Findings: 3 Medium severity · 1 Low severity

Open (4)

Comment thread scripts/generate_golden.py
Comment thread scripts/generate_golden.py
Comment thread src/mobius/models/nemotron3_diarization.py
Comment thread src/mobius/models/nemotron3_diarization.py Outdated
@titaiwangms

Copy link
Copy Markdown
Contributor

Follow-up review of head 9ba8220b (read-only; no changes made). Thank you for addressing the previous review. The diarization probability comparator now fails on any elementwise allclose violation, both golden consumers require PASS, and the threshold-crossing regression covers the reported false pass. The default offline model now uses a chunked ONNX Loop, with a 6000-mel-frame L4 golden to cover cache updates across multiple chunks. The shared diarization task's output-frame documentation and the Sortformer provenance fields were also updated. These changes address the two previously reported Major findings at the implementation level.

New finding: non-aligned non-final streaming windows desynchronize output and cache

Major — src/mobius/models/nemotron3_diarization.py:980-982,1047-1055,1111-1124, with the advertised input/output contract in src/mobius/tasks/_diarization_streaming.py:28-57.

The new output trim uses raw_window_frames - num_lookahead_frames * subsampling_factor, but the cache update stores ceil(raw_window_frames / subsampling_factor) - num_lookahead_frames whole embeddings. For a 13-frame window, factor 2, and two lookahead embeddings, the output covers 9 raw frames while the five new embeddings committed to FIFO/cache cover 10. Advancing the next window by nine frames processes the tenth frame twice; advancing by ten drops a frame from the output. This is a supported scenario according to the revised task contract and the new non-aligned-window shape test, which checks output length but not the across-call consumed-frame invariant.

Please either constrain non-final windows to a multiple of the subsampling factor (while retaining short, possibly unaligned final windows), or explicitly carry the partial-frame/feature-stacking state needed to make the emitted and cached boundaries agree. A two-call regression using an unaligned non-final window would establish the contract; matching against the HF reference would further validate the chosen behavior.

Other observations

  • The config docstring in src/mobius/_configs/_base.py still describes offline inference as a single full-sequence pass and says the streaming_* settings are used only by streaming, although the new offline Loop uses the shared AOSC capacity/scoring configuration. Please update those comments.
  • The new _fold_transpose.py change correctly removes a subgraph node from its owning graph. A non-blocking question remains: its fold cache is keyed only by initializer name and inserts replacements at the root; could two distinct, same-named initializers in sibling subgraphs be conflated? We have not established a reachable example in this PR's generated models, so this is not reported as a confirmed defect. A small graph-scoping regression would settle it.
  • The committed short and multichunk Nemotron goldens record different Transformers commit SHAs. It would help to confirm that both reference revisions implement the same diarization behavior, or regenerate them from one pinned reference.

Verification boundary: This review was static; I did not independently execute the PR or inspect its binary golden tensors. The author reports a completed local real-checkpoint run; the latest L4/L5 CI jobs on this head are cancelled, not evidence of either a pass or a model failure. This follow-up has no independent fp16/bf16 or real-weight parity check for unaligned streaming windows.

themason2011 and others added 2 commits September 29, 2026 20:21
Fixes 4 findings from Copilot's automated re-review of PR #748:

1/2. scripts/generate_golden.py: _generate_diarization_offline and
   _generate_diarization_streaming both accepted --device but never used
   it -- the sortformer branch hard-coded map_location="cpu" and the
   HuggingFace branches never moved the model or synthetic mel tensors
   off CPU, so --device cuda silently still ran entirely on CPU. Move
   the model and inputs to the requested device (matching the .to(device)
   pattern already used by every other _generate_* function in this
   file) and .cpu() every tensor before converting to NumPy.

3. nemotron3_diarization.py forward_streaming: num_lookahead_frames is a
   runtime ONNX graph input with an implicit contract
   (0 <= num_lookahead_frames < num_new_embeds) that was never enforced.
   An out-of-range caller-supplied value would silently corrupt FIFO/AOSC
   occupancy bookkeeping or fail inside ORT with a confusing error. Add a
   defensive Min/Max clamp in the graph itself (protects every runtime
   caller, not just mobius's own golden generator/tests) and document the
   bound plus the clamping behavior in DiarizationStreamingTask's
   docstring.

4. nemotron3_diarization.py: corrected a stale comment claiming
   silence_embeds is "unused (and pruned by the optimizer)" by the
   offline graph. This is no longer true since the offline Loop rewrite:
   the offline forward's chunked Loop calls the same
   _run_chunk_and_update_cache compression branch as forward_streaming,
   so silence_embeds is realized and consumed whenever a multi-chunk
   offline recording triggers AOSC compression.

Verified: tests/build_graph/speech_test.py (15 diarization/nemotron3
tests passed), tests/e2e_golden_test.py (5 diarization/sortformer golden
tests passed, confirming the new clamp is a numeric no-op for in-range
inputs), and the full fast suite (10517 passed, only 4 pre-existing
unrelated failures already present on main). ruff/lintrunner clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: themason2011 <masoncorey@microsoft.com>
…olden provenance

Addresses titaiwangms's PR #748 review:

- Major: forward_streaming's final output trim subtracted
  num_lookahead_frames*subsampling_factor from raw_num_frames, diverging
  from HuggingFace's actual reference trim ([:, :num_frames], no lookahead
  subtraction). This under-emitted exactly one frame whenever a non-final
  streaming window wasn't a multiple of subsampling_factor, desyncing
  speaker_probs's length from the number of frames committed to the
  FIFO/cache in the same call. Fixed by trimming directly to raw_num_frames,
  relying on Slice's documented end-index clamping -- matching HF exactly
  and requiring no window-alignment constraint or extra cross-call state.
  Verified algebraically equivalent to the old formula for every case
  already covered by tests (aligned non-final windows, final/lookahead=0
  windows), so this is a pure bug fix with no risk of regressing existing
  golden data. Updated _diarization_streaming.py's output docstring to
  describe the corrected, always-in-sync output-length formula. Added a
  two-call regression test (tests/build_graph/speech_test.py) using an
  unaligned non-final window followed by a second chunk, asserting output
  length exactly matches FIFO growth in both calls -- this also caught and
  corrected a pre-existing test that had encoded the old, buggy expected
  frame count.

- Minor: Nemotron3DiarizationConfig's docstring and field comments in
  _configs/_base.py still described offline inference as a single
  full-sequence pass and claimed the AOSC/FIFO policy fields were
  streaming-only, both stale since the offline Loop rewrite. Corrected to
  reflect that offline is chunked via an ONNX Loop and that the AOSC policy
  fields are shared by both offline and streaming via
  _run_chunk_and_update_cache.

- Minor: investigated whether FoldTransposedInitializerPass's fold cache
  (keyed by initializer name only, always inserting into the root graph)
  could conflate same-named initializers across sibling subgraphs.
  Confirmed this cannot happen for any mobius-built graph: onnxscript's
  OpBuilder always registers initializers on the root graph
  (_root._graph.register_initializer), even when emitting into a Loop/If
  body, so every mobius graph has one globally unique initializer
  namespace by construction. Added a regression test
  (test_transpose_inside_loop_body_folds_against_root_initializer) that
  pins this expectation directly with a manually-built Loop body.

- Minor: the two offline Nemotron3Diarization goldens (short and
  multichunk) recorded different transformers_commit provenance values,
  and the streaming golden matched the short one but not multichunk.
  Regenerated all three with the currently-installed transformers so all
  three now share one pinned commit (0291458166a6834a37b179a24c5235f242dadea0).

Verified: tests/build_graph/speech_test.py -k 'nemotron3 or diarization'
and tests/e2e_golden_test.py -k 'diarization or sortformer' (25 passed);
full fast suite (10,518 passed, only the same 4 pre-existing unrelated
failures already confirmed present on origin/main); lintrunner/ruff clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: themason2011 <masoncorey@microsoft.com>
@themason2011

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough re-review! Root-caused and fixed all 4 items in 8503dcce (pushed).

Major — streaming output/cache desync: Confirmed by reading the actual pinned HuggingFace reference (Nemotron3DiarizationForAudioFrameClassification.forward) rather than guessing. It was a genuine bug, not an inherent architectural limitation: our final output trim subtracted num_lookahead_frames * subsampling_factor from raw_num_frames, but HF's real reference trims to plain raw_num_frames (logits[:, :num_frames], no lookahead subtraction — that trim is a no-op safety net that only ever activates on a final, lookahead == 0 chunk). Our extra subtraction under-emitted exactly one frame whenever a non-final window wasn't a multiple of subsampling_factor — precisely your example (raw=13, factor=2, lookahead=2 → we emitted 9, cache committed 10).

Fixed by trimming directly to raw_num_frames (relying on ONNX Slice's documented end-index clamping), matching HF exactly. This is provably a no-op for every case already covered by tests (aligned non-final windows, and any lookahead == 0 final window) — so it's a pure bug fix with zero risk to existing goldens, and doesn't require either of your two proposed remediations (window-alignment constraint or extra cross-call state): once the trim matches HF, output length always exactly equals the frames committed to the FIFO/cache. Added the two-call regression test you asked for (tests/build_graph/speech_test.py::test_non_final_chunk_with_misaligned_window_has_exact_frame_count) — an unaligned non-final chunk followed by a second chunk consuming the returned cache state, asserting speaker_probs length exactly matches FIFO growth × subsampling_factor in both calls. It also caught a pre-existing test that had (accidentally) encoded the old buggy expected count — now corrected.

Config docstring: Corrected Nemotron3DiarizationConfig's docstring and the AOSC-policy field comment in _configs/_base.py — offline is now described as Loop-chunked (not single-pass), and the shared-vs-per-mode field split (only fifo_length/speaker_cache_update_period differ; the rest of the AOSC policy is shared via _run_chunk_and_update_cache) is now stated accurately.

_fold_transpose.py fold-cache scoping: Investigated — confirmed not reachable for any mobius-built graph. onnxscript's OpBuilder always registers initializers on the root graph (self._root._graph.register_initializer(...)), even while emitting into a Loop/If body; subgraphs only ever reference outer-scope initializers by name, never declare their own local ones. So every mobius graph has a single, globally unique initializer namespace by construction, and the pass's name-only cache can't conflate two distinct initializers. Added the regression test you suggested (test_transpose_inside_loop_body_folds_against_root_initializer) that pins this directly with a manually-built Loop body, so this invariant is now guarded rather than just asserted.

Golden provenance mismatch: Confirmed real — the multichunk golden was on a different transformers_commit than the other two (short + streaming, which already matched each other). Regenerated all three offline/streaming diarization goldens with the currently-installed transformers so all three now share one pinned commit (0291458166a6834a37b179a24c5235f242dadea0), same model revision as before.

Verified: targeted diarization/sortformer suite (25 passed), full fast suite (10,518 passed, only the same 4 pre-existing failures already confirmed present on origin/main, unrelated to this PR), lintrunner f/ruff clean.

@themason2011
themason2011 merged commit c2fde93 into main Sep 30, 2026
24 of 26 checks passed
@themason2011
themason2011 deleted the users/masoncorey/AddingNemotron3DiarizationModelSupport branch September 30, 2026 17:52
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