It is essential to understand the principles and workflow of a javascript engine as a front-end development tool.

JavascriptHow the engine works

The working principles of Javascript engines are basically the same, and the overall process is divided into the following steps:

  1. willJavascriptThe code parses toATS(Abstract syntax tree).
  2. Based on theAST, the interpreter (interpreter) will beASTConvert to bytecode (bytecode), where the JS engine actually executes the JS code.
  3. For further optimization, optimize the compiler (optimizing compilerCompile hotspot functions optimally into machine instructions (machine code) is carried out.
  4. If the optimization assumption fails, the optimization compiler will revert the machine code back to bytecode.

If you have any questions about the above process, don’t worry, it will be explained in detail next.

Major browsersjsEngine contrast

Before understanding the specific workflow of V8, let’s take a look at the specific workflow of the JS engine of each major browser.

As mentioned above, the ““js’ ‘ ‘engine works roughly the same way:

  • The interpreter is responsible for quickly generating unoptimized bytecode,
  • The optimizer compiler is responsible for generating optimized machine code but takes a relatively long time.

But different JS engine optimization process and strategy will have some differences.

V8engine

The V8 engine is used in Chrome and NodeJs. V8’s interpreter, called Ignition, is responsible for generating and executing bytecode.

Ignition collects analysis data as it executes the bytecode for subsequent optimization. For example, when a function is called frequently, it becomes a hotspot function, where the bytecode and analysis data are passed to the optimizer compiler for further optimization.

The optimized compiler for V8, called TurboFan, produces highly optimized machine instructions based on the analyzed data.

SpiderMonkeyengine

SpiderMonkey is Mozilla’s JS engine for Firefox and SpiderNode.

SpiderMonkey has two optimized compilers. The interpreter passes the bytecode to the Baseline compiler, which optimizes parts of the code and executes them, generating analysis data during execution. Combined with the analysis data, the IonMonkey compiler produces highly optimized code. If optimization fails, IonMonkey will roll back the code to the Baseline generated code.

Chakraengine

Chakra is Microsoft’s JS engine for Edge and Node-Chakracore.

Chakra also has two optimization compilers. The interpreter hands the bytecode over to the SimpleJIT compiler, which does some of the optimization. Combined with the analysis data, the FullJIT compiler produces highly optimized code. If optimization fails, FullJIT rolls back the code to bytecode.

JavaScriptCoreengine

JavaScriptCore (abbreviated JSC), is Apple’s JS engine for Safari and React Native.

JSC introduces three optimization compilers. LLInt the low-level Interpreter takes the code to the Baseline compiler and after the Baseline is optimized it takes the code to the DFG (Data Flow Graph) compiler. Finally, DFG hands the code over to the FTL (Faster Than Light) compiler.

By comparison, it is not difficult to find that the overall architecture of JS engine is basically the same, which is parser -> Interpreter -> Compiler. So why do some have just one optimized compiler and some have more than one? It’s really to make some trade-offs.

Interpreter is a quick way to produce executable code, but the code execution isn’t efficient. The compiler takes a little more time to optimize, but the result is machine code that executes efficiently. So there is a trade-off between generating and executing quickly and taking more time to generate and executing efficiently, and some engines introduce multiple optimized compilers with different time/efficiency characteristics at the cost of increasing complexity in order to have more granular control over these trade-offs. There is also a memory tradeoff involved, with machine code taking up more memory than bytecode, which is discussed in detail next.

jsEngine optimization tradeoffs

Js engine in the process of optimization tradeoff, the core point is the above mentioned interpreter can quickly generate executable code, but code execution efficiency is not high. The compiler takes a little more time to optimize, but the result is machine code that executes efficiently

Here’s an example of how browsers handle this:

let result = 0;
for (let i = 0; i < 4242424242; ++i) {
	result += i;
}
console.log(result);
Copy the code

V8 engine

V8 generates the bytecode in the Ignition interpreter and executes it. During the execution, V8 collects analysis data on functions that are executed multiple times, called hotspot functions, and then starts the TurboFan Frontend. The TurboFan Frontend is part of the TurboFan compiler and is dedicated to integrating analytical data and building a base machine representation of the code. This result is then sent to the TurboFan Optimizer on another thread for further optimization.

While the optimizer is running, V8 continues to execute the bytecode in the Ignition compiler. When the optimization is complete and executable machine code is generated, V8 then takes over the execution with machine code.

In Chrome 91, due out in 2021, V8 adds a new compiler, Sparkplug, between Ignition and TurboFan

SpiderMonkeyengine

SpiderMonkey also generates bytecode in the interpreter and executes it. It also has an additional Baseline layer, where hotspot functions are first sent to the Baseline compiler. The Baseline compiler generates Baseline Code in the main thread and executes Baseline Code instead.

After Baseline code is executed for a certain amount of time, SpiderMonkey will start the IonMonkey Frontend and then start the IonMonkey Optimizer on another thread. The process is very similar to V8, where Baseline code is executed during the tuning process, and when the tuning is complete, the last optimized code takes over.

Chakraengine

The Chakra engine does this by completely separating the compiler into a dedicated process. Chakra copies the bytecode and any analysis data the compiler may need and sends it to the compiler. This has the advantage of not blocking the execution of the main thread.

Chakra executes the SimpleJIT code after the SimpleJIT compiler generates the code. The FullJIT process is similar. The benefit of this is that the replication time is usually shorter than running the entire compiler (frontend section). The downside is that this heuristic copy may miss some information needed for some optimization, so it is partly a trade-off for time with code quality.

JavaScriptCoreengine

In JavaScriptCore, the compiler is completely independent of the main thread, which triggers the compilation process, and the compiler then uses a complex locking mechanism to fetch analysis data from the main thread.

The advantage of this approach is that it reduces the blocking caused by optimization on the main thread. The disadvantage is that it has to deal with some multi-threaded issues and the cost of locking for various operations.

Memory usage tradeoffs

While we’ve been talking about generating fast versus slow, there’s actually another tradeoff, which is memory usage. As mentioned briefly above, machine code takes up more memory than bytecode. Here’s an example to see why.

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

add(1.2);
Copy the code

Here we declare and execute a function that adds two numbers. Taking V8 as an example, the bytecode generated by Ignition is as follows:

StackCheck
Ldar a1
Add a0, [0]
Return
Copy the code

It doesn’t matter, the point is that the bytecode generated here is only four short lines

If the add function is called multiple times, the add function becomes a hot function, and TurboFan further generates highly optimized machine code like this:

leaq rcx,[rip+0x0] movq rcx,[rcx-0x37] testb [rcx+0xf],0x1 jnz CompileLazyDeoptimizedCode push rbp movq rbp,rsp push rsi  push rdi cmpq rsp,[r13+0xe88] jna StackOverflow movq rax,[rbp+0x18] test al,0x1 jnz Deoptimize movq rbx,[rbp+0x10] testb rbx,0x1 jnz Deoptimize movq rdx,rbx shrq rdx, 32 movq rcx,rax shrq rcx, 32 addl rdx,rcx jo Deoptimize shlq rdx, 32 movq rax,rdx movq rsp,rbp pop rbp ret 0x18Copy the code

The amount of code is huge compared to the bytecode above, which is often much smaller than optimized machine code. Bytecode requires an interpreter to execute, while optimized machine code can be executed directly by the processor.

This also explains why the JS engine doesn’t optimize all the code. One reason is that the optimizations mentioned above take time, but the main reason is that machine code takes up more memory.

Summary:

The JS engine has different optimization layers in order to balance the problem of generating fast running and slow running against generating fast jogging. Using multiple optimization layers allows more granular decisions to be made, but introduces additional complexity and overhead. There is also a trade-off between the optimization level and the memory footprint of the code. This also explains why the JS engine only tries to optimize the hotspot function.

V8Detailed engine workflow

Here’s another picture to review the basic flow of the V8 engine:

Let’s look at this process in detail

Lexical analysis

Parsing is actually a two-step process: the scanner does the lexical analysis, and the parser does the parsing.

We know that the JS code is actually just a string of characters, which cannot be executed directly by the machine. It needs to go through a series of transformations. Lexical analysis is to split the string in the code and generate a series of tokens.

Token: a lexical unit. It is the smallest unit that can no longer be divided. It can be a single character or a string.

What does a token look like? Here is an example:

function foo() {
  let bar = 1;
  return bar;
}
Copy the code

This code goes through the lexing process to generate the following token list:

[{"type": "Keyword"."value": "function"
    },
    {
        "type": "Identifier"."value": "foo"
    },
    {
        "type": "Punctuator"."value": "("
    },
    {
        "type": "Punctuator"."value": ")"
    },
    {
        "type": "Punctuator"."value": "{"
    },
    {
        "type": "Keyword"."value": "let"
    },
    {
        "type": "Identifier"."value": "bar"
    },
    {
        "type": "Punctuator"."value": "="
    },
    {
        "type": "Numeric"."value": "1"
    },
    {
        "type": "Punctuator"."value": ";"
    },
    {
        "type": "Keyword"."value": "return"
    },
    {
        "type": "Identifier"."value": "bar"
    },
    {
        "type": "Punctuator"."value": ";"
    },
    {
        "type": "Punctuator"."value": "}"}]Copy the code

And you can see that you’re essentially breaking up the code, breaking it up into different types of representations.

Online parsing can be done through Esprima

Parsing (parser)

Lexical analysis is followed by grammatical analysis. The input of syntax analysis is the output of lexical analysis, and the output is the AST abstract syntax tree. AST is a tree representing token relations. It is an abstract representation of the syntax structure of source code, and it expresses the syntax structure in the form of a tree. V8 throws exceptions during the parse phase when there are syntax errors in the code.

Again, the generated AST looks like this:

{
  "type": "Program"."body": [{"type": "FunctionDeclaration"."id": {
        "type": "Identifier"."name": "foo"
      },
      "params": []."body": {
        "type": "BlockStatement"."body": [{"type": "VariableDeclaration"."declarations": [{"type": "VariableDeclarator"."id": {
                  "type": "Identifier"."name": "bar"
                },
                "init": {
                  "type": "Literal"."value": 1."raw": "1"}}]."kind": "let"
          },
          {
            "type": "ReturnStatement"."argument": {
              "type": "Identifier"."name": "bar"}}},"generator": false."expression": false."async": false}]."sourceType": "script"
}
Copy the code

I will write an article about AST in detail.

The interpreter (Ignition)

Once the AST tree is generated, the bytecode is generated from the AST, which is done by the V8 Ignition compiler. And Ignition executes bytecode. During this execution, if a function is called multiple times, it is marked as a hotspot function and its bytecode and execution information is sent to TurboFan for optimization.

The distinguishing feature of this step is that V8 generates bytecode quickly, but bytecode execution is inefficient.

The compiler (TurboFan)

TurboFan compiles bytecode into optimized machine code based on assumptions made by TurboFan based on execution information to further optimize the code. If the hypothesis is true, the next time the function is called, the optimized compiled machine code is executed to improve the performance of the code. If the assumption fails, the fallback operation will be the Deoptimize step in the figure above, which returns machine code to bytecode.

So what is a hypothesis? What is the fallback? Here’s an example:

function sum (a, b) {
    return a + b;
}
Copy the code

JavaScript is a dynamically typed language, where a and B can be arbitrary data types. When the sum function is executed, the Ignition interpreter checks the data types of A and B and adds or concatenates strings accordingly.

If the sum function is called multiple times, it can be a waste of time to check the data types of the arguments on each execution. This is where TurboFan comes in. It analyzes the execution information of the function, and TurboFan presets sum’s arguments to numeric types every time sum has been called, and compilers them into machine code.

However, if a call is passed with an argument that is no longer a number, TurboFan’s assumptions are wrong and the machine code generated by the optimized compilation is no longer usable, so you need to fall back to bytecode.

I believe that you have seen here to the browser’s general implementation process has a macro understanding.