Hosting Nemotron ASR Streaming 0.6B for a Voice Agent on a CPU
Our website copilot Waffles uses Cartesia Ink-2 STT. On 4 live calls it took 1.1 s from the end of speech to the first byte of text. That is 59 % of the 1.9 s the caller waits before the first audio comes back, the gap our voice agent latency budget splits. The LLM and the TTS on rented 3090s take 0.4 and 0.3 s of the rest. The LLM is Gemma 4 26B-A4B on a rented RTX 3090. The recognizer is the slow part of the chain.
NVIDIA ships Nemotron ASR Streaming, an English streaming recognizer at 0.6B parameters, and sherpa-onnx ships it as an int8 export that runs on a CPU. We put it on a 6-core desktop and measured how many callers it serves and how long the final transcript takes. Self-hosting streaming STT measured the same end-of-speech to final transcript number on a GPU.
The outcome
On a desktop with a Ryzen 7500F, 6 cores and 32 GB, in a container, no card. The export is the sherpa-onnx int8 asset of nvidia/nemotron-speech-streaming-en-0.6b for the 1120 ms chunk, 464 MB, on sherpa-onnx 1.13.8 and onnxruntime 1.28.2:
sherpa_onnx.OnlineRecognizer.from_transducer(
tokens="tokens.txt", encoder="encoder.int8.onnx",
decoder="decoder.int8.onnx", joiner="joiner.int8.onnx",
num_threads=6, sample_rate=16000, feature_dim=128,
enable_endpoint_detection=False, decoding_method="greedy_search",
blank_penalty=1.0, provider="cpu:session.conf")
session.conf holds one line, session.intra_op.spin_duration_us=200. The served config is the 1120 ms chunk, 6 threads per stream and 0.5 s of preroll. One socket per call, one recognizer stream per turn.
Two measures repeat below. The final latency is the time from the end of the turn to the final transcript. The real time factor, RTF, is processing time over audio time. At speech speed the client sends audio as fast as it is spoken. Unpaced, it sends with no wait, so the RTF is the processing cost per stream.
| Metric | Result | Context |
|---|---|---|
| Memory | 1.0 GB resident | 977 to 1,136 MB after the load. 4 streams add about 100 MB. Mac |
| Final latency, 1 caller | 58 ms p50, 63 ms p90 | 24 clips at speech speed. Two runs of one config differ by 2.5 ms at p50 |
| Final latency, 3 callers | 62 ms p50, 74 ms p90 | 300 clips, 2,193 s of audio |
| Final latency, 12 callers | 66 ms p50, 212 ms p90 | 300 clips. The count we serve |
| Final latency, 24 callers | 125 ms p50, 575 ms p90 | 48 clips. RTF 1.025 at speech speed |
| Breaking point | between 24 and 48 callers | at 48 the RTF at speech speed is 1.517 and the final takes 2,648 ms. 4 threads |
| RTF, 1 caller, unpaced | 0.067 | 14.9 times speech speed |
| RTF, 12 callers, unpaced | 0.327 per stream | 36.7 times speech speed in total |
| First partial | 1,301 ms after the audio starts | 1 caller, the 16.7 s test clip of the export. The clock holds the 500 ms of preroll. 1,620 ms p50 on 300 other clips at 12 callers |
| Finalization floor | 45 ms | the 80 and 160 ms exports, in process. 75 ms at 1120 ms |
| Word error rate | 3.1 % mean, 0.0 % median | 300 LibriSpeech test-clean clips. The same at 3 and at 12 callers |
| Flush | 28 words with 1 chunk of padding, 28 with 4 | at every caller count. An end of turn with no speech answers with an empty final. A turn that opens 500 ms late keeps its first word |
| Rented recognizer | 1,125 ms mean, 1,544 ms max | 4 live calls, the first byte. The 66 ms final is 17 times under it |
| Cost | 0.176 core-seconds per audio second | 2 threads, 1 caller, mac. 0.364 at 4 threads for the same wall time. No rented host, section 8 |
The bench feeds each clip over the socket in 20 ms frames at speech speed, then ends the turn, on the host that serves, over loopback. The 24 clip set holds the p50 within 2.5 ms of the 300 clip set and understates the p90 by 27 ms.
1. The chunk size
The encoder is cache-aware. It takes one chunk of audio, reads 90 ms past it for right context, and carries its state to the next chunk. The export bakes the chunk in, so one chunk size is one asset of 464 MB and a change of chunk is a new pull. The English release ships 80, 160, 560 and 1120 ms. The 40-language release, 3.5, ships those and 320 ms.
The chunk sets 3 things. The client sees no partial until the first chunk fills. Finalization, the last encoder call after the turn ends, runs over one chunk. And the processing time per second of audio rises as the chunk falls. In process, 4 threads, the 16.71 s test clip of the export, medians of 3 to 5 repetitions:
| Model | Chunk | Finalization, last sample to final | Processing per second of audio | Word error rate, test clip |
|---|---|---|---|---|
| en | 1,120 ms | 75.3 ms | 63.0 ms | 6.93 % |
| 3.5 | 1,120 ms | 73.8 ms | 61.3 ms | 7.91 % |
| 3.5 | 560 ms | 58.9 ms | 96.7 ms | 7.99 % |
| 3.5 | 320 ms | 47.0 ms | 145.4 ms | 8.27 % |
| 3.5 | 160 ms | 45.1 ms | 274.6 ms | 8.88 % |
| 3.5 | 80 ms | 45.0 ms | 645.6 ms | 9.43 % |
Finalization is one encoder call. The chunk times the processing time per millisecond predicts it within 7 %. It has a floor of 45 ms, and the 80 ms export cuts 0.1 ms off the 160 ms export for 2.4 times the processing. From 1120 ms down to 80 ms the processing per second of audio rises 10.5 times.
The English model beats the 3.5 model by about one point at every shared chunk, on this clip and on the published rows. The 3.5 release holds the only 320 ms export, and its word error rate there is worse than the English 160 ms export.
The same finalization reads 73.8 ms in process and 73 ms over the websocket, on one clip.
Another process held 1 to 2 cores for part of this run, so the rows give the shape. Section 7 re-measures 560 against 1120 over the socket on 300 clips.
2. The turn
The client sends a start-of-turn message, then signed 16-bit mono at 16 kHz in 20 ms frames, then an end-of-turn message. The endpoint answers each partial transcript and one final per turn. The client sets the turn boundary. The recognizer has an endpoint detector and we keep it off, because the agent already decides when the caller has stopped.
One recognizer stream serves one turn. The encoder carries state from chunk to chunk, so a stream cut inside an utterance drops the left context, and a cut inside a word loses the last words. A cut at an utterance end loses no words. The rule for the agent: cut at an utterance end.
The voice activity detector on the agent reports speech late. So audio flows for the whole call, and the endpoint keeps the last 0.5 s before the start of the turn and feeds it into the new stream first. The check: a turn that opens 500 ms late keeps its first word.
At the end of the turn the endpoint feeds one chunk of silence and then ends the input. Without the padding the last partial chunk never reaches the encoder and the final loses its last words. One chunk of padding against four on 40 clips loses 0 words at every chunk. The text differs on 0 clips at 1120 ms, 1 at 560 and 2 at 160. The 90 ms right context of the export does not change the final. An end of turn with no speech answers with an empty final, at every caller count.
The endpoint takes a bearer key, and a connection without the key closes with code 1008. There is no TLS. The key and the audio cross the network in the clear.
3. Threads
The thread count is the onnxruntime intra-op count of one stream, the threads one encoder call spreads over. Each connection runs its decode in its own worker thread, so 12 callers at 6 threads ask for 72 threads on 12 hardware threads. 1120 ms chunk, 24 clips, no session option, one repetition per point:
| Threads | 1 caller, final p50 | 1 caller, p90 | 12 callers, final p50 | 12 callers, p90 | 12 callers, RTF per stream, unpaced |
|---|---|---|---|---|---|
| 1 | 196.0 ms | 292 ms | 200.5 ms | 325 ms | 0.321 |
| 2 | 114.0 ms | 125 ms | 115.5 ms | 275 ms | 0.333 |
| 3 | 86.0 ms | 92 ms | 87.0 ms | 247 ms | 0.342 |
| 4 | 71.0 ms | 80 ms | 81.0 ms | 237 ms | 0.354 |
| 6 | 61.5 ms | 80 ms | 85.0 ms | 243 ms | 0.391 |
| 8 | 72.0 ms | 88 ms | 79.5 ms | 316 ms | 0.480 |
| 12 | 67.0 ms | 78 ms | 121.5 ms | 422 ms | 0.564 |
The final latency falls with the thread count and flattens: 196, 114, 86, 71 and 61.5 ms from 1 to 6 threads. It stops falling at 6, the core count. 8 and 12 threads lose at 1 caller, and at 12 callers their p90 reads 316 and 422 ms against 237. The oversubscription at 6 threads and 12 callers does not invert the order.
The table ranks latency and hides the price of a thread. Two runs of one config differ by 2.5 ms at 1 caller and by 16.5 ms at 12, so a 1 ms gap in the table is noise. On the mac, 2 threads cost half the processor of 4 at 1 caller for the same wall time, section 8. We run 6 on the desktop.
4. The runtime session file
An onnxruntime thread pool spins after a task, waiting for the next one, before it blocks. By default the spin runs a fixed iteration count, and its length in time follows the CPU. spin_duration_us sets the length in time instead, and the onnxruntime 1.28.2 session option header names 500 to 2,000 us for client workloads. The settings are session options, and sherpa-onnx gives no argument for them. Its provider string takes a path after a colon, cpu:session.conf, and reads one key per line from the file. A key under the SessionConfig prefix reaches onnxruntime. An unknown key drops without a message. The positive control is ProfilingFilePrefix: it writes one profile per graph, and 3 files appear, for the encoder, the decoder and the joiner.
1120 ms chunk, 4 threads, 12 callers, 24 clips, one repetition per row:
| Option | Final p50 | Final p90 | RTF per stream, unpaced |
|---|---|---|---|
| none | 73.0 ms | 220 ms | 0.345 |
| intra_op.allow_spinning=0 | 86.0 ms | 232 ms | 0.324 |
| force_spinning_stop=1 | 105.0 ms | 241 ms | 0.323 |
| intra_op.spin_duration_us=200 | 73.0 ms | 228 ms | 0.318 |
| dynamic_block_base=1 | 76.0 ms | 252 ms | 0.354 |
| dynamic_block_base=4 | 90.0 ms | 241 ms | 0.349 |
Spin off and spin stop cut the processing 6 % and add 13 to 32 ms to the final latency. The 2 block sizes add 3 and 17 ms and cut no processing. A 200 us spin cuts the processing and keeps the latency, so we repeated it, one row per run:
| Option | Callers | Final p50 | Final p90 | RTF per stream, unpaced |
|---|---|---|---|---|
| none | 12 | 74.0, 72.5 ms | 232, 256 ms | 0.339, 0.339 |
| spin 200 us | 12 | 78.5, 73.0 ms | 223, 235 ms | 0.318, 0.324 |
| none | 24 | 177.0 ms | 641 ms | 0.645 |
| spin 200 us | 24 | 118.5 ms | 506 ms | 0.583 |
The option and the thread count interact. At 4 threads it cuts the processing 8 %. At 6 threads it cuts 25 %, 0.391 to 0.295 at 12 callers. We serve 6 threads and the option. Against 4 threads and no option, that cuts the final latency 21 % at 1 caller, 73.5 to 58 ms, and the processing 21 %, 0.085 to 0.067.
5. Callers per host
One recognizer serves every connection, and each connection decodes in its own thread. The cost comes with the streams that hold a full chunk at the same time, because each one calls the encoder. The bench refuses a run with a 1 minute load over 1.0. The other containers on the host sit idle under 1.3 % of one core.
1120 ms chunk, 4 threads, one repetition per point. 24 clips up to 12 callers and 48 clips from 12, so every point runs full batches:
| Callers | Final p50 | Final p90 | RTF at speech speed | RTF per stream, unpaced |
|---|---|---|---|---|
| 1 | 73.5 ms | 93 ms | 1.012 | 0.085 |
| 2 | 72.0 ms | 77 ms | 1.012 | 0.107 |
| 3 | 72.0 ms | 110 ms | 1.014 | 0.123 |
| 6 | 85.0 ms | 115 ms | 1.015 | 0.206 |
| 12 | 97.5 ms | 227 ms | 1.019 | 0.346 |
| 24 | 179.0 ms | 619 ms | 1.027 | 0.680 |
| 48 | 2,648.0 ms | 3,354 ms | 1.517 | 1.293 |
The recognizer keeps up with speech to 24 callers. The RTF at speech speed stays at 1.01 to 1.03, and the breaking point sits between 24 and 48. Unpaced throughput rises 2.95 times from 1 caller to 12, 11.8 to 34.8 times speech speed at 4 threads, and 14.9 to 36.7 at 6 threads with the spin option.
Tail latency sets the limit. The p90 rises from 93 ms at 1 caller to 619 ms at 24, while the p50 moves 106 ms. So the ceiling depends on the limit the operator sets. At 114 ms p90 it is 6 callers. At 250 ms it is 12. At 1,125 ms, the rented mean, it is 24. We set 250 ms and serve 12.
Load does not change the text. The word error rate reads 3.4 mean at every caller count of the 24 clip set. It reads 2.3 on 48 clips and 3.1 on 300, so a small corpus does not rank text, and the sweep rows rank latency only.
The served config, 6 threads and spin, on 300 clips at 3 and at 12 callers and 48 clips at 24: 62 and 74 ms at 3, 66 and 212 at 12, 125 and 575 at 24. It serves 12 callers at the limit and breaks at 24.
6. The batched decode
The runtime offers one decode call over many streams. We moved the decode to one thread over the ready streams and dropped the thread per connection. Spin 200 us on every row:
| Path | Threads | Callers | Final p50 | Final p90 | RTF per stream, unpaced |
|---|---|---|---|---|---|
| thread | 4 | 1 | 71.0 ms | 80 ms | 0.082 |
| batched | 4 | 1 | 72.0 ms | 79 ms | 0.078 |
| thread | 4 | 12 | 73.0 ms | 235 ms | 0.324 |
| batched | 4 | 12 | 77.0 ms | 417 ms | 0.627 |
| batched | 6 | 12 | 65.0 ms | 318 ms | 0.538 |
| thread | 6 | 24 | 127.5 ms | 565 ms | 0.619 |
| batched | 6 | 24 | 1,016.5 ms | 1,536 ms | 1.061 |
The batched call loses. Audio arrives in 20 ms frames, so at any instant few streams hold a full chunk. The one thread then runs the streams in series where the worker threads ran them side by side. At 24 callers the RTF at speech speed passes 1, at 1.182, and the final takes a second. The batched entry also couples the streams: one slow stream holds the whole batch. The endpoint keeps one thread per connection.
7. The 560 ms chunk
The same config with the 560 ms export halves the wait for the first chunk and doubles the encoder calls. 300 clips at 12 callers, 6 threads and spin:
| Chunk | Final p50 | Final p90 | RTF per stream, unpaced | First partial p50 | Word error rate | 24 callers, p50 and p90 |
|---|---|---|---|---|---|---|
| 1,120 ms | 66 ms | 212 ms | 0.327 | 1,620 ms | 3.1 % mean | 125 ms, 575 ms |
| 560 ms | 54 ms | 197 ms | 0.478 | 1,461 ms | 3.1 % mean | 247 ms, 1,033 ms |
The shorter chunk cuts the final latency 12 ms and costs 46 % more processing per call. Both serve 12 callers at the 250 ms limit. At 24 the p90 reads 1,033 ms against 575. The chunk does not change the text on this set. Both read 3.1 mean and 0.0 median.
The first partial moves 159 ms, a quarter of the 560 ms the chunk moves. The first partial waits for the first spoken word, and the clips carry leading silence. The clock starts at the first audio frame, 500 ms before the start of the turn, so the preroll sits inside it. At 1 caller on the test clip the 1120 ms chunk gives 1,301 ms.
A deployment that shows partials during a turn pays the processing for them. Ours shows the final only, so we serve 1120 ms. We keep no 160 ms config: 560 ms already costs 46 % more processing for the same text.
8. Memory and cost per audio second
The desktop rows rank latency and hold no processor cost. We sampled the cost on a mac, in process, no socket and no container. Apple M2, 1120 ms chunk, no session option, the 2 test clips of the export, 3 repetitions per caller:
| Threads | Callers | Core-seconds per audio second | RTF, unpaced | Final p50 | Final p90 | Resident after load | Resident at end |
|---|---|---|---|---|---|---|---|
| 2 | 1 | 0.176 | 0.078 | 85 ms | 87 ms | 1,029 MB | 1,054 MB |
| 2 | 3 | 0.177 | 0.122 | 130 ms | 139 ms | 1,136 MB | 1,015 MB |
| 2 | 4 | 0.207 | 0.156 | 165 ms | 177 ms | 1,033 MB | 1,130 MB |
| 4 | 1 | 0.364 | 0.077 | 79 ms | 91 ms | 977 MB | 1,004 MB |
| 4 | 3 | 0.327 | 0.158 | 155 ms | 198 ms | 996 MB | 1,089 MB |
| 4 | 4 | 0.319 | 0.193 | 196 ms | 227 ms | 1,067 MB | 1,168 MB |
The process uses 33 MB before the load and 977 to 1,136 MB after it. 4 streams add about 100 MB. 2 threads cost half the processor of 4 at 1 caller, 0.176 against 0.364 core-seconds per audio second, for a final latency of 85 against 79 ms. The cost per audio second stays flat from 1 to 3 callers and rises 17 % at 4. 4 callers at 2 threads take 0.83 cores while every caller speaks, and the final latency doubles from 85 to 165 ms, still 6 times under the rented mean.
The deployment target is the host the agent runs on, a Hetzner cx23 at 5.49 euro a month, 2 shared vCPU and 4 GB. The agent caps calls at 4. A shared Intel vCPU runs slower than an M2 performance core and loses cycles to its neighbours, so each mac row is a floor. The next tier is a cx33, 4 vCPU and 8 GB, at 8.49 euro.
9. What we did not measure
- A shared vCPU. The cost rows come from an M2.
- A price per voice minute.
- The blank penalty and the preroll. Every row uses 1.0 and 0.5 s.
- The 160 ms and 80 ms English exports over the socket. The chunk table runs the 3.5 release in process.
- A gating corpus. Every clip is LibriSpeech test-clean read speech at 16 kHz, no browser channel.
- A word error rate under 300 clips. 24 clips read 3.4 and 48 read 2.3 on one config.
- The published normalizer. NVIDIA reports 2.32 % on LibriSpeech test-clean at 1120 ms with whisper-normalizer. Ours drops case and punctuation only, so 3.1 % and 2.32 % are not one measure.
- Beam search. The runtime ships greedy only for this transducer.
- Another sample rate. The endpoint skips a clip at another rate and resamples nothing.
- The resident memory and the idle processor of the agent host under a call.
- A second runtime provider. xnnpack falls back to the CPU without a message.
- A second host for the latency rows. Each number is one Ryzen 7500F, 1 repetition per sweep point and 300 clips per served row.