YJIT and performance tuning

Ruby · Expert · 14 min

YJIT gives real-world Rails apps large speedups for free, and profiling tells you where the rest of the time goes.

YJIT is CRuby's just-in-time compiler, written in Rust by Shopify and production-ready since Ruby 3.2. It compiles hot methods to machine code using lazy basic block versioning: it generates code for a block only when it runs, specialised for the types actually seen. Typical Rails apps see 15 to 30 percent lower latency.

Enable it with ruby --yjit, the env var RUBY_YJIT_ENABLE=1, or at runtime with RubyVM::YJIT.enable (3.3+). Rails 7.2+ turns it on automatically in a generated initializer when available. Check with RubyVM::YJIT.enabled?, and read RubyVM::YJIT.runtime_stats when running with --yjit-stats. It costs some extra memory for compiled code; --yjit-mem-size caps it.

Code that YJIT, and the interpreter, handle well is monomorphic: call sites that see the same classes and object shapes every time. Initialise all instance variables in initialize, avoid redefining methods at runtime, and keep hot paths free of method_missing and heavy send.

Measure before changing anything:

  • benchmark-ips reports iterations per second with error bars and warms up first, which matters with a JIT.
  • Sampling profilers such as stackprof and vernier (which also shows GVL and GC time) find the slow code in a real workload, and rbspy attaches to a running process.
  • Often the real cost is in SQL queries, N+1 loads or allocations, not Ruby execution.

Keep Ruby updated: each release brings substantial YJIT and GC improvements.

Example

# Run with: ruby --yjit bench.rb   (and compare with plain `ruby bench.rb`)
require "benchmark"

def fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2)

puts "YJIT enabled? #{!!(defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?)}"
fib(20)                                 # warm up so hot code gets compiled
t = Benchmark.realtime { fib(27) }
puts "fib(27): #{t.round(3)}s"
# Typical: interpreter ~0.20s, YJIT ~0.05s (varies by machine)

# For real comparisons use benchmark-ips:
#   require "benchmark/ips"
#   Benchmark.ips do |x|
#     x.report("map.sum") { arr.map { it * 2 }.sum }
#     x.report("sum")     { arr.sum { it * 2 } }
#     x.compare!
#   end

Remember

  • Enable YJIT with --yjit, RUBY_YJIT_ENABLE=1 or RubyVM::YJIT.enable
  • Monomorphic call sites and stable object shapes JIT best
  • Profile with stackprof or vernier and benchmark with benchmark-ips

Watch out for

  • Benchmarking without warm-up, so JIT compilation time pollutes the measurement.

Check yourself

  1. Why should benchmarks of YJIT-enabled code include a warm-up phase?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.ruby-lang.org/en/3.4/yjit/yjit_md.html

Builds on

Unlocks

Nothing builds on this yet.