Skip to content
academia.sh

Lesson 07 / 09

Memory Management

While the same job runs with the same code, how many objects a block allocates is also a measurement question. The lesson measures allocation counts, the garbage collector's visible counters, and why the first run differs from the later ones.

Published Updated

In this lesson

  • Measures the number of objects a block allocates per iteration
  • Shows the allocation difference between strings, symbols, and frozen strings with counts
  • Reads the garbage collector's counters and an object's size with the standard library
  • Explains the warm-up share of the first run as an input of the measurement itself
Contents

The previous lesson showed that the environment determines the worker count. Since every worker has its own memory, the next question is what a worker’s memory fills up with. That question ties back to the course’s measuring axis: what determines this result? The number of objects a block allocates is not the work of the machine or of time; it is the work of the block itself. This lesson’s observation is the allocated objects row, and its determinant is code.

M11/K06 asked which resource (compile · run · memory · wait) a cost moved to. Here the question is what determines the number of allocated objects; the cost is measured, but the class is the determinant. How the garbage collector ages and collects objects was covered in M08/K04. This lesson does not go inside that mechanism; it measures only the numbers readable from the outside, the allocation counts. The core’s determinant.rb file and this lesson’s excerpt memory.rb are given below as they are; both are source and have no output. The core is a model, and this lesson’s measurement uses only the model’s rule: the number of allocated objects is the work of the block, not of the running environment.

# determinant.rb — a run model and a helper that classifies what determines a result

# Run: the three inputs a result may depend on. Everything outside these three is code.
Run = Data.define(:lock, :seed, :environment)

BASE = Run.new(
  lock: { "formatter" => "1.4.2", "parser" => "2.0.1" },
  seed: 1,
  environment: { clock: Time.utc(2026, 10, 16, 9, 0), cores: 4, variables: {} }
)

# Each variant changes a single input; the others stay as in the base.
VARIANTS = {
  dependency: BASE.with(lock: { "formatter" => "1.5.0", "parser" => "2.1.0" }),
  order: BASE.with(seed: 2),
  environment: BASE.with(environment: { clock: Time.utc(2027, 3, 1, 9, 0), cores: 8,
                                        variables: { "WORKER_COUNT" => "3" } })
}

# Rule: the computation runs first in the base, then once in each variant. The first variant
# that changes the result (dependency, order, environment, in that order) is the determinant;
# if none changes it, code.
def determinant(computation)
  base = computation.call(BASE)
  changed = VARIANTS.to_h { |name, run| [name, computation.call(run) != base] }
  kind = changed.key(true)&.to_s || "code"
  evidence = changed.map { |name, c| "#{name[0].upcase}#{c ? '+' : '-'}" }.join(" ")
  [kind, evidence, base]
end
# memory.rb — the block runs three times; the allocations of the last run are counted
# Memory: the block runs three times; the first runs are warm-up, the last run's allocations are counted.
def allocated_objects
  result = nil
  3.times do
    before = GC.stat(:total_allocated_objects)
    yield
    result = GC.stat(:total_allocated_objects) - before
  end
  result
end

The Same Thousand-Element Array, Different Object Counts

allocated_objects runs a block three times and takes the difference of the GC.stat(:total_allocated_objects) counter on the last one. The counter is the total of the objects allocated so far, and it does not go down when the garbage collector collects an object; so the difference equals the number of objects the block created. The block below builds the same thousand-element array in different ways.

# measure.rb — the same array, different object counts
require_relative "memory"

measurements = {
  "i.to_s" => -> { Array.new(1000) { |i| i.to_s } },
  "i" => -> { Array.new(1000) { |i| i } },
  ":constant" => -> { Array.new(1000) { :constant } },
  "\"constant\"" => -> { Array.new(1000) { "constant" } },
  "\"constant\".freeze" => -> { Array.new(1000) { "constant".freeze } },
  "-\"constant\"" => -> { Array.new(1000) { -"constant" } },
  "map" => -> { (1..1000).map { _1 * 2 } },
  "each_slice" => -> { (1..1000).each_slice(10).to_a },
  "<<" => -> { s = +""; 1000.times { s << "a" }; s },
  "+=" => -> { s = ""; 1000.times { s += "a" }; s }
}
measurements.each { |name, block| puts "#{name}: #{allocated_objects(&block)}" }
i.to_s: 1001
i: 1
:constant: 1
"constant": 1001
"constant".freeze: 1
-"constant": 1
map: 2
each_slice: 107
<<: 1002
+=: 2001

Every line reads by the same logic. An integer and a symbol allocate no object; the array itself is a single object, so i and :constant give one. i.to_s and "constant" create a new string on every iteration, so they give a thousand and one. A frozen string is immutable and the same object is reused: "constant".freeze and -"constant" give one. map allocates two, for the array and the result array, while each_slice allocates a hundred and seven with its hundred intermediate slices. The last two lines show string building: << appends to the same string, += builds a new string at every step, and the count roughly doubles.

None of these lines measures time. Time depends on the machine, while the number of allocated objects depends on the block; this lesson’s measure is the second. RU9. These counts were measured on Ruby 4.0.7; if the array and string implementations change between versions, the counts change too.

Now the same blocks go to the determinant rule. The chosen block is an integer array of a thousand elements.

# rule.rb — what determines the allocation count
require_relative "determinant"
require_relative "memory"

p determinant(->(_r) { allocated_objects { Array.new(1000) { |i| i } } })
["code", "D- O- E-", 1]

The result is one and the evidence is D- O- E-: the versions in the lock file, the seed, and the environment changed, and the count did not. The determinant is code. Writing i instead of i.to_s inside the block takes the count from a thousand and one to one, and no other input does that. RU10. The rule moves only the core’s three inputs; another variable that could affect memory allocation, for example the garbage collector’s settings, is not in the model here.

The Garbage Collector’s Visible Counters

Allocated objects are the garbage collector’s business: an object is collected when it becomes unreachable and lives as long as it stays reachable. The block below puts a thousand strings in an array, forces the garbage collector to run, and counts that the strings are alive. Then it checks that the expected keys exist among the garbage collector’s counters and that an object’s size is positive. The values of the counters are not printed: they differ on every run and on every machine.

# gc.rb — garbage collector counters and object size
require "objspace"

retained = Array.new(1000) { |i| i.to_s }
GC.start
p ObjectSpace.each_object(String).count >= 1000
p %i[major_gc_count minor_gc_count heap_live_slots].all? { |key| GC.stat.key?(key) }
p ObjectSpace.memsize_of("a" * 1000).positive?
p retained.size
true
true
true
1000

Three booleans and one number were printed. The strings are alive after the GC.start call too, because the retained array reaches them. The counters have names; instead of looking at their values we checked that they exist. ObjectSpace.memsize_of gives the number of bytes an object takes in memory, and it is positive for a string of a thousand characters. RU11. The values themselves, byte counts included, were not printed in the measurement, because they change, and a changing value weakens the trust in the numbers this course carries.

The Bounding Measurement

Why does allocated_objects run the block three times? Because the first run can differ from the later ones. On the first call the interpreter’s internal caches may not be filled yet, and the objects allocated for that preparation are not repeated in later runs. We do not measure the cause here; we measure only the direction of the difference. The block below runs the same block three times and compares the first count with the last.

# warmup.rb — the first run is warm-up, the measurement itself is an input
counts = Array.new(3) do
  before = GC.stat(:total_allocated_objects)
  Array.new(1000) { |i| i.to_s }
  GC.stat(:total_allocated_objects) - before
end
p counts.first > counts.last
true

The first run allocated more objects than the last. The same block, the same code, the same inputs; the difference comes from the measurement itself. This is why allocated_objects counts the first two runs as warm-up and returns only the last one’s difference. A single-run measurement would carry this warm-up share too, and the number would come out larger than the block’s real work. The result: how the measurement is made behaves like one of the inputs that determine the result; when a measurement is shared, how the warm-up was handled must be shared with it.

This also shows the rule’s limit. determinant moves three inputs; it does not move the measurement method itself. The warm-up share is a property of the measurement, not of the code, and it depends on none of the three variants. That is why it is kept inside allocated_objects, outside the rule. RU12. The size of the warm-up was measured here only by its direction; whether one or two extra runs are enough does not follow from this measurement.

Summary

  • The number of objects a block allocates is determined by the code: the rule saw the same count in all three variants.
  • An integer and a symbol allocate no object; a frozen string reuses the same object; i.to_s and "constant" create a new string on every iteration.
  • With string building, += allocates about twice as many objects as <<.
  • The garbage collector’s counters are read with GC.stat and an object’s size with ObjectSpace.memsize_of; the values are not printed, their existence and sign are checked.
  • The first run carries a warm-up share; the measurement method itself is an input that affects the result.

Next Step

How many objects a block allocates is known; which method of a program is the hot path is not yet known. The next lesson counts call counts and shows that the slow one cannot be read off the call count.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close