Salta al contenuto principale

Scaling self-hosted LLM: multi-GPU, multi-node e Ray cluster orchestration

Scaling self-hosted LLM: multi-GPU, multi-node e Ray cluster orchestration Ollama su una sola GPU regge circa 20 utenti concorrenti.

Cosa ne pensi di questo articolo?

Scaling self-hosted LLM: multi-GPU, multi-node e Ray cluster orchestration
AI InfrastructureFoto:Tara WinsteadPexels

In Breve

Scaling self-hosted LLM: multi-GPU, multi-node e Ray cluster orchestration Ollama su una sola GPU regge circa 20 utenti concorrenti.

Scaling self-hosted LLM: multi-GPU, multi-node e Ray cluster orchestration

Ollama su una sola GPU regge circa 20 utenti concorrenti. Funziona benissimo finché non superi quella soglia. Poi la latenza sale, la coda si allunga e inizi a perdere richieste. La risposta ovvia è "aggiungi un'altra GPU". La risposta tecnica corretta è più sfumata: come aggiungi quella GPU, e con quale software, fa la differenza tra un sistema che scala linearmente e uno che diventa ingestibile dopo la seconda macchina.

In questo episodio vediamo l'intera traiettoria di scaling per un LLM self-hosted: dalla condivisione intelligente di una singola GPU con NVIDIA MPS, al tensor parallelism di vLLM su più GPU dello stesso nodo, fino al Ray cluster multi-node che orchestra decine di macchine con una configurazione YAML. Vedremo anche il caso reale del VPS Hetzner CCX23 - single-GPU oggi, ma pronto a espandersi con un Ray cluster Hetzner quando il traffico lo richiederà.

Il framework decisionale è semplice: inizia con il minimo indispensabile, misura, poi scala solo dove la metrica lo giustifica. Kubernetes non è la risposta giusta per la maggior parte delle startup con meno di 10 nodi. Ray lo è.

Cosa Imparerai

  • Come funziona NVIDIA MPS per condividere una GPU tra processi LLM multipli
  • Configurare vLLM con tensor_parallel_size e pipeline_parallel_size per multi-GPU e multi-node
  • Avviare un Ray cluster con head node + worker nodes e deployare un LLM con Ray Serve
  • Implementare load balancing Nginx davanti a nodi Ray per routing L7
  • Progettare data locality per embedding store shardato vicino ai nodi di inferenza
  • Decidere tra Ray e Kubernetes in base al contesto operativo
  • Applicare questi pattern al VPS Hetzner CCX23 (anche con single GPU)

Prerequisiti

  • EP01-EP03 di questa serie (Ollama vs vLLM, setup Ollama, vLLM PagedAttention)
  • Conoscenza base di GPU e CUDA (driver, nvidia-smi)
  • Esperienza con Docker e bash scripting
  • Nozioni di networking: load balancer, reverse proxy, DNS interno

Sezione 1 - Il problema dello scaling: quando 1 GPU non basta più

Prima di aggiungere hardware, è fondamentale capire dove è il collo di bottiglia. Un LLM self-hosted può essere limitato da tre risorse distinte: VRAM (il modello non entra), compute (il prefill o il decode è troppo lento), o memoria di sistema (il KV cache satura la RAM GPU). Ognuna ha una soluzione diversa.

Se la VRAM è il problema, la risposta è tensor parallelism: dividi il modello su più GPU. Se il compute è il collo di bottiglia, la risposta è replicazione: più copie dello stesso modello su GPU diverse, con un load balancer davanti. Se il KV cache satura, puoi usare PagedAttention di vLLM (EP03) o aumentare la VRAM.

Nel caso del VPS Hetzner CCX23 con singola GPU (NVIDIA A10G 24GB), il ceiling pratico con Llama 3.1 8B quantizzato a 4-bit è circa 15-20 richieste concorrenti a latenza p99 < 500ms. Oltre quella soglia, devi scalare. Ma non devi necessariamente passare subito a un cluster multi-node: puoi prima ottimizzare la condivisione della GPU con NVIDIA MPS.

Sezione 2 - NVIDIA MPS: condividere una GPU tra processi multipli

NVIDIA Multi-Process Service (MPS) è spesso confuso con il time-slicing. La differenza è sostanziale: il time-slicing alterna i processi in modo sequenziale (un processo alla volta), mentre MPS permette l'esecuzione concorrente di kernel CUDA di processi diversi sullo stesso hardware. Il risultato è che più processi possono saturare la GPU contemporaneamente, eliminando i tempi morti del context-switching.

