Architettura di NodeJS II: V8, Ignition e TurboFan
Nel post precedente, abbiamo parlato brevemente delle sfide legate all'utilizzo di JavaScript nel backend e di come strumenti come V8 e Libuv si inseriscano in questo processo. Inizialmente, i browser avevano bisogno di uno standard affinché tutti potessero implementare JavaScript e ottenere gli stessi risultati. È stato allora che, nel 1997, l'azienda Netscape ha sottoposto le definizioni del linguaggio all'organizzazione ECMA international, la quale ha ufficializzato lo standard con il nome di ECMAScript. Questo standard è mantenuto ed evoluto da un comitato tecnico all’interno di ECMA, chiamato TC39, composto da rappresentanti di diverse aziende come Google, Apple, Microsoft, Mozilla, ecc.
Da allora in poi sono emerse diversi engine per l’interpretazione di JavaScript nel browser, come SpiderMonkey, JavaScriptCore e V8. Sebbene implementino tutti lo standard ECMAScript, ciascuna gestisce l’esecuzione del codice a modo suo, ed è esattamente l’architettura interna di V8 che ora andremo a esplorare.
Con un forte focus sull’ottimizzazione, Google ha creato uno strumento potente e un processo di compilazione robusto con V8, rilasciato nella prima versione di Chrome. Tra le principali ottimizzazioni realizzate da V8, possiamo evidenziare l’implementazione della compilazione JIT, o compilazione Just-in-time. Questa metodologia prevede che la compilazione del codice JavaScript avvenga direttamente in tempo di esecuzione e, per supportare tale processo, in V8 è stato strutturato un complesso meccanismo interno.
Il punto di ingresso di questo flusso è il codice sorgente scritto dagli sviluppatori in JavaScript. Il primo passo consiste nell’elaborazione di una struttura chiamata AST, o Abstract Syntax Tree, al fine di creare una rappresentazione semantica capace di identificare ed evidenziare ogni risorsa del linguaggio utilizzata nel sorgente.
Per approfondire, possiamo esplorare il repository di V8 su GitHub. L’ingestione del codice JavaScript inizia con l'inizializzazione della struct “Scanner”, che legge iterativamente le righe di codice attraverso il metodo “Scan()” (def: src/parsing/scanner-inl.h).

In questo processo, le parole riservate del linguaggio, come “function”, “const” e “class”, vengono separate dal resto del codice, generando elementi per l’AST che vengono chiamate token. L'istanza dello Scanner viene creata e gestita dalla funzione “ParseProgram()” della struct “Parser” (src/parsing/parser.cc) che, di seguito, delega il lavoro pesante alla funzione “DoParseProgram()”. Quest’ultima è una funzione generica che affida il parsing a funzioni specializzate per ciascun tipo di token trovato nel codice, come ParseFunction, ParseModuleItemList, ParseImportDeclaration, ecc. In queste funzioni specializzate avviene la chiamata per la costruzione dell’albero AST, effettuata tramite la classe AstNodeFactory.

La struttura dell’AST creata durante il parsing rappresenta il modo in cui V8 ha interpretato il codice JavaScript, restituendolo in forma strutturata e astratta, pronta per la successiva compilazione. Per illustrare l’aspetto di questo albero, analizziamo un semplice esempio in cui il sistema esegue una somma:

Questa singola riga di codice viene convertita in un AST composto da vari nodi: una dichiarazione di variabile di tipo “const”, un’espressione binaria di somma e due valori letterali per i numeri 2 e 3. L’AST risultante ha la seguente struttura JSON:

