DEV Community

Cover image for Mastering JavaScript high performance
Marc
Marc

Posted on • Edited on • Originally published at marcradziwill.com

Mastering JavaScript high performance

Intro to JavaScript performance optimization

JavaScript performance optimization techniques are simple to apply if you stick to some "rules" or better-said bits of advice. The implementation of these rules come with a minimum of understanding of how JavaScript engines work today.


If you like this article, smile for a moment, share it, follow me, check out my RSS feed and subscribe to my newsletter.


For the sake of simplicity, we focus on the V8 the JavaScript engine inside of Chrome and Node.js. The V8 engine offers us passionate performance web developers several possibilities to optimize our JavaScript code.

I posted this article initially on my blog.

Tl;dr

In this article, you read about the JavaScipt engine V8, its main parts for parsing and compiling JavaScript, and how you can help V8 to optimize your JavaScript code. I outline techniques for you to implement.

Table of Contents

  1. What is a JavaScript engine?
  2. The Performance Advice
  3. How to optimize your JavaScript code
  4. Conclusion

What is a JavaScript engine?

In simple words, the JavaScript engine converts your code into byte-code or optimized machine-code that runs on your device. The V8 engine has two main parts that play together in this process. As the V8 team choose car part names for the engine, it stuck with that by naming the subprocesses: Ignition and Turbofan.

Ignition

Ignition is the low-level register-based interpreter. It has a fast start-up, so it's valuable for code executed at page load and rarely executed code in your application. It knows all the JavaScript rules and the ECMAScript standards and won't skip any parts of your code. Therefore the byte-code runs, but it doesn't run fast.

Web Performance Checklist

Turbofan

Turbofan is the optimization compiler. It generates swift machine code by making optimistic assumptions (we come back to that later). Turbofan is perfect for code after page load and frequently executed code.

But how does this help us to write more efficient JavaScript code? It is the combination of the interpreter and compiler that allows a high-speed JavaScript execution and the best part of it. We can feed the optimization compiler with code that is likely to be optimized.

The duet of Ignition and Turbofan

Let's have a quick, simplified look under the hood. After our JavaScript file is loaded and parsed the V8 generates an abstract syntax tree from it and forwards it to the interpreter which generates byte-code from the AST. As well as Ignition generates byte-code, it also gathers type feedback from the code. Let's assume we have the following simple function.

(a) => a + 2;
Enter fullscreen mode Exit fullscreen mode

While Ignition executes the code, it gathers type feedback. For our function, this is that a is a Number. To make it even more simple, we assume that we always pass a number for a. The heuristics in Ignition then triggers and marks the function as hot code (code with optimization possibilities).

Pipeline of the JavaScript engine v8

Ignition then passes the function to Turbofan and provides the type feedback as well. Now it's Turbofans turn. Turbofan makes optimistic assumptions about the code to generate highly optimized machine code. To stay with our example above Turbofan generates machine code for the case that a is a number and that code can execute much faster then the byte-code from Ignition could.

But as JavaScript is dynamic, we could call our function with a String instead of a Number. In this case, the optimistic assumption Turbofan made is wrong, and we have to go back to Ignitions execution.

Let's recap what just happened. First, Ignition passes our code to Turbofan, and the code is optimized. After the assumption was wrong because we added another "type" the code is de-optimized.

So what do we want for the V8 and the other optimization compilers? Code that can be optimized!

The Performance Advice

The most useful advice when it comes to performance optimization for your codebase is measure before you optimize! As we go through these bits of advice and steps to optimize our JavaScript code, we come across many micro-optimizations. Small parts of your codebase like changing the way you handle arrays access or the initialization of objects. You don't want to change every single line of code and apply all these performance suggestions. You instead need to find the blocks in your application that have a significant influence on your performance (measure) and optimize these parts (optimize).

You won't be happy to go through your codebase of hundreds of thousands lines of code and change everything. You always have to measure before. Take your most popular page and measure where you might have a performance issue related to JavaScript.

How to optimize your JavaScript code

Hidden classes in JavaScript engines

Hidden classes are the academic term for generating similar shapes of our JavaScript code. Each JavaScript engine has its term. SpiderMonkey uses Shapes, in V8 they are called Maps, Chakra uses Types and JSC Structures. Let's consider the following example:

let objectA = {
  x: 1,
  y: 2,
  z: 3,
};

let objectD = {
  x: 1,
  y: 2,
  z: 3,
};
Enter fullscreen mode Exit fullscreen mode

