Where the Time and Memory Go

Article · 14:00

Ruby is fast enough for almost everything, and the times it is not are usually one of a handful of mistakes. Measure first, which this lesson does with a small timing helper rather than guesswork: string building, the N+1, hoisting, and memoisation that breaks on nil.

This lesson has 8 runnable examples. Press Try yourself! under any of them to open it in the editor.

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.

RB Example 1 17 lines
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.

RB Example 2 19 lines
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.

RB Example 3 17 lines
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.

RB Example 4 19 lines
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

RB Example 5 16 lines
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

RB Example 6 12 lines
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

RB Example 7 35 lines
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

RB Example 8 14 lines
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_MONOTONIC before changing anything.
  • join for 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 on nil and false; use defined?.
  • lazy when only the first few results are wanted from a large source.