Skip to main content

Cyber AI România

Vineri, 6 noiembrie 2026

Automatic Prefix Caching în vLLM: măsori hit rate-ul și reduci prefill-ul repetat

Un serviciu intern primește aceeași documentație lungă la fiecare întrebare, dar schimbă ultimele câteva rânduri ale promptului. Fără reutilizarea KV cache-ului, GPU-ul recalculează prefixul comun pentru fiecare cerere; cu Automatic Prefix Caching, vLLM poate sări peste blocurile deja procesate.

Laboratorul de mai jos pornește același model în două configurații izolate: o dată fără prefix caching și o dată cu optimizarea activată. Clientul trimite aceleași întrebări secvențiale despre RFC 9110, măsoară time to first token — TTFT — și citește contoarele Prometheus vllm:prefix_cache_queries_total și vllm:prefix_cache_hits_total.

Rezultatul este verificabil în trei artefacte:

  • results/apc_off.json, cu măsurătorile baseline;
  • results/apc_on.json, cu măsurătorile Automatic Prefix Caching;
  • results/comparison.md, cu hit rate-ul și diferența mediană de TTFT.

Acest experiment izolează reutilizarea prefixului. Nu compară modele, cuantizări sau lungimi diferite de output. Pentru arhitectura completă de serving, inclusiv continuous batching, PagedAttention și KV cache, punctul de plecare este ghidul despre deployarea unui LLM cu vLLM.

Fixăm laboratorul la vLLM 0.29.0 și un prefix de aproximativ 4.096 de tokeni

Configurația folosită este:

  • Ubuntu Server 24.04 LTS;
  • Python 3.12;
  • Docker Engine 27 sau mai nou;
  • NVIDIA Container Toolkit funcțional;
  • imaginea vllm/vllm-openai:v0.29.0-cu129;
  • modelul Qwen/Qwen2.5-1.5B-Instruct;
  • o placă NVIDIA cu minimum 16 GB VRAM;
  • minimum 16 GB RAM și 20 GB spațiu liber;
  • aproximativ 30–60 de minute, inclusiv descărcarea imaginii și a modelului.

Modelul are licență Apache 2.0, conform fișei sale oficiale. Corpusul este textul RFC 9110, preluat de la RFC Editor și folosit sub condițiile IETF Trust Legal Provisions. Dimensiunea fișierului se măsoară după descărcare, în loc să fie presupusă.

Verifică gazda înainte să creezi proiectul:

nvidia-smi
docker version --format 'Docker server: {{.Server.Version}}'
docker run --rm --gpus all ubuntu:24.04 nvidia-smi
python3 --version
df -h .
free -h

Containerul de test trebuie să vadă aceeași placă NVIDIA ca gazda. Versiunea driverului și cantitatea de memorie afișată diferă între sisteme, dar comenzile trebuie să se încheie fără eroare.

Un rezultat valid are forma:

Docker server: 27.x sau mai nou
Python 3.12.x
Containerul ubuntu:24.04 afișează placa NVIDIA și versiunea driverului
Cel puțin 20 GB disponibili pe volumul proiectului

Dacă docker run --gpus all nu vede placa, nu continua cu vLLM: repară mai întâi integrarea NVIDIA Container Toolkit.

Instalăm clientul de benchmark și materializăm corpusul RFC 9110

Pregătirea comună rămâne într-un singur bloc. Comenzile creează un mediu virtual și fixează versiunile bibliotecilor clientului; vLLM rulează separat, în imaginea Docker.

sudo apt-get update
sudo apt-get install -y python3-venv curl
mkdir -p "$HOME/vllm-apc-lab"/{data,results,scripts}
cd "$HOME/vllm-apc-lab"
python3 -m venv .venv
. .venv/bin/activate
cat > requirements.txt <<'EOF'
prometheus-client==0.21.1
requests==2.32.3
transformers==4.51.3
EOF
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
curl --fail --location 
  https://www.rfc-editor.org/rfc/rfc9110.txt 
  --output data/rfc9110.txt
wc -c data/rfc9110.txt

Fișierul RFC Editor este corpusul real al experimentului. Nu îl trimitem integral modelului: scriptul de pregătire îl tokenizează cu tokenizerul modelului și păstrează un prefix apropiat de 4.096 de tokeni.

Structura finală va fi:

vllm-apc-lab/
├── .venv/                         mediul Python al clientului
├── data/
│   ├── rfc9110.txt                corpusul original
│   ├── shared_context.txt         prefixul trimis fiecărei cereri
│   └── context_metadata.json      tokenizerul și lungimea măsurată
├── results/
│   ├── apc_off.json               baseline fără APC
│   ├── apc_on.json                rularea cu APC
│   └── comparison.md              raportul comparativ
├── scripts/
│   ├── prepare_context.py         construiește prefixul comun
│   ├── serve.sh                   pornește configurația aleasă
│   ├── benchmark.py               măsoară TTFT și contoarele cache
│   ├── compare.py                 validează și compară rezultatele
│   └── run_experiment.sh          orchestrează experimentul complet
└── requirements.txt               dependențele clientului

Volumul Docker vllm-apc-hf-cache va păstra modelul între cele două rulări. Astfel, descărcarea nu contaminează comparația de latență.

Pasul 1 — Transformăm RFC 9110 într-un prefix stabil pentru toate cererile

Prefix caching funcționează numai pentru tokenii identici de la începutul cererilor. Normalizarea diferită, un timestamp introdus în mesajul de sistem sau reordonarea textului produce alte secvențe de tokeni și reduce hit rate-ul.

Fișierul scripts/prepare_context.py:

import json
from pathlib import Path

from transformers import AutoTokenizer

MODEL_ID = "Qwen/Qwen2.5-1.5B-Instruct"
TARGET_TOKENS = 4096
SOURCE_PATH = Path("data/rfc9110.txt")
OUTPUT_PATH = Path("data/shared_context.txt")
METADATA_PATH = Path("data/context_metadata.json")


