Suggerimenti per le prestazioni di JavaScript in V8

Chris Wilson
Chris Wilson

Introduzione

Daniel Clifford ha tenuto un'ottima presentazione alla conferenza Google I/O su suggerimenti utili per migliorare le prestazioni di JavaScript nella V8. Daniel ci ha incoraggiato a "richiedere più velocemente", ad analizzare attentamente le differenze di prestazioni tra C++ e JavaScript e a scrivere il codice in modo consapevole del funzionamento di JavaScript. In questo articolo viene fornito un riepilogo dei punti più importanti dell'intervento di Daniele; lo terremo inoltre aggiornato man mano che le linee guida sul rendimento cambiano.

Il consiglio più importante

È importante contestualizzare qualsiasi consiglio sul rendimento. I consigli per il rendimento danno dipendenza e, a volte, dare prima una consulenza approfondita può distrarre dai problemi effettivi. Devi avere una visione olistica delle prestazioni della tua applicazione web. Prima di concentrarti su questi suggerimenti per il rendimento, probabilmente dovresti analizzare il codice con strumenti come PageSpeed e ottenere un aumento del punteggio. In questo modo eviterai un'ottimizzazione prematura.

Il miglior consiglio di base per ottenere buone prestazioni nelle applicazioni web è:

  • Preparati prima che tu abbia (o rilevi) un problema
  • Quindi, identifica e comprendi il punto cruciale del tuo problema
  • Infine, correggi ciò che conta

Per completare questi passaggi, può essere importante capire come V8 ottimizza JS, in modo da poter scrivere codice tenendo presente il 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 discorso; questo documento descrive solo alcuni dei punti più importanti del design del motore V8.

Passiamo ai suggerimenti V8!

Corsi nascosti

JavaScript ha informazioni di tipo limitate in fase di compilazione: i tipi possono essere modificati in fase di runtime, quindi è naturale aspettarsi che sia costoso ragionare sui tipi di JS in fase di compilazione. Questo potrebbe portarti a chiederti come le prestazioni di JavaScript potrebbero avvicinarsi a 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 viene aggiunto un altro membro ".z", p1 e p2 internamente hanno la stessa classe nascosta, quindi V8 può generare una singola versione dell'assemblaggio ottimizzato per il codice JavaScript che manipola p1 o p2. Maggiore è la capacità di evitare di causare la divergenza delle classi nascoste, migliore sarà il rendimento.

Pertanto

  • Inizializza tutti i membri dell'oggetto nelle funzioni del costruttore (in modo che le istanze non cambino tipo in seguito)
  • 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 dai valori che utilizzi il tipo di numero con cui hai a che fare. Una volta che V8 ha effettuato questa inferenza, utilizza il tagging per rappresentare i valori in modo efficiente, perché questi tipi possono cambiare in modo dinamico. 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, è preferibile utilizzare numeri interi con segno a 31 bit, ove 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 grandi e sparsi, esistono due tipi di archiviazione array internamente:

  • Elementi rapidi: archiviazione lineare per set di chiavi compatti
  • Elementi del dizionario: altrimenti archiviazione della tabella hash

È preferibile non far passare lo 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. > 64.000 elementi) alla loro dimensione massima, ma ampliali man mano che procedi
  • Non eliminare elementi negli array, in particolare quelli 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 tiene traccia dei tipi di elementi, mentre gli array contenenti solo i doppi vengono unboxed (il che determina una modifica nascosta della classe).Tuttavia, la manipolazione non curata degli array può causare lavoro aggiuntivo dovuto al boxe e all'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 convertita in un Array di doppi senza riquadri, ma poi l'assegnazione di a[3] fa sì che venga riconvertita in un Array che può contenere valori (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 valori letterali di array per array di piccole dimensioni a dimensioni fisse
  • Prealloca array piccoli (<64.000) alle dimensioni corrette prima di utilizzarli
  • Non memorizzare valori non numerici (oggetti) in array numerici
  • Fai attenzione a non causare una nuova conversione di array di piccole dimensioni se esegui l'inizializzazione senza valori letterali.

Compilazione JavaScript

Sebbene JavaScript sia un linguaggio molto dinamico e le sue implementazioni originali fossero interpretatori, i moderni motori di runtime JavaScript utilizzano la compilazione. In realtà la V8 (JavaScript di Chrome) ha due diversi compilatori Just-In-Time (JIT):

  • Il compilatore "Completo", che può generare un codice valido per qualsiasi codice JavaScript
  • Il compilatore Optimizing, che produce un ottimo codice per la maggior parte di JavaScript, ma richiede più tempo per la compilazione.

Compilatore completo

Nella versione V8, il compilatore completo viene eseguito su tutto il codice e inizia a eseguire il codice appena possibile, generando rapidamente codice valido ma non di qualità. Questo compilatore non presuppone quasi nulla sui tipi al momento della compilazione, si aspetta che i tipi di variabili possano e cambieranno durante il runtime. Il codice generato dal compilatore completo utilizza le cache in linea (IC) per perfezionare le informazioni sui tipi durante l'esecuzione del programma, migliorando l'efficienza all'istante.

L'obiettivo 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 dei tipi e poi utilizza la cache in linea per abbreviare l'operazione. Tuttavia, ciò significa che le operazioni che accettano più tipi avranno meno prestazioni.

Pertanto

  • L'uso monomorfico delle operazioni è preferito rispetto 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 argomenti possono cambiare tipo in chiamate diverse 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 che ottimizza

Parallelamente al compilatore completo, V8 ricompila le funzioni "hot" (ovvero le funzioni che vengono eseguite molte volte) con un compilatore ottimizzato. Questo compilatore utilizza il feedback sul tipo per rendere più veloce il codice compilato, infatti utilizza i tipi presi dagli IC di cui abbiamo appena parlato.

Nel compilatore di ottimizzazione, le operazioni vengono incorporate in modo speculativo (posizionate direttamente dove vengono chiamate). Questo accelera l'esecuzione (a fronte del costo dell'ingombro di memoria), ma consente anche altre ottimizzazioni. Le funzioni e i costruttori monomorfici possono essere incorporati completamente (questo è un altro motivo per cui il monomorfismo è una buona idea nella versione 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 caratteristiche impediscono l'esecuzione del compilatore di ottimizzazione in una determinata funzione (un "bail-out"). In particolare, il compilatore di ottimizzazione al momento salva le funzioni con i blocchi {} catch {} per provare.

Pertanto

  • Inserisci il codice perf-sensitive in una funzione nidificata se hai provato {} catch {} blocchi: ```js function perf_sensitive() { // Do performance-sensitive work here }

prova { perf_sensitive() } catch (e) { // Gestisci le eccezioni qui } ```

Queste indicazioni probabilmente cambieranno in futuro, man mano che abilitiamo i blocchi provi/catch nel compilatore di ottimizzazione. Potete esaminare come il compilatore di ottimizzazione stia salvando le funzioni usando l'opzione "--trace-opt" con d8 come sopra, che fornisce maggiori informazioni su quali funzioni sono state escluse:

d8 --trace-opt primes.js

Disattivazione dell'ottimizzazione

Infine, l'ottimizzazione eseguita da questo compilatore è speculativa: a volte non funziona e ci rimettiamo in funzione. Il processo di "disottimizzazione" elimina il codice ottimizzato e riprende l'esecuzione nel punto giusto nel codice di compilazione "completo". La riottimizzazione potrebbe essere attivata di nuovo in un secondo momento, ma nel breve periodo l'esecuzione rallenta. In particolare, se si verificano modifiche nelle classi nascoste di variabili dopo l'ottimizzazione delle funzioni, si verificherà questa disattivazione.

Pertanto

  • Evitare modifiche nascoste alla classe nelle funzioni dopo che sono state ottimizzate

Come con altre ottimizzazioni, puoi ottenere un log delle funzioni che V8 ha dovuto disattivare l'ottimizzazione 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 anche per la profilazione:

% out/ia32.release/d8 primes.js --prof

Questo utilizza il profiler di campionamento integrato, che prende un campione ogni millisecondo e scrive v8.log.

In sintesi

Per preparare la creazione di codice JavaScript ad alte prestazioni, è importante identificare e capire come funziona il motore V8 con il codice. Ancora una volta, il consiglio di base è:

  • Preparati prima che tu abbia (o rilevi) un problema
  • Quindi, identifica e comprendi il punto cruciale del tuo problema
  • Infine, correggi ciò che conta

Ciò significa che devi assicurarti che il problema sia presente nel tuo codice JavaScript, utilizzando prima altri strumenti come PageSpeed, possibilmente riducendo al minimo JavaScript (senza DOM) prima di raccogliere le metriche, quindi utilizzare queste metriche per individuare i colli di bottiglia ed eliminare quelli importanti. Speriamo che il discorso di Daniele (e questo articolo) ti aiuti a capire meglio come la V8 esegue JavaScript, ma assicurati di concentrarti anche sull'ottimizzazione dei tuoi algoritmi.

Riferimenti