Suggerimenti per le prestazioni di JavaScript in V8

Chris Wilson
Chris Wilson

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 incoraggiato a "richiedere più velocemente", per analizzare attentamente le differenze di prestazioni tra C++ e JavaScript e scrivere il codice tenendo presente il 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 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 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 è:

  • Prepararsi prima di riscontrare (o rilevare) un problema
  • Quindi, identifica e comprendi il nocciolo 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. 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 in fase 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 degli oggetti 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, perché 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, ove 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

  • Prediligi valori numerici che possono essere rappresentati come numeri 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 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 doppi sono più veloci: la classe nascosta dell'array monitora i tipi di elementi, mentre gli array contenenti solo doppi vengono unboxed (il che provoca un cambiamento di classe nascosto).Tuttavia, la manipolazione disinvolta 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 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 valore letterale e la classe nascosta può essere determinata in anticipo.

  • Inizializzazione mediante letterali di array per piccoli array di dimensioni fisse
  • 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 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 Ottimizzazione, che produce un ottimo codice per la maggior parte di JavaScript, ma richiede più tempo per la compilazione.

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 Full utilizza le cache in linea (IC) per perfezionare la conoscenza dei tipi durante l'esecuzione del programma, migliorando l'efficienza in tempo reale.

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 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 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, 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 esce dalle funzioni utilizzando l'opzione "--trace-opt" con d8 come sopra, che fornisce ulteriori informazioni sulle funzioni da cui è stato eseguito l'abbandono:

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, l'esecuzione di modifiche nelle classi nascoste di variabili dopo che le funzioni sono state ottimizzate causerà 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 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.

Riferimenti