Problem – OOM During Concurrent A/B‑Test Variants of GPT‑4 Inference
In production we run an A/B testing framework that sends several prompt variants to the OpenAI GPT‑4 endpoint in parallel. Under load the inference workers start throwing Memory allocation failed or CUDA out of memory exceptions, causing request failures and downstream SLA breaches.
Root Cause – Uncontrolled Model Instantiation and Memory Fragmentation
The failure is not a transient GPU pressure spike; it is deterministic when the number of parallel variants exceeds the memory budget of a single GPU node. The primary causes are:
- Per‑request model loading: The client library (e.g.,
openaiPython SDK wrapped bytorchfor local caching) creates a newGPT4Modelobject for every variant. Each object allocates a full copy of the 80 GB FP16 model weights, quickly exhausting the 96 GB GPU. - Process‑level duplication: The A/B test harness spawns a separate
multiprocessing.Processper variant. CUDA contexts are not shared across processes, so each process repeats the full model load. - Missing
torch.no_grad()andtorch.cuda.empty_cache(): Inference code retains intermediate activation tensors after each request, leading to fragmentation that prevents the allocator from re‑using freed memory.
Debug – Investigation Steps and Evidence
1. Log Inspection
2024-10-24 12:03:41,872 ERROR worker-7 CUDA out of memory. Tried to allocate 12.00 MiB.
2024-10-24 12:03:41,873 INFO worker-7 GPU memory usage: 93.2/96.0 GiB
2024-10-24 12:03:41,874 DEBUG A/B variant "control" completed in 124ms
2024-10-24 12:03:41,875 ERROR worker-8 Memory allocation failed: CUDA error: out of memory
2. Runtime Metrics
Running nvidia-smi while the test is active shows a linear increase in memory per variant:
+-----------------------------------------------------------------------------+
| GPU Name Persistence-M| Bus-Id | Disp.A | Volatile Uncorr. ECC |
| Fan Temp Perf Pwr:Usage/Cap| Memory-Usage | GPU-Util Compute M. |
|===============================+===============================+============|
| 0 NVIDIA A100 Off | 00000000:3B:00.0 Off | 0% 30C P0 250W / 400W |
| | 0MiB / 81920MiB | 0% Default |
+-----------------------------------------------------------------------------+
...
| 0 NVIDIA A100 Off | 00000000:3B:00.0 Off | 0% 30C P0 250W / 400W |
| | 81920MiB / 81920MiB | 99% Default |
+-----------------------------------------------------------------------------+
3. Process Inspection
$ ps -ef | grep inference_worker
root 12458 12430 0 12:03 ? 00:00:00 python inference_worker.py --variant control
root 12459 12430 0 12:03 ? 00:00:00 python inference_worker.py --variant treatment
Each worker loads the model independently, confirmed by tracing torch.cuda.memory_allocated() at startup:
>>> import torch, gc
>>> torch.cuda.memory_allocated()
0
>>> model = GPT4Model()
>>> torch.cuda.memory_allocated()
82208358400 # ~76.5 GiB per process
4. Code Review – Before Change
def run_variant(variant_id, prompt):
# A new process per variant
from multiprocessing import Process
def worker():
client = OpenAI(api_key=API_KEY) # triggers local cache load
response = client.completions.create(
model="gpt-4",
prompt=prompt,
temperature=0.7,
)
print(f"{variant_id} result: {response}")
p = Process(target=worker)
p.start()
p.join()
Solution – Shared Model Pool, Concurrency Limiting, and Memory Hygiene
1. Centralised Model Loader
Instantiate the model once per GPU node and share it across all variant workers using a thread‑safe pool. This eliminates duplicate CUDA contexts.
# model_pool.py
import torch
from threading import Lock
class GPT4Pool:
_instance = None
_lock = Lock()
def __new__(cls):
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.model = GPT4Model().half().to('cuda')
cls._instance.model.eval()
return cls._instance
def get(self):
return self.model
2. Async Queue with Bounded Concurrency
Replace multiprocessing.Process with an asyncio.Semaphore that caps concurrent inferences to MAX_CONCURRENT=4 (adjusted to fit GPU memory).
# inference_worker_async.py
import asyncio
from model_pool import GPT4Pool
import openai
MAX_CONCURRENT = 4
semaphore = asyncio.Semaphore(MAX_CONCURRENT)
async def run_variant(variant_id, prompt):
async with semaphore:
model = GPT4Pool().get()
# No additional model load; use shared instance
async with openai.AsyncClient() as client:
resp = await client.completions.create(
model="gpt-4",
prompt=prompt,
temperature=0.7,
max_tokens=256,
)
print(f"{variant_id} result: {resp}")
# Example entry point
async def main():
prompts = {...} # dict of variant_id -> prompt
await asyncio.gather(*(run_variant(v, p) for v, p in prompts.items()))
if __name__ == "__main__":
asyncio.run(main())
3. Explicit Memory Cleanup
Wrap inference in torch.no_grad() and call torch.cuda.empty_cache() after each batch.
async def run_variant(...):
async with semaphore:
model = GPT4Pool().get()
with torch.no_grad():
# inference logic …
pass
torch.cuda.empty_cache()
Before vs. After Comparison
| Aspect | Before | After |
|---|---|---|
| Model loading | One full copy per process (≈76 GiB) | Single shared copy per GPU node |
| Concurrency control | Unbounded, each variant spawns a process | Bounded semaphore (e.g., 4 concurrent) |
| Memory hygiene | No explicit cleanup, tensors retained | torch.no_grad() + torch.cuda.empty_cache() |
| Peak GPU usage | ≈96 GiB → OOM | ≈30 GiB (single model + overhead) |
Verify – Confirming the Fix
- Run a controlled load test with the same number of variants as production (e.g., 8 concurrent variants).
- Observe
nvidia-smioutput; GPU memory should stay below the configured threshold (e.g., <90 % of total). - Check logs for the absence of
CUDA out of memoryentries. - Validate functional correctness by comparing a sample of control‑variant outputs before and after the change (they should be identical within floating‑point tolerance).
Sample successful log excerpt:
2024-10-24 13:15:02,112 INFO worker-0 GPU memory usage: 31.4/96.0 GiB
2024-10-24 13:15:02,115 INFO variant control completed in 112ms
2024-10-24 13:15:02,117 INFO variant treatment completed in 118ms
Prevent – Operational Guardrails
- Resource quotas: Define
requestsandlimits for GPU memory in the Kubernetes pod spec (e.g.,limits: nvidia.com/gpu: 1andannotations: gpu.memory: "64Gi"). - Back‑pressure: Integrate a queueing layer (e.g., RabbitMQ or Kafka) that throttles new A/B test jobs when
GPUUtilization > 80%. - Monitoring & alerts: Export
torch.cuda.memory_allocatedandnvidia_smimetrics to Prometheus; fire an alert if usage exceeds 85 % for more than 5 minutes. - Health checks: Add an endpoint that runs a lightweight inference (e.g., 1‑token prompt) and verifies memory stays under a safe margin.
- CI validation: Include a test that spawns the maximum expected number of concurrent variants and asserts no OOM occurs.
Related Topic Hub: LLM Systems Troubleshooting Hub
FAQ
- Why does the OOM happen only when A/B testing, not with single‑variant traffic?
The single‑variant path reuses the same model instance, keeping GPU memory constant. The A/B framework creates a new process per variant, each loading its own copy of the 80 GB model, which exceeds the GPU capacity.
- Can I keep the multiprocessing approach and still avoid OOM?
Only if you use
torch.multiprocessing.set_start_method('spawn')and share the CUDA context viatorch.cuda.ipc_collect(), but this adds complexity and is less reliable than a thread‑based pool. - How do I determine the safe value for
MAX_CONCURRENT?Run
torch.cuda.memory_reserved()after loading a single model, add a safety margin (≈20 %), and divide the remaining memory by the per‑request overhead (typically 1–2 GiB for prompt encoding and activations). - Will using DeepSpeed or TensorRT inference eliminate the OOM?
They reduce per‑request memory but do not solve the root cause of multiple full model copies. Combining a shared model pool with an inference optimizer yields the best results.
- Is it safe to call
torch.cuda.empty_cache()after every request?Yes, in a high‑throughput service it incurs a negligible latency penalty and prevents fragmentation that can otherwise trigger OOM under bursty loads.