06 — Reliability: OOMs, Preemption, Backpressure, Multi-Tenancy, Abuse¶
Latency is what customers feel on a good day. Reliability is what they remember on a bad one. In LLM serving the failure modes are unlike anything in traditional web serving: memory is dynamic and pathological, cost of failure is asymmetric (a single 200GB weight reload costs minutes), and the load shape is adversarial in ways your CRUD services never had to think about. This doc catalogues the failure taxonomy and the operational patterns that keep an inference fleet up when the traffic is real.
1. The OOM Taxonomy — Why Your GPU Died¶
“CUDA out of memory” is not a single failure. It is at least five different failure modes with different fixes. Getting the taxonomy right is the first reliability skill.
1.1 The five OOMs¶
# |
Class |
When |
Root cause |
Fix |
|---|---|---|---|---|
1 |
Weights OOM |
At startup |
Model + framework overhead > VRAM |
Smaller model / more quant / more TP / bigger GPU |
2 |
KV growth OOM |
Steady state, long sessions |
Concurrent seqs × seq_len × KV bytes exceeded free pool |
Cap |
3 |
Fragmentation OOM |
Hours in, under load |
PyTorch caching allocator holes; long-lived tensors block free blocks |
|
4 |
Activation-spike OOM |
On a specific request |
A single very-long prefill blows past reserved pool |
Enable chunked-prefill; set |
5 |
Companion-process OOM |
Anywhere |
Tokenizer worker / logging sidecar / metrics scraper spikes host RAM, gets OOM-killed, takes engine with it |
Pod memory limits + reservations; separate sidecars |
The engine’s Prometheus metrics tell you which one before you read the traceback. vllm:num_preemptions_total rising steadily = class 2 (KV growth pushing scheduler into evict mode). Sudden preemption with gpu_cache_usage_perc still moderate = class 3 (fragmentation). Latency spike + one request cancelled + no others affected = class 4.
1.2 The reservation model in your head¶
vLLM at startup allocates roughly gpu-memory-utilization × total_vram and hands the rest to PyTorch’s caching allocator and CUDA context. Of the vLLM slab:
vram_vllm = weights + activations_peak + kv_cache_pool
kv_cache_pool = vram_vllm - weights - activations_peak
max_concurrent_tokens = kv_cache_pool / bytes_per_token_of_kv
For Llama-70B fp16 on 4×H100 SXM (4×80GB = 320GB, 90% util = 288GB usable):
Weights: 140GB (35GB per shard with TP=4)
Activations reserved (
--max-num-batched-tokens 8192): ~4GB per GPURemaining KV pool per GPU: ~40GB → ~160GB cluster-wide
Per-token KV (Llama-70B, GQA 8 KV heads): 2 × 80 layers × 8 KV heads × 128 head_dim × 2 bytes = 320KB/token
Max total resident tokens ≈ 160GB / 320KB ≈ 500,000 tokens across all concurrent sequences
If someone with a 128k-token prompt walks in and 3 concurrent 128k sessions already exist, you are one long-prompt away from class 4. Set --max-model-len to what your KV pool can survive at your realistic concurrency, not to what the model architecture supports.
1.3 The single most useful setting¶
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
This changes PyTorch’s caching allocator to a growable-segment strategy that dramatically reduces class 3 fragmentation. Every serious vLLM/SGLang production deployment sets it. Confirm it stuck by grepping the pod env — this is one of the top-3 debugging shortcuts.
1.4 The Zoho angle¶
Your on-prem customers cannot tolerate a fleet-wide OOM cascade at 3am. Ship each deployment with an OOM runbook that maps the five classes to the specific vLLM flag to change, and validate the runbook by causing each failure in staging (long-prompt torture test, 200-concurrent-session soak, 48-hour fragmentation soak). A vendor who has proved they can survive each failure mode on the customer’s hardware is a vendor who gets renewed. Almost no one ships this. You will.
2. Preemption vs Swap vs Recompute¶
When the KV pool saturates, the scheduler has to make room. Three strategies exist; each has a completely different latency profile. Understanding which one your engine chose matters for postmortems.
2.1 The three moves¶
Move |
Mechanism |
Cost |
When it wins |
|---|---|---|---|
Preempt (recompute) |
Evict a sequence’s KV; put it back in queue; re-prefill from scratch when re-scheduled |
Wastes prefill FLOPs; TTFT-equivalent spike on the victim |
Short sequences, cheap prefill; default in vLLM V1 |
Swap (offload) |
Copy the KV to host RAM; bring back when GPU has room |
PCIe bandwidth spike per swap (~64 GB/s Gen4, ~128 GB/s Gen5); wall-clock latency for the victim |
Long sequences where recompute is more expensive than a PCIe round-trip |
Chunked-prefill co-scheduling |
Never lets prefill starve decode; no preemption needed under steady load |
None on hit; requires token-budget tuning |
Prevention beats cure; default on in vLLM/SGLang since 2025 |
vLLM’s V1 scheduler defaults to recompute (preemption) because with prefix caching hot, re-prefill often costs less than a swap round-trip. Watch vllm:num_preemptions_total. A healthy production fleet has this metric slowly ticking, not spiking. Sustained preemption rate > 1 per second per replica = you are undersized or your max-num-seqs is set too aggressively.
2.2 The sneaky third failure¶
Class-3-fragmentation-triggered preemption looks identical to class-2-capacity preemption in the metrics but is a completely different problem. If your gpu_cache_usage_perc is at 60% and you’re still preempting, your allocator is fragmented, not full. That is when expandable_segments:True earns its keep.
3. Backpressure & Timeout Design¶
The default failure mode of a naive LLM server under load is: accept every request, queue infinitely, TTFT drifts to minutes, clients time out, retry, doubling the queue. This is the LLM-serving equivalent of a metastable failure. You must design the pressure valve deliberately.
3.1 The layers where you push back¶
Four places to reject or defer, in order of preference (earlier = cheaper):
Gateway rate limit (per API key, per tenant, per model). Cheapest reject; never touches the engine. Envoy/Kong/APISIX or your gateway of choice.
Admission control at the router (queue-depth aware). If
vllm:num_requests_waitingon the target replica > threshold, return 429 withRetry-Afterbefore dispatching. Both llm-d Inference Gateway and Dynamo router do this natively.Engine-level cap. vLLM’s
--max-num-seqsand--max-num-batched-tokenscap what the scheduler will accept per iteration. New requests wait; the wait metric surges; autoscaler reacts.Per-request timeout at the API layer. If the request has been queued longer than the client’s expected TTFT SLO, kill it before it ever prefills — you save the compute and the client got their 504 either way.
3.2 The retry-storm inequality¶
For any queue-based system:
if (retry_rate × retry_count > drain_rate): the queue is a bomb
LLM serving has a specific twist: retry cost equals full re-prefill on the retry replica, and if prefix-cache-aware routing is not wired up, the retry goes to a random replica whose cache is cold. Two mitigations that work in production:
Client-side exponential backoff with jitter, mandatory. Publish this in your SDK and reject retries with no
Retry-Afterheader respect.Idempotency keys. If the client resends with the same idempotency key inside the window, the gateway can return the cached in-flight completion instead of dispatching a new one. Non-trivial; matters when you have flaky mobile clients.
3.3 Deadline propagation¶
Every internal call in the chain (gateway → router → engine) should carry a deadline header (x-deadline or gRPC deadlines). If the deadline has passed by the time the request reaches the engine, drop it. This is standard SRE for RPC systems and almost nobody does it for LLM serving. Do it and your tail latency under load looks like nobody else’s.
4. Multi-Tenancy: Fair Scheduling & Per-Tenant Budgets¶
The moment your platform serves more than one customer, “one greedy tenant kills everyone” becomes a Sunday-morning incident. The engines have partial support; the rest is on you.
4.1 Priority classes¶
vLLM supports request priority via the priority field (integer, higher = more important) and the scheduler will honour it when --scheduling-policy priority is set. SGLang has similar. Use it to build tiers:
Interactive (priority 100): user-facing chat, low TTFT SLO
Agentic (priority 50): tool-calling loops, medium latency, high token budget per session
Batch (priority 10): async analytics, bulk summarization, embed-then-index jobs
Priority does not solve fairness on its own — a single interactive tenant flooding requests still starves the others.
4.2 Token buckets per tenant¶
The correct primitive is a token-bucket rate limiter keyed on tenant, applied at the gateway. Two dimensions:
RPS bucket (requests per second) — cheap, coarse
TPM bucket (tokens per minute, weighted by prompt + expected output) — the honest budget
Redis with a Lua script does this at low latency. OpenAI publishes their per-model limits publicly; that is the schema to imitate. For Zoho this maps cleanly to per-org limits already flowing through your API gateway — extending it with a token dimension is a week of work with outsized payback.
4.3 KV-cache fairness¶
Priority + rate limit is not enough. A tenant with a very long prompt hogs KV pool disproportionately. Add:
Per-tenant
max-tokenscaps at admissionPer-tenant concurrency caps (max in-flight sequences)
KV budget accounting — an experimental feature in llm-d, watch the community for it. Until it lands, an approximation: multiply tenant priority by
1 / (avg_prompt_tokens_last_hour)in your custom router.
4.4 Isolation strategies (ranked by strength)¶
Strength |
Mechanism |
When |
|---|---|---|
Weak |
Rate limits + priority queues on shared engine |
Most SaaS multi-tenancy |
Medium |
LoRA-per-tenant on shared base model |
Fine-tuned tenants sharing a base |
Strong |
Dedicated replica per tenant |
Big enterprise contracts, on-prem |
Nuclear |
Dedicated GPU / MIG slice / node |
Regulated tenants, compliance mandate |
For Zoho, the middle two are the money zone: LoRA-per-CRM-account on a shared Qwen or Llama base gives you strong-enough tenant separation and 10x the density. Dedicated replicas are how enterprise tiers get sold.
5. Abuse Patterns You Will See In Week 2¶
The moment your endpoint is exposed, adversarial and semi-adversarial traffic starts arriving. Some patterns:
5.1 The long-prompt DoS¶
An attacker sends max_model_len - 1 tokens of gibberish. Prefill is O(N²) in attention; a single request can stall your decode iterations for seconds. Multiply by 100 concurrent attackers and your interactive fleet is dead.
Mitigations, in order:
Reject at gateway on
prompt_tokens > tenant_cap. Default cap = 16k unless raised.Enable
--enable-chunked-prefill(default on recent vLLM) so long prefills interleave with decode.Set
--long-prefill-token-thresholdso the scheduler chunks aggressively.Per-tenant prompt-token TPM bucket.
Reject prompts whose measured entropy is too low (gibberish detection); the mirror-nginx blocklist is a starting point.
5.2 The generation-length DoS¶
max_tokens=8192 on every request even for a simple factoid. Wastes decode bandwidth. Cap it: per-tenant, per-model, and enforce at the router before the engine sees it.
5.3 The tool-loop bomb¶
An adversarial agent that keeps calling tools in a loop, growing the conversation, consuming your context budget and burning tokens. Because agentic products at Zoho are the target here, this is the pattern to instrument for. Detect: agent.turn_number > threshold, or session_tokens_lifetime > threshold. Break out with a hard cap.
5.4 Prompt injection as reliability (not just security)¶
Prompt injection is treated in 11_security_supply_chain.md, but from a reliability lens: successful injections often manifest as tool-calling loops, malformed structured outputs, and unusually long generations. Alarm on all three and you catch injection attempts before someone else does.
5.5 The scraper¶
Same client key, n streaming requests with stream=true opened and never read. Server sockets pile up. Fix:
Aggressive
keepalive_timeoutandkeepalive_requestsat the fronting proxyServer-side timeout on cumulative SSE-idle-time (Envoy
stream_idle_timeout: 60s)Per-tenant concurrent-stream limit at the gateway
5.6 The prefix-poisoning attack (novel)¶
Because prefix caching is content-addressed, a hostile tenant on a shared engine can prefix-warm the cache with attacker-chosen strings, then observe timing to infer other tenants’ prompts. Real risk in shared multi-tenant, publicly reported. Mitigations:
Prefix cache scoping: bind cache entries to
tenant_idprefix. vLLM has a--enable-prefix-caching-hash-inputmechanism; SGLang has a per-user cache namespace. Turn one of them on.Consider: no shared prefix cache across tenant boundaries at all (accept the cache-hit hit).
6. The Reliability Instrumentation Kit¶
The minimum set of alerts a serious platform ships with:
Alert |
Signal |
Threshold |
Runbook link |
|---|---|---|---|
Queue growing unbounded |
|
Sustained |
Scale up / rate-limit / investigate hot tenant |
Preemption storm |
|
5 minutes |
Class-2 or class-3 OOM diagnosis |
TTFT SLO burn |
|
Sustained |
Capacity investigation |
ITL SLO burn |
Same for time_per_output_token |
Sustained |
Batch too large, or KV pool starved |
Preemption + high cache usage |
Combined |
Any |
Class-2 (KV capacity) |
Preemption + moderate cache usage |
Combined |
Any |
Class-3 (fragmentation) |
GPU ECC DBE |
DCGM_FI_DEV_ECC_DBE_VOL_TOTAL > 0 |
Any |
Cordon node, RMA GPU |
GPU thermal throttle |
DCGM_FI_DEV_CLOCKS_EVENT_REASONS ≠ 0 |
Any |
Datacenter cooling issue |
Xid errors |
DCGM_FI_DEV_XID_ERRORS ≠ 0 |
Any |
Kernel-level GPU fault; cordon and reboot |
Model-load-≠-ready |
Container |
Startup |
Fix probes per doc 03 |
Each alert should link to a runbook file with: symptoms, likely causes (ranked), one-command diagnostics, and the specific vLLM/engine flag or K8s action to take. The runbook set is a real deliverable — publish it internally at Zoho, and it becomes your reliability credibility artefact.
7. The Uncomfortable Truth About Long Runs¶
LLM engines are not yet mature enough to run for months without incident. Every serious production deployment restarts replicas on a schedule (weekly is common) to reset the caching allocator, the CUDA context, and the Python heap. This is stigma-free — it is called “planned refresh” — and doing it during known-quiet windows costs nothing. Not doing it means an OOM at 3am on a Sunday.
Your rolling upgrade pipeline (doc 03) is already the mechanism. Wire in a cron:
kubectl rollout restart deployment/llm-serving-70b --namespace=inference
# every Sunday 03:00 UTC when traffic is at nadir
Blast radius: zero if your rolling upgrade is correct. Failure to do this: silent time-bomb.
8. Reading list (verified)¶
vLLM production stability RFC series (github.com/vllm-project/vllm/issues, search
label:stability)Character.AI: “Optimizing AI Inference at Character.AI” — https://blog.character.ai/optimizing-ai-inference-at-character-ai — real numbers on preemption, KV pressure, in-house scheduler tuning
DoorDash Eng: “How DoorDash Uses ML Model Serving at Scale” — backpressure and admission control lessons
Anthropic postmortems on the status page (
anthropic.com/status) — read the incident timelinesvLLM
--scheduling-policy prioritydocsPYTORCH_CUDA_ALLOC_CONF=expandable_segments:True— PyTorch docs on caching allocator
9. Exit test¶
You have earned this doc when you can, unaided:
Given a Prometheus screenshot showing
num_preemptions_totalrising andgpu_cache_usage_percsteady at 65%, correctly diagnose class-3 fragmentation and prescribeexpandable_segments:Trueplus a weekly restart schedule.Design a per-tenant admission control policy for a Zoho-style multi-tenant CRM-agent workload (three tiers, priority classes, token buckets, LoRA-per-tenant on a shared base) and explain the failure mode each mechanism defends against.
Reproduce class-4 (activation-spike) OOM in staging on demand by dispatching a
max_model_len - 100gibberish prompt, then defeat it by tuning chunked-prefill flags — with metrics screenshots proving the fix.Present a one-page “reliability posture” for a hypothetical on-prem 8×H100 Zoho deployment covering the OOM runbook, alert catalogue, weekly refresh cadence, and multi-tenancy isolation strategy — defensible line by line.