Sugerencias de rendimiento para JavaScript en V8

Chris Wilson
Chris Wilson

Introducción

Daniel Clifford dio una excelente charla en Google I/O sobre sugerencias y trucos para mejorar el rendimiento de JavaScript en V8. Daniel nos animó a "exigir más rápido" para analizar cuidadosamente las diferencias de rendimiento entre C++ y JavaScript, y escribir código con atención al funcionamiento de JavaScript. En este artículo, se incluye un resumen de los puntos más importantes de la charla de Daniel. Además, lo mantendremos actualizado a medida que cambien las indicaciones sobre el rendimiento.

El consejo más importante

Es importante poner todos los consejos de rendimiento en contexto. Los consejos de rendimiento son adictivos y, a veces, centrarse primero en los consejos detallados puede distraer bastante los problemas reales. Debes tener una vista integral del rendimiento de tu aplicación web. Antes de enfocarte en estas sugerencias de rendimiento, deberías analizar tu código con herramientas como PageSpeed y mejorar tu puntuación. Esto te ayudará a evitar una optimización prematura.

El mejor consejo básico para obtener un buen rendimiento en aplicaciones web es:

  • Prepárate antes de tener (o advertir) un problema
  • Luego, identifica y comprende la clave del problema.
  • Finalmente, corrige lo importante

Para lograr estos pasos, puede ser importante comprender cómo V8 optimiza JS, de modo que puedas escribir código consciente del diseño del tiempo de ejecución de JS. También es importante que conozcas las herramientas disponibles y cómo pueden ayudarte. En su charla, Daniel explica más cómo usar las herramientas para desarrolladores: este documento solo captura algunos de los puntos más importantes del diseño del motor V8.

Pasemos a los consejos de V8.

Clases ocultas

JavaScript tiene información de tipos de tiempo de compilación limitada: los tipos se pueden cambiar en el tiempo de ejecución, por lo que es normal esperar que sea costoso razonar sobre los tipos de JS en el tiempo de compilación. Esto podría llevarte a cuestionar cómo el rendimiento de JavaScript podría acercarse a C++. Sin embargo, V8 tiene tipos ocultos creados internamente para objetos en el tiempo de ejecución. Los objetos con la misma clase oculta pueden usar el mismo código optimizado que se generó.

Por ejemplo:

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!```

Hasta que la instancia de objeto p2 tenga el miembro adicional “.z” agregado, p1 y p2 internamente tienen la misma clase oculta, por lo que V8 puede generar una sola versión de ensamblado optimizado para código JavaScript que manipula p1 o p2. Cuanto más puedas evitar que las clases ocultas diverjan, mejor será el rendimiento que obtendrás.

Por lo tanto:

  • Inicializa todos los miembros de los objetos en las funciones de constructor (para que las instancias no cambien de tipo más adelante).
  • Siempre inicializa los miembros de los objetos en el mismo orden

Numbers

V8 usa el etiquetado para representar valores de manera eficiente cuando los tipos pueden cambiar. V8 infiere a partir de los valores que usas el tipo de número con el que trabajas. Una vez que V8 hace esta inferencia, usa el etiquetado para representar los valores de manera eficiente, ya que estos tipos pueden cambiar de forma dinámica. Sin embargo, a veces hay un costo para cambiar estas etiquetas de tipo, por lo que es mejor usar los tipos de números de forma coherente y, en general, lo más óptimo es usar números enteros firmados de 31 bits cuando corresponda.

Por ejemplo:

var i = 42;  // this is a 31-bit signed integer
var j = 4.2;  // this is a double-precision floating point number```

Por lo tanto:

  • Opta por valores numéricos que puedan representarse como números enteros con firma de 31 bits.

Arrays

Para manejar arrays grandes y dispersos, existen dos tipos de almacenamiento de array a nivel interno:

  • Elementos rápidos: almacenamiento lineal para conjuntos de claves compactos
  • Elementos del diccionario: de lo contrario, se usa el almacenamiento de tablas hash

Es mejor no hacer que el almacenamiento del array cambie de un tipo a otro.

Por lo tanto:

  • Usa claves contiguas a partir de 0 para arreglos
  • No preasignes arrays grandes (p. ej., más de 64,000 elementos) a su tamaño máximo; en su lugar, aumenta a medida que avanzas.
  • No borres elementos de los arrays, especialmente los numéricos.
  • No cargues elementos borrados o sin inicializar:
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.
}

Además, los arrays de dobles son más rápidos: la clase oculta del array realiza un seguimiento de los tipos de elementos, y los arrays que solo contienen dobles se quitan (lo que provoca un cambio de clase oculto).Sin embargo, la manipulación descuidada de los arrays puede generar trabajo adicional debido al boxeo y al unboxing, p.ej.,

var a = new Array();
a[0] = 77;   // Allocates
a[1] = 88;
a[2] = 0.5;   // Allocates, converts
a[3] = true; // Allocates, converts```

es menos eficiente que:

var a = [77, 88, 0.5, true];

porque, en el primer ejemplo, las asignaciones individuales se realizan una tras otra y la asignación de a[2] hace que el array se convierta en un array de dobles sin caja. Sin embargo, la asignación de a[3] hace que se vuelva a convertir en un array que puede contener cualquier valor (objetos o números). En el segundo caso, el compilador conoce los tipos de todos los elementos en el literal, y la clase oculta se puede determinar de antemano.

  • Inicializa con literales de array para arrays de tamaño fijo pequeños.
  • Asigna previamente arrays pequeños (menos de 64,000) para corregir su tamaño antes de usarlos
  • No almacenes valores no numéricos (objetos) en arrays numéricos
  • Ten cuidado de no hacer que los arrays pequeños se vuelvan a convertir si inicializas sin literales.

Compilación de JavaScript

Aunque JavaScript es un lenguaje muy dinámico y sus implementaciones originales eran intérpretes, los motores modernos de tiempo de ejecución de JavaScript usan compilación. De hecho, V8 (JavaScript de Chrome) tiene dos compiladores Just-In-Time (JIT):

  • El plan de estudios "Full" que puede generar buen código para cualquier código de JavaScript
  • El compilador de optimización, que produce un excelente código para la mayor parte de JavaScript, pero tarda más en compilarse.

El compilador completo

En V8, el compilador Full se ejecuta en todo el código y comienza a ejecutarlo lo antes posible, lo que genera rápidamente un código bueno, pero no excelente. Este compilador no supone casi nada de los tipos en el tiempo de compilación; espera que los tipos de variables puedan cambiar y lo harán en el tiempo de ejecución. El código que genera el compilador completo utiliza cachés intercaladas (IC) para definir mejor el conocimiento sobre los tipos mientras se ejecuta el programa, lo que mejora la eficiencia sobre la marcha.

El objetivo de las cachés intercaladas es administrar tipos de forma eficiente, almacenando en caché código dependiente del tipo para las operaciones. cuando se ejecute el código, primero validará las suposiciones de tipo y, luego, usará la caché intercalada para agregar un atajo a la operación. Sin embargo, esto significa que las operaciones que aceptan varios tipos tendrán un rendimiento menor.

Por lo tanto:

  • Se prefiere el uso de operaciones monomórficas en lugar de las operaciones polimórficas

Las operaciones son monomórficas si las clases ocultas de entrada son siempre iguales; de lo contrario, son polimórficas, lo que significa que algunos de los argumentos pueden cambiar de tipo en diferentes llamadas a la operación. Por ejemplo, la segunda llamada add() de este ejemplo causa polimorfismo:

function add(x, y) {
  return x + y;
}

add(1, 2);      // + in add is monomorphic
add("a", "b");  // + in add becomes polymorphic```

El compilador optimizador

En paralelo con el compilador completo, V8 vuelve a compilar "hot" funciones (es decir, funciones que se ejecutan muchas veces) con un compilador optimizador. Este compilador usa comentarios de tipo para acelerar el código compilado. De hecho, usa los tipos tomados de los IC de los que acabamos de hablar.

En el compilador de optimización, las operaciones se intercalan de manera especulativa (se colocan directamente donde se llaman). Esto acelera la ejecución (a costa de la huella en memoria), pero también habilita otras optimizaciones. Las funciones y los constructores monomórficos se pueden integrar por completo (ese es otro motivo por el que el monomorfismo es una buena idea en V8).

Puedes registrar lo que se optimiza con el “d8” independiente versión del motor V8:

d8 --trace-opt primes.js

(esto registra los nombres de las funciones optimizadas en stdout).

Sin embargo, no todas las funciones se pueden optimizar; algunas evitan que el compilador de optimización se ejecute en una función determinada (un "resguardo"). En particular, el compilador de optimización actualmente se encarga de las funciones con los bloques de prueba {} catch {}.

Por lo tanto:

  • Coloca código sensible al rendimiento en una función anidada si pruebas con los bloques {} catch {}: ```js function perf_sensitive() { // Haga aquí un trabajo que tenga en cuenta el rendimiento }.

intenta { perf_sensitive() } catch (e) { // Gestionar excepciones aquí }. ```

Es probable que esta guía cambie en el futuro, a medida que habilitemos bloques try/catch en el compilador de optimización. Puedes examinar cómo el compilador de optimización se protege de las funciones con "--trace-opt" con d8, como se muestra más arriba, que te brinda más información sobre qué funciones se ejecutaron:

d8 --trace-opt primes.js

Desoptimización

Por último, la optimización realizada por este compilador es especulativa: a veces, no funciona y nos retractamos. Proceso de "desoptimización" lanza código optimizado y reanuda la ejecución en el lugar correcto, completamente el código del compilador. Es posible que la reoptimización se active de nuevo más adelante, pero, a corto plazo, la ejecución se ralentizará. En particular, provocar cambios en las clases ocultas de variables después de que se hayan optimizado las funciones provocará esta desoptimización.

Por lo tanto:

  • Evita los cambios de clase ocultos en las funciones después de que estén optimizadas

Al igual que con otras optimizaciones, puedes obtener un registro de las funciones que V8 tuvo que anular con una marca de registro:

d8 --trace-deopt primes.js

Otras herramientas de V8

Por cierto, también puedes pasar las opciones de registro de V8 a Chrome durante el inicio:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```

Además de usar la generación de perfiles de las herramientas para desarrolladores, también puedes usar d8 para generar perfiles:

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

Para ello, se usa el generador de perfiles de muestreo integrado, que toma una muestra cada milisegundo y escribe v8.log.

En resumen

Es importante identificar y comprender cómo funciona el motor V8 con tu código a fin de prepararte para compilar JavaScript con buen rendimiento. Una vez más, el consejo básico es:

  • Prepárate antes de tener (o advertir) un problema
  • Luego, identifica y comprende la clave del problema.
  • Finalmente, corrige lo importante

Esto significa que debes asegurarte de que el problema esté en tu JavaScript, utilizando primero otras herramientas como PageSpeed. tal vez reducir a JavaScript puro (sin DOM) antes de recopilar métricas y, luego, usar esas métricas para localizar cuellos de botella y eliminar los importantes. Esperamos que la charla de Daniel (y este artículo) te ayude a comprender mejor cómo ejecuta JavaScript V8, pero asegúrate de enfocarte también en optimizar tus propios algoritmos.

Referencias