feat(cli): larql speak-serve — keep the speech model resident - #257
Open
sam-shepp wants to merge 1 commit into
Open
feat(cli): larql speak-serve — keep the speech model resident#257sam-shepp wants to merge 1 commit into
sam-shepp wants to merge 1 commit into
Conversation
`run --speak` pays the whole load bill for one utterance: safetensors
load, the Q4_K quantisation pass, tokenizer. Measured on an M-series
32 GB machine, a 3.92 s utterance cost 33.3 s wall for ~5 s of actual
work — roughly 26 s of it startup, paid again every invocation.
speak-serve pays it once and then serves utterance after utterance
against the hot model, which is the shape MossSpeech and MossSession
already had: the loaded model borrowed as one unit, a fresh session per
utterance. Steady state 33.3 s -> 5.3 s. With the per-frame `stream:`
path, first sound lands at 2.0-2.1 s on a 7.76 s utterance with zero
underruns. Parity checked rather than assumed: the same text, seed and
voice through the resident path produce bit-identical tokens to the
one-shot path.
This is the process-level step toward docs/tts-funnel.md §5 ("keep the
speech machine hot") and explicitly not the realtime runtime that
section describes — no PCM ring buffer, no audio callback, and the codec
stays external per §6. Every reply carries first_frame=Nms, which gives
the post-step-5 TTFA gate a measurement surface; stderr carries the
prefill/steady split so steady-state rate is never confused with the
end-to-end rate a short utterance amortises badly.
Requests are `key: value` blocks terminated by a blank line, read from
stdin or a --listen FIFO. Unknown keys are rejected rather than ignored,
so a typo'd `voise:` cannot silently synthesise the default voice and
look like a model bug. Reply FIFOs are opened O_NONBLOCK and retried to
a short deadline: a blocking write-open on a FIFO whose reader has died
never returns, which wedged the service until restart when it happened.
The FIFO paths are unix-only and refuse with a message naming the
portable alternative; stdin requests and `tokens:` output work
everywhere, so the amortisation is available on every platform.
read_token_rows and write_token_rows widen to pub(super) rather than
being restated, so `run --speak` stays the reference for the token-row
format and the two paths cannot drift.
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.
Summary
run --speakpays the whole load bill for one utterance: safetensors load, the Q4_Kquantisation pass, tokenizer. Measured on an M-series 32 GB machine, a 3.92 s utterance
cost 33.3 s wall for ~5 s of actual work — model load 12.2 s, quantisation 4.1 s,
generation 4.8 s, external codec 11.7 s. Roughly 26 s of every invocation was startup,
paid again each time.
speak-servepays it once and then serves utterance after utterance against the hot model.That is what the existing API was already shaped for —
MossSpeechis "the loaded modelborrowed as one unit" and
MossSessionis per-utterance(
larql-inference/src/speech/moss_session.rs) — so this is a loop around that seam ratherthan new inference machinery.
Result: 33.3 s → 5.3 s steady state per utterance, with generation now ~95 % of what
remains. With the per-frame
stream:path, first sound at 2.0–2.1 s on a 7.76 sutterance, 0 underruns.
Where this sits on the roadmap
This is the process-level step toward
docs/tts-funnel.md§5's "keep the speechmachine hot", and it is deliberately not the realtime runtime that section describes:
no PCM ring buffer, no audio callback, and the codec stays external (§6 defers
codec-in-LARQL). It is also the "server-side home for a persistent engine" that §7 lists
under session/turn state.
It gives the post-step-5 perf gate (first-turn TTFA below 500 ms) a measurement surface:
every reply carries
first_frame=Nms, and stderr carries the prefill/steady split.What's in the diff
commands/primary/speak_serve.rscommands/primary/mod.rspub mod speak_serve;main.rsSpeakServevariant + dispatch arm (6 lines)commands/primary/run_cmd_speak.rsread_token_rows/write_token_rowswidenedfn→pub(super)Nothing existing changes behaviour. The two widened fns are shared rather than
reimplemented, so
run --speakstays the reference for the token-row format and the twopaths cannot drift.
Protocol
One request is a block of
key: valuelines terminated by a blank line (or EOF);text:is the only required key. Every other key falls back to the startup flag, so the common
case is one line.
Reply is a single line —
ok frames=N audio=Ns gen=Ns first_frame=Nmsorerr <message>—on
reply:when given, else stdout.Design points worth calling out, each of which was earned rather than guessed:
voise:would otherwise silentlysynthesise the default voice and look like a model bug.
voice: -is an explicit "unconditioned", distinct from an absent key (which inheritsthe server default). So switching voices needs no restart.
stream:flushes per frame, and closing the FIFO is the end-of-utterance signal —no in-band terminator that could be mistaken for a frame.
short utterance amortises badly, and the end-to-end number flatters or damns a run
depending only on its length. (Related: an early Metal reading here looked like a 15 %
win and was warm-up contamination — the same trap
tts-funnel.mdrecords.)Placement — a CLI subcommand now,
larql-serverlaterFlagging the structural question up front, since it's the obvious one: for always-on
serving this belongs in
larql-server, that being the inference server. No argument — treatthis CLI subcommand as the near-term shape rather than the destination, and move it when the
server-side session/turn-state work (
docs/tts-funnel.md§7) gives it a proper home.Why it's still worth landing here first:
transport. Proving it in the smallest surface keeps this to 10 lines outside one new file,
and makes the eventual move a transport swap rather than a rewrite.
printf 'text: hi\n\n' > fifo— no client library, no port, no JSON.stream:path needs per-frame flush latency with nothing between generation andthe player's decoder. Better that constraint informs the server route's design than gets
discovered after it.
The reusable half is
serve_one: resident model in, one utterance out, no I/O policy of itsown beyond the paths it's handed. An HTTP or gRPC route should be able to call it more or less
unchanged — the FIFO/stdin plumbing is the throwaway half. Move or rename freely.
The one design question:
--voiceand roadmap P1speak-serve --voice <TOKENS>takes a path to a token-rows file, matchingrun --speaktoday. ROADMAP P1 ("Voice bank") plans
larql speak --voice aru-12 "…"resolving a namedvoice from a bank, and says outright that maintaining
aru-12.tokensas a manually preparedmagic file is "beneath the abstraction level of the rest of the system" — i.e. exactly what
this flag currently takes.
I've kept the path form so this is consistent with the command it amortises, rather than
inventing half a voice bank. The intended seam: when
voice clone/voice listland,--voiceand the per-requestvoice:key resolve a name through the bank and thissubcommand doesn't otherwise change. Flagging it so it's a decision rather than a
fait accompli.
Portability
The FIFO paths are Unix-only and refuse with a clear message elsewhere; the stdin request
path and
tokens:output work on every platform, so the amortisation is availableeverywhere.
--listenneedsmkfifo(3), andreply:needs a non-blocking write-open tobe safe against a dead client:
Tests
19 unit tests over the parsing and reporting surface —
Request::absorb(every key, unknown-keyrejection, the
voice: -sentinel, unparseable numbers, trimming),drain(block splitting,trailing block at EOF, and that neither a malformed block nor a failing request takes the
server down),
stage_split(medians, and that frame 0's absent backbone time doesn't dragthem down),
truncate(multibyte safety).Verification
cargo test -p larql-cli --bins speak_serve— 19 passed.cargo fmt -p larql-cli -- --check— clean.cargo clippy -p larql-cli --bins --tests --no-deps -- -D warnings— clean.cfgs locally (no Windows host available);clean apart from the artificial
cfgwarnings that inversion introduces.bit-identical tokens to the one-shot
run --speakpath.Measurements
M-series, 32 GB, MOSS-TTS-Realtime,
--q4.run --speakspeak-servestream:)Steady state on a 7.76 s utterance: prefill 1.45 s fixed, then 41 ms/frame = 1.95×
realtime. Inside a frame, depth is ~70 % (28–34 ms vs backbone 12 ms) — consistent
with §5 naming a fused
moss_depth_frameas the steady-state target.Two null/negative results, recorded so they don't get rediscovered:
--metalchanges nothing measurable in the speech path today. CPU 1.45–1.54 s prefill/ 39–44 ms per frame vs Metal 1.44–1.66 s / 40–44 ms; backbone p50 identical at 12 ms. So
§5's "Metal FFN ⇒ TTFA ≈ 550 ms" remains a projection for an unbuilt kernel.
pages MOSS out: the next utterance measured first frame 13.3 s / 147 ms per frame against
1.5 s / 52 ms, putting first sound at 33 s. Re-speaking with no LLM in front returned
straight to 1.5 s / 52 ms. Anything wanting both models resident needs to re-warm.
Not included
Barge-in / cancellation (§5's 100 ms budget) needs a cancel path rather than more speed.
Voice-prefix KV caching — identical for every utterance in a voice and currently recomputed
each time — is the next amortisation win and is not attempted here.
Unrelated: pre-existing red CI on
main, same root cause as #256Not from this PR, and already reported — adding one data point rather than a new issue. The
--routed-from --metalblock inrun_cmd.rsreferenceslarql_compute_metalwith no gating,and that single omission is reachable from two directions:
larql-compute-metalon ubuntu #256's — Linux with default features, where the crate is linked but its items are#[cfg(target_os = "macos")]-gated, soroute_witnessisn't found.--no-default-features, where the crate isn't linked at all:larql-compute-metalisoptional = true(larql-cli/Cargo.toml:17), pulled in only byfeature
gpu, anddefault = ["gpu"]— so macOS with default features is the oneconfiguration where this block compiles, which is presumably why it slipped through.
Introduced by
7da7df35. Worth noting that a#[cfg(feature = "gpu")]alone won't closeboth: #256's Linux case needs
target_os = "macos"too, and per that thread thelet backend = …binding then falls out of scope atrun_cmd.rs:1054.Mentioning it here only because
.github/workflows/larql-cli.ymlruns the--no-default-featuresclippy step on all three OSes, so this PR's checks may show a red jobthat predates the branch.