Forward map - parakeet

July 13, 2026 · View on GitHub

Reference: NVIDIA NeMo (ASRModel.from_pretrained -> EncDecRNNTBPEModel / EncDecCTCModelBPE / EncDecHybridRNNTCTCBPEModel) @ NeMo v2.7.x Closest in-tree analog: src/arch/parakeet/ itself — this stage extends the existing TDT v1 port to cover RNNT, CTC, the 1.1B FastConformer-XL geometry, and encoder.use_bias=true. No new in-tree analog is needed.

A compact map from the reference forward pass to the C++ port. Family-level because all 10 parakeet variants share a Conformer encoder; per-head differences (TDT vs RNNT vs CTC) and per-variant geometry deltas go in "Variant Notes" at the bottom.

Frontend

StageReference locationOutput shapeGate tensorggml / C++ patternIn-tree analog
Mel / log-mel filterbankmodel.preprocessor (AudioToMelSpectrogramPreprocessor -> FilterbankFeatures, n_fft=512, win=400, hop=160, hann_periodic, preemph=0.97, per-feature CMVN)[T_mel, n_mels]enc.mel.intranscribe::MelFrontend constructed once at load() from stt.frontend.* KVunchanged from existing parakeet

n_mels is 80 for every new variant except unified-en-0.6b (128). Existing tdt-0.6b-v2/v3 use 128. Frontend is otherwise identical across the family.

Encoder

Shared FastConformer encoder. The two existing geometry knobs are subsampling_factor (always 8) and n_mels (80 or 128). The new knobs introduced by Stage 4 are n_layers (17/24/42), d_model (512/1024), subsampling_channels (256), and use_bias (true/false).

StageReference locationOutput shapeGate tensorggml / C++ patternIn-tree analog
Pre-encode subsampling (factor 8 = 3x stride-2 conv)model.encoder.pre_encode (dw_striding)[T_enc, d_model]enc.pre_encode.outconf::build_pre_encode over PreEncodeView. pre_encode_freq = subsampling_channels * (n_mels / 8) — 256·10=2560 (n_mels=80) or 256·16=4096 (n_mels=128). Final linear projects to d_model.existing parakeet
Relative positional embeddingRelPositionalEncoding[2*T_enc-1, d_model]enc.pos_embhost-built sin/cos table, uploaded as graph inputunchanged
Speaker-kernel injection (multitalker variants)SpeakerKernelMixin._get_spk_kernel_hook_pre_layer (forward_pre_hook on encoder.layers[0])[T_enc, d_model](folded into enc.block.0.out)apply_spk_kernel_injection in encoder.cpp, on the block-0 input (post pre-encode + xscale): x += W3·relu(W0·x+b0)+b3 then x += W3_bg·relu(b0_bg)+b3_bg (bg input is all-zeros → constant broadcast). Gated on hp.has_spk_kernel (KV stt.parakeet.encoder.spk_kernel_layers); no-op on every other variant.new — multitalker-parakeet-streaming-0.6b-v1 only
Conformer block (FF1 → rel-pos MHSA → ConvModule(BN) → FF2 → LN)model.encoder.layers[i][T_enc, d_model]enc.block.{0, n/2, n-1}.outconf::build_conformer_block over BlockView. Bias slots populated only when enc_use_bias=true (11 biases per block: ff1.linear1/linear2, attn.q/k/v/out, conv.pw1/dw/pw2, ff2.linear1/linear2).existing parakeet — extended for biases
Final encoder LNimplicit in layers[-1] (norm_out)[T_enc, d_model]enc.finalconf::named on the final tensorunchanged
Prompt MLP (multilingual variants only)model.prompt_kernel (NeMo EncDecRNNTBPEModelWithPrompt): concat(enc[d_model], one_hot(prompt_id)[num_prompts])Linear(d_model+P → prompt_hidden) → ReLU → Linear(prompt_hidden → d_model)[T_enc, d_model]enc.prompted (encoder-side) + dec.enc_out_prompted (decoder-side; same tensor, separate dump for drift attribution)host-built one-hot broadcast over T frames, then two-layer MLP via ggml_mul_mat with bias adds and ggml_relu between. Loader reads stt.parakeet.prompt.* KVs and the four prompt.mlp.{0,2}.{weight,bias} tensors. When the KVs are absent the path is a no-op (English-only variants).new — nemotron-3.5-asr-streaming-0.6b is the first variant exercising the prompt-conditioned RNN-T joint feed