Per l'inferenza LLM, MPS è particolarmente utile quando vuoi eseguire più modelli piccoli (es. due istanze di Llama 3.1 3B) sulla stessa GPU, oppure quando vuoi isolare i workload Ollama e vLLM sulla stessa scheda. La limitazione principale è che MPS richiede che tutti i processi condividano lo stesso contesto CUDA: un processo malfunzionante può potenzialmente corrompere la GPU per tutti gli altri. Non è adatto per ambienti multi-tenant con utenti non fidati.

Ecco come abilitare MPS su un sistema Linux con driver NVIDIA aggiornati:

# Sezione 2 - Setup NVIDIA MPS per condivisione GPU tra processi LLM
# Requisito: driver NVIDIA >= 450, GPU Volta o superiore (A10G, V100, A100, RTX 30xx/40xx)

# 1. Verifica che nvidia-smi sia disponibile e la GPU sia compatibile
nvidia-smi --query-gpu=name,compute_cap --format=csv,noheader
# Output atteso: A10G, 8.6   (Ampere = compatibile MPS)

# 2. Imposta la GPU in modalità esclusiva (richiesta da MPS daemon)
sudo nvidia-smi -i 0 -c EXCLUSIVE_PROCESS
# -i 0 = prima GPU; -c EXCLUSIVE_PROCESS = modalità esclusiva per MPS

# 3. Avvia il daemon MPS in background
sudo nvidia-cuda-mps-control -d
# Verifica che il daemon sia attivo
echo "get_server_list" | sudo nvidia-cuda-mps-control

# 4. Imposta il limite di client concorrenti (default: 48, max: 128 per Ampere)
# Ridurre il numero se si vuole isolare meglio i processi
echo "set_default_client_count 16" | sudo nvidia-cuda-mps-control

# 5. Imposta la quota memoria per processo (facoltativo, per evitare monopolio)
# Ogni processo può usare al massimo il 30% della VRAM
export CUDA_MPS_ENABLE_PER_CTX_DEVICE_MULTIPROCESSOR_PARTITIONING=1
export CUDA_MPS_ACTIVE_THREAD_PERCENTAGE=30

# 6. Test: avvia due istanze vLLM in parallelo sullo stesso GPU
# Terminale 1:
python -m vllm.entrypoints.openai.api_server \
    --model meta-llama/Llama-3.2-3B-Instruct \
    --port 8000 \
    --gpu-memory-utilization 0.45

# Terminale 2 (contemporaneamente):
python -m vllm.entrypoints.openai.api_server \
    --model Qwen/Qwen2.5-3B-Instruct \
    --port 8001 \
    --gpu-memory-utilization 0.45

# Verifica che entrambi i processi usino la GPU contemporaneamente
watch -n 1 nvidia-smi

# 7. Per fermare il daemon MPS (quando non più necessario)
echo "quit" | sudo nvidia-cuda-mps-control
sudo nvidia-smi -i 0 -c DEFAULT

In benchmark reali pubblicati da Databricks nel 2025, MPS su modelli piccoli (<7B parametri) può ridurre il costo per token del 50% con solo il 7.5% di impatto sulle prestazioni peak. Ma attenzione: oltre i 7B parametri o con contesti lunghi (>8k token), il beneficio si annulla perché il modello stesso satura già la GPU.

Strategia Caso d'uso ideale Throughput gain Rischio operativo Compatibilità
Time-slicing Dev/test, modelli eterogenei, multi-tenant non fidato Nessuno (scheduling sequenziale) Basso (isolamento completo) Tutte le GPU NVIDIA
NVIDIA MPS Modelli piccoli (<7B), processi fidati, alta concorrenza +40-100% vs time-slicing Medio (contesto CUDA condiviso) Volta+ (V100, A100, A10G, RTX 30xx/40xx)
vLLM Tensor Parallelism Modelli grandi (>7B), multi-GPU same-node, SLA stringenti Lineare con N GPU (70-90%) Basso (process isolation) CUDA + NVLink consigliato
MIG (Multi-Instance GPU) Multi-tenant sicuro, carichi prevedibili, A100/H100 only Nessuno (partizionamento fisso) Molto basso (isolamento hardware) Solo A100, H100, A30

Sezione 3 - vLLM Tensor Parallelism: distribuire il modello su più GPU

Quando il modello non entra su una sola GPU o quando vuoi aumentare il throughput in modo significativo, il tensor parallelism è la soluzione corretta. vLLM implementa il tensor parallelism dividendo ogni layer del transformer orizzontalmente: ogni GPU gestisce una porzione dei pesi e del KV cache, poi i risultati vengono aggregati via NVLink o PCIe al termine di ogni layer.

La regola pratica di vLLM è diretta: tensor_parallel_size corrisponde al numero di GPU per nodo, pipeline_parallel_size al numero di nodi. Per un modello Llama 3.1 70B su 2 nodi con 4 GPU ciascuno, imposti TP=4, PP=2 - in totale 8 GPU consumate.

# Sezione 3 - Configurazione vLLM multi-GPU con tensor e pipeline parallelism
# Installa: pip install vllm>=0.6.0

from vllm import LLM, SamplingParams

# Scenario A: 4 GPU su nodo singolo - solo tensor parallelism
# Adatto per modelli fino a ~70B parametri in FP16
llm_single_node = LLM(
    model="meta-llama/Llama-3.1-70B-Instruct",
    tensor_parallel_size=4,      # Divide il modello su 4 GPU
    dtype="bfloat16",            # BF16 riduce VRAM vs FP32 senza perdita significativa
    gpu_memory_utilization=0.90, # Usa il 90% della VRAM disponibile per KV cache
    max_model_len=8192,          # Lunghezza massima del contesto
    trust_remote_code=True,
)

# Scenario B: 2 nodi x 4 GPU = 8 GPU totali - tensor + pipeline parallelism
# Questo richede Ray come distributed executor backend
# Avvio da CLI (consigliato per multi-node):
# vllm serve meta-llama/Llama-3.1-70B-Instruct \
#     --tensor-parallel-size 4 \
#     --pipeline-parallel-size 2 \
#     --distributed-executor-backend ray \
#     --dtype bfloat16

# Scenario C: modello 405B su 4 nodi x 8 GPU = 32 GPU totali
# TP=8 (usa NVLink intra-nodo), PP=4 (usa Ethernet inter-nodo)
# vllm serve meta-llama/Llama-3.1-405B-Instruct \
#     --tensor-parallel-size 8 \
#     --pipeline-parallel-size 4 \
#     --distributed-executor-backend ray

# Esecuzione inferenza (identica indipendentemente dalla parallelizzazione)
params = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=512,
)

risposte = llm_single_node.generate(
    ["Spiega la differenza tra tensor e pipeline parallelism in 3 frasi."],
    params,
)

for risposta in risposte:
    print(risposta.outputs[0].text)

# Note importanti:
# - Con TP>1 su nodo singolo, preferisci NVLink (PCIe dimezza il throughput)
# - Con PP>1 su nodi distinti, la latenza inter-nodo (Ethernet 10Gbe) è il bottleneck
# - Su Hetzner CCX23 (single GPU), usa tensor_parallel_size=1 e pipeline_parallel_size=1

Un dettaglio spesso trascurato: il tensor parallelism funziona molto meglio con NVLink (comunicazione intra-nodo ad alta banda) che con PCIe o Ethernet. Se stai usando GPU su nodi Hetzner collegati via rete standard, il pipeline parallelism è preferibile perché minimizza la comunicazione inter-layer: ogni nodo processa uno stage completo prima di passare il risultato al nodo successivo, con una sola trasmissione di rete per layer group invece di sincronizzarsi ad ogni operazione matriciale.

Sezione 4 - Ray Cluster: setup e orchestrazione multi-node

Ray è il framework di riferimento per scalare LLM su più nodi. vLLM usa Ray come backend distribuito nativo, e Ray Serve aggiunge auto-scaling, prefix cache awareness nel routing, e una API OpenAI-compatibile out of the box. La complessità operativa è significativamente inferiore a Kubernetes per cluster con meno di 20 nodi.

Un cluster Ray ha due tipi di nodi: il head node che coordina il cluster, espone l'API e gestisce il GCS (Global Control Store), e i worker nodes che forniscono GPU per l'inferenza. L'head node non necessita di GPU se lo separi fisicamente dai worker, oppure può avere GPU se vuoi minimizzare il numero di macchine.

Ecco il file YAML per configurare un cluster Ray su Hetzner con 1 head node CPU + 2 worker nodes GPU:

# Sezione 4 - Ray cluster configuration YAML per Hetzner VPS
# File: ray-cluster-hetzner.yaml
# Compatibile con Ray 2.40+

cluster_name: llm-prod-cluster

# Configurazione provider (per Ray autoscaler su Hetzner, usa custom provider o ray up manuale)
# Questo file descrive la struttura logica del cluster
provider:
  type: local  # Per setup manuale su VPS Hetzner esistenti

# Head node: CCX13 (CPU only, 8 vCPU, 32GB RAM) - coordina il cluster
head_node_type:
  node_config:
    resources:
      CPU: 8
      memory: 32000000000  # 32GB in bytes
  # Head node non ha GPU: risparmia costo e gestisce solo scheduling/routing

