December 25, 2023·6 min read·engineeringrubyyjitprismruntime

Ruby 3.3 makes YJIT's compilation budget more explicit

Ruby 3.3 changes YJIT's memory-limit behavior and compilation selection, distributes Prism for tooling, and retains explicit compatibility limits around experimental runtime work.

A JIT's memory limit defines a policy as well as a capacity. When compiled code fills the available region, the runtime can discard code and start again, or stop compiling new code while retaining what it has. Ruby 3.3 changes that default choice for YJIT, making the executable-memory limit a boundary for further compilation.

The December 25 release announcement introduces that change alongside broader YJIT improvements and the Prism parser. RJIT replaces MJIT for experimental work, while YJIT remains the recommended production JIT. The release develops several runtime implementations without treating every new component as the production default.

I find Ruby's readable syntax valuable, but the runtime still needs a defensible account of its resource use. The YJIT changes interest me because they explain when compilation starts, which code it considers, and what happens when its code budget is full.

Reaching the code limit no longer triggers collection by default

The YJIT documentation tagged with Ruby 3.3.0 says code garbage collection is disabled by default. When the code region reaches the executable-memory limit, YJIT stops compiling new code.

Previously, code collection could discard machine code and cause compilation to start over. The release announcement associates disabling that behavior with avoiding sudden performance drops and improving copy-on-write behavior in reforking servers.

The option remains available for applications that want it. Enabling yjit-code-gc restores a policy that discards machine code when the limit is reached. The tagged documentation explains that this can accommodate a smaller executable-memory limit at the cost of a possible performance decline when collection happens.

That is a real tradeoff between retaining compiled work and reusing the region for newly encountered code. The default favors retaining existing code. It does not claim that a finite compiled-code region can contain every operation in an application forever.

The following command explicitly requests the collection behavior that is no longer the default. The option changes what happens when the executable region fills.

ruby --yjit --yjit-code-gc application.rb

Its purpose is to choose the memory-management policy. It is not a general instruction that every application should enable collection after upgrading.

Executable memory is only part of JIT overhead

The tagged documentation gives a default executable-memory region of sixty-four MiB. It also states that YJIT allocates metadata in addition to generated code. The executable-memory option therefore does not cap the Ruby process's total memory or even every allocation associated with YJIT.

The release reduces metadata use and generates more compact ARM64 code. Those are separate improvements to the cost of retaining compiled work. A smaller instruction sequence and less supporting metadata both matter, but the memory limit's meaning remains specific.

This distinction is useful in a multi-worker service. Each process has its own runtime resources, so a setting that looks small in isolation can contribute to a larger application footprint. The documentation explicitly discusses the need to account for metadata and worker counts.

The release also makes more allocation-related statistics available by default. These observations help explain what the JIT retains, rather than forcing every memory question into the size of the executable region.

Compilation becomes more selective

YJIT's call threshold starts at thirty and rises to one hundred and twenty when the process reaches forty thousand instruction sequences, according to the tagged documentation. A larger application therefore requires more calls before a function is compiled.

The release also adds a cold threshold to avoid compiling cold instruction sequences. These policies decide where to spend compilation effort and memory. They address the fact that not every method encountered by a large application has equal value as compiled code.

Selective compilation complements the new full-region behavior. If the runtime retains compiled code once the region fills, choosing useful code before that point becomes consequential. Thresholds provide a mechanism for making that choice without requiring application source annotations on every method.

The announcement does not give one application-wide speedup factor from these policies. It reports a result above three times the interpreter on Optcarrot, which remains a named benchmark result. The mechanisms explain why performance can improve without converting that benchmark into a promise for unrelated programs.

Runtime enablement separates boot from sustained execution

RubyVM::YJIT.enable can start YJIT from Ruby code. The release describes using it after application boot, avoiding compilation of initialization work that may not be used again.

The disable option allows tuning arguments to be present while leaving YJIT off at startup. A process can then enable it at an application-selected point.

ruby --yjit-disable --yjit-exec-mem-size=64 application.rb
RubyVM::YJIT.enable

Those two controls answer different questions. The command configures the region and initial state, while the API call decides when compilation becomes active. They do not change the application's Ruby semantics into a different language mode.

I prefer this separation to treating the whole process lifetime as one uniform optimization target. Startup and repeated request handling can contain different code. Giving the application an explicit enablement point lets that difference affect what the JIT compiles.

Broader compiled execution supports the budget decisions

Ruby 3.3 also improves support for splat, rest, and optional arguments and compiles exception handlers. Unsupported call types and megamorphic call sites no longer force the exits described in the release notes.

Specific operations receive optimized treatment, including integer multiplication, string byte access, and common type and block checks. The announcement also describes inlining of basic Rails blank? and specialized present? behavior.

These changes concern what generated code can do once execution reaches it. The memory and threshold policies concern what code is generated and retained. Both matter to a useful JIT, but they are different parts of the implementation.

The statistics interface is correspondingly useful. The ratio_in_yjit statistic is available in release builds through the statistics option, and profiling support includes Linux perf integration and sampled exit tracing. These are ways to investigate compiled execution, with the tagged documentation noting runtime costs for gathering statistics.

Prism gives tools a portable parsing implementation

The release distributes Prism as a default gem. Its 0.19.0 project documentation describes a C99 parser with no dependencies and separate shared-library and CRuby-extension forms.

The shared library does not bind to CRuby, while the native extension links against Ruby's headers. That division allows projects outside the CRuby runtime to use the parser implementation without importing CRuby's whole extension environment.

The release calls Prism portable and error tolerant, and describes it as suitable for tooling in place of Ripper. Tools often need to examine source that is incomplete or invalid, so a parser interface is useful independently of executing that source as an application.

The documented APIs include parsing into a result containing the syntax tree, retrieving comments, and checking whether source parses without errors. Each operation returns the kind of information its caller requested.

require "prism"

source = "items.map { |item| item.to_s }"
result = Prism.parse(source)
valid = Prism.parse_success?(source)
comments = Prism.parse_comments(source)

This example uses the public operations listed by the release announcement. It does not depend on a particular internal node layout or assume that every Ruby source tool already consumes Prism's tree.

Garbage collection also changes object aging

The release changes how young objects referenced by old objects enter the older generation. They are no longer immediately promoted, which the announcement associates with fewer major collections. It also adds write barriers to core types including Time and MatchData. Those changes affect management of ordinary Ruby objects, separately from YJIT's compiled-code collection policy. Using the same word for both kinds of collection should not conceal the different memory being managed.

Parser distribution and execution selection remain distinct

Ruby 3.3 offers the parser=prism option for experimenting with Prism compilation, but the announcement labels it debugging-only. That qualification should remain attached to the option. Shipping a production-ready parser library for tools is a different claim from making every compiler integration path the default.

Lrama also remains part of the release. It replaces Bison as the LALR parser generator, while Prism is a separate recursive-descent parser implementation. The two names describe different components and responsibilities.

RJIT has a similarly explicit status. It is a pure-Ruby JIT for experimental purposes, restricted to x86_64 Unix platforms and not requiring a runtime C compiler. The project still tells production users to choose YJIT.

The release's M:N scheduler likewise remains disabled by default on the main Ractor because of native-extension compatibility concerns. Its presence does not mean all existing application threads silently switch scheduling models.

Those boundaries make the release usable without conflating its different kinds of maturity. YJIT's new default code policy applies to its production-oriented compilation model. Prism supplies a parser library and a debugging-only compiler experiment, while RJIT and optional main-Ractor scheduling retain their stated limits.