Suggerimenti per le prestazioni di JavaScript in V8

Chris Wilson
Chris Wilson

Introduzione

Daniel Clifford ha tenuto un eccellente discorso al Google I/O su suggerimenti utili per migliorare le prestazioni di JavaScript in V8. Daniele ci ha incoraggiati a "chiedere più velocemente" - Analizzare attentamente le differenze di prestazioni tra C++ e JavaScript e scrivere il codice tenendo presente il funzionamento di JavaScript. In questo articolo viene fornito un riepilogo dei punti più importanti dell'intervento di Daniele. Lo terremo aggiornato man mano che le indicazioni sul rendimento cambiano.

Il consiglio più importante

È importante contestualizzare i consigli sul rendimento. I consigli sulle prestazioni creano dipendenza e a volte concentrarsi prima sui consigli più approfonditi può distrarre molto dai problemi reali. Devi avere una visione olistica delle prestazioni della tua applicazione web. Prima di concentrarti su questi suggerimenti per le prestazioni, probabilmente dovresti analizzare il codice con strumenti come PageSpeed e aumentare il tuo punteggio. In questo modo, eviterai un'ottimizzazione prematura.

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

  • Prepararsi prima di riscontrare (o rilevare) un problema
  • Quindi, identifica e comprendi il punto cruciale del problema
  • Infine, correggi ciò che conta

Per eseguire questi passaggi, è importante capire in che modo V8 ottimizza JS, in modo da poter scrivere il codice pensando alla progettazione del runtime di JS. È importante anche conoscere gli strumenti a tua disposizione e come possono aiutarti. Nel suo discorso, Daniele fornisce ulteriori spiegazioni su come utilizzare gli strumenti per sviluppatori: questo documento illustra solo alcuni dei punti più importanti della progettazione del motore V8.

Passiamo ora ai consigli V8.

Corsi nascosti

JavaScript ha informazioni limitate sul tipo di compilazione: i tipi possono essere modificati in fase di runtime, quindi è naturale che sia costoso ragionare sui tipi JS in fase di compilazione. Questo potrebbe portarti a chiederti come le prestazioni di JavaScript possano mai avvicinarsi a C++. Tuttavia, V8 presenta tipi nascosti creati internamente per gli oggetti in fase di runtime; gli oggetti con la stessa classe nascosta possono 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é l'istanza dell'oggetto p2 non ha un membro aggiuntivo ".z" add, p1 e p2 internamente hanno 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 di divergere i corsi nascosti, migliori saranno le prestazioni.

Pertanto,

  • Inizializza tutti i membri degli oggetti nelle funzioni costruttore (in modo che le istanze non cambino tipo in seguito)
  • 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 che hai a che fare. Una volta che V8 ha effettuato questa inferenza, utilizza il tagging per rappresentare i valori in modo efficiente, poiché questi tipi possono cambiare in modo dinamico. Tuttavia, la modifica di questi tag di tipo comporta talvolta un costo, quindi è meglio utilizzare i tipi di numeri in modo coerente e, in generale, è consigliabile 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,

  • Prediligi 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 interna per array:

  • Elementi veloci: archiviazione lineare per set di tasti compatti
  • Elementi del dizionario: archiviazione della tabella hash in caso contrario

È meglio non fare in modo che lo spazio di archiviazione dell'array non passi da un tipo all'altro.

Pertanto,

  • Usa chiavi contigue che iniziano da 0 per gli array
  • Non pre-allocare array di grandi dimensioni (ad es.più di 64.000 elementi) alla dimensione massima, ma aumenta in base al consumo
  • Non eliminare gli 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 dati raddoppiati sono più veloci: la classe nascosta dell'array monitora i tipi di elementi, mentre gli array contenenti solo doppi vengono unboxed (il che causa un cambio di classe nascosto).Tuttavia, la manipolazione disinvoltura degli array può causare lavoro aggiuntivo a causa del boxing e dell'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 confezionati, 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 i valori letterali di array per array di dimensioni fisse piccole
  • Prealloca array di piccole dimensioni (< 64.000) alla dimensione corretta prima di utilizzarli
  • Non archiviare valori non numerici (oggetti) in array numerici
  • Fai attenzione a non causare la riconversione di array di piccole dimensioni se esegui l'inizializzazione senza valori letterali.

Compilation JavaScript

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

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

Compilatore completo

Nella V8, il compilatore Full viene eseguito su tutto il codice e inizia a eseguire il codice il prima possibile, generando rapidamente un codice buono ma non eccezionale. Questo compilatore non presuppone quasi nulla dei tipi al momento della compilazione: si aspetta che i tipi di variabili possano cambiare e cambino durante l'esecuzione. Il codice generato dal compilatore Full 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 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 e poi utilizza la cache in linea per eseguire l'operazione come scorciatoia. Tuttavia, ciò significa che le operazioni che accettano più tipi avranno prestazioni inferiori.

Pertanto,

  • L'uso monmorfico 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 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 redattore dell'ottimizzazione

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

Nel compilatore di ottimizzazione, le operazioni vengono in linea in modo speculativo (posizionate direttamente dove vengono chiamate). Questo accelera l'esecuzione (a scapito dell'ingombro della memoria), ma consente anche altre ottimizzazioni. Le funzioni e i costruttori monomorfi possono essere completamente integrati (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 caratteristiche impediscono al compilatore di ottimizzazione di essere eseguito su una determinata funzione (un "bail-out"). In particolare, il compilatore per l'ottimizzazione attualmente evita le funzioni con i blocchi di prova {} catch {}.

Pertanto,

  • Inserisci il codice sensibile alle prestazioni in una funzione nidificata se hai provato {} blocchi {} catch: ```js function perf_sensitive() { // Esegui operazioni sensibili alle prestazioni qui }

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

Questa guida probabilmente cambierà in futuro, man mano che abiliteremo i blocchi test/catch nel compilatore per l'ottimizzazione. Puoi esaminare in che modo il compilatore di ottimizzazione sta rinunciando alle funzioni utilizzando il comando "--trace-opt" con d8 come sopra, che offre maggiori informazioni su quali funzioni sono state ignorate:

d8 --trace-opt primes.js

Annullamento dell'ottimizzazione

Infine, l'ottimizzazione eseguita da questo compilatore è speculativa: a volte non funziona e facciamo un passo indietro. La procedura di "disattivazione" elimina il codice ottimizzato e ne riprende l'esecuzione nella posizione corretta il codice del compilatore. La riottimizzazione potrebbe essere attivata di nuovo in un secondo momento, ma nel breve periodo l'esecuzione rallenta. In particolare, l'applicazione di modifiche nelle classi nascoste di variabili dopo l'ottimizzazione delle funzioni causerà questa deottimizzazione.

Pertanto,

  • Evita modifiche nascoste alla classe nelle funzioni dopo averle ottimizzate

È possibile, come con altre ottimizzazioni, 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 a usare la profilazione degli strumenti per sviluppatori, puoi usare d8 anche per la profilazione:

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

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

In sintesi

È importante identificare e comprendere come funziona il motore V8 con il tuo codice per prepararti a creare un codice JavaScript ad alte prestazioni. Ancora una volta, il consiglio di base è:

  • Prepararsi prima di riscontrare (o rilevare) un problema
  • Quindi, identifica e comprendi il punto cruciale del problema
  • Infine, correggi ciò che conta

Questo significa che devi assicurarti che il problema sia presente nel tuo codice JavaScript, utilizzando prima altri strumenti come PageSpeed. riducendo i problemi in JavaScript puro (no DOM) prima di raccogliere le metriche, per poi utilizzare queste metriche per individuare i colli di bottiglia ed eliminare quelli importanti. Speriamo che il discorso di Daniele (e questo articolo) vi aiuti a capire meglio come V8 esegue JavaScript, ma assicuratevi di concentrarvi anche sull'ottimizzazione dei vostri algoritmi.

Riferimenti