JavaScript-væsentlige: hvorfor du skal vide, hvordan motoren fungerer

Denne artikel er også tilgængelig på spansk.

I denne artikel vil jeg forklare, hvad en softwareudvikler, der bruger JavaScript til at skrive applikationer, skal vide om motorer, så den skrevne kode udføres korrekt.

Du ser nedenfor en one-liner-funktion, der returnerer egenskabens efternavn for det beståede argument. Bare ved at tilføje en enkelt egenskab til hvert objekt, ender vi med et præstationsfald på mere end 700%!

Som jeg vil forklare detaljeret, driver JavaScript's mangel på statiske typer denne adfærd. Når det engang er set som en fordel i forhold til andre sprog som C # eller Java, viser det sig at være mere en "faustisk handel".

Bremsning ved fuld hastighed

Normalt behøver vi ikke kende internerne i en motor, der kører vores kode. Browserleverandørerne investerer meget i at få motorerne til at køre kode meget hurtigt.

Store!

Lad de andre gøre det tunge løft. Hvorfor bekymre sig om, hvordan motorerne fungerer?

I vores kodeeksempel nedenfor har vi fem objekter, der gemmer for- og efternavne på Star Wars-tegn. Funktionen getNamereturnerer værdien på efternavnet. Vi måler den samlede tid, det tager denne funktion at køre 1 milliard gange:

(() => { const han = {firstname: "Han", lastname: "Solo"}; const luke = {firstname: "Luke", lastname: "Skywalker"}; const leia = {firstname: "Leia", lastname: "Organa"}; const obi = {firstname: "Obi", lastname: "Wan"}; const yoda = {firstname: "", lastname: "Yoda"}; const people = [ han, luke, leia, obi, yoda, luke, leia, obi ]; const getName = (person) => person.lastname;
 console.time("engine"); for(var i = 0; i < 1000 * 1000 * 1000; i++) { getName(people[i & 7]); } console.timeEnd("engine"); })();

På en Intel i7 4510U er udførelsestiden ca. 1,2 sekunder. Så langt så godt. Vi tilføjer nu en anden egenskab til hvert objekt og udfører det igen.

(() => { const han = { firstname: "Han", lastname: "Solo", spacecraft: "Falcon"}; const luke = { firstname: "Luke", lastname: "Skywalker", job: "Jedi"}; const leia = { firstname: "Leia", lastname: "Organa", gender: "female"}; const obi = { firstname: "Obi", lastname: "Wan", retired: true}; const yoda = {lastname: "Yoda"};
 const people = [ han, luke, leia, obi, yoda, luke, leia, obi];
 const getName = (person) => person.lastname;
 console.time("engine"); for(var i = 0; i < 1000 * 1000 * 1000; i++) { getName(people[i & 7]); } console.timeEnd("engine");})();

Vores udførelsestid er nu 8,5 sekunder, hvilket er omkring en faktor 7 langsommere end vores første version. Dette føles som at ramme bremserne i fuld fart. Hvordan kunne det ske?

Tid til at se nærmere på motoren.

Kombinerede styrker: tolk og kompilator

Motoren er den del, der læser og udfører kildekode. Hver større browser-leverandør har sin egen motor. Mozilla Firefox har Spidermonkey, Microsoft Edge har Chakra / ChakraCore, og Apple Safari navngiver sin motor JavaScriptCore. Google Chrome bruger V8, som også er motoren til Node.js.

Udgivelsen af ​​V8 i 2008 markerede et afgørende øjeblik i motorernes historie. V8 erstattede browserens relativt langsomme fortolkning af JavaScript.

Årsagen til denne massive forbedring ligger primært i kombinationen af ​​tolk og kompilator. I dag bruger alle fire motorer denne teknik.

Tolken udfører kildekoden næsten med det samme. Compileren genererer maskinkode, som brugerens system udfører direkte.

Da kompilatoren arbejder med generering af maskinkode, anvender den optimeringer. Både kompilering og optimering resulterer i hurtigere kodeudførelse på trods af den ekstra tid, der kræves i kompileringsfasen.

Hovedideen bag moderne motorer er at kombinere det bedste fra begge verdener:

  • Hurtig applikationsstart af tolken.
  • Hurtig udførelse af compileren.

At nå begge mål starter med tolk. Parallelt markerer motoren ofte udførte kodedele som en "Hot Path" og sender dem til compileren sammen med kontekstuelle oplysninger indsamlet under udførelse. Denne proces lader kompilatoren tilpasse og optimere koden til den aktuelle kontekst.

Vi kalder kompilatorens opførsel "Just in Time" eller blot JIT.

Når motoren kører godt, kan du forestille dig visse scenarier, hvor JavaScript endda overgår C ++. Ikke underligt, at det meste af motorens arbejde går ind i den “kontekstuelle optimering”.

Statiske typer under kørsel: Inline caching

Inline Caching, eller IC, er en vigtig optimeringsteknik inden for JavaScript-motorer. Tolken skal udføre en søgning, før den kan få adgang til et objekts ejendom. Denne egenskab kan være en del af et objekts prototype, have en getter-metode eller endda være tilgængelig via en proxy. Søgning efter ejendommen er ret dyr med hensyn til eksekveringshastighed.

Motoren tildeler hvert objekt til en "type", som den genererer i løbet af løbetiden. V8 kalder disse “typer”, som ikke er en del af ECMAScript-standarden, skjulte klasser eller objektformer. For at to objekter skal dele den samme objektform, skal begge objekter have nøjagtigt de samme egenskaber i samme rækkefølge. Så et objekt {firstname: "Han", lastname: "Solo"}ville blive tildelt en anden klasse end {lastname: "Solo", firstname: "Han"}.

Ved hjælp af objektformerne kender motoren hukommelsesplaceringen for hver ejendom. Motoren hardkoder disse placeringer i den funktion, der får adgang til ejendommen.

Hvad Inline Caching gør er at fjerne opslagsoperationer. Ikke underligt, at dette giver en enorm præstationsforbedring.

Vender tilbage til vores tidligere eksempel: Alle objekterne i den første kørsel havde kun to egenskaber firstnameog lastnamei samme rækkefølge. Lad os sige, at det interne navn på denne objektform er p1. Når kompilatoren anvender IC, antager den, at funktionen kun passerer objektets form p1og returnerer værdien af lastnamestraks.

I det andet løb behandlede vi dog 5 forskellige objektformer. Hver genstand havde en ekstra ejendom og yodamanglede firstnamefuldstændigt. Hvad sker der, når vi har at gøre med flere objektformer?

Intervenerende ænder eller flere typer

Funktionel programmering har det velkendte koncept "duck typing", hvor god kodekvalitet kræver funktioner, der kan håndtere flere typer. I vores tilfælde er alt i orden, så længe det passerede objekt har et efternavn på en ejendom.

Inline Caching eliminerer den dyre opslag efter en ejendoms hukommelsesplacering. Det fungerer bedst, når objektet har samme objektform ved hver ejendomsadgang. Dette kaldes monomorf IC.

Hvis vi har op til fire forskellige objektformer, er vi i en polymorf IC-tilstand. Som i monomorf, "kender" den optimerede maskinkode allerede alle fire placeringer. Men det skal kontrollere, hvilken af ​​de fire mulige objekter, som det beståede argument hører til. Dette resulterer i et præstationsfald.

Once we exceed the threshold of four, it gets dramatically worse. We are now in a so-called megamorphic IC. In this state, there is no local caching of the memory locations anymore. Instead, it has to be looked up from a global cache. This results in the extreme performance drop we have seen above.

Polymorphic and Megamorphic in Action

Below we see a polymorphic Inline Cache with 2 different object shapes.

And the megamorphic IC from our code example with 5 different object shapes:

JavaScript Class to the rescue

OK, so we had 5 object shapes and ran into a megamorphic IC. How can we fix this?

We have to make sure that the engine marks all 5 of our objects as the same object shape. That means the objects we create must contain all possible properties. We could use object literals, but I find JavaScript classes the better solution.

For properties that are not defined, we simply pass null or leave it out. The constructor makes sure that these fields are initialised with a value:

(() => { class Person { constructor({ firstname = '', lastname = '', spaceship = '', job = '', gender = '', retired = false } = {}) { Object.assign(this, { firstname, lastname, spaceship, job, gender, retired }); } }
 const han = new Person({ firstname: 'Han', lastname: 'Solo', spaceship: 'Falcon' }); const luke = new Person({ firstname: 'Luke', lastname: 'Skywalker', job: 'Jedi' }); const leia = new Person({ firstname: 'Leia', lastname: 'Organa', gender: 'female' }); const obi = new Person({ firstname: 'Obi', lastname: 'Wan', retired: true }); const yoda = new Person({ lastname: 'Yoda' }); const people = [ han, luke, leia, obi, yoda, luke, leia, obi ]; const getName = person => person.lastname; console.time('engine'); for (var i = 0; i < 1000 * 1000 * 1000; i++) { getName(people[i & 7]); } console.timeEnd('engine');})();

When we execute this function again, we see that our execution time returns to 1.2 seconds. Job done!

Summary

Modern JavaScript engines combine the benefits of interpreter and compiler: Fast application startup and fast code execution.

Inline Caching is a powerful optimisation technique. It works best when only a single object shape passes to the optimised function.

My drastic example showed the effects of Inline Caching’s different types and the performance penalties of megamorphic caches.

Using JavaScript classes is good practice. Static typed transpilers, like TypeScript, make monomorphic IC’s more likely.

Further Reading

  • David Mark Clements: Performance Killers for TurboShift and Ignition: //github.com/davidmarkclements/v8-perf
  • Victor Felder: JavaScript Engines Hidden Classes

    //draft.li/blog/2016/12/22/javascript-engines-hidden-classes

  • Jörg W. Mittag: Overview of JIT Compiler and Interpreter

    //softwareengineering.stackexchange.com/questions/246094/understanding-the-differences-traditional-interpreter-jit-compiler-jit-interp/269878#269878

  • Vyacheslav Egorov: What’s up with Monomorphism

    //mrale.ph/blog/2015/01/11/whats-up-with-monomorphism.html

  • WebComic explaining Google Chrome

    //www.google.com/googlebooks/chrome/big_00.html

  • Huiren Woo: Differences between V8 and ChakraCore

    //developers.redhat.com/blog/2016/05/31/javascript-engine-performance-comparison-v8-charkra-chakra-core-2/

  • Seth Thompson: V8, Advanced JavaScript, & the Next Performance Frontier

    //www.youtube.com/watch?v=EdFDJANJJLs

  • Franziska Hinkelmann - Performance Profiling for V8

    //www.youtube.com/watch?v=j6LfSlg8Fig

  • Benedikt Meurer: En introduktion til spekulativ optimering i V8

    //ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8

  • Mathias Bynens: Grundlæggende JavaScript-motor: figurer og indbyggede cacher

    //mathiasbynens.be/notes/shapes-ics