Sijin T V
Sijin T V A passionate Software Engineer who contributes to the wonders happenning on the internet

Ruby YJIT Memory Footprint: Optimization Strategies for Kubernetes Deployments

Since Rails 7.2 made YJIT the default JIT in August 2024, most Rails apps picked up a 15-25% throughput bump for free. The bill shows up on Kubernetes in a different metric: RSS. We upgraded a fairly standard Puma/Rails app and immediately started crash-looping on OOMKilled, which we traced not to leaked objects but to YJIT’s machine-code buffer. This post covers how we measured it, capped it, and what I’d do differently on the next migration.

Where the memory actually goes

YJIT compiles Ruby bytecode to native machine code as methods run. The generated code lives in a single lazily-committed region bounded by --yjit-exec-mem-size (default 64 MiB since Ruby 3.3). Two things regularly surprise people:

  • The region is reserved as one large virtual mapping, which makes ps look scary. RSS only counts pages that are actually touched, but on a hot workload almost the entire region ends up committed — so treat exec-mem-size as a real upper bound on the code cache, not a soft cap.
  • The metric that matters in Prometheus is code_region_size, not the “virtual size” your container tooling shows you.

On our app the cache climbed to ~58 MiB and parked there — effectively pinned at the 64 MiB ceiling. The second, bigger surprise was allocator fragmentation. The new native allocation pattern (small code pages interleaved with heap churn) made glibc’s arena behavior dramatically worse, and that’s where most of the “unexplained” RSS came from.

Measure before you tune

1
2
3
4
5
6
7
8
9
10
11
12
# script/yjit_stats.rb — run one copy per host, scrape via Prometheus textfile exporter
require "logger"
log = Logger.new($stdout)

loop do
  if defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?
    s = RubyVM::YJIT.runtime_stats
    log.info(format("code_region_size=%d live_iseq_count=%d yjit_alloc_size=%d",
                    s[:code_region_size], s[:live_iseq_count], s[:yjit_alloc_size]))
  end
  sleep 30
end

Two weeks of this data before we changed anything is what stopped us from guessing. code_region_size told us the code cache was saturated; yjit_alloc_size tracked how much heap YJIT itself was touching.

The tuning stack

We landed on four changes, roughly in order of impact:

  1. --yjit-exec-mem-size=32 in RUBYOPT. This halves the code cache. Cold-start compilation drops slightly and we measured ~2% steady-state throughput loss — acceptable given we were OOM-killing.
  2. jemalloc in the runtime image. The single biggest win: about 90 MiB of peak RSS that glibc never returned, mostly under GC churn during deploy surges.
  3. MALLOC_ARENA_MAX=2. With jemalloc this is a no-op, but it keeps the fallback glibc path sane in base images we haven’t converted yet.
  4. RUBY_GC_HEAP_GROWTH_FACTOR=1.1 plus a bounded RUBY_GC_MALLOC_LIMIT_MAX, so the heap grows in smaller steps instead of doubling straight into a cgroup limit.
1
2
3
4
5
6
7
8
# Dockerfile (final state)
FROM ruby:3.3.5-slim
ENV RUBYOPT="--yjit-exec-mem-size=32"
ENV MALLOC_ARENA_MAX=2
ENV RUBY_GC_HEAP_GROWTH_FACTOR=1.1
ENV RUBY_GC_MALLOC_LIMIT_MAX=67108864
# install jemalloc, then:
ENV LD_PRELOAD=/usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2

The war story

The app was on EKS with 8 Puma workers x 5 threads and a 768 MiB container limit. Pre-YJIT RSS sat around 480 MiB. After the Rails 7.2 upgrade, steady state climbed to ~620 MiB and deploy surges pushed peaks past 1.1 GiB — straight into the OOM killer, which took out all 8 workers in the pod and crash-looped the deployment for half an hour.

The combination above brought steady-state RSS to ~600 MiB and peak under 800 MiB. We also raised the limit to 1 GiB for headroom rather than trying to squeeze further. OOM kills went from a dozen a week to zero, p99 latency was unchanged, and aggregate throughput dropped about 2% from the smaller code cache. That trade is worth making every time.

When the code cache isn’t enough

Long-running background workers that touch thousands of distinct code paths can still churn against a small code region. Ruby 3.3 ships --yjit-code-gc as an experimental option that reclaims dead code; it is off by default for good reason, and our metrics showed cache pressure is a cold-start problem, not a steady-state one. We left it off and instead sized exec-mem-size for the worker workload separately from the web workload.

Production lessons that held up:

  • Ship exec-mem-size in the container image, keyed per workload (web vs worker), not globally.
  • Watch code_region_size trend before tuning; “big” or “small” code cache is meaningless without the metric.
  • jemalloc fixes fragmentation; it won’t fix an oversized code cache. Tune both, or you’ll just move the OOM around.
  • Set the cgroup limit from measured peak plus 20%, never from steady-state RSS.
  • On Ruby 3.3, YJIT is already on — the question is only how much memory you give it, and that is now an explicit engineering decision.

comments powered by Disqus