How JavaScript Works: Under the Hood of the V8 Engine

As a full-stack developer who writes JavaScript code every day, both for web browsers and Node.js servers, I‘ve always been fascinated by what‘s happening under the hood. How does the JavaScript engine actually execute code, and what tricks does it use to make code run as fast as possible?

To answer these questions, I decided to dive deep into the internals of V8, the JavaScript engine that powers both Chrome and Node.js. What I discovered was a complex pipeline that takes raw JavaScript code and puts it through multiple steps of parsing, compiling, optimizing, and finally executing the code. In this article, I‘ll share what I learned with you.

The life of a JavaScript function

To understand how V8 works, let‘s follow the lifecycle of a simple JavaScript function from start to finish. Here‘s the code:

function add(a, b) {
  return a + b;
}

console.log(add(2, 3));

This function takes two numbers, adds them together, and returns the result. Here‘s a high-level overview of the steps V8 will take to execute this code:

  1. Parsing: V8 parses the source code into an Abstract Syntax Tree (AST).
  2. Ignition: The AST is compiled to bytecode and executed by the Ignition interpreter.
  3. Turbofan: If the function is called often, it will be optimized by the Turbofan compiler.
  4. Deoptimization: If Turbofan makes an optimization that turns out to be wrong, the code is deoptimized.

Let‘s dig into each of these steps in more detail.

Parsing

The first step is parsing the source code. V8 uses a custom parser that it claims is faster than Bison or Yacc parsers. The parser uses a technique called "Pratt parsing" for expressions. Pratt parsing associates parsing rules with token types, which lets it handle complex operator precedence in a simple way.

The output of the parsing stage is an Abstract Syntax Tree, or AST. The AST is a tree structure where each node represents a construct in the code. For example, here‘s a simplified AST for the add function:

FunctionDeclaration
  Identifier (name: add)
  Params 
    a
    b
  BlockStatement
    ReturnStatement
      BinaryExpression
        Identifier (name: a)  
        Identifier (name: b)

Ignition and TurboFan

Once V8 has an AST, it‘s time to convert it to executable code. In the early days, V8 would convert the AST directly to machine code. But this made it hard to optimize the code later, because the AST doesn‘t contain all the information needed for optimizations.

So the V8 team introduced Ignition, an interpreter, and TurboFan, an optimizing compiler. Ignition first converts the AST to bytecode. Here‘s what the bytecode for the add function might look like:

Parameter a
Parameter b
Ldar a
Ldar b
Add
Return

Ignition‘s bytecode is denser than the AST, so it takes up less memory. The bytecode also includes metadata needed for debugging, like source line positions.

Ignition executes bytecode in a big switch statement with cases for each opcode. This is faster than other interpreters that use techniques like threaded dispatch, because it takes advantage of the CPU‘s branch predictor.

If Ignition runs a function many times, it will be optimized by TurboFan. TurboFan looks at type feedback collected by Ignition to make speculative optimizations. For example, if a function is always called with numbers, TurboFan can generate code that only works for numbers.

TurboFan uses a multi-tier optimization pipeline with several intermediate representations:

  1. The original Ignition bytecode
  2. A control-flow graph in "machine-independent" form
  3. A control-flow graph with machine-specific opcodes
  4. Final machine code

TurboFan performs different optimizations at each tier. Some key optimizations are:

  • Type specialization based on feedback
  • Inlining to replace function calls with the called function‘s body
  • Elimination of redundant code and unused allocations
  • Replacing heap allocations with stack allocations

If TurboFan makes an optimization that turns out to be incorrect, like if a value‘s type changes, it has to deoptimize the code. This means throwing out the optimized code and switching back to the Ignition bytecode. TurboFan will eventually try to re-optimize the function if it becomes hot again.

Inline caches

One of the most important optimizations in V8 is the use of inline caches. An inline cache speeds up property accesses by remembering information from previous lookups.

Here‘s an example of how inline caches work:

function getX(obj) {
  return obj.x;
}

let o1 = {x: 1};
let o2 = {x: 2};

getX(o1); // Premonomorphic
getX(o1); // Monomorphic 
getX(o2); // Polymorphic
getX(o1); // Polymorphic hit

The first time getX is called, the inline cache is in a "premonomorphic" state. It doesn‘t have any prior information about the shape of obj. So it does the normal lookup, walking the prototype chain to find the x property.

The second time getX is called with o1, the inline cache is "monomorphic". It knows that obj had the same shape as last time, so it can skip the prototype walk and go straight to the right offset to find x.

The third time getX is called, o2 has a different shape. Now the inline cache becomes "polymorphic", and has to keep track of multiple shapes. It will check each shape in turn to find the right offset for x.

On the final call to getX, the inline cache is still polymorphic. But one of the shapes it knows about matches o1, so it can use the cached offset and avoid a prototype walk.

Inline caches work best when objects have the same shape. That‘s why it‘s a good idea to initialize all properties in object constructors, and always add properties in the same order.

Performance benchmarks

The V8 team tracks performance of the engine using several benchmarks. The most well-known is probably Octane 2.0, which measures performance on a suite of JS applications. Here‘s how V8‘s Octane score has improved over time:

Version Octane Score
Chrome 45 18,000
Chrome 51 25,000
Chrome 57 32,000
Chrome 61 37,000

As you can see, V8‘s Octane score has more than doubled in the past few years. And this improvement isn‘t just on synthetic benchmarks. The V8 team also tracks real-world performance using metrics like page load time.

For example, here‘s a chart showing how V8‘s startup time has decreased over the past few years:

V8 startup time improvements

Source: V8 blog

This faster startup time has a big impact on page load times, especially for pages with a lot of JavaScript.

Writing JS for V8

As a developer, you don‘t usually need to think about the JS engine when writing code. But there are a few best practices that can help your code run faster in V8 and other modern engines:

  • Initialize all object properties in constructors
  • Add properties to objects in the same order
  • Use the same types for variables and function parameters
  • Avoid creating large numbers of short-lived objects
  • Avoid using with and eval
  • Use for loops instead of for-in or forEach
  • Avoid using arguments and use rest parameters instead

Following these guidelines can help ensure that your code takes full advantage of V8‘s optimizations. But don‘t sacrifice readability or maintainability just to eke out a bit more performance. In most cases, the engine will optimize your code just fine without any special tweaks.

The future of V8

The V8 team continues to work hard on improving the engine‘s performance, security, and developer experience. Some of the major initiatives they‘re working on include:

  • Sparkplug: A non-optimizing compiler that produces machine code directly from the AST. This will provide faster startup times for web pages.

  • Pointer compression: Reducing memory usage by compressing 64-bit pointers to 32 bits plus an offset.

  • Improving WebAssembly support: Making WebAssembly faster and easier to integrate with JavaScript.

It‘s an exciting time to be a JavaScript developer. With engines like V8 constantly pushing the boundaries of performance, we can build ever more ambitious applications that run in the browser and on the server.

Conclusion

I hope this deep dive into V8 has given you a better understanding of how your JavaScript code is executed. We‘ve seen how V8 parses code, generates bytecode, optimizes hot functions, and manages memory. We‘ve also looked at some techniques V8 uses to make code run faster, like inline caches and type feedback.

As a developer, you generally don‘t need to optimize your code for a specific engine. But it‘s still valuable to have a mental model of how the engine works. Knowing the fundamentals can help you make better decisions about your code, like how to structure your objects or when to use certain language features.

At the end of the day, the best way to write fast JavaScript is to write clean, idiomatic code. Avoid micro-optimizations and focus on writing maintainable code that expresses your intent clearly. Let the engine handle the low-level details of optimizing your code.

And if you‘re curious to learn more about V8 internals, I highly recommend checking out some of these resources:

Happy coding!

Similar Posts