Introduzione
Daniel Clifford ha tenuto un ottimo intervento alla Google I/O su suggerimenti e trucchi per migliorare il rendimento di JavaScript in V8. Daniel ci ha incoraggiati a "chiedere di più", ad analizzare attentamente le differenze di prestazioni tra C++ e JavaScript e a scrivere codice tenendo conto del funzionamento di JavaScript. In questo articolo è riportato un riepilogo dei punti più importanti del discorso di Daniel e lo aggiorneremo man mano che le indicazioni sul rendimento cambiano.
Il consiglio più importante
È importante contestualizzare qualsiasi consiglio sul rendimento. I consigli sul rendimento sono coinvolgenti e a volte concentrarsi prima su consigli approfonditi può distogliere l'attenzione dai problemi reali. Devi avere una visione olistica del rendimento della tua applicazione web. Prima di concentrarti su questi suggerimenti per il rendimento, ti consigliamo di analizzare il codice con strumenti come PageSpeed e di migliorare il punteggio. In questo modo eviterai un'ottimizzazione prematura.
Il miglior consiglio di base per ottenere un buon rendimento nelle applicazioni web è:
- Preparati prima che si verifichi (o notifichi) un problema
- Quindi, identifica e comprendi il nocciolo del problema.
- Infine, correggi ciò che conta
Per completare 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. È importante anche conoscere gli strumenti disponibili e come possono aiutarti. Daniel spiega meglio come utilizzare gli strumenti per sviluppatori nel suo intervento; questo documento illustra solo alcuni dei punti più importanti della progettazione del motore V8.
Passiamo ai suggerimenti per V8.
Classi nascoste
JavaScript ha informazioni limitate sui tipi a tempo di compilazione: i tipi possono essere modificati in fase di esecuzione, quindi è naturale aspettarsi che sia costoso ragionare sui tipi JS a tempo di compilazione. Questo potrebbe farti dubitare di come le prestazioni di JavaScript possano avvicinarsi a quelle di C++. Tuttavia, V8 ha tipi nascosti creati internamente per gli oggetti in fase di esecuzione; gli oggetti con lo stesso tipo nascosto 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 aggiuntivo ".z", p1 e p2 hanno internamente la stessa classe nascosta, quindi V8 può generare una singola versione dell'assembly ottimizzato per il codice JavaScript che manipola p1 o p2. Maggiore è la capacità di evitare la divergenza delle classi nascoste, migliore sarà il rendimento ottenuto.
Pertanto
- Inizializza tutti i membri dell'oggetto nelle funzioni del costruttore (in modo che le istanze non cambino tipo in un secondo momento)
- Inizializza sempre i membri dell'oggetto nello stesso ordine
Numeri
V8 utilizza il tagging per rappresentare i valori in modo efficiente quando i tipi possono cambiare. V8 deduce dal valore che utilizzi il tipo di numero con cui hai a che fare. Una volta effettuata questa deduzione, 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 tipi di numeri in modo coerente e, in generale, è ottimale utilizzare numeri interi con segno a 31 bit, se opportuno.
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 i valori numerici che possono essere rappresentati come interi con segno a 31 bit.
Array
Per gestire array grandi e sparsi, sono disponibili internamente due tipi di archiviazione degli array:
- Elementi rapidi: archiviazione lineare per insiemi di chiavi compatti
- Elementi del dizionario: spazio di archiviazione della tabella hash in caso contrario
È meglio non causare il passaggio dello spazio di archiviazione dell'array da un tipo all'altro.
Pertanto
- Utilizza chiavi contigue che iniziano da 0 per gli array
- Non preallocare array di grandi dimensioni (ad es.più di 64.000 elementi) alle dimensioni massime, 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 doppi sono più veloci: la classe nascosta dell'array monitora i tipi di elementi e gli array contenenti solo doppi non sono sottoposti a boxing (il che causa una modifica della classe nascosta). Tuttavia, la manipolazione incauta degli array può causare un lavoro extra a causa del boxing e del 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 doppi non destrutturati, ma poi l'assegnazione di a[3]
fa sì che venga riconvinto in un array che può contenere qualsiasi valore (numeri o oggetti). Nel secondo caso, il compilatore conosce i tipi di tutti gli elementi nel letterale e la classe nascosta può essere determinata in anticipo.
- Inizializzazione mediante letterali di array per piccoli array di dimensioni fisse
- Prealloca piccoli array (<64 KB) con le dimensioni corrette prima di utilizzarli
- Non archiviare valori non numerici (oggetti) in array numerici
- Fai attenzione a non causare la riconversione di piccoli array se esegui l'inizializzazione senza letterali.
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 codice JavaScript di Chrome) dispone di due diversi compilatori Just-In-Time (JIT), ovvero:
- Il compilatore "Full", che può generare codice valido per qualsiasi JavaScript
- Il compilatore ottimizzato, che produce un ottimo codice per la maggior parte del codice 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 codice buono, ma non ottimo. Questo compilatore non presuppone quasi nulla sui tipi in fase di compilazione, ma si aspetta che i tipi di variabili possano e debbano cambiare in fase di esecuzione. Il codice generato dal compilatore completo utilizza le cache in linea per perfezionare le conoscenze sui tipi durante l'esecuzione del programma, migliorando l'efficienza al volo.
Lo scopo delle cache in linea è 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 in linea per eseguire un collegamento ipertestuale all'operazione. Tuttavia, ciò significa che le operazioni che accettano più tipi avranno un rendimento inferiore.
Pertanto
- L'utilizzo monomorfo delle operazioni è preferito rispetto alle operazioni polimorfe
Le operazioni sono monomorfe se le classi nascoste degli input sono sempre le stesse, altrimenti sono polimorfe, il che significa che alcuni degli argomenti possono cambiare tipo in base alle diverse chiamate all'operazione. Ad esempio, la seconda chiamata a 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 ottimizzato
In parallelo con il compilatore completo, V8 ricompila le funzioni "hot" (ovvero le funzioni eseguite molte volte) con un compilatore ottimizzatore. Questo compilatore utilizza il feedback sul tipo per velocizzare il codice compilato, infatti utilizza i tipi presi dagli IC di cui abbiamo appena parlato.
Nel compilatore ottimizzato, le operazioni vengono inserite in modo speculativo (posizionate direttamente dove vengono chiamate). Ciò velocizza l'esecuzione (a costo dell'impronta in memoria), ma consente anche altre ottimizzazioni. Le funzioni e i costruttori monomorfi 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 autonoma "d8" del motore V8:
d8 --trace-opt primes.js
(questo registra i nomi delle funzioni ottimizzate in stdout).
Tuttavia, non tutte le funzioni possono essere ottimizzate: alcune funzionalità impediscono l'esecuzione del compilatore ottimizzatore su una determinata funzione (un "bail-out"). In particolare, al momento il compilatore ottimizzato esce dalle 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() { // Esegui qui il lavoro sensibile alle prestazioni }
try { perf_sensitive() } catch (e) { // Gestisci le eccezioni qui } ```
Queste indicazioni probabilmente cambieranno in futuro, man mano che attiveremo i blocchi try/catch nel compilatore ottimizzato. Puoi esaminare il modo in cui il compilatore ottimizzato esegue il 'estrazione di emergenza" per le funzioni utilizzando l'opzione "--trace-opt" con d8 come sopra, che fornisce ulteriori informazioni sulle funzioni per le quali è stato eseguito l'estrazione di emergenza:
d8 --trace-opt primes.js
De-ottimizzazione
Infine, l'ottimizzazione eseguita da questo compilatore è speculativa: a volte non funziona e torniamo indietro. Il processo di "deottimizzazione" elimina il codice ottimizzato e riprende l'esecuzione nel punto giusto nel codice del compilatore "completo". La nuova ottimizzazione potrebbe essere attivata di nuovo in un secondo momento, ma a breve termine l'esecuzione rallenta. In particolare, le modifiche alle classi nascoste delle variabili dopo l'ottimizzazione delle funzioni causano questa deottimizzazione.
Pertanto
- Evita modifiche nascoste alle classi 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 monitoraggio V8 a Chrome all'avvio:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```
Oltre a utilizzare la profilazione degli strumenti per sviluppatori, puoi utilizzare d8 per eseguire la profilazione:
% out/ia32.release/d8 primes.js --prof
Viene utilizzato il profiler del campionamento integrato, che acquisisce un campione ogni millisecondo e scrive il file v8.log.
In sintesi
Per prepararti a creare codice JavaScript efficiente, è importante identificare e comprendere il funzionamento del motore V8 con il tuo codice. Ancora una volta, il consiglio di base è:
- Preparati prima che si verifichi (o notifichi) un problema
- Quindi, identifica e comprendi il nocciolo del problema.
- Infine, correggi ciò che conta
Ciò significa che devi assicurarti che il problema riguardi il codice JavaScript, utilizzando prima altri strumenti come PageSpeed; eventualmente, riduci il codice 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 il talk di Daniel (e questo articolo) ti aiutino a capire meglio come V8 esegue JavaScript, ma assicurati di concentrarti anche sull'ottimizzazione dei tuoi algoritmi.