Turboalimentando V8 con números mutables en el montón
En V8, siempre estamos buscando mejorar el rendimiento de JavaScript. Como parte de este esfuerzo, recientemente volvimos a analizar el conjunto de pruebas de JetStream2 para eliminar caídas de rendimiento. Esta publicación detalla una optimización específica que realizamos y que generó una mejora significativa de 2.5x en la prueba de referencia async-fs, contribuyendo a un aumento notable en la puntuación general. La optimización se inspiró en el benchmark, pero este tipo de patrones también aparecen en código del mundo real.
El benchmark async-fs, como su nombre indica, es una implementación de un sistema de archivos en JavaScript, centrado en operaciones asíncronas. Sin embargo, existe un sorprendente cuello de botella de rendimiento: la implementación de Math.random. Utiliza una implementación personalizada y determinista de Math.random para obtener resultados consistentes entre ejecuciones. La implementación es:
let seed;
Math.random = (function() {
return function () {
seed = ((seed + 0x7ed55d16) + (seed << 12)) & 0xffffffff;
seed = ((seed ^ 0xc761c23c) ^ (seed >>> 19)) & 0xffffffff;
seed = ((seed + 0x165667b1) + (seed << 5)) & 0xffffffff;
seed = ((seed + 0xd3a2646c) ^ (seed << 9)) & 0xffffffff;
seed = ((seed + 0xfd7046c5) + (seed << 3)) & 0xffffffff;
seed = ((seed ^ 0xb55a4f09) ^ (seed >>> 16)) & 0xffffffff;
return (seed & 0xfffffff) / 0x10000000;
};
})();
La variable clave aquí es seed. Se actualiza en cada llamada a Math.random, generando la secuencia pseudoaleatoria. Es crucial destacar que aquí seed se almacena en un ScriptContext.
Un ScriptContext sirve como un lugar de almacenamiento para valores accesibles dentro de un script en particular. Internamente, este contexto se representa como un arreglo de valores etiquetados de V8. En la configuración predeterminada de V8 para sistemas de 64 bits, cada uno de estos valores etiquetados ocupa 32 bits. El bit menos significativo de cada valor actúa como una etiqueta. Un 0 indica un Entero Pequeño (SMI) de 31 bits. El valor entero real se almacena directamente, desplazado a la izquierda por un bit. Un 1 indica un puntero comprimido a un objeto en el montón, donde el valor del puntero comprimido se incrementa en uno.
Esta etiquetación diferencia cómo se almacenan los números. Los SMIs residen directamente en el ScriptContext. Los números más grandes o aquellos con partes decimales se almacenan indirectamente como objetos HeapNumber inmutables en el montón (un doble de 64 bits), con el ScriptContext conteniendo un puntero comprimido hacia ellos. Este enfoque maneja de manera eficiente varios tipos numéricos mientras optimiza para el caso común de SMI.
El cuello de botella
El análisis del rendimiento de Math.random reveló dos problemas importantes:
-
Asignación de
HeapNumber: La ranura dedicada a la variableseeden el contexto del script apunta a unHeapNumberestándar e inmutable. Cada vez que la funciónMath.randomactualizaseed, se debe asignar un nuevo objetoHeapNumberen el montón, lo que genera una presión significativa de asignación y recolección de basura. -
Aritmética en punto flotante: Aunque los cálculos dentro de
Math.randomson operaciones enteras (usando desplazamientos y sumas en bits), el compilador no puede aprovechar esto completamente. Debido a queseedse almacena como unHeapNumbergenérico, el código generado utiliza instrucciones en punto flotante más lentas. El compilador no puede demostrar queseedsiempre contendrá un valor representable como entero. Aunque el compilador podría especular sobre rangos de enteros de 32 bits, V8 principalmente se centra enSMIs. Incluso con especulación en enteros de 32 bits, todavía sería necesaria una conversión potencialmente costosa de punto flotante de 64 bits a entero de 32 bits, junto con una verificación sin pérdida.
La solución
Para abordar estos problemas, implementamos una optimización de dos partes:
-
Seguimiento de tipos de ranuras / ranuras de números de montón mutables: Extendimos el seguimiento de valores constantes del contexto de script (variables
letque se inicializaron pero nunca se modificaron) para incluir información de tipo. Rastreamos si ese valor de ranura es constante, unSMI, unHeapNumbero un valor etiquetado genérico. También introdujimos el concepto de ranuras de números de montón mutables dentro de los contextos de script, similar a los campos de números de montón mutables paraJSObjects. En lugar de apuntar a unHeapNumberinmutable, la ranura del contexto de script posee elHeapNumber, y no debe filtrar su dirección. Esto elimina la necesidad de asignar un nuevoHeapNumberen cada actualización para el código optimizado. ElHeapNumberpropio se modifica directamente en su lugar. -
Int32en montón mutable: Mejoramos los tipos de ranuras de contexto de script para rastrear si un valor numérico está dentro del rango deInt32. Si lo está, elHeapNumbermutable almacena el valor como unInt32sin procesar. Si es necesario, la transición a undoublelleva el beneficio adicional de no requerir la reasignación delHeapNumber. En el caso deMath.random, el compilador puede observar queseedse actualiza constantemente con operaciones enteras y marcar la ranura como conteniendo unInt32mutable.
Es importante notar que estas optimizaciones introducen una dependencia de código en el tipo del valor almacenado en la ranura del contexto. El código optimizado generado por el compilador JIT depende de que la ranura contenga un tipo específico (aquí, un Int32). Si algún código escribe un valor en la ranura seed que cambia su tipo (por ejemplo, escribiendo un número de punto flotante o una cadena), el código optimizado tendrá que desoptimizarse. Esta desoptimización es necesaria para garantizar la corrección. Por lo tanto, la estabilidad del tipo almacenado en la ranura es crucial para mantener el máximo rendimiento. En el caso de Math.random, el enmascaramiento de bits en el algoritmo asegura que la variable seed siempre contenga un valor Int32.
Los resultados
Estos cambios aceleran significativamente la peculiar función Math.random:
-
Sin asignación / actualizaciones rápidas en su lugar: El valor
seedse actualiza directamente dentro de su ranura mutable en el contexto de script. No se asignan nuevos objetos durante la ejecución deMath.random. -
Operaciones con enteros: El compilador, armado con el conocimiento de que la ranura contiene un
Int32, puede generar instrucciones enteras altamente optimizadas (desplazamientos, sumas, etc.). Esto evita la sobrecarga de la aritmética de punto flotante.

El efecto combinado de estas optimizaciones es una notable aceleración de ~2.5x en el benchmark async-fs. Esto, a su vez, contribuye a una mejora de ~1.6% en la puntuación general de JetStream2. Esto demuestra que un código aparentemente simple puede crear cuellos de botella de rendimiento inesperados, y que pequeñas optimizaciones específicas pueden tener un gran impacto no solo para el benchmark.