Skip to content

Speed up voxtral_realtime streaming and stop EOS from ending a stream#116

Merged
0xShug0 merged 3 commits into
0xShug0:mainfrom
patrickvonplaten:pvp/voxtral-streaming-perf
Jul 26, 2026
Merged

Speed up voxtral_realtime streaming and stop EOS from ending a stream#116
0xShug0 merged 3 commits into
0xShug0:mainfrom
patrickvonplaten:pvp/voxtral-streaming-perf

Conversation

@patrickvonplaten

Copy link
Copy Markdown
Contributor

Extracted from #112, which mixes two unrelated things: live stdin/PCM capture (CLI plumbing) and streaming throughput (engine). This is only the second. It touches src/models/voxtral_realtime/ and include/engine/models/voxtral_realtime/ plus docs and one new test — no app/ changes, no new CLI flags. #112 keeps the stdin work and will need a rebase if this lands.

Verification here is offline streaming transcription: --mode streaming --audio <file>. No live source involved.

Throughput

A streaming step always advances 80 ms of audio, so it has to cost under 80 ms to track a realtime source. Measured on an M3 Air (Metal, q8_0, assets/resources/sample_16k.wav), every configuration producing the same transcript:

Config ms/step Realtime Encoder Decoder
upstream 10cb78de 133.2 1.67x ✗
this branch, default 78.2 0.98x 30.2 48.2
stream_batch_tokens=4 60.4 0.76x 12.9 47.4

Sustained numbers are worse, and that is the number to budget against. Over speech.wav (418 s, 5219 steps) the default measured 87.9 ms/step and stream_batch_tokens=4 measured 73.9. A short clip run immediately after the long one still measured 87.9 rather than dropping back to 78.2, so this is the fanless machine staying warm, not something that resets between sessions. Practically: the default is borderline at realtime on this hardware and stream_batch_tokens=4 is what clears it.

Offline mode benefits too, since the attention change applies to its decode path: 52.2 → 45.9 ms/token, transcript identical at 186 tokens.

What changed

EOS no longer ends a stream. process_one_stream_chunk set stream_reached_eos_, the consuming loop tested it, and nothing outside reset() ever cleared it — the first EOS halted transcription permanently, and once halted the buffer-trim block became unreachable while process_audio_chunk kept appending. The vLLM reference gives EOS no special treatment: the sampled token feeds back unconditionally and only the audio queue's sentinel ends the stream. tokenizer_.is_stream_text_token already returns false for special ids, so no text is emitted for them and the transcript is unchanged. The offline generate() EOS stop is retained. Removing utterance resets is what turns the fixed-size decode cache below from a nicety into a requirement.

This is a latent bug on the files I have — EOS never fires on them — so the justification is matching the reference, not a reproduced failure.

One event per partial, without coarsening them. The session invoked its own stream_event_sink_ and returned the event that app/streaming/streaming.cpp forwards, so every partial was delivered twice, over the CLI and the server's SSE transcript.text.delta alike. Worth flagging for reviewers: simply dropping the session-side sink call — the obvious fix, and the one #112 currently has — is not equivalent. Several chunks can be consumed per process_audio_chunk call, so the sink is the route that yields a partial per step while the return value yields one per caller chunk; dropping the sink silently coarsened partials from 33 distinct to 25 on a 14 s clip. The returned event now drops what the sink already delivered instead, which is the nemotron_asr pattern. Measured: 59 raw events → 34, with the distinct sequence identical to the baseline's.

Strided cache views instead of a materialized transpose (text_decoder.cpp, audio_encoder.cpp) — the single largest win. The old code transposed and ggml_conted the entire K and V cache per layer per token. FlashGroupedViewKV and FlashPreserveViews already exist in the framework and are used by fish_audio, higgs_audio_tts, vibevoice_asr, qwen3_tts and outetts, so this is a lowering-enum change plus dropping four ggml_cont calls.

F16 encoder KV cache (audio_encoder.cpp). The sliding-window cache is never exported, so there is no TransformerKVState boundary to convert — just the tensor type and a relaxed set_audio_kv_rows validation, since ggml_set_rows converts f32 rows on write.

4-bit weight types (text_decoder.cpp, audio_encoder.cpp). normalize_weight_storage widened to accept q4_0/q4_k/q5_k/q6_k. BackendWeightStore already falls back to F32 for rows that are not whole blocks (the AdaRMS 32-wide projection, the 3-wide conv kernels), so no per-tensor special-casing. Requesting one of these requantizes at load (~3 min from the shipped q8_0), so the docs point at the published q4_k GGUF package instead. Encoder stays native: it is not bandwidth-bound in streaming mode.

