paint-brush
Hvorfor er JavaScript-benchmarking et rod?ved@asyncbanana
Ny historie

Hvorfor er JavaScript-benchmarking et rod?

ved AsyncBanana7m2025/01/04
Read on Terminal Reader

For langt; At læse

JavaScript-benchmarking er stadig nødvendigt, især da JavaScript bruges i mere præstationsfølsomme applikationer. JavaScript-motorer har gjort deres bedste for at afbøde timingangreb, sammen med et skift til at flytte til [Do Not Track] JavaScript-motorer, der med vilje gør timing-målinger unøjagtige.
featured image - Hvorfor er JavaScript-benchmarking et rod?
AsyncBanana HackerNoon profile picture
0-item
1-item

Jeg hader benchmarking-kode, ligesom ethvert menneske (hvilket på nuværende tidspunkt de fleste seere af denne sandsynligvis ikke er ¯\ (ツ) /¯). Det er meget sjovere at foregive, at din cachelagring af en værdi øgede ydeevnen med 1000 % i stedet for at teste for at se, hvad den gjorde. Ak, benchmarking i JavaScript er stadig nødvendigt, især da JavaScript bruges ( når det ikke burde være det? ) i mere præstationsfølsomme applikationer. På grund af mange af dets centrale arkitektoniske beslutninger gør JavaScript desværre ikke benchmarking nemmere.

Hvad er der galt med JavaScript?

JIT-kompileren ophører med nøjagtigheden(?)

For dem, der ikke er bekendt med trolddommen i moderne scriptsprog som JavaScript, kan deres arkitektur være ret kompleks. I stedet for kun at køre kode gennem en fortolker, der straks spytter instruktioner ud, bruger de fleste JavaScript-motorer en arkitektur, der ligner et kompileret sprog som C - de integrerer flere lag af "kompilatorer" .


Hver af disse compilere tilbyder en anden afvejning mellem kompileringstid og runtime-ydeevne, så brugeren ikke behøver at bruge computeroptimering af kode, der sjældent køres, mens han drager fordel af den mere avancerede compilers ydeevnefordele for den kode, der køres oftest ( de "varme stier"). Der er også nogle andre komplikationer, der opstår, når du bruger optimeringskompilere, der involverer smarte programmeringsord som " funktionsmonomorfisme ", men jeg vil spare dig og undgå at tale om det her.


Så... hvorfor betyder det noget for benchmarking? Nå, som du måske har gættet, fordi benchmarking er måling af kodes ydeevne , kan JIT-kompileren have en ret stor indflydelse. Mindre stykker kode kan, når de benchmarkedes, ofte se 10x+ præstationsforbedringer efter fuld optimering, hvilket introducerer en masse fejl i resultaterne.


For eksempel i din mest grundlæggende benchmarking-opsætning (brug ikke noget som nedenstående af flere årsager):

 for (int i = 0; i<1000; i++) { console.time() // do some expensive work console.timeEnd() }

(Bare rolig, vi vil også tale om console.time )


Meget af din kode vil blive cachelagret efter et par forsøg, hvilket reducerer tiden pr. operation betydeligt. Benchmark-programmer gør ofte deres bedste for at eliminere denne caching/optimering, da det også kan få programmer testet senere i benchmark-processen til at fremstå relativt hurtigere. Du skal dog i sidste ende spørge, om benchmarks uden optimeringer matcher ydeevnen i den virkelige verden.


Sikker på, i visse tilfælde, som sjældent besøgte websider, er optimering usandsynlig, men i miljøer som servere, hvor ydeevne er vigtigst, bør optimering forventes. Hvis du kører et stykke kode som middleware for tusindvis af anmodninger i sekundet, må du hellere håbe, at V8 optimerer det.


Så dybest set, selv inden for en motor, er der 2-4 forskellige måder at køre din kode på med forskellige niveauer af ydeevne. Åh, også, det er utroligt svært i visse tilfælde at sikre, at bestemte optimeringsniveauer er aktiveret. God fornøjelse :).

Motorer gør deres bedste for at forhindre dig i at timing nøjagtigt

Kender du fingeraftryk? Teknikken, der gjorde det muligt at bruge Do Not Track til at hjælpe med sporing ? Ja, JavaScript-motorer har gjort deres bedste for at afbøde det. Denne indsats, sammen med et skridt for at forhindre timingangreb , førte til, at JavaScript-motorer med vilje gjorde timingen unøjagtig, så hackere ikke kan få præcise målinger af den aktuelle computers ydeevne eller hvor dyr en bestemt operation er.


Desværre betyder det, at benchmarks har det samme problem uden at justere tingene.


Eksemplet i det foregående afsnit vil være unøjagtigt, da det kun måler med millisekund. Skift nu det ud for performance.now() . Stor.


Nu har vi tidsstempler i mikrosekunder!

 // Bad console.time(); // work console.timeEnd(); // Better? const t = performance.now(); // work console.log(performance.now() - t);

Bortset fra... de er alle i trin på 100μs. Lad os nu tilføje nogle overskrifter for at mindske risikoen for timing af angreb. Ups, vi kan stadig kun trin på 5 μs. 5μs er nok præcision nok til mange brugssager, men du bliver nødt til at lede andre steder efter alt, der kræver mere granularitet. Så vidt jeg ved, tillader ingen browser mere granulære timere. Det gør Node.js, men det har selvfølgelig sine egne problemer.


Selvom du beslutter dig for at køre din kode gennem browseren og lade compileren gøre sit, vil du klart stadig have mere hovedpine, hvis du vil have præcis timing. Åh ja, og ikke alle browsere er lavet lige.

Hvert miljø er forskelligt

Jeg elsker Bun for, hvad den har gjort for at skubbe JavaScript på serversiden fremad, men det gør benchmarking af JavaScript til servere meget sværere. For et par år siden var de eneste JavaScript-miljøer på serversiden, folk bekymrede sig om, Node.js og Deno , som begge brugte V8 JavaScript-motoren (den samme i Chrome). Bun bruger i stedet JavaScriptCore, motoren i Safari, som har helt andre ydelsesegenskaber.


Dette problem med flere JavaScript-miljøer med deres egne præstationskarakteristika er relativt nyt i JavaScript på serversiden, men har plaget klienter i lang tid. De 3 forskellige almindeligt anvendte JavaScript-motorer, V8, JSC og SpiderMonkey til henholdsvis Chrome, Safari og Firefox, kan alle udføre betydeligt hurtigere eller langsommere på et tilsvarende stykke kode.


Et eksempel på disse forskelle er i Tail Call Optimization (TCO). TCO optimerer funktioner, der gentager sig i enden af deres krop, som dette:

 function factorial(i, num = 1) { if (i == 1) return num; num *= i; i--; return factorial(i, num); }


Prøv at benchmarke factorial(100000) i Bun. Prøv nu det samme i Node.js eller Deno. Du skulle få en fejl svarende til denne:

 function factorial(i, num = 1) { ^ RangeError: Maximum call stack size exceeded


I V8 (og i forlængelse heraf Node.js og Deno), hver gang factorial() kalder sig selv i slutningen, skaber motoren en helt ny funktionskontekst for den indlejrede funktion at køre i, som til sidst er begrænset af opkaldsstakken. Men hvorfor sker det ikke i Bun? JavaScriptCore, som Bun bruger, implementerer TCO, som optimerer disse typer funktioner ved at gøre dem til en for-løkke mere som denne:

 function factorial(i, num = 1) { while (i != 1) { num *= i; i--; } return i; }

Ikke alene undgår ovenstående design grænser for opkaldsstack, men det er også meget hurtigere, fordi det ikke kræver nogen nye funktionskontekster, hvilket betyder, at funktioner som ovenstående vil benchmarke meget forskelligt under forskellige motorer.


Grundlæggende betyder disse forskelle blot, at du skal benchmarke på tværs af alle motorer, som du forventer skal køre din kode for at sikre, at kode, der er hurtig i den ene, ikke er langsom i en anden. Hvis du også udvikler et bibliotek, som du forventer skal bruges på tværs af mange platforme, skal du sørge for at inkludere mere esoteriske motorer som Hermes ; de har drastisk forskellige præstationskarakteristika.

Hæderlige omtaler

  • Skraldesamleren og dens tendens til at sætte alt på pause.


  • JIT compilerens evne til at slette al din kode, fordi det "ikke er nødvendigt."


  • Frygtelig brede flammegrafer i de fleste JavaScript-udviklerværktøjer.


  • Jeg tror du forstår pointen.

Så ... Hvad er løsningen?

Jeg ville ønske, jeg kunne pege på en npm-pakke, der løser alle disse problemer, men der er virkelig ikke en.


På serveren har du det lidt nemmere. Du kan bruge d8 til manuelt at styre optimeringsniveauer, styre skraldeopsamleren og få præcis timing. Selvfølgelig skal du bruge noget Bash-fu for at opsætte en veldesignet benchmark pipeline til dette, da d8 desværre ikke er godt integreret (eller overhovedet integreret) med Node.js.


Du kan også aktivere visse flag i Node.js for at få lignende resultater, men du vil gå glip af funktioner som at aktivere specifikke optimeringsniveauer.

 v8 --sparkplug --always-sparkplug --no-opt [file]

Et eksempel på D8 med et specifikt kompileringsniveau (sparkplug) aktiveret. D8 inkluderer som standard mere kontrol over GC og mere debug info generelt.


Du kan få nogle lignende funktioner på JavaScriptCore??? Helt ærligt, jeg har ikke brugt JavaScriptCores CLI meget, og det er stærkt underdokumenteret. Du kan aktivere specifikke niveauer ved hjælp af deres kommandolinjeflag , men jeg er ikke sikker på, hvor meget fejlretningsinformation du kan hente. Bun indeholder også nogle nyttige benchmarking-værktøjer , men de er begrænset på samme måde som Node.js.


Alt dette kræver desværre basismotoren/testversionen af motoren, hvilket kan være ret svært at få fat i. Jeg har fundet ud af, at den enkleste måde at administrere motorer på er esvu parret med eshost-cli , da de tilsammen gør det betydeligt nemmere at administrere motorer og køre kode på tværs af dem. Selvfølgelig er der stadig meget manuelt arbejde påkrævet, da disse værktøjer bare administrerer kørende kode på tværs af forskellige motorer - du skal stadig selv skrive benchmarking-koden.


Hvis du bare forsøger at benchmarke en motor med standardindstillinger så præcist som muligt på serveren, er der off-the-shelf Node.js-værktøjer som mitata , der hjælper med at forbedre timing-nøjagtigheden og GC-relaterede fejl. Mange af disse værktøjer, som Mitata, kan også bruges på tværs af mange motorer; selvfølgelig skal du stadig oprette en pipeline som ovenstående.


På browseren er alt meget sværere. Jeg kender ikke til nogen løsninger til mere præcis timing, og styringen af motoren er langt mere begrænset. Den mest information, du kan få i forbindelse med runtime JavaScript-ydeevne i browseren, vil være fra Chrome-devtools , som tilbyder grundlæggende flammegraf og simuleringsværktøjer til CPU-afmatning.

Konklusion

Mange af de samme designbeslutninger, der gjorde JavaScript (relativt) effektivt og bærbart, gør benchmarking betydeligt sværere, end det er på andre sprog. Der er mange flere mål at benchmarke, og du har meget mindre kontrol over hvert mål.


Forhåbentlig vil en løsning en dag forenkle mange af disse problemer. Jeg vil måske med tiden lave et værktøj til at forenkle benchmarking på tværs af motorer og kompileringsniveauer, men indtil videre kræver det en del arbejde at skabe en pipeline til at løse alle disse problemer. Det er selvfølgelig vigtigt at huske, at disse problemer ikke gælder for alle – hvis din kode kun kører i ét miljø, så spild ikke din tid på at benchmarke andre miljøer.


Uanset hvordan du vælger at benchmarke, håber jeg, at denne artikel viste dig nogle af problemerne i JavaScript-benchmarking. Fortæl mig, om en tutorial om implementering af nogle af de ting, jeg beskriver ovenfor, ville være nyttig.