Skip to content

perf: cut CPU Whisper latency and prune the catalog by measured WER - #47

Merged
Mr-Sunglasses merged 3 commits into
mainfrom
perf/cpu-whisper-decoding-and-catalog-audit
Sep 3, 2026
Merged

perf: cut CPU Whisper latency and prune the catalog by measured WER#47
Mr-Sunglasses merged 3 commits into
mainfrom
perf/cpu-whisper-decoding-and-catalog-audit

Conversation

@Mr-Sunglasses

@Mr-Sunglasses Mr-Sunglasses commented Sep 3, 2026

Copy link
Copy Markdown
Member

Why

Two problems, found while looking at why Whisper is slow on a machine without a GPU.

whisper.cpp was running on whisper-cli's own defaults — 4 threads and beam 5 / best-of 5, which are batch-transcription settings — and it ignored the WebUI's CPU threads box entirely. That box only ever reached faster-whisper and sherpa-onnx.

The catalog carried tiers that nothing measures well. It also had a real gap: the fastest multilingual Whisper a CPU-only host could pick was small.

Performance

Measured on a 17 s clip, ggml-tiny.en, GPU disabled, page cache warmed:

wall time
before (-t 4, beam 5, best-of 5) 1.83 s
after (-t <cores>, beam 2, best-of 2) 0.77 s

Same transcript. Full greedy was 0.67 s but measurably degraded the text, so it was rejected. Temperature fallback stays on — it is what rescues a segment from a repetition loop, and disabling it only bought 0.03 s.

Other changes on that path:

  • One thread policy (system.inference_thread_count) replacing a min(os.cpu_count(), 8) duplicated across two engines. It counts physical cores — performance cores on Apple silicon, 8 rather than 10 on an M1 Pro — and clamps to the cgroup quota. The old code read the host's core count inside a container and oversubscribed the quota.
  • faster-whisper skips silence with Silero VAD and stops decoding timestamp tokens nothing downstream reads. An empty VAD pass retries the full clip, so a quiet recording cannot turn into a failure.
  • sherpa-onnx builds its waveform with numpy instead of one Python float per sample (~½ million short-lived objects per 30 s clip), which also matches the “1-D float32 tensor” the binding documents.
  • Event-loop stall fixed: FFmpegNormalizer read the whole PCM file and summed its squares on the event loop, stalling every concurrent request. Now on a worker thread, and vectorized where numpy is present — proved numerically identical to the old formula, with both branches tested.
  • installed_path() walked and sized every installed model to return one path it never used a size from. Now O(1) from the catalog index.

Catalog

Retirements are backed by published measurements, not by download size. Retired entries stay listed and manageable when installed and point at a replacement; nothing is deleted from disk.

Retired Replaced by Evidence
WhisperKit Large v3 Turbo (1610 MB) _626MB compressed Argmax runs both over the same 2,620 LibriSpeech utterances: 2.40% vs 2.49% WER. 0.08 points for 984 MB.
WhisperKit Small (484 MB) _626MB compressed 3.95% vs 2.49% WER for 142 MB more. Its RAM floor drops to 8 GB to cover that tier.
whisper.cpp Medium / Medium EN Large v3 Turbo Turbo is smaller and better than Medium.
whisper.cpp Large v3 (3 GB, 24 GB RAM) faster-whisper:large-v3 Same weights, resident and INT8, instead of a CLI that reloads 3 GB per request.
faster-whisper Distil Large v3 Distil Large v3.5 Same size; 5.40 avg WER, ahead of full Large v3's 5.78.

Added (all verified against the HF API for real sizes and licences): faster-whisper:large-v3-turbo, large-v3, medium, medium.en, distil-large-v3.5. Large v3 Turbo is the important one — it keeps Large v3's encoder and swaps its 32-layer decoder for four, and is now recommended for non-Apple hosts.

Also corrected a false accuracy claim. Turbo entries advertised "Most accurate", but the Open ASR Leaderboard puts Turbo at 6.36 against full Large v3's 5.78 — and the catalog offers both, plus the Q5 build of the full weights. The label sent people to the least accurate of the three.

Deliberately not changed

  • whisper.cpp's per-request model reload. Measured: 46 ms of 745 ms for tiny.en, 268 ms of 6,870 ms for a 574 MB model — the load fraction shrinks as models grow, to ~4%. A persistent whisper-server (port, lifecycle, health, crash recovery) is not worth 4%.
  • Moonshine and sherpa-onnx entries. Every one holds a Pareto point. Notably batch Moonshine Tiny (11.78 WER) beats streaming Tiny (12.36), so that pair is a real frontier, not duplication.
  • .en variants. Separately trained weights, measurably better on English (3.56% vs 3.95%), not a second quantization. There is now a test asserting they survive.

Testing

ruff format, ruff check, flake8 --select=WPS, mypy --strict clean. 540 tests pass (up from 508). Pins and docs/models.md regenerated, both enforced by tests.

New invariants: no recommendation may point at a retired model; every replacement id must resolve to an active one; the .en pairs must both survive; no Turbo build may claim the top accuracy label; and installed_path must agree with the full scan.

Verified end-to-end against the real ffmpeg and whisper-cli binaries, including that silent audio is still rejected.

Known follow-up

whisper.cpp returns a bare string, so the pipeline records model_load_ms = 0 for it — the Pair & test benchmark therefore shows zero model load even though it reloads every request. Pre-existing, but adjacent to this work and worth fixing separately.

Review follow-up (second commit)

A review pass after the first commit found two things, both since fixed:

  • A bogus vCPU topology could run everything single-threaded. _linux_physical_cpus fell back to the logical count only when /proc/cpuinfo reported no topology, not a degenerate one. Some hypervisors give every vCPU the same (physical id, core id) pair, which parses as exactly one core — so a 16-vCPU guest would have run at 1 thread instead of 8, silently, and slower than the code this replaces. A count implying more than two threads per core is now treated as a floor rather than believed. Reproduced in a test before fixing.
  • The sherpa-onnx numpy import rested on a false claim. Its docstring said numpy ships with sherpa-onnx; it does not — sherpa-onnx declares only sherpa-onnx-core, and numpy arrives via faster-whisper → onnxruntime. The guarantee held only because the engines extra is monolithic. That extra now declares numpy outright. app.audio keeps its stdlib fallback, since a core install genuinely has no numpy.

543 tests pass.

whisper.cpp ran on whisper-cli's own defaults — 4 threads and beam 5 /
best-of 5, which are batch-transcription settings — and ignored the WebUI's
CPU threads box entirely. Measured on a 17 s clip with ggml-tiny.en and the
GPU disabled, asking for the machine's cores and a narrower beam takes the
run from 1.83 s to 0.77 s with no change to the transcript. Temperature
fallback stays on: it is what rescues a segment from a repetition loop.

Thread counts now come from one policy in app.system — physical performance
cores, clamped by the cgroup quota. The old min(os.cpu_count(), 8) read the
host's core count inside a container and oversubscribed the quota, and it
spread work across hyperthread siblings that then held the batch back.

faster-whisper now skips silence with Silero VAD and stops decoding
timestamp tokens nothing downstream reads, retrying without VAD when VAD
finds no speech so a quiet clip cannot turn into a failure. sherpa-onnx
builds its waveform with numpy rather than one Python float per sample.

Two pipeline fixes outside the engines: FFmpegNormalizer read the whole PCM
file and summed its squares on the event loop, stalling every concurrent
request in flight; that now runs on a worker and is vectorized where numpy
is present. ModelManager.installed_path walked and sized every installed
model to return a single path, and now resolves it from the catalog index.

Catalog changes are backed by the Open ASR Leaderboard and Argmax's own
WhisperKit evaluation rather than by download size alone:

- Retire WhisperKit Large v3 Turbo (1610 MB). Argmax runs both builds over
  the same 2,620 LibriSpeech utterances: 2.40% WER against 2.49% for the
  626 MB compressed build. Eight hundredths of a point is not worth 984 MB.
