Skip to content

ASR: real native streaming for Nemotron 3.5 + Granite Speech; granite encoder graph cache - #582

Draft
drzsdrtfg wants to merge 14 commits into
0xShug0:mainfrom
drzsdrtfg:fix/asr-native-streaming
Draft

drzsdrtfg wants to merge 14 commits into
0xShug0:mainfrom
drzsdrtfg:fix/asr-native-streaming

Conversation

@drzsdrtfg

@drzsdrtfg drzsdrtfg commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

ASR: real native streaming for Nemotron 3.5 + Granite Speech (and a granite graph cache)

Summary

Two ASR families shipped a streaming mode that never streamed. Both process_audio_chunk
implementations only buffered incoming audio and returned an empty event — all decoding
happened in finalize() over the whole buffer. Clients of /v1/audio/transcriptions/live
therefore saw zero partials while the user was speaking (nemotron emitted its deltas as
one burst after the stream closed; granite emitted nothing at all until the final result),
which defeats the point of the live endpoint.

This PR makes both sessions stream the way the model publishers intend, reusing machinery
that was already in the tree:

nemotron_asr — incremental chunk pipeline

The decoder was already fully incremental inside decode_streaming() (stateful greedy
TDT loop with on_text_delta per token), and the encoder already had cache-aware
encode_stream_chunk() + NemotronEncoderStreamState. The session simply never used them
per chunk. It now:

  • feeds each native chunk (subsampling × (lookahead + 1) mel frames, the publisher's
    geometry) through encode_stream_chunk() with a persistent NemotronEncoderStreamState,
  • decodes it incrementally and emits transcript.text.delta partials per chunk,
  • zero-pads the final partial window at finalize() so the tail is encoded instead of
    dropped (previously up to win_length + hop·(lookahead+1) samples of trailing audio
    never reached the encoder),
  • exposes the decoder state machine as a public incremental API
    (begin_stream_decode() / decode_stream_chunk() / finish_stream_decode()), with
    decode_streaming() reimplemented as a thin wrapper over it — pull-style callers keep
    their exact behavior.

granite5asr — chunked CTC streaming (the publisher's recipe)

Granite5ASRStreamingSession was a stub: buffer + transcribe_audio() at close. It now
implements chunked TurboCTC inference: every center chunk
(granite5asr.center_chunk_sec, default 1 s) is re-encoded together with its left context
(granite5asr.left_context_sec, default 2 s) and CTC-greedy decoded with a continuous
collapse across window boundaries (running previous-token state), streaming a partial per
chunk. The window→CTC-frame mapping accounts for the full token stride
(hop_length × stack_factor × 2^|subsample_layers| — the encoder subsamples twice), which
was the subtle part: mapping frames at the mel rate silently decodes nothing from window 2
onwards. Both options are declared in model_specs/granite5asr.json.

granite5asr CPU optimization: encoder graph cache + cache-aware streaming

transcribe_features() rebuilt the entire 16-block conformer graph (context + gallocr +
graph) on every call. Chunked streaming re-decodes the same window shape every chunk and
repeated offline turns cluster around a few lengths, so graphs are now cached per input
shape (small LRU, 6 entries).

More importantly, the streaming session no longer re-encodes a left-context window per
chunk. Granite's attention is block-local (context_size 128-frame blocks attend only
within themselves — the rel-pos slice is block-local too), so a chunk needs no attention
cache and no left context: each center chunk is encoded exactly once, prepended only with
a 0.3 s waveform carry covering the two stride-2 subsample blocks' convolution reach, the
avg-pool pair alignment, and the frontend's reflect padding; tokens fully inside the carry
are skipped (one CTC token = hop x stack x 2^|subsample_layers| = 1280 samples). Every
frame is decoded exactly once, which also eliminates the word-duplication artifacts
windowed re-encoding showed at small left contexts. left_context_sec is removed;
center_chunk_sec stays as the partial cadence (center 2 s trades ~20-25% more
end-of-turn latency for ~30% less total compute).

