JavaScript Basics: Why You Should Know How a JS Engine Works

For prospective students on the course “JavaScript Developer. Basic” prepared a translation of useful material.

We also invite you to an open webinar on the topic “What tasks test your knowledge of JavaScript”: take test questions from different systems and see what these questions are about, what they test and what you need to know in order to answer them correctly.


In this article, I want to explain that a software developer who uses JavaScript to write applications must understand the engines in order for the written code to work correctly.

Below you will see a one-line function that returns a property lastName of the passed argument. By simply adding one property to each object, we get over 700% performance drop!

I will explain in more detail why this is happening. The lack of static typing in JavaScript leads to this behavior. If we consider this as an advantage over other languages ​​such as C # or Java, then in this case it turns out rather “Faustian bargain” (“Faustian deal”. The sacrifice of spiritual values ​​for material benefits; the origin of the expression is associated with the name of J. Faust).

Braking at full speed

We usually don’t need to know the internals of the engine that runs our code. Browser makers are investing heavily in making their engines run code very quickly.

Great!

Let others do the hard work. Why bother with how the engines work?

In our example code below, we have five objects that store the first and last names of characters from Star Wars. The getName function returns the value of the last name. Let’s measure the total execution time of this function:

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

one line code example

console.time("engine");  for(var i = 0; i < 1000 * 1000 * 1000; i++) {     getName(people[i & 7]);   }  console.timeEnd("engine"); })();

On Intel i7 4510U, the execution time is about 1.2 seconds. So far so good. Now we will add one more property to each object and execute it again.

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

Our runtime is now 8.5 seconds, which is about 7 times slower than our first version. It’s like braking at full speed. How could this happen?

It’s time to take a closer look at the engine’s work.

Combined Forces: Interpreter and Compiler

An engine is that part (component) of a program that reads and executes source code. Every major browser manufacturer has its own engine. Mozilla Firefox has Spidermonkey, Microsoft Edge is Chakra / ChakraCore, and Apple Safari calls its engine JavaScriptCore. Google Chrome uses V8, which is also the engine for Node. js. The release of the V8 in 2008 marked a turning point in engine history. V8 replaced the browser’s relatively slow JavaScript interpreter.

The reason for this significant improvement is mainly due to the combination of interpreter and compiler. To date, all four engines use this technique.

The interpreter executes the source code almost immediately. The compiler generates machine code, which the user’s system then executes on its own.

Since the compiler is working on generating machine code, it uses optimization. Compilation and optimization together result in faster code execution despite the additional time required to compile.

The main idea of ​​modern engines is to combine the best of both worlds:

  • Quick launch of the interpreter.

  • Fast compiler execution.

A modern engine uses an interpreter and a compiler.  Source: imgflip
A modern engine uses an interpreter and a compiler. Source: imgflip

Both goals begin with interpretation. In parallel, the engine marks frequently executed portions of code as “Hot Path” and passes them to the compiler along with contextual information gathered at runtime. This process allows the compiler to adapt and optimize the code for the current context.

We call the compiler behavior “Just in Time” or just JIT (Just-in-time compilation).

With good engine performance, some scenarios are possible in which JavaScript is even superior to C ++. Unsurprisingly, most of his effort goes into “contextual optimization”.

Interaction between the Interpreter and the Compiler
Interaction between the Interpreter and the Compiler

Static Types at Runtime: Inline Caching

Inline caching, or IC, is the main optimization technique in JavaScript engines. The interpreter must search before it can access the properties of an object. This property can be part of the object’s prototype, it must be able to access it using the Getter method (getter method) or even through a proxy server. Finding a property is a rather expensive process in terms of execution speed.

The engine assigns each object a “type” that it generates at runtime. V8 calls these “types”, which are not part of the ECMAScript standard, hidden classes or object forms. For two objects to have the same object shape, they must have exactly the same properties in the same order. So the object {firstname: "Han", lastname: "Solo"} will be assigned to a different class than {lastname: "Solo", firstname: "Han"}

Using the shape of the object, the engine determines the memory localization of each property. The engine hard-codes these places into a function that accesses the property.

What Inline Caching does is eliminate lookups. Unsurprisingly, this translates into significant performance gains.

Going back to our previous example: All objects in the first run had only two properties, firstname and lastname, in the same order. Let’s say the internal name of this object form is – p1… When the compiler applies IC, it assumes that the function only passes the shape of the object p1 and immediately returns lastname

Inline Caching in Action (Monomorphic)
Inline Caching in Action (Monomorphic)

However, in the second run, we were dealing with 5 different object shapes. Each object had an additional property, and in yoda absent firstname… What happens when we are dealing with multiple object shapes?

Intervening Ducks or Multiple Types

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

Internal caching eliminates costly property memory location lookups. This works best when the object has the same shape every time the property is accessed. This is called a monomorphic IC.

If we have up to four different forms of an object, then we are in the IC polymorphic state. As in the monomorphic state, the optimized machine code already knows all four locations. But it must check which of the four possible forms of the object the passed argument belongs to. This leads to a decrease in performance.

As soon as we go beyond the threshold of four, it dramatically decreases performance. We are now in the so-called Megamorphic IC. In this state, local caching of memory cells no longer occurs. Instead, they need to be viewed from the global cache. This leads to the extreme drop in performance we saw above.

Polymorphism and Megamorphism in Action

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

Polymorphic Inline Cache
Polymorphic Inline Cache

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

Megamorphic Inline Cache
Megamorphic Inline Cache

JavaScript class to help

So, we had 5 object shapes and we were faced with a megamorphic IC. How can we fix this?

We need to make sure that the engine marks all 5 of our objects and their shapes as the same. This means that all objects we create will have to be endowed with all possible properties. We could use object literals, but I find JavaScript classes to be the best solution.

For properties that are not defined, we just pass null or skip. The constructor makes sure that these fields are value-initialized:

(() => {  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 its execution time returns to 1.2 seconds. Mission accomplished!

Summary

Modern JavaScript engines combine the advantages of an interpreter and a compiler: Fast application launch and fast code execution.

Inline Caching is a powerful optimization technique. It works best when only one object shape is passed to the optimized function.

My example showed the effectiveness of Inline caching of various types and the problems when working with megamorphic caches.

Using JavaScript classes is good practice. Static typed transpilers like TypeScript make monomorphic IC more attractive to use.


Learn more about the course “JavaScript Developer. Basic”

Watch an open webinar on the topic: “What tasks test your knowledge of JavaScript”

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *