Optimizing JavaScript performance for V8

Original author: Chris Wilson
  • Transfer
  • Tutorial

Foreword


Daniel Clifford made an excellent talk on Google I / O about the features of optimizing JavaScript code for the V8 engine. Daniel encouraged us to strive for greater speed, carefully analyze the differences between C ++ and JavaScript, and write code, remembering how the interpreter works. I have collected in this article a summary of the most important points of Daniel’s performance, and I will update it as the engine changes.

The most important advice


It is very important to give any performance tips in context. Optimization often becomes an obsession, and deep immersion in the wilds can actually distract from more important things. You need a holistic view of web application performance - before focusing on these optimization tips, you should analyze your code with tools like PageSpeed and first get a good result overall. This will help to avoid premature optimization.

The best strategy for building a fast web application is:

  • Think it over before you run into problems.
  • Understand and get into the core of the problem.
  • Correct only what matters.

To adhere to this strategy, it is important to understand how V8 optimizes JS, to imagine how everything happens at runtime. It is also important to have the right tools. In his speech, Daniel devoted more time to developer tools; in this article, I mainly look at the features of the V8 architecture.

So let's get started.

Hidden classes


At the compilation stage, information about types in JavaScript is very limited: types may change at runtime, so it is natural to expect that it is difficult to make assumptions about them during compilation. The question arises - how, in such conditions, can one even get closer to the speed of C ++? However, V8 manages to create hidden classes for objects at runtime. Objects that have the same class share the same optimized code.

For instance:

function Point(x, y) {
  this.x = x;
  this.y = y;
}
var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// В этой точке p1 и p2 относятся к одному и тому же скрытому классу
p2.z = 55;
// Внимание! Здесь p1 и p2 становятся экземплярами разных классов!

Until the p2property " .z" was added to it , p1and p2inside the compiler they had the same hidden class, and V8 could use the same optimized machine code for both objects. The less often you change the hidden class, the better the performance.

Conclusions:

  • Initialize all objects in the constructors so that they change as little as possible in the future.
  • Always initialize the properties of an object in the same order.

The numbers


V8 keeps track of how you use variables, and uses the most efficient representation for each type. Changing the type can be quite expensive, so try not to mix floating point numbers and integers. In general, it is better to use integers.

For instance:

var i = 42;  // это 31-битное целое со знаком
var j = 4.2;  // а это число двойной точности с плавающей запятой

Conclusions:

  • Try to use 31-bit signed integers wherever possible.

Arrays


V8 uses two kinds of internal representation of arrays:

  • Real arrays for compact sequential key sets.
  • Hash tables in other cases.

Conclusions:

  • Do not force arrays to jump from one category to another.
  • Use continuous index numbering starting at 0 (exactly like in C).
  • Do not prefill large arrays (containing more than 64K elements) - this will not do anything.
  • Do not remove elements from arrays (especially numeric ones).
  • Do not access uninitialized or deleted items. Example:

    a = new Array();
    for (var b = 0; b < 10; b++) {
      a[0] |= b;  // Ни в коем случае!
    }
    //vs.
    a = new Array();
    a[0] = 0;
    for (var b = 0; b < 10; b++) {
      a[0] |= b;  // Вот так гораздо лучше! Быстрее в два раза.
    }
    

    Arrays of numbers with double precision work most quickly - the values ​​in them are unpacked and stored as elementary types, and not as objects. Mindless use of arrays can lead to frequent unpacking:

    var a = new Array();
    a[0] = 77;   // Выделение памяти
    a[1] = 88;
    a[2] = 0.5;  // Выделение памяти, распаковка
    a[3] = true; // Выделение памяти, упаковка
    

    It will be much faster like this:

    var a = [77, 88, 0.5, true];
    

    In the first example, individual assignments occur sequentially, and at the moment when it a[2]receives the value, the compiler converts it ainto an array of decompressed numbers with double precision, and when it is a[3]initialized with a non-numeric element, the inverse conversion occurs. In the second example, the compiler will immediately select the desired array type.

Thus:

  • Small fixed arrays are best initialized using an array literal.
  • Fill small arrays (<64K) before use.
  • Do not store non-numeric values ​​in numeric arrays.
  • Try to avoid conversions when initializing not through literals.

JavaScript compilation


Although JavaScript is a dynamic language, and was originally interpreted, all modern engines are actually compilers. Two compilers work in V8 at once:

  • The base compiler that generates code for the entire script.
  • An optimizing compiler that generates very fast code for the hottest sites. This compilation takes longer.

Base compiler


In V8, he first begins to process all the code and runs it as quickly as possible. The code generated by it is almost not optimized - the basic compiler makes almost no assumptions about types. During execution, the compiler uses inline caches, which store type-dependent sections of code. When this code is restarted, the compiler checks the types used in it before choosing the appropriate version of ready-made code from the cache. Therefore, operators that can work with different types are slower to execute.

Conclusions:

  • Prefer monomorphic operators to polymorphic ones.

An operator is monomorphic if the hidden type of operands is always the same, and polymorphic if it can change. For example, the second call add()makes the code polymorphic:

function add(x, y) {
  return x + y;
}
add(1, 2);      // + внутри add() мономорфен
add("a", "b");  // + внутри add() становится полиморфным

Optimizing compiler


In parallel with the work of the basic compiler, the optimizing compiler recompiles the "hot", that is, those that are executed frequently, sections of code. It uses type information stored in inline caches.

The optimizing compiler tries to embed functions in call locations, which speeds up execution (but increases memory consumption) and allows for additional optimizations. Monomorphic functions and constructors can easily be built in entirely, and this is another reason why you should strive to use them.

You can see what exactly is optimized in your code using the standalone version of the d8 engine:

d8 --trace-opt primes.js
(names of optimized functions will be displayed in stdout)

Not all functions can be optimized. In particular, the optimizing compiler skips any functions that contain blocks try/catch.

Conclusions:

If you need to use a block try/catch, put performance-critical code outside. Example:

function perf_sensitive() {
  // Критичный к скорости код
}
try {
  perf_sensitive()
} catch (e) {
  // Обрабатываем исключения здесь
}

Perhaps in the future the situation will change, and we will be able to compile blocks with an try/catchoptimizing compiler. You can see exactly which functions are ignored by specifying an option --trace-bailoutwhen starting d8:

d8 --trace-bailout primes.js

Deoptimization


The code generated by the optimizing compiler is not always faster. In this case, the original, non-optimized version is used. Unsuccessfully optimized code is thrown away, and execution continues from the appropriate place in the code created by the base compiler. Perhaps this code will soon be optimized again, if circumstances permit. In particular, changing hidden classes inside already optimized code leads to deoptimization.

Conclusions:

  • Avoid changing hidden classes in optimized functions.

You can see exactly which functions are being optimized by running d8 with the option --trace-deopt:

d8 --trace-deopt primes.js

Other V8 Tools


The above functions can be transferred to Google Chrome at startup:

/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt --trace-bailout

There is also a profiler in d8:

d8 primes.js --prof

The d8 sampling profiler takes snapshots every millisecond and writes to v8.log.

Summary


It's important to understand how the V8 engine works in order to write well-optimized code. And do not forget about the general principles described at the beginning of the article:

  • Think it over before you run into problems.
  • Understand and get into the core of the problem.
  • Correct only what matters.

This means that you have to make sure that it is in JavaScript, using tools such as PageSpeed. It might be worth getting rid of DOM calls before looking for bottlenecks. I hope that Daniel’s presentation (and this article) will help you better understand the work of V8, but do not forget that it is often more useful to optimize the program algorithm rather than adjust to a specific engine.

References:



Also popular now: