JavaScripti põhitõed: miks peaksite teadma, kuidas mootor töötab?

See artikkel on saadaval ka hispaania keeles.

Selles artiklis tahan selgitada, mida peaks tarkvaraarendaja, kes kasutab rakenduste kirjutamiseks JavaScripti, mootorite kohta teadma, et kirjutatud kood korralikult toimiks.

Allpool näete ühe liini funktsiooni, mis tagastab edastatud argumendi atribuudi lastName. Lihtsalt lisades igale objektile ühe atribuudi, langeb jõudluse langus üle 700%!

Nagu ma üksikasjalikult selgitan, ajendab seda käitumist JavaScripti staatiliste tüüpide puudumine. Kui seda on peetud eeliseks teiste keelte, näiteks C # või Java ees, osutub see pigem "Fausti pakkumiseks".

Pidurdamine täiskiirusel

Tavaliselt ei pea me teadma meie koodi töötava mootori sisemisi osi. Brauserimüüjad investeerivad palju selleks, et mootorid töötaksid koodi väga kiiresti.

Suurepärane!

Las teised teevad rasket tõstmist. Miks peaks muretsema mootorite töö pärast?

Allolevas koodinäites on meil viis objekti, mis salvestavad Tähesõdade tähemärkide ees- ja perekonnanimed. Funktsioon getNametagastab perekonnanime väärtuse. Me mõõdame kogu selle funktsiooni käitamiseks kuluvat aega miljard korda korda:

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

Intel i7 4510U puhul on käivitamise aeg umbes 1,2 sekundit. Siiamaani on kõik korras. Lisame nüüd igale objektile veel ühe atribuudi ja täidame selle uuesti.

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

Meie teostusaeg on nüüd 8,5 sekundit, mis on umbes 7 korda aeglasem kui meie esimene versioon. See tundub nagu pidurdaks täiskiirusel. Kuidas see juhtuda sai?

Aeg mootorit lähemalt uurida.

Kombineeritud jõud: tõlk ja koostaja

Mootor on see osa, mis loeb ja käivitab lähtekoodi. Igal suuremal brauserimüüjal on oma mootor. Mozilla Firefoxil on Spidermonkey, Microsoft Edge'il on Chakra / ChakraCore ja Apple Safari nimetab oma mootori JavaScripti. Google Chrome kasutab V8-d, mis on ka Node.js mootor.

V8 väljaandmine 2008. aastal oli mootorite ajaloos pöördeline hetk. V8 asendas brauseri JavaScripti suhteliselt aeglase tõlgenduse.

Selle tohutu paranemise põhjus peitub peamiselt tõlgi ja koostaja kombinatsioonis. Täna kasutavad seda tehnikat kõik neli mootorit.

Tõlk käivitab lähtekoodi peaaegu kohe. Koostaja genereerib masinakoodi, mille kasutaja süsteem täidab otse.

Kuna kompilaator töötab masinakoodi genereerimisel, rakendab see optimeerimisi. Nii kompileerimise kui ka optimeerimise tulemuseks on kiirem koodi täitmine, vaatamata kompileerimise etapis vajaminevale lisaajale.

Kaasaegsete mootorite peamine mõte on ühendada mõlema maailma parimad küljed:

  • Tõlgi kiire rakenduse käivitamine.
  • Kompilaatori kiire täitmine.

Mõlema eesmärgi saavutamine algab tõlgist. Paralleelselt märgistab mootor sageli täidetavad koodiosad “kuumana” ja edastab need koos täitmisel kogutud kontekstuaalse teabega kompilaatorile. See protsess võimaldab kompilaatoril kohandada ja optimeerida koodi praeguse konteksti jaoks.

Nimetame koostaja käitumist lihtsalt õigeks ajaks või lihtsalt JIT-iks.

Kui mootor töötab hästi, võite ette kujutada teatud stsenaariume, kus JavaScript edestab isegi C ++. Pole ime, et suurem osa mootori tööst läheb selle “kontekstuaalse optimeerimise” alla.

Staatilised tüübid käituse ajal: sisemine vahemällu salvestamine

Inline Caching ehk IC on JavaScripti mootorite peamine optimeerimistehnika. Tõlk peab enne objekti atribuudile juurde pääsemist otsingu tegema. See omadus võib olla osa objekti prototüübist, omada meetodit getter või pääseda isegi puhverserveri kaudu. Vara otsimine on täitmise kiiruse poolest üsna kallis.

Mootor määrab iga objekti tüübile, mille see käitamise ajal genereerib. V8 nimetab neid tüüpe, mis ei kuulu ECMAScript standardisse, peidetud klassideks või objektikujudeks. Et kahel objektil oleks sama objekti kuju, peavad mõlemal objektil olema täpselt samad omadused samas järjekorras. Niisiis määratakse objekt {firstname: "Han", lastname: "Solo"}teisele klassile kui {lastname: "Solo", firstname: "Han"}.

Objektikujude abil teab mootor iga omaduse mälu asukohta. Mootor kodeerib need asukohad kõvasti funktsiooniks, millel on juurdepääs varale.

See, mida sisemine vahemälu teeb, on otsinguoperatsioonide välistamine. Pole ime, et see suur jõudlust parandab.

Tulles tagasi meie varasema näite juurde: kõigil esimese käigu objektidel oli ainult kaks omadust firstnameja lastnamesamas järjekorras. Oletame, et selle objekti kuju sisemine nimi on p1. Kui kompilaator rakendab IC-d, eeldab ta, et funktsioon läbib ainult objekti kuju p1ja tagastab lastnamekohe väärtuse .

Teises sõidus tegelesime aga 5 erineva objektikujuga. Igal objektil oli täiendav omadus ja yodasee puudus firstnametäielikult. Mis juhtub, kui meil on tegemist mitme objektikujuga?

Sekkuvad pardid või mitut tüüpi

Funktsionaalsel programmeerimisel on tuntud parditüüpimise kontseptsioon, kus hea koodikvaliteet nõuab funktsioone, mis suudavad hallata mitut tüüpi. Meie puhul on kõik korras, kuni möödunud objektil on omadus perekonnanimi.

Sisseehitatud vahemälu välistab kalli otsingu vara mälu asukoha kohta. See töötab kõige paremini siis, kui igal atribuudile juurdepääsu korral on objektil sama objekti kuju. Seda nimetatakse monomorfseks IC-ks.

Kui meil on kuni neli erinevat objekti kuju, oleme polümorfses IC-olekus. Nagu monomorfsena, nii teab ka optimeeritud masinakood kõiki nelja asukohta. Kuid see peab kontrollima, millisesse neljast võimalikust objektivormist kuulub edastatud argument. Selle tulemuseks on jõudluse langus.

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 - V8 jõudlusprofiil

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

  • Benedikt Meurer: Sissejuhatus spekulatiivsesse optimeerimisse V8-s

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

  • Mathias Bynens: JavaScripti mootori põhialused: kujundid ja sisseehitatud vahemälud

    //mathiasbynens.be/notes/shapes-ics