Source-linked AI summary

Support Local Variables

Maxwell Bernstein, Takashi Kokubun, Aaron Patterson, Si Xing "Alan" Wu, Kevin Menard

arXiv:2609.01502v1cs.PL

TL;DR

Ruby’s reflective and dynamic local-variable semantics make efficient compilation difficult, especially when environments can escape or code can alter local state. ZJIT addresses this with a method-based, SSA-oriented compiler that lifts locals into SSA values and uses speculation and deoptimization; it is faster than the interpreter and competitive with YJIT on larger applications. The approach remains subject to known compatibility bugs and slow-path costs around environment escape and inlining.

  • Problem

    Ruby local variables are difficult to compile efficiently because reflection and environment escape can expose or modify them dynamically.

  • Method

    ZJIT compiles Ruby methods with an SSA-based representation, lifting local variables into SSA values and using speculation and deoptimization.

  • Results

    ZJIT is faster than the interpreter and YJIT on microbenchmarks, and faster than the interpreter while competitive with YJIT on larger Rails-based applications.

  • Takeaways & Limitations

    The paper demonstrates a speculative SSA-based approach for Ruby locals and reports compatibility with the Ruby test suite and large web applications.

  • Takeaways & Limitations

    Environment escape requires slow deoptimization, and inlining can leave garbage in the VM frame when no frame-requiring code flushes it.

Abstract

from arXiv · show

Ruby is a dynamically typed and object-oriented programming language. Its primary implementation, CRuby, contains a bytecode virtual machine and a mature lazy basic block versioning (LBBV) just-in-time (JIT) compiler called YJIT. In order to both implement more advanced optimizations than YJIT supports and also encourage more outside contributions, we present a new method-based JIT called ZJIT. Like YJIT, ZJIT compiles from bytecode to machine code. Unlike YJIT, ZJIT has multiple global and local optimization passes. ZJIT's high-level intermediate representation is in static single assignment (SSA) form. In order to optimize Ruby's local variables, ZJIT lifts local variables into SSA values. This is a departure from how other Ruby compilers handle locals: other JIT compilers either leave local variables as memory loads and stores or do advanced partial evaluation to recover SSA values from memory. While implementing locals, we (re-)discovered what features make local variables in Ruby especially challenging to compile correctly and efficiently. We demonstrate these features and illustrate how we solved these problems in ZJIT.

1 Introduction

ZJIT is a new method-based Ruby JIT designed to support more advanced optimization while handling Ruby local-variable semantics. It lifts locals into SSA values and uses speculation and deoptimization, achieving compatibility with the Ruby test suite and large web applications.

  • Problem: Ruby local variables are challenging because they store temporary values in method frames while Ruby permits reflection and dynamic behavior.The paper focuses on semantic corner cases that must be implemented faithfully for compatibility.
  • Contribution: ZJIT is a new compiler for Ruby that aims to enable more advanced optimizations than existing YJIT supports.It is implemented in the context of CRuby and is intended to encourage outside contributions through its compiler design.
  • Approach: ZJIT lifts local variables into SSA values instead of repeatedly flushing and reloading them from the VM frame.It uses speculation and deoptimization to recover when assumptions about local variables fail.
  • Current status: ZJIT passes the Ruby test suite and runs many large web applications with only a handful of known bugs.The paper reports compatibility similar to YJIT.
  • Novelty: The speculative local-variable approach is novel among Ruby compilers because Ruby permits extensive reflection.The paper contrasts it with approaches used by YJIT, JRuby, and TruffleRuby.
  • Motivation: The SSA-based approach can eliminate redundant type checks and memory operations while improving value numbering.These optimizations motivate lifting locals into the high-level intermediate representation.

2 Background

Ruby is difficult to optimize because values are objects, operations are commonly method calls, and behavior can change dynamically. CRuby uses a stack-based bytecode interpreter, while YJIT compiles YARV bytecode with lazy basic block versioning.

  • Ruby programming language: Ruby optimization is challenging because every value is an object, nearly every operation is a method call, and methods may be redefined at runtime.Ruby also provides eval and reflection APIs that can access local variables.
  • CRuby virtual machine: CRuby is a stack-based interpreter for YARV bytecode and implements many built-in libraries as C functions.Those C functions make program behavior harder for compilers to infer from bytecode.
  • YJIT: YJIT is a lazy basic block versioning JIT that compiles YARV bytecode to arm64 or x86_64 machine code.It propagates type and frame information across succeeding basic blocks.
  • YJIT: YJIT does not encode observed values in its compact block contexts, limiting cross-block optimizations that require value information.Constant folding is given as an example of such an optimization.

3 Architecture of ZJIT

ZJIT compiles complete methods into an SSA-based intermediate representation, using interpreter profiling to guide speculation and optimization. Its pipeline builds HIR from bytecode, applies inlining and optimization passes, lowers to machine code, and handles dynamic Ruby behavior through guards, patch points, and deoptimization.

  • Architecture: ZJIT compiles whole methods and inlined callees, unlike YJIT’s basic-block compilation, enabling more advanced optimizations.The design also uses a traditional SSA-based compiler structure to encourage outside contributions.
  • Profiling: Because Ruby bytecode has no type information, ZJIT profiles interpreter execution before compiling methods.Profiling begins after P calls and compilation after C further calls, with defaults of 25 and 5.
  • Profiling: The profiler rewrites selected bytecode instructions with variants that record operand types in a side table before executing the original instructions.The feedback vector records distributions of up to four observed types.
  • HIR construction: ZJIT constructs SSA-based HIR by abstractly interpreting bytecode across the control-flow graph.It creates HIR blocks from bytecode blocks and represents stack and local state with compile-time virtual structures.
  • Optimization: The optimizer performs inlining, SSA minimization, store-to-load forwarding, redundant-store elimination, constant folding, and flow typing.Flow typing is repeated between passes until SSA value types reach a fixpoint.
  • Code generation: ZJIT lowers HIR to LIR, maps SSA values to LIR operands, allocates registers with linear scan, and emits arm64 or x86_64 machine code.LIR instructions may be implemented inline or through C runtime helpers.
  • Runtime integration: ZJIT uses type guards, PatchPoints, and deoptimization to manage speculative assumptions, method redefinition, and exceptions.Deoptimization metadata maps native storage back to VM frame state before native frames expire.

4 Local Variables

Ruby local variables are method-scoped, initially nil, and governed by parse-time rules that distinguish local reads from method calls. Blocks and Procs can capture, mutate, and expose enclosing environments, creating optimization challenges that are uncommon in practice.

  • The Basics: Local variables are defined within methods, may be assigned repeatedly, and initially contain nil.The interpreter initializes frame slots with nil.
  • The Basics: Ruby may use an identifier before assignment, while parse-time resolution determines whether it is a local-variable read or a zero-argument method call.An assignment makes syntactically later references resolve as local reads.
  • Blocks: Blocks can refer to and mutate enclosing local environments, with nested blocks reaching arbitrarily outward.The examples show an enclosing count changing from 0 to 3 and then to 9.
  • Blocks: A block passed as a parameter can be invoked directly, but other uses transform it into a Proc paired with a captured environment.The block parameter refers simply to block code until a use requires environment capture.
  • Procs: Proc creation moves the referenced environment to the heap, where code holding the Proc can arbitrarily read and write captured locals.This can modify a local variable without a visible write or reference in the block.
  • Procs: Environment escape and bound-environment writes complicate optimistic local-variable optimization, but environment escaping occurs roughly 35 times per Rails web request and is relatively rare beside other slow paths.A large Rails benchmark reports other slow-path events occurring hundreds of thousands of times.

5 Optimizing Locals

ZJIT lifts local variables into SSA values to optimize stack-resident locals, but Ruby’s blocks, environment escape, eval, and inlining complicate correctness. The implementation combines frame-state tracking, VM-frame synchronization, and patched exits to preserve interpreter-visible state.

  • SSA lifting: ZJIT lifts locals into SSA during construction and uses FrameState to map each local variable to its current SSA value.The same FrameState mechanism also supports stack maps for exits to the interpreter.
  • Design trade-offs: The current approach assumes escape and local writes are infrequent; a large Rails benchmark observed roughly 35 environment escapes per web request.The design therefore prioritizes the fast path for locals that remain on the stack, while penalizing writes to enclosing environments to make reads fast.
  • Blocks and enclosing environments: Because blocks use separate instruction sequences and translation units, ZJIT flushes the VM frame before block sends and reloads locals syntactically written by the block.This approach generally works but encounters inlining, environment escape, and eval problems.
  • Inlining: Inlining can leave the VM frame unflushed, causing post-send reloads to read garbage data when the inlined method requires no VM frame.The failure arises because the reload assumes the frame was flushed before the send.
  • Blocks and enclosing environments: For block-referenced locals, the projected solution keeps values on the VM frame so parent and child environments consistently read and write the same locations.Each use of an enclosing local receives GetLocal and SetLocal instructions that perform pointer chasing across frame levels.
  • Environment escape: When a Proc captures locals, the environment moves from the VM stack to the heap, invalidating SSA assumptions because external code may modify captured locals.ZJIT assumes locals are not modified through environment objects and patches escape points to exit to the interpreter.
  • Environment escape: At a NoEPEscape patch point, ZJIT exits before an unsafe return when escape occurs, while an exit without escape may need to initialize interpreter-required local-variable memory slots.The pre-patch code can reify a constant on demand, but the interpreter expects a memory slot for every local.
  • eval: Dynamically generated eval code can read and write locals through a shared VM frame without necessarily causing environment escape, challenging the NoEPEscape inference.The inference that no escape means no local write requires an API invariant that eval violates.

