Salta al contenuto principale

vLLM infrastructure deep-dive: PagedAttention, tensor parallelism, batch processing

vLLM infrastructure deep-dive: PagedAttention, tensor parallelism, batch processing vLLM V1 engine, rilasciato a gennaio 2025, evita il Head-of-Line...

Cosa ne pensi di questo articolo?

vLLM infrastructure deep-dive: PagedAttention, tensor parallelism, batch processing
AI InfrastructureFoto:Pavel DanilyukPexels

In Breve

vLLM infrastructure deep-dive: PagedAttention, tensor parallelism, batch processing vLLM V1 engine, rilasciato a gennaio 2025, evita il Head-of-Line...

vLLM infrastructure deep-dive: PagedAttention, tensor parallelism, batch processing

vLLM V1 engine, rilasciato a gennaio 2025, evita il Head-of-Line blocking che affligge ogni sistema di serving LLM basato su batching statico. Il risultato concreto: 100 richieste concorrenti servite simultaneamente su una singola GPU A100, con latenza stabile fino a saturazione. Questo non è marketing - è il risultato diretto di tre scelte architetturali che Woosuk Kwon e il team di Berkeley hanno codificato nel paper Efficient Memory Management for Large Language Model Serving with PagedAttention (Kwon et al., SOSP 2023).

In questo episodio analizziamo le tre tecnologie core di vLLM - PagedAttention, tensor parallelism e continuous batching - con codice funzionante, tabelle di benchmark reali e una guida step-by-step per deployare su multi-GPU e cluster Ray. Se stai ancora usando Ollama per servire più di 5 utenti simultanei, alla fine di questo articolo capirai esattamente cosa stai lasciando sul tavolo.

Cosa Imparerai

  • Come PagedAttention gestisce la KV cache con pagine logiche non contigue, riducendo la frammentazione all'4%
  • Configurare tensor_parallel_size su 1, 2, 4 e 8 GPU con benchmark Llama 3.1 70B reali
  • Come il continuous batching elimina il Head-of-Line blocking e mantiene la GPU satura
  • Impostare speculative decoding con un modello draft per ridurre la latency per token
  • Distribuire vLLM su cluster Ray multi-node con YAML di configurazione completo
  • Fare benchmark asincroni con asyncio per misurare throughput a diversi livelli di concorrenza
  • Monitorare GPU memory e utilizzo con nvidia-smi in loop continuo

Prerequisiti

  • Aver letto EP01 - Ollama vs vLLM e EP02 - Ollama setup production
  • Accesso a GPU NVIDIA con CUDA 11.8+ (almeno RTX 3090 24GB o A100 40GB consigliata)
  • Python 3.10+ con pip e un ambiente virtuale
  • Familiarità con asyncio Python e REST API
  • vLLM installato: pip install vllm

Sezione 1 - PagedAttention: la meccanica della KV cache a pagine

Per capire PagedAttention devi prima capire il problema che risolve. Ogni richiesta LLM genera una KV cache (Key-Value cache): i vettori di attention calcolati per ogni token vengono memorizzati per evitare di ricalcolarli a ogni step. Il problema è la dimensione: Llama 3.1 70B in FP16 usa circa 160 KB per token su tutti i layer. Una richiesta con 2048 token di contesto occupa ~327 MB di VRAM solo per la KV cache.

I sistemi tradizionali pre-allocano memoria contigua per la lunghezza massima possibile di ogni sequenza al momento dell'arrivo della richiesta. Se la richiesta poi termina a 200 token invece di 2048, i 295 MB rimanenti restano bloccati fino al completamento. Secondo Kwon et al. (2023), questo porta a frammentazione interna e esterna del 60-80% della VRAM disponibile per la KV cache.

PagedAttention risolve questo applicando il principio della memoria virtuale paginata dei sistemi operativi. La KV cache viene divisa in blocchi logici da 16 token ciascuno. Ogni blocco logico viene mappato su un blocco fisico in VRAM tramite una block table. I blocchi fisici non devono essere contigui. I blocchi vengono allocati on-demand: solo quando vengono effettivamente generati i token.

