Optimizing Rails for Memory Usage Part 2: Tuning the GC

This is part two in a four-part series on optimizing a potentially memory-heavy Rails action without resorting to pagination. The posts in the series are:

Part 1: Before You Optimize


Part 2: Tuning the GC


Part 3: Pluck and Database Laziness


Part 4: Lazy JSON Generation and Final Thoughts

So, you know you need to optimize the memory usage of your Rails application and you have set up metrics. Before modifying your application code, the first and easiest thing to do is to change Ruby’s garbage collection parameters.

You can change how often Ruby reclaims unused memory by modifying a series of environment variables. The variables are listed below with their default values and lower bounds as of Ruby 2.2.0:[1]

RUBY_GC_HEAP_FREE_SLOTS=4096              #           Must be > 0
RUBY_GC_HEAP_INIT_SLOTS=10000             #           Must be > 0
RUBY_GC_HEAP_GROWTH_FACTOR=1.8            #           Must be > 1.0
RUBY_GC_HEAP_GROWTH_MAX_SLOTS=0           # Disabled; Must be > 0
RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR=2.0   #           Must be > 0
RUBY_GC_MALLOC_LIMIT=16777216             # 16 MiB;   Must be > 0
RUBY_GC_MALLOC_LIMIT_MAX=33554432         # 32 MiB;   Must be > 0
RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR=1.4    #           Must be > 1.0
RUBY_GC_OLDMALLOC_LIMIT=16777216          # 16 MiB;   Must be > 0
RUBY_GC_OLDMALLOC_LIMIT_MAX=134217728     # 128 MiB;  Must be > 0
RUBY_GC_OLDMALLOC_LIMIT_GROWTH_FACTOR=1.2 #           Must be > 1.0

Generally, making values smaller will cause Ruby to trigger GC more often. There are a lot of parameters, so which values should you change?

A Short Primer on Ruby GC

Ruby 2.1 and later uses a generational garbage collector. Most objects in a program are short-lived. For example, you might normalize some arguments given to your function, but the function is short and once it has completed then those normalized arguments are no longer needed. Generational garbage collectors take advantage of the short life of most objects by looking for garbage only among recently allocated objects. Since most object die young, a generational GC maximizes the amount of memory freed for the amount of time spent collecting garbage.

Because looking only at young objects will not find all unused objects, from time to time the GC will also look through older objects for garbage. This takes longer but will free up memory missed by the young generation collections.

In an ideal world, your application will occupy as little memory as possible after a full GC run because all unused objects have been freed. However, it’s not an ideal world. Because of memory fragmentation there will be free gaps in your memory space that cannot be released to the operating system.[2]

A demonstration of what happens to your memory space as a program runs. This diagram was generated by repeatedly freeing and mallocing regions of a random size between 64 and 1023 bytes.

While a compacting garbage collector would reduce fragmentation,[3] Ruby does not yet support compaction. Instead, to reduce fragmentation you have to run GC more often. More specifically, you want to ensure that both young-object GC and full GC run more often.

Aggressive GC Parameters

To trigger more young-object GC runs (also known as “minor GC”), you can lower the RUBY_GC_HEAP_GROWTH_FACTOR or perhaps set a RUBY_GC_HEAP_GROWTH_MAX_SLOTS. For our purposes, I set:

RUBY_GC_HEAP_GROWTH_FACTOR=1.1

You can also set how much memory Ruby is allowed to allocate off-heap[4] before Ruby runs minor GC. You may want to lower that threshold:

RUBY_GC_MALLOC_LIMIT=4000100
RUBY_GC_MALLOC_LIMIT_MAX=16000100
RUBY_GC_MALLOC_LIMIT_GROWTH_FACTOR=1.1

Similarly, you may want to reduce how much memory Ruby allocates off-heap before it runs a full major GC:

RUBY_GC_OLDMALLOC_LIMIT=16000100
RUBY_GC_OLDMALLOC_LIMIT_MAX=16000100

Consider these parameters a starting point for optimizing your app if your application is memory-constrained. You should notice your memory usage decrease at the cost of a small increase in response time.

As you tune your GC parameters, knowing what all the GC parameters do is not as helpful as you might expect. Lowering a parameter to trigger more GC runs will sometimes do nothing more than make your app slower, and sometimes it will even increase your memory usage. That’s why having a controlled test setup is so important. However, if you want to have some sense of what you are doing, Thorsten Ball has a good write-up on Ruby 2.1’s GC parameters. Ruby 2.2 is pretty much the same. If you want more specifics you will have to read Ruby’s gc.c [5] If you know of a good resource I’ve missed, please share with us in the comments.

In our application, the above aggressive GC parameters helped somewhat. We also periodically triggered full GC runs with GC.start during the request. Running GC.start during a request is usually considered bad practice because it hurts the application’s response time. In our case, the tradeoff was acceptable.

In your application, if GC tuning alone fixes your memory problems, great! If not, you will have to dive into your application’s code. We will discuss that next.

On to Part 3: Pluck and Database Laziness →

[1] Look here for the latest default parameters: https://github.com/ruby/ruby/blob/trunk/gc.c

[2] I presume that memory pages that are completely empty can and are released to the operating system. If you know more about malloc’s behavior than I do, please leave a comment to let everyone know if this is correct!

[3] There are some fun overview visualizations of various GC strategies here. Ruby currently uses a kind of mark-sweep collector.

[4] The Ruby heap vs. extra malloc’d space is explained in this post. However, the discussion of specific GC parameters is dated.

[5] Or you could also try asking me on Twitter. I’m insatiably curious and might end up reading the source for you because I can’t help myself. But, really, you should learn to read source code instead of relying on others. Your skill as a software engineer will be hampered if you don’t learn to read source code.

Photo of Brian Hempel

Brian worked with us long before he came on full-time, and had we seen the baby face lurking beneath his programmer beard, we probably wouldn’t have assumed he was as smart. He proved quickly that he has earned the beard, both as a graduate of Michigan Tech in Bioinformatics and Biochemistry/Molecular Biology, and as an experienced coder who picks up new tools quickly.

An occasional violinist and lover of birds, Brian is a cheerful addition to the office.

Comments:


Post a Comment

(optional)
(optional — will be included as a link.)
  1. Hi,
    There is a typo on page 2.
    It should be “RUBY_GC_OLDMALLOC_LIMIT” in the last “parameter box”. (“OLD” is missing).
    Thanx a lot for a very good post!!!

    July 28, 2015 at 11:54 AM
  2. Yes, that does appear to be a mistake. Corrected. Thank you for pointing that out.

    August 03, 2015 at 4:30 AM