# Worker nodes: CCX23 con GPU dedicata (A10G 24GB)
worker_node_types:
  - name: gpu-worker
    node_config:
      resources:
        CPU: 16
        GPU: 1
        memory: 64000000000  # 64GB RAM
        # Custom resource per identificare il tipo di nodo
        accelerator_type:A10G: 1
    min_workers: 1    # Sempre almeno 1 worker attivo
    max_workers: 4    # Scala fino a 4 worker in base al carico

# Configurazione autoscaler
autoscaling_config:
  # Scala verso l'alto quando ci sono richieste in attesa da più di 30 secondi
  upscaling_speed: 1.0
  idle_timeout_minutes: 5   # Riduci worker inattivi dopo 5 minuti
  target_utilization_fraction: 0.8  # Scala quando utilizzo supera 80%

# Comandi di setup eseguiti su ogni nodo al bootstrap
setup_commands:
  - pip install ray[serve]>=2.40.0 vllm>=0.6.0
  - pip install hf-transfer  # Download modelli più veloce da HuggingFace Hub

# Comandi eseguiti solo sull'head node
head_setup_commands:
  - pip install prometheus-client  # Metriche per il monitoraggio (vedi EP05)

# Variabili d'ambiente condivise nel cluster
env_vars:
  HF_HUB_ENABLE_HF_TRANSFER: "1"
  VLLM_WORKER_MULTIPROC_METHOD: "spawn"  # Evita fork issues con CUDA multi-process

Per avviare il cluster manualmente su VPS Hetzner (approccio consigliato per il controllo operativo):

# Sezione 4b - Avvio manuale Ray cluster su Hetzner VPS
# Esegui questi comandi SSH su ciascun nodo

# --- HEAD NODE (es. 10.0.0.1, CCX13 CPU-only) ---
# Installa Ray
pip install "ray[serve]>=2.40.0"

# Avvia l'head node con porta dashboard e metriche
ray start --head \
  --port=6379 \
  --dashboard-host=0.0.0.0 \
  --dashboard-port=8265 \
  --metrics-export-port=8080 \
  --num-cpus=8 \
  --num-gpus=0 \
  --block &  # Esegui in background

# Verifica che il cluster sia attivo
ray status

# --- WORKER NODE 1 (es. 10.0.0.2, CCX23 con GPU A10G) ---
pip install "ray[serve]>=2.40.0" vllm

# Connetti il worker all'head node (usa IP privato Hetzner per bassa latenza)
ray start \
  --address=10.0.0.1:6379 \
  --num-cpus=16 \
  --num-gpus=1 \
  --resources='{"accelerator_type:A10G": 1}' \
  --block &

# Aggiungi ulteriori worker allo stesso modo per worker node 2, 3, 4...

# --- HEAD NODE: verifica stato cluster ---
ray status
# Output atteso:
# Resources
# ---------------------------------------------------------------
# Usage:
#  0.0/8.0 CPU
#  0.0/2.0 GPU           ← 2 GPU totali da 2 worker node
#  0B/96.0GiB memory
# Available:
#  2/2 worker nodes      ← entrambi i worker sono connessi

Sezione 5 - Ray Serve LLM Deployment: deployare un modello sul cluster

Con il cluster Ray attivo, il passo successivo è deployare il modello usando Ray Serve LLM. Ray Serve 2.40+ include un'API dedicata per LLM con integrazione nativa vLLM, auto-scaling delle repliche, e routing prefix-cache aware. Ogni replica del modello occupa le GPU configurate tramite accelerator_type e tensor_parallel_size.

# Sezione 5 - Ray Serve LLM deployment con vLLM backend
# Installa: pip install "ray[serve]>=2.40.0" vllm>=0.6.0
# Esegui dall'head node o da una macchina connessa al cluster

import ray
from ray import serve
from ray.serve.llm import LLMConfig, build_openai_app

# Connetti al cluster Ray esistente (head node IP)
ray.init(address="ray://10.0.0.1:6379")

# Configurazione del modello LLM da deployare
llm_config = LLMConfig(
    model_loading_config={
        "model_id": "llama-3.1-8b-instruct",     # ID interno per il routing
        "model_source": "meta-llama/Llama-3.1-8B-Instruct",  # HuggingFace Hub
    },
    engine_kwargs={
        "dtype": "bfloat16",
        "max_model_len": 8192,
        "gpu_memory_utilization": 0.85,
        "tensor_parallel_size": 1,    # 1 GPU per replica (A10G 24GB regge 8B in BF16)
    },
    deployment_config={
        "autoscaling_config": {
            "min_replicas": 1,           # Sempre almeno 1 replica attiva
            "max_replicas": 4,           # Scala fino a 4 repliche (= 4 GPU worker)
            "target_ongoing_requests": 20,   # Scala quando >20 richieste in volo
            "max_ongoing_requests": 50,      # Hard limit per replica
            "upscale_delay_s": 30,           # Attendi 30s prima di scalare verso l'alto
            "downscale_delay_s": 300,        # Attendi 5 minuti prima di ridurre
        },
        # Richiedi acceleratore A10G per garantire placement sui worker corretti
        "ray_actor_options": {
            "resources": {"accelerator_type:A10G": 0.01},
            "num_gpus": 1,
        },
    },
)

# Build dell'applicazione OpenAI-compatible
# Espone endpoint: /v1/chat/completions, /v1/completions, /v1/models
app = build_openai_app({"llama-3.1-8b-instruct": llm_config})

# Deploy sul cluster Ray
serve.run(
    app,
    host="0.0.0.0",
    port=8000,
    name="llm-serving",
    route_prefix="/",
)

# Test dell'endpoint (dalla macchina client)
# curl http://10.0.0.1:8000/v1/chat/completions \
#   -H "Content-Type: application/json" \
#   -d '{"model": "llama-3.1-8b-instruct",
#        "messages": [{"role": "user", "content": "Hello"}],
#        "max_tokens": 100}'

Un aspetto chiave di Ray Serve 2.40+ è il routing prefix-cache aware: quando una richiesta arriva con un prefix identico a quello già in cache su una replica specifica, Ray la instrada preferenzialmente verso quella replica. Questo riduce la latenza TTFT (Time To First Token) del 20-40% in workload con system prompt comuni o prompt template ripetuti, come tipico nei chatbot con RAG.

Sezione 6 - Load Balancing: Nginx davanti al cluster Ray

Ray Serve gestisce già il routing interno tra repliche. Ma è buona pratica aggiungere un layer Nginx davanti al cluster per TLS termination, rate limiting HTTP, logging centralizzato e failover a livello L7. Nginx funge da punto di ingresso unico, mentre Ray si occupa dello scheduling interno tra le repliche GPU.

La configurazione chiave è un upstream con health check attivi: se un nodo Ray è irraggiungibile (crash della GPU, riavvio del worker), Nginx lo esclude automaticamente dal pool entro 2-3 secondi.

# Sezione 6 - Nginx upstream load balancer per nodi Ray LLM
# File: /etc/nginx/sites-available/llm-cluster
# Compatibile con Nginx 1.22+

upstream ray_llm_cluster {
    # Strategia: least_conn preferisce il nodo con meno connessioni attive
    # Alternativa: round-robin (default), ip_hash (stickiness per client)
    least_conn;

    # Head node Ray Serve (porta 8000 = Ray Serve HTTP)
    server 10.0.0.1:8000 weight=1 max_fails=3 fail_timeout=10s;

    # Worker node 1 (se Ray Serve replica è sul worker, non sull'head)
    # In configurazione tipica Ray Serve, le repliche sono sui worker
    # ma l'endpoint HTTP è solo sull'head node - in questo caso rimuovi le righe sotto
    # e punta tutto su 10.0.0.1:8000

    # Per setup multi-head (avanzato, 2 head nodes attivi):
    # server 10.0.0.3:8000 weight=1 max_fails=3 fail_timeout=10s;

    # Keepalive connections al pool upstream (evita TCP handshake ad ogni richiesta)
    keepalive 32;
}

server {
    listen 443 ssl http2;
    server_name llm-api.federicocalo.dev;

    # TLS - certificato Let's Encrypt (rinnovo automatico con certbot)
    ssl_certificate     /etc/letsencrypt/live/llm-api.federicocalo.dev/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/llm-api.federicocalo.dev/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;

    # Timeout estesi per richieste LLM (generazione può durare 30-60 secondi)
    proxy_read_timeout  120s;
    proxy_send_timeout  120s;
    proxy_connect_timeout 5s;

    # Rate limiting per endpoint (vedi EP04 per logica avanzata)
    # 10 richieste al secondo per IP (burst fino a 30)
    limit_req zone=llm_api burst=30 nodelay;
    limit_req_status 429;

    location /v1/ {
        proxy_pass         http://ray_llm_cluster;
        proxy_http_version 1.1;
        proxy_set_header   Connection "";         # Abilita keepalive upstream
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;

        # Streaming SSE per token streaming (vLLM OpenAI-compatible)
        proxy_buffering    off;     # CRITICO: disabilita buffering per streaming
        proxy_cache        off;
        chunked_transfer_encoding on;
    }

    # Health check endpoint (bypassa rate limiting)
    location /health {
        proxy_pass http://ray_llm_cluster/health;
        access_log off;
    }

    # Dashboard Ray (solo da IP trusted)
    location /ray-dashboard/ {
        allow 10.0.0.0/24;    # Solo rete privata Hetzner
        deny all;
        proxy_pass http://10.0.0.1:8265/;
    }
}

