de Rainer Hahnekamp

Esențial JavaScript: de ce ar trebui să știți cum funcționează motorul

Esential JavaScript de ce ar trebui sa stiti cum functioneaza
Fotografie de Moto „Club4AG” Miwa pe Flickr

Acest articol este disponibil și în Spaniolă.

În acest articol, vreau să explic ce ar trebui să știe un dezvoltator de software, care folosește JavaScript pentru a scrie aplicații, despre motoare, astfel încât codul scris să se execute corect.

Veți vedea mai jos o funcție one-liner care returnează proprietatea lastName a argumentului trecut. Doar adăugând o singură proprietate la fiecare obiect, ajungem la o scădere a performanței de peste 700%!

După cum voi explica în detaliu, lipsa de tipuri statice a JavaScript-ului determină acest comportament. Odată văzut ca un avantaj față de alte limbaje precum C # sau Java, se dovedește a fi mai mult o „afacere faustiană”.

Frânarea la viteză maximă

De obicei, nu este nevoie să cunoaștem internele unui motor care rulează codul nostru. Furnizorii de browsere investesc mult în a face ca motoarele să ruleze codul foarte rapid.

Grozav!

Lasă-i pe ceilalți să facă greutăți mari De ce să vă faceți griji cu privire la modul în care funcționează motoarele?

În exemplul nostru de cod de mai jos, avem cinci obiecte care stochează numele și prenumele personajelor Star Wars. Functia getName returnează valoarea prenumelui. Măsurăm timpul total necesar acestei funcții pentru a rula de 1 miliard de ori:

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

Pe un Intel i7 4510U, timpul de execuție este de aproximativ 1,2 secunde. Până acum, bine. Acum adăugăm o altă proprietate la fiecare obiect și o executăm din nou.

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

Timpul nostru de execuție este acum de 8,5 secunde, ceea ce reprezintă un factor de 7 ori mai lent decât prima noastră versiune. Se simte ca și cum ai da frâna la viteză maximă. Cum s-ar putea întâmpla asta?

E timpul să aruncăm o privire mai atentă asupra motorului.

Forțe combinate: interpret și compilator

Motorul este partea care citește și execută codul sursă. Fiecare furnizor major de browsere are propriul motor. Mozilla Firefox are Spidermonkey, Microsoft Edge are Chakra / ChakraCore și Apple Safari își numește motorul JavaScriptCore. Google Chrome folosește V8, care este și motorul Node.js.
Lansarea V8 în 2008 a marcat un moment esențial în istoria motoarelor. V8 a înlocuit interpretarea relativ lentă a browserului de JavaScript.

Motivul din spatele acestei îmbunătățiri masive constă în principal în combinația de interpret și compilator. Astăzi, toate cele patru motoare folosesc această tehnică.
Interpretul execută codul sursă aproape imediat. Compilatorul generează codul mașinii pe care sistemul utilizatorului îl execută direct.

Pe măsură ce compilatorul funcționează la generarea codului mașinii, acesta aplică optimizări. Atât compilarea, cât și optimizarea au ca rezultat o executare mai rapidă a codului, în ciuda timpului suplimentar necesar în faza de compilare.

Ideea principală din spatele motoarelor moderne este de a combina cele mai bune din ambele lumi:

  • Pornirea rapidă a aplicației interpretului.
  • Execuție rapidă a compilatorului.
1611484327 946 Esential JavaScript de ce ar trebui sa stiti cum functioneaza
Un motor modern folosește un interpret și un compilator. Sursa: imgflip

Atingerea ambelor obiective începe cu interpretul. În paralel, semnalizatoarele motorului execută frecvent părți de cod ca o „Hot Path” și le transmit compilatorului împreună cu informațiile contextuale colectate în timpul execuției. Acest proces permite compilatorului să adapteze și să optimizeze codul pentru contextul actual.

Numim comportamentul compilatorului „Just in Time” sau pur și simplu JIT.
Când motorul funcționează bine, vă puteți imagina anumite scenarii în care JavaScript depășește chiar și C ++. Nu este de mirare că cea mai mare parte a activității motorului merge în acea „optimizare contextuală”.

Esential JavaScript de ce ar trebui sa stiti cum functioneaza
Interacțiune între interpret și compilator

Tipuri statice în timpul runtime: cache în linie

Inline Caching, sau IC, este o tehnică majoră de optimizare în cadrul motoarelor JavaScript. Interpretul trebuie să efectueze o căutare înainte de a putea accesa proprietatea unui obiect. Această proprietate poate face parte din prototipul unui obiect, poate avea o metodă getter sau poate fi accesibilă prin intermediul unui proxy. Căutarea proprietății este destul de costisitoare în ceea ce privește viteza de execuție.

Motorul atribuie fiecare obiect unui „tip” pe care îl generează în timpul rulării. V8 numește aceste „tipuri”, care nu fac parte din standardul ECMAScript, clase ascunse sau forme de obiect. Pentru ca două obiecte să aibă aceeași formă de obiect, ambele obiecte trebuie să aibă exact aceleași proprietăți în aceeași ordine. Deci un obiect{firstname: "Han", lastname: "Solo"} ar fi alocat unei clase diferite de {lastname: "Solo", firstname: "Han"}.

Cu ajutorul formelor obiectului, motorul cunoaște locația de memorie a fiecărei proprietăți. Motorul codifică acele locații în funcția care accesează proprietatea.

Ceea ce face Inline Caching este să elimine operațiile de căutare. Nu este de mirare că aceasta produce o îmbunătățire imensă a performanței.

Revenind la exemplul nostru anterior: Toate obiectele din prima rundă aveau doar două proprietăți, firstname și lastname, în aceeași ordine. Să presupunem că numele intern al acestei forme de obiect este p1. Când compilatorul aplică IC, presupune că funcția este trecută doar de forma obiectuluip1 și returnează valoarea lui lastname imediat.

1611484329 760 Esential JavaScript de ce ar trebui sa stiti cum functioneaza
Cache în linie în acțiune (monomorfă)

În cea de-a doua rundă, totuși, am tratat 5 forme de obiecte diferite. Fiecare obiect avea o proprietate suplimentară și yoda lipsea firstname în întregime. Ce se întâmplă odată ce avem de-a face cu mai multe forme de obiecte?

Rațe care intervin sau mai multe tipuri

Programarea funcțională are binecunoscutul concept de „tastare de rață”, unde o calitate bună a codului necesită funcții care pot gestiona mai multe tipuri. În cazul nostru, atâta timp cât obiectul trecut are un nume de proprietate, totul este în regulă.

Inline Caching elimină căutarea costisitoare pentru locația de memorie a unei proprietăți. Funcționează cel mai bine atunci când, la fiecare acces la proprietate, obiectul are aceeași formă de obiect. Aceasta se numește IC monomorf.

Dacă avem până la patru forme de obiect diferite, ne aflăm într-o stare IC polimorfă. La fel ca în monomorf, codul optimizat al mașinii „cunoaște” deja toate cele patru locații. Dar trebuie să verifice careia dintre cele patru forme posibile de obiect aparține argumentul trecut. Acest lucru duce la o scădere a performanței.

Odată ce depășim pragul de patru, se agravează dramatic. Acum ne aflăm într-un așa-numit IC megamorfic. În această stare, nu mai există stocarea în cache a locațiilor de memorie. În schimb, trebuie căutat dintr-un cache global. Acest lucru duce la scăderea extremă a performanței pe care am văzut-o mai sus.

Polimorf și megamorf în acțiune

Mai jos vedem un cache polimorf Inline cu 2 forme de obiect diferite.

1611484330 592 Esential JavaScript de ce ar trebui sa stiti cum functioneaza
Memorie cache polimorfă

Și IC megamorfic din exemplul nostru de cod cu 5 forme de obiect diferite:

1611484331 234 Esential JavaScript de ce ar trebui sa stiti cum functioneaza
Cache Megamorphic Inline

JavaScript Class pentru salvare

OK, deci am avut 5 forme de obiecte și am întâlnit un IC megamorf. Cum putem remedia acest lucru?

Trebuie să ne asigurăm că motorul marchează toate cele 5 obiecte ca fiind aceeași formă a obiectului. Asta înseamnă că obiectele pe care le creăm trebuie să conțină toate proprietățile posibile. Am putea folosi literele obiectelor, dar găsesc clasele JavaScript cea mai bună soluție.

Pentru proprietățile care nu sunt definite, pur și simplu trecem null sau lăsați-l afară Constructorul se asigură că aceste câmpuri sunt inițializate cu o valoare:

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

Când executăm din nou această funcție, vedem că timpul nostru de execuție revine la 1,2 secunde. Treaba făcuta!

rezumat

Motoarele JavaScript moderne combină avantajele interpretorului și compilatorului: pornirea rapidă a aplicației și executarea rapidă a codului.

Inline Caching este o tehnică puternică de optimizare. Funcționează cel mai bine atunci când doar o formă de obiect trece la funcția optimizată.

Exemplul meu drastic a arătat efectele diferitelor tipuri de Inline Caching și penalizările de performanță ale cache-urilor megamorfice.

Utilizarea orelor JavaScript este o bună practică. Transpilatoarele tipizate static, precum TypeScript, fac ca IC-urile monomorfe să fie mai probabile.

Lecturi suplimentare