Introduzione
Daniel Clifford ha tenuto un eccellente discorso al Google I/O su suggerimenti utili per migliorare le prestazioni di JavaScript in V8. Daniel ci ha incoraggiato a "esigere più velocità", ad analizzare attentamente le differenze di prestazioni tra C++ e JavaScript e a scrivere codice tenendo conto del funzionamento di JavaScript. In questo articolo viene riportato un riepilogo dei punti più importanti dell'intervento di Daniel, che verrà aggiornato man mano che cambiano le indicazioni sul rendimento.
Il consiglio più importante
È importante contestualizzare qualsiasi consiglio sul rendimento. I consigli sul rendimento creano dipendenza e, a volte, concentrarsi prima su consigli approfonditi può distrarre dai problemi reali. Devi avere una visione olistica delle prestazioni della tua applicazione web. Prima di concentrarti su questi suggerimenti per le prestazioni, dovresti probabilmente analizzare il tuo codice con strumenti come PageSpeed e migliorare il tuo punteggio. In questo modo eviterai un'ottimizzazione prematura.
Il miglior consiglio di base per ottenere un buon rendimento nelle applicazioni web è:
- Preparati prima di riscontrare (o notare) un problema
- Poi, identifica e comprendi il nocciolo del problema.
- Infine, correggi ciò che conta
Per eseguire questi passaggi, può essere importante capire in che modo V8 ottimizza JS, in modo da poter scrivere codice tenendo conto del design del runtime JS. È anche importante conoscere gli strumenti disponibili e come possono aiutarti. Daniel fornisce ulteriori spiegazioni su come utilizzare gli strumenti per sviluppatori nel suo intervento; questo documento illustra solo alcuni dei punti più importanti della progettazione del motore V8.
Quindi, passiamo ai suggerimenti per V8.
Corsi nascosti
JavaScript ha informazioni limitate sui tipi in tempo di compilazione: i tipi possono essere modificati in tempo di esecuzione, quindi è naturale aspettarsi che sia costoso ragionare sui tipi JS in tempo di compilazione. Questo potrebbe farti dubitare che le prestazioni di JavaScript possano mai avvicinarsi a quelle di C++. Tuttavia, V8 ha tipi nascosti creati internamente per gli oggetti in fase di runtime; gli oggetti con la stessa classe nascosta possono quindi utilizzare lo stesso codice generato ottimizzato.
Ad esempio:
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!```
Finché all'istanza dell'oggetto p2 non viene aggiunto un membro ".z", p1 e p2 hanno internamente la stessa classe nascosta, quindi V8 può generare una singola versione di assembly ottimizzato per il codice JavaScript che manipola p1 o p2. Più riesci a evitare che le classi nascoste divergano, migliori saranno le prestazioni che otterrai.
Pertanto
- Inizializza tutti i membri degli oggetti nelle funzioni di costruttore (in modo che le istanze non cambino tipo in un secondo momento)
- Inizializza sempre i membri degli oggetti nello stesso ordine
Numeri
V8 utilizza il tagging per rappresentare i valori in modo efficiente quando i tipi possono cambiare. V8 deduce dai valori che utilizzi il tipo di numero con cui hai a che fare. Una volta effettuata questa inferenza, V8 utilizza il tagging per rappresentare i valori in modo efficiente, poiché questi tipi possono cambiare dinamicamente. Tuttavia, a volte la modifica di questi tag di tipo comporta un costo, quindi è meglio utilizzare i tipi di numeri in modo coerente e, in generale, è più ottimale utilizzare numeri interi con segno a 31 bit, se appropriato.
Ad esempio:
var i = 42; // this is a 31-bit signed integer
var j = 4.2; // this is a double-precision floating point number```
Pertanto
- Preferisci valori numerici che possono essere rappresentati come numeri interi con segno a 31 bit.
Array
Per gestire array di grandi dimensioni e sparsi, esistono due tipi di archiviazione degli array internamente:
- Fast Elements: archiviazione lineare per set di chiavi compatti
- Elementi del dizionario: archiviazione della tabella hash in caso contrario
È meglio evitare che lo spazio di archiviazione dell'array passi da un tipo all'altro.
Pertanto
- Utilizza chiavi contigue che iniziano da 0 per gli array
- Non preallocare array di grandi dimensioni (ad es. > 64.000 elementi) alla loro dimensione massima, ma aumentali man mano che procedi
- Non eliminare elementi negli array, in particolare negli array numerici
- Non caricare elementi non inizializzati o eliminati:
for (var b = 0; b < 10; b++) {
a[0] |= b; // Oh no!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
a[0] |= b; // Much better! 2x faster.
}
Inoltre, gli array di valori double sono più veloci: la classe nascosta dell'array tiene traccia dei tipi di elementi e gli array contenenti solo valori double vengono unboxed (il che causa una modifica della classe nascosta). Tuttavia, una manipolazione incauta degli array può causare un lavoro aggiuntivo a causa di boxing e unboxing, ad esempio
var a = new Array();
a[0] = 77; // Allocates
a[1] = 88;
a[2] = 0.5; // Allocates, converts
a[3] = true; // Allocates, converts```
è meno efficiente di:
var a = [77, 88, 0.5, true];
perché nel primo esempio le singole assegnazioni vengono eseguite una dopo l'altra e l'assegnazione di a[2] fa sì che l'array venga convertito in un array di double unboxed, ma poi l'assegnazione di a[3] fa sì che venga riconvertito in un array che può contenere qualsiasi valore (numeri o oggetti). Nel secondo caso, il compilatore conosce i tipi di tutti gli elementi nel valore letterale e la classe nascosta può essere determinata in anticipo.
- Inizializza utilizzando espressioni letterali dell'array per array di piccole dimensioni fisse
- Prealloca gli array di piccole dimensioni (<64k) alla dimensione corretta prima di utilizzarli
- Non archiviare valori non numerici (oggetti) in array numerici
- Se l'inizializzazione avviene senza valori letterali, fai attenzione a non causare la riconversione di array di piccole dimensioni.
Compilazione JavaScript
Sebbene JavaScript sia un linguaggio molto dinamico e le sue implementazioni originali fossero interpreti, i moderni motori di runtime JavaScript utilizzano la compilazione. V8 (il motore JavaScript di Chrome) ha due diversi compilatori JIT (Just-In-Time):
- Il compilatore "Full", che può generare un buon codice per qualsiasi JavaScript
- Il compilatore di ottimizzazione, che produce un codice eccellente per la maggior parte di JavaScript, ma richiede più tempo per la compilazione.
Il compilatore completo
In V8, il compilatore completo viene eseguito su tutto il codice e inizia a eseguirlo il prima possibile, generando rapidamente un codice buono, ma non eccellente. Questo compilatore non fa quasi nessuna ipotesi sui tipi in tempo di compilazione, ma si aspetta che i tipi di variabili possano cambiare e cambieranno in tempo di esecuzione. Il codice generato dal compilatore completo utilizza le cache in linea (IC) per perfezionare la conoscenza dei tipi durante l'esecuzione del programma, migliorando l'efficienza in tempo reale.
L'obiettivo delle cache inline è gestire i tipi in modo efficiente, memorizzando nella cache il codice dipendente dal tipo per le operazioni. Quando il codice viene eseguito, convalida prima le ipotesi sul tipo, quindi utilizza la cache inline per abbreviare l'operazione. Tuttavia, ciò significa che le operazioni che accettano più tipi saranno meno efficienti.
Pertanto
- L'utilizzo monomorfico delle operazioni è preferibile alle operazioni polimorfiche
Le operazioni sono monomorfiche se le classi nascoste degli input sono sempre le stesse, altrimenti sono polimorfiche, il che significa che alcuni degli argomenti possono cambiare tipo tra diverse chiamate all'operazione. Ad esempio, la seconda chiamata add() in questo esempio causa il polimorfismo:
function add(x, y) {
return x + y;
}
add(1, 2); // + in add is monomorphic
add("a", "b"); // + in add becomes polymorphic```
Il compilatore di ottimizzazione
In parallelo con il compilatore completo, V8 ricompila le funzioni "hot" (ovvero le funzioni che vengono eseguite molte volte) con un compilatore di ottimizzazione. Questo compilatore utilizza il feedback sui tipi per velocizzare il codice compilato. Di fatto, utilizza i tipi presi dagli IC di cui abbiamo appena parlato.
Nel compilatore di ottimizzazione, le operazioni vengono inserite in modo speculativo (posizionate direttamente dove vengono chiamate). In questo modo l'esecuzione è più rapida (a scapito del footprint della memoria), ma sono possibili anche altre ottimizzazioni. Le funzioni e i costruttori monomorfici possono essere inseriti completamente in linea (questo è un altro motivo per cui il monomorfismo è una buona idea in V8).
Puoi registrare ciò che viene ottimizzato utilizzando la versione "d8" autonoma del motore V8:
d8 --trace-opt primes.js
(In questo modo, i nomi delle funzioni ottimizzate vengono registrati in stdout).
Tuttavia, non tutte le funzioni possono essere ottimizzate: alcune funzionalità impediscono l'esecuzione del compilatore di ottimizzazione su una determinata funzione (un "bail-out"). In particolare, il compilatore di ottimizzazione attualmente non supporta le funzioni con blocchi try {} catch {}.
Pertanto
- Inserisci il codice sensibile alle prestazioni in una funzione nidificata se hai blocchi try {} catch {}: ```js function perf_sensitive() { // Do performance-sensitive work here }
try { perf_sensitive() } catch (e) { // Handle exceptions here } ```
Queste indicazioni probabilmente cambieranno in futuro, man mano che attiveremo i blocchi try/catch nel compilatore di ottimizzazione. Puoi esaminare il modo in cui il compilatore di ottimizzazione esce dalle funzioni utilizzando l'opzione "--trace-opt" con d8 come sopra, che ti fornisce ulteriori informazioni sulle funzioni da cui è uscito:
d8 --trace-opt primes.js
De-ottimizzazione
Infine, l'ottimizzazione eseguita da questo compilatore è speculativa: a volte non funziona e viene annullata. Il processo di "deottimizzazione" elimina il codice ottimizzato e riprende l'esecuzione nel punto giusto del codice del compilatore "completo". La riottimizzazione potrebbe essere attivata di nuovo in un secondo momento, ma a breve termine l'esecuzione rallenta. In particolare, la deottimizzazione si verifica se vengono apportate modifiche alle classi nascoste delle variabili dopo l'ottimizzazione delle funzioni.
Pertanto
- Evita modifiche alle classi nascoste nelle funzioni dopo che sono state ottimizzate
Come per altre ottimizzazioni, puoi ottenere un log delle funzioni che V8 ha dovuto deottimizzare con un flag di logging:
d8 --trace-deopt primes.js
Altri strumenti V8
A proposito, puoi anche passare le opzioni di tracciamento V8 a Chrome all'avvio:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```
Oltre alla profilazione degli strumenti per sviluppatori, puoi utilizzare d8 per eseguire la profilazione:
% out/ia32.release/d8 primes.js --prof
In questo modo viene utilizzato il profiler di campionamento integrato, che acquisisce un campione ogni millisecondo e scrive v8.log.
In breve
È importante identificare e comprendere il funzionamento del motore V8 con il tuo codice per prepararti a creare JavaScript ad alte prestazioni. Ancora una volta, il consiglio di base è:
- Preparati prima di riscontrare (o notare) un problema
- Poi, identifica e comprendi il nocciolo del problema.
- Infine, correggi ciò che conta
Ciò significa che devi assicurarti che il problema risieda nel tuo JavaScript, utilizzando prima altri strumenti come PageSpeed; possibilmente riducendo a JavaScript puro (senza DOM) prima di raccogliere le metriche, quindi utilizza queste metriche per individuare i colli di bottiglia ed eliminare quelli importanti. Ci auguriamo che l'intervento di Daniel (e questo articolo) ti aiutino a capire meglio come V8 esegue JavaScript, ma assicurati di concentrarti anche sull'ottimizzazione dei tuoi algoritmi.