Where the Time and Memory Go
Ruby is fast enough for almost everything, and the times it is not are usually one of a handful of mistakes. Measure first: this lesson uses a small timing helper rather than guesswork.
def timed(label)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = yield
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000
puts format("%-22s %7.1fms", label, elapsed)
result
end
def allocations
before = GC.stat(:total_allocated_objects)
yield
GC.stat(:total_allocated_objects) - before
end
timed("baseline loop") { 500_000.times { |i| i * 2 } }
puts "allocated #{allocations { 10_000.times { +'a new string' } }} objects"
puts "allocated #{allocations { 10_000.times { :a_symbol } }} objects"
Open this example in the editor and change it. Nothing you type is saved.
String building
+ creates a new string every time. << appends to the one you have.
def timed(label)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
puts format("%-22s %7.1fms", label, (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000)
end
words = Array.new(20_000) { |i| "word#{i}" }
timed("plus") do
out = +""
words.each { |w| out = out + w + "," }
end
timed("shovel") do
out = +""
words.each { |w| out << w << "," }
end
timed("join") { words.join(",") }
Open this example in the editor and change it. Nothing you type is saved.
join wins because it counts the total size once and allocates a single
buffer. Reach for it whenever the pieces are already in an array.
Frozen and deduplicated strings
Every evaluation of a string literal allocates. In a loop that matters.
def allocations
before = GC.stat(:total_allocated_objects)
yield
GC.stat(:total_allocated_objects) - before
end
puts "literal in a loop: #{allocations { 50_000.times { 'status' } }}"
puts "frozen literal: #{allocations { 50_000.times { -'status' } }}"
puts "symbol: #{allocations { 50_000.times { :status } }}"
# -"text" returns one shared frozen instance for that content.
puts (-"status").equal?(-"status")
puts "status".equal?("status")
# Which is why a hot lookup should key on symbols, or on frozen strings.
LOOKUP = { "gb" => "United Kingdom", "fr" => "France" }.freeze
puts LOOKUP[-"gb"]
Open this example in the editor and change it. Nothing you type is saved.
The N+1, in miniature
The single most common performance bug in a web application is a query inside a loop. Here the "queries" are just array scans, but the shape is exactly the one you will meet.
authors = (1..200).map { |id| { id: id, name: "Author #{id}" } }
books = (1..2_000).map { |id| { id: id, author_id: (id % 200) + 1, title: "Book #{id}" } }
def timed(label)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
puts format("%-22s %7.1fms", label, (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000)
end
# One lookup per book: 2,000 scans of the authors list.
timed("lookup per book") do
books.map { |b| authors.find { |a| a[:id] == b[:author_id] } [:name] }
end
# Build the index once, then every lookup is a hash hit.
timed("index once") do
by_id = authors.to_h { |a| [a[:id], a] }
books.map { |b| by_id[b[:author_id]][:name] }
end
Open this example in the editor and change it. Nothing you type is saved.
The fix is always the same: fetch the related data once, key it, then look up.
In a real application that is includes or a second query, not two hundred.
Work that did not need to be in the loop
def timed(label)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
puts format("%-22s %7.1fms", label, (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000)
end
lines = Array.new(50_000) { |i| "2024-05-17 level=#{i.even? ? 'INFO' : 'ERROR'} id=#{i}" }
timed("regex built each time") do
lines.count { |line| line =~ /level=(ERROR|FATAL)/ }
end
PATTERN = /level=(ERROR|FATAL)/
timed("regex hoisted") { lines.count { |line| line =~ PATTERN } }
timed("match? no captures") { lines.count { |line| PATTERN.match?(line) } }
Open this example in the editor and change it. Nothing you type is saved.
match? is the fastest of the three because it does not build a MatchData or
set $~. Use it whenever you only want the yes or no.
Choosing the right structure
def timed(label)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
puts format("%-22s %7.1fms", label, (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000)
end
haystack_array = (1..20_000).to_a
haystack_hash = haystack_array.to_h { |n| [n, true] }
needles = Array.new(2_000) { rand(20_000) }
timed("array include?") { needles.count { |n| haystack_array.include?(n) } }
timed("hash key?") { needles.count { |n| haystack_hash.key?(n) } }
Open this example in the editor and change it. Nothing you type is saved.
include? on an array walks it. key? on a hash does not. If you are testing
membership more than once, build the hash.
Memoising, and its one trap
class Report
def initialize(rows) = @rows = rows
def total
@total ||= begin
puts " (computing total)"
@rows.sum
end
end
# ||= re-runs whenever the value is nil or false, so a legitimately false
# or nil answer is computed every single time.
def empty_check
@empty ||= begin
puts " (computing empty?)"
@rows.empty?
end
end
# defined? asks whether the variable was set, which is the honest question.
def safe_empty_check
return @safe_empty if defined?(@safe_empty)
puts " (computing safe empty?)"
@safe_empty = @rows.empty?
end
end
report = Report.new([1, 2, 3])
puts report.total
puts report.total
puts report.empty_check
puts report.empty_check # computed again - the value was false
puts report.safe_empty_check
puts report.safe_empty_check # computed once
Open this example in the editor and change it. Nothing you type is saved.
Streaming instead of loading
def allocations
before = GC.stat(:total_allocated_objects)
result = yield
puts " allocated #{GC.stat(:total_allocated_objects) - before} objects"
result
end
source = (1..100_000)
# Three full intermediate arrays, all discarded.
allocations { source.map { |n| n * 2 }.select(&:even?).first(5) }
# One element at a time, nothing kept.
allocations { source.lazy.map { |n| n * 2 }.select(&:even?).first(5) }
Open this example in the editor and change it. Nothing you type is saved.
What to take away
- Measure with
CLOCK_MONOTONICbefore changing anything. joinfor building strings from a collection,<<when appending.-"text"and symbols avoid an allocation per evaluation.- The N+1 fix is always: fetch once, index by key, then look up.
- Hoist regexes and anything else constant out of the loop; prefer
match?. ||=memoisation breaks onnilandfalse; usedefined?.lazywhen only the first few results are wanted from a large source.