DEV Community

isabelle dubuis
isabelle dubuis

Posted on

Streaming ASR on Consumer CPUs: What Broke Our PyCon Demo and How We Fixed It

During a live product demo at PyCon 2024, our on‑stage model stalled at 2.3 seconds of audio, turning a 15‑second demo into a 45‑second freeze.

The hardware mismatch: GPU‑centric models on a laptop CPU

Why the default TensorRT build fails on Intel i5‑12400

Our pipeline was built around a TensorRT engine optimized for an NVIDIA RTX 3080. The moment we dropped the laptop into a conference room, the Intel i5‑12400 stared back with no discrete GPU. TensorRT fell back to a CPU implementation that still expected GPU‑style memory layouts, causing massive cache thrashing.

Profiling the CPU‑only path

perf record -g -- python run_asr.py showed the bulk of time spent in gemm kernels that were simply not vectorized for AVX2. The measured latency was 187 ms per 100 ms audio chunk, a real‑time factor of 1.87×.

Swap experiment – we replaced the TensorRT engine with an ONNX Runtime CPU execution provider. The same 30‑second utterance dropped from 2.4 s to 1.1 s. The gain came from ONNX Runtime’s native thread‑pool and MKL‑based matmuls that respect the CPU’s cache hierarchy.

Batch size vs. streaming window: the hidden latency killer

Static 32‑frame batches vs. dynamic 10‑frame windows

Our original code accumulated 32 frames (≈320 ms at 100 fps) before feeding them to the model. This static batch kept the GPU busy but forced the streaming loop to wait for the full buffer, inflating end‑to‑end latency.

Impact on GPU occupancy

When we switched to a sliding window of 10 frames (≈100 ms), the GPU occupancy dropped to ~45 % but the overall latency halved. The data point: reducing batch size from 32 to 8 cut average end‑to‑end latency from 112 ms to 48 ms per chunk.

On a MacBook Air M2, a 5‑minute podcast stayed under the 100 ms budget only after we implemented that 10‑frame sliding window. The GPU stayed warm, but the CPU‑driven decoder never stalled.

Memory bandwidth throttling on integrated graphics

Shared‑memory contention with the OS

Integrated GPUs on laptops share the system’s DDR4/LPDDR5 bus with the CPU. Our model streamed 16‑bit floats at 4 bytes per sample, saturating ~8 GB/s of bandwidth and leaving the CPU starving for cache lines.

Quantization as a bandwidth lever

We quantized the encoder to 8‑bit using ONNX Runtime’s static quantizer. The result: 42 % of memory bandwidth freed, and GPU latency fell from 73 ms to 41 ms per chunk.

On a Raspberry Pi 4 paired with a Coral Edge TPU, the quantized model kept the CPU idle, extending battery life from 3.2 h to 5.7 h during continuous dictation. The Edge TPU’s on‑chip SRAM handled the 8‑bit weights without ever hitting the shared bus.

Audio front‑end mismatches: sample rate conversion overhead

48 kHz microphone vs. 16 kHz model input

Our demo microphone captured at 48 kHz, but the acoustic model was trained on 16 kHz audio. Each 100 ms chunk triggered a libsamplerate resample, adding 23 ms of pure conversion time.

Using libsamplerate vs. ffmpeg

Switching to ffmpeg’s -ar 16000 flag reduced the per‑chunk penalty to 8 ms, but the cleanest fix was to capture natively at 16 kHz. The data point: native 16 kHz capture avoided a 23 ms conversion penalty per 100 ms chunk.

On a Windows 11 tablet, setting the mic driver to 16 kHz eliminated a jitter spike that had been inflating the word‑error‑rate (WER) by 6 %. The improvement is documented in the ASR literature (Wikipedia).

Power management: why the OS throttles your ASR thread

CPU governor settings

By default, many laptops run the “powersave” governor, scaling frequency down after a few seconds of idle. Our ASR thread was silently throttled, adding 31 ms of latency per chunk.

Real‑time priority tricks

We set the governor to ‘performance’ via cpupower frequency-set -g performance and gave the process SCHED_FIFO priority. The data point: setting the governor to 'performance' reduced average latency by 31 ms (15 % improvement) on a Dell XPS 13.

A stray Chrome tab was pulling the CPU into ‘powersave’. Using perf stat we spotted the governor switch, then added a small systemd service that pins the ASR process to core 0 and forces the governor back to performance. The fix was invisible to the user but saved the demo.

Open‑source lessons: community patches that saved the demo

Pull request #342 fixing thread pinning

A contributor added pthread_setaffinity_np calls to pin the encoder and decoder threads. Merging PR #342 cut the cold‑start time from 1.9 s to 0.7 s on a Surface Pro 7.

Fork that adds ONNX Runtime WebGPU backend

Another fork introduced a WebGPU execution provider for ONNX Runtime. After pulling it in, the same model ran in Chrome at 63 ms latency per chunk, matching native CPU performance while offloading work to the GPU without any driver gymnastics.

After 6 months running this in production at our voice platform, the latency budget broke down like this:

Config Latency (ms) CPU % Battery drain (mW)
TensorRT GPU (fallback CPU) 112 78 820
ONNX Runtime CPU 87 65 730
Quantized ONNX CPU 49 42 460
WebGPU in‑browser (ONNX) 63 38 500
#!/usr/bin/env bash
# Example: log latency, CPU, and power for a given config
CONFIG=$1   # e.g., trt, onnx_cpu, quant_cpu, webgpu
CMD="python run_asr.py --config $CONFIG"
perf stat -e cycles,instructions,cache-references,cache-misses -r 5 $CMD 2>&1 |
  grep -E 'cycles|instructions|cache' > perf_${CONFIG}.log
powertop --csv=powertop_${CONFIG}.csv --duration 30 &
sleep 35
kill $!   # stop powertop
echo "Metrics for $CONFIG saved."
Enter fullscreen mode Exit fullscreen mode

Takeaway

If you want sub‑100 ms streaming ASR on any consumer laptop, drop the heavyweight GPU pipeline, tune batch windows, quantize to 8‑bit, and lock the CPU governor—otherwise you’ll spend twice the budget fixing a broken demo.

Top comments (0)