Benchmarking Ruby code: measure and compare performance
Before you can improve the performance of your Ruby code, you need to measure it. Benchmarking Ruby code is a skill that pays off every time you face a performance question. Guessing which approach is faster often leads you astray; your intuition about what is slow rarely matches reality. The standard library Benchmark module gives you precise, repeatable measurements so you can make decisions based on data.
This guide covers the Benchmark module’s core methods, when to use each one, and how to avoid common pitfalls that make measurements unreliable.
TL;DR
Benchmark the smallest thing that answers the question you are asking. Use Benchmark.measure for one block, Benchmark.bm when you want side-by-side comparisons, and benchmark-ips when the operation is so fast that absolute time becomes noisy. The output only matters if the input is stable, so warm up the code, compare the same work, and run the benchmark more than once.
How do I time a single block with Benchmark.measure?
The simplest way to time a block of code is with Benchmark.measure. It returns a Benchmark::Tms object with breakdown of user CPU time, system CPU time, and wall-clock elapsed time.
require 'benchmark'
time = Benchmark.measure do
1_000_000.times { a = "1" }
end
puts time
# => 0.220000 0.000000 0.220000 ( 0.227313)
The output shows user time, system time, total CPU time, and elapsed real time in parentheses. For most purposes, real is the most useful figure; it tells you how long the operation took from invocation to return. The user time counts CPU cycles spent in your Ruby code, while system time accounts for kernel-level operations that your code triggered indirectly.
You can also access individual fields from the Benchmark::Tms object:
require 'benchmark'
n = 1_000_000
time = Benchmark.measure do
n.times { |i| i.to_s }
end
puts "User: #{time.utime}"
# => User: 0.210000
puts "Real: #{time.real}"
# => Real: 0.215312
Accessing utime and real directly is useful when you want to log or compare specific time components without parsing the default string output. The utime value tells you how much CPU your process actually consumed; if it is much lower than real, your code is spending time waiting on I/O or blocked by the scheduler. That gap is often the first signal that a performance problem is not in the Ruby code itself but in an external dependency.
Benchmark.measure is best for timing a single piece of code. When you need to compare multiple approaches side by side, use Benchmark.bm. A single measurement answers “how long did this take?” while a comparison answers “which version should I keep?” The tool is the same, but the question changes the shape of the output you need.
How do I compare multiple approaches with Benchmark.bm?
Benchmark.bm runs multiple code blocks and prints a side-by-side comparison table. Pass an integer to set a minimum label width so columns line up neatly.
require 'benchmark'
n = 5_000_000
Benchmark.bm(7) do |x|
x.report("for:") { for i in 1..n; a = "1"; end }
x.report("times:") { n.times { a = "1" } }
x.report("upto:") { 1.upto(n) { a = "1" } }
end
The bm method takes a label width argument (7 in the example above) to align the report columns neatly, regardless of label length. Each x.report call takes a name string and a block. Under the hood, bm runs each block, captures the timing data, and formats it into the table shown below. The output columns cover user CPU time, system CPU time, total CPU time, and real elapsed time in parentheses:
user system total real
for: 1.010000 0.000000 1.010000 ( 1.015688)
times: 1.000000 0.000000 1.000000 ( 1.003611)
upto: 1.030000 0.000000 1.030000 ( 1.028098)
Each x.report call times one block and prints a row. The real column is what you care about most; it shows wall-clock time and is the most representative measure of what your users experience. The system column tends to stay near zero for pure computation, but it can spike when your code does heavy file I/O or spawns subprocesses.
Before trusting the result, make sure the benchmarked blocks are doing the same work. If one block allocates more objects, touches the file system, or triggers extra logging, the comparison stops being fair. Good benchmarks isolate a single difference so the final table points to a real decision instead of a noisy accident.
How do I reduce GC noise with Benchmark.bmbm?
One problem with Benchmark.bm is that garbage collection runs unpredictably. If one benchmark block creates many objects, it can trigger GC during the next block, making that block look slower than it actually is.
Benchmark.bmbm addresses this by running each block twice. First a “rehearsal” pass warms up memory, then a second pass calls GC.start before each block to minimise allocation-related variance.
require 'benchmark'
array = (1..1_000_000).map { rand }
Benchmark.bmbm(7) do |x|
x.report("sort!:") { array.dup.sort! }
x.report("sort:") { array.dup.sort }
end
bmbm runs each block twice. The first pass, labeled “Rehearsal,” executes every block in sequence to warm up memory and let the GC settle. The second pass is the real measurement, and it calls GC.start before each individual block to reduce the chance that one benchmark’s allocations slow down the next one. Here is what the output looks like:
Rehearsal -----------------------------------------
sort!: 1.440000 0.010000 1.450000 ( 1.446833)
sort: 1.440000 0.000000 1.440000 ( 1.448257)
--------------------------------- total: 2.890000sec
user system total real
sort!: 1.460000 0.000000 1.460000 ( 1.458065)
sort: 1.450000 0.010000 1.450000 ( 1.455963)
The rehearsal pass shows all blocks running without GC pauses between them. The second pass is the real measurement, with GC.start called before each block. Notice that the rehearsal total is sometimes slightly faster or slower than the measured pass; this is normal and reflects OS-level timing variance, not a problem with your code.
Keep in mind that bmbm only handles GC variance. It cannot protect against JIT compilation effects, OS scheduler preemption, or CPU frequency scaling. For workloads where those factors matter, running the full benchmark script multiple times in separate Ruby processes gives more stable results.
Measuring simple elapsed time
If you only need the wall-clock time and nothing else, Benchmark.realtime returns a plain Float:
require 'benchmark'
elapsed = Benchmark.realtime do
sleep 0.1
end
puts elapsed.round(2)
# => 0.1
Benchmark.realtime is useful when you want a quick measurement without dealing with Benchmark::Tms objects. It is also the lightest-weight option: no label formatting, no table output, just a number. When you are logging performance data to a file or feeding it into a monitoring system, a plain float is often easier to work with than a structured object.
Controlling garbage collection manually
For very tight microbenchmarks where even bmbm’s GC handling is insufficient, you can disable GC entirely during the measurement:
require 'benchmark'
GC.disable
begin
result = Benchmark.bm do |x|
x.report("fast") { 1_000_000.times { 1 + 2 } }
end
ensure
GC.enable
end
Always re-enable GC in an ensure block so it gets called even if your benchmark raises an exception. Leaving GC disabled too long causes memory to accumulate and can make your system unresponsive. The begin...ensure...end pattern here is not optional; skipping the re-enable step turns a benchmark into a memory leak.
For most benchmarks, a better approach is to call GC.start once before the entire benchmark run instead of disabling GC entirely:
GC.start # prime GC before measuring
Benchmark.bm(7) do |x|
x.report("approach A") { ... }
x.report("approach B") { ... }
end
Calling GC.start once before the benchmark run pushes a full collection out of the way so the measured blocks start from a predictable heap state. This is less invasive than disabling GC and carries no risk of leaving the process in a broken state. The trade-off is that occasional GC pauses can still happen during the run if the workload allocates enough to trigger one.
How do I measure iterations per second with benchmark-ips?
Ruby’s standard Benchmark module measures absolute time. For very fast code, iterations per second (IPS) is often more intuitive. The benchmark-ips gem measures how many times your code can run per second.
Install it with:
gem install benchmark-ips
Once installed, you create a benchmark block with Benchmark.ips. The gem runs your code repeatedly in a tight loop, counts how many iterations complete per second, and computes a stable average across the measurement window. The warmup parameter gives the JIT time to settle, and time controls how many seconds of data the gem collects before reporting:
require 'benchmark/ips'
Benchmark.ips do |x|
x.config(warmup: 2, time: 5)
x.report("addition") { 1 + 2 }
x.report("multiply") { 1 * 2 }
x.compare!
end
The warmup phase runs each block for the configured number of seconds without recording results, giving the JIT compiler a chance to optimize the hot path. After warmup, the measurement phase collects timing data and computes iterations per second along with a standard deviation. The compare! call at the end prints a relative speed comparison, showing which variant is faster and by how much:
Warming up --------------------------------------
addition 3.572M i/100ms
multiply 3.581M i/100ms
Calculating -------------------------------------
addition 36.209M (± 2.8%) i/s (27.62 ns/i)
multiply 36.215M (± 2.1%) i/s (27.61 ns/i)
Comparison:
addition: 36209044.5 i/s
multiply: 36215173.7 i/s
x.config(warmup: 2, time: 5) sets 2 seconds of warmup and 5 seconds of measurement. The default is 2 seconds warmup and 5 seconds measurement. x.compare! prints a comparison table at the end. The standard deviation percentage (the ± 2.8% in the output) tells you how much variation exists across iterations; lower numbers mean more stable benchmarks.
This style is especially useful when the code is tiny and the difference between approaches is measured in nanoseconds. In that range, the question is not whether one run won once. The question is whether one approach keeps winning across repeated runs with the same setup. IPS makes that pattern easier to see because it aggregates thousands or millions of iterations into a single, stable number.
High standard deviation (above 10%) means your results are noisy and you should run the benchmark again with longer warmup or measurement time. The smaller the code under test, the more discipline the benchmark needs. Warmup, iteration count, and repeatability matter more than the specific API you use. If the benchmark does not survive a second run, the numbers are not ready to guide a decision yet.
Warm-up effects and JIT
Ruby 3.x includes YJIT and MJIT, which optimise code after the first few executions. MJIT was deprecated in Ruby 3.2; YJIT is the current default. This means your first run is always slower than subsequent runs. Always warm up with a few iterations before taking measurements:
require 'benchmark'
# warm up JIT
3.times { 1_000_000.times { 1 + 2 } }
# now measure
time = Benchmark.measure do
1_000_000.times { 1 + 2 }
end
puts time.real
Run your benchmark multiple times across separate Ruby invocations and compare the results. JIT state persists within a process, so one slow run might be a cold-start effect instead of a genuine performance difference. Running the script with ruby --yjit enables YJIT explicitly, which is the default in Ruby 3.2 and later.
Frequently asked questions about benchmarking Ruby code
When should I use Benchmark.measure instead of Benchmark.bm?
Use Benchmark.measure when you are timing a single block of code and only care about one result. Use Benchmark.bm when you want to compare two or more approaches side by side. The output format is the main difference; measure returns an object while bm prints a formatted table.
How do I make benchmarking Ruby code more reliable?
Warm up the JIT with a few dry runs before measuring, use Benchmark.bmbm when GC noise is a concern, and run the entire benchmark script multiple times to catch variance. Single-run measurements are rarely reliable enough to base a decision on.
What is the difference between real time and CPU time?
Real time is wall-clock elapsed time, the same thing a stopwatch measures. CPU time (user + system) counts only the cycles your process actually used. When real time is much higher than CPU time, your code is waiting on I/O, a network call, or the operating system scheduler.
See Also
- Ruby array methods guide — Benchmark results often involve sorting or searching arrays
Enumerable#map— Collection iteration patterns frequently compared in benchmarksHash#merge— Hash lookup and manipulation benchmarks- Ruby GC tuning guide — Deeper look at Ruby garbage collection and performance
- Ruby Benchmark module docs — Official Ruby API reference