Skip to content

parakeet-cpp: add plain RNN-T (Transducer) decoder head#90

Open
rends-east wants to merge 3 commits into
tetherto:masterfrom
rends-east:pr/plain-rnnt-head
Open

parakeet-cpp: add plain RNN-T (Transducer) decoder head#90
rends-east wants to merge 3 commits into
tetherto:masterfrom
rends-east:pr/plain-rnnt-head

Conversation

@rends-east

@rends-east rends-east commented Jul 12, 2026

Copy link
Copy Markdown

What

Adds a plain RNN-T (Transducer) decoder head to parakeet-cpp/, alongside the existing CTC / TDT / EOU / Sortformer heads — GGUF model type rnnt. This enables duration-less Transducer checkpoints, in particular the RNN-T branch of NeMo hybrid EncDecHybridRNNTCTCBPEModel models (verified on nvidia/stt_ka_fastconformer_hybrid_large_pc, Georgian), which currently have no working path:

  • the converter's detect_model_type() classifies a duration-less RNN-T checkpoint as tdt and crashes on the missing model_defaults.tdt_durations;
  • the TDT loader defaults num_durations=5, mismatching a plain RNN-T joint (V+1 outputs);
  • the EOU decoder is itself a duration-less transducer but hard-wires <EOU>/<EOB> handling (its eou_id fallback vocab_size-2 would alias real BPE tokens).

How

Plain RNN-T is structurally EOU minus the special tokens, so the head reuses the EOU predictor/joint runtime instead of adding a new decoder:

  • converter: --head {auto,ctc,tdt,rnnt,eou,sortformer} override + hybrid checkpoint detection; emits parakeet.rnnt.* metadata and rnnt.predict.* / rnnt.joint.* tensors (the hybrid's CTC aux head is not exported). Guards: joint rows must equal vocab+1 and joint.num_classes must equal decoder.vocab_size, so a TDT-shaped joint fails at convert time.
  • loader: ParakeetModelType::RNNT; rnnt.* tensors load into the shared EOU weight slots; eou_id/eob_id forced to -1;
  • decoder: EouDecodeOptions.disable_special_tokens — the greedy loop breaks only on the transducer blank, matching NeMo greedy RNN-T semantics. Decode is scalar CPU (like EOU); n_gpu_layers offloads the encoder;
  • dispatch: decode arms in Engine::transcribe_samples, both streaming paths (transcribe_samples_stream, StreamSession::process_window, stream_start), and the CLI;
  • test: test-rnnt-decoder-parity (CPU vs GPU-offloaded-encoder run, plus bit-exact token-id comparison against a NeMo dump from the new scripts/dump-rnnt-reference.py), following the PROGRESS.md parity methodology. Registered via parakeet_register_test with REQUIRES fixture paths: it shows in ctest -N and stays DISABLED until the external GGUF + wav + reference dump are dropped in place (no public checkpoint for this head).
  • docs: README, PROGRESS.md Phase 18 journal entry, CLI usage, header contract comments.

Verification

Measured 2026-06-09 on nvidia/stt_ka_fastconformer_hybrid_large_pc: the C++ greedy decoder reproduces NeMo greedy RNN-T token IDs 75/75 bit-for-bit on a FLEURS ka clip; CPU == Metal at f16; Metal == NeMo at q8_0; FLEURS ka WER matches NeMo within 0.31%.

After rebasing onto current master: clean build with -DGGML_METAL=ON -DGGML_METAL_EMBED_LIBRARY=ON; test-rnnt-decoder-parity registers as Disabled without fixtures; model-free ctest passes. The parity numbers above were measured at the original base; I can re-run them against a current build on request.

Downstream wiring for packages/transcription-parakeet (qvac monorepo) is a separate follow-up PR: tetherto/qvac#3228.

Developed with AI assistance (disclosed in the commit trailers); reviewed and verified against NeMo references by the author.

rends-east and others added 2 commits July 12, 2026 16:51
Adds a `rnnt` model type alongside CTC/TDT/EOU/Sortformer for plain RNN-T
(Transducer) ASR -- e.g. the Transducer branch of a hybrid
EncDecHybridRNNTCTCBPEModel (Georgian stt_ka_fastconformer_hybrid_large_pc).

Plain RNN-T is structurally EOU minus the <EOU>/<EOB> tokens, so it reuses the
EOU predictor/joint runtime (EouRuntimeWeights), eou_prepare_runtime, and
eou_decode_window:

- converter: detect hybrid / `--head rnnt`; emit parakeet.rnnt.* metadata +
  rnnt.predict.*/rnnt.joint.* tensors (TDT minus the duration head).
- loader: ParakeetModelType::RNNT; load rnnt.* into the shared EouWeights slot;
  eou_prepare_runtime forces eou_id/eob_id = -1 for rnnt.
- decoder: EouDecodeOptions.disable_special_tokens makes the greedy loop break
  only on the transducer blank (matches NeMo greedy).
- dispatch: Engine::transcribe_samples + CLI (main.cpp) decode arms.
- test: test-rnnt-decoder-parity + scripts/dump-rnnt-reference.py.

Verified on nvidia/stt_ka_fastconformer_hybrid_large_pc: the C++ greedy decoder
reproduces NeMo greedy RNN-T token IDs bit-for-bit (75/75 on a FLEURS ka clip;
CPU == ggml/Metal graph), and FLEURS ka WER matches NeMo within 0.31%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the plain RNN-T head through transcribe_samples_stream (windowed) and the
duplex StreamSession decode so an rnnt model never falls into the CTC branch
(which would dereference the absent CTC head). Mirrors the EOU streaming arms
with disable_special_tokens and reuses the persistent eou_state; stream_start
initialises eou_state for rnnt too.

Not required for the non-streaming target model but removes a crash-on-misuse
edge if an rnnt GGUF is fed to the streaming API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@rends-east rends-east requested review from a team as code owners July 12, 2026 12:56
@github-actions

Copy link
Copy Markdown

Review Status

Current Status: ❌ PENDING
Approvals so far: none

Pending reviews: Needs 1 Management or Team Lead, and 1 more from Management, Team Lead, or Member.

Post-review hardening of the plain RNN-T head; no decode-semantics changes.

- CMake: register test-rnnt-decoder-parity via parakeet_register_test with
  REQUIRES fixture paths (shows in `ctest -N`, DISABLED until the external
  GGUF + wav + NeMo ref dump are dropped in place) instead of an
  unregistered build-only target.
- Parity harness: enforce int32/C-order `.npy` (reject int64/fortran dumps
  loudly), hard-fail when an explicitly requested ref-dir cannot be loaded
  (was WARN + exit 0), validate the wav sample rate against the model, add
  missing <cctype>, and fix the usage text -- the RNN-T decode is scalar
  CPU in both runs; n_gpu_layers only moves the encoder.
- Converter (both this repo's script and the vendored monorepo copy stay
  hunk-identical): guard --head rnnt on non-transducer checkpoints, assert
  joint rows == vocab+1 (a TDT-shaped joint fails at convert time, not at
  decode time), assert joint.num_classes == decoder.vocab_size, warn when
  NeMo's uncapped max_symbols is baked to 10, fix the wrong
  "label vocab + blank" comment, document the rnnt flavour in the module
  docstring.
- Docs: README (engine list, --head, dump-script list, rnnt prose),
  PROGRESS.md Phase 18 journal entry with the 2026-06-09 parity numbers,
  CLI usage ("five engine families"), model_type() contract comment,
  umbrella/streaming/attributed header enumerations, energy-VAD comment
  scope (plain RNNT takes the CTC/TDT opt-in RMS VAD), capture-parity
  harness model-type label.

Verified: clean Metal build; test-rnnt-decoder-parity registers Disabled
without fixtures; model-free ctest passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant