<?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-10-09T09:31:55+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">Ractor-age Rails: A How-To Guide</title><link href="https://railsatscale.com/2026-09-16-ractor-age-rails-a-how-to-guide/" rel="alternate" type="text/html" title="Ractor-age Rails: A How-To Guide" /><published>2026-09-16T00:00:00+00:00</published><updated>2026-09-16T00:00:00+00:00</updated><id>https://railsatscale.com/2026-09-16-ractor-age-rails-a-how-to-guide/</id><content type="html" xml:base="https://railsatscale.com/2026-09-16-ractor-age-rails-a-how-to-guide/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>In our <a href="https://railsatscale.com/2026-08-11-ractors-on-rails/">last post</a> on Ractors, <a href="https://railsatscale.com/authors/edouard-chin/">Edouard</a> demonstrated the potential benefits
of using Ractors in a Rails application. He also explained how our team has been
working towards making Rails and its dependencies Ractor-friendly. While we’ve
been able to get smaller applications running on Ractors, we still have a long
way to go to unlock larger applications, which use more features of Rails and
many more gems. However, we <em>have</em> reached a good point where we can start to
talk about the process itself.</p>

<p>In this post, I want to discuss some of the patterns we’ve used to refactor code
to work with Ractors. But first, let’s talk about why any of this is necessary
by looking at the constraints that Ractors impose, and why those constraints
even exist.</p>

<h2 id="fearless-concurrency-comes-at-a-cost">Fearless Concurrency Comes at a Cost</h2>

<p>Let’s start by talking about “what’s wrong” with one of today’s common
concurrency primitives, Threads. Threads provide an illusion of parallelism by
interleaving their execution, but every thread shares CRuby’s Global VM Lock
(GVL), so only one Thread can ever execute Ruby code at a time. If the GVL could
be removed, then every Thread could execute in parallel, but that would also
expose anything shared between Threads to data races. What we’d really like is
something that removes the GVL, but <em>also</em> prevents those data races.</p>

<p>That’s exactly what Ractors do.</p>

<p>Each Ractor has its own GVL, which allows them to execute Ruby code in parallel.
They also prevent data races by disallowing mutable state from being shared
between Ractors.</p>

<p>Of course, it’s that second bit that necessitates making changes to existing
code. Let’s look at some examples to see what that means:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">Rails</span>
  <span class="no">VERSION</span> <span class="o">=</span> <span class="p">[</span><span class="mi">8</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">0</span><span class="p">].</span><span class="nf">join</span><span class="p">(</span><span class="s2">"."</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Here’s a simplified implementation of <code class="language-plaintext highlighter-rouge">Rails::VERSION</code>. Can we use it in a
Ractor?</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Ractor</span><span class="p">.</span><span class="nf">new</span> <span class="p">{</span> <span class="no">Rails</span><span class="o">::</span><span class="no">VERSION</span> <span class="p">}.</span><span class="nf">value</span>
<span class="c1"># can not access non-shareable objects in constant Rails::VERSION of a class/module created by another Ractor. (Ractor::IsolationError)</span>
</code></pre></div></div>

<p>Nope!<sup id="fnref:1"><a href="#fn:1" class="footnote" rel="footnote" role="doc-noteref">1</a></sup> To use Strings created in other Ractors, they have to be <em>shareable</em>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">Rails</span>
  <span class="no">VERSION</span> <span class="o">=</span> <span class="p">[</span><span class="mi">8</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">0</span><span class="p">].</span><span class="nf">join</span><span class="p">(</span><span class="s2">"."</span><span class="p">).</span><span class="nf">freeze</span>
<span class="k">end</span>

<span class="no">Ractor</span><span class="p">.</span><span class="nf">new</span> <span class="p">{</span> <span class="no">Rails</span><span class="o">::</span><span class="no">VERSION</span> <span class="p">}.</span><span class="nf">value</span>
<span class="c1"># =&gt; "8.2.0"</span>
</code></pre></div></div>

<p>And this “shareable” requirement applies to almost everything you could want to
use across Ractors. For simple objects like <code class="language-plaintext highlighter-rouge">String</code>, <code class="language-plaintext highlighter-rouge">Array</code>, and <code class="language-plaintext highlighter-rouge">Hash</code>,
“shareable” just means the objects need to be deeply frozen. You can either
<code class="language-plaintext highlighter-rouge">freeze</code> values individually, or you can use <a href="https://docs.ruby-lang.org/en/4.0/Ractor.html#method-c-make_shareable"><code class="language-plaintext highlighter-rouge">Ractor.make_shareable</code></a> to deep
freeze an entire object graph.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># frozen_string_literal: true</span>

<span class="no">A</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">msgs: </span><span class="p">[</span><span class="s2">"This won't work"</span><span class="p">]</span> <span class="p">}.</span><span class="nf">freeze</span>

<span class="no">Ractor</span><span class="p">.</span><span class="nf">new</span> <span class="p">{</span> <span class="no">A</span><span class="p">[</span><span class="ss">:msgs</span><span class="p">].</span><span class="nf">first</span> <span class="p">}.</span><span class="nf">value</span>
<span class="c1"># can not access non-shareable objects in constant Object::A of a class/module created by another Ractor. (Ractor::IsolationError)</span>

<span class="no">B</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">msgs: </span><span class="p">[</span><span class="s2">"But this will!"</span><span class="p">].</span><span class="nf">freeze</span> <span class="p">}.</span><span class="nf">freeze</span>

<span class="no">Ractor</span><span class="p">.</span><span class="nf">new</span> <span class="p">{</span> <span class="no">B</span><span class="p">[</span><span class="ss">:msgs</span><span class="p">].</span><span class="nf">first</span> <span class="p">}.</span><span class="nf">value</span>
<span class="c1"># =&gt; "But this will!"</span>

<span class="no">C</span> <span class="o">=</span> <span class="no">Ractor</span><span class="p">.</span><span class="nf">make_shareable</span><span class="p">({</span> <span class="ss">msgs: </span><span class="p">[</span><span class="s2">"This will also work!"</span><span class="p">]</span> <span class="p">})</span>

<span class="no">Ractor</span><span class="p">.</span><span class="nf">new</span> <span class="p">{</span> <span class="no">C</span><span class="p">[</span><span class="ss">:msgs</span><span class="p">].</span><span class="nf">first</span> <span class="p">}.</span><span class="nf">value</span>
<span class="c1"># =&gt; "This will also work!"</span>
</code></pre></div></div>

<blockquote>
  <p>💡 Tip</p>

  <p>You can use RuboCop’s <a href="https://docs.rubocop.org/rubocop/latest/cops_style.html#stylemutableconstant">Style/MutableConstant</a> cop to help enforce
this.<sup id="fnref:2"><a href="#fn:2" class="footnote" rel="footnote" role="doc-noteref">2</a></sup> We also added a new <a href="https://github.com/rubocop/rubocop/pull/15166">Recursive</a> mode which ensures constants
aren’t just frozen shallowly.</p>
</blockquote>

<p>Okay, so just deep freeze everything and we should be good, right?
Unfortunately, it’s not quite that simple.</p>

<h2 id="stop-being-so-lazy">Stop Being so Lazy!</h2>

<p>We’ve previously written about how the <a href="https://railsatscale.com/2023-10-24-memoization-pattern-and-object-shapes">memoization pattern</a> can affect
performance by increasing the number of Object Shapes a class will have. The
memoization pattern can <em>also</em> be a problem for Ractors, since Ractors need
shared objects to be immutable and instance variables can’t be set on frozen
objects.</p>

<p>We’ve solved these kinds of issues a few different ways.</p>

<h3 id="remove-the-memoization">Remove the Memoization</h3>

<p>The first is to re-evaluate the use of memoization. Does the value’s computation
really require caching? Perhaps it was justified when originally written, but
maybe it’s fast enough today to run on demand and the memoization can be
removed.</p>

<p>For example, Active Record used to memoize whether a model had overridden
<code class="language-plaintext highlighter-rouge">default_scope</code>. We <a href="https://github.com/rails/rails/pull/57642">removed this memoization</a> and now perform the check each
time instead:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="k">if</span> <span class="n">default_scope_override</span><span class="p">.</span><span class="nf">nil?</span>
  <span class="nb">self</span><span class="p">.</span><span class="nf">default_scope_override</span> <span class="o">=</span> <span class="o">!</span><span class="no">Base</span><span class="p">.</span><span class="nf">is_a?</span><span class="p">(</span><span class="nb">method</span><span class="p">(</span><span class="ss">:default_scope</span><span class="p">).</span><span class="nf">owner</span><span class="p">)</span>
<span class="k">end</span>

<span class="k">if</span> <span class="n">default_scope_override</span>
  <span class="c1"># ...</span>
<span class="k">end</span>

<span class="c1"># After</span>
<span class="k">if</span> <span class="o">!</span><span class="no">Base</span><span class="p">.</span><span class="nf">is_a?</span><span class="p">(</span><span class="nb">method</span><span class="p">(</span><span class="ss">:default_scope</span><span class="p">).</span><span class="nf">owner</span><span class="p">)</span>
  <span class="c1"># ...</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="compute-on-freeze">Compute on <code class="language-plaintext highlighter-rouge">freeze</code>
</h3>

<p>If the computation really needs to only happen once, another option is to
perform it on <code class="language-plaintext highlighter-rouge">freeze</code>. By defining a <code class="language-plaintext highlighter-rouge">freeze</code> method, we can hook into the
exact moment where the object will be frozen and perform the computation at the
last possible moment.</p>

<p>For example, <code class="language-plaintext highlighter-rouge">ActiveSupport::TaggedLogging::Formatter</code> lazily computes a key the
first time its tag stack is accessed:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">tag_stack</span>
  <span class="vi">@thread_key</span> <span class="o">||=</span> <span class="s2">"activesupport_tagged_logging_tags:</span><span class="si">#{</span><span class="nb">object_id</span><span class="si">}</span><span class="s2">"</span>
  <span class="no">IsolatedExecutionState</span><span class="p">[</span><span class="vi">@thread_key</span><span class="p">]</span> <span class="o">||=</span> <span class="no">TagStack</span><span class="p">.</span><span class="nf">new</span>
<span class="k">end</span>
</code></pre></div></div>

<p>If the formatter were frozen before <code class="language-plaintext highlighter-rouge">tag_stack</code> was called, setting
<code class="language-plaintext highlighter-rouge">@thread_key</code> would raise a <code class="language-plaintext highlighter-rouge">FrozenError</code>. We <a href="https://github.com/rails/rails/pull/57644">added a <code class="language-plaintext highlighter-rouge">freeze</code> hook</a> which
forces the key to be computed first:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">freeze</span>
  <span class="n">tag_stack</span>
  <span class="k">super</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="compute-eagerly">Compute Eagerly</h3>

<p>The last option I’ll mention is performing the computation eagerly. This is
similar to hooking into <code class="language-plaintext highlighter-rouge">freeze</code>, but we’ll “hook” into <code class="language-plaintext highlighter-rouge">initialize</code> instead.
This option tends to be best for immutable values that won’t change over the
object’s lifetime and, as mentioned, can result in fewer object shapes as well.</p>

<p>For example, <code class="language-plaintext highlighter-rouge">ActiveSupport::Cache::Strategy::LocalCache</code> used to lazily memoize
its <code class="language-plaintext highlighter-rouge">local_cache_key</code>. Since the key never changes for the lifetime of a
<code class="language-plaintext highlighter-rouge">Cache</code>, we <a href="https://github.com/rails/rails/pull/58061">moved the computation</a> to <code class="language-plaintext highlighter-rouge">initialize</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="k">def</span> <span class="nf">local_cache_key</span>
  <span class="vi">@local_cache_key</span> <span class="o">||=</span>
    <span class="s2">"</span><span class="si">#{</span><span class="nb">self</span><span class="p">.</span><span class="nf">class</span><span class="p">.</span><span class="nf">name</span><span class="p">.</span><span class="nf">underscore</span><span class="si">}</span><span class="s2">_local_cache_</span><span class="si">#{</span><span class="nb">object_id</span><span class="si">}</span><span class="s2">"</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="sr">/[\/-]/</span><span class="p">,</span> <span class="s2">"_"</span><span class="p">).</span><span class="nf">to_sym</span>
<span class="k">end</span>

<span class="c1"># After</span>
<span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>
  <span class="k">super</span>
  <span class="vi">@local_cache_key</span> <span class="o">=</span>
    <span class="s2">"</span><span class="si">#{</span><span class="nb">self</span><span class="p">.</span><span class="nf">class</span><span class="p">.</span><span class="nf">name</span><span class="p">.</span><span class="nf">underscore</span><span class="si">}</span><span class="s2">_local_cache_</span><span class="si">#{</span><span class="nb">object_id</span><span class="si">}</span><span class="s2">"</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="sr">/[\/-]/</span><span class="p">,</span> <span class="s2">"_"</span><span class="p">).</span><span class="nf">to_sym</span>
<span class="k">end</span>

<span class="nb">attr_reader</span> <span class="ss">:local_cache_key</span>
</code></pre></div></div>

<h2 id="immutable-but-also-configurable">Immutable, but also Configurable?</h2>

<p>For values which never change, deep freezing is quick and easy. However, Rails
allows many things to be configured during boot, so we can’t just freeze
everything.</p>

<p>One of the patterns we used for making configurable values Ractor-compatible is
<strong>Read-Copy-Update (RCU)</strong>. For example, let’s look at <a href="https://github.com/rails/rails/pull/57682">how we changed</a>
<a href="https://api.rubyonrails.org/v8.1/classes/ActiveRecord/ReadonlyAttributes/ClassMethods.html#method-i-attr_readonly"><code class="language-plaintext highlighter-rouge">attr_readonly</code></a>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="k">def</span> <span class="nf">attr_readonly</span><span class="p">(</span><span class="o">*</span><span class="n">attributes</span><span class="p">)</span>
  <span class="nb">self</span><span class="p">.</span><span class="nf">_attr_readonly</span> <span class="o">|=</span> <span class="n">attributes</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:to_s</span><span class="p">)</span>
<span class="k">end</span>

<span class="c1"># After</span>
<span class="k">def</span> <span class="nf">attr_readonly</span><span class="p">(</span><span class="o">*</span><span class="n">attributes</span><span class="p">)</span>
  <span class="nb">self</span><span class="p">.</span><span class="nf">_attr_readonly</span> <span class="o">=</span> <span class="no">Ractor</span><span class="p">.</span><span class="nf">make_shareable</span><span class="p">(</span><span class="nb">self</span><span class="p">.</span><span class="nf">_attr_readonly</span> <span class="o">|</span> <span class="n">attributes</span><span class="p">.</span><span class="nf">map</span><span class="p">(</span><span class="o">&amp;</span><span class="ss">:to_s</span><span class="p">))</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Before, <code class="language-plaintext highlighter-rouge">_attr_readonly</code> was a mutable array, and new attributes are just added
to it. To make it Ractor compatible, it must be frozen, so now it:</p>

<ul>
  <li>
<strong>reads</strong> the old array</li>
  <li>creates a <strong>copy</strong> that adds the new attributes</li>
  <li>deeply freezes it with <code class="language-plaintext highlighter-rouge">make_shareable</code>
</li>
  <li>and finally <strong>updates</strong> the variable to point to the new value</li>
</ul>

<p>This pattern works well because <code class="language-plaintext highlighter-rouge">_attr_readonly</code> is an internal/private API, so
we can make it frozen without worrying about breaking applications. For public
APIs, we have to be more careful.</p>

<p>Both Rails and some of its dependencies have values which are documented as
being mutable. We can’t freeze these values by default, or even use
Read-Copy-Update internally, as that would break the compatibility guarantee.</p>

<p>For these, we typically have to deprecate the existing API in favor of a new
one. But this also comes with another challenge: how can we make progress
towards Ractor compatibility while still supporting the existing API?</p>

<p>For these cases, we have to get creative.</p>

<p>One example of this creativity is how we changed <a href="https://github.com/rack/rack/commit/f1a240d07f0a2a9cca6a78ba4d315420d40983e7"><code class="language-plaintext highlighter-rouge">Rack::MethodOverride</code></a>.
This is the Rack middleware that enables forms to submit <code class="language-plaintext highlighter-rouge">PUT</code> requests by
including a hidden <code class="language-plaintext highlighter-rouge">_method</code> field. It has two constants, <code class="language-plaintext highlighter-rouge">HTTP_METHODS</code> and
<code class="language-plaintext highlighter-rouge">ALLOWED_METHODS</code>, which are both currently public and unfrozen.</p>

<p>To make the middleware compatible with Ractors, we introduced a new API:
<code class="language-plaintext highlighter-rouge">initialize</code> will now accept <code class="language-plaintext highlighter-rouge">http_methods</code> and <code class="language-plaintext highlighter-rouge">allowed_methods</code> keywords which
default to the current constants, and store the values as instance variables.
This solves both the immediate Ractor compatibility issue as well as the
backwards compatibility issue:</p>

<ul>
  <li>if an application is currently mutating the constants, it can continue to do
so (but will get a deprecation warning)</li>
  <li>if an application wants to be Ractor compatible, it can use
<code class="language-plaintext highlighter-rouge">Ractor.make_shareable</code> to deep freeze the middleware and the constants will
be frozen since they are stored as instance variables!<sup id="fnref:3"><a href="#fn:3" class="footnote" rel="footnote" role="doc-noteref">3</a></sup>
</li>
</ul>

<h2 id="just-try-to-be-shareable">Just Try to be Shareable</h2>

<p>So far, we’ve mostly looked at values that Rails controls. However, many Rails
APIs accept objects from applications, and Rails frequently stores those objects
inside its own object graph. Callbacks are a particularly common example:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># frozen_string_literal: true</span>

<span class="k">class</span> <span class="nc">Post</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span>
  <span class="n">prefix</span> <span class="o">=</span> <span class="o">+</span><span class="s2">"Draft: "</span>
  <span class="n">before_create</span> <span class="p">{</span> <span class="n">title</span><span class="p">.</span><span class="nf">prepend</span><span class="p">(</span><span class="n">prefix</span><span class="p">)</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The user provided object here is the block passed to <code class="language-plaintext highlighter-rouge">before_create</code>. Since
Rails can’t guarantee that blocks like this can be made shareable with
<code class="language-plaintext highlighter-rouge">Ractor.shareable_proc</code>, we <a href="https://github.com/rails/rails/pull/57626">introduced</a> an internal helper which can be
configured to <code class="language-plaintext highlighter-rouge">warn</code> or <code class="language-plaintext highlighter-rouge">raise</code> if the block can’t be made shareable:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># frozen_string_literal: true</span>

<span class="k">class</span> <span class="nc">Post</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Base</span>
  <span class="n">prefix</span> <span class="o">=</span> <span class="o">+</span><span class="s2">"Draft: "</span>
  <span class="n">before_create</span> <span class="p">{</span> <span class="n">title</span><span class="p">.</span><span class="nf">prepend</span><span class="p">(</span><span class="n">prefix</span><span class="p">)</span> <span class="p">}</span>
  <span class="c1"># Logs:</span>
  <span class="c1">#</span>
  <span class="c1"># DEPRECATION WARNING: Rails attempted to make a Proc from your application Ractor shareable but a Ractor</span>
  <span class="c1"># Isolation error was raised. The proc being returned is not Ractor safe and a runtime</span>
  <span class="c1"># error may occur anytime during the request lifecycle.</span>
  <span class="c1">#</span>
  <span class="c1"># #&lt;Proc:0x0000000128ff3ad8 app/models/post.rb:3&gt;</span>
  <span class="c1">#  (called from &lt;class:Post&gt; at app/models/post.rb:3)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This can be configured by setting
<code class="language-plaintext highlighter-rouge">ActiveSupport::Ractors.unshareable_proc_action</code>:</p>

<ul>
  <li>
<code class="language-plaintext highlighter-rouge">nil</code> doesn’t attempt to make application objects shareable (same as today)</li>
  <li>
<code class="language-plaintext highlighter-rouge">:warn</code> attempts to make them shareable, but emits a deprecation warning if it
can’t</li>
  <li>
<code class="language-plaintext highlighter-rouge">:raise</code> attempts to make them shareable and raises the
<code class="language-plaintext highlighter-rouge">Ractor::IsolationError</code> if it can’t</li>
</ul>

<blockquote>
  <p>Note that the issue with <code class="language-plaintext highlighter-rouge">Post</code>’s callback above is that the block references
<code class="language-plaintext highlighter-rouge">prefix</code>, which is a mutable String. Freezing the string or inlining the value
would allow the block to successfully be made shareable.</p>
</blockquote>

<h2 id="meanwhile-back-on-the-main-ractor">Meanwhile, Back on the Main Ractor</h2>

<p>Finally, there’s some stuff which is just… harder to fix. For example, Active
Record wants to cache the list of columns which <code class="language-plaintext highlighter-rouge">INSERT ... RETURNING</code>
statements will return. However, for some adapters this list depends on queried
database metadata which is only available after database connections are
established. Since booting a Rails application shouldn’t depend on a database
connection being available, this work must be performed lazily. So, how do we do
it?</p>

<p>Well, we can’t set class instance variables on <em>non-main</em> Ractors, but we <em>can</em>
set them on the <em>main</em> Ractor.</p>

<p>For these cases, we <a href="https://github.com/rails/rails/pull/57662">added</a> a wrapper around <a href="https://railsatscale.com/authors/john-hawthorn/">John Hawthorn</a>’s
<a href="https://github.com/jhawthorn/ractor-dispatch"><code class="language-plaintext highlighter-rouge">ractor-dispatch</code></a> gem. As the name implies, it provides a simple API for
non-main Ractors to dispatch work to the main Ractor.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Before</span>
<span class="k">module</span> <span class="nn">ModelSchema</span>
  <span class="k">def</span> <span class="nf">_returning_columns_for_insert</span><span class="p">(</span><span class="n">connection</span><span class="p">)</span>
    <span class="vi">@_returning_columns_for_insert</span> <span class="o">||=</span> <span class="n">do_computation</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># After</span>
<span class="k">module</span> <span class="nn">ModelSchema</span>
  <span class="k">def</span> <span class="nf">_returning_columns_for_insert</span><span class="p">(</span><span class="n">connection</span><span class="p">)</span>
    <span class="vi">@_returning_columns_for_insert</span> <span class="o">||</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Ractors</span><span class="p">.</span><span class="nf">on_main</span><span class="p">(</span><span class="nb">self</span><span class="p">)</span> <span class="k">do</span>
      <span class="vi">@_returning_columns_for_insert</span> <span class="o">||=</span> <span class="n">do_computation</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The downside of dispatching work to the main Ractor is that we can lose the
parallelism benefits that Ractors provide. If every Ractor is waiting on the
main Ractor to do its work, then we’ve effectively re-introduced a single GVL.
However, as long as it’s used sparingly, it can be a useful tool for making code
Ractor-safe without having to do significant refactors. In our case, we only
used it for a few memoized values, so the performance impact is negligible.</p>

<h2 id="so-are-we-ractor-yet">So, are we Ractor Yet?</h2>

<p>As Edouard concluded in his post, this is only the beginning of the journey
towards Ractor-age Rails. We’ve been able to run small applications on Ractors
to demonstrate the performance benefits, and we’ve established a series of
patterns for making code Ractor-safe that has worked well for us so far. We also
know there’s some patterns which we still need to figure out, like using some
sort of Ractor safe equivalent to <code class="language-plaintext highlighter-rouge">concurrent-ruby</code>, which currently only
provides Thread safety.</p>

<p>However, we still have a long way to go. Big applications have more lines of
code, more dependencies, and use more features of Rails which all need to be
Ractor-safe. Luckily, we have a few more tricks up our sleeve for making
progress… you should come learn about them at <a href="https://rubyonrails.org/world/2026/sessions/ractors-rails-robots">Rails World</a>!</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:1">
      <p>This is <a href="https://github.com/rails/rails/pull/58695">now</a> fixed on <code class="language-plaintext highlighter-rouge">main</code>. <a href="#fnref:1" class="reversefootnote" role="doc-backlink">↩</a></p>
    </li>
    <li id="fn:2">
      <p>We <a href="https://github.com/rails/rails/pull/57323">enabled</a> this in <code class="language-plaintext highlighter-rouge">rails/rails</code>’s RuboCop configuration. <a href="#fnref:2" class="reversefootnote" role="doc-backlink">↩</a></p>
    </li>
    <li id="fn:3">
      <p>For Rails, all middleware will be recursively frozen because the
middleware stack is an instance variable on <code class="language-plaintext highlighter-rouge">Rails.application</code>, so
<code class="language-plaintext highlighter-rouge">Ractor.make_shareable</code> will deep freeze all of the middleware in the stack. <a href="#fnref:3" 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-09-16-ractor-age-rails-a-how-to-guide" /><summary type="html"><![CDATA[Patterns, Pitfalls, and Pull Requests]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-09-16-ractor-age-rails-a-how-to-guide/e7f4a14797bbdfd711c1deb2bb4a22e62df06b5d.png" /><media:content medium="image" url="https://railsatscale.com/2026-09-16-ractor-age-rails-a-how-to-guide/e7f4a14797bbdfd711c1deb2bb4a22e62df06b5d.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Introducing Rubydex linter: structural checks for Ruby projects</title><link href="https://railsatscale.com/2026-09-08-introducing-rubydex-linter-structural-checks-for-ruby-projects/" rel="alternate" type="text/html" title="Introducing Rubydex linter: structural checks for Ruby projects" /><published>2026-09-08T00:00:00+00:00</published><updated>2026-09-08T00:00:00+00:00</updated><id>https://railsatscale.com/2026-09-08-introducing-rubydex-linter-structural-checks-for-ruby-projects/</id><content type="html" xml:base="https://railsatscale.com/2026-09-08-introducing-rubydex-linter-structural-checks-for-ruby-projects/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<h2 id="what-is-rubydex-linter">What is Rubydex linter</h2>

<p><a href="https://github.com/Shopify/rubydex">Rubydex</a> is a static analysis toolkit for Ruby. My teammate Vini introduced it in more detail in <a href="https://railsatscale.com/2026-05-12-one-engine-many-tools/">One engine, many tools — Introducing Rubydex</a>. <a href="https://github.com/Shopify/rubydex/blob/v0.4.1/docs/linter.md">Rubydex linter</a> is Rubydex’s built-in linter for writing project-specific structural rules that use Rubydex’s understanding of a codebase.</p>

<h2 id="why-structural-linting">Why structural linting</h2>

<p>Many checks can be decided by reading one file. But in large codebases, we sometimes need to require or prohibit patterns that span several files.</p>

<p>Consider this example: a project has two modules, <code class="language-plaintext highlighter-rouge">LegacyFooHelper</code> and <code class="language-plaintext highlighter-rouge">FooHelper</code>, that provide different implementations of the same <code class="language-plaintext highlighter-rouge">foo</code> method.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">module</span> <span class="nn">LegacyFooHelper</span>
  <span class="k">def</span> <span class="nf">foo</span>
    <span class="n">behaviour_for_legacy_models</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="k">module</span> <span class="nn">FooHelper</span>
  <span class="k">def</span> <span class="nf">foo</span>
    <span class="n">new_behaviour</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>We never want the same model class to include both helpers. But how can we check that?</p>

<p>A linter that reads one file at a time can prevent this direct case:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">User</span>
  <span class="kp">include</span> <span class="no">LegacyFooHelper</span>
  <span class="kp">include</span> <span class="no">FooHelper</span>
<span class="k">end</span>
</code></pre></div></div>

<p>But add a bit of inheritance, and the check becomes more difficult:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># user.rb</span>
<span class="k">class</span> <span class="nc">User</span>
  <span class="kp">include</span> <span class="no">LegacyFooHelper</span>
<span class="k">end</span>

<span class="c1"># admin_user.rb</span>
<span class="k">class</span> <span class="nc">AdminUser</span> <span class="o">&lt;</span> <span class="no">User</span>
  <span class="kp">include</span> <span class="no">FooHelper</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The same conflict can also happen through a mixin:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># bar_helper.rb</span>
<span class="k">module</span> <span class="nn">BarHelper</span>
  <span class="kp">include</span> <span class="no">LegacyFooHelper</span>
  <span class="c1"># and does something else</span>
<span class="k">end</span>

<span class="c1"># user.rb</span>
<span class="k">class</span> <span class="nc">User</span>
  <span class="kp">include</span> <span class="no">FooHelper</span>
  <span class="kp">include</span> <span class="no">BarHelper</span> <span class="c1"># oops, now foo is overridden</span>
<span class="k">end</span>
</code></pre></div></div>

<p>To reliably detect every violation, we need the complete ancestor chain for every model class. We can then check whether both helpers appear in each chain.</p>

<p>A tool that only reads one file at a time does not have this information. Before Rubydex, we could only get it at runtime: load the entire codebase, then inspect every model class.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">test_no_duplicate_foo_helper_inclusion</span>
  <span class="n">eager_load_the_app!</span> <span class="c1"># Slow, but necessary to load every model class.</span>
  <span class="n">violated_models</span> <span class="o">=</span> <span class="n">model_classes</span><span class="p">.</span><span class="nf">select</span> <span class="k">do</span> <span class="o">|</span><span class="n">model</span><span class="o">|</span>
    <span class="n">include_both_foo_helpers?</span><span class="p">(</span><span class="n">model</span><span class="p">.</span><span class="nf">ancestors</span><span class="p">)</span>
  <span class="k">end</span>
  <span class="n">assert_empty</span> <span class="n">violated_models</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Rubydex statically builds ancestor chains for the classes and modules it indexes, including dependencies. We can now prohibit this pattern without adding a slow runtime test.</p>

<h2 id="how-we-use-it-in-shopify-monolith">How we use it in Shopify monolith</h2>

<p>In Shopify’s Rails monolith, we found many tests that did the same work as <code class="language-plaintext highlighter-rouge">test_no_duplicate_foo_helper_inclusion</code>: they eager-loaded the whole application and then inspected class hierarchies, method definitions, constant definition locations, allowlists, etc.</p>

<p>For example, we use rules to check that controllers inherit from the correct shared controller class, serializers do not override prohibited methods, and allowlists still refer to real classes.</p>

<p>Those checks are important, but runtime tests are an expensive way to express them. Running each of those tests took minutes because they eager-loaded the whole app.</p>

<p>After we built the linter, we moved the checks that Rubydex can represent into more than 20 linter rules. They now run during local checks and CI without starting the application. The whole process, from indexing the monolith to running every rule, now takes about 30 to 45 seconds.</p>

<h2 id="write-a-rule-for-our-example">Write a rule for our example</h2>

<p>Unlike most linters, Rubydex linter does not ship with a default set of structural rules for your application. This is because these structural rules are highly specific to individual projects. So the best way to try it is to write a new rule for your project!</p>

<p>First, add Rubydex to your project:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"rubydex"</span>
</code></pre></div></div>

<p>Then run <code class="language-plaintext highlighter-rouge">bundle install</code>.</p>

<p>Rubydex loads rules only from <code class="language-plaintext highlighter-rouge">rubydex_linter/rules/</code> or <code class="language-plaintext highlighter-rouge">lib/rubydex_linter/rules/</code>, so create <code class="language-plaintext highlighter-rouge">rubydex_linter/rules/no_conflicting_foo_helpers.rb</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Prevents a class from including both FooHelper and LegacyFooHelper.</span>
<span class="c1">#</span>
<span class="c1"># This description appears in `rdx lint explain NoConflictingFooHelpers`.</span>
<span class="k">class</span> <span class="nc">Rubydex::Linter::Rules::NoConflictingFooHelpers</span> <span class="o">&lt;</span> <span class="no">Rubydex</span><span class="o">::</span><span class="no">Linter</span><span class="o">::</span><span class="no">CustomRule</span>
  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">default_severity</span> <span class="o">=</span> <span class="no">Rubydex</span><span class="o">::</span><span class="no">Severity</span><span class="o">::</span><span class="no">Error</span>

  <span class="k">def</span> <span class="nf">lint</span>
    <span class="c1"># Find every class with FooHelper in its ancestor chain, including indirect ancestors.</span>
    <span class="n">child_classes</span><span class="p">(</span><span class="s2">"FooHelper"</span><span class="p">).</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">model</span><span class="o">|</span>
      <span class="c1"># Check whether LegacyFooHelper is also in the ancestor chain.</span>
      <span class="k">next</span> <span class="k">unless</span> <span class="n">model</span><span class="p">.</span><span class="nf">has_ancestor?</span><span class="p">(</span><span class="s2">"LegacyFooHelper"</span><span class="p">)</span>

      <span class="c1"># Report the class definition when both helpers are present.</span>
      <span class="n">definition</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="nf">definitions</span><span class="p">.</span><span class="nf">first</span>

      <span class="n">add_diagnostic</span><span class="p">(</span>
        <span class="s2">"`</span><span class="si">#{</span><span class="n">model</span><span class="p">.</span><span class="nf">name</span><span class="si">}</span><span class="s2">` must not include both `FooHelper` and `LegacyFooHelper`."</span><span class="p">,</span>
        <span class="n">diagnostic_location</span><span class="p">(</span><span class="n">definition</span><span class="p">),</span>
      <span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Once it’s there, run the rule with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">exec </span>rdx lint
</code></pre></div></div>

<p>Rules can use <a href="https://github.com/Shopify/rubydex/blob/main/lib/rubydex/linter/rule_test_case.rb"><code class="language-plaintext highlighter-rouge">Rubydex::Linter::RuleTestCase</code></a> for focused tests. It builds a small Ruby workspace for each example and lets you mark the expected diagnostic directly below the source code.</p>

<p>Here are a few directions you can explore for your codebase:</p>

<ol>
  <li>Require certain ancestor relationships: if a class includes <code class="language-plaintext highlighter-rouge">A</code>, it must also include either <code class="language-plaintext highlighter-rouge">B</code> or <code class="language-plaintext highlighter-rouge">C</code>.</li>
  <li>Connect file names, class names, and inheritance: every file ending in <code class="language-plaintext highlighter-rouge">_test.rb</code> must define a class ending in <code class="language-plaintext highlighter-rouge">Test</code> that inherits from <code class="language-plaintext highlighter-rouge">MyProjectTestCase</code>.</li>
  <li>Prohibit certain method overrides: descendants of <code class="language-plaintext highlighter-rouge">BaseSerializer</code> must not define <code class="language-plaintext highlighter-rouge">serializable_hash</code>.</li>
  <li>Enforce dependency boundaries: files under <code class="language-plaintext highlighter-rouge">app/domain/**/*.rb</code> must not reference constants under <code class="language-plaintext highlighter-rouge">Web::*</code>.</li>
  <li>Restrict where classes can be reopened: <code class="language-plaintext highlighter-rouge">User</code> must not be defined outside <code class="language-plaintext highlighter-rouge">app/models/user.rb</code>.</li>
</ol>

<h2 id="configure-and-share-rules">Configure and share rules</h2>

<p>By default, Rubydex recursively indexes every <code class="language-plaintext highlighter-rouge">.rb</code>, <code class="language-plaintext highlighter-rouge">.rake</code>, <code class="language-plaintext highlighter-rouge">.rbs</code>, and <code class="language-plaintext highlighter-rouge">.ru</code> file it finds in the workspace. Use the <code class="language-plaintext highlighter-rouge">[graph]</code> section of <a href="https://github.com/Shopify/rubydex/blob/v0.4.1/docs/linter.md#configuration"><code class="language-plaintext highlighter-rouge">rubydex.toml</code></a> to prevent generated or irrelevant files from being indexed:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">graph</span><span class="k">]</span>
<span class="n">exclude</span> <span class="o">=</span><span class="w"> </span><span class="p">[</span>
  <span class="s">"vendor"</span><span class="p">,</span>
  <span class="s">"**/*.generated.rb"</span><span class="p">,</span>
<span class="p">]</span>
</code></pre></div></div>

<p>Graph exclusions are relative to the workspace root and support glob patterns. They prevent matching files and directories from being indexed.</p>

<p>Rubydex already skips these directories at the workspace root: <code class="language-plaintext highlighter-rouge">.bundle</code>, <code class="language-plaintext highlighter-rouge">.claude</code>, <code class="language-plaintext highlighter-rouge">.git</code>, <code class="language-plaintext highlighter-rouge">.github</code>, <code class="language-plaintext highlighter-rouge">.ruby-lsp</code>, <code class="language-plaintext highlighter-rouge">.vscode</code>, <code class="language-plaintext highlighter-rouge">log</code>, <code class="language-plaintext highlighter-rouge">node_modules</code>, and <code class="language-plaintext highlighter-rouge">tmp</code>. Entries under <code class="language-plaintext highlighter-rouge">[graph].exclude</code> are added to these defaults.</p>

<p>Rules can be configured separately:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">linter</span><span class="k">.</span><span class="n">rules</span><span class="k">.</span><span class="n">NoConflictingFooHelpers</span><span class="k">]</span>
<span class="n">severity</span> <span class="o">=</span><span class="w"> </span><span class="s">"warning"</span>
<span class="n">exclude</span> <span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="s">"test/**"</span><span class="p">]</span>
</code></pre></div></div>

<ul>
  <li>Loaded rules are enabled by default. Set <code class="language-plaintext highlighter-rouge">enabled = false</code> in a rule’s section to disable it.</li>
  <li>
<code class="language-plaintext highlighter-rouge">severity</code> overrides the rule’s default severity. Available values are <code class="language-plaintext highlighter-rouge">hint</code>, <code class="language-plaintext highlighter-rouge">information</code>, <code class="language-plaintext highlighter-rouge">warning</code>, and <code class="language-plaintext highlighter-rouge">error</code>. The process exits with code 1 only when the linter reports at least one diagnostic with <code class="language-plaintext highlighter-rouge">error</code> severity.</li>
  <li>A rule-level <code class="language-plaintext highlighter-rouge">exclude</code> only suppresses diagnostics in matching files; it does not prevent those files from being indexed.</li>
</ul>

<p>Running <code class="language-plaintext highlighter-rouge">bundle exec rdx lint explain NoConflictingFooHelpers</code> displays the documentation written above the rule. Rules can also be distributed in gems by placing them under <code class="language-plaintext highlighter-rouge">lib/rubydex_linter/rules/</code>.</p>

<h3 id="editor-integration">Editor integration</h3>

<p>At the time of writing, editor integration requires the Ruby LSP beta server, which you can enable in VS Code via:</p>

<div class="language-jsonc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="c1">// VS Code settings. Enable the beta version of the server.</span><span class="w">
  </span><span class="nl">"rubyLsp.featureFlags"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w">
    </span><span class="nl">"betaServer"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
  </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>For other editors, pass the <code class="language-plaintext highlighter-rouge">betaServer</code> flag through <a href="https://shopify.github.io/ruby-lsp/editors.html">initialization options</a>.</p>

<p>To learn more about writing rules for your project, see our <a href="https://github.com/Shopify/rubydex/blob/main/docs/linter.md">linter guide</a>.</p>

<h2 id="how-does-this-compare-with-rubocops-rubydex-integration">How does this compare with RuboCop’s Rubydex integration?</h2>

<p><a href="https://metaredux.com/posts/2026/08/05/rubocop-1-89.html">RuboCop added optional Rubydex-backed project indexing in 1.87 and expanded it in 1.89</a>. RuboCop users can write custom cops for the same structural checks shown above. The rule APIs are different, so a Rubydex linter rule cannot run unchanged as a RuboCop cop.
As the team behind Rubydex, we have our own vision for it and its built-in tools. We have always envisioned Rubydex with a built-in linter, and we will continue building toward that vision.</p>

<p>The two tools can coexist. Both support structural linting, and users can choose the tool that best suits the needs of their project.</p>

<h2 id="current-scope-and-limitations">Current scope and limitations</h2>

<p>Rubydex does not yet understand every kind of Ruby code. It does not expose local variables or the context around conditions and rescue clauses, for example. It also does not understand what DSL calls such as <code class="language-plaintext highlighter-rouge">belongs_to</code> mean.</p>

<p>For some rules, we used <a href="https://github.com/ruby/prism">Prism</a> to parse the targeted files and collect the missing information. We aim to reduce these cases as we continue to expand the context Rubydex captures.</p>

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

<p>Rubydex linter is the first Ruby linter designed specifically for checking project-defined structural patterns across the whole codebase. We look forward to discovering and learning from the community about all the things we can use it for.</p>

<p>As <a href="https://railsatscale.com/2026-05-12-one-engine-many-tools/">One engine, many tools — Introducing Rubydex</a> states: “one engine, many tools.” Rubydex linter is one of the tools we’re experimenting with on top of Rubydex, and we will share more in future posts.</p>
</body></html>]]></content><author><name>Stan Lo</name></author><category term="posts" /><category term="2026-09-08-introducing-rubydex-linter-structural-checks-for-ruby-projects" /><summary type="html"><![CDATA[Rubydex linter is Rubydex's built-in linter for writing project-specific structural rules that use Rubydex's understanding of a codebase.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-09-08-introducing-rubydex-linter-structural-checks-for-ruby-projects/95ad26d3f0c372b68e18adbe6dd10d7ae73d2b2e.png" /><media:content medium="image" url="https://railsatscale.com/2026-09-08-introducing-rubydex-linter-structural-checks-for-ruby-projects/95ad26d3f0c372b68e18adbe6dd10d7ae73d2b2e.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">ZJIT 🤝 GC: Inlining GC Fastpath in ZJIT</title><link href="https://railsatscale.com/2026-09-01-zjit-gc-fastpath/" rel="alternate" type="text/html" title="ZJIT 🤝 GC: Inlining GC Fastpath in ZJIT" /><published>2026-09-01T00:00:00+00:00</published><updated>2026-09-01T00:00:00+00:00</updated><id>https://railsatscale.com/2026-09-01-zjit-gc-fastpath/</id><content type="html" xml:base="https://railsatscale.com/2026-09-01-zjit-gc-fastpath/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>It’s been 8 years since the first just-in-time compiler was introduced in Ruby. However, throughout this time, the garbage collector has been a black box to the JIT compilers. This meant that JIT compilers were not able to optimize any object allocations and instead had to perform function calls. This has all changed now that we have integration of the GC fastpath into ZJIT. In this article, we’ll be looking at how object allocations work in the GC, what it means to inline the GC fastpath into ZJIT, and the performance implications.</p>

<h2 id="object-allocation">Object Allocation</h2>

<p>In Ruby’s default GC, object allocations previously used a free list based allocator. The free list allocator keeps a linked list of all of the empty slots. When it allocates an object, it allocates it at the head of the free list and moves to the next element. We can see an animation of the free list allocator:</p>

<figure><img src="free_list_alloc.gif" alt="Animation showing how the free list allocator works."><figcaption>Animation showing how the free list allocator works.</figcaption></figure>

<p>In Ruby pseudocode, it looks like this:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">allocate_from_freelist</span>
  <span class="n">obj</span> <span class="o">=</span> <span class="vi">@freelist</span>
  <span class="k">if</span> <span class="n">obj</span> <span class="c1"># If we are able to allocate at the head of free list</span>
    <span class="vi">@freelist</span> <span class="o">=</span> <span class="n">obj</span><span class="p">.</span><span class="nf">next</span> <span class="c1"># Move to next element of free list</span>
    <span class="n">obj</span> <span class="c1"># Return allocated object</span>
  <span class="k">else</span> <span class="c1"># Free list is empty</span>
    <span class="n">gc_slowpath</span> <span class="c1"># Allocate more memory or run GC</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>In contrast, a bump pointer allocator keeps a <code class="language-plaintext highlighter-rouge">cursor</code> and a <code class="language-plaintext highlighter-rouge">limit</code>. It allocates objects at the <code class="language-plaintext highlighter-rouge">cursor</code> and increments, or “bumps”, the <code class="language-plaintext highlighter-rouge">cursor</code> by the size of the object. If the allocation pushes the <code class="language-plaintext highlighter-rouge">cursor</code> past the <code class="language-plaintext highlighter-rouge">limit</code>, then the allocation would not succeed because we do not have enough memory for the allocation. Here’s an animation showing the bump pointer allocator:</p>

<figure><img src="bump_pointer_alloc.gif" alt="Animation showing how the bump pointer allocator works."><figcaption>Animation showing how the bump pointer allocator works.</figcaption></figure>

<p>In Ruby pseudocode, it looks like this:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">allocate_from_bump_pointer</span><span class="p">(</span><span class="n">size</span><span class="p">)</span>
  <span class="n">obj</span> <span class="o">=</span> <span class="vi">@cursor</span>
  <span class="vi">@cursor</span> <span class="o">+=</span> <span class="n">size</span> <span class="c1"># Increment cursor by size of object</span>
  <span class="k">if</span> <span class="vi">@cursor</span> <span class="o">&lt;=</span> <span class="vi">@limit</span> <span class="c1"># Enough space for allocation</span>
    <span class="n">obj</span> <span class="c1"># Return allocated object</span>
  <span class="k">else</span> <span class="c1"># Not enough space for allocation</span>
    <span class="n">gc_slowpath</span> <span class="c1"># Allocate more memory or run GC</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>If you compare the pseudocode of the free list allocator and the bump pointer allocator, it seems like they should both perform roughly the same right? There is one critical difference. A free list allocator calls <code class="language-plaintext highlighter-rouge">obj.next</code>, which requires reading from the slot that it has allocated. This extra memory read has significant performance penalties compared to the simple addition of the bump pointer allocator.</p>

<p>This was one of the main reasons why we <a href="https://github.com/ruby/ruby/pull/17201">switched Ruby’s default GC to use a bump pointer allocator from a free list allocator</a>. However, we note that, due to technical limitations, the bump pointer allocator in Ruby’s default GC is not quite a canonical bump pointer allocator as it does not allow the allocation of truly dynamic object sizes. It still maintains several heaps each with their own fixed slot sizes. Each bump pointer is only allowed to bump by a certain fixed size.</p>

<p>Another GC implementation that we’re working on is <a href="https://github.com/ruby/mmtk">MMTk</a>. MMTk natively supports bump pointer allocators and the bump pointer allocator can allocate dynamic object sizes. We often use MMTk as a testbed for new techniques since it already has support for many advanced GC features.</p>

<h2 id="gc-fastpath">GC Fastpath</h2>

<p>Even with a bump pointer allocator, every object allocation requires a function call into the GC. This function call has significant overhead for object allocations. So how do we avoid this function call? The answer is for the JIT compiler to generate instructions to inline the fastpath of object allocations and we’ve built that into ZJIT.</p>

<p>But what is the “fastpath”? There are currently three possible paths an object allocation can take. Unsurprisingly, they’re called fast, medium, and slow paths:</p>

<ul>
  <li>
<strong>Fastpath</strong>: the path shown above in the <a href="#object-allocation">Object Allocation</a> section. This tries to bump the pointer in the current block to allocate the object.</li>
  <li>
<strong>Medium path</strong>: if the fastpath fails to yield an object, it means that we have exhausted the memory of the block. We need to find the next block to allocate memory into and we can retry the fastpath.</li>
  <li>
<strong>Slow path</strong>: if we have no more blocks, then we are really out of memory. We have to either allocate more memory or trigger a new GC cycle. This will create blocks that are empty, and we can retry the medium path.</li>
</ul>

<p>We only inline the fastpath in ZJIT. If it fails, we fall back to using a function call into the GC. Since the vast majority of the allocations will hit the fastpath, it will yield the most performance gains without dealing with the complexity of the medium and slow paths.</p>

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

<p>The GC fastpath was introduced in <a href="https://github.com/ruby/ruby/pull/17277">Ruby PR #17277</a>. There are several moving parts that together make it all work, and we’ll look at it piece by piece.</p>

<h4 id="gc-implementations">GC Implementations</h4>

<p>Support for the GC fastpath starts at the GC implementations themselves. Since the introduction of the <a href="/2025-01-08-new-for-ruby-3-4-modular-garbage-collectors-and-mmtk">modular GC feature</a> there are multiple GC implementations in Ruby. GC fastpath implementation for each GC is different and so we have a GC API function <code class="language-plaintext highlighter-rouge">rb_gc_impl_zjit_new_obj_fastpath</code> that is given information about an object that we want to allocate. It will then decide whether it can perform the allocation using the fastpath or not. If it can, it will also emit the data necessary for the allocation, such as which <code class="language-plaintext highlighter-rouge">cursor</code> and <code class="language-plaintext highlighter-rouge">limit</code> to use.</p>

<p>The implementation here differs depending on whether we are using the default GC or MMTk. In this article, we will focus on the default GC.</p>

<h4 id="zjit-gc-fastpath">ZJIT GC Fastpath</h4>

<p>Once <code class="language-plaintext highlighter-rouge">rb_gc_impl_zjit_new_obj_fastpath</code> determines if we can use the fastpath allocator and provides us with the necessary data to do so, we can emit ZJIT LIR instructions for the fastpath. This happens in the file <a href="https://github.com/ruby/ruby/blob/66c8e49a183966388a939f3df0ee98eb3a109f03/zjit/src/codegen/gc_fastpath.rs"><code class="language-plaintext highlighter-rouge">zjit/src/codegen/gc_fastpath.rs</code></a> which defines the functions <a href="https://github.com/ruby/ruby/blob/66c8e49a183966388a939f3df0ee98eb3a109f03/zjit/src/codegen/gc_fastpath.rs#L163-L203"><code class="language-plaintext highlighter-rouge">emit_default_new_obj_fastpath</code></a> and <a href="https://github.com/ruby/ruby/blob/66c8e49a183966388a939f3df0ee98eb3a109f03/zjit/src/codegen/gc_fastpath.rs#L208-L361"><code class="language-plaintext highlighter-rouge">emit_mmtk_new_obj_fastpath</code></a> which emits the ZJIT LIR for the default GC and MMTk, respectively.</p>

<h4 id="zjit-hir-instructions">ZJIT HIR Instructions</h4>

<p>Implementing the GC fastpath doesn’t automatically give us any gains because no objects are allocated through it. To take advantage of the GC fastpath, we have to use it when generating LIR for various ZJIT HIR instructions that allocate. For example, <a href="https://github.com/ruby/ruby/pull/17717">PR #17717 implements the GC fastpath for the newhash instruction when the hash is empty</a>. The most important code is in <code class="language-plaintext highlighter-rouge">gen_new_hash</code>. I have extracted the relevant code from that function and added some comments:</p>

<div class="language-rust highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Determine the size of the object.</span>
<span class="k">let</span> <span class="n">alloc_size</span> <span class="o">=</span> <span class="k">unsafe</span> <span class="p">{</span> <span class="nf">rb_zjit_hash_new_size</span><span class="p">()</span> <span class="p">};</span>
<span class="c1">// Flags for object metadata.</span>
<span class="k">let</span> <span class="n">flags</span> <span class="o">=</span> <span class="n">RUBY_T_HASH</span> <span class="k">as</span> <span class="nb">u64</span><span class="p">;</span>
<span class="c1">// Class of the object.</span>
<span class="k">let</span> <span class="n">klass</span> <span class="o">=</span> <span class="k">unsafe</span> <span class="p">{</span> <span class="n">rb_cHash</span> <span class="p">};</span>

<span class="c1">// Use the GC fastpath to allocate the object.</span>
<span class="k">let</span> <span class="n">hash</span> <span class="o">=</span> <span class="nn">gc_fastpath</span><span class="p">::</span><span class="nf">gc_fastpath_new_obj</span><span class="p">(</span><span class="n">jit</span><span class="p">,</span> <span class="n">asm</span><span class="p">,</span> <span class="n">alloc_size</span><span class="p">,</span> <span class="n">flags</span><span class="p">,</span> <span class="n">klass</span><span class="p">,</span> <span class="p">|</span><span class="n">asm</span><span class="p">|</span> <span class="p">{</span>
    <span class="c1">// Closure for a fallback that gets called when the GC fastpath is not</span>
    <span class="c1">// supported by the GC or when it is out of memory.</span>
    <span class="c1">// This calls rb_hash_new, which is a C function that allocates a new</span>
    <span class="c1">// empty hash.</span>
    <span class="nd">asm_ccall!</span><span class="p">(</span><span class="n">asm</span><span class="p">,</span> <span class="n">rb_hash_new</span><span class="p">,)</span>
<span class="p">});</span>
<span class="c1">// Set up the allocated hash with nil as the default value.</span>
<span class="n">asm</span><span class="nf">.store</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="n">VALUE_BITS</span><span class="p">,</span> <span class="n">hash</span><span class="p">,</span> <span class="n">RUBY_OFFSET_RHASH_IFNONE</span><span class="p">),</span> <span class="n">Qnil</span><span class="nf">.into</span><span class="p">());</span>
<span class="c1">// Return the allocated hash object.</span>
<span class="n">hash</span>
</code></pre></div></div>

<p>So far, we have implemented GC fastpath on the following instructions:</p>

<table>
  <thead>
    <tr>
      <th>ZJIT HIR Instruction</th>
      <th>Description</th>
      <th>Fastpath Support</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">NewHash</code></td>
      <td>Hash literal.</td>
      <td>Empty hash or a small (less than or equal to 8 elements) hash with only symbols as keys.</td>
    </tr>
    <tr>
      <td>
<code class="language-plaintext highlighter-rouge">NewRange</code> / <code class="language-plaintext highlighter-rouge">NewRangeFixnum</code>
</td>
      <td>Range literal.</td>
      <td>When either endpoint of the range is <code class="language-plaintext highlighter-rouge">nil</code> or both are fixnums (small integers).</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ObjectAllocClass</code></td>
      <td>Creating a new instance of a class.</td>
      <td>When the class uses the default allocator.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">StringCopy</code></td>
      <td>A string literal in a file without the  <code class="language-plaintext highlighter-rouge">frozen_string_literal: true</code>  magic comment.</td>
      <td>Strings of maximum allocation size by the GC (999 bytes for default GC, unlimited for MMTk).</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ArrayDup</code></td>
      <td>An array literal with only compile time constants (e.g. integers, strings, symbols, etc.).</td>
      <td>When the array has less than or equal to 3 elements.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">NewArray</code></td>
      <td>An array literal with dynamic values (e.g. values from variables).</td>
      <td>When the array has less than or equal to 3 elements.</td>
    </tr>
  </tbody>
</table>

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

<p>Since the GC fastpath API is designed with a fallback closure when the fastpath is not able to allocate, we can easily turn off the fastpath by always executing the fallback. To clearly see the performance improvements, we can write small microbenchmarks that compare the allocation performance of various types. For example, the following benchmark allocates hashes with 3 elements in a loop.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">run</span><span class="p">(</span><span class="n">times</span><span class="p">)</span>
  <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span>
  <span class="k">while</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">times</span>
    <span class="n">a</span> <span class="o">=</span> <span class="p">{</span><span class="ss">a: </span><span class="mi">1</span><span class="p">,</span> <span class="ss">b: </span><span class="mi">2</span><span class="p">,</span> <span class="ss">c: </span><span class="mi">3</span><span class="p">}</span>
    <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="mi">30</span><span class="p">.</span><span class="nf">times</span> <span class="p">{</span> <span class="n">run</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span> <span class="p">}</span> <span class="c1"># Warm up ZJIT</span>
<span class="n">run</span><span class="p">(</span><span class="mi">10_000_000</span><span class="p">)</span> <span class="c1"># Run the benchmark</span>
</code></pre></div></div>

<p>We can see from the benchmark results below that the fastpath nearly doubles the performance of hash allocations. Note that this benchmark also includes interpreter and garbage collection overhead, which isn’t optimized by the GC fastpath:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Benchmark 1: fastpath
  Time (mean ± σ):      66.4 ms ±   1.4 ms    [User: 59.4 ms, System: 5.4 ms]
  Range (min … max):    64.7 ms …  73.1 ms    43 runs

Benchmark 2: no fastpath
  Time (mean ± σ):     117.2 ms ±   1.5 ms    [User: 113.1 ms, System: 2.9 ms]
  Range (min … max):   115.0 ms … 121.6 ms    25 runs

Summary
  fastpath ran
    1.77 ± 0.04 times faster than no fastpath
</code></pre></div></div>

<p>We can write similar benchmarks for other types and also see 2-3x performance gains.</p>

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

<p>For the first time, the garbage collector is no longer a black box to a Ruby JIT compiler. By having the GC hand ZJIT everything it needs to allocate an object ZJIT can inline the allocation fastpath and skip the C function call entirely. We’ve already brought this to hashes, ranges, arrays, strings, and object instances, with significant speedups seen in microbenchmarks.</p>
</body></html>]]></content><author><name>Peter Zhu</name></author><category term="posts" /><category term="2026-09-01-zjit-gc-fastpath" /><summary type="html"><![CDATA[Object allocations from the garbage collector are now inlined in ZJIT.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-09-01-zjit-gc-fastpath/b3abab76cae6279480058e28f1a4f5f60c9b386f.png" /><media:content medium="image" url="https://railsatscale.com/2026-09-01-zjit-gc-fastpath/b3abab76cae6279480058e28f1a4f5f60c9b386f.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Bringing Rails into the Ractor-age</title><link href="https://railsatscale.com/2026-08-11-ractors-on-rails/" rel="alternate" type="text/html" title="Bringing Rails into the Ractor-age" /><published>2026-08-11T00:00:00+00:00</published><updated>2026-08-11T00:00:00+00:00</updated><id>https://railsatscale.com/2026-08-11-ractors-on-rails/</id><content type="html" xml:base="https://railsatscale.com/2026-08-11-ractors-on-rails/"><![CDATA[<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body>
<p>Ractors have been experimental for years, but things are changing fast. Last year, a team of four on the Shopify Ruby Infrastructure team tackled Ractor scalability, performance, and stability. Ractors are now viable in production, and Ruby 4.1 will make them even better.</p>

<p>It’s time for Rails to embrace them.</p>

<p>In this post, we’ll explain why: what Ractors unlock for Rails applications, and the first milestone we’re aiming for in the framework.</p>

<h2 id="first-a-refresher">First, a refresher</h2>

<p>A <a href="https://docs.ruby-lang.org/en/4.0/Ractor.html">Ractor, or Ruby Actor</a>, brings true multi-core parallelism to Ruby. If you aren’t familiar with CRuby internals, you might expect Threads to already provide this — but they don’t, because of the Global VM Lock (GVL). <code class="language-plaintext highlighter-rouge">Threads</code> give you concurrency; Ractors give you parallelism.</p>

<p>For a Rails application, that unlocks two things:</p>

<ul>
  <li>
<strong>Speed</strong> — CPU-bound work can actually run in parallel instead of taking turns.</li>
  <li>
<strong>Memory</strong> — you get that parallelism inside a single process, instead of forking a full copy of the app per worker.</li>
</ul>

<h2 id="the-cost-of-forking">The cost of forking</h2>

<p>When several users hit a threaded Rails server like Puma, their requests run as separate threads in one process — but the GVL lets only one thread execute Ruby at any instant. If the first request does CPU-bound work, the others run only in the gaps. They interleave on a single core instead of running in parallel, so each one’s latency goes up.</p>

<figure><img src="threads-interleaving.gif" alt="The GVL lets only one thread run Ruby at any instant."><figcaption>The GVL lets only one thread run Ruby at any instant.</figcaption></figure>

<p>To work around this, most Ruby servers fork the application: each worker runs in its own process, with its own memory and its own GVL. Our two users can now be served in parallel by two workers. This works — but it introduces a new trade-off: memory.</p>

<p>Because each forked worker has its own copy of the application, total memory usage goes up. Servers soften this by preloading the application before forking, so workers share those initial pages via <a href="https://en.wikipedia.org/wiki/Copy-on-write">Copy-on-Write</a>. But that sharing is only a starting point: as each worker allocates objects, grows caches, and runs GC, its pages diverge and its footprint grows independently.</p>

<p><strong>This is the trade-off Ractors let us sidestep.</strong> They run in parallel within a single process, so we get true parallelism without forking a full copy of the app per worker.</p>

<h2 id="testing-the-theory-on-a-real-application">Testing the theory on a real application</h2>

<p>That’s the theory. To see whether it holds outside microbenchmarks, we decided to put it to the test with an experiment: could we serve requests in a real Rails application using a Ractor-compatible web server, <a href="https://github.com/yaroslav/kino">Kino</a>?</p>

<p>We picked <a href="https://github.com/basecamp/writebook">Basecamp’s Writebook</a> as an ideal candidate: it’s a standard Rails application, well written and small enough. The questions we wanted to answer were:</p>

<ul>
  <li>Is this experiment even doable, <strong>or are Ractors still fundamentally incompatible with Rails?</strong>
</li>
  <li>
<strong>How much memory could we save</strong> with a pool of N Ractors versus a clustered Puma setup with N workers?</li>
  <li><strong>Does latency increase?</strong></li>
</ul>

<p>With the help of an AI agent, we worked our way toward making a few endpoints Ractor-safe:</p>

<ul>
  <li>
<strong><code class="language-plaintext highlighter-rouge">GET /up</code></strong> — A super lightweight endpoint that just returns a green webpage.</li>
  <li>
<strong><code class="language-plaintext highlighter-rouge">GET /</code></strong> — The home page after setting up Writebook. It’s similarly lightweight, but exercises <code class="language-plaintext highlighter-rouge">ERB</code> rendering and executes a couple of DB queries.</li>
  <li>
<strong><code class="language-plaintext highlighter-rouge">POST /first_run</code></strong> — The heaviest endpoint of the three: multiple DB queries, markdown rendering, image upload and analysis, and HTML sanitization.</li>
</ul>

<p>We selected these endpoints so we could gradually see whether latency increased depending on the type of computation.</p>

<p>Multiple changes had to be made to let Writebook serve requests in a Ractor — inside gems, the Rails framework, and Writebook itself. A few native gem extensions were deliberately left Ractor-incompatible so we could measure the performance tax we pay when using an escape hatch, <a href="https://github.com/jhawthorn/ractor-dispatch">Ractor Dispatch</a>, which lets us dispatch work to the main Ractor.</p>

<p>The source code of this experiment and the benchmarks are available in <a href="https://github.com/shopify/writebook/tree/ec-ractor-safe">this repository</a> if you’d like to run them on your own machine. <strong>Note that none of this is meant to be pretty or permanent — it’s just an experiment to gather numbers.</strong></p>

<h2 id="results">Results</h2>

<h3 id="memory">Memory</h3>

<figure><img src="memory-gain.png" alt="Memory usage scaling with N: puma-cluster grows while a Ractor pool stays flat, reaching nearly 7× less memory at N=8."><figcaption>Memory usage scaling with N: puma-cluster grows while a Ractor pool stays flat, reaching nearly 7× less memory at N=8.</figcaption></figure>

<p>The memory savings compounded as we increased the number of Ractors / Puma workers. Measured as PSS (which fairly accounts for memory shared between forked workers), a pool of 8 Ractors used nearly 7× less memory than 8 forked Puma workers serving the same load.</p>

<h3 id="latency">Latency</h3>

<figure><img src="latency-up.png" alt="Latency for GET /up: base and Ractor are virtually identical."><figcaption>Latency for GET /up: base and Ractor are virtually identical.</figcaption></figure>

<p>The latency difference on the most trivial endpoint is, unsurprisingly, virtually identical.</p>

<figure><img src="latency-root.png" alt="Latency for GET /: a couple of DB queries are dispatched to the main Ractor."><figcaption>Latency for GET /: a couple of DB queries are dispatched to the main Ractor.</figcaption></figure>

<p>Things get more interesting on an endpoint that uses the Ractor Dispatch escape hatch, where a couple of DB queries are funneled to the main Ractor. The latency difference goes up slightly, but in absolute terms it’s negligible.</p>

<figure><img src="latency-first-run.png" alt="Latency for POST /first_run: most of the overhead comes from work dispatched to the main Ractor."><figcaption>Latency for POST /first_run: most of the overhead comes from work dispatched to the main Ractor.</figcaption></figure>

<p>Finally, on the heaviest endpoint — where many operations are performed to set up a brand-new Writebook instance — latency clearly increases. It’s important to note, however, that most of the overhead comes from dispatching work to the main Ractor multiple times, because of native extensions that aren’t Ractor-compatible. Each dispatch incurs a full cross-Ractor round-trip — making the block shareable, then passing it and its result across the boundary — which ultimately increases latency.</p>

<p>As the Ruby ecosystem’s support for Ractors matures, this performance tax will fade away.</p>

<h2 id="concluding-the-experiment">Concluding the experiment</h2>

<p>This experiment confirmed that the benefit of Ractors isn’t only theoretical, and that there’s a viable path for Rails to support them. Of course, none of this is free. Making a Rails application Ractor-safe means freezing state, sharing objects explicitly, and — for now — funneling things like database queries to the main Ractor as we figure out how each Ractor can manage its own database connections.</p>

<p>This undeniably adds complexity to the framework, but we’re taking it on deliberately, because we believe the payoff is worth it and most of today’s rough edges are temporary.</p>

<h2 id="bringing-ractors-to-rails">Bringing Ractors to Rails</h2>

<p>Our first milestone is deliberately modest: generate a brand-new Rails app, scaffold a resource, and serve its requests inside a Ractor. It sounds small, but a freshly scaffolded app already exercises a surprising amount of the framework — routing, the request/response cycle, view rendering, translation, database queries, the asset pipeline, and the many Active Support helpers.</p>

<p>We’re <a href="https://github.com/rails/rails/pulls?q=is%3Apr+label%3A%22Ractor+Support%22+">fixing all of these issues</a> and are nearing the end. Getting all of that Ractor-safe is the foundation everything else builds on, and it puts Ractors within reach of anyone who can run <code class="language-plaintext highlighter-rouge">rails new</code>.</p>

<p>But it’s only the beginning. Beyond that first milestone, there’s a long list of work ahead — for instance, making Rails Ractor-safe in a non-eager-loaded environment so it works during local development, or extending Ractor support to the rest of the framework, such as Active Job, Active Storage, and Action Cable. There’s a lot left to do, and we’re excited to bring the rest of Rails into the Ractor-age.</p>
</body></html>]]></content><author><name>Edouard Chin</name></author><category term="posts" /><category term="2026-08-11-ractors-on-rails" /><summary type="html"><![CDATA[Ractors are now viable in production, and it's time for Rails to embrace them. In this post, we explain why — what Ractors unlock for Rails applications, and the first milestone we're aiming for in the framework.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-08-11-ractors-on-rails/e7b995988d28e8b9f87866f882ebf887e49948e1.png" /><media:content medium="image" url="https://railsatscale.com/2026-08-11-ractors-on-rails/e7b995988d28e8b9f87866f882ebf887e49948e1.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The inliner is yielding benefits for ZJIT</title><link href="https://railsatscale.com/2026-07-28-the-inliner-is-yielding-benefits/" rel="alternate" type="text/html" title="The inliner is yielding benefits for ZJIT" /><published>2026-07-28T00:00:00+00:00</published><updated>2026-07-28T00:00:00+00:00</updated><id>https://railsatscale.com/2026-07-28-the-inliner-is-yielding-benefits/</id><content type="html" xml:base="https://railsatscale.com/2026-07-28-the-inliner-is-yielding-benefits/"><![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 enabled a really cool feature in ZJIT that makes it feel like a
Real Compiler™: the inliner! We’ll write more about it soon. In this
post, we’ll talk about one excellent concrete benefit we are already seeing and
how it optimizes blocks in pretty much every Ruby program.</p>

<p>I’ll start off with a refresher on how blocks work in the Ruby interpreter,
then show you how ZJIT understands and optimizes that bytecode, and then show
you the impact of the inliner.</p>

<h2 id="ruby-a-refresher">Ruby: a refresher</h2>

<p>In the beginning, there were loops.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">arr</span> <span class="o">=</span> <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">]</span>
<span class="n">i</span> <span class="o">=</span> <span class="mi">0</span>
<span class="n">sum</span> <span class="o">=</span> <span class="mi">0</span>
<span class="k">while</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">arr</span><span class="p">.</span><span class="nf">length</span>
  <span class="n">sum</span> <span class="o">+=</span> <span class="n">arr</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
  <span class="n">i</span> <span class="o">+=</span> <span class="mi">1</span>
<span class="k">end</span>
</code></pre></div></div>

<p>People used them to navigate and manipulate variable-length structures, like
arrays and strings. This was fine.</p>

<p>Then, in the 1970s, a small group of computer scientists at Palo Alto Research
Center invented a programming language called Smalltalk. One of the core
features of Smalltalk was that everything was an object and computation was
done by sending messages to objects.</p>

<p>This meant that iteration wouldn’t do at all. Instead, we would have to send
the <code class="language-plaintext highlighter-rouge">do:</code> message to the array object and pass it a block object.</p>

<div class="language-smalltalk highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">#(</span><span class="m">1</span> <span class="m">2</span> <span class="m">3</span><span class="ss">)</span> <span class="nf">do:</span> <span class="p">[</span><span class="o">:</span><span class="nv">a</span> <span class="p">|</span> <span class="nv">sum</span> <span class="o">:=</span> <span class="nv">sum</span> <span class="nf">+</span> <span class="nv">a</span><span class="p">].</span>
</code></pre></div></div>

<p>Then, in the 1990s, Matz, inspired by Smalltalk and Perl, created Ruby. We
still have “normal” loops but we also have a very Smalltalk-y way of doing it,
too:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">arr</span> <span class="o">=</span> <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">]</span>
<span class="n">sum</span> <span class="o">=</span> <span class="mi">0</span>
<span class="n">arr</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">a</span><span class="o">|</span>
  <span class="n">sum</span> <span class="o">+=</span> <span class="n">a</span>
<span class="k">end</span>
</code></pre></div></div>

<p>When this program gets compiled to Ruby bytecode, it ends up looking like a
mostly normal method call to <code class="language-plaintext highlighter-rouge">each</code> except that we pass a special kind of
argument to it: a block argument.</p>

<p>To see how this works inside CRuby, we’re going to look at a listing of YARV
bytecode—CRuby bytecode. For more on YARV, I recommend Kevin Newton’s
excellent <a href="https://kddnewton.com/2022/11/30/advent-of-yarv-part-0.html">Advent of
YARV</a>.</p>

<p>Ignore most of the bytecode dump below except for the instruction at <code class="language-plaintext highlighter-rouge">0009</code>,
the <code class="language-plaintext highlighter-rouge">send</code> instruction. We are <em>send</em>-ing (see? a message!) <code class="language-plaintext highlighter-rouge">each</code> with the
block argument <code class="language-plaintext highlighter-rouge">block in &lt;main&gt;</code> (passed a different way than “normal”
arguments, hence the <code class="language-plaintext highlighter-rouge">argc:0</code>).</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ ruby --dump=insns /tmp/arrayeach.rb
== disasm: #&lt;ISeq:&lt;main&gt;@/tmp/arrayeach.rb:1 (1,0)-(5,3)&gt;
local table (...)
[ 2] arr@0      [ 1] sum@1
0000 duparray                   [1, 2, 3]                 (   1)[Li]
0002 setlocal_WC_0              arr@0
0004 putobject_INT2FIX_0_                                 (   2)[Li]
0005 setlocal_WC_0              sum@1
0007 getlocal_WC_0              arr@0                     (   3)[Li]
0009 send                       &lt;calldata!mid:each, argc:0&gt;, block in &lt;main&gt;
0012 leave
...continued below...
</code></pre></div></div>

<p>This <code class="language-plaintext highlighter-rouge">block in &lt;main&gt;</code> from the bytecode listing above is the generated name of
the <em>instruction sequence</em> (bytecode) corresponding to the block we passed to
<code class="language-plaintext highlighter-rouge">each</code>. Its code, shown below, is the next thing in the bytecode dump. You can
see the usage of local variables <code class="language-plaintext highlighter-rouge">sum</code> and <code class="language-plaintext highlighter-rouge">a</code> via <code class="language-plaintext highlighter-rouge">getlocal</code> and <code class="language-plaintext highlighter-rouge">setlocal</code>
variants and also <code class="language-plaintext highlighter-rouge">opt_plus</code>, which represents addition (<code class="language-plaintext highlighter-rouge">+</code>). Again, the
details are not terribly important:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>...continued from above...
== disasm: #&lt;ISeq:block in &lt;main&gt;@/tmp/arrayeach.rb:3 (3,9)-(5,3)&gt;
local table (...)
[ 1] a@0&lt;AmbiguousArg&gt;
0000 getlocal_WC_1              sum@1                     (   4)[LiBc]
0002 getlocal_WC_0              a@0
0004 opt_plus                   &lt;calldata!mid:+, argc:1, ARGS_SIMPLE&gt;[CcCr]
0006 dup
0007 setlocal_WC_1              sum@1
0009 leave                                                (   5)[Br]
$
</code></pre></div></div>

<p>To make this work, <code class="language-plaintext highlighter-rouge">Array</code> has an <code class="language-plaintext highlighter-rouge">each</code> method. This <code class="language-plaintext highlighter-rouge">each</code> method takes in
its optional block argument and calls it once for every element in the array.</p>

<p>As with most of the core data structures, <code class="language-plaintext highlighter-rouge">Array</code>’s methods tend to be written
in C and <code class="language-plaintext highlighter-rouge">each</code> is no different. Here is its nice and short definition with
comments added by me:</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">VALUE</span>
<span class="nf">rb_ary_each</span><span class="p">(</span><span class="n">VALUE</span> <span class="n">ary</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">long</span> <span class="n">i</span><span class="p">;</span>
    <span class="c1">// Some debug-mode-only checks</span>
    <span class="n">ary_verify</span><span class="p">(</span><span class="n">ary</span><span class="p">);</span>
    <span class="c1">// If we didn't pass a block in, return an enumerator object</span>
    <span class="n">RETURN_SIZED_ENUMERATOR</span><span class="p">(</span><span class="n">ary</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">ary_enum_length</span><span class="p">);</span>
    <span class="c1">// We passed a block in, so call it for every element</span>
    <span class="k">for</span> <span class="p">(</span><span class="n">i</span><span class="o">=</span><span class="mi">0</span><span class="p">;</span> <span class="n">i</span><span class="o">&lt;</span><span class="n">RARRAY_LEN</span><span class="p">(</span><span class="n">ary</span><span class="p">);</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">rb_yield</span><span class="p">(</span><span class="n">RARRAY_AREF</span><span class="p">(</span><span class="n">ary</span><span class="p">,</span> <span class="n">i</span><span class="p">));</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">ary</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>You can’t really see the block parameter to the C code because it’s passed in a
special location on the Ruby VM’s own stack called the <em>block handler</em>. All you
need to know is that <code class="language-plaintext highlighter-rouge">rb_yield</code> knows where to find that and how to call it.</p>

<p>There’s just one more thing, which is… hang on, weren’t we building a JIT to
optimize Ruby code? How are we going to optimize this C code?</p>

<h2 id="jits-a-refresher">JITs: a refresher</h2>

<p>Rewriting code from C to Ruby means that the JIT compiler gets a chance to
introspect the run-time behavior and code. This means that, over time, as the
compiler and the runtime system grow together, more and more code might get
rewritten in Ruby. This started happening a couple of years ago with YJIT.</p>

<p>YJIT precipitated some interesting changes to Ruby VM internals. For example,
in 2022, <code class="language-plaintext highlighter-rouge">Array#each</code> being written in C started to hurt: it was an opaque blob
that the JIT couldn’t reason about. So Kokubun submitted <a href="https://github.com/ruby/ruby/pull/6687">a
PR</a> to rewrite it in Ruby. After some
back and forth, in 2024, Kokubun landed <a href="https://github.com/ruby/ruby/pull/11955">a different
PR</a> that everyone was happy with.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">Array</span>
  <span class="k">def</span> <span class="nf">each</span> <span class="c1"># :nodoc:</span>
    <span class="no">Primitive</span><span class="p">.</span><span class="nf">attr!</span> <span class="ss">:inline_block</span><span class="p">,</span> <span class="ss">:c_trace</span><span class="p">,</span> <span class="ss">:without_interrupts</span>

    <span class="k">unless</span> <span class="k">defined?</span><span class="p">(</span><span class="k">yield</span><span class="p">)</span>
      <span class="k">return</span> <span class="no">Primitive</span><span class="p">.</span><span class="nf">cexpr!</span> <span class="s1">'SIZED_ENUMERATOR(self, 0, 0, ary_enum_length)'</span>
    <span class="k">end</span>
    <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="k">until</span> <span class="no">Primitive</span><span class="p">.</span><span class="nf">rb_jit_ary_at_end</span><span class="p">(</span><span class="n">i</span><span class="p">)</span>
      <span class="k">yield</span> <span class="no">Primitive</span><span class="p">.</span><span class="nf">rb_jit_ary_at</span><span class="p">(</span><span class="n">i</span><span class="p">)</span>
      <span class="n">i</span> <span class="o">=</span> <span class="no">Primitive</span><span class="p">.</span><span class="nf">rb_jit_fixnum_inc</span><span class="p">(</span><span class="n">i</span><span class="p">)</span>
    <span class="k">end</span>
    <span class="nb">self</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Ah, finally. A version that JITs can reason about.</p>

<p>I keep saying “reason about” and what that means concretely here is a) that
it’s written in a format that the JIT can ingest and optimize: Ruby, and b) mostly a
brief rehashing of the key lessons in the venerable Smalltalk (!) paper
<a href="https://dl.acm.org/doi/pdf/10.1145/800017.800542">Efficient Implementation of the Smalltalk-80
System</a> (PDF):</p>

<ul>
  <li>Though systems may offer very dynamic behavior, people don’t frequently make
use of wild features all over the place
    <ul>
      <li>Most people pass fewer than 4 types of objects through a given method</li>
      <li>As a corollary, even if there are many classes in a system, code locality
is super important, and we can take advantage of that</li>
      <li>Also, most people do not define, re-define, and otherwise continuously
modify method definitions</li>
    </ul>
  </li>
</ul>

<p>(And if you don’t believe me that the lessons still apply, check out the
excellent paper <a href="https://dl.acm.org/doi/pdf/10.1145/3563834.3567538">Who You Gonna
Call</a> (PDF) by Sophie
Kaleba, Octave Larose, Richard Jones, and Stefan Marr.)</p>

<p>So JITs like to watch what types of objects flow through methods before
compiling them. And JITs like to, since they know the types of objects, cache
method lookups and specialize method invocations on those objects.</p>

<p>For example, take a look at this code:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">next_capacity</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
  <span class="n">x</span><span class="p">.</span><span class="nf">length</span> <span class="o">*</span> <span class="mi">2</span> <span class="o">+</span> <span class="mi">1</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">x</code> could be anything. There is no way of knowing its type by looking at the
code. This means that the <code class="language-plaintext highlighter-rouge">length</code> method could be anything. Furthermore, there
is no way of knowing what the return type of <code class="language-plaintext highlighter-rouge">x.length</code> is, so we can’t
specialize the method lookups or invocations of <code class="language-plaintext highlighter-rouge">*</code> or <code class="language-plaintext highlighter-rouge">+</code> either.</p>

<p>But! Per our lessons above, likely only a few types flow through this code. Say
the JIT’s profiler notices that <code class="language-plaintext highlighter-rouge">x</code> has historically been an <code class="language-plaintext highlighter-rouge">Array</code>. Then we
might reasonably assume that it will continue to be an <code class="language-plaintext highlighter-rouge">Array</code>, so when we
compile the method, we add a run-time type check: if the type is no longer an
<code class="language-plaintext highlighter-rouge">Array</code>, jump back into the interpreter.</p>

<p>Let’s see what optimized code ZJIT can construct by combining profiling
information with the above bytecode. ZJIT operates on its own <em>high-level
intermediate representation</em> called, uncreatively, HIR. In the following HIR
snippet, we can see this very run-time type check (“guard”) for the <code class="language-plaintext highlighter-rouge">Array</code>
class:</p>

<div class="language-zjit-hir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">fn</span><span class="w"> </span><span class="nf">next_capacity</span><span class="p">@</span><span class="nn">file.rb:8:</span>
...
<span class="nl">bb3</span>(<span class="nv">v9</span><span class="p">:</span><span class="kt">BasicObject</span>,<span class="w"> </span><span class="nv">v10</span><span class="p">:</span><span class="kt">BasicObject</span>):<span class="w">  </span><span class="c1"># v9 is self and v10 is x</span>
<span class="w">  </span><span class="k">PatchPoint</span><span class="w"> </span><span class="no">NoSingletonClass</span>(<span class="no">Array</span>@0xdecaf1)
<span class="w">  </span><span class="k">PatchPoint</span><span class="w"> </span><span class="no">MethodRedefined</span>(<span class="no">Array</span>@0xdecaf2,<span class="w"> </span>length@0xdecaf3,<span class="w"> </span>cme:0xdecaf4)
<span class="w">  </span><span class="nv">v35</span><span class="p">:</span><span class="kt">ArrayExact</span><span class="w"> </span>=<span class="w"> </span><span class="k">GuardType</span><span class="w"> </span><span class="nv">v10</span>,<span class="w"> </span><span class="no">ArrayExact</span><span class="w"> </span>recompile
<span class="w">  </span><span class="nv">v36</span><span class="p">:</span><span class="kt">CInt64</span><span class="w"> </span>=<span class="w"> </span><span class="k">ArrayLength</span><span class="w"> </span><span class="nv">v35</span>
<span class="w">  </span><span class="nv">v37</span><span class="p">:</span><span class="kt">Fixnum</span><span class="w"> </span>=<span class="w"> </span><span class="k">BoxFixnum</span><span class="w"> </span><span class="nv">v36</span>
<span class="w">  </span><span class="c1"># ...</span>
</code></pre></div></div>

<p>(with real pointers replaced by fake ones for readability)</p>

<p>Because classes are, among other things, collections of methods, this type
information tells us what the call target of <code class="language-plaintext highlighter-rouge">x.length</code> is: it’s
<code class="language-plaintext highlighter-rouge">Array#length</code>! We have a special fast code snippet to read an array’s length
so we do that instead of a method call.</p>

<p>And in case the methods ever get changed out from underneath us, we leave
behind these markers called <code class="language-plaintext highlighter-rouge">PatchPoint</code> that invalidate the code.</p>

<p>Finally, because we know that the result of <code class="language-plaintext highlighter-rouge">Array#length</code> is always a small
integer (<code class="language-plaintext highlighter-rouge">Fixnum</code>), we can special case the method lookups for <code class="language-plaintext highlighter-rouge">*</code> and <code class="language-plaintext highlighter-rouge">+</code> as
well.</p>

<div class="language-zjit-hir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">fn</span><span class="w"> </span><span class="nf">next_capacity</span><span class="p">@</span><span class="nn">file.rb:8:</span>
...
<span class="nl">bb3</span>(<span class="nv">v9</span><span class="p">:</span><span class="kt">BasicObject</span>,<span class="w"> </span><span class="nv">v10</span><span class="p">:</span><span class="kt">BasicObject</span>):<span class="w">  </span><span class="c1"># v9 is self and v10 is x</span>
<span class="w">  </span><span class="k">PatchPoint</span><span class="w"> </span><span class="no">NoSingletonClass</span>(<span class="no">Array</span>@0xdecaf)
<span class="w">  </span><span class="k">PatchPoint</span><span class="w"> </span><span class="no">MethodRedefined</span>(<span class="no">Array</span>@0xdecaf,<span class="w"> </span>length@0xdecaf,<span class="w"> </span>cme:0xdecaf)
<span class="w">  </span><span class="nv">v35</span><span class="p">:</span><span class="kt">ArrayExact</span><span class="w"> </span>=<span class="w"> </span><span class="k">GuardType</span><span class="w"> </span><span class="nv">v10</span>,<span class="w"> </span><span class="no">ArrayExact</span><span class="w"> </span>recompile
<span class="w">  </span><span class="nv">v36</span><span class="p">:</span><span class="kt">CInt64</span><span class="w"> </span>=<span class="w"> </span><span class="k">ArrayLength</span><span class="w"> </span><span class="nv">v35</span>
<span class="w">  </span><span class="nv">v37</span><span class="p">:</span><span class="kt">Fixnum</span><span class="w"> </span>=<span class="w"> </span><span class="k">BoxFixnum</span><span class="w"> </span><span class="nv">v36</span>
<span class="w">  </span><span class="nv">v18</span><span class="p">:</span><span class="kt">Fixnum</span>[2]<span class="w"> </span>=<span class="w"> </span><span class="k">Const</span><span class="w"> </span><span class="no">Value</span>(2)
<span class="w">  </span><span class="k">PatchPoint</span><span class="w"> </span><span class="no">MethodRedefined</span>(<span class="no">Integer</span>@0xdecaf,<span class="w"> </span>*@0xdecaf,<span class="w"> </span>cme:0xdecaf)
<span class="w">  </span><span class="nv">v41</span><span class="p">:</span><span class="kt">Fixnum</span><span class="w"> </span>=<span class="w"> </span><span class="k">FixnumMult</span><span class="w"> </span><span class="nv">v37</span>,<span class="w"> </span><span class="nv">v18</span>
<span class="w">  </span><span class="nv">v23</span><span class="p">:</span><span class="kt">Fixnum</span>[1]<span class="w"> </span>=<span class="w"> </span><span class="k">Const</span><span class="w"> </span><span class="no">Value</span>(1)
<span class="w">  </span><span class="k">PatchPoint</span><span class="w"> </span><span class="no">MethodRedefined</span>(<span class="no">Integer</span>@0xdecaf,<span class="w"> </span>+@0xdecaf,<span class="w"> </span>cme:0xdecaf)
<span class="w">  </span><span class="nv">v45</span><span class="p">:</span><span class="kt">Fixnum</span><span class="w"> </span>=<span class="w"> </span><span class="k">FixnumAdd</span><span class="w"> </span><span class="nv">v41</span>,<span class="w"> </span><span class="nv">v23</span>
<span class="w">  </span><span class="k">CheckInterrupts</span>
<span class="w">  </span><span class="k">Return</span><span class="w"> </span><span class="nv">v45</span>
</code></pre></div></div>

<p>There you have it. This is how JITs work: observe, assume, specialize. So why
am I telling you all this? How does this circle back to blocks?</p>

<p>Well, blocks work not quite the same way, but similarly. Instead of having an
object that we call a method on, we just have the target <em>instruction
sequence</em><sup id="fnref:procs-and-such"><a href="#fn:procs-and-such" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>. But if we observe that the block argument, in its
special location, has been consistently one object, we can specialize the call
to it.</p>

<p>This is more or less fine for some cases of code. For example, in the following
code snippet, we only have one caller to <code class="language-plaintext highlighter-rouge">custom_iterator</code>, so its profiled
block will be <em>monomorphic</em> (one observed shape).</p>

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

<span class="n">custom_iterator</span> <span class="k">do</span> <span class="o">|</span><span class="n">x</span><span class="o">|</span>
  <span class="nb">puts</span> <span class="n">x</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This reinforces what we know and love about the Smalltalk-80 paper: code
locality wins! Yes!</p>

<p>But unfortunately this falls apart when we start thinking about all of the
core library methods (and potentially the methods and classes you have
stashed away in the grab-bag <code class="language-plaintext highlighter-rouge">utils.rb</code> in your application). Those methods are
probably <em>megamorphic</em> (many many observed shapes). They probably see all sorts
of stuff because they are general-purpose utilities that everybody needs, all
the time.</p>

<p>One such example in the Ruby core library is the venerable <code class="language-plaintext highlighter-rouge">Array#each</code>
that we saw earlier. Because there are a million different call sites to
<code class="language-plaintext highlighter-rouge">Array#each</code> across your application and each probably passes a totally
different block, we’re in an unhappy situation. How can we possibly optimize
for so many different blocks? What happened to our code locality? How do we fix
this?</p>

<h2 id="the-code-locality-is-in-another-tower">The code locality is in another tower</h2>

<p>It’s okay. Code locality still rules. Look at all the various callers of
<code class="language-plaintext highlighter-rouge">Array#each</code>. They all pass a different, but constant<sup id="fnref:not-always-constant"><a href="#fn:not-always-constant" class="footnote" rel="footnote" role="doc-noteref">2</a></sup>,
block at the call-site:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">method_a</span><span class="p">(</span><span class="n">some_array</span><span class="p">)</span>
  <span class="n">some_array</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">x</span><span class="o">|</span>
    <span class="nb">puts</span> <span class="n">x</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">method_b</span><span class="p">(</span><span class="n">some_array</span><span class="p">)</span>
  <span class="n">some_array</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">x</span><span class="o">|</span>
    <span class="nb">puts</span> <span class="n">x</span><span class="o">*</span><span class="mi">2</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">method_c</span><span class="p">(</span><span class="n">some_array</span><span class="p">)</span>
  <span class="n">some_array</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">x</span><span class="o">|</span>
    <span class="nb">puts</span> <span class="n">x</span><span class="o">*</span><span class="mi">4</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>So the code locality that we need in <code class="language-plaintext highlighter-rouge">Array#each</code> to optimize the code is one
level up at the caller. If we can use that <em>call context</em>, we can specialize
the code.</p>

<p>YJIT accomplishes this by <em>splitting</em>, which is a very natural transformation
for basic block versioning and tracing. Such compilers are good at following
code paths as they would be executed and putting together context across method
calls. However, YJIT’s heuristic for splitting blocks is based on
manual annotations: it will only kick in for certain Ruby library functions
specially annotated with <code class="language-plaintext highlighter-rouge">Primitive.attr! :inline_block</code> (and the name is a bit
of a misnomer).</p>

<p>The team that builds ZJIT, a method JIT, decided not to add splitting
facilities. We could split methods (and blocks), but we have an easier time
reasoning about larger code units than YJIT does because we optimize an entire
method at once. So instead, ZJIT chooses to get call context by <em>inlining</em>.</p>

<h2 id="inlining">Inlining</h2>

<p>Method inlining refers to copying the body of the callee into the caller. In
the above example, it means copying the body of <code class="language-plaintext highlighter-rouge">Array#each</code> into each of
<code class="language-plaintext highlighter-rouge">method_a</code>, <code class="language-plaintext highlighter-rouge">method_b</code>, and <code class="language-plaintext highlighter-rouge">method_c</code>. I don’t mean the Ruby code and I don’t
mean the bytecode: I mean the HIR. ZJIT does this by building the HIR of the
callee (<code class="language-plaintext highlighter-rouge">Array#each</code>) into the existing HIR of the caller (<code class="language-plaintext highlighter-rouge">method_a</code>, …).</p>

<p>The illustrious <a href="/authors/kevin-menard/">Kevin Menard</a> wrote ZJIT’s inliner and
he’ll write a post about all the details soon. It’s pretty interesting stuff.</p>

<p>For now, we can take a look at the (lightly edited) result of <code class="language-plaintext highlighter-rouge">Array#each</code>
being inlined into <code class="language-plaintext highlighter-rouge">method_a</code>. The details don’t matter, but there are two
things I want to call out:</p>

<ul>
  <li>What used to be a dynamic <code class="language-plaintext highlighter-rouge">invokeblock</code> is now what we call
<code class="language-plaintext highlighter-rouge">InvokeBlockIseqDirect</code> because we know from the call context what block
<code class="language-plaintext highlighter-rouge">method_a</code> is passing to <code class="language-plaintext highlighter-rouge">Array#each</code>.</li>
  <li>What used to be a method call is now a loop: the condition check is in
<code class="language-plaintext highlighter-rouge">bb10</code>, the body is in <code class="language-plaintext highlighter-rouge">bb9</code>, and the stuff after the loop is <code class="language-plaintext highlighter-rouge">bb13</code>/<code class="language-plaintext highlighter-rouge">bb4</code>.
If you’re interested, try to find the iteration variable and where it gets
incremented. See the footnote<sup id="fnref:iteration-variable"><a href="#fn:iteration-variable" class="footnote" rel="footnote" role="doc-noteref">3</a></sup> for the answer.</li>
</ul>

<p>This is a massive improvement over the previous very generic operations.</p>

<div class="language-zjit-hir highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">fn</span><span class="w"> </span><span class="nf">method_a</span><span class="p">@</span><span class="nn">/tmp/arrayeach.rb:2:</span>
<span class="c1"># ...</span>
<span class="nl">bb3</span>(<span class="nv">v9</span><span class="p">:</span><span class="kt">BasicObject</span>,<span class="w"> </span><span class="nv">v10</span><span class="p">:</span><span class="kt">BasicObject</span>):
<span class="w">  </span><span class="c1"># ...</span>
<span class="w">  </span><span class="nv">v55</span><span class="p">:</span><span class="kt">Fixnum</span>[0]<span class="w"> </span>=<span class="w"> </span><span class="k">Const</span><span class="w"> </span><span class="no">Value</span>(0)
<span class="w">  </span><span class="k">Jump</span><span class="w"> </span><span class="nl">bb10</span>(<span class="nv">v25</span>,<span class="w"> </span><span class="nv">v55</span>)
<span class="nl">bb10</span>(<span class="nv">v68</span><span class="p">:</span><span class="kt">ArrayExact</span>,<span class="w"> </span><span class="nv">v69</span><span class="p">:</span><span class="kt">Fixnum</span>):
<span class="w">  </span><span class="nv">v73</span><span class="p">:</span><span class="kt">CInt64</span><span class="w"> </span>=<span class="w"> </span><span class="k">ArrayLength</span><span class="w"> </span><span class="nv">v68</span>
<span class="w">  </span><span class="nv">v74</span><span class="p">:</span><span class="kt">Fixnum</span><span class="w"> </span>=<span class="w"> </span><span class="k">BoxFixnum</span><span class="w"> </span><span class="nv">v73</span>
<span class="w">  </span><span class="nv">v75</span><span class="p">:</span><span class="kt">BoolExact</span><span class="w"> </span>=<span class="w"> </span><span class="k">FixnumGe</span><span class="w"> </span><span class="nv">v69</span>,<span class="w"> </span><span class="nv">v74</span>
<span class="w">  </span><span class="nv">v77</span><span class="p">:</span><span class="kt">CBool</span><span class="w"> </span>=<span class="w"> </span><span class="k">Test</span><span class="w"> </span><span class="nv">v75</span>
<span class="w">  </span><span class="k">CondBranch</span><span class="w"> </span><span class="nv">v77</span>,<span class="w"> </span><span class="nl">bb13</span>(),<span class="w"> </span><span class="nl">bb9</span>(<span class="nv">v68</span>,<span class="w"> </span><span class="nv">v69</span>)
<span class="nl">bb13</span>():
<span class="w">  </span><span class="k">CheckInterrupts</span>
<span class="w">  </span><span class="k">Jump</span><span class="w"> </span><span class="nl">bb4</span>(<span class="nv">v68</span>)
<span class="nl">bb9</span>(<span class="nv">v90</span><span class="p">:</span><span class="kt">ArrayExact</span>,<span class="w"> </span><span class="nv">v91</span><span class="p">:</span><span class="kt">Fixnum</span>):
<span class="w">  </span><span class="nv">v96</span><span class="p">:</span><span class="kt">CInt64</span><span class="w"> </span>=<span class="w"> </span><span class="k">UnboxFixnum</span><span class="w"> </span><span class="nv">v91</span>
<span class="w">  </span><span class="nv">v97</span><span class="p">:</span><span class="kt">BasicObject</span><span class="w"> </span>=<span class="w"> </span><span class="k">ArrayAref</span><span class="w"> </span><span class="nv">v90</span>,<span class="w"> </span><span class="nv">v96</span>
<span class="w">  </span><span class="nv">v99</span><span class="p">:</span><span class="kt">CPtr</span><span class="w"> </span>=<span class="w"> </span><span class="k">GetEP</span><span class="w"> </span>0
<span class="w">  </span><span class="nv">v100</span><span class="p">:</span><span class="kt">CInt64</span><span class="w"> </span>=<span class="w"> </span><span class="k">LoadField</span><span class="w"> </span><span class="nv">v99</span>,<span class="w"> </span>:<span class="no">VM_ENV_DATA_INDEX_SPECVAL</span>@-0x8
<span class="w">  </span><span class="nv">v101</span><span class="p">:</span><span class="kt">CInt64</span>[-4]<span class="w"> </span>=<span class="w"> </span><span class="k">Const</span><span class="w"> </span><span class="no">CInt64</span>(-4)
<span class="w">  </span><span class="nv">v102</span><span class="p">:</span><span class="kt">CInt64</span><span class="w"> </span>=<span class="w"> </span><span class="k">IntAnd</span><span class="w"> </span><span class="nv">v100</span>,<span class="w"> </span><span class="nv">v101</span>
<span class="w">  </span><span class="nv">v103</span><span class="p">:</span><span class="kt">BasicObject</span><span class="w"> </span>=<span class="w"> </span><span class="k">InvokeBlockIseqDirect</span><span class="w"> </span>(0x16f3d5968),<span class="w"> </span><span class="nv">v102</span>,<span class="w"> </span><span class="nv">v97</span>
<span class="w">  </span><span class="nv">v107</span><span class="p">:</span><span class="kt">Fixnum</span>[1]<span class="w"> </span>=<span class="w"> </span><span class="k">Const</span><span class="w"> </span><span class="no">Value</span>(1)
<span class="w">  </span><span class="nv">v108</span><span class="p">:</span><span class="kt">Fixnum</span><span class="w"> </span>=<span class="w"> </span><span class="k">FixnumAdd</span><span class="w"> </span><span class="nv">v91</span>,<span class="w"> </span><span class="nv">v107</span>
<span class="w">  </span><span class="k">PatchPoint</span><span class="w"> </span><span class="no">NoEPEscape</span>(each)
<span class="w">  </span><span class="k">Jump</span><span class="w"> </span><span class="nl">bb10</span>(<span class="nv">v90</span>,<span class="w"> </span><span class="nv">v108</span>)
<span class="nl">bb4</span>(<span class="nv">v114</span><span class="p">:</span><span class="kt">BasicObject</span>):
<span class="w">  </span><span class="k">PopInlineFrame</span>
<span class="w">  </span><span class="k">PatchPoint</span><span class="w"> </span><span class="no">NoEPEscape</span>(method_a)
<span class="w">  </span><span class="k">CheckInterrupts</span>
<span class="w">  </span><span class="k">Return</span><span class="w"> </span><span class="nv">v114</span>
</code></pre></div></div>

<p>You may notice that there is still one call in the loop: the call to the block
(<code class="language-plaintext highlighter-rouge">InvokeBlockIseqDirect</code>). That’s next on our list to tackle. We are optimistic
that we will soon also be able to inline block calls. Then the whole thing will
<em>really</em> be just a loop!</p>

<p>The code that enables us to reason about which block got passed into the
inlined callee (<code class="language-plaintext highlighter-rouge">Array#each</code>) only landed <a href="https://github.com/ruby/ruby/commit/c35fbd6fb27a13842299597d46b49a10f018c5b0">a couple of days
ago</a>
(July 10, 2026), written by <a href="https://github.com/luke-gruber">Luke Gruber</a>. This
was one of Luke’s first changes to ZJIT.</p>

<h2 id="so-what-does-performance-look-like">So what does performance look like?</h2>

<p>Well, for starters, the microbenchmarks that we use as “performance unit tests”
for specific aspects of Ruby went wild. Some benchmarks that were using
block-based looping got <em>much</em> faster; they were previously bounded by ZJIT’s
block invocation performance.</p>

<p>Take a look at the <code class="language-plaintext highlighter-rouge">cfunc_itself</code> benchmark, which tests that we can fold away
the call to Ruby’s built-in <code class="language-plaintext highlighter-rouge">itself</code> method. ZJIT ends up optimizing the
<code class="language-plaintext highlighter-rouge">times</code> method to invoke the <code class="language-plaintext highlighter-rouge">do</code> block directly, and the <code class="language-plaintext highlighter-rouge">do</code> block gets
optimized to nothing but a guard on the self’s class to make sure it hasn’t
changed.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">run_benchmark</span><span class="p">(</span><span class="mi">500</span><span class="p">)</span> <span class="k">do</span>
  <span class="mi">500000</span><span class="p">.</span><span class="nf">times</span> <span class="k">do</span> <span class="o">|</span><span class="n">i</span><span class="o">|</span>
    <span class="n">itself</span>
    <span class="n">itself</span>
    <span class="n">itself</span>
    <span class="n">itself</span>
    <span class="n">itself</span>
    <span class="n">itself</span>
    <span class="n">itself</span>
    <span class="n">itself</span>
    <span class="n">itself</span>
    <span class="n">itself</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Because we had previously optimized the body away, the result of the direct
block invocation is a massive speedup:</p>

<figure><img src="cfunc_itself_benchmark.png" alt="The yellow line represents ZJIT speedup over the interpreter. Higher is
better. 35x!" width="80%"><figcaption>The yellow line represents ZJIT speedup over the interpreter. Higher is
better. 35x!</figcaption></figure>

<p>Other benchmarks also kind of stop making sense because of the amount of
inlining. Our bmethod benchmark, which benchmarked how fast we can call methods
defined with <code class="language-plaintext highlighter-rouge">define_method</code>, also stopped measuring anything of use. We’re
going to have to rework the benchmark to be more fair…</p>

<figure><img src="send_bmethod_benchmark.png" alt="The yellow line represents ZJIT speedup over the interpreter. Higher is
better. 139x!" width="80%"><figcaption>The yellow line represents ZJIT speedup over the interpreter. Higher is
better. 139x!</figcaption></figure>

<p>As expected, larger Rails benchmarks don’t see a ton of change; they exercise a
diffuse set of features so optimizing any one feature bumps the big benchmarks
only a little bit.</p>

<p>I am excited to see what happens when we can fully turn <code class="language-plaintext highlighter-rouge">each</code> and friends into
call-less loops!</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>Code locality rules. The inliner helps inject more of it and reason across
method calls. ZJIT, a little over one year old, is growing up! :’)</p>

<p>We’re still tuning the inliner knobs. Some of the code in this post required
tweaking <code class="language-plaintext highlighter-rouge">--zjit-inline-threshold</code> to convince the compiler to inline
<code class="language-plaintext highlighter-rouge">Array#each</code> into <code class="language-plaintext highlighter-rouge">method_a</code> because of <code class="language-plaintext highlighter-rouge">Array#each</code>’s size. It will take some
time for the ZJIT developers to figure out reasonable defaults.</p>

<p>Try out our HIR explorer at <a href="https://tryzjit.fly.dev/">tryzjit.fly.dev</a>. Try
out ZJIT in your application by adding the <code class="language-plaintext highlighter-rouge">--zjit</code> flag to a Ruby over 4.0.
Thanks for reading and see you next time.</p>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:procs-and-such">
      <p>Mostly. I am glossing over <code class="language-plaintext highlighter-rouge">nil</code>, procs, ifuncs, etc. But
the common path is by and away iseq blocks. <a href="#fnref:procs-and-such" class="reversefootnote" role="doc-backlink">↩</a></p>
    </li>
    <li id="fn:not-always-constant">
      <p>It’s not always the case that these methods are called
with a constant block iseq. Sometimes they are called with the <code class="language-plaintext highlighter-rouge">&amp;:symbol</code>
form, for example. Or with the <code class="language-plaintext highlighter-rouge">&amp;some_block_variable</code> form. In that case,
we can use an inline cache to (with a guard) make it constant once more.
But we have not implemented that yet, because it is rarer. <a href="#fnref:not-always-constant" class="reversefootnote" role="doc-backlink">↩</a></p>
    </li>
    <li id="fn:iteration-variable">
      <p>The iteration variable is <code class="language-plaintext highlighter-rouge">v91</code>! You can see it get used
as an array index when it gets unboxed as <code class="language-plaintext highlighter-rouge">v96</code> and passed to <code class="language-plaintext highlighter-rouge">ArrayAref</code>.
You can also see it get incremented with <code class="language-plaintext highlighter-rouge">FixnumAdd</code> and the constant <code class="language-plaintext highlighter-rouge">1</code>.
In <code class="language-plaintext highlighter-rouge">bb10</code> it has a different name, <code class="language-plaintext highlighter-rouge">v69</code>, which gets checked against the
array length. <a href="#fnref:iteration-variable" 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-07-28-the-inliner-is-yielding-benefits" /><summary type="html"><![CDATA[ZJIT's new method inliner helps us specialize even megamorphic block invocations.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://railsatscale.com/2026-07-28-the-inliner-is-yielding-benefits/d61fec98ac88dc8361e445aee7081b372b3cc16f.png" /><media:content medium="image" url="https://railsatscale.com/2026-07-28-the-inliner-is-yielding-benefits/d61fec98ac88dc8361e445aee7081b372b3cc16f.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><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></feed>