SLM · Concetti fondamentali

Vocabolario, Embedding e Parametri

Cosa sa il modello, come lo rappresenta, e perché "50 milioni di parametri" è una misura concreta.

1. Vocabolario

Il vocabolario è l'elenco di tutte le unità elementari che il modello riconosce. Non parole intere, non lettere singole in generale: token — le unità decise dal tokenizer.

Char-level

Ogni carattere Unicode distinto nel corpus è un token. Il corpus italiano di partenza produce ~102 token: le 26 lettere latine, le vocali accentate (à é ì ò ù), la punteggiatura, le cifre, lo spazio.

vocab_size = 102
token("a") = 65,  token("è") = 87,  token(".") = 12

BPE — Byte Pair Encoding

Il tokenizer parte dai singoli byte, poi fonde iterativamente le coppie più frequenti. Dopo 16.000 iterazioni di merge si ottengono vocab_size = 16.000 token: combinazioni come zione, mente, dell', ing.

# Esempio: stessa frase, tokenizzazione diversa
char-level : ["c","a","p","i","t","o","l","o"]   → 8 token
BPE 16k    : ["cap","ito","lo"]                  → 3 token

Con BPE ogni token trasporta più informazione semantica, e la stessa frase occupa 3–5 volte meno posizioni nel contesto — il che rende il training molto più efficiente.

vocab_size non è il numero di parole che il modello "conosce". È il numero di unità lessicali primitive nel suo alfabeto. Le parole emergono come combinazioni di questi token durante il training.

2. La embedding table

Quando un token entra nella rete, il modello deve convertire il suo ID intero (es. 42) in un vettore numerico su cui i calcoli hanno senso. Questo è il compito dell'embedding table.

nn.Embedding(vocab_size, d_model)
# = una matrice   vocab_size × d_model
# = una tabella di lookup: token_id → vettore

Concretamente: una matrice di vocab_size righe e d_model colonne. Quando il modello vede il token ID 42, prende la riga 42 — questo è tutto. Non c'è calcolo: è un accesso a memoria.

token ID
0→
1→
42→
…→
N-1→
d_model = 512 valori float
0.1
-0.3
0.7
…
-0.2
0.5
0.1
…
0.8
-0.1
0.4
…
0.0
0.2
-0.6
…
-0.5
0.3
0.2
…
riga 42
selezionata dal lookup

Cosa impara la embedding table

Durante il training, backprop modifica questi valori finché token semanticamente simili producono vettori vicini nello spazio. Il famoso esempio:

vec("re") − vec("uomo") + vec("donna") ≈ vec("regina")

Questo non viene programmato: emerge dall'ottimizzazione sulla distribuzione del testo. Con 552KB di corpus italiano otteniamo morfologia (suffissi, accordo); con 200MB di Wikipedia aggiungiamo semantica lessicale più ricca.

3. Cosa sono i parametri

Un parametro è un singolo numero float32 dentro la rete. Non i token, non le attivazioni, non l'input: i pesi — i valori che backpropagation aggiusta a ogni step di training.

Ogni layer ha la sua matrice di pesi. Esempio: un linear layer che trasforma 512 dimensioni in 512 dimensioni ha:

nn.Linear(512, 512) → 512 × 512 = 262.144 parametri

Contare i parametri di un modello significa sommare le dimensioni di tutte queste matrici.

Formula per un TransformerBlock

Ogni blocco contiene: quattro proiezioni di attenzione (Q, K, V, O) e due proiezioni feed-forward.

parametri per blocco ≈ 4 × d_model²  +  2 × d_model × d_ff

Con d_model=512 e d_ff=2048:

Q, K, V, O   = 4 × 512² = 1.048.576
FF (up+down)  = 2 × 512 × 2048 = 2.097.152
─────────────────────────────────────
Totale/blocco ≈ 3.145.728  (~3.1M)

Parametri totali — preset medium

Componente Formula Parametri Memoria fp32
Embedding table 16.000 × 512 8.192.000 31 MB
12 TransformerBlock 12 × 3.145.728 37.748.736 144 MB
LayerNorm finale trascurabile — —
Totale (con weight tying) ≈ 46.000.000 ≈ 175 MB

46 milioni di numeri float32 = 46M × 4 byte = 175 MB su disco. In addestramento la RAM è 3–5× superiore (gradienti, ottimizzatore), quindi ~500–800 MB. Su M2 con MPS è gestibile.

4. Il rapporto critico: embedding / totale

L'embedding table è parametri "passivi": memorizzano un vocabolario ma non ragionano. I TransformerBlock sono parametri "attivi": catturano relazioni tra token, generalizzano, costruiscono rappresentazioni. Il rapporto tra i due determina quanto del modello pensa.

Preset small — BPE 16k (il problema)

small: d_model=256, 4.8M parametri totali
Embedding
85%
Transformer
15%

Problema: con vocab 16k e d_model=256, la tabella di lookup occupa 4.1M dei 4.8M totali. I sei TransformerBlock si spartiscono solo 700K parametri — quasi nulla per imparare relazioni linguistiche.

Preset medium — BPE 16k (soluzione)

medium: d_model=512, 46M parametri totali
Embedding
18%
Transformer
82%

Corretto: l'embedding è il 18% del totale — nella norma per i modelli moderni (tipicamente 10–20%). I 12 TransformerBlock dispongono di 37M parametri per costruire rappresentazioni linguistiche.

La regola pratica: con BPE, d_model deve essere abbastanza grande da rendere l'embedding table una minoranza ragionevole del totale. Un rapporto 80:20 o 85:15 (transformer:embedding) è salutare.

5. Weight tying

Il modello usa la stessa matrice due volte: all'ingresso per convertire token ID → vettori, e all'uscita per convertire vettori → logit (probabilità sul vocabolario).

# In model.py — stessa matrice, usata in entrambe le direzioni
self.token_embedding = nn.Embedding(vocab_size, d_model)
self.lm_head         = nn.Linear(d_model, vocab_size, bias=False)

self.lm_head.weight  = self.token_embedding.weight
# lm_head non aggiunge parametri: usa la trasposta dell'embedding

Ha senso geometricamente: se il vettore di "gatto" punta in una certa direzione nello spazio, proiettare un'attivazione in quella stessa direzione dovrebbe dare alta probabilità a "gatto" in output. La simmetria è reale, non una scorciatoia.

Effetto pratico: risparmiamo vocab_size × d_model parametri in uscita — con 16k vocab e d_model=512 sono 8.2M parametri in meno. E il training converge più velocemente perché il gradiente dell'output affina anche l'embedding.

Configurazione vocab_size d_model Embedding Transformer Totale RAM train
char-level small ✓ 102 256 26K (0.5%) 4.8M 4.8M ~50 MB
BPE small ⚠ 16.000 256 4.1M (85%) 0.7M 4.8M ~50 MB
BPE medium ← target 16.000 512 8.2M (18%) 37.8M 46M ~600 MB

Prossimo step: riavviare il training con --preset medium sul corpus letterario (2.9MB), riusando il tokenizer SPM già addestrato. Pre-training su Wikipedia → fine-tuning sulla propria voce.

7. Il tokenizer

Il tokenizer fa una cosa sola: converte testo in una sequenza di numeri interi.

"capitolo" → [423, 17, 891]          # BPE 16k
"capitolo" → [99, 14, 7, 3, 22, 8, 11, 8]  # char-level

Non è una rete neurale. Non ha pesi. Non impara durante il training del modello. È un algoritmo deterministico con una tabella di regole fissa.

Cosa contiene fisicamente

checkpoints/tokenizer.model è un file SentencePiece binario con due cose:

ContenutoEsempio
Vocabolario — 16.000 righe, una per token {"zione": 0, "ment": 1, "dell": 2, …}
Regole di merge — ordine in cui fondere le coppie di byte z + i → zi, zi + one → zione, …

Dato "capitolo", il tokenizer esegue le regole nell'ordine fisso e restituisce sempre gli stessi ID. Nessuna casualità.

Perché possiamo riusarlo

La embedding table del modello ha vocab_size righe — una per ogni token. La riga 423 contiene il vettore che rappresenta il token zione, aggiornato durante il training.

Se usiamo lo stesso tokenizer, il token zione è sempre l'ID 423 — in Wikipedia, nel corpus letterario, nel pre-training, nel fine-tuning. La riga 423 mantiene il suo significato in tutti i run.

# Tokenizer A (riusato) → contratto stabile
"zione" → ID 423   embedding[423] imparata in pre-training ✓

# Tokenizer B (nuovo) → contratto rotto
"zione" → ID 7801  embedding[423] ora punta a "ing" — checkpoint corrotto ✗

Il tokenizer è il contratto tra i dati e il modello. Finché il contratto non cambia, il checkpoint è valido e il fine-tuning funziona. Addestrare un nuovo tokenizer invalida ogni checkpoint precedente.

8. I preset architetturali

--preset è una scorciatoia che evita di passare ogni iperparametro architetturale a mano:

# Senza preset — verboso
python3 train.py --d-model 512 --num-layers 12 --num-heads 8 --d-ff 2048 --seq-len 512

# Con preset — equivalente
python3 train.py --preset medium

I valori definiti in train.py:

