vLLM GPU LLM Kubernetes performance

vLLM Inference - Performance Degradation from KV Cache Fragmentation

Fixing vLLM throughput degradation after hours of operation: diagnosing KV cache fragmentation, enabling prefix caching, tuning gpu-memory-utilization, and scheduled restart.

·
A vLLM inference cluster starts with throughput of ~3500 tok/s, but after 6-8 hours of production traffic with variable-length prompts (100-32K tokens) performance drops to ~1500 tok/s - a degradation of 40-60%. GPU utilisation remains high (>90%), which rules out thermal throttling. The problem builds up gradually and only disappears after restarting the vLLM process.

This runbook covers diagnosing and fixing KV cache fragmentation in vLLM. We discuss GPU scheduling and AI model autoscaling in detail in our article Agentic AI infrastructure - GPU scheduling and autoscaling on Kubernetes. For a comparison of inference costs on self-hosted infrastructure vs managed APIs, see Azure OpenAI vs self-hosted models - cost, latency, and data residency in the EU. If you need help optimising GPU inference - book a consultation.

Symptom

Throughput (tok/s) measured at the vLLM /metrics endpoint drops gradually over time. Prometheus shows a characteristic pattern: stable performance for the first hour, then a linear decline:

# Sprawdź aktualny throughput z metryki vLLM
kubectl exec -n inference deploy/vllm-server -- \
  curl -s localhost:8000/metrics | grep -E "vllm:generation_tokens_total|vllm:prompt_tokens_total"

# Metryki KV cache - kluczowe dla diagnozy fragmentacji
kubectl exec -n inference deploy/vllm-server -- \
  curl -s localhost:8000/metrics | grep -E "vllm:gpu_cache_usage_perc|vllm:num_preemptions_total"

# Sprawdź GPU memory utilization (nvidia-smi)
kubectl exec -n inference deploy/vllm-server -- nvidia-smi --query-gpu=memory.used,memory.total,utilization.gpu --format=csv,noheader

# Porównanie TTFT (Time To First Token) - świeży start vs po 8h
kubectl exec -n inference deploy/vllm-server -- \
  curl -s localhost:8000/metrics | grep "vllm:time_to_first_token"

Prometheus query to detect degradation over time:

# Rate generowania tokenów - porównanie last 1h vs 8h ago
rate(vllm:generation_tokens_total[5m])

# Spadek throughput > 30% vs baseline (alert rule)
(
  avg_over_time(rate(vllm:generation_tokens_total[5m])[1h:5m])
  /
  avg_over_time(rate(vllm:generation_tokens_total[5m])[1h:5m] offset 8h)
) < 0.7

# KV cache utilization - jeśli oscyluje blisko 100% to sygnał fragmentacji
vllm:gpu_cache_usage_perc > 0.95

# Liczba preemptions - rosnąca liczba oznacza brak ciągłych bloków
rate(vllm:num_preemptions_total[5m]) > 0

How to distinguish fragmentation from other causes of throughput degradation:

Symptom KV cache fragmentation Thermal throttling OOM / swap
GPU utilisation >90% (high) Drops to 50-70% Spikes / crashes
GPU temperature <80C (normal) >85C Normal
vllm:num_preemptions Grows over time Steady N/A (crash)
vllm:gpu_cache_usage Oscillates 90-99% Steady ~80% 100% then OOM kill
Restart fixes it Yes (for several hours) No Temporarily

Root Cause

The problem stems from the PagedAttention mechanism in vLLM and how KV cache memory blocks are allocated:

1. Block fragmentation with variable-length prompts:

vLLM divides GPU memory into fixed-size blocks (default 16 tokens/block). When short requests (100 tokens = 7 blocks) and long requests (32K tokens = 2048 blocks) arrive alternately, after short requests finish, “holes” appear - free blocks scattered between occupied blocks of long requests. New long requests cannot allocate a contiguous sequence of blocks and must wait for preemption.

2. gpu_memory_utilization set too high (>0.92):

With the default setting --gpu-memory-utilization 0.95, vLLM allocates 95% of GPU memory to KV cache. No headroom remains for temporary allocations during block reorganisation. The result: the scheduler increasingly has to preempt (interrupt) active requests to reclaim blocks.

# Typowa konfiguracja powodująca problem
python -m vllm.entrypoints.openai.api_server \
  --model meta-llama/Meta-Llama-3.1-70B-Instruct \
  --tensor-parallel-size 4 \
  --gpu-memory-utilization 0.95 \     # ZA WYSOKO - brak headroom
  --max-model-len 32768 \
  --disable-log-requests

3. No prefix caching = repeated allocation of identical system prompts:

