- 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.
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.
Instead, the compiler escapes the code ahead of time and saves it, rather than escaping it at run time.
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.
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.
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.
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 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.
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.
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.
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.