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:
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:
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.
While a compacting garbage collector would reduce fragmentation, 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:
You can also set how much memory Ruby is allowed to allocate off-heap 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:
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  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.
 Look here for the latest default parameters: https://github.com/ruby/ruby/blob/trunk/gc.c
 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!
 There are some fun overview visualizations of various GC strategies here. Ruby currently uses a kind of mark-sweep collector.
 The Ruby heap vs. extra malloc’d space is explained in this post. However, the discussion of specific GC parameters is dated.
 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.