Odio o código de benchmarking, como calquera humano (que, neste momento, a maioría dos espectadores deste probablemente non sexan ¯\ (ツ) /¯). É moito máis divertido finxir que o almacenamento en caché dun valor aumentou o rendemento nun 1000 % en lugar de probar para ver o que fixo. Por desgraza, o benchmarking en JavaScript aínda é necesario, especialmente porque JavaScript se usa ( cando non debería ser? ) en aplicacións máis sensibles ao rendemento. Desafortunadamente, debido a moitas das súas decisións arquitectónicas fundamentais, JavaScript non facilita o benchmarking.
Para aqueles que non estean familiarizados coa feiticería das linguaxes de script modernas como JavaScript, a súa arquitectura pode ser bastante complexa. En lugar de executar só código a través dun intérprete que cuspir inmediatamente instrucións, a maioría dos motores JavaScript utilizan unha arquitectura máis semellante a unha linguaxe compilada como C: integran varios niveis de "compiladores" .
Cada un destes compiladores ofrece unha compensación diferente entre o tempo de compilación e o rendemento en tempo de execución, polo que o usuario non necesita gastar un código de optimización de computación que raramente se executa mentres aproveita os beneficios de rendemento do compilador máis avanzado para o código que se executa con máis frecuencia ( os “camiños quentes”). Tamén hai outras complicacións que xorden ao usar compiladores de optimización que implican palabras de programación extravagantes como " monomorfismo de funcións ", pero vouche aforrar e evitar falar diso aquí.
Entón... por que importa isto para o benchmarking? Ben, como poderías ter adiviñado, porque o benchmarking é medir o rendemento do código, o compilador JIT pode ter unha influencia bastante grande. As pezas de código máis pequenas, cando se fan benchmarking, adoitan ver melloras de rendemento 10x+ despois da optimización total, o que introduce moitos erros nos resultados.
Por exemplo, na súa configuración de benchmarking máis básica (non use nada como o seguinte por varios motivos):
for (int i = 0; i<1000; i++) { console.time() // do some expensive work console.timeEnd() }
(Non te preocupes, falaremos tamén de console.time
)
Gran parte do teu código almacenarase na memoria caché despois duns poucos ensaios, o que reduce significativamente o tempo por operación. Os programas de referencia adoitan facer todo o posible para eliminar esta caché/optimización, xa que tamén pode facer que os programas probados máis tarde no proceso de referencia aparezan relativamente máis rápidos. Non obstante, ao final debes preguntar se os benchmarks sen optimizacións coinciden co rendemento no mundo real.
Por suposto, nalgúns casos, como páxinas web de acceso pouco frecuente, a optimización é improbable, pero en contornas como servidores, onde o rendemento é o máis importante, débese esperar optimización. Se estás executando unha peza de código como middleware para miles de solicitudes por segundo, é mellor que esperes que V8 o optimice.
Polo tanto, basicamente, mesmo dentro dun motor, hai 2-4 formas diferentes de executar o teu código con diferentes niveis de rendemento. Ah, tamén, é incriblemente difícil en certos casos asegurarse de que determinados niveis de optimización estean activados. Disfrútao :).
Sabes a impresión dixital? A técnica que permitiu empregar Do Not Track para facilitar o seguimento ? Si, os motores JavaScript fixeron todo o posible para mitigalo. Este esforzo, xunto cun movemento para evitar ataques de temporización , levou a que os motores JavaScript fixeran intencionadamente a sincronización inexacta, polo que os piratas informáticos non poden obter medicións precisas do rendemento dos ordenadores actuais nin do caro que é unha determinada operación.
Desafortunadamente, isto significa que sen modificar as cousas, os benchmarks teñen o mesmo problema.
O exemplo da sección anterior será inexacto, xa que só mide en milisegundos. Agora, cambia iso para performance.now()
. Genial.
Agora, temos marcas de tempo en microsegundos.
// Bad console.time(); // work console.timeEnd(); // Better? const t = performance.now(); // work console.log(performance.now() - t);
Excepto... todos están en incrementos de 100μs. Agora, imos engadir algunhas cabeceiras para mitigar o risco de ataques de temporización. Vaia, aínda podemos aumentar só 5 μs. 5μs probablemente sexa suficiente precisión para moitos casos de uso, pero terás que buscar noutro lugar calquera cousa que requira máis granularidade. Polo que sei, ningún navegador permite temporizadores máis granulares. Node.js si, pero por suposto, iso ten os seus propios problemas.
Aínda que decides executar o teu código a través do navegador e deixas que o compilador faga as súas cousas, está claro que aínda terás máis dores de cabeza se queres un tempo preciso. Ah, si, e non todos os navegadores son iguais.
Encántame Bun polo que fixo para impulsar o JavaScript do servidor, pero dificulta moito o benchmarking de JavaScript para servidores. Hai uns anos, os únicos entornos JavaScript do servidor que lles preocupaban á xente eran Node.js e Deno , ambos os cales utilizaban o motor JavaScript V8 (o mesmo en Chrome). Bun usa JavaScriptCore, o motor de Safari, que ten características de rendemento completamente diferentes.
Este problema de varios ambientes JavaScript coas súas propias características de rendemento é relativamente novo en JavaScript do servidor, pero ten problemas aos clientes durante moito tempo. Os 3 motores JavaScript de uso habitual, V8, JSC e SpiderMonkey para Chrome, Safari e Firefox, respectivamente, poden funcionar significativamente máis rápido ou máis lento nunha peza de código equivalente.
Un exemplo destas diferenzas é a Tail Call Optimization (TCO). TCO optimiza funcións que se repiten ao final do seu corpo, como esta:
function factorial(i, num = 1) { if (i == 1) return num; num *= i; i--; return factorial(i, num); }
Proba a facer benchmarking factorial(100000)
en Bun. Agora, proba o mesmo en Node.js ou Deno. Debería obter un erro similar a este:
function factorial(i, num = 1) { ^ RangeError: Maximum call stack size exceeded
En V8 (e por extensión Node.js e Deno), cada vez que o factorial()
se chama a si mesmo ao final, o motor crea un contexto de función totalmente novo para que se execute a función aniñada, que finalmente está limitado pola pila de chamadas. Pero por que non ocorre isto en Bun? JavaScriptCore, que utiliza Bun, implementa TCO, que optimiza este tipo de funcións converténdoas nun bucle for máis como este:
function factorial(i, num = 1) { while (i != 1) { num *= i; i--; } return i; }
O deseño anterior non só evita os límites da pila de chamadas, senón que tamén é moito máis rápido porque non require ningún contexto de función novo, o que significa que funcións como a anterior compararanse de forma moi diferente en diferentes motores.
Esencialmente, estas diferenzas só significan que debes comparar en todos os motores que esperas executar o teu código para garantir que o código que é rápido nun non sexa lento noutro. Ademais, se estás a desenvolver unha biblioteca que esperas que se use en moitas plataformas, asegúrate de incluír motores máis esotéricos como Hermes ; teñen características de rendemento drasticamente diferentes.
Gustaríame poder sinalar un paquete npm que resolva todos estes problemas, pero realmente non o hai.
No servidor, tes un tempo un pouco máis sinxelo. Podes usar d8 para controlar manualmente os niveis de optimización, controlar o colector de lixo e obter un tempo preciso. Por suposto, necesitarás algo de Bash-fu para configurar unha canalización de referencia ben deseñada para iso, xa que, por desgraza, d8 non está ben integrado (ou non está integrado en absoluto) con Node.js.
Tamén podes activar determinadas marcas en Node.js para obter resultados similares, pero perderás funcións como habilitar niveis de optimización específicos.
v8 --sparkplug --always-sparkplug --no-opt [file]
Un exemplo de D8 cun nivel de compilación específico (bujía) activado. D8, por defecto, inclúe máis control do GC e máis información de depuración en xeral.
Podes obter algunhas funcións similares en JavaScriptCore??? Sinceramente, non usei moito a CLI de JavaScriptCore e está moi pouco documentada. Podes activar niveis específicos usando as súas marcas de liña de comandos , pero non estou seguro de canta información de depuración podes recuperar. Bun tamén inclúe algunhas utilidades de benchmarking útiles, pero están limitadas de forma similar a Node.js.
Desafortunadamente, todo isto require o motor base/versión de proba do motor, que pode ser bastante difícil de conseguir. Descubrín que o xeito máis sinxelo de xestionar motores é esvu emparejado con eshost-cli , xa que xuntos facilitan considerablemente a xestión dos motores e a execución de código entre eles. Por suposto, aínda se require moito traballo manual, xa que estas ferramentas só xestionan o código de execución en diferentes motores; aínda tes que escribir o código de benchmarking ti mesmo.
Se só estás tentando comparar un motor con opcións predeterminadas coa maior precisión posible no servidor, hai ferramentas Node.js dispoñibles como mitata que axudan a mellorar a precisión do tempo e os erros relacionados co GC. Moitas destas ferramentas, como Mitata, tamén se poden usar en moitos motores; por suposto, aínda terás que configurar unha canalización como a anterior.
No navegador, todo é moito máis difícil. Non coñezo solucións para unha sincronización máis precisa e o control do motor é moito máis limitado. A maior parte da información que podes obter sobre o rendemento de JavaScript en tempo de execución no navegador será das ferramentas de desenvolvemento de Chrome , que ofrecen utilidades básicas de simulación de gráficos de chama e ralentización da CPU.
Moitas das mesmas decisións de deseño que fixeron que JavaScript (relativamente) sexa eficiente e portátil dificultan significativamente o benchmarking que noutros idiomas. Hai moitos máis obxectivos para comparar e tes moito menos control sobre cada obxectivo.
Con sorte, unha solución algún día simplificará moitos destes problemas. Podería eventualmente facer unha ferramenta para simplificar o benchmarking entre motores e niveis de compilación, pero de momento, crear unha canalización para resolver todos estes problemas leva bastante traballo. Por suposto, é importante lembrar que estes problemas non se aplican a todos: se o teu código só se está a executar nun ambiente, non perdas o tempo comparando outros ambientes.
De calquera xeito que elixas facer benchmarking, espero que este artigo che mostre algúns dos problemas presentes no benchmarking de JavaScript. Avísame se un tutorial sobre a implementación dalgunhas das cousas que describo anteriormente sería útil.