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.
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]))
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.