- Retire WhisperKit Small (3.95% WER); the compressed Large v3 beats it
  outright for 142 MB more, and its RAM floor drops to 8 GB to cover that
  tier.
- Retire whisper.cpp Medium, Medium EN and the 3 GB Large v3.
- Retire faster-whisper Distil Large v3 for v3.5, the same size at 5.40
  average WER — ahead of full Large v3's 5.78.
- Add faster-whisper Large v3 Turbo, Large v3, Medium, Medium EN and Distil
  Large v3.5. Before this the fastest multilingual Whisper a CPU-only host
  could choose was `small`.
- Stop Turbo entries claiming to be the most accurate. The leaderboard puts
  Turbo at 6.36 against full Large v3's 5.78, and the catalog offers both.

Retired entries stay listed and manageable when installed and point at a
replacement; nothing is deleted from disk. Pins and docs/models.md are
regenerated, and the pin harvester no longer writes pins for models that can
never be downloaded again.
@netlify

netlify Bot commented Sep 3, 2026

Copy link
Copy Markdown

Deploy Preview for voca-gateway canceled.

Name Link
🔨 Latest commit 697db33
🔍 Latest deploy log https://app.netlify.com/projects/voca-gateway/deploys/6a99dbc9da621b0008c4ef4e

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mr-Sunglasses has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

…need

Two follow-ups from review of the preceding commit.

`_linux_physical_cpus` fell back to the logical count only when /proc/cpuinfo
reported no topology at all, not when it reported a degenerate one. Some
hypervisors give every vCPU the same (physical id, core id) pair, which parses
as exactly one core — so a 16-vCPU guest would have run faster-whisper,
sherpa-onnx and whisper.cpp single-threaded, several times slower than the
min(os.cpu_count(), 8) this replaced, and silently. No CPU this gateway targets
runs more than two threads per core, so a count implying otherwise is now taken
as a floor rather than believed. A processor block that omits its own physical
id also no longer inherits the previous block's.

The sherpa-onnx waveform reader imports numpy unguarded, on a docstring claim
that numpy ships with sherpa-onnx. It does not: sherpa-onnx declares only
sherpa-onnx-core, and numpy arrives by way of faster-whisper's onnxruntime.
The guarantee held only because the engines extra happens to be monolithic, so
the extra now declares numpy outright and the docstring says where it comes
from. app.audio keeps its stdlib fallback, since a core install still has no
numpy and is not meant to.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mr-Sunglasses has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

CI type-checks and runs the whole suite against `uv sync --all-groups`, which
installs no extras, while `just sync` adds `--all-extras` locally. numpy only
ships with the `engines` extra, so the local gate was weaker than CI's and the
preceding commit broke the CI environment two ways.

`app/models/sherpa_onnx.py` imported numpy unguarded. mypy could not resolve
it, and tests do reach `_read_wave_samples` through `transcribe` and
`_decode_wave_online`, so the suite would have failed there once mypy passed.
It now falls back to the stdlib comprehension it replaced, exactly as
`app.audio` already does for its RMS gate, and a test pins the two paths to the
same waveform for mono and multi-channel input. Slicing by the channel stride
unconditionally also drops a branch: `[::1]` is a no-op.

numpy joins the optional-extra modules in the mypy overrides for the same
reason `faster_whisper` and `sherpa_onnx` are already there. It stays declared
in the `engines` extra so real deployments keep the vectorized path, but the
code no longer depends on that being true.

Verified against both environments: `uv sync --locked --all-groups` and
`uv sync --all-groups --all-extras` each pass ruff, flake8, mypy and pytest.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mr-Sunglasses has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@Mr-Sunglasses
Mr-Sunglasses merged commit f4baec5 into main Sep 3, 2026
8 checks passed
@Mr-Sunglasses
Mr-Sunglasses deleted the perf/cpu-whisper-decoding-and-catalog-audit branch September 3, 2026 20:50
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.

1 participant