<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://railsatscale.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://railsatscale.com/" rel="alternate" type="text/html" /><updated>2026-07-21T10:04:11+00:00</updated><id>https://railsatscale.com/feed.xml</id><title type="html">Rails at Scale</title><subtitle>The Ruby and Rails Infrastructure team at Shopify exists to help ensure that Ruby and Rails are 100-year tools that will continue to merit being our toolchain of choice.</subtitle><author><name>Shopify Engineering</name></author><entry><title type="html">How I Think About Tests: Skips</title><link href="https://railsatscale.com/2026-06-08-how-i-think-about-tests-skips/" rel="alternate" type="text/html" title="How I Think About Tests: Skips" /><published>2026-06-08T00:00:00+00:00</published><updated>2026-06-08T00:00:00+00:00</updated><id>https://railsatscale.com/2026-06-08-how-i-think-about-tests-skips/</id><content type="html" xml:base="https://railsatscale.com/2026-06-08-how-i-think-about-tests-skips/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>If you’ve ever written a test for your code, you’re probably familiar with
typical test framework methods: <code class="language-plaintext highlighter-rouge">test</code>/<code class="language-plaintext highlighter-rouge">it</code> to define test cases, and
<code class="language-plaintext highlighter-rouge">assert</code>/<code class="language-plaintext highlighter-rouge">expect</code> to make assertions about the behavior of your code.</p>

<p>However, I want to highlight a less commonly used method: in other languages or
frameworks it goes by other names, but in Ruby’s <code class="language-plaintext highlighter-rouge">minitest</code> it’s called <code class="language-plaintext highlighter-rouge">skip</code>.
In this post, I’ll cover what <code class="language-plaintext highlighter-rouge">skip</code> does, when it may be useful, and, most
importantly, when you should probably use something else.</p>

<h2 id="just-skip-to-the-good-stuff">Just <code class="language-plaintext highlighter-rouge">skip</code> to the good stuff</h2>

<p>Okay, so what does <code class="language-plaintext highlighter-rouge">skip</code> do? Put simply, it allows you to <em>not</em> run a test.</p>

<p>More concretely: in <code class="language-plaintext highlighter-rouge">minitest</code>, none of the test code after <code class="language-plaintext highlighter-rouge">skip</code> is run, an
<code class="language-plaintext highlighter-rouge">S</code> will be printed instead of the usual <code class="language-plaintext highlighter-rouge">.</code>/<code class="language-plaintext highlighter-rouge">F</code>/<code class="language-plaintext highlighter-rouge">E</code>, and you’ll see it included
in the number of <code class="language-plaintext highlighter-rouge">skipped</code> tests in the summary:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># test.rb</span>
<span class="nb">require</span> <span class="s2">"minitest/autorun"</span>

<span class="k">class</span> <span class="nc">SkipTest</span> <span class="o">&lt;</span> <span class="no">Minitest</span><span class="o">::</span><span class="no">Test</span>
  <span class="k">def</span> <span class="nf">test_skip</span>
    <span class="n">skip</span> <span class="s2">"This test is skipped."</span>
    <span class="n">assert_equal</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">2</span> <span class="c1"># Notice that this assertion _would_ fail</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">test_normal</span>
    <span class="n">assert_equal</span> <span class="mi">1</span><span class="p">,</span> <span class="mi">1</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>ruby test.rb
Run options: <span class="nt">--seed</span> 9367

<span class="c"># Running:</span>

.S

Finished <span class="k">in </span>0.000576s, 3472.2225 runs/s, 1736.1112 assertions/s.

2 runs, 1 assertions, 0 failures, 0 errors, 1 skips

You have skipped tests. Run with <span class="nt">--verbose</span> <span class="k">for </span>details.
</code></pre></div></div>

<p>So it’s not <em>quite</em> as simple as “just not running a test”. <code class="language-plaintext highlighter-rouge">skip</code> also includes
some signals to make sure you know “hey, by the way, this test didn’t actually
run”.</p>

<h2 id="skip-dont-run">
<code class="language-plaintext highlighter-rouge">skip</code>, don’t run</h2>

<p>The most common use of <code class="language-plaintext highlighter-rouge">skip</code> is to temporarily disable a test. Let’s say you
have a newly failing test, and maybe it’s caused by a dependency upgrade, or
maybe you’re just in the middle of a really big refactor. In either case, you
know you need to fix the test eventually, but you don’t want to deal with it
right now. This is a good use case for <code class="language-plaintext highlighter-rouge">skip</code>!</p>

<p>Instead of <code class="language-plaintext highlighter-rouge">skip</code>, you <em>could</em> comment out the test and leave a <code class="language-plaintext highlighter-rouge">TODO</code>. However,
this approach is worse because it’s much easier to forget that the test exists at
all. With <code class="language-plaintext highlighter-rouge">skip</code>, you get a reminder every time you run your test suite that
“you should probably fix these”.</p>

<p>In the <code class="language-plaintext highlighter-rouge">rails/rails</code> test suite, we also use <code class="language-plaintext highlighter-rouge">skip</code> to indicate something is
missing from a developer’s environment. For example, the Active Support test
suite contains tests for <code class="language-plaintext highlighter-rouge">ActiveSupport::Cache</code> that depend on <code class="language-plaintext highlighter-rouge">redis</code> and
<code class="language-plaintext highlighter-rouge">memcached</code>. If those services aren’t running locally, the tests depending on
them are skipped<sup id="fnref:rails"><a href="#fn:rails" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> and a message is printed telling the developer why.</p>

<p>This is another good use of <code class="language-plaintext highlighter-rouge">skip</code>! It allows developers who aren’t actively
working on <code class="language-plaintext highlighter-rouge">ActiveSupport::Cache</code> to run the Active Support test suite without
requiring them to set up more complex dependencies. But it also signals to those
developers that there <em>are</em> more tests to run, they just aren’t currently
running.</p>

<h2 id="dont-skip-this-next-part">Don’t <code class="language-plaintext highlighter-rouge">skip</code> this next part</h2>

<p>We’ve looked at a few good examples of using <code class="language-plaintext highlighter-rouge">skip</code>, but I also see it used in
places where it shouldn’t be.</p>

<p>Here’s a (not real) example using <code class="language-plaintext highlighter-rouge">ActiveSupport::Cache</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">SharedCacheTests</span>
  <span class="k">def</span> <span class="nf">test_some_redis_specific_thing</span>
    <span class="n">skip</span> <span class="k">unless</span> <span class="n">cache_store</span><span class="p">.</span><span class="nf">is_a?</span><span class="p">(</span><span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Cache</span><span class="o">::</span><span class="no">RedisCacheStore</span><span class="p">)</span>

    <span class="c1"># ... test code that only works for RedisCacheStore</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Don’t use <code class="language-plaintext highlighter-rouge">skip</code> for this!</p>

<p>This is bad because it completely ruins the value of the <code class="language-plaintext highlighter-rouge">skip</code> signal. If <em>any</em>
tests are always skipped, then the test output will always have <code class="language-plaintext highlighter-rouge">S</code>s, and the
final <code class="language-plaintext highlighter-rouge">skips</code> count will always be nonzero. Both of these signals are useful
because of their rarity; if a developer sees them, they know there’s something
for them to do. If <code class="language-plaintext highlighter-rouge">skip</code> is used for tests where there <em>isn’t</em> anything for a
developer to do, then the useful signals gets drowned out by the noise.</p>

<p>Another issue with using <code class="language-plaintext highlighter-rouge">skip</code> like this is the runtime cost: <code class="language-plaintext highlighter-rouge">minitest</code>’s
<code class="language-plaintext highlighter-rouge">skip</code> happens at test <em>runtime</em>. That means all the code before the <code class="language-plaintext highlighter-rouge">skip</code> call
still runs: any <code class="language-plaintext highlighter-rouge">setup</code>/<code class="language-plaintext highlighter-rouge">teardown</code> hooks in the test’s own class as well as any
<code class="language-plaintext highlighter-rouge">setup</code>/<code class="language-plaintext highlighter-rouge">teardown</code> hooks in the test class’ ancestors. Maybe you’re lucky and
your test suite is fast enough that this doesn’t matter, but in a larger
codebase this could add up to a significant amount of wasted time.</p>

<p>So, if you shouldn’t use <code class="language-plaintext highlighter-rouge">skip</code> in these scenarios, what should you use instead?</p>

<p>There are (at least) three good alternatives.</p>

<p>In the <code class="language-plaintext highlighter-rouge">ActiveSupport::CacheStore</code> example above, the <code class="language-plaintext highlighter-rouge">skip</code>ped test is specific
to a particular cache store (<code class="language-plaintext highlighter-rouge">redis</code>). So really, it doesn’t belong in the
shared tests for all cache stores. Put it where it belongs!</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">SharedCacheTests</span>
  <span class="c1"># ... shared tests for all cache stores</span>
<span class="k">end</span>

<span class="k">class</span> <span class="nc">RedisCacheStoreTest</span> <span class="o">&lt;</span> <span class="no">Minitest</span><span class="o">::</span><span class="no">Test</span>
  <span class="kp">include</span> <span class="no">SharedCacheTests</span>

  <span class="k">def</span> <span class="nf">test_some_redis_specific_thing</span>
    <span class="c1"># ... test code that only works for RedisCacheStore</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Maybe you run your entire test suite with different configurations: instead of a
test class per backend you run tests against each backend in a separate
process<sup id="fnref:backend"><a href="#fn:backend" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>. Since each backend will have different capabilities, some
tests may not apply to every backend. Instead of conditionally <code class="language-plaintext highlighter-rouge">skip</code>ping those
tests, you can lift the conditional out of the test so that the test isn’t even
defined if it shouldn’t run.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">CacheTest</span> <span class="o">&lt;</span> <span class="no">Minitest</span><span class="o">::</span><span class="no">Test</span>
  <span class="k">if</span> <span class="n">cache_store</span><span class="p">.</span><span class="nf">supports_multi_get?</span>
    <span class="k">def</span> <span class="nf">test_multi_get</span>
      <span class="c1"># ...</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Or, if you aren’t using <code class="language-plaintext highlighter-rouge">minitest</code>, your test framework may have a way to
annotate tests so that they only run in certain scenarios.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">CacheTest</span> <span class="o">&lt;</span> <span class="no">Megatest</span><span class="o">::</span><span class="no">Test</span>
  <span class="nb">test</span> <span class="s2">"only works with redis"</span><span class="p">,</span> <span class="ss">store: :redis</span> <span class="k">do</span>
    <span class="c1"># ...</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Skip tests that only work with redis</span>
<span class="nv">$ </span>megatest <span class="o">!</span> :@store<span class="o">=</span>redis
</code></pre></div></div>

<p>In all of these cases, the <code class="language-plaintext highlighter-rouge">skip</code> signals in the test output remain actionable
and the test suite remains fast, while still ensuring only the relevant tests
are run in each scenario.</p>

<h2 id="skip-to-the-end">
<code class="language-plaintext highlighter-rouge">skip</code> to the end…</h2>

<p><code class="language-plaintext highlighter-rouge">skip</code> is a powerful tool for signaling to developers that some tests aren’t
running. However, it <em>must</em> be used conservatively to ensure the signal retains
its value.</p>

<p>Luckily, there are many alternatives to <code class="language-plaintext highlighter-rouge">skip</code> for those cases where action
isn’t required. Don’t skip out on using them!</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:rails">
      <p>In <code class="language-plaintext highlighter-rouge">rails/rails</code> CI, <code class="language-plaintext highlighter-rouge">skip</code> will actually <code class="language-plaintext highlighter-rouge">fail</code> the test to ensure
that the test suite isn’t accidentally succeeding without running all of the
tests. <a href="#fnref:rails" class="reversefootnote" role="doc-backlink">↩</a></p>
    </li>
    <li id="fn:backend">
      <p>For example, <code class="language-plaintext highlighter-rouge">rails/rails</code> runs the whole Active Record test suite
against SQLite, PostgreSQL, and MySQL, each in their own process. <a href="#fnref:backend" class="reversefootnote" role="doc-backlink">↩</a></p>
    </li>
  </ol>
</div>
</body></html>]]></content><author><name>Hartley McGuire</name></author><category term="posts" /><category term="2026-06-08-how-i-think-about-tests-skips" /><summary type="html"><![CDATA[You won't want to skip this one]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-06-08-how-i-think-about-tests-skips/3321fa0ff42ee893fd60af7e3a3864a2aedad0ec.png" /><media:content medium="image" url="https://railsatscale.com/2026-06-08-how-i-think-about-tests-skips/3321fa0ff42ee893fd60af7e3a3864a2aedad0ec.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">A new Register Allocator for ZJIT</title><link href="https://railsatscale.com/2026-05-27-a-new-register-allocator-for-zjit/" rel="alternate" type="text/html" title="A new Register Allocator for ZJIT" /><published>2026-05-27T00:00:00+00:00</published><updated>2026-05-27T00:00:00+00:00</updated><id>https://railsatscale.com/2026-05-27-a-new-register-allocator-for-zjit/</id><content type="html" xml:base="https://railsatscale.com/2026-05-27-a-new-register-allocator-for-zjit/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>We recently landed a new register allocator in ZJIT and I thought I’d write a post about it!</p>

<h2 id="what-is-a-register-allocator">What is a register allocator?</h2>

<p>Whenever a compiler generates machine code it needs to decide where to put values.
Those values usually take the shape of a variable in your function, though the compiler can also compute intermediate values as well.
When we need to perform a calculation on some value, the CPU needs to know how to find the value.</p>

<p>The CPU can typically only compute output based on inputs that are in registers, though some architectures (like x86) allow computations on values stored in memory.
That said, reading and writing to registers is much faster than memory, so it behooves the compiler to keep values in registers as long as possible.</p>

<p>Any particular function in your program could have tons of variables, but the number of available registers is finite, and architecture dependent.
This is where a register allocator comes in.
The register allocator looks at all of the variables, then figures out which registers they should go in, and if there aren’t enough registers figures out how to “spill” those variables to memory.</p>

<h2 id="how-does-it-work">How does it work?</h2>

<p>There are several well-known approaches to register allocation, each with different trade-offs between compile time and code quality.
For ZJIT, we chose to implement a linear scan register allocator based on a reduced version of <a href="https://bernsteinbear.com/assets/img/wimmer-linear-scan-ssa.pdf">Christian Wimmer’s paper titled “Linear Scan Register Allocation on SSA Form”</a>.
The paper isn’t very long so I highly recommend giving it a read when you have time!
Alternatively, Max Bernstein <a href="https://bernsteinbear.com/blog/linear-scan/">wrote a blog post breaking down the paper as well</a>.</p>

<p>ZJIT uses <a href="https://en.wikipedia.org/wiki/Static_single-assignment_form">Static single-assignment form</a>, or SSA form in its back end.
“SSA form” is a representation of code that only allows a variable to be assigned once.</p>

<p>For example, this Ruby code:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">a</span> <span class="o">=</span> <span class="mi">123</span>
<span class="n">a</span> <span class="o">+=</span> <span class="mi">1</span>
<span class="n">a</span>
</code></pre></div></div>

<p>Would be represented in our backend Low-level Intermediate Representation (or “LIR”) kind of like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>v1 = Const(123)
v2 = Const(1)
v3 = Add v1, v2
CRet v3
</code></pre></div></div>

<p>In the above pseudocode (it’s not exactly the same as the IR we use in the backend, but quite close), <code class="language-plaintext highlighter-rouge">v1</code>, <code class="language-plaintext highlighter-rouge">v2</code>, and <code class="language-plaintext highlighter-rouge">v3</code> are all different variables.
No variables are allowed to be re-assigned like in the corresponding Ruby code.
The generated intermediate representation has exactly the same semantics as the Ruby code, but adds the restriction that a variable can only be written to once.
When ZJIT generates an intermediate representation of the Ruby code, it translates all variables to these numeric SSA variables and also uses SSA variables for temporary values.
In the example translation above we can think of <code class="language-plaintext highlighter-rouge">v1</code> as being equivalent to <code class="language-plaintext highlighter-rouge">a</code>, <code class="language-plaintext highlighter-rouge">v2</code> as a temporary variable with the value 1, and <code class="language-plaintext highlighter-rouge">v3</code> as a <em>new version</em> of <code class="language-plaintext highlighter-rouge">a</code> that has been added with <code class="language-plaintext highlighter-rouge">v2</code>.</p>

<p>Variables can be written to once, but you can read from the variable as many times as you want.
We call the place where the variable was written the “definition” and the place where the variable is read a “use”.</p>

<h3 id="lifetimes">Lifetimes</h3>

<p>The first thing a register allocator needs to understand is <em>when</em> each value is alive and for how long.
We call this duration a “lifetime” or “live range”.
A value’s lifetime (or live range) starts at the point where it’s defined (the definition) and ends at the place it is last used (its “last use”).
If two values have overlapping lifetimes they can’t share the same register.
If there are more overlapping lifetimes than registers, then we know we need to spill a value to memory.</p>

<p>Since these ranges refer to the “lifetime” of the variable, it’s common to say that the variable “came to life” at its definition, and then “died” at its last use.</p>

<p>Let’s look at an example. Consider this Ruby method that is already in SSA form:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">add_twice</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">,</span> <span class="n">c</span><span class="p">)</span>
  <span class="n">d</span> <span class="o">=</span> <span class="n">a</span> <span class="o">+</span> <span class="n">b</span>
  <span class="n">e</span> <span class="o">=</span> <span class="n">d</span> <span class="o">+</span> <span class="n">c</span>
  <span class="n">e</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Here are the lifetimes for each value:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>#  Instruction     |  a  |  b  |  c  |  d  |  e
-------------------+-----+-----+-----+-----+----
1  d = a + b       |  x  |  x  |  .  |  .  |
2  e = d + c       |     |     |  x  |  x  |  .
3  return e        |     |     |     |     |  x
</code></pre></div></div>

<p>A <code class="language-plaintext highlighter-rouge">.</code> character means that the variable is alive at that instruction.
An <code class="language-plaintext highlighter-rouge">x</code> character means the variable dies, but is still used at that instruction.</p>

<p>We can also express these lifetimes as ranges. The live range for each value is <code class="language-plaintext highlighter-rouge">[definition, last use]</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>a: [0, 1]
b: [0, 1]
c: [0, 2]
d: [1, 2]
e: [2, 3]
</code></pre></div></div>

<p>Where instruction 0 represents the function entry (parameter definitions).</p>

<ul>
  <li>The parameters <code class="language-plaintext highlighter-rouge">a</code>, <code class="language-plaintext highlighter-rouge">b</code>, and <code class="language-plaintext highlighter-rouge">c</code> are all alive at instruction 1 because they were defined before the method body.</li>
  <li>
<code class="language-plaintext highlighter-rouge">a</code> and <code class="language-plaintext highlighter-rouge">b</code> are last used at instruction 1, so they die after that.</li>
  <li>
<code class="language-plaintext highlighter-rouge">c</code> stays alive through instruction 2 because that’s where it’s last used.</li>
  <li>
<code class="language-plaintext highlighter-rouge">d</code> is defined at instruction 1 and last used at instruction 2, so it’s alive for both.</li>
  <li>
<code class="language-plaintext highlighter-rouge">e</code> is defined at instruction 2 and last used at instruction 3.</li>
</ul>

<p>At instruction 1, <code class="language-plaintext highlighter-rouge">a</code> and <code class="language-plaintext highlighter-rouge">b</code> die and <code class="language-plaintext highlighter-rouge">d</code> comes to life.
Since we know that <code class="language-plaintext highlighter-rouge">a</code> and <code class="language-plaintext highlighter-rouge">b</code> are never used again, we’re free to reuse one of their registers for <code class="language-plaintext highlighter-rouge">d</code>.
So we only need three registers at instruction 1: one each for <code class="language-plaintext highlighter-rouge">a</code>/<code class="language-plaintext highlighter-rouge">d</code>, <code class="language-plaintext highlighter-rouge">b</code>, and <code class="language-plaintext highlighter-rouge">c</code>.
Similarly at instruction 2, <code class="language-plaintext highlighter-rouge">c</code> and <code class="language-plaintext highlighter-rouge">d</code> die as <code class="language-plaintext highlighter-rouge">e</code> is born, so we can reuse a register again.</p>

<p>Computing lifetimes involves a backward dataflow analysis over the control flow graph.
We walk the instructions in reverse order, tracking which values are currently live.
When we see the definition or use of a value, we know it is live at that instruction.</p>

<p>ZJIT has a debugging option to dump live range graphs like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ ruby --zjit-call-threshold=2 --zjit-dump-lir=live_intervals ../test.rb
</code></pre></div></div>

<p>Here is an example of the output graph from one basic block as an excerpt from many blocks in a larger function:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>          v0  v1  v2  v3  v4  v5  v6  v7  v8  v9  v10 v11
          --- --- --- --- --- --- --- --- --- --- --- ---
i0     :   .   .   .   .   .   .   .   .   .   .   .   .  FrameSetup
i2     :   v   .   .   .   .   .   .   .   .   .   .   .  v0 = Load [x21 - 0x28]
i4     :   █   v   .   .   .   .   .   .   .   .   .   .  v1 = Load [x21 - 0x20]
i6     :   █   █   .   .   .   .   .   .   .   .   .   .  Jmp bb3_l2([x19 + 0x18], v0, v1)
</code></pre></div></div>

<p>As mentioned earlier, in ZJIT, SSA variables names are just numbers.
In the block above we have 12 variables numbered 0 through 11.
These variables are listed in the first row along the top of the graph, numbered <code class="language-plaintext highlighter-rouge">v0</code> through <code class="language-plaintext highlighter-rouge">v11</code>.</p>

<p>The first column lists the instruction numbers.
In this case we have 4 instructions (<code class="language-plaintext highlighter-rouge">i0</code> through <code class="language-plaintext highlighter-rouge">i6</code>), and for reasons outside the scope of this post, they’ve been numbered with even numbers.</p>

<p>The last column of the graph lists the actual LIR instruction.
I’m not going to dive in to exactly what each of these instructions means, but you can see that some of them define variables (like <code class="language-plaintext highlighter-rouge">i2</code> and <code class="language-plaintext highlighter-rouge">i4</code> define <code class="language-plaintext highlighter-rouge">v0</code> and <code class="language-plaintext highlighter-rouge">v1</code>), and some of them use variables (like <code class="language-plaintext highlighter-rouge">i6</code> <em>uses</em> <code class="language-plaintext highlighter-rouge">v0</code> and <code class="language-plaintext highlighter-rouge">v1</code>).</p>

<p>In the center of the chart, the <code class="language-plaintext highlighter-rouge">v</code> character indicates when a variable came to life, the solid block is when a variable is “alive”, and a <code class="language-plaintext highlighter-rouge">^</code> character indicates last use (though there are no “last uses” in this example).</p>

<h3 id="interference-graphs">Interference graphs</h3>

<p>Once we know the lifetimes of all values, we need to figure out how many ranges overlap and where.
One way to do this is to build an “interference graph”.
An interference graph is a straightforward graph data structure where each node in the graph represents a live range, and each edge in the graph represents an “overlap”, or “interference”, with another live range.</p>

<p>Once you have an interference graph, you can treat allocation as a <a href="https://en.wikipedia.org/wiki/Graph_coloring">graph coloring problem</a>, where each color represents a physical register available on that CPU.
If we have <em>k</em> physical registers, we need a valid <em>k</em>-coloring of the interference graph.
This is NP-complete in the general case, but heuristics can help simplify the problem.</p>

<p>While graph coloring produces excellent results, building and manipulating the interference graph can be expensive in terms of both time and memory, especially for large functions.
JIT compilers should be fast, so we opted for a different algorithm: Linear Scan.</p>

<h3 id="linear-scan">Linear Scan</h3>

<p>As I mentioned earlier, we’re using a linear scan register allocator based on <a href="https://bernsteinbear.com/assets/img/wimmer-linear-scan-ssa.pdf">Christian Wimmer’s paper</a>.</p>

<p>The algorithm is fairly straightforward.
Once you’ve computed live ranges, iterate over those live ranges in order.
When you get to an instruction where a live range starts, “pull” a free register from a pool, and assign the register to the live range.
When you get to an instruction where a live range ends, put the register back in the pool.</p>

<p>If, at some point, you run out of registers in the pool, spill either the new live range, or an existing one.</p>

<h3 id="local-vs-global-allocation">Local vs global allocation</h3>

<p>The techniques we’ve discussed so far like lifetimes, interference graphs, and linear scan can all be applied at different scopes.
In the register allocator world there are two main types you’ll read about, and the difference is to do with how they process the program’s control flow graph.</p>

<p>First is the “local” register allocator.
Every function is broken down in to multiple basic blocks, and program control flows through these blocks.
A local register allocator will only ever “see” one single block at a time.</p>

<p>The second allocator is a “global” register allocator.
A global register allocator will process the function’s entire graph at once.
Confusingly, this allocator is not “global” in the sense of “global variables” in a program, but “global” in the sense that it analyzes an entire function at once rather than one basic block.</p>

<p>ZJIT’s previous register allocator was a local allocator inherited from YJIT.
It only tracked lifetimes within a single basic block.
This means that at every block boundary, all live values had to be moved to well-known locations (like the stack or fixed registers) so the next block could pick them up.</p>

<p>For a very lazy block-at-a-time compiler like YJIT, this is a reasonable trade-off.
YJIT compiles one basic block at a time, so it never has the full picture of the function anyway.
Using YJIT’s allocator helped us to bootstrap ZJIT quickly.</p>

<p>But ZJIT compiles entire methods, so we have all the information we need to do better.</p>

<p>A global allocator can keep a variable in the same register across block boundaries.
If a value is defined in one block and used in a later block, the allocator can let its live range span both blocks and assign it a single register for the whole duration.
This avoids unnecessary stores and loads at block boundaries, which can make a real difference in tight loops.</p>

<p>A global allocator also unlocks features that are difficult or impossible with a local one.
For example, splitting and adding new basic blocks in various optimization passes was very tricky due to keeping track of what variables went where.
Now basic blocks can be easily manipulated without worrying about which blocks use what variable.
It’s also a prerequisite for method inlining, where the inlined callee’s code becomes part of the caller’s control flow graph and its values need to participate in the same allocation.</p>

<h2 id="where-we-are-now">Where we are now</h2>

<p>The new register allocator <a href="https://github.com/ruby/ruby/pull/16295">has landed</a> and is working well.
We’re now building on top of it!
Method inlining is <a href="https://github.com/ruby/ruby/pull/16966">actively in progress</a> and relies heavily on the global allocation we now have.</p>

<p>There are still improvements to make to the allocator itself.
One big one is lifetime holes.
Right now, a value’s live range is a single contiguous interval from its definition to its last use.
But in practice, a value can be “dead” in the middle of its range.</p>

<p>For example, if a value is used in one branch of an <code class="language-plaintext highlighter-rouge">if</code> but not the other, the register allocator could accidentally keep the value “live” for one branch.
This can be a problem because the value will end up using valuable resources (physical registers) that should be used for values that are actually alive at that time. 
The below Ruby program kind of visualizes this issue:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">example</span><span class="p">(</span><span class="n">cond</span><span class="p">,</span> <span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span>
  <span class="n">x</span> <span class="o">=</span> <span class="n">a</span> <span class="o">+</span> <span class="n">b</span>

  <span class="k">if</span> <span class="n">cond</span>
    <span class="n">y</span> <span class="o">=</span> <span class="n">a</span> <span class="o">*</span> <span class="mi">2</span>
    <span class="n">use</span><span class="p">(</span><span class="n">y</span><span class="p">)</span>
  <span class="k">else</span>
    <span class="n">use</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Depending on how the code is linearized, the value <code class="language-plaintext highlighter-rouge">x</code> could be considered “alive” inside the true side of the <code class="language-plaintext highlighter-rouge">if</code> statement even though we can clearly see it’s not used.
In other words, the register assigned to the value <code class="language-plaintext highlighter-rouge">x</code> will be reserved inside the true side even though it’s not actually used.</p>

<p>Representing these holes would let us reuse registers more aggressively in those gaps, reducing spills.</p>

<p>We’re excited about the foundation this gives us and looking forward to building on it!</p>
</body></html>]]></content><author><name>Aaron Patterson</name></author><category term="posts" /><category term="2026-05-27-a-new-register-allocator-for-zjit" /><summary type="html"><![CDATA[We recently landed a new register allocator in ZJIT. Learn about lifetimes, interference graphs, and linear scan — and why a global allocator unlocks features like method inlining.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-05-27-a-new-register-allocator-for-zjit/6bdf42c0fd13f8a51e884dc2a8ed8622efb62546.png" /><media:content medium="image" url="https://railsatscale.com/2026-05-27-a-new-register-allocator-for-zjit/6bdf42c0fd13f8a51e884dc2a8ed8622efb62546.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">One engine, many tools — Introducing Rubydex</title><link href="https://railsatscale.com/2026-05-12-one-engine-many-tools/" rel="alternate" type="text/html" title="One engine, many tools — Introducing Rubydex" /><published>2026-05-12T00:00:00+00:00</published><updated>2026-05-12T00:00:00+00:00</updated><id>https://railsatscale.com/2026-05-12-one-engine-many-tools/</id><content type="html" xml:base="https://railsatscale.com/2026-05-12-one-engine-many-tools/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<h2 id="one-engine-many-tools">One engine, many tools</h2>

<p>A few years ago, the new Ruby parser Prism was released. One of its primary goals was to unify the community since we
had multiple implementations of Ruby parsers, each with their own bugs, differences in implementation and portability.
By having a single parser, community investments in performance and correctness benefit every single tool built on top
of it (including Ruby itself!).</p>

<p>However, the story of repeated implementations of highly complex foundational blocks doesn’t end at the parser level.
Move one level up the stack and the pattern repeats. Today, we have multiple tools that implement code indexing and
related static analysis algorithms. Consider just a few examples:</p>

<ul>
  <li>
<strong>Language servers</strong>: tools like <a href="https://github.com/Shopify/ruby-lsp">Ruby LSP</a> and
<a href="https://github.com/castwide/solargraph">Solargraph</a> need code indexing to provide go to definition, hover, signature
help, completion and so on</li>
  <li>
<strong>Type checkers</strong>: tools like <a href="https://github.com/sorbet/sorbet">Sorbet</a> and <a href="https://github.com/soutaro/steep">Steep</a>
need code indexing for all of the previous reasons plus having the ability to type check code</li>
  <li>
<strong>Documentation generators</strong>: tools like <a href="https://github.com/ruby/rdoc">RDoc</a> and <a href="https://github.com/lsegal/yard">YARD</a>
need code indexing to aggregate all declarations and their respective documentation for navigating and generating the
static website</li>
  <li>
<strong>Dead code detectors</strong>: tools like <a href="https://github.com/Shopify/spoom">Spoom</a> and
<a href="https://github.com/seattlerb/debride">debride</a> need code indexing to match declarations and references, so that they
can identify what declarations are dead (i.e.: unused)</li>
  <li>
<strong>Linters</strong>: tools like <a href="https://github.com/rubocop/rubocop">RuboCop</a> and
<a href="https://github.com/standardrb/standard">Standard</a> don’t currently use code indexing, but could provide much more
sophisticated linting capabilities given a global knowledge of the codebase</li>
</ul>

<p>The story we have here is the same. Multiple implementations of code indexing with varying performance, implementation
differences and correctness discrepancies. On top of that, none of them are packaged and portable as an API that any
other project can use.</p>

<p>It’s another case of our community’s efforts being diluted when we could instead have compounding benefits of working
together in a foundational building block. We ought to do something about it.</p>

<h2 id="introducing-rubydex">Introducing Rubydex</h2>

<p><a href="https://github.com/Shopify/rubydex">Rubydex</a> is a new portable static analysis engine intended to provide features such
as code indexing and type analysis through a convenient API.</p>

<p>An important thing to note is that Rubydex is a framework/engine. It is not a tool by itself, but rather the core
building block to create other tools. Despite being early in its development, Rubydex can already:</p>

<ul>
  <li>Collect all definitions in a codebase and its dependencies, including classes, modules, constants, singleton classes,
instance variables, class variables, global variables and methods</li>
  <li>Index RBS documents (including the bundled core and stdlib files and any RBS files in the workspace)</li>
  <li>Resolve constant references</li>
  <li>Track constant and instance variable references completely and method references with limitations<sup id="fnref:method-refs"><a href="#fn:method-refs" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>
</li>
  <li>Create declarations from the discovered definitions and constant references<sup id="fnref:declarations"><a href="#fn:declarations" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>
</li>
  <li>Linearize ancestor chains</li>
  <li>Track descendants</li>
  <li>Query the resulting graph in many ways</li>
</ul>

<h3 id="built-for-portability">Built for portability</h3>

<p>One of our goals with Rubydex is portability. If someone wants to write sophisticated tooling for Ruby using a different
language or maybe target the browser through WASM, then they should be able to!</p>

<p>For this reason, Rubydex is built with Rust, C and Ruby to ship 3 distinct components:</p>

<ul>
  <li>
<strong>The main Rust crate</strong>: this is where the entire logic is implemented. Rust allows for high performance and easy parallelism,
which are incredibly valuable when implementing static analysis tooling where the performance constraints are intense.
Other Rust projects can use this directly, like creating a Zed extension that can understand Ruby code or writing a
linter in Rust.</li>
  <li>
<strong>A Rust FFI crate</strong>: this crate provides C compatible bindings to use the main crate’s logic, allowing other languages to
integrate with Rubydex. Developers can use this to write tooling in other languages, like a VS Code extension that can
analyze Ruby codebases with no Ruby runtime dependency.</li>
  <li>
<strong>A Ruby gem</strong>: a native extension that provides the Ruby API, which interacts with the underlying Rust implementation
through the FFI crate. The gem ships with pre-compiled binaries for macOS (Intel and M series), Linux (x64 and ARM64)
and Windows. For any other platforms, <code class="language-plaintext highlighter-rouge">rubydex</code> has a dependency on <code class="language-plaintext highlighter-rouge">cargo</code> (the Rust package manager) in order to
compile correctly when installed.</li>
</ul>

<h2 id="impact-on-existing-tools">Impact on existing tools</h2>

<p>As of the time of this writing, we have either completed or started migrating our existing tools to use Rubydex. The
impact story for all of them is essentially the same: better performance, higher accuracy and a lot less code to
maintain.</p>

<h3 id="tapioca">Tapioca</h3>

<p>You may know Tapioca for all of its runtime analysis, which is what allows the tool to output static RBI information for
more accurate Sorbet type checking. However, Tapioca also consumes static information. There are two main use cases for
static analysis in Tapioca:</p>

<ul>
  <li>Fetching documentation for a given declaration so that it can be included in RBIs. This is important so that Sorbet
can show documentation on hover</li>
  <li>Bootstrapping the initial analysis for generating gem RBIs. We discover the initial set of constants defined in a gem
statically and then proceed to uncover the rest of the information by using the runtime</li>
</ul>

<p>Tapioca adopted Rubydex to <a href="https://github.com/Shopify/tapioca/pull/2524">handle documentation</a>, replacing the original
approach which used YARD. After the change, we saw a massive performance improvement, dropping the total execution time
for generating gem RBIs in our Core monolith from ~6 minutes to ~20 seconds, while using much less memory. We also
replaced the <a href="https://github.com/Shopify/tapioca/pull/2607">gem RBI bootstrapping</a>.</p>

<p>In addition to the performance gains, the correctness of Rubydex’s constant resolution algorithm and declaration
handling allows comments to be attached in a more predictable way. This ensures that the comments are associated with
the declaration they were meant to document, improving the quality of hover results for the Sorbet language server.</p>

<p>You can enjoy the Rubydex improvements starting in Tapioca v0.19.0.</p>

<h3 id="packwerk">Packwerk</h3>

<p>Packwerk relies heavily on constant resolution to identify package violations. The problem is that constant resolution
in Ruby is hard. The algorithm is complex and the amount of edge cases for static analysis increases the difficulty in
getting it right. Investing the time to get it right in Rubydex means we don’t have to re-invent the wheel
multiple times.</p>

<p>In our <a href="https://github.com/Shopify/packwerk/pull/447">initial exploration</a> of replacing Packwerk’s logic with Rubydex as
the engine, we see the power of being able to reuse battle tested algorithms:</p>

<ul>
  <li>Considerable reduction in codebase size (about -3000 lines)</li>
  <li>Significantly more violations found thanks to correct constant resolution (about 3x more constant references resolved
in our monolith)</li>
  <li>Varying levels of performance improvements depending on the conditions (single-threaded vs parallel mode)</li>
</ul>

<h3 id="spoom">Spoom</h3>

<p>We started prototyping the migration of Spoom’s dead code candidate detection to Rubydex. The story repeats once again.
Faster analysis and higher accuracy thanks to being able to account for constant resolution and ancestors correctly.
However, in the middle of the work, a question struck the team. Why not bundle this into Rubydex? It feels like dead
code detection can be a part of the core engine, even if it’s only detecting candidates.</p>

<p>For that reason, we paused the Spoom migration to explore moving it into Rubydex instead. We hope to be able to ship the
ability to find dead code candidates directly through our API.</p>

<h3 id="ruby-lsp">Ruby LSP</h3>

<p>For the Ruby LSP, Rubydex replaces the entire old code indexer with a significantly more performant and correct
implementation. Several cases that we could not handle correctly are now understood by the language server and
performance should be better across the board. The features directly impacted by Rubydex are:</p>

<ul>
  <li>
<strong>Go to definition, hover and signature help</strong>: better handling of nesting resolution will provide more accurate results</li>
  <li>
<strong>Completion</strong>: the new completion engine in Rubydex provides significantly better results than the previous implementation</li>
  <li>
<strong>Workspace symbol</strong>: new fuzzy search is much faster than the previous implementation</li>
  <li>
<strong>Type hierarchy</strong>: ancestor information is more complete and correct. Additionally, descendant tracker allows us to
implement the sub-types feature</li>
  <li>
<strong>Test discovery for the explorer</strong>: faster analysis for test discovery</li>
  <li>
<strong>Find references and rename</strong>: fast reference finding and renaming since Rubydex automatically tracks those</li>
  <li>
<strong>Foundation</strong>: the previous indexer was not fast enough, so we ran indexing asynchronously in a thread. This improved
responsiveness, but created several possible race conditions where taking an action in the editor before indexing was
complete could result in discrepancies between the features and the actual state of the codebase. Rubydex is fast enough
that we can simply index and manage the analysis synchronously, allowing us to eliminate the chances for race conditions
and greatly simplifying the code in the Ruby LSP</li>
</ul>

<p>In addition to the features already migrated to Rubydex, there are other improvements we are now able to deliver that
weren’t possible before. For example, in semantic highlighting we treat all constant references with the token
<code class="language-plaintext highlighter-rouge">namespace</code>. The reasoning behind this was performance. Resolving every constant reference in the old indexer was slow
and semantic highlighting runs on every keypress, which would cause the editor to grind to a halt. With Rubydex, we can
now solve this issue and correctly classify constant references as <code class="language-plaintext highlighter-rouge">class</code>, <code class="language-plaintext highlighter-rouge">namespace</code> or <code class="language-plaintext highlighter-rouge">variable.constant</code>.</p>

<p>Another possibility to explore is better test discovery. The current implementation of the test explorer relies on glob
patterns to find test files in a performant way. With Rubydex, identifying all classes that inherit from
<code class="language-plaintext highlighter-rouge">Minitest::Test</code> as a test group and all public methods prefixed with <code class="language-plaintext highlighter-rouge">test_</code> is a breeze. Maybe we can finally ditch
the glob pattern and achieve higher accuracy in discovery?</p>

<p>Safe to say, there are lots of opportunities for more sophisticated and correct functionality in the Ruby LSP, without
compromising performance in any way.</p>

<h4 id="add-ons">Add-ons</h4>

<p>The switch to Rubydex means breaking changes for most add-ons. Please refer to the Ruby LSP’s
<a href="https://shopify.github.io/ruby-lsp/">documentation</a> and <a href="https://github.com/Shopify/ruby-lsp/releases">release notes</a>
for migration instructions.</p>

<h2 id="mcp-server">MCP server</h2>

<p>As part of Rubydex, we are also including an experimental MCP server. The purpose is to provide powerful tools for LLMs
to explore the codebase more efficiently. Instead of reading files in their entirety and grepping based on text, the LLM
can directly request information about declarations, definitions, references, ancestors, descendants (basically anything
that you could query through the regular API).</p>

<p>We are still working on improvements and ironing out the best way to distribute the MCP server so that it can find
dependencies correctly, integrating with version managers and Bundler (which can mutate where dependencies are installed
in the system, making discovering them not so straightforward). For now, the MCP server must be built from source, but
we hope to ship it as part of the Ruby gem in a future release.</p>

<p>In early testing, we’re seeing 15 to 80% reduction in token usage, cost and duration for certain LLM tasks thanks to the
ability of quickly navigating the codebase. One example where this is easy to understand is asking the LLM to perform a
refactor. Instead of scanning the entire codebase for usages of a class/module, it can directly ask Rubydex to get the
information.</p>

<h2 id="current-api">Current API</h2>

<p>The Rubydex API is quite extensive to support all use cases in the Ruby LSP, Tapioca, Packwerk and other tools. Here are
a few simple examples of how to use it, but please refer to the <a href="https://github.com/Shopify/rubydex">repo</a> for more
complete API documentation.</p>

<p>For this example, consider the following files present in the workspace:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># foo.rb</span>
<span class="k">class</span> <span class="nc">Foo</span>
  <span class="k">def</span> <span class="nf">found_me</span><span class="p">;</span> <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># bar.rb</span>
<span class="k">class</span> <span class="nc">Bar</span> <span class="o">&lt;</span> <span class="no">Foo</span>
  <span class="k">def</span> <span class="nf">found_me</span><span class="p">;</span> <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># qux.rb</span>
<span class="k">module</span> <span class="nn">Zip</span>
  <span class="k">class</span> <span class="nc">Bar::Qux</span>
    <span class="nc">Foo</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>We can use Rubydex to ask several questions about the state of the code:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">require</span> <span class="s2">"rubydex"</span>

<span class="c1"># The graph is Rubydex's representation of the codebase. It's where everything starts and where all data is stored. To</span>
<span class="c1"># populate the graph, we first index the workspace (all .rb, .rbs files + dependencies) and then run the resolution step</span>
<span class="c1"># to transform definition information into semantic declarations.</span>
<span class="c1">#</span>
<span class="c1"># Find all of the other methods for managing the state of the graph in the repository's documentation.</span>
<span class="n">graph</span> <span class="o">=</span> <span class="no">Rubydex</span><span class="o">::</span><span class="no">Graph</span><span class="p">.</span><span class="nf">new</span>
<span class="n">graph</span><span class="p">.</span><span class="nf">index_workspace</span>
<span class="n">graph</span><span class="p">.</span><span class="nf">resolve</span>

<span class="c1"># Once the graph is ready, we can query it for any information we would like. This is what powers all language server's</span>
<span class="c1"># features in the new v0.27 and forward for the Ruby LSP.</span>
<span class="c1">#</span>
<span class="c1"># Find all of the querying features in the repository's documentation.</span>
<span class="n">declaration</span> <span class="o">=</span> <span class="n">graph</span><span class="p">[</span><span class="s2">"Foo"</span><span class="p">]</span>
<span class="nb">puts</span> <span class="n">declaration</span><span class="p">.</span><span class="nf">ancestors</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:name</span><span class="p">)</span> <span class="c1"># =&gt; ["Foo", "Object", "Kernel", "BasicObject"]</span>
<span class="nb">puts</span> <span class="n">declaration</span><span class="p">.</span><span class="nf">descendants</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:name</span><span class="p">)</span> <span class="c1"># =&gt; ["Bar"]</span>

<span class="nb">puts</span> <span class="n">graph</span><span class="p">.</span><span class="nf">search</span><span class="p">(</span><span class="s2">"#found_me()"</span><span class="p">)</span> <span class="c1"># =&gt; [#&lt;Declaration name=Foo#found_me()&gt;, #&lt;Declaration name=Bar#found_me()&gt;]</span>

<span class="c1"># Resolve a constant reference to `Foo` inside the `Zip, Baz::Qux` nesting</span>
<span class="nb">puts</span> <span class="n">graph</span><span class="p">.</span><span class="nf">resolve_constant</span><span class="p">(</span><span class="s2">"Foo"</span><span class="p">,</span> <span class="p">[</span><span class="s2">"Zip"</span><span class="p">,</span> <span class="s2">"Baz::Qux"</span><span class="p">])</span> <span class="c1"># =&gt; [#&lt;Declaration name=Foo&gt;]</span>
</code></pre></div></div>

<h2 id="future-plans">Future plans</h2>

<p>This announcement marks the first iteration on Rubydex. There are a lot of interesting experiments that we want to do
and lots to build. Here are some of the future ideas we have in our backlog.</p>

<h3 id="shared-database">Shared database</h3>

<p>One of the ideas we partially experimented with and are eager to build is allowing Rubydex to save the analysis into a
database. This benefits tools in a few ways:</p>

<ul>
  <li>
<strong>Reducing boot time</strong>: since the analysis is saved in the database, Rubydex wouldn’t have to start from scratch every
time it is booted. This is regardless of how it gets initiated (language server, linter, MCP server)</li>
  <li>
<strong>Loading partial data</strong>: developers rarely interact with 100% of their codebase all at the same time. Usually, you’re
really only working in a small subset of the codebase at a time. This is true of LLMs too, they don’t need to read every
single file in the codebase if you’re making a targeted change. Maintaining the entire analysis data in-memory at all
times is wasteful. A lot of the information is simply not relevant for the changes being executed. If Rubydex can trace
all of the dependencies of a document (and we believe it’s possible), then we can load a subset of the graph into memory
and leave the rest in the database. This approach reduces the amount of memory being used by offloading to disk and it
makes it so that memory usage does not scale linearly with the number of tools based on Rubydex (since they can share
the same database)</li>
</ul>

<p>If we can get this right, it would mean that you can have a language server, MCP server, linter, type checker all
operating at the same time, reusing the same database to avoid multiple rounds of analysis and consuming little memory
since most of the data is offloaded to disk.</p>

<h3 id="semantic-linting">Semantic linting</h3>

<p>Another hope we have for Rubydex is enabling semantic linting. Rubydex can already collect diagnostics such as undefined
constants. The idea is to offer a rich set of APIs so that linting rules can be created with the multitude of data
available across the different phases of analysis.</p>

<p>The main benefits of semantic linting are reducing false positives and writing more complex rules that simple AST
inspection cannot achieve. Here are some example rules that are possible (and even easy to write) with Rubydex, but not
possible without a complete code indexing engine:</p>

<ul>
  <li>
<strong>Prohibiting inheriting from a certain ancestor</strong>: Maybe you’re deprecating the use of a module or parent class and you
want to detect all existing uses (direct or transitive)</li>
  <li>
<strong>Detecting potential dead code</strong>: Dead code is basically any declaration for which no references have been found. Of
course, with meta-programming and untyped code, we can’t always be 100% sure it’s dead. However, our team has seen great
benefit in flagging dead code candidates that then get reviewed by a developer to remove</li>
  <li>
<strong>Detecting invalid mocks in tests</strong>: For example, stubbing a method to return a boolean when in fact it returns a string</li>
  <li>
<strong>Detecting methods that are always shadowed</strong>: For example, a method in a parent class with real behaviour, but all of
the subclasses override the method without invoking <code class="language-plaintext highlighter-rouge">super</code>
</li>
  <li>
<strong>Detecting incorrectly decomposed modules</strong>: For example, a module that depends on two instance variables, but none of
the classes that include it define both instance variables, leading to module methods that can only be invoked when
included into specific classes</li>
</ul>

<p>And many, many others.</p>

<h3 id="type-aware-analysis">Type aware analysis</h3>

<p>Currently, Rubydex has no concept of types and performs no inference. This limits the framework’s ability to understand
method calls. For example:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">var</span> <span class="o">=</span> <span class="no">Foo</span><span class="p">.</span><span class="nf">something</span>
<span class="n">var</span><span class="p">.</span><span class="nf">other_thing</span>
</code></pre></div></div>

<p>To know what defines <code class="language-plaintext highlighter-rouge">other_thing</code>, we need to know the type of <code class="language-plaintext highlighter-rouge">var</code>, and to know the type of <code class="language-plaintext highlighter-rouge">var</code>, we need to know
the type returned by <code class="language-plaintext highlighter-rouge">Foo.something</code>. Only by propagating types through the analysis can we know for sure which methods
are being used (or if they exist at all). Without this, Rubydex cannot associate method references to their declarations
like it can for constants and instance variables.</p>

<p>The natural next step is to consume type annotations to improve the accuracy of the analysis. For that to work, we need
to design the internal type system, consume annotations already used by the community (e.g.: Sorbet sigs and RBS), and
experiment with different algorithms. The experimentation lies in the approaches we can take and understanding how well
they can fit the Ruby language (e.g.: constraint solving vs set theoretic types).</p>

<h2 id="conclusion">Conclusion</h2>

<p>Prism showed what happens when the Ruby community consolidates around a shared parser: every tool got better and every
improvement compounded. We believe the same is possible one layer up. Analyzing Ruby statically is inherently hard, but
we don’t have to solve the same problems separately.</p>

<p>Rubydex is our bet on a shared foundation. If you maintain a linter, a language server, a documentation generator, a
type checker, a code-mod tool, or something we haven’t imagined yet, we’d love to build this with you.</p>

<p>Try it through the Ruby LSP, Tapioca or Packwerk, or directly use Rubydex’s API. File bugs, open issues, send PRs, tell
us what’s missing in the <a href="https://github.com/Shopify/rubydex">repo</a>. Every improvement made to Rubydex benefits all
tools—even the ones that haven’t been built yet.</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:method-refs">
      <p>Tracking method references with high accuracy depends on inferring the type of the receiver, which is
currently not supported. <a href="#fnref:method-refs" class="reversefootnote" role="doc-backlink">↩</a></p>
    </li>
    <li id="fn:declarations">
      <p>In Rubydex’s architecture, we use the definitions and declarations nomenclature to differentiate
between two concepts. If you re-open the class <code class="language-plaintext highlighter-rouge">Foo</code> twice, then you have two <strong>definitions</strong> of that class.
However, both definitions contribute to the same global unique concept of <code class="language-plaintext highlighter-rouge">Foo</code>. That global concept is what we
call the <strong>declaration</strong> of <code class="language-plaintext highlighter-rouge">Foo</code>. For example:</p>

      <div class="language-ruby highlighter-rouge">
<div class="highlight"><pre class="highlight"><code><span class="c1"># First definition</span>
<span class="k">class</span> <span class="nc">Foo</span>
  <span class="k">def</span> <span class="nf">bar</span><span class="p">;</span> <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># Second definition</span>
<span class="k">class</span> <span class="nc">Foo</span>
  <span class="k">def</span> <span class="nf">baz</span><span class="p">;</span> <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># Both definitions contribute to the same entity `Foo`. It does not matter how many times you re-open the</span>
<span class="c1"># namespace, all of the methods, instance variables, class variables, constants and other members end up</span>
<span class="c1"># associated with that global unique entity</span>
<span class="n">instance</span> <span class="o">=</span> <span class="no">Foo</span><span class="p">.</span><span class="nf">new</span>
<span class="n">instance</span><span class="p">.</span><span class="nf">bar</span>
<span class="n">instance</span><span class="p">.</span><span class="nf">baz</span>
</code></pre></div>      </div>

      <p>We won’t dive into the implementation details here, but the importance of the differentiation between definitions
and declarations is allowing for fast updates in the analysis when the codebase is being modified. As an example
scenario, consider two <code class="language-plaintext highlighter-rouge">Foo</code> definitions created in separate files and then one of the files gets deleted. The
<code class="language-plaintext highlighter-rouge">Foo</code> entity still exists, but one of its definitions is now gone. Having these two concepts allows us to
efficiently update the internal representation of <code class="language-plaintext highlighter-rouge">Foo</code>. <a href="#fnref:declarations" class="reversefootnote" role="doc-backlink">↩</a></p>
    </li>
  </ol>
</div>
</body></html>]]></content><author><name>Vinicius Stock</name></author><category term="posts" /><category term="2026-05-12-one-engine-many-tools" /><summary type="html"><![CDATA[Introducing Rubydex — a portable static analysis engine powering Ruby LSP, Tapioca, Packwerk, and more. One foundation, compounding benefits for the whole ecosystem.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-05-12-one-engine-many-tools/b449b93fe463dd9234bc113dab90a1c0b6307836.png" /><media:content medium="image" url="https://railsatscale.com/2026-05-12-one-engine-many-tools/b449b93fe463dd9234bc113dab90a1c0b6307836.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hitting a Reverted Breaking Change in Rust</title><link href="https://railsatscale.com/2026-04-20-hitting-a-reverted-breaking-change-in-rust/" rel="alternate" type="text/html" title="Hitting a Reverted Breaking Change in Rust" /><published>2026-04-20T00:00:00+00:00</published><updated>2026-04-20T00:00:00+00:00</updated><id>https://railsatscale.com/2026-04-20-hitting-a-reverted-breaking-change-in-rust/</id><content type="html" xml:base="https://railsatscale.com/2026-04-20-hitting-a-reverted-breaking-change-in-rust/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>The story starts with Rust 1.85.0 giving me a surprise <code class="language-plaintext highlighter-rouge">SIGTRAP</code> in some test code. For <a href="https://github.com/ruby/ruby/pull/16647">reasons</a> not important here, I had to bump <code class="language-plaintext highlighter-rouge">opt-level</code> from 0 to 1 for tests. That made a bunch of tests fail with <code class="language-plaintext highlighter-rouge">SIGTRAP</code>, whose default signal handler terminates the process.</p>

<p>The <code class="language-plaintext highlighter-rouge">SIGTRAP</code> happened in Rust code code that call C.</p>

<h2 id="passing-a-rust-closure-through-c-code">Passing a Rust closure through C code</h2>

<p>The code that broke passes a Rust closure through a C function and invokes the closure in a callback.</p>

<p>The C API takes a user data argument in addition to the callback function, for passing through context:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Calls (*proc)(data). VALUE is uintptr_t.</span>
<span class="n">VALUE</span> <span class="nf">rb_protect</span><span class="p">(</span><span class="n">VALUE</span> <span class="p">(</span><span class="o">*</span> <span class="n">proc</span><span class="p">)</span> <span class="p">(</span><span class="n">VALUE</span><span class="p">),</span> <span class="n">VALUE</span> <span class="n">data</span><span class="p">,</span> <span class="kt">int</span> <span class="o">*</span><span class="n">pstate</span><span class="p">);</span>
</code></pre></div></div>

<p>Each closure has a distinct anonymous type as they can vary in size depending on what’s captured. There is no way to separate out the captures and the function pointer to fit the C API. But, we can use a trait object to talk about a category of closure types, and the trait object has one uniform size:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">closure_info</span><span class="p">(</span><span class="n">closure</span><span class="p">:</span> <span class="k">impl</span> <span class="nf">FnMut</span><span class="p">())</span> <span class="p">{</span>
    <span class="k">use</span> <span class="nn">std</span><span class="p">::</span><span class="nn">mem</span><span class="p">::</span><span class="n">size_of_val</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">trait_object</span><span class="p">:</span> <span class="o">&amp;</span><span class="k">dyn</span> <span class="nf">FnMut</span><span class="p">()</span> <span class="o">=</span> <span class="o">&amp;</span><span class="n">closure</span><span class="p">;</span>
    <span class="nd">println!</span><span class="p">(</span>
        <span class="s">"closure_size={} trait_object_size={}"</span><span class="p">,</span>
        <span class="nf">size_of_val</span><span class="p">(</span><span class="o">&amp;</span><span class="n">closure</span><span class="p">),</span>
        <span class="nf">size_of_val</span><span class="p">(</span><span class="o">&amp;</span><span class="n">trait_object</span><span class="p">)</span>
    <span class="p">);</span>
<span class="p">}</span>

<span class="k">fn</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">let</span> <span class="k">mut</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">one_capture</span> <span class="o">=</span> <span class="p">||</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">42</span><span class="p">;</span>
    <span class="k">let</span> <span class="n">no_capture</span> <span class="o">=</span> <span class="p">||</span> <span class="p">{};</span>
    <span class="nf">closure_info</span><span class="p">(</span><span class="n">one_capture</span><span class="p">);</span> <span class="c1">// closure_size=8 trait_object_size=16</span>
    <span class="nf">closure_info</span><span class="p">(</span><span class="n">no_capture</span><span class="p">);</span>  <span class="c1">// closure_size=0 trait_object_size=16</span>
    <span class="c1">// Varying closure size, same trait_object_size.</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The trait object is too big to fit the pointer-sized data argument, but we can solve that by taking a reference of it. That gives <code class="language-plaintext highlighter-rouge">&amp;mut &amp;mut dyn FnMut()</code>, a double reference to the trait object. <code class="language-plaintext highlighter-rouge">&amp;mut</code> is pointer-sized, unlike <code class="language-plaintext highlighter-rouge">&amp;mut dyn</code>. Great, we can now pass the closure through. Now we need to write the callback that invokes the closure.</p>

<h3 id="the-transmute">The transmute</h3>

<p>To call the closure, we need to first turn the <code class="language-plaintext highlighter-rouge">VALUE</code> back into the double reference to trait object. I used <code class="language-plaintext highlighter-rouge">std::mem::transmute</code> for this, and it blew up.
Rust 1.85.0 compiles the following function to a single UD2 instruction which raises <code class="language-plaintext highlighter-rouge">SIGTRAP</code>.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">#[repr(transparent)]</span>
<span class="k">struct</span> <span class="nf">VALUE</span><span class="p">(</span><span class="nb">usize</span><span class="p">);</span>

<span class="k">extern</span> <span class="s">"C"</span> <span class="k">fn</span> <span class="nf">c_callback</span><span class="p">(</span><span class="n">obj</span><span class="p">:</span> <span class="n">VALUE</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">closure</span><span class="p">:</span> <span class="o">&amp;</span><span class="k">mut</span> <span class="o">&amp;</span><span class="k">mut</span> <span class="k">dyn</span> <span class="nf">FnMut</span><span class="p">()</span> <span class="o">=</span> <span class="k">unsafe</span> <span class="p">{</span> <span class="nn">std</span><span class="p">::</span><span class="nn">mem</span><span class="p">::</span><span class="nf">transmute</span><span class="p">(</span><span class="n">obj</span><span class="p">)</span> <span class="p">};</span>
    <span class="nf">closure</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Surprise <code class="language-plaintext highlighter-rouge">SIGTRAP</code>s like these from LLVM makes me think of trap mode in <a href="https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html">UndefinedBehaviorSanitizer</a>. What rules did I break?</p>

<h2 id="probably-pointer-provenance">Probably pointer provenance</h2>

<p>Integer to pointer transmute is <a href="https://doc.rust-lang.org/1.95.0/std/mem/fn.transmute.html#transmutation-between-pointers-and-integers">documented</a> to have unspecified behavior, but our <code class="language-plaintext highlighter-rouge">struct VALUE(usize)</code> definition is not an integer. Like many parts of Unsafe Rust, it’s hard to say how the transmute should have behaved. Rust 1.78.0 includes a change that seems to draw a clear line. <a href="https://github.com/rust-lang/rust/pull/121282">“Lower transmutes from int to pointer type as gep on null”</a> (“transmute patch” from here on) changes how <code class="language-plaintext highlighter-rouge">transmute</code> picks the provenance of the target pointer. Before, the transmute acted like an integer-to-pointer <code class="language-plaintext highlighter-rouge">as</code> cast, picking a previously exposed provenance. Now, it’s based on the null pointer. The null pointer is invalid for access, and so is the derived pointer. The <code class="language-plaintext highlighter-rouge">SIGTRAP</code> is probably trying to say, in an obtuse way, that I’m calling an invalid function pointer.</p>

<p>I had a hunch this is related to pointer provenance changes. I also remember from discussions about provenance something about writing a signature on the Rust side that uses a pointer type to accept an integer argument from the C side. I’m not sure if it’s a good idea to misrepresent types like this, but it’s useful to do it as an experiment to see if the <code class="language-plaintext highlighter-rouge">SIGTRAP</code> goes away:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Experiment: the C side calls this with an integer (VALUE), but we claim it's a pointer</span>
<span class="k">extern</span> <span class="s">"C"</span> <span class="k">fn</span> <span class="nf">c_callback</span><span class="p">(</span><span class="n">obj</span><span class="p">:</span> <span class="o">*</span><span class="k">mut</span> <span class="p">())</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">closure</span><span class="p">:</span> <span class="o">&amp;</span><span class="k">mut</span> <span class="o">&amp;</span><span class="k">mut</span> <span class="k">dyn</span> <span class="nf">FnMut</span><span class="p">()</span> <span class="o">=</span> <span class="k">unsafe</span> <span class="p">{</span> <span class="nn">std</span><span class="p">::</span><span class="nn">mem</span><span class="p">::</span><span class="nf">transmute</span><span class="p">(</span><span class="n">obj</span><span class="p">)</span> <span class="p">};</span>
    <span class="nf">closure</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And this version doesn’t <code class="language-plaintext highlighter-rouge">SIGTRAP</code>! So this is probably related to pointer provenance, rules that stipulate that in addition to having a good address, a pointer is valid for dereference only when obtained a certain way. The pointer in both the working version and the <code class="language-plaintext highlighter-rouge">SIGTRAP</code> version have the same address and in-memory representation, but only one is valid for dereference.
The <code class="language-plaintext highlighter-rouge">ptr</code> module has <a href="https://doc.rust-lang.org/core/ptr/index.html#provenance">documentation</a> about provenance rules.</p>

<p>Searching in <code class="language-plaintext highlighter-rouge">rust-lang/rust</code> found me the transmute patch, and <a href="https://godbolt.org/#z:OYLghAFBqd5TKALEBjA9gEwKYFFMCWALugE4A0BIEAZgQDbYB2AhgLbYgDkAjF%2BTXRMiAZVQtGIHgBYBQogFUAztgAKAD24AGfgCsp5eiyahSAVyVFyKxqiIEh1ZpgDC6embZMpAJnLOAGQImbAA5TwAjbFIQAFZyAAd0JWIHJjcPL19E5NShIJDwtiiY%2BJtsOzSRIhZSIgzPbx4/csqhatqiArDI6LjrGrqGrOaBzu6ikriASmt0M1JUTi4AUh8AZhXYgCFSbATSCCJSYyUE2uYiaa2AERWtAEFLczsAagA1B4CFXAgLAgAXthruttvcHuC1psdkx0AB9NjGYCMW7g7DqIjRJivNY%2BFy4140bGoOHiej0CIsVAAawg6Ai%2Bg%2BXx%2B0xxAHYwY9XtzXowiK9UPRkgtODifAA2NhmflrSXS16YACe2IAYkwALLSiCslbrG6vMxMJQsGjYdnbV6WTAgEAcNg246nKWYukMnVsu6g8E8gVCpQi7W6zkQj1cWb0bixfjeLg6cjobgAJQs/KU80WZqhfHIRG0Ydm1LiWkM3Gk/Ht61iADoABxaNk%2BACcPHFrbb4rZ5BjcYTXH4ShAxdzsbD5DgsBQGDYCQY0Uo1CnM8YMVIPDZbOLdHomNIA4gETz5AiwVqiu42ePrFIioA8gyKsPs1OOMIb0x6GeR%2BQcBEzMAXBI9ADrw/A4IiJiSF%2BhB7JUABu2DAXG6IVNKyxxsEmIRl%2B9AEBEJzXm4OCHscBD2iB5DwaQETJNgNzYOByLBKAI6zDQRjAEo7wENgADuN4JMw578IIwhiBInAyHIwjKGomhfvofhGCYIDmJYhi4QOkCzOgCT2EIwEALQGdghB6Uwuo3D4Wg%2BDwrwGW4ukGYw8H0BZ2boJRpAEDgmlQKwHAgCZeRMBREhmMsVk%2BGW0yzK0ZlOEwrjuI0IA%2BPEgTBD0xR9DwxZJCkZnDN4aU5AVaQTL0MS5dY2C2GZHRDMlWQlXFVSDF0mWTDlxaWJ0RWpWU7UVdlVVaLF6ZLFI4aRtGh69q8qlEKgrw8FW65VlorwQPgxBkGK6w8NM/DDjoMXkEg2AsDgMTaiWXBluQFbilWPhsjINaNq2dY2eKNZdnN3D9oOOZ5rM47IGg6DTrOFBUBAi4wypa51gIDA7nuB5fpep5CUeJ7XneugPrjz6XG%2BH6Hj%2Bf4AeSwHZmBSKQXG0EPgQ8GIfwyGoKhh4YbVh44Xhp6EWhx1eWR2aUdRKh0QxOHKSxAjsZx3F8QJMbZiJohkhJshazJGiHvo6yGEiKkpupES%2BdpulpIZxmmWkFmRbZ9k6UQTnYC5bn8B50ReT58AQP5nBBWZoUeBF1nRbFtWs44EDOP1PD%2BIlw1TCn%2BXBcnpXBenOU1XVbV9U1TSF/HTANR1hSVQYvWNZkZf19XWUZ%2BNCyTYdd1Rv9X7zYty2rWyNYbVtO0kKQ%2B2HcdoOzBdV19LdWEPfar1Vo2Nk2TWraxAdLbxN2vuA9YwMnfm5CFrExZYess198fZ9g4g4MQJOUNLnOcMI8uSPNpuaPRAxoebG15cYgNvPeOwJMoYviIOTT8zNsC/n/IBOmoF6KM1Ft%2BAgMF7Ds0PFzHmX4%2BZYTjILfCioRbEXFrjKWNFZaMyYqDJWLAOJcV4vxQS5EtZiUkJJfWKhDbyQMEpUwFtBbW3jLbfS3AjJhydnqF2dkHIe2crVH28ZPLeQQkHEOgVHZCAjuFbgkUY7lzaN4ROiUc4ZRriNAwWdCqlwcbkMy%2BcqrmPqu1HOrV2hDU6rXFOzcfH%2BLsW3OYHcJLTS4D3Q%2B8ZuALRTIPdeq1NrbVMntLMR0QYsQLEWO6K98lxN7EDIczCX5v2hsuec8N36I1XI2LQ/9tyAOoJjOM4CwH4wgUTKB5FSavnfAg9ByCaZAVxgzCCWCWZwR0V%2BQhmJebCH5thXCFCqFfhIhLfgdCZYYIgkwxWbFWEqw4erXGPCdZSD1vIA2ck4z6FkKI82akJHwBtmZe28ihDO2sq7FRntvZ6nclowOsA9HfJCrBMKUcbLn18ZYpOzieAp1sa3Pof1HFpGTpnVx5UAn2L%2BgiquOLPHFzqO4kARLvHIqCaE9FMQaztwzFNbud8ewJIHitdeWhR7pN2pPLJM9ckFPLEUgGfYT5lJFZfa%2B3Bb69w5ZKx%2B0SfDsqPsq2eFFAFpBANIIAA%3D">experiments</a> on Compiler Explorer with various Rust versions showed 1.78.0 to be the first version that compiles the function to a single UD2 instruction. It’s the first release that includes the transmute patch.</p>

<p>But why does it <code class="language-plaintext highlighter-rouge">SIGTRAP</code> only on older Rusts? What changed?</p>

<h2 id="revert-due-to-llvm-provenance-bug">Revert due to LLVM provenance bug</h2>

<p>In the <code class="language-plaintext highlighter-rouge">rust_has_provenance</code> <a href="https://rust-lang.github.io/rfcs/3559-rust-has-provenance.html">RFC</a>, the lack of proper treatment of provenance in LLVM is stated as a drawback. Turns out, the transmute patch triggers such bugs and so was <a href="https://github.com/rust-lang/rust/pull/147541">reverted</a> in version 1.91.0. In some situations, the existence of a pointer with invalid provenance in the system can have LLVM confused and wrongly decide that an unrelated pointer is invalid for access. To be clear, the LLVM bugs are not the reason my code raises <code class="language-plaintext highlighter-rouge">SIGTRAP</code>; the transmute patch is designed to break such code. The unintended breakages from the transmute patch were due to the LLVM bugs giving bad output for code completely absent of Unsafe Rust.</p>

<p>There are many reports of code breakages due to the transmute patch, but the Rust team did not consider it to be a breaking change. I think it deserved a place in the release note as a compatibility issue. It also would have been nice if it panicked with a clear message rather than a nondescript <code class="language-plaintext highlighter-rouge">SIGTRAP</code>, but maybe the UD2 comes from a place too deep in LLVM’s pipeline to realistically replace.</p>

<p>With the revert, the <code class="language-plaintext highlighter-rouge">SIGTRAP</code> is gone. Absence of problematic runtime behavior does not imply absence of <a href="https://doc.rust-lang.org/reference/behavior-considered-undefined.html">Undefined Behavior</a>, though, and the revert did not change rules of the language. The current documentation for <code class="language-plaintext highlighter-rouge">std::mem::transmute</code> is clear about this subject:</p>

<blockquote>
  <p>Transmuting integers to pointers is a largely unspecified operation. It is likely not equivalent to an as cast. Doing non-zero-sized memory accesses with a pointer constructed this way is currently considered undefined behavior.</p>
</blockquote>

<p>Let’s try to follow the rules.</p>

<h2 id="the-fix">The fix</h2>

<p>Using an <code class="language-plaintext highlighter-rouge">as</code> cast instead of <code class="language-plaintext highlighter-rouge">transmute</code> <a href="https://godbolt.org/#z:OYLghAFBqd5TKALEBjA9gEwKYFFMCWALugE4A0BIEAZgQDbYB2AhgLbYgDkAjF%2BTXRMiAZVQtGIHgBYBQogFUAztgAKAD24AGfgCsp5eiyahSAVyVFyKxqiIEh1ZpgDC6embZMQANgBM5M4AMgRM2AByngBG2KQgsgAO6ErEDkxuHl6%2BAUkp9kIhYZFsMXGyNth2aSJELKREGZ7e/tbYtvlMNXVEhRHRsfHWtfWNWS2W3b3FpfEAlNboZqSonFwApH4AzGsArABCpNgJpBBEpMZKCXXMRLO7ACJrWgCCluZ2ANQAas9BCrgQCwEABe2Dumz2T2eUIA9DCPmsfFpLJgQCAEmc0QB3YhIAD62HUuWwmDxx3QADdmMYVoitB8mOgiB9jtgVMIPqEPjwAHQAdj5PK0PKhG22%2B0ZeLYxmAjAeUMJRFiTARfj8Lg2fg%2BNBVqDx4no9CiLFQAGsIOgovpvr9/rMEXzIS8Pi6Poxmah6MkltgEZt7h9LbohR8WEoPgAqDBMSyRthmZmYACeKoAYkwALIJiDgp3PV0fMwxlg0X1rR0fCARqNepQ%2B2Y5h2PCGivn3LjzejcHb8bxcHTkdDcABKFmZSkWyzLWz45CI2g781NIB2WkM3Gk/DYK7XfYHQ64/CUIDX8/7HfIcFgKAwbASDFilGot/vjDipB4ArXdHoStIx4gKIF3IKJQjqJNuFnUDWFIJMAHkrUqM9Z1vDhhDgph6Ag89yBwKIzGAFwJHoY9eH4HBpRMSQcMIQ4qipUiB0JSoE1WAdQiVLscPoAgonOWC3BwYCzgIbcyPIKlSCiZJsHubBKNlUJQHPeYaCMYAlC%2BAhsCxOCEmYSD%2BEEYQxAkTgZDkYRlDUTQcP0AIjBMEBzEsQxeOPSB5nQDE0lIgBaPySVSIQ1n9PwtD8HgPj8twMT8xgqXoUL7lnSlYlIAgcA8qBWA4EAgo6CSJDMVZwr8TdZnmCoqkcCBnFGbweECJhMCmfo4ia3JgvSdwmgMLqOjakoBia6qOi6EZeqyUa2iQ6phh6UI%2BmGjqhm6BqDAmeohpmHgqsnFYpE7bte2Ag8PhcohUG5flBXpCB8GIMhVU2Pb%2BDPHRKvIJBsBYHA4hzdcuE3chtz8HZyD3fgDyPE85wXeYr2QNB0DvB8KCoCAX3R5zPwADm/Bg/wAoCcOg8DDJAsDYIQ3QkMp1CbgwrDgLwgiiMNUjZwomVqIHWi5oY4DmNQVjgI4tpgJ4vjwMEtj3oysTZ0k6SVDkhSeKclSBHUzTtN0/S%2B1nYzRANczZBN6yNGA/RNkMGVnLHNyomyryfKEfzAsIDpkrKqKYu8oh4uwRLktSySMqy%2BAIFyzgCrSIqPFKiKKqq2aau8OqWo2prgiW6YRvIAa0hzovkm6nbC7G%2Bb1qmxrWnaGvtvz9rNoW0utsWopW72hYlkO3uuJ7SGzu4C6x2u3k%2BTxkMHu956xTe%2BGVPmH6/oGQGuJBsGIahwduFh08EaXHcgc2U6cJh5fPsvRAkYgG9UdfR9Mext9cYATh4Qnf1iEngPJrBSmgD4KITsAzVGaEiDM2wvzbA%2BFCLES5uReSvN5a4QIHRewQscIizFjhCWXEBzS34kmOWwlFaUxVjJdWvMlIIx1iwDSWkdJ6QMuJE2plJAWUtioa2dkDCOVME7aWrtBzuxjNwAK8cQphQiv7WKQcEptDDtDCOmU2TR1jvlb2CcKTFWTuVRcDc5q1XqnXAwedu4rX6uXDopdi4FBbjYmajchATQaBY1xpjOgLUrqtTuHc/HON2vtfu5ljpcGHnvc6l1J48i/rPR6JBSAvSXh9Yxy5VxA23qfGJB9rBwwyYjO%2Bj80ZvifFjJ%2BOMPwfy0D/Ym1BSYDhAcA6moC6bgPEozdCmFYEoIQRzEilMeZUXQQLeimjcHqBYkqcWwhJbcV4qQ8hOERJK34NQtWqCqL0O1mpJhetWGG0ppws2UgLbyCtrZAc%2BhZBCMdq5UR8A3YdE9jIpgvt5HRUUcHUO/pw7pQ0dlGO7A466KEInEq3A/bGOrmY7OXjmqtRCYXRxPVMj13Rf4za6dxrtyRfC3xkxUUBIJZituJLrGhL7lOI6QNomjy4OPSw8S6lJPnqkxesx3rHxyVuPJTLD7X0yafLi58R6XwKcUyJfgL77mlXyySKRHDSCAA%3D%3D">avoids</a> the <code class="language-plaintext highlighter-rouge">SIGTRAP</code> in all Rust versions:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">extern</span> <span class="s">"C"</span> <span class="k">fn</span> <span class="nf">c_callback</span><span class="p">(</span><span class="n">obj</span><span class="p">:</span> <span class="n">VALUE</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">closure</span> <span class="o">=</span> <span class="n">obj</span><span class="na">.0</span> <span class="k">as</span> <span class="o">*</span><span class="k">const</span> <span class="o">*</span><span class="k">mut</span> <span class="k">dyn</span> <span class="nf">FnMut</span><span class="p">();</span>
    <span class="k">unsafe</span> <span class="p">{</span> <span class="p">(</span><span class="o">**</span><span class="n">closure</span><span class="p">)()</span> <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<p>I understand why <code class="language-plaintext highlighter-rouge">as</code> cast works here better than <code class="language-plaintext highlighter-rouge">transmute</code> as follows:
<code class="language-plaintext highlighter-rouge">transmute</code> works solely with the bits of the input value, but provenance is not represented in the input integer (integers have no provenance),
so the output pointer has no provenance and is invalid for dereference. On the other hand, <code class="language-plaintext highlighter-rouge">as</code> casts can add things to the output not in the input. For casting from a smaller integer size to a larger one, it adds bits. Here, it adds provenance.</p>

<h2 id="ready-for-round-two">Ready for round two</h2>

<p>I wrote code that triggers <a href="https://doc.rust-lang.org/reference/behavior-considered-undefined.html">Undefined Behavior</a>. We fix the code and move on, having learned a bit about pointer provenance language rules. If and when the transmute patch is reintroduced after the LLVM bugs are fixed or mitigated, our code will work.</p>

</body></html>]]></content><author><name>Alan Wu</name></author><category term="posts" /><category term="2026-04-20-hitting-a-reverted-breaking-change-in-rust" /><summary type="html"><![CDATA[The story starts with Rust 1.85.0 giving me a surprise SIGTRAP in some test code. For reasons not important here, I had to bump opt-level from 0 to 1 for tests. That made a bunch of tests fail with SIGTRAP, whose default signal handler terminates the process.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-04-20-hitting-a-reverted-breaking-change-in-rust/27888e10c3a3655a637b02613dd5602d5c99bd8c.png" /><media:content medium="image" url="https://railsatscale.com/2026-04-20-hitting-a-reverted-breaking-change-in-rust/27888e10c3a3655a637b02613dd5602d5c99bd8c.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Using Perfetto in ZJIT</title><link href="https://railsatscale.com/2026-03-27-using-perfetto-in-zjit/" rel="alternate" type="text/html" title="Using Perfetto in ZJIT" /><published>2026-03-27T00:00:00+00:00</published><updated>2026-03-27T00:00:00+00:00</updated><id>https://railsatscale.com/2026-03-27-using-perfetto-in-zjit/</id><content type="html" xml:base="https://railsatscale.com/2026-03-27-using-perfetto-in-zjit/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>Look! A trace of slow events in a benchmark! Hover over the image to see it get bigger.</p>

<style>
img.hover-zoom:hover {
  transform: scale(2);
  transition: transform 0.1s ease-in;
}
img.hover-zoom:not(:hover) {
  transition: transform 0.1s ease-out;
}
</style>

<figure><img src="demo.png" alt="A sneak preview of what the trace looks like." class="hover-zoom"><figcaption>A sneak preview of what the trace looks like.</figcaption></figure>

<p>Now read on to see what the slow events are and how we got this pretty picture.</p>

<h2 id="the-rules">The rules</h2>

<p>The first rule of just-in-time compilers is: you stay in JIT code. The second
rule of JIT is: you STAY in JIT code!</p>

<p>When control leaves the compiled code to run in the interpreter—what the ZJIT
team calls either a “side-exit” or a “deopt”, depending on who you talk
to—things slow down. In a well-tuned system, this should happen pretty
rarely. Right now, because we’re still bringing up the compiler and runtime
system, it happens more than we would like.</p>

<p>We’re reducing the number of exits over time.</p>

<h2 id="lies-damned-lies-and-statistics">Lies, damned lies, and statistics</h2>

<p>We can track our side-exit reduction progress with <code class="language-plaintext highlighter-rouge">--zjit-stats</code>, which,
on process exit, prints out a tidy summary of the counters for all of the bad
stuff we track. It’s got side-exits. It’s got calls to C code. It’s got calls
to slow-path runtime helpers. It’s got everything.</p>

<p>Here is a chopped-up sample of stats output for the Lobsters benchmark,
which is a large Rails app:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ WARMUP_ITRS=0 MIN_BENCH_ITRS=20 MIN_BENCH_TIME=0 ruby --zjit-stats benchmarks/lobsters/benchmark.rb
...
***ZJIT: Printing ZJIT statistics on exit***
...
Top-20 side exit reasons (100.0% of total 12,549,876):
                   guard_type_failure: 6,020,734 (48.0%)
                  guard_shape_failure: 5,556,147 (44.3%)
  block_param_proxy_not_iseq_or_ifunc:   445,358 ( 3.5%)
                   unhandled_hir_insn:   215,168 ( 1.7%)
                        compile_error:   181,474 ( 1.4%)
...
compiled_iseq_count:                               5,581
failed_iseq_count:                                     2
compile_time:                                    1,443ms
...
guard_type_count:                            133,425,094
guard_type_exit_ratio:                              4.5%
guard_shape_count:                            49,386,694
guard_shape_exit_ratio:                            11.3%
...
code_region_bytes:                            31,571,968
side_exit_size_ratio:                              33.1%
zjit_alloc_bytes:                             19,329,659
total_mem_bytes:                              50,901,627
...
ratio_in_zjit:                                     82.8%
$
</code></pre></div></div>

<p>(I’ve cut out significant chunks of the stats output and replaced them with
<code class="language-plaintext highlighter-rouge">...</code> because it’s overwhelming the first time you see it.)</p>

<p>The first thing you might note is that the thing I just described as terrible
for performance is happening <em>over twelve million times</em>. The second thing you
might notice is that despite this, we’re staying in JIT code seemingly a high
percentage of the time. Or are we? Is 80% high? Is a 4.5% class guard miss
ratio high? What about 11% for shapes? It’s hard to say.</p>

<p>The counters are great because they’re <em>quick</em> and they’re reasonably stable
proxies for performance. There’s no substitute for painstaking measurements on
a quiet machine but if the counter for Bad Slow Thing goes down (and others do
not go up), we’re probably doing a good job.</p>

<p>But they’re not great for building intuition. For intuition, we want more
tangible feeling numbers. We want to see things.</p>

<h2 id="building-intuition">Building intuition</h2>

<p>The third thing is that you might ask yourself “where are these exits
coming from?” Unfortunately, counters cannot tell you that. For that, we
want stack traces. This lets us know where in the guest (Ruby) code triggers
an exit.</p>

<p>Ideally also we would want some notion of time: we would want to know not just
where these events happen but also when. Are the exits happening early, at
application boot? At warmup? Even during what should be steady state
application time? Hard to say.</p>

<p>So we need more tools. Thankfully, <a href="https://perfetto.dev/">Perfetto</a> exists.
Perfetto is a system for visualizing and analyzing traces and profiles that your
application generates. It has both a web UI and a command-line UI.</p>

<p>We can emit traces for Perfetto and visualize them there.</p>

<h2 id="a-look-at-perfetto">A look at Perfetto</h2>

<p>Take a look at this <a href="https://ui.perfetto.dev/#!/?url=https://railsatscale.com/2026-03-27-using-perfetto-in-zjit/perfetto-36885.fxt">sample ZJIT Perfetto
trace</a>
generated by running Ruby with <code class="language-plaintext highlighter-rouge">--zjit-trace-exits</code><sup id="fnref:sampled"><a href="#fn:sampled" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>. What do you see?</p>

<p>I see a couple arrows on the left. Arrows indicate “instant” point-in-time
events. Then I see a mess of purple to the right of that until the end of the
trace.</p>

<p>Hover over an arrow. Find out that each arrow is a side-exit. Scream silently.</p>

<p>But it’s a friendly arrow. It tells you what the side-exit reason is. If you
click it, it even tells you the stack trace in the pop-up panel on the bottom.
If we click a couple of them, maybe we can learn more.</p>

<p>We can also zoom by mousing over the track, holding Ctrl, and scrolling. That
will get us look closer. But there are so many…</p>

<p>Fortunately, Perfetto also provides a SQL interface to the traces. We can write
a query to aggregate all of the side exit events from the <code class="language-plaintext highlighter-rouge">slice</code> table and
line them up with the topmost method from the backtrace arguments in the <code class="language-plaintext highlighter-rouge">args</code>
table:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span>
  <span class="n">s</span><span class="p">.</span><span class="n">name</span> <span class="k">AS</span> <span class="n">reason</span><span class="p">,</span>
  <span class="n">a</span><span class="p">.</span><span class="n">display_value</span> <span class="k">AS</span> <span class="k">method</span><span class="p">,</span>
  <span class="k">COUNT</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="k">count</span>
<span class="k">FROM</span> <span class="n">slice</span> <span class="n">s</span>
<span class="k">JOIN</span> <span class="n">args</span> <span class="n">a</span> <span class="k">ON</span> <span class="n">a</span><span class="p">.</span><span class="n">arg_set_id</span> <span class="o">=</span> <span class="n">s</span><span class="p">.</span><span class="n">arg_set_id</span> <span class="k">AND</span> <span class="n">a</span><span class="p">.</span><span class="k">key</span> <span class="o">=</span> <span class="s1">'0'</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="n">s</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">a</span><span class="p">.</span><span class="n">display_value</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="k">count</span> <span class="k">DESC</span>
</code></pre></div></div>

<p>This pulls up a query box at the bottom showing us that there are a couple big
hotspots:</p>

<figure><img src="method-query.png" alt="Query results showing in columns left to right: reason for side-exit, method
that exited, and count. The top three are above 1k but it quickly falls off
after that." class="hover-zoom"><figcaption>Query results showing in columns left to right: reason for side-exit, method
that exited, and count. The top three are above 1k but it quickly falls off
after that.</figcaption></figure>

<p>It even has a helpful option to export the results Markdown table so I can
paste (an edited version) into this blog post:</p>

<div style="overflow-x: auto; font-size: 0.65em; margin-left: max(-10em, calc(-50vw + 50%)); margin-right: max(-10em, calc(-50vw + 50%));">

  <table>
    <thead>
      <tr>
        <th>reason</th>
        <th>method</th>
        <th>count</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>GuardShape(ShapeId(2475))</td>
        <td>ActiveModel::AttributeRegistration::ClassMethods#attribute_types</td>
        <td>5119</td>
      </tr>
      <tr>
        <td>GuardShape(ShapeId(2099268))</td>
        <td>ActiveRecord::ConnectionAdapters::AbstractAdapter#extended_type_map_key</td>
        <td>2295</td>
      </tr>
      <tr>
        <td>GuardType(FalseClass)</td>
        <td>ActiveModel::Type::Value#cast</td>
        <td>1025</td>
      </tr>
      <tr>
        <td>GuardShape(ShapeId(2099698))</td>
        <td>ActiveRecord::Associations#association_instance_get</td>
        <td>904</td>
      </tr>
      <tr>
        <td>BlockParamProxyNotIseqOrIfunc</td>
        <td>ActiveRecord::AttributeMethods::Read#_read_attribute</td>
        <td>902</td>
      </tr>
      <tr>
        <td>GuardShape(ShapeId(526450))</td>
        <td>Rack::Request::Env#get_header</td>
        <td>636</td>
      </tr>
      <tr>
        <td>GuardType(Class[class_exact*:Class@VALUE(0x128c60100)])</td>
        <td>ActiveRecord::Base._reflections</td>
        <td>622</td>
      </tr>
      <tr>
        <td>GuardType(ObjectSubclass[class_exact:Story])</td>
        <td>ActiveRecord::Associations#association</td>
        <td>565</td>
      </tr>
      <tr>
        <td>GuardShape(ShapeId(2098982))</td>
        <td>ActiveRecord::Reflection::AssociationReflection#polymorphic?</td>
        <td>510</td>
      </tr>
      <tr>
        <td>GuardType(StringSubclass[class_exact:ActiveSupport::SafeBuffer])</td>
        <td>ActionView::OutputBuffer#&lt;&lt;</td>
        <td>500</td>
      </tr>
      <tr>
        <td>GuardShape(ShapeId(2475))</td>
        <td>ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods#primary_key</td>
        <td>492</td>
      </tr>
      <tr>
        <td>GuardType(ObjectSubclass[class_exact:ActiveModel::Type::String])</td>
        <td>ActiveModel::Type::Value#deserialize</td>
        <td>442</td>
      </tr>
      <tr>
        <td>GuardShape(ShapeId(2098982))</td>
        <td>ActiveRecord::Reflection::AssociationReflection#deprecated?</td>
        <td>376</td>
      </tr>
      <tr>
        <td>GuardType(ObjectSubclass[class_exact:Bundler::Dependency])</td>
        <td>Gem::Dependency#matches_spec?</td>
        <td>355</td>
      </tr>
      <tr>
        <td>UnhandledHIRInvokeBuiltin</td>
        <td>Time#initialize</td>
        <td>346</td>
      </tr>
    </tbody>
  </table>

</div>

<p>Looks like we should figure out why we’re having shape misses so much and that will
clear up a lot of exits. (Hint: it’s because once we make our first guess about
what we think the object shape will be, we don’t re-assess… <strong>yet</strong>.)</p>

<p>This has been a taste of Perfetto. There’s probably a lot more to explore.
Please join the <a href="https://zjit.zulipchat.com">ZJIT Zulip</a> and let us know if you have any cool
tracing or exploring tricks.</p>

<p>Now I’ll explain how you too can use Perfetto from your system. Adding support
to ZJIT was pretty straightforward.</p>

<h2 id="implementation">Implementation</h2>

<p>The first thing is that you’ll need some way to get trace data out of your
system. We write to a file with a well-known location
(<code class="language-plaintext highlighter-rouge">/tmp/perfetto-PID.fxt</code>), but you could do any number of things. Perhaps you
can stream events over a socket to another process, or to a server that
aggregates them, or store them internally and expose a webserver that serves
them over the internet, or… anything, really.</p>

<p>Once you have that, you need a couple lines of code to emit the data. Perfetto
accepts a number of formats. For example, in his <a href="https://thume.ca/2023/12/02/tracing-methods/">excellent blog post</a>,
Tristan Hume opens with such a simple snippet of code for logging Chromium
Trace JSON-formatted events (lightly modified by me):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">event_name</span> <span class="o">=</span> <span class="bp">...</span>
<span class="n">timestamp</span> <span class="o">=</span> <span class="bp">...</span>
<span class="n">duration</span> <span class="o">=</span> <span class="bp">...</span>
<span class="n">f</span> <span class="o">=</span> <span class="nf">open</span><span class="p">(</span><span class="sh">'</span><span class="s">trace.json</span><span class="sh">'</span><span class="p">,</span><span class="sh">'</span><span class="s">a</span><span class="sh">'</span><span class="p">)</span>
<span class="n">f</span><span class="p">.</span><span class="nf">write</span><span class="p">(</span><span class="sh">"</span><span class="s">[</span><span class="se">\n</span><span class="sh">"</span><span class="p">)</span>

<span class="c1"># ... emit some events here ...
</span>
<span class="c1"># Log a single event
</span><span class="n">f</span><span class="p">.</span><span class="nf">write</span><span class="p">(</span><span class="sh">'</span><span class="s">{</span><span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="s">: </span><span class="sh">"</span><span class="s">%s</span><span class="sh">"</span><span class="s">, </span><span class="sh">"</span><span class="s">ts</span><span class="sh">"</span><span class="s">: %d, </span><span class="sh">"</span><span class="s">dur</span><span class="sh">"</span><span class="s">: %d, </span><span class="sh">"</span><span class="s">cat</span><span class="sh">"</span><span class="s">: </span><span class="sh">"</span><span class="s">hi</span><span class="sh">"</span><span class="s">, </span><span class="sh">"</span><span class="s">ph</span><span class="sh">"</span><span class="s">: </span><span class="sh">"</span><span class="s">X</span><span class="sh">"</span><span class="s">, </span><span class="sh">"</span><span class="s">pid</span><span class="sh">"</span><span class="s">: 1, </span><span class="sh">"</span><span class="s">tid</span><span class="sh">"</span><span class="s">: 1, </span><span class="sh">"</span><span class="s">args</span><span class="sh">"</span><span class="s">: {}},</span><span class="se">\n</span><span class="sh">'</span> <span class="o">%</span>
  <span class="p">(</span><span class="n">event_name</span><span class="p">,</span> <span class="n">timestamp</span><span class="p">,</span> <span class="n">duration</span><span class="p">))</span>

<span class="c1"># ... emit some events here ...
</span>
<span class="c1"># ... at process exit, close the file ...
</span><span class="n">f</span><span class="p">.</span><span class="nf">write</span><span class="p">(</span><span class="sh">"</span><span class="s">]</span><span class="sh">"</span><span class="p">)</span> <span class="c1"># this closing ] isn't actually required
</span><span class="n">f</span><span class="p">.</span><span class="nf">close</span><span class="p">()</span>
</code></pre></div></div>

<p>This snippet is great. It shows, end-to-end, writing a stream of one event. It
is a <em>complete</em> (X) event, as opposed to either:</p>

<ul>
  <li>two discrete timestamped <em>begin</em> (B) and <em>end</em> (E) events that book-end
something, or</li>
  <li>an <em>instant</em> (i) event that has no duration, or</li>
  <li>a couple other event types in the <a href="https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview">Chromium Trace Event Format doc</a>
</li>
</ul>

<p>It was enough to get me started. Since it’s JSON, and we have a lot of side
exits, the trace quickly ballooned to 8GB large for a several second benchmark.
Not great. Now, part of this is our fault—we should side exit less—and part
of it is just the verbosity of JSON.</p>

<p>Thankfully, Perfetto ingests more compact binary formats, such as the <a href="https://fuchsia.dev/fuchsia-src/reference/tracing/trace-format">Fuchsia
trace format</a>.
In addition to being more compact, FXT even supports string interning. After
modifying the tracer to emit FXT, we ended with closer to 100MB for the same
benchmark.</p>

<p>We can reduce further by <em>sampling</em>—not writing every exit to the trace, but
instead every <em>K</em> exits (for some (probably prime) K). This is why we provide
the <code class="language-plaintext highlighter-rouge">--zjit-trace-exits-sample-rate=K</code> option.</p>

<p>Check out the <a href="https://github.com/ruby/ruby/blob/eb8051185122d4b7bc9c6a6df694a85f34ced681/zjit/src/stats.rs#L988">trace writer</a> implementation from the point this article
was written.</p>

<h2 id="tracing-more-things">Tracing more things</h2>

<p>We could trace:</p>

<ul>
  <li>When methods get compiled</li>
  <li>How big the generated code is</li>
  <li>How long each compile phase takes</li>
  <li>When (and where) invalidation events happen</li>
  <li>When (and where) allocations happen from JITed code</li>
  <li>Garbage collection events</li>
  <li>and more!</li>
</ul>

<h2 id="conclusion">Conclusion</h2>

<p>Visualizations are awesome. Get your data in the right format so you can ask
the right questions easily. Thanks for Perfetto!</p>

<p>Also, looks like visualizations are now available in Perfetto canary. Time to
go make some fun histograms…</p>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:sampled">
      <p>This is also sampled/strobed, so not every exit is in there. This
is just 1/K of them for some K that I don’t remember. <a href="#fnref:sampled" class="reversefootnote" role="doc-backlink">↩</a></p>
    </li>
  </ol>
</div>
</body></html>]]></content><author><name>Max Bernstein</name></author><category term="posts" /><category term="2026-03-27-using-perfetto-in-zjit" /><summary type="html"><![CDATA[We added Perfetto tracing support to ZJIT so we could visualize and query slow events. Take a look at the pretty colors and see how you can add this to your system too.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-03-27-using-perfetto-in-zjit/5e25b1e658f8301440e1e91eafbb48286c0748f0.png" /><media:content medium="image" url="https://railsatscale.com/2026-03-27-using-perfetto-in-zjit/5e25b1e658f8301440e1e91eafbb48286c0748f0.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Engineering Rigor in the AI Age: Building a Benchmark You Can Trust</title><link href="https://railsatscale.com/2026-03-18-engineering-rigor-in-the-ai-age-building-a-benchmark-you-can-trust/" rel="alternate" type="text/html" title="Engineering Rigor in the AI Age: Building a Benchmark You Can Trust" /><published>2026-03-18T00:00:00+00:00</published><updated>2026-03-18T00:00:00+00:00</updated><id>https://railsatscale.com/2026-03-18-engineering-rigor-in-the-ai-age-building-a-benchmark-you-can-trust/</id><content type="html" xml:base="https://railsatscale.com/2026-03-18-engineering-rigor-in-the-ai-age-building-a-benchmark-you-can-trust/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>The Rails Infrastructure team has been <a href="https://railsatscale.com/2026-03-09-faster-bundler/" target="_blank">working on making Bundler faster</a> and our work has paid off. A cold <code class="language-plaintext highlighter-rouge">bundle install</code> is 3x faster on a Gemfile with 452 gems compared to Bundler 2.7. But “faster” only means something if everyone agrees on what’s being measured. If two people are running benchmarks with different definitions of “cold install” or different cache states, the results aren’t comparable. We needed a shared tool that would give us confidence, both internally and externally, that we’re tackling the right problems and actually making Bundler faster. And along the way we learned when Claude can be helpful and when to not outsource our own expertise and thinking.</p>

<h2 id="what-affects-bundle-install-time">What affects bundle install time</h2>

<p>Before building anything, we had to understand what variables go into <code class="language-plaintext highlighter-rouge">bundle install</code> performance. There are more than you’d expect:</p>

<ul>
  <li>
<strong>Number of gems</strong>: A Gemfile with 35 gems takes less time to install than a Gemfile with 500 gems.</li>
  <li>
<strong>Depth of dependencies</strong>: A flat Gemfile with no transitive dependencies resolves faster than a deep dependency tree.</li>
  <li>
<strong>Native extensions</strong>: Gems like <code class="language-plaintext highlighter-rouge">bigdecimal</code> take orders of magnitude longer to install than pure Ruby gems (<code class="language-plaintext highlighter-rouge">bigdecimal</code> alone takes 3 seconds to install). The ratio of native extension gems to pure Ruby gems in your Gemfile changes the install profile significantly. It’s rare to have a Gemfile without at least a few dependencies on native extensions.</li>
  <li>
<strong>How Ruby is compiled</strong>: Optimization flags, compiler version, and platform all affect gem compilation time.</li>
  <li>
<strong>Network time</strong>: Downloading from rubygems.org introduces latency and rate limiting that can skew results.</li>
  <li>
<strong>Number of cores</strong>: Bundler parallelizes installs across worker threads, so core count matters.</li>
  <li>
<strong>Endpoint security software</strong>: On company issued, managed machines, security software that scans file writes adds measurable time to every gem install. Running the same benchmark on a personal non-managed device vs a managed device produced very different numbers with no code change. If your benchmarks aren’t reproducible across machines, this is worth checking.</li>
</ul>

<p>We needed a way to remove as many of these variables as possible so when we made changes, we could trust our benchmarks were correct. The goal was to remove guesswork, ensure everyone is testing from the same starting point, and provide a straightforward way to run benchmarks when making changes.</p>

<h2 id="what-we-built">What we built</h2>

<p>It took a few weeks to get reliable results and as part of building the benchmark we also implemented a full <a href="https://github.com/eileencodes/bundler-perf-toolkit" target="_blank">toolkit</a> that includes scripts for installing, benchmarking and profiling Ruby package managers.</p>

<p>Getting a reliable benchmark took a lot of iteration. The first version of the benchmark was basic, and every time we ran it we’d find something that wasn’t quite right. We worked with Claude to make the original benchmark and tweak it as we found issues with the runs. In some cases we had to do the tedious work of debugging the benchmark ourselves.</p>

<p>Back in 2018 when I was working on improving Rails integration test performance, I kept an <a href="https://github.com/eileencodes/integration_performance_test" target="_blank">entire repo</a> with all my benchmarks and profile scripts so I could track changes over time, but also so I could share it with the community. When your benchmark is open source, you’re not working in a vacuum and everyone else can check your assumptions. That lesson stuck with me, and it’s why this toolkit follows a similar pattern.</p>

<p>The toolkit includes everything you need to benchmark and profile Bundler.</p>

<ul>
  <li>
<strong>Setup scripts:</strong> Scripts to install for both macOS and Linux that lets you choose which package managers you want to benchmark.</li>
  <li>
<strong>Benchmarking tool:</strong> The benchmark tool uses <a href="https://github.com/sharkdp/hyperfine" target="_blank">hyperfine</a> for statistical timing with standard deviation, min/max, and outlier detection.
    <ul>
      <li>It supports running against multiple branches and package managers, switching the Ruby version, changing the number of iterations, and provides multiple Gemfile scenarios.</li>
      <li>It automatically runs both warm and cold scenarios and outputs how much faster or slower each is than the baseline.</li>
      <li>It includes a fake gemserver. Thanks to Claude I was able to quickly build a fake gemserver that served real gems based on Aaron’s <a href="https://github.com/tenderlove/slow-gemserver" target="_blank">slow-gemserver</a> and use that to eliminate deviations caused by network round trips to <a href="http://rubygems.org" target="_blank">Rubygems</a>org and/or rate limiting.</li>
    </ul>
  </li>
  <li>
<strong>Profiler tool for Bundler:</strong> The profiler tool can currently only profile Bundler but also includes everything you need using either <a href="https://github.com/mstange/samply" target="_blank">Samply</a> or <a href="https://github.com/jhawthorn/vernier" target="_blank">Vernier</a>
    <ul>
      <li>It supports switching the Ruby version, running with cold or warm cache mode, choosing the Gemfile scenario, and changing the output path of the profile.</li>
      <li>It also can optionally use the fake gemserver to avoid profiling network time.</li>
    </ul>
  </li>
</ul>

<h2 id="how-we-defined-what-to-measure">How we defined what to measure</h2>

<p>As part of building this benchmark we also needed to define what we wanted to measure in order to have the same understanding of the scenarios we are trying to improve.</p>

<p><strong>Cold</strong> is defined as a first-ever install. Before each iteration, hyperfine’s <code class="language-plaintext highlighter-rouge">--prepare</code> hook nukes all caches: download cache, compact index cache, installed gems, bundle home, and removes the lockfile. The install has to resolve dependencies, download every gem, and install from scratch. This is the case where nothing is compiled or installed.</p>

<p><strong>Warm</strong> is defined as reinstalling gems that have been downloaded previously. The benchmark setup first runs one full cold install to populate the download cache. Then for each timed iteration, the <code class="language-plaintext highlighter-rouge">--prepare</code> hook removes only installed gems and the <code class="language-plaintext highlighter-rouge">.bundle</code> directory, keeping the download cache and lockfile intact. The install runs with <code class="language-plaintext highlighter-rouge">BUNDLE_FROZEN=1</code> so it skips resolution and only extracts and installs.</p>

<p><strong>Getting “warm” right was harder than it sounds.</strong> <a href="https://github.com/ruby/rubygems" target="_blank">Bundler</a>, <a href="https://github.com/gel-rb/gel" target="_blank">gel</a>, <a href="https://github.com/spinel-coop/rv" target="_blank">rv</a>, and <a href="https://github.com/tobi/scint" target="_blank">scint</a> package managers all have different cache structures. Early on Bundler’s warm results were barely faster than cold, and we spent time debugging before realizing it was a cache isolation issue in the benchmark itself, not a Bundler problem. The benchmark was wrong, not the code. Interestingly, this was a case where AI wasn’t that helpful. Claude kept missing this specific environment variable, so we had to debug the hard way (this is also why the script has a <code class="language-plaintext highlighter-rouge">BENCH_DEBUG</code> mode). But it paid off because we gained a better understanding of which environment variables affect the caches.</p>

<p>In the future we may want to define other cache scenarios to measure. There are scenarios between our pre-defined cold and warm scenarios like <code class="language-plaintext highlighter-rouge">bundle update</code> or having the gems installed but no lockfile so resolution needs to occur again. The beauty of this toolkit being open source is that if there’s a scenario you want to test, we can easily add that to the benchmark script.</p>

<h2 id="using-the-benchmark-script">Using the benchmark script</h2>

<p>In order to support multiple tools we implemented a <code class="language-plaintext highlighter-rouge">--run</code> argument that is specified as a <code class="language-plaintext highlighter-rouge">LABEL:TOOL[:PATH]</code> triple. The label isolates caches, the tool selects the package manager, and the path optionally points to a local checkout or git worktree. Multiple runs can be compared in a single invocation, with the first treated as baseline.</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ruby run_benchmark.rb <span class="se">\</span>
  <span class="nt">--run</span> master:bundler:~/rubygems <span class="se">\</span>
  <span class="nt">--run</span> patched:bundler:~/rubygems-patched <span class="se">\</span>
  <span class="nt">--scenario</span> rails <span class="se">\</span>
  <span class="nt">--iterations</span> 5 <span class="se">\</span>
  <span class="nt">--source</span> http://localhost:9292
</code></pre></div></div>

<p>Caches are fully isolated per label under <code class="language-plaintext highlighter-rouge">.caches/&lt;label&gt;/</code> using environment variables so comparing two Bundler versions in the same invocation won’t contaminate results. The comparison output shows relative speed so you can quickly see if your change is how many times faster or slower it is than the baseline.</p>

<p>Each scenario is just a directory containing a Gemfile. The <code class="language-plaintext highlighter-rouge">rails</code> scenario represents a typical Rails application with 35 gems. The <code class="language-plaintext highlighter-rouge">large</code> scenario is a stress test with 452 gems. You can add your own by creating a directory with a Gemfile and passing <code class="language-plaintext highlighter-rouge">--scenario yourdir</code> to the script.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ ruby run_benchmark.rb --run bundler27:bundler:~/bundler27 --run master:bundler:~/rubygems/ --scenario large --iterations 3 --source http://localhost:9292 --ruby /usr/local/bin/ruby
Benchmark matrix
Ruby: ruby 4.0.1 (2026-01-13 revision e04267a14b) +PRISM [x86_64-linux]
Source: http://localhost:9292
Iterations: 3
Runs:
  bundler27: bundler (/home/ubuntu/bundler27)
  master: bundler (/home/ubuntu/rubygems)

=== Scenario: large (452 gems, bundler27) ===
  Running cold benchmark (3 runs)...
Benchmark 1: bundler27 (cold)
  Time (mean ± σ):     51.368 s ±  0.058 s    [User: 106.055 s, System: 22.181 s]
  Range (min … max):   51.332 s … 51.435 s    3 runs

  Running warm benchmark (3 runs)...
Benchmark 1: bundler27 (warm)
  Time (mean ± σ):      8.895 s ±  0.150 s    [User: 7.743 s, System: 4.658 s]
  Range (min … max):    8.742 s …  9.042 s    3 runs

  Cold median: 51.34s  Warm median: 8.9s

=== Scenario: large (452 gems, master) ===
  Running cold benchmark (3 runs)...
Benchmark 1: master (cold)
  Time (mean ± σ):     16.012 s ±  0.023 s    [User: 107.344 s, System: 21.568 s]
  Range (min … max):   15.987 s … 16.033 s    3 runs

  Running warm benchmark (3 runs)...
Benchmark 1: master (warm)
  Time (mean ± σ):      7.202 s ±  0.027 s    [User: 4.908 s, System: 3.061 s]
  Range (min … max):    7.173 s …  7.228 s    3 runs

  Cold median: 16.02s  Warm median: 7.2s

Results written to /home/ubuntu/bundler-bench/results/bundler27_20260318_161305.json
Results written to /home/ubuntu/bundler-bench/results/master_20260318_161305.json

=== Comparison Summary ===

Scenario: large (452 gems)
                             Cold     +/-                        Warm     +/-
  ------------------------------------------------------------------------------
  bundler27                51.34s   0.06s  baseline             8.90s   0.15s  baseline
  master                   16.02s   0.02s  3.21x faster         7.20s   0.03s  1.24x faster
</code></pre></div></div>

<p><em>Note these numbers will vary across macOS and Linux, as well as machines with endpoint security software. While we aimed to reduce many variables, you still may not see the same numbers, however times faster should be between 2-3.5x for cold and 1-1.5x for warm for bundle install. This script was run on an AWS Sandbox and therefore has no other traffic or endpoint security altering the numbers. It is also using the fake gemserver, so network round trips aren’t involved.</em></p>

<h3 id="using-the-profiling-script">Using the profiling script</h3>

<p>Benchmarks tell you whether something got faster. Profiles tell you why it’s slow. The profiling tool runs a single <code class="language-plaintext highlighter-rouge">bundle install</code> under <a href="https://github.com/jhawthorn/vernier" target="_blank">Vernier</a> or <a href="https://github.com/mstange/samply" target="_blank">samply</a> to produce flamegraphs.</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ruby profile_bundler.rb <span class="se">\</span>
  <span class="nt">--run</span> master:bundler:~/rubygems <span class="se">\</span>
  <span class="nt">--scenario</span> rails <span class="se">\</span>
  <span class="nt">--mode</span> warm <span class="se">\</span>
  <span class="nt">--profiler</span> vernier
</code></pre></div></div>

<p>It supports both cold and warm modes so you can profile the specific phase you’re investigating. Profiles are written to <code class="language-plaintext highlighter-rouge">profiles/</code> with filenames that include the label, scenario, mode, platform, and timestamp so you can compare across runs and machines.</p>

<p>Here’s an example of the Vernier output for the <code class="language-plaintext highlighter-rouge">master</code> branch on the AWS linux sandbox for the cold cache mode.</p>

<figure><img src="./vernier-linux-cold-bundler-master.png" alt="Vernier flamegraph for cold bundle install on Linux"><figcaption>Vernier flamegraph for cold bundle install on Linux</figcaption></figure>

<p><em>See all the yellow bars? Those are native extensions compiling and blocking the threads from doing other work.</em></p>

<h3 id="setup-scripts">Setup scripts</h3>

<p>Reproducing someone else’s benchmark results is only possible if you’re starting from the same place. The repository includes setup scripts for both macos and linux which will install Ruby, hyperfine, profiling tools, and clone the repos you need:</p>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>./setup-benchmark-mac.sh <span class="nt">--tools</span> bundler,bundler27,gel,scint
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">--tools</code> flag lets you pick which package managers or versions to install. It defaults to <code class="language-plaintext highlighter-rouge">bundler,bundler27</code> so you can compare the current master against the last stable release without extra setup. We wanted anyone on the team (or in the community) to be able to spin up a fresh machine and get comparable results without hunting down the right Ruby version, compiler flags, or repo branches.</p>

<h2 id="what-we-learned">What we learned</h2>

<p>A shared benchmark is a source of truth. When someone says “my change is faster” and someone else disagrees, you need a neutral tool that everyone agreed on beforehand. Without that, performance discussions turn into competing anecdotes. The toolkit gives us a way to settle those disagreements with data instead of intuition.</p>

<p>Reproducibility matters just as much. If you can’t reproduce similar results on someone else’s machine, you can’t verify the claims. “It’s faster on my machine” isn’t useful if it’s not faster for everyone, or worse faster on Linux but slower on macOS. When results differ across machines, we can start asking why instead of arguing about whether.</p>

<p>Back in 2018 I gave a talk called <a href="https://www.youtube.com/watch?v=oT74HLvDo_A" target="_blank">How to Performance</a> which was on the surface about how I sped up integration tests in Rails, but really it was a talk about how to write benchmarks you could trust so you know when you actually made something faster. Many of the lessons I learned back then came up again during this project.</p>

<p>Profiles and guesswork are only one part of the equation. A profile can show you a hot spot, and you can write a fix that looks faster, but without a proper benchmark you don’t actually know. You don’t know if the gain on macOS is a regression on Linux. You don’t know if “cold” got faster but “warm” got slower. You don’t know if the improvement holds across different Gemfile sizes. The benchmark is what turns a hypothesis into evidence.</p>

<p>This matters even more now than it did in 2018. Engineering rigor is more important in the AI world than it was before. It’s easy to generate output that looks correct or looks faster. It’s easy to make a benchmark that looks reasonable but cheats on the warm caches. AI is good at producing plausible code and plausible explanations. Humans are good at critical thinking and using our gut to know when something doesn’t look right. We have taste, discernment, and scrutiny, AI has data.</p>

<p>That’s not a dig on AI. I used Claude extensively throughout this project. It was great at writing the setup scripts, which are uninteresting and error prone, and it wrote the original benchmark tool. But it also got things wrong. The warm cache bug I mentioned earlier? Claude missed setting <code class="language-plaintext highlighter-rouge">BUNDLE_USER_HOME</code> in the environment, which meant Bundler was writing to the system bundle home instead of the isolated one. Warm caches on the master branch looked broken because they were being shared across runs. I spent time debugging Bundler before I realized the benchmark itself was wrong. Claude didn’t catch it because it doesn’t have the deep institutional knowledge of how Bundler’s cache layers interact. I caught it because I knew what the numbers should look like and they didn’t add up.</p>

<p>That’s not a reason to stop using AI. It’s a reminder to not outsource our thinking and to always test our assumptions. Applying engineering rigor is how we can be sure the work we’re doing, whether it’s us or AI doing it, is valid and achieves our goals.</p>

<p>The toolkit is available at <a href="https://github.com/eileencodes/bundler-perf-toolkit" target="_blank">bundler-perf-toolkit</a>. If you’re working on Bundler performance or just curious about how your Gemfile affects install times, give it a try. We welcome PRs with new scenarios, corrections to cache handling if you spot something we got wrong, and support for other tools to test against.</p>
</body></html>]]></content><author><name>[&quot;Eileen Alayce&quot;]</name></author><category term="posts" /><category term="2026-03-18-engineering-rigor-in-the-ai-age-building-a-benchmark-you-can-trust" /><summary type="html"><![CDATA[The Rails Infrastructure team built an open-source benchmarking toolkit to reliably measure Bundler performance improvements, and along the way learned that AI is great for scaffolding tools but engineering rigor — trusting your gut when numbers don't add up — is something you can't outsource.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-03-18-engineering-rigor-in-the-ai-age-building-a-benchmark-you-can-trust/755f034da9ce9efc4494dca56129e6586b81262c.png" /><media:content medium="image" url="https://railsatscale.com/2026-03-18-engineering-rigor-in-the-ai-age-building-a-benchmark-you-can-trust/755f034da9ce9efc4494dca56129e6586b81262c.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">How ZJIT removes redundant object loads and stores</title><link href="https://railsatscale.com/2026-03-18-how-zjit-removes-redundant-object-loads-and-stores/" rel="alternate" type="text/html" title="How ZJIT removes redundant object loads and stores" /><published>2026-03-18T00:00:00+00:00</published><updated>2026-03-18T00:00:00+00:00</updated><id>https://railsatscale.com/2026-03-18-how-zjit-removes-redundant-object-loads-and-stores/</id><content type="html" xml:base="https://railsatscale.com/2026-03-18-how-zjit-removes-redundant-object-loads-and-stores/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<h2 id="intro">Intro</h2>
<p>Since the <a href="https://railsatscale.com/2025-12-24-launch-zjit/">post</a> at the end of last year, ZJIT has grown and
changed in some exciting ways. This is the story of how a new, self-contained
optimization pass causes ZJIT performance to surpass YJIT on an interesting
<a href="https://rubybench.github.io/benchmarks/ruby-bench.html#setivar">microbenchmark</a>. It has been 10 months since ZJIT was merged
into Ruby, and we’re now beginning to see the design differences between YJIT
and ZJIT manifest themselves in performance divergences. In this post, we will
explore the details of one new optimization in ZJIT called load-store
optimization. This implementation is part of ZJIT’s optimizer in HIR. Recall
that the structure of ZJIT looks roughly like the following.</p>

<pre><code class="language-mermaid">flowchart LR
        A(["Ruby"])
        A --&gt; B(["YARV"])
        B --&gt; C(["HIR"])
        C --&gt; D(["LIR"])
        D --&gt; E(["Assembly"])
</code></pre>

<p>This post will focus on optimization passes in HIR, or “High-level” Intermediate
Representation. At the HIR level, we have two capabilities that are distinct
from other compilation stages. Our optimizations in HIR typically utilize the
benefits of our <a href="https://bernsteinbear.com/blog/ssa/">SSA</a> representation in addition to the HIR
instruction effect system.</p>

<p>These are the current analysis passes in ZJIT without load-store optimization,
as well as the order in which the passes are executed.</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">run_pass!</span><span class="p">(</span><span class="n">type_specialize</span><span class="p">);</span>
<span class="nd">run_pass!</span><span class="p">(</span><span class="n">inline</span><span class="p">);</span>
<span class="nd">run_pass!</span><span class="p">(</span><span class="n">optimize_getivar</span><span class="p">);</span>
<span class="nd">run_pass!</span><span class="p">(</span><span class="n">optimize_c_calls</span><span class="p">);</span>
<span class="nd">run_pass!</span><span class="p">(</span><span class="n">fold_constants</span><span class="p">);</span>
<span class="nd">run_pass!</span><span class="p">(</span><span class="n">clean_cfg</span><span class="p">);</span>
<span class="nd">run_pass!</span><span class="p">(</span><span class="n">remove_redundant_patch_points</span><span class="p">);</span>
<span class="nd">run_pass!</span><span class="p">(</span><span class="n">eliminate_dead_code</span><span class="p">);</span>
</code></pre></div></div>

<p>Here’s where load-store optimization gets added.</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  run_pass!(type_specialize);
  run_pass!(inline);
  run_pass!(optimize_getivar);
  run_pass!(optimize_c_calls);
<span class="gi">+ run_pass!(optimize_load_store);
</span>  run_pass!(fold_constants);
  run_pass!(clean_cfg);
  run_pass!(remove_redundant_patch_points);
  run_pass!(eliminate_dead_code);
</code></pre></div></div>

<h2 id="overview">Overview</h2>
<p>Ruby is an object-oriented programming language, so CRuby needs to have some
notion of object loads, modifications, and stores. In fact, this is a topic
already covered by another Rails at Scale <a href="https://railsatscale.com/2023-10-24-memoization-pattern-and-object-shapes/">blog post</a>. The shape
system provides performance improvements in CRuby (both interpreter and JIT),
but there is still plenty of opportunity to improve JIT performance. Sometimes
optimizing interpreter opcodes one at a time leaves repeated loads or stores
that can be cleaned up with a program analysis optimization pass. Before getting
into the weeds about this pass, let’s talk performance.</p>

<h3 id="results">Results</h3>
<p>The <code class="language-plaintext highlighter-rouge">setivar</code> <a href="https://rubybench.github.io/benchmarks/ruby-bench.html#setivar">benchmark</a> for ZJIT changes dramatically on
2026-03-06. This is when load-store optimization landed in ZJIT. At the time of
this writing, ZJIT takes an average of <code class="language-plaintext highlighter-rouge">2ms</code> per iteration on this benchmark,
while YJIT takes an average of <code class="language-plaintext highlighter-rouge">5ms</code>.</p>

<figure><img src="benchmark.png" alt='This graph shows ZJIT (yellow) and YJIT (green) as "times faster than interpreter" (blue). You can see the moment where load-store optimization is implemented and ZJIT overtakes YJIT.'><figcaption>This graph shows ZJIT (yellow) and YJIT (green) as "times faster than interpreter" (blue). You can see the moment where load-store optimization is implemented and ZJIT overtakes YJIT.</figcaption></figure>

<p>This is the second time that ZJIT has clearly surpassed YJIT. The first example
is <a href="https://rubybench.github.io/benchmarks/ruby-bench.html#object-new">here</a>.</p>

<p>At a high level, this means that ZJIT is over twice as fast as YJIT for repeated
instance variable assignment, and more than <strong>25 times</strong> faster than the
interpreter!</p>

<h3 id="a-troubling-development">A Troubling Development</h3>
<p>However, there’s an important question we have to address - why should an
optimization pass for object loads and stores have anything to do with instance
variable assignment? It turns out that ZJIT’s High Intermediate Representation
(HIR) uses <code class="language-plaintext highlighter-rouge">LoadField</code> and <code class="language-plaintext highlighter-rouge">StoreField</code> instructions both for both object
instance variables, and for object shapes. We’re going to have to dig deeper
into CRuby shapes and ZJIT HIR internals in order to make sense of this.</p>

<h3 id="background">Background</h3>
<p>So far, we’ve learned that HIR has <code class="language-plaintext highlighter-rouge">LoadField</code> and <code class="language-plaintext highlighter-rouge">StoreField</code> instructions.
We’ve claimed that they are multi-purpose and that the performance wins come
from optimizing object shapes, but that they can also apply to object instance
variables. Because the algorithm works just as well for both situations, the
rest of this post will focus on object instance variables. This allows us to
demonstrate concepts in pure Ruby to make things more approachable.</p>

<h4 id="example">Example</h4>
<p>Let’s start with a simple example we can all agree on. Clearly this code
snippet has a double store, and we can safely remove one of the <code class="language-plaintext highlighter-rouge">@a = value</code>
calls.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">C</span>
  <span class="k">def</span> <span class="nf">initialize</span>
    <span class="n">value</span> <span class="o">=</span> <span class="mi">1</span>
    <span class="vi">@a</span> <span class="o">=</span> <span class="n">value</span>
    <span class="vi">@a</span> <span class="o">=</span> <span class="n">value</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Here’s the same code snippet with an example of the call we remove. Here, we
have elided a redundant <code class="language-plaintext highlighter-rouge">StoreField</code> instruction.</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  class C
    def initialize
      value = 1
      @a = value
<span class="gd">-     @a = value
</span>    end
  end
</code></pre></div></div>

<p>When should we remove <code class="language-plaintext highlighter-rouge">LoadField</code> and <code class="language-plaintext highlighter-rouge">StoreField</code> instructions? The HIR code
snippets will come later. For now, we only need to know the mapping between Ruby
and HIR for instance variable loads and stores.</p>

<table>
  <thead>
    <tr>
      <th>Ruby</th>
      <th>HIR</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">@var = value</code></td>
      <td><code class="language-plaintext highlighter-rouge">StoreField var, @obj@offset, value</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">@var</code></td>
      <td><code class="language-plaintext highlighter-rouge">LoadField var, @obj@offset</code></td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p>Note: In a class’s <code class="language-plaintext highlighter-rouge">initialize</code> method, instance variable operations are
likely to cause <code class="language-plaintext highlighter-rouge">LoadField</code> and <code class="language-plaintext highlighter-rouge">StoreField</code> instructions due to shape
transitions. Outside of an initialize method, the loads and stores are more
likely to be related to the instance variables themselves. We decided that
more complicated Ruby code snippets would clarify the kind of <code class="language-plaintext highlighter-rouge">LoadField</code> or
<code class="language-plaintext highlighter-rouge">StoreField</code> but overly clutter the code snippets in this post.</p>
</blockquote>

<h4 id="cases">Cases</h4>
<p>Let’s consider every edge case for our algorithm through short Ruby snippets
to illustrate scenarios where we can and cannot elide <code class="language-plaintext highlighter-rouge">LoadField</code> or
<code class="language-plaintext highlighter-rouge">StoreField</code> HIR instructions.</p>

<blockquote>
  <p>Note: The following examples could replace the <code class="language-plaintext highlighter-rouge">value</code> variable with the
constant <code class="language-plaintext highlighter-rouge">1</code>, but in ZJIT this could cause other optimizations such as
constant folding to interfere with our load-store demonstrations. We will use
these more complex code snippets in case the reader wants to follow along with
<a href="http://tryzjit.fly.dev/">a compiler explorer</a>.</p>
</blockquote>

<h5 id="redundant-store">Redundant Store</h5>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">C</span>
  <span class="k">def</span> <span class="nf">initialize</span>
    <span class="n">value</span> <span class="o">=</span> <span class="mi">1</span>
    <span class="vi">@a</span> <span class="o">=</span> <span class="n">value</span>
    <span class="c1"># This store is redundant and should be elided in HIR</span>
    <span class="vi">@a</span> <span class="o">=</span> <span class="n">value</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h5 id="redundant-load">Redundant Load</h5>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">C</span>
  <span class="k">def</span> <span class="nf">initialize</span>
    <span class="n">value</span> <span class="o">=</span> <span class="mi">1</span>
    <span class="vi">@a</span> <span class="o">=</span> <span class="n">value</span>
    <span class="c1"># We already know that this load is `value` and should be replaced</span>
    <span class="vi">@a</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<h5 id="redundant-store-with-aliasing">Redundant Store with Aliasing</h5>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">C</span>
  <span class="nb">attr_accessor</span> <span class="ss">:a</span>

  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
    <span class="vi">@a</span> <span class="o">=</span> <span class="n">value</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="k">class</span> <span class="nc">D</span>
  <span class="nb">attr_accessor</span> <span class="ss">:a</span>

  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
    <span class="vi">@a</span> <span class="o">=</span> <span class="n">value</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">multi_object_test</span>
  <span class="n">x</span> <span class="o">=</span> <span class="no">C</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
  <span class="n">y</span> <span class="o">=</span> <span class="no">D</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
  <span class="n">new_x_val</span> <span class="o">=</span> <span class="mi">2</span>
  <span class="n">new_y_val</span> <span class="o">=</span> <span class="mi">3</span>
  <span class="n">x</span><span class="p">.</span><span class="nf">a</span> <span class="o">=</span> <span class="n">new_x_val</span>
  <span class="n">y</span><span class="p">.</span><span class="nf">a</span> <span class="o">=</span> <span class="n">new_y_val</span>
  <span class="c1"># We would like to elide this (but currently do not)</span>
  <span class="n">x</span><span class="p">.</span><span class="nf">a</span> <span class="o">=</span> <span class="n">new_x_val</span>
<span class="k">end</span>
</code></pre></div></div>
<p>With variables pointing to distinct objects, we could elide the second store to
object <code class="language-plaintext highlighter-rouge">x</code>. This is not currently implemented, but is a possible improvement
with a technique called <a href="https://bernsteinbear.com/blog/toy-tbaa/">type-based alias analysis</a>.</p>

<h5 id="required-store-with-aliasing">Required Store with Aliasing</h5>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">C</span>
  <span class="nb">attr_accessor</span> <span class="ss">:a</span>

  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
    <span class="vi">@a</span> <span class="o">=</span> <span class="n">value</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">multi_object_test</span>
  <span class="n">x</span> <span class="o">=</span> <span class="no">C</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
  <span class="n">y</span> <span class="o">=</span> <span class="n">x</span>
  <span class="n">new_x_val</span> <span class="o">=</span> <span class="mi">2</span>
  <span class="n">new_y_val</span> <span class="o">=</span> <span class="mi">3</span>
  <span class="n">x</span><span class="p">.</span><span class="nf">a</span> <span class="o">=</span> <span class="n">new_x_val</span>
  <span class="n">y</span><span class="p">.</span><span class="nf">a</span> <span class="o">=</span> <span class="n">new_y_val</span>
  <span class="c1"># We should not elide the second `x.a` assignment because the `y.a` assignment modifies `x`</span>
  <span class="c1"># The `x.a` store after this comment is no longer redundant</span>
  <span class="n">x</span><span class="p">.</span><span class="nf">a</span> <span class="o">=</span> <span class="n">new_x_val</span>
<span class="k">end</span>
</code></pre></div></div>
<p>With multiple multiple variables aliasing to the same object, we cannot elide
the second store to <code class="language-plaintext highlighter-rouge">x</code>. While technically we could elide <code class="language-plaintext highlighter-rouge">y.a = new_y_val</code> and
the initial <code class="language-plaintext highlighter-rouge">y = x</code> assignment, these improvements are out of scope for this
post. The key point here is that aliasing needs to be considered. If we assume
that <code class="language-plaintext highlighter-rouge">y</code> and <code class="language-plaintext highlighter-rouge">x</code> reference different objects and elide the second
<code class="language-plaintext highlighter-rouge">x.a = new_x_val</code> call, we alter program behavior.</p>

<h5 id="required-store-with-effects">Required Store with Effects</h5>
<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">scary_method</span><span class="p">(</span><span class="n">obj</span><span class="p">)</span>
  <span class="n">obj</span><span class="p">.</span><span class="nf">a</span> <span class="o">=</span> <span class="s2">"We have modified the object. The second store is no longer redundant"</span>
<span class="k">end</span>

<span class="k">class</span> <span class="nc">C</span>
  <span class="nb">attr_accessor</span> <span class="ss">:a</span>

  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
    <span class="vi">@a</span> <span class="o">=</span> <span class="n">value</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">effectful_operations_between_stores_test</span>
  <span class="n">x</span> <span class="o">=</span> <span class="no">C</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
  <span class="n">x</span><span class="p">.</span><span class="nf">a</span> <span class="o">=</span> <span class="mi">5</span>
  <span class="n">scary_method</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
  <span class="c1"># We want to elide this but `scary_method` can modify `x`</span>
  <span class="n">x</span><span class="p">.</span><span class="nf">a</span> <span class="o">=</span> <span class="mi">5</span>
<span class="k">end</span>
</code></pre></div></div>
<p>In this case, the second store looks redundant, but it might not be. An
arbitrary Ruby method (or C call, or some HIR instructions) could modify the <code class="language-plaintext highlighter-rouge">x</code>
object and breaks the assumptions we can make about the state of the <code class="language-plaintext highlighter-rouge">x</code> object.
In such cases, we cannot perform load-store optimization.</p>

<h3 id="the-algorithm">The Algorithm</h3>

<h4 id="key-idea">Key Idea</h4>
<p>With these cases, we have covered everything needed to implement our load-store
optimization algorithm. The algorithm is a lightweight
<a href="https://en.wikipedia.org/wiki/Abstract_interpretation">abstract interpretation</a> over objects. This approach allows us to
minimize the computation required to perform our optimization pass while
ensuring soundness. In layperson’s terms, this means that every load we replace
and every store we eliminate will not change program behavior, but that we will
potentially miss some loads or stores that could be eliminated.</p>

<h4 id="tricky-details">Tricky Details</h4>

<h5 id="basic-blocks">Basic Blocks</h5>
<p>Our load-store optimization pass scans through basic blocks, searches for
redundant loads and stores, and updates the HIR instructions accordingly.
Unnecessary <code class="language-plaintext highlighter-rouge">StoreField</code> operations are elided, and unnecessary <code class="language-plaintext highlighter-rouge">LoadField</code>
operations are replaced with the instruction already holding the value. While
one key benefit of ZJIT is that it can optimize entire methods, load-store
optimization is (for now) block-local only.</p>

<h5 id="loadfield-and-storefield-distinctions">LoadField and StoreField Distinctions</h5>
<p>So far, we’ve talked about elision and instruction removal. We can get away with
deleting <code class="language-plaintext highlighter-rouge">StoreField</code> instructions because no other instructions point to
<code class="language-plaintext highlighter-rouge">StoreField</code> instructions. Conversely, <code class="language-plaintext highlighter-rouge">LoadField</code> instructions <em>do</em> have
dependencies and are referenced by other instructions. These references need to
be fixed up. Each reference to <code class="language-plaintext highlighter-rouge">LoadField</code> gets replaced with the cached value
that was the target of a load.</p>

<h5 id="the-writebarrier-instruction">The WriteBarrier Instruction</h5>
<p>ZJIT has <code class="language-plaintext highlighter-rouge">WriteBarrier</code> instructions to support garbage collection. These also
can modify objects and act similarly to stores. We need to handle this case in
our algorithm.</p>

<h5 id="pointer-intricacies">Pointer Intricacies</h5>
<p>The pseudo code we are about to introduce uses the term “offset” to denote the
number of bytes from the object’s base address in memory. We use this to
detect redundant loads and stores, as well as clear the cache from effectful
instructions and write barriers. However, it is not immediately obvious that
simply checking offsets would be enough. How can we be sure that the memory
regions we are tracking remain untouched by some other instruction? Fortunately,
HIR instructions <em>always</em> point to the base of an object and use offsets that
are in bounds of the object. If we have two offsets that are not equal, they
cannot reference the same region of memory. If the offsets are equal, then
object aliasing must be considered.</p>

<h4 id="algorithm-sketch">Algorithm Sketch</h4>
<p>Here’s the pseudo-code for a given basic block.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>For each HIR instruction in the basic block
    initialize an empty cache as a hashmap
    
    if instruction is `LoadField`
        check if the object, offset, and value triple is in the cache
        if so, delete instruction and replace references to it with the loaded value
        else, cache the loaded value with the object, offset pair as a key
        
    if instruction is `StoreField`
        check if the object, offset, and value triple is in the cache
        if so, delete the instruction
        else, remove each cache entry with the same offset (the flags field) to avoid aliasing issues
        
    if instruction is `WriteBarrier`
        # This instruction is needed for the garbage collector and is complex
        # It works similarly to `StoreField` in practice
        # This instruction is never removed but the cache cleaning is still needed
        remove each cache entry with the same offset to avoid aliasing issues
        
    if instruction can modify objects
        flush the cache
        
    else
        continue
          
return the pruned HIR instructions
</code></pre></div></div>

<h4 id="source-code">Source Code</h4>
<p>The source at the time of this writing can be found <a href="https://github.com/ruby/ruby/blob/a47827c854fe94b2a582e994c0ea2ff239439267/zjit/src/hir.rs#L4952">here</a>.</p>

<h3 id="hir-improvements">HIR Improvements</h3>
<p>After the optimization, here are examples of how the HIR changes.</p>

<p>This the new HIR for our first redundant load example.</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  fn initialize@../scripts/double_load.rb:3:
  bb1():
    EntryPoint interpreter
    v1:BasicObject = LoadSelf
    v2:NilClass = Const Value(nil)
    Jump bb3(v1, v2)
  bb2():
    EntryPoint JIT(0)
    v5:BasicObject = LoadArg :self@0
    v6:NilClass = Const Value(nil)
    Jump bb3(v5, v6)
  bb3(v8:BasicObject, v9:NilClass):
    v13:Fixnum[1] = Const Value(1)
    PatchPoint SingleRactorMode
    v30:HeapBasicObject = GuardType v8, HeapBasicObject
    v31:CShape = LoadField v30, :_shape_id@0x4
    v32:CShape[0x80000] = GuardBitEquals v31, CShape(0x80000)
    StoreField v30, :@a@0x10, v13
    WriteBarrier v30, v13
    v35:CShape[0x80008] = Const CShape(0x80008)
    StoreField v30, :_shape_id@0x4, v35
<span class="gd">-   v20:HeapBasicObject = RefineType v8, HeapBasicObject
</span>    PatchPoint SingleRactorMode
<span class="gd">-   v38:CShape = LoadField v20, :_shape_id@0x4
-   v39:CShape[0x80008] = GuardBitEquals v38, CShape(0x80008)
-   v40:BasicObject = LoadField v20, :@a@0x10
</span>    CheckInterrupts
<span class="gd">-   Return v40
</span><span class="gi">+   Return v13
</span></code></pre></div></div>
<p>This the new HIR for our first redundant store example.</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">bb1():
</span>  EntryPoint interpreter
  v1:BasicObject = LoadSelf
  v2:NilClass = Const Value(nil)
  Jump bb3(v1, v2)
<span class="p">bb2():
</span>  EntryPoint JIT(0)
  v5:BasicObject = LoadArg :self@0
  v6:NilClass = Const Value(nil)
  Jump bb3(v5, v6)
<span class="p">bb3(v8:BasicObject, v9:NilClass):
</span>  v13:Fixnum[1] = Const Value(1)
  PatchPoint SingleRactorMode
  v35:HeapBasicObject = GuardType v8, HeapBasicObject
  v36:CShape = LoadField v35, :_shape_id@0x4
  v37:CShape[0x80000] = GuardBitEquals v36, CShape(0x80000)
  StoreField v35, :@a@0x10, v13
  WriteBarrier v35, v13
  v40:CShape[0x80008] = Const CShape(0x80008)
  StoreField v35, :_shape_id@0x4, v40
  v20:HeapBasicObject = RefineType v8, HeapBasicObject
  PatchPoint NoEPEscape(initialize)
  PatchPoint SingleRactorMode
<span class="gd">- v43:CShape = LoadField v20, :_shape_id@0x4
- v44:CShape[0x80008] = GuardBitEquals v43, CShape(0x80008)
- StoreField v20, :@a@0x10, v13
</span>  WriteBarrier v20, v13
  CheckInterrupts
  Return v13
</code></pre></div></div>

<p>And that’s load-store optimization!</p>

<h3 id="design-discussion">Design Discussion</h3>
<p>You may notice that our optimization is pruning the graph of loads and stores
on an object. We are solving a very similar problem to the SSA form baked into
the HIR. While it would be great to have “more SSA” at the object level, this
comes at a cost. Computing SSA at this level could necessitate structural
changes to HIR and make things less ergonomic or more confusing in regions of
the codebase outside of load-store optimization. In fact, this question of “more
SSA” is a complex design decision and contentious topic with a
<a href="https://en.wikipedia.org/wiki/Sea_of_nodes">rich</a> <a href="https://www.jikesrvm.org/JavaDoc/org/jikesrvm/compilers/opt/ssa/SSA.html">history</a> in compilers such as V8 or Jikes
RVM. So far, we’ve decided to use a lightweight SSA representation in ZJIT that
causes us to work a bit harder for certain optimization passes, yielding subtle
design simplifications across the rest of HIR.</p>

<h2 id="future-work">Future Work</h2>

<p>There’s still a lot of exciting work to be done and there are improvements to
be made before we hit diminishing returns. Dead store elimination utilizes many
of the same ideas and could help improve object initialization performance. We
could implement <a href="https://bernsteinbear.com/blog/toy-tbaa/">type based alias analysis</a>, though this
requires care, as <a href="https://phrack.org/issues/70/9#article">type confusion bugs</a> are quite
dangerous in JIT compilers. See section 4.1 in the phrack article for further
details.</p>

<h2 id="conclusion">Conclusion</h2>
<p>Thanks for reading the first post about ZJIT’s optimizer. We have lots more to
come, so stay tuned.</p>
</body></html>]]></content><author><name>Jacob Denbeaux</name></author><category term="posts" /><category term="2026-03-18-how-zjit-removes-redundant-object-loads-and-stores" /><summary type="html"><![CDATA[ZJIT's optimizer now removes redundant object loads and stores, improving JIT performance of CRuby's shape system. This post explains how the optimization works.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-03-18-how-zjit-removes-redundant-object-loads-and-stores/a8af7ce28b60c651dd883d4404993de7f3eb7c3d.png" /><media:content medium="image" url="https://railsatscale.com/2026-03-18-how-zjit-removes-redundant-object-loads-and-stores/a8af7ce28b60c651dd883d4404993de7f3eb7c3d.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Faster bundler</title><link href="https://railsatscale.com/2026-03-09-faster-bundler/" rel="alternate" type="text/html" title="Faster bundler" /><published>2026-03-09T00:00:00+00:00</published><updated>2026-03-09T00:00:00+00:00</updated><id>https://railsatscale.com/2026-03-09-faster-bundler/</id><content type="html" xml:base="https://railsatscale.com/2026-03-09-faster-bundler/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>At Shopify, we want our development environments to be fast.
Installing dependencies is slow, especially in an application as large as Shopify. <code class="language-plaintext highlighter-rouge">bun</code> and <code class="language-plaintext highlighter-rouge">uv</code> have dramatically
improved install times for TypeScript and Python dependencies. What if we could do the same for Bundler and the Ruby community?</p>

<p>Our team at Shopify has been working on a series of improvements to Bundler and RubyGems.
Bundler <strong>downloads gems up to 200% faster. Cloning git gems is now 3x faster</strong> in our monolith.</p>

<p>We were also able to <strong>decrease the overall <code class="language-plaintext highlighter-rouge">bundle install</code> time by 3.5x</strong> in one of our applications
by precompiling gems thanks to <a href="https://github.com/shopify/cibuildgem">cibuildgem</a>, a new
precompilation toolchain we’d love you to try!</p>

<p>Here’s an overview of the improvements we’ve made in the last few months:</p>

<h2 id="faster-gem-downloads">Faster gem downloads</h2>

<p>One impactful change was deceptively simple. Bundler’s HTTP fetcher had a connection pool size of 1.
This meant that during parallel gems installation, every thread was fighting over a single HTTP connection.</p>

<figure><img src="connection-pool.png" alt="A profile that shows the threads waiting for the only connection to be available"><figcaption>A profile that shows the threads waiting for the only connection to be available</figcaption></figure>
<blockquote>
  <p>Pink spikes are threads waiting for the connection to be available.</p>
</blockquote>

<p>By increasing the pool of HTTP connections, Bundler can download more gems in parallel.
The speed gain with this change is even more dramatic during peak hours when RubyGems.org is under heavy load,
or when you are geographically far from the CDN, where latency amplifies the cost of waiting on a single connection.</p>

<p>To benchmark this change, we opted to only measure download and extraction time (no compilation of native extensions)
and built a local gem server where we can control latency at our will.</p>

<p>This is the result in a freshly generated Rails application when all gems are served with a 100ms latency.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Scenario: rails (164 gems)
                             Cold     +/-                        Warm     +/-
  ------------------------------------------------------------------------------
  5 HTTP connections       5.86s   0.10s  baseline             4.17s   0.02s  baseline
  1 HTTP connection       19.80s   0.02s  237.6% slower        4.16s   0.02s  0.3% faster
</code></pre></div></div>

<h2 id="hotspots-and-optimizations">Hotspots and optimizations</h2>

<p>We regularly profile Bundler with different Gemfiles and identify hotspots we might optimize.
While no single optimization is dramatic, their collective impact has been significant.</p>

<p>One such optimization involves gem installation. A <code class="language-plaintext highlighter-rouge">.gem</code> file is a compressed tarball — and gzip has a built-in
integrity check: if decompression succeeds, the content is guaranteed to be intact. Despite this, RubyGems was
walking every entry in the tarball and reading all bytes upfront as an explicit corruption check, before proceeding with
installation. This redundant verification step was thrown away entirely, since a successful decompression already
provides the same guarantee.</p>

<figure><img src="verify-gz.png" alt="A profile that shows the time spent verifying the tarball's content"><figcaption>A profile that shows the time spent verifying the tarball's content</figcaption></figure>
<blockquote>
  <p>9-17% of the time installing a gem is spent verifying the tarball’s content.</p>
</blockquote>

<p>Another hotspot during installation is the check RubyGems performs to determine whether a gem
includes a RubyGems plugin and, if so, whether its plugin file needs to be regenerated. The vast majority of gems
don’t include a RubyGems plugin, yet every gem pays the cost of a <code class="language-plaintext highlighter-rouge">Dir.glob</code> with an expensive pattern just to
handle the small minority that do.</p>

<p>It turns out that unconditionally regenerating the plugin file is faster than performing this upfront check.</p>

<figure><img src="verify-plugin.png" alt="A profile that shows how frequently Bundler is spending time checking whether checking whether regenerating a gem plugin is required"><figcaption>A profile that shows how frequently Bundler is spending time checking whether checking whether regenerating a gem plugin is required</figcaption></figure>
<blockquote>
  <p>Bundler checking whether regenerating a gem plugin is required</p>
</blockquote>

<h2 id="parallel-git-clones">Parallel git clones</h2>

<p>Many Rails applications depend on gems sourced directly from git repositories. This is particularly useful if a gem
has upstream changes that aren’t yet released. Previously, Bundler would fetch each git repository sequentially,
even though there’s no technical limitation on fetching them all at once.</p>

<p>Shopify’s Core Rails monolith includes 33 git gems. After introducing this change to parallelize <code class="language-plaintext highlighter-rouge">git clone</code>,
we saw a 3x performance improvement for fetching git gems.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Bundler 2.7.2</th>
      <th>Bundler 4.0.7</th>
      <th>Performance improvement</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Fetching 33 git gems</td>
      <td>121.57s</td>
      <td>38.75s</td>
      <td>68% faster</td>
    </tr>
  </tbody>
</table>

<h2 id="native-extensions">Native extensions</h2>

<p>By far the biggest bottleneck when running <code class="language-plaintext highlighter-rouge">bundle install</code> is the compilation of native extensions.
Many gems in the Ruby ecosystem include C code that must be compiled on each developer’s machine when installed.
Common examples are <code class="language-plaintext highlighter-rouge">json</code>, <code class="language-plaintext highlighter-rouge">date</code>, and <code class="language-plaintext highlighter-rouge">bigdecimal</code>.
Even if your Gemfile doesn’t directly depend on native extensions, it’s likely they will be included in your
<code class="language-plaintext highlighter-rouge">Gemfile.lock</code> as transitive dependencies.</p>

<figure><img src="build-extension.png" alt="A profile that shows the time spent compiling a gem"><figcaption>A profile that shows the time spent compiling a gem</figcaption></figure>
<blockquote>
  <p>An installer thread spending 92% of the time compiling the gem.</p>
</blockquote>

<p>To illustrate how slow compilation is, we can run <code class="language-plaintext highlighter-rouge">bundle install</code> on a freshly generated Rails application.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th> </th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Total number of gems</strong></td>
      <td>126</td>
    </tr>
    <tr>
      <td><strong>Gems with native extensions</strong></td>
      <td>18</td>
    </tr>
    <tr>
      <td>
<strong>Time to <code class="language-plaintext highlighter-rouge">bundle install</code></strong><sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>
</td>
      <td>~13 seconds</td>
    </tr>
    <tr>
      <td><strong>Time to <code class="language-plaintext highlighter-rouge">bundle install</code> (without compilation)</strong></td>
      <td>~2 seconds (15%)</td>
    </tr>
    <tr>
      <td><strong>Time to <code class="language-plaintext highlighter-rouge">bundle install</code> (only native extensions)</strong></td>
      <td>~11 seconds (85%)</td>
    </tr>
  </tbody>
</table>

<p>Installing the 18 native extension gems accounts for 85% of the time spent running <code class="language-plaintext highlighter-rouge">bundle install</code>.</p>

<h2 id="precompiled-gems">Precompiled gems</h2>

<p>Remember when Nokogiri used to take forever to install? Those days are behind us thanks to the amazing work of its
maintainer, Mike Dalessio. Mike updated the gem’s publishing pipeline to precompile its native extensions into
platform-specific binaries and releases separate gems for each supported platform (macOS, Windows, Linux). Now Nokogiri
installs as fast as pure Ruby gems.</p>

<p>Imagine if we extended this to the rest of the Ruby ecosystem. <strong>If the community works together</strong> to ship precompiled
binaries for our most popular native-extension gems, everyone will benefit from a lightning-fast <code class="language-plaintext highlighter-rouge">bundle install</code>.</p>

<p>One way to build binary gems is with the popular <a href="https://github.com/rake-compiler/rake-compiler-dock">Rake-compiler-dock</a>
toolchain, which provides a cross-compilation environment and allows compilation to run inside Docker containers.
However, cross-compiling can be brittle and presents hard-to-debug issues. Compiling on the target platform is
ultimately far more reliable.</p>

<p>Many CI providers now offer free access to cloud machines. GitHub Actions, for example, is widely popular, and the
Ruby community has built many easy-to-use actions around it (e.g., <code class="language-plaintext highlighter-rouge">ruby/setup-ruby</code>). Could we apply the same approach
and leverage those machines to natively compile binary gems?</p>

<h2 id="introducing-cibuildgem">Introducing cibuildgem</h2>

<p>At Shopify, we wanted to build an easy-to-use tool to help developers release gems with precompiled binaries using
a native compilation approach via GitHub Workflows.</p>

<p><a href="https://github.com/Shopify/cibuildgem">cibuildgem</a> lets you generate a standard GitHub Actions workflow.
Once triggered, multiple jobs run to:</p>

<ol>
  <li>Compile the binaries and package the gems</li>
  <li>Run a matrix of test suites</li>
  <li>Verify the <code class="language-plaintext highlighter-rouge">.gem</code> files are not corrupted and installable</li>
  <li>Release the gems to RubyGems.org</li>
</ol>

<figure><img src="cibuildgem.png" alt="A screenshot of the GitHub workflow when cibuildgem is triggered"><figcaption>A screenshot of the GitHub workflow when cibuildgem is triggered</figcaption></figure>
<blockquote>
  <p>Releasing a binary gem with cibuildgem</p>
</blockquote>

<p>We aimed to make cibuildgem easy and fast to set up. Since many gems with native extensions are already configured with
Rake Compiler for development compilation, we chose to piggyback on that so cibuildgem can run without any extra
configuration for most gems.</p>

<p>The workflow generated by cibuildgem is intentionally standard.</p>
<ul>
  <li>Want to compile your gem on Linux AArch64? Add it to the
matrix.</li>
  <li>Want to trigger the workflow automatically when pushing a new
git tag? No problem — tweak it to your liking.</li>
</ul>

<p>We also wanted to ensure that the binaries compiled by cibuildgem would work in a macOS development environment and a
Linux production environment on a real Rails application.</p>

<p>As an <strong>experiment</strong>, we used <a href="https://github.com/shopify/cibuildgem">cibuildgem</a> to compile dozens of open-source gems and
publish them under a “namespace” on RubyGems.org (e.g., <code class="language-plaintext highlighter-rouge">sassc</code> -&gt; <code class="language-plaintext highlighter-rouge">precompiled-sassc</code>).</p>

<p>The goal was to see how much performance improvement we could get with precompiled binaries. To test this, we created a
<a href="https://github.com/shopify/precompiled_gems">Bundler plugin</a> that hijacked the Bundler resolver to download the gems
with precompiled binaries we had just published. For example, it would force-install <code class="language-plaintext highlighter-rouge">precompiled-json</code> if the <code class="language-plaintext highlighter-rouge">json</code>
gem was requested anywhere in the dependency tree.</p>

<p>We tested this and deployed on an internal application which included 235 gems. By precompiling 17 of them,
we saw a 3.5x performance improvement.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Without precompiled binaries</th>
      <th>With some precompiled binaries</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">bundle install</code></td>
      <td>24.2s</td>
      <td>7.0s (3.5x faster)<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>
</td>
    </tr>
  </tbody>
</table>

<p>This experiment demonstrates how much faster <code class="language-plaintext highlighter-rouge">bundle install</code> could be when gems are precompiled.
It has also given us confidence that cibuildgem builds compatible binaries for macOS and Linux.</p>

<p>In fact, a few gems at Shopify are now released with precompiled binaries (<a href="https://rubygems.org/gems/stack_frames">stack_frames</a>,
<a href="https://rubygems.org/gems/heap-profiler">heap_profiler</a>, <a href="https://rubygems.org/gems/rubydex">rubydex</a>) thanks to
cibuildgem.</p>

<p>If you maintain a gem with a native extension, we’d love for you to <a href="https://github.com/shopify/cibuildgem">give it a try</a>
and share your feedback ❤️!</p>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>Network speed and computation power affects those results. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
    </li>
    <li id="fn:2">
      <p>5 gems are still being compiled, we could decrease install time even further. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">↩</a></p>
    </li>
  </ol>
</div>
</body></html>]]></content><author><name>[&quot;Edouard Chin&quot;, &quot;Eileen Alayce&quot;]</name></author><category term="posts" /><category term="2026-03-09-faster-bundler" /><summary type="html"><![CDATA[How Shopify contributed a series of improvements to Bundler and RubyGems to make gem installation significantly faster.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-03-09-faster-bundler/5e11197b5102f0dbffc178dbfb80e6c5a543d578.png" /><media:content medium="image" url="https://railsatscale.com/2026-03-09-faster-bundler/5e11197b5102f0dbffc178dbfb80e6c5a543d578.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">ZJIT is now available in Ruby 4.0</title><link href="https://railsatscale.com/2025-12-24-launch-zjit/" rel="alternate" type="text/html" title="ZJIT is now available in Ruby 4.0" /><published>2025-12-24T00:00:00+00:00</published><updated>2025-12-24T00:00:00+00:00</updated><id>https://railsatscale.com/2025-12-24-launch-zjit/</id><content type="html" xml:base="https://railsatscale.com/2025-12-24-launch-zjit/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>ZJIT is a new just-in-time (JIT) Ruby compiler built into the reference Ruby
implementation, <a href="https://en.wikipedia.org/wiki/YARV">YARV</a>, by the same compiler group that brought you YJIT.
We (Aaron Patterson, Aiden Fox Ivey, Alan Wu, Jacob Denbeaux, Kevin Menard, Max
Bernstein, Maxime Chevalier-Boisvert, Randy Stauner, Stan Lo, and Takashi
Kokubun) have been working on ZJIT since the beginning of this year.</p>

<p>In case you missed the last post, we’re building a new compiler for Ruby
because we want to both raise the performance ceiling (bigger compilation unit
size and SSA IR) and encourage more outside contribution (by becoming a more
traditional method compiler).</p>

<p>It’s been a long time since we gave an official update on ZJIT. Things are
going well. We’re excited to share our progress with you. We’ve done a lot
<a href="/2025-05-14-merge-zjit/">since May</a>.</p>

<h2 id="in-brief">In brief</h2>

<p>ZJIT is compiled by default—but not enabled by default—in Ruby 4.0. Enable
it by passing the <code class="language-plaintext highlighter-rouge">--zjit</code> flag or the <code class="language-plaintext highlighter-rouge">RUBY_ZJIT_ENABLE</code> environment variable
or calling <code class="language-plaintext highlighter-rouge">RubyVM::ZJIT.enable</code> after starting your application.</p>

<p>It’s faster than the interpreter, but not yet as fast as YJIT. <strong>Yet.</strong> But we
have a plan, and we have some more specific numbers below. The TL;DR is we have
a great new foundation and now need to pull out all the Ruby-specific stops to
match YJIT.</p>

<p>We encourage you to experiment with ZJIT, but maybe hold off on deploying it in
production for now. This is a very new compiler. You should expect crashes and
wild performance degradations (or, perhaps, improvements). Please test locally,
try to run CI, etc, and let us know what you run into on <a href="https://bugs.ruby-lang.org/projects/ruby-master/issues?set_filter=1&amp;tracker_id=1">the Ruby issue
tracker</a> (or, if you don’t want to make a Ruby Bugs account, we would
also take reports <a href="https://github.com/Shopify/ruby/issues">on GitHub</a>).</p>

<h2 id="state-of-the-compiler">State of the compiler</h2>

<p>To underscore how much has happened since the <a href="/2025-05-14-merge-zjit/">announcement of being merged
into CRuby</a>, we present to you a series of comparisons:</p>

<h3 id="side-exits">Side-exits</h3>

<p>Back in May, we could not side-exit from JIT code into the interpreter. This
meant that the code we were running had to continue to have the same
preconditions (expected types, no method redefinitions, etc) or the JIT would
safely abort. <strong>Now,</strong> we can side-exit and use this feature liberally.</p>

<blockquote>
  <p>For example, we gracefully handle the phase transition from integer to string;
a guard instruction fails and transfers control to the interpreter.</p>

  <div class="language-ruby highlighter-rouge">
<div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">add</span> <span class="n">x</span><span class="p">,</span> <span class="n">y</span>
  <span class="n">x</span> <span class="o">+</span> <span class="n">y</span>
<span class="k">end</span>

<span class="n">add</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">4</span>
<span class="n">add</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">4</span>
<span class="n">add</span> <span class="mi">3</span><span class="p">,</span> <span class="mi">4</span>
<span class="n">add</span> <span class="s2">"three"</span><span class="p">,</span> <span class="s2">"four"</span>
</code></pre></div>  </div>
</blockquote>

<p>This enables running a lot more code!</p>

<h3 id="more-code">More code</h3>

<p>Back in May, we could only run a handful of small benchmarks. <strong>Now,</strong> we can
run all sorts of code, including passing the full Ruby test suite, the test
suite and shadow traffic of a large application at Shopify, and the test suite
of GitHub.com! Also a bank, apparently.</p>

<p>Back in May, we did not optimize much; we only really optimized operations
on fixnums (small integers) and method sends to the <code class="language-plaintext highlighter-rouge">main</code> object. <strong>Now,</strong>
we optimize a lot more: all sorts of method sends, instance variable reads
and writes, attribute accessor/reader/writer use, struct reads and writes,
object allocations, certain string operations, optional parameters, and more.</p>

<blockquote>
  <p>For example, we can <a href="https://en.wikipedia.org/wiki/Constant_folding">constant-fold</a> numeric operations. Because we also have a
(small, limited) inliner borrowed from YJIT, we can constant-fold the entirety
of <code class="language-plaintext highlighter-rouge">add</code> down to <code class="language-plaintext highlighter-rouge">3</code>—and still handle redefinitions of <code class="language-plaintext highlighter-rouge">one</code>, <code class="language-plaintext highlighter-rouge">two</code>,
<code class="language-plaintext highlighter-rouge">Integer#+</code>, …</p>

  <div class="language-ruby highlighter-rouge">
<div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">one</span>
  <span class="mi">1</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">two</span>
  <span class="mi">2</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">add</span>
  <span class="n">one</span> <span class="o">+</span> <span class="n">two</span>
<span class="k">end</span>
</code></pre></div>  </div>
</blockquote>

<h3 id="register-spilling">Register spilling</h3>

<p>Back in May, we could not compile many large functions due to limitations of
our backend that we borrowed from YJIT. <strong>Now,</strong> we can compile absolutely
enormous functions just fine. And quickly, too. Though we have not been
focusing specifically on compiler performance, we compile even large methods in
under a millisecond.</p>

<h3 id="c-methods">C methods</h3>

<p>Back in May, we could not even optimize calls to built-in C methods. <strong>Now,</strong>
we have a feature similar to JavaScriptCore’s DOMJIT, which allows us to emit
inline HIR versions of certain well-known C methods. This allows the optimizer
to reason about these methods and their effects (more on this in a future post)
much more… er, effectively.</p>

<blockquote>
  <p>For example, <code class="language-plaintext highlighter-rouge">Integer#succ</code>, which is defined as adding <code class="language-plaintext highlighter-rouge">1</code> to an integer, is a
C method. It’s used in <code class="language-plaintext highlighter-rouge">Integer#times</code> to drive the <code class="language-plaintext highlighter-rouge">while</code> loop. Instead of
emitting a call to it, our C method “inliner” can emit our existing <code class="language-plaintext highlighter-rouge">FixnumAdd</code>
instruction and take advantage of the rest of the type inference and
constant-folding.</p>

  <div class="language-rust highlighter-rouge">
<div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">inline_integer_succ</span><span class="p">(</span><span class="n">fun</span><span class="p">:</span> <span class="o">&amp;</span><span class="k">mut</span> <span class="nn">hir</span><span class="p">::</span><span class="n">Function</span><span class="p">,</span>
                       <span class="n">block</span><span class="p">:</span> <span class="nn">hir</span><span class="p">::</span><span class="n">BlockId</span><span class="p">,</span>
                       <span class="n">recv</span><span class="p">:</span> <span class="nn">hir</span><span class="p">::</span><span class="n">InsnId</span><span class="p">,</span>
                       <span class="n">args</span><span class="p">:</span> <span class="o">&amp;</span><span class="p">[</span><span class="nn">hir</span><span class="p">::</span><span class="n">InsnId</span><span class="p">],</span>
                       <span class="n">state</span><span class="p">:</span> <span class="nn">hir</span><span class="p">::</span><span class="n">InsnId</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="nb">Option</span><span class="o">&lt;</span><span class="nn">hir</span><span class="p">::</span><span class="n">InsnId</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="k">if</span> <span class="o">!</span><span class="n">args</span><span class="nf">.is_empty</span><span class="p">()</span> <span class="p">{</span> <span class="k">return</span> <span class="nb">None</span><span class="p">;</span> <span class="p">}</span>
    <span class="k">if</span> <span class="n">fun</span><span class="nf">.likely_a</span><span class="p">(</span><span class="n">recv</span><span class="p">,</span> <span class="nn">types</span><span class="p">::</span><span class="n">Fixnum</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">let</span> <span class="n">left</span> <span class="o">=</span> <span class="n">fun</span><span class="nf">.coerce_to</span><span class="p">(</span><span class="n">block</span><span class="p">,</span> <span class="n">recv</span><span class="p">,</span> <span class="nn">types</span><span class="p">::</span><span class="n">Fixnum</span><span class="p">,</span> <span class="n">state</span><span class="p">);</span>
        <span class="k">let</span> <span class="n">right</span> <span class="o">=</span> <span class="n">fun</span><span class="nf">.push_insn</span><span class="p">(</span><span class="n">block</span><span class="p">,</span> <span class="nn">hir</span><span class="p">::</span><span class="nn">Insn</span><span class="p">::</span><span class="n">Const</span> <span class="p">{</span> <span class="n">val</span><span class="p">:</span> <span class="nn">hir</span><span class="p">::</span><span class="nn">Const</span><span class="p">::</span><span class="nf">Value</span><span class="p">(</span><span class="nn">VALUE</span><span class="p">::</span><span class="nf">fixnum_from_usize</span><span class="p">(</span><span class="mi">1</span><span class="p">))</span> <span class="p">});</span>
        <span class="k">let</span> <span class="n">result</span> <span class="o">=</span> <span class="n">fun</span><span class="nf">.push_insn</span><span class="p">(</span><span class="n">block</span><span class="p">,</span> <span class="nn">hir</span><span class="p">::</span><span class="nn">Insn</span><span class="p">::</span><span class="n">FixnumAdd</span> <span class="p">{</span> <span class="n">left</span><span class="p">,</span> <span class="n">right</span><span class="p">,</span> <span class="n">state</span> <span class="p">});</span>
        <span class="k">return</span> <span class="nf">Some</span><span class="p">(</span><span class="n">result</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="nb">None</span>
<span class="p">}</span>
</code></pre></div>  </div>
</blockquote>

<h3 id="fewer-c-calls">Fewer C calls</h3>

<p>Back in May, the machine code ZJIT generated called a lot of C functions from
the CRuby runtime to implement our HIR instructions in LIR. We have pared this
down significantly and now “open code” the implementations in LIR.</p>

<blockquote>
  <p>For example, <code class="language-plaintext highlighter-rouge">GuardNotFrozen</code> used to call out to <code class="language-plaintext highlighter-rouge">rb_obj_frozen_p</code>. Now, it
requires that its input is a heap-allocated object and can instead do a load, a
test, and a conditional jump.</p>

  <div class="language-rust highlighter-rouge">
<div class="highlight"><pre class="highlight"><code><span class="k">fn</span> <span class="nf">gen_guard_not_frozen</span><span class="p">(</span><span class="n">jit</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">JITState</span><span class="p">,</span>
                        <span class="n">asm</span><span class="p">:</span> <span class="o">&amp;</span><span class="k">mut</span> <span class="n">Assembler</span><span class="p">,</span>
                        <span class="n">recv</span><span class="p">:</span> <span class="n">Opnd</span><span class="p">,</span>
                        <span class="n">state</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">FrameState</span><span class="p">)</span> <span class="k">-&gt;</span> <span class="n">Opnd</span> <span class="p">{</span>
    <span class="k">let</span> <span class="n">recv</span> <span class="o">=</span> <span class="n">asm</span><span class="nf">.load</span><span class="p">(</span><span class="n">recv</span><span class="p">);</span>
    <span class="c1">// It's a heap object, so check the frozen flag</span>
    <span class="k">let</span> <span class="n">flags</span> <span class="o">=</span> <span class="n">asm</span><span class="nf">.load</span><span class="p">(</span><span class="nn">Opnd</span><span class="p">::</span><span class="nf">mem</span><span class="p">(</span><span class="mi">64</span><span class="p">,</span> <span class="n">recv</span><span class="p">,</span> <span class="n">RUBY_OFFSET_RBASIC_FLAGS</span><span class="p">));</span>
    <span class="n">asm</span><span class="nf">.test</span><span class="p">(</span><span class="n">flags</span><span class="p">,</span> <span class="p">(</span><span class="n">RUBY_FL_FREEZE</span> <span class="k">as</span> <span class="nb">u64</span><span class="p">)</span><span class="nf">.into</span><span class="p">());</span>
    <span class="c1">// Side-exit if frozen</span>
    <span class="n">asm</span><span class="nf">.jnz</span><span class="p">(</span><span class="nf">side_exit</span><span class="p">(</span><span class="n">jit</span><span class="p">,</span> <span class="n">state</span><span class="p">,</span> <span class="n">GuardNotFrozen</span><span class="p">));</span>
    <span class="n">recv</span>
<span class="p">}</span>
</code></pre></div>  </div>
</blockquote>

<h3 id="more-teammates">More teammates</h3>

<p>Back in May, we had four people working full-time on the compiler. <strong>Now,</strong> we
have more internally at Shopify—and also more from the community! We have
had several interested people reach out, learn about ZJIT, and successfully
land complex changes. For this reason, we have opened up <a href="https://zjit.zulipchat.com">a chat
room</a> to discuss and improve ZJIT.</p>

<h3 id="a-cool-graph-visualization-tool">A cool graph visualization tool</h3>

<p>You <em>have to</em> check out our intern Aiden’s <a href="/2025-11-19-adding-iongraph-support/">integration of Iongraph into
ZJIT</a>. Now we have clickable, zoomable,
scrollable graphs of all our functions and all our optimization passes. It’s
great!</p>

<p>Try zooming (Ctrl-scroll), clicking the different optimization passes on the
left, clicking the instruction IDs in each basic block (definitions and uses),
and seeing how the IR for the below Ruby code changes over time.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Point</span>
  <span class="nb">attr_accessor</span> <span class="ss">:x</span><span class="p">,</span> <span class="ss">:y</span>
  <span class="k">def</span> <span class="nf">initialize</span> <span class="n">x</span><span class="p">,</span> <span class="n">y</span>
    <span class="vi">@x</span> <span class="o">=</span> <span class="n">x</span>
    <span class="vi">@y</span> <span class="o">=</span> <span class="n">y</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="no">P</span> <span class="o">=</span> <span class="no">Point</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="mi">4</span><span class="p">).</span><span class="nf">freeze</span>

<span class="k">def</span> <span class="nf">test</span> <span class="o">=</span> <span class="no">P</span><span class="p">.</span><span class="nf">x</span> <span class="o">+</span> <span class="no">P</span><span class="p">.</span><span class="nf">y</span>
</code></pre></div></div>

<iframe title="Iongraph Viewer" aria-label="Interactive compiler graph visualization" src="viewer.html" width="100%" height="400"></iframe>

<h3 id="more">More</h3>

<p>…and so, so many garbage collection fixes.</p>

<p>There’s still a lot to do, though.</p>

<h2 id="to-do">To do</h2>

<p>We’re going to optimize <code class="language-plaintext highlighter-rouge">invokeblock</code> (<code class="language-plaintext highlighter-rouge">yield</code>) and <code class="language-plaintext highlighter-rouge">invokesuper</code> (<code class="language-plaintext highlighter-rouge">super</code>)
instructions, each of which behaves similarly, but not identically, to a
normal <code class="language-plaintext highlighter-rouge">send</code> instruction. These are pretty common.</p>

<p>We’re going to optimize <code class="language-plaintext highlighter-rouge">setinstancevariable</code> in the case where we have to
transition the object’s shape. This will help normal <code class="language-plaintext highlighter-rouge">@a = b</code> situations. It
will also help <code class="language-plaintext highlighter-rouge">@a ||= b</code>, but I think we can even do better with the latter
using some kind of value numbering.</p>

<p>We only optimize monomorphic calls right now—cases where a method send only
sees one class of receiver while being profiled. We’re going to optimize
polymorphic sends, too. Right now we’re laying the groundwork (a new register
allocator; see below) to make this much easier. It’s not as much of an
immediate focus, though, because most (high 80s, low 90s percent) of sends are
monomorphic. <!-- TODO throwback to Smalltalk-80 --></p>

<p>We’re in the middle of re-writing the register allocator after reading the
entire history of linear scan papers and several implementations. That will
unlock performance improvements and also allow us to make the IRs easier to
use.</p>

<p>We don’t handle phase changes particularly well yet; if your method call
patterns change significantly after your code has been compiled, we will
frequently side-exit into the interpreter. Instead, we would like to use these
side-exits as additional profile information and re-compile the function.</p>

<p>Right now we have a lot of traffic to the VM frame. JIT frame pushes are
reasonably fast, but with every effectful operation, we have to flush our local
variable state and stack state to the VM frame. The instances in which code
might want to read this reified frame state are rare: frame unwinding due to
exceptions, <code class="language-plaintext highlighter-rouge">Binding#local_variable_get</code>, etc. In the future, we will instead
defer writing this state until it needs to be read.</p>

<p>We only have a limited inliner that inlines constants, <code class="language-plaintext highlighter-rouge">self</code>, and parameters.
In the fullness of time, we will add a general-purpose method inlining
facility. This will allow us to reduce the amount of polymorphic sends, do some
branch folding, and reduce the amount of method sends.</p>

<p>We only support optimizing positional parameters, required keyword parameters,
and optional parameters right now but we will work on optimizing optional
keyword arguments as well. Most of this work is in marshaling the complex
Ruby calling convention into one coherent form that the JIT can understand.</p>

<h2 id="performance">Performance</h2>

<p>We have public performance numbers for a selection of macro- and
micro-benchmarks on <a href="https://rubybench.github.io/">rubybench</a>. Here is a screenshot of what those
per-benchmark graphs look like. The Y axis is speedup multiplier vs the
interpreter and the X axis is time. Higher is better:</p>

<figure><img src="benchmark.png" alt="A line chart of ZJIT performance on railsbench improving over time, passing
interpreter performance, catching up to YJIT"><figcaption>A line chart of ZJIT performance on railsbench improving over time, passing
interpreter performance, catching up to YJIT</figcaption></figure>

<p>You can see that we are improving performance on nearly all benchmarks over
time. Some of this comes from from optimizing in a similar way as YJIT does
today (e.g. specializing ivar reads and writes), and some of it is optimizing
in a way that takes advantage of ZJIT’s high-level IR (e.g. constant folding,
branch folding, more precise type inference).</p>

<p>We are using both raw time numbers and also our internal performance counters
(e.g. number of calls to C functions from generated code) to drive
optimization.</p>

<h2 id="try-it-out">Try it out</h2>

<p>While Ruby now ships with ZJIT compiled into the binary by default, it is not
<em>enabled</em> by default at run-time. Due to performance and stability, YJIT is
still the default compiler choice in Ruby 4.0.</p>

<p>If you want to run your test suite with ZJIT to see what happens, you
absolutely can. Enable it by passing the <code class="language-plaintext highlighter-rouge">--zjit</code> flag or the
<code class="language-plaintext highlighter-rouge">RUBY_ZJIT_ENABLE</code> environment variable or calling <code class="language-plaintext highlighter-rouge">RubyVM::ZJIT.enable</code> after
starting your application.</p>

<h2 id="on-yjit">On YJIT</h2>

<p>We devoted a lot of our resources this year to developing ZJIT. While we did
not spend much time on YJIT (outside of a great <a href="/2025-05-21-fast-allocations-in-ruby-3-5/">allocation speed
up</a>), YJIT isn’t going anywhere soon.</p>

<h2 id="thank-you">Thank you</h2>

<p>This compiler was made possible by contributions to your <del>PBS station</del> open
source project from programmers like you. Thank you!</p>

<ul>
  <li>Aaron Patterson</li>
  <li>Abrar Habib</li>
  <li>Aiden Fox Ivey</li>
  <li>Alan Wu</li>
  <li>Alex Rocha</li>
  <li>André Luiz Tiago Soares</li>
  <li>Benoit Daloze</li>
  <li>Charlotte Wen</li>
  <li>Daniel Colson</li>
  <li>Donghee Na</li>
  <li>Eileen Uchitelle</li>
  <li>Étienne Barrié</li>
  <li>Godfrey Chan</li>
  <li>Goshanraj Govindaraj</li>
  <li>Hiroshi SHIBATA</li>
  <li>Hoa Nguyen</li>
  <li>Jacob Denbeaux</li>
  <li>Jean Boussier</li>
  <li>Jeremy Evans</li>
  <li>John Hawthorn</li>
  <li>Ken Jin</li>
  <li>Kevin Menard</li>
  <li>Max Bernstein</li>
  <li>Max Leopold</li>
  <li>Maxime Chevalier-Boisvert</li>
  <li>Nobuyoshi Nakada</li>
  <li>Peter Zhu</li>
  <li>Randy Stauner</li>
  <li>Satoshi Tagomori</li>
  <li>Shannon Skipper</li>
  <li>Stan Lo</li>
  <li>Takashi Kokubun</li>
  <li>Tavian Barnes</li>
  <li>Tobias Lütke</li>
</ul>

<p>(via a lightly touched up <code class="language-plaintext highlighter-rouge">git log --pretty="%an" zjit | sort -u</code>)</p>
</body></html>]]></content><author><name>[&quot;Max Bernstein&quot;]</name></author><category term="posts" /><category term="2025-12-24-launch-zjit" /><summary type="html"><![CDATA[ZJIT is now available with the release of Ruby 4.0. Here's an update of our progress.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2025-12-24-launch-zjit/72bca59f7ad87072e57ded2ddc5c7ddc5f00ba46.png" /><media:content medium="image" url="https://railsatscale.com/2025-12-24-launch-zjit/72bca59f7ad87072e57ded2ddc5c7ddc5f00ba46.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Introducing Aliki: A Modern Theme for Ruby Documentation</title><link href="https://railsatscale.com/2025-12-22-introducing-aliki-a-modern-theme-for-ruby-documentation/" rel="alternate" type="text/html" title="Introducing Aliki: A Modern Theme for Ruby Documentation" /><published>2025-12-22T00:00:00+00:00</published><updated>2025-12-22T00:00:00+00:00</updated><id>https://railsatscale.com/2025-12-22-introducing-aliki-a-modern-theme-for-ruby-documentation/</id><content type="html" xml:base="https://railsatscale.com/2025-12-22-introducing-aliki-a-modern-theme-for-ruby-documentation/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>Ruby has always been a joy to write. But for a long time, reading Ruby documentation on <a href="https://docs.ruby-lang.org">docs.ruby-lang.org</a> hasn’t really matched that experience.</p>

<p>Last year, I brought a <a href="https://st0012.dev/ruby-3-4-docs">new look to the Darkfish theme</a> by updating its visuals and improving mobile support. It was a visible improvement, but it wasn’t enough.</p>

<p>So this year, I built something new from the ground up. Starting with RDoc 7.0.0, Aliki is now the default theme for <a href="https://github.com/ruby/rdoc">RDoc</a>.</p>

<p>This release also coincides with Ruby’s 30th anniversary and the <a href="https://www.ruby-lang.org/en/news/2025/12/22/redesign-site-identity/">redesign of ruby-lang.org</a>—a great moment to give Ruby’s documentation a fresh look as we head into the next chapter with Ruby 4.0.</p>

<figure><img src="./desktop-class-light.png" alt="Screenshot of docs.ruby-lang.org with Aliki theme - desktop view" width="100%"><figcaption>Screenshot of docs.ruby-lang.org with Aliki theme - desktop view</figcaption></figure>

<h2 id="why-a-new-theme">Why a New Theme?</h2>

<p>Even after last year’s improvements, I still didn’t enjoy using docs.ruby-lang.org as much as I wanted. Every time I needed to look something up, the experience felt dated.</p>

<p>And it was difficult to further improve Darkfish because:</p>

<ul>
  <li>It lacks documentation, especially around the original design decisions</li>
  <li>Some of the patterns it uses were outdated</li>
  <li>Some third-party themes build on Darkfish, so updating it too much risks breaking them</li>
</ul>

<p>RDoc itself added more constraints: It can’t depend on any gem that doesn’t ship with Ruby itself, and it can’t run a modern JavaScript build pipeline.</p>

<p>RDoc was created in the pre-Node.js era and hasn’t evolved with frontend tooling. Adopting modern toolchains would raise the dependency requirements for everyone—Ruby’s documentation generation pipeline, gems like IRB and Reline, and so on.</p>

<p>So all JavaScript, CSS, and HTML had to be written directly—no frameworks, no build tools, no npm packages.</p>

<p>And honestly, given all the constraints, this project wouldn’t have been possible without AI coding agents.
Last year, just getting Darkfish’s code block styling right took me hours. It was a struggle for me to implement the look I wanted, and then to make it work with surrounding elements.
At that pace, building an entire new theme wasn’t realistic.</p>

<p>This year, however, I discovered that I could try three different UI styles in an hour with AI agents. So I decided to take on the impossible task.</p>

<p>My goal was simple: make docs.ruby-lang.org look modern and actually enjoyable to use.</p>

<p>I collected all the features I wished a documentation site would have, gathered feedback from Rubyists around me, cherry-picked the improvements the community added to Darkfish last year (SEO, search enhancements, etc.), and put them together into a new theme.</p>

<p>Ok, enough of the back stories. Let’s see what Aliki brings:</p>

<h2 id="search">Search</h2>

<p>The old search wasn’t intuitive. It supported fuzzy matching, but getting the sorting right was difficult—searching <code class="language-plaintext highlighter-rouge">Arr</code> never actually got you the <code class="language-plaintext highlighter-rouge">Array</code> class as the first result.</p>

<p>After a few patch-ups, it was still buggy, so I rewrote it with a new UI:</p>

<ul>
  <li>
    <figure><img src="./desktop-search-dropdown.png" alt="Aliki search dropdown on desktop showing type-aware ranking with classes, modules, methods, and constants" width="70%"><figcaption>Aliki search dropdown on desktop showing type-aware ranking with classes, modules, methods, and constants</figcaption></figure>
  </li>
  <li>
    <figure><img src="./mobile-search-dropdown.jpeg" alt="Aliki search dropdown on mobile showing full-screen search modal" width="50%"><figcaption>Aliki search dropdown on mobile showing full-screen search modal</figcaption></figure>
  </li>
</ul>

<p>Some notable new features/improvements:</p>

<ul>
  <li>
<strong>Case-aware ranking</strong>: If you search <code class="language-plaintext highlighter-rouge">parse</code> (lowercase), methods show up first. If you search <code class="language-plaintext highlighter-rouge">Parser</code> (capitalized), classes and modules come first.</li>
  <li>
<strong>Fuzzy matching</strong>: This existed before, but fuzzy results used to pollute the top of the list. Now we have a smarter ranking system to make sure exact/substring matches show up before fuzzy results.</li>
  <li>
<strong>Constants included</strong>: You can now search for constants, along with classes, modules, and methods.</li>
  <li>
<strong>Type labels</strong>: Each result shows whether it’s a class, module, method, or constant.</li>
  <li>
<strong>Keyboard support</strong>: Did you know you can press <code class="language-plaintext highlighter-rouge">/</code> to focus the search bar? This existed in Darkfish too, but I thought it was worth mentioning.</li>
</ul>

<h2 id="dark-mode">Dark Mode</h2>

<p>Aliki has a light/dark toggle. It saves your preference and respects your OS dark mode setting by default.</p>

<figure><img src="./desktop-hash-class-dark.png" alt="Ruby Hash class documentation page in dark mode" width="100%"><figcaption>Ruby Hash class documentation page in dark mode</figcaption></figure>

<p><br></p>

<figure><img src="./desktop-hash-class-light.png" alt="Ruby Hash class documentation page in light mode" width="100%"><figcaption>Ruby Hash class documentation page in light mode</figcaption></figure>

<h2 id="layout">Layout</h2>

<p>The layout has three columns:</p>

<ul>
  <li>
<strong>Left sidebar</strong>: Navigation for pages, ancestors, methods, and class/module index</li>
  <li>
<strong>Center</strong>: The documentation content</li>
  <li>
<strong>Right sidebar</strong>: A table of contents generated from headings, with the current section highlighted as you scroll</li>
</ul>

<p>Sidebar sections can collapse. For example, when you’re on a class or module page, the pages section is automatically collapsed so you can focus on the relevant navigation, with page documents still accessible if you need them.</p>

<figure><img src="./desktop-collapsible-sidebar.gif" alt="Animated demonstration of collapsible sidebar sections in Aliki" width="100%"><figcaption>Animated demonstration of collapsible sidebar sections in Aliki</figcaption></figure>

<p>Speaking of pages, I also reorganized the pages list this year. It used to be a long, rather flat list—now pages are grouped and much easier to navigate. In the coming year, we’ll continue improving page documentation so it feels more like a coherent guide rather than a collection of loosely related pages.</p>

<p>On mobile, the layout is a single column with a hamburger menu and a full-screen search modal—same as before.</p>

<h2 id="code-features">Code Features</h2>

<p><strong>Code blocks now have copy buttons:</strong></p>

<figure><img src="./desktop-code-block.gif" alt="Animated demonstration of code block copy button in Aliki" width="100%"><figcaption>Animated demonstration of code block copy button in Aliki</figcaption></figure>

<p><strong>C code is now highlighted too:</strong></p>

<figure><img src="./desktop-c-highlight.png" alt="Screenshot of C syntax highlighting" width="100%"><figcaption>Screenshot of C syntax highlighting</figcaption></figure>

<h2 id="for-gem-documentation">For Gem Documentation</h2>

<p>Aliki works for any gem, not just Ruby core. If you generate documentation with RDoc 7.0+, your users will see this theme automatically.</p>

<p>You can also customize the footer links now. For example, in your <code class="language-plaintext highlighter-rouge">.rdoc_options</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">footer_content</span><span class="pi">:</span>
  <span class="na">DOCUMENTATION</span><span class="pi">:</span>
    <span class="na">Home</span><span class="pi">:</span> <span class="s">index.html</span>
  <span class="na">RESOURCES</span><span class="pi">:</span>
    <span class="na">GitHub Repository</span><span class="pi">:</span> <span class="s">https://github.com/your/repo</span>
    <span class="na">Issue Tracker</span><span class="pi">:</span> <span class="s">https://github.com/your/repo/issues</span>
</code></pre></div></div>

<p>This is useful for linking to your gem’s repository, issue tracker, or other resources.</p>

<figure><img src="./desktop-footer.png" alt="Aliki footer showing customizable documentation and resource links" width="80%"><figcaption>Aliki footer showing customizable documentation and resource links</figcaption></figure>

<p>To keep using Darkfish in your project, you can switch back with:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">generator_name</span><span class="pi">:</span> <span class="s">darkfish</span>
</code></pre></div></div>

<h2 id="acknowledgments">Acknowledgments</h2>

<p>Thanks to <a href="https://github.com/tompng">@tompng</a> and <a href="https://github.com/earlopain">@earlopain</a> for reviewing the code and helping polish things up.</p>

<h2 id="try-it-out">Try It Out</h2>

<p>You can see Aliki at <a href="https://docs.ruby-lang.org/en/master/">docs.ruby-lang.org/en/master/</a> or <a href="https://ruby.github.io/rdoc/">ruby.github.io/rdoc/</a>.</p>

<p>If you find issues or have suggestions, <a href="https://github.com/ruby/rdoc/issues">open an issue</a> on GitHub.</p>

<h2 id="whats-next">What’s Next</h2>

<p>Now that reading docs is enjoyable again, the next step for RDoc is to make writing docs enjoyable too.</p>

<h2 id="about-the-name">About the Name</h2>

<p>Aliki is my cat. I’m not good at naming things, so I just named the new theme after her.</p>

<figure><img src="./aliki.jpg" alt="Photo of Aliki the cat" width="50%"><figcaption>Photo of Aliki the cat</figcaption></figure>
</body></html>]]></content><author><name>Stan Lo</name></author><category term="posts" /><category term="2025-12-22-introducing-aliki-a-modern-theme-for-ruby-documentation" /><summary type="html"><![CDATA[Ruby's documentation gets a fresh look. Starting with RDoc 7.0.0, Aliki is the new default theme—bringing dark mode, better search, and a modern layout to docs.ruby-lang.org and gem documentation.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2025-12-22-introducing-aliki-a-modern-theme-for-ruby-documentation/bce1e97fa3e899489dc0871076d888a8a58e4e4a.png" /><media:content medium="image" url="https://railsatscale.com/2025-12-22-introducing-aliki-a-modern-theme-for-ruby-documentation/bce1e97fa3e899489dc0871076d888a8a58e4e4a.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>