Javascript engines generate shapes of each object that you create. If you create similar objects, they share the same shape (Hidden class, Map, Structure, etc.). In the example above, objectA shares a shape with objectB thus, the engine does not need to allocate additional memory for each object shape. It only needs to store its values. This performance boost is strictly dependent on the order and the way you initialize your objects.

Let's assume we have the code below. If you think of all the objects as a JavaScript developer, there is not much difference. But for JavaScript engines, the order and the way you initialize and declare your objects is significantly essential. In this case, objectA, objectB and objectC won't have the same shape (hidden class), and so V8 can not optimize them concerning their hidden classes. objectD instead has the same hidden class as objectA and can be accessed faster. V8 knows it's shape/hidden class and can access the stored values in memory faster.

let objectA = {
  x: 1,
  y: 2,
  z: 3,
};

let objectB = {};

objectB.x = 1;
objectB.y = 2;
objectB.z = 3;

let objectC = Object.assign({}, objectA);
let objectD = {
  x: 1,
  y: 2,
  z: 3,
};
Enter fullscreen mode Exit fullscreen mode

Takeaways for hidden classes

  • Initialize all object members in constructor functions.
  • Always initialize object members in the same order.

Inline Caching

Shapes or hidden classes enable inline caching. Inline caching is the crucial element to making JavaScript run fast. On an abstract level, inline caching describes that repeated calls on the same method favour to happen on the same objects. Thus V8 caches the type of objects that we pass as a parameter in method calls. Then it uses that information to assume the type of object that we pass as a parameter in the future. If the assumption is true V8 can skip the access to the real object properties in memory and return the cached values instead.

So how are do inline caching and hidden classes work together? Whenever a function is called, V8 looks up the hidden class for that specific object. If the method on that object or an object with the same hidden class is called multiple times, V8 caches the information where to find the object property in memory and returns it instead of looking up the memory itself. Thus on calls in the future V8 can jump directly into the memory address for the object property as long as the hidden class does not change.

That's why it is so essential to implement objects with the same properties in the same order to have the same hidden class. Otherwise, V8 won't be able to optimize your code. In V8 words, you want to stay as much monomorphic as possible. Check out the table below which I found on a blogpost of Benedikt Meurer where you find the different states for inline caching.

Monomorphic, Polymorphic, Megamorphic

As JavaScript is very dynamic, you can do many things without thinking about the types. As described above, it's crucial for performance reasons to stay with the same hidden classes that V8 and the other engines can make assumptions to our code. Like I mentioned in the paragraph above there are different states for inline caching. In the table from Benedikt Meurer, you find their definition.

Marker Name Description
0 UNINITIALIZED The property access was not executed so far.
. PREMONOMORPHIC The property access was executed once, and we are likely going to go MONOMORPHIC on the next hit.
1 MONOMORPHIC The property access was always executed with the same shape.
P POLYMORPHIC The property access was always executed with one of four different shapes.
N MEGAMORPHIC The property access has seen too many different shapes.
source

So our goal is to stay as much monomorphic as possible. But what we want to check the inline caching states itself? V8 gives us a possibility with a local build on your machine. To do this, we need to install V8 on our mac, in particular, a debug build of V8 the d8. We can follow the steps on the official V8 site. For me, as a Mac user, the instructions of kevincennis on github worked as well.

⚠️ Alias setup ⚠️: As I used the setup some alias in my bash profile to make the commands of v8 more readable. If you skip this, keep in mind to use the full path to your local V8 installation.

Let's read through the following code. It looks quite similar to the example from inline caching. As you probably already noticed is that I declared two objects with the "same" properties in different ways. First obj1 is declared and initialized with x and y. obj2 only contains property x at the beginning and we add the property y after the first initialization.

function getX(o) {
  return o.x;
}
const obj1 = {
  x: 2,
  y: 4.1,
};

const obj2 = {
  x: 4,
};
obj2.y = 2.2;

let iterations = 1e7;
while (iterations--) {
  getX(obj1);
  getX(obj2);
}
Enter fullscreen mode Exit fullscreen mode

As we know, we should avoid this kind of implementation to help V8 optimize our code, and thus we can assume that our code won't be monomorphic. After we installed the debug build of V8, we can use d8 to check the inline caching states. With the following command, we run the code with d8 and pass the parameter to check the inline caching: $ d8 --trace-ic inline-caching.js.

d8 now creates a file called v8.log which we use to display the inline caches state. We now use the inline caching processor of v8 and our previously generated file v8.log. With $ ic-processor v8.log the inline caching processor of v8 outputs the states. In our case, we are looking for two lines of code.

LoadIC (0->1) at ~getX inline-caching.js:2:11 x (map 0x1b6008284ef1)
LoadIC (1->P) at ~getX inline-caching.js:2:11 x (map 0x1b6008284e79)
Enter fullscreen mode Exit fullscreen mode

