In Ruby, how do you reduce the objects a hot loop allocates, and how do you prove the change with GC.stat?
answer
- fewer objects, fewer collections
- chains build intermediate arrays
- sum, count, filter_map, flat_map
- bang methods may return nil
- GC.stat(:total_allocated_objects) delta
basics
~20 sStop 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 sMost 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 linesdef 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 Arraygo deeper
Recall that each chained select or map builds a new array and that block forms like sum and count avoid it.
Explain objects versus malloc buffers, in-place methods and their nil returns, and measuring with total_allocated_objects deltas.
Show you target allocation in profiled hot paths, verify the saving in collection counts, and keep the code readable elsewhere.
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