Benchmarking & Profiling
Reviewed & published by Brayan K
Benchmarking is measuring how long code takes so you can compare approaches with evidence, using Ruby's built-in Benchmark library instead of guessing which version is faster.
Part of the free Ruby course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
In this lesson you'll measure fairly with bm, bmbm, and realtime — and learn to report the winner, not machine-specific seconds.
What You'll Learn in This Lesson
- Timing a block with Benchmark.realtime
- Comparing approaches with Benchmark.bm
- Fair head-to-head measurement with bmbm
- Why frozen string literals are faster
- How object allocation hurts performance
- Reporting the winner, not the raw seconds
1️⃣ Benchmark.realtime: Time a Block
The simplest tool is Benchmark.realtime, which returns the elapsed wall-clock seconds for a block. Wrap each approach, compare the two numbers, and print the conclusion — never the raw seconds, since those depend on the machine. Here we prove that appending with << beats rebuilding strings with +.
require 'benchmark'
# Benchmark.realtime returns elapsed wall-clock seconds for a block.
# We compare two ways to build a big string and report which is faster.
n = 200_000
concat_time = Benchmark.realtime do
s = ""
n.times { s = s + "x" } # creates a new string every time (slow)
end
append_time = Benchmark.realtime do
s = +"" # a mutable string
n.times { s << "x" } # appends in place (fast)
end
# Don't print machine-specific seconds — print the CONCLUSION.
faster = append_time < concat_time ? "<< (append in place)" : "+ (concat)"
puts "Comparing string building (#{n} iterations):"
puts "Faster approach: #{faster}"2️⃣ Benchmark.bm: Labelled Comparisons
Benchmark.bm prints a tidy table of labelled blocks and returns their measurements. We capture each report result, compare the .real times, and announce the winner. Here, appending to an array with << beats building a fresh array with + each step.
require 'benchmark'
# Benchmark.bm prints a labelled table; we capture each result and report
# the WINNER, never the raw seconds (which vary by machine).
n = 100_000
Benchmark.bm(14) do |bm|
@push = bm.report("array <<") do
n.times do
a = []
100.times { |i| a << i } # append in place
end
end
@concat = bm.report("array + [x]") do
n.times do
a = []
100.times { |i| a = a + [i] } # build a NEW array every time
end
end
end
winner = @push.real < @concat.real ? "<< (append in place)" : "+ (concat)"
puts "Faster: #{winner}"3️⃣ bmbm, Frozen Strings & Allocation
Benchmark.bmbm runs a rehearsal pass first so warm-up and garbage-collection effects don't bias the result — the fairest comparison. We use it to show that a frozen string literal, reused as one object, beats allocating a fresh String on every call. The lesson underneath: fewer allocations, faster code.
require 'benchmark'
# Benchmark.bmbm runs a REHEARSAL first, then the real measurement,
# so warm-up/GC effects don't skew the comparison. Great for fairness.
# Here: a frozen (interned) string literal vs a fresh allocation each call.
n = 1_000_000
results = Benchmark.bmbm(16) do |bm|
bm.report("frozen literal") do
n.times { "hello".freeze } # same frozen object reused
end
bm.report("fresh string") do
n.times { +"hello" } # a brand-new String each iteration
end
end
frozen, fresh = results
winner = frozen.real < fresh.real ? "frozen literal" : "fresh string"
puts "Faster: #{winner} (no per-call allocation)"🎯 Your Turn
Measure symbol-key vs string-key hash lookups. Replace the ___ with the Benchmark method that returns elapsed seconds for a block.
require 'benchmark'
# 🎯 YOUR TURN — fill in the ___ so we measure the elapsed time of the block.
n = 500_000
symbol_time = Benchmark.___ do # 👉 which method returns elapsed seconds?
h = { name: "Ada", role: "dev" }
n.times { h[:name] } # symbol-key lookup
end
string_time = Benchmark.realtime do
h = { "name" => "Ada", "role" => "dev" }
n.times { h["name"] } # string-key lookup (must hash the string)
end
winner = symbol_time < string_time ? "symbol keys" : "string keys"
puts "Faster: #{winner}"
# ✅ Expected output:
# Faster: symbol keysCommon Errors (and the fix)
- "uninitialized constant Benchmark" — ❌ Missing require. ✅ Add require 'benchmark'.
- Quoting absolute seconds — ❌ "It runs in 0.03s" isn't portable. ✅ Report which approach is faster.
- Iteration count too low — ❌ Noise swamps the signal. ✅ Loop enough times that the difference is clear and stable.
- Unfair first-run penalty — ❌ Warm-up skews bm. ✅ Use bmbm for a rehearsal pass.
- Benchmarking trivial code — ❌ Micro-optimising cold paths. ✅ Profile first; optimise the hot loop that actually matters.
Pro Tips
- 💡 Compare, don't quote: the reproducible result is the winner, not the seconds.
- 💡 benchmark-ips gem: for serious work, it reports iterations-per-second with statistical confidence.
- 💡 Measure before optimising: a profiler tells you where time actually goes, so you don't tune the wrong code.
Mini-Challenge: Loop vs Built-in sum
Use Benchmark.realtime to compare a manual each accumulation against the built-in sum, and print which wins. Run with ruby bench.rb.
# 🎯 MINI-CHALLENGE: compare a manual loop vs Enumerable#sum.
# Save as bench.rb and run with: ruby bench.rb
#
# 1. require 'benchmark'.
# 2. Time summing a 1000-element array with: (a) an each loop that
# accumulates a total, and (b) the built-in numbers.sum.
# 3. Use Benchmark.realtime for each, compare, and print the WINNER
# (not the raw seconds — those depend on the machine).
#
# ✅ Expected output:
# Comparing two ways to total 1000 numbers:
# Faster: numbers.sum (built-in)
# your code here📋 Quick Reference — Benchmarking
| Goal | Ruby |
|---|---|
| Load it | require 'benchmark' |
| Time one block | Benchmark.realtime { ... } |
| Labelled table | Benchmark.bm { |b| b.report(...) } |
| Fair rehearsal | Benchmark.bmbm { ... } |
| Read a result | result.real |
| Freeze literals | # frozen_string_literal: true |
| Report | the winner, not the seconds |
🎉 Lesson Complete!
- ✅ Benchmark.realtime times a block in seconds
- ✅ Benchmark.bm compares labelled approaches in a table
- ✅ Benchmark.bmbm rehearses first for a fair fight
- ✅ Frozen literals and fewer allocations mean faster code
- ✅ Report the winner, not machine-specific seconds
Practice quiz
Which require do you need before using the Benchmark library?
- require 'time'
- require 'bench'
- require 'benchmark'
- No require is needed
Answer: require 'benchmark'. Benchmark lives in the standard library; load it with require 'benchmark'.
What does Benchmark.realtime { ... } return?
- The elapsed wall-clock seconds (a Float) for the block
- A labelled timing table
- The number of iterations
- Nothing — it only prints
Answer: The elapsed wall-clock seconds (a Float) for the block. Benchmark.realtime returns the elapsed seconds for the block as a Float.
What is the key difference between Benchmark.bm and Benchmark.bmbm?
- bm is for arrays, bmbm is for strings
- bmbm prints nothing
- There is no difference
- bmbm runs a rehearsal pass first to reduce warm-up/GC bias
Answer: bmbm runs a rehearsal pass first to reduce warm-up/GC bias. bmbm runs every block once as a rehearsal before the measured pass, giving a fairer comparison.
When reporting benchmark results, what should you publish?
- The exact seconds, e.g. '0.03s'
- The relative winner — which approach is consistently faster
- The machine's CPU model
- The iteration count only
Answer: The relative winner — which approach is consistently faster. Raw seconds depend on the machine; the reproducible conclusion is which approach wins.
Why are frozen string literals often faster in hot loops?
- Ruby reuses one immutable object instead of allocating a new String each time
- They run on a separate thread
- They skip the garbage collector entirely forever
- They are compiled to machine code
Answer: Ruby reuses one immutable object instead of allocating a new String each time. Freezing lets Ruby reuse a single object, cutting allocations and GC work.
In a hot loop, appending in place with << usually beats building a new array with + because:
- + is a syntax error on arrays
- << sorts as it goes
- + allocates a brand-new array every iteration, creating GC pressure
- << uses less memory by compressing data
Answer: + allocates a brand-new array every iteration, creating GC pressure. a = a + [x] builds a new array each step; a << x mutates in place with far fewer allocations.
Given two Benchmark report results r1 and r2, how do you read each one's measured time?
- r1.seconds
- r1.real
- r1.time
- r1.elapsed
Answer: r1.real. A report result exposes .real (and .user/.system/.total) — compare r1.real < r2.real.
Why shouldn't you trust a single timing measurement?
- Ruby caches all results
- Single runs are always too fast
- The first run is always the fastest
- Noise from GC, CPU scaling, and background work makes one run unreliable
Answer: Noise from GC, CPU scaling, and background work makes one run unreliable. Background processes and especially GC make a lone run noisy; run many iterations and compare.
How do you freeze every string literal in a whole file at once?
- Call freeze on each string
- Add the magic comment # frozen_string_literal: true as the first line
- Run with ruby --freeze
- Wrap the file in a freeze block
Answer: Add the magic comment # frozen_string_literal: true as the first line. The magic comment on line one freezes all string literals in that file.
What is the main reason heavy object allocation in a loop hurts performance?
- It uses more disk space
- It disables the JIT permanently
- Each object must be tracked and freed by the garbage collector, costing time
- Allocation always blocks the network
Answer: Each object must be tracked and freed by the garbage collector, costing time. Every allocated object eventually burdens the GC, so fewer throwaway objects means faster code.
Continue this course
- Previous: CLI Scripts with OptionParser
- Next: Checkpoint: Advanced Ruby — Combine Comparable, a lazy Enumerator and threads in a build — then a quiz
- Quick reference: Ruby cheat sheet
Frequently asked questions
Why shouldn't I trust a single timing measurement?
A single run is noisy: background processes, CPU frequency scaling, and especially garbage collection can make the same code look fast one moment and slow the next. The raw seconds also depend entirely on the machine, so they aren't portable. That's why good benchmarking runs each approach many times over a large iteration count and compares relative results — which one is consistently faster — rather than quoting an absolute number. The conclusion ('append beats concat') travels; the seconds don't.
What is the difference between Benchmark.bm and Benchmark.bmbm?
Both run labelled blocks and print a timing table, but bmbm adds a rehearsal: it runs every block once to warm up before the measured pass. That rehearsal lets the JIT, memory allocator, and caches settle, so the real measurement isn't unfairly penalising whichever block happened to run first. Use bm for a quick look and bmbm when you want the fairest head-to-head comparison, particularly if one approach allocates a lot and would otherwise trigger garbage collection mid-measurement.
Why are frozen string literals faster?
When you freeze a string literal, Ruby can reuse a single immutable object instead of allocating a brand-new String every time that literal is evaluated. Fewer allocations means less work for the garbage collector and better cache behaviour, which adds up in hot loops. You can opt in file-wide with the magic comment frozen_string_literal: true at the top of a file. It's one of the cheapest performance wins available — though it only helps when you don't need to mutate the string.
How does object allocation affect performance?
Every object Ruby creates must eventually be tracked and freed by the garbage collector, so code that allocates heavily in a loop — building new arrays or strings each iteration — spends real time on allocation and GC pauses. Mutating in place with << or map!, reusing frozen literals, and choosing methods that allocate less are common optimisations. The benchmarks in this lesson all illustrate the same theme: the approach that creates fewer throwaway objects usually wins, which is why measuring allocation matters as much as raw speed.