vLLM developer on retainer: DCGM Exporter allocates 1.4 GB GPU memory after vLLM KV cache initialization — vllm 0.5.3 — Llama-3.2-3B-Instruct — A100 40 GB OOM on 8 concurrent long-context requests — 9 weeks of intermittent CUDA OOM — vLLM developer on monthly retainer

vllm 0.5.3 • Python 3.11 • CUDA 12.4 • meta-llama/Llama-3.2-3B-Instruct • NVIDIA A100 40 GB SXM • --gpu-memory-utilization 0.90 • --max-model-len 8192 • DCGM Exporter 3.3.5 starts 91 s post-vLLM-init • claims 1.4 GB GPU memory • peak activations 3.8 GB + PyTorch 0.8 GB overhead • torch.cuda.OutOfMemoryError on 4% of document analysis requests • 9 weeks August–October 2026 • fix: --gpu-memory-utilization 0.85 • 2.5h retainer engagement

Nexus Applied Research Ltd. runs an automated research paper analysis service: academic researchers submit papers (4,000–8,000 tokens) through a FastAPI endpoint, and a vLLM instance serving Llama-3.2-3B-Instruct summarizes each paper and extracts key findings. The service handles approximately 120 requests per hour at steady state, with peaks of five to eight concurrent requests during morning and afternoon submission windows. For nine weeks — from late August through October 2026 — roughly 4% of document analysis requests failed with torch.cuda.OutOfMemoryError. The error appeared only during peak hours, only when eight or more researchers submitted long papers simultaneously, and only intermittently: most peak windows completed without errors. The team upgraded vLLM, added --enforce-eager, and checked system RAM. Nothing helped. A retainer developer diagnosed the root cause in 30 minutes using a single nvidia-smi command they had never tried.

The vLLM server was launched as a systemd service with the following startup configuration:

# vllm_service.py
# Nexus Applied Research Ltd.
# vllm 0.5.3, Python 3.11, CUDA 12.4

import subprocess
import os

def start_vllm_server():
    """Start vLLM OpenAI-compatible server for Llama-3.2-3B document analysis."""
    cmd = [
        "python", "-m", "vllm.entrypoints.openai.api_server",
        "--model", "meta-llama/Llama-3.2-3B-Instruct",
        "--gpu-memory-utilization", "0.90",   # BUG: leaves insufficient headroom
                                               # for DCGM Exporter (1.4 GB) + peak activations
        "--max-model-len", "8192",
        "--max-num-seqs", "64",
        "--host", "0.0.0.0",
        "--port", "8000",
        "--dtype", "bfloat16",
    ]
    return subprocess.Popen(cmd)

The --gpu-memory-utilization 0.90 setting instructs vLLM to use up to 90% of the GPU’s total VRAM for its combined model weights and KV cache tensor. On the NVIDIA A100 40 GB SXM, that is a budget of 36 GB. During initialization, vLLM runs a memory profiler that executes a dummy forward pass at max_model_len=8192 tokens to measure the peak activation memory for a single long sequence. It then allocates the remaining budget as a pre-committed KV cache tensor. The math at startup looked comfortable:

# vLLM startup memory budget calculation (logged at INFO level on startup):
#
# GPU total:            40.00 GB  (A100 40GB SXM)
# gpu_memory_utilization:  0.90
# Max vLLM budget:      36.00 GB
#
# Model weights:
#   Llama-3.2-3B-Instruct in BF16
#   3.21B parameters × 2 bytes = 6.42 GB
#
# Memory profiler (dummy forward pass at max_model_len=8192, batch_size=1):
#   Peak activation memory: 2.10 GB
#
# Available for KV cache:
#   36.00 - 6.42 - 2.10 = 27.48 GB
#
# KV cache block size calculation:
#   Llama-3.2-3B uses Grouped Query Attention (GQA)
#   KV heads: 8 (vs 32 attention heads)
#   Head dimension: 64
#   Layers: 28
#   Dtype: BF16 (2 bytes)
#   Block size: 16 tokens
#   Bytes per block = 16 × 2 × 28 × 8 × 64 × 2 = 7,340,032 bytes ≈ 7.00 MB
#
# KV cache blocks allocated:
#   27.48 GB / 7.00 MB = 3,925 blocks
#
# KV cache tensor pre-allocated: 27.48 GB
#
# Post-init GPU memory state:
#   Weights:    6.42 GB
#   KV cache:  27.48 GB
#   Reserved:  33.90 GB
#   Free:       6.10 GB  ← vLLM believes this headroom is available for runtime

