How to find Node.js Performance Optimization Killers

You might have read in several articles that “in Node.js the code is optimized dynamically.” In this article, we will see what this means and how to detect what part of the code are optimized.

After reading this post on Node.js performace optimization killers, you should be able to:

  • Detect if a function is optimized by the JavaScript engine (V8)
  • Detect if an optimized function is de-optimized
  • Detect why a function cannot be optimized

Despite this ambitious agenda, this article will be quite simple. The goal is to make those methods available for most Node.js developers.

Brief overview of Node.js Performance Optimization in v8

As of January 2017, even if Node.js is planned to become VM neutral, most Node.js instances are based on the V8 JavaScript engine. We will focus on that for the scope of this article.

Static analysis of JavaScript code is a very complex issue. As a result, unlike other languages, it is difficult to optimize JavaScript code at compilation.

The process occurs during runtime. V8 analyzes the behavior of the code, develops heuristics and proceeds to optimizations based on what it observed.

For instance, V8 spies on the inputs and the outputs of the functions in order to see if it can perform type assertions. If the type of the arguments of a function is always the same, it seems safe to optimize this function from this assertion.

V8 performs diverse cases of optimization, but the one based on the argument's type is probably the easiest to describe.

Anatomy of an optimization...

Let’s take a look at this small snippet:

// index.js

function myFunc(nb) {  
    return nb + nb;
}

for (let i = 0; i < 2000; ++i) {  
    myFunc(i);
}

Usually, to run this file, we would use the command $ node index.js. To trace optimizations, we will add an argument to the command line.

Let’s run

$ node --trace-opt index.js | grep myFunc

The | grep myFunc part is here only to keep the logs related to the function we are observing.

The result appears in the standard output:

