Unlocking Speed: Rails Performance Tuning with Ruby 3 YJIT
In Ruby 3.3, YJIT is production-ready. Shopify’s numbers, the Optcarrot benchmark, and the Rails core team all point the same direction: roughly 15–25% latency improvement on real Rails workloads, for the cost of a bounded amount of executable memory. The catch in March 2024 is that Rails has not yet made it the default — that lands with Rails 7.2 in August. So today you enable it yourself, and you want to do it right.
The flag
The most reliable way is an environment variable on the process that boots Puma, so it applies to every initialization path including Bootsnap, the loaders, and your initializers:
1
RUBYOPT="--yjit" bin/rails server
RUBYOPT matters more than editing boot.rb: anything that forks or execs Puma without going through rails server — systemd units, Docker CMD, deploy wrappers — picks it up only if the env is present. In our Dockerfile:
1
ENV RUBYOPT="--yjit"
Then verify it took, because nothing is more embarrassing than a “YJIT” post-mortem where YJIT was off:
1
RubyVM::YJIT.enabled? #=> true
What it actually buys us
The headline on our checkout service (Rails 7.1, Ruby 3.3.0, Puma, 8 threads per process, 6 processes, m5.xlarge):
| metric | before | with YJIT | delta |
|---|---|---|---|
| p50 latency | 68 ms | 58 ms | -15% |
| p95 latency | 210 ms | 165 ms | -21% |
| p99 latency | 480 ms | 390 ms | -19% |
| RSS (per process) | ~1.1 GB | ~1.17 GB | +60 MB |
The p95 win is the one that matters for SLOs; tail latency is exactly what a JIT is good at smoothing. The RSS cost — about 5% on an already-heavy Rails process — is the machine-code cache, and Ruby 3.3 made that smaller than 3.2’s: metadata is more compact, code GC is off by default, and --yjit-call-threshold auto-raises to 120 once you have more than 40,000 ISEs so cold methods do not get compiled.
Budgeting memory
--yjit-exec-mem-size defaults to 128 MiB and in 3.3 acts as a hard ceiling — when it is hit, YJIT simply stops compiling, which is graceful but means you leave performance on the table. For containerized deploys, size the limit as:
1
limit = app_rss + yjit_exec_mem_size
and watch yjit_alloc_size from stats so you know how much of the budget is actually in use. If you hit OOMs from this, prefer raising the container limit over lowering exec-mem-size; the latency you lose by truncating compilation exceeds the RSS you save.
Watching it in telemetry
RubyVM::YJIT.runtime_stats gives you compiled-code counts and ratio_in_yjit in release builds. We log it every minute from one process:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# config/initializers/yjit_stats.rb
if defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?
Rails.application.config.after_initialize do
Thread.new do
loop do
stats = RubyVM::YJIT.runtime_stats
Rails.logger.info(
"yjit compiled_iseq=#{stats[:compiled_iseq_count]} " \
"ratio=#{stats[:ratio_in_yjit]} alloc=#{stats[:yjit_alloc_size]}"
)
sleep 60
end
end
end
end
Two things to watch: ratio_in_yjit should climb and plateau above 0.9 on a warm process (ours sits at 0.97), and compiled_iseq_count should stop growing after the first few minutes of traffic. If it keeps climbing you have a pathological megamorphic call site, and --yjit-trace-exits will tell you where.
Enabling at runtime
If your deploy tooling cannot set env consistently, RubyVM::YJIT.enable works at runtime and can be gated:
1
2
3
4
# config/boot.rb
if ENV["YJIT_DISABLE"] != "1" && defined?(RubyVM::YJIT)
RubyVM::YJIT.enable
end
Rails 7.2 formalizes this pattern by flipping YJIT on by default for apps running Ruby 3.3+. The difference between doing it now and in August is a couple of months of benchmarked p95 data.
The lessons that stuck
- Measure p95, not p50; the JIT’s payoff is tail latency.
- Set it at the process level via
RUBYOPTso every boot path gets it, and assertenabled?in CI. - Treat
--yjit-exec-mem-sizeas a hard budget and size containers for app RSS plus code cache, not just app RSS. - Pair with jemalloc on multi-threaded Puma; the allocator fragmentation story is orthogonal but complementary.
- Keep the stats reporter in telemetry — you cannot tune what you are not measuring.
YJIT is the cheapest 20% your Rails app will ever get. The flag is one line; the discipline is in knowing what it is actually doing.