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