A Comprehensive Guide to Identifying Memory Problems in Ruby

It’s safe to say that not all Ruby developers are fortunate enough to avoid memory-related problems. For many of us, identifying and resolving memory issues in Ruby applications can feel like searching for a needle in a haystack. The good news is that modern Ruby versions (2.1 and later) offer powerful tools and strategies for tackling these common headaches. While I might be in the minority, I find the process of memory optimization to be both engaging and satisfying.

Hunting Down Memory Issues In Ruby
If you thought bugs were pesky, wait until you hunt for memory issues.

It’s important to remember that optimization often comes at the cost of increased code complexity. Therefore, it’s crucial to ensure that any optimization efforts result in measurable and significant improvements to justify the added complexity.

The examples provided in this discussion are based on the standard MRI Ruby, specifically version 2.2.4. However, other versions of Ruby 2.1 and later should exhibit similar behavior.

Don’t Jump to “Memory Leak”!

When encountering a memory problem, it’s easy to assume a memory leak. For example, in web applications, you might observe that memory usage steadily climbs with each request to the same endpoint after the server starts. While genuine memory leaks can occur, many memory issues mimic this pattern without actually being leaks.

Consider a simplified Ruby code snippet that repeatedly constructs and discards a large array of hashes. Let’s begin with some code that will be used across various examples in this post:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# common.rb
require "active_record"
require "active_support/all"
require "get_process_mem"
require "sqlite3"

ActiveRecord::Base.establish_connection(
  adapter: "sqlite3",
  database: "people.sqlite3"
)

class Person < ActiveRecord::Base; end

def print_usage(description)
  mb = GetProcessMem.new.mb
  puts "#{ description } - MEMORY USAGE(MB): #{ mb.round }"
end

def print_usage_before_and_after
  print_usage("Before")
  yield
  print_usage("After")
end

def random_name
  (0...20).map { (97 + rand(26)).chr }.join
end

Now, let’s look at the array builder:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# build_arrays.rb
require_relative "./common"

ARRAY_SIZE = 1_000_000

times = ARGV.first.to_i

print_usage(0)
(1..times).each do |n|
  foo = []
  ARRAY_SIZE.times { foo << {some: "stuff"} }

  print_usage(n)
end

We’ll use the [get\_process\_mem](https://github.com/schneems/get_process_mem) gem, a handy tool for retrieving memory usage information for the current Ruby process. As anticipated, we observe a continuous rise in memory consumption, just like the scenario described earlier.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ ruby build_arrays.rb 10
0 - MEMORY USAGE(MB): 17
1 - MEMORY USAGE(MB): 330
2 - MEMORY USAGE(MB): 481
3 - MEMORY USAGE(MB): 492
4 - MEMORY USAGE(MB): 559
5 - MEMORY USAGE(MB): 584
6 - MEMORY USAGE(MB): 588
7 - MEMORY USAGE(MB): 591
8 - MEMORY USAGE(MB): 603
9 - MEMORY USAGE(MB): 613
10 - MEMORY USAGE(MB): 621

However, as we execute more iterations, we’ll eventually reach a plateau.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ ruby build_arrays.rb 40
0 - MEMORY USAGE(MB): 9
1 - MEMORY USAGE(MB): 323
...
32 - MEMORY USAGE(MB): 700
33 - MEMORY USAGE(MB): 699
34 - MEMORY USAGE(MB): 698
35 - MEMORY USAGE(MB): 698
36 - MEMORY USAGE(MB): 696
37 - MEMORY USAGE(MB): 696
38 - MEMORY USAGE(MB): 696
39 - MEMORY USAGE(MB): 701
40 - MEMORY USAGE(MB): 697

Reaching this plateau is a clear indicator that we’re not dealing with a true memory leak or that any potential leak is insignificant compared to the overall memory usage. The intriguing question is why memory usage continues to grow after the initial iteration. We created a substantial array but immediately discarded it and started building a new one of the same size. Shouldn’t it reuse the memory space freed up by the previous array? Unfortunately, the answer is no, and this explains our issue.

Except for garbage collector tuning, you lack direct control over its execution timing. In the build_arrays.rb example, new memory allocations are occurring before the garbage collector has had a chance to clean up our old, discarded objects.

Do not panic if you see a sudden rise in the memory usage of your app. Apps can run out of memory for all sorts of reasons - not just memory leaks.

It’s important to note that this isn’t a flaw in Ruby’s memory management. It’s a common characteristic of garbage-collected languages. To confirm this, I replicated a similar example in Go and observed comparable behavior. That said, certain Ruby libraries can make it easier to create this type of memory problem.

Breaking Down the Problem

If our work involves handling large datasets, are we destined to simply throw more RAM at the issue? Thankfully, no. If we revisit the build_arrays.rb example and reduce the array size, the memory usage plateau will also decrease proportionally.

This highlights an important point: by dividing our tasks into smaller chunks for processing and avoiding the simultaneous existence of too many objects, we can dramatically reduce our memory footprint. However, this often entails transforming elegant, concise code into more verbose code that achieves the same outcome but with improved memory efficiency.

Zeroing in on Memory Hotspots

In a real-world codebase, pinpointing the source of a memory issue is rarely as straightforward as in the build_arrays.rb example. Before delving into a fix, isolating the memory issue is crucial. It’s easy to make inaccurate assumptions about the root cause.

I typically employ two techniques, frequently in tandem, to track down memory issues: profiling the code without modification and observing memory usage while selectively disabling and enabling suspicious code sections. While I’ll use [memory\_profiler](https://github.com/SamSaffron/memory_profiler) for profiling here, other popular options include [ruby-prof](https://github.com/ruby-prof/ruby-prof) and [derailed\_benchmarks](https://github.com/schneems/derailed_benchmarks), which offers excellent Rails-specific features.

Let’s examine some code that consumes a significant amount of memory, where the biggest memory hog might not be immediately apparent:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# people.rb
require_relative "./common"

def run(number)
  Person.delete_all

  names = number.times.map { random_name }

  names.each do |name|
    Person.create(name: name)
  end

  records = Person.all.to_a

  File.open("people.txt", "w") { |out| out << records.to_json }
end

Using [get\_process\_mem](https://github.com/schneems/get_process_mem), we can quickly confirm that it does indeed use a lot of memory when dealing with a large number of Person records.

1
2
3
4
5
6
# before_and_after.rb
require_relative "./people"

print_usage_before_and_after do
  run(ARGV.shift.to_i)
end

Result:

1
2
3
$ ruby before_and_after.rb 10000
Before - MEMORY USAGE(MB): 37
After - MEMORY USAGE(MB): 96

Inspecting the code reveals several potential memory-intensive steps: constructing a large string array, invoking #to_a on an Active Record relation to create a large array of Active Record objects (not ideal, but for demonstration purposes), and serializing the Active Record object array.

Let’s profile the code to pinpoint memory allocation hotspots:

1
2
3
4
5
6
7
8
# profile.rb
require "memory_profiler"
require_relative "./people"

report = MemoryProfiler.report do
  run(1000)
end
report.pretty_print(to_file: "profile.txt")

Notice that the input to run is 1/10th of the previous example’s value. This is because the profiler itself consumes a lot of memory and can lead to memory exhaustion when profiling already memory-intensive code.

The results file is extensive, providing insights into memory and object allocation and retention at the gem, file, and line levels. While there’s a wealth of information to analyze, let’s focus on a few interesting snippets:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
allocated memory by gem
-----------------------------------
  17520444  activerecord-4.2.6
   7305511  activesupport-4.2.6
   2551797  activemodel-4.2.6
   2171660  arel-6.0.3
   2002249  sqlite3-1.3.11

...

allocated memory by file
-----------------------------------
   2840000  /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activesupport-4.2.6/lib/activ
e_support/hash_with_indifferent_access.rb
   2006169  /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activerecord-4.2.6/lib/active
_record/type/time_value.rb
   2001914  /Users/bruz/code/mem_test/people.rb
   1655493  /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activerecord-4.2.6/lib/active
_record/connection_adapters/sqlite3_adapter.rb
   1628392  /Users/bruz/.rvm/gems/ruby-2.2.4/gems/activesupport-4.2.6/lib/activ
e_support/json/encoding.rb

The highest number of allocations occur within Active Record, suggesting that either instantiating all the objects in the records array or serialization with #to_json is the culprit. Let’s test our memory usage without the profiler by disabling these suspects one by one. Since we can’t retrieve records without the serialization step, we’ll disable serialization first.

1
  # File.open("people.txt", "w") { |out| out << records.to_json }

Result:

1
2
3
$ ruby before_and_after.rb 10000
Before: 36 MB
After: 47 MB

It appears that serialization is indeed the major memory consumer. The before/after memory delta drops by a substantial 81% when it’s skipped. Now, let’s see what happens if we prevent the creation of the large records array.

1
2
3
4
  # records = Person.all.to_a
  records = Person.all

  # File.open("people.txt", "w") { |out| out << records.to_json }

Result:

1
2
3
$ ruby before_and_after.rb 10000
Before: 36 MB
After: 40 MB

This also reduces memory usage, but the reduction is an order of magnitude smaller than disabling serialization. At this point, we’ve identified the primary culprits and can decide on optimization strategies based on this data.

Although this example was contrived, the approaches used are broadly applicable. Profiler results might not directly pinpoint the problematic code section and can be misinterpreted. Therefore, it’s always wise to validate findings by observing actual memory usage with and without specific code sections enabled. Next, we’ll examine some common scenarios where memory usage becomes a problem and explore optimization techniques.

Deserialization: Taming Data Inflow

Deserializing large volumes of data from formats like XML, JSON, or others is a frequent source of memory issues. Methods such as JSON.parse or Active Support’s Hash.from_xml offer great convenience. However, when dealing with substantial data loads, the resulting in-memory data structures can become massive.

If you have control over the data source, consider implementing filtering or pagination to limit the amount of data retrieved. However, when dealing with external or uncontrollable sources, an alternative approach is to use a streaming deserializer. For XML, one option is [Ox](https://github.com/ohler55/ox). For JSON, [yajl-ruby](https://github.com/brianmario/yajl-ruby) appears to offer similar functionality, though my experience with it is limited.

Just because you have limited memory doesn't mean you cannot parse large XML or JSON documents safely. Streaming deserializers allow you to incrementally extract whatever you need from these documents and still keep the memory footprint low.

Let’s look at an example of parsing a 1.7MB XML file using Hash#from_xml.

1
2
3
4
5
6
7
8
9
# parse_with_from_xml.rb
require_relative "./common"

print_usage_before_and_after do
  # From http://www.cs.washington.edu/research/xmldatasets/data/mondial/mondial-3.0.xml
  file = File.open(File.expand_path("../mondial-3.0.xml", __FILE__))
  hash = Hash.from_xml(file)["mondial"]["continent"]
  puts hash.map { |c| c["name"] }.join(", ")
end
1
2
3
4
$ ruby parse_with_from_xml.rb
Before - MEMORY USAGE(MB): 37
Europe, Asia, America, Australia/Oceania, Africa
After - MEMORY USAGE(MB): 164

A 1.7MB file resulting in 111MB of memory usage? This clearly won’t scale effectively. Now let’s see the streaming parser version:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# parse_with_ox.rb
require_relative "./common"
require "ox"

class Handler < ::Ox::Sax
  def initialize(&block)
    @yield_to = block
  end

  def start_element(name)
    case name
    when :continent
      @in_continent = true
    end
  end

  def end_element(name)
    case name
    when :continent
      @yield_to.call(@name) if @name
      @in_continent = false
      @name = nil
    end
  end

  def attr(name, value)
    case name
    when :name
      @name = value if @in_continent
    end
  end
end

print_usage_before_and_after do
  # From http://www.cs.washington.edu/research/xmldatasets/data/mondial/mondial-3.0.xml
  file = File.open(File.expand_path("../mondial-3.0.xml", __FILE__))
  continents = []
  handler = Handler.new do |continent|
    continents << continent
  end
  Ox.sax_parse(handler, file)

  puts continents.join(", ")
end
1
2
3
4
$ ruby parse_with_ox.rb
Before - MEMORY USAGE(MB): 37
Europe, Asia, America, Australia/Oceania, Africa
After - MEMORY USAGE(MB): 37

This reduces memory consumption to a negligible amount and should handle significantly larger files without issues. The tradeoff is the introduction of 28 lines of handler code, which might seem error-prone and would ideally have tests written for it in a production environment.

Serialization: Efficient Data Output

As demonstrated in the memory usage hotspot section, serialization can be memory-intensive. Here’s the relevant snippet from people.rb from earlier:

1
2
3
4
5
6
# to_json.rb
require_relative "./common"

print_usage_before_and_after do
  File.open("people.txt", "w") { |out| out << Person.all.to_json }
end

With 100,000 records in the database, we get:

1
2
3
$ ruby to_json.rb
Before: 36 MB
After: 505 MB

The problem with calling #to_json here is that it instantiates an object for each record before encoding it to JSON. Generating JSON on a record-by-record basis, so that only one record object exists at a time, significantly reduces memory usage. While popular Ruby JSON libraries don’t seem to handle this directly, a common recommendation is to construct the JSON string manually. The [json-write-stream](https://github.com/camertron/json-write-stream) gem offers a user-friendly API for this purpose. Converting our example looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# json_stream.rb
require_relative "./common"
require "json-write-stream"

print_usage_before_and_after do
  file = File.open("people.txt", "w")
  JsonWriteStream.from_stream(file) do |writer|
    writer.write_object do |obj_writer|
      obj_writer.write_array("people") do |arr_writer|
        Person.find_each do |people|
          arr_writer.write_element people.as_json
        end
      end
    end
  end
end

Once again, optimization has increased code size, but the result seems worthwhile:

1
2
3
$ ruby json_stream.rb
Before: 36 MB
After: 56 MB

Embracing Laziness

Introduced in Ruby 2.0, lazy enumerators are a fantastic feature for improving memory efficiency when chaining methods on enumerators. Let’s begin with a non-lazy code example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# not_lazy.rb
require_relative "./common"

number = ARGV.shift.to_i

print_usage_before_and_after do
  names = number.times
                .map { random_name }
                .map { |name| name.capitalize }
                .map { |name| "#{ name } Jr." }
                .select { |name| name[0] == "X" }
                .to_a
end

Result:

1
2
3
$ ruby not_lazy.rb 1_000_000
Before: 36 MB
After: 546 MB

In this scenario, each step in the chain iterates over every element in the enumerator, generating an array that has the next method in the chain invoked on it, and so on. Now let’s introduce laziness by simply adding a lazy call to the enumerator obtained from times:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# lazy.rb
require_relative "./common"

number = ARGV.shift.to_i

print_usage_before_and_after do
  names = number.times.lazy
                .map { random_name }
                .map { |name| name.capitalize }
                .map { |name| "#{ name } Jr." }
                .select { |name| name[0] == "X" }
                .to_a
end

Result:

1
2
3
$ ruby lazy.rb 1_000_000
Before: 36 MB
After: 52 MB

Finally, an example demonstrating a significant memory usage improvement without significant code bloat! Note that if we didn’t need to accumulate results at the end (e.g., if each item was saved to the database and could be discarded), memory usage would be even lower. To force evaluation of a lazy enumerable at the end of the chain, simply append a call to force.

Another point worth noting is that the chain starts with a call to times before lazy. This consumes minimal memory, as it merely returns an enumerator that generates an integer upon each invocation. Therefore, starting the chain with an enumerable instead of a large array, if possible, is beneficial.

Keeping everything in huge arrays and maps is convenient, but in real world scenarios, you rarely need to do that.

One practical application of lazily feeding an enumerable into a processing pipeline is handling paginated data. Rather than fetching all pages into a single large array, they can be exposed through an enumerator that abstracts away the pagination details. Here’s a possible implementation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
def records
  Enumerator.new do |yielder|
    has_more = true
    page = 1

    while has_more
      response = fetch(page)
      response.records.each { |record| yielder << record }

      page += 1
      has_more = response.has_more
    end
  end
end

Conclusion

We’ve explored memory usage characteristics in Ruby, examined general tools for identifying memory issues, and discussed common problem areas and optimization techniques. The cases presented are not exhaustive and are influenced by my personal experiences. However, the most significant takeaway might be adopting a mindset that considers the memory implications of code.

Licensed under CC BY-NC-SA 4.0