Benchmarks — engine, service, accuracy¶
This document covers the three top-level harnesses:
| Harness | Measures |
|---|---|
run.py --family engine |
In-process ASREngine — pure GPU + Python overhead, no IPC, no HTTP/WS. |
run.py --family service |
End-to-end oasr-server (Rust + HTTP + PyO3 dispatcher + engine). |
run.py --family accuracy |
WER/CER and speed in the same CSV row. |
All three emit the workload schema, so an engine row and a service row are one query apart rather than a CSV-to-JSON join.
For controlled, reproducible measurement across backends — deterministic
workloads drawn from prepared corpora, single-request / batch / closed- and
open-loop load / paced streaming / long-form protocols, resource telemetry,
regression gating and reports — use the ASR inference suite,
asrbench.md (python -m benchmarks.asrbench). The three
harnesses here remain the per-component tools engine work iterates on. benchmarks/bench_engine.py,
bench_service.py and bench_accuracy.py remain as shims into run.py.
Run the first two back-to-back on the same machine for an apples-to-apples comparison — the engine number is the ceiling, the service number is what real clients see.
Kernel-level benchmarking is the same CLI with a kernel family selected —
see benchmarks/README.md and the
/benchmark-kernel skill.
Measurement protocol¶
Three rules, each of which has produced a wrong conclusion when skipped:
- Interleave the arms. A single-order A/B lets the second arm benefit from a warm allocator. Report a σ over several iterations, not one run.
- Watch issue time against wall time. At small batch the encoders are CPU-issue-bound, and a change that removes GPU work can still make them slower.
- Verify a fresh JIT hash directory before trusting a kernel comparison —
rm -rf ~/.cache/oasr/jitafter editing a header that the cache key does not cover. Every run records the hashes it used in<output>.meta.json, so this is checkable after the fact.
Setup¶
-
Install the Python package — this also builds the Rust serving core into
oasr._coreand installs theoasr-serverconsole script (needs a Rust toolchain +protobuf-compileronPATH): -
Copy
.env.exampleto.env, edit the paths, then source it:
The recipes below assume these env vars are exported:
| Variable | Purpose |
|---|---|
CKPT_DIR |
WeNet checkpoint directory (expanded into --ckpt-dir) |
AUDIO_DIR |
Directory of mono 16 kHz .wav files (expanded into --audio-dir) |
OASR_RS_BIN |
Optional override for the oasr-server path (bench_service.py reads it directly); defaults to the oasr-server console script on PATH, then rust/target/release/oasr-server |
NUM_UTTERANCES |
Default for --num-utterances (both scripts) |
MAX_BATCH_SIZE |
Default for --max-batch-size (both scripts) |
CONCURRENCY |
Default for --concurrency (bench_service.py) |
CHUNK_MS |
Default for --chunk-ms (bench_service.py) |
The CLI flag still wins when both are given — .env sets the default, the
flag overrides it for a single run.
Engine benchmark¶
bench_engine.py runs the engine directly — no HTTP, no IPC, just transcribe(...) /
transcribe_offline(...) calls. Use it to characterise the GPU + Python ceiling.
Template — substitute the bracketed placeholders, or drop the flag entirely
to pick up the matching .env default (CLI flag still wins when both are
given):
python benchmarks/run.py --family engine \
--ckpt-dir [CKPT_DIR] \
--audio-dir [AUDIO_DIR] \
--subroutine [offline|streaming|offline_wfst|streaming_wfst] \
--max-batch-size [MAX_BATCH_SIZE] \
--num-utterances [NUM_UTTERANCES] \
--chunk-size [CHUNK_SIZE] \
--dtype [float16|bfloat16|float32] \
--cuda-graphs [on|off]
Concrete invocations with .env sourced:
# Offline — length-bucketed batches
python benchmarks/run.py --family engine \
--ckpt-dir "$CKPT_DIR" --audio-dir "$AUDIO_DIR" \
--subroutines offline \
--max-batch-size "$MAX_BATCH_SIZE" \
--num-utterances "$NUM_UTTERANCES"
# Streaming — interleaved chunk-by-chunk decode, paged KV cache
python benchmarks/run.py --family engine \
--ckpt-dir "$CKPT_DIR" --audio-dir "$AUDIO_DIR" \
--subroutines streaming \
--max-batch-size "$MAX_BATCH_SIZE" \
--num-utterances "$NUM_UTTERANCES"
# WFST decoding (in-tree GPU decoder) — pass the lang dir (contains HLG.pt;
# words.txt beside it provides the word table) or a direct .img/.pt path.
# The HLG.pt is exported to a cached .img next to it on first use.
python benchmarks/run.py --family engine \
--ckpt-dir "$CKPT_DIR" --audio-dir "$AUDIO_DIR" \
--wfst-path /path/to/lang_bpe \
--subroutines offline_wfst streaming_wfst \
--max-batch-size "$MAX_BATCH_SIZE" \
--num-utterances "$NUM_UTTERANCES"
# CUDA-Graph toggle — captured (default) vs eager replay for profiling
python benchmarks/run.py --family engine \
--ckpt-dir "$CKPT_DIR" --audio-dir "$AUDIO_DIR" \
--subroutines streaming --cuda-graphs off
# Export per-subroutine results to CSV
python benchmarks/run.py --family engine \
--ckpt-dir "$CKPT_DIR" --audio-dir "$AUDIO_DIR" \
--output engine_results.csv
The output is one block per --subroutines value:
RTFx is audio_seconds / wall_seconds — higher is faster — and means the same
thing in every harness. The engine harness used to report its reciprocal under
the name rtf while the service harness reported audio/wall under that same
name, so the two columns were inverses of each other.
Throughput / RTF here represent the GPU + scheduler ceiling for this batch / chunk-size config.
Service benchmark¶
bench_service.py auto-spawns oasr-server (resolved via $OASR_RS_BIN, then
the oasr-server console script on PATH, then rust/target/release/oasr-server),
waits for /readyz, drives it with the chosen subroutines, then shuts it
down on exit. Use it to measure the gap closed (or not) by Rust + HTTP/WS.
Template — substitute the bracketed placeholders, or drop the flag entirely
to pick up the matching .env default (CLI flag still wins when both are
given):
python benchmarks/run.py --family service \
--ckpt-dir [CKPT_DIR] \
--audio-dir [AUDIO_DIR] \
--subroutine [offline|streaming|grpc_offline|grpc_streaming|whisper] \
--num-utterances [NUM_UTTERANCES] \
--concurrency [CONCURRENCY] \
--max-batch-size [MAX_BATCH_SIZE] \
--chunk-ms [CHUNK_MS] \
--wire-encoding [f32_le|i16_le] \
--realtime [0|1] \
--dtype [float16|bfloat16|float32]
Concrete invocations with .env sourced:
# Offline (HTTP POST /v1/transcriptions) — i16_le default (halves wire bytes)
python benchmarks/run.py --family service \
--ckpt-dir "$CKPT_DIR" --audio-dir "$AUDIO_DIR" \
--subroutines offline \
--num-utterances "$NUM_UTTERANCES" \
--concurrency "$CONCURRENCY" \
--max-batch-size "$MAX_BATCH_SIZE" \
--wire-encoding i16_le
# Same, explicit f32_le baseline for comparison
python benchmarks/run.py --family service \
--ckpt-dir "$CKPT_DIR" --audio-dir "$AUDIO_DIR" \
--subroutines offline \
--num-utterances "$NUM_UTTERANCES" \
--concurrency "$CONCURRENCY" \
--max-batch-size "$MAX_BATCH_SIZE" \
--wire-encoding f32_le
# WFST decoding end to end (server runs the in-tree GPU WFST decoder):
# --fst-path takes a prebuilt .img or a k2 HLG.pt (exported + cached on first
# use); the words.txt beside it provides the word table. Works for the
# grpc_offline / grpc_streaming subroutines the same way.
python benchmarks/run.py --family service \
--ckpt-dir "$CKPT_DIR" --audio-dir "$AUDIO_DIR" \
--subroutines offline \
--decoder-type ctc_wfst \
--fst-path /path/to/lang_bpe/HLG.pt \
--num-utterances "$NUM_UTTERANCES" \
--concurrency "$CONCURRENCY" \
--max-batch-size "$MAX_BATCH_SIZE"
# Streaming (WS /v1/stream) — no realtime pacing for max-rate test
python benchmarks/run.py --family service \
--ckpt-dir "$CKPT_DIR" --audio-dir "$AUDIO_DIR" \
--subroutines streaming --realtime 0 \
--num-utterances "$NUM_UTTERANCES" \
--concurrency "$CONCURRENCY" \
--max-batch-size "$MAX_BATCH_SIZE" \
--chunk-ms "$CHUNK_MS" \
--wire-encoding i16_le
# Streaming under live-mic pacing (each chunk waits chunk-ms wall-time)
python benchmarks/run.py --family service \
--ckpt-dir "$CKPT_DIR" --audio-dir "$AUDIO_DIR" \
--subroutines streaming --realtime 1 \
--num-utterances "$NUM_UTTERANCES" \
--concurrency "$CONCURRENCY" \
--chunk-ms "$CHUNK_MS"
# gRPC variants — same args, different subroutine
python benchmarks/run.py --family service \
--ckpt-dir "$CKPT_DIR" --audio-dir "$AUDIO_DIR" \
--subroutines grpc_offline \
--num-utterances "$NUM_UTTERANCES" --concurrency "$CONCURRENCY"
python benchmarks/run.py --family service \
--ckpt-dir "$CKPT_DIR" --audio-dir "$AUDIO_DIR" \
--subroutines grpc_streaming --realtime 0 \
--num-utterances "$NUM_UTTERANCES" --concurrency "$CONCURRENCY" \
--chunk-ms "$CHUNK_MS"
The summary block reports requests, wall, audio, RTF (audio_s / wall_s),
throughput, latency percentiles, and (streaming only) first-partial time +
partials per request. The numbers below illustrate the format — they came
from one run on one box and are not a target:
streaming (WS /v1/stream, chunk=640ms, i16_le):
requests ok=2000 rejected=0 fail=0
wall 27.71 s
audio 13362.03 s
RTF 482.29x (audio_seconds / wall_seconds — higher is faster)
throughput 72.19 req/s
latency mean=871 ms p50=604 p90=1647 p95=2475 p99=5412 max=6044
first-partial mean=270 ms p50=132 p95=599
partials/req mean=10.8
Accuracy benchmark¶
benchmarks/bench_accuracy.py is the accuracy counterpart: manifest-driven
WER/CER with RTFx, throughput, p50 and p99 in the same CSV row, so accuracy
and speed are visible together.
# Build a manifest from a corpus (audio is not shipped with the repo)
python benchmarks/bench_accuracy.py --build-manifest \
--audio-dir "$AUDIO_DIR" --out benchmarks/manifests/my_corpus.jsonl
# Measure
python benchmarks/run.py --family accuracy \
--ckpt-dir "$CKPT_DIR" --manifest benchmarks/manifests/my_corpus.jsonl \
--output accuracy.csv
- WER is the corpus rate (total errors / total reference words), with
Whisper
EnglishTextNormalizersemantics —oasr/testing/wer.py. - Manifests under
benchmarks/manifests/ship without audio; build one for your local corpus with--build-manifest. --build-manifestunwraps(...), because the English normalizer deletes bracketed spans while a reader speaks them aloud. Skipping that step is pure measurement error.oasr/testing/accuracy.pyholds the manifest and transcribe helpers shared with the CI accuracy gate (tests/accuracy/test_accuracy.py, reference rates inci/wer-reference.json).--decode-option k=v(repeatable) reaches the decode family's own knobs, and the CSV carries them, so two rows that differ only by an option stay tellable apart. This is how a storage or capture path is checked at corpus scale rather than tensor by tensor:
python benchmarks/bench_accuracy.py --ckpt-dir "$SPEECH_LLM_CKPT" \
--manifest benchmarks/manifests/ljspeech_200.jsonl --audio-root "$WAV_DIR" \
--dtype bfloat16 --decode-option kv_storage=dense --decode-option step_graphs=0
An entry in ci/wer-reference.json may pin the same options, which the llm
row does for its prompt: for a speech-LLM the prompt is part of the decode
configuration, and changing it moves the WER with no defect behind it.
- --vad-mode and --vad-backend are sweep axes too, with --vad-model-dir
and a repeatable --vad-option k=v; the cell is a CSV column and each row
gets its own --save-transcripts file, so "did the transcript change?" is a
diff:
python benchmarks/bench_accuracy.py --ckpt-dir "$CKPT_DIR" \
--manifest benchmarks/manifests/ljspeech_200.jsonl --audio-root "$WAV_DIR" \
--vad-mode off segment --vad-backend energy silero
A corpus of short utterances measures almost nothing here: the splitter
declines to cut audio one padded span already covers, so segment scores
identically to off — a useful negative control, not evidence that
segmentation works. Verify that on long-form audio, and read the segment
count and the seconds dropped beside the rate.
Notes¶
--service-modeonbench_service.pyis auto-derived from--subroutines(offline / whisper / grpc_offline →offline; streaming / grpc_streaming →streaming). Mixed subroutine sets are rejected.- An explicit-only architecture needs
--architecture(e.g.--architecture transducer): the harness passes it to the spawned server through--engine-config. Without it an icefall pruned-RNNT directory sniffs aszipformerand every request fails. - gRPC requests carry no
language_codeunless--language-codeis given. Only a family with a language control (Whisper, speech-LLM) accepts one; the others reject it at admission, so the old fixeden-USfailed every gRPC request on them. Pass--language-code en-USfor Whisper to keep its old behaviour. - The client is one asyncio process (request bodies are encoded before the
clock starts). On a fast model it, not the server, is the ceiling — about
2,400x RTFx over HTTP on an RTX 5090 against a server that sustains several
times that. To measure the server itself, drive it from several client
processes. Clients that reconnect in bursts also need the server's
--listen-backlog(default 4096; it was 128): past the accept queue each new connection waits out a 1 s SYN retransmit. --num-workers > 1is rejected —oasr-serveris now one-process-per-GPU. For multi-GPU, launch Noasr-serverprocesses manually with distinct--http-bind/--grpc-bind+CUDA_VISIBLE_DEVICES.- Wire encoding
i16_lehalves the HTTP/WS bytes vsf32_le. Server-side decode (oasr-asr::decode_raw_pcm) widens i16 back to f32 by dividing by 32768, matching the bench's scale-by-32767 encode (one count short of saturation at ±1.0). - For Nsight-Compute kernel profiling, see
benchmarks/run.py --profileand the/benchmark-kernelskill. - Point-in-time results — engine, serving, decoders and kernels — live under
.artifacts/(engine_perf.md,serving_perf.md,decoder_perf.md,fmha_tuning.md,gemm_tuning.md,profiling_report.md). Record new measurements there, not in this file.