Fixed-size streaming decode cache (text_decoder.cpp, session.cpp). voxtral_realtime.stream_decode_cache_steps, default 1024 (~82 s). Pinning both sliding_window and min_cache_steps to the cap makes target_cache_steps constant, so the graph is built once at begin_stream. The 418 s run does exactly one decode-graph build across 5219 steps and stays coherent across five ring wraparounds.

Encoder batching (frontend.cpp, session.cpp, text_decoder.cpp). voxtral_realtime.stream_batch_tokens=N, default 1. One encoder forward covers N tokens (8N feature frames → 4N encoder steps → N audio tokens, which falls out of the existing conv arithmetic); the decoder still steps once per token, each consuming its own row via a new explicit audio_row. Opt-in because it delays each partial by up to N*80 ms.

Conv-cache update folded into the encoder graph (audio_encoder.cpp), dropping a per-step tensor_copy_async pair and a GPU sync. Worth ~0.2% of throughput — it is in for the removed sync, not the speed.

Instrumentation — per-step frontend/encoder/decoder wall time plus upload/compute/readback/sample splits. This is what redirected the work: the encoder turned out to be ~98% graph compute rather than dispatch-bound, and the F16 cache gained ~10 ms/step where halved read traffic alone predicts ~2, so Metal is taking a genuinely faster F16 flash-attention path.

Tests

New hermetic voxtral_realtime_stream_chunking_test builds a VoxtralRealtimeFrontend from a default config — no model, weights or backend — and pins the batched chunk arithmetic: steady_stream_chunk_samples(N) == N*1280 + 400, advance linear in N, defaults unchanged at 1680/1280, 8N feature frames, agreement between the fresh-STFT and cached-prefix routes, and rejection of non-positive token counts.

ctest -E asr_standalone_gguf_test is 24/27. gguf_tensor_source_test and scaled_dot_product_attention_test also fail on 10cb78de (verified against a clean upstream build); audio_dsp_test passes standalone and flakes only under parallel load.

Verified

  • Partial sequence and final transcript identical to upstream once the baseline's duplicate events are collapsed.
  • Both utterances of a file with a 3 s pause transcribe on both builds, 383 steps each.
  • Offline transcript identical, 186 tokens.
  • 418 s stream coherent across five cache wraparounds, one decode-graph build.
  • --backend cpu agrees with Metal on both builds, so the F16 cache and view-KV lowerings are not Metal-only.
  • CUDA is unmeasured — no device here. The lowerings are used by other models on CUDA already, but the F16 encoder cache is new.

🤖 Generated with Claude Code

A streaming step advances 80 ms of audio, so it has to cost under 80 ms to
track a realtime source. It cost 133 ms/step. Instrument the streaming path
first, then optimize against the measurements.

Correctness first. process_one_stream_chunk set stream_reached_eos_ on an EOS
token and the consuming loop tested it, while nothing outside reset() ever
cleared it, so the first EOS halted transcription permanently; once halted the
buffer-trim block became unreachable while audio kept being appended. The
reference gives EOS no special treatment: the sampled token feeds back
unconditionally and only the audio source running dry ends the stream. Special
ids emit no text, so the transcript is unchanged. The offline generate() EOS
stop is retained. Removing utterance resets is also what makes the fixed-size
decode cache below a requirement rather than a nicety.

The session also invoked its own event sink and returned the same event to the
caller, which forwards it, so every partial was delivered twice, over the CLI
and the server's SSE deltas alike. Several chunks can be consumed per
process_audio_chunk call, so the sink is the route that yields a partial per
step; the returned event now drops what the sink already delivered instead.
Granularity is unchanged, the duplicates are gone.

Throughput:

- Feed the KV caches to flash attention as strided views in both static
  attention builders instead of materializing a transpose. This was copying the
  whole K and V cache per layer per token and was the single largest cost.
- Hold the encoder's sliding-window KV cache in F16. It is never exported, so
  this is local, and Metal's flash attention has a much faster F16 path than
  the halved read traffic alone predicts.
- Accept q4_0/q4_k/q5_k/q6_k for the encoder and decoder weights. The published
  q4_k GGUF avoids the load-time requantization these otherwise cost.
- Pin the streaming decode cache to a fixed size (default 1024 steps, ~82 s)
  built once at begin_stream, so a long session never stalls on a cache-growth
  rebuild.
- Add stream_batch_tokens=N: one encoder forward covers N audio tokens while
  the decoder still steps once per token. The encoder is dominated by fixed
  per-forward work, so N=4 more than halves it, at the price of delaying each
  partial by up to N*80 ms. Default 1, so nothing changes unless asked for.
- Fold the conv-cache update into the encoder graph, dropping a per-step async
  copy and a GPU sync.

Measured on an M3 Air (Metal, q8_0), transcripts identical to the baseline:

  baseline          133.2 ms/step  1.67x
  default            78.2          0.98x
  batch_tokens=4     60.4          0.76x