Measured behavior (v0.8.0 → this branch, same machine)

6.4 s English sample, /v1/audio/transcriptions/live, paced realtime upload, SSE timing
client-side; "end-of-turn" = last audio byte → transcript.text.done:

model threads partials while speaking (v0.8.0 → PR) end-of-turn → final text (v0.8.0 → PR)
nemotron 0.6B 1 CPU none (burst after end) → streamed per chunk ~3934 ms → ~617 ms
nemotron 0.6B 2 CPU none (burst after end) → streamed per chunk ~1845 ms → ~312 ms
nemotron 0.6B 3 CPU none (burst after end) → streamed per chunk ~1423 ms → ~253 ms
nemotron 0.6B 6 CPU none (burst after end) → streamed per chunk ~1170 ms → ~201 ms
nemotron 0.6B Vulkan none (burst after end) → streamed per chunk ~567 ms → ~91 ms
granite 470M 1 CPU none at all → streamed per chunk ~1841 ms → ~1007 ms
granite 470M 2 CPU none at all → streamed per chunk ~923 ms → ~347 ms
granite 470M 3 CPU none at all → streamed per chunk ~671 ms → ~261 ms
granite 470M 6 CPU none at all → streamed per chunk ~465 ms → ~146 ms
granite 470M Vulkan none at all → streamed per chunk ~148 ms → ~72 ms

Offline transcripts are unchanged (checked on the reference samples), and offline RTFs are
unchanged (the granite graph cache only removes fixed build overhead; on repeated
same-shape requests the encoder no longer rebuilds the graph at all).

CPU profiling at low thread counts (1-3 threads)

Both models are compute-bound with no significant waste left in the graph implementations:

  • nemotron offline @ 1 thread: encoder compute 2218 ms of 2391 ms total (93%), decoder 167 ms, frontend 5 ms. Thread scaling is near-ideal (1T→2T = 2.0x).
  • granite offline @ 1 thread: encoder compute 1691 ms of 1710 ms (99%).
  • granite streaming: windowed re-encode amplifies compute ~2.9x vs offline (3 s window per 1 s of audio). The granite5asr.center_chunk_sec / left_context_sec knobs trade this off: center 2 s cuts total streaming encoder compute ~30% (3390 vs 4807 ms @ 1T, 4 windows vs 7) at equal accuracy, but grows the final flush window and adds ~20-25% end-of-turn latency, so the 1 s default stays for latency; left contexts below 2 s duplicate words across window boundaries. These options are currently only reachable through the CLI (--session-option); the server does not expose config/request session options — worth addressing upstream.

Remaining, larger follow-ups (not in this PR):

  1. Lookahead hold for granite streaming: each frame is now decoded exactly once, but frames near a chunk boundary lack right context and can drop a boundary word (observed: one word on the reference sample). Holding the last tokens of a chunk until the next chunk confirms them (small right lookahead) would recover those.
  2. Sub-q8_0 encoder kernels measured: no gain on AVX2 without VNNI. Implemented and benchmarked (weight re-quantization to q4_0/q4_k/q6_k via the existing storage pipeline): nemotron offline @ 1 thread q8_0 2623 ms vs q4_k 3376 ms (slower) vs q4_0 2825 ms; granite streaming unchanged within noise; transcripts unchanged even at q4_0. Comet Lake has no int8 dot-product instructions, so q8_0 GEMM is already the efficient path. The weight-type plumbing is kept (opt-in) for VNNI-capable CPUs.
  3. Server-side session-option exposure (config or /live query) so the tunables are reachable outside the CLI.
  4. VAD-gated offline for nemotron (granite already supports audio_chunk_mode=vad; measured neutral on dense speech, helps pause-heavy real turns).
  5. Lookahead-hold for granite boundary words — attempted and analyzed: decoding each window with 1-2 future tokens and holding their tokens recovers the dropped boundary word but re-decodes the previous word at shifted CTC alignments (the same audio region aligns differently under a different attention window), producing duplicated words at every boundary. Reverted; the windowed left-context design (whose boundary drops are rarer) is the better trade-off until a CTC re-scoring pass exists.

Known issue found while testing: short-turn empty text (pre-existing, upstream)

While validating the plugin integration end-to-end we found that the
streaming decode under-contexts each window's trailing lookahead
frames: a 0.85 s "Hello" decodes only blank tokens over /live (the word
emerges only when a window tail finally provides future context), while
the offline full-context decode of the same audio is correct. This
reproduces on the SHIPPED v0.8.0 build as well (both 0.85 s and 1.3 s
clips finalize empty in streaming mode), so it predates this PR - the
PR's per-chunk streaming makes the mid-stream blanks visible as missing
partials instead of a single empty final.

The proper fix is center-only emission with overlapping windows (each
window = [center | lookahead] encoded frames, advance by center only,
the lookahead region re-encoded by the next window). We prototyped it;
it requires the encoder graph builder to support overlapping window
advances (the current attention/prefix cache bookkeeping assumes
contiguous non-overlapping windows - a time_mask dim mismatch arises
for the new shapes), so it is out of scope here and filed as follow-up
work. Downstream, the integrating application should treat the offline
POST as the authoritative final transcript for each turn and use /live
only for live partials.

Notes

  • decode_streaming() keeps its old signature and semantics; the new incremental API is
    additive.
  • The nemotron finalize path keeps the old "shorter than the first required chunk" error
    for streams that end before the first native chunk.
  • The granite continuous CTC collapse can (rarely) revise an earlier word when a later
    window disagrees; in that case the current full transcript is re-emitted as the delta
    (documented in the code). The final transcript is always the authoritative text.

Requesting review — happy to adjust the granite defaults or fold the incremental decoder
API differently if you prefer a different split.

…aph cache

nemotron_asr: process_audio_chunk only buffered samples and the whole
stream was decoded in finalize(), so 'streaming' produced a burst of
deltas only after the audio ended. The decoder already was incremental
inside decode_streaming(); this exposes that state machine as
begin_stream_decode / decode_stream_chunk / finish_stream_decode and
rewires the session to encode + decode each native chunk as it lands
(cache-aware encode_stream_chunk, publisher chunk geometry), with a
zero-padded final window so the tail is not dropped.

granite5asr: the streaming session was a stub (accumulate + offline
decode at close). It now runs the publisher's chunked-CTC recipe: every
center chunk (granite5asr.center_chunk_sec, default 1 s) is re-encoded
together with its left context (granite5asr.left_context_sec, default
2 s) and CTC-greedy decoded continuously, streaming partials per chunk.
The window-to-CTC-frame mapping accounts for the encoder's
subsample_layers stride (frames advance every hop * stack * 2^|subsample|
samples). Session options declared in the model spec.

granite5asr encoder: transcribe_features rebuilt the whole 16-block
conformer graph per call; graphs are now cached per input shape (small
LRU), which removes the per-chunk/per-request build latency.

decode_streaming() is now a thin wrapper over the incremental API, so
pull-style callers keep their behavior while live callers (server /live)
get true per-chunk partials.

Verified on the Windows CPU + Vulkan builds: nemotron and granite now
stream word-by-word partials through /v1/audio/transcriptions/live
(previously zero partials), end-of-turn latency drops accordingly, and
offline transcripts are unchanged.
…vance

decode_stream_chunk() iterated the chunk's encoded frames with a for
loop, moving to the next frame after EVERY token. The TDT loop may emit
up to max_symbols_per_step tokens on the same frame (advancing only on
blank or the symbol cap, exactly like decode()/decode_streaming());
advancing per token dropped those extra symbols, which truncated words
('midnight' -> 'night'). Restructured to a while loop that advances the
frame pointer only on blank/force-advance.
Measured at 1 CPU thread on a 6.4 s sample: center 2 s cuts total
streaming encoder compute ~30% (3390 vs 4807 ms, 4 windows vs 7) at
equal transcript quality, but grows the final flush window and adds
~20-25% end-of-turn latency (1256 vs 1011 ms) plus coarser partials.
Left contexts below 2 s duplicate words across window boundaries
('midnight midnight') because the continuous CTC collapse cannot dedupe
re-decoded boundary tokens across different windows. Defaults stay at
center 1 s / left 2 s for latency; document the compute knob.
@drzsdrtfg

Copy link
Copy Markdown
Contributor Author

work in progress do not review yet.

Granite's attention is block-local: each context_size (128) frame block
attends only within itself, so streaming chunks never needed a left-
context window at all — that was a 2.9x compute amplification for
nothing. The streaming session now encodes each center chunk exactly
once, prepended only with a short (0.3 s) waveform carry that covers the
two stride-2 subsample blocks' convolution reach, the avg-pool pair
alignment, and the frontend's reflect padding; tokens fully inside the
carry region are skipped (each CTC token spans hop * stack *
2^|subsample_layers| = 1280 samples). Every frame is decoded exactly
once, which also removes the word-duplication artifacts the windowed
approach showed at small left contexts.

left_context_sec is gone (the carry replaces it); center_chunk_sec
remains as the partial cadence. End-of-turn on the reference sample:
6 CPU threads 200 -> 146 ms, Vulkan 92 -> 72 ms; 1-3 threads unchanged
to slightly better (1007/347/261 ms), all with identical partial
cadence. One boundary word at chunk edges can still be dropped
(truncated right context — inherent to streaming CTC without lookahead);
a lookahead-hold refinement is a possible follow-up.
…ain)

matmul_weight_type now also accepts q4_0/q4_1/q5_0/q5_1/q4_k/q5_k/q6_k
(the store re-quantizes from the source weights via dequant ->
ggml_quantize_chunk). Measured on a Comet Lake i5-10400 (no VNNI):
q4_k is 29% SLOWER than q8_0 offline at 1 thread, q4_0 8% slower, and
granite streaming is unchanged within noise - the q8_0 GEMM is already
the efficient path without int8 dot-product instructions. Accuracy was
unchanged on the reference samples even at q4_0. Plumbing kept for
VNNI-capable CPUs; documented as a negative result.
The streaming chunk producer only encoded a chunk when the remaining
waveform still filled a full 32-mel-frame window; the tail (up to ~330 ms)
was silently dropped. Turns whose speech ended inside that tail lost the
last word(s) - short utterances like 'Hello' came back empty, while long
phrases only lost trailing silence and looked fine. Below one full first
window (252 ms) the request even failed outright.

Zero-pad the tail to the full window (the reference processor right-pads
the final chunk) and encode one last chunk at end of stream; pad the
waveform up to the first required chunk instead of throwing. Also trace
decoded token ids at TRACE level for empty-transcript diagnosis.
… lookahead-0 crashes

- /live gains lookahead_tokens (model right-context; chunk duration =
  (lookahead+1) x 80 ms on nemotron) and stream_chunk_ms (ingest batching
  bound) query params. The session already honored lookahead_tokens as a
  request option; the /live edge just never exposed it, and the ingest
  layer batched a full policy second per read regardless of the model
  window. lookahead < 1 is rejected: a single-frame first window decodes
  garbage (the reference chunking is not validated for it here).
- Fix the streaming window builder for windows that precede the signal
  start (lookahead 0: the second window begins at hop - n_fft/2 = -96):
  zero-pad the left context like the first chunk's center pad instead of
  indexing before begin() (heap corruption / ggml abort).
- Streaming encoder graphs get their own smaller default arena (256 MB):
  the prefix ladder builds one graph variant per prefix step and the
  offline graph's 1 GB default times ~56 variants exhausted commit; the
  warm-up ladder is also capped at 16 variants, the rest build lazily.
- Cache hand-off between graph variants now checks layouts by name and
  hands attention caches over through the host staging path.
- TRACE: decoder token ids and encoder stream stage markers for
  empty-transcript / mid-stream-crash diagnosis.
…; reject lookahead 0