If we compare the states in the output to our overview table, we see that the state first changes from (0->1) so from UNINITIALIZED to MONOMORPHIC and then from MONOMORPHIC to POLYMORPHIC with (1->P).

Let's make a small change in our code and repeat the steps to check the inline caching again. We change the obj2 so it has the same hidden class as obj1. Now we run $ d8 --trace-ic inline-caching.js and $ ic-processor v8.log again.

const obj2 = {
  x: 4,
  y: 2.2,
};
Enter fullscreen mode Exit fullscreen mode

As you see the inline caching state of our code remains monomorphic just by having the same order of object creation.

LoadIC (0->1) at ~getX inline-caching.js:2:11 x (map 0x22c208284e79)
Enter fullscreen mode Exit fullscreen mode

To have a clearer seperation you will find a new JavaScript file called inline-cachine-opti.js with the optimized code in the belonging repository.

Takeaways for inline caching

  • keep the type of parameters safe and don't mix them up.
  • Always initialize object members in the same order.

Take care of JavaScript arrays

To understand how arrays work, you need to know that array indexes are handled differently in V8 than objects. Array indexes are stored separately in V8 even if they often behave the same as object properties. V8 calls the stored Array indexes elements.

Objects have properties that match to values and arrays have indexes that map to elements (by Mathias Bynens at SmashingConf London 2018.)

To optimize our arrays the best, V8 keeps track of which kind of element the array contains. V8 differences between 6 kinds of elements.

  1. PACKED_SMI_ELEMENTS
const array = [1, 2, 3];
Enter fullscreen mode Exit fullscreen mode
  1. PACKED_DOUBLE_ELEMENTS
const array = [1, 2, 3, 4.5];
Enter fullscreen mode Exit fullscreen mode
  1. PACKED_ELEMENTS
const array = [1, 2, 3, 4.5, 'string'];
Enter fullscreen mode Exit fullscreen mode

From 1. to 6. the elements stand for different kinds of stored value groups and 1. is more specific where 6. is more generic. Each has its own set of optimization, and you can imagine that the optimization takes longer the more generic it is. PACKED means, that the array has no empty entries, SMI stands for small integers, DOUBLE for double values and the rest is a PACKED_ELEMENTS. HOLEY_{} elements kindes are a little different. It means the array has holes in it or better said it has empty entries. In this case, the JavaScript engine has to perform more checks and needs to follow the prototype chain up what takes ways longer that checks on a PACKED_{} array.

  1. HOLEY_SMI_ELEMENTS
const array = [1, 2, , 4];
Enter fullscreen mode Exit fullscreen mode
  1. HOLEY_DOUBLE_ELEMENTS
const array = [1, 2, , 4, 5.2];
Enter fullscreen mode Exit fullscreen mode
  1. HOLEY_ELEMENTS
const array = [1, 2, , 4, 'string'];
Enter fullscreen mode Exit fullscreen mode

Elements kinds can change. If you change your array on the fly like below, it gets transformed into the next state of elements kinds. You can transform elements kinds from specific to generic but never backwards. So if you have a HOLEY_ELEMENTS array, it stays holey forever and takes longer to optimize.

const array = [1, 2, 3];
// PACKED_SMI_ELEMENTS

array.push(4.5);
// PACKED_DOUBLE_ELEMENTS
Enter fullscreen mode Exit fullscreen mode

Great so now just rewrite every line of code? Nope. Remember the advice from above --> always measure before optimize. Optimizing all your arrays like above can even lead to a slower performance if you do not measure. If you have an array that is not holey and you add many items to it, the V8 has to re-allocate memory. In this case, it can be better to optimize the creation of the arrays instead of the elements kindes and allocate the arrays the known size (new Array(n)).

If you want to check what kind of elements your line of code has, you can follow the instructions on a blog post if Mathias Bynens.

Takeaways for arrays

  • Use consistent indices starting at 0 for Arrays
  • Only pre-allocate large Arrays if you are sure they are going to be big. If you have an average array size, grow as you go.
  • Don't delete elements in Arrays.
  • Don't access deleted elements or indices in Arrays.

Conclusion

You find all the resources for this article on GitHub. Feel free to check it out, try some stuff or fork it.

In this post, I tried to show you some bits of advice you can use to optimize your JavaScript code for performance issues and some understanding of what happens under the hood of JavaScript engines.

If you like this article, smile for a moment, share it, follow me, check out my RSS feed and subscribe to my newsletter.

Cheers Marc

Photo by RKTKN on Unsplash

Top comments (0)