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 "exigir más velocidad", a analizar con cuidado 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, y también 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 de rendimiento son adictivos y, a veces, enfocarse primero en los consejos profundos 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 debas analizar tu código con herramientas como PageSpeed y mejorar 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 punto crucial de tu problema.
- Por último, corrige lo que importa
Para completar 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 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. En este documento, solo se capturan algunos de los puntos más importantes del diseño del motor V8.
Así que, ¡vamos a las sugerencias de V8!
Clases ocultas
JavaScript tiene información limitada sobre los tipos en el tiempo de compilación: los tipos se pueden cambiar en el entorno 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 cuestionar cómo el rendimiento de JavaScript podría acercarse al de C++. Sin embargo, V8 tiene tipos ocultos creados internamente para objetos en el entorno 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 de 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 rendimiento obtendrás.
Por lo tanto:
- Inicializa todos los miembros de objetos en funciones de constructor (para que las instancias no cambien de tipo más adelante).
- Siempre inicializa los miembros de un 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 lidiando. Una vez que V8 realiza 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 por cambiar estas etiquetas de tipo, por lo que es mejor usar los tipos de números de manera coherente y, en general, lo más óptimo es usar números enteros con signo 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 signo.
Arrays
Para controlar arrays grandes y dispersos, existen dos tipos de almacenamiento de arrays de forma interna:
- Fast Elements: Almacenamiento lineal para conjuntos de claves compactos
- Elementos de diccionario: de lo contrario, almacenamiento de tabla de hash
Lo mejor es no provocar 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., >64,000 elementos) a su tamaño máximo; en su lugar, aumenta el tamaño a medida que avanzas.
- No borres elementos de arrays, en especial, de arrays numéricos
- No cargues elementos no inicializados 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 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 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:
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 conversión, pero luego 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.
- Inicializa con arrays de literales para arrays pequeños de tamaño fijo
- Asigna previamente arrays pequeños (<64 KB) al tamaño correcto antes de usarlos.
- No almacenes valores no numéricos (objetos) en arrays numéricos
- Ten cuidado de no provocar la reconversión de arrays pequeños si los inicializas sin literales.
Compilación de JavaScript
Aunque JavaScript es un lenguaje muy dinámico y sus implementaciones originales eran intérpretes, los motores de ejecución de JavaScript modernos usan la compilación. De hecho, V8 (el motor de 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 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 ejecutarlo 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 tiempo de compilación, sino que espera que los tipos de variables puedan cambiar y cambien durante el tiempo de ejecución. El código generado por 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 Inline Caches es controlar los tipos de forma eficiente, almacenando en caché el código dependiente del tipo para las operaciones; cuando se ejecuta el código, primero validará las suposiciones de tipo y, luego, usará la Inline Cache para acortar 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 monomórfico de las operaciones por sobre las operaciones polimórficas
Las operaciones son monomórficas si las clases ocultas de las entradas son siempre 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 IC 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 habilita otras optimizaciones. Las funciones y los constructores monomórficos se pueden intercalar 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 características impiden que el compilador de optimización se ejecute en una función determinada (un "rescate"). En particular, el compilador de optimización actualmente se detiene 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) { // Controla las excepciones aquí } ```
Es probable que esta orientación cambie en el futuro, ya que habilitaremos los bloques try/catch en el compilador de optimización. Puedes examinar cómo el compilador de optimización se está retirando de las funciones usando la opción "--trace-opt" con d8 como se indicó anteriormente, lo que te brinda más información sobre las funciones de las que se retiró:
d8 --trace-opt primes.js
Desoptimización
Por último, la optimización que realiza este compilador es especulativa: a veces no funciona y retrocedemos. El proceso de "desoptimización" descarta el código optimizado y reanuda la ejecución en el lugar correcto del código del compilador "completo". Es posible que la reoptimización se vuelva a activar más adelante, pero, a corto plazo, la ejecución se ralentiza. En particular, provocar cambios en las clases ocultas de variables después de que se hayan optimizado las funciones provocará que se produzca esta desoptimización.
Por lo tanto:
- Evita los cambios de clases ocultas 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 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
Esto usa el generador de perfiles de muestreo integrado, que toma una muestra cada milisegundo y escribe v8.log.
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 punto crucial de tu problema.
- Por último, corrige lo que importa
Esto significa que debes asegurarte de que el problema esté en tu JavaScript. Para ello, primero usa otras herramientas, como PageSpeed, y, posiblemente, reduce a JavaScript puro (sin DOM) antes de recopilar métricas. Luego, usa esas métricas para localizar 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.