Odio hacer benchmarking de código, como cualquier humano (y, en este punto, la mayoría de los espectadores de esto probablemente no lo sean ¯\ (ツ) /¯). Es mucho más divertido fingir que el almacenamiento en caché de un valor aumentó el rendimiento en un 1000% en lugar de probar para ver qué hizo. Lamentablemente, el benchmarking en JavaScript sigue siendo necesario, especialmente porque JavaScript se usa ( ¿cuando no debería? ) en aplicaciones más sensibles al rendimiento. Desafortunadamente, debido a muchas de sus decisiones arquitectónicas centrales, JavaScript no hace que el benchmarking sea más fácil.
Para quienes no están familiarizados con la magia de los lenguajes de programación modernos como JavaScript, su arquitectura puede ser bastante compleja. En lugar de ejecutar código únicamente a través de un intérprete que inmediatamente emite instrucciones, la mayoría de los motores de JavaScript utilizan una arquitectura más similar a la de un lenguaje compilado como C: integran múltiples niveles de "compiladores" .
Cada uno de estos compiladores ofrece un equilibrio diferente entre el tiempo de compilación y el rendimiento en tiempo de ejecución, por lo que el usuario no necesita dedicar tiempo a optimizar el código que rara vez se ejecuta y, al mismo tiempo, aprovechar las ventajas de rendimiento del compilador más avanzado para el código que se ejecuta con mayor frecuencia (las "rutas activas"). También surgen otras complicaciones al utilizar compiladores optimizadores que implican palabras de programación sofisticadas como " monomorfismo de funciones ", pero no hablaré de eso aquí.
Entonces… ¿por qué esto es importante para la evaluación comparativa? Bueno, como habrás adivinado, dado que la evaluación comparativa mide el rendimiento del código, el compilador JIT puede tener una influencia bastante grande. Cuando se evalúan fragmentos de código más pequeños, a menudo se pueden ver mejoras de rendimiento diez veces superiores después de la optimización completa, lo que introduce una gran cantidad de errores en los resultados.
Por ejemplo, en su configuración de evaluación comparativa más básica (no utilice nada como lo siguiente por varias razones):
for (int i = 0; i<1000; i++) { console.time() // do some expensive work console.timeEnd() }
(No os preocupéis, también hablaremos de console.time
)
Gran parte de su código se almacenará en caché después de algunas pruebas, lo que reducirá significativamente el tiempo por operación. Los programas de evaluación comparativa a menudo hacen todo lo posible para eliminar este almacenamiento en caché/optimización, ya que también puede hacer que los programas probados más adelante en el proceso de evaluación comparativa parezcan relativamente más rápidos. Sin embargo, en última instancia debe preguntarse si las evaluaciones comparativas sin optimizaciones coinciden con el rendimiento en el mundo real.
Por supuesto, en ciertos casos, como páginas web a las que se accede con poca frecuencia, la optimización es poco probable, pero en entornos como los servidores, donde el rendimiento es lo más importante, se debe esperar que se produzca una optimización. Si está ejecutando un fragmento de código como middleware para miles de solicitudes por segundo, es mejor que espere que V8 lo optimice.
Básicamente, incluso dentro de un mismo motor, hay de 2 a 4 formas diferentes de ejecutar el código con distintos niveles de rendimiento. Además, en ciertos casos es increíblemente difícil garantizar que se habiliten determinados niveles de optimización. Diviértete :).
¿Conoces la técnica de huellas dactilares? ¿La técnica que permitió que se utilizara Do Not Track para facilitar el seguimiento ? Sí, los motores de JavaScript han hecho todo lo posible para mitigarla. Este esfuerzo, junto con una medida para evitar ataques de sincronización , llevó a que los motores de JavaScript hicieran que la sincronización fuera inexacta intencionalmente, de modo que los piratas informáticos no pudieran obtener mediciones precisas del rendimiento actual de las computadoras o de lo costosa que es una determinada operación.
Lamentablemente, esto significa que, sin modificar las cosas, los puntos de referencia tienen el mismo problema.
El ejemplo de la sección anterior será inexacto, ya que solo mide en milisegundos. Ahora, cámbielo por performance.now()
. Genial.
¡Ahora tenemos marcas de tiempo en microsegundos!
// Bad console.time(); // work console.timeEnd(); // Better? const t = performance.now(); // work console.log(performance.now() - t);
Excepto que… todos están en incrementos de 100 μs. Ahora, agreguemos algunos encabezados para mitigar el riesgo de ataques de tiempo. Ups, todavía solo podemos incrementos de 5 μs. 5 μs es probablemente suficiente precisión para muchos casos de uso, pero tendrás que buscar en otro lado cualquier cosa que requiera más granularidad. Hasta donde sé, ningún navegador permite temporizadores más granulares. Node.js sí, pero por supuesto, eso tiene sus propios problemas.
Incluso si decides ejecutar tu código a través del navegador y dejar que el compilador haga lo suyo, está claro que tendrás más dolores de cabeza si quieres una sincronización precisa. Ah, sí, y no todos los navegadores son iguales.
Me encanta Bun por lo que ha hecho para impulsar el desarrollo de JavaScript del lado del servidor, pero, caray, hace que la evaluación comparativa de JavaScript para servidores sea mucho más difícil. Hace unos años, los únicos entornos de JavaScript del lado del servidor que interesaban a la gente eran Node.js y Deno , los cuales usaban el motor de JavaScript V8 (el mismo que se usa en Chrome). En cambio, Bun usa JavaScriptCore, el motor de Safari, que tiene características de rendimiento completamente diferentes.
Este problema de múltiples entornos de JavaScript con sus propias características de rendimiento es relativamente nuevo en JavaScript del lado del servidor, pero ha afectado a los clientes durante mucho tiempo. Los tres motores de JavaScript de uso común, V8, JSC y SpiderMonkey para Chrome, Safari y Firefox, respectivamente, pueden funcionar significativamente más rápido o más lento en un fragmento de código equivalente.
Un ejemplo de estas diferencias es la optimización de llamadas de cola (TCO, por sus siglas en inglés). La TCO optimiza las funciones que se repiten al final de su cuerpo, como se muestra a continuación:
function factorial(i, num = 1) { if (i == 1) return num; num *= i; i--; return factorial(i, num); }
Prueba a hacer una evaluación comparativa factorial(100000)
en Bun. Ahora, prueba lo mismo en Node.js o Deno. Deberías obtener un error similar a este:
function factorial(i, num = 1) { ^ RangeError: Maximum call stack size exceeded
En V8 (y por extensión en Node.js y Deno), cada vez que factorial()
se llama a sí mismo al final, el motor crea un contexto de función completamente nuevo para que se ejecute la función anidada, que finalmente está limitado por la pila de llamadas. Pero, ¿por qué no sucede esto en Bun? JavaScriptCore, que utiliza Bun, implementa TCO, que optimiza este tipo de funciones al convertirlas en un bucle for más parecido a esto:
function factorial(i, num = 1) { while (i != 1) { num *= i; i--; } return i; }
El diseño anterior no solo evita los límites de la pila de llamadas, sino que también es mucho más rápido porque no requiere ningún contexto de función nuevo, lo que significa que funciones como la anterior se evaluarán de manera muy diferente en diferentes motores.
Básicamente, estas diferencias solo significan que debes realizar una evaluación comparativa entre todos los motores en los que esperas que se ejecute tu código para asegurarte de que el código que es rápido en uno no sea lento en otro. Además, si estás desarrollando una biblioteca que esperas que se use en muchas plataformas, asegúrate de incluir motores más esotéricos como Hermes ; tienen características de rendimiento drásticamente diferentes.
Desearía poder señalar un paquete npm que resuelva todos estos problemas, pero realmente no existe ninguno.
En el servidor, la cosa es un poco más sencilla. Puedes usar d8 para controlar manualmente los niveles de optimización, controlar el recolector de basura y obtener tiempos precisos. Por supuesto, necesitarás algo de Bash-fu para configurar un pipeline de benchmark bien diseñado para esto, ya que, lamentablemente, d8 no está bien integrado (o no está integrado en absoluto) con Node.js.
También puedes habilitar ciertas banderas en Node.js para obtener resultados similares, pero perderás características como habilitar niveles de optimización específicos.
v8 --sparkplug --always-sparkplug --no-opt [file]
Un ejemplo de D8 con un nivel de compilación específico (Sparkplug) habilitado. D8, de manera predeterminada, incluye más control de GC y más información de depuración en general.
¿Puedes obtener algunas funciones similares en JavaScriptCore? Honestamente, no he usado mucho la CLI de JavaScriptCore y está muy poco documentada. Puedes habilitar niveles específicos usando sus indicadores de línea de comandos , pero no estoy seguro de cuánta información de depuración puedes recuperar. Bun también incluye algunas utilidades de evaluación comparativa útiles, pero están limitadas de manera similar a Node.js.
Lamentablemente, todo esto requiere la versión base/de prueba del motor, que puede ser bastante difícil de conseguir. He descubierto que la forma más sencilla de gestionar los motores es esvu junto con eshost-cli , ya que juntos hacen que la gestión de los motores y la ejecución de código en ellos sea considerablemente más sencilla. Por supuesto, todavía se requiere mucho trabajo manual, ya que estas herramientas solo gestionan la ejecución de código en diferentes motores; aún tienes que escribir tú mismo el código de evaluación comparativa.
Si solo está intentando comparar un motor con opciones predeterminadas con la mayor precisión posible en el servidor, existen herramientas Node.js listas para usar como mitata que ayudan a mejorar la precisión de tiempo y los errores relacionados con GC. Muchas de estas herramientas, como Mitata, también se pueden usar en muchos motores; por supuesto, aún tendrá que configurar una canalización como la anterior.
En el navegador, todo es mucho más difícil. No conozco ninguna solución para lograr tiempos más precisos y el control del motor es mucho más limitado. La mayor cantidad de información que puede obtener en relación con el rendimiento de JavaScript en tiempo de ejecución en el navegador será a través de las herramientas de desarrollo de Chrome , que ofrecen utilidades básicas de simulación de gráficos de llama y ralentización de la CPU.
Muchas de las mismas decisiones de diseño que hicieron que JavaScript fuera (relativamente) eficiente y portátil hacen que la evaluación comparativa sea significativamente más difícil que en otros lenguajes. Hay muchos más objetivos para evaluar y se tiene mucho menos control sobre cada uno de ellos.
Con suerte, algún día una solución simplificará muchos de estos problemas. Es posible que con el tiempo cree una herramienta para simplificar la evaluación comparativa entre motores y niveles de compilación, pero por ahora, crear una secuencia de comandos para resolver todos estos problemas requiere bastante trabajo. Por supuesto, es importante recordar que estos problemas no se aplican a todos: si su código solo se ejecuta en un entorno, no pierda el tiempo evaluando otros entornos.
Independientemente de cómo elijas realizar la evaluación comparativa, espero que este artículo te haya mostrado algunos de los problemas presentes en la evaluación comparativa de JavaScript. Avísame si te resultaría útil un tutorial sobre cómo implementar algunas de las cosas que describo anteriormente.