Rebuild an ASR encoder graph when the cached one is too big - #619
Open
christopherthompson81 wants to merge 1 commit into
Open
christopherthompson81 wants to merge 1 commit into
christopherthompson81 wants to merge 1 commit into
Conversation
ensure_graph() reused any cached graph at least as large as the request, and encode() zero-pads up to that capacity. The masks remove the padding from the result but not from the arithmetic, so the graph runs at its built size on every call -- quadratic in frames for self-attention. The capacity only ratchets upward, because only a larger request rebuilds. One long transcription therefore leaves the longest clip the process has ever seen as a floor under every later request, which is why the reported penalty scales with the previous request rather than being constant, and why unload_models clears it. parakeet_tdt already carried this fix and its measurements. Move the rule into engine::modules::asr_graph_capacity_usable() so nemotron_asr and hviske_asr get it too and the threshold lives in one place. Measured on nemotron_asr q8_0, CPU backend, 8 threads, 33s clip after a 262s clip: 25.7s -> 3.2s, matching the 3.1s fresh-server figure, with RSS after the sequence falling from 4377032 KiB to 1664236 KiB. The long request itself is unchanged. Output is unaffected either way: the same clip transcribes byte-identically on a matched graph and on a 262s oversized one, so this is a cost defect rather than a correctness one. Also bound relative_positional_encoding_cache_, which is keyed by frame count and holds (2 * frames - 1) * hidden floats per entry. Rebuilds are now common, so an unbounded map would grow without bound in a long-lived server. hviske_asr is fixed by inspection rather than measurement; the tolerance is inherited from parakeet_tdt's measurements rather than re-derived. Fixes 0xShug0#617 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QCCx4pMMex72qrNomPh5yL
christopherthompson81
force-pushed
the
asr-graph-capacity
branch
from
September 20, 2026 10:30
95a9e04 to
a878e29
Compare
christopherthompson81
marked this pull request as ready for review
September 20, 2026 10:34
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #617.
On the CPU backend, one long transcription leaves a latency floor behind: every later request, however short, pays roughly the long request's price until the model is unloaded or the process restarts. Reproduced, fixed, and measured below.
What the state actually is
Not a leak, and not a cache in the KV sense — it is the encoder compute graph.
ensure_graph()reuses a cached graph whenever it is at least as large as the request:// src/models/nemotron_asr/encoder.cpp graph_->input_frames >= input_frames &&and
encode()zero-pads the audio up to that capacity. The masks are then filled to the valid length, so the padding is masked out of the result but not out of the arithmetic. The graph executes at its built size on every call, and conformer self-attention is quadratic in frames.So the capacity only ever ratchets upward — a larger request rebuilds, nothing ever lowers it — and the longest clip the process has ever seen becomes a floor under every later one. That is why the reporter measured the penalty scaling with the previous request rather than being a constant, and why
unload_modelsclears it instantly: it drops the graph along with the model.This was already solved once here
parakeet_tdthas the same encoder shape, hit the same bug, and carries the fix with its own measurements:nemotron_asrandhviske_asrnever got it. Rather than add a third copy of the constant, the rule moves intoengine::modules::asr_graph_capacity_usable()inasr_helpers, and all three encoders call it. Parakeet keeps its measured numbers in a comment but no longer owns the threshold.A cached graph is reused only while it is no more than 10% larger than the request. The tolerance keeps a stream of clips whose lengths wobble slightly from rebuilding on every call, while capping the wasted compute at roughly the same fraction.
Measured
audiocpp_server,--backend cpu,threads=8,lazy_load, Nemotron 3.5 ASR Streaming 0.6B q8_0. Clips cut fromassets/resources/speech.wavto the reporter's 33 s and 262 s at 16 kHz mono. Same machine, same flags, before/after differing only in this patch:An 8.3x penalty, against an 8x ratio between the clip lengths — which is what a graph running at the longer capacity should cost. This box is far faster than the reporter's Pi 5 in absolute terms, but it is the same phenomenon at the same ratio. The long request itself is unchanged (31.4 → 29.1 s is run-to-run noise), so the rebuild is not being paid anywhere visible.
Two corroborations worth noting:
Also in this change
relative_positional_encoding_cache_is keyed by frame count and unbounded, and each entry is(2 * frames - 1) * hiddenfloats — tens of MB at conversational lengths. Under the old behaviour few distinct sizes ever occurred, so it stayed small; making rebuilds common would have turned it into a genuine growth path in a long-lived server. It is now bounded innemotron_asrandparakeet_tdt. This trades nothing for the fix, but it is a second-order consequence of it rather than part of the reported bug.tests/unittests/test_asr_graph_capacity.cppcovers exact fit, undersized, inside-tolerance, the #617 case (an 8x oversized graph must rebuild), and zero sizes. It needs no weights, so CI runs it. Verified that it fails without the fix.Scope and limits
hviske_asris fixed by inspection, not by measurement. Same code shape and the same reasoning applies, but I only had Nemotron weights on hand; I have not run a before/after on it.!graph_->streamingguard, and its chunk sizes are fixed.Interaction with
prepare_capacity().prepare()builds a graph sized frommax_input_samples, andencode()then asks for the real frame count. Where those agree — the server's offline path, per request — nothing changes: prepare builds it, encode reuses it, and the measured 3.2 s confirms no build is being paid twice. Where a caller passes a ceiling much larger than the audio, prepare's graph is now discarded on first encode instead of being reused at the wrong size. That is a real cost, but it is the same tradeparakeet_tdtalready makes, and the alternative is the bug this fixes.prepare_capacity()'s other job — failing early when a request exceedsmax_position_embeddings— is unaffected.Other encoders sharing the shape, deliberately not touched here. Checked while scoping:
sense_asr,fun_asr_nano(encoder and adaptor) key their graph cache on exact frame equality, so they never reuse an oversized graph and have no version of this bug. Butindex_tts2/semantic_encoder,ace_step/condition_encoder,qwen3_ttsandvietneu_ttsspeaker encoders,audio8_tts/codecandfish_dac_codec_runtimeall reuse on a barecapacity >= request, and all of them carry attention. They are plausibly the same latent bug on TTS and codec paths. I have not measured any of them and their input sizes are driven by text or tokens rather than audio length, so I would rather flag them than fix them blind in a PR about ASR latency. Happy to open a separate issue if that is useful.Full suite: 36/36.
🤖 Generated with Claude Code
https://claude.ai/code/session_01QCCx4pMMex72qrNomPh5yL