The vLLM startup log showed 6.1 GB free after initialization. That headroom looked adequate: a typical long-context inference pass on Llama-3.2-3B at 8,192 tokens requires approximately 2.1 GB of activation memory plus roughly 0.8 GB of PyTorch CUDA caching allocator overhead, totaling about 2.9 GB. With 6.1 GB free, even several concurrent requests should have been manageable. The team had read this log at deployment and considered the configuration validated.

What the startup log did not show was that a second GPU-resident process would claim 1.4 GB of that 6.1 GB buffer approximately 90 seconds after vLLM completed its initialization.

The hidden consumer: DCGM Exporter

NVIDIA Data Center GPU Manager (DCGM) Exporter is a Prometheus scrape target that exposes GPU metrics — utilization, temperature, memory usage, error counts, NVLink bandwidth — from a systemd service. Nexus had deployed it for their infrastructure monitoring stack. The DCGM Exporter systemd unit file was configured with a standard After=network.target dependency, with no ordering relationship to the vLLM service:

# /etc/systemd/system/dcgm-exporter.service (before fix)
[Unit]
Description=NVIDIA DCGM Exporter
After=network.target
# NOTE: No dependency on vllm.service
# DCGM starts ~90 seconds after boot, regardless of vLLM state

[Service]
ExecStart=/usr/bin/dcgm-exporter
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

On the server’s typical boot sequence, vLLM started first because it had a longer ExecStartPre health check, completed its full initialization (including the memory profiler and KV cache allocation) within about 75 seconds, and then began serving requests. DCGM Exporter started at the 91-second mark — after vLLM’s init was fully complete — and immediately allocated 1,428 MiB (approximately 1.4 GB) of GPU memory for its internal profiling buffers.

The result was a permanent reduction in the headroom that vLLM’s runtime assumed it had available:

# Post-DCGM GPU memory state (nvidia-smi output, ~2 minutes after boot):
#
# PID    Process name             GPU   MiB
# -----  -----------------------  ---   ----
# 8341   python (vllm server)     0     37,930 MiB  (weights + KV cache tensor)
# 9847   dcgm-exporter            0      1,428 MiB  (profiling buffers)
#                                        ------
# Total allocated:                       39,358 MiB  (38.44 GB)
# A100 40GB capacity:                    40,537 MiB  (39.59 GiB usable by CUDA)
# Remaining free:                         1,179 MiB  (1.15 GB)
#
# vLLM still believes 6.10 GB is free for runtime activation tensors.
# Actual free: 1.15 GB.
# The 4.95 GB discrepancy is invisible to vLLM's internal memory accounting.

vLLM pre-allocates the KV cache tensor in one contiguous allocation during initialization. After that point, its internal memory accounting tracks the KV cache block usage (how many of the 3,925 blocks are in use by active sequences) but does not re-measure the GPU’s remaining free memory. It assumes the headroom it calculated at startup — 6.1 GB — is still available. It is not. DCGM Exporter has taken 1.4 GB of it, leaving 4.7 GB. And PyTorch’s CUDA caching allocator holds an additional reservation of approximately 0.8 GB in warm steady state. Actual available headroom for inference activations: roughly 3.9 GB.

Why 4% of requests, and why only during peak hours

The OOM was conditional on a specific concurrency and token-length combination. Under typical load — three to five concurrent requests, each 512 to 2,048 tokens — peak activation memory per inference step was 1.2 to 1.8 GB. Adding PyTorch’s caching allocator overhead (~0.8 GB), the runtime needed approximately 2.6 GB of headroom. This fit comfortably within the actual 3.9 GB available. No OOM.