Every request allocates new blocks for the system prompt, even if it is identical to the previous one. With a 2000-token system prompt and 100 req/s, this means 100x allocation and deallocation of the same blocks every minute - massive fragmentation.

4. Long requests “pin” blocks:

A request with a 32K-token context window occupies ~2048 KV cache blocks for the entire duration of generation (which can take 30-60s). Short requests finishing in the meantime release their blocks, but these free spaces are too small for subsequent long requests. The effect accumulates over hours.

5. No compaction mechanism at runtime:

In vLLM versions <0.8 there is no memory defragmentation mechanism during operation. The only way to “reset” the block layout is to restart the process.

Resolution

A) Enable prefix caching (--enable-prefix-caching):

Prefix caching allows sharing KV cache blocks between requests with the same prefix (system prompt, few-shot examples). This drastically reduces the number of allocations/deallocations:

# Dodaj flagę do komendy startowej vLLM
python -m vllm.entrypoints.openai.api_server \
  --model meta-llama/Meta-Llama-3.1-70B-Instruct \
  --tensor-parallel-size 4 \
  --gpu-memory-utilization 0.88 \
  --max-model-len 32768 \
  --enable-prefix-caching \
  --disable-log-requests

In Kubernetes Deployment:

# deployment-vllm.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-server
  namespace: inference
spec:
  replicas: 2
  selector:
    matchLabels:
      app: vllm-server
  template:
    metadata:
      labels:
        app: vllm-server
    spec:
      containers:
        - name: vllm
          image: vllm/vllm-openai:v0.8.3
          args:
            - "--model"
            - "meta-llama/Meta-Llama-3.1-70B-Instruct"
            - "--tensor-parallel-size"
            - "4"
            - "--gpu-memory-utilization"
            - "0.88"
            - "--max-model-len"
            - "32768"
            - "--enable-prefix-caching"
            - "--disable-log-requests"
          resources:
            limits:
              nvidia.com/gpu: 4
          ports:
            - containerPort: 8000

B) Lower --gpu-memory-utilization to 0.85-0.88:

Leave 12-15% of GPU memory as headroom. This allows the scheduler to reorganise blocks more effectively without preemption:

# Oblicz optymalną wartość:
# GPU VRAM: 80 GB (A100)
# Model weights: ~35 GB (Llama 3.1 70B w FP16, TP=4 → ~35GB/GPU)
# KV cache headroom: min 10% VRAM = 8 GB wolnego
# Optymalne: (80 - 35 - 8) / 80 ≈ 0.46 → ale vLLM liczy to inaczej
# Praktycznie: 0.85-0.88 daje najlepszy balans throughput vs fragmentacja

# Zweryfikuj ile pamięci faktycznie używa KV cache
kubectl exec -n inference deploy/vllm-server -- \
  curl -s localhost:8000/metrics | grep "vllm:gpu_cache_usage_perc"
# Powinno oscylować między 0.70-0.85, nie powyżej 0.92

C) Scheduled rolling restart (CronJob + PodDisruptionBudget):

Schedule restarts every 6 hours while maintaining availability:

# pdb-vllm.yaml - gwarantuj min 1 pod dostępny
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: vllm-pdb
  namespace: inference
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app: vllm-server

---
# cronjob-restart.yaml - rolling restart co 6h
apiVersion: batch/v1
kind: CronJob
metadata:
  name: vllm-rolling-restart
  namespace: inference
spec:
  schedule: "0 */6 * * *"  # co 6 godzin
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: vllm-restart-sa
          containers:
            - name: restart
              image: bitnami/kubectl:1.30
              command:
                - /bin/sh
                - -c
                - |
                  echo "Starting rolling restart of vllm-server..."
                  kubectl rollout restart deployment/vllm-server -n inference
                  kubectl rollout status deployment/vllm-server -n inference --timeout=600s
                  echo "Rolling restart completed."
          restartPolicy: OnFailure

---
# rbac for restart SA
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: vllm-restart-role
  namespace: inference
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "patch"]
  - apiGroups: ["apps"]
    resources: ["deployments/status"]
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: vllm-restart-binding
  namespace: inference
subjects:
  - kind: ServiceAccount
    name: vllm-restart-sa
roleRef:
  kind: Role
  name: vllm-restart-role
  apiGroup: rbac.authorization.k8s.io

D) Routing by prompt length - separate node pools:

Split traffic into two deployments: short context (<4K tokens) and long context (>4K tokens). This eliminates alternating block allocations of vastly different sizes:

# Istio VirtualService - routing po headerze x-prompt-length
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: vllm-routing
  namespace: inference
