Skip to content

perf(lfm2_audio): reuse the turn before's prefill and encoder output - #845

Merged
0xShug0 merged 4 commits into
0xShug0:mainfrom
Liquid4All:lfm2-audio-s2s-history-cache
Oct 9, 2026
Merged

0xShug0 merged 4 commits into
0xShug0:mainfrom
Liquid4All:lfm2-audio-s2s-history-cache

Conversation

@ykhrustalev

Copy link
Copy Markdown
Contributor

Follow-up to #838: a later S2S turn restores the prefill blocks it shares with the turn before and reuses the earlier questions' encoder output, instead of running the whole conversation again. Output does not change.

Problem

  • Each S2S turn with history ran its chunked prefill from position 0 and sent every earlier question through the encoder again, so the wait for a turn's first audio grew with the conversation. On an Intel Core i9-13900HK laptop CPU at 8 threads with Q4_0 (main, with its repacked CPU kernels), the tenth turn of a ten-turn English conversation, streamed, reached its first audio after 11.1 s: 0.46 s encoding ten questions, then 10.4 s of prefill
  • When feat(lfm2_audio): multi-turn speech-to-speech with client-carried history #838 was merged, the maintainer asked for a follow-up in which its block graphs use perf(lfm2_audio): use ggml's repacked CPU kernels for quantized weights #834's validate_backend_graph_with_cpu_extra_buffers, as the backbone's prefill and decode graphs do, instead of validate_backend_graph_supported. No failure from the omission was found

What this PR changes

  • BlockGraph validates with validate_backend_graph_with_cpu_extra_buffers. It uses the weights only through LinearModule's matmuls, as the one-shot prefill does, so the checks pass and nothing changes
  • The backbone keeps a host copy of what each full 256-step block of the last chunked prefill wrote (keys and values, 24 KiB per step; the conv state, 160 KiB per block) with the block's input. The next chunked prefill restores the leading blocks whose input is the same, bit for bit, and runs the rest; its last block always runs. A one-shot prefill neither reads nor replaces them
  • The decode cache allocates its host import scratch only when a one-shot prefill imports a state, so a cache that only chunked prefills have written no longer holds 24 KiB per cache step of zeros
  • The S2S session keeps the questions of its last request with history or return_codes, as mono 16 kHz samples, with their encoder output. An earlier question with the same samples (size and bytes) takes the kept output; the new question is always encoded. A request without history or return_codes keeps nothing and leaves what is kept. A request over the conversation limit fails as before and leaves the kept questions as they were
  • Logs: lfm2_audio.prefill.resumed_steps (timing) and lfm2_audio.session.reused_questions (trace). The backbone runtime gets resumed_prefill_steps(), the S2S session resumed_prefill_steps() and reused_questions()
  • Tests: lfm2_audio_backbone_test checks restored prefills against a fresh runtime, bit for bit, for prompts of 300 to 1000 steps at 1, 4 and 8 threads and with caches of other lengths, and what stops a resume. lfm2_audio_chat_test runs a four-turn conversation in one session, offline and streamed, against fresh sessions, then its fourth turn again after another conversation, over the limit and after a request without history or return_codes. lfm2_audio_encoder_test now compares bit for bit, as the reuse needs the same bits for the same samples
  • Docs: what a session keeps for the next turn, how much memory that takes, and which requests replace it

What changes for users

  • Output does not change: every turn, and every request without history, gives main's bytes (see Testing)
  • Later turns reach their first audio sooner, more so as the conversation grows (table below). A first turn keeps no blocks, so the third turn is the first to restore any. Requests without history or return_codes run as before
  • A session keeps its last turn's blocks and questions in host memory until a request replaces them or the session closes. The blocks take about 70 MiB at the tenth turn of a 2900-step conversation, and blocks and questions together up to about 350 MiB at the 8192-step limit. In the runs below, peak RSS went down or moved by under 10 MB against main
  • The gain needs consecutive turns of one conversation on one session. After a later turn of another conversation (one with history) on the same session, the next turn of this one runs in full, as on main; after another conversation's first turn with return_codes, it encodes its earlier questions again but still restores its prefill blocks. A turn that leaves out the oldest turns runs its prefill in full, though its questions still take the kept encoder output. A question sent back as other samples than its turn had is encoded again, and the prefill runs again from the block it starts in; a float WAV holds the samples exactly

