GPU Autoscaling on Kubernetes
Nodes with Karpenter, gangs with KAI Scheduler, replicas on queue depth, cache-aware routing, event storyboards.
- Tasks and commands
- Annotated code
- Diagrams
- Comparisons
- Defaults and numbers
- Troubleshooting
- Questions and tips
Tasks#
| Task | Code |
|---|---|
| Stop GPU job eviction | consolidationPolicy: WhenEmpty |
| Consolidate empty nodes after an hour | consolidateAfter: 1h |
| Avoid the eviction default | not WhenEmptyOrUnderutilized |
| Do not scale on duty cycle | not DCGM_FI_DEV_GPU_UTIL |
| Keep one replica ready | min_workers= |
| Boot nodes with weights on NVMe | snapshot ID in EC2NodeClass |
| Route on cached prefix | kv.cache.block_added |
| Compare the three scalers | simulate(strategy, |
Safe disruption for GPU pools#
Karpenter NodePool, GPU pool
disruption: consolidationPolicy: WhenEmptynever evict a running job consolidateAfter: 1hconsolidate empty nodes after an hourThree autoscaling layers#
Scaling strategies in main.py#
| Factor | DUTY_CYCLE | QUEUE_DEPTH | KAI_GANG |
|---|---|---|---|
| Signal | DCGM_FI_DEV_GPU_UTIL | len(queue) | len(queue) |
| Scale up when | util > TARGET_GPU_UTIL | qd > 5 | qd > 3 |
| Step up | replicas_target += | max(1, | max(2, |
| Scale down when | util < 20 | qd = | qd = |
| Result | Drops requests | Reacts to the backlog | Avoids partial-alloc stalls |
Simulator constants#
| Setting | Default | Note |
|---|---|---|
NODE_PROVISION_SEC | 50 | Karpenter ~45-60s |
CLUSTER_AUTOSCALER_SEC | 110 | Slower comparison |
MODEL_LOAD_SEC | 45 | 70B weights plus engine init |
HPA_TICK_SEC | 15 | Control loop interval |
TARGET_GPU_UTIL | 70 | Duty-cycle target |
MAX_REPLICAS | 16 | Replica ceiling |
CACHE_HIT_MS | 80 | TTFT with prefix resident |
CACHE_MISS_MS | 800 | TTFT on cold prefill |
Storyboard stages#
Cache-aware routing#
Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
| HPA never scales at 100% | Duty cycle hides 10 or 100 requests | Scale on queue depth or KV utilization |
| Memory HPA never scales down | vLLM pre-allocates KV cache | Drop memory as a signal |
| 7 of 8 pods wait and burn money | No gang scheduling | KAI Scheduler, all or nothing |
| Running inference evicted | WhenEmptyOrUnderutilized | WhenEmpty plus consolidateAfter: 1h |
| Request times out at 2 a.m. | Node provisioning took 3 minutes | Karpenter plus a warm pool |
| Cache hit rate drops to 8% | Round-robin load balancer | Cache-aware router |
| Replica refuses to start in DR | Tokenizer or quant config not synced | Back up the three-file DR manifest |
| One HPA for prefill and decode | Two pod classes, two triggers | Per-role HPA on separate Services |
Event storyboard project#
python3 scripts/project_test.py kubernetes-event-storyboard --init /tmp/kubernetes-event-storyboard-workpython3 scripts/project_test.py kubernetes-event-storyboard --stage 1 --path /tmp/kubernetes-event-storyboard-work --strictpython3 scripts/project_test.py kubernetes-event-storyboard --all --solution --strictcd projects/kubernetes-event-storyboard/solutiongo run . --input fixtures/rollout.json --out /tmp/context-pack.jsonGo packnode --experimental-strip-types cli.ts /tmp/context-pack.json /tmp/timeline.htmlHTMLkubectl get events -A -o jsonexport your own snapshotStoryboard errors#
| Command | What it does |
|---|---|
bundle exceeds one MiB | Input is over 1<<20 bytes |
trailing JSON | Extra data after the bundle |
observedAt must be RFC3339 | Bad capture timestamp |
invalid occurrence range or count | Last before first, or count below 1 |
event UID changed identity | UID reused for another object or reason |
invalid or duplicate workload UID | Empty or repeated workload UID |
invalid context pack | TypeScript rejects the Go pack |
Q&A#
- Karpenter or Cluster Autoscaler?
- Karpenter is ~45-60s. Cluster Autoscaler is ~90-120s for GPU nodes.
- Which signal for prefill pods?
- Queue depth. Decode pods scale on KV cache pressure.
- Route across regions?
- Rarely. 440 ms round trip beats the 800 to 80 ms saving.
- What does round-robin cost?
- Cache hit is ~80 ms, miss is ~800 ms. A 10x gap.
- Two snapshots with counts 2 and 4?
- One series. The count is 4, not 6.
- Two revisions found?
- The revision stays unknown. Never guess from names.
Tips#
- Back up
tokenizer.jsonortokenizer.modelwith the weights. - Back up
quantize_config.jsonandvllm_config.yaml. - Run a DR drill quarterly.
- Run KAI as a secondary scheduler. Annotate workloads to use it.
- Keep a tensor-parallel workload on one NVLink domain.
- Give
min_workers=1-2to live chat and voice paths only.