Ever since YJIT’s introduction, I’ve felt simultaneously close to and distant from Ruby’s JIT compiler. I know how to enable it in my Ruby programs. I know it makes my Ruby programs run faster by compiling some of them into machine code. But my understanding around YJIT, or JIT compilers in Ruby in general, seems to end here.
A few months ago, my colleague Max Bernstein wrote ZJIT has been merged into Ruby to explain how ZJIT compiles Ruby’s bytecode to HIR, LIR, and then to native code. It sheds some light on how JIT compilers can compile our program, which is why I started to contribute to ZJIT in July. But I still had many questions unanswered before digging into the source code and asking the JIT experts around me (Max, Kokubun, and Alan).
So I want to use this post to answer some questions/mental gaps you might also have about JIT compilers for Ruby:
Where does JIT-compiled code actually live? How does Ruby actually execute JIT code? How does Ruby decide what to compile? Why does JIT-compiled code fall back to the interpreter?
While we use ZJIT (Ruby’s experimental next-generation JIT) as our reference, these concepts apply equally to YJIT as well.
Where JIT-Compiled Code Actually Lives
Ruby ISEQs and YARV Bytecode
When Ruby loads your code, it compiles each method into an Instruction Sequence (ISEQ) - a data structure containing YARV (CRuby virtual machine) bytecode instructions.
(If you’re not familiar with YARV instructions or want to learn more, Kevin Newton wrote a great blog series to introduce them)
Let’s start with a simple example:
... continue reading