Skip to content
tarıtas
Production engineering POST-23 6 min read

What Breaks in Voice AI at High Call Concurrency? The GPU Math Most Teams Get Wrong

At high call concurrency, voice AI rarely breaks from call volume alone. It breaks where a specific component was never tested under sustained concurrent use. At taritas, that component was a self-hosted GPU running our text-to-speech model. Its memory slowly filled to 99 percent over 42 days, with no spike in calls, because nothing was watching the trend closely enough, and the monitoring that should have shown the climb had failed at the same time. The fix was not a bigger GPU. It was measuring the real number, about 3 concurrent synthesis streams per GPU, which supports 9 to 15 concurrent calls once you account for turn-taking. An earlier linear estimate had assumed 100 concurrent calls would need about 33 GPUs, wrong by an order of magnitude. Around that measured number we built four layers of automatic recovery: replicas, scheduled recycling, a memory-based health check, and dedicated GPU monitoring.

Published · Updated · Supreet Tare

All names, numbers, and identifiers in this post are anonymized. The patterns are real.

Two stacked panels. Top: a line chart climbing slowly from about 40 percent to 99 percent GPU memory over 42 days, with a second line showing the GPU monitoring metric going flat and gapped, labeled NVML down, and a marker at day 42 reading manual restart. Bottom: four fix layers between calls and the GPU, replicas with anti-affinity, a weekly scheduled recycle, a memory-based liveness probe, and dedicated GPU metrics, with a side path to a non-GPU fallback voice

The number most teams get wrong

One GPU handles far more concurrent calls than most capacity plans assume. Here is the number worth remembering: a 16 GB GPU running a self-hosted text-to-speech model supports about 3 concurrent synthesis streams. Because a phone call has turn-taking, only one side speaking at a time, those 3 streams support roughly 9 to 15 concurrent calls, not 3.

An early estimate on one of our projects assumed the opposite. It scaled per call, in a straight line: 100 concurrent calls would need about 33 GPUs. That estimate was off by a factor of ten. The real number, measured under load, was a handful of GPUs from a small buffer already sitting idle.

The lesson generalizes past this one project. Any capacity plan for voice AI that multiplies calls by resource-per-call in a straight line is almost certainly wrong. It ignores turn-taking, batching, and the gap between peak and average load. Measure the real concurrent-streams-per-GPU number before you provision anything.

How we found that number

One of our voice agents answers a public-sector service desk’s phone line. It speaks through a self-hosted text-to-speech model, Chatterbox Turbo, running on a single GPU node, an Azure NC4as_T4_v3 with a 16 GB Tesla T4. We self-host it because the managed standard voice available in the client’s required region had been judged too robotic for a citizen-facing line. We cover that decision, and the managed voice generation that later changed the calculus, in our TTS journey post.

This is one of three related answers to what breaks at high call concurrency, alongside a call-transfer gateway that dropped calls at a fixed timeout (the Envoy timeout story) and a per-tenant database counter that needed to be atomic under concurrent writes (the Postgres rate limiter story). None of the three were about raw call volume. Each was a specific component that had never been tested under sustained concurrent load.

The GPU story started as an operations incident. After 42 days of uninterrupted pod uptime, the GPU’s memory reached 99 percent and needed a manual restart. There was no spike in call volume that day, just slow accumulation the whole time. Uptime had looked like health. It was actually the clock on a slow leak nobody was watching.

Restarting the pod fixed the incident immediately, which told us this was resource exhaustion, not a logic bug. Confirming the slow-leak theory was harder, because the metrics we needed were themselves unreliable. The node’s monitoring layer intermittently threw “Failed to initialize NVML,” NVIDIA’s library for GPU metrics, while the model kept generating speech normally through CUDA, the separate compute layer underneath it. Compute and monitoring are two different systems on the same card. One had failed while the other kept working, and we saw the same failure again on a different node during a later check.

The capacity math correction above came out of the same investigation, done for an unrelated reason: planning for more concurrent calls. Finding the real number, and finding why the linear estimate was wrong, became the most useful part of the whole incident.

The root cause

To state it plainly: the GPU did not fail. It filled up exactly as any long-running GPU process will, on a single node with no replica and no scheduled reset, and the monitoring that should have shown the trend had a failure of its own running at the same time. Three things made this hard to catch: uptime looked like health, the metric that would have shown the slope was itself down in a way that never touched inference, and a single-GPU setup meant the first real signal was a caller hearing degraded voice quality.

The fix: four layers, not one restart

Restarting the pod fixed the immediate incident. It did not fix the underlying risk. Any long-running GPU model server should be assumed to leak, and the system around it has to absorb that automatically.

Replicas, so no single GPU is a single point of failure.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: tts-server
spec:
  replicas: 3
  template:
    spec:
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app: tts-server
              topologyKey: kubernetes.io/hostname

A scheduled restart, so the leak gets amnesty on a calendar instead of a pager.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: tts-weekly-recycle
spec:
  schedule: "0 4 * * 0"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: recycle
              image: bitnami/kubectl
              command: ["kubectl", "rollout", "restart", "deployment/tts-server"]
          restartPolicy: OnFailure

A memory-based liveness probe, checking GPU memory against a threshold, so a leaking pod recycles itself before quality degrades.

Dedicated GPU observability, using NVIDIA’s DCGM exporter for real per-GPU metrics. DCGM’s own health also surfaces separately from the model’s inference path, so it catches the NVML-down state itself.

Scaling on top of these four layers uses three signals together. A scheduled cron baseline covers known busy periods. An active-calls counter is the primary, leading indicator, since a caller entering a queue predicts synthesis load before the GPU feels it. GPU utilization is only a safety net, because by the time utilization alone triggers a scale-up, callers are often already hearing the delay.

def on_call_start():
    redis_client.incr("voice:active_calls")  # agent-side admission signal
    redis_client.expire("voice:active_calls", ttl=CALL_TIMEOUT_SECONDS)

def on_call_end():
    redis_client.decr("voice:active_calls")  # hangup or failure, either way

def choose_tts_backend():
    if tts_fleet_saturated_or_unhealthy():
        return "managed_fallback_voice"  # avoid queueing calls indefinitely
    return "self_hosted_gpu_voice"

That fallback path matters as much as the scaling logic. A tested, non-GPU text-to-speech option turns a GPU incident into a brief quality dip instead of a caller hearing dead air.

Hardening

  • Treat uptime as a risk signal for any model server holding a GPU, not a sign of health. Anything running for weeks needs a scheduled recycle.
  • Alert on the slope of GPU memory, not just the level. The 42-day climb to 99 percent was visible as a trend long before it became an incident.
  • Monitor the monitor. Give a GPU metrics exporter its own health alert, since its failure mode looks like everything is fine.
  • Keep a tested, non-GPU fallback voice path wired in at all times.
  • Plan capacity from a measured concurrent-streams-per-GPU number and real turn-taking behavior, never from a linear per-call estimate.

Key takeaways

  • Measure your own concurrent-streams-per-GPU number before you plan capacity. Do not estimate it. A linear per-call estimate can overstate GPU needs by an order of magnitude, as it did here.
  • If you run a self-hosted GPU model server, put a scheduled recycle on the calendar now, before uptime turns into an incident.
  • Check whether your GPU metrics exporter has its own health alert, separate from the metrics it reports. If it does not, add one this week.
  • Confirm you have a tested, non-GPU fallback path for your voice pipeline. If you have never tested it under load, that is the gap to close next.

What this means if you are an IT services firm

Ask your team three questions about any voice AI running on a self-hosted GPU: what is our measured concurrent-streams-per-GPU number, what is the recycle schedule, and what happens when the GPU metrics themselves go down. If any answer is “we have not checked,” that is the next thing to test, before a client’s production concurrency finds the gap for you. This kind of capacity planning and GPU operations work is part of what we do behind IT services firms, under their brand.

Related questions
What actually breaks in voice AI at high call concurrency?
Rarely the call volume itself. Almost always, it is one component nobody load-tested for sustained concurrent use. We have seen a self-hosted GPU model leak memory over weeks. We have seen a per-tenant call cap set once and forgotten. We have seen a database counter that was never checked under simultaneous writes. The fix is the same shape each time: measure the real capacity of that one component, then add redundancy and automatic recovery for the failure you found.
How many concurrent calls can one GPU handle for voice AI text-to-speech, and how do you find that number?
Load test with concurrent synthesis streams on one GPU, not an estimate from a spec sheet. On our 16 GB Tesla T4 running Chatterbox Turbo, the measured number was about 3 clean concurrent streams. Beyond that, quality degrades by queueing, not by crashing. Streams are not calls. A phone call has turn-taking, so 3 streams supported roughly 9 to 15 concurrent calls, depending on how much headroom we kept.
Why does a self-hosted text-to-speech server's GPU memory grow over weeks?
Allocator fragmentation and cache growth inside a long-running CUDA process. This is not a bug you fix in application code. The useful response is to assume any model server holding a GPU for weeks will grow its memory footprint, and schedule a recycle before that growth becomes a problem.
What is the NVML failure that can hide GPU problems in a voice AI deployment?
NVML is NVIDIA's monitoring library. It can fail with an error like failed to initialize NVML while the GPU keeps computing normally through CUDA, the separate compute layer. The result is a dashboard that looks fine, or goes blank, right when a real problem is building. Give your GPU metrics exporter its own health alert, separate from the metrics it reports.
Is self-hosting a GPU text-to-speech model worth the operational cost versus a managed API?
It bought voice quality a client needed when a managed standard voice did not clear the bar. The price was owning GPU capacity planning, monitoring, and recycling, the entire subject of this post. That calculus can change once a new managed voice generation reaches the required quality and region. See the companion post on that decision.

Reading this because a client asked for voice AI? That is the conversation we are built for. What taritas does for partners.

More from Production engineering
PROJECT taritas.com/blog
DWG POST-23
REV 1.0
DATE 2026-07-31