I Gradienti Locali si Moltiplicano
Il Forward Pass
Il forward pass di ANDREA-120M fa passare l'input attraverso una sequenza di operazioni:
x = embed(token_ids) # embedding dei token
for layer in 12_layers:
x = x + attn(LN(x)) # sottostrato attention
x = x + mlp(LN(x)) # sottostrato MLP
logits = LN(x) @ embed.T # proiezione di output condivisa
loss = cross_entropy(logits, targets)
Ogni operazione legge tensori in ingresso e produce tensori in uscita. Il forward pass termina in un singolo scalare: la cross-entropy loss per questo batch.
Il Backward Pass
L'addestramento aggiorna i pesi nella direzione che diminuisce la loss. Per ottenere le direzioni di aggiornamento, il motore ha bisogno di:
dL/dW per ogni W apprendibile nel modello
La regola della catena lo fornisce. Per una catena loss = f(g(h(x))):
dL/dx = (dL/df) * (df/dg) * (dg/dh) * (dh/dx)
Ogni fattore è un gradiente locale: come l'output di un'operazione cambia quando il suo input cambia di una piccola quantità. Moltiplicando i gradienti locali all'indietro attraverso il grafo, si propaga il segnale di perdita a ogni peso.
Differenziazione in Modalità Reverse
Backprop calcola i gradienti in ordine inverso: partendo da dL/dlogits = 1, poi procedendo all'indietro attraverso l'entropia crociata, poi la proiezione di output, poi la normalizzazione del layer, poi dodici blocchi transformer, poi gli embedding. A ogni passo, si moltiplica il gradiente in arrivo per il Jacobiano locale.
La modalità reverse è efficiente quando l'output è un singolo scalare (la perdita) e ci sono molti input (i pesi). Un singolo passaggio all'indietro produce gradienti per ogni peso nel modello. La modalità forward richiederebbe un passaggio per peso; per ANDREA-120M con ~120M pesi, la modalità forward è impraticabile.
Perché la Modalità Reverse
Ogni Operazione Forward Ha un Gemello Backward
La Disciplina di Accoppiamento
microgpt_cuda.cu include due kernel CUDA per ogni operazione: uno che calcola l'output forward, uno che calcola i gradienti degli input dati i gradienti degli output. L'accoppiamento è uno-a-uno:
| Kernel forward | Kernel backward | Operazione |
|---|---|---|
k_embed_fwd | k_embed_bwd | Ricerca embedding dei token |
k_layernorm_fwd | k_layernorm_bwd | Normalizzazione layer |
k_attn_qkv_fwd | k_attn_qkv_bwd | Proiezioni Q, K, V |
k_attn_fwd | k_attn_bwd | Attenzione scaled dot-product |
k_attn_out_fwd | k_attn_out_bwd | Proiezione di output W_O |
k_mlp_fwd | k_mlp_bwd | MLP (con GELU) |
k_residual_add | k_residual_add_bwd | Connessione residua |
k_loss_fwd | k_loss_bwd | Perdita cross-entropy |
Otto coppie di operazioni coprono l'intero transformer. Più alcuni kernel di utilità: k_grad_norm_partial, k_grad_norm_final, k_grad_scale per il clipping dei gradienti (vedi attività 75).
Il Compito di un Kernel Backward
Dato il gradiente che arriva dai layer successivi (grad_output), un kernel backward calcola:
1. grad_input: il gradiente rispetto al tensore di input dell'operazione. Questo viene passato ulteriormente all'indietro.
2. grad_weight: il gradiente rispetto ai parametri apprendibili nell'operazione. Questo va nello stato dell'ottimizzatore.
Entrambi vengono calcolati in un singolo lancio di kernel. I thread CUDA cooperano su tile del tensore di gradiente in parallelo.
Tensori Salvati
Il calcolo all'indietro spesso necessita di valori dal passaggio forward. Ad esempio, k_layernorm_bwd necessita della media e varianza calcolate durante il forward; k_mlp_bwd necessita del pre-attivazione GELU. Il motore di training li memorizza in buffer dedicati durante il forward, poi li legge durante l'indietro.
Costo in memoria: approssimativamente la stessa forma dell'output forward per ogni tensore salvato. Per ANDREA-120M con batch=8, seq=1024, d_model=768, un tensore salvato è 8 × 1024 × 768 × 4 bytes = 25 MB. Attraverso 12 layer & più tensori salvati per layer, le attivazioni dominano la VRAM durante l'addestramento (~5-10 GB su una scheda da 24 GB).
Tracciamento di un Passo Backward
Dove Risiedono i Gradienti in Memoria
Un Tensore di Gradiente per Ogni Tensore di Peso
Ogni tensore di peso apprendibile in ANDREA-120M ha un tensore di gradiente corrispondente della stessa forma. Per ogni blocco:
W_Q [768, 768] ↔ grad_W_Q [768, 768]
W_K [768, 768] ↔ grad_W_K [768, 768]
W_V [768, 768] ↔ grad_W_V [768, 768]
W_O [768, 768] ↔ grad_W_O [768, 768]
W_1 [768, 3072] ↔ grad_W_1 [768, 3072]
W_2 [3072, 768] ↔ grad_W_2 [3072, 768]
LN1.gamma [768] ↔ grad_LN1.gamma [768]
LN1.beta [768] ↔ grad_LN1.beta [768]
LN2.gamma [768] ↔ grad_LN2.gamma [768]
LN2.beta [768] ↔ grad_LN2.beta [768]
Più gli embedding dei token, embedding di posizione e una normalizzazione finale del layer. La memoria totale del buffer dei gradienti corrisponde alla memoria dei pesi: ~120M float, ~480 MB a FP32, ~240 MB a FP16.
Accumulo Attraverso Microbatch
Il batch_size = 8 di ANDREA entra in VRAM a FP16. Batch effettivi più grandi richiedono accumulo di gradienti: eseguire più passaggi forward+backward su batch piccoli, sommando i gradienti nello stesso buffer, poi eseguire un passo dell'ottimizzatore.
for microbatch in range(n_microbatches):
forward(microbatch)
backward() # AGGIUNGE ai buffer dei gradienti, non sovrascrive
scale_grads(1.0 / n_microbatches) # media tra i microbatch
optimizer_step()
zero_grads() # reset per il prossimo step di training
I kernel di backward usano una semantica +=, non =. Ogni chiamata aggiunge contributi di gradiente al buffer esistente; il buffer mantiene la somma cumulativa fino a quando zero_grads() lo azzera.
Lo Stato dell'Ottimizzatore
AdamW (attività 73) mantiene due buffer aggiuntivi per peso: primo momento m & secondo momento v. Memoria totale durante l'addestramento:
pesi: 1× conteggio pesi
gradienti: 1× conteggio pesi
Adam m: 1× conteggio pesi
Adam v: 1× conteggio pesi
attivazioni salvate: ~2-4× a seconda di strati & batch
──────────────────────────────────────────
totale: ~6-8× conteggio pesi
ANDREA-120M a FP16: ~240 MB × 4 buffer (peso, grad, m, v) + ~5-10 GB attivazioni = ~10-12 GB totali. Ben al di sotto del limite di 24 GB della RTX 4090. ANDREA-12M addestrato in 1.4 GB; il ridimensionamento parametri 10× porta ~10× memoria.
Dimensionamento Buffer Gradiente
Controllo Completo di Memoria & Precisione
Cosa Costano i Framework Generici
PyTorch & JAX rendono autograd comodo: scrivi codice Python, ottieni gradienti automaticamente. Il costo: un layer di dispatch generico tra il tuo codice & CUDA. Ogni operazione passa attraverso l'overhead dell'interprete Python, contabilità del framework, & selezione dinamica del kernel. Per addestrare un piccolo modello linguistico su una GPU, quell'overhead conta.
Costi concreti che ANDREA evita:
1. Latenza dell'interprete Python. Ogni operazione PyTorch attraversa il confine Python/C++. Per ~100 lanci di kernel per step di training a ~9 step/min, ciò significa ~900 attraversamenti di confine al minuto. La dispatch a livello C elimina questo.
2. Imprevedibilità dell'allocatore del framework. L'allocatore di caching di PyTorch offre un buon throughput in media ma memoria di picco imprevedibile. Il motore di training di ANDREA pre-alloca ogni buffer all'avvio; nessuna riallocazione durante il training, nessuna frammentazione, nessun OOM a sorpresa allo step 100K.
3. Selezione di kernel generici. PyTorch seleziona i kernel a runtime tramite euristiche. ANDREA seleziona i kernel a compile time, ottimizzati per le dimensioni delle tile del tensor core RTX 4090.
4. Idraulica a precisione mista. Il percorso cuBLAS FP16 di ANDREA-120M e gli esperimenti tensor core FP8 E4M3 di ANDREA richiedono un controllo preciso su quali tensori risiedono a quale precisione. I framework generici espongono questo controllo attraverso API stratificate; le scritture CUDA personalizzate lo implementano direttamente.
Il Compromesso
Costi del CUDA personalizzato: più codice da scrivere, più bug da trovare, nessun ecosistema comunitario. Il file microgpt_cuda.cu di ANDREA è ~6000 righe di CUDA scritto a mano che ha richiesto mesi per il debug. Ogni nuova operazione richiede la scrittura di un kernel forward, un kernel backward e test.
Cosa guadagna ANDREA:
- Riproducibilità completa. Il pipeline di training è un binario C più un proxy Python. Nessuna deriva di versione tra i rilasci di PyTorch, nessuna incompatibilità di versione CUDA con le wheel del framework.
- Riprese bit-exact. SIGTERM attiva la scrittura di un checkpoint che cattura ogni tensore esattamente come lo vede la GPU. La ripresa riprende la stessa traiettoria di loss del run.
- Memoria prevedibile. ANDREA-120M addestrato per 200K step senza OOM. La memoria è stata contabilizzata all'avvio del motore.
- Accesso diretto all'hardware. Dimensioni dei tile del tensor core, impostazioni FP8 E4M3, copie di memoria asincrone: tutto direttamente indirizzabile in CUDA, opaco nei framework generici.
Riproducibilità Come Missione
La sezione 9 del whitepaper ANDREA elenca lo stack completo di riproducibilità:
Motore di addestramento: microgpt/microgpt_cuda.cu
Proxy di addestramento: microgpt/training_proxy.py
Configurazioni esperimento: experiments/ANDREA-*-TRAIN.json
Pipeline dati: scripts/pull-hermes3.py, scripts/prep-megachat.py
Dashboard: scripts/live-loss-dashboard.html
Specifica Bandit: docs/FIREHOSE-BANDIT.md
Documentazione modello: docs/ANDREA.md
Requisito hardware: una GPU NVIDIA con ≥8 GB VRAM (RTX 3060 o superiore). Chiunque può riprodurre ANDREA-12M da questi artefatti. Il percorso CUDA personalizzato è parte del motivo: nessuna versione del framework congelata, nessuna sorpresa di dipendenze tra cinque anni.
Segnali & Checkpoint
Il ciclo di addestramento CUDA risponde a due segnali POSIX:
- SIGTERM: scrive un checkpoint immediato, poi esce. Utilizzato quando si ferma l'addestramento in modo pulito.
- SIGUSR1: scrive un checkpoint immediato, continua l'addestramento. Utilizzato durante la pivotatura di polish in v3 per catturare lo stato senza interrompere l'esecuzione.
Formato checkpoint: [int32 step][int32 n_params][n_params × float32 weights][n_params × float32 m][n_params × float32 v]. Contatore di step, conteggio pesi, poi pesi seguiti dai momenti di Adam. Riprende bit-exattamente. Il proxy archivia .samples.json & .state.json separatamente su polish; .loss.json non viene mai archiviato (accumula la storia completa dell'addestramento).