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 recomendó "exigir más rápido" para analizar cuidadosamente las diferencias de rendimiento entre C++ y JavaScript, y escribir código con atención sobre el 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 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 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 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 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 conocer las herramientas disponibles y cómo pueden ayudarte. Daniel explica con más detalle cómo usar las herramientas para desarrolladores en su charla. En este documento, solo se incluyen 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 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 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 del objeto 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 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 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 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:
- 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, especialmente 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, ya que 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 lo siguiente:
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 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 hacer que los arrays pequeños se vuelvan a convertir si 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 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 manejar tipos de forma eficiente, almacenando en caché código dependiente del tipo para operaciones; cuando el código se ejecute, validará las suposiciones de tipo primero 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 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 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 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 código sensible al rendimiento en una función anidada si probaste los bloques {} de catch: ```js function perf_sensitive() { // Haz trabajos sensibles al rendimiento aquí. }
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 salva 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 salvaron:
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 reoptimización se active de nuevo más adelante, pero, a corto plazo, la ejecución se ralentizará. En particular, causar 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 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 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 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.
- Finalmente, corrige lo importante
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.