Sustained over a 7-minute stream those become 87.9 and 73.9 ms/step; a short
clip run immediately after still measured 87.9, so the gap is the fanless
machine staying warm, not per-session drift. Budget for the sustained figure.

Verified: partial sequence and final transcript identical to the baseline once
the baseline's duplicates are collapsed; both utterances of a file with a 3 s
pause transcribe on both builds; offline output identical at 186 tokens with
the decoder 52.2 -> 45.9 ms/token; a 418 s stream stays coherent across five
cache wraparounds with exactly one decode-graph build; CPU backend agrees with
Metal. ctest 24/27, matching upstream (gguf_tensor_source_test and
scaled_dot_product_attention_test fail on main too, audio_dsp_test passes
standalone). CUDA is unmeasured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@0xShug0

0xShug0 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

@patrickvonplaten Thank you for the PR and I will test it later. Interestingly, I already backported the new FlashPreserveViews optimization pattern, but the improvement in Voxtral ASR was within the noise level, so I decided not to keep the change. It may need to be combined with the other changes, or it may show larger gains under different settings.

@patrickvonplaten

patrickvonplaten commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

By far the biggest win is doing the F16 encoder KV cache. This seems to give the biggest speed up. What's quite nice is that with the above optimizations Voxtral Streaming now runs well below 1.0 Realtime on MacBook M* series with metal.

This PR is quite heavily aided by code agents, but it should touch mostly only Voxtral specific code. Happy to adapt the PR in any way / making it smaller. If you believe certain optimizations are not worth it and clutter the code too much - let's leave them out.

Want to upload some q8 and q4 checkpoints on: https://huggingface.co/mistral-experimental to make it easy to use. Lemme know your thoughts

@0xShug0

0xShug0 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

I’d be happy to host the models there. I’ve already submitted a request to join Mistral Experimental.

@patrickvonplaten

patrickvonplaten commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Also what's important is that EOS should not end the generation. Since voxstral Realtime should in theory run "forever" - a.k.a. as long as audio is streamed in - I think we should not add logic that makes it end with EOS but only have the following two cases:

  • a) You pass a static audio file of say N seconds. Given that Voxtral Realtime outputs exactly 1 token for 320 ms + delay etc..., we know before-hand exactly after how many tokens it should stop
  • b) [The more important use case] If audio is streamed into the model, then we should always only stop when the audio stream is stopped.

For more reference here:

This should also be fixed in this PR

@patrickvonplaten

Copy link
Copy Markdown
Contributor Author

I’d be happy to host the models there. I’ve already submitted a request to join Mistral Experimental.

Great approved you - thanks!

@patrickvonplaten

Copy link
Copy Markdown
Contributor Author

I sadly currently don't have access to CUDA - so it would probably be good to test this PR on CUDA as well

@0xShug0

0xShug0 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Yes I will test on CUDA and Vulkan.

@0xShug0

0xShug0 commented Jul 26, 2026

Copy link
Copy Markdown
Owner

Looks like solid improvements. Merged. Thanks!

Case: A = main baseline, B = PR default, C = PR opt-in batch mode.

Case Backend Option session.wall_ms audio_encoder_ms text_decoder_ms step_total_ms RTF
A CUDA baseline 874.609 45.767 823.679 784.485 RTF 0.0622
B CUDA stream_batch_tokens=1 869.619 46.955 817.847 778.216 RTF 0.0618
C CUDA stream_batch_tokens=4 829.001 44.416 779.941 741.604 RTF 0.0589
A Vulkan baseline 875.250 48.177 823.370 785.044 RTF 0.0622
B Vulkan stream_batch_tokens=1 839.184 36.212 797.227 762.909 RTF 0.0596
C Vulkan stream_batch_tokens=4 835.985 35.074 797.088 762.891 RTF 0.0594

Streaming Server

Case Backend Option Server TTFT ms Client TTFT ms Streaming RTF Delta events Peak VRAM MiB
A CUDA baseline 291.238 293.909 0.1394 59 6486
B CUDA stream_batch_tokens=1 322.396 324.627 0.1305 34 6567
C CUDA stream_batch_tokens=4 297.787 300.262 0.1116 20 6755
A Vulkan baseline 2913.820 2916.451 0.3491 59 5905
B Vulkan stream_batch_tokens=1 451.835 454.176 0.1753 34 5960
C Vulkan stream_batch_tokens=4 490.153 492.649 0.1475 20 5872

@0xShug0
0xShug0 merged commit e00c7c9 into 0xShug0:main Jul 26, 2026
4 checks passed
@patrickvonplaten

Copy link
Copy Markdown
Contributor Author

Nice to see, thanks for the benchmarking and merging!

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.

2 participants