- Accepted sockets for the live endpoint disable Nagle: small SSE events
  waited for delayed-ACKs while the client was still uploading, so
  partials generated during speech reached the client only at end of turn
  (measured first-partial-on-wire for a 1.9 s phrase: 1861 -> 526 ms at
  1 CPU thread).
- lookahead_tokens=1 (160 ms chunks) decodes correctly (validated against
  three phrases at two ingest cadences) although the GGUF's embedded
  supported list omits it; the model card declares 80-1120 ms chunks as
  runtime knobs. EOT->final on a 1.9 s phrase: 423 -> 277 ms (1 thread),
  130 -> 100 ms (6 threads), 84 -> 73 ms (Vulkan).
- lookahead_tokens=0 (80 ms chunks) stays rejected: measured wrong text
  (empty / wrong-language output) and no speed benefit - the single-frame
  first window is not covered by the reference chunking validation.
@drzsdrtfg

Copy link
Copy Markdown
Contributor Author

Follow-up fixes pushed to this branch (2026-09-18)

All measured on the same machine (6C/12T), real-time-paced streaming upload, median of 5 turns.

  • Whole-buffer streaming path now flushes the final partial window (84b300a): the chunk producer dropped the tail after the last full window, so turns whose speech ended inside that tail lost the last word(s) — short utterances ("Hello") came back empty. The tail is zero-padded like the reference processor's final chunk. Also fixes the below-first-chunk request failing outright, and adds nemotron_asr.decoder.token_ids TRACE output for empty-transcript diagnosis.
  • Configurable streaming latency knobs on /v1/audio/transcriptions/live (02b9280): lookahead_tokens (model right-context; chunk duration = (lookahead+1) x 80 ms on nemotron) and stream_chunk_ms (ingest batching bound). The session policy batched a full second per read, which delayed every partial by that much regardless of the model window.
  • Lookahead-0 crash fixes (part of 02b9280): the second streaming window at lookahead 0 starts at hop - n_fft/2 = -96 samples (before the signal) — zero-pad the left context instead of indexing before begin() (heap corruption); streaming encoder graphs get a smaller default arena and a bounded warm-up ladder (~56 prefix variants at lookahead 0 exhausted commit); cross-graph cache hand-off is layout-checked by name and moved to host staging.
  • lookahead_tokens=1 (160 ms chunks) accepted, 0 rejected (88bd897): 160 ms decodes correctly (validated on three phrases at two ingest cadences) and cuts end-of-turn latency on a 1.9 s phrase from 423 -> 277 ms (1 thread), 130 -> 100 ms (6 threads). Lookahead 0 measured wrong text and no speed benefit, so it is rejected with a clear 400.
  • TCP_NODELAY on the live endpoint's accepted sockets (88bd897): small SSE events were Nagle/delayed-ACK-stalled while the client was still uploading — first partial on the wire for a 1.9 s phrase went from 1861 ms to 526 ms (1 thread) / 356 ms (Vulkan).

Ingest cadence below the model window size changes nothing (window completion drives the decode schedule), so the plugin-recommended config stays stream_chunk_ms=160 at the default 320 ms window.

The incremental streaming schedule emits every encoded frame the moment
its chunk is done, so the frames at each chunk's end are encoded without
their lookahead right-context. The reference avoids this by re-encoding
the right context every step (~1.8-4x encoder compute, not viable on
CPU). Long turns tolerate the approximation (most frames sit mid-chunk),
but short utterances put most of their speech frames at chunk ends: a
0.55 s 'Okay' turn decoded '' on the live path while the offline
endpoint decoded the identical audio to 'Okay.' - and a 0.73 s 'Hello'
came back 'Hel' on lookahead 1.

finalize() now re-decodes turns of up to 1 s (and any turn whose
incremental final came back blank) through the offline encoder - one
full-context pass over the buffered turn. The cost is bounded by the
turn length (measured ~310-370 ms EOT at 2 threads for sub-second
turns), long turns keep the streaming fast path, and live partials are
unaffected. Note run_streaming_audio (the buffered streaming path)
shares the chunked graph and does NOT fix this - the offline encoder is
required.
@drzsdrtfg

