Βασικά στοιχεία JavaScript: γιατί πρέπει να γνωρίζετε πώς λειτουργεί ο κινητήρας

Αυτό το άρθρο είναι επίσης διαθέσιμο στα ισπανικά.

Σε αυτό το άρθρο, θέλω να εξηγήσω τι πρέπει να γνωρίζει ένας προγραμματιστής λογισμικού, ο οποίος χρησιμοποιεί JavaScript για τη σύνταξη εφαρμογών, για τις μηχανές, έτσι ώστε ο γραπτός κώδικας να εκτελείται σωστά.

Θα δείτε παρακάτω μια συνάρτηση με μία επένδυση που επιστρέφει την ιδιότητα lastName του ορίσματος που πέρασε. Ακριβώς προσθέτοντας μια μεμονωμένη ιδιότητα σε κάθε αντικείμενο, καταλήγουμε σε πτώση απόδοσης άνω του 700%!

Όπως θα εξηγήσω λεπτομερώς, η έλλειψη στατικών τύπων JavaScript οδηγεί σε αυτήν τη συμπεριφορά. Μόλις θεωρηθεί ως πλεονέκτημα σε σχέση με άλλες γλώσσες όπως το C # ή το Java, αποδεικνύεται ότι είναι περισσότερο «Faustian παζάρι».

Φρενάρισμα σε πλήρη ταχύτητα

Συνήθως, δεν χρειάζεται να γνωρίζουμε τα εσωτερικά ενός κινητήρα που τρέχει τον κωδικό μας. Οι προμηθευτές του προγράμματος περιήγησης επενδύουν σε μεγάλο βαθμό στο να κάνουν τους κινητήρες να τρέχουν κώδικα πολύ γρήγορα.

Μεγάλος!

Αφήστε τους άλλους να κάνουν τη βαριά ανύψωση. Γιατί να ανησυχείτε για το πώς λειτουργούν οι κινητήρες;

Στο παρακάτω παράδειγμα κώδικα, έχουμε πέντε αντικείμενα που αποθηκεύουν το όνομα και το επώνυμο των χαρακτήρων του Star Wars. Η συνάρτηση getNameεπιστρέφει την τιμή του επώνυμου. Μετράμε το συνολικό χρόνο που χρειάζεται αυτή η συνάρτηση για να τρέξει 1 δισεκατομμύριο φορές:

(() => { 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, ο χρόνος εκτέλεσης είναι περίπου 1,2 δευτερόλεπτα. Μέχρι εδώ καλά. Τώρα προσθέτουμε μια άλλη ιδιότητα σε κάθε αντικείμενο και την εκτελούμε ξανά.

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

Ο χρόνος εκτέλεσης είναι τώρα 8,5 δευτερόλεπτα, δηλαδή περίπου 7 συντελεστής πιο αργός από την πρώτη μας έκδοση. Αυτό φαίνεται σαν να χτυπάτε τα φρένα με πλήρη ταχύτητα. Πώς θα μπορούσε να συμβεί αυτό;

Ώρα να ρίξετε μια πιο προσεκτική ματιά στον κινητήρα.

Συνδυασμένες δυνάμεις: Διερμηνέας και μεταγλωττιστής

Ο κινητήρας είναι το μέρος που διαβάζει και εκτελεί τον πηγαίο κώδικα. Κάθε μεγάλος προμηθευτής προγράμματος περιήγησης έχει τη δική του μηχανή. Το Mozilla Firefox έχει Spidermonkey, το Microsoft Edge έχει Chakra / ChakraCore και το Apple Safari ονομάζει τον κινητήρα του JavaScriptCore. Το Google Chrome χρησιμοποιεί το V8, το οποίο είναι επίσης η μηχανή του Node.js.

Η κυκλοφορία του V8 το 2008 σηματοδότησε μια σημαντική στιγμή στην ιστορία των κινητήρων. Το V8 αντικατέστησε τη σχετικά αργή ερμηνεία του JavaScript του προγράμματος περιήγησης.

Ο λόγος πίσω από αυτήν τη μαζική βελτίωση έγκειται κυρίως στο συνδυασμό διερμηνέα και μεταγλωττιστή. Σήμερα, και οι τέσσερις κινητήρες χρησιμοποιούν αυτήν την τεχνική.

Ο διερμηνέας εκτελεί τον πηγαίο κώδικα σχεδόν αμέσως. Ο μεταγλωττιστής δημιουργεί κώδικα μηχανής τον οποίο εκτελεί άμεσα το σύστημα του χρήστη.

Καθώς ο μεταγλωττιστής λειτουργεί στη δημιουργία κώδικα του μηχανήματος, εφαρμόζει βελτιστοποιήσεις. Τόσο η συλλογή όσο και η βελτιστοποίηση έχουν ως αποτέλεσμα ταχύτερη εκτέλεση κώδικα παρά τον επιπλέον χρόνο που απαιτείται στη φάση μεταγλώττισης.

Η κύρια ιδέα πίσω από τους σύγχρονους κινητήρες είναι ο συνδυασμός των καλύτερων και των δύο κόσμων:

  • Γρήγορη εκκίνηση εφαρμογών του διερμηνέα.
  • Γρήγορη εκτέλεση του μεταγλωττιστή.

Η επίτευξη και των δύο στόχων ξεκινά με τον διερμηνέα. Παράλληλα, ο κινητήρας επισημαίνει τα συχνά εκτελούμενα τμήματα κώδικα ως «Hot Path» και τα μεταδίδει στον μεταγλωττιστή μαζί με πληροφορίες περιβάλλοντος που συγκεντρώθηκαν κατά την εκτέλεση. Αυτή η διαδικασία επιτρέπει στον μεταγλωττιστή να προσαρμόσει και να βελτιστοποιήσει τον κώδικα για το τρέχον πλαίσιο.

Καλούμε τη συμπεριφορά του μεταγλωττιστή «Just in Time» ή απλά JIT.

Όταν ο κινητήρας λειτουργεί καλά, μπορείτε να φανταστείτε ορισμένα σενάρια όπου το JavaScript ξεπερνά ακόμη και το C ++. Δεν υπάρχει αμφιβολία ότι το μεγαλύτερο μέρος της εργασίας του κινητήρα πηγαίνει σε αυτή τη «βελτιστοποίηση με βάση τα συμφραζόμενα»

Στατικοί τύποι κατά τη διάρκεια του χρόνου εκτέλεσης: Inline Caching

Το Inline Caching ή IC είναι μια σημαντική τεχνική βελτιστοποίησης στις μηχανές JavaScript. Ο διερμηνέας πρέπει να πραγματοποιήσει αναζήτηση για να έχει πρόσβαση στην ιδιότητα ενός αντικειμένου. Αυτή η ιδιότητα μπορεί να είναι μέρος του πρωτοτύπου ενός αντικειμένου, να έχει μια μέθοδο λήψης ή ακόμη και να είναι προσβάσιμη μέσω ενός διακομιστή μεσολάβησης. Η αναζήτηση του ακινήτου είναι αρκετά ακριβή όσον αφορά την ταχύτητα εκτέλεσης.

Η μηχανή εκχωρεί κάθε αντικείμενο σε έναν "τύπο" που δημιουργεί κατά τη διάρκεια του χρόνου εκτέλεσης. Το V8 καλεί αυτούς τους «τύπους», οι οποίοι δεν αποτελούν μέρος του προτύπου ECMAScript, κρυφές κλάσεις ή σχήματα αντικειμένων. Για να μοιραστούν δύο αντικείμενα το ίδιο σχήμα αντικειμένου, και τα δύο αντικείμενα πρέπει να έχουν ακριβώς τις ίδιες ιδιότητες με την ίδια σειρά. Έτσι ένα αντικείμενο {firstname: "Han", lastname: "Solo"}θα εκχωρηθεί σε διαφορετική κλάση από {lastname: "Solo", firstname: "Han"}.

Με τη βοήθεια των σχημάτων αντικειμένων, ο κινητήρας γνωρίζει τη θέση μνήμης κάθε ιδιοκτησίας. Ο κινητήρας κωδικοποιεί αυτές τις τοποθεσίες στη λειτουργία που έχει πρόσβαση στην ιδιότητα.

Αυτό που κάνει το Inline Caching είναι η εξάλειψη των λειτουργιών αναζήτησης. Δεν υπάρχει αμφιβολία ότι αυτό επιφέρει τεράστια βελτίωση της απόδοσης.

Επιστρέφοντας στο προηγούμενο παράδειγμά μας: Όλα τα αντικείμενα στην πρώτη εκτέλεση είχαν μόνο δύο ιδιότητες firstnameκαι lastname, με την ίδια σειρά. Ας υποθέσουμε ότι το εσωτερικό όνομα αυτού του σχήματος αντικειμένου είναι p1. Όταν ο μεταγλωττιστής εφαρμόζει IC, προϋποθέτει ότι η συνάρτηση περνά μόνο το σχήμα του αντικειμένου p1και επιστρέφει την τιμή του lastnameαμέσως.

Στη δεύτερη εκτέλεση, ωστόσο, ασχοληθήκαμε με 5 διαφορετικά σχήματα αντικειμένων. Κάθε αντικείμενο είχε μια επιπλέον ιδιότητα και yodaέλειπε firstnameεντελώς. Τι συμβαίνει όταν ασχολούμαστε με πολλά σχήματα αντικειμένων;

Παρεμβατικές πάπιες ή πολλαπλοί τύποι

Functional programming has the well-known concept of “duck typing” where good code quality calls for functions that can handle multiple types. In our case, as long as the passed object has a property lastname, everything is fine.

Inline Caching eliminates the expensive lookup for a property’s memory location. It works best when, at each property access, the object has the same object shape. This is called monomorphic IC.

If we have up to four different object shapes, we are in a polymorphic IC state. Like in monomorphic, the optimised machine code “knows” already all four locations. But it has to check to which one of the four possible object shapes the passed argument belongs. This results in a performance decrease.

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

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

  • Benedikt Meurer: Μια εισαγωγή στη κερδοσκοπική βελτιστοποίηση στο V8

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

  • Mathias Bynens: Βασικές αρχές μηχανών JavaScript: Shapes and Inline Caches

    //mathiasbynens.be/notes/shapes-ics