# Redirect HTTP -> HTTPS
server {
    listen 80;
    server_name llm-api.federicocalo.dev;
    return 301 https://$host$request_uri;
}

# Zona rate limiting (definita nel blocco http{} di nginx.conf)
# limit_req_zone $binary_remote_addr zone=llm_api:10m rate=10r/s;

Per il VPS Hetzner CCX23 attuale (single-GPU, single-node), questa configurazione Nginx non è necessaria: Ray Serve espone già un endpoint HTTP diretto. La preparate quando aggiungete il secondo nodo: è uno dei pochi casi in cui è più efficiente preparare l'infrastruttura prima di averne bisogno, perché modificare il routing in produzione è rischioso.

Sezione 7 - Data Locality: embedding store shardato vicino ai nodi

In un sistema RAG multi-node, il vettore store centralizzato è un collo di bottiglia nascosto. Se hai 3 nodi di inferenza che puntano tutti a un singolo Redis o Qdrant, ogni query RAG traversa la rete: 20-50ms di latenza aggiuntiva per ogni hop. Con 10 chunk recuperati per richiesta, sono 200-500ms solo per il retrieval - prima ancora che il modello abbia elaborato un token.

La soluzione è la data locality: replica o sharda l'embedding store per nodo, così ogni replica del modello legge localmente. Non è sempre possibile (i dati devono restare sincronizzati), ma per embedding statici o aggiornati poco frequentemente, la replica locale è la soluzione ottimale.

# Sezione 7 - Embedding store shardato con data locality per cluster multi-node
# Pattern: ogni nodo inferenza ha una replica locale dell'embedding store
# Tecnologia: Redis Stack (RediSearch) con replica master-replica

import redis
import numpy as np
from typing import List, Tuple

class LocalEmbeddingStore:
    """
    Wrapper per embedding store con data locality.
    Si connette al Redis locale del nodo (loopback) invece che al nodo centralizzato.
    Fallback al nodo master se il locale non è disponibile.
    """

    def __init__(
        self,
        local_host: str = "127.0.0.1",  # Redis locale sul nodo corrente
        local_port: int = 6379,
        master_host: str = "10.0.0.10",  # Redis master (centralizzato)
        master_port: int = 6379,
        index_name: str = "llm_embeddings",
    ):
        self.index_name = index_name

        # Connessione al Redis locale (latenza ~0.1ms)
        try:
            self.local_client = redis.Redis(
                host=local_host,
                port=local_port,
                decode_responses=False,
                socket_timeout=0.5,  # Timeout basso: se locale non risponde, usa master
            )
            self.local_client.ping()
            self.use_local = True
            print(f"[EmbeddingStore] Connesso al Redis locale ({local_host}:{local_port})")
        except (redis.ConnectionError, redis.TimeoutError):
            self.use_local = False
            print(f"[EmbeddingStore] Redis locale non disponibile, uso master")

        # Connessione al Redis master (latenza ~5-20ms via rete privata Hetzner)
        self.master_client = redis.Redis(
            host=master_host,
            port=master_port,
            decode_responses=False,
            socket_timeout=2.0,
        )

    @property
    def client(self) -> redis.Redis:
        """Ritorna il client ottimale: locale se disponibile, master come fallback."""
        return self.local_client if self.use_local else self.master_client

    def search_similar(
        self,
        query_embedding: np.ndarray,
        top_k: int = 10,
        score_threshold: float = 0.7,
    ) -> List[Tuple[str, float]]:
        """
        Cerca i chunk più simili all'embedding della query.
        Usa RediSearch HNSW per ricerca approssimata O(log n).
        """
        # Converti embedding in formato binario per RediSearch
        embedding_bytes = query_embedding.astype(np.float32).tobytes()

        # Query KNN su indice HNSW locale
        try:
            risultati = self.client.execute_command(
                "FT.SEARCH", self.index_name,
                f"*=>[KNN {top_k} @embedding $blob AS score]",
                "PARAMS", "2", "blob", embedding_bytes,
                "SORTBY", "score",
                "RETURN", "2", "content", "score",
                "DIALECT", "2",
            )

            # Parsing dei risultati
            chunks = []
            # risultati[0] = numero totale, poi coppie (key, fields)
            for i in range(1, len(risultati), 2):
                fields = dict(zip(risultati[i+1][::2], risultati[i+1][1::2]))
                score = float(fields.get(b"score", 1.0))
                content = fields.get(b"content", b"").decode("utf-8")

                # Filtra per soglia di similarità (score RediSearch = distanza, non similarità)
                if 1.0 - score >= score_threshold:
                    chunks.append((content, 1.0 - score))

            return chunks

        except redis.RedisError as e:
            # Se il Redis locale fallisce durante la query, fallback al master
            if self.use_local:
                print(f"[EmbeddingStore] Errore locale, fallback a master: {e}")
                self.use_local = False
                return self.search_similar(query_embedding, top_k, score_threshold)
            raise

