Skip to Content
Course content

112: Memory Profiling with memory_profiler

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've been staring at a production log for a report generator I wrote a few months ago. Every time it runs, the memory usage of the worker process spikes aggressively. It doesn't actually "leak" in the sense that the memory never comes back, but the sheer volume of allocations is triggering the Garbage Collector (GC) so often that the whole process slows to a crawl. I can tell it's happening, but I can't tell where. I don't want to guess and check; I want to see the actual numbers.

Why is this report eating my RAM?

Here is the snippet of code I'm worried about. It takes a list of transactions and generates a formatted summary string. It looks innocent enough, but when the input size hits 10,000 records, it starts acting up.

def generate_report(transactions)
  summary = ""
  transactions.each do |t|
    summary += "Transaction #{t[:id]}: #{t[:amount]} USD on #{t[:date]}\n"
  end
  summary
end

# Simulating a larger dataset
data = Array.new(10_000) do |i|
  { id: i, amount: rand(1..1000), date: "2023-01-01" }
end

generate_report(data)

If you run this, it finishes quickly. But in a real app, doing this inside a loop or with larger datasets creates a mountain of temporary objects. I suspect the += on the string is the culprit, but let's actually prove it using the memory_profiler gem. I'm not going to trust my gut here.

Bringing in the Profiler

First, I'll grab the gem: gem install memory_profiler. Now, instead of just calling the method, I'll wrap it in a MemoryProfiler.report block. This is the magic part—it tracks every single object allocation that happens inside that block.

require 'memory_profiler'

report = MemoryProfiler.report do
  generate_report(data)
end

report.pretty_print

When I run this, the output is massive. It's a wall of text, but there are two specific sections I always look at first: allocated memory and retained memory. Allocated is everything created during the block; retained is what stayed in memory after the block finished. In this case, the "allocated" section is screaming.

Reading the Map

I scroll down to the "allocated memory by location" section. I see something like this:

# Location: report_gen.rb:3
#  10001 objects, 450.2 KB allocated
#  String (10001)

There it is. Line 3 is where I'm doing the string interpolation and concatenation. I'm creating over 10,000 string objects. Wait, why 10,000? I'm only returning one final summary string.

Ah, right. In Ruby, summary += "..." doesn't just append to the existing string. It creates a brand new string object by combining the old summary and the new fragment, then throws the old summary object away. As the report grows, I'm creating increasingly larger strings and abandoning them for the GC to clean up. It's an O(n²) disaster in terms of memory allocation.

Cutting the Waste

Now that I have the evidence, I'll switch to a more efficient way of building strings. I'll use an array to collect the fragments and then join them at the end. This is a classic Ruby pattern, but seeing the profiler confirm the waste makes me feel much better about the change.

def generate_report(transactions)
  fragments = []
  transactions.each do |t|
    fragments < < "Transaction #{t[:id]}: #{t[:amount]} USD on #{t[:date]}"
  end
  fragments.join("\n")
end

I'll run the profiler again. The number of allocated strings drops significantly because I'm no longer re-creating the entire report body on every single iteration. I'm just creating the small fragments and one final joined string. The "allocated memory" section now shows a much leaner profile, and the execution time is snappier because the GC isn't panicking.

My takeaway here? Don't optimize based on a "feeling" that a loop is slow. Wrap the suspect code in MemoryProfiler.report, look at the "allocated memory by location" list, and target the line that's churning through the most objects.




📋 Practical Task

Exercise: Optimizing the CSV Data Parser

You have been given a method that parses a mock CSV string into a list of hashes. Currently, it's creating a massive amount of duplicate string objects for the keys in every single row, which is bloating memory usage during large imports.

require 'memory_profiler'

def parse_csv(csv_text)
  lines = csv_text.split("\n")
  headers = lines.shift.split(",")
  
  lines.map do |line|
    values = line.split(",")
    row = {}
    headers.each_with_index do |header, index|
      row[header] = values[index]
    end
    row
  end
end

# Mock CSV data: 5000 rows with the same 3 headers
csv_data = "name,email,city\n" + (["John Doe,john@example.com,New York\n"] * 5000).join

report = MemoryProfiler.report do
  parse_csv(csv_data)
end

report.pretty_print

Your Goal:

  1. Run the code and observe the "allocated memory" section. Notice how many String objects are being created for the keys (the headers).
  2. Modify the parse_csv method to ensure that the header strings are reused across all row hashes rather than being re-allocated or processed in a way that creates duplicates. (Hint: Look into how you handle the headers array or consider using String#-@ to freeze strings).
  3. Run the profiler again and verify that the total number of allocated objects has decreased.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.