Testing
Three trees built from main at c0d84e1, #838's merge: main, main with the first two commits, and this PR. This PR is the same four patches rebased onto main at adedaf2, which since c0d84e1 adds only ef860c9 (the encoder's CPU repack check moves to an extended test) and a whistle_asr change. On each machine, each tree ran the same ten-turn English conversation in one process, with fixed seeds and return_codes, sending its own replies back as history, and the ten questions asked alone. Every output file was compared byte for byte: the reply artifact's payload, meta and kind, the text, the audio and the stream events. lfm2_audio.cpu_repack was on, main's default, unless noted.

  • Intel Core i9-13900HK laptop CPU (AVX2, no AVX-512 or AMX), 8 threads, Q4_0 and Q8_0, offline and streamed, and Q4_0 streamed with cpu_repack=false: this PR gives main's bytes in every configuration, and the first two commits do with Q4_0 streamed; 900 of 900 files match over the 14 comparisons. Main and this PR, run twice with Q4_0 streamed, repeat themselves
  • M3 Ultra, CPU at 16 threads (Q4_0, Q8_0) and Metal (Q4_0), offline and streamed: main and this PR give the same bytes in 1250 of 1250 files, and so do main and the first two commits. Each tree's conversation, run twice, repeats itself
  • A10 (CUDA), Q4_0 and F16, offline and streamed: 7110 comparisons across the three trees, none differ. The tenth turn sent with the same history to a new process gives the conversation's tenth turn in all three
  • Xeon 8358 (the A10's host), CPU at 8 threads: with Q4_0 streamed, the conversation and the questions alone give the same bytes in all three trees, and this PR's tenth turn in a new process matches; with Q8_0 streamed, the conversation gives the same bytes on main and this PR
  • The A10's trees, for the CUDA and the Xeon runs alike, each carry 🚨 fix(ggml-cuda): backport the block_reduce shared-memory race fix (llama.cpp #26385) #837, the block_reduce race fix, which is not merged yet. Without it ggml-cuda's softmax can change a run's bytes, so a difference between trees could come from the race rather than from this PR
  • ctest on the i9-13900HK: the lfm2_audio unit tests and cpu_extra_buffers_test pass on main and this PR. On this PR rebased onto adedaf2, built with the extended tests, 12 of 12 pass: those, lfm2_audio_encoder_cpu_repack_test and model_spec_system_test
  • ctest on the M3 Ultra, with the EN F16 package installed: every lfm2_audio test passes, test_lfm2_audio_asr, test_lfm2_audio_tts and test_lfm2_audio_s2s (CPU) included, and so does cpu_extra_buffers_test; main gives every test the same status. lfm2_audio_chat_test now takes 3.2 s there instead of 0.6 s, and lfm2_audio_backbone_test 1.6 s instead of 1.1 s
  • On the A10's CUDA builds, the lfm2_audio unit tests, lfm2_audio_cuda_test and cpu_extra_buffers_test pass in all three trees. On the Xeon, a CPU build of this PR without 🚨 fix(ggml-cuda): backport the block_reduce shared-memory race fix (llama.cpp #26385) #837 passes the lfm2_audio unit tests and cpu_extra_buffers_test. In the M3 Ultra's and the A10's lfm2_audio-only builds, 13 tests of other families do not run because their executables do not link, the same on main
  • Not run on a CPU with AMX

Turn 10 of the conversation, streamed, main / first two commits / this PR. Main and the first two commits encode ten questions, this PR one. Replies, and so prompt lengths, differ between machines and packages, so the steps restored differ too. The first two commits were not run with Q8_0 or with cpu_repack=false on the i9-13900HK, nor with Q8_0 on the Xeon.

First audio ms Prefill ms Steps restored Encoder ms
i9-13900HK, 8 threads, Q4_0 11052 / 2181 / 1695 10380 / 1526 / 1454 0 / 2560 / 2560 459 / 452 / 47
i9-13900HK, 8 threads, Q8_0 24302 / - / 4620 23232 / - / 4187 0 / - / 2560 740 / - / 65
i9-13900HK, 8 threads, Q4_0, cpu_repack=false 26335 / - / 3598 25246 / - / 3340 0 / - / 2560 850 / - / 73
Xeon 8358, 8 threads, Q4_0 8225 / 1775 / 1400 7602 / 1166 / 1195 0 / 2560 / 2560 422 / 423 / 43
Xeon 8358, 8 threads, Q8_0 12281 / - / 2439 11513 / - / 2182 0 / - / 2816 487 / - / 48
M3 Ultra CPU, 16 threads, Q4_0 1993 / 625 / 401 1690 / 324 / 325 0 / 2304 / 2304 145 / 146 / 15
M3 Ultra CPU, 16 threads, Q8_0 2332 / 647 / 427 2013 / 330 / 331 0 / 2816 / 2816 144 / 145 / 14
M3 Ultra Metal, Q4_0 751 / 350 / 125 455 / 60 / 60 0 / 3072 / 3072 155 / 156 / 16
A10, Q4_0 474 / 218 / 118 250 / 64 / 65 0 / 2816 / 2816 96 / 96 / 10
A10, F16 399 / 205 / 120 201 / 44 / 42 0 / 2560 / 2560 82 / 84 / 8
  • Over the ten turns streamed on the i9-13900HK with Q4_0, the first audio took 61.0 / 20.7 / 18.4 s in all and the turns 133.5 / 94.1 / 90.9 s
  • Over the ten turns, the prefills took 56.4 / 16.3 / 16.3 s on the i9-13900HK with Q4_0 (116.6 / - / 32.2 s with Q8_0, 131.7 / - / 35.6 s with cpu_repack=false), 40.0 / 11.5 / 11.8 s on the Xeon with Q4_0, 8.8 / 2.5 / 2.5 s on the M3 Ultra's CPU with Q4_0 (offline), 2.4 / 0.7 / 0.7 s on Metal and 1.30 / 0.46 / 0.46 s on the A10 with Q4_0. The last two commits do not change the prefill, so the differences between the first two commits and this PR there are run-to-run noise
  • Offline, the tenth turn took 16601 / - / 7072 ms on the i9-13900HK with Q4_0 and 33580 / - / 13224 ms with Q8_0, 4259 / 2890 / 2674 ms on the M3 Ultra's CPU with Q4_0, 1963 / 1557 / 1332 ms on Metal and 2712 / 2530 / 2349 ms on the A10 with Q4_0
  • Sent with the same history to a new process, which has nothing kept, this PR's tenth turn on the Xeon reached its first audio after 8321 ms, including the new process's first-request warmup, about main's 8225 ms in the conversation, against 1400 ms in this PR's conversation
  • The ten questions asked alone restore and reuse nothing and take the same time in the three trees, within 2.6% on the i9-13900HK
  • Peak RSS of the conversation: on the i9-13900HK 1445 / 1386 / 1390 MiB with Q4_0 streamed and 2100 / - / 2051 MiB with Q8_0 streamed; on the Xeon 1605 / 1549 / 1554 MiB with Q4_0 and 2309 / - / 2248 MiB with Q8_0; on the A10 with F16, offline, about 750 / 683 / 689 MiB. With Q4_0 on the A10 (1427 MiB in every tree) and on the M3 Ultra (within 10 MB either way), the ten questions asked alone reach the same peak as the conversation, so the peak is set elsewhere in the process
  • The i9-13900HK was otherwise idle. Main and this PR ran its Q4_0 streamed conversation twice, with the tenth turn's first audio at 11052 and 11237 ms on main and 1695 and 1717 ms with this PR, and the ten turns' wall time about 1% apart between runs; its other runs, and those of the first two commits, ran once. The Xeon timings are one run per tree. The M3 Ultra was shared with other jobs: its conversations ran twice and, but for one stall, repeated the tenth turn within a few ms; the numbers here leave out a few one-off stalls of several seconds that the other run did not show. Metal runs started on an idle GPU. On the A10, CUDA runs started with no other process on the GPU, and the timings use the runs during which no other job loaded the host

Build and run

cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DENGINE_BUILD_TESTS=ON \
  -DENGINE_BUILD_MODEL_TESTS=ON -DAUDIOCPP_MODEL_SET=custom -DAUDIOCPP_MODELS=lfm2_audio
ninja -C build lfm2_audio_backbone_test lfm2_audio_chat_test lfm2_audio_encoder_test \
  cpu_extra_buffers_test test_lfm2_audio_s2s
(cd build && ctest -R '^(lfm2_audio_(backbone|chat|encoder)_test|cpu_extra_buffers_test)$')
./build/bin/test_lfm2_audio_s2s  # with lfm2_audio_1_5b_f16 in models/

The chunked prefill that later S2S turns run builds its 256-step block
graphs with the backbone's weights, which with lfm2_audio.cpu_repack may
sit in CPU extra buffers, but validated them with
validate_backend_graph_supported, where the backbone's other graphs and
those of the encoder, depthformer and detokenizer use
validate_backend_graph_with_cpu_extra_buffers. The block graphs use
those weights only through LinearModule's matmuls, as the one-shot
prefill does, so the extended checks pass and nothing changes; a later
change that viewed such a weight, or fed it a strided input, now fails
when the graph is built rather than reading misplaced rows.

With lfm2_audio.cpu_repack on, main's default, every later turn of a
ten-turn English conversation passed the checks with the Q4_0 package
on an Intel Core i9-13900HK laptop CPU and on a Xeon 8358, and with the
Q4_0 and Q8_0 packages on the CPU of an M3 Ultra, types that ggml's
repacked kernels take there. Together with the next commit, which
changes no output either, that conversation streamed with Q4_0 and its
questions asked alone give main's bytes on all three, and
cpu_extra_buffers_test and the lfm2_audio unit tests pass.
A later turn's prompt is the turn before's with its reply and the new
question added, but every chunked prefill ran from position 0, so a
turn's prefill grew with the conversation. In a ten-turn English
conversation, streamed with the Q4_0 package on an Intel Core i9-13900HK
laptop CPU at 8 threads, the tenth turn's prefill took 10.4 s of its
11.1 s to first audio.

A block of the chunked prefill computes the same bits from the same
span, on the same keys, values and conv state, whatever the cache's
length. So the backbone now keeps a host copy of what each full block
of the last chunked prefill wrote: its rows of each attention layer's
keys and values, 24 KiB per step for the published checkpoints, the
conv state after it, 160 KiB, and the block's span. The next chunked
prefill compares its blocks' spans with the kept ones from the start,
ids, positions and frame rows by value and audio rows by their bytes,
restores the blocks that match into the cache it cleared, as running
them would have left it, and runs the rest as before. Its last block
always runs, as its logits are the prefill's. A shorter last block is
never kept, nor is anything a one-shot prefill or a decode step wrote,
and a one-shot prefill leaves the kept blocks as they are.

The decode cache now allocates the host scratch it stages a whole
state in only when a one-shot prefill imports one, so a cache that
only chunked prefills have written no longer holds 24 KiB per cache
step of zeros there. The bytes it writes are the same.

In that conversation, in one run of each, the tenth turn now restores
2560 of its prompt steps, prefills in 1.5 s and reaches its first audio
in 2.2 s, and the ten turns' prefills take 16.3 s instead of 56.4 s.
The conversation's peak RSS went from 1445 MiB to 1386 MiB. With Q4_0,
the tenth turn reaches its first audio in 1775 ms instead of 8225 ms
on a Xeon 8358 at 8 threads, in 625 ms instead of 1993 ms on an M3
Ultra's CPU at 16 threads, in 350 ms instead of 751 ms on its Metal and
in 218 ms instead of 474 ms on an A10 (CUDA). Peak RSS went from
1605 MiB to 1549 MiB on the Xeon and, with the F16 package offline,
from about 750 MiB to 683 MiB on the A10; on the M3 Ultra, and on the
A10 with Q4_0, it moved by under 10 MB. Each turn's reply artifact,
text, audio and stream events are main's bytes, and so are those of the
ten questions asked alone: offline and streamed on the M3 Ultra's CPU
with Q4_0 and Q8_0, on its Metal with Q4_0 and on the A10 with Q4_0 and
F16, and streamed with Q4_0 on the i9-13900HK and the Xeon. Each A10
tree carried the fix for ggml-cuda's block_reduce race, which can
otherwise change a run's bytes.

The timing log gets lfm2_audio.prefill.resumed_steps, and the runtime
resumed_prefill_steps(). lfm2_audio_backbone_test checks prompts of
300 to 1000 steps on one runtime against a fresh one, bit for bit, in
the prefill's logits and six steps after it, at 1, 4 and 8 threads and
with caches of other lengths, and how much a bit flipped in an audio
row, a changed frame code, a one-shot request and a text-only chunked
request in between let the next prefill resume.
Each later turn also ran every earlier question through the encoder
again: the tenth turn of the ten-turn English conversation, streamed
with the Q4_0 package on an Intel Core i9-13900HK laptop CPU at
8 threads, spent 0.45 s encoding its ten questions.

The S2S session now keeps the questions of its last request with
history or return_codes, as the mono 16 kHz samples the encoder took,
with their encoder output. An earlier question whose samples are those
of a kept one, compared by size and bytes, takes that output instead
of the features and the encoder, which on one session give the same
bits for the same samples whatever they encoded before. The new
question always goes through the encoder. A request without history or
return_codes keeps nothing and leaves the kept questions as they are,
so the session keeps none of that request's audio. The conversation
limit is checked at the same points with the same counts, so a request
fails with the same error as before. The trace log gets
lfm2_audio.session.reused_questions, and the session
resumed_prefill_steps() and reused_questions().

A request replaces the kept questions only once it has passed the
conversation limit, so one that fails leaves them as they were. The
questions it takes from them it shares with them, rather than holding
a second copy of their samples and output.

The tenth turn of that conversation now encodes one question, in
47 ms, and reaches its first audio in 1.7 s, against 2.2 s with the
commit before and 11.1 s on main; the ten turns' encoder calls take
0.5 s instead of 2.8 s. With the Q8_0 package its first audio comes
after 4.6 s against 24.3 s on main, and with Q4_0 and
lfm2_audio.cpu_repack off after 3.6 s against 26.3 s. With Q4_0, it
comes after 1400 ms, against 1775 ms and 8225 ms, on a Xeon 8358 at
8 threads, after 401 ms against 625 ms and 1993 ms on an M3 Ultra's
CPU at 16 threads, after 125 ms against 350 ms and 751 ms on its Metal,
and after 118 ms against 218 ms and 474 ms on an A10 (CUDA). The
conversation's peak RSS on the i9-13900HK was 1390 MiB, against
1386 MiB with the commit before and 1445 MiB on main, and on the Xeon
1554 MiB, against 1549 MiB and 1605 MiB.

The ten turns and the ten questions asked alone give main's bytes:
offline and streamed on the i9-13900HK with Q4_0 and Q8_0, and
streamed with Q4_0 and cpu_repack off; offline and streamed on the M3
Ultra's CPU with Q4_0 and Q8_0, on its Metal with Q4_0 and on the A10
with Q4_0 and F16, each A10 tree with ggml-cuda's block_reduce race
fix; and streamed on the Xeon with Q4_0. On the Xeon the ten turns
streamed with Q8_0 give main's bytes too. The tenth turn sent with the
same history to a new process gives the conversation's tenth turn, on
the A10 and on the Xeon.

lfm2_audio_encoder_test now compares bit for bit where it checks that
a runtime gives a fresh one's output after chunks of other lengths,
one past 30 s whose buffer is freed among them, and the same output
for the same input after other input.

lfm2_audio_chat_test runs a four-turn conversation in one session,
offline and streamed, whose prompts pass 256 and 512 steps: each
turn's text, audio and reply artifact are a fresh session's, with 0, 0,
256 and 512 prefill steps resumed and 0 to 3 questions reused. The
fourth turn then runs again after each of these: another
conversation's first two turns, after which it resumes nothing; that
conversation's first turn alone, with return_codes, after which it
encodes its questions again but still resumes; itself over the limit,
which fails with a fresh session's error after each question and on
the whole prompt; and a request without history or return_codes.

The session's comment no longer says it keeps nothing between
requests.
The Conversations section said the S2S session keeps nothing between
requests and that each turn prefills the whole conversation again. It
now says what a session keeps for the next turn of one conversation:
the questions of its last request with history or return_codes, with
their encoder output, about 160 KiB per second of question, and the
full prefill blocks of its last turn with history, 6 to 8 MiB per
256 steps, up to about 350 MiB in all at the conversation limit. It
says that a turn gives the same bytes with or without them, which
requests replace which of them and what the next turn then runs
again, that a turn that leaves out the oldest turns runs its prefill
in full, and that a question sent back as other samples is encoded
again. The validation section covers the new tests, and the memory
section points to the Conversations section.
@0xShug0
0xShug0 merged commit fa14ee4 into 0xShug0:main Oct 9, 2026
6 checks passed
@0xShug0

0xShug0 commented Oct 9, 2026

Copy link
Copy Markdown
Owner

@ykhrustalev Thansk, PR merged!

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