# Utilizzo nel contesto Ray Serve (ogni replica ha il suo store locale)
# store = LocalEmbeddingStore(
#     local_host="127.0.0.1",   # Redis sul nodo corrente
#     master_host="10.0.0.10",  # Redis master su nodo dedicato
# )
# chunks = store.search_similar(query_embedding, top_k=5)
# context = "\n".join([chunk for chunk, _ in chunks])

Per mantenere sincronizzati i Redis locali con il master, usa la replica asincrona nativa di Redis: ogni Redis locale è configurato come replica read-only del master. Le scritture (nuovi documenti indicizzati) vanno sempre al master e si propagano ai replica in millisecondi. Il risultato: latenza di retrieval da 20-50ms (rete) a 0.5-2ms (loopback), con un impatto trascurabile sui costi Hetzner (Redis replica consuma circa 256MB-1GB RAM per un corpus di 100k chunk).

Sezione 8 - Kubernetes vs Ray: la tabella decisionale

La domanda più comune quando si scala un LLM self-hosted è: "dovrei usare Kubernetes?". La risposta onesta è: dipende da quante GPU hai, da che team hai, e da quanta complessità operativa puoi sostenere. Ray e Kubernetes non sono mutuamente esclusivi - KubeRay fa girare Ray dentro Kubernetes - ma per la maggior parte delle startup con meno di 10 nodi GPU, Ray standalone è la scelta corretta.

Dimensione Ray standalone Kubernetes + KubeRay
Setup iniziale 30 minuti (ray start + ray serve) 2-5 giorni (cluster K8s + KubeRay operator + CRD)
Numero nodi GPU Ideale 2-15 nodi Conviene da 20+ nodi
Team necessario 1 ML engineer con bash skills Platform engineer dedicato Kubernetes
Auto-scaling Integrato (Ray autoscaler) HPA + custom metrics (configurazione complessa)
Multi-model serving Nativo con Ray Serve deployment groups Richiede Helm chart per ogni modello
Failover GPU Automatico (worker reconnect) Automatico (pod restart policy)
Observability Dashboard Ray + Prometheus integrato Prometheus + Grafana stack (potente ma da configurare)
Costo operativo mensile ~2h/settimana ~8-16h/settimana (mantenimento K8s)
Quando fare upgrade Quando superi 15 nodi o hai requisiti multi-cloud Già in produzione con K8s per altri servizi

Il caso più comune in cui Kubernetes vince è quando l'azienda ha già un cluster K8s in produzione per altri microservizi: in quel caso, aggiungere KubeRay ha senso perché riutilizza l'infrastruttura esistente. Se parti da zero con solo LLM serving, Ray standalone è più rapido, più semplice e altrettanto robusto fino a 15 nodi.

Sezione 9 - Il caso Hetzner CCX23: single-GPU oggi, Ray cluster domani

Il VPS Hetzner CCX23 che uso per il portfolio (con Ollama e llama3.1:8b) è single-GPU per design: il traffico attuale del portfolio non giustifica multi-GPU, e il costo di un secondo nodo GPU (>250€/mese) non è proporzionale al beneficio. Ma l'architettura è già predisposta per scalare con Ray quando il traffico lo richiederà.

La strategia concreta: quando il throughput medio supera 10 richieste concorrenti con latenza p95 > 300ms, è il momento di aggiungere un secondo nodo Hetzner con GPU e connettere i due con un Ray cluster. Il processo richiede circa 2 ore di lavoro operativo e zero downtime se preparato correttamente.

Nel frattempo, le ottimizzazioni che fanno la differenza su single-GPU sono: quantizzazione aggressiva (4-bit GGUF per Ollama, AWQ per vLLM), prefix caching abilitato in vLLM (--enable-prefix-caching), e chunked prefill per ridurre il picco di memoria durante richieste con context lungo. Questi interventi tipicamente raddoppiano il throughput effettivo senza aggiungere hardware.

FAQ

Quando ha senso passare da single-GPU a multi-GPU per un LLM self-hosted?

