December 25, 2022·6 min read·engineeringrubyregexyjitwebassembly

Ruby 3.2 bounds regex work and reduces YJIT memory overhead

Ruby 3.2 pairs a memoized regex matcher with timeout controls and moves YJIT out of experimental status. The release also brings CRuby to WASI with explicit limits on the initial port.

A regex matcher can revisit the same position in its input through many possible execution paths. Repeating equivalent work is what makes some apparently small patterns consume disproportionate time. Ruby 3.2 records selected matching states so that the engine can avoid repeating them, while retaining a timeout for patterns the optimization cannot handle.

The December 25 release announcement makes both mechanisms part of Ruby 3.2.0. It also removes YJIT's experimental status and includes the initial WASI-based WebAssembly port. These changes have different scopes, but each makes runtime costs or execution requirements more explicit. The most important details are the limits and mechanisms behind the release's performance claims.

I like Ruby's readable collection operations and blocks, but readability can conceal runtime work. A short expression need not have a small execution cost. The regex change is useful because it addresses repeated work inside the engine, where rewriting every affected application pattern individually would leave the same underlying risk in other libraries.

Memoization changes the matching algorithm

The proposal behind the optimization explains a matcher's state in terms of input position and execution state. If the engine repeatedly arrives at the same relevant pair, it can repeat work that has already failed. Caching that information avoids revisiting equivalent possibilities.

The release describes the resulting improvement as linear-time matching for most patterns in the project's experiments. Its approximate ninety-percent figure belongs to those experiments. It should not be interpreted as proof that any arbitrary pattern selected by an application will receive the same treatment.

The proposal also explains why this is different from picking a timeout. A useful time budget depends on input size and the environment in which the operation runs. Improving the algorithm removes repeated work for eligible patterns without requiring the application to decide which duration distinguishes ordinary execution from abuse.

Memoization has a memory cost. The release says the optimization may allocate memory proportional to the input length, with allocation normally delayed. Reducing repeated computation by recording states trades additional storage for less search work. It does not make the matcher independent of the size of the text being processed.

That tradeoff deserves to remain in the explanation. An application previously exposed to excessive matching time benefits from avoiding redundant paths, but the engine still needs resources to remember the relevant states. The optimization is a change in algorithm and resource use, not a general guarantee that matching arbitrary input is free of operational limits.

Some patterns still need a time budget

The release explicitly excludes some regex features from the optimization, including backreferences and look-around. Very large fixed repetition counts can also prevent it from applying. Those features need additional matching information or create cases beyond the optimization's supported model.

Ruby 3.2 therefore also provides a timeout mechanism. A process can set a global limit, while an individual constructed regex can use a different setting.

Regexp.timeout = 1.0

identifier = Regexp.new("[A-Z]+", timeout: 0.25)
p identifier.match?("READY")

When a match exceeds the configured limit, Ruby raises Regexp::TimeoutError. That adds a failure outcome beyond an ordinary unsuccessful match. Callers using a budget need to preserve the distinction between finding no match and stopping an operation that did not complete within its limit.

The final release announcement uses Float::INFINITY as an explicit instance-level escape from the global timeout. That is a consequential override, because it removes interruption by this mechanism for the selected regex. The example is part of the final API account, so it should not be replaced with an assumption copied from an earlier preview.

The two protections are complementary. Memoization changes how much repeated work an eligible pattern performs. The timeout bounds the duration permitted for a match, including unsupported cases. The release does not claim that timeout configuration transforms those unsupported patterns into linear-time algorithms.

I regard that distinction as the practical strength of the design. An application can benefit from a better matcher while still expressing a limit for work the matcher cannot optimize. Neither mechanism has to be described as solving every possible text-processing workload.

YJIT changes how executable memory is used

YJIT is no longer experimental in this release. The announcement attributes that decision to more than a year of production workload testing. That is the project's experience, not a claim about any deployment of mine. It supports x86-64 and arm64/aarch64 on the Unix platforms described in the notice.

The YJIT documentation shipped with Ruby 3.2.0 describes lazy compilation using basic block versioning. It targets applications such as Ruby on Rails servers. Generated machine code and compiler state use memory beyond what the interpreter alone needs, so the memory policy remains part of the runtime tradeoff.

The release changes physical memory allocation for generated code. Reserving executable address space through --yjit-exec-mem-size no longer means mapping all of it to physical pages immediately. Pages become physically backed as generated code uses them. That can reduce resident memory for a process that uses only part of the reserved region.

Code garbage collection handles another part of the budget. The release says it frees all code pages when generated-code memory reaches the configured limit. The YJIT documentation notes that a smaller executable-memory allocation can lead to more frequent collection, with a performance tradeoff. A lower configured budget therefore affects runtime behaviour beyond the number reported as reserved memory.

Statistics make this behaviour inspectable. RubyVM::YJIT.runtime_stats includes code-collection information, and many statistics are available in release builds through --yjit-stats. The announcement explicitly notes overhead from collecting statistics. Observability itself is an execution choice with a cost.

The published benchmark has a defined comparison

The release reports a geometric-mean speedup of forty-one percent over the interpreter on yjit-bench, together with lower memory overhead than the previous YJIT release. Those are project benchmark results. They do not establish the same improvement for an arbitrary application, and they should not be blended into one unsupported claim about all Ruby programs.

The mechanism provides a more useful basis for understanding the change. Lazy physical allocation can reduce unused resident pages. Code collection constrains generated code under the configured budget. Finer-grained constant invalidation reduces the code that must be invalidated when constants are defined. Each addresses a specific source of runtime cost.

Building YJIT now requires Rust 1.58.0 or newer, according to the final announcement. A suitable Rust compiler must be present before configuration to ensure the build includes YJIT. That requirement concerns constructing the interpreter with its compiler component, and should not be confused with a requirement to rewrite application code in Rust.

WASI remains an initial port

The WebAssembly support makes a CRuby binary available to WASI hosts, including the browser-playground use described in the announcement. WASI supplies a system interface, while CRuby uses Asyncify to accommodate missing execution facilities needed for fibers, exceptions and garbage collection. A virtual filesystem helps package an application into a .wasm file.

The release is explicit about coverage. The initial port passes basic and bootstrap tests that do not use the Thread API. Shipping in a stable Ruby release does not remove that qualification or establish that every native extension and operating-system interaction is available in the new environment.

That distinction separates the interpreter's implementation from the facilities offered by its host. Porting CRuby preserves the actual interpreter in a new embedding environment, but the embedding still determines which system features can be supplied. The announcement's test scope gives a concrete boundary for what the project claims today.

Package installation also changes in this release through Bundler 2.4's move to the PubGrub resolver. The announcement warns that resolution results can differ and asks users to report such cases. RubyGems itself still uses Molinillo, so describing the entire package toolchain as having switched would be inaccurate. This is another reason to identify the component responsible for an observed behaviour. Interpreter execution, JIT compilation and dependency solving can all change within one Ruby distribution while retaining separate implementations and different compatibility consequences for an application.

Ruby 3.2 therefore offers several different controls over execution. The matcher caches supported states to avoid repeated search, timeouts limit individual matches, and YJIT manages generated code through lazy memory allocation and code collection. The WASI port addresses a different question, adapting the interpreter to a host whose control-flow and system facilities differ from its existing native environments.