skip to content

In Ruby, how do you reduce the objects a hot loop allocates, and how do you prove the change with GC.stat?

level: middleimportance: must knowfreq 50%

answer

  1. fewer objects, fewer collections
  2. chains build intermediate arrays
  3. sum, count, filter_map, flat_map
  4. bang methods may return nil
  5. GC.stat(:total_allocated_objects) delta

basics

~20 s

Stop building intermediate collections and per-iteration temporaries: aggregate with block forms such as sum, count and filter_map, append to strings with << rather than +=, and mutate in place where safe. Prove it with the GC.stat(:total_allocated_objects) delta before and after.

solid answer

~40 s

Most Ruby GC cost comes from allocation, so the fix is to allocate less. Each step of a chain like `select { }.map { }.sum` builds a new Array; `filter_map`, `sum { }`, `count { }`, `any?` and `each_with_object` do the same work in one pass without the intermediate collection, and `flat_map` avoids the extra array of `map.flatten`. Inside the loop, avoid temporaries: `str << part` appends in place while `str += part` makes a new String each time, and interpolating or `to_s`-ing values you only compare creates strings for nothing. In-place bang methods (`map!`, `select!`, `concat`) reuse an array, but `select!` returns `nil` when nothing was removed, so never chain them. Measure with `GC.stat(:total_allocated_objects)` before and after the code, and watch `:minor_gc_count` fall.

code

ruby · 12 lines
ruby
def allocated
  before = GC.stat(:total_allocated_objects)
  yield
  GC.stat(:total_allocated_objects) - before
end

ids = (1..10_000).to_a

p allocated { ids.map { "id:#{it}" }.select { it.end_with?("0") }.size }
# tens of thousands: a String per interpolation, plus two Arrays
p allocated { ids.count { (it % 10).zero? } }
# close to 0: no Strings and no intermediate Array

go deeper

for a junior

Recall that each chained select or map builds a new array and that block forms like sum and count avoid it.

for a middle

Explain objects versus malloc buffers, in-place methods and their nil returns, and measuring with total_allocated_objects deltas.

for a senior

Show you target allocation in profiled hot paths, verify the saving in collection counts, and keep the code readable elsewhere.

for a principal

Frame allocation budgets as a performance review habit for hot paths rather than a style rule applied everywhere.

## Why allocation is the lever In CRuby every object - a String, an Array, a Hash, a Range - takes a slot in the managed heap. Collections run when slots run out or when memory allocated with `malloc` for object buffers crosses a limit. So the number of collections is driven mainly by **how much you allocate**, not by how much you keep. Cutting allocations in hot code reduces minor collections, keeps short-lived data from being promoted to old, and saves the CPU that building the objects cost in the first place. Two kinds of allocation matter: - **Objects** - each counts in `GC.stat(:total_allocated_objects)`. - **Buffers** - a 10,000-element Array is one object, but its element storage is `malloc`ed memory. That does not show in the object count; it shows in `:malloc_increase_bytes` and pushes toward the malloc limit (`RUBY_GC_MALLOC_LIMIT`, 16 MB initially). ## Techniques that remove intermediate collections | Instead of | Use | What disappears | |---|---|---| | `list.select { }.map { }` | `list.filter_map { }` | one intermediate Array | | `list.map { }.sum` | `list.sum { }` | the mapped Array | | `list.select { }.size` | `list.count { }` | the selected Array | | `list.map { }.flatten` | `list.flat_map { }` | the nested result Array | | `list.map { }.to_h` built in steps | `each_with_object({})` or `to_h { }` | pair Arrays kept around | | `list.select { }.any?` / `.first` | `list.any? { }` / `list.find { }` | the whole scan and the Array | The rule behind the table: if you only need an **aggregate** or the **first match**, use the method that computes it directly. ## Techniques that remove per-iteration temporaries 1. **Append, do not rebuild.** `buffer << line` appends to one String; `buffer += line` allocates a new String each time and copies everything so far. 2. **Mutate in place when you own the object.** `map!`, `select!`, `reject!`, `concat` and `String#<<` reuse the receiver. Watch return values: `select!` and `reject!` return `nil` when nothing changed, so `list.select! { }.size` can raise `NoMethodError`. 3. **Avoid conversions you do not need.** Comparing numbers as strings, building a key with interpolation for every row, or calling `to_a` on a Range just to iterate all allocate. 4. **Hoist constants out of loops.** A literal Array or Hash written inside a block is usually built again on every iteration; a frozen constant defined outside the loop is built once. String literal freezing is its own topic; here it is enough that mutable literals in hot loops can allocate on each pass. ## Proving the change Measure object allocations directly around the code: - read `GC.stat(:total_allocated_objects)` before and after, and subtract; - run the code once first so one-time allocations (constants, method caches) do not skew the number; - compare `GC.count` or `:minor_gc_count` over a larger run to see the effect on collections. `GC.stat(:key)` returns one Integer and does not itself allocate a Hash, which keeps the probe from polluting the measurement. ## Keep it proportionate - Change hot paths shown by a profile, not every `map` in the codebase. - Readability counts: a chain of two small calls over ten elements is fine. - Re-measure after each change; some rewrites save nothing because the per-element objects, not the arrays, dominate.

  • A 10,000-element intermediate Array shows up as only one allocated object; why can it still trigger GC?
    The Array object takes one heap slot, but its element storage is a `malloc`ed buffer. That memory counts toward `:malloc_increase_bytes`, and when it crosses `:malloc_increase_bytes_limit` (starting from `RUBY_GC_MALLOC_LIMIT`, 16 MB) a collection starts with `gc_by: :malloc`. Large temporary buffers therefore cause collections even when object counts look small.
  • Why is chaining in-place bang methods risky in Ruby?
    Several bang methods return `nil` when they change nothing: `select!`, `reject!`, `uniq!`, `compact!`, `flatten!`. A chain such as `list.uniq!.sort!` raises `NoMethodError` on `nil` whenever the list had no duplicates. Call them as separate statements, or use the non-bang form when you need a return value.

saying these in an interview costs you the question

  • Allocation is free in Ruby; only retained objects cost anything
  • str += part appends to the existing string in place
  • select! always returns the array, so it is safe to chain
  • An intermediate Array of existing objects counts as one allocation per element
  • Rewrite every map chain in the codebase to avoid allocations