feat: keep models in memory, offload them when idle, and give whisper.cpp a resident worker - #50
Merged
Merged
Conversation
Settings → Speech engine gains "Offload model when idle": off by default, it releases a resident model after 10, 15, 30, 60, or 120 minutes without a transcription and loads it again on the next request. The choice is saved in the runtime config and carried by /v1/admin/config. Engines that own a loaded model now share a MemoryResidentEngine protocol, so the idle monitor unloads faster-whisper, sherpa-onnx, Moonshine, MLX Audio, WhisperKit, and VocaMac's persistent path through one interface. Batch and streaming requests take an engine lease for their duration, which keeps the monitor from unloading a model that is in use, and the Overview model-cache card plus a Models badge report the offloaded state. The readiness section of the README now also spells out what Load does and which engines keep nothing resident between requests.
whisper-cli reloads the model and rebuilds the accelerator context on every run, which dominates a short dictation clip. When the build ships whisper-server beside whisper-cli, the engine now starts it on an ephemeral 127.0.0.1 port, keeps the parsed model and the GPU context loaded between requests, and transcribes over /inference. Nothing extra is published and no host port is added. Measured on an M1 Pro with ggml-tiny.en, a warm request drops from ~300 ms to ~90 ms; the saving grows with the model and with a CUDA or Vulkan context. Warmup starts the worker, so Load brings the model into memory instead of only warming the page cache, and Offload model when idle now reaches whisper.cpp: the process is terminated and the next transcription starts it again. The selected output language travels with every request, because the server decodes as English whatever the audio is when the field is absent. A host without whisper-server, a build that cannot serve, or a worker that dies mid-request falls back to one whisper-cli run per transcription, which is what every earlier release did. A port lost between reserving it and binding it is retried on a fresh one, so a race does not retire the fast path for the life of the engine. VOCAGATEWAY_WHISPER_SERVER_BINARY overrides the binary, and all three images now ship whisper-server next to whisper-cli.
✅ Deploy Preview for voca-gateway canceled.
|
There was a problem hiding this comment.
Mr-Sunglasses has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
This was referenced Sep 4, 2026
Every settings write went through _EngineBuilder.swap, which rebuilt the engine and closed the previous one. Choosing an offload interval therefore discarded the very model the setting is about, and the next recording paid a cold load. configure() now compares the settings that actually decide which engine gets built — engine, device, precision, thread count, and each model selection — and a change that touches none of them is persisted without rebuilding. Leases now also protect a swap. Idle offload already waited for them, but a settings change closed the old engine immediately, which could unload the model out from under a request that was still transcribing with it: a lease keeps the engine object it was handed. Engines are retired through a lease book instead, so a replaced engine is closed on the release of its last lease, and one that nothing holds is closed at once as before.
…ree the worker The server path lost the CLI's -nt. whisper-server defaults to no_timestamps=false and, with token_timestamps unset, derives it as !no_timestamps, so response_format=text only hid the timestamps: the worker still decoded them and still ran the token-level pass over the signal. Both fields now travel with every request, and -nt is passed at launch so a default the request somehow misses is already the gateway's. Measured against a real whisper-server (1.9.2, ggml-tiny.en, 10 s clip, Metal) the saving is ~3%; it is decoder-side work, so it should be worth more on a CPU-only host and a larger model. The HTTP client is rewritten on asyncio streams. urllib in a thread could not be cancelled: the socket stayed open until the thread returned, so a timed-out or cancelled clip left the worker decoding audio nobody wanted and the next recording queued behind it. Closing the socket is exactly what the pinned server watches — it passes is_connection_closed() to whisper.cpp's abort callback — so the decode now stops with the request. A worker that does not answer again within a short grace period is terminated and reloaded before the next clip rather than serving it late. This also retires the timeout classification that could hand the same audio to the CLI: a wrapped URLError no longer exists to be misread. A start now records the process the moment it is spawned. Ownership was taken only once the worker answered, so cancelling a load could leave a process that went on reading a whole model into memory with nothing left to stop it. Readiness polling is asynchronous for the same reason, and the child is terminated on cancellation or a failed start. One deadline covers the whole request. The worker start, the inference, and any CLI fallback share it instead of each spending a full timeout while the caller waits. The worker's stderr is captured instead of discarded, which is where whisper.cpp names the ggml backend it selected and the thread count it got; its tail is reported when a build cannot serve the model. Transcripts never reach it — the segment printer writes to stdout, and only under --print-realtime, which the gateway does not pass. VOCAGATEWAY_WHISPER_DECODER_PRESET adds the knob a CPU decoding comparison needs: fast decodes greedily (whisper.cpp picks the greedy sampler whenever the beam size is not above one), quality keeps the narrowed beam search and stays the default until a word error rate comparison on the target machine says otherwise. Temperature fallback stays on in both. On the same local clip greedy was ~10% faster with an equivalent transcript.
… framing
Three defects in the worker rewrite, each verified against a real
whisper-server (1.9.2, ggml-tiny.en) rather than the stand-in.
Terminating a worker mid-decode blocked the event loop for the rest of that
decode. whisper-server catches SIGTERM and shuts down gracefully, so it
waits for the request in flight: measured at 2760 ms against a real one,
against 18 ms for an idle worker. The only caller that terminates a busy
worker is the liveness check, which by then has already established that the
decode will not be given up — so that one path kills instead of asking, and
every other stop stays polite. A test with a SIGTERM-ignoring stand-in fails
if the distinction is lost.
A chunked reply was handed back as the transcript, chunk headers and all:
`14\r\n resident transcript\r\n0`. The pinned server answers from a string
and always sets Content-Length, so nothing hit this in practice, but an
HTTP/1.1 client that ignores Transfer-Encoding is simply wrong. Chunked
replies are now decoded.
A transcript that began with `{` was rejected as an invalid response. The
JSON unwrapping — there for older builds that wrap the text — sniffed the
first character of the payload. It now keys off the response content type,
which is `text/html` for `response_format=text` and `application/json` for
a wrapped one, so a dictated brace stays a transcript.
Two smaller wins measured on the same setup. Readiness polling ran on a flat
100 ms interval, which handed back most of the load it was waiting for;
polling from 5 ms with a backoff to 50 ms cuts a tiny.en cold start from 205
to 185 ms, and is bounded on any model. And the request body no longer joins
the recording into one buffer and then concatenates it onto the head — the
head, the audio, and the trailer are written as they are, so a couple of
minutes of speech is held once rather than three times over.
There was a problem hiding this comment.
Mr-Sunglasses has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
12 tasks
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
Three related changes to when a model is in memory.
Model memory lifecycle — Settings → Speech engine gains Offload model when idle (off by default): a resident model is released after 10, 15, 30, 60, or 120 minutes without a transcription and loads again on the next request. Engines that own a loaded model share a
MemoryResidentEngineprotocol, so the idle monitor unloads faster-whisper, sherpa-onnx, Moonshine, MLX Audio, WhisperKit, and VocaMac's persistent path through one interface. Batch and streaming requests take an engine lease for their duration, so the monitor never unloads a model that is in use. The Overview model-cache card and a Models badge report the offloaded state, and the choice is persisted in the runtime config and carried by/v1/admin/config.Leases now protect an engine swap as well. A settings change used to close the previous engine immediately, which could unload the model out from under a request still transcribing with it — a lease keeps the engine object it was handed. A replaced engine is retired and closed on the release of its last lease; one that nothing holds is closed at once, as before. And
configure()compares the settings that actually decide which engine gets built — engine, device, precision, thread count, and each model selection — so choosing an offload interval no longer discards the very model the setting is about.A resident whisper.cpp worker —
whisper-clireloads the model and rebuilds the accelerator context on every run, which dominates a short dictation clip. When the build shipswhisper-serverbesidewhisper-cli, the engine starts it on an ephemeral127.0.0.1port, keeps the parsed model and the GPU context loaded between requests, and transcribes over/inference. Nothing extra is published and no host port is added. Warmup starts the worker, so Load brings the model into memory rather than only warming the page cache, and idle offload now reaches whisper.cpp too.Measured on an M1 Pro with
ggml-tiny.enand a realwhisper-server, a warm request drops from ~300 ms to ~70 ms; the saving grows with the model size and with a CUDA or Vulkan context, where building the context is the expensive part.Nothing regresses on a host without the binary: a missing
whisper-server, a build that cannot serve, or a worker that dies mid-request falls back to onewhisper-clirun per transcription, exactly as before. A port lost between reserving it and binding it is retried on a fresh one so a race does not retire the fast path, and a read timeout is reported as a slow transcription rather than a dead worker (which would otherwise re-run the same audio and double the wait).The worker holds up under a request that goes wrong — the client is written on asyncio streams rather than urllib in a thread, because a blocking client cannot be cancelled: the socket stayed open until the thread returned, so a timed-out or cancelled clip left the worker decoding audio nobody wanted and the next recording queued behind it. Closing the socket is exactly what the pinned server watches — it passes
is_connection_closed()to whisper.cpp's abort callback — so the decode now stops with the request. A worker that does not answer again within a short grace period is killed and reloaded before the next clip rather than serving it late; it is killed rather than asked politely becausewhisper-servercatches SIGTERM and shuts down gracefully, which means waiting out the decode it is running.A start records the process the moment it is spawned, so cancelling a load can no longer leave a process reading a whole model into memory with nothing left to stop it, and readiness polling is asynchronous for the same reason. One deadline covers the whole request: the worker start, the inference, and any CLI fallback share it instead of each spending a full timeout while the caller waits.
The worker's stderr is captured instead of discarded — that is where whisper.cpp names the ggml backend it selected and the thread count it got — and its tail is reported when a build cannot serve the model. Transcripts never reach it: the segment printer writes to stdout, and only under
--print-realtime, which the gateway does not pass.Decoding parity and a knob for measuring it — the server path had lost the CLI's
-nt.whisper-serverdefaults tono_timestamps=falseand, withtoken_timestampsunset, derives it as!no_timestamps, soresponse_format=textonly hid the timestamps: the worker still decoded them and still ran the token-level pass over the signal. Both fields now travel with every request, and-ntis passed at launch so a default a request somehow misses is already the gateway's.VOCAGATEWAY_WHISPER_DECODER_PRESETadds what a CPU decoding comparison needs:fastdecodes greedily (whisper.cpp picks the greedy sampler whenever the beam size is not above one),qualitykeeps the narrowed beam search and stays the default until a word error rate comparison on the target machine says otherwise. Temperature fallback stays on in both.Behavior note: transcripts are now collapsed to a single line on both whisper.cpp paths — the server prefixes each segment line with a space, and the CLI already emitted one line per segment.
Verification
just testequivalents: ruff check, ruff format --check,flake8 --select=WPS,E999 app, mypy strict, 571 tests,docker compose config,uv sync --locked --checkanduv pip checkwhisper-serverto the binaries copied out of the existing whisper.cpp build, andwhisper-serveris a build target of the pinnedv1.9.1. The Container workflow builds all three accelerators on this PR.docs/configuration.md,docs/troubleshooting.md,.env.example,compose.yaml, and the Settings hintChecked against the pinned whisper.cpp v1.9.1 source:
-bs/-bo,--host/--portand-ntexist; the per-requestlanguage,response_format,no_timestampsandtoken_timestampsfields are read;token_timestampsdefaults to!no_timestamps;autois accepted; inference is serialized behind the server's own mutex, which the handler takes before it looks at the request; andabort_callbackisreq.is_connection_closed().Measured end to end against a real
whisper-server(1.9.2,ggml-tiny.en), not the test stand-in:stop(), idle worker vs. mid-decodeAlso verified live that omitting the language field decodes as English whatever the audio is, which is why it is sent on every request.
Privacy and security