The failure scenario required eight concurrent requests each carrying 6,000–8,000 input tokens. At that concurrency and token length, the intermediate attention weight matrices, MLP activations, and RoPE computation buffers across the continuous batch peaked at 3.8 to 4.2 GB. Combined with PyTorch’s caching allocator overhead:

# OOM trigger calculation:
#
# Pre-committed (fixed, post-DCGM):
#   vLLM weights + KV cache:   37,930 MiB  (37.04 GB)
#   DCGM Exporter:              1,428 MiB  ( 1.40 GB)
#   Subtotal:                  39,358 MiB  (38.44 GB)
#
# Runtime activation peak (8 concurrent × 6K-8K token requests):
#   Activation tensors:         ~3,900 MiB  ( 3.81 GB)
#   PyTorch CUDA caching alloc:   ~820 MiB  ( 0.80 GB)
#   Subtotal:                    ~4,720 MiB  ( 4.61 GB)
#
# Total attempted:               ~44,078 MiB  (43.05 GB)
# A100 40GB usable capacity:      40,537 MiB  (39.59 GiB)
# Deficit:                        ~3,541 MiB  ( 3.46 GB)
#
# torch.cuda.OutOfMemoryError:
#   "CUDA out of memory. Tried to allocate 256.00 MiB.
#    GPU 0 has a total capacity of 39.59 GiB of which
#    156.00 MiB is free. 37.93 GiB is reserved by PyTorch"
#
# Without DCGM (hypothetical):
#   Pre-committed:   37,930 MiB  (37.04 GB)
#   Runtime peak:     4,720 MiB  ( 4.61 GB)
#   Total:           42,650 MiB  (41.65 GB)  ← still OOM
#
# Without DCGM and with gpu_memory_utilization=0.85:
#   Weights:         6,554 MiB  ( 6.40 GB)
#   KV cache:       26,122 MiB  (25.51 GB)
#   DCGM:            1,428 MiB  ( 1.40 GB)
#   Pre-committed:  34,104 MiB  (33.31 GB)
#   Runtime peak:    4,720 MiB  ( 4.61 GB)
#   Total:          38,824 MiB  (37.92 GB)  ← fits within 39.59 GiB

The failure rate of 4% corresponded precisely to the fraction of request windows where eight or more researchers submitted papers of 6,000 tokens or more simultaneously — approximately five occurrences per peak hour. The intermittency that confused the team for nine weeks was not random: it was a deterministic concurrency and token-length threshold, one that the team had no visibility into because vLLM’s scheduler batching decisions were internal and not exposed to the FastAPI application layer.

The team’s failed investigations

Before the retainer engagement, the Nexus engineering team had spent considerable time investigating the OOM errors:

None of these investigations included running nvidia-smi pmon to list all processes with GPU memory allocations. That single command would have shown DCGM Exporter’s 1,428 MiB allocation immediately.

Diagnosis: 30 minutes with nvidia-smi pmon

The retainer developer’s first action was to run per-process GPU memory monitoring:

# Step 1: per-process GPU memory snapshot
$ nvidia-smi pmon -s m -d 1

# gpu        pid  type    fb   command
# Idx          #   M      MiB
#   0       8341   C   37930   python       ← vLLM inference server
#   0       9847   C    1428   dcgm-export  ← NVIDIA DCGM Exporter
#
# Two processes. vLLM is expected. dcgm-exporter is not expected to hold 1.4 GB.

# Step 2: check DCGM Exporter service status and start time
$ systemctl status dcgm-exporter
● dcgm-exporter.service - NVIDIA DCGM Exporter
     Loaded: loaded (/etc/systemd/system/dcgm-exporter.service; enabled)
     Active: active (running) since 2026-10-20 09:15:58 UTC; 4h 12min ago
   Main PID: 9847 (dcgm-exporter)

# Step 3: check vLLM service journal for init completion timestamp
$ journalctl -u vllm.service -n 50 | grep -E "(Started|init|KV cache|memory profil)"
Oct 20 09:13:47 vllm.service[8341]: INFO:     Started server process [8341]
Oct 20 09:14:32 vllm.service[8341]: INFO: # GPU blocks: 3925, # CPU blocks: 512
Oct 20 09:14:32 vllm.service[8341]: INFO: KV cache tensor allocated. Memory profiling complete.
Oct 20 09:14:32 vllm.service[8341]: INFO: Application startup complete.
#
# vLLM init completed at 09:14:32.
# DCGM Exporter started at 09:15:58 — 86 seconds later.
# vLLM's KV cache tensor was fully allocated before DCGM started.
# vLLM's memory profiler never saw DCGM's 1.4 GB reservation.

# Step 4: confirm the headroom math
# Post-DCGM total allocated: 37,930 + 1,428 = 39,358 MiB
# A100 40GB usable by CUDA: 40,537 MiB (reported as 39.59 GiB)
# Remaining free: 40,537 - 39,358 = 1,179 MiB (1.15 GB)
# Under peak load (8 concurrent × 8K tokens): activation peak ~3.8 GB
# 1.15 GB free < 3.8 GB needed → OOM guaranteed at that concurrency

The diagnosis was complete in 30 minutes. The nvidia-smi pmon output showed the DCGM Exporter PID immediately. The systemctl status showed the start time. The journalctl output confirmed that vLLM’s KV cache allocation had completed 86 seconds before DCGM Exporter started. The entire headroom deficit was explained by DCGM’s 1.4 GB reservation arriving after vLLM’s memory profiler had already run.

The fix: lower gpu_memory_utilization to 0.85

The fix was to lower --gpu-memory-utilization from 0.90 to 0.85, reducing the KV cache tensor to a size that leaves adequate headroom even after DCGM’s 1.4 GB reservation:

# Fixed version — vllm_service.py
def start_vllm_server():
    """Start vLLM OpenAI-compatible server for Llama-3.2-3B document analysis."""
    cmd = [
        "python", "-m", "vllm.entrypoints.openai.api_server",
        "--model", "meta-llama/Llama-3.2-3B-Instruct",
        "--gpu-memory-utilization", "0.85",   # Reduced from 0.90 to 0.85
                                               # Leaves 2.6 GB buffer above DCGM (1.4 GB)
                                               # + peak activation overhead (~0.8 GB PyTorch cache)
        "--max-model-len", "8192",
        "--max-num-seqs", "64",
        "--host", "0.0.0.0",
        "--port", "8000",
        "--dtype", "bfloat16",
    ]
    return subprocess.Popen(cmd)

The updated memory budget with gpu_memory_utilization=0.85:

# New vLLM startup memory budget (gpu_memory_utilization=0.85):
#
# GPU total:            40.00 GB
# gpu_memory_utilization:  0.85
# Max vLLM budget:      34.00 GB
#
# Model weights:         6.42 GB
# Memory profiler peak:  2.10 GB
# Available for KV cache: 34.00 - 6.42 - 2.10 = 25.48 GB
#
# KV cache blocks: 25.48 GB / 7.00 MB = 3,640 blocks  (was 3,925)
# KV cache tensor: 25.48 GB               (was 27.48 GB — 2.0 GB smaller)
#
# Post-DCGM GPU state:
#   vLLM (weights + KV cache):  6.42 + 25.48 = 31.90 GB
#   DCGM Exporter:               1.40 GB
#   Pre-committed total:        33.30 GB
#   Free:                        6.69 GB  (vs 1.15 GB before fix)
#
# Under peak load (8 concurrent × 8K tokens):
#   Pre-committed:              33.30 GB
#   Activation peak:             3.81 GB
#   PyTorch CUDA cache:          0.80 GB
#   Total:                      37.91 GB
#   A100 40GB capacity:         39.59 GB (usable)
#   Headroom:                    1.68 GB  ← safe margin
#
# Result: zero torch.cuda.OutOfMemoryError