_PRESETS = {
    'small':  dict(d_model=256, num_layers=6,  num_heads=8, d_ff=1024, seq_len=256),
    'medium': dict(d_model=512, num_layers=12, num_heads=8, d_ff=2048, seq_len=512),
}
ParametrosmallmediumEffetto
d_model256512larghezza di ogni vettore interno
num_layers612profondità della rete
num_heads88teste di attenzione parallele
d_ff10242048larghezza del feed-forward (= 4 × d_model)
seq_len256512contesto massimo in token
Parametri totali~4.8M~46M

I preset si possono sovrascrivere parzialmente — il valore esplicito vince sul preset:

# medium ma con 16 layer invece di 12
python3 train.py --preset medium --num-layers 16

9. d_model nel codice

d_model è la larghezza di ogni vettore che scorre attraverso la rete. Non è un parametro astratto — è letteralmente la seconda dimensione di ogni tensore intermedio.

# Input: batch di token ID
x = token_ids                            # shape: [batch, seq_len]

# 1. Embedding: ogni ID → vettore di d_model numeri
x = self.token_embedding(x)              # [batch, seq_len, d_model]

# 2. Positional encoding: somma un vettore di d_model alle posizioni
x = x + pos_enc                          # [batch, seq_len, d_model]

# 3. Dentro ogni TransformerBlock:

#    — Proiezioni Q, K, V, O
Q = nn.Linear(d_model, d_model)(x)       # [batch, seq_len, d_model]
K = nn.Linear(d_model, d_model)(x)
V = nn.Linear(d_model, d_model)(x)
O = nn.Linear(d_model, d_model)(attn)

#    — Feed-forward: si allarga a d_ff poi rientra
x = nn.Linear(d_model, d_ff)(x)          # [batch, seq_len, d_ff]  ← si allarga
x = nn.Linear(d_ff,    d_model)(x)       # [batch, seq_len, d_model] ← rientra

# 4. LayerNorm finale
x = nn.LayerNorm(d_model)(x)             # [batch, seq_len, d_model]

# 5. LM head: d_model → vocab_size (logit per ogni token)
logits = nn.Linear(d_model, vocab_size)(x)  # [batch, seq_len, vocab_size]

d_model è il contratto interno del modello. Ogni componente riceve un tensore largo d_model e restituisce un tensore largo d_model. L'unica eccezione è il feed-forward, che si allarga temporaneamente a d_ff = 4 × d_model per poi rientrare.

Raddoppiare d_model da 256 a 512 non cambia la struttura — cambia la capacità:

Effettod_model = 256d_model = 512
Ogni nn.Linear(d, d)65.536 param262.144 param (+4×)
Dimensione d_k per testa3264
Informazione per token256 dimensioni512 dimensioni

10. CPU, GPU, CUDA e MPS

Il training di una rete neurale è quasi interamente moltiplicazioni di matrici. Una CPU esegue queste operazioni in sequenza sui suoi core; una GPU le esegue in parallelo su migliaia di core più piccoli. Per un modello da 46M parametri la differenza è 10–50×.

Le tre opzioni di PyTorch

DeviceHardwareVelocitàDisponibile su
cpu Processore lento ovunque
cuda GPU NVIDIA velocissimo PC/server con scheda NVIDIA
mps GPU Apple M-series veloce Mac con M1/M2/M3/M4

Il tuo M2 ha una GPU integrata nello stesso chip della CPU — non è una scheda separata. MPS (Metal Performance Shaders) è l'API di Apple che permette a PyTorch di usarla.

CUDA e Metal: due linguaggi per la stessa idea

La GPU non esegue Python. Esegue programmi scritti in un linguaggio specializzato per il calcolo parallelo:

ProduttoreLinguaggio GPUAPI PyTorch
NVIDIACUDAdevice="cuda"
AppleMetaldevice="mps"

PyTorch contiene le operazioni in forma generica (matmul, softmax, gelu…). Per eseguirle sulla GPU deve compilare una versione specializzata nel linguaggio della GPU — uno shader — ottimizzata per le dimensioni esatte di quel tensore.

Compilazione degli shader: prima run lenta

La prima volta che PyTorch esegue un'operazione su MPS con dimensioni specifiche — d_model=512, batch=16, seq_len=512 — deve compilare lo shader Metal corrispondente e salvarlo in cache su disco:

# Percorso della cache shader su macOS
~/Library/Caches/com.apple.metal/

# Prima run — compila e salva (I/O disco intenso, 5-15 minuti)
# Run successive con le stesse dimensioni — carica dalla cache (secondi)

Questo spiega il comportamento alla prima avviata del preset medium: CPU bassa, disco a 140–240 MB/s, silenzio prolungato prima che parta il training. Non è un blocco — è il compilatore Metal al lavoro.

Il pattern .to(device) in PyTorch

Tutto quello che deve girare sulla GPU va esplicitamente spostato su di essa. I tensori e il modello vivono in RAM (CPU) per default:

