|
# 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)
|