Source :::: http://www.jayconrod.com/posts/51/a-tour-of-v8-full-compiler
Author :::: Jay Conrod
Un tour di V8: compilatore completo
Pubblicato il 2012-11-04
Modificati su 2015-11-24
Tagged: Javascript optimization v8 virtual-machines
Negli ultimi cinque anni le prestazioni di JavaScript è aumentato incredibilmente rapidamente, in gran parte a causa di una transizione dalla interpretazione alla compilazione JIT in JavaScript di macchine virtuali. Questo ha aumentato notevolmente l’utilità di JavaScript e applicazioni web in generale. Come risultato, JavaScript è ora la forza motrice di HTML5, la prossima ondata di tecnologie web. Uno dei primi motori di JavaScript per generare ed eseguire codice nativo è stato V8, che viene utilizzato in Google Chrome, il browser di Android, WebOS e altri progetti come node.js.
A poco più di un anno fa, ho aderito a un team presso la mia azienda che ottimizza la V8 per la nostra propria microarchitettura del braccio. Da quel momento, ho visto SunSpider prestazioni con doppia, e V8 prestazioni benchmark aumentare di circa il 50%, grazie ai contributi di entrambi i componenti hardware e software.
V8 è davvero un interessante progetto su cui lavorare, ma purtroppo la documentazione per è un po’ scarsa. Nei prossimi articoli, fornirò una panoramica di alto livello, che si spera possa essere interessante per chiunque sia curioso circa i meccanismi interni di macchine virtuali o compilatori.
Architettura di alto livello
V8 compila tutti i JavaScript in codice nativo prima dell’esecuzione. Non vi è alcuna interpretazione e non di bytecode. La compilazione viene effettuata una funzione alla volta (al contrario di trace-basa la compilazione come usato in TraceMonkey, il vecchio FireFox VM). Di solito, le funzioni non vengono realmente compilato fino a quando il primo tempo sono chiamati, così se si includono una grande libreria di script, la VM non sprecare tempo a compilare le parti inutilizzate del.
V8 effettivamente utilizza due diversi compilatori di JavaScript. Mi piace pensare a loro come the simple compiler and the helper compiler. Il compilatore completo (di cui si parlerà in questo articolo) è un compilatore unoptimizing. Il suo lavoro è quello di produrre un codice nativo quanto più rapidamente possibile, il che è importante per il mantenimento della pagina tempi di carico scattanti. Albero a gomiti è il compilatore di ottimizzazione. V8 compila tutto prima con il compilatore completa, quindi utilizza un built-in profiler per selezionare “hot” funzioni di essere ottimizzato mediante albero a gomiti. Poiché V8 è prevalentemente single-threaded (come per la versione 3.14), l’esecuzione è in pausa mentre l’uno o l’altro compilatore è in esecuzione. Di conseguenza, entrambi i compilatori sono progettati per produrre rapidamente il codice invece di spendere un sacco di tempo alla produzione di molto codice efficiente. Nelle versioni future di V8, albero a gomiti (o almeno una sua porzione) verrà eseguito in un thread separato, in concomitanza con l’esecuzione di JavaScript, consentendo più costosi di ottimizzazione.
Perché non di bytecode?
Più macchine virtuali contengono un bytecode interprete, ma questo è soprattutto assente dal V8. Ci si potrebbe chiedere perché il compilatore completo esiste se non potrebbe essere più semplice per la compilazione di bytecode ed eseguire. Si scopre che la compilazione di unoptimized codice nativo non è in realtà molto più costoso di compilazione di bytecode. Considerare i processi di compilazione per entrambi:
Bytecode compilazione:
- Analisi di sintassi (parsing)
- Analisi di portata
- Tradurre albero di sintassi per bytecode
Compilazione nativo:
- Analisi di sintassi (parsing)
- Analisi di portata
- Tradurre albero di sintassi per nativi
In entrambi i casi abbiamo bisogno di analizzare il codice sorgente e produrre un albero sintattico astratto (AST). Abbiamo bisogno di eseguire analisi di portata, che ci indica se ciascun simbolo si riferisce ad una variabile locale, variabile di contesto (utilizzato da chiusure), o proprietà globale. La fase di traduzione è la sola parte che è diverso. È possibile fare molto elaborate le cose qui, ma se si desidera che il compilatore di essere il più veloce possibile, è molto bisogno di fare una traduzione diretta: ogni Syntax Tree node sarebbe ottenere tradotto in una sequenza fissa di bytecodes o istruzioni native.
Consideriamo ora come si potrebbe scrivere un interprete per il bytecode. Una navata attuazione sarebbe un loop che recupera il prossimo bytecode, entra in una grande istruzione switch, ed esegue una sequenza fissa di istruzioni. Ci sono various ways to improve su questo, ma essi si riconducono tutte alla stessa struttura.
Invece di generare bytecode e utilizzando un interprete loop, cosa succede se abbiamo appena emesso l’appropriata sequenza fissa di istruzioni per ogni operazione? Come succede, questo è esattamente come il compilatore completa opere. Questo elimina la necessità di un interprete e semplifica le transizioni tra unoptimized e ottimizzato il codice.
In generale, il bytecode è utile in situazioni in cui è possibile fare alcuni del compilatore di lavoro prima del tempo. Questo non è il caso all’interno di un web browser, in modo che il compilatore completa più senso per V8.
In linea di cache: accelerare il codice unoptimized
Se si guardano i ECMAScript spec, troverete che la maggior parte delle operazioni sono assurdamente complicata. Prendere l’ operatore + per esempio. Se entrambi gli operandi sono numeri, esegue oltre. Se un operando è una stringa, esegue la concatenazione delle stringhe. Se gli operandi sono qualcosa di diverso da numeri o stringhe, alcune complicate (eventualmente definito dall’utente) conversione alla primitiva si verifica prima che il numero sia aggiunta o la concatenazione delle stringhe. Solo guardando il codice sorgente, non vi è alcun modo per dire quali sono le istruzioni devono essere emessi. Carichi di proprietà (esempio: o.x) sono un buon esempio di un altro potenzialmente complessa operazione. Guardando il codice, non è possibile stabilire se si sta caricando un normale proprietà all’interno dell’oggetto (un “proprio” proprietà), una proprietà di un oggetto prototipo, un metodo getter o qualche magia di browser-definita richiamata. La proprietà può anche non esistere. Se state per gestire tutti i possibili casi in full-codice compilato, anche questa semplice operazione richiederebbe centinaia di istruzioni.
In linea di cache (ICs) offrono una soluzione elegante a questo problema. Un inline cache è fondamentalmente una funzione con molteplici possibili implementazioni (di solito generata al volo) che può essere chiamato per gestire una specifica operazione. Ho scritto in precedenza su polymorphic inline caches for function calls. V8 utilizza ICs per una serie molto più vasta di operazioni: il compilatore utilizza ICs per implementare i carichi, memorizza le chiamate, binario, unario e operatori di confronto, come pure la ToBoolean operazione implicita.
L’attuazione di un IC è chiamato uno stub. Stub si comportano come funzioni nel senso che li si chiama, e ritorno, ma non necessariamente impostare un frame dello stack e seguire la piena convenzione di chiamata. Gli stub sono solitamente generati al volo, ma per i comuni casi essi possono essere memorizzato nella cache e riutilizzato da più di ICs. Lo stub che implementa un IC contiene tipicamente un codice ottimizzato che gestisce i tipi di operandi che particolare IC ha incontrato in passato (che è il motivo per cui è chiamata cache). Se lo stub incontra un caso non è preparata a gestire, “salta” e chiede che il C++ runtime codice. Il runtime gestisce il caso, genera quindi un nuovo stub che può gestire il caso perse (nonché i casi precedenti). La chiamata al vecchio diramazioni in pieno il codice compilato è riscritto per chiamare il nuovo stub, e l’esecuzione riprende come se lo stub erano stati chiamati normalmente.
Diamo un esempio semplice: una proprietà di carico.
function f(o) { return o.x; }
Quando il compilatore completo prima di generare il codice per questa funzione, si utilizzerà un IC per il carico. Il IC inizia in stato non inizializzato, utilizzando un banale stub che non è in grado di gestire eventuali casi. Ecco come fare il pieno di codice compilato chiede lo stub.
;; full compiled call site ldr r0, [fp, #+8] ; load parameter "o" from stack ldr r2, [pc, #+84] ; load string "x" from constant pool ldr ip, [pc, #+84] ; load uninitialized stub from constant pool blx ip ; call the stub ... dd 0xabcdef01 ; address of stub loaded above ; this gets replaced when the stub misses
(siamo spiacenti se non si ha familiarità con il gruppo del braccio. Speriamo che le osservazioni rendono chiaro ciò che sta succedendo.)
Ecco il codice per lo stub non inizializzata:
;; uninitialized stub ldr ip, [pc, #8] ; load address of C++ runtime "miss" function bx ip ; tail call it ...
La prima volta che questo avanzo è chiamato, sarà “miss” e il runtime genererà il codice per gestire qualunque caso effettivamente causato la miss. In V8, il modo più comune per memorizzare una proprietà è in corrispondenza di un offset fisso all’interno di un oggetto, quindi vediamo un esempio. Ogni oggetto ha un puntatore a unamappa, che è principalmente una struttura immutabile che descrive la struttura dell’oggetto. Il carico di oggetti stub avrà il controllo dell’oggetto mappa contro un noto mappa (quello che si vede quando lo stub non inizializzata perse) di verificare velocemente l’oggetto ha la proprietà desiderata nella giusta posizione. Questa mappa controllare ci consente di evitare una costosa ricerca della tabella hash.
;; monomorphic in-object load stub tst r0, #1 ; test if receiver is an object beq miss ; miss if not ldr r1, [r0, #-1] ; load object's map ldr ip, [pc, #+24] ; load expected map cmp r1, ip ; are they the same? bne miss ; miss if not ldr r0, [r0, #+11] ; load the property bx lr ; return miss: ldr ip, [pc, #+8] ; load code to call the C++ runtime bx ip ; tail call it ...
Fintanto che questa espressione ha soltanto a che fare con le proprietà dell’oggetto di carichi, il carico può essere eseguita rapidamente con nessun ulteriore generazione di codice. Poiché l’IC può gestire solo un caso, è in un stato monomorfo. Se un altro caso viene in su, e l’IC manca ancora, uno stub megamorphic verrà generata che è più generale.
Per essere continuato…
Come si può vedere, il compilatore completo soddisfa il suo obiettivo di generare rapidamente ragionevolmente ben-esecuzione di codice di base. Poiché ICs sono usati estensivamente, pieno di codice compilato è molto generico, il che rende il compilatore completo molto semplice. L’ICs rendere il codice molto flessibile e in grado di gestire qualsiasi caso.
Nel prossimo articolo esamineremo come V8 rappresenta gli oggetti nella memoria, consentendo O(1) accesso nella maggior parte dei casi senza alcuna specifica strutturale dal programmatore (come una dichiarazione di classe).