Original link: blog.logrocket.com/how-javascr…

How JavaScript Works: Optimizing the V8 Compiler for Efficiency

This article was originally published on the public account CoyPan as expected

Understanding how JavaScript works is key to writing efficient JavaScript code.

Forget the trivial millisecond improvements: Using object attributes incorrectly can slow down a simple line of code by a factor of seven.

Given the ubiquity of JavaScript at all levels of the software stack, if not all levels of infrastructure are likely to experience a slight slowdown, not just in menu animations for websites.

There are many ways to write efficient JavasScript code, but in this article, we will focus on compiler-friendly optimization methods, which means that source code makes compiler optimization simple and efficient.

We’ll narrow our discussion down to V8, the JavaScript engine that supports Electron, Node.js, and Google Chrome. To understand compiler-friendly optimizations, we first need to discuss how JavaScript is compiled.

JavaScript execution in V8 can be broken down into three phases:

  • Source to Abstract syntax tree: The parser generates an abstract syntax tree (AST) from source code
  • Abstract syntax tree to bytecode: V8’s interpreter Ignition generates bytecode from the abstract syntax tree. Note that the bytecode generation step is not available until 2017.
  • Bytecode to machine code: TurboFan, V8’s compiler, generates a graph from bytecode, replacing parts of bytecode with highly optimized machine code.

The first phase is beyond the scope of this article, but the second and third phases have a direct impact on writing optimized JavaScript.

We’ll discuss these optimizations and how code can exploit (or abuse) them. By understanding the basics of JavaScript execution, you can not only understand these performance recommendations, but also learn how to spot some of your optimizations.

In fact, the second and third stages are tightly coupled. These two phases operate in a just-in-time (JIT) paradigm. To understand the importance of JIT, we will examine the previous approach of converting source code to machine code.

Just – in – Time (JIT) paradigm

In order to execute any program, the computer must convert the source code into code that the machine can run.

There are two ways to do this.

The first option is to use an interpreter. The interpreter effectively translates and executes line by line.

The second method is to use a compiler. The compiler converts all source code to machine language immediately before execution.

Below, we discuss the advantages and disadvantages of both approaches.

Advantages and disadvantages of the interpreter

The interpreter works using the read-eval-print loop (REPL) approach — this approach has a number of advantages:

  • Easy to implement and understand
  • Timely feedback
  • A more appropriate programming environment

However, these benefits come at the cost of slow implementation:

(1) The overhead of EVAL instead of running machine code.

(2) Unable to optimize each part across the program.

More formally, the interpreter does not recognize repetitive work when working with different code segments. If you run the same line of code through the interpreter 100 times, the interpreter will translate and execute the same line of code 100 times, unnecessarily retranslating 99 times.

To summarize, the interpreter is simple and fast to start, but slow to execute.

Advantages and disadvantages of compilers

The compiler translates all source code before execution.

As complexity increases, compilers can optimize globally (for example, sharing machine code for duplicate lines of code). This gives the compiler the only advantage over the interpreter — faster execution time.

To summarize, compilers are complex, slow to start, but fast to execute.

Just-in-time compilation (JIT)

The just-in-time compiler attempts to combine the best of both the interpreter and the compiler, making code conversion and execution faster.

The basic idea is to avoid repeated transformations. First, the profiler runs through the code through the interpreter. During code execution, the profiler keeps track of hot code pieces that run several times and hot code pieces that run many times.

The JIT sends hot code snippets to the baseline compiler to reuse as much compiled code as possible.

The JIT also sends hot code snippets to the optimized compiler. The optimization compiler uses the information gathered by the interpreter to make assumptions and optimizes based on those assumptions (for example, object properties always appear in a particular order).

However, if these assumptions are invalid, the optimization compiler performs de-optimization, discarding the optimized code.

The process of optimizing and de-optimizing is expensive. This resulted in a class of JavaScript optimizations that are described in detail below.

The JIT’s need to store optimized machine code, profiler execution information, etc., naturally introduces memory overhead. Although this can’t be improved with optimized JavaScript, it inspires the V8 interpreter.

The V8 compilation

V8’s interpreter and compiler perform the following functions:

  • The interpreter translates the abstract syntax tree into bytecode. The bytecode queue is then executed and feedback is collected through an inline cache. This feedback is used by the interpreter itself for subsequent parsing, and the compiler uses this feedback for speculative optimizations.
  • The compiler translates bytecode into architecture-specific machine code based on feedback, thereby speculatively optimizing bytecode.

V8 interpreter – Ignition

The JIT compiler shows overhead memory consumption. Ignition addresses this problem by achieving three goals: reducing memory usage, reducing startup time, and reducing complexity.

All three goals are achieved by converting the AST to bytecode and collecting feedback during program execution.

  • Bytecode is treated as source code, eliminating the need to reparse JavaScript at compile time. This means that using bytecode, TurboFan’s de-tuning process no longer requires raw code.
  • As an example of optimization based on program execution feedback, inline caching allows V8 to optimize repeated calls to functions with the same type of arguments. Specifically, the inline cache stores the input type of the function. The fewer types there are, the less type checking is required. Reducing the number of type checks can significantly improve performance.

Both AST and bytecode are exposed to TurboFan.

V8 compiler – TurboFan

When released in 2008, the V8 engine initially compiled source code directly into machine code, skipping intermediate bytecode representations. When it was released, the V8 was 10 times faster than its competitors.