Caratteristica Allocazione Contigua Tradizionale PagedAttention (Kwon et al. 2023)
Allocazione iniziale Pre-alloca lunghezza massima al momento della richiesta Alloca blocchi da 16 token on-demand
Frammentazione interna 60–80% (token non usati nella sequenza) <4% (max 15 slot nell'ultimo blocco parziale)
Frammentazione esterna Elevata (gap tra sequenze di lunghezze diverse) Zero (blocchi fisici riusati da un pool libero)
KV cache sharing Impossibile (memoria dedicata per sequenza) Sì (prefix caching: stessa chiave logica = stesso blocco fisico)
Batch size massimo (A100 80GB) ~12–15 richieste con contesto 2K ~60–80 richieste con contesto 2K
Throughput A100 Llama 3.1 8B ~300–600 tok/s ~12.500 tok/s (H100), ~2.500 tok/s (A100)

Il kernel CUDA custom di vLLM implementa un'operazione di gather che raccoglie i blocchi non contigui tramite la block table a ogni step di attention. L'overhead di questo gather è minimo rispetto al guadagno in VRAM e batch size. Il paper riporta un miglioramento di throughput di 2-4x rispetto a FasterTransformer e Orca con la stessa latenza.

Sezione 2 - Tensor Parallelism: split del modello su N GPU

Tensor parallelism (TP) divide il modello su più GPU partizionando i pesi dei layer. In un transformer, i pesi delle proiezioni Q, K, V e della FFN vengono divisi per colonne (column-parallel) e righe (row-parallel) in modo che ogni GPU elabori una frazione degli head di attention o dei neuroni FFN. Alla fine di ogni layer, le GPU sincronizzano via NCCL all-reduce per sommare i contributi parziali.

Il parametro chiave è tensor_parallel_size. Ecco come configurarlo sia da riga di comando sia programmaticamente con Python:

# Configurazione tensor parallelism via Python API vLLM
# Questo snippet avvia il motore con 4 GPU in parallelo
from vllm import LLM, SamplingParams

# Inizializza il modello distribuito su 4 GPU
# tensor_parallel_size deve essere una potenza di 2 e non superare il numero di GPU disponibili
llm = LLM(
    model="meta-llama/Meta-Llama-3.1-70B-Instruct",
    tensor_parallel_size=4,       # Split su 4 GPU: ogni GPU gestisce 1/4 degli head
    dtype="bfloat16",             # bfloat16 per A100/H100, float16 per GPU consumer
    gpu_memory_utilization=0.90,  # Usa il 90% della VRAM disponibile per KV cache
    max_model_len=8192,           # Contesto massimo per richiesta
    enforce_eager=False,          # Usa CUDAGraph per ottimizzare il throughput
)

# Parametri di sampling per la generazione
sampling_params = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=512,
)

# Inferenza batch: vLLM gestisce automaticamente il dynamic batching
prompts = [
    "Spiega il teorema di Bayes in termini semplici.",
    "Scrivi un'implementazione Python di quicksort commentata.",
    "Quali sono le differenze tra TCP e UDP?",
]

outputs = llm.generate(prompts, sampling_params)

for output in outputs:
    print(f"Prompt: {output.prompt[:50]}...")
    print(f"Output: {output.outputs[0].text[:200]}")
    print(f"Token generati: {len(output.outputs[0].token_ids)}")

Il benchmark seguente mostra il throughput di Llama 3.1 70B (BF16) al variare del numero di GPU su hardware A100 80GB, con 50 richieste concorrenti e output di 256 token ciascuna:

Configurazione GPU Totali VRAM totale Throughput (tok/s) TTFT mediano (ms) Speedup vs 1 GPU
TP=1 (Llama 70B Q4 AWQ) 1x A100 80GB 80 GB ~180 tok/s ~410 ms 1x (baseline)
TP=2 2x A100 80GB 160 GB ~340 tok/s ~230 ms 1.9x
TP=4 4x A100 80GB 320 GB ~640 tok/s ~130 ms 3.6x
TP=8 8x A100 80GB 640 GB ~1.100 tok/s ~80 ms 6.1x

