The inliner is yielding benefits for ZJIT | Rails at Scale
We recently enabled a really cool feature in ZJIT that makes it feel like a<br>Real Compiler™: the inliner! We’ll write more about it soon. In this<br>post, we’ll talk about one excellent concrete benefit we are already seeing and<br>how it optimizes blocks in pretty much every Ruby program.
I’ll start off with a refresher on how blocks work in the Ruby interpreter,<br>then show you how ZJIT understands and optimizes that bytecode, and then show<br>you the impact of the inliner.
Ruby: a refresher
In the beginning, there were loops.
arr = [1, 2, 3]<br>i = 0<br>sum = 0<br>while i arr.length<br>sum += arr[i]<br>i += 1<br>end
People used them to navigate and manipulate variable-length structures, like<br>arrays and strings. This was fine.
Then, in the 1970s, a small group of computer scientists at Palo Alto Research<br>Center invented a programming language called Smalltalk. One of the core<br>features of Smalltalk was that everything was an object and computation was<br>done by sending messages to objects.
This meant that iteration wouldn’t do at all. Instead, we would have to send<br>the do: message to the array object and pass it a block object.
#(1 2 3) do: [:a | sum := sum + a].
Then, in the 1990s, Matz, inspired by Smalltalk and Perl, created Ruby. We<br>still have “normal” loops but we also have a very Smalltalk-y way of doing it,<br>too:
arr = [1, 2, 3]<br>sum = 0<br>arr.each do |a|<br>sum += a<br>end
When this program gets compiled to Ruby bytecode, it ends up looking like a<br>mostly normal method call to each except that we pass a special kind of<br>argument to it: a block argument.
To see how this works inside CRuby, we’re going to look at a listing of YARV<br>bytecode—CRuby bytecode. For more on YARV, I recommend Kevin Newton’s<br>excellent Advent of<br>YARV.
Ignore most of the bytecode dump below except for the instruction at 0009,<br>the send instruction. We are send-ing (see? a message!) each with the<br>block argument block in (passed a different way than “normal”<br>arguments, hence the argc:0).
$ ruby --dump=insns /tmp/arrayeach.rb<br>== disasm: #@/tmp/arrayeach.rb:1 (1,0)-(5,3)><br>local table (...)<br>[ 2] arr@0 [ 1] sum@1<br>0000 duparray [1, 2, 3] ( 1)[Li]<br>0002 setlocal_WC_0 arr@0<br>0004 putobject_INT2FIX_0_ ( 2)[Li]<br>0005 setlocal_WC_0 sum@1<br>0007 getlocal_WC_0 arr@0 ( 3)[Li]<br>0009 send , block in<br>0012 leave<br>...continued below...
This block in from the bytecode listing above is the generated name of<br>the instruction sequence (bytecode) corresponding to the block we passed to<br>each. Its code, shown below, is the next thing in the bytecode dump. You can<br>see the usage of local variables sum and a via getlocal and setlocal<br>variants and also opt_plus, which represents addition (+). Again, the<br>details are not terribly important:
...continued from above...<br>== disasm: #@/tmp/arrayeach.rb:3 (3,9)-(5,3)><br>local table (...)<br>[ 1] a@0<br>0000 getlocal_WC_1 sum@1 ( 4)[LiBc]<br>0002 getlocal_WC_0 a@0<br>0004 opt_plus [CcCr]<br>0006 dup<br>0007 setlocal_WC_1 sum@1<br>0009 leave ( 5)[Br]
To make this work, Array has an each method. This each method takes in<br>its optional block argument and calls it once for every element in the array.
As with most of the core data structures, Array’s methods tend to be written<br>in C and each is no different. Here is its nice and short definition with<br>comments added by me:
VALUE<br>rb_ary_each(VALUE ary)<br>long i;<br>// Some debug-mode-only checks<br>ary_verify(ary);<br>// If we didn't pass a block in, return an enumerator object<br>RETURN_SIZED_ENUMERATOR(ary, 0, 0, ary_enum_length);<br>// We passed a block in, so call it for every element<br>for (i=0; iRARRAY_LEN(ary); i++) {<br>rb_yield(RARRAY_AREF(ary, i));<br>return ary;
You can’t really see the block parameter to the C code because it’s passed in a<br>special location on the Ruby VM’s own stack called the block handler. All you<br>need to know is that rb_yield knows where to find that and how to call it.
There’s just one more thing, which is… hang on, weren’t we building a JIT to<br>optimize Ruby code? How are we going to optimize this C code?
JITs: a refresher
Rewriting code from C to Ruby means that the JIT compiler gets a chance to<br>introspect the run-time behavior and code. This means that, over time, as the<br>compiler and the runtime system grow together, more and more code might get<br>rewritten in Ruby. This started happening a couple of years ago with YJIT.
YJIT precipitated some interesting changes to Ruby VM internals. For example,<br>in 2022, Array#each being written in C started to hurt: it was an opaque blob<br>that the JIT couldn’t reason about. So Kokubun submitted a<br>PR to rewrite it in Ruby. After some<br>back and forth, in 2024, Kokubun landed a different<br>PR that everyone was happy with.
class Array<br>def each # :nodoc:<br>Primitive.attr! :inline_block, :c_trace, :without_interrupts
unless defined?(yield)<br>return Primitive.cexpr! 'SIZED_ENUMERATOR(self, 0, 0, ary_enum_length)'<br>end<br>i = 0<br>until Primitive.rb_jit_ary_at_end(i)<br>yield Primitive.rb_jit_ary_at(i)<br>i =...