vLLM GPU LLM Kubernetes performance

vLLM - degradacja wydajności wnioskowania przez fragmentację KV cache

Naprawa spadku wydajności vLLM po godzinach pracy: diagnoza fragmentacji KV cache, włączenie prefix caching, tuning gpu-memory-utilization i cykliczny restart.

·
Klaster vLLM do wnioskowania startuje z przepustowością ~3500 tok/s, ale po 6-8 godzinach pracy z ruchem produkcyjnym i promptami o zmiennej długości (100-32K tokenów) wydajność spada do ~1500 tok/s - degradacja rzędu 40-60%. Wykorzystanie GPU pozostaje wysokie (>90%), co wyklucza throttling termiczny. Problem narasta stopniowo i znika dopiero po restarcie procesu vLLM.

Ten runbook opisuje diagnozę i naprawę fragmentacji KV cache w vLLM. Temat planowania GPU i autoskalowania modeli AI omawiamy szczegółowo w artykule Infrastruktura Agentic AI - GPU scheduling i autoskalowanie na Kubernetes. Porównanie kosztów wnioskowania na własnej infrastrukturze vs zarządzane API znajdziesz w Azure OpenAI vs modele self-hosted - koszty, latency i rezydencja danych w UE. Jeśli potrzebujesz pomocy z optymalizacją wnioskowania na GPU - umów konsultacje.

Objaw

Przepustowość (tok/s) mierzona na punkcie końcowym /metrics vLLM spada stopniowo w czasie. Prometheus pokazuje charakterystyczny wzorzec: stabilna wydajność przez pierwszą godzinę, potem liniowy spadek:

# 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 do wykrycia degradacji w czasie:

# 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

Jak odróżnić fragmentację od innych przyczyn spadku wydajności:

Objaw Fragmentacja KV cache Thermal throttling OOM / swap
Wykorzystanie GPU >90% (wysokie) Spada do 50-70% Skoki / crashe
GPU temperature <80°C (normalna) >85°C Normalna
vllm:num_preemptions Rośnie w czasie Stały N/A (crash)
vllm:gpu_cache_usage Oscyluje 90-99% Stały ~80% 100% → OOM kill
Restart naprawia Tak (na kilka godzin) Nie Chwilowo

Przyczyna

Problem wynika z mechanizmu PagedAttention w vLLM i sposobu alokacji bloków pamięci KV cache:

1. Fragmentacja bloków przy zmiennej długości promptów:

vLLM dzieli pamięć GPU na bloki o stałym rozmiarze (domyślnie 16 tokenów/blok). Gdy na klaster trafiają naprzemiennie krótkie zapytania (100 tokenów = 7 bloków) i długie (32K tokenów = 2048 bloków), po zakończeniu krótkich zapytań powstają „dziury” - wolne bloki rozrzucone pomiędzy zajętymi blokami długich zapytań. Nowe długie zapytania nie mogą zaalokować ciągłej sekwencji bloków i muszą czekać na preemption.

2. gpu_memory_utilization ustawione zbyt wysoko (>0.92):

Przy domyślnym ustawieniu --gpu-memory-utilization 0.95 vLLM alokuje 95% pamięci GPU na KV cache. Nie pozostaje rezerwa na tymczasowe alokacje podczas reorganizacji bloków. Efekt: scheduler coraz częściej musi preemptować (przerywać) aktywne zapytania, żeby odzyskać bloki.

# 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. Brak prefix caching = powtórna alokacja identycznych system promptów:

Każde zapytanie alokuje nowe bloki dla system promptu, nawet jeśli jest identyczny jak w poprzednim. Przy system prompt o długości 2000 tokenów i 100 zap./s oznacza to 100× alokację i dealokację tych samych bloków co minutę - masywna fragmentacja.

4. Długie zapytania „pinują” bloki:

Zapytanie z context window 32K tokenów zajmuje ~2048 bloków KV cache przez cały czas generowania (może to trwać 30-60s). Krótkie zapytania kończące się w międzyczasie zwalniają swoje bloki, ale te wolne przestrzenie są zbyt małe dla kolejnych długich zapytań. Efekt kumuluje się przez godziny.

5. Brak mechanizmu kompakcji w runtime:

W wersjach vLLM <0.8 nie ma mechanizmu defragmentacji pamięci w trakcie pracy. Jedynym sposobem na „reset” layoutu bloków jest restart procesu.

Rozwiązanie

A) Włącz prefix caching (--enable-prefix-caching):

Prefix caching pozwala współdzielić bloki KV cache między zapytaniami o tym samym prefixie (system prompt, few-shot examples). Drastycznie redukuje liczbę alokacji/dealokacji:

# 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

W 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) Obniż --gpu-memory-utilization do 0.85–0.88:

Zostaw 12–15% pamięci GPU jako rezerwę. To pozwala schedulerowi na efektywniejszą reorganizację bloków bez 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) Cykliczny rolling restart (CronJob + PodDisruptionBudget):

Zaplanuj restarty co 6 godzin z zachowaniem dostępności:

# 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 po długości promptu - separacja node pools:

Rozdziel ruch na dwa deployments: krótki kontekst (<4K tokenów) i długi kontekst (>4K tokenów). Eliminuje to naprzemienne alokacje bloków o skrajnie różnych rozmiarach:

# 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

Alternatywnie - prosty proxy w Pythonie klasyfikujący zapytania przed wysłaniem do odpowiedniego serwera:

# 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 do vLLM 0.8+ z ulepszonym zarządzaniem pamięcią:

vLLM 0.8 wprowadza ulepszony allocator bloków z częściową defragmentacją online. Upgrade znacząco łagodzi 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__)"

Walidacja

Po wdrożeniu rozwiązań zweryfikuj stabilność przepustowości przez minimum 24h:

# 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 - wizualizacja stabilności po naprawie:

# 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]))
Degradacja przepustowości vLLM ma bezpośredni wpływ na koszt na token. Jeśli GPU A100 (koszt ~$2-3/h w chmurze) generuje 1500 tok/s zamiast 3500 tok/s, efektywny koszt tokenu rośnie 2.3×. Przy klastrze wnioskowania z 8 GPU to dodatkowe $200-400/dzień wydane na moc obliczeniową, która nie jest w pełni wykorzystana. Wdrożenie prefix caching + tuning memory utilization + cykliczny restart eliminuje ten problem bez dodatkowych kosztów infrastrukturalnych.

 

Jerzy Kopaczewski

vLLM traci wydajność po kilku godzinach?

Umów bezpłatną 30-minutową rozmowę. Przeanalizujemy konfigurację Twojego klastra wnioskowania, zidentyfikujemy źródło fragmentacji i wdrożymy rozwiązanie zapewniające stabilną przepustowość 24/7.