Vuoi far girare un modello aperto sulla tua macchina e ti servono tre risposte prima di scaricare qualunque cosa: ci sta in memoria, quante richieste regge insieme, e quale programma conviene usare per servirlo. La memoria si stima con due voci, i pesi (parametri per bit per peso) e la cache chiave-valore, che cresce con la lunghezza delle sequenze e con il numero di richieste attive; la capacità di servire più richieste dipende da come il motore gestisce quella cache e da come costruisce i gruppi di richieste; il programma lo scegli guardando ciò che la sua documentazione dichiara di ottimizzare e ciò che il tuo hardware permette. Nessuna delle tre risposte richiede un modello per essere capita: bastano conti e piccole simulazioni, che trovi qui sotto.
Questo è l'ottavo e ultimo articolo della serie Intelligenza Artificiale Moderna. Arrivi da quello sui modelli che ragionano, che spiega perché più calcolo per risposta ha un costo, e questo articolo mostra dove quel costo si paga in memoria e in tempo. Basta Python con numpy. Nessun modello viene scaricato o chiamato, e per questo non troverai misure di token al secondo: dipendono dalla tua macchina, e un numero preso altrove non direbbe nulla di essa.
Quanto pesano i pesi, e che cosa si perde a quantizzarli
Un modello da 7 miliardi di parametri in precisione a 16 bit occupa, per i soli pesi, 7e9 per 2 byte, cioè circa 13 GiB. Quantizzare significa rappresentare ogni peso con meno bit: a 8 bit la memoria dimezza, a 4 bit si riduce a un quarto, più una piccola quota per le scale che servono a ricostruire i valori. Il costo è un errore di arrotondamento, e conviene vedere quanto grande e da che cosa dipende, prima di fidarsi di una percentuale letta in giro.
Il listato quantizza per gruppi di 64 pesi, con uno schema simmetrico e arrotondamento al più vicino: per ogni gruppo si calcola una scala dal peso massimo in valore assoluto, si dividono i pesi per la scala, si arrotondano a interi e si ricostruisce. Lo applico a una matrice sintetica 512 per 512 di pesi gaussiani e poi alla stessa matrice in cui l'1% dei pesi è 30 volte più grande, perché nei modelli reali alcuni pesi e alcune attivazioni hanno valori anomali.
"""Quantizzazione dei pesi per gruppi: memoria ed errore, su pesi sintetici.
Schema: simmetrico, round-to-nearest, una scala fp16 ogni gruppo di 64 pesi."""
import numpy as np
GRUPPO = 64
def quantizza(w, bit):
"""Restituisce i pesi ricostruiti dopo quantizzazione per gruppi."""
qmax = 2 ** (bit - 1) - 1
g = w.reshape(-1, GRUPPO)
scala = np.abs(g).max(axis=1, keepdims=True) / qmax
scala = np.where(scala == 0, 1.0, scala)
q = np.clip(np.round(g / scala), -qmax, qmax)
return (q * scala).reshape(w.shape), scala
def misura(nome, w, x, normali):
print(nome)
for bit in (8, 4):
wq, scala = quantizza(w, bit)
err = wq - w
mse = float((err ** 2).mean())
previsto = float((scala ** 2 / 12).mean()) # secondo calcolo: delta^2 / 12
rel_pesi = np.sqrt((err[normali] ** 2).mean()) / w[normali].std()
rel_uscita = np.linalg.norm(x @ wq - x @ w) / np.linalg.norm(x @ w)
print(f" {bit} bit: errore relativo sui pesi normali {rel_pesi:.4f}, sull'uscita {rel_uscita:.4f}, "
f"MSE misurato/previsto {mse / previsto:.2f}")
rng = np.random.default_rng(3)
W = rng.normal(0.0, 0.02, size=(512, 512))
X = rng.normal(size=(64, 512))
misura("pesi gaussiani", W, X, np.ones(W.shape, dtype=bool))
W_out = W.copy() # 1% di pesi 30 volte piu' grandi
idx = rng.choice(W_out.size, size=W_out.size // 100, replace=False)
W_out.flat[idx] *= 30.0
normali = np.ones(W.shape, dtype=bool)
normali.flat[idx] = False
misura("1% di pesi 30 volte piu' grandi", W_out, X, normali)
print("memoria di un modello da 7 miliardi di parametri (solo pesi):")
N = 7e9
for nome, bit_per_peso in [("fp16", 16.0), ("int8 + scale", 8 + 16 / GRUPPO),
("4 bit + scale", 4 + 16 / GRUPPO)]:
print(f" {nome:14s} {bit_per_peso:5.2f} bit/peso -> {N * bit_per_peso / 8 / 2**30:5.1f} GiB")
# Output:
# pesi gaussiani
# 8 bit: errore relativo sui pesi normali 0.0059, sull'uscita 0.0060, MSE misurato/previsto 0.99
# 4 bit: errore relativo sui pesi normali 0.1074, sull'uscita 0.1079, MSE misurato/previsto 0.98
# 1% di pesi 30 volte piu' grandi
# 8 bit: errore relativo sui pesi normali 0.0504, sull'uscita 0.0162, MSE misurato/previsto 0.98
# 4 bit: errore relativo sui pesi normali 0.5419, sull'uscita 0.1751, MSE misurato/previsto 0.35
# memoria di un modello da 7 miliardi di parametri (solo pesi):
# fp16 16.00 bit/peso -> 13.0 GiB
# int8 + scale 8.25 bit/peso -> 6.7 GiB
# 4 bit + scale 4.25 bit/peso -> 3.5 GiB
Nel codice, quantizza calcola scala come il massimo del gruppo diviso per qmax, e questa riga decide tutto: un solo peso grande allarga il passo di quantizzazione di tutti i 63 vicini. Come secondo calcolo indipendente confronto l'errore quadratico misurato con la previsione teorica per l'arrotondamento uniforme, passo al quadrato diviso 12: nel caso gaussiano il rapporto è 0,99 e 0,98, quindi la teoria regge. Nel caso con valori anomali a 4 bit il rapporto scende a 0,35, perché il passo diventa più grande dei pesi comuni, che finiscono quasi tutti arrotondati a zero: l'errore su di essi è il loro valore, non più un errore uniforme, e la formula non vale.
La riga che pesa è quella sui pesi normali, cioè il 99% non anomalo. Senza valori anomali l'errore relativo è 0,006 a 8 bit e 0,107 a 4 bit; con l'1% di valori anomali sale a 0,050 e 0,542: i pesi ordinari sono quantizzati peggio perché dividono il gruppo con un compagno ingombrante, quasi metà dei gruppi (1 meno 0,99 alla 64, circa il 47%) ne contiene almeno uno. L'errore sull'uscita è più contenuto perché in questo caso sintetico i pesi grandi, ricostruiti con errore relativo piccolo, pesano molto nella norma dell'uscita. Questo è il problema che i metodi della letteratura affrontano: GPTQ quantizza in un solo passaggio usando informazione approssimata del secondo ordine, e AWQ osserva che non tutti i pesi hanno la stessa importanza e che proteggerne l'1% riduce molto l'errore, individuandoli dalla distribuzione delle attivazioni e non da quella dei pesi. Il mio listato non li implementa: mostra perché servono.
La cache chiave-valore: quanto pesa un token, quanto se ne spreca
Durante la generazione, ogni livello conserva le chiavi e i valori dei token già visti per non ricalcolarli. Il suo peso per token è 2 (chiavi e valori) per il numero di livelli, per le teste di chiave-valore, per la dimensione della testa, per i byte per elemento. Il listato usa una configurazione di modello inventata, che non corrisponde a nessun prodotto, e lo verifica creando un array con quelle dimensioni e leggendone nbytes.
"""KV cache: quanto pesa un token, e quanto spazio spreca una prenotazione fissa
contro un'allocazione a blocchi. Configurazione del modello INVENTATA, solo per i conti."""
import math
import numpy as np
STRATI, TESTE_KV, DIM_TESTA = 32, 8, 128 # modello di esempio, non un prodotto reale
BYTE = 2 # fp16
per_token = 2 * STRATI * TESTE_KV * DIM_TESTA * BYTE # 2 = chiavi + valori
controllo = np.empty((2, STRATI, TESTE_KV, DIM_TESTA), dtype=np.float16).nbytes
assert per_token == controllo
print(f"KV cache per token: {per_token} byte = {per_token // 1024} KiB")
print(f"una sequenza di 4096 token: {per_token * 4096 / 2**20:.0f} MiB")
# richieste sintetiche: lunghezza totale (prompt + risposta) lognormale, tagliata a 2048
rng = np.random.default_rng(11)
lunghezze = np.clip(rng.lognormal(mean=5.5, sigma=0.8, size=1000), 16, 2048).astype(int)
MAX_LEN, BLOCCO, POOL = 2048, 16, 120_000 # POOL: slot di token disponibili in memoria
usati = int(lunghezze.sum())
fisso = MAX_LEN * len(lunghezze)
a_blocchi = sum(math.ceil(n / BLOCCO) * BLOCCO for n in lunghezze)
print(f"lunghezza media {lunghezze.mean():.0f} token, mediana {int(np.median(lunghezze))}")
print(f"prenotazione fissa a {MAX_LEN}: utilizzo {usati / fisso:.1%}")
print(f"blocchi da {BLOCCO}: utilizzo {usati / a_blocchi:.1%} "
f"(spreco medio {(a_blocchi - usati) / len(lunghezze):.1f} token a richiesta, "
f"atteso circa {(BLOCCO - 1) / 2:.1f})")
def ammesse(costo):
"""Quante richieste, in ordine, entrano prima che il pool finisca."""
totale = n = 0
for c in costo:
if totale + c > POOL:
break
totale += c
n += 1
return n
costo_fisso = [MAX_LEN] * len(lunghezze)
costo_blocchi = [math.ceil(n / BLOCCO) * BLOCCO for n in lunghezze]
print(f"richieste in memoria con {POOL} slot: prenotazione fissa {ammesse(costo_fisso)}, "
f"blocchi {ammesse(costo_blocchi)}")
# Output:
# KV cache per token: 131072 byte = 128 KiB
# una sequenza di 4096 token: 512 MiB
# lunghezza media 338 token, mediana 255
# prenotazione fissa a 2048: utilizzo 16.5%
# blocchi da 16: utilizzo 97.8% (spreco medio 7.6 token a richiesta, atteso circa 7.5)
# richieste in memoria con 120000 slot: prenotazione fissa 58, blocchi 350
Con quei numeri un token pesa 128 KiB e una sequenza di 4096 token occupa 512 MiB: per questo la memoria disponibile, più dei pesi, limita quante richieste reggi insieme. La seconda parte del listato simula 1000 richieste di lunghezza sintetica (lognormale, media 338 token, tagliata a 2048). Se prenoti per ciascuna lo spazio della lunghezza massima, l'utilizzo medio è 16,5%; se assegni la memoria a blocchi da 16 token, man mano che servono, è 97,8% e lo spreco è quello dell'ultimo blocco, 7,6 token a richiesta contro i 7,5 attesi. Con un pool di 120.000 posti la prenotazione fissa ne fa entrare 58, i blocchi 350.
I numeri di questa simulazione sono miei e la distribuzione è inventata, ma la logica è quella del lavoro su PagedAttention (Kwon e colleghi, 2023): gli autori osservano che nei sistemi esistenti solo il 20,4%-38,2% della memoria della cache contiene stati di token effettivi, e propongono di gestirla come la memoria virtuale di un sistema operativo, a blocchi; nel loro lavoro il blocco predefinito di vLLM è di 16 token. Riportano per vLLM uno spreco quasi nullo e un aumento del throughput di 2-4 volte a pari latenza rispetto ai sistemi di confronto, con miglioramento più marcato per sequenze più lunghe e modelli più grandi: sono misure loro, su loro carichi di lavoro, e non le ho rifatte.
Batching continuo: non aspettare la richiesta più lenta
Il secondo spreco è nel tempo. Un motore che serve gruppi fissi di richieste parte con otto, e riparte solo quando la più lunga ha finito: le altre sette lasciano il loro posto vuoto, a girare a vuoto, per tutta la differenza di durata. Il batching continuo assegna un posto libero alla richiesta successiva appena si libera. Il listato lo simula a passi di generazione, con 200 richieste di durata sintetica (media 75 passi, massima 400) e otto posti.
"""Batching statico contro batching continuo, a passi di generazione.
Ogni passo produce un token per ogni richiesta attiva; durate sintetiche, seme fisso."""
import math
import numpy as np
rng = np.random.default_rng(5)
DURATE = np.clip(rng.lognormal(mean=4.0, sigma=0.9, size=200), 4, 400).astype(int)
B = 8 # richieste servite insieme
def statico(durate):
"""Gruppi da B: si riparte solo quando la richiesta piu' lunga del gruppo finisce."""
passi = 0
for i in range(0, len(durate), B):
passi += int(durate[i:i + B].max())
return passi
def continuo(durate):
"""Appena una richiesta finisce, il suo posto va alla successiva in coda."""
coda = list(durate)
attive = [coda.pop(0) for _ in range(min(B, len(coda)))]
passi = 0
while attive:
passi += 1
attive = [d - 1 for d in attive]
attive = [d for d in attive if d > 0]
while len(attive) < B and coda:
attive.append(coda.pop(0))
return passi
tot = int(DURATE.sum())
limite = math.ceil(tot / B)
ps, pc = statico(DURATE), continuo(DURATE)
print(f"200 richieste, durata media {DURATE.mean():.0f} passi, massima {DURATE.max()}, B = {B}")
print(f"limite inferiore (token totali / B): {limite} passi")
print(f"statico : {ps} passi, occupazione degli slot {tot / (ps * B):.1%}")
print(f"continuo: {pc} passi, occupazione degli slot {tot / (pc * B):.1%}")
assert limite <= pc <= ps
# Output:
# 200 richieste, durata media 75 passi, massima 400, B = 8
# limite inferiore (token totali / B): 1874 passi
# statico : 5318 passi, occupazione degli slot 35.2%
# continuo: 2000 passi, occupazione degli slot 93.7%
La funzione statico somma, per ogni gruppo di otto, la durata massima; continuo rimpiazza ogni richiesta finita con la prima in coda. Il limite inferiore, token totali divisi per otto, è 1874 passi e l'asserzione finale verifica che entrambi non scendano sotto di esso. Il batching statico impiega 5318 passi, con un'occupazione media degli slot del 35,2%; quello continuo 2000, con il 93,7%. La simulazione ignora il costo di leggere il prompt, i limiti di memoria della sezione precedente e la latenza della singola richiesta: non prevede il guadagno reale, mostra che lo spreco del gruppo fisso cresce con la varianza delle durate, e che sta tutto in come si riempiono i posti. La documentazione di vLLM cita fra le sue caratteristiche sia PagedAttention sia il batching continuo delle richieste in arrivo, e l'articolo su PagedAttention richiama come lavoro precedente lo scheduling a livello di iterazione di Orca.
Ollama, llama.cpp, vLLM: che cosa dichiarano di fare
Ti riporto ciò che dichiarano le loro pagine ufficiali, consultate il 2026-11-13. Non ho eseguito nessuno dei tre, e non do confronti di velocità, perché non li ho misurati.
| Strumento | Che cosa dichiara la documentazione |
|---|---|
| llama.cpp | Inferenza di LLM in C/C++ con configurazione minima; implementazione senza dipendenze; quantizzazione a interi da 1,5 a 8 bit; backend per CPU e per varie GPU; inferenza ibrida CPU+GPU per modelli più grandi della memoria della scheda; un server con API compatibile con OpenAI. |
| Ollama | Permette di usare modelli aperti (il README parla di uso locale o in cloud) ed espone un'API REST (l'esempio del README usa localhost sulla porta 11434); fra i backend supportati indica il progetto llama.cpp. |
| vLLM | Libreria di inferenza e serving; elenca gestione efficiente della memoria di chiavi e valori con PagedAttention, batching continuo, chunked prefill e prefix caching; quantizzazione fra cui GPTQ, AWQ e GGUF; server compatibile con l'API di OpenAI; supporto a GPU NVIDIA e AMD e a CPU x86, ARM e PowerPC. |
La tabella non classifica niente, e la scelta nasce dalle misure che hai fatto sopra. Se sei una persona sola su un portatile, la memoria dei pesi e la quantizzazione contano più del batching, e gli strumenti che puntano su portabilità e quantizzazione sono i candidati naturali. Se servi molte richieste insieme su una scheda grafica, contano la cache e il riempimento dei posti, che sono proprio ciò che vLLM dichiara di ottimizzare. Se esponi il motore su una rete, mettilo dietro un proxy con autenticazione e non su un'interfaccia pubblica: non ho verificato quali di questi strumenti offrano un'autenticazione propria, e non lo affermo.
Locale non vuol dire conforme
Far girare il modello sul proprio server evita di inviare i prompt a un fornitore esterno per l'inferenza, e questo cambia chi tratta i dati. Non elimina però gli obblighi di chi li tratta né quelli di chi mette in servizio un sistema di intelligenza artificiale, e i testi cambiano: il regolamento (UE) 2026/1744 dell'8 luglio 2026, che ho letto su EUR-Lex il 2026-11-13, modifica il regolamento (UE) 2024/1689 e porta, per esempio, la data di applicazione degli obblighi dei sistemi ad alto rischio dell'allegato III al 2 dicembre 2027. Non è una consulenza legale: se il tuo uso tocca dati personali o rientra in un ambito regolato, il testo vigente e un professionista del settore sono la sede giusta.
Che cosa non ho misurato
I tre listati usano pesi, richieste e durate sintetici, con semi fissi, e una configurazione di modello inventata: provano i meccanismi e le relazioni, non le prestazioni di un programma o di una macchina. Le cifre di PagedAttention (20,4%-38,2%, 2-4 volte) sono dei suoi autori. Non ho misurato token al secondo, latenze, qualità delle risposte di un modello quantizzato né consumi, e non ho eseguito i tre strumenti: per questo la tabella riporta solo ciò che dichiarano. Le caratteristiche di software che cambia di continuo vanno riverificate sulla pagina del progetto il giorno in cui decidi.
Domande frequenti
Quanta memoria serve per un modello da N miliardi di parametri?
Per i pesi, N miliardi per i bit per peso diviso 8: con 7 miliardi sono circa 13 GiB a 16 bit, 6,7 GiB a 8 bit con scale e 3,5 GiB a 4 bit con scale (schema del listato). A questi si aggiungono la cache chiave-valore, proporzionale a lunghezza e numero di richieste, e un margine per il programma: la somma, non i soli pesi, deve stare in memoria.
Quantizzare a 4 bit peggiora le risposte?
Aumenta l'errore sui pesi, e nel listato l'errore relativo passa da 0,006 a 0,107 sui pesi gaussiani e peggio con valori anomali. Quanto questo si traduca in qualità delle risposte dipende dal modello, dal metodo e dal compito, e non l'ho misurato: va valutato sul tuo caso d'uso, con le tue domande.
Ollama e llama.cpp sono lo stesso programma?
No. Il README di Ollama indica llama.cpp fra i backend supportati, quindi il secondo è una parte del primo, ma Ollama espone un'API locale e gestisce i modelli, mentre llama.cpp è il motore di inferenza in C/C++ con i suoi strumenti. Per i dettagli di quanto condividono valgono le pagine dei due progetti.
Che cosa sai fare adesso
Sai stimare se un modello ci sta, sommando pesi quantizzati e cache; sai dire perché un solo valore anomalo peggiora la quantizzazione dei vicini; sai spiegare perché prenotare la lunghezza massima spreca memoria e perché il gruppo fisso spreca tempo; e sai leggere la documentazione di un motore di inferenza chiedendoti quale di questi sprechi dichiara di ridurre.
Riepilogo della serie
La serie ha percorso, in otto tappe, gli elementi su cui poggiano i sistemi moderni basati su modelli linguistici, dall'architettura fino a come farli girare. Qui sono tutti, in ordine.
- Architettura Transformer: attention, encoder, decoder
- LLM scaling laws e capacità emergenti
- RAG fondamentale: embedding, vector store, retrieval
- RAG avanzato: re-ranking, hybrid search, chunk strategy
- Agenti AI: ReAct, tool-use, planning
- Model Context Protocol (MCP): standard di integrazione
- Reasoning models: o1, DeepSeek-R1, RLHF
- Inferenza on-prem con Ollama, vLLM, llama.cpp (questo articolo)
L'ordine ha un senso: i primi due articoli spiegano che cosa è un modello e come cresce, i due sul recupero spiegano come dargli conoscenza che non ha, i due su agenti e protocollo come fargli usare strumenti, quello sul ragionamento come spendere più calcolo per risposta, e questo dove quel calcolo gira e quanto costa in memoria e in tempo. Se hai letto in ordine, hai visto lo stesso metodo ripetuto: un meccanismo costruito in piccolo, un secondo calcolo di conferma, e la dichiarazione di ciò che l'esperimento non dice. È il metodo che conviene portarti via, più di qualunque singola cifra, perché le cifre cambiano e il metodo resta valido. Torna all'indice della serie per riprendere un tema da dove ti serve.
Fonti
- Efficient Memory Management for Large Language Model Serving with PagedAttention (Kwon et al., 2023) - abstract e testo: spreco di memoria della cache, 20,4%-38,2%, blocco predefinito di 16 token, miglioramento di throughput riportato dagli autori, consultata il 2026-11-13
- GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers (Frantar et al., 2022) - solo l'abstract: quantizzazione one-shot con informazione approssimata del secondo ordine, consultata il 2026-11-13
- AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration (Lin et al., 2023) - solo l'abstract: i pesi non hanno la stessa importanza, proteggere l'1% riduce l'errore, individuati dalle attivazioni, consultata il 2026-11-13
- llama.cpp - README - obiettivo, quantizzazione da 1,5 a 8 bit, backend, inferenza ibrida CPU+GPU, server compatibile con OpenAI, consultata il 2026-11-13
- Ollama - README - API REST locale, porta dell'esempio, llama.cpp fra i backend supportati, consultata il 2026-11-13
- vLLM - documentazione - caratteristiche dichiarate: PagedAttention, batching continuo, chunked prefill, prefix caching, quantizzazioni, server compatibile con OpenAI, hardware supportato, consultata il 2026-11-13
- Regolamento (UE) 2026/1744 (Omnibus digitale sull'IA), EUR-Lex - titolo, data, modifica dell'articolo 113 del regolamento (UE) 2024/1689 con le date del 2 dicembre 2027 e del 2 agosto 2028, consultata il 2026-11-13
I tre listati sono stati eseguiti con Python 3.14.4 e numpy 2.3.5 (python3 -I), due volte ciascuno, con uscita identica, ciascuno in meno di un secondo. Dati sintetici con semi fissi; la configurazione del modello nel secondo listato è inventata. I tre strumenti della tabella non sono stati eseguiti. Il testo del regolamento è stato letto nella versione inglese pubblicata su EUR-Lex.
Commenti
Caricamento commenti...
Accedi per commentare