Lo scaling non è lineare a causa dell'overhead di sincronizzazione NCCL (all-reduce inter-GPU). Con NVLink (A100 SXM) l'efficienza è del 76% su 8 GPU; con PCIe scende al 60%. Per Llama 3.1 70B FP16 (140 GB di pesi), la configurazione minima pratica è TP=2 su A100 80GB oppure TP=1 su A100 80GB con quantizzazione AWQ 4-bit (~35 GB).

Sezione 3 - Avviare vLLM multi-GPU con NVIDIA_VISIBLE_DEVICES

In produzione si usa il server OpenAI-compatible di vLLM. Il comando bash seguente avvia un server su porta 8000 usando 4 GPU specifiche, con logging strutturato e limite di memoria configurato:

# Avvio vLLM server multi-GPU con tensor parallelism
# Specifica esattamente quali GPU usare tramite indice (0,1,2,3)
# NCCL_P2P_DISABLE=1 solo se le GPU non supportano NVLink peer-to-peer

export CUDA_VISIBLE_DEVICES=0,1,2,3
export NCCL_DEBUG=WARN  # Riduci verbosità NCCL in produzione

python -m vllm.entrypoints.openai.api_server \
    --model meta-llama/Meta-Llama-3.1-70B-Instruct \
    --tensor-parallel-size 4 \
    --dtype bfloat16 \
    --gpu-memory-utilization 0.90 \
    --max-model-len 8192 \
    --max-num-seqs 256 \
    --port 8000 \
    --host 0.0.0.0 \
    --served-model-name llama-70b \
    --enable-prefix-caching \
    --disable-log-requests  # Disabilita log per ogni singola richiesta in produzione

# Verifica che il server sia attivo e rispondente
# Il primo avvio richiede 2-3 minuti per caricare i pesi su 4 GPU
curl http://localhost:8000/v1/models | python3 -m json.tool

Il flag --enable-prefix-caching è fondamentale in produzione: attiva il prefix caching (chiamato anche prompt caching), che riusa i blocchi KV cache per prompt con lo stesso prefisso. In scenari con system prompt fisso (chatbot, RAG), questo riduce il TTFT del 50-80% dopo la prima richiesta.

Sezione 4 - Batching Strategy: da static a continuous batching

Il batching statico tradizionale attende che tutte le richieste di un batch terminino prima di processare il successivo. Immagina 8 richieste: 7 generano 50 token, 1 genera 2000 token. Le 7 richieste corte finiscono in 2 secondi, ma devono aspettare che la richiesta lunga completi i suoi 2000 token. La GPU resta occupata a generare token per 1 richiesta mentre le altre 7 aspettano. Questo è il Head-of-Line blocking.

vLLM usa iteration-level scheduling: a ogni step di decodifica, lo scheduler valuta quali richieste aggiungere al batch in esecuzione. Quando una sequenza termina (genera il token EOS), viene rimossa dal batch e il suo slot viene immediatamente assegnato a una nuova richiesta in coda. Il batch è costantemente pieno, la GPU non aspetta mai.

Il benchmark seguente misura il throughput con asyncio simulando 50 richieste concorrenti verso un server vLLM (Llama 3.1 8B, A100 40GB):

# Benchmark continuous batching con asyncio
# Misura throughput (token/s) e latenza percentile a diversi livelli di concorrenza
import asyncio
import time
import statistics
from openai import AsyncOpenAI

# Client asincrono verso server vLLM locale
client = AsyncOpenAI(
    base_url="http://localhost:8000/v1",
    api_key="non-necessaria-per-vllm-locale",  # vLLM non richiede autenticazione di default
)

async def invia_richiesta_singola(prompt: str, req_id: int) -> dict:
    """Invia una singola richiesta e misura tempo a primo token e totale."""
    inizio = time.perf_counter()
    primo_token_time = None
    token_count = 0

    async with client.chat.completions.with_streaming_response.create(
        model="llama-3.1-8b",
        messages=[{"role": "user", "content": prompt}],
        max_tokens=256,
        stream=True,
        temperature=0.7,
    ) as risposta:
        async for chunk in risposta:
            delta = chunk.choices[0].delta.content
            if delta:
                if primo_token_time is None:
                    # Registra il tempo al primo token (TTFT)
                    primo_token_time = time.perf_counter() - inizio
                token_count += 1

    durata_totale = time.perf_counter() - inizio
    return {
        "req_id": req_id,
        "ttft_ms": round((primo_token_time or 0) * 1000, 1),
        "durata_totale_s": round(durata_totale, 3),
        "token_generati": token_count,
        "throughput_req": round(token_count / durata_totale, 1),
    }

async def benchmark_concorrenza(num_richieste: int, concorrenza: int) -> None:
    """
    Esegui num_richieste con al massimo concorrenza richieste simultanee.
    Misura throughput aggregato e distribuzione della latenza.
    """
    semaphore = asyncio.Semaphore(concorrenza)
    prompts = [
        f"Spiega il concetto numero {i} in machine learning in 3 frasi."
        for i in range(num_richieste)
    ]

    async def richiesta_limitata(prompt: str, req_id: int) -> dict:
        async with semaphore:
            return await invia_richiesta_singola(prompt, req_id)

    inizio_totale = time.perf_counter()
    tasks = [richiesta_limitata(p, i) for i, p in enumerate(prompts)]
    risultati = await asyncio.gather(*tasks, return_exceptions=True)
    durata_totale = time.perf_counter() - inizio_totale

    # Filtra eventuali errori
    successi = [r for r in risultati if isinstance(r, dict)]
    token_totali = sum(r["token_generati"] for r in successi)

    ttft_valori = [r["ttft_ms"] for r in successi]
    print(f"\n=== Benchmark: {num_richieste} richieste, concorrenza={concorrenza} ===")
    print(f"Durata totale:        {durata_totale:.2f}s")
    print(f"Throughput aggregato: {token_totali / durata_totale:.0f} tok/s")
    print(f"TTFT mediana:         {statistics.median(ttft_valori):.1f} ms")
    print(f"TTFT p99:             {sorted(ttft_valori)[int(len(ttft_valori)*0.99)]:.1f} ms")
    print(f"Richieste/sec:        {len(successi) / durata_totale:.1f}")

if __name__ == "__main__":
    # Test a diversi livelli di concorrenza
    for concorrenza in [1, 10, 50, 100]:
        asyncio.run(benchmark_concorrenza(num_richieste=100, concorrenza=concorrenza))

I risultati tipici su A100 40GB con Llama 3.1 8B BF16 mostrano che il throughput scala quasi linearmente da 1 a 50 richieste concorrenti (da ~200 a ~2.500 tok/s), poi si stabilizza tra 50 e 150 richieste mentre la latenza TTFT aumenta. Oltre 150 richieste, la GPU è satura e il TTFT degrada rapidamente.

Sezione 5 - Sampling Parameters per request

vLLM espone l'API OpenAI-compatible, quindi i parametri di sampling si passano nel body JSON della richiesta. Ogni richiesta può avere parametri diversi: temperatura, top_p, presence_penalty e così via. Il JSON seguente mostra i parametri più rilevanti con i valori consigliati per casi d'uso tipici:

{
  "model": "llama-3.1-70b",
  "messages": [
    {
      "role": "system",
      "content": "Sei un assistente tecnico specializzato in infrastruttura cloud."
    },
    {
      "role": "user",
      "content": "Spiega le differenze tra Kubernetes e Docker Compose per il deployment di LLM."
    }
  ],
  "max_tokens": 1024,
  "temperature": 0.3,
  "top_p": 0.9,
  "top_k": 40,
  "frequency_penalty": 0.1,
  "presence_penalty": 0.0,
  "stop": ["", "[/INST]"],
  "stream": true,
  "extra_body": {
    "repetition_penalty": 1.1,
    "skip_special_tokens": true,
    "spaces_between_special_tokens": false,
    "guided_json": null
  }
}

Il campo extra_body contiene estensioni vLLM-specific non presenti nella spec OpenAI standard. In particolare, guided_json abilita la structured output generation: vLLM garantisce che il modello generi JSON valido conforme a uno schema specificato, utile per pipeline di estrazione dati.

