When ||= Means "Assign Once"
Ruby developers reach for the double pipe equals operator (||=) for lazy initialization all the time. Syntactically, the operator means "assign if the variable is not set." In practice, though, it's almost always used with a stronger intention: "assign once, then cache the result."
That idiomatic use is a subset of what the operator can do. For TruffleRuby—the high-performance Ruby implementation built on GraalVM—recognizing that subset in the compiler opens the door to generating less machine code and compiling faster.
Finding the Pattern
Before optimizing for this behavior, engineers at Shopify wanted evidence that the "assign once" usage was common enough to matter. Static profiling of 20 popular open-source Ruby projects turned up 2,082 uses of ||=. Of those, 64% qualified as lazy initialization under conservative criteria:
- The assigned value is a constant, built only from integer, string, symbol, hash, array, or constant-variable literals (
a ||= [2 * PI]). - The statement appears inside a method, assigns an instance or class variable, and the variable name contains the method name (or vice versa) with no parameters. Example:
def get_a; @a ||= func_call; end.
Because the criteria were strict, many more cases probably follow the lazy-initialization pattern without matching these rules. The signal was strong enough: this idiom is worth special handling.
How TruffleRuby Compiles
TruffleRuby doesn't go through the traditional Java bytecode pipeline. It's built on GraalVM and the Truffle framework, which lets language implementers define an Abstract Syntax Tree (AST) interpreter. GraalVM then applies partial evaluation to that interpreter, turning it into optimized machine code directly, guided by runtime profiling.
A key tool in TruffleRuby's performance strategy is deoptimization—the ability to bail out of fast compiled code back into the interpreter. This is especially useful for handling monkey patching: a method redefinition is rare, so instead of emitting checks for it in every compiled call site, TruffleRuby can simply deoptimize when a redefinition happens and look up the new method then.
The same mechanism applies to lazy initialization, but inverted: the uncommon case becomes the one where the right-hand side of ||= actually needs to run again after the first assignment.
The Node Swap
TruffleRuby represents Ruby programs as small Java objects called nodes. The standard OrNode handles the ||= operator, with the left side as the condition and the right side as the assignment action. A ConditionProfile tracks how often each branch executes, and by default both sides get compiled into machine code once they've been seen.
The optimization replaces the standard OrNode with a new OrLazyValueDefinedNode in the BodyTranslator that converts Ruby's AST into Truffle nodes. The new node changes what happens on the "already defined" path: the else branch counts executions, and if the right side has run fewer than twice, it becomes a deoptimization instead of compiled code.
The result is that the common lazy-init path—where the value is fetched and the assignment is skipped—stays compact and fast. The machinery for calling the right-hand method, assigning variables, and managing that control flow only exists in the interpreter, where it lives until (or unless) it's actually needed.
What the Graphs Show
Comparing the Graal compiler graphs before and after the change makes the benefit visible. Without the optimization, the compiled flow for a simple memoized method includes nodes for variable assignment, method invocation, and the surrounding control flow. With it, the graph shrinks considerably—the compiled code doesn't need to know anything about the assignment or the method call at all.
In concrete terms, the optimization made a sample method compile about 6% faster and reduced generated machine code by roughly 63% by memory—around half the assembly instructions. Faster compilation leaves more time for application code to run, and smaller machine code reduces memory and cache pressure.
These measurements come from isolated benchmarks, which are noisy by nature. In a large production codebase, the effect is harder to isolate. Still, the direction is clear.
Fallback Behavior
This is purely an optimization, not a semantic change. If you're using ||= for actual conditional assignment, need the right side to execute many times, or require it to be fast on every repeated execution, the optimization correctly backs off. The OrLazyValueDefinedNode counts how many times the else branch runs and only treats the call as a deoptimization until it's seen more than once. After that, behavior reverts to the normal compiled path.
Why It Matters
The change is small—beyond creating the new node, only one line in BodyTranslator was touched. But it demonstrates something larger about optimizing dynamic languages: real-world codebases contain patterns that aren't visible in language specifications. Profiling industrial usage to find those patterns, then leaning on deoptimization to make the rare and common paths equally fast, is a strategy that pays off.
Shopify has been running TruffleRuby against production storefront traffic to shake out bugs and improve tooling, with an eye toward faster customer browsing experiences. This lazy-initialization work is one small piece of that larger effort.