spec:
  hosts:
    - vllm.inference.svc.cluster.local
  http:
    - match:
        - headers:
            x-prompt-tokens:
              regex: "^[0-3][0-9]{0,3}$"  # 0-3999 tokenów
      route:
        - destination:
            host: vllm-short-context
            port:
              number: 8000
    - route:
        - destination:
            host: vllm-long-context
            port:
              number: 8000

Alternatively - a simple Python proxy that classifies requests before sending them to the appropriate server:

# router.py - przykład prostego token-length routera
import tiktoken
from fastapi import FastAPI, Request
import httpx

app = FastAPI()
enc = tiktoken.get_encoding("cl100k_base")

SHORT_BACKEND = "http://vllm-short-context:8000"
LONG_BACKEND = "http://vllm-long-context:8000"
THRESHOLD_TOKENS = 4096

@app.post("/v1/chat/completions")
async def route_request(request: Request):
    body = await request.json()
    messages = body.get("messages", [])
    total_tokens = sum(len(enc.encode(m.get("content", ""))) for m in messages)

    backend = SHORT_BACKEND if total_tokens < THRESHOLD_TOKENS else LONG_BACKEND

    async with httpx.AsyncClient(timeout=120) as client:
        resp = await client.post(f"{backend}/v1/chat/completions", json=body)
        return resp.json()

E) Upgrade to vLLM 0.8+ with improved memory management:

vLLM 0.8 introduces an improved block allocator with partial online defragmentation. Upgrading significantly alleviates the problem:

# Upgrade obrazu w deployment
kubectl set image deployment/vllm-server -n inference \
  vllm=vllm/vllm-openai:v0.8.3

# Zweryfikuj wersję po starcie
kubectl exec -n inference deploy/vllm-server -- python -c "import vllm; print(vllm.__version__)"

Validation

After deploying the solutions, verify throughput stability for a minimum of 24 hours:

# Prometheus - throughput powinien być stabilny (±10%) przez 24h
# Alert rule: spadek >20% vs baseline
- alert: VLLMThroughputDegradation
  expr: |
    (
      avg_over_time(rate(vllm:generation_tokens_total[5m])[30m:5m])
      /
      avg_over_time(rate(vllm:generation_tokens_total[5m])[30m:5m] offset 6h)
    ) < 0.8
  for: 15m
  labels:
    severity: warning
    team: ml-platform
  annotations:
    summary: "vLLM throughput spadł >20% vs 6h temu"
    description: "Pod  generuje  bazowego throughput. Możliwa fragmentacja KV cache."
# Porównanie before/after - wykonaj po 24h od wdrożenia
# Before: metryki z dnia przed fix (z Prometheus/Grafana)
# After: bieżące metryki

# Sprawdź brak preemptions (kluczowy wskaźnik)
kubectl exec -n inference deploy/vllm-server -- \
  curl -s localhost:8000/metrics | grep "vllm:num_preemptions_total"
# Expected: wartość nie rośnie lub rośnie bardzo wolno (<1/min)

# Sprawdź cache hit ratio (po włączeniu prefix caching)
kubectl exec -n inference deploy/vllm-server -- \
  curl -s localhost:8000/metrics | grep "vllm:prefix_cache_hit"
# Expected: hit ratio > 60% dla workloadów z powtarzalnym system promptem

# Sprawdź stabilność TTFT w czasie
kubectl exec -n inference deploy/vllm-server -- \
  curl -s localhost:8000/metrics | grep "vllm:time_to_first_token"
# p50 powinno być stabilne (nie rosnąć w czasie)

Grafana dashboard query - visualising stability after the fix:

# Panel 1: Throughput (tokens/s) - powinien być płaski
rate(vllm:generation_tokens_total[5m])

# Panel 2: KV cache utilization - powinien oscylować 70-85%, nie 95%+
vllm:gpu_cache_usage_perc

# Panel 3: Preemptions rate - powinien być bliski zera
rate(vllm:num_preemptions_total[5m])

# Panel 4: Prefix cache hit ratio - powinien rosnąć po warm-up
rate(vllm:prefix_cache_hit_total[5m]) / (rate(vllm:prefix_cache_hit_total[5m]) + rate(vllm:prefix_cache_miss_total[5m]))
vLLM throughput degradation has a direct impact on the cost per token. If a GPU A100 (costing ~$2-3/hour in the cloud) generates 1500 tok/s instead of 3500 tok/s, the effective token cost increases 2.3x. For an inference cluster with 8 GPUs, that is an additional $200-400/day spent on compute that is not being fully utilised. Deploying prefix caching + memory utilisation tuning + scheduled restart eliminates this problem without additional infrastructure costs.

 

Jerzy Kopaczewski

vLLM losing performance after a few hours?

Book a free 30-minute call. We will analyse your inference cluster configuration, identify the source of fragmentation, and implement a solution ensuring stable 24/7 throughput.