Copy link
Copy Markdown
Contributor Author

Follow-up: short utterances came back empty on the live path — root cause and fix (24b7b7e)

Diagnosis (measured 2026-09-18, 6C/12T CPU): the incremental schedule emits every encoded frame the moment its 4-frame chunk is done, so chunk-end frames are encoded without their lookahead right-context. The offline endpoint decodes the identical audio correctly while the live path returns blank:

turn (preroll 0.2 s + speech + tail) live (la3) offline
0.55 s "Okay" '' Okay.
0.53 s "Hello" '' Hello
0.47 s "Yes" '' Yes.

Long turns are unaffected (most frames sit mid-chunk). Reducing the chunk to 160 ms (lookahead_tokens=1) narrows the loss but does not close it ('Hel').

Fix: finalize() re-decodes turns of up to 1 s — and any turn whose incremental final is blank — through the offline encoder (one full-context pass over the buffered turn; note run_streaming_audio does not help, it shares the chunked graph). Cost is bounded by the turn length: 310-370 ms EOT at 2 threads for sub-second turns; long turns keep the streaming fast path (1.9 s phrase: 99-131 ms, 5 live partials). The full 18-case short-turn matrix passes with the fix (the only two failures are zero-preroll turns, which fail offline too).

…v sizing, ladder budget; gate stays closed

la0 (80 ms chunks) is the only schedule that is exact AND realtime on one
CPU thread (emit-all has no chunk-end truncation when lookahead is 0), so
this lays the groundwork:

- The first streaming window now covers at least one full encoded frame
  (subsampling_factor mel frames): at la0 the previous single-mel-frame
  first window made the subsampling convs compute encoded frame 0 from
  zero-padded cache input.
