Come funziona Llama 2: Inferenza e architettura in puro PHP
L'ecosistema PHP è da sempre il motore del web. Quando si parla di intelligenza artificiale, Large Language Models (LLM) o calcolo tensoriale, la mente corre immediatamente a Python, C++ o CUDA. Eppure, il modo migliore per comprendere davvero una tecnologia complessa è spogliarla delle sue astrazioni, allontanarsi dalle librerie "magiche" come PyTorch o TensorFlow, e riscriverla da zero.
Guidato dalla filosofia del "se non posso crearlo, non lo capisco", ho provato a seguire quanto già fatto da Andrej Karpathy sul suo progetto llama2.c riscrivendolo in PHP llama2-php. Si tratta di un'implementazione nativa, in puro PHP (senza estensioni C o dipendenze esterne), per l'inferenza dell'architettura Llama 2.
In questo articolo, esploreremo in dettaglio come funziona Llama 2 sotto il cofano, come è stato tradotto in PHP, e le sfide architettoniche e prestazionali affrontate durante lo sviluppo.
Come funziona Llama 2
Per capire il codice, dobbiamo prima capire l'architettura. Llama 2 di Meta si basa sulla classica architettura Transformer, un modello introdotto da Google nel 2017 (con il celebre paper "Attention Is All You Need") che ha letteralmente stravolto il mondo dell'intelligenza artificiale.
Prima dei Transformer, le reti neurali (come le RNN o le LSTM) leggevano il testo un po' come farebbe un bambino alle prime armi: una parola alla volta, in sequenza stretta. Questo approccio aveva un limite enorme: arrivato alla fine di un lungo paragrafo, il modello finiva per "dimenticarsi" i concetti espressi all'inizio. Il Transformer ha eliminato questo collo di bottiglia processando l'intero testo in parallelo.
Per intenderci, immagina di dover analizzare un contratto molto complesso. Invece di leggerlo riga per riga perdendo il filo, il Transformer è in grado di guardare l'intera pagina in un singolo istante e tracciare istantaneamente dei fili invisibili che collegano un pronome a pagina 3 con il soggetto nominato a pagina 1. Riesce a farlo soppesando l'importanza di ogni parola rispetto a tutte le altre, simultaneamente.
Partendo da questa base rivoluzionaria, Llama 2 introduce alcune ottimizzazioni fondamentali che lo rendono ancora più veloce ed efficiente dal punto di vista computazionale. Vediamole una per una:
1. Tokenizzazione e Embedding
Un LLM non comprende le parole, ma i numeri. Il primo passo quindi è la tokenizzazione, in cui il testo viene frammentato in "token" (che possono essere parole intere, sillabe o singoli caratteri) mappati a un ID intero.
Questi ID vengono poi convertiti in Embeddings, ovvero vettori ad alta dimensionalità.
Immagina una mappa tridimensionale immensa che contiene tutte le parole della lingua italiana. La parola "Re" in questa mappa si trova a una certa coordinata, "Regina" sarà molto vicino a essa (perchè condivide con Re lo stesso contesto o significato).... ma "Mela" sarà in tutt'altra zona! L'Embedding prende il nostro token e lo posiziona in questo spazio, permettendo al modello di capirne il significato semantico tramite la distanza vettoriale con le altre parole.
2. RoPE (Rotary Positional Embeddings)
A differenza del nostro cervello (e delle vecchie reti neurali sequenziali che leggevano una parola alla volta), i Transformer elaborano tutti i token in parallelo. Questo garantisce velocità bruta, ma li rende totalmente ciechi rispetto all'ordine delle parole: senza un aiuto, il modello non saprebbe distinguere tra 'Il cane morde l'uomo' e 'L'uomo morde il cane'.
L'unico modo per risolvere questo problema è intervenire sui dati stessi: dobbiamo "marchiare" le parole prima che entrino nel "frullatore" matematico. È qui che entra in gioco l'Encoding Posizionale. Visto che la rete neurale non possiede il concetto di tempo o sequenza, prendiamo l'Embedding di ogni singolo token (il vettore che ne rappresenta il significato di cui parlavamo prima) e vi sommiamo un secondo vettore che codifica la sua posizione all'interno della frase.
In questo modo, il token "cane" non entra nel modello portando con sé solo l'informazione semantica dell'animale, ma porta con sé l'informazione fusa "io sono il concetto di cane e mi trovo alla posizione numero due". È proprio da questa necessità che nascono sistemi come il RoPE di Llama 2, che invece di sommare un valore, "ruotano" i vettori nello spazio multidimensionale per preservare in modo ancora più efficiente le distanze relative tra le parole.
3. Self-Attention
Il vero cuore pulsante del Transformer, la magia che gli permette di "comprendere" il linguaggio, è il meccanismo di Self-Attention. Per capire come funziona, dobbiamo immaginare che ogni parola, non appena entra nella rete neurale, subisca una scomposizione. Attraverso delle moltiplicazioni matriciali, per ogni singolo token il modello genera tre nuovi vettori distinti: Query, Key e Value.
Possiamo vederli come i tre ruoli che una parola assume all'interno di una conversazione:
Query (Q): Cosa sto cercando? È la domanda che il token pone al resto della frase per capire meglio se stesso.
Key (K): Cosa ho da offrire? È l'etichetta che il token espone agli altri, indicando le informazioni che contiene.
Value (V): Qual è il mio vero significato? È la sostanza, l'essenza semantica pura del token che verrà effettivamente utilizzata per costruire la comprensione finale.
Prendi per esempio una frase molto ambigua, come "La banca ha chiuso la filiale perché non aveva fondi".
La parola "fondi", presa singolarmente, non ha un significato univoco: potrebbero essere fondi di caffè, fondi d'investimento, o i fondi delle bottiglie. Come fa il modello a disambiguare? Quando è il turno di processare la parola "fondi", il suo vettore Query inizia a scansionare l'intera frase. Matematicamente, questo avviene calcolando il prodotto scalare (dot product) tra la Query di "fondi" e le Key di tutte le altre parole presenti. Il prodotto scalare è un'operazione che restituisce un numero alto se due vettori sono simili, e un numero basso se sono ortogonali (cioè non c'entrano nulla l'uno con l'altro).
La Query di "fondi" "chiede": "C'è qualcuno qui che ha a che fare con finanza, agricoltura o altro?". Le parole "banca" e "filiale" espongono le loro Key che urlano: "Noi parliamo di economia, istituti di credito e denaro!".
Il calcolo matematico tra la Query di "fondi" e le Key di "banca" e "filiale" produce un punteggio altissimo. Al contrario, il punteggio di affinità con parole come "perché" o "ha" sarà vicino allo zero.
A questo punto avviene la fusione: il modello prende i vettori Value di tutte le parole e li somma tra loro, ma non in parti uguali. Li "pesa" in base ai punteggi appena calcolati. Poiché "banca" ha ottenuto un punteggio altissimo, il suo Value (il suo significato finanziario) aiuterà la giusta interpretazione della parola "fondi".
Il risultato finale? Il token "fondi" esce da questo blocco di attenzione profondamente mutato. Non è più il generico token da dizionario, ma è diventato un vettore altamente contestualizzato che significa inequivocabilmente denaro. Ha letteralmente prestato "attenzione" al contesto giusto.
In Llama 2, questo processo è stato ulteriormente ottimizzato con una tecnica chiamata Grouped-Query Attention (GQA). Invece di calcolare Key e Value univoci per ogni singola Query (il che consuma una quantità enorme di memoria RAM durante l'inferenza), più Query vengono raggruppate per condividere le stesse Key e Value, riducendo drasticamente il carico computazionale senza perdere precisione.
4. RMSNorm e SwiGLU
Una volta che il meccanismo di Attention ha fuso i significati delle parole tra loro, i vettori risultanti devono passare attraverso due fasi cruciali prima di essere inviati al layer successivo: devono essere stabilizzati e poi elaborati da una rete neurale "classica".
La stabilizzazione: RMSNorm In una rete profonda come Llama 2, che possiede decine di layer impilati, la grandezza dei numeri tende a sfuggire di mano. Moltiplicazione dopo moltiplicazione, i valori all'interno dei vettori possono diventare astronomicamente grandi o microscopici, rovinando poi i calcoli futuri.
I vecchi Transformer usavano la Layer Normalization, che calcolava la media di tutti i valori del vettore, la sottraeva (per centrare i dati sullo zero) e poi divideva per la varianza. Llama 2 taglia la testa al toro e usa la Root Mean Square Normalization (RMSNorm). I ricercatori si sono accorti che centrare i dati sulla media era un passaggio computazionalmente superfluo. RMSNorm si limita a dividere i valori per la loro radice quadratica media per garantire che i numeri restino sempre in un range gestibile (generalmente tra -1 e 1), riducendo le operazioni di calcolo di circa il 10% per ogni singolo layer. Nel contesto di un'implementazione in PHP, dove ogni ciclo sulle array ha un costo, saltare il ricalcolo della media su vettori di migliaia di elementi fa un'enorme differenza in termini di prestazioni.
L'elaborazione: La rete Feed-Forward e SwiGLU Se l'Attention serve a far "parlare" i token tra loro per capirne il contesto, la rete Feed-Forward (FFN) successiva serve a elaborare il singolo token in modo indipendente, estraendone pattern logici complessi.
Qui entra in gioco la funzione di attivazione. Una rete neurale senza una funzione di attivazione non lineare sarebbe solo una gigantesca, inutile addizione. Fino a qualche anno fa, lo standard industriale era la ReLU (Rectified Linear Unit), che ha una regola brutale: se il numero è negativo diventa 0; se è positivo resta com'è. Questo approccio è rapido ma causa il problema dei dead neurons: i numeri negativi vengono letteralmente distrutti e il "neurone" smette di apprendere (zero, appunto, per le future moltiplicazioni).
Llama 2 fa un salto di qualità usando SwiGLU (Swish-Gated Linear Unit). Questa architettura è molto più sofisticata e combina due concetti:
- Swish: Invece del taglio netto a zero della ReLU, usa una curva più morbida che attenua i numeri negativi ma non li azzera completamente, così che possano continuare a trasmettere sfumature di informazioni utili al modello.
- GLU (Gated Linear Unit): Invece di far passare il dato semplicemente attraverso un set di pesi, SwiGLU divide il flusso. Una parte calcola l'attivazione effettiva, l'altra parte agisce come un "cancello" (Gate) che moltiplica il risultato, decidendo attivamente quanto di quel dato deve effettivamente passare al livello successivo.
In sintesi, SwiGLU agisce come un interruttore dimmerabile che usiamo per alcune luci di casa: non si limita ad accendere o spegnere un neurone, ma ne regola finemente l'intensità del segnale. Sebbene richieda più parametri (tre matrici di pesi invece delle classiche due), empiricamente permette a Llama 2 di apprendere concetti molto più astratti a parità di risorse computazionali.
L'Implementazione in PHP
Portare tutto questo in PHP ha richiesto un approccio architetturale molto severo, simile a quello che ho adottato nel mio database vettoriale vektor. Niente ORM, niente framework, solo strutture dati a basso livello e pura manipolazione della memoria.
Lettura e gestione dei pesi
Il modello addestrato (i "pesi" della rete neurale) è tipicamente salvato in un enorme file binario (nel mio caso generato tramite l'esportazione dal progetto C di Andrej Karpathy).
In PHP, l'accesso a questi file non può avvenire caricando tutto in memoria. Se provassimo a fare un file_get_contents() di un modello da svariati Gigabyte, il processo PHP andrebbe immediatamente in Fatal Error superando il memory_limit.
L'implementazione utilizza ovviamente gli stream:
$fp = fopen('model.bin', 'rb');
// Legge un header per capire la dimensione del modello
$header = unpack('i*', fread($fp, 28));
I tensori vengono letti a blocchi e decodificati usando unpack('f*', $dati_binari). Per i modelli più grandi, va studiato invece un approccio di memory-mapping virtuale, cioè leggere parti del file su disco esattamente quando il layer corrente le richiede, calcolare le moltiplicazioni matriciali, e scartare il dato per mantenere un overhead RAM prossimo allo zero.
La Moltiplicazione Matriciale (MatMul)
Il vero collo di bottiglia è l'operazione matematica principale: C = A * B.
Nel core di Llama, questo significa moltiplicare il vettore di input per l'enorme matrice dei pesi. In llama2-php, la funzione matmul è stata scritta tenendo conto della disposizione in memoria.
function matmul(array &$out, array &$x, array &$w, int $n, int $d) {
// x è l'input (dimensione $d)
// w sono i pesi (matrice $d x $n appiattita)
for ($i = 0; $i < $n; $i++) {
$val = 0.0;
for ($j = 0; $j < $d; $j++) {
$val += $w[$i * $d + $j] * $x[$j];
}
$out[$i] = $val;
}
}
L'implementazione matriciale è volutamente "piatta" (fatta con array monodimensionali) per massimizzare la velocità di accesso ed evitare l'overhead disastroso che PHP introduce con gli array multidimensionali, che sotto il cofano sono gestiti come pesanti hash tables.
La precisione dei Floating Point
In C, i modelli più leggeri usano il formato float a 32-bit (FP32). PHP internamente usa esclusivamente il tipo double a 64-bit per tutto ciò che è a virgola mobile. Quando si leggono i pesi con unpack('f*'), PHP li converte tacitamente in 64-bit. Sebbene questo aumenti teoricamente la precisione, altera leggermente il calcolo delle funzioni logaritmiche rispetto all'originale in C. Questo porta inevitabilmente ad alcune differenze di moltiplicazione rispetto alle altre implementazioni.
In conclusione
Trasformare llama2.c in llama2-php aveva un unico scopo: studiare e comprendere il funzionamento interno del motore.
Implementare concetti astratti come l'Attention e il RoPE mi ha permesso di toccare con mano l'eleganza matematica di queste reti. Ho scoperto che l'intelligenza artificiale non è magia nera, ma "soltanto" milioni di moltiplicazioni sequenziali, letture binarie strutturate e, di nuovo, milioni di moltiplicazioni....