I’ve started working on a new version of Ruby Under a Microscope that covers Ruby 3.x. I’m working on this in my spare time, so it will take a while. Leave a comment or drop me a line and I’ll email you when it’s finished.
Here’s an excerpt from the completely new material from Chapter 4 about YJIT and ZJIT. I’m still finishing it… so it’s fresh off the content page! It was a lot of fun for me to learn how JIT compilers work as well as sharpen my Rust skills. And it’s very exciting to see all the impressive work done by Shopify’s Ruby team and other contributors to improve Ruby’s runtime performance.
Chapter 4: Compiling Ruby to Machine Language
| Interpreting vs Compiling Ruby Code | 4 |
| Yet another JIT (YJIT) | 6 |
| Virtual Machines and Real Machines | 6 |
| How to count and block calls | 8 |
| YJIT block | 8 |
| YJIT branch stubs | 10 |
| Execution of YJIT blocks and branches | 11 |
| deferred compilation | 12 |
| Revival of YJIT branch | 12 |
| YJIT Guard | 14 |
| Adding two integers using machine language | 15 |
| Experiment 4-1: What code does YJIT optimize? | 18 |
| How does YJIT recompile code? | 22 |
| Finding a Block Version | 22 |
| Saving multiple block versions | 24 |
| ZJIT, Ruby’s next generation JIT | 26 |
| How to count and block calls | 27 |
| ZJIT block | 29 |
| Method based JIT | 31 |
| rust inside ruby | 33 |
| Experiment 4-2: Reading ZJIT HIR and LIR | 35 |
| Summary | 37 |
How to count and block calls
To find hot spots, YJIT counts how many times your program calls each function or block. When this count reaches a certain limit, YJIT stops your program and converts that part of the code into machine language. Later Ruby would execute the machine language version instead of the original YARV instructions.
To keep track of these calculations, YJIT saves an internal counter near the YARV instruction sequence for each function or block.
Figure 4-5: YJIT stores information associated with each set of YARV instructions
Figure 4-5 shows the YARV instruction sequence generated by the main Ruby compiler. sum += i Block at (3) in Listing 4-1. At the top, above the YARV instructions, Figure 4-5 shows two YJIT related values:
jit_entry And jit_entry_callAs we’ll see in a moment, jit_entry Starts as a null value but will later hold a pointer to the machine language instructions produced by YJIT for this Ruby block. Below jit_entryFigure 4-5 also shows jit_entry_callYJIT’s internal counter.
Every time the program in Listing 4-1 calls this block, the value of YJIT is incremented. jit_entry_callSince the range of (1) in Listing 4-1 extends from 1 to 40, this counter will start at zero and increment by 1 each time, range#each Calls the block at (3).
When jit_entry_call Upon reaching a particular threshold, YJIT will compile the YARV instructions into machine language. YJIT in Ruby 3.5 uses a limit of 30 by default for small Ruby programs. Larger programs, such as Ruby on Rails web applications, will use a larger threshold value of 120. (You can also change the limit by passing —yazit-call-threshold When you run your Ruby program.)
YJIT block
When compiling your Ruby program, YJIT saves the machine language instructions it creates. YJIT blockYJIT blocks, which are distinct from Ruby blocks, each contain a sequence of machine language instructions bound to a series of related YARV instructions, By grouping YARV instructions and compiling each group into a YJIT block, YJIT can produce more optimized code that conforms to the behavior of your program and can avoid compiling code that your program does not need,
As we’ll see next, not a single YJIT block is analogous to a Ruby function or block. YJIT blocks instead represent small sections of code: individual YARV instructions or a short series of YARV instructions. Each Ruby function or block usually consists of several YJIT blocks.
Let’s see how it works for our example. After the program in Listing 4-1 executes Ruby block (3) 29 times, YJIT will increment jit_entry_call Just before Ruby runs the block for the 30th time, counter again. since jit_entry_call Upon reaching a threshold value of 30, YJIT triggers the compilation process.
YJIT compiles the first YARV instructions getlocal_WC_1
and saves machine language instructions that perform similar tasks getlocal_WC_1 In a new YJIT block:
Figure 4-6: Creating a YJIT block
On the left, Figure 4-6 shows the YARV instructions for sum += i Ruby Block. On the right, Figure 4-6 shows the corresponding new YJIT block. getlocal_WC_1,
Next, the YJIT compiler issues and compiles the second YARV instruction from the left side of Figure 4-7: getlocal_WC_0 At index 2.
Figure 4-7: Adding to the YJIT block
On the left, Figure 4-7 shows the same YARV instruction sum += i The ruby block that we saw above in Figure 4-6. But now the two dotted arrows indicate that the YJIT block on the right contains both equivalent machine language instructions getlocal_WC_1 And getlocal_WC_0,
Let’s take a look inside this new block. YJIT compiles or translates Ruby YARV instructions into machine language instructions. In this example, running on my Mac laptop, YJIT writes the following machine language instructions into this new block:
Figure 4-8: Contents of a YJIT block
Figure 4-8 shows a close-up view of the new YJIT block that appears on the right side of Figures 4-6 and 4-7. Inside the block, Figure 4-8 shows the assembly language dictionaries corresponding to the ARM64 machine language instructions that YJIT generated for the two YARV instructions shown on the left. On the left are the YARV instructions: getlocal_WC_1which loads a value from a local variable located in the previous stack frame and saves it on the YARV stack, and getlocal_WC_0Which loads a local variable from the current stack and also saves it on the YARV stack. The machine language instructions on the right of Figure 4-8 do the same thing, loading these values into registers on my M1 microprocessor:
x1 And x9If you’re curious and want to learn more about what machine language instructions mean and how they work, the “Adding two integers using machine language” section discusses the instructions for this example in more detail,
YJIT branch stubs
Next, YJIT continues the sequence of YARV instructions and compiles
opt_plus YARV instruction at index 4 in Figures 4-6 and 4-7. But this time, YJIT encountered a problem: it did not know the type of the additional arguments. i.e. willpower opt_plus Add two integers? Or two strings, floating point numbers, or some other type?
Machine language is very specific. To add two 64-bit integers on the M1 microprocessor, YJIT can be used They say Assembly language instructions. But adding two floating pointer numbers would require separate instructions. And, of course, joining or joining two wires is a different operation altogether.
How to let YJIT know which machine language instructions to save into a YJIT block opt_plusYJIT needs to know what types of values the Ruby program might ever add to (3) in Listing 4-1. You and I can tell by reading Listing 4-1 that the Ruby code is adding integers. We immediately know that sum += 1 The block at (3) is always adding one integer to another. But YJIT doesn’t know this.
YJIT uses a clever trick to solve this problem. Instead of analyzing the entire program ahead of time to determine all possible types of values. opt_plus The YARV instruction may need to be added at any time, YJIT simply waits until the block runs and sees what the program actually goes through.
YJIT uses branch stump To achieve this wait-and-see compilation behavior, as shown in Figure 4-9.
Figure 4-9: A YJIT block, branch and stub
Figure 4-9 shows the YARV instruction on the left, and the YJIT block for index 0000-0002 on the right. But notice the lower right corner of Figure 4-7, which shows an arrow pointing from the block to the box labeled Stub. This arrow represents the YJIT branch. Since this new branch does not yet point to a block, YJIT sets up the branch to point to the branch stub instead.