October 16, 2024

What is good memory management in Ruby? In this post – the third part of our series – we’ll look at the best practices for preventing memory issues, how to identify and distinguish them, and how to use Scout like a pro to take your memory mastery to the next level. 

Feel free to jump right in to this article, but if you want an overview on some of the prerequisite info, you can check out the first two sections:

Since one of the best ways to deal with memory issues is to avoid them in the first place, let’s begin our discussion in that spirit!

Measuring Ruby memory allocation

The MemoryProfiler gem is an effective way to track the memory usage of your Ruby code. 

Let’s take a look: 


require "memory_profiler"
require "yaml"

mappings = nil

report = MemoryProfiler.report do
 mappings = YAML.load_file("./config/mappings.yml")
end

Report.pretty_print

This will give you a result similar to this:


Total allocated: 2069188 bytes (21455 objects)
Total retained: 584820 bytes (6825 objects)

So, if the number of allocated objects is high, but retained objects don't increase, that's memory bloat. Meanwhile, if the retained value is growing with an increasing number of operations – it's a leak.

Additionally, to check if you have a memory leak in an environment, you can try using derailed_benchmarks and running this command:

bundle exec derailed exec perf:mem_over_time

This will send a number of requests to your application, then track memory usage over time. If there’s a memory leak, the memory usage will keep on increasing over time. If there’s no leak, you’ll observe the usage increase gradually until it hits a plateau, and levels off.

MemoryProfiler is really granular and detailed and allows us to find memory bloats and leaks, while the derailed command above simply hits the same endpoint and echoes the memory usage. Go deeper into the derailed docs here.

And, for more Ruby profiling tools, check out this article from the Scout team: Five Tools for Profiling Rails Apps.

Now that we understand how to measure memory usage, let's explore how to prevent one of the most common memory issues: memory fragmentation.

Preventing memory fragmentation

Memory fragmentation is a common problem resulting from the unmanaged use of memory blocks over long periods of time. Specifically, since memory is allocated to objects in small chunks, when parts of the allocated memory are freed, only that particular chunk or slot is released. So, although you may have some empty slots spread across a heap page, it doesn’t guarantee there will be a contiguous block of free memory that can be allocated to a new object.

There are a number of measures that developers can take to curb this problem:

  • Try to do all of your static allocations in one big block. If you can get all your permanent classes and data on the same heap pages, a lot of other pages will be free for comparatively longer times – this can make your fragmentation ratio significantly better.
  • For big dynamic allocations, try to do all of them at the beginning of your code. This will make it easier for the slots to be allocated closer to your bigger block of static allocations, and it may also provide you with more freed pages for later requests.
  • If you declare a reference to some object that uses memory, make sure to dispose of it when its job is done. This is a great way to ensure that memory is not withheld unnecessarily.
  • If you have a cache that’s small and never cleared, try to combine it with the permanent allocations in the beginning, or remove it altogether.

Next up, let's examine how to identify and address memory leaks, a persistent  and sometimes insidious memory issue.

Reckoning with memory leaks

Memory leaks occur when allocated memory slots are not freed up after they’ve been used, and this results in more and more slots being allocated as the code continues to run. 

In practice, coding sensibly is your first line of defense against memory leaks:

  • Carefully manage object references to ensure they’re available for garbage collection when no longer needed. Start by regularly using a memory profiler (more on this in a moment) to spot any variables or objects that remain in memory unintentionally. If this sounds intimidating, no worries, Scout makes it simple so you don’t need to run a memory profiler over every PR.
  • For example, explicitly setting objects to nil after using them helps signal to the garbage collector that they can be released. 
  • Additionally, be cautious of circular references. This is where two objects reference each other and prevent cleanup.
  • Watch event listeners, especially in long-running applications. Listeners that aren’t removed when an object is no longer active can hold unintended references. 
  • Similarly, caches that grow indefinitely or retain stale data can lead to significant memory leaks.

Am I dealing with memory leaks or memory bloat?

Let’s take a quick detour to examine memory leaks vs. memory bloat. Memory bloat centers around unplanned memory allocation in the code, and this issue doesn’t originate from the runtime environment or bad memory management, but instead, from too many objects being allocated in memory at once.

But how to differentiate leaks and bloat?

Memory bloat is a sharp increase in memory usage due to the allocation of many objects. This is a more time-sensitive problem than a memory leak, which is a slow, continued increase in memory usage that can be mitigated via scheduled restarts.

Let’s try and visualize the difference:

If one of your app’s power users happens to trigger a slow SQL query, the impact is momentary. Performance will likely return to normal because it’s rare for a slow query to trigger long-term poor performance.

However, if that user happens to perform an action that triggers memory bloat, the increased memory usage will be present for the life of the Ruby process. (While Ruby does release memory, it happens very slowly.)

It’s best to think of your app’s memory usage as a high-water mark: memory usage has nowhere to go but up. This behavior changes the calculation about how you should debug a memory problem versus a performance issue.

Solving memory bloat with Scout

All of the above practices and techniques are essential to have in your toolkit, and while there are a lot of Ruby gems that can help you track down memory usage throughout your application. 

That said, it also might be impossible to recreate the issue locally since the data and traffic in production might be too specific, and obviously, committing a memory profiler to prod is not going to happen.

So, at a certain point, understanding how to augment this expertise with a monitoring service like Scout can be the X factor that really saves the day. So, let’s take a look!

To start, let’s talk about what Scout can help you do, quickly and painlessly:

  • Identify activities with the greatest number of allocations.
  • View transaction traces of memory-intensive requests, letting you isolate hot-spots and eventually narrowing them down to specific lines of code.
  • Identifying users that are triggering memory bloats.

First of all, if you’re new to Scout for Ruby, we recommend running through our Ruby setup documentation before proceeding.

When installing Scout for the first time, the insights tab can be a great place to start for finding your biggest performance issues. There is a special Memory Bloat Insights tab which makes finding the causes of memory bloat as simple as possible.

Identifying allocation-heavy activities with Scout

No endpoint left behind! The Endpoints section of Scout can be a good resource to gain a general understanding of which controller-actions are responsible for the biggest amount of memory bloat in your application:

From here, as indicated above we can sort by the “% Allocations” column. This column represents the maximum number of allocations recorded for any single request for the given controller-action and timeframe. 

Why max and not mean allocations? This chart below shows requests from two endpoints, A and B, and each circle represents a single request. Which endpoint has the greater impact on memory usage?

Analysis:

  • Endpoint A has greater throughput
  • Endpoint A averages more allocations per-request
  • Endpoint A allocates far more objects, in total, over the time period

If the y-axis was “response time” and you were optimizing CPU or database resources, you’d very likely start optimizing Endpoint A first. However, since we’re optimizing memory, look for the endpoint with the single request that triggers the most allocations. So, in this case, Endpoint B has the greatest impact on memory usage.

Back on track, click on an endpoint to dive into the “Endpoint Detail” view. From here, you can click the “Allocations - Max” chart panel to view allocations over time.

Beneath the overview chart, you’ll see traces Scout captured over the current time period. Click the “Most Allocations” sort field from the pulldown. You’ll see traces ordered from most to least allocations:

Analyzing a Scout memory trace

As shown below, the method calls that are displayed in the trace details are ordered from the most to least allocations. The horizontal bars next to the calls count represent the number of allocations associated with the number of calls. The light green shade represents the usual case, whereas the darker green represents a case of memory bloat:

The method calls displayed in the trace details are organized from most to least allocations. The horizontal bar on the right visually represents the number of allocations associated with the method calls on the left. 

Some of the bars may have two shades of green: the lighter green represents the control case (what we view as a normal request) and the darker green represents the memory-hungry case.

Identifying users triggering memory bloat

Memory bloat can frequently be isolated to a specific set of users, and checking Transaction Traces you can use Scout’s custom context API to associate your app’s current_user with each transaction trace if it’s not easily identifiable from a trace url.

Solving memory bloat

So, we’ve identified some memory bloat issues with Scout's monitoring tools. Now, here are some strategies to prevent and resolve them. 

Optimize ActiveRecord usage. One of the most common sources of memory bloat in Rails applications is loading too many records into memory at once. So, instead of loading entire tables, use batching to process records in smaller chunks:


# Instead of loading all records at once:
users = User.all.map { |u| u.process_data }

# Use batching:
User.find_each(batch_size: 1000) do |user|
  user.process_data
end

# Or if you need an array result:
users = []
User.find_each(batch_size: 1000) do |user|
  users << user.process_data
end

The find_each method loads records in batches of 1,000 by default, significantly reducing memory overhead. This is particularly important when processing large datasets or running background jobs and is probably a first-line defense to deal with memory bloat issues.

For web interfaces, pagination is essential to implement in order to prevent memory bloat when displaying large collections. Therefore, instead of loading all records for display, break them into manageable pages:


# In your controller:
def index
  @posts = Post.page(params[:page]).per(20)
end

# In your view:
<% @posts.each do |post| %>
  <%= render post %>
<% end %>
<%= paginate @posts %>

This approach not only reduces memory usage but also improves page load times and user experience. (Libraries like kaminari can make pagination implementation more straightforward.)

When you only need specific fields from your records, avoid loading entire objects into memory. Use select or pluck to fetch only the required data:


# Instead of:
users = User.all.map { |u| u.email }

# Use:
emails = User.pluck(:email)

# Or when you need multiple fields:
user_details = User.select(:id, :name, :email)

This approach can dramatically reduce memory usage when dealing with models that have many attributes or associations.

Another option is to exclude lengthy metadata columns by default:


class User < ActiveRecord::Base
  self.ignored_columns = %w[audit_metadata]
End

Memory doesn’t have to be a mystery

It’s impossible to completely avoid Ruby memory management issues – but this doesn’t mean that it has to be a mystery! While it may be challenging to monitor each line of code individually, it’s still critical to monitor how your resources are being consumed. 

Yes, there are methods such as memory profiling to help recognize memory issues on the fly. But when real-time applications come into play, even the best of the best would want a solution that provides quick and accurate statistics, with a user-friendly interface. 

…Hey, that sounds like Scout! It does all of that, providing timely summaries of your app’s performance, isolates Ruby memory bloat, tracks N+1 queries, and much more at a very low overhead – check it out

Of course, there’s a lot more to go in the world of Ruby memory management, and actually, we’ve really just scratched the surface, so let’s toss out some useful links to help you continue your journey:

Related Articles

Complement Your Monitoring: Making Logs Readable for Humans & Machines

Complement Your Monitoring: Making Logs Readable for Humans & Machines

While Scout provides powerful monitoring tools (try it now!) mastering logging is an awesome complement to these skills. In this post, we’ll see how to create readable, actionable logs for both humans and machines. You’ll improve your logging strategy, drastically...

Ruby memory mastery: a Scout roadmap to monitoring like a pro | part 2

Ruby memory mastery: a Scout roadmap to monitoring like a pro | part 2

Preventing and solving memory issues is at the heart of good memory management in Ruby – and of course, at Scout Monitoring, we also know that solid monitoring can be the X factor that makes all the difference. But what exactly are we looking for when we load up...

Scout Monitoring Changelog – September 2024

Scout Monitoring Changelog – September 2024

We had a couple of nice releases in September and we are still cranking away on some nice treats this month as well. Here’s what we are looking back on: Python Log Management We’ve released our python package for Log Management! It leverages a pre-configured Otel SDK...

Subscribe to our newsletter