PyTorch CUDA out of memory in an inference service: autograd, batch size and fragmentation
Our text classification service crashed with CUDA out of memory every few hours, always on a big batch. Logits kept for drift monitoring were holding autograd graphs alive, batches were sized by count not tokens, and the allocator was fragmented. How we measured each one and fixed it.
We run a GPU service that classifies support tickets into 140 intents with a fine-tuned transformer encoder. It sat behind a small dynamic batcher that groups requests arriving within 20 ms. After we added a bulk re-classification endpoint for a large customer, pods started dying every three to six hours with:
torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 1.88 GiB. GPU 0 has a total capacity of 22.05 GiB of which 1.06 GiB is free. Including non-PyTorch memory, this process has 20.97 GiB memory in use. Of the allocated memory 16.42 GiB is allocated by PyTorch, and 4.12 GiB is reserved by PyTorch but unallocated. If reserved but unallocated memory is large try setting PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True to avoid fragmentation. See documentation for Memory Management (https://pytorch.org/docs/stable/notes/cuda.html#environment-variables)
Kubernetes restarted the pod, in-flight requests failed with 500s, and the cycle repeated. The model itself is about 1.7 GB in fp32. Something else was using 16 GB.
Reproducing it
We replayed a day of production traffic against a single pod on a staging GPU and logged torch.cuda.memory_allocated() after every batch. Two patterns appeared. First, allocated memory never returned to the baseline after a batch; it crept upward by tens of megabytes per minute. Second, the spikes that killed the process were always batches of 64 long tickets, around 512 tokens each, from the bulk endpoint.
What we ruled out
- Another process on the GPU.
nvidia-smishowed only our Python process, and the error's "this process has 20.97 GiB in use" matched. - A leak in the tokenizer or CPU-side code. Host memory was flat; only device memory grew.
- The model being too large for the card. A single 512-token request needed about 2.3 GB of peak memory including weights. The problem was what accumulated around it.
Root cause
We found three contributing causes, in order of impact:
- Autograd graphs kept alive. The handler called the model without
torch.no_grad()ortorch.inference_mode(). Normally that only wastes memory during the forward pass, because the graph is freed when the outputs go out of scope. But for drift monitoring we appended each batch's logits to adeque(maxlen=5000)that a background thread summarised every 10 minutes. Each stored logits tensor had agrad_fn, which kept references to the saved activations from every layer of its forward pass. The deque was holding gigabytes of activations. - Batches sized by count, not by tokens. The batcher capped batches at 64 requests. With short tickets (median 60 tokens) that is small; with 64 bulk tickets padded to 512 tokens, attention and feed-forward activations are roughly an order of magnitude larger.
- Fragmentation. Variable sequence lengths produce differently sized allocations every batch. The caching allocator had 4.12 GiB reserved but split into blocks too small for the 1.88 GiB request, as the error message itself pointed out.
What is happening under the hood
When a tensor that requires gradients flows through a model, PyTorch records the operations and saves the intermediate tensors needed for the backward pass. The output's grad_fn is the root of that graph. As long as anything references the output, the saved activations stay in GPU memory. torch.no_grad() turns off recording; torch.inference_mode() does the same and also skips version counter and view tracking, which makes it slightly faster. Tensors created in inference mode cannot later be used in autograd, which is exactly what a serving path wants.
PyTorch's CUDA caching allocator keeps freed blocks reserved for reuse instead of returning them to the driver. That makes allocation fast but means that with many different allocation sizes, free memory can be scattered across blocks that do not fit a new large request. Setting PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True lets the allocator grow segments in place, which reduces this kind of fragmentation for workloads with changing shapes.
Environment
| Component | Version / value |
|---|---|
| GPU | NVIDIA A10G (24 GB) on a cloud instance |
| Driver / CUDA | 550 series driver, CUDA 12.4 |
| PyTorch | 2.5.1 |
| Transformers | 4.47 |
| Model | Fine-tuned DeBERTa-v3-large encoder, fp32 weights |
| Serving | FastAPI with a custom 20 ms dynamic batcher, 1 worker per GPU |
| Batching | Max 64 requests, sequences padded to the longest in the batch, max 512 tokens |
Minimal reproduction
# Excerpt: the original handler.
recent_logits = deque(maxlen=5000)
def predict(batch_texts: list[str]):
enc = tokenizer(batch_texts, padding=True, truncation=True, max_length=512, return_tensors="pt").to("cuda")
out = model(**enc) # autograd recording is on
recent_logits.append(out.logits) # keeps grad_fn and every saved activation alive
return out.logits.softmax(-1).argmax(-1).tolist()
Running this in a loop with random lengths and printing torch.cuda.memory_allocated() shows steady growth until the deque fills, at a level far above what the model needs.
Who is affected
Any PyTorch serving code that runs models outside inference_mode or no_grad and holds outputs anywhere: caches, metrics buffers, debug lists, closures. Services with variable-length inputs and count-based batching are especially exposed to peak spikes and fragmentation.
The fix
1. Inference mode for the whole serving path, and detached CPU copies for anything stored.
@torch.inference_mode()
def predict(batch_texts: list[str]):
enc = tokenizer(batch_texts, padding=True, truncation=True, max_length=512, return_tensors="pt").to("cuda")
with torch.autocast("cuda", dtype=torch.float16):
logits = model(**enc).logits
recent_logits.append(logits.float().cpu()) # small, on the host, no graph
return logits.argmax(-1).tolist()
We also call model.eval() once at startup (it was already there, but we added an assertion), because dropout in training mode would change predictions, not just memory.
2. Batch by token budget. The batcher now sorts pending requests by length and fills a batch until batch_size * padded_length reaches 16,384 tokens. Short tickets still batch 64 or more at a time; long ones batch 32.
def take_batch(pending: list[Request], budget: int = 16_384) -> list[Request]:
pending.sort(key=lambda r: r.num_tokens)
batch: list[Request] = []
for r in pending:
longest = max([b.num_tokens for b in batch] + [r.num_tokens])
if (len(batch) + 1) * longest > budget:
break
batch.append(r)
return batch
3. Half precision. Mixed precision with torch.autocast roughly halves activation memory. We compared predictions on 20,000 labelled tickets between fp32 and fp16: identical top-1 labels on 99.96% of them and no change in macro F1 to two decimals.
4. Allocator configuration. We set the environment variable in the deployment:
env:
- name: PYTORCH_CUDA_ALLOC_CONF
value: "expandable_segments:True"
We did not add torch.cuda.empty_cache() calls in the request path. It returns cached memory to the driver, which helps other processes see free memory but does not fix leaks, and calling it per request slows every allocation afterwards.
Alternatives and trade-offs
max_split_size_mbstops the allocator from splitting large blocks and is an older remedy for fragmentation. We tested it butexpandable_segmentsbehaved better with our shapes.- Bucketed padding (pad to 64, 128, 256, 512) reduces the number of distinct shapes further and helps fragmentation, at the cost of some wasted compute on padding.
- A dedicated serving framework with built-in dynamic batching and memory management. Worth it for larger fleets; for one model on a few GPUs our batcher was simpler to keep.
- Separate pools for bulk and interactive traffic. We did this later so bulk jobs cannot affect interactive latency.
How to test the fix
We used PyTorch's memory snapshot tooling during a replay to see what was holding memory before and after:
torch.cuda.memory._record_memory_history(max_entries=100_000)
run_replay("samples/2024-12-18.jsonl")
torch.cuda.memory._dump_snapshot("snapshot.pickle")
Loading the snapshot in PyTorch's memory visualiser showed thousands of live activation blocks attributed to the encoder layers before the fix, and only weights plus the current batch after it. We also added a test that runs 500 random-length batches and asserts that torch.cuda.memory_allocated() returns to within 50 MB of the post-warmup baseline:
pytest tests/test_gpu_memory.py -m gpu
Confirming in production
- Peak allocated memory per pod, read from
torch.cuda.max_memory_allocated()and exported as a metric, dropped from 20.9 GiB to 5.8 GiB under the same replayed traffic. - Zero OOM restarts in the 30 days after deploy, compared with 4 to 7 per day before.
- Throughput on the bulk endpoint rose from about 210 to about 480 tickets per second per GPU, thanks to half precision and length-sorted batching.
Regression guard
- The GPU memory test runs nightly on a GPU runner and fails if memory does not return to baseline.
- A lint rule flags model calls in the
serving/package that are not inside a function decorated withtorch.inference_mode. - The service exports
memory_allocated,memory_reservedandmax_memory_allocatedper pod, with an alert when reserved minus allocated stays above 30% of the card for 15 minutes, which is our early warning for fragmentation.
Security implications
An OOM crash is a denial-of-service vector: before the fix, a client able to send many maximum-length tickets to the bulk endpoint could reliably restart our pods. Token-budget batching and a per-customer rate limit on the bulk endpoint close that. Storing logits on the host instead of the GPU does not change data exposure; they were already held in memory, and they are aggregated and discarded every ten minutes.
Prevention checklist
- Wrap every serving path in
torch.inference_mode()and callmodel.eval(). - Store only detached, CPU-side copies of model outputs, and keep those buffers small.
- Batch by total tokens, not request count, and sort by length.
- Measure peak memory with
max_memory_allocatedunder realistic traffic before launch. - Try
expandable_segments:Truewhen the OOM message reports large reserved-but-unallocated memory.
References
- PyTorch documentation: CUDA semantics, memory management and environment variables — https://docs.pytorch.org/docs/stable/notes/cuda.html
- PyTorch documentation:
torch.inference_mode— https://docs.pytorch.org/docs/stable/generated/torch.autograd.grad_mode.inference_mode.html - PyTorch documentation: Understanding CUDA memory usage — https://docs.pytorch.org/docs/stable/torch_cuda_memory.html
- PyTorch documentation: Automatic Mixed Precision package,
torch.amp— https://docs.pytorch.org/docs/stable/amp.html
Add a label
Embed this post
<iframe src="https://hibug.me/pytorch-cuda-out-of-memory-inference" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Related Stories
Loss becomes NaN after switching to fp16 mixed precision
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.