Sezione 6 - Speculative Decoding: draft model per ridurre la latency

Lo speculative decoding è un'ottimizzazione per ridurre la latenza per token quando il batch è piccolo. L'idea: un modello draft piccolo (es. 68M parametri) genera K token in anticipo in un solo forward pass. Il modello target poi verifica in parallelo se quei K token sarebbero stati scelti anch'esso. Se i token draft coincidono con quelli del modello target, tutti K vengono accettati senza ulteriori forward pass del modello grande. Se uno diverge, il token corretto viene preso dal modello target e i successivi draft scartati.

Il guadagno pratico dipende dall'acceptance rate: quanto spesso il modello draft indovina correttamente. Per testi strutturati (codice, JSON, risposta tecnica) l'acceptance rate è tipicamente 70-85%, portando a una riduzione della latency per token del 40-60% con batch piccoli (1-4 richieste).

# Configurazione speculative decoding in vLLM con modello draft
# Richiede vLLM versione 0.4+ (non supportato in V1 engine al momento della scrittura)
from vllm import LLM, SamplingParams

# Il modello draft deve condividere il tokenizer con il modello target
# Llama 3.2 1B è un buon draft per Llama 3.1 8B (stesso vocabolario)
llm_speculative = LLM(
    model="meta-llama/Meta-Llama-3.1-8B-Instruct",       # Modello target grande
    speculative_model="meta-llama/Llama-3.2-1B-Instruct", # Modello draft piccolo
    num_speculative_tokens=5,   # Quanti token draft generare ad ogni step
    speculative_draft_tensor_parallel_size=1,  # Draft su singola GPU (è piccolo)
    tensor_parallel_size=1,     # Target su 1 GPU (8B ci sta su RTX 3090)
    dtype="bfloat16",
    gpu_memory_utilization=0.88,  # Lascia spazio sia per target che per draft
    max_model_len=4096,
)

# Sampling params - stessi di prima, speculative decoding è trasparente all'utente
sampling_params = SamplingParams(
    temperature=0.1,   # Temperatura bassa aumenta l'acceptance rate del draft
    top_p=0.9,
    max_tokens=256,
)

# Test comparativo: stessa richiesta con e senza speculative decoding
import time

prompt_test = "Scrivi un'implementazione Python di un server HTTP minimale con logging."

inizio = time.perf_counter()
output = llm_speculative.generate([prompt_test], sampling_params)
durata = time.perf_counter() - inizio

token_generati = len(output[0].outputs[0].token_ids)
print(f"Token generati: {token_generati}")
print(f"Durata: {durata:.3f}s")
print(f"Latency per token: {durata / token_generati * 1000:.1f} ms/tok")
# Con speculative decoding attivo: ~18-25 ms/tok vs ~45-60 ms/tok senza

Attenzione: V1 Engine e Speculative Decoding

vLLM V1 (introdotto a gennaio 2025) non supporta ancora lo speculative decoding. Se hai bisogno di questa feature, avvia vLLM con il flag --use-v2-block-manager oppure imposta VLLM_USE_V1=0 per tornare all'engine legacy. Il team vLLM sta lavorando all'implementazione per V1 - monitora il changelog su github.com/vllm-project/vllm/releases.

Sezione 7 - Multi-node con Ray Cluster

Quando un singolo nodo non ha abbastanza GPU per il modello scelto, si scala su più nodi con Ray cluster. Ray è il runtime di default per vLLM in scenari multi-node: una volta avviato il cluster, vLLM lo usa automaticamente senza configurazioni aggiuntive nel codice applicativo.