def main() -> None:
    if not SOURCE_PATH.is_file():
        raise SystemExit(
            "Lipsește data/rfc9110.txt. Descarcă documentul de la RFC Editor."
        )

    source = SOURCE_PATH.read_text(encoding="utf-8")
    tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)

    source_token_ids = tokenizer.encode(source, add_special_tokens=False)
    if len(source_token_ids) < TARGET_TOKENS:
        raise SystemExit(
            f"Corpus insuficient: {len(source_token_ids)} tokeni, "
            f"necesari {TARGET_TOKENS}."
        )

    selected_ids = source_token_ids[:TARGET_TOKENS]
    shared_context = tokenizer.decode(
        selected_ids,
        skip_special_tokens=True,
        clean_up_tokenization_spaces=False,
    )
    roundtrip_ids = tokenizer.encode(shared_context, add_special_tokens=False)

    if len(roundtrip_ids) < 4000:
        raise SystemExit(
            f"Prefixul rezultat are doar {len(roundtrip_ids)} tokeni."
        )

    OUTPUT_PATH.write_text(shared_context, encoding="utf-8")
    metadata = {
        "model": MODEL_ID,
        "requested_source_tokens": TARGET_TOKENS,
        "saved_context_tokens": len(roundtrip_ids),
        "source_bytes": SOURCE_PATH.stat().st_size,
        "context_bytes": OUTPUT_PATH.stat().st_size,
    }
    METADATA_PATH.write_text(
        json.dumps(metadata, indent=2, ensure_ascii=False) + "n",
        encoding="utf-8",
    )
    print(json.dumps(metadata, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main()

Rulează pregătirea:

cd "$HOME/vllm-apc-lab"
. .venv/bin/activate
python scripts/prepare_context.py

TARGET_TOKENS se aplică înainte de operația decode–encode. Din cauza normalizării tokenizerului, valoarea salvată poate fi apropiată, dar nu obligatoriu identică cu 4.096. Scriptul refuză orice rezultat sub 4.000 de tokeni.

Exemplul de output va conține valorile măsurate local:

{
  "model": "Qwen/Qwen2.5-1.5B-Instruct",
  "requested_source_tokens": 4096,
  "saved_context_tokens": <valoarea_măsurată>,
  "source_bytes": <valoarea_măsurată>,
  "context_bytes": <valoarea_măsurată>
}

Punct de control: data/shared_context.txt trebuie să existe, iar saved_context_tokens din data/context_metadata.json trebuie să fie cel puțin 4.000.

Pasul 2 — Pornim același server cu APC oprit sau activat explicit

Cele două rulări trebuie să schimbe numai starea prefix cache-ului. Modelul, limita contextului, memoria rezervată, portul și imaginea Docker rămân identice.

Fișierul scripts/serve.sh:

#!/usr/bin/env bash
set -euo pipefail

MODE="${1:-}"
CONTAINER_NAME="vllm-apc-lab"
IMAGE="vllm/vllm-openai:v0.29.0-cu129"
MODEL="Qwen/Qwen2.5-1.5B-Instruct"

if [[ "$MODE" != "off" && "$MODE" != "on" ]]; then
  echo "Utilizare: $0 off|on" >&2
  exit 2
fi

docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true

CACHE_ARGS=(--no-enable-prefix-caching)
if [[ "$MODE" == "on" ]]; then
  CACHE_ARGS=(
    --enable-prefix-caching
    --prefix-caching-hash-algo sha256_cbor
  )
fi

docker run --detach 
  --name "$CONTAINER_NAME" 
  --gpus all 
  --ipc=host 
  --publish 127.0.0.1:8000:8000 
  --mount source=vllm-apc-hf-cache,target=/root/.cache/huggingface 
  "$IMAGE" 
  --host 0.0.0.0 
  --port 8000 
  --model "$MODEL" 
  --served-model-name qwen-apc 
  --max-model-len 8192 
  --gpu-memory-utilization 0.85 
  "${CACHE_ARGS[@]}"

echo "Server pornit în modul APC=$MODE."
echo "Loguri: docker logs -f $CONTAINER_NAME"

Aplică permisiunea de execuție și verifică prima configurație:

chmod +x scripts/serve.sh
./scripts/serve.sh off
docker logs -f vllm-apc-lab

--no-enable-prefix-caching produce baseline-ul, iar --enable-prefix-caching activează reutilizarea. sha256_cbor folosește serializare CBOR canonică și SHA-256 pentru hash-uri reproductibile între limbaje și procese. Portul este publicat numai pe 127.0.0.1, deci laboratorul nu expune API-ul în rețea.

--max-model-len 8192 lasă loc pentru prefix, formatul chat, întrebarea variabilă și răspuns. --gpu-memory-utilization 0.85 îi permite vLLM să rezerve 85% din memoria GPU disponibilă pentru execuție și KV cache, păstrând o marjă pentru procesele gazdei.

Când serverul este pregătit, trebuie să răspundă:

curl --fail http://127.0.0.1:8000/health
curl --fail http://127.0.0.1:8000/v1/models
curl --fail http://127.0.0.1:8000/metrics 
  | grep -E 'vllm:(prefix_cache|time_to_first_token)'

Exemplul de structură a răspunsului pentru modele este:

Un obiect JSON cu modelul servit sub numele qwen-apc

Punct de control: dacă /health nu returnează succes, inspectează docker logs vllm-apc-lab; benchmark-ul nu trebuie pornit cât timp modelul încă se încarcă.

Pasul 3 — Măsurăm TTFT și contoarele de tokeni interogați și reutilizați

Metricile prefix cache din vLLM numără tokeni, nu cereri. Hit rate-ul relevant este:

delta(prefix_cache_hits_total) / delta(prefix_cache_queries_total)

Folosim diferența dintre contoarele citite înainte și după test, nu valoarea cumulată de la pornirea procesului. Clientul trimite o cerere de încălzire, apoi 12 întrebări cu același mesaj de sistem și aceeași documentație.

Fișierul scripts/benchmark.py:

import argparse
import json
import statistics
import time
from pathlib import Path
from typing import Any

import requests
from prometheus_client.parser import text_string_to_metric_families

BASE_URL = "http://127.0.0.1:8000"
MODEL = "qwen-apc"
CONTEXT_PATH = Path("data/shared_context.txt")

QUESTIONS = [
    "Care este scopul semanticii HTTP descrise în document?",
    "Cum diferențiază documentul resursele de reprezentări?",
    "Ce rol au metodele HTTP în procesarea unei cereri?",
    "Cum este descrisă metoda GET?",
    "Ce proprietăți are metoda HEAD?",
    "Cum este explicată metoda POST?",
    "Ce înseamnă o metodă idempotentă?",
    "Cum sunt tratate câmpurile de antet?",
    "Ce rol are identificatorul URI?",
    "Cum este descris codul de stare al răspunsului?",
    "Ce spune documentul despre conținutul unei reprezentări?",
    "Cum trebuie interpretate cererile condiționale?",
    "Care este relația dintre cache și semantica unei metode?",
]


def wait_for_server(timeout_seconds: int = 600) -> None:
    deadline = time.monotonic() + timeout_seconds
    last_error = "serverul nu a răspuns încă"

    while time.monotonic() < deadline:
        try:
            response = requests.get(f"{BASE_URL}/health", timeout=5)
            if response.ok:
                return
            last_error = f"HTTP {response.status_code}"
        except requests.RequestException as exc:
            last_error = str(exc)
        time.sleep(2)

    raise SystemExit(
        f"Serverul vLLM nu a devenit sănătos în {timeout_seconds}s: "
        f"{last_error}"
    )


def read_metrics() -> dict[str, float]:
    response = requests.get(f"{BASE_URL}/metrics", timeout=15)
    response.raise_for_status()

    wanted = {
        "vllm:prefix_cache_queries_total": 0.0,
        "vllm:prefix_cache_hits_total": 0.0,
    }

    for family in text_string_to_metric_families(response.text):
        for sample in family.samples:
            if sample.name in wanted:
                wanted[sample.name] += float(sample.value)

    return wanted


def stream_request(context: str, question: str) -> dict[str, Any]:
    payload = {
        "model": MODEL,
        "messages": [
            {
                "role": "system",
                "content": (
                    "Răspunde exclusiv din documentul de mai jos. "
                    "Dacă răspunsul nu apare în document, spune acest lucru.nn"
                    + context
                ),
            },
            {"role": "user", "content": question},
        ],
        "temperature": 0,
        "max_tokens": 64,
        "stream": True,
        "stream_options": {"include_usage": True},
    }

    started = time.perf_counter()
    first_token_at: float | None = None
    usage: dict[str, int] = {}

    with requests.post(
        f"{BASE_URL}/v1/chat/completions",
        json=payload,
        stream=True,
        timeout=(10, 300),
    ) as response:
        response.raise_for_status()

        for raw_line in response.iter_lines():
            if not raw_line:
                continue

            line = raw_line.decode("utf-8")
            if not line.startswith("data: "):
                continue

            data = line[6:]
            if data == "[DONE]":
                break

            event = json.loads(data)
            if event.get("usage"):
                usage = event["usage"]

            choices = event.get("choices", [])
            if choices:
                content = choices[0].get("delta", {}).get("content")
                if content and first_token_at is None:
                    first_token_at = time.perf_counter()

    finished = time.perf_counter()
    if first_token_at is None:
        raise RuntimeError(
            "Răspunsul streaming nu a conținut niciun token de text."
        )

    return {
        "question": question,
        "ttft_seconds": first_token_at - started,
        "total_seconds": finished - started,
        "prompt_tokens": usage.get("prompt_tokens"),
        "completion_tokens": usage.get("completion_tokens"),
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--label", choices=["apc_off", "apc_on"], required=True)
    args = parser.parse_args()

    if not CONTEXT_PATH.is_file():
        raise SystemExit(
            "Lipsește data/shared_context.txt. Rulează prepare_context.py."
        )

    context = CONTEXT_PATH.read_text(encoding="utf-8")
    wait_for_server()
    before = read_metrics()

    warmup = stream_request(context, QUESTIONS[0])
    measured = [
        stream_request(context, question)
        for question in QUESTIONS[1:]
    ]

    after = read_metrics()
    query_delta = (
        after["vllm:prefix_cache_queries_total"]
        - before["vllm:prefix_cache_queries_total"]
    )
    hit_delta = (
        after["vllm:prefix_cache_hits_total"]
        - before["vllm:prefix_cache_hits_total"]
    )
    hit_rate = hit_delta / query_delta if query_delta > 0 else None

    result = {
        "label": args.label,
        "model": MODEL,
        "request_count": len(measured),
        "warmup": warmup,
        "requests": measured,
        "median_ttft_seconds": statistics.median(
            item["ttft_seconds"] for item in measured
        ),
        "median_total_seconds": statistics.median(
            item["total_seconds"] for item in measured
        ),
        "metrics_before": before,
        "metrics_after": after,
        "prefix_cache_query_tokens_delta": query_delta,
        "prefix_cache_hit_tokens_delta": hit_delta,
        "prefix_cache_hit_rate": hit_rate,
    }

    output_path = Path("results") / f"{args.label}.json"
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(
        json.dumps(result, indent=2, ensure_ascii=False) + "n",
        encoding="utf-8",
    )
    print(f"Rezultat salvat: {output_path}")
    print(f"Median TTFT: {result['median_ttft_seconds']:.6f}s")
    print(f"Prefix query tokens: {query_delta:.0f}")
    print(f"Prefix hit tokens: {hit_delta:.0f}")
    if hit_rate is None:
        print("Prefix hit rate: indisponibil")
    else:
        print(f"Prefix hit rate: {hit_rate:.6%}")


if __name__ == "__main__":
    main()

temperature: 0 reduce variația răspunsurilor, iar max_tokens: 64 ține decode-ul scurt pentru ca diferența de prefill să fie vizibilă. TTFT este măsurat dinaintea cererii HTTP până la primul fragment text primit. Include coada, rețeaua locală, tokenizarea și prefill-ul, deci nu este o măsură directă a numărului de operații GPU.

Output-ul fiecărei rulări începe astfel:

Rezultat salvat: results/apc_off.json
Median TTFT: <valoare_măsurată>s
Prefix query tokens: <valoare_măsurată>
Prefix hit tokens: <valoare_măsurată>
Prefix hit rate: <valoare_măsurată_sau_indisponibil>

Punct de control: results/apc_on.json trebuie să conțină un prefix_cache_hit_tokens_delta mai mare decât zero. Dacă este zero, nu concluziona că APC nu ajută înainte să verifici identitatea prefixurilor și metricile versiunii instalate.

Pasul 4 — Rulăm baseline-ul și APC în procese curate

Repornirea între variante elimină blocurile KV și contoarele acumulate de configurația anterioară. Modelul rămâne în același volum local, astfel încât transferul din Hugging Face nu intră în timpii măsurați.

Fișierul scripts/run_experiment.sh:

#!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "$0")/.."
. .venv/bin/activate

cleanup() {
  docker rm -f vllm-apc-lab >/dev/null 2>&1 || true
}
trap cleanup EXIT

python scripts/prepare_context.py

./scripts/serve.sh off
python scripts/benchmark.py --label apc_off
docker rm -f vllm-apc-lab >/dev/null

./scripts/serve.sh on
python scripts/benchmark.py --label apc_on
docker rm -f vllm-apc-lab >/dev/null

python scripts/compare.py

Nu rula alte cereri către portul 8000 în timpul experimentului. Contoarele Prometheus sunt globale procesului, iar trafic suplimentar ar intra în diferența măsurată.

Acordă permisiunea și pornește:

chmod +x scripts/run_experiment.sh
./scripts/run_experiment.sh

Prima pornire poate dura mai mult deoarece imaginea și modelul trebuie descărcate. Scriptul așteaptă până la zece minute ca endpointul /health să devină disponibil; timpul de inițializare nu intră în TTFT.

Ce trebuie să vezi:

Server pornit în modul APC=off.
Rezultat salvat: results/apc_off.json
Server pornit în modul APC=on.
Rezultat salvat: results/apc_on.json

Punct de control: cele două fișiere JSON trebuie să declare același model și același număr de cereri.

Pasul 5 — Validăm hit-urile și calculăm reducerea mediană de TTFT

Automatic Prefix Caching reduce calculele de prefill numai pentru blocurile găsite în cache. Nu scurtează calculul tokenilor generați și nu promite automat un anumit procent de reducere a latenței.

Fișierul scripts/compare.py:

import json
import statistics
from pathlib import Path
from typing import Any

OFF_PATH = Path("results/apc_off.json")
ON_PATH = Path("results/apc_on.json")
REPORT_PATH = Path("results/comparison.md")


def load_result(path: Path) -> dict[str, Any]:
    if not path.is_file():
        raise SystemExit(f"Lipsește rezultatul: {path}")

    data = json.loads(path.read_text(encoding="utf-8"))
    required = {
        "label",
        "model",
        "request_count",
        "requests",
        "prefix_cache_query_tokens_delta",
        "prefix_cache_hit_tokens_delta",
        "prefix_cache_hit_rate",
    }
    missing = required.difference(data)
    if missing:
        raise SystemExit(
            f"{path} nu conține câmpurile: {sorted(missing)}"
        )
    return data


def median_ttft(data: dict[str, Any]) -> float:
    values = [
        float(request["ttft_seconds"])
        for request in data["requests"]
    ]
    if not values:
        raise SystemExit(f"{data['label']} nu conține cereri măsurate.")
    return statistics.median(values)


def format_rate(value: float | None) -> str:
    return "indisponibil" if value is None else f"{value:.2%}"


def main() -> None:
    off = load_result(OFF_PATH)
    on = load_result(ON_PATH)

    if off["model"] != on["model"]:
        raise SystemExit("Modelele diferă între cele două rulări.")
    if off["request_count"] != on["request_count"]:
        raise SystemExit("Numărul de cereri diferă între rulări.")
    if float(on["prefix_cache_query_tokens_delta"]) <= 0:
        raise SystemExit(
            "APC activ nu a raportat tokeni interogați în prefix cache."
        )
    if float(on["prefix_cache_hit_tokens_delta"]) <= 0:
        raise SystemExit(
            "APC activ nu a produs niciun hit; comparația este invalidă."
        )

    off_ttft = median_ttft(off)
    on_ttft = median_ttft(on)
    reduction = ((off_ttft - on_ttft) / off_ttft) * 100.0

    report = f"""# Comparație vLLM Automatic Prefix Caching

| Configurație | Cereri măsurate | Median TTFT (s) | Tokeni interogați | Tokeni reutilizați | Hit rate |
|---|---:|---:|---:|---:|---:|
| APC oprit | {off["request_count"]} | {off_ttft:.6f} | {off["prefix_cache_query_tokens_delta"]:.0f} | {off["prefix_cache_hit_tokens_delta"]:.0f} | {format_rate(off["prefix_cache_hit_rate"])} |
| APC activ | {on["request_count"]} | {on_ttft:.6f} | {on["prefix_cache_query_tokens_delta"]:.0f} | {on["prefix_cache_hit_tokens_delta"]:.0f} | {format_rate(on["prefix_cache_hit_rate"])} |

Reducerea mediană TTFT măsurată: {reduction:.2f}%.

Hit rate-ul reprezintă ponderea tokenilor de prefix găsiți în cache.
Este un proxy pentru prefill-ul evitat, nu o măsurare directă a FLOP-urilor
sau a consumului energetic.
"""

    REPORT_PATH.write_text(report, encoding="utf-8")
    print("PASS: APC a produs hit-uri pentru prefixul comun.")
    print(f"Raport salvat: {REPORT_PATH}")
    print(report)


if __name__ == "__main__":
    main()

Raportul final conține valorile produse de hardware-ul local:

PASS: APC a produs hit-uri pentru prefixul comun.
Raport salvat: results/comparison.md
# Comparație vLLM Automatic Prefix Caching

Testul de capăt este:

test -s results/apc_off.json
test -s results/apc_on.json
test -s results/comparison.md
grep -F 'APC activ' results/comparison.md
grep -F 'Hit rate' results/comparison.md

Toate comenzile trebuie să termine cu codul de ieșire zero. Tabelul trebuie interpretat pe trei axe:

  • Tokeni interogați arată cât prefix a fost verificat în cache;
  • Tokeni reutilizați arată cât prefill a putut fi sărit;
  • Median TTFT arată efectul observat de client în acest test secvențial.

Dacă apc_on are hit-uri, dar TTFT nu scade, GPU-ul poate fi prea rapid pentru modelul ales, promptul poate fi prea scurt pentru hardware sau latența poate fi dominată de tokenizare și infrastructură. Pentru prompturi mult mai lungi, separarea costului de prefill de decode poate fi continuată cu Disaggregated Prefill în vLLM.

Citim hit rate-ul ca raport de tokeni, nu ca procent de cereri reușite

În Prometheus, raportul pe o fereastră de cinci minute este:

sum(rate(vllm:prefix_cache_hits_total[5m]))
/
sum(rate(vllm:prefix_cache_queries_total[5m]))

Pentru un singur proces, fără agregare:

rate(vllm:prefix_cache_hits_total[5m])
/
rate(vllm:prefix_cache_queries_total[5m])

Numitorul zero trebuie tratat ca „nu există interogări măsurate”, nu ca hit rate zero. Un raport ridicat confirmă reutilizarea tokenilor, dar nu dovedește singur o latență bună: cererile pot aștepta în coadă, iar decode-ul rămâne de executat.

APC este potrivit pentru conversații cu istoric comun, documente lungi interogate repetat și aplicații cu instrucțiuni de sistem stabile. Într-un agent care reconstruiește diferit profilul și conversația la fiecare apel, organizarea memoriei descrisă în articolul despre separarea profilului de istoricul conversației poate ajuta și la păstrarea unui prefix stabil.

Cache-ul nu schimbă răspunsul modelului și nu permite solicitărilor să citească textul altor utilizatori. Documentația vLLM precizează că blocurile KV nu sunt inversate în tokenii originali. Totuși, aplicația trebuie să păstreze izolarea datelor la nivel de autentificare, autorizare și logging.

Defecțiuni care indică exact unde s-a rupt experimentul

docker: Error response from daemon: could not select device driver "" with capabilities: [[gpu]]

Docker nu are configurat runtime-ul NVIDIA. Confirmă mai întâi testul minimal:

docker run --rm --gpus all ubuntu:24.04 nvidia-smi

Dacă acesta eșuează, instalează sau repară NVIDIA Container Toolkit conform documentației NVIDIA și repornește Docker. Nu modifica scripturile vLLM pentru a masca problema.

ValueError: No available memory for the cache blocks

După încărcarea modelului nu mai rămâne suficient VRAM pentru KV cache. Închide procesele GPU străine și verifică memoria:

nvidia-smi
docker rm -f vllm-apc-lab

Laboratorul cere o placă de minimum 16 GB tocmai pentru a păstra --max-model-len 8192 și configurația comună. Reducerea arbitrară a limitei numai într-una dintre rulări invalidează comparația.

Serverul vLLM nu a devenit sănătos în 600s

Modelul nu a fost încărcat sau procesul a ieșit. Citește finalul logului:

docker logs --tail 200 vllm-apc-lab
docker inspect vllm-apc-lab 
  --format '{{.State.Status}} {{.State.ExitCode}}'

Cauzele relevante sunt eșecul descărcării modelului, incompatibilitatea driverului cu imaginea CUDA sau lipsa VRAM.

Răspunsul streaming nu a conținut niciun token de text.

API-ul a terminat fluxul fără un fragment content. Verifică răspunsul direct și logurile serverului:

curl --fail http://127.0.0.1:8000/v1/models
docker logs --tail 100 vllm-apc-lab

Nu înlocui măsurarea cu timpul total al cererii: aceea ar amesteca prefill-ul cu întregul decode.

APC activ nu a raportat tokeni interogați în prefix cache.

Endpointul /metrics nu a expus contoarele așteptate sau procesul testat nu este cel pornit de script. Inspectează numele efective:

curl --fail http://127.0.0.1:8000/metrics 
  | grep -F 'prefix_cache'
docker inspect vllm-apc-lab 
  --format '{{json .Config.Cmd}}'

Metricile și semantica lor pot evolua între versiuni. Tutorialul fixează vLLM 0.29.0; dacă folosești alt tag, adaptează parserul numai după ce verifici documentația acelei versiuni.

APC activ nu a produs niciun hit; comparația este invalidă.

Cererile au fost procesate, dar prefixul comun nu a fost reutilizat. Confirmă că scriptul rulează varianta on, că toate cererile folosesc același shared_context.txt și că nu ai introdus date variabile înaintea documentului:

docker inspect vllm-apc-lab 
  --format '{{json .Config.Cmd}}'
sha256sum data/shared_context.txt
cat data/context_metadata.json

Timestampurile, identificatorii de request și metadatele utilizatorului trebuie mutate după prefixul stabil dacă semantica aplicației permite acest lucru.

Curățăm laboratorul fără să ștergem alte imagini sau volume Docker

Oprește containerul, apoi elimină numai mediul și cache-ul create de acest proiect:

cd "$HOME"
docker rm -f vllm-apc-lab >/dev/null 2>&1 || true
docker volume rm vllm-apc-hf-cache >/dev/null 2>&1 || true
rm -rf "$HOME/vllm-apc-lab"

docker volume rm vllm-apc-hf-cache șterge modelul descărcat în acest laborator. Nu folosi docker system prune, deoarece ar putea elimina imagini și volume fără legătură cu experimentul.

Lista de acceptare pentru configurația APC

  • ☐ Containerul NVIDIA vede placa GPU fără eroare.
  • ☐ Serverul folosește imaginea vllm/vllm-openai:v0.29.0-cu129.
  • ☐ Ambele rulări servesc modelul sub numele qwen-apc.
  • ☐ Prefixul salvat are cel puțin 4.000 de tokeni.
  • ☐ Baseline-ul pornește cu --no-enable-prefix-caching.
  • ☐ Varianta optimizată pornește cu --enable-prefix-caching.
  • ☐ results/apc_on.json raportează tokeni reutilizați mai mulți decât zero.
  • ☐ Hit rate-ul este calculat din diferența contoarelor, nu din totalurile istorice.
  • ☐ Tabelul compară același număr de cereri și aceeași limită de output.
  • ☐ Reducerea TTFT este raportată ca măsurătoare locală, nu ca performanță garantată.

Următorul experiment logic este concurența: păstrezi corpusul și modelul, dar trimiți cereri simultane pentru a observa interacțiunea dintre APC, coada schedulerului și continuous batching. După aceea, poți repeta exact workload-ul cu o versiune cuantizată și aplica metodologia din comparația AWQ, GPTQ și FP8, fără să amesteci efectul cuantizării cu efectul prefix cache-ului.

Întrebări frecvente

Automatic Prefix Caching accelerează și generarea tokenilor de răspuns?

Nu. APC evită recalcularea blocurilor reutilizabile din prefill; etapa de decode pentru tokenii noi continuă să fie executată.

Hit rate-ul vLLM reprezintă procentul cererilor servite din cache?

Nu. În configurația măsurată, raportul folosește numărul tokenilor de prefix găsiți în cache împărțit la numărul tokenilor interogați.

De ce este exclusă prima cerere din mediana TTFT?

Prima cerere încălzește modelul și introduce prefixul în cache. Mediana este calculată pe următoarele 12 cereri, care testează reutilizarea prefixului comun.

Pot compara rezultate produse cu alte modele sau alte limite de context?

Nu direct. Pentru o comparație validă între APC oprit și activat trebuie păstrate același model, același prefix, aceeași limită de context, aceeași lungime maximă a răspunsului și același hardware.

Surse

Facebook
X
WhatsApp
Automatic Prefix Caching în vLLM: cum măsori hit rate-ul și reduci compute-ul pentru prompturi lungi

Te-ar putea interesa si: