Salta al contenuto principale

99 - Far girare un modello in locale: quantizzazione, memoria e latenza misurate

Quantizza pesi sintetici a 8, 4 e 2 bit e misura errore, byte reali per peso, outlier e clipping, fino a una piccola rete, con NumPy e PyTorch.

Cosa ne pensi di questo articolo?

99 - Far girare un modello in locale: quantizzazione, memoria e latenza misurate
Machine Learning Engineerfedericocalo.dev

In Breve

Quantizza pesi sintetici a 8, 4 e 2 bit e misura errore, byte reali per peso, outlier e clipping, fino a una piccola rete, con NumPy e PyTorch.

Un modello da otto miliardi di parametri, scritto in 16 bit per peso, occupa 14,96 GiB; lo stesso modello nel formato Q4_K_M ne occupa 4,58. Sono i numeri della tabella che la documentazione di llama.cpp riporta per Llama 3.1 8B (tools/quantize/README.md), e dicono perché la quantizzazione è la leva che decide se un modello entra o no nella memoria che hai. Dicono anche, a chi li legge con attenzione, che Q4_K_M non vale 4 bit per peso ma 4,8944, e che nessuna tabella di dimensioni risponde alla domanda che conta: quanta precisione hai perso per entrarci.

Questa lezione risponde sul pezzo più piccolo che contiene tutta la questione, un tensore di pesi. Lo quantizzo a 8, 4 e 2 bit, con una scala per tutto il tensore o una per riga, in forma simmetrica e asimmetrica, e misuro tre cose: l'errore che introduce, i byte che costa davvero e il punto in cui il risultato smette di essere utile. Poi salgo di un gradino, a uno strato lineare e a una piccola rete addestrata, per vedere come l'errore sui pesi diventa errore sull'uscita.

Dichiaro subito che cosa questa lezione non è. Non ho eseguito alcun modello linguistico e non ho scaricato pesi: non troverai token al secondo né memoria di GPU, perché sarebbero cifre di una macchina che non è la tua e che qui non potrei rifare. Le cifre sui modelli veri (formati GGUF, GPTQ, AWQ, bitsandbytes) vengono dalla documentazione ufficiale e dai paper, e sono attribuite alla fonte. Tutto il resto l'ho eseguito, due volte, con uscita identica: Python 3.14.4 e NumPy 2.3.5 per i listati sui tensori, PyTorch 2.12.0 su CPU, con la GPU nascosta e due thread, per l'ultimo. La macchina era carica (carico medio intorno a 33 su 12 core), e per questo i tempi, dove compaiono, sono intervalli.

Il righello con poche tacche

Immagina di dover riportare la lunghezza di mille assi di legno usando un righello con sole 16 tacche, invece delle 65.536 che avresti a 16 bit. Ogni misura va arrotondata alla tacca più vicina, quindi l'errore massimo è mezzo intervallo fra due tacche. Fin qui è facile. La difficoltà sta in due decisioni che il righello non prende da solo. La prima è fin dove arriva: se l'asse più lunga misura tre metri e tutte le altre trenta centimetri, un righello unico di tre metri con 16 tacche le ha a 20 centimetri di distanza, e quasi tutte le assi corte finiscono sullo zero. La seconda è che cosa fare dell'asse eccezionale: accorciare il righello e sacrificarla, oppure tenerlo lungo e perdere tutte le altre.

Le tre leve della quantizzazione sono queste. Il numero di tacche è la precisione, in bit. Dare un righello a ogni gruppo di assi, invece di uno solo per tutte, è la scala per canale o per gruppo. Accorciare il righello è il clipping. In formule, ogni peso w diventa un intero q = round(w / s) e viene ricostruito come q · s, dove s è la scala, il passo fra due tacche. Il resto della lezione misura che cosa costa ciascuna scelta, partendo da un tensore che puoi rifare.

Un tensore in mano: tre bit, due scale, due schemi

La funzione quantizza è mia: non viene da nessuna libreria, serve solo NumPy. Prende il tensore e quattro scelte. bit è la precisione (qui 8, 4 o 2). per_canale, falso per impostazione, decide se la scala è una sola per tutto il tensore o una per riga, cioè per canale di uscita: con True il tensore deve essere a due dimensioni, perché la riduzione avviene sull'asse 1. simmetrica, vera per impostazione, usa i livelli da -qmax a +qmax con qmax = 2^(bit-1) - 1, quindi lo zero resta esatto; con la forma asimmetrica scala e zero point coprono l'intervallo da minimo a massimo e usano tutti i 2^bit livelli. clip, 1,0 per impostazione, è la frazione dell'intervallo che il righello copre: sotto 1 i pesi più estremi vengono tagliati.

Due dettagli del codice vanno guardati prima dell'uscita. A 2 bit simmetrici qmax vale 1, e i livelli sono solo tre (-1, 0, +1): il quarto codice, che a 2 bit esisterebbe, non si usa. E il tensore di prova è costruito con un'ampiezza diversa per ogni riga (da 0,0042 a 0,1123, una scelta mia per mettere alla prova la scala per canale): in un modello vero l'eterogeneità fra righe va misurata, non presunta.

import numpy as np

def quantizza(w, bit, per_canale=False, simmetrica=True, clip=1.0):
    """Quantizza w a `bit` bit e restituisce (interi, scala, zero, pesi ricostruiti).
    per_canale=False: una scala per tutto il tensore; True: una per riga (canale di uscita)."""
    kd = {"axis": 1, "keepdims": True} if per_canale else {}
    if simmetrica:
        qmax = 2 ** (bit - 1) - 1                      # 127, 7, 1
        a = np.abs(w).max(**kd) * clip
        scala = np.where(a > 0, a / qmax, 1.0)
        q = np.clip(np.round(w / scala), -qmax, qmax).astype(np.int32)
        return q, scala, 0, q * scala
    lo, hi = w.min(**kd) * clip, w.max(**kd) * clip
    scala = np.where(hi > lo, (hi - lo) / (2 ** bit - 1), 1.0)
    zero = np.round(-lo / scala)
    q = np.clip(np.round(w / scala) + zero, 0, 2 ** bit - 1).astype(np.int32)
    return q, scala, zero, (q - zero) * scala

def metriche(w, r):
    e = w - r
    mse = float(np.mean(e ** 2))
    snr = 10 * np.log10(float(np.mean(w ** 2)) / mse) if mse > 0 else float("inf")
    return mse, float(np.abs(e).max()), snr

def pesi_sintetici(righe=512, colonne=512, seme=2398):
    """Pesi a campana; ogni riga ha una propria ampiezza (log-normale): una scelta mia, per mettere alla prova la scala per canale."""
    rng = np.random.default_rng(seme)
    ampiezza = 0.02 * np.exp(0.5 * rng.standard_normal((righe, 1)))
    return (rng.standard_normal((righe, colonne)) * ampiezza).astype(np.float32)

W = pesi_sintetici()
print("forma", W.shape, "deviazione standard %.4f" % W.std(), "|w| massimo %.4f" % np.abs(W).max())
print("ampiezza delle righe: da %.4f a %.4f" % (W.std(axis=1).min(), W.std(axis=1).max()))
print()
print("bit  scala         schema      MSE          errore max   SNR dB   livelli usati")
for bit in (8, 4, 2):
    for per_canale in (False, True):
        for simm in (True, False):
            q, s, z, r = quantizza(W, bit, per_canale, simm)
            mse, emax, snr = metriche(W, r)
            print("%3d  %-12s  %-10s  %.3e    %.5f      %6.2f   %d" % (
                bit, "per canale" if per_canale else "per tensore",
                "simmetrica" if simm else "asimmetrica", mse, emax, snr, len(np.unique(q))))

# Output:
# forma (512, 512) deviazione standard 0.0247 |w| massimo 0.3712
# ampiezza delle righe: da 0.0042 a 0.1123
#
# bit  scala         schema      MSE          errore max   SNR dB   livelli usati
#   8  per tensore   simmetrica  7.100e-07    0.00146       29.34   166
#   8  per tensore   asimmetrica  6.336e-07    0.00138       29.84   177
#   8  per canale    simmetrica  3.385e-08    0.00146       42.56   255
#   8  per canale    asimmetrica  2.926e-08    0.00138       43.19   256
#   4  per tensore   simmetrica  1.889e-04    0.02651        5.09   13
#   4  per tensore   asimmetrica  1.566e-04    0.02345        5.91   15
#   4  per canale    simmetrica  1.118e-05    0.02651       17.37   15
#   4  per canale    asimmetrica  8.455e-06    0.02335       18.59   16
#   2  per tensore   simmetrica  6.040e-04    0.18525        0.05   3
#   2  per tensore   asimmetrica  5.795e-04    0.11723        0.23   4
#   2  per canale    simmetrica  4.340e-04    0.18525        1.48   3
#   2  per canale    asimmetrica  2.096e-04    0.11712        4.64   4

Le prime quattro righe dopo l'intestazione sono gli otto bit, e l'ultima colonna dice già una cosa che si vedrà più volte: con una scala sola il tensore usa 166 livelli su 255 (simmetrico), con una scala per riga li usa tutti. Gli errori massimi coincidono fra i due schemi (0,00146 a 8 bit, 0,02651 a 4) perché sono la metà del passo della riga più ampia, 0,3712 / 127 / 2 e 0,3712 / 7 / 2, e quella riga ha la stessa scala in entrambi. Quello che cambia è l'errore delle altre righe, e lo vede il rapporto segnale/rumore (SNR).

  • La scala per canale guadagna 13,2 dB a 8 bit (da 29,34 a 42,56), 12,3 dB a 4 bit (da 5,09 a 17,37) e 1,4 dB a 2 bit (da 0,05 a 1,48).
  • La forma asimmetrica guadagna da 0,2 a 0,8 dB con una scala sola, e da 0,6 a 3,2 dB con una scala per riga.
  • A 2 bit il tensore con una scala sola e forma simmetrica ha SNR 0,05 dB: l'errore è grande quanto il segnale, perché quasi tutti i pesi finiscono sullo zero.

Come leggere questi tre numeri: la granularità della scala pesa molto più della forma. A 4 bit passare da una scala per tensore a una per riga vale 12 dB, passare da simmetrica ad asimmetrica da 0,8 a 1,2 dB. La forma asimmetrica comincia a contare dove i livelli sono pochi, perché a 2 bit offre quattro livelli invece di tre e per riga arriva a 4,64 dB contro 1,48. Il costo è una seconda quantità da conservare per ogni scala, lo zero point, e lo si paga in byte: ne parlo nella sezione sui byte reali.

Sei decibel per bit

Quanto vale un bit in più? Per un quantizzatore uniforme la risposta è nota: l'errore quadratico cala di un fattore quattro, cioè guadagni 20·log10(2) = 6,02 dB per bit. Lo verifico su un campione gaussiano di 262.144 valori, con la scala fissata sul massimo, per i bit da 2 a 8. Il listato riusa quantizza e metriche del blocco precedente: i blocchi di questa lezione girano nell'ordine in cui compaiono, nella stessa sessione.

rng = np.random.default_rng(1)
G = rng.standard_normal((512, 512)).astype(np.float32)        # N(0,1), 262.144 valori
print("bit   SNR dB   guadagno per bit aggiunto   MSE")
prec = None
serie = []
for bit in range(2, 9):
    mse, emax, snr = metriche(G, quantizza(G, bit)[3])
    serie.append((bit, snr))
    print("%3d   %6.2f   %s   %.3e" % (bit, snr, "      -      " if prec is None else "%11.2f dB " % (snr - prec), mse))
    prec = snr
print("massimo |x| del campione: %.3f deviazioni standard" % np.abs(G).max())

# Output:
# bit   SNR dB   guadagno per bit aggiunto   MSE
#   2     0.39         -         9.106e-01
#   3     7.44          7.05 dB    1.797e-01
#   4    14.81          7.36 dB    3.298e-02
#   5    21.41          6.60 dB    7.213e-03
#   6    27.73          6.32 dB    1.681e-03
#   7    33.88          6.15 dB    4.084e-04
#   8    39.97          6.09 dB    1.004e-04
# massimo |x| del campione: 4.406 deviazioni standard

Il guadagno per bit parte da 7,05 dB e scende a 6,09: tende al valore teorico di 6,02 da sopra. Fra 7 e 8 bit l'MSE passa da 4,084e-04 a 1,004e-04, un fattore 4,07. I primi gradini valgono di più perché a pochi bit l'ipotesi del quantizzatore uniforme non regge: molti valori cadono sullo zero e il massimo del campione (4,4 deviazioni standard) fissa un passo largo. È una spiegazione che ricavo dalla forma dei numeri; non ho separato i contributi.

Decibel contro bit per peso0102030402345678bit per pesoSNR (dB)misurato6,02 dB per bit
SNR di un campione gaussiano di 262.144 valori quantizzato con scala sul massimo, da 2 a 8 bit (dati del listato precedente). La retta tratteggiata ha pendenza 6,02 dB per bit e passa per il punto a 8 bit: i punti misurati stanno sopra la retta a pochi bit e la raggiungono a 8.

La regola da portarsi via è questa: ogni bit tolto costa circa sei decibel, e due bit tolti moltiplicano per sedici l'errore quadratico. Per questo il passaggio da 8 a 4 bit, che dimezza i byte, fa scendere l'SNR da 39,97 a 14,81 dB, 25,2 dB in meno: il formato che si sceglie in pratica non è quello con meno bit, ma quello in cui i decibel che restano bastano al compito. Il compito non lo sa dire un tensore sintetico; per misurarlo servono un compito e un insieme di casi, come quelli della lezione sulla valutazione dei sistemi generativi.

Quanto pesa davvero un peso quantizzato

Il nome di un formato dice i bit dei valori e tace il resto: ogni scala che accompagna un gruppo di pesi occupa byte anch'essa. Per vederlo senza ipotesi impacchetto davvero gli interi, due per byte a 4 bit e quattro per byte a 2, li scompatto e verifico con un assert che tornino identici. Poi conto i byte come li conterebbe un file: pesi impacchettati più una scala fp16 (2 byte) per ogni gruppo, per quattro schemi di gruppo.

def impacchetta(q, bit):
    """Porta gli interi con segno a codici senza segno e ne mette 8/bit in ogni byte."""
    off = 2 ** (bit - 1)
    u = (q.reshape(-1) + off).astype(np.uint8)
    per = 8 // bit
    u = u.reshape(-1, per)
    out = np.zeros(u.shape[0], dtype=np.uint8)
    for k in range(per):
        out |= (u[:, k] << (bit * k)).astype(np.uint8)
    return out

def scompatta(b, bit, forma):
    off = 2 ** (bit - 1)
    per = 8 // bit
    maschera = (1 << bit) - 1
    u = np.stack([(b >> (bit * k)) & maschera for k in range(per)], axis=1)
    return (u.astype(np.int32) - off).reshape(forma)

n = W.size
print("pesi:", n, "  float32 = %d byte" % W.astype(np.float32).nbytes)
for bit in (8, 4, 2):
    q = quantizza(W, bit, per_canale=True)[0]
    # con 2 bit simmetrici i valori sono -1, 0, 1: il codice 0 (= -2) non compare mai
    pk = impacchetta(q, bit)
    assert np.array_equal(scompatta(pk, bit, q.shape), q)
    print("%d bit: %d byte impacchettati, ricostruzione identica: sì" % (bit, pk.nbytes))
print()
print("bit  schema di scale         byte scale  byte totali  bit/peso  formula b + 16/g")
for bit in (8, 4, 2):
    for nome, g in (("per tensore", n), ("per canale (512)", 512), ("gruppi da 128", 128), ("gruppi da 32", 32)):
        scale = np.zeros(n // g, dtype=np.float16)         # una scala fp16 per gruppo
        byte = n * bit // 8 + scale.nbytes
        print("%3d  %-22s  %9d  %11d  %8.4f  %8.4f" % (bit, nome, scale.nbytes, byte, 8 * byte / n, bit + 16 / g))

# Output:
# pesi: 262144   float32 = 1048576 byte
# 8 bit: 262144 byte impacchettati, ricostruzione identica: sì
# 4 bit: 131072 byte impacchettati, ricostruzione identica: sì
# 2 bit: 65536 byte impacchettati, ricostruzione identica: sì
#
# bit  schema di scale         byte scale  byte totali  bit/peso  formula b + 16/g
#   8  per tensore                     2       262146    8.0001    8.0001
#   8  per canale (512)             1024       263168    8.0312    8.0312
#   8  gruppi da 128                4096       266240    8.1250    8.1250
#   8  gruppi da 32                16384       278528    8.5000    8.5000
#   4  per tensore                     2       131074    4.0001    4.0001
#   4  per canale (512)             1024       132096    4.0312    4.0312
#   4  gruppi da 128                4096       135168    4.1250    4.1250
#   4  gruppi da 32                16384       147456    4.5000    4.5000
#   2  per tensore                     2        65538    2.0001    2.0001
#   2  per canale (512)             1024        66560    2.0312    2.0312
#   2  gruppi da 128                4096        69632    2.1250    2.1250
#   2  gruppi da 32                16384        81920    2.5000    2.5000

L'impacchettamento è senza perdita (le tre stampe di ricostruzione lo confermano) e i byte coincidono con la formula bit + 16/g, dove g è il numero di pesi che condividono una scala. Con una scala per tutto il tensore il sovraccarico è invisibile, 2 byte su 262.146. Con una scala ogni 32 pesi diventa mezzo bit: a 4 bit si pagano 4,5 bit per peso, e a 8 bit 8,5. Se la scala fosse asimmetrica, ci sarebbe da aggiungere lo zero point per gruppo: nel conto sopra non l'ho incluso, quindi quei valori sono il minimo per la forma simmetrica.

I blocchi di llama.cpp, riscritti

I numeri 4,5 e 8,5 non sono un caso: sono i formati più semplici di llama.cpp. Nel sorgente (ggml-common.h) block_q4_0 contiene una scala fp16 e 16 byte di valori a 4 bit, cioè 18 byte per 32 pesi, e block_q8_0 una scala fp16 e 32 interi a 8 bit, 34 byte; un static_assert nel file fissa le dimensioni. Le funzioni di quantizzazione di riferimento, quantize_row_q4_0_ref e quantize_row_q8_0_ref in ggml-quants.c, le ho lette e riscritte in NumPy: per il Q4_0 la scala è max / -8, dove max è il valore con segno di modulo massimo del blocco, e il codice è min(15, int(x/d + 8.5)).

La scrittura in NumPy conserva due dettagli che in una versione "fatta a occhio" si perdono. Il segno nella scala fa sì che il peso più grande cada esattamente sul livello -8, e permette di usare tutti i 16 codici invece dei 15 della mia versione simmetrica. E il roundf del Q8_0 arrotonda i mezzi lontano da zero, mentre np.round li porta al pari: per questo uso sign · floor(|t| + 0.5). Il confronto è sullo stesso tensore W con gruppi da 32.

# Blocchi nello stile di llama.cpp, riscritti da ggml-quants.c (quantize_row_q4_0_ref / q8_0_ref)
Q4_0 = np.dtype([("d", "<f2"), ("qs", "u1", 16)])     # 2 + 16 byte per 32 pesi
Q8_0 = np.dtype([("d", "<f2"), ("qs", "i1", 32)])     # 2 + 32 byte per 32 pesi
print("byte per blocco da 32 pesi: Q4_0 = %d, Q8_0 = %d  ->  %.2f e %.2f bit/peso" % (
    Q4_0.itemsize, Q8_0.itemsize, 8 * Q4_0.itemsize / 32, 8 * Q8_0.itemsize / 32))

def q4_0(x):
    b = x.reshape(-1, 32).astype(np.float32)
    i = np.abs(b).argmax(axis=1)
    mx = b[np.arange(len(b)), i]                       # il valore CON SEGNO di modulo massimo
    d = (mx / -8).astype(np.float16)
    idv = np.where(d != 0, 1.0 / d.astype(np.float32), 0.0)[:, None]
    xi = np.minimum(15, np.floor(b * idv + 8.5)).astype(np.int32)
    return ((xi - 8) * d.astype(np.float32)[:, None]).reshape(x.shape)

def q8_0(x):
    b = x.reshape(-1, 32).astype(np.float32)
    d = (np.abs(b).max(axis=1) / 127).astype(np.float16)
    idv = np.where(d != 0, 1.0 / d.astype(np.float32), 0.0)[:, None]
    t = b * idv
    q = (np.sign(t) * np.floor(np.abs(t) + 0.5)).astype(np.int32)   # roundf: mezzi lontano da zero
    return (q * d.astype(np.float32)[:, None]).reshape(x.shape)

print()
print("schema                                    bit/peso   SNR dB")
for nome, r, bpw in (
    ("Q8_0 (blocchi da 32, scala fp16)", q8_0(W), 8.5),
    ("mio 8 bit simmetrico, gruppi da 32", quantizza(W.reshape(-1, 32), 8, True)[3].reshape(W.shape), 8.5),
    ("Q4_0 (blocchi da 32, scala fp16)", q4_0(W), 4.5),
    ("mio 4 bit simmetrico, gruppi da 32", quantizza(W.reshape(-1, 32), 4, True)[3].reshape(W.shape), 4.5),
    ("mio 4 bit simmetrico, per canale", quantizza(W, 4, True)[3], 4.0 + 16 / 512),
    ("mio 4 bit simmetrico, per tensore", quantizza(W, 4)[3], 4.0)):
    print("%-40s  %8.4f   %6.2f" % (nome, bpw, metriche(W, r)[2]))

# Output:
# byte per blocco da 32 pesi: Q4_0 = 18, Q8_0 = 34  ->  4.50 e 8.50 bit/peso
#
# schema                                    bit/peso   SNR dB
# Q8_0 (blocchi da 32, scala fp16)            8.5000    45.45
# mio 8 bit simmetrico, gruppi da 32          8.5000    45.45
# Q4_0 (blocchi da 32, scala fp16)            4.5000    21.28
# mio 4 bit simmetrico, gruppi da 32          4.5000    20.24
# mio 4 bit simmetrico, per canale            4.0312    17.37
# mio 4 bit simmetrico, per tensore           4.0000     5.09

Il Q8_0 e il mio 8 bit simmetrico a gruppi da 32 danno lo stesso SNR, 45,45 dB: la lettura del layout e dell'algoritmo è coerente. Il Q4_0 guadagna 1,04 dB sul mio 4 bit simmetrico (21,28 contro 20,24), a parità di byte, per i 16 codici invece di 15. Il confronto che pesa di più è l'ultimo: lo stesso 4 bit con una scala per tensore ha SNR 5,09 dB, con una per riga 17,37 e con una ogni 32 pesi 20,24. Passare da 4,0 a 4,5 bit per peso, mezzo bit di scale, vale 15 dB. È il motivo per cui i formati reali non quantizzano mai "a 4 bit puri".

Le cifre dei modelli veri, attribuite

Per i formati più complessi, le k-quants (Q4_K_M, Q6_K, Q2_K), non rifaccio il conto da zero: la loro struttura a super-blocchi con scale a 6 bit e tensori trattati in modo diverso è nel sorgente, e le misure sui modelli veri sono nella documentazione. Le righe che seguono le ho copiate dalla tabella di tools/quantize/README.md (Llama 3.1 8B: bit per peso e dimensione in GiB). Il listato ne ricava il numero di pesi implicato: se la tabella è coerente, sette formati diversi devono dare lo stesso.

# Righe copiate dalla tabella di tools/quantize/README.md di llama.cpp (Llama 3.1 8B):
# (bit per peso, dimensione in GiB). Da queste due colonne si ricava il numero di pesi.
tabella = {"Q2_K": (3.1593, 2.95), "Q4_K_S": (4.6672, 4.36), "Q4_K_M": (4.8944, 4.58),
           "Q5_K_M": (5.7036, 5.33), "Q6_K": (6.5633, 6.14), "Q8_0": (8.5008, 7.95), "F16": (16.0005, 14.96)}
print("formato   bit/peso  GiB     pesi implicati (miliardi)   errore di chi legge il nome come bit")
for nome, (bpw, gib) in tabella.items():
    pesi = gib * 2 ** 30 * 8 / bpw
    nominale = {"Q2_K": 2, "Q4_K_S": 4, "Q4_K_M": 4, "Q5_K_M": 5, "Q6_K": 6, "Q8_0": 8, "F16": 16}[nome]
    print("%-8s  %7.4f  %5.2f   %.3f                       %+.1f %%" % (nome, bpw, gib, pesi / 1e9, 100 * (nominale - bpw) / bpw))

# Output:
# formato   bit/peso  GiB     pesi implicati (miliardi)   errore di chi legge il nome come bit
# Q2_K       3.1593   2.95   8.021                       -36.7 %
# Q4_K_S     4.6672   4.36   8.025                       -14.3 %
# Q4_K_M     4.8944   4.58   8.038                       -18.3 %
# Q5_K_M     5.7036   5.33   8.027                       -12.3 %
# Q6_K       6.5633   6.14   8.036                       -8.6 %
# Q8_0       8.5008   7.95   8.033                       -5.9 %
# F16       16.0005  14.96   8.031                       -0.0 %

I sette formati implicano fra 8,021 e 8,038 miliardi di pesi: la tabella è coerente, con la dispersione dovuta alle due cifre decimali della dimensione. L'ultima colonna è il motivo per cui il blocco esiste. Chi legge Q4_K_M come "quattro bit" sottostima la dimensione del 18,3%; chi legge Q2_K come "due bit" del 36,7%, perché in quel formato il peso medio vale 3,16 bit. Sono numeri della documentazione, non una mia misura su file GGUF: li ho usati per mostrare un errore di lettura, non per dire che cosa dentro il file è in quale formato.

Gli outlier e il clipping

La scala per canale risolve un problema, quello di righe con ampiezze diverse. Ne resta un altro, più insidioso: pochi pesi molto più grandi degli altri dentro la stessa riga. Con la scala fissata sul massimo, un solo valore anomalo allarga il passo per tutti. Costruisco il caso in due tempi. Prima un tensore omogeneo senza outlier, per vedere se accorciare il righello conviene anche lì; poi lo stesso tensore con 26 pesi (lo 0,01%) portati a 25-40 deviazioni standard.

Il parametro che tocco è clip, già visto: la scala di riferimento è il massimo moltiplicato per il valore, e i pesi che lo superano vengono saturati al livello estremo. Il valore migliore lo cerco su una griglia, per ogni numero di bit. Nel secondo tempo, oltre all'SNR totale, separo l'errore sui pesi normali (l'MSE senza outlier) da quello sugli outlier, perché il clipping sposta l'errore da una parte all'altra e l'SNR totale da solo non lo dice.

rng = np.random.default_rng(7)
H = (0.02 * rng.standard_normal((512, 512))).astype(np.float32)   # un tensore omogeneo, sigma 0,02
sigma = float(H.std())
print("tensore omogeneo senza outlier: scala di riferimento = massimo * clip")
print("bit  clip   SNR dB  (il migliore del gruppo è marcato con *)")
for bit in (8, 4, 2):
    res = [(clip, metriche(H, quantizza(H, bit, clip=clip)[3])[2]) for clip in (1.0, 0.8, 0.6, 0.5, 0.4, 0.3, 0.2)]
    best = max(res, key=lambda t: t[1])
    print("%3d  " % bit + "   ".join("%.1f:%.2f%s" % (c, s, "*" if (c, s) == best else "") for c, s in res))
print()
Ho = H.copy().reshape(-1)
posizioni = rng.choice(Ho.size, 26, replace=False)                # 0,01% dei pesi
Ho[posizioni] = rng.choice([-1, 1], 26) * rng.uniform(25, 40, 26) * sigma
Ho = Ho.reshape(H.shape)
mask = np.zeros(Ho.size, bool); mask[posizioni] = True; mask = mask.reshape(Ho.shape)
print("con %d outlier (da 25 a 40 sigma): |w| massimo = %.1f sigma" % (mask.sum(), np.abs(Ho).max() / sigma))
print("4 bit simmetrico, per tensore")
print("clip     SNR dB   MSE senza outlier   errore medio sugli outlier   pesi a zero")
for clip in (1.0, 0.5, 0.25, 0.15, 0.10):
    r = quantizza(Ho, 4, clip=clip)[3]
    e = Ho - r
    print("%-6.2f  %6.2f   %.3e           %.4f                       %5.1f %%" % (
        clip, metriche(Ho, r)[2], np.mean(e[~mask] ** 2), np.abs(e[mask]).mean(), 100 * np.mean(r == 0)))
print("(riferimento senza outlier, clip 1: SNR %.2f dB, pesi a zero %.1f %%)" % (
    metriche(H, quantizza(H, 4)[3])[2], 100 * np.mean(quantizza(H, 4)[3] == 0)))
print()
print("4 bit simmetrico con gli stessi outlier, scale più locali (clip 1)")
for nome, r in (("per canale (512 scale)", quantizza(Ho, 4, True)[3]),
                ("gruppi da 128", quantizza(Ho.reshape(-1, 128), 4, True)[3].reshape(Ho.shape)),
                ("gruppi da 32", quantizza(Ho.reshape(-1, 32), 4, True)[3].reshape(Ho.shape))):
    print("%-22s SNR %.2f dB   pesi a zero %.1f %%" % (nome, metriche(Ho, r)[2], 100 * np.mean(r == 0)))
print()
print("potenza del segnale con outlier / senza: %.3f" % (np.mean(Ho ** 2) / np.mean(H ** 2)))

# Output:
# tensore omogeneo senza outlier: scala di riferimento = massimo * clip
# bit  clip   SNR dB  (il migliore del gruppo è marcato con *)
#   8  1.0:39.74   0.8:40.63*   0.6:29.62   0.5:22.96   0.4:17.05   0.3:11.86   0.2:7.32
#   4  1.0:14.58   0.8:16.52   0.6:18.68   0.5:18.68*   0.4:16.05   0.3:11.69   0.2:7.30
#   2  1.0:0.33   0.8:0.98   0.6:2.50   0.5:3.80   0.4:5.54   0.3:7.08*   0.2:6.39
#
# con 26 outlier (da 25 a 40 sigma): |w| massimo = 39.4 sigma
# 4 bit simmetrico, per tensore
# clip     SNR dB   MSE senza outlier   errore medio sugli outlier   pesi a zero
# 1.00      0.50   3.930e-04           0.0263                        99.5 %
# 0.50      2.57   2.370e-04           0.2505                        84.1 %
# 0.25      7.07   6.605e-05           0.4475                        52.0 %
# 0.15      9.28   2.380e-05           0.5263                        32.7 %
# 0.10     10.11   1.053e-05           0.5657                        22.2 %
# (riferimento senza outlier, clip 1: SNR 14.58 dB, pesi a zero 25.4 %)
#
# 4 bit simmetrico con gli stessi outlier, scale più locali (clip 1)
# per canale (512 scale) SNR 12.50 dB   pesi a zero 22.1 %
# gruppi da 128          SNR 16.41 dB   pesi a zero 17.0 %
# gruppi da 32           SNR 19.52 dB   pesi a zero 13.5 %
#
# potenza del segnale con outlier / senza: 1.105

Anche senza outlier accorciare il righello conviene, e dipende dai bit: a 8 bit l'ottimo della griglia è 0,8 (40,63 dB contro 39,74, meno di un decibel), a 4 bit sta fra 0,6 e 0,5 (18,68 dB contro 14,58, quattro decibel) e a 2 bit è 0,3 (7,08 dB contro 0,33). Meno bit ci sono, più conviene sacrificare le code per infittire il centro, dove sta la massa dei pesi. Questo vale per pesi a campana; non l'ho verificato su distribuzioni con code pesanti.

Con gli outlier il quadro si rovescia. Senza clipping, a 4 bit il 99,5% dei pesi va a zero (il passo è 5,6 deviazioni standard, e sotto 2,8 il peso si arrotonda a zero) e l'SNR scende a 0,50 dB: 26 pesi hanno distrutto la rappresentazione di 262.118. Con clip 0,10 l'SNR risale a 10,11 dB e i pesi a zero scendono al 22,2%, ma gli outlier vengono saturati: l'errore medio su di loro passa da 0,0263 a 0,5657, cioè perdono quasi tutto il loro valore. Se quei 26 pesi contano per l'uscita, il clipping ha tolto proprio quelli che contavano. Lo scambio non si decide su un tensore: si decide su un compito.

Le scale più locali evitano di scegliere. Con 512 scale (una per riga) l'SNR con gli outlier è 12,50 dB, con gruppi da 128 è 16,41 e con gruppi da 32 è 19,52, più del riferimento senza outlier con una scala sola (14,58). Il confronto non è esatto, perché gli outlier alzano la potenza del segnale di un fattore 1,105 (ultima riga dell'uscita), ma l'ordine di grandezza regge: l'outlier infetta soltanto il suo gruppo, e un gruppo da 32 pesi è piccolo. La stessa idea sta dietro ai formati a blocchi, e il prezzo sono i mezzi bit per peso del conto di prima.

Dai pesi all'uscita di uno strato

Un errore del 12% sui pesi, che cosa fa all'uscita? Una spiegazione che si sente spesso è che "gli errori si compensano", perché ogni uscita somma migliaia di prodotti. La misuro su uno strato lineare 256×256 con ingressi gaussiani indipendenti: confronto l'errore relativo sui pesi con quello sull'uscita y = X · Aᵀ, ottenuto con gli stessi pesi quantizzati. Poi quantizzo anche le attivazioni, W8A8, calcolando la moltiplicazione con interi a 32 bit e riscalando: è quello che fa l'hardware con le istruzioni intere.

La seconda parte del listato riproduce, in piccolo, il fenomeno che il paper LLM.int8() descrive nei modelli grandi: pochi canali di ingresso con ampiezza molto maggiore degli altri, che dominano il risultato. Moltiplico per 40 quattro colonne di X e misuro quanto soffre il W8A8 con una scala sola per le attivazioni. La riparazione è la decomposizione descritta nell'abstract del paper, riscritta da me in poche righe: le colonne con un massimo oltre una soglia (6, valore mio, scelto perché le colonne normali non lo raggiungono) restano in virgola mobile, e il resto passa in 8 bit.

rng = np.random.default_rng(11)
din, dout, nx = 256, 256, 2048
A = (rng.standard_normal((dout, din)) * np.exp(0.5 * rng.standard_normal((dout, 1))) / np.sqrt(din)).astype(np.float32)
X = rng.standard_normal((nx, din)).astype(np.float32)
Y = X @ A.T
def rel(y, yq): return float(np.linalg.norm(y - yq) / np.linalg.norm(y))
print("strato lineare %dx%d, %d ingressi gaussiani; errore relativo = ||y - yq|| / ||y||" % (dout, din, nx))
print("bit  scala        errore sui pesi   errore sull'uscita   rapporto")
for bit in (8, 4, 2):
    for pc in (False, True):
        r = quantizza(A, bit, pc)[3]
        ew, ey = rel(A, r), rel(Y, X @ r.T)
        print("%3d  %-11s  %.5f           %.5f              %.3f" % (bit, "per canale" if pc else "per tensore", ew, ey, ey / ew))
print()
# W8A8: anche le attivazioni a 8 bit, matmul intera in int32 e riscalatura
def w8a8(X, A, soglia=None):
    qw, sw, _, _ = quantizza(A, 8, True)                          # scala per canale di uscita
    if soglia is None:
        mo = np.zeros(X.shape[1], bool)
    else:
        mo = np.abs(X).max(axis=0) > soglia                        # colonne "outlier", tenute in float
    qx, sx, _, _ = quantizza(X[:, ~mo], 8)                         # una scala per tutta la matrice
    y = (qx.astype(np.int32) @ qw[:, ~mo].astype(np.int32).T) * sx * sw.T
    if mo.any():
        y = y + X[:, mo] @ (qw[:, mo] * sw).T
    return y, int(mo.sum())
Xo = X.copy()
canali = rng.choice(din, 4, replace=False)
Xo[:, canali] *= 40                                                # 4 canali di ingresso con ampiezza 40 volte
Yo = Xo @ A.T
print("attivazioni normali:     solo pesi 8 bit %.5f   W8A8 %.5f" % (rel(Y, X @ quantizza(A, 8, True)[3].T), rel(Y, w8a8(X, A)[0])))
print("4 canali a 40x:          solo pesi 8 bit %.5f   W8A8 %.5f" % (rel(Yo, Xo @ quantizza(A, 8, True)[3].T), rel(Yo, w8a8(Xo, A)[0])))
y3, n3 = w8a8(Xo, A, soglia=6.0)
print("4 canali a 40x, soglia 6 (colonne in float: %d):  W8A8 %.5f" % (n3, rel(Yo, y3)))

# Output:
# strato lineare 256x256, 2048 ingressi gaussiani; errore relativo = ||y - yq|| / ||y||
# bit  scala        errore sui pesi   errore sull'uscita   rapporto
#   8  per tensore  0.02579           0.02571              0.997
#   8  per canale   0.00697           0.00696              0.998
#   4  per tensore  0.44769           0.44644              0.997
#   4  per canale   0.12691           0.12626              0.995
#   2  per tensore  0.99035           0.99025              1.000
#   2  per canale   0.80964           0.80732              0.997
#
# attivazioni normali:     solo pesi 8 bit 0.00696   W8A8 0.01321
# 4 canali a 40x:          solo pesi 8 bit 0.00682   W8A8 0.07029
# 4 canali a 40x, soglia 6 (colonne in float: 4):  W8A8 0.00714

Il rapporto fra errore sull'uscita e errore sui pesi vale da 0,995 a 1,000 in tutte le sei righe: gli errori non si compensano, si trasferiscono uno a uno. Con ingressi indipendenti e di uguale ampiezza ogni colonna dei pesi contribuisce in proporzione al proprio quadrato, e l'errore relativo dell'uscita eguaglia quello dei pesi. A 4 bit con una scala sola l'uscita ha il 44,6% di errore, a 4 bit per canale il 12,6%, a 8 bit per canale lo 0,70%. Nei modelli veri gli ingressi non sono indipendenti né di uguale ampiezza, e l'abstract di AWQ sostiene proprio che non tutti i pesi contano allo stesso modo (proteggerne l'1% riduce molto l'errore, secondo gli autori): questo conto, quindi, è il caso più semplice, non una previsione.

La seconda metà dice qualcosa di più pratico. Con attivazioni normali il W8A8 sbaglia l'1,32%, contro lo 0,70% dei soli pesi a 8 bit: quantizzare anche le attivazioni raddoppia l'errore. Con quattro canali a 40 volte l'ampiezza il solo-pesi non si muove (0,68%), mentre il W8A8 sale al 7,03%, oltre cinque volte di più: la scala unica delle attivazioni si allarga per i canali anomali e schiaccia gli altri. Tenere quelle quattro colonne in virgola mobile riporta l'errore allo 0,71%. Il costo è un caso speciale nel kernel, e le quattro colonne su 256 sono l'1,6%; le percentuali dei modelli veri le riporta il paper (più del 99,9% dei valori resta in 8 bit), non questo toy.

Una rete piccola, addestrata

Un tensore dice quanto sbaglia la rappresentazione, non se il risultato serve. Per avvicinarmi a una domanda di accuratezza addestro una rete minuscola: 32 ingressi, 64 neuroni nascosti con ReLU, 10 classi, 2.762 parametri, su dati sintetici (dieci nuvole gaussiane sovrapposte, seme fisso, 4.000 esempi di addestramento e 2.000 di prova). L'addestramento è una discesa del gradiente scritta a mano, 300 passi con il batch intero. Poi quantizzo i due pesi (non i bias) a 8, 4, 3 e 2 bit, con una scala per tensore o per canale, senza riaddestrare, e conto le risposte giuste sul test.

Aggiungo una seconda colonna, quante predizioni coincidono con quelle della rete a virgola mobile, perché due reti possono avere la stessa accuratezza sbagliando su esempi diversi. L'ultima riga dell'uscita dà l'errore standard dell'accuratezza con 2.000 esempi: serve a decidere quali differenze sono rumore. Il dominio è un giocattolo, scelto perché si addestra in secondi su CPU e perché ogni cifra si rifà; non è una misura su un modello linguistico.

rng = np.random.default_rng(5)
K, D, H1, NTR, NTE = 10, 32, 64, 4000, 2000
centri = rng.standard_normal((K, D)) * 1.1
def campione(n):
    y = rng.integers(0, K, n)
    return (centri[y] + rng.standard_normal((n, D)) * 1.6).astype(np.float32), y
Xtr, ytr = campione(NTR); Xte, yte = campione(NTE)
def softmax(z):
    e = np.exp(z - z.max(1, keepdims=True)); return e / e.sum(1, keepdims=True)
W1 = (rng.standard_normal((H1, D)) * np.sqrt(2 / D)).astype(np.float32); b1 = np.zeros(H1, np.float32)
W2 = (rng.standard_normal((K, H1)) * np.sqrt(2 / H1)).astype(np.float32); b2 = np.zeros(K, np.float32)
def avanti(X, W1, b1, W2, b2):
    h = np.maximum(X @ W1.T + b1, 0); return h, h @ W2.T + b2
for epoca in range(300):                                           # discesa del gradiente, batch intero
    h, z = avanti(Xtr, W1, b1, W2, b2)
    g = softmax(z); g[np.arange(NTR), ytr] -= 1; g /= NTR
    gW2, gb2 = g.T @ h, g.sum(0)
    gh = (g @ W2) * (h > 0)
    gW1, gb1 = gh.T @ Xtr, gh.sum(0)
    for P, G in ((W1, gW1), (b1, gb1), (W2, gW2), (b2, gb2)): P -= 0.5 * G
_, zf = avanti(Xte, W1, b1, W2, b2)
pf = zf.argmax(1)
print("rete %d-%d-%d, %d parametri; float32: %d / %d giuste sul test (%.1f %%)" % (
    D, H1, K, W1.size + b1.size + W2.size + b2.size, (pf == yte).sum(), NTE, 100 * (pf == yte).mean()))
print()
print("bit  scala         giuste   come il float   errore relativo sui logit")
for bit in (8, 4, 3, 2):
    for pc in (False, True):
        q1, q2 = quantizza(W1, bit, pc)[3], quantizza(W2, bit, pc)[3]
        _, zq = avanti(Xte, q1, b1, q2, b2)
        pq = zq.argmax(1)
        print("%3d  %-11s  %5d    %5d           %.4f" % (
            bit, "per canale" if pc else "per tensore", (pq == yte).sum(), (pq == pf).sum(),
            np.linalg.norm(zf - zq) / np.linalg.norm(zf)))
p = (pf == yte).mean()
print()
print("errore standard della precisione con %d esempi: %.2f punti percentuali" % (NTE, 100 * np.sqrt(p * (1 - p) / NTE)))

# Output:
# rete 32-64-10, 2762 parametri; float32: 1878 / 2000 giuste sul test (93.9 %)
#
# bit  scala         giuste   come il float   errore relativo sui logit
#   8  per tensore   1879     1999           0.0079
#   8  per canale    1878     2000           0.0046
#   4  per tensore   1870     1946           0.1483
#   4  per canale    1877     1969           0.0833
#   3  per tensore   1792     1838           0.3371
#   3  per canale    1838     1913           0.2328
#   2  per tensore    621      614           0.9595
#   2  per canale    1521     1553           0.7381
#
# errore standard della precisione con 2000 esempi: 0.54 punti percentuali

La rete a virgola mobile fa 1.878 su 2.000. L'errore standard è 0,54 punti percentuali, cioè circa 11 esempi: tutto ciò che sta entro una dozzina di risposte è indistinguibile da zero. A 8 bit (1.879 e 1.878) e a 4 bit per canale (1.877) la quantizzazione non cambia l'accuratezza, e a 4 bit per tensore perde 8 risposte (1.870), ancora dentro il rumore. La colonna accanto mostra però quello che l'accuratezza nasconde: a 4 bit per canale, 31 predizioni su 2.000 sono diverse da quelle del float, e il pareggio dell'accuratezza è il risultato di cambi che si bilanciano.

Il dirupo comincia sotto i 4 bit. A 3 bit per tensore la rete perde 86 risposte (1.792), 8 volte il rumore, e per canale 40 (1.838). A 2 bit per tensore ne azzecca 621, il 31%: poco più del triplo del caso (10 classi, circa il 10%), e con la scala per canale 1.521, il 76%. L'errore relativo sui logit racconta lo stesso: 0,0046 a 8 bit per canale, 0,083 a 4, 0,23 a 3, 0,74 a 2. I numeri sono di una rete, un seme, un dominio sintetico; il punto non è la soglia, che nei modelli veri sarà altrove, ma la forma: un piano quasi piatto fino a 4 bit, poi una caduta.

La quantizzazione dinamica di PyTorch

PyTorch ha una quantizzazione già pronta per i livelli nn.Linear: torch.ao.quantization.quantize_dynamic. La documentazione ufficiale la descrive come conversione di un modello float in uno quantizzato "dynamic (i.e. weights-only)", con dtype torch.qint8 per impostazione, un insieme di tipi di modulo da sostituire e inplace=False per non modificare l'originale. Ho verificato in questa versione (2.12.0) che l'API esiste e funziona, ma la chiamata emette un DeprecationWarning: il testo dell'avviso dice che il modulo torch.ao.quantization è deprecato e verrà rimosso in 2.10, e rimanda a torchao. L'avviso e il fatto che su 2.12 la funzione giri convivono: è una trappola da conoscere prima di scrivere codice che vive a lungo.

Il listato costruisce una rete di tre strati (1.024, 1.024, 10 neuroni), la quantizza, serializza i due state_dict in memoria per misurarne i byte, ispeziona il peso del primo strato e confronta l'uscita. Poi rifà a mano lo stesso calcolo con i miei pesi a 8 bit simmetrici per tensore, prima con le attivazioni in virgola mobile, poi quantizzate con 256 e con 128 livelli. Le ultime due prove nascono da un sospetto: con soli pesi a 8 bit mi aspetto un errore dell'uscita molto più piccolo di quello che PyTorch produce.

import io, os, warnings
os.environ["CUDA_VISIBLE_DEVICES"] = ""
import numpy as np, torch
from torch import nn
torch.set_num_threads(2)
torch.manual_seed(2398)
print("torch", torch.__version__, "- motore di quantizzazione:", torch.backends.quantized.engine)

modello = nn.Sequential(nn.Linear(1024, 1024), nn.ReLU(), nn.Linear(1024, 1024), nn.ReLU(), nn.Linear(1024, 10)).eval()
with warnings.catch_warnings(record=True) as avvisi:
    warnings.simplefilter("always")
    qm = torch.ao.quantization.quantize_dynamic(modello, {nn.Linear}, dtype=torch.qint8)
print("avvisi emessi dalla chiamata:", sorted({a.category.__name__ for a in avvisi}))

def byte_serializzati(m):
    buf = io.BytesIO(); torch.save(m.state_dict(), buf); return buf.getbuffer().nbytes

bf, bq = byte_serializzati(modello), byte_serializzati(qm)
print("float32 : %d byte serializzati (state_dict)" % bf)
print("qint8   : %d byte serializzati (state_dict), %.2f volte meno" % (bq, bf / bq))
w, b = qm[0]._packed_params._weight_bias()
print("peso quantizzato:", w.dtype, "schema", w.qscheme(), "scala %.3e" % w.q_scale(), "zero point", w.q_zero_point())

x = torch.randn(256, 1024)
with torch.no_grad():
    y, yq = modello(x).numpy(), qm(x).numpy()
rel = lambda a, b: float(np.linalg.norm(a - b) / np.linalg.norm(a))
print("errore relativo dell'uscita, qint8 di PyTorch contro float: %.5f" % rel(y, yq))

def pesi8(W):                                    # 8 bit simmetrici, una scala per tensore
    s = np.abs(W).max() / 127
    return np.clip(np.round(W / s), -127, 127) * s
def attiv(h, livelli):                           # attivazioni: affine per tensore, con `livelli` livelli
    lo, hi = min(h.min(), 0), max(h.max(), 0)
    s = (hi - lo) / (livelli - 1); z = np.round(-lo / s)
    return (np.clip(np.round(h / s) + z, 0, livelli - 1) - z) * s
def rete(livelli=None):
    h = x.numpy()
    for l in modello:
        if isinstance(l, nn.Linear):
            h = (h if livelli is None else attiv(h, livelli)) @ pesi8(l.weight.detach().numpy()).T + l.bias.detach().numpy()
        else:
            h = np.maximum(h, 0)
    return h
print("a mano, solo pesi a 8 bit:                       %.5f" % rel(y, rete()))
print("a mano, pesi a 8 bit e attivazioni a 256 livelli: %.5f" % rel(y, rete(256)))
print("a mano, pesi a 8 bit e attivazioni a 128 livelli: %.5f" % rel(y, rete(128)))

# Output:
# torch 2.12.0+cu130 - motore di quantizzazione: x86
# avvisi emessi dalla chiamata: ['DeprecationWarning']
# float32 : 8440469 byte serializzati (state_dict)
# qint8   : 2120255 byte serializzati (state_dict), 3.98 volte meno
# peso quantizzato: torch.qint8 schema torch.per_tensor_affine scala 2.451e-04 zero point 0
# errore relativo dell'uscita, qint8 di PyTorch contro float: 0.02677
# a mano, solo pesi a 8 bit:                       0.00665
# a mano, pesi a 8 bit e attivazioni a 256 livelli: 0.01451
# a mano, pesi a 8 bit e attivazioni a 128 livelli: 0.02654

La dimensione cala di 3,98 volte (da 8.440.469 a 2.120.255 byte): non quattro esatto, perché i bias restano float32 e il file ha dei metadati. Il peso è qint8 con schema per tensore e zero point 0, cioè simmetrico, come il mio. L'errore dell'uscita è 0,02677, quattro volte quello che ottengo a mano con i soli pesi (0,00665). Non coincide con il documentato "weights-only"; coincide, invece, con il mio calcolo che quantizza anche le attivazioni a 128 livelli (0,02654). È una deduzione dai numeri: non ho letto il kernel, e la corrispondenza a 128 livelli si spiegherebbe con un'attivazione a 7 bit di precisione ("reduce range"), che è un'ipotesi, non un fatto verificato. Il dato solido è l'altro: l'uscita di quantize_dynamic si discosta dal float del 2,7%, e non del 0,7% che il nome "solo pesi" farebbe pensare.

Resta il tempo. Il listato che segue esegue il modello float e quello quantizzato su un batch da 256, sette volte per giro, per tre giri, e stampa minimo e massimo di ciascun giro. È l'unico blocco che non riproduce la stessa uscita da una esecuzione all'altra, per costruzione.

import os, time
os.environ["CUDA_VISIBLE_DEVICES"] = ""
import warnings; warnings.simplefilter("ignore")
import torch
from torch import nn
torch.set_num_threads(2); torch.manual_seed(2398)
m = nn.Sequential(nn.Linear(1024, 1024), nn.ReLU(), nn.Linear(1024, 1024), nn.ReLU(), nn.Linear(1024, 10)).eval()
q = torch.ao.quantization.quantize_dynamic(m, {nn.Linear}, dtype=torch.qint8)
x = torch.randn(256, 1024)
def tempi(f, n=7):
    with torch.no_grad():
        f(x); t = []
        for _ in range(n):
            a = time.perf_counter(); f(x); t.append((time.perf_counter() - a) * 1e3)
    return min(t), max(t)
for giro in range(3):
    f32, i8 = tempi(m), tempi(q)
    print("giro %d  float32 %5.1f - %5.1f ms   qint8 %5.1f - %5.1f ms" % (giro + 1, *f32, *i8))

# Output (cambia a ogni esecuzione, macchina carica):
# giro 1  float32  47.1 -  85.9 ms   qint8  26.3 -  47.9 ms
# giro 2  float32  31.1 -  84.9 ms   qint8   7.3 -  49.9 ms
# giro 3  float32  10.1 -  16.3 ms   qint8  13.4 -  42.1 ms

Dei tre giri, due vedono il modello quantizzato più veloce (minimi 26,3 e 7,3 ms contro 47,1 e 31,1) e uno no (13,4 contro 10,1 ms): con la macchina carica, e con una scala di questo ordine, non è una misura di velocità. Quello che si può dire è che il vantaggio, se c'è, è dello stesso ordine della dispersione, e che una misura seria richiede una macchina libera, più ripetizioni e il confronto sul modello vero. Il vantaggio di memoria, invece, è un conto e non una misura del tempo: 3,98 volte meno byte.

Dai tensori ai modelli veri: schemi da adattare

Quanto sopra si riferisce a pesi che ho generato io. Per un modello vero i comandi sono altri, e qui non li ho eseguiti: richiedono pesi che non ho scaricato. Li riporto perché sono il punto di arrivo della lezione. Il primo è il comando di llama-quantize di llama.cpp, preso dalla documentazione: converte un GGUF in 16 bit nel formato scelto, qui Q4_K_M. È uno schema da adattare, non eseguito: i nomi dei file sono quelli dell'esempio ufficiale, e il binario si ottiene compilando il progetto.

# schema da adattare, NON eseguito qui (richiede un GGUF in 16 bit e llama.cpp compilato)
./build/bin/llama-quantize gemma-4-E2B-it-bf16.gguf gemma-4-E2B-it-Q4_K_M.gguf Q4_K_M

Il secondo è la quantizzazione al caricamento con bitsandbytes, esposta da Transformers tramite BitsAndBytesConfig. Nella documentazione di Hugging Face, load_in_4bit=True abilita i 4 bit e bnb_4bit_quant_type="nf4" sceglie il tipo NF4, descritto come adattato a pesi inizializzati da una distribuzione normale; per l'inferenza la scelta del tipo, dicono, non ha un grande impatto. Il tipo di dato viene dal paper QLoRA, che lo definisce "information theoretically optimal for normally distributed weights". Anche questo è uno schema da adattare, non eseguito: model_id è un segnaposto per un modello a tua scelta, e serve una GPU supportata.

# schema da adattare, NON eseguito qui (richiede una GPU supportata e i pesi del modello)
from transformers import AutoModelForCausalLM, BitsAndBytesConfig

nf4_config = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4")
model_nf4 = AutoModelForCausalLM.from_pretrained(model_id, dtype="auto", quantization_config=nf4_config)

Gli altri nomi che incontrerai hanno un paper ciascuno, e sono descritti da chi li ha scritti. GPTQ è una quantizzazione dei pesi "one-shot" basata su informazione approssimata del secondo ordine: gli autori dichiarano di quantizzare modelli da 175 miliardi di parametri in circa quattro ore di GPU, a 3 o 4 bit per peso, con degrado trascurabile rispetto all'originale. AWQ, già citato, protegge i canali "salienti" scalandoli, e li sceglie guardando la distribuzione delle attivazioni, non dei pesi. LLM.int8() isola le dimensioni anomale in una moltiplicazione a 16 bit. Sono affermazioni degli autori sui loro modelli e sui loro benchmark: non le ho riprodotte, e non le presento come mie misure.

Che cosa non ho misurato

I confini di ciò che questa lezione sa dire vanno scritti, perché i numeri che hai letto sono veri e piccoli. Non so quanta memoria occupi un modello vero in esecuzione, perché oltre ai pesi serve la cache chiave-valore, che cresce con il contesto e non dipende dal formato dei pesi, più il grafo di calcolo. Non so quanti token al secondo faccia: dipende dalla banda della memoria della tua macchina e dai kernel disponibili, e la documentazione di llama.cpp riporta per ciascun formato tempi di elaborazione e di generazione, che non ho riprodotto e che non riporto. Non so quanta qualità si perda su un compito vero: l'accuratezza sul giocattolo ha una forma, non una soglia.

Quello che questa lezione dimostra, invece, regge su qualunque modello: i byte si calcolano (bit + 16/g per peso), l'errore cala di sei decibel per bit, gli outlier decidono quanto vale la scala, e il costo vero si misura sul compito, con un insieme di casi e un intervallo di confidenza, come nella lezione sul RAG da zero si misurava un recupero. I pesi che quantizzi in un modello vero sono le matrici di proiezione dell'attenzione e dei blocchi feed-forward, quelle che la lezione sul Transformer da zero costruisce con 27.253 parametri.

Trade-off

  • Precisione contro memoria. Ogni bit tolto costa circa sei decibel; a 4 bit con scale locali la rete piccola non cambia accuratezza, a 3 e 2 la perde. Mitigazione: scendere un gradino alla volta misurando sul proprio compito.
  • Scale locali contro byte. Un gruppo da 32 pesi aggiunge mezzo bit per peso e vale, nel nostro tensore, 15 dB a 4 bit rispetto alla scala unica. Mitigazione: scegliere la dimensione del gruppo guardando la formula bit + 16/g prima di guardare l'SNR.
  • Clipping contro outlier. Accorciare il righello migliora il centro e distrugge le code; se le code contano, si perde proprio quello. Mitigazione: scale per gruppo, o tenere gli outlier in precisione maggiore, come fa LLM.int8().
  • Solo pesi contro pesi e attivazioni. Quantizzare le attivazioni accelera l'aritmetica intera e raddoppia l'errore nel toy, fino a moltiplicarlo per cinque con canali anomali. Mitigazione: partire dai soli pesi.

Le quattro righe sono quattro modi di dire la stessa cosa: la quantizzazione sposta il costo da un posto all'altro, e il posto migliore dipende da che cosa il tuo compito non tollera. Se i byte sono il vincolo, si scelgono le scale locali e si paga mezzo bit; se la qualità sulle code è il vincolo, si lasciano gli outlier a parte. Nessuna delle quattro scelte è sbagliata in sé: lo diventa quando la si fa senza avere misurato.

Le trappole in cui si cade davvero

  • Stimare la dimensione dal nome del formato. Sintomo: "Q4 vuol dire mezzo byte per parametro" e un modello che non entra. Causa: scale e tensori promossi di precisione portano Q4_K_M a 4,8944 bit per peso. Rimedio: pesi × bit per peso / 8, con i bit per peso della documentazione o del file.
  • Giudicare dall'accuratezza e basta. Sintomo: accuratezza uguale e comportamento diverso. Causa: nella rete di prova, a 4 bit per canale, 31 predizioni su 2.000 cambiano e si compensano. Rimedio: guardare anche quante risposte coincidono con il modello originale.
  • Fidarsi della descrizione di un'API. Sintomo: quantize_dynamic descritto come "solo pesi" e un errore dell'uscita quattro volte più grande del previsto. Causa: probabile quantizzazione delle attivazioni a runtime. Rimedio: misurare l'errore dell'uscita contro il float prima di fidarsi.
  • Fidarsi di un'API deprecata. Sintomo: DeprecationWarning ignorato. Causa: il modulo è annunciato come rimosso in 2.10 ed è ancora presente in 2.12. Rimedio: leggere l'avviso e decidere se migrare a torchao prima di scriverci sopra altro codice.
  • Tagliare gli outlier perché l'SNR sale. Sintomo: SNR migliore e uscita peggiore. Causa: il clipping riduce l'errore sul centro e lo sposta sulle code. Rimedio: misurare l'errore sull'uscita, non solo sui pesi.

In comune, queste cinque trappole hanno un'abitudine: leggere un numero o un nome come se fosse la cosa che descrive. Il bit del nome non è il bit del file, la parola "pesi" non dice che cosa viene quantizzato, e l'SNR dei pesi non è l'errore dell'uscita. La difesa è la stessa di tutta la lezione: un conto che puoi rifare, accanto a ogni affermazione.

Esercizi

Esercizio 1

I byte di una matrice

Una matrice 4096×4096 viene quantizzata a 4 bit simmetrici con una scala fp16 per gruppo. Calcola i byte totali con gruppi da 64 pesi e con una scala per riga, e la differenza fra i due.

Mostra la soluzione

I pesi sono 4096 · 4096 = 16.777.216. Con gruppi da 64 i bit per peso sono 4 + 16/64 = 4,25, quindi 16.777.216 · 4,25 / 8 = 8.912.896 byte. Con una scala per riga i valori occupano 16.777.216 · 4 / 8 = 8.388.608 byte, più 4096 · 2 = 8192 byte di scale: 8.396.800. La differenza è 516.096 byte, circa 0,49 MiB: il prezzo di scale più locali su una singola matrice.

Esercizio 2

Due bit in meno

Passando da 8 a 6 bit su un campione gaussiano con la scala sul massimo, quanto prevede la regola dei sei decibel per bit? Confrontala con la tabella del campione gaussiano.

Mostra la soluzione

Due bit valgono 2 · 6,02 = 12,04 dB, cioè un fattore 4² = 16 sull'errore quadratico. La tabella dà 39,97 - 27,73 = 12,24 dB e un rapporto fra gli MSE di 1,681e-03 / 1,004e-04 ≈ 16,7: la regola sbaglia di due decimi di decibel, e sottostima il fattore di circa il 5% (16 contro 16,7), coerente con il guadagno per bit che tende a 6,02 da sopra.

Esercizio 3

L'outlier che azzera il tensore

Un tensore gaussiano con deviazione standard 0,02 contiene un peso che porta il massimo a 0,8. Con 4 bit simmetrici e una scala sola, quanto vale il passo, sotto quale modulo un peso va a zero e quale frazione dei pesi gaussiani cade lì?

Mostra la soluzione

Il passo è 0,8 / 7 ≈ 0,1143. Un peso si arrotonda a zero se il suo modulo è sotto metà passo, 0,0571, cioè 2,857 deviazioni standard. La frazione di una gaussiana entro ±2,857 σ è erf(2,857 / √2) ≈ 0,9957: il 99,6% dei pesi diventa zero, che è l'ordine di grandezza del 99,5% misurato nel tensore con gli outlier.

Domande frequenti

Q4_K_M vale davvero 4 bit per peso?

No. Nella tabella della documentazione di llama.cpp per Llama 3.1 8B vale 4,8944 bit per peso, perché ogni blocco porta con sé le scale e perché alcuni tensori sono salvati con più bit. Il nome indica la famiglia del formato, non la sua dimensione: per la dimensione servono i bit per peso, che si leggono nella tabella o si ricavano dal file.

Conviene una scala per tensore o una per canale?

Per canale, quando i pesi hanno ampiezze diverse da riga a riga. Nel tensore di prova guadagna 13,2 dB a 8 bit e 12,3 dB a 4 bit, al costo di due byte per riga (1.024 byte su 262.144 pesi). Con una sola ampiezza per tutte le righe il guadagno sparisce: va misurato sui propri pesi.

Quantizzare i pesi basta a risparmiare memoria?

Riduce quella dei pesi, che i byte contati sopra rendono certa: 3,98 volte meno nella rete di prova con qint8. Non riduce da sola la cache chiave-valore né i buffer di calcolo di un modello in esecuzione, che questa lezione non ha misurato.

Che cosa sai fare adesso

Sai quantizzare un tensore a più bit, con una scala per tensore, per riga o per gruppo, e sai misurare l'errore con MSE, errore massimo e SNR. Sai calcolare i byte di un formato con bit + 16/g, verificarli impacchettando gli interi e riconoscere perché Q4_K_M non vale quattro bit. Sai prevedere che cosa fa un outlier a una scala unica, scegliere un clip su una griglia e capire che cosa il clipping sacrifica. Sai misurare come l'errore dei pesi diventa errore dell'uscita di uno strato e di una rete, e quanto vale la differenza fra quantizzare i soli pesi e anche le attivazioni. E sai che cosa non hai ancora: la qualità di un modello vero su un compito vero, che si misura con un insieme di casi, e non con un tensore.

Nella stessa serie trovi anche ML-Ops: dal notebook al servizio (articolo successivo).

Fonti

Le nove fonti esterne sono primarie e le ho aperte il 2026-11-12 e ho letto i passi citati: nel README e nei sorgenti di llama.cpp la tabella, le strutture e le due funzioni di quantizzazione; negli abstract arXiv le affermazioni attribuite agli autori; nella documentazione di Hugging Face e di PyTorch gli esempi e la descrizione di quantize_dynamic. Delle cifre dei paper riporto solo quelle che compaiono nei loro abstract, attribuite agli autori. L'ultima voce dichiara da dove vengono le cifre misurate: una misura fatta in questo repository si cita con il programma che la produce.

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