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 velocidad”, a analizar cuidadosamente las diferencias de rendimiento entre C++ y JavaScript, y a 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. Además, lo mantendremos actualizado a medida que cambie la guía de rendimiento.
El consejo más importante
Es importante contextualizar cualquier consejo de rendimiento. Los consejos sobre el rendimiento son adictivos y, a veces, enfocarse primero en los consejos detallados puede distraer bastante de los problemas reales. Debes tener una visión integral del rendimiento de tu aplicación web. Antes de enfocarte en estas sugerencias de rendimiento, probablemente deberías analizar tu código con herramientas como PageSpeed y aumentar tu puntuación. Esto te ayudará a evitar la 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 problema principal.
- Por último, corrige lo que importa
Para realizar estos pasos, puede ser importante comprender cómo V8 optimiza JS, de modo que puedas escribir código teniendo en cuenta el diseño del entorno de ejecución de JS. También es importante conocer las herramientas disponibles y cómo pueden ayudarte. En su charla, Daniel explica con más detalle cómo usar las herramientas para desarrolladores. En este documento, solo se incluyen algunos de los puntos más importantes del diseño del motor V8.
Ahora, pasemos a los consejos de V8.
Clases ocultas
JavaScript tiene información de tipo limitada en el tiempo de compilación: los tipos se pueden cambiar durante el tiempo de ejecución, por lo que es natural esperar que sea costoso razonar sobre los tipos de JS en el tiempo de compilación. Esto podría llevarte a preguntarte cómo el rendimiento de JavaScript podría acercarse al de C++. Sin embargo, V8 tiene tipos ocultos creados de forma interna para objetos en el tiempo de ejecución. Los objetos con la misma clase oculta pueden usar el mismo código generado 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 se agregue el miembro adicional ".z" a la instancia del objeto p2, p1 y p2 tendrán internamente la misma clase oculta, por lo que V8 puede generar una sola versión de ensamblado optimizado para el código JavaScript que manipule 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 del objeto en las funciones de constructor (para que las instancias no cambien de tipo más adelante).
- Siempre inicializa los miembros del objeto en el mismo orden
Numbers
V8 usa el etiquetado para representar valores de manera eficiente cuando los tipos pueden cambiar. V8 infiere de los valores que usas con qué tipo de número estás trabajando. Una vez que V8 realiza esta inferencia, usa el etiquetado para representar valores de manera eficiente, ya que estos tipos pueden cambiar de forma dinámica. Sin embargo, a veces cambiar estas etiquetas de tipo tiene un costo, por lo que es mejor usar tipos de números de forma coherente y, en general, es más conveniente 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:
- Se prefieren los valores numéricos que se pueden representar como números enteros de 31 bits con firma.
Arrays
Para controlar arrays grandes y dispersos, hay dos tipos de almacenamiento de arrays de forma interna:
- Elementos rápidos: almacenamiento lineal para conjuntos de claves compactos
- Elementos del diccionario: Almacenamiento de la tabla hash en caso contrario
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 los arrays
- No asignes previamente arrays grandes (p. ej., más de 64,000 elementos) a su tamaño máximo, sino que crece a medida que avanzas.
- No borres elementos de los arrays, en especial los numéricos.
- No cargues elementos sin inicializar o borrados:
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 números dobles son más rápidos: la clase oculta del array hace un seguimiento de los tipos de elementos, y los arrays que contienen solo números dobles se desempaquetan (lo que provoca un cambio de clase oculta).Sin embargo, la manipulación descuidada de los arrays puede causar trabajo adicional debido al empaquetado y desempaquetado, 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 las siguientes opciones:
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 números dobles sin serializar, pero la asignación de a[3]
hace que se vuelva a convertir en un array que puede contener cualquier valor (números u objetos). En el segundo caso, el compilador conoce los tipos de todos los elementos del literal, y la clase oculta se puede determinar de antemano.
- Cómo inicializar con literales de array para arrays pequeños de tamaño fijo
- Asignar previamente arrays pequeños (<64 K) para corregir el tamaño antes de usarlos
- No almacenes valores no numéricos (objetos) en arreglos numéricos.
- Ten cuidado de no volver a convertir arrays pequeños si los inicializas sin literales.
Compilación de JavaScript
Si bien JavaScript es un lenguaje muy dinámico y sus implementaciones originales eran intérpretes, los motores de tiempo de ejecución de JavaScript modernos usan compilación. De hecho, V8 (JavaScript de Chrome) tiene dos compiladores Just In Time (JIT) diferentes:
- El compilador "Full", que puede generar un buen código para cualquier JavaScript
- El compilador de optimización, que produce un código excelente para la mayoría de los programas JavaScript, pero tarda más tiempo en compilarse.
El compilador completo
En V8, el compilador completo 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 sobre los tipos en el momento de la compilación, ya que espera que los tipos de variables puedan cambiar y lo hagan durante el tiempo de ejecución. El código que genera el compilador completo usa 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 controlar los tipos de manera eficiente almacenando en caché el código dependiente del tipo para las operaciones. Cuando se ejecuta el código, primero se validan las suposiciones de tipo y, luego, se usa la caché intercalada para abreviar 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 monómero de operaciones en lugar de las operaciones polimórficas.
Las operaciones son monomórficas si las clases ocultas de las entradas siempre son las mismas; 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 a add() en 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
En paralelo con el compilador completo, V8 vuelve a compilar las funciones "hot" (es decir, las funciones que se ejecutan muchas veces) con un compilador de optimización. Este compilador usa comentarios de tipo para que el código compilado sea más rápido; de hecho, usa los tipos tomados de los CI de los que acabamos de hablar.
En el compilador de optimización, las operaciones se intercalan de forma especulativa (se colocan directamente donde se las llama). Esto acelera la ejecución (a costa del espacio en memoria), pero también permite otras optimizaciones. Las funciones y los constructores monomórficos se pueden intercalar 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 impiden que el compilador de optimización se ejecute en una función determinada (un "escape"). En particular, el compilador de optimización actualmente falla en las funciones con bloques try {} catch {}.
Por lo tanto:
- Coloca el código sensible al rendimiento en una función anidada si tienes bloques try {} catch {}: ```js function perf_sensitive() { // Do performance-sensitive work here }
try { perf_sensitive() } catch (e) { // Handle exceptions here } ```
Es probable que esta guía cambie en el futuro, ya que habilitamos bloques try/catch en el compilador de optimización. Puedes examinar cómo el compilador de optimización abandona las funciones con la opción "--trace-opt" con d8 como se indicó anteriormente, lo que te brinda más información sobre qué funciones se abandonaron:
d8 --trace-opt primes.js
Anulación de la optimización
Por último, la optimización que realiza este compilador es especulativa; a veces, no funciona y retrocedemos. El proceso de "deoptimización" descarta el código optimizado y reanuda la ejecución en el lugar correcto en el código del compilador "completo". Es posible que la nueva optimización se active nuevamente más adelante, pero, a corto plazo, la ejecución se ralentiza. En particular, si se producen cambios en las clases ocultas de variables después de que se hayan optimizado las funciones, se producirá esta desoptimización.
Por lo tanto:
- Evita los cambios de clase ocultos en las funciones después de que se optimicen
Al igual que con otras optimizaciones, puedes obtener un registro de las funciones que V8 tuvo que desoptimizar con una marca de registro:
d8 --trace-deopt primes.js
Otras herramientas de V8
Por cierto, también puedes pasar opciones de seguimiento 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 el generador de perfiles de las herramientas para desarrolladores, también puedes usar d8 para generar perfiles:
% out/ia32.release/d8 primes.js --prof
Esto usa el generador de perfiles de muestreo integrado, que toma un muestreo cada milisegundo y escribe v8.log.
En resumen
Es importante identificar y comprender cómo funciona el motor V8 con tu código para prepararte 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 problema principal.
- Por último, corrige lo que importa
Esto significa que debes asegurarte de que el problema esté en tu código JavaScript. Para ello, primero usa otras herramientas, como PageSpeed. Es posible que debas reducirlo a JavaScript puro (sin DOM) antes de recopilar métricas y, luego, usar esas métricas para ubicar los 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 asegúrate de enfocarte también en optimizar tus propios algoritmos.