La configurazione standard combina tensor parallelism (GPU all'interno dello stesso nodo, comunicazione via NVLink o PCIe) con pipeline parallelism (tra nodi diversi, comunicazione via Ethernet/InfiniBand). Il YAML seguente descrive un cluster Ray per un setup 2-nodi con 4 GPU per nodo:

# ray-cluster-vllm.yaml
# Configurazione Ray cluster per vLLM multi-node su 2 nodi con 4 GPU ciascuno
# Usato con: ray up ray-cluster-vllm.yaml

cluster_name: vllm-production

# Head node: coordina il cluster e serve l'API
head_node_type: head_node

available_node_types:
  head_node:
    resources:
      CPU: 32
      GPU: 4
    node_config:
      # Configurazione specifica al provider cloud (es. AWS, GCP, Hetzner)
      instance_type: a100-4gpu-node
    docker:
      image: vllm/vllm-openai:latest
      container_name: vllm_head
      run_options:
        - --gpus all
        - --shm-size=16gb  # Shared memory per comunicazione inter-processo
        - --network host
        - -e NCCL_DEBUG=WARN
        - -e NCCL_SOCKET_IFNAME=eth0  # Interfaccia di rete per NCCL tra nodi

  worker_node:
    min_workers: 1
    max_workers: 1
    resources:
      CPU: 32
      GPU: 4
    node_config:
      instance_type: a100-4gpu-node
    docker:
      image: vllm/vllm-openai:latest
      container_name: vllm_worker
      run_options:
        - --gpus all
        - --shm-size=16gb
        - --network host
        - -e NCCL_DEBUG=WARN
        - -e NCCL_SOCKET_IFNAME=eth0

# Comando avviato sul head node dopo che il cluster è pronto
setup_commands:
  - pip install -U vllm ray[default]

head_start_ray_commands:
  - ray start --head --port=6379 --object-store-memory=10000000000

worker_start_ray_commands:
  - ray start --address=$RAY_HEAD_IP:6379

# Avvio del server vLLM sul head node:
# Con 2 nodi x 4 GPU: tensor_parallel_size=4, pipeline_parallel_size=2
# Questo divide il modello: ogni nodo gestisce metà dei layer (pipeline),
# ogni GPU nel nodo gestisce 1/4 delle attention head (tensor parallel)

Una volta avviato il cluster, il comando di lancio del server vLLM è:

# Avvio vLLM su Ray cluster multi-node
# Eseguire sul head node dopo ray up
# tensor_parallel_size=4: 4 GPU per nodo (tensor parallel dentro il nodo)
# pipeline_parallel_size=2: 2 nodi (pipeline parallel tra i nodi)
# Con Llama 3.1 70B FP16 (140GB): 8 GPU A100 80GB = 640GB VRAM disponibili

CUDA_VISIBLE_DEVICES=0,1,2,3 python -m vllm.entrypoints.openai.api_server \
    --model meta-llama/Meta-Llama-3.1-70B-Instruct \
    --tensor-parallel-size 4 \
    --pipeline-parallel-size 2 \
    --dtype bfloat16 \
    --gpu-memory-utilization 0.92 \
    --max-model-len 16384 \
    --max-num-seqs 512 \
    --port 8000 \
    --enable-prefix-caching \
    --distributed-executor-backend ray  # Usa Ray come executor distribuito

# Monitoraggio del cluster Ray durante il serving
ray status  # Mostra nodi connessi, GPU allocate, tasks in esecuzione

Sezione 8 - Monitoraggio GPU con nvidia-smi

Durante il serving in produzione è fondamentale monitorare l'utilizzo delle GPU in tempo reale. Lo script bash seguente lancia un loop di monitoraggio continuo che scrive le metriche su file CSV per analisi successiva, oltre a mostrare un dashboard testuale aggiornato ogni secondo:

#!/bin/bash
# Script di monitoraggio GPU per vLLM in produzione
# Salva utilization, memoria usata/libera e temperatura su CSV
# Utile per identificare memory leak, saturazione o idle GPU

LOG_FILE="/var/log/vllm/gpu_metrics_$(date +%Y%m%d_%H%M%S).csv"
INTERVALLO_SECONDI=2

# Crea la directory di log se non esiste
mkdir -p "$(dirname "$LOG_FILE")"

# Intestazione CSV
echo "timestamp,gpu_id,util_pct,mem_used_mb,mem_free_mb,mem_total_mb,temp_c,power_w" > "$LOG_FILE"

echo "Monitoraggio GPU avviato. Log: $LOG_FILE"
echo "Premi Ctrl+C per fermare."

while true; do
    TIMESTAMP=$(date +%Y-%m-%dT%H:%M:%S)

    # Raccoglie metriche per tutte le GPU visibili
    # --query-gpu: seleziona le colonne da estrarre
    # --format=csv,noheader,nounits: output pulito per parsing
    nvidia-smi \
        --query-gpu=index,utilization.gpu,memory.used,memory.free,memory.total,temperature.gpu,power.draw \
        --format=csv,noheader,nounits | while IFS=',' read -r gpu_id util mem_used mem_free mem_total temp power; do
            # Rimuovi spazi bianchi
            echo "${TIMESTAMP},${gpu_id// /},${util// /},${mem_used// /},${mem_free// /},${mem_total// /},${temp// /},${power// /}" >> "$LOG_FILE"
    done

    # Dashboard testuale aggiornato sul terminale
    clear
    echo "=== vLLM GPU Monitor - $(date) ==="
    nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu,power.draw \
               --format=csv,noheader | column -t -s','

    # Mostra anche le metriche vLLM se il server è attivo
    if curl -sf http://localhost:8000/metrics > /dev/null 2>&1; then
        echo ""
        echo "=== vLLM Metrics ==="
        curl -sf http://localhost:8000/metrics | grep -E "vllm:(gpu_cache|num_running|e2e)" | head -10
    fi

    sleep "$INTERVALLO_SECONDI"
done

Per monitoring più strutturato in produzione, vLLM espone nativamente un endpoint Prometheus su /metrics. Le metriche più importanti da tracciare sono: vllm:gpu_cache_usage_perc (utilizzo KV cache, allarme oltre 95%), vllm:num_running_requests (richieste in elaborazione attiva), vllm:num_waiting_requests (coda), e vllm:e2e_request_latency_seconds (latenza end-to-end per percentile). La configurazione Prometheus e Grafana è trattata in dettaglio nell'episodio 5 di questa serie.

Sezione 9 - Profilo di latenza: analisi cold start e warm state

vLLM ha due fasi operative con caratteristiche di latenza molto diverse. Il cold start include: caricamento dei pesi in VRAM (2-4 minuti per 70B), compilazione dei CUDAGraph (30-120 secondi, opzionale ma consigliato per throughput), e warmup dell'engine. In produzione, il cold start va pianificato: il server deve essere già "caldo" prima che il traffico arrivi.

Una volta in warm state, la latenza si divide in tre componenti:

  • Prefill latency: tempo per processare il prompt iniziale (creazione KV cache). Per un prompt di 1K token su Llama 70B TP=4: ~150-200 ms. Scala linearmente con la lunghezza del prompt.
  • TTFT (Time To First Token): prefill + scheduling overhead. Con prefix caching e prompt già in cache: scende a ~15-30 ms. Senza cache: uguale al prefill.
  • ITL (Inter-Token Latency): tempo tra un token e il successivo nella decodifica. Con vLLM V1 su A100, l'ITL è stabile a 11-21 ms indipendentemente dal batch size fino a saturazione. Questo è il vantaggio del continuous batching: l'ITL non degrada con il carico come nel batching statico.

Regola pratica per dimensionare il deployment

Con Llama 3.1 8B su A100 40GB: ITL = ~12ms. Per servire 50 utenti con ITL target <20ms, il throughput massimo sostenibile è ~50 × (1000ms / 12ms) ≈ 4.100 tok/s, che corrisponde al throughput massimo dell'hardware su questo modello. La GPU non è il bottleneck in questo scenario.

Con Llama 3.1 70B TP=4 su 4x A100 80GB: ITL = ~25ms. Per lo stesso SLA di 50 utenti: 50 × (1000ms / 25ms) = 2.000 tok/s massimi sostenibili. Qui il numero di GPU diventa il fattore limitante.

FAQ

Cos'è PagedAttention e perché riduce la frammentazione della KV cache?

PagedAttention (Kwon et al., SOSP 2023) applica il principio della memoria virtuale paginata dei sistemi operativi alla KV cache dei transformer. Invece di pre-allocare blocchi contigui per ogni sequenza, divide la cache in blocchi logici da 16 token mappati su blocchi fisici non contigui tramite una block table. La frammentazione scende dall'60-80% (sistemi tradizionali) a meno del 4%.

Come si configura tensor_parallel_size in vLLM?

Il parametro si imposta con --tensor-parallel-size N da riga di comando oppure LLM(model='...', tensor_parallel_size=N) via Python API. N deve essere una potenza di 2 e non superare il numero di GPU fisicamente disponibili sul nodo. Per multi-node, si combina con pipeline_parallel_size: TP per le GPU nello stesso nodo, PP tra nodi diversi.

Cosa si intende per continuous batching in vLLM e perché elimina il Head-of-Line blocking?

Il batching statico aspetta che tutte le richieste di un batch completino prima di processarne altre. vLLM usa iteration-level scheduling: a ogni step di decodifica, le richieste completate vengono rimosse dal batch e subito sostituite da nuove. Questo mantiene la GPU sempre satura ed elimina il blocco causato da richieste lunghe su quelle corte.

Quando conviene usare lo speculative decoding?

Lo speculative decoding conviene quando il batch size medio è basso (1-4 richieste simultanee) e la latenza per token è il collo di bottiglia. Con batch pieno (100+ richieste), il throughput è già ottimizzato e lo speculative decoding aggiunge overhead. Nota: non è supportato nel vLLM V1 engine al momento.

Quante GPU servono per Llama 3.1 70B in FP16?

I pesi di Llama 3.1 70B in BF16/FP16 occupano circa 140 GB. Servono minimo 2x A100 80GB con TP=2, oppure 4x GPU da 40GB. Con quantizzazione AWQ 4-bit (~35 GB), il modello gira su una singola A100 80GB con throughput inferiore ma deployment molto più semplice e costo dimezzato.

Come si configura un Ray cluster per vLLM multi-node?

Sul head node: ray start --head --port=6379. Su ogni worker: ray start --address=HEAD_IP:6379. Poi si avvia vLLM normalmente con i parametri TP e PP desiderati: vLLM rileva automaticamente il cluster Ray e distribuisce il modello. La regola pratica: TP = numero di GPU per nodo, PP = numero di nodi.

Come si monitora l'utilizzo GPU durante il serving con vLLM?

Il monitoraggio base usa nvidia-smi dmon -s u,m per aggiornamenti continui. Per monitoring strutturato, vLLM espone metriche Prometheus su /metrics: le più importanti sono vllm:gpu_cache_usage_perc (soglia allarme: 95%), vllm:num_running_requests e vllm:e2e_request_latency_seconds.

Conclusione

PagedAttention non è un'ottimizzazione marginale: è un cambio di paradigma nel modo in cui la KV cache viene gestita. Portare la frammentazione dall'80% al 4% non significa solo "meno spreco di memoria" - significa batch size 4-6x più grandi sullo stesso hardware, throughput 2-4x superiore rispetto ai sistemi precedenti, e la capacità di servire decine di utenti simultanei su GPU consumer come RTX 3090 o RTX 4090. Il paper di Kwon et al. (SOSP 2023) ha posto le fondamenta di tutta l'infrastruttura LLM moderna.

Tensor parallelism e continuous batching completano il quadro: TP ti permette di distribuire modelli da 70B o 405B parametri su hardware che normalmente non potrebbe ospitarli; continuous batching mantiene la GPU satura indipendentemente dal profilo delle richieste. Combinati, questi tre meccanismi spiegano perché vLLM può servire 100 richieste simultanee dove Ollama ne gestisce 4-5.

Il passo successivo naturale, dopo aver configurato un'infrastruttura che regge il carico, è proteggerla e controllarla. Senza rate limit, un singolo utente può consumare l'intero budget di compute in pochi minuti. Senza circuit breaker, un picco di traffico porta all'OOM e al crash dell'intero server. Prossimo episodio: EP04 - Production safety: rate limit, circuit breaker, cost cap e SLA guarantee.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti