Introduction
The JIT makes optimistic assumptions to generate fast code: it assumes types stay stable, methods are not redefined, and no debugging hooks are active. When any assumption breaks, the JIT must deoptimize—falling back to the interpreter for that code path. Understanding deoptimization helps you avoid accidentally destroying JIT performance.
Key Concepts
- Side exit: A point where compiled code bails out to the interpreter because it encountered a type or condition it was not compiled for.
- Invalidation: When the JIT discards compiled code entirely because a global assumption (like a method definition) has changed.
- TracePoint: Ruby's debugging hook mechanism, which can force the JIT to invalidate all compiled code when activated.
Real World Context
A single TracePoint.new(:call).enable in a debugging gem can invalidate every piece of compiled code in your process, dropping throughput back to interpreter levels. In production, ensure no debugging hooks are accidentally left active.
Deep Dive
Side Exits
When compiled code encounters an unexpected type, it takes a side exit back to the interpreter:
rubydef process(x) x.to_s # Compiled assuming x is Integer end process(1) # Uses compiled code — Integer#to_s process("hi") # Side exit! Type mismatch, falls back to interpreter
The first call causes YJIT to compile process with Integer-specialized code. The second call finds a String instead, triggering a side exit. YJIT may then recompile with a polymorphic check, but each side exit has a cost.
Causes of Deoptimization
Four main categories trigger deoptimization:
ruby# 1. Type changes — different argument types than compiled for def calculate(val) val * 2 end calculate(10) # Compiled for Integer calculate(3.14) # Side exit: Float # 2. Method redefinition — class reopened, method changed class String def length 42 # Redefining a core method invalidates compiled code end end # 3. TracePoint activation — debugging hooks TracePoint.new(:call) { |tp| puts tp.method_id }.enable # Invalidates ALL compiled code! # 4. Constant mutation — changing a constant's value MAX = 100 MAX = 200 # Warning + invalidation of code using MAX
Each of these breaks an assumption the JIT relied on when generating machine code.
Monitoring Deoptimization
YJIT provides statistics to track side exits and invalidations:
rubystats = RubyVM::YJIT.runtime_stats # Total invalidations from TracePoint or method redefinition puts stats[:invalidate_everything] # For detailed exit tracing, run with: # ruby --yjit --yjit-trace-exits=100 script.rb # This records the first 100 side exits with backtraces
The --yjit-trace-exits=N flag is the most actionable diagnostic. It shows you exactly which code locations cause exits, so you can fix the highest-impact ones first.
Freezing and Constants
Freezing objects and using proper constants helps the JIT maintain its assumptions:
ruby# Frozen constants are safe for JIT optimization DEFAULTS = { timeout: 30, retries: 3 }.freeze # The JIT can inline constant lookups when values are stable class Config TIMEOUT = 30 # Never reassign this end
When constants are never mutated, the JIT can treat them as compile-time values.
Common Pitfalls
- Leaving TracePoint enabled in production — Even a single active TracePoint with
:callevent invalidates all compiled code. Ensure debugging gems likebinding.pryare not loaded in production. - Redefining core methods for monkey-patching — Reopening
String,Array, orHashto override methods triggers global invalidation. Use Refinements instead.
Best Practices
- Freeze all constants — Freezing prevents accidental mutation and lets the JIT treat constant values as stable.
- Profile with --yjit-trace-exits before optimizing — Fixing one megamorphic call site that causes 50% of side exits is more impactful than rewriting ten methods.
Summary
- Side exits occur when compiled code encounters unexpected types, falling back to the interpreter.
- Method redefinition, TracePoint, and constant mutation cause global code invalidation.
- Use
--yjit-trace-exits=Nto find the highest-impact deoptimization sites. - Freeze constants and avoid monkey-patching core classes to maintain JIT assumptions.
Code Examples
# Monitor YJIT invalidations in a production health check
stats = RubyVM::YJIT.runtime_stats
if stats[:invalidate_everything] > 0
warn "WARNING: #{stats[:invalidate_everything]} global invalidations detected"
warn "Check for active TracePoints or method redefinitions"
end
puts "Code GCs: #{stats[:code_gc_count]}"