/ Tags: RUBY 3 / Categories: RUBY

Writing Fast Ruby — Benchmarking with benchmark-ips and Reading the Results Honestly

Someone on the team claims map.compact is faster than filter_map. Someone else says the opposite. Both have benchmarks. Both benchmarks are wrong, in different ways — one measured a hundred iterations on a cold VM, the other measured an empty array. Micro-benchmarking Ruby is easy to do and surprisingly hard to do correctly, and the difference between the two is mostly about what you leave out of the measured block.

Why Benchmark.measure Isn’t Enough


The standard library’s Benchmark reports elapsed time for a fixed number of iterations. That’s the wrong unit for comparing implementations.

Example:

require "benchmark"

n = 100_000
Benchmark.bm do |x|
  x.report("map.compact")  { n.times { data.map { |i| i if i.even? }.compact } }
  x.report("filter_map")   { n.times { data.filter_map { |i| i if i.even? } }}
end

Two problems. You had to pick n yourself, and if you picked badly the whole run finishes before the JIT and the GC settle into steady state. And the output is seconds, so a 4% difference is buried in noise you have no way to quantify.

benchmark-ips inverts it: fix the time, measure the iterations, and report the variance.

The Basic Shape


Setup:

gem install benchmark-ips

Example:

require "benchmark/ips"

data = Array.new(1_000) { rand(100) }

Benchmark.ips do |x|
  x.report("map.compact") { data.map { |i| i if i.even? }.compact }
  x.report("filter_map")  { data.filter_map { |i| i if i.even? } }
  x.report("select.map")  { data.select(&:even?).map { |i| i } }

  x.compare!
end

Output:

Warming up --------------------------------------
         map.compact     4.312k i/100ms
          filter_map     5.891k i/100ms
          select.map     4.508k i/100ms
Calculating -------------------------------------
         map.compact     43.201k (± 1.8%) i/s  (23.15 μs/i) -    216.  in   5.002047s
          filter_map     58.904k (± 1.4%) i/s  (16.98 μs/i) -    295.  in   5.008321s
          select.map     45.117k (± 2.1%) i/s  (22.16 μs/i) -    225.  in   5.001884s

Comparison:
          filter_map:    58904.2 i/s
          select.map:    45117.3 i/s - 1.31x  slower
         map.compact:    43201.1 i/s - 1.36x  slower

Three things to read:

  • i/s — iterations per second, higher is better. This is the number.
  • ± percentage — standard deviation. Above roughly 5% and the run is too noisy to trust; something else on your machine is competing for CPU.
  • x.compare! — prints the ratios so you don’t do arithmetic in your head.

The warmup phase exists so the VM reaches steady state before measurement begins. That alone eliminates the most common source of bogus results.

What to Put Inside the Block — and What Not To


The block runs millions of times. Anything inside it is measured, including setup you didn’t mean to measure.

Example:

# Wrong — measuring array construction, not the method under test
Benchmark.ips do |x|
  x.report("filter_map") do
    data = Array.new(1_000) { rand(100) }     # ← dominates the measurement
    data.filter_map { |i| i if i.even? }
  end
end

Example:

# Right — build the fixture once, outside
data = Array.new(1_000) { rand(100) }

Benchmark.ips do |x|
  x.report("filter_map") { data.filter_map { |i| i if i.even? } }
end

When each iteration genuinely needs fresh state, use x.report with a per-iteration setup you accept as constant overhead across all variants — the comparison stays valid even if the absolute numbers include the setup, as long as every variant pays the same cost.

The Fixture Determines the Answer


This is the failure that produces the most confident wrong conclusions. Method performance depends on the data, and a benchmark on unrepresentative data measures nothing useful.

Example:

require "benchmark/ips"

def bench(label, data)
  puts "\n=== #{label} (#{data.size} elements) ==="
  Benchmark.ips do |x|
    x.report("include?") { data.include?(999_999) }
    x.report("Set#include?") do
      @set ||= Set.new(data)
      @set.include?(999_999)
    end
    x.compare!
  end
end

bench("tiny",  (1..10).to_a)
bench("small", (1..1_000).to_a)
bench("large", (1..1_000_000).to_a)

On ten elements, Array#include? wins — building a Set costs more than a linear scan. On a million, Set#include? is thousands of times faster. Either result, published without the size, is misleading.

Benchmark with the data shape you actually have. Empty arrays, single-element arrays, and the worst case all belong in the run.

Measuring Allocations, Not Just Time


Speed and memory are different questions, and in a long-running Rails process allocation pressure often matters more than raw i/s because it drives GC.

Example:

require "benchmark/memory"

data = Array.new(1_000) { rand(100) }

Benchmark.memory do |x|
  x.report("map.compact") { data.map { |i| i if i.even? }.compact }
  x.report("filter_map")  { data.filter_map { |i| i if i.even? } }
  x.compare!
end

Output:

Comparison:
          filter_map:      8264 allocated
         map.compact:     16472 allocated - 1.99x more

map.compact allocates two arrays; filter_map allocates one. That’s the actual reason for the speed difference, and knowing the mechanism is more transferable than knowing the ratio.

For a quick check without another gem:

require "objspace"

before = GC.stat(:total_allocated_objects)
1_000.times { data.filter_map { |i| i if i.even? } }
puts GC.stat(:total_allocated_objects) - before

Controlling for GC


A garbage collection during a measurement window skews that window. benchmark-ips runs long enough that GC averages out, but for tight comparisons you can suppress it:

Example:

Benchmark.ips do |x|
  x.config(time: 5, warmup: 2)

  x.report("candidate") do
    GC.disable
    result = expensive_operation
    GC.enable
    result
  end
end

Be careful with this. Disabling GC measures the algorithm in isolation, which is sometimes what you want and sometimes hides the very cost that matters. If one candidate allocates ten times more than another, the GC pressure is the difference — suppressing it produces a benchmark that says the wrong thing about production.

Pro-Tip: Before optimizing anything based on a micro-benchmark, work out how many times the code actually runs. A method that takes 23μs instead of 17μs is 6μs slower — call it a thousand times per request and you’ve saved 6 milliseconds, which is invisible next to a single 40ms database query. Micro-benchmarks are for choosing between two equally readable options, not for finding what to optimize. To find that, use a sampling profiler on the real workload: stackprof with mode: :wall over a hundred requests will point at the one method consuming 30% of the time, and it is essentially never the one you’d have guessed. Profile to decide where, benchmark to decide how.

A Benchmark You Can Commit


For a change where performance is the point, put the benchmark in the repo so the next person can verify the claim.

Example:

# benchmarks/serialization.rb
require_relative "../config/environment"
require "benchmark/ips"

ACCOUNT = Account.includes(orders: :line_items).first

Benchmark.ips do |x|
  x.config(time: 10, warmup: 3)

  x.report("as_json")        { ACCOUNT.as_json(include: { orders: { include: :line_items } }) }
  x.report("AccountSerializer") { AccountSerializer.new(ACCOUNT).to_h }

  x.compare!
  x.hold! "tmp/serialization_benchmark.json"
end

hold! persists results across runs, so you can benchmark one implementation, switch branches, benchmark the other, and get a comparison between them — which is otherwise awkward when the two versions can’t coexist in one process.

Conclusion


benchmark-ips gets the methodology right by default: it warms up, it fixes time rather than iteration count, and it reports variance so you know whether to believe the number. The remaining discipline is yours — keep fixture construction out of the measured block, benchmark the data shape you actually have including the degenerate cases, check allocations alongside time, and confirm the code is hot before you optimize it at all. A 1.36× win on a method called twice per request is a rounding error; the same win on a method called ten thousand times is worth the pull request.

FAQs


Q1: How long should a benchmark run?
The defaults — 2 seconds warmup, 5 seconds measurement — are fine for most comparisons. Increase them via x.config(time:, warmup:) when the standard deviation is above 5% or when the operation itself takes milliseconds.

Q2: Why do I get different results on my laptop than in CI?
Different CPU, different Ruby build, thermal throttling, and noisy neighbours on shared runners. Absolute numbers aren’t portable; ratios between candidates measured in the same run usually are.

Q3: Does YJIT change benchmark results?
Substantially, and not uniformly — JIT-friendly code can improve several times over while allocation-bound code barely moves. Benchmark with the same JIT configuration you run in production, and state which one you used when sharing results.

Q4: What’s the difference between i/s and μs/i?
They’re reciprocals of the same measurement. Iterations per second is easier to compare across candidates; microseconds per iteration is easier to reason about when estimating the impact on a request.

Q5: Should benchmarks live in the test suite?
Not as assertions — they’re too machine-dependent to gate a build reliably. Keep them in a benchmarks/ directory as scripts anyone can run, and reserve CI performance checks for end-to-end timings with generous thresholds.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More