October 14, 2021·4 min read·engineeringrubyjitcompilersperformance

YJIT specializes the Ruby code that execution actually reaches

Shopify's October account of YJIT explains a compiler focused on application workloads and fast warmup. Lazy basic block versioning specializes observed paths while keeping the CRuby interpreter available for unsupported work.

A Ruby compiler needs to preserve dynamic behavior while avoiding the cost of repeatedly rediscovering types at runtime. It also needs to compile cheaply enough that applications benefit before their work is over. Shopify's October 14 account of YJIT presents a design centered on those constraints, with Rails workloads and startup behavior among its priorities.

Maxime Chevalier-Boisvert describes a compiler built inside CRuby by contributors from Shopify and GitHub. The article reports encouraging application-oriented benchmarks and an invitation to integrate the work into Ruby 3.1. That is the state of the project on this date, rather than a claim that a final Ruby 3.1 release already contains it.

I find Ruby's readable object model useful, but its execution costs remain a reason to question where it fits. YJIT interests me because it tries to improve those costs within the implementation people already run. Compatibility is part of the compiler's design problem, alongside the machine code it produces.

Lazy versioning retains information along an executed path

The underlying basic block versioning paper, by Chevalier-Boisvert and Marc Feeley, describes tracking type information while generating code. A type test establishes information for a successor path. A specialized version of the next block can retain that information and omit a redundant test when the answer is already known.

Several versions of a block can represent different incoming type contexts. Lazy generation creates a version only when execution requires it. This avoids preparing every possible combination in advance. A version limit bounds pathological growth, with less specialized code available when retaining another precise context would exceed that policy.

The paper evaluates this technique in a JavaScript implementation. Its measured results should not be presented as Ruby performance numbers. Its relevance here is the code-generation method that YJIT adapts to Ruby's runtime, not an assumption that two languages or implementations share identical results.

CRuby remains available for work YJIT cannot compile

The proposal to merge YJIT explains that it translates YARV instructions into machine code and returns control to the interpreter for unsupported instructions. This supports incremental coverage without requiring a complete second implementation of Ruby before the compiler can execute useful paths.

That arrangement changes how incomplete support should be understood. An unsupported instruction need not imply rejecting a valid Ruby program. It can mean performing that work through the existing interpreter. Coverage then determines how much execution benefits from compilation, while correctness still requires preserving the transition between compiled and interpreted work.

The proposal also identifies integration hooks for events such as changes to constants and redefinition of basic operations. Specialized code depends on assumptions about the runtime. When those assumptions change, the implementation must stop using code that relied on them. Direct integration with CRuby gives the compiler access to those notifications.

The implementation exposes its specialization policy

A contemporary YJIT README describes a compiler built into the CRuby binary, targeting macOS and Linux on x86-64. ARM64 support is a future plan. It also lists controls for the call threshold, executable-memory allocation and maximum block versions.

Those controls correspond to separate costs. A call threshold determines when a function begins to justify compilation. A version limit governs how much specialization the compiler retains for a block. An executable-memory setting bounds a resource that generated machine code consumes. Changing one is not equivalent to improving all aspects of compiler behavior.

The README identifies a significant limitation, the lack of garbage collection for generated code. Invalidated or unusable machine code does not yet have its memory reclaimed. An ordinary object collector does not automatically solve that problem because executable code has its own references and lifetime requirements. The documented limitation concerns the resource retained by compilation itself.

Warmup belongs in the performance model

The October article emphasizes quick warmup alongside improvements on application-oriented benchmarks. A compiler that generates good machine code only after expensive preparation can spend a meaningful part of a short process's life preparing it. Conversely, cheap compilation can make a smaller per-operation improvement useful earlier.

This is why a single steady-state number cannot fully describe the tradeoff. Process lifetime determines how many executions can amortize compilation. Code coverage determines which paths receive the benefit. Memory used by specialized versions adds a cost that persists alongside the application's ordinary objects.

The architecture offers concrete reasons to investigate that balance. It generates code for reached paths and retains local type context, limiting the need for a large preliminary analysis. That makes the compiler's preparation strategy relevant to application behavior, not merely an internal implementation preference.

I regard the ability to identify interpreted exits as particularly useful evidence. The README documents statistics for a debug build and disassembly for compiled methods. These facilities connect a performance explanation to what the compiler actually generated and where it returned to the interpreter, instead of assuming that enabling a JIT means all execution became native code.

Development and performance builds serve different purposes

The README distinguishes debugging and statistics builds from builds intended for maximum performance. Assertions and diagnostics help investigate correctness but carry overhead. A performance result must therefore identify the configuration whose behavior it reports. Comparing a diagnostic build to an ordinary interpreter can answer a different question from comparing release configurations.

This matters to the project's integration work as well. Interpreter compatibility and runtime invalidation need correctness evidence, while speed claims need appropriate build conditions. Neither kind of evidence substitutes for the other. The available tools make those questions inspectable separately.

The documented source organization reinforces that division. An in-memory assembler emits instructions, code-generation logic translates bytecodes, and an interface layer connects the compiler to CRuby. Each layer supplies part of the execution model.

On October 14, YJIT is an experimental compiler with an upstream integration path and stated platform limits. Its specific contribution is an execution-driven specialization strategy inside CRuby. The surrounding interpreter remains responsible for unsupported operations, and the runtime must invalidate compiled assumptions when Ruby's dynamic behavior changes them.