The author:Gavin, prohibit reprinting without authorization.
preface
Have you ever wondered how the V8 engine in the browser, Node, or Deno executes your code? We all know that JS is an interpreted language, not a compiled one. Does that mean our code doesn’t have any kind of conversion during execution?
What is JIT (Just-in-time)
In general, each browser and runtime may implement its own JIT compiler, but usually the theory is the same and follows the same structure.
V8 execution logic
The interpreter
As JS is an interpreted language, the JS engine needs to translate the code line by line into executable code. Executable code has many forms, among which direct execution based on AST and ByteCode are more common.
Monitor (analyzer)
As the interpreter executes the code, the monitor analyzes the code, tracking the number of times different statements are hit, and as the number of hits increases, the statement is marked as warm, hot, very hot (really hot in SpiderMonkey). In other words, the monitor detects which parts of the code are being used the most and sends them to the JIT for compilation and storage. The JIT engine compiles this code line by line and stores it in cells of a table (the form cells point to the compiled machine code).
- Warm sends the interpreted execution code to the JIT engine, which compiles it to machine code, but does not replace it here
- hotThe explain execution code will be replaced by
warm
Compiled machine code execution - Very HOT sends the interpreted execution code to the optimization compiler to create and compile more efficient machine code execution code and replace it
Baseline compiler
The warm part of the code will be compiled into bytecode and then run by an interpreter optimized for that type of code to make the code execute faster. If possible, the baseline compiler will also try to optimize the code by analyzing each instruction to create “stubs”, for example:
function concat(arr) {
let res = ' ';
for (let i = 0; i < arr.length; i++) {
res += arr[i];
}
return res;
}
console.log(concat(['a'.1.'b'.true.'c']));
Copy the code
The baseline compiler converts res += arr[I] to a stub, but because this instruction is polymorphic (there is nothing to guarantee that I will be an integer every time or that arr[I] will be a string every time), it will create a stub for every possible combination.
Consider each step of the for loop, and the interpreter checks:
i
It’s an integer, right?res
It’s a string, right?arr
It’s an array, right?arr[i]
It’s a string, right?
Optimized compiler
The optimization compiler is responsible for putting all these isolated stubs into a group, and if possible, stubbing the entire function, the code above optimized by the JIT compiler will only end up doing type checking once: once before the function is called. Returning to the above example, if the array length is 10000, 30000 times will be done:
arr
It’s an array, right? isi
It’s an integer, right? isres
It’s a string, right? is
If you knew their types before the loop, you would, of course, have optimized them, saving 30,000 judgments. Now you only care about the type of the element in the array, because you don’t know its type until you read it.
What does the bytecode in Node look like
# compile jit/bytecode.js file into bytecode output to JIT /bytecode.txt
$ node --print-bytecode jit/bytecode.js > jit/bytecode.txt
Copy the code
The bytecode instruction set in V8
The JS engine in Node
In fact, each JS engine compiled bytecode is different, common:
- Google v8
- SpiderMonkey
- Chakra Core
The difference between
The Chakra Core supports parallel JIT compilation, in which JS code is first read and parsed into an AST. Once the AST is generated, the code is passed to a bytecode generator for analysis, unlike V8, which has a decision process that uses a monitor to determine whether a piece of code should be analyzed and optimized or converted to bytecode. SpiderMonkey, on the other hand, first converts JS code into AST and then bytecode. SpiderMonkey also uses the monitor to find hot code and the baseline compiler to compile bytecode (non-optimal: When part of hot Code becomes really hot code, SpiderMonkey will start its JIT compiler (IonMonkey) to compile the best bytecode (optimal: the weight of performance is higher than the weight of compile time).
Js Engine switch
$ npx jsvu
Copy the code
Is JIT worth it
Of course, this is not as efficient as statically typed compilation languages, but the JIT does analyze and monitor the code, and when the code is running for a long time, once it starts getting warm signed code, the performance gains are obvious. On the other hand, if you are creating scripts with a very short lifetime, you don’t need to worry about the JIT at all.
Optimize your code with the JIT
Do not change the shape of the object
class Car {
constructor(color, made, model) {
this.color = color
this.made = made
this.model = model
}
}
const car1 = new Car("red"."chevrolet"."spark")
const car2 = new Car("blue"."hyundai"."tucson")
car1.doors = 2
car2.radio = true
Copy the code
Code above 10 behavior Car created a hidden object class, the 11 line continue to use the same hidden classes, in line 13, 14, by dynamically adding attributes to change the shape of the object, the compiler are losing money, can’t suppose car1 and car2 hidden belong to the same classes, so it needs to create a new class, change, the more The harder the monitor tracks until a preset threshold is reached, eventually it loses all possible optimization options.
Leave the function parameters unchanged
In fact, the more you change the type of attribute used to call a function, the more complex the function becomes in the eyes of the compiler. The optimizer will try every possible combination for attributes to create stub, so if you always put the string passed to the function, the monitor will assume that this is the only type, it needs to mark it as simplex, tracking it on fast lookup table, if this time again into different types, internal lookup table will begin to grow, because now transfer function as a state function.
If it continues to be called with more and more type combinations, to the point where it becomes supersymmetric, all its references will be moved into a global lookup table, losing all internal optimizations. Ex. :
function yourFunction(a, b) {
/ /... your logic goes here
}
yourFunction(1.2) // monomorphic
yourFunction("string a"."string b") // now it's polymorphic...
yourFunction(true.false)
yourFunction(1."string c")
yourFunction(false.(err) = > { // oops, now it's megamorphic
//....
})
Copy the code
Avoid creating classes in functions
function newPerson(name) {
class Person {
constructor(name) {
this.name = name
}
return new Person(name)
}
}
function dealWithPerson(person) {
/ /... your logic here
}
Copy the code
Similar to the previous tip, each time the newPerson function is called, a new hidden class is created for the returned instance. Therefore, on the second call to this function, essentially calling dealWithPerson with a Person object as an argument will become polymorphic, and if you continue calling it, it will become supersymmetric, ending all internal optimizations. Therefore, keep the class definition outside of the function.
Refer to the article
How has WebAssembly evolved into a “second programming language for browsers”?