Il passaggio conviene quando il throughput della GPU singola è saturo (utilizzo >85% costante), quando il modello non entra nella VRAM disponibile, oppure quando la latenza p99 supera i 500ms con traffico concorrente. Per un VPS Hetzner CCX23 con A10G 24GB, il threshold pratico è circa 15-20 richieste concorrenti con modelli da 7-13B parametri.

Qual è la differenza pratica tra NVIDIA MPS e il tensor parallelism di vLLM?

MPS condivide una GPU tra più processi CUDA in modo concorrente: è utile per modelli piccoli (<7B) con bassa latenza richiesta o per eseguire più modelli distinti sullo stesso hardware. Il tensor parallelism di vLLM divide il modello stesso su più GPU, ripartendo pesi e KV cache: scala fino a 16+ GPU e gestisce modelli che non entrano su una sola scheda. Sono complementari, non alternativi.

Ray cluster è overkill per una startup con 2-3 GPU?

No, Ray è sorprendentemente leggero da avviare: un head node + 1 worker node con pochi comandi bash. Per startup con 2-3 nodi GPU, Ray offre auto-scaling, failover automatico e API compatibile OpenAI senza la complessità operativa di Kubernetes. Il punto di svolta verso Kubernetes è quando hai un platform team dedicato e più di 20 nodi.

Come funziona il routing prefix-cache aware di Ray Serve?

Ray Serve 2.40+ mantiene un registro dei prefix hash per ogni replica attiva. Quando arriva una richiesta, il router controlla se il prefix del prompt (tipicamente il system prompt o il contesto RAG) è già in cache su una replica specifica e la instrada preferenzialmente verso quella. Il risultato è una riduzione del TTFT (Time To First Token) del 20-40% in workload con prompt template comuni.

Cos'è la data locality per embedding store e perché conta nel multi-node?

In un cluster multi-node, se l'embedding store è centralizzato, ogni nodo inferenza deve traversare la rete per ogni query RAG: 20-50ms per hop. La data locality significa replicare o shardare l'embedding store per nodo, così l'inferenza legge localmente. La latenza di retrieval scende da 20-50ms (rete) a 0.5-2ms (loopback). Con 10 chunk per richiesta RAG, risparmia 200-480ms per ogni generazione.

pipeline_parallel_size vs tensor_parallel_size: quando uso l'uno e quando l'altro?

Tensor parallelism (TP) divide ogni layer su più GPU della stessa macchina: sfrutta la banda NVLink ad alta velocità. Pipeline parallelism (PP) divide i layer in stadi su nodi diversi: adatto quando un singolo nodo non ha abbastanza GPU o quando la comunicazione inter-nodo è il fattore limitante. Regola pratica: TP = numero GPU per nodo, PP = numero nodi. Per Llama 3.1 70B su 2 nodi con 4 GPU ciascuno: TP=4, PP=2.

Quanto costa scalare da 1 a 4 nodi GPU su Hetzner per un LLM self-hosted?

Un nodo Hetzner con GPU dedicata (A10G 24GB, tipo GX2/CCX53) costa circa €250-350/mese. Quattro nodi significano €1000-1400/mese. Il break-even rispetto a cloud API (OpenAI GPT-4o a ~€15/1M token output) dipende dal volume: oltre 5 milioni di token generati al mese, il self-hosted multi-node inizia a generare un risparmio netto del 40-60%.

Conclusione

Lo scaling di un LLM self-hosted non è un problema che si risolve con una singola tecnologia. È una sequenza di decisioni incrementali: prima ottimizza la GPU singola (quantizzazione, prefix caching, MPS per modelli piccoli), poi scala orizzontalmente con vLLM tensor parallelism su più GPU dello stesso nodo, poi espandi a un cluster multi-node con Ray quando il traffico lo giustifica. Kubernetes entra in scena solo quando hai già un'infrastruttura K8s e un team platform dedicato.

Il vantaggio di Ray rispetto a Kubernetes non è nelle feature - KubeRay offre le stesse - ma nella semplicità operativa: 30 minuti per avviare il cluster contro 2-5 giorni per un setup K8s completo. Per la maggior parte delle startup AI con meno di 15 nodi GPU, questa differenza si traduce in settimane di sviluppo risparmiate ogni anno.

Il caso Hetzner CCX23 dimostra che anche con single-GPU è possibile costruire l'architettura giusta fin dal principio: Nginx configurato, Ray installato ma non ancora in cluster mode, embedding store con fallback locale già nel codice. Quando il traffico crescerà, l'upgrade sarà questione di ore, non di settimane.

Prossimo episodio: LLM serving alternatives 2026 - quando vLLM è troppo complesso, LM Studio, SGLang, text-generation-webui e Llamafile offrono tradeoff diversi per contesti diversi. Vediamo quando ciascuno vince.

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