• A crash course in Just-in-time (JIT) compilers
  • Originally written by Lin Clark
  • The Nuggets translation Project
  • Translator: zhouzihanntu
  • Proofreader: Tina92, Germxu

This article is the second in a WebAssembly series. If you haven’t read the previous article, we suggest youFrom the very beginning.

JavaScript was slow when it first came out, but with the advent of the JIT, its performance quickly improved. So how does JIT work?

How JavaScript works in a browser

As a developer, you have a goal and a problem when you add JavaScript code to a web page.

Goal: You want to tell the computer what to do.

Problem: You and the computer speak a different language.

You use human language, and computers use machine language. Even if you don’t want to admit it, JavaScript and other high-level programming languages are human languages to computers. These languages are designed for human cognition, not machines.

So what a JavaScript engine does is convert the human language you’re using into something that a machine can understand.

I think it’s like when humans and aliens try to talk to each other in the movie descension.

One man gestured with source code, the alien responded with binary

In the movie, humans and aliens don’t just translate word for word as they try to communicate. These two groups have different ways of thinking about the world, as do humans and machines (which I’ll explain in more detail in my next article).

So how does that happen?

In programming, we usually use the interpreter and compiler to translate program code into machine language.

The interpreter escapes the code line by line while the program is running.

A man is translating code into binary at a whiteboard

Instead, the compiler escapes the code ahead of time and saves it, rather than escaping it at run time.

One man holds a page of translated binary code

Both of the above transformation methods have their advantages and disadvantages.

Advantages and disadvantages of the interpreter

The interpreter can start working quickly. You don’t have to wait for all the assembly steps to complete before running the code, just start escaping the first line of code to run the program.

Therefore, the interpreter seems a natural fit for languages like JavaScript. It’s important for Web developers to be able to run code quickly.

This is why browsers used JavaScript interpreters in their early days.

But when you run the same code over and over again, the interpreter’s disadvantage becomes apparent. For example, if you are in a loop, you have to repeatedly transform the body of the loop.

Advantages and disadvantages of compilers

Compilers have the opposite advantages and disadvantages of interpreters.

Using a compiler takes a little more time to start because it must complete all the steps of the compilation before starting. But code in the body of the loop runs faster because it doesn’t need to compile every time the loop is run.

Another difference is that the compiler has more time to view and edit the code to make the program run faster. These edits are what we call optimizations.

The interpreter works while the program is running, so it cannot spend a lot of time during the escape process to determine these optimizations.

The best of both worlds solution – a JIT compiler

To address the inefficiency caused by the interpreter compiling repeatedly during loops, browsers began to mix compilers in.

Different browsers implement it in slightly different ways, but the basic idea is the same. They add a new widget to the JavaScript engine that we call a monitor (aka profiler). The monitor monitors and records how many times the code is run and the types used as it runs.

At first, the monitor simply performs all operations through the interpreter.

The monitor monitors code execution and signals code interpretation

If a piece of code is run several times, it is called warm code; When this code is run many times, it is called hot code.

Baseline compiler

When a function is run several times, the JIT sends the function to the compiler for compilation and saves the compilation results.

The monitor detects that a function has been run several times, indicating that this function should be sent to the baseline compiler to create a stub

Each line of this function is compiled into a “stub” indexed by the line number and variable type (this is important, as I’ll explain later). If the monitor detects that the program is running the code again with the same type of variable, it simply extracts the compiled version of the corresponding code.

This helps speed up your program, but as I said, the compiler can do more. With a little time, it can determine the most efficient way to execute, which is optimization.

The baseline compiler can do some optimizations (I’ll show you examples later). However, it doesn’t want to spend too much time optimizing in order not to block the process for too long.

However, if the code runs too many times, it may be worth the extra time to optimize it further.

Optimized compiler

When a piece of code runs very frequently, the monitor sends it to the optimization compiler. Then you get another, faster version of the function and save it.

The monitor detects that a piece of code has been run more times, indicating that the code should be fully optimized

To get a faster version of the code, the optimization compiler makes some assumptions.

For example, if it can assume that all objects created by a particular constructor have the same structure, that is, all objects have the same attribute names, and those attributes are added in the same order, then it can be optimized based on that.

The optimization compiler makes a judgment based on the information gathered by the monitor when the code is running. If a value is always true in a previous loop, it assumes that the value will be true in subsequent loops.

But nothing is guaranteed in JavaScript. You might get 99 objects with the same structure first, but the 100th might be missing an attribute.

So compiled code needs to check if the assumptions are valid before running. If it does, the compiled code runs. But if it doesn’t, the JIT thinks it made an incorrect assumption and destroys the corresponding optimized code.

The monitor detects a type mismatch and signals back to the interpreter. The optimizer destroys the resulting optimized code

The process falls back to the version compiled by the interpreter or baseline compiler. This process is called de-optimization (or contingency mechanism).

Optimizing compilers usually speeds up code, but sometimes they can cause unexpected performance problems. If your code is constantly being optimized and de-optimized, it will run slower than the baseline compiled version.

To prevent this from happening, many browsers add restrictions to break the optimization-de-optimize cycle when it occurs. For example, the JIT stops the current optimization when it has attempted it 10 times without success.

Optimization example: Type specialization

There are many types of optimization, but I’ll show you just one so you understand how it happens. One of the biggest successes of optimizing compilers comes from type specialization.

The dynamic typing system used by JavaScript requires a little extra work at runtime. For example, the following code:

function arraySum(arr) {
  var sum = 0;
  for(var i = 0; i < arr.length; i++) { sum += arr[i]; }}Copy the code

Performing the += step in the loop seems simple enough. It seems like you can get the result in one step, but due to the dynamic typing of JavaScript, processing it requires more steps than you might think.

Suppose arR is an array of 100 integers. After the code has been executed a few times, the baseline compiler creates a stub for each operation in the function. Sum += arr[I] will have a stub that adds += to integers.

However, there is no guarantee that sum and arr[I] are integers. Because data types are dynamic in JavaScript, it is possible that arr[I] in the next loop will be a string. Integer addition and string concatenation are two completely different operations, and therefore also compile to very different machine code.

The JIT handles this situation by compiling multiple baseline stubs. A piece of code that is singleton (that is, always called by the same type) gets a stub. If it is polymorphic (that is, called by different types), it will get stubs that correspond to operations of each type combination.

This means that the JIT asks many questions before determining the stub.

Decision trees for four types of checks

In the baseline compiler, because each line of code has its own stub, the JIT constantly checks for the type of action on that line of code each time it runs. So the JIT will ask the same question each time through the loop.

The code loop for the type needs the JIT to ask for each loop

If the JIT does not have to repeat these checks, the code runs much faster. This is part of the job of optimizing the compiler.

In an optimized compiler, the entire function is compiled together. So type checking can be done before the loop starts.

A code loop that asks questions before the loop begins

Some JIT compilers have made further optimizations. For example, in Firefox there is a special category for arrays that contain only integers. If arr is an array under this classification, the JIT does not need to check if arr[I] is an integer. This means that the JIT can complete all type checks before entering the loop.

conclusion

In a nutshell, this is JIT. It speeds up JavaScript by monitoring code execution to identify and optimize high-frequency code, thus increasing the performance of most JavaScript applications by several times.

Even with these improvements, JavaScript performance is unpredictable. To speed up code execution, the JIT adds the following overhead at runtime:

  • Optimize and de-optimize
  • Memory used to store monitor records and recovery information during an emergency fallback
  • Memory used to store baseline and optimized versions of functions

There is room for improvement: remove the overhead above and improve the predictability of performance. This is one of the jobs of the WebAssembly implementation.

In the next article, I’ll say more about assembly and explain the compiler and how it works.