Learn Ruby - Performance Optimization & Profiling
Series/Learn Ruby/Episode 17
Episode 17 of 23

Learn Ruby - Performance Optimization & Profiling

This episode covers Ruby performance optimization: enabling YJIT with the --yjit and --yjit-mem-size flags, measuring speed with Benchmark, profiling with ruby-prof, and understanding GC.stat, GC.config, and allocation hotspots to find the cause of slow applications.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

A secure and correct application isn't enough — in production, speed determines server costs and user experience. Episode 17 covers Ruby performance optimization: enabling YJIT, measuring speed with Benchmark, profiling with ruby-prof, and understanding garbage collection.

Many slow applications aren't slow because Ruby is slow, but because the code allocates excessive objects or skips the JIT. We start with the easiest step with a big impact: turning on YJIT. Throughout the episode, always tie every decision to measured numbers.

Enabling YJIT

The --yjit Flag

YJIT is a production-ready Just-in-Time compiler for CRuby. Without changing code at all, you can get significant speedups just by adding a flag when running Ruby:

Run Ruby with YJIT
ruby --yjit app.rb
ruby --yjit --yjit-mem-size=64 app.rb

ruby --yjit app.rb enables the compiler inside the process. --yjit-mem-size limits the memory used by compiled code, 128 MiB by default. For Rails, enable it through the RUBY_YJIT_ENABLE=1 environment variable or in config/production.rb. Once enabled, confirm the process is actually using it with RubyVM::YJIT.enabled?.

Measuring with RubyVM::YJIT.runtime_stats

YJIT provides internal statistics. Enable statistics with the RUBY_YJIT_STATS=1 environment variable, then read them from code:

RubyRead YJIT statistics
puts RubyVM::YJIT.runtime_stats

RubyVM::YJIT.runtime_stats returns a hash with metrics like the amount of compiled code, the number of side-exits, and the compilation ratio. Compare statistics before and after code optimization — focus on high side-exits because they indicate code that gets deoptimized frequently. Excessive side-exits usually come from methods that frequently change argument types.

Benchmark and Profiling

Benchmark

Before optimizing, measure first. Benchmark compares implementations and gives numbers you can base decisions on:

RubyA simple benchmark
require "benchmark"
 
time = Benchmark.realtime do
  1_000_000.times { "ruby".upcase }
end
puts "waktu: #{time} detik"

Benchmark.realtime returns the seconds the block spent. To compare several approaches at once, use Benchmark.bm, which prints a table per implementation:

RubyCompare several implementations
require "benchmark"
 
Benchmark.bm do |x|
  x.report("upcase") { 1_000_000.times { "ruby".upcase } }
  x.report("capitalize") { 1_000_000.times { "ruby".capitalize } }
end

Don't optimize without a baseline — measured numbers always beat intuition. Repeat measurements several times because the CPU can fluctuate, then take the median value.

ruby-prof

Benchmark measures total time, but doesn't tell you which part is slow. That's where ruby-prof comes in: it profiles every method call and compiles a report:

Run ruby-prof
gem install ruby-prof
ruby-prof -m --printer=call_stack app.rb

ruby-prof -m --printer=call_stack app.rb writes the profile in wall-time mode and prints the call stack. Look for methods with the largest self time — that's where allocations and computations pile up. ruby-prof produces various formats, including graph and flat for analysis, and can be integrated with Rails via rack middleware.

Understanding GC and Memory

GC.stat and GC.config

Ruby cleans up unused objects through garbage collection. Monitor its activity with GC.stat, and adjust its behavior with GC.config:

RubyMonitor and configure GC
puts GC.stat[:count]
GC.config(major_gc_interval: 180)

GC.stat[:count] shows how many times GC has run since the process started. GC.config(major_gc_interval: 180) sets the major GC interval in seconds — useful for reducing freezes in latency-sensitive applications. The RUBY_GC_HEAP_INIT_SLOTS and RUBY_GC_HEAP_GROWTH_FACTOR environment variables can also be set externally to control the heap growth rate.

ObjectSpace and Allocation Hotspots

Allocation hotspots are points where code creates excessive temporary objects — the cause of GC running too often. ObjectSpace helps measure:

RubyCount string allocations
puts ObjectSpace.count_objects[:T_STRING]

ObjectSpace.count_objects[:T_STRING] counts the String objects currently alive. A drastic rise and fall indicates excessive allocation in a loop or in a request hot path. Common reduction patterns: avoid creating an array per iteration, use frozen literals, and move object creation outside loops. Also monitor the process's RSS growth over time to see memory leaks.

Warning

Optimization is the science of measurement. Without profiling, you're just guessing. Measure first with Benchmark and ruby-prof, change one thing at a time, then measure again.

Conclusion

Key takeaways:

  • YJIT activates with just --yjit and gives speedups without changing code.
  • --yjit-mem-size limits compiled code memory, 128 MiB by default.
  • RubyVM::YJIT.runtime_stats reveals sides that get deoptimized frequently.
  • Benchmark provides the time baseline that decisions are based on.
  • ruby-prof finds methods with the largest self time.
  • GC.stat and GC.config monitor and adjust GC behavior.
  • Allocation hotspots in large objects are the cause of excessive GC.

In episode 18 we add precision: the Ruby type system with RBS and Steep for static type checking, and Sorbet with gradual typing and sorbet-typed. After performance, it's the turn of data-type reliability guaranteed by the compiler.

Learn Ruby - Performance Optimization & Profiling | Learn Ruby