Block-spot dump indices vary by encoder depth: layers in {0, n/2, n-1}. For n=17: {0, 8, 16}. For n=24: {0, 12, 23}. For n=42: {0, 21, 41}. The docs/porting/families/parakeet.md Stage-2 entry already lists these.

Decoder

Three head kinds dispatched on stt.parakeet.head_kind (default tdt when KV missing, for the legacy v2/v3 GGUFs). All three host-side implementations live in src/arch/parakeet/decoder.cpp.

TDT (v2, v3, tdt-1.1b, tdt_ctc-110m, tdt_ctc-1.1b)

StageReference locationOutput shapeGate tensorggml / C++ patternIn-tree analog
Predictor embedmodel.decoder.prediction.embed[1, pred_hidden] (per token)dec.embed.0ggml_get_rows-equivalent (host fp32 lookup)existing parakeet
Predictor LSTM (1 or 2 layers)model.decoder.prediction.dec_rnn[pred_hidden] per layer (h, c)dec.lstm.{layer}.{h,c}.0hand-rolled fp32 LSTM step in decoder.cppexisting parakeet — pred_n_layers=1 first exercised by tdt_ctc-110m
Joint enc/pred projections + activation + outmodel.joint (TDTJoint)[vocab+1+num_extra] per (t, u)dec.joint.0fp32 matmul + relu/sigmoid/tanh + matmul on hostexisting parakeet
TDT decode loop (greedy)RNNTGreedyDecodeBatched with TDT modeper-emit token + duration(tokens)decode_tdt_greedyexisting parakeet

RNNT (rnnt-0.6b, rnnt-1.1b, unified-en-0.6b)

Predictor + joint identical in shape to TDT, but the joint emits vocab+1 logits (no duration extra-output channels). Decode loop is the same shape minus duration:

StageReference locationOutput shapeGate tensorggml / C++ patternIn-tree analog
Predictor embedmodel.decoder.prediction.embed[1, pred_hidden]dec.embed.0reuse TDT predictor mirrorTDT path above
Predictor LSTMmodel.decoder.prediction.dec_rnn[pred_hidden]dec.lstm.{layer}.{h,c}.0reuse TDT pathTDT path above
Jointmodel.joint (RNNTJoint, no extras)[vocab+1]dec.joint.0reuse TDT path with joint_num_extra_outputs=0. Allowed by existing hp validation.TDT path above
RNNT decode loop (greedy)RNNTGreedyDecodeBatched (no TDT mode)per-emit token(tokens)decode_rnnt_greedy: blank → advance 1 frame, non-blank → emit + stay (max_symbols cap, default 10)new — sibling of decode_tdt_greedy

CTC (ctc-0.6b, ctc-1.1b)

StageReference locationOutput shapeGate tensorggml / C++ patternIn-tree analog
CTC head (1×1 Conv1d)model.decoder.decoder_layers.0[T_enc, vocab+1]dec.ctc.logprobs (full), dec.ctc.logprobs.0 (frame 0)head.ctc.weight @ enc[t] + head.ctc.bias per frame, then log_softmax. Computed host-side from the encoder readback.new — minimal host head
CTC greedy decode (per-frame argmax + run-length collapse + drop blank)model.decoding.greedy_search (CTC)token sequence(tokens)decode_ctc_greedynew

Generation / KV Path

Parakeet has no autoregressive transformer KV cache. The TDT/RNNT predictor LSTM has a step-pair (h, c) per layer that is committed on every non-blank emit and rolled back on blank — host-managed in decoder.cpp. CTC has no recurrent state. The Stage 4 mid-generation-gate-tensor requirement (decoder KV correctness past n_past>0) does not apply to this family — there is no transformer KV cache to mis-index.

Capabilities And Language Controls

CapabilityReference behaviorC++ API behaviorFamily-doc Capability Validation row
Transcribe (en)transcribe() returns greedy texttranscribe-cli -m <gguf> --language en <wav> produces non-empty English transcriptone row per variant
Punctuation / casing (tdt_ctc-1.1b, tdt_ctc-110m, unified-en-0.6b)upstream training data preserves PnCnon-empty transcript with capital letters and ,.?!one row per PnC variant
Streaming (unified-en-0.6b)shared offline+streaming weightsn/a — offline only in v1ACCEPTED GAP — streaming infra deferred
Word timestampsNeMo word-alignerderived host-side from emit-frame indices (TDT/RNNT) or per-frame argmax (CTC)PASS — same code path as the existing v2/v3 word-timestamp gate, no per-variant differences
Translation, lang-detect, VADnot advertisedn/an/a

Deviations From Closest Analog

  • head_kind dispatch: same family handler covers TDT (predictor+joint+durations), RNNT (predictor+joint, no durations), and CTC (single 1×1 conv head). Loader reads stt.parakeet.head_kind (string KV); legacy v2/v3 GGUFs lack the KV and default to "tdt". Predictor/joint/tdt-durations KV reads are conditional on the resolved head_kind so a CTC GGUF does not fail at "predictor.hidden missing".
  • encoder.use_bias per-variant: existing v2/v3 ship with use_bias=false (every linear/conv biased); the 8 new variants ship with use_bias=true, contributing 11 biases per block. The shared transcribe::conformer::BlockView already exposes nullable bias slots; loader populates them only when the hparam is true.
  • n_mels=80 path: every new variant except unified-en-0.6b uses 80-bin mels (vs 128 on v2/v3/unified-en). The pre-encode chain handles arbitrary even multiples of 8 mels — the existing "only 8/128 implemented" guard in encoder.cpp was a safety check from Phase 4 step 3a, now removed.
  • CTC head storage: a single (vocab+1, d_model, 1) conv tensor flattened to head.ctc.weight + (vocab+1,) head.ctc.bias in the GGUF. Host decoder mirrors only these two tensors when head_kind=ctc; predictor and joint mirrors stay empty.
  • TDT-CTC hybrids (tdt_ctc-110m, tdt_ctc-1.1b): the upstream checkpoints carry both heads, but the converter emits TDT-only (the auxiliary CTC head is silently dropped — pure CTC variants cover that path). At Stage 4 these load through the standard TDT path with head_kind="tdt".
  • Streaming attention (att_context_style = "chunked_limited"): introduced by nemotron-speech-streaming-en-0.6b. NeMo's cache-aware streaming model trains with chunked attention but keeps the full RelPositionalEncoding (pos_emb buffer at 2T-1, not the shortened LocalAttRelPositionalEncoding size). The "regular" style is the existing path used by every other variant. Loader reads stt.parakeet.encoder.att_context_style as optional with default "regular" so legacy GGUFs are unaffected; only att_context_style == "chunked_limited" selects the new mask, computed as chunk_size = att_context_right + 1, left_chunks = att_context_left / chunk_size (every k-frame whose chunk index is in [q_chunk - left_chunks, q_chunk] is allowed). The mask is built host-side and threaded as a graph input that broadcasts across heads; added to matrix_bd before flash_attn_ext.
  • Causal depthwise conv (conv_context_left, conv_context_right): streaming-friendly Conformer convolution. NeMo's offline default is centered (k-1)/2 on both sides; nemotron-streaming uses [k-1, 0] (left=8, right=0 for k=9) so the depthwise convolution does not look ahead. Loader reads both as optional with default -1; when -1, the conformer uses centered (k-1)/2. Otherwise depthwise conv padding becomes (left, right).
  • Speaker-kernel FF injection (spk_kernel_layers, multitalker): introduced by multitalker-parakeet-streaming-0.6b-v1 (NeMo EncDecMultiTalkerRNNTBPEModel / SpeakerKernelMixin). An always-on forward_pre_hook on encoder.layers[0] injects a per-speaker FF kernel and a background FF kernel into the block-0 input; it fires unconditionally, so it is load-bearing for the in-scope single-speaker path, not just multi-speaker decoding. In single-speaker mode it reduces to x += W3·relu(W0·x+b0)+b3 (spk, per frame) then x += W3_bg·relu(b0_bg)+b3_bg (bg input masked to zeros → a per-channel constant broadcast over T). Loader reads stt.parakeet.encoder.{spk_kernel_type,spk_kernel_layers,add_bg_spk_kernel} (optional; absent → no-op) and binds enc.spk_kernel.0.{0,3}.{weight,bias} + enc.bg_spk_kernel.0.{0,3}.{weight,bias}. Only layer-0 injection is implemented (the checkpoint ships spk_kernel_layers=[0]); the loader rejects any other layer index. Applied in both the offline and the cache-aware streaming encoder graphs.
  • Variable-length batched pre-encode masking (causal variants) — found + fixed during the multitalker Stage 4 via tensor bisection of batched-vs-single encoder output. Two shared-code bugs in the offline var-len batched path, both specific to the cache-aware streaming (causal chunked_limited) variants and invisible to same-length / text-only parity:
    1. Off-by-one frame countmodel.cpp::pre_encode_t_out used the non-causal subsample formula (in-1)/2+1; causal pre-encode needs in/2+1. Undercounted real_tenc (dropped the last encoder frame → truncated hyps) and mis-sized the pad masks. Fixed by making pre_encode_t_out causal-aware (mirrors weights.cpp::pre_encode_F_prime).
    2. Unmasked causal subsampling — masked subsampling was gated var_len_masks && !causal_pre_encode, so for causal variants the zero-padded mel tail passed through conv0, whose bias made it relu to a NON-zero constant; the causal convs' right-context (right = stride-1) then pulled that constant into the last valid encoder frame (a large boundary-frame drift vs single-shot). Fixed by enabling masked subsampling for the causal path too (the per-stage valid lengths are now causal-aware, so the same masks are correct). Result: batched var-len encoder output is bit-exact (0.0) to single-shot on the production flash path across arbitrary length mixes; LibriSpeech test-clean batch-8 WER matches batch-1 exactly (2.19%). Also fixes the sibling nemotron-3.5 / nemotron-speech-streaming (0.098 → 0.0).
    3. Manual-path all-masked-query NaN — in a var-len batch a padded query row (past the real length) can have its entire chunked-attention window fall on padded keys; with a -inf key-pad sentinel the manual (TRANSCRIBE_NO_FLASH) softmax computes 0/0 = NaN, which then poisons real frames at the next layer via 0*NaN. Fixed in transcribe-batch-util.cpp::fill_keypad_mask by using a large FINITE negative sentinel (-1e30) instead of -inf: bit-exact with -inf for any row that has a non-masked key (expf underflows to exactly 0), casts to -inf in F16 so flash is unchanged, but makes a fully-masked row softmax to a finite value instead of NaN. Manual diverse-length batched output now matches single-shot to f32 reduction-order noise (~1e-7); flash is exactly 0.0.
  • LayerNorm in conv module (conv_norm_type = "layer_norm"): streaming-stable replacement for BatchNorm. Same tensor names (conv.bn.weight / conv.bn.bias) but the running_mean / running_var tensors are absent and the per-batch statistics are computed online (it really is a LayerNorm over d_model, not a fused affine). Loader reads stt.parakeet.encoder.conv_norm_type as optional with default "batch_norm"; when "layer_norm", the BN-fusion step is skipped and conv_module applies an unfused LayerNorm using the same scale/bias tensors.

