The JIT in JavaScript: Just In Time Compiler
Understanding JS interpreters and just-in-time compilers — using that to write a more optimized code.
Have you ever wondered how web browsers or even the V8 for back-end runtimes (i.e Node and Deno) execute your JavaScript code? We all know that JavaScript is an interpreted language, but what exactly does that mean? And does that only mean one thing?
Clearly, interpreted doesn’t mean compiled, but does that mean our code is not transformed in any shape or form during execution? Maybe that was true during the first years of JavaScript, but the truth is a lot more complex these days.
What the JIT?
Yeah, sorry, it was an easy joke, so I had to go for it.
On a more serious note, this is where the JIT compiler comes into play. JIT stands for Just In Time, meaning, unlike with a compiled language, such as C, where the compilation is done ahead of time (in other words, before the actual execution of the code), with JavaScript, the compilation is done during execution. I know, it sounds awkward, but trust me, it works!
The great thing about a JIT compiler, is that once the code starts running, it is able to optimize it. And interestingly enough, given a complex enough code base, your code could be optimized differently for each user, depending on how each one uses your application. Let me explain.
What’s in the JIT?
Oh god, I can’t stop…
So, every browser and runtime in general probably implements their own version of a JIT compiler, but the theory and the structure are usually the same across the board. A JIT compiler is supposed to follow the same structure:
The monitor or profiler
While the code is executed by the interpreter, this component will profile it and keep track of how many times the different statements get hit. The moment that number starts growing, it’ll mark it as “warm” and if it grows enough, it’ll mark it as “hot”. In other words, it’ll detect which parts of your code are being used the most, and then it’ll send them over to be compiled and stored.
The baseline compiler
The warm sections of your code will be compiled into bytecode, which in turn, is run by an interpreter that is optimized for that type of code. This alone will make those sections run faster, and if possible, the baseline compilation will also try to optimize the code by creating “stubs” for every instruction being analyzed. For example, with the following code:
The baseline compiler will turn the result += arr[i]
line into a stub, but because this instruction is polymorphic (in other words, there is nothing ensuring that i
is going to be an integer every time or that arr[i]
is going to be a string for every position on the array) it’ll create a stub for every possible combination.
In turn, and because this process is not really an improvement, considering that you have a different version of the instruction based on all possible combinations of types, and you’re checking for types on every iteration of the loop, the optimizing compiler will take over.
Think about every step of the for
loop, the interpreter is asking:
- Is
i
an integer? - Is
result
a string? - Is
arr
actually an array? - Is
arr[i]
a string?
The optimizing compiler
Finally, the optimizing compiler will take charge and turn all those isolated stubs into a group, essentially stubbing the whole function if required. This allows for the type checks to happen only once, before the function call, instead of on every loop.
With this, imagine dealing with an array of 1000 elements, without this step, you’re asking all three questions we saw before 1000 times. However, if you can know the answer to some of those questions beforehand, then you’ll be able to optimize the code even further.
So yes, multiple stubs for the function will be created, but considering you’re calling the function once with a 1000 elements array, you’ll only ask once the following questions:
- Is
arr
an array? YES - Is
i
an integer? YES because now we’re stubbing everything, not just the inside of the loop. - Is
result
a string? YES because of the same reason, the function is packed together and the types that can be set afix are.
Now we only need to worry about the type of the element inside the array, which we can’t really know until we read it.
Is this JIT worth it then?
I gotta keep going now, don’t I?
Well, although not as performant as a statically typed compiled language, the JIT can be a great ally in the performance wars, especially if the code is long running because it needs time to analyze and monitor the execution of the code, but once it starts picking up warm code signatures, the performance is noticeable.
On the other hand, if you’re creating small scripts with a very short lifespan, then I wouldn’t recommend even worrying about the JIT.
Taking advantage of your JIT
Yeap… another one
So, can you take advantage of the fact that you’re dealing with a JIT compiler and optimize your code in a way that’ll make it happy and help it in turn, optimize your code during compilation?
Well yes, yes you can. And bare in mind, these tips are not going to work for all JIT compilers out there, especially for the front-end since every browser will implement their own version of the concept.
Tip #1: Don’t change object shape
When you´re dealing with classes, every time you instantiate a new object of a class, internally, the compiler creates something called a “hidden class”. As long as you don´t change the shape of your object, the hidden class remains the same, thus allowing the JIT compiler to optimize object operations and loop-up operations for multiple instances of your classes.
However, if you start changing shapes like so:
Line 10 of the above code creates a hidden class for the Car objects, and it keeps using the same one on line 11, because it understands you’re using the same object shape.
However, on lines 13 and 14, you’re changing the shape of the object by dynamically adding new properties. This is great and JS allows it, but at the same time, because you’re changing things around, the compiler is at a loss and can no longer assume both car1
and car2
belong to the same hidden class, thus it needs to create new ones and the more you change the more it’ll try to keep track of until it reaches a pre-set threshold, and then it loses any possible optimization options.
Tip #2: Keep function arguments constant
Essentially, the more you change the types of the attributes you use to call your functions the more complex the function becomes in the eyes of the compiler.
Remember, the optimizer is going to try and create a stub for every possible combination you use for your attributes, so if you always pass strings to your function, it’ll assume that’s the only type it requires, basically flagging it as monomorphic and like before, keeping track of it on a quick look-up table. If you start giving it different types, the internal look-up table will start growing because you’ve now turned your function into a polymorphic function.
If you keep calling it with even more type combinations, it’ll grow to a point where it becomes megamorphic (I know, it sounds like a word taken from a Power Rangers episode) and then all its references will be moved out into a global look-up table. Loosing all possible internal optimizations.
To illustrate, here is a basic example:
As you can see, although allowing for dynamically typed function arguments might sound cool and great to have, this is not a great idea if performance is important to you.
Tip #3: Avoid creating classes inside your functions
I mean, really, why would you? Not judging, not judging, but if you’re doing it, please stop.
Look at the following example:
In a similar fashion to the previous tips, everytime you call the newPerson
function, you’re creating a new hidden class for the instance you’re returning. So by the second call to that function, dealWithPerson
which essentially is called with a Person
object as parameter, will become polymorphic and if you keep calling it, it’ll become megamorphic , thus ending all internal possible optimizations. So, keep your class definitions outside your functions.
Conclusion
Hey! No easy JIT-joke for this one!
Understanding how the JIT compiler works and what it means for performance is relevant and important, although not crucial if you’re just building a basic UI or your run-of-the-mill API.
It’s always important to understand when your coding patterns might be counter-productive and could be affecting performance, especially if you’re writing code that depends on running fast.
Let me know down below if you’ve ever had to go through this process in order to fix non-performant logic, I’d love to know what kind of changes you had to implement in order to get the JIT compiler to optimize your code correctly.
Share & Manage Reusable JS Components with Bit
Use Bit (Github) to share, document, and manage reusable components from different projects. It’s a great way to increase code reuse, speed up development, and build apps that scale.
Bit supports Node, TypeScript, React, Vue, Angular, and more.