- The first chunk's mask sizes are derived from the actual conv prepend
  (zero frame + cache = 2 frames of left context, no right pad). The
  centered formula assumed a right pad that does not exist and overcounted
  for even mel counts (mask 5 vs conv 4 at la0's 8-mel window); odd counts
  (la3's 25-mel window) are unaffected.
- prepare_streaming_capacity mirrors the session's first-window size so
  the first chunk hits the prebuilt graph.
- Streaming graph arena defaults to 96 MB and the prefix-ladder warm-up is
  bounded by a 6 GB commit budget instead of a variant count.
- warm bench: the streaming flow now calls start_stream (the missing call
  left the window geometry zero-initialized); MSVC setenv fix.

The /live endpoint still rejects lookahead_tokens < 1: with the geometry
fixed, the incremental transducer under-emits at 1-frame chunks (measured
'Hel'/'' finals at 1-2 threads) - the per-chunk emission needs a dedicated
investigation before la0 can ship.
…ssion open

la0 status after the deep-dive:
- The encoder now produces varying, input-tracking outputs at 1-frame
  chunks (the la0 groundwork geometry + the key-only 1-frame prefix KV
  trim fixed the constant-garbage outputs).
- The offline encoder at la0 decodes all phrases correctly.
- The incremental transducer still under-emits at 1-frame chunks (live
  partials blank; finals rely on the blank-final offline re-decode, which
  succeeds for turns with >=150 ms of trailing audio — the mic test
  client carries 250 ms).
- Evidence trail for the remaining bug: mel inputs identical to offline,
  KV cache copies bit-perfect, attention bias allows all keys, yet the
  encoder outputs degrade with the copied prefix length (corruption grows
  with prefix frames; a single-key prefix approximates offline closely).
  Suspect: the attention prefix mechanism (positions/KV reuse) at
  1-frame chunks.
- /live accepts lookahead_tokens=0; the blank-final offline re-decode
  covers the incremental gap. The client default remains lookahead 3.
…n bug

Root cause of the la0 under-emission, found by frame-level A/B tracing
(mel inputs, encoder outputs, KV cache contents, incremental vs offline):

The graph allocator aliased the streaming graph's output caches: in the
first chunk's graph, next_attention_key_cache, next_attention_value_cache
and next_subsampling_cache0 shared ONE data pointer (verified: identical
pointers, and the 'KV' read-back returned the subsampling cache's padded
mel content). The cache hand-off copied bit-perfect copies of clobbered
memory, so every chunk after the first encoded from corrupted state —
outputs went flat (~0.19 constant) and the transducer emitted blanks.

Fix: chain a zero-weighted scalar dependency (ggml_sum + ggml_scale(0) +
ggml_add1) from every next-cache tensor into the graph's output node.
The allocator must keep every cache alive until the graph ends; the
buffers stay distinct. The scales are exactly zero, so the encoder
output is mathematically unchanged.

Result at lookahead 0 (80 ms chunks, the exact emit-all schedule):
- The incremental transducer now emits LIVE (deltas stream during
  speech; measured tok 'Hel'+'lo' at frame 7).
- End-of-turn -> final latency drops to 89-321 ms on 1-2 CPU threads
  (was 600-1200 ms buffered).
- 5/7 phrases fully correct at 1-2 threads; the two remaining failures
  ('Hel', 'Tell me a sto') lose the final syllable carried by the
  zero-padded flush window — the next investigation target.

Also: the decoder per-frame debug traces and the bisect env switches are
removed; the warm bench keeps its start_stream fix.
…t transformers reference

- Session + ladder use a 4-encoded-frame chunk floor at lookahead 0
  (320ms native chunks) so CPU keeps up with realtime; la3 keeps its
  existing geometry (max(la+1,4)*8 mel matches 8*(la+1) for la>=3).
- Verified against the official reference (transformers
  nemotron3_5_asr, NVIDIA-contributed): mel windows bit-identical,
  first-chunk encoder output exact, transcripts match on the short-
  phrase set; /live flip-flopping is gone (25/25 deterministic).
- Opt-in NEMOTRON_DUMP_CHUNKS per-chunk mel/encoder dump for
  reference diffs.
- Remove temporary [INC]/[OFF] decoder traces and unused includes.
@drzsdrtfg

Copy link
Copy Markdown
Contributor Author

Lookahead-0 verified against the official reference implementation

Validated this branch's nemotron streaming against the NVIDIA-contributed reference in transformers (nemotron3_5_asr, the checkpoint's own model class, which carries both the language-prompt projector and the streaming/cache machinery). Harness: chunked encoder driven with the reference chunk contract (first chunk 1+8*la mel, subsequent 8*(la+1) mel, final chunk zero-padded), f32 weights, greedy RNNT decode.

Findings:

  • Mel windows produced by this implementation are identical to the reference (within float tolerance) for lookahead 0 at both 80 ms and 320 ms native chunks.
  • First-chunk encoder output matches the f32 reference at cosine 0.9997 (after aligning the language prompt id, en-US = 0).
  • Remaining per-frame divergences are isolated (a minority of frames in prefixed chunks, cos 0.6-0.9), plausibly q8_0 quantization + accumulation-order amplified by near-tied attention; transcripts still match on hello/okay/hi/capital/count.
  • With the gallocr aliasing fix + the 4-frame chunk floor, /v1/audio/transcriptions/live at lookahead_tokens=0 is deterministic and correct: 25/25 across 5 phrases x 5 repeats (previously flip-flopping).

Lookahead 0 native 320 ms vs lookahead 3 (same binary, end-of-turn -> final transcript, realtime-paced upload):

config hello okay hi capital count story
la3 cpu x1 208 201 612 432 220 428
la0 cpu x1 421 403 614 206 428 216
la3 cpu x2 108 96 306 218 102 234
la0 cpu x2 210 220 323 100 224 106
la3 cpu x3 75 73 223 183 84 171
la0 cpu x3 156 161 236 102 157 91
la3 vulkan 54 66 138 119 58 120
la0 vulkan 103 88 125 63 93 54

(All ms, median of 3.) Known edge: a quiet trailing syllable can be dropped at la0 (story -> "Tell me a sto") that la3 still catches; the f32 reference decodes the same audio fully.

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