Nella struttura possiamo notare chiavi presenti in quasi tutti i nodi, come “type”, oltre a “start” ed “end”, che indicano gli indici di inizio e fine del blocco corrispondente all’interno del codice originale. Troviamo inoltre proprietà come “value” e “raw” per i dati letterali letterali, e “name” per identificare variabili e funzioni. Esistono molte altre chiavi utilizzate per definire risorse alla compilazione, ma queste possono essere esplorate liberamente simulando blocchi di codice su AST Explorer (https://astexplorer.net/).
A partire dall’AST, un componente introdotto in V8 nel 2016 chiamato Ignition entra per ottimizzare il processo di compilazione di JavaScript, che fino ad allora era un’operazione onerosa per il compilatore. A differenza del processo precedente, invece di compilare direttamente in codice macchina, Ignition è responsabile deçça compilare del codice JavaScript in Bytecode, un tipo di codice composto da un codice operativo (opcode) e da operandi (dati che verranno elaborati), in cui ogni opcode ha la lunghezza di un byte, permettendo l’esecuzione di centinaia di operazioni. In questo modo, l’impatto principale di Ignition è l’ottimizzazione sia della compilazione sia dello spazio occupato dal codice finale, inoltre, riesce a garantire una grande portabilità, poiché il Bytecode funge da intermediario universale per diversi processori, rendendo possibile la generazione di codice per architetture AMD, ARM, Intel, ecc.
A partire dal Bytecode generato avremo diverse istruzioni differenti, come creazione di variabili, operazioni matematiche, loop, ecc. Queste operazioni, a loro volta, vengono interpretate dai Bytecode Handler in tempo di esecuzione, eseguendo il processo di trasformazione nel codice macchina eseguibile. Ciascuna istruzione ha il suo Handler equivalente e specializzato per l'ambiente in cui il codice verrà eseguito, potendo variare tra i browser, per JavaScript web, o anche, tra i sistemi operativi e processori, per un runtime del NodeJS.
Nel corso dell’esecuzione di un programma è molto comune avere funzioni e loop che vengono eseguiti più volte. Per questi blocchi di codice, Ignition inserisce nel Bytecode un “contatore invisibile” in grado di calcolare quali blocchi vengono eseguiti frequentemente. Quando questo contatore lavora calcolando il numero di volte in cui una funzione è stata eseguita, lo chiamiamo “invocation counter”, mentre per il conteggio delle esecuzioni dei loop parliamo di “back-edge counter”. Con queste informazioni siamo in grado di classificare le cosiddette “Hot Functions”.
Queste funzioni che vengono utilizzate frequentemente passano attraverso un processo di compilazione differente all’interno del JIT. Dopo essere state identificate come “Hot Functions”, vengono compilate direttamente per il linguaggio macchina da un altro componente di V8, in modo parallelo e senza bloccare il thread principale. Il componente responsabile di questa ottimizzazione è chiamato TurboFan.
In questo modo, il processo di compilazione può essere riassunto nei seguenti passaggi:
- Il codice JavaScript viene letto da Scanner e trasformato in un’AST per il Parser;
- Il Ignition entra in azione e converte l’AST in istruzioni con i Bytecode in tempo di esecuzione (JIT);
- Funzioni che vengono essere utilizzati frequentemente sono compilati direttamente per il codice macchine per TurboFan

JavaScript è un linguaggio a tipizzazione dinamica, il che significa che i tipi vengono risolti in tempo di esecuzione. Di conseguenza, una variabile dichiarata con un valore numerico può successivamente diventare un valore di tipo testo o booleano. Il problema sorge quando portiamo questo concetto al compilatore, poi ogni volta che modifichiamo il tipo di un parametro, l’intero blocco deve essere compilato nuovamente. Quando uniamo questo concetto all’ottimizzazione eseguita da TurboFan, arriviamo a un punto critico, in cui modificare il tipo di dato fa sì che il codice ottimizzato generato da TurboFan viene scartato e l’esecuzione torna a Ignition con il Bytecode, finchè la funzione non viene classificata nuovamente come Hot Function e ottimizzata ancora una volta da TurboFan. Questo processo è molto più oneroso rispetto al flusso ottimizzato del compilatore, per questo motivo, dobbiamo fare attenzione ad evitare questo tipo di dinamismo nel nostro codice per migliorare le prestazioni del processo di compilazione. Questo processo interno al compilatore è chiamato “deoptimization” o “bailout”. Osserva il codice sottostante, mostriamo un esempio di codice in cui si verifica un cambio di tipo dei parametri dopo l'ottimizzazione di una Hot Function da parte di TurboFan:

Per identificare questo tipo di evento indesiderato nel codice, possiamo eseguire il comando “node –trace-deopt <path>.js”, qui verranno mostrate il deoptimizaion verificate durante l'esecuzione del programma. Osserva l'esempio relativo al codice precedente:

Comprendere questo processo di ottimizzazione è fondamentale per capire perché la coerenza dei tipi in NodeJS sia di estrema importanza. Inoltre, ci aiuta anche a comprendere come strumenti come TypeScript, pur non prevenendo del tutto il deoptimization, ci aiutino a mantenere il nostro software all’interno delle buone pratiche, consentendo ai compilatori di sfruttare al massimo le proprie risorse.
Nel prossimo post esploreremo dove array, oggetti e altre strutture dati risiedono nella memoria di V8, e come il Garbage Collector gestisca questo spazio.