confucius4_r2t2: make streaming chunks incremental instead of O(utterance) - #696
scriptease wants to merge 2 commits into
Conversation
|
@scriptease I'm not sure whether this is only a documentation issue or indicates a more subtle problem. The streaming behavior appears to have changed. Cached audio windows are not always immutable. R2T2 recomputes a global log-mel floor from all accumulated audio. Later, louder audio can therefore change features belonging to earlier windows, but this PR permanently reuses their cached embeddings. I reproduced this with a realistic quiet-then-normal stream:
|
Every LSP chunk re-ran the mel frontend, the whole audio tower, and a full thinker prefill over the accumulated audio, so per-chunk cost grew with the utterance. Once a chunk took longer than its 320 ms of audio the live route fell behind, and the queued chunks were drained at stream end, which showed up as a multi-second finalize at full GPU. The audio tower is block-diagonal per attention window with per-chunk convolutions and positions, so embeddings of completed windows are final: cache them and re-encode only the trailing partial window. The thinker prompt is head, audio tokens, close, stable prefix, so the K/V rows of the head plus cached audio tokens are retained in the decode cache between chunks and only the remainder is prefilled, in blocks sized to that suffix. QwenCausalDecodeRuntime::prefill_embeddings_into_cache gains an optional keep_prefix_steps that retains resident rows (falling back to a full prefill when the cache was rebuilt), with retainable_prefix_steps so callers can skip preparing kept rows. GreedyQwenDecoderRuntime::generate_incremental exposes it with a 512-step capacity bucket. Existing callers are unchanged. Streaming output is identical on the 14 s and 60 s clips; the 60 s clip drops from 87 s to 39 s wall on Metal, and the golden parity and graph reuse tests pass unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Peak normalization and the log-mel floor are computed over all accumulated audio, so a louder segment rewrites the features of earlier windows. The cached window embeddings and the thinker K/V rows of those audio tokens were still reused, which changed intermediate and sometimes final streaming output for quiet-then-normal speech. Keep the log-mel features of the cached windows and compare them bitwise on every chunk. On any difference, drop the encoder cache and keep only the prompt head in the thinker cache, so that chunk runs a full re-encode and prefill. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
c860013 to
6ec45bf
Compare
|
Thanks for testing this, you're right. Cached windows kept stale features once louder audio moved the peak normalization and log-mel floor. I reproduced it with the first 8.5 s of The new commit keeps the log-mel features of cached windows and compares them bitwise on every chunk. On any difference it drops the encoder cache and the thinker rows of those audio tokens, so that chunk recomputes fully. With it, every chunk's committed and decoded text matches baseline exactly on those clips and on the unmodified sample. The cost on normal speech is unchanged: 36 s against 80 s for baseline on a 60 s clip. I also rebased onto main, including 955c872, and thanks for merging that. 🤖 Generated with Claude Code |
Confucius4-R2T2 streaming re-decodes the accumulated audio on every LSP chunk. Each chunk re-ran the mel frontend, the full audio tower and a full thinker prefill over all audio so far, so per-chunk cost grew with the utterance. Once a chunk took longer than its 320 ms of audio, the live route fell behind the socket and drained the queued chunks after the speaker stopped. On longer dictations this shows up as a finalize of 10 s or more at full GPU. Two users of a TypeWhisper integration have hit it.
This PR keeps the reference algorithm and its output, and only removes the recomputation.
Changes
confucius4_r2t2/session.cpp): the tower is block-diagonal pern_window_inferwindow with per-chunk convolutions and positions, so embeddings of completed windows do not change. They are cached, and only the trailing partial window is re-encoded.QwenCausalDecodeRuntime::prefill_embeddings_into_cachegains an optionalkeep_prefix_steps, default 0. It retains the K/V rows of an unchanged prompt prefix and falls back to a full prefill whenever the cache was rebuilt.retainable_prefix_stepsreports what would be kept.GreedyQwenDecoderRuntime::generate_incrementalexposes this with 512-step capacity buckets. Existing callers are unchanged.transcript.text.doneare produced exactly as before.Validation
Apple Silicon, Metal,
r2t2-q8_0.gguf, 320 ms chunks.transcript.text.doneaudiocpp_cli --mode streaming, wallsample_16k.wav, wallqwen_chunked_prefill_testis extended with kept-prefix parity against the reference prefill, including decode after a kept prefix, block-size changes and the fallback paths. It passes.test_confucius4_r2t2_graph_reuse --backend metalandtest_confucius4_r2t2_transcription --backend metalpass.tests/confucius4_r2t2/compare.pyagainstgolden_sample16k.jsongives the same result as before this change, including the same two pre-existing internal chunk differences.Known limits
Credit
The R2T2 port is by @davidxifeng. His branch
r2t2_accindependently takes the same approach, includingmemcmpchecks that fall back whenever reuse would not be bit-identical; this PR now does the same for the audio cache. I asked him first in davidxifeng#1. If you or he prefer his version, I'm happy to close this.🤖 Generated with Claude Code