JavaScript essentials: waarom u moet weten hoe de engine werkt

Dit artikel is ook beschikbaar in het Spaans.

In dit artikel wil ik uitleggen wat een softwareontwikkelaar, die JavaScript gebruikt om applicaties te schrijven, zou moeten weten over engines zodat de geschreven code correct wordt uitgevoerd.

Je ziet hieronder een one-liner-functie die de eigenschap lastName van het doorgegeven argument retourneert. Alleen al door een enkele eigenschap aan elk object toe te voegen, eindigen we met een prestatiedaling van meer dan 700%!

Zoals ik in detail zal uitleggen, drijft het gebrek aan statische typen van JavaScript dit gedrag. Ooit gezien als een voordeel ten opzichte van andere talen zoals C # of Java, blijkt het meer een "Faustiaans koopje" te zijn.

Remmen op volle snelheid

Meestal hoeven we de interne onderdelen van een engine waarop onze code draait niet te kennen. De browserverkopers investeren veel om de engines zeer snel code te laten uitvoeren.

Super goed!

Laat de anderen het zware werk doen. Waarom zou u zich zorgen maken over hoe de motoren werken?

In ons codevoorbeeld hieronder hebben we vijf objecten waarin de voor- en achternaam van Star Wars-personages worden opgeslagen. De functie getNameretourneert de waarde van achternaam. We meten de totale tijd die deze functie nodig heeft om 1 miljard keer te draaien:

(() => { 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"); })();

Op een Intel i7 4510U is de uitvoeringstijd ongeveer 1,2 seconden. Tot zover goed. We voegen nu aan elk object een andere eigenschap toe en voeren deze opnieuw uit.

(() => { 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");})();

Onze uitvoeringstijd is nu 8,5 seconden, wat ongeveer een factor 7 langzamer is dan onze eerste versie. Dit voelt alsof je op volle snelheid remt. Hoe kan dat gebeuren?

Tijd om de motor eens nader te bekijken.

Combined Forces: tolk en compiler

De engine is het onderdeel dat de broncode leest en uitvoert. Elke grote browserleverancier heeft zijn eigen engine. Mozilla Firefox heeft Spidermonkey, Microsoft Edge heeft Chakra / ChakraCore en Apple Safari noemt de engine JavaScriptCore. Google Chrome gebruikt V8, dat ook de motor is van Node.js.

De introductie van de V8 in 2008 was een cruciaal moment in de geschiedenis van motoren. V8 heeft de relatief trage interpretatie van JavaScript door de browser vervangen.

De reden achter deze enorme verbetering ligt voornamelijk in de combinatie van interpreter en compiler. Tegenwoordig gebruiken alle vier de motoren deze techniek.

De tolk voert de broncode vrijwel onmiddellijk uit. De compiler genereert machinecode die het systeem van de gebruiker rechtstreeks uitvoert.

Terwijl de compiler werkt aan het genereren van machinecodes, past deze optimalisaties toe. Zowel compilatie als optimalisatie resulteren in snellere code-uitvoering ondanks de extra tijd die nodig is in de compilatiefase.

Het belangrijkste idee achter moderne motoren is om het beste van twee werelden te combineren:

  • Snelle opstart van de toepassing van de tolk.
  • Snelle uitvoering van de compiler.

Het behalen van beide doelen begint bij de tolk. Tegelijkertijd markeert de engine vaak uitgevoerde codedelen als een "Hot Path" en geeft deze door aan de compiler, samen met contextuele informatie die tijdens de uitvoering is verzameld. Met dit proces kan de compiler de code aanpassen en optimaliseren voor de huidige context.

We noemen het gedrag van de compiler "Just in Time" of gewoon JIT.

Als de engine goed draait, kun je je bepaalde scenario's voorstellen waarin JavaScript zelfs beter presteert dan C ++. Geen wonder dat het meeste werk van de engine in die "contextuele optimalisatie" gaat.

Statische typen tijdens runtime: inline caching

Inline Caching, of IC, is een belangrijke optimalisatietechniek binnen JavaScript-engines. De interpreter moet een zoekopdracht uitvoeren voordat hij toegang kan krijgen tot de eigenschap van een object. Die eigenschap kan onderdeel zijn van het prototype van een object, een getter-methode hebben of zelfs toegankelijk zijn via een proxy. Het zoeken naar het pand is vrij duur in termen van uitvoeringssnelheid.

De engine wijst elk object toe aan een "type" dat het tijdens de runtime genereert. V8 noemt deze "typen", die geen deel uitmaken van de ECMAScript-standaard, verborgen klassen of objectvormen. Om twee objecten dezelfde objectvorm te laten hebben, moeten beide objecten exact dezelfde eigenschappen hebben in dezelfde volgorde. Een object {firstname: "Han", lastname: "Solo"}zou dus aan een andere klasse worden toegewezen dan {lastname: "Solo", firstname: "Han"}.

Met behulp van de objectvormen kent de motor de geheugenlocatie van elke eigenschap. De engine codeert die locaties hard in de functie die toegang heeft tot het pand.

Wat Inline Caching doet, is het elimineren van opzoekbewerkingen. Geen wonder dat dit een enorme prestatieverbetering oplevert.

Terugkomend op ons eerdere voorbeeld: alle objecten in de eerste run hadden slechts twee eigenschappen, firstnameen lastnamein dezelfde volgorde. Laten we zeggen dat de interne naam van deze objectvorm is p1. Wanneer de compiler IC toepast, wordt ervan uitgegaan dat de functie alleen de objectvorm krijgt p1en de waarde van lastnameonmiddellijk retourneert .

In de tweede run hebben we echter 5 verschillende objectvormen behandeld. Elk object had een extra eigenschap en yodaontbrak firstnamevolledig. Wat gebeurt er als we te maken hebben met meerdere objectvormen?

Tussenliggende eenden of meerdere soorten

Functioneel programmeren heeft het bekende concept van "ducktyping", waarbij een goede codekwaliteit vraagt ​​om functies die meerdere typen aankunnen. In ons geval, zolang het doorgegeven object een eigenschap achternaam heeft, is alles in orde.

Inline caching elimineert het dure zoeken naar de geheugenlocatie van een eigenschap. Het werkt het beste als het object bij elke toegang tot een eigenschap dezelfde objectvorm heeft. Dit heet monomorf IC.

Als we maximaal vier verschillende objectvormen hebben, bevinden we ons in een polymorfe IC-toestand. Net als bij monomorf, "kent" de geoptimaliseerde machinecode al alle vier de locaties. Maar het moet controleren tot welke van de vier mogelijke objectvormen het doorgegeven argument behoort. Dit resulteert in een prestatieverlaging.

Zodra we de drempel van vier overschrijden, wordt het dramatisch erger. We zitten nu in een zogenaamd megamorfisch IC. In deze toestand is er geen lokale caching van de geheugenlocaties meer. In plaats daarvan moet het worden opgezocht vanuit een globale cache. Dit resulteert in de extreme prestatiedaling die we hierboven hebben gezien.

Polymorf en megamorf in actie

Hieronder zien we een polymorfe Inline Cache met 2 verschillende objectvormen.

En de megamorfe IC uit ons codevoorbeeld met 5 verschillende objectvormen:

JavaScript Class schiet te hulp

OK, dus we hadden 5 objectvormen en kwamen een megamorfe IC tegen. Hoe kunnen we dit oplossen?

We moeten ervoor zorgen dat de motor alle 5 onze objecten markeert als dezelfde objectvorm. Dat betekent dat de objecten die we maken alle mogelijke eigenschappen moeten bevatten. We zouden objectletters kunnen gebruiken, maar ik vind JavaScript-klassen de betere oplossing.

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: An Introduction to Speculative Optimization in V8

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

  • Mathias Bynens: JavaScript engine fundamentals: Shapes and Inline Caches

    //mathiasbynens.be/notes/shapes-ics