Today, however, TurboFan has embraced Ignition’s bytecode 10 times faster than it launched. The V8 compiler went through a series of iterations:

  • 2008 – Full – Codegen
    • Compilers with hidden classes and inline caches that quickly traverse the AST
    • Cons: Just-in-time compilation without optimization
  • 2010 – Crankshaft
    • Optimize the just-in-time compiler using type feedback and de-optimization.
    • Disadvantages: no extension to modern JavaScript, heavy reliance on de-optimization, limited static type analysis, tight coupling with Codegen, high porting overhead
  • 2015 – TurboFan
    • Optimize the just-in-time compiler with type and range analysis

TurboFan is optimized for peak performance, use of static type information, compiler front, middle, and back end separation, and testability, according to the Google Munich Technical Talk (Titzer, March 16). The result is a key contribution: the nodal sea.

In the node sea, nodes represent calculations and variables represent dependencies.

Unlike control Flow diagrams (CFG), node sea can relax the evaluation order for most operations. As with CGF, the control and effect edges of stateful operations constrain the order of execution when needed.

Titzer further refines this definition into a node soup in which the control flow subgraph is further relaxed. This provides many benefits – for example, it avoids the elimination of redundant code.

Graph reduction is applied to this series of nodes through bottom-up or top-down graph transformation.

TurboFan follows four steps to convert bytecode to machine code. Note that the optimizations in the pipeline below were performed based on feedback from Ignition.

  • Represents the program as a JavaScript operator. (for example: JSADD)
  • Represents the program as an intermediate operator. (Virtual machine-level operators; Unknowable number representation, e.g. NumberAdd)
  • Represents a program as a machine operator. (Corresponding to the machine operator, for example: Int32Add)
  • Use order constraints to arrange the execution order. Create a traditional control flow diagram.

TurboFan’s online JIT style compilation and optimization means V8’s conversion from source to machine code is over.

How to optimize your JavaScript

TurboFan optimizations improve JavaScript web performance by mitigating the effects of bad JavaScript. However, understanding these optimizations can provide further acceleration.

Here are seven tips for using optimizations in V8 to improve performance. The first four focuses on reduction to optimization.

Tip1: Declare object properties in constructors

Changing object properties produces new hidden classes. Take the following example from Google I/O 2012.

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y; }}var p1 = new Point(11.22);  // hidden class Point created
var p2 = new Point(33.44);

p1.z = 55;  // another hidden class Point created
Copy the code

As you can see, P1 and P2 now have different hidden classes. This hampers TurboFan’s optimization attempts: specifically, any method that accepts a Point object is now de-optimized.

All of these functions have been re-optimized using two hidden classes. This applies to any modification of the object’s shape.

Tip2: Leave object properties unchanged

Changing the order of object attributes results in new hidden classes because the order is contained in the object shape.

const a1 = { a: 1 };  # hidden class a1 created
a1.b = 3;

const a2 = { b: 3 };  # different hidden class a2 created
a2.a = 1;
Copy the code

In the code above, A1 and A2 have different hidden classes. The repair sequence allows the compiler to reuse the same hidden class. Because the added fields (including the order) are used to generate the ID of the hidden class

Tip3: Fixes function parameter types

The function changes the object shape based on the value type of a particular parameter position. If this type changes, the function is de-optimized and re-optimized.

After seeing four different object shapes, the function becomes megamorphic, and TurboFan will no longer try to optimize it.

Look at this example:

function add(x, y) {
  return x + y
}

add(1, 2);  # monomorphic
add("a", "b");  # polymorphic
add(true, false);
add([], []);
add({}, {});  # megamorphic
Copy the code

TurboFan will no longer optimize the Add function after line 9.

Tip4: Declare classes in script scope

Do not declare classes in function scope. Take this example:

function createPoint(x, y) {
  class Point {
    constructor(x, y) {
      this.x = x;
      this.y = y; }}return new Point(x, y);
}

function length(point) {... }Copy the code

Each time the createPoint function is called, a new Point prototype is created.

Each new prototype corresponds to a new object shape, so each time the Length function sees a new object shape for Point.

As before, when you see four different object shapes, the function becomes megamorphic and TurboFan doesn’t try to optimize anymore

The length function.

By declaring class Point in script scope, we can avoid generating a different object shape each time we call createPoint.

The next tip is on the V8 engine.

Tip5:The for... in

This is a quirk in the V8 engine. This feature was previously included in the original Crankshaft and was ported to Ignition and Turbofan.

The for… The in loop is 4-6 times faster than function iteration, function iteration with arrow functions, and object.keys in the for loop.

The next two tips are refutations of the first two. Thanks to changes in the modern V8 engine, both claims are no longer true.

Tip6: Irrelevant characters do not affect performance

Crankshafts used to use the number of bytes of a function to determine whether or not a function was inlined. TurboFan is built on the AST and uses the number of AST nodes to determine the size of the function.

Therefore, irrelevant characters, such as whitespace, comments, variable name length, function signature, etc., do not affect function performance.

Tip7: Try/catch/finally is not destructive

Try blocks used to be prone to costly optimizations – de-optimizations cycles. Turbofan no longer shows a significant performance impact when functions are called within a Try block today.

conclusion

In general, optimization approaches generally focus on reducing de-optimizations and avoiding non-optimizable Megamorphic functions.

From our understanding of the V8 engine framework, we can also infer additional optimization methods not listed above, and reuse methods to take advantage of inlining as much as possible. You now have an understanding of JavaScript compilation and its impact on everyday JavaScript use.