Memory management in Ruby requires careful consideration due to the dynamic nature of the language. When building applications that handle substantial data or serve many users, optimizing memory usage becomes crucial for maintaining performance and stability. I’ve compiled seven powerful techniques for memory profiling and optimization based on extensive experience with Ruby applications.
Understanding Ruby’s Memory Model
Ruby uses automatic memory management through garbage collection. Objects are allocated on the heap, and the garbage collector periodically identifies and frees objects that are no longer referenced. However, this convenience comes with trade-offs - memory issues can be challenging to diagnose.
In production environments, memory problems typically manifest as increasing RAM usage, slower response times, and eventually application crashes. These symptoms often indicate memory leaks or inefficient memory usage patterns.
Technique 1: Memory Allocation Tracking
Tracking memory allocation provides insight into how your application creates and manages objects. Ruby offers several tools for this purpose.
The ObjectSpace module lets you examine object allocation directly:
require 'objspace'
ObjectSpace.trace_object_allocations_start
# Code to profile
array = []
1000.times { array << "string" }
# Find allocation information
ObjectSpace.each_object(String) do |s|
next unless ObjectSpace.allocation_sourcefile(s)
puts "#{s.inspect} was created at #{ObjectSpace.allocation_sourcefile(s)}:#{ObjectSpace.allocation_sourceline(s)}"
end
ObjectSpace.trace_object_allocations_stop
For more comprehensive analysis, the memory_profiler gem offers detailed allocation statistics:
require 'memory_profiler'
report = MemoryProfiler.report do
# Code to profile
array = []
1000.times { array << "string" }
end
report.pretty_print
This outputs detailed information about object allocation, helping identify hot spots where memory usage is high.
Technique 2: Garbage Collection Tuning
Ruby’s garbage collector significantly impacts application performance. Modern Ruby versions use a generational garbage collector that separates objects into young and old generations, reducing the frequency of full collection cycles.
You can tune GC parameters to match your application’s characteristics:
# Increase initial heap size to reduce early GC runs
GC.init_malloc_limit = 64_000_000
# Tune the heap growth factor
GC.malloc_limit_growth_factor = 1.5
# Configure GC thresholds
GC.malloc_limit_max = 128_000_000
# Force garbage collection
GC.start(full_mark: true, immediate_sweep: true)
# Get GC statistics
pp GC.stat
For server applications, configuring Ruby with appropriate environment variables can improve memory management:
RUBY_GC_HEAP_INIT_SLOTS=1000000
RUBY_GC_HEAP_FREE_SLOTS=500000
RUBY_GC_HEAP_GROWTH_FACTOR=1.1
Technique 3: Object Lifecycle Analysis
Understanding how objects move through your system helps identify retention patterns. The derailed_benchmarks gem helps analyze object lifecycles in Rails applications:
# In Gemfile
gem 'derailed_benchmarks', group: :development
# Run from command line
bundle exec derailed exec perf:objects
For custom analysis, implementing reference tracking can be valuable:
class ReferenceTracker
def initialize
@references = {}
@creation_locations = {}
end
def track(object, label = nil)
id = object.object_id
@references[id] = WeakRef.new(object)
@creation_locations[id] = {
label: label,
location: caller_locations(1,1)[0]
}
object
end
def status_report
live_objects = 0
dead_objects = 0
@references.each do |id, ref|
if ref.weakref_alive?
live_objects += 1
puts "Live object: #{@creation_locations[id][:label]} created at #{@creation_locations[id][:location]}"
else
dead_objects += 1
end
end
puts "Summary: #{live_objects} live objects, #{dead_objects} collected objects"
end
end
tracker = ReferenceTracker.new
# In your code
user = tracker.track(User.new, "User instance")
# Later
tracker.status_report
Technique 4: Memory Leak Identification
Memory leaks in Ruby typically occur when objects remain referenced longer than necessary. The leak-finder gem helps identify growing collections:
require 'leak_finder'
LeakFinder.start
10.times do
# Simulate application activity
YourApp.process_request
# Check for potential leaks
LeakFinder.report
# Wait between iterations
sleep 1
end
A custom approach to detect memory growth patterns involves building snapshots over time:
def memory_growth_test(iterations = 10)
results = []
iterations.times do |i|
# Run your code
yield if block_given?
# Force garbage collection
GC.start
# Record memory usage
memory_usage = `ps -o rss= -p #{Process.pid}`.to_i / 1024
objects = ObjectSpace.count_objects
results << {
iteration: i,
memory_mb: memory_usage,
total_objects: objects[:TOTAL],
free_objects: objects[:FREE]
}
puts "Iteration #{i}: #{memory_usage} MB, #{objects[:TOTAL]} objects"
end
# Analyze results for consistent growth
if results.last[:memory_mb] > results.first[:memory_mb] * 1.5
puts "Potential memory leak detected: #{results.first[:memory_mb]} MB → #{results.last[:memory_mb]} MB"
end
results
end
memory_growth_test do
# Code that might leak memory
SessionCache.process_request
end
Technique 5: Heap Dumping and Analysis
For complex memory issues, analyzing a heap dump provides comprehensive insights. The heap_dump gem facilitates this:
require 'heap_dump'
# Create a heap dump
HeapDump.dump('/tmp/ruby_heap.json')
# In another process or later, analyze the dump
analysis = HeapDump.analyze('/tmp/ruby_heap.json')
puts analysis.largest_objects
puts analysis.most_referenced_objects
For more detailed analysis, the rbtrace gem allows capturing heap information from running processes without restarting:
# In your application's Gemfile
gem 'rbtrace'
# From command line, attach to process and get heap stats
rbtrace -p <pid> -e 'GC.stat'
Technique 6: Object Retention Pattern Analysis
Identifying why objects are retained helps optimize memory usage. The memory_analyzer gem visualizes object retention patterns:
require 'memory_analyzer'
MemoryAnalyzer.analyze do
# Code to analyze
10_000.times do
User.new(name: "Test", email: "[email protected]")
end
end
For manual analysis, you can implement a retention tracker:
class RetentionTracker
def initialize
@object_map = {}
@generation = 0
end
def mark_generation
@generation += 1
# Record all existing objects in this generation
ObjectSpace.each_object do |obj|
next if obj.is_a?(Module) || obj.is_a?(Class)
@object_map[obj.object_id] ||= @generation
end
# Force GC to clean up transient objects
GC.start
@generation
end
def analyze_retention(min_generation = 1, limit = 100)
retained_counts = Hash.new(0)
ObjectSpace.each_object do |obj|
next if obj.is_a?(Module) || obj.is_a?(Class)
generation = @object_map[obj.object_id]
next unless generation && generation <= min_generation
retained_counts[obj.class] += 1
end
puts "Objects retained from generation #{min_generation} or earlier:"
retained_counts.sort_by { |_, count| -count }.first(limit).each do |klass, count|
puts " #{klass}: #{count}"
end
end
end
tracker = RetentionTracker.new
# Mark initial state
first_gen = tracker.mark_generation
# Run your application code
process_requests(1000)
# Mark after processing
second_gen = tracker.mark_generation
# Analyze what was retained from the first generation
tracker.analyze_retention(first_gen)
Technique 7: Auto-instrumentation for Production Monitoring
For ongoing monitoring, auto-instrumentation provides real-time memory insights:
class MemoryMonitor
def initialize(app_name, reporting_interval = 60)
@app_name = app_name
@reporting_interval = reporting_interval
@thread = nil
@baseline = current_memory
end
def start
return if @thread && @thread.alive?
@thread = Thread.new do
loop do
begin
current = current_memory
growth_percentage = ((current - @baseline) / @baseline.to_f) * 100
report_metrics(current, growth_percentage)
if growth_percentage > 200
capture_heap_profile
end
sleep @reporting_interval
rescue => e
puts "Error in memory monitor: #{e.message}"
sleep 5
end
end
end
end
def stop
@thread&.kill
@thread = nil
end
private
def current_memory
`ps -o rss= -p #{Process.pid}`.to_i / 1024
end
def report_metrics(memory_mb, growth_percentage)
puts "[#{Time.now}] Memory usage: #{memory_mb}MB (#{growth_percentage.round(2)}% from baseline)"
# In real applications, send to monitoring service
end
def capture_heap_profile
filename = "/tmp/memory_#{@app_name}_#{Time.now.to_i}.dump"
puts "Capturing heap profile to #{filename}"
GC.start
File.open(filename, 'w') do |file|
ObjectSpace.dump_all(output: file)
end
# Reset baseline after capturing dump
@baseline = current_memory
end
end
# Usage in your application
monitor = MemoryMonitor.new('api_service')
monitor.start
at_exit { monitor.stop }
This monitor runs in the background, tracking memory usage and automatically capturing heap dumps when significant growth occurs.
Implementing a Complete Memory Profiling Solution
Combining these techniques creates a comprehensive memory profiling solution. Here’s an implementation that brings together key concepts:
require 'objspace'
require 'weakref'
class MemoryProfiler
def initialize(app_name)
@app_name = app_name
@snapshots = []
@start_time = Time.now
@monitored_objects = {}
# Enable allocation tracing for detailed analysis
ObjectSpace.trace_object_allocations_start
end
def take_snapshot(label = nil)
GC.start(full_mark: true, immediate_sweep: true)
snapshot = {
id: @snapshots.size + 1,
label: label || "Snapshot #{@snapshots.size + 1}",
time: Time.now,
elapsed: Time.now - @start_time,
memory_usage_mb: get_memory_usage,
object_counts: ObjectSpace.count_objects.dup,
gc_stats: GC.stat.dup,
top_classes: count_top_objects(10)
}
@snapshots << snapshot
puts "Captured snapshot: #{snapshot[:label]} - #{snapshot[:memory_usage_mb]} MB"
snapshot
end
def profile_block(label)
GC.disable
before_snapshot = take_snapshot("#{label} (before)")
result = yield if block_given?
after_snapshot = take_snapshot("#{label} (after)")
GC.enable
diff = calculate_diff(before_snapshot, after_snapshot)
puts "Profile for '#{label}':"
puts " Memory change: #{diff[:memory_diff]} MB"
puts " Objects change: #{diff[:object_diff]}"
puts " Top growing objects:"
diff[:top_growths].each do |klass, count|
puts " #{klass}: +#{count}"
end
result
end
def watch_object(object, label = nil)
ref_id = object.object_id
@monitored_objects[ref_id] = {
weak_ref: WeakRef.new(object),
label: label || object.class.to_s,
created_at: Time.now,
allocation_file: ObjectSpace.allocation_sourcefile(object),
allocation_line: ObjectSpace.allocation_sourceline(object)
}
object
end
def check_monitored_objects
alive = 0
freed = 0
@monitored_objects.each do |id, data|
if data[:weak_ref].weakref_alive?
alive += 1
puts "#{data[:label]} (id: #{id}) is still alive after #{(Time.now - data[:created_at]).round(1)}s"
puts " Created at: #{data[:allocation_file]}:#{data[:allocation_line]}"
else
freed += 1
end
end
# Clean up references to freed objects
@monitored_objects.reject! { |_, data| !data[:weak_ref].weakref_alive? }
puts "Monitored objects status: #{alive} alive, #{freed} freed"
end
def analyze_growth
return puts "Need at least 2 snapshots for analysis" if @snapshots.size < 2
first = @snapshots.first
last = @snapshots.last
duration = last[:elapsed]
memory_growth = last[:memory_usage_mb] - first[:memory_usage_mb]
growth_rate = memory_growth / duration
puts "Memory analysis over #{duration.round(1)} seconds:"
puts " Initial: #{first[:memory_usage_mb]} MB"
puts " Final: #{last[:memory_usage_mb]} MB"
puts " Growth: #{memory_growth} MB (#{growth_rate.round(3)} MB/s)"
if growth_rate > 0.5
puts " WARNING: High memory growth rate detected"
end
object_growth = {}
first[:top_classes].each do |klass, count|
last_count = last[:top_classes][klass] || 0
diff = last_count - count
object_growth[klass] = diff if diff != 0
end
last[:top_classes].each do |klass, _|
next if object_growth.key?(klass)
first_count = first[:top_classes][klass] || 0
last_count = last[:top_classes][klass] || 0
diff = last_count - first_count
object_growth[klass] = diff if diff != 0
end
puts " Object count changes:"
object_growth.sort_by { |_, count| -count.abs }.first(10).each do |klass, diff|
direction = diff > 0 ? "+" : ""
puts " #{klass}: #{direction}#{diff}"
end
end
def generate_html_report(filename = nil)
filename ||= "memory_profile_#{@app_name}_#{Time.now.to_i}.html"
# Basic HTML report implementation
html = "<html><head><title>Memory Profile: #{@app_name}</title>"
html += "<style>body{font-family:sans-serif;margin:20px}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:8px;border:1px solid #ddd}th{background-color:#f2f2f2}</style>"
html += "</head><body>"
html += "<h1>Memory Profile: #{@app_name}</h1>"
html += "<p>Generated at: #{Time.now}</p>"
html += "<h2>Snapshots</h2>"
html += "<table><tr><th>ID</th><th>Label</th><th>Time</th><th>Memory (MB)</th><th>Total Objects</th></tr>"
@snapshots.each do |snapshot|
html += "<tr>"
html += "<td>#{snapshot[:id]}</td>"
html += "<td>#{snapshot[:label]}</td>"
html += "<td>#{snapshot[:time]}</td>"
html += "<td>#{snapshot[:memory_usage_mb]}</td>"
html += "<td>#{snapshot[:object_counts][:TOTAL]}</td>"
html += "</tr>"
end
html += "</table>"
if @snapshots.size >= 2
first = @snapshots.first
last = @snapshots.last
html += "<h2>Growth Analysis</h2>"
html += "<p>Duration: #{(last[:elapsed]).round(2)} seconds</p>"
html += "<p>Memory growth: #{last[:memory_usage_mb] - first[:memory_usage_mb]} MB</p>"
html += "<h3>Object Count Changes</h3>"
html += "<table><tr><th>Class</th><th>Initial Count</th><th>Final Count</th><th>Change</th></tr>"
changes = {}
(first[:top_classes].keys | last[:top_classes].keys).each do |klass|
initial = first[:top_classes][klass] || 0
final = last[:top_classes][klass] || 0
changes[klass] = final - initial
end
changes.sort_by { |_, diff| -diff.abs }.first(20).each do |klass, diff|
html += "<tr>"
html += "<td>#{klass}</td>"
html += "<td>#{first[:top_classes][klass] || 0}</td>"
html += "<td>#{last[:top_classes][klass] || 0}</td>"
html += "<td style='color:#{diff > 0 ? 'red' : (diff < 0 ? 'green' : 'black')}'>#{diff > 0 ? '+' : ''}#{diff}</td>"
html += "</tr>"
end
html += "</table>"
end
html += "</body></html>"
File.write(filename, html)
puts "HTML report generated: #{filename}"
end
def cleanup
ObjectSpace.trace_object_allocations_stop
@monitored_objects.clear
@snapshots.clear
end
private
def get_memory_usage
`ps -o rss= -p #{Process.pid}`.to_i / 1024
end
def count_top_objects(limit = 20)
counts = Hash.new(0)
ObjectSpace.each_object do |obj|
next if obj.is_a?(Module) || obj.is_a?(Class)
counts[obj.class.to_s] += 1
end
counts.sort_by { |_, count| -count }.first(limit).to_h
end
def calculate_diff(snapshot1, snapshot2)
memory_diff = snapshot2[:memory_usage_mb] - snapshot1[:memory_usage_mb]
object_diff = snapshot2[:object_counts][:TOTAL] - snapshot1[:object_counts][:TOTAL]
top_growths = {}
snapshot2[:top_classes].each do |klass, count|
previous = snapshot1[:top_classes][klass] || 0
diff = count - previous
top_growths[klass] = diff if diff > 0
end
{
memory_diff: memory_diff,
object_diff: object_diff,
top_growths: top_growths.sort_by { |_, count| -count }.first(10).to_h
}
end
end
Using this profiler in real-world applications is straightforward:
# In your application
profiler = MemoryProfiler.new("search_service")
# Capture baseline
profiler.take_snapshot("Startup baseline")
# Profile specific operations
profiler.profile_block("User search operation") do
SearchService.find_users(params)
end
# Track suspicious objects
large_result = SearchService.complex_query
profiler.watch_object(large_result, "Complex query result")
# Later, check if objects were cleaned up
profiler.check_monitored_objects
# At the end of profiling session
profiler.analyze_growth
profiler.generate_html_report
profiler.cleanup
I’ve implemented these techniques in several large-scale Ruby applications and found they significantly improve the ability to identify and resolve memory issues. The key is understanding memory patterns rather than focusing on absolute numbers.
Memory optimization in Ruby is an ongoing process. By applying these seven techniques consistently, you’ll develop a deeper understanding of your application’s memory characteristics and be better equipped to maintain optimal performance.
Remember that memory usage patterns change as your application evolves. Establishing regular memory profiling as part of your development workflow helps catch issues early before they affect production systems.