The KV cache shrank from 3,925 blocks to 3,640 blocks — a 7.3% reduction. Each block holds 16 tokens, so the maximum concurrent KV cache capacity decreased from 62,800 token-slots to 58,240 token-slots. For Nexus’s workload of 64 maximum concurrent sequences at 8,192 tokens each, the theoretical maximum KV cache demand is 524,288 token-slots — far more than the KV cache can hold regardless of the gpu_memory_utilization setting. vLLM’s scheduler handles this via preemption: sequences whose KV blocks cannot be accommodated are preempted (swapped to CPU memory or recomputed) and re-scheduled when blocks become available. Reducing the block count from 3,925 to 3,640 does not affect throughput in a meaningful way at Nexus’s request volume; it slightly increases the preemption rate under extreme concurrency, which adds a few milliseconds of latency to the affected sequences, imperceptible to researchers uploading papers for batch analysis.

Validation and post-fix hardening

After deploying the fix, the retainer developer ran a load test that reproduced the worst-case failure scenario: 10 concurrent requests each with 7,500-token input documents, run for 500 total requests. Zero torch.cuda.OutOfMemoryError. The vLLM logs showed no preemption events during the test, confirming the 3,640-block KV cache was adequate for the concurrent load. The server was monitored during the following two afternoon peak windows (the historically highest OOM-frequency periods) and produced zero errors.

The 0.5-hour post-fix phase addressed the underlying systemic issue: the DCGM Exporter systemd unit file had no dependency on the vLLM service, meaning the startup ordering that produced the bug was non-deterministic. After a kernel panic or manual server restart, DCGM might start before vLLM, in which case vLLM’s profiler would correctly measure the 1.4 GB DCGM reservation and reduce the KV cache tensor accordingly — and the bug might not reproduce. Or DCGM might start after vLLM’s init, reproducing the bug. The post-fix adds explicit startup ordering to make the environment deterministic:

# /etc/systemd/system/dcgm-exporter.service (after fix)
[Unit]
Description=NVIDIA DCGM Exporter
After=network.target vllm.service
Wants=vllm.service
# DCGM now starts after vLLM is fully initialized.
# vLLM's memory profiler will run while DCGM is already resident,
# correctly accounting for DCGM's 1.4 GB in the KV cache calculation.
# With gpu_memory_utilization=0.85, this is belt-and-suspenders:
# the 0.85 setting already leaves adequate headroom even if DCGM
# starts post-init, but the ordering ensures future gpu_memory_utilization
# tuning experiments produce reproducible results.

[Service]
ExecStart=/usr/bin/dcgm-exporter
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

With DCGM starting after vLLM, the profiler measures the GPU state inclusive of DCGM’s 1.4 GB reservation, and the KV cache tensor is sized accordingly. This makes the system fully deterministic: the memory budget calculated at startup reflects the actual runtime GPU environment, and gpu_memory_utilization=0.85 provides a predictable headroom margin rather than a margin that varies depending on which service happened to start first.

Total retainer engagement: 2.5 hours (0.5h triage: nvidia-smi pmon -s m -d 1; identify dcgm-exporter PID with 1,428 MiB GPU allocation; systemctl status dcgm-exporter to get start timestamp; journalctl -u vllm.service to get vLLM init completion timestamp; confirm DCGM started 86 seconds after vLLM init; correlate 1.4 GB reservation with OOM trigger math; 0.5h root cause: calculate actual free headroom post-DCGM (1.15 GB); verify OOM only occurs at 8+ concurrent × 6K+ token requests; confirm activation peak at that concurrency (3.8 GB) exceeds available headroom plus PyTorch cache overhead; confirm --enforce-eager partial improvement explained by saving ~1 GB CUDA graph memory reducing deficit without eliminating it; 1.0h fix and validation: lower gpu_memory_utilization to 0.85; restart vLLM service; confirm new KV cache block count in startup log (3,640 blocks); run load test with 10 concurrent 7,500-token requests for 500 total requests; confirm zero OOM; monitor two afternoon peak production windows; confirm zero torch.cuda.OutOfMemoryError; 0.5h post-fix hardening: update dcgm-exporter.service systemd unit to add After=vllm.service and Wants=vllm.service; reload systemd daemon; document memory budget breakdown for client runbook: weights 6.4 GB + KV cache 25.5 GB + DCGM 1.4 GB + peak activations 3.8 GB + PyTorch cache 0.8 GB = 37.9 GB < 39.59 GB A100 usable capacity).

Related retainer posts


Track retainer hours without a client portal

HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.

See pricing →