Variant Notes

  • tdt-0.6b-v2, tdt-0.6b-v3 (existing baseline): 24 layers, d_model=1024, n_mels=128, use_bias=false, TDT head, pred_n_layers=2. v3 multilingual (25 languages) — capability difference only; same forward graph.
  • tdt-1.1b: 42 layers, d_model=1024, n_mels=80, use_bias=true, TDT head, pred_n_layers=2. First exercise of the 1.1B FastConformer-XL geometry, the 80-mel pre-encode, and the encoder bias path.
  • tdt_ctc-1.1b: 42 layers, d_model=1024, n_mels=80, use_bias=true, TDT head, pred_n_layers=2. Same encoder geometry as tdt-1.1b, but only variant in the family using local attention (att_context_size=[128,128], NeMo's LocalAttRelPositionalEncoding). Drives the pos_emb buffer length (257 instead of 2T-1) and adds a -INF band pad before rel_shift so out-of-window keys drop out of softmax.
  • tdt_ctc-110m: 17 layers, d_model=512, n_mels=80, use_bias=true, TDT head, pred_n_layers=1. First family member with 1-layer predictor and the smaller "Medium" Conformer config (d_model=512, channels=256).
  • rnnt-0.6b: 24 layers, d_model=1024, n_mels=80, use_bias=true, RNNT head, pred_n_layers=2. First exercise of the RNNT decode loop (no durations).
  • rnnt-1.1b: 42 layers, d_model=1024, n_mels=80, use_bias=true, RNNT head, pred_n_layers=2. Sibling of rnnt-0.6b on the 1.1B encoder.
  • unified-en-0.6b: 24 layers, d_model=1024, n_mels=128 (the only new variant that keeps the v2/v3 mel geometry), use_bias=true, RNNT head, pred_n_layers=2. Streaming-shared weights but offline-only in v1.
  • ctc-0.6b: 24 layers, d_model=1024, n_mels=80, use_bias=true, CTC head. First exercise of CTC head loading and CTC greedy decode.
  • ctc-1.1b: 42 layers, d_model=1024, n_mels=80, use_bias=true, CTC head. Sibling of ctc-0.6b on the 1.1B encoder.
  • nemotron-speech-streaming-en-0.6b: 24 layers, d_model=1024, n_mels=128, use_bias=false, RNNT head, pred_n_layers=2. First variant exercising att_context_style="chunked_limited", causal depthwise conv (conv_context_left=8, conv_context_right=0 for kernel=9), conv_norm_type="layer_norm", and fe_normalize="none" (NeMo's normalize="NA" no-op, canonicalised at convert time). Frontend feature stats (per-feature mean/std) are baked into training rather than computed at inference, so the C++ mel frontend emits unnormalised log-mel and the loader accepts normalize="none" alongside "per_feature". Streaming runtime knobs (lookahead, encoder chunk size, decoder context) are deferred to the streaming bring-up — offline-only in v1, same as unified-en-0.6b.
  • multitalker-parakeet-streaming-0.6b-v1: 24 layers, d_model=1024, n_mels=128, use_bias=false, RNNT head, pred_n_layers=2. Same cache-aware streaming FastConformer as nemotron-speech-streaming-en-0.6b (chunked_limited [70,13], causal depthwise conv, layer_norm conv, fe_normalize="none"); first and only variant exercising the always-on layer-0 speaker-kernel FF injection (see the Deviations bullet). Ported in SINGLE-SPEAKER mode; the full multitalker path (external Sortformer diarization, N-instance orchestration, SegLST) is OUT OF SCOPE (see the multitalker integration brief). The +8 speaker-kernel tensors are the only weight-set delta from the English predecessor.
  • nemotron-3.5-asr-streaming-0.6b: 24 layers, d_model=1024, n_mels=128, use_bias=false, RNNT head, pred_n_layers=2. Same FastConformer geometry as the English predecessor; first variant exercising the multilingual prompt-conditioning MLP (EncDecRNNTBPEModelWithPrompt, num_prompts=128, prompt_hidden read from the GGUF; encoder output passes through prompt_kernel before the RNN-T joint sees it). Left context shrinks 70 → 56 frames in the chunked_limited mask (training-time att_context_size=[56,13]); the runtime mask code reads the GGUF KV, so this is a data-only delta. Vocab grows 1024 → 13087 with 39 explicit <lang-XX> SPM tag tokens used by target_lang=auto. Auxiliary CTC head present in the upstream checkpoint but dropped at convert time per family Open-Decision #1.