Introduction
Every object Ruby allocates eventually needs to be garbage collected. When your hot loop creates thousands of short-lived objects, GC pauses add up. Allocation profiling shows you exactly where those objects come from.
Key Concepts
- Allocation hotspot: A line of code that creates a disproportionate number of objects during execution.
- GC pressure: The rate at which new allocations fill heap slots, forcing the garbage collector to run more frequently.
- Retained vs allocated: Allocated objects are everything created during profiling. Retained objects are those still alive at the end — these are the ones that survive GC and consume long-term memory.
Real World Context
A Rails endpoint that allocates 50,000 objects per request will spend 10-15% of its time in GC. Reducing allocations by half can cut response times by 30ms or more — a meaningful improvement at scale.
Deep Dive
The memory_profiler gem tracks every allocation and groups results by gem, file, and line:
rubyrequire 'memory_profiler' report = MemoryProfiler.report do 1000.times { User.new(name: "test") } end report.pretty_print
The output separates allocated objects (created during the block) from retained objects (still alive afterward). Focus on retained objects first, since they indicate potential memory leaks.
StackProf's :object mode is lighter-weight and works well in production:
rubyrequire 'stackprof' StackProf.run(mode: :object, out: 'alloc.dump') do allocation_heavy_code end # stackprof alloc.dump --text
Common allocation hotspots include string interpolation in loops, implicit splat arrays, and hash literals created repeatedly:
ruby# BAD: 1000 string allocations 1000.times { |i| log("Processing item #{i}") } # BETTER: only allocate when log level is enabled 1000.times { |i| log("Processing item #{i}") if logger.debug? } # BAD: splat creates a new array on every call def process(*args) args.each { |a| handle(a) } end # BAD: hash literal in a loop 1000.times { { key: compute_value } } # BETTER: reuse a mutable hash if safe buffer = {} 1000.times { buffer[:key] = compute_value; consume(buffer) }
Common Pitfalls
- Optimizing allocated instead of retained — Many allocated objects die immediately and are collected cheaply by minor GC. Focus on retained objects first, since they cause long-term memory growth.
- Freezing strings that are never duplicated —
"constant".freezeonly helps when the same literal appears in a hot loop. When the# frozen_string_literal: truepragma is active in a file, individual freezes are redundant.
Best Practices
- Use
memory_profilerin development, StackProf:objectin production —memory_profilerinstruments every allocation (high overhead), while StackProf samples (low overhead). - Pre-allocate arrays with known sizes —
Array.new(size)avoids repeated resizing. For hashes, useHash.newwith a default rather than re-creating literals.
Summary
- Excessive allocations increase GC frequency and pause time.
- Use
memory_profilerfor detailed allocation/retention reports and StackProf:objectfor sampling. - Focus on retained objects for memory leaks and allocated objects in hot paths for latency.
Code Examples
require 'memory_profiler'
report = MemoryProfiler.report do
data = []
1000.times do |i|
data << { id: i, label: "Item #{i}" } # 1000 Hashes + 1000 Strings
end
end
report.pretty_print(to_file: 'mem_report.txt')
# Key output lines:
# Total allocated: 2000 objects (1000 Hash, 1000 String)
# Total retained: 2000 objects (all kept in `data` array)