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 alentó a “demandar más rápido” para analizar cuidadosamente las diferencias de rendimiento entre C++ y JavaScript, y escribir código teniendo en cuenta cómo funciona JavaScript. En este artículo, se incluye un resumen de los puntos más importantes de la charla de Daniel. Este artículo también se actualizará a medida que cambien las pautas de rendimiento.

El consejo más importante

Es importante poner en contexto los consejos sobre el rendimiento. Los consejos de rendimiento son adictivos y, a veces, enfocarse primero en los consejos detallados puede distraer mucho de los problemas reales. Debes tener una vista integral del rendimiento de tu aplicación web. Antes de enfocarte en esta sugerencia de rendimiento, probablemente deberías analizar tu código con herramientas como PageSpeed y subir tu puntuación. Esto te ayudará a evitar una optimización prematura.

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

  • Prepárate antes de tener (o notar) un problema
  • Luego, identifica y comprende el punto crucial de tu problema
  • Por último, corrige lo importante

Para llevar a cabo estos pasos, puede ser importante que comprendas cómo V8 optimiza JS, de modo que puedas escribir código teniendo en cuenta el diseño del tiempo de ejecución de JS. También es importante que conozcas las herramientas disponibles y cómo pueden ayudarte. Daniel explica con más detalle cómo usar las herramientas para desarrolladores en su charla. Este documento solo captura algunos de los puntos más importantes del diseño del motor V8.

Pasemos a los consejos para V8.

Clases ocultas

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

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”, p1 y p2 internamente tendrán la misma clase oculta; por lo tanto, V8 puede generar una sola versión de ensamblado optimizado para código JavaScript que manipule p1 o p2. Cuanto más evites que las clases ocultas diverjan, mejor rendimiento obtendrás.

Por lo tanto:

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

Números

V8 usa el etiquetado para representar valores de manera eficaz cuando los tipos pueden cambiar. V8 infiere el tipo de número con el que estás trabajando a partir de los valores que usas. Luego de que V8 haya hecho esta inferencia, usa etiquetas para representar valores de manera eficaz, ya que estos tipos pueden cambiar de forma dinámica. Sin embargo, cambiar estas etiquetas de tipo a veces tiene un costo, por lo que es mejor usar tipos de números de manera coherente y, en general, lo más óptimo es usar números enteros de 31 bits firmados 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 firmados de 31 bits.

Arrays

Para controlar arrays grandes y dispersos, hay dos tipos de almacenamiento interno de arrays:

  • Elementos rápidos: almacenamiento lineal para conjuntos de claves compactas
  • Elementos del diccionario: De lo contrario, se almacena la tabla hash

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

Por lo tanto:

  • Usa claves contiguas que comiencen en 0 para arrays
  • No asignes previamente arrays grandes (p. ej., > 64,000 elementos) a su tamaño máximo; en su lugar, aumenta el tamaño a medida que avanzas.
  • No borres elementos de los arrays, especialmente de 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 dobles son más rápidos: la clase oculta del array hace un seguimiento de los tipos de elementos y los arrays que solo contienen dobles se desempaquetan (lo que provoca un cambio de clase oculto).Sin embargo, la manipulación descuidada de los arrays puede causar trabajo adicional debido al empaquetado y al desempaque, 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 desempaquetados, pero, luego, 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 pequeños de tamaño fijo
  • Asigna previamente arrays pequeños (<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 causar una reconversión de arreglos pequeños si realizas la inicialización sin literales.

Compilación de JavaScript

Si bien JavaScript es un lenguaje muy dinámico y sus implementaciones originales eran intérpretes, los motores modernos del entorno de ejecución de JavaScript utilizan la compilación. V8 (JavaScript de Chrome) tiene dos compiladores Just-In-Time (JIT) diferentes, de hecho:

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

El compilador completo

En V8, el compilador completo se ejecuta en todo el código y comienza a ejecutar código lo antes posible, lo que genera rápidamente un código bueno, pero no excelente. Este compilador no asume casi nada sobre los tipos en el momento de la compilación; espera que los tipos de variables puedan cambiar y puedan cambiar en el tiempo de ejecución. El código que genera el compilador completo usa cachés intercaladas (IC) para perfeccionar 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 manejar los tipos de forma eficiente, almacenando en caché el código dependiente del tipo para operaciones. Cuando el código se ejecute, primero validará las suposiciones de tipo y, luego, usará la caché intercalada para acceder a la operación. Sin embargo, esto significa que las operaciones que aceptan múltiples tipos tendrán menos rendimiento.

Por lo tanto:

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

Las operaciones son monomórficas si las clases ocultas de entradas son siempre las mismas. De lo contrario, son polimórficas, lo que significa que algunos de los argumentos pueden cambiar de tipo en las 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 de optimización

Al mismo tiempo que el compilador completo, V8 vuelve a compilar las funciones "calientes" (es decir, funciones que se ejecutan muchas veces) con un compilador de optimización. Este compilador usa comentarios de tipos 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 del espacio en memoria), pero también habilita otras optimizaciones. Las funciones y constructores monomórficos se pueden integrar por completo (esa es otra razón por la que el monomorfismo es una buena idea en V8).

Puedes registrar lo que se optimiza con la versión independiente "d8" 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 funciones evitan que el compilador de optimización se ejecute en una función determinada (un "rescate"). En particular, el compilador de optimización actualmente responde a las funciones con la prueba de bloques {} catch {}.

Por lo tanto:

  • Coloca el código sensible al rendimiento en una función anidada si pruebas {} catch {} block: ```js function perf_sensitive() { // Haz trabajos sensibles al rendimiento aquí }

try { perf_sensitive() } catch (e) { // Administra excepciones aquí } ```

Es probable que esta guía cambie en el futuro, cuando habilitemos bloques try/catch en el compilador de optimización. Puedes examinar cómo el compilador de optimización mejora las funciones usando la opción "--trace-opt" con d8 como se muestra arriba, lo que te brinda más información sobre las funciones que se descartaron:

d8 --trace-opt primes.js

De optimización

Por último, la optimización que realiza este compilador es especulativa. A veces, no funciona y nos retiramos. El proceso de "desoptimización" desecha el código optimizado y reanuda la ejecución en el lugar correcto en el código de compilador "completo". Es posible que la reoptimización se active de nuevo más adelante, pero, a corto plazo, la ejecución se vuelve más lenta. En particular, esta acción se producirá si se realizan cambios en las clases ocultas de variables después de que se hayan optimizado las funciones.

Por lo tanto:

  • Evita los cambios de clase ocultos en las funciones después de que se hayan optimizado.

Al igual que con otras optimizaciones, puedes obtener un registro de las funciones que V8 tuvo que anular la optimización 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 al 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 generarla:

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

Esto utiliza el generador de perfiles de muestras 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 para prepararse para compilar JavaScript de alto rendimiento. Una vez más, el consejo básico es el siguiente:

  • Prepárate antes de tener (o notar) un problema
  • Luego, identifica y comprende el punto crucial de tu problema
  • Por último, corrige lo importante

Esto significa que debes asegurarte de que el problema se encuentre en tu JavaScript. Para ello, usa primero otras herramientas como PageSpeed. Posiblemente, lo reduzcas a JavaScript puro (sin DOM) antes de recopilar métricas y, luego, úsalas para localizar cuellos de botella y eliminar los importantes. Esperamos que la charla de Daniel (y este artículo) te ayuden a comprender mejor cómo V8 ejecuta JavaScript, pero también asegúrate de enfocarte en optimizar tus propios algoritmos.

Referencias