Add cache-aware streaming and RNN-T head for Parakeet#46122
Add cache-aware streaming and RNN-T head for Parakeet#46122jmayank1511 wants to merge 18 commits into
Conversation
- Encoder gains cache-aware fields (att_context_size, att_context_style, conv_context_size, causal_downsampling, conv_norm_type) so cache-aware Parakeet checkpoints load with correct architecture in offline mode. - Subsampling Conv2D handles causal time padding and pre-pads freq by 1 bin to match NeMo dw_striding (128 mel -> 17 freq dims). - Conformer conv module handles asymmetric/causal padding and LayerNorm. - New ParakeetRNNTConfig, ParakeetRNNTJointNetwork, ParakeetForRNNT reusing ParakeetTDTDecoder. ParakeetRNNTGenerationMixin advances on blank, stays on non-blank with max_symbols_per_step guard. - Conversion script learns the rnnt model_type and propagates the preprocessor.normalize flag (NeMo `NA` -> do_normalize=False). - Auto-class registration: AutoModelForRNNT + parakeet_rnnt mappings. End-to-end test on cache_aware_rnnt.nemo matches NeMo: "What is natural language processing?" on en-US_sample.wav. Co-Authored-By: Claude Opus 4.7 <[email protected]>
NeMo's `dw_striding` causal subsampling pads each strided Conv2d with `(left=kernel-1, right=stride-1)` on BOTH freq and time axes — not the asymmetric (left-only-on-time) scheme initially tried. Replace the custom pre-pad + per-layer half-pad logic with this uniform per-layer pad, matching the CausalConv2D source exactly. Subsampling outputs now match NeMo bit-for-bit (max diff 7e-4 vs prior 1.9e3) and `_get_subsampling_output_length` is updated to compute the correct (kernel-1)+(stride-1) padding total for causal mode. Verified WER on 261-sample eval: HF 21.33% vs NeMo 21.59% (324/1519 errors vs 328/1519) — within numerical noise. Co-Authored-By: Claude Opus 4.7 <[email protected]>
- ParakeetConverter now dispatches on `proto.trainer_spec.model_type`, building a Unigram tokenizer when the SentencePiece model is type 1 and BPE when it is type 2. parakeet-rnnt-1.1b uses Unigram and now converts cleanly. - convert_nemo_to_hf.py ignores the training-only `nb_augmentation_prob` preprocessor flag. - parakeet.md gains an architecture bullet and a usage section for ParakeetForRNNT, plus autodoc stubs for ParakeetRNNTConfig and ParakeetForRNNT. Co-Authored-By: Claude Opus 4.7 <[email protected]>
…e_encoded, buffer) This pulls in the encoder streaming runtime so cache-aware Parakeet checkpoints can run chunk-by-chunk online inference, alongside their existing offline support. Encoder additions: - ParakeetEncoder.forward gains cache_last_channel/_time/_channel_len, att_context_size, use_cache, drop_extra_pre_encoded. - _resolve_att_context_size, get_initial_cache_state(), _build_att_window_mask helpers. - ParakeetEncoderRelPositionalEncoding.forward accepts a context_length override. - ParakeetEncoderConvolutionModule threads cache_last_time and emits an updated cache. - ParakeetEncoderAttention threads cache_last_channel (sliding window K/V) and emits an updated cache; relative-shift is sliced over total_key_length. - ParakeetEncoderBlock returns (hidden, new_cache_channel, new_cache_time). Output classes gain cache fields; new ParakeetCTCModelOutput. ParakeetForCTC.forward accepts the cache args and returns the new output type. ParakeetCacheAwareStreamingBuffer derives chunk sizing, pre-encode cache and STFT lookahead from the model+processor; iterates (inputs, drop_extra_pre_encoded) pairs. convert_encoder_config now derives intermediate_size = d_model * ff_expansion_factor, passes through att_context_probs (training-only) and the cache-aware fields. WER on 261-sample eval, batch=8: - Cache-aware CTC streaming via buffer: 8.95% (136/1519) - Cache-aware RNN-T offline: 21.20% (322/1519) — matches NeMo (21.59%) Co-Authored-By: Claude Opus 4.7 <[email protected]>
Adds a usage section showing chunk-by-chunk online inference with ParakeetCacheAwareStreamingBuffer + autodoc stub. Co-Authored-By: Claude Opus 4.7 <[email protected]>
…nts) Supports NeMo's hybrid_rnnt_ctc_bpe_models_prompt variant where the encoder output is conditioned on a language/task prompt before the joint network. ParakeetRNNTConfig: - num_prompts: int (default 0; >0 enables prompt conditioning) - prompt_dictionary: dict[str, int] mapping language code -> prompt index - __post_init__ validates indices vs num_prompts. ParakeetForRNNT: - When num_prompts>0, builds `prompt_kernel = Sequential(Linear(num_prompts+enc_hidden, 2*enc_hidden), ReLU, Linear(2*enc_hidden, enc_hidden))` matching NeMo's structure. - get_audio_features and forward accept `target_lang: str` or `prompt_id: int`. After the encoder, a one-hot prompt is concatenated with the encoder output and projected back through prompt_kernel. ParakeetRNNTGenerationMixin: - _prepare_model_inputs pops target_lang/prompt_id from kwargs before the per-step forward (which only sees a slice of the encoder output) and forwards them only to get_audio_features. convert_nemo_to_hf: - When NeMo's model_defaults has initialize_prompt_feature=True, the converter reads num_prompts and prompt_dictionary into the HF config. NeMo's prompt_kernel.0/.2 weights now load directly into HF's nn.Sequential of the same shape (no warnings). Smoke test on cache_aware_40_langs_unified_600m... checkpoint: model.generate(input_features, attention_mask, target_lang="en-US") -> "What is natural language processing? <en-US>" Co-Authored-By: Claude Opus 4.7 <[email protected]>
Mirrors NeMo's `_greedy_decode_blank_as_pad_loop_frames` + `conformer_stream_step` for cache-aware Parakeet RN-T checkpoints, including the prompt-conditioned multilingual variant. ParakeetForRNNT additions: - get_initial_streaming_state(batch_size, target_lang, prompt_id): returns the streaming state dict — encoder caches, decoder LSTM state from BEFORE last_token (`dec_h`, `dec_c`), cached decoder output `last_dec_g`, last committed token, and resolved prompt index. - _decoder_pred_step(last_token, dec_h, dec_c) -> (g, new_h, new_c): manually drives the LSTM + projector so the new state can be discarded if the prediction is blank. Mirrors NeMo's `RNNTDecoder.predict`. - streaming_step(inputs, drop_extra_pre_encoded, state, att_context_size=None): runs the encoder cache-aware on one chunk, applies the prompt MLP per-chunk when num_prompts > 0, then iterates encoder frames. Inner loop emits up to max_symbols_per_step non-blanks per frame; commits LSTM state only on non-blank predictions. Returns the chunk's emitted tokens per batch element. Why a manual pred step (not reusing `ParakeetTDTDecoder` cache): The decoder's internal cache updates whenever the input token is non-blank, but RN-T greedy needs the cache to update only when the *prediction* is non-blank. Re-calling the decoder with the same `last_token` after a non-blank emission double-steps the LSTM and corrupts state. Manually driving the LSTM lets us decide AFTER the joint+argmax whether to commit — matching NeMo exactly. Validation on 261-sample English manifest: - Cache-aware Parakeet RN-T (English) streaming: 7.04% (107/1519). Offline: 8.56%. - Cache-aware Parakeet RN-T (multilingual, prompt-conditioned, en-US) streaming: 9.22% (140/1519). NeMo offline-only on same: 9.15%. Streaming and offline both produce reasonable transcriptions; streaming is the correct mode for these cache-aware-trained checkpoints. Co-Authored-By: Claude Opus 4.7 <[email protected]>
ParakeetCacheAwareStreamingBuffer's chunk-0 layout (no pre-encode pad, no drop) mismatches the encoder's training-time input distribution: the encoder is trained with cache_last_channel always non-None, which makes forward_internal always slice drop_extra_pre_encoded encoded frames off the front. With the previous default, chunk 0 fed the conformer drop=0 noisy left-padded frames, producing a corrupted initial decoder state that silenced output for the first several seconds on short utterances. Match NeMo's reference streaming script (pad_and_drop_preencoded=True): on chunk 0 the buffer now uses the steady-state chunk_size, prepends pre_encode_cache_mel zero mel frames (post-normalization), and yields the same drop_extra_pre_encoded as subsequent chunks. The old behavior is preserved behind the constructor flag for parity testing. WER on the 4 manifests that initially regressed (multi_rnnt vs NeMo offline): 051022_English_Bias_Corpus_1.0: 5.66% -> 5.02% (~62% of gap closed) ASR_custom_2_0: 8.11% -> 7.65% (~65% of gap closed) bs_general_noise_00: 18.91% -> 18.19% (~60% of gap closed) bs_general_noise_10: 11.19% -> 10.78% (~51% of gap closed)
…ibutions
Adds the standard joint-authorship copyright line under the existing
HuggingFace one (same Apache 2.0 license), following the pattern already
established by upstream transformers files like megatron_bert:
# Copyright YYYY The HuggingFace Inc. team. All rights reserved.
# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
Applied to the five files with substantive NVIDIA-authored additions in
this PR: modular_parakeet.py (RNN-T head, cache-aware streaming buffer,
prompt conditioning, streaming RN-T greedy decoder), configuration,
generation, convert_nemo_to_hf, and the Unigram branch in
convert_slow_tokenizer. modeling_parakeet.py is auto-generated from the
modular and inherits the header on regeneration.
eustlb
left a comment
There was a problem hiding this comment.
Thanks a lot for raising this PR! 🤗
Looks to me that a lot of the duplicated logic from RNNT and TDT does not have to be duplicated as TDT should be an extension of RNNT
| class ParakeetForRNNTModelTester: | ||
| def __init__( | ||
| self, | ||
| parent, |
There was a problem hiding this comment.
Let's add integration tests
There was a problem hiding this comment.
The model nvidia/nemotron-3.5-asr-streaming-0.6b will be available soon, will add the integration tests soon
| # Streaming: prepend cached K/V from previous chunks and update the sliding window cache. | ||
| new_cache = None | ||
| if cache_last_channel is not None: | ||
| cache_len = cache_last_channel.shape[1] | ||
| cache_shape = (batch_size, cache_len, -1, self.head_dim) | ||
| k_cache = self.k_proj(cache_last_channel).view(cache_shape).transpose(1, 2) | ||
| v_cache = self.v_proj(cache_last_channel).view(cache_shape).transpose(1, 2) | ||
| key_states = torch.cat([k_cache, key_states], dim=2) | ||
| value_states = torch.cat([v_cache, value_states], dim=2) | ||
| new_cache = torch.cat([cache_last_channel, hidden_states], dim=1)[:, -cache_len:] | ||
|
|
||
| total_key_length = key_states.shape[2] | ||
|
|
There was a problem hiding this comment.
We have SlidingWindowCache. It should be initialised like here
then all this should be:
| # Streaming: prepend cached K/V from previous chunks and update the sliding window cache. | |
| new_cache = None | |
| if cache_last_channel is not None: | |
| cache_len = cache_last_channel.shape[1] | |
| cache_shape = (batch_size, cache_len, -1, self.head_dim) | |
| k_cache = self.k_proj(cache_last_channel).view(cache_shape).transpose(1, 2) | |
| v_cache = self.v_proj(cache_last_channel).view(cache_shape).transpose(1, 2) | |
| key_states = torch.cat([k_cache, key_states], dim=2) | |
| value_states = torch.cat([v_cache, value_states], dim=2) | |
| new_cache = torch.cat([cache_last_channel, hidden_states], dim=1)[:, -cache_len:] | |
| total_key_length = key_states.shape[2] | |
| if past_key_values is not None: | |
| key_states, value_states = past_key_values.update(key_states, value_states, self.layer_idx) |
| cache_last_channel: torch.Tensor | None = None, | ||
| **kwargs: Unpack[TransformersKwargs], | ||
| ) -> tuple[torch.Tensor, torch.Tensor]: | ||
| ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]: |
There was a problem hiding this comment.
past_key_values is used throughout the lib with a strong API. Use
| ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]: | |
| def forward( | |
| self, | |
| hidden_states: torch.Tensor, | |
| position_embeddings: torch.Tensor | None, | |
| attention_mask: torch.Tensor | None = None, | |
| past_key_values: Cache | None = None, | |
| **kwargs: Unpack[TransformersKwargs], | |
| ) -> tuple[torch.Tensor, torch.Tensor | None]:: |
| cache_last_channel (`torch.Tensor` of shape `(num_layers, batch, left_ctx, hidden_size)`, *optional*): | ||
| Updated attention cache from the encoder (sliding KV window). Pass to the next chunk call. | ||
| cache_last_time (`torch.Tensor` of shape `(num_layers, batch, hidden_size, conv_left_ctx)`, *optional*): | ||
| Updated convolution cache from the encoder. Pass to the next chunk call. | ||
| cache_last_channel_len (`torch.Tensor` of shape `(batch,)`, *optional*): | ||
| Number of valid frames currently stored in `cache_last_channel`. |
There was a problem hiding this comment.
cf other comments but:
- kv cache should be past_key_values, as throughout the lib
- our convention for the conv cache is padding_cache
| class ParakeetRNNTJointNetwork(nn.Module): | ||
| """Joint network that combines encoder and decoder outputs to predict tokens (no duration head).""" | ||
|
|
||
| def __init__(self, config: ParakeetRNNTConfig): | ||
| super().__init__() | ||
| self.activation = ACT2FN[config.hidden_act] | ||
| self.head = nn.Linear(config.joint_hidden_size, config.vocab_size) | ||
|
|
||
| def forward( | ||
| self, | ||
| decoder_hidden_states: torch.Tensor, | ||
| encoder_hidden_states: torch.Tensor, | ||
| ) -> torch.Tensor: | ||
| joint_output = self.activation(encoder_hidden_states + decoder_hidden_states) | ||
| return self.head(joint_output) |
There was a problem hiding this comment.
no reason to be defined distincly from TDTJointNetwork no?
| if self.num_prompts > 0: | ||
| enc_hidden = config.encoder_config.hidden_size | ||
| self.prompt_kernel = nn.Sequential( | ||
| nn.Linear(self.num_prompts + enc_hidden, 2 * enc_hidden), | ||
| nn.ReLU(), | ||
| nn.Linear(2 * enc_hidden, enc_hidden), | ||
| ) |
There was a problem hiding this comment.
what released models use this feature? Looks like it can be removed no?
There was a problem hiding this comment.
nvidia/nemotron-3.5-asr-streaming-0.6b will use this feature. This will be available in hugginface in next few days.
| target_lang: str | None = None, | ||
| prompt_id: int | None = None, | ||
| **kwargs: Unpack[TransformersKwargs], | ||
| ) -> ParakeetRNNTOutput: |
There was a problem hiding this comment.
here the signature should be (almost) the same as for voxtral realtime, specifically with padding_cache and past_key_values
…gual docs at nvidia/nemotron-3.5-asr-streaming-0.6b
|
[For maintainers] Suggested jobs to run (before merge) run-slow: auto, parakeet |
|
This was added in #46331, closing here |
What does this PR do?
Extends Parakeet TDT (#44171) with cache-aware streaming + an RNN-T
head. The cache-aware variant is shipped as Nemotron Speech
Streaming (
nvidia/nemotron-speech-streaming-en-0.6b).Adds
ParakeetForRNNT— RNN-T head (LSTM prediction net + jointnetwork) on the shared encoder. Supports regular and cache-aware
checkpoints. Offline via
generate(...).ParakeetCacheAwareStreamingBuffer+streaming_step()+get_initial_streaming_state()— chunk-by-chunk greedy RNN-Tdecoding with encoder KV cache, decoder LSTM state, and last-token
threaded across chunks. Mirrors NeMo's
_greedy_decode_blank_as_pad_loop_frames.encoder, resolved via
target_lang.CausalConv2Dpadding alignment in subsampling.ParakeetConverter.ParakeetForRNNT.Code Agent Policy
Before submitting
guideline](https://github.com/huggingface/transformers/blob/main/CON
TRIBUTING.md#create-a-pull-request)?
changes?
Who can review?
@eustlb @ebezzam @vasqu