6 Evaluation

ZJIT is evaluated against other Ruby VMs on microbenchmarks and Rails-based workloads, with results showing strong performance on local-variable and method-call tests and competitive real-world performance.

  • Evaluation setup: ZJIT is evaluated on four benchmarks covering local variables, method calls, and Rails-based web applications.The benchmarks include 30k_variables, 30k_methods, erubi-rails, and railsbench.
  • Evaluation setup: ZJIT’s experiments compare it with YJIT, the CRuby interpreter, JRuby, and TruffleRuby using repeated warmed-up iterations.Each benchmark ran for 1,000 iterations, with the first half discarded as warm-up.
  • Microbenchmarks: Variable-to-variable copies in 30k_variables incur no cost for ZJIT, yielding performance comparable to TruffleRuby with low variance.ZJIT achieves this by lifting local variables into SSA values.
  • Microbenchmarks: YJIT is much slower than ZJIT on 30k_variables because only five fixed local variables use registers while the remaining 95 operate on memory.The comparison concerns the benchmark’s local-variable-heavy workload.
  • Microbenchmarks: ZJIT runs faster than YJIT on 30k_methods by writing less deoptimization metadata and fewer VM-frame fields at method-call safepoints.ZJIT remains slower than TruffleRuby because it still writes some frame fields eagerly.
  • Rails workloads: ZJIT is comparable to, but slightly behind, YJIT on erubi-rails and railsbench because it lacks specialization for complex argument-passing features such as array splats.Those calls therefore use the interpreter’s C functions.

7 Related Work

Related Ruby and language implementations handle potentially escaping locals through static analysis, runtime adaptation, or storage strategies that differ from ZJIT’s speculative SSA approach.

  • Overview: Ruby’s reflective features make direct comparisons among Ruby VMs challenging, while Lua’s upvalue semantics are comparatively close to Ruby’s escaping locals.The related implementations choose different strategies for handling locals that may escape.
  • JRuby: JRuby pessimistically marks bindings as escaping unless static analysis proves otherwise, then stores escaping bindings in heap-allocated environments.Non-escaping bindings remain in JVM frame storage that the JVM JIT may optimize into registers.
  • TruffleRuby: TruffleRuby relies on metacompilation and partial escape analysis over typed Truffle frames rather than performing its own explicit escape analysis.Its frame operations become specialized IR nodes during partial evaluation.
  • TruffleRuby: TruffleRuby adapts to runtime behavior by deoptimizing when a frame begins to escape and reconstructing its heap-allocated representation.Paths not yet taken can be ignored during partial escape analysis until runtime behavior changes.
  • Lua and LuaJIT: Lua moves escaped upvalues into separate cells, while non-escaping upvalues can alias their original stack slots.Lua does not provide a mechanism for escaping an entire scope.
  • Lua and LuaJIT: LuaJIT transforms locals and non-escaping upvalue accesses into SSA values, while escaped upvalues remain accessed through storage cells.Lua’s less extensive metaprogramming facilities simplify this optimization strategy.
  • LLVM: LLVM promotes memory-allocated automatic variables into SSA values with mem2reg, a simpler strategy for languages without Ruby’s flexible reflection.Ruby’s reflective features complicate direct application of this approach.

8 Conclusion

The paper presents ZJIT as a new method-based Ruby JIT using an optimistic approach to local variables, and reports strong microbenchmark and Rails results while identifying remaining optimization work.

  • Contributions: The paper introduces ZJIT, a new method-based JIT compiler, and compares it with the CRuby interpreter and YJIT.
  • Contributions: It explains Ruby’s difficult local-variable semantics and presents an optimistic assumption-based approach for compiling locals.The discussion also describes current bugs being fixed in the approach.
  • Results: ZJIT is faster than both the interpreter and YJIT on microbenchmarks, and faster than the interpreter while competitive with YJIT on Rails-based applications.
  • Future work: ZJIT does not yet provide a partial-escape-analysis optimization pass for further optimizing locals in blocks.This work is left for future research.
  • Future work: Implementing more built-in Ruby library functions remains future work to expand the code that ZJIT can analyze and optimize.
Loading 2609.01502v1…