Project

General

Profile

Bug #22390 ยป repro_gc.rb

chucke (Tiago Cardoso), 09/29/2026 11:53 AM

 
# typed: false

require 'json'
#
# Compare across Rubies:
# ASDF_RUBY_VERSION=3.4.10 asdf exec ruby bugreports/repro_gc.rb
# ASDF_RUBY_VERSION=4.0.6 asdf exec ruby bugreports/repro_gc.rb
#
# What to watch: heap_live_slots should stay roughly flat across the run (no
# object leak -- everything is reclaimable garbage). On 3.4.x, RSS and
# heap_empty_pages should track that flat live-slot count closely, because
# empty pages get unmapped soon after a major GC. On 4.0.x, heap_empty_pages
# and RSS should climb and plateau *above* where 3.4.x settles and never come
# back down, because the release gate (max_free = total_slots * 0.65) shrinks
# its own denominator every time pages are unlinked, making the threshold
# unreachable after the first release round.
#
# Tunable via env: ITERATIONS, PRINT_EVERY, FULL_GC_EVERY, IDLE_MS, RETAIN_EVERY.

ITERATIONS = (ENV['ITERATIONS'] || 200_000).to_i
PRINT_EVERY = (ENV['PRINT_EVERY'] || 200).to_i
FULL_GC_EVERY = (ENV['FULL_GC_EVERY'] || 25).to_i
IDLE_MS = (ENV['IDLE_MS'] || 2).to_i
RETAIN_EVERY = (ENV['RETAIN_EVERY'] || 0).to_i # 0 = no simulated cache growth
FREE_RATIO_GATE = 0.65 # Ruby 4.0's page-release gate: freeable when heap_free_slots / heap_available_slots exceeds this
MEGA_STRING_BYTES = (ENV['MEGA_STRING_BYTES'] || 200_000).to_i

RETAINED = []

FIELD_NAMES =('a'..'i').to_a.freeze

def random_field_ref
"#{FIELD_NAMES.sample}.#{rand(1_000)}"
end

# Mirrors the shape of objects being created
def simulate_workload_1
run = { id: "p_#{rand(1_000_000_000)}", a: 'b', c: {}, d: [] }

(rand(5..19)).times do |i|
step = {
id: "s_#{rand(1_000_000_000)}",
i: i,
a: ('a'..'f').to_a.sample,
c: {},
cr: {},
l: [],
}

12.times { |j| step[:c]["f_#{j}"] = random_field_ref }
8.times { |j| step[:cr]["c_#{j}"] = rand < 0.5 ? rand(10_000) : "_#{rand(10_000)}" }
20.times { |j| step[:l] << "w=#{step[:id]} l=#{j} c=#{random_field_ref}" }

run[:d] << step
run[:c][step[:id]] = { r_at: Time.now.to_f, a: step[:a] }
end

run[:blob] = simulate_blob(run[:d])

run
end

# Mirrors large file downloaded from remote storage which is forwarded to JSON.parse
def simulate_blob(steps)
mega_string = ('x' * MEGA_STRING_BYTES) + JSON.generate(steps)
JSON.parse(mega_string[MEGA_STRING_BYTES..])
mega_string
end

# Mirrors the shape of objects being created
def simulate_workload_2
Array.new(rand(3..12)) do |i|
{
l: i,
mc: Array.new(rand(2..5)) { random_field_ref },
rv: rand < 0.3 ? nil : "r_#{rand(100_000)}",
}
end
end

def rss_mb
`ps -o rss= -p #{Process.pid}`.to_i / 1024
end

def print_stats(iteration, start_time)
stat = GC.stat
elapsed = Time.now - start_time
free_ratio = stat[:heap_free_slots].to_f / stat[:heap_available_slots]

# GC.stat only exposes malloc_increase_bytes, which resets on every GC --
# accumulate it ourselves for a running total across the whole run.
@last_malloc_increase ||= 0
@total_malloc_bytes ||= 0
@total_malloc_bytes += stat[:malloc_increase_bytes] >= @last_malloc_increase ? stat[:malloc_increase_bytes] - @last_malloc_increase : stat[:malloc_increase_bytes]
@last_malloc_increase = stat[:malloc_increase_bytes]

printf(
"iter=%-7d elapsed=%6.1fs rss=%6dMB live_slots=%9d available_slots=%9d empty_pages=%6d allocated_pages=%6d free_ratio=%.3f total_malloc_bytes=%12dB major_gc=%5d minor_gc=%6d malloc_increase=%9dB\n",
iteration,
elapsed,
rss_mb,
stat[:heap_live_slots],
stat[:heap_available_slots],
stat[:heap_empty_pages],
stat[:heap_allocated_pages],
free_ratio,
@total_malloc_bytes,
stat[:major_gc_count],
stat[:minor_gc_count],
stat[:malloc_increase_bytes],
)
end

puts "ruby=#{RUBY_VERSION} yjit=#{defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?} " \
"iterations=#{ITERATIONS} full_gc_every=#{FULL_GC_EVERY} idle_ms=#{IDLE_MS} retain_every=#{RETAIN_EVERY} " \
"boot_rss=#{rss_mb}MB"

# Warm the heap to a steady baseline before measuring, the same call
# production makes before fork -- and the call that flips on the GC's
# post-warmup page-release path.
500.times do
simulate_workload_1
simulate_workload_2
end
Process.warmup

start_time = Time.now
print_stats(0, start_time)

ITERATIONS.times do |i|
simulate_workload_1
simulate_workload_2

RETAINED << simulate_workload_1 if RETAIN_EVERY > 0 && (i % RETAIN_EVERY).zero?

GC.start if FULL_GC_EVERY > 0 && (i % FULL_GC_EVERY).zero?

sleep(IDLE_MS / 1000.0) if IDLE_MS > 0

print_stats(i + 1, start_time) if ((i + 1) % PRINT_EVERY).zero?
end

print_stats(ITERATIONS, start_time)
    (1-1/1)