[email protected]:~/WebstormProjects/perf$ node --trace-deopt --trace-opt index.js | grep myFunc  
[marking 0x2bc3091e7fc9 <JS Function myFunc (SharedFunctionInfo 0x1866a5c5eeb1)> for recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
[compiling method 0x2bc3091e7fc9 <JS Function myFunc (SharedFunctionInfo 0x1866a5c5eeb1)> using Crankshaft]
[optimizing 0x2bc3091e7fc9 <JS Function myFunc (SharedFunctionInfo 0x1866a5c5eeb1)> - took 0.009, 0.068, 0.036 ms]
[completed optimizing 0x2bc3091e7fc9 <JS Function myFunc (SharedFunctionInfo 0x1866a5c5eeb1)>]

The function was marked for recompilation. That is the first step of the optimization of a function.

The function has then been recompiled and optimized.

... followed by a de-optimization

// index.js

function myFunc(nb) {  
    return nb + nb;
}

for (let i = 0; i < 2000; ++i) {  
    myFunc(i);
}

for (let i = 0; i < 2000; ++i) {  
    myFunc(i + '');
}

The code is pretty much the same here. But this time, after calling the function with numbers only, we call it with a few strings. It is still a perfectly valid code since the + operator can be used for number addition and string concatenation.

Let’s run this code with:

$ node --trace-deopt --trace-opt index.js | grep myFunc
[email protected]:~/WebstormProjects/perf$ node --trace-deopt --trace-opt index.js | grep myFunc  
[marking 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)> for recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
[compiling method 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)> using Crankshaft]
[optimizing 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)> - took 0.010, 0.076, 0.021 ms]
[completed optimizing 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)>]
[deoptimizing (DEOPT eager): begin 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)> (opt #0) @1, FP to SP delta: 24, caller sp: 0x7ffe2cde6f40]
  reading input frame myFunc => node=4, args=2, height=1; inputs:
      0: 0xc6b3e5e7fb9 ; [fp - 16] 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)>
  translating frame myFunc => node=4, height=0
    0x7ffe2cde6f10: [top + 0] <- 0xc6b3e5e7fb9 ;  function    0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)>  (input #0)
[deoptimizing (eager): end 0xc6b3e5e7fb9 <JS Function myFunc (SharedFunctionInfo 0x87d8115eec1)> @1 => node=4, pc=0x30c7754496c6, caller sp=0x7ffe2cde6f40, state=NO_REGISTERS, took 0.047 ms]
[removing optimized code for: myFunc]
[evicting entry from optimizing code map (notify deoptimized) for 0x87d8115eec1 <SharedFunctionInfo myFunc>]

The first part of this log is pretty similar to the previous paragraph.

However, there is a second part in which the function is de-optimized: V8 detected that the type assumption made before (“inputs of myFunc are numbers”) was false.

A wrong heuristic

In this short example, we saw how to trace the optimization and the de-optimization of a function. We also saw how the heuristics made by V8 could be fragile. It leads us to a first statement:

Even if JavaScript is not strongly typed, V8 has optimization rules which are. Therefore, it is a good idea to have coherent typings as arguments and return values of a function.

Non-optimization

In the previous example, we saw that before being optimized, a function is marked for recompilation.

Sometimes, V8 will mark function as non-optimizable. Let’s run the following code:

// try.js
function myFunc(nb) {  
    try {
        return nb + nb;
    }
    catch (err) {
        return err;
    }
}

for (let i = 0; i < 2000; ++i) {  
    myFunc(i);
}
[email protected]:~/WebstormProjects/perf$ node --trace-deopt --trace-opt try.js | grep myFunc  
[disabled optimization for 0x3a450705eeb1 <SharedFunctionInfo myFunc>, reason: TryCatchStatement]

So this time, instead of being marked for optimization, myFunc has been marked as “not optimizable”. The reason is provided in the log: “TryCatchStatement”.

By design, functions that contain a try - catch statement are considered as not optimizable.

The logic here is simple; there are patterns in JavaScript that can have very different behaviors at runtime. V8 decides to never optimize those functions to avoid to fall in a de-optimization hell.

De-optimization hell

De-optimization hell happens in V8 when a function is optimized and de-optimized a lot during the runtime.

After a few cycles optimization/de-optimization, V8 will flag the method as not optimizable. However, a significant amount of time will have been lost in this cycle with impact on the process performances and memory consumption.

Other cases of non-optimization

There are a lot of other patterns that prevent optimization by V8. They are listed in a Github repository.

Going around non-optimizations

We will now see a not-so-elegant method for dealing with non-optimization patterns, through the example of the try-catch statement. Let’s run:

function tryThis (run, caught) {

    try {
        return run();
    }
    catch (err) {
        return caught(err);
    }
}

function myFunc(nb) {  
    return tryThis(() => nb + nb, (err) => err)
}

for (let i = 0; i < 2000; ++i) {  
    myFunc(i);
}
[email protected]:~/WebstormProjects/perf$ node --trace-opt tryHack.js | grep -E 'myFunc|tryThis'  
[disabled optimization for 0x33aa5d55ecf1 <SharedFunctionInfo tryThis>, reason: TryCatchStatement]
[marking 0x5099c3e7e89 <JS Function myFunc (SharedFunctionInfo 0x33aa5d55edb1)> for recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
[compiling method 0x5099c3e7e89 <JS Function myFunc (SharedFunctionInfo 0x33aa5d55edb1)> using Crankshaft]
[marking 0x5099c3f4c11 <JS Function tryThis (SharedFunctionInfo 0x33aa5d55f729)> for recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
[marking 0x5099c3fb269 <JS Function tryThis (SharedFunctionInfo 0x33aa5d55f729)> for recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
[optimizing 0x122928c04f49 <JS Function myFunc (SharedFunctionInfo 0x33aa5d55edb1)> - took 0.013, 0.103, 0.052 ms]
[completed optimizing 0x122928c04f49 <JS Function myFunc (SharedFunctionInfo 0x33aa5d55edb1)>]
[marking 0x122928c94901 <JS Function tryThis (SharedFunctionInfo 0x33aa5d55f729)> for recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]

In this log, it appears that:

  • Optimization is disabled for tryThis since it contains a try-catch statement
  • myFunc is optimized
  • tryThis is marked for recompilation but it never happens since it is disabled for this function

That leads us to another principle of V8 optimization:

Isolate non-optimization patterns in separate functions that will not be optimized.

Conclusion

In this article, we saw how to trace optimizations, de-optimizations, and non-optimizations in Node.js. This is a good starting point for your journey through optimizing your Node.js code.

A high-level tool to explore optimization and de-optimization is named IRHydra. A short introduction to its usage with Node.js can be found on Eugene Obrezkov’s blog.

Feel free to share this article if you found it interesting. Also, do not hesitate to contact me on Twitter or by email ([email protected]) if you want to discuss or you have questions related to Sqreen or my article.

To read more articles from me, follow the Sqreen blog. I write about Node.js (and often about Security in Node.js). Also, if you have a Node.js application in production, you should definitely check out what we do at Sqreen: we provide a simple solution to monitor security related events in your application and protect it against attacks. The best part being, installing Sqreen is as simple as adding an npm package to your code.