device = torch.device("mps")        # seleziona la GPU Apple

model = GPT(config).to(device)      # sposta i 46M parametri sulla GPU

# Nel training loop, ogni batch deve essere sul device corretto
x, y = x.to(device), y.to(device)  # input e target → GPU

loss.backward()                      # gradienti calcolati sulla GPU
optimizer.step()                     # pesi aggiornati sulla GPU

# Solo per stampare la loss, il valore torna sulla CPU
print(loss.item())                   # .item() copia il singolo scalare in RAM

Su M2: la GPU condivide la RAM con la CPU (Unified Memory) — lo spostamento .to(device) non copia fisicamente i dati, cambia solo i metadati che dicono dove il tensore è accessibile. È uno dei vantaggi dell'architettura Apple Silicon rispetto a una scheda NVIDIA separata con VRAM dedicata.

11. Ottimizzare il training

Alla prima avviata su un corpus grande (200MB+), train.py trascorre la maggior parte del tempo in silenzio prima ancora che parta il training. Questa sezione spiega le leve disponibili e il loro impatto.

Le fasi di avvio e dove si perde tempo

1. Lettura corpus da disco          ~ secondi      (I/O, veloce)
2. SPM encoding 40M token           ~ 10–20 min    ← bottleneck principale
3. Costruzione tensor PyTorch        ~ 1–2 min      (allocazione RAM)
4. Compilazione shader Metal (MPS)   ~ 5–15 min     (solo prima run)
5. Step 1/30000 — loss: X.XXXX      ← training partito

Intervento 1 — Cache dei token (implementato)

Dopo il primo encoding, il tensore viene salvato su disco come file .tokens.pt. Le run successive lo caricano direttamente — saltando completamente la fase 2.

# Prima run: encoding + salvataggio cache
data/wiki_it.txt.bpe-spm.v16000.tokens.pt   ← creato (~160 MB)

# Run successive: caricamento diretto
  Cache   : data/wiki_it.txt.bpe-spm.v16000.tokens.pt
  Token   : 39,847,203  (da cache — avvio istantaneo)

La cache viene invalidata automaticamente se il corpus o il tokenizer SPM sono stati modificati dopo la creazione del file. Per forzare il re-encoding:

python3 train.py --tokenizer bpe-spm --data data/wiki_it.txt \
    --preset medium --no-cache

Risparmio: da 10–20 minuti di attesa silenziosa a 2–3 secondi. Impatto maggiore su corpus grandi (100MB+). Sul corpus letterario (3MB) l'encoding era già veloce.

Come funziona l'invalidazione

Il file cache ha un nome che incorpora il tipo di tokenizer e il vocab size:

<corpus>.<tokenizer>.v<vocab_size>.tokens.pt

All'avvio, train.py controlla tre condizioni prima di usare la cache:

  1. Il file cache esiste
  2. Il corpus non è stato modificato dopo la cache (mtime)
  3. Il file tokenizer.model SPM non è stato modificato dopo la cache

Se anche una sola condizione fallisce, re-encoding e sovrascrittura della cache.

Intervento 2 — Ridurre seq_len

Il costo computazionale dell'attention scala con il quadrato della lunghezza di sequenza. Dimezzare seq_len da 512 a 256 rende ogni forward pass circa 4× più veloce:

costo attention ∝ seq_len²   →   256² / 512² = 1/4
# medium con seq_len ridotto — più veloce, contesto dimezzato
python3 train.py --tokenizer bpe-spm --data data/wiki_it.txt \
    --preset medium --seq-len 256
seq_lenStep/sec (M2)ContestoUso consigliato
256~2–3×256 tokenesperimenti veloci, corpus piccoli
512baseline512 tokenpre-training Wikipedia (preset medium)

Intervento 3 — Aumentare batch_size

Batch più grandi aumentano il throughput della GPU (più token per step) e stabilizzano i gradienti. Il limite è la RAM disponibile.

# medium su M2 con 16GB — batch 32 è sicuro
python3 train.py --tokenizer bpe-spm --data data/wiki_it.txt \
    --preset medium --batch 32
batchRAM stimataStabilità gradienti
8~400 MBrumorosi
16~550 MBbuona
32~850 MBottima — consigliato
64~1.5 GBeccellente

Riepilogo delle leve

InterventoImpattoTrade-offFlag
Cache token elimina 10–20 min attesa ~160 MB su disco automatica; --no-cache per disabilitare
seq_len 256 → 512 4× più veloce per step contesto dimezzato --seq-len 256
batch 16 → 32 ~1.5× throughput +300 MB RAM --batch 32
Corpus ridotto (50 MB) encoding più rapido meno vocabolario --max-mb 50 su wiki_to_corpus.py