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

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 keys

Common Errors (and the fix)

Pro Tips

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

GoalRuby
Load itrequire 'benchmark'
Time one blockBenchmark.realtime { ... }
Labelled tableBenchmark.bm { |b| b.report(...) }
Fair rehearsalBenchmark.bmbm { ... }
Read a resultresult.real
Freeze literals# frozen_string_literal: true
Reportthe winner, not the seconds

🎉 Lesson Complete!

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

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.