Your Inference GPUs Are Starved, Not Slow: Finding the Idle Time in Your AI Bill

It usually shows up as a billing question, not an alert. The GPU line on the cloud bill has climbed quarter over quarter while throughput has stayed flat, and someone from finance wants to know why. You open the accelerator dashboard expecting pegged GPUs and instead find them hovering around 25–30% utilization. You are renting some of the most expensive compute available and using a third of it.

This is the most common failure mode in production AI infrastructure right now, and it is rarely a "buy fewer GPUs" problem. The GPUs are not slow. They are starved, fragmented, or idle — and every one of those is fixable without touching the model.

Step 1: distrust the utilization number you have

The first mistake is trusting nvidia-smi. Its headline GPU-Util field does not mean what most people assume. The NVIDIA docs define it as the percent of time over the sample window during which one or more kernels was executing. A single tiny kernel copying data counts the same as a fully saturated matrix multiply. It is entirely normal to see GPU-Util read 100% while the tensor cores — the part you are actually paying a premium for — sit almost idle.

To see the honest picture you need profiling counters, which means DCGM (NVIDIA Data Center GPU Manager) rather than the summary tool:

# "utilization.gpu" here only means a kernel was running --
# it is NOT a measure of how busy the SMs or tensor cores were
nvidia-smi --query-gpu=utilization.gpu,memory.used --format=csv -l 1

# Real occupancy comes from profiling counters exposed by DCGM
dcgm-exporter &
curl -s localhost:9400/metrics | grep -E 'DCGM_FI_PROF_(SM|PIPE_TENSOR)_ACTIVE'

Two counters matter most. DCGM_FI_PROF_SM_ACTIVE is the fraction of time at least one warp was resident on a streaming multiprocessor, averaged across all SMs. DCGM_FI_PROF_PIPE_TENSOR_ACTIVE is the fraction of cycles the tensor pipes were actually doing work. For a transformer inference workload, tensor-pipe activity in the single digits while GPU-Util reads near 100% is the classic signature of an accelerator that is technically "busy" and economically wasted. Chart those two counters next to your GPU spend and the gap you have been paying for becomes visible.

GPU time you pay for vs. GPU time that computes Before ~30% busy After ~85% busy time → computing (paid) idle (still paid) Utilization figures illustrative, not measured telemetry.

Where the idle time actually hides

The GPU is starved by the input pipeline

The most common cause of low occupancy is that the GPU spends its time waiting for data. Decoding images, tokenizing text, or pulling batches from object storage happens on the CPU, and if the CPU cannot keep the GPU fed, the accelerator stalls between batches. The fix is to overlap host-side preparation with device-side compute so the next batch is ready before the current one finishes:

loader = DataLoader(
    dataset,
    batch_size=64,
    num_workers=8,          # parallel CPU preprocessing
    pin_memory=True,        # faster, async host->device copies
    prefetch_factor=4,      # keep batches queued ahead of the GPU
    persistent_workers=True # don't respawn workers every epoch
)

If tuning the loader does not close the gap, the preprocessing itself is too heavy for the CPU allocation. Move it off the hot path: precompute and cache tokenized or resized inputs, or push decode work onto the GPU with something like NVIDIA DALI. The rule of thumb is that the input pipeline should sustain a throughput comfortably higher than the model can consume, so the GPU is never the one waiting.

The batches are too small to fill the machine

A GPU is a throughput device. Feeding it one request at a time leaves most of its parallel units empty. Under real-time serving, requests arrive one by one, so a naive server processes them individually and never fills the hardware. The answer is server-side batching that collects requests arriving within a short window and runs them together. In Triton Inference Server that is a few lines of config:

# config.pbtxt
max_batch_size: 64
dynamic_batching {
  preferred_batch_size: [ 16, 32, 64 ]
  max_queue_delay_microseconds: 2000   # trade <=2ms latency for batching
}

For LLM serving the equivalent is continuous (in-flight) batching, which engines like vLLM and TensorRT-LLM do by default: instead of waiting for every sequence in a batch to finish, they admit new requests as soon as any sequence completes, keeping the GPU full even when generation lengths vary wildly. If you are running a hand-rolled generation loop that batches statically, this single change is often the largest throughput win available.

One small model is holding a whole GPU

Plenty of production models — embedding models, rerankers, classifiers, smaller distilled models — cannot come close to filling a modern data-center GPU. Give each one a dedicated device and you pay for 80GB of accelerator to run a workload that needs a fraction of it. Two mechanisms let you pack them:

  • MIG (Multi-Instance GPU) on A100/H100/Blackwell-class cards partitions one physical GPU into hardware-isolated instances, each with its own SMs and memory slice, so a noisy tenant cannot starve the others.
  • MPS (Multi-Process Service) lets multiple processes share a GPU's SMs concurrently with less isolation but finer packing — useful when the workloads trust each other.
# Split one GPU into isolated instances so small models share it
sudo nvidia-smi -mig 1                 # enable MIG mode
nvidia-smi mig -lgip                   # list available instance profiles
sudo nvidia-smi mig -cgi 19,19,19 -C   # create three instances (profile IDs
                                       # vary by GPU model)
nvidia-smi mig -lgi                    # confirm the instances exist

The GPU is idle between requests

The last bucket is the simplest and the most expensive: an accelerator that is allocated and running but serving no traffic — overnight, on weekends, or for a batch job that finished hours ago. On-demand serving should scale to zero when the request queue drains. With KEDA driving a Kubernetes deployment off a Prometheus query, that is declarative:

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
spec:
  scaleTargetRef:
    name: inference-server
  minReplicaCount: 0        # scale the GPU pods to zero when idle
  cooldownPeriod: 120       # wait before scaling down, to avoid flapping
  triggers:
    - type: prometheus
      metadata:
        query: sum(rate(inference_requests_total[1m]))
        threshold: "1"

For batch and streaming pipelines the same idea shows up as pause/resume: managed runners such as Dataflow now let you pause a GPU pipeline and release the accelerators between bursts rather than holding them for the pipeline's whole lifetime. The prerequisite for any of this is checkpointing — if a long job cannot resume from a saved state, you cannot safely stop it, which also means you cannot run it on cheaper preemptible or spot GPUs. Checkpoint frequently enough that losing a node costs minutes, not hours, and a large slice of batch training and offline inference moves onto spot capacity at a fraction of on-demand cost.

The order that pays off

Work these in sequence, because each one changes the number the next one sees. Measure real occupancy with DCGM first — not nvidia-smi — so you are optimizing against the truth. Then fix starvation, because a GPU waiting on its input pipeline will not benefit from bigger batches. Then batch, to fill the device that is now being fed. Then pack idle-by-design small models with MIG or MPS. Finally, scale to zero and adopt preemptible capacity so the hours you are not serving stop appearing on the invoice. It is common for the first three steps alone to double effective throughput per GPU, which is the same thing as halving the cost per request without buying anything.

The lesson

"We need more GPUs" and "we are wasting the GPUs we have" produce identical dashboards if the only number you look at is nvidia-smi's GPU-Util. The expensive part of an accelerator is the tensor pipeline, so that is what you should be measuring and defending. Before anyone approves a bigger GPU budget, prove the ones you already own are actually computing — the reclaimed capacity is almost always cheaper than the next order.


Hitting something like this in production? I help teams with performance engineering, SRE/observability, and AI-driven root cause analysis — work with me.

Comments

Popular posts from this blog

Performance Testing 102: Little's Law and It's usage in Performance Testing

Performance Testing 104: Workload Modelling Designing & Process

Mastering the Art of Scaling in SaaS Applications