CrackedRuby logo

CrackedRuby

GC Compaction

A comprehensive guide to Ruby's garbage collection compaction for memory optimization and heap defragmentation.

Core Modules GC Module
3.6.3

Overview

Ruby's garbage collection compaction reduces memory fragmentation by moving live objects to eliminate gaps in the heap. This process consolidates allocated memory regions and creates larger contiguous free spaces. The compactor runs during major garbage collection cycles and relocates objects to minimize heap fragmentation.

The GC module provides the primary interface for compaction operations through methods like GC.compact and GC.auto_compact. When compaction occurs, Ruby updates all references to moved objects automatically, maintaining program correctness while optimizing memory layout.

Compaction addresses fragmentation that accumulates as objects are allocated and freed. Without compaction, the heap develops scattered free regions that cannot satisfy larger allocation requests, leading to unnecessary heap growth.

# Check current compaction status
GC.stat(:compact_count)
# => 0

# Trigger manual compaction
GC.compact
# => {:considered=>12845, :moved=>8234}

# Verify compaction occurred
GC.stat(:compact_count)
# => 1

The compactor operates on object generations, typically focusing on older objects that are less likely to be freed soon. This generational approach minimizes the performance impact while achieving significant memory layout improvements.

# Enable automatic compaction
GC.auto_compact = true

# Configure compaction timing
GC.auto_compact = true
major_gc_count = GC.stat[:major_gc_count]
GC.start(full_mark: true)
# Compaction may occur during this major GC

Basic Usage

Manual compaction provides direct control over when defragmentation occurs. The GC.compact method performs immediate compaction and returns statistics about the operation.

# Basic compaction execution
result = GC.compact
puts "Objects considered: #{result[:considered]}"
puts "Objects moved: #{result[:moved]}"
# => Objects considered: 15432
# => Objects moved: 9876

Automatic compaction eliminates the need for manual triggering by integrating compaction into regular garbage collection cycles. Enable automatic mode through GC.auto_compact.

# Enable automatic compaction
GC.auto_compact = true

# Check current setting
puts "Auto compaction: #{GC.auto_compact}"
# => Auto compaction: true

# Disable when not needed
GC.auto_compact = false

The compaction process reports detailed statistics through the returned hash. The :considered key indicates total objects evaluated for movement, while :moved shows objects actually relocated.

def analyze_compaction
  before_stats = GC.stat
  compact_result = GC.compact
  after_stats = GC.stat
  
  {
    objects_moved: compact_result[:moved],
    heap_pages_before: before_stats[:heap_sorted_length],
    heap_pages_after: after_stats[:heap_sorted_length],
    compaction_time: measure_compaction_time
  }
end

stats = analyze_compaction
puts "Moved #{stats[:objects_moved]} objects"
puts "Heap pages: #{stats[:heap_pages_before]}#{stats[:heap_pages_after]}"

Compaction works with all object types except those explicitly pinned by the virtual machine. Most user-defined objects participate in compaction automatically without special handling.

class DataProcessor
  def initialize
    @buffer = Array.new(10000) { rand(1000) }
    @cache = {}
  end
  
  def compact_and_process
    # Objects in @buffer and @cache can be moved
    GC.compact
    process_data
  end
end

processor = DataProcessor.new
# All instance variables participate in compaction
processor.compact_and_process

The timing of compaction affects application performance. Manual compaction during low-activity periods minimizes user impact, while automatic compaction distributes the overhead across normal operations.

Performance & Memory

Compaction reduces memory usage by eliminating fragmentation holes that prevent efficient allocation. Applications with high object churn see the largest improvements as frequent allocations and deallocations create scattered free regions.

def measure_memory_impact
  # Create fragmentation
  objects = Array.new(50000) { Object.new }
  objects.select!.with_index { |_, i| i.even? }
  
  before_memory = GC.stat(:heap_allocated_pages)
  before_free = GC.stat(:heap_free_slots)
  
  GC.compact
  
  after_memory = GC.stat(:heap_allocated_pages)
  after_free = GC.stat(:heap_free_slots)
  
  {
    pages_freed: before_memory - after_memory,
    slots_consolidated: after_free - before_free
  }
end

impact = measure_memory_impact
puts "Pages freed: #{impact[:pages_freed]}"
puts "Slots consolidated: #{impact[:slots_consolidated]}"

Compaction overhead varies based on object count and reference density. Applications with many interconnected objects experience higher compaction costs as the collector must update more references.

require 'benchmark'

class ReferenceHeavyStructure
  def initialize(size)
    @nodes = Array.new(size) { { data: rand(1000), refs: [] } }
    @nodes.each_with_index do |node, i|
      # Create cross-references
      refs = (0...size).to_a.sample(10)
      node[:refs] = refs.map { |idx| @nodes[idx] }
    end
  end
end

# Measure compaction time with different reference densities
sizes = [1000, 5000, 10000]
sizes.each do |size|
  structure = ReferenceHeavyStructure.new(size)
  
  time = Benchmark.realtime { GC.compact }
  puts "#{size} objects: #{(time * 1000).round(2)}ms"
end
# => 1000 objects: 12.34ms
# => 5000 objects: 89.67ms
# => 10000 objects: 245.12ms

Memory layout improvements from compaction enable more efficient allocation patterns. Consolidated free space allows larger objects to be allocated without growing the heap.

def demonstrate_allocation_efficiency
  # Create fragmentation pattern
  small_objects = Array.new(100000) { "x" * 100 }
  small_objects.select!.with_index { |_, i| i % 3 == 0 }
  
  # Attempt large allocation before compaction
  begin
    large_object = "x" * 1_000_000
    puts "Large allocation succeeded without compaction"
  rescue NoMemoryError
    puts "Large allocation failed - heap fragmented"
  end
  
  GC.compact
  
  # Try large allocation after compaction
  large_object = "x" * 1_000_000
  puts "Large allocation succeeded after compaction"
end

The frequency of compaction affects overall application performance. Too frequent compaction wastes CPU cycles, while infrequent compaction allows fragmentation to accumulate.

class CompactionScheduler
  def initialize(threshold: 100)
    @threshold = threshold
    @last_compact_count = GC.stat(:major_gc_count)
  end
  
  def should_compact?
    current_count = GC.stat(:major_gc_count)
    return false if current_count - @last_compact_count < @threshold
    
    # Check fragmentation level
    total_slots = GC.stat(:heap_allocated_pages) * GC.stat(:heap_page_length)
    used_slots = GC.stat(:heap_live_slots)
    fragmentation = 1.0 - (used_slots.to_f / total_slots)
    
    fragmentation > 0.3 # Compact if >30% fragmented
  end
  
  def compact_if_needed
    return unless should_compact?
    
    result = GC.compact
    @last_compact_count = GC.stat(:major_gc_count)
    result
  end
end

Error Handling & Debugging

Compaction failures are rare but can occur when the heap reaches critical memory limits. The compactor may skip moving objects if insufficient free space exists for temporary storage during the move process.

def safe_compaction_with_retry
  attempts = 0
  max_attempts = 3
  
  begin
    result = GC.compact
    puts "Compaction successful: #{result[:moved]} objects moved"
    result
  rescue NoMemoryError => e
    attempts += 1
    if attempts < max_attempts
      puts "Compaction failed, retrying (attempt #{attempts})"
      # Force full GC to free memory for compaction
      GC.start(full_mark: true)
      retry
    else
      puts "Compaction failed after #{max_attempts} attempts: #{e.message}"
      raise
    end
  end
end

safe_compaction_with_retry

Debugging compaction issues requires monitoring heap statistics before and after operations. Changes in object counts and memory usage reveal compaction effectiveness.

class CompactionDebugger
  def self.debug_compaction(&block)
    before_stats = GC.stat.dup
    before_time = Time.now
    
    result = yield
    
    after_time = Time.now
    after_stats = GC.stat.dup
    
    report = {
      duration: (after_time - before_time) * 1000,
      objects_moved: result[:moved],
      objects_considered: result[:considered],
      move_ratio: result[:moved].to_f / result[:considered],
      heap_pages_before: before_stats[:heap_allocated_pages],
      heap_pages_after: after_stats[:heap_allocated_pages],
      pages_freed: before_stats[:heap_allocated_pages] - after_stats[:heap_allocated_pages]
    }
    
    puts "=== Compaction Debug Report ==="
    report.each { |key, value| puts "#{key}: #{value}" }
    puts "==============================="
    
    result
  end
end

# Usage with debugging
CompactionDebugger.debug_compaction { GC.compact }

Object pinning prevents certain objects from being moved during compaction. This occurs with objects that have references from C extensions or are otherwise locked by the virtual machine.

def analyze_pinned_objects
  # Create mix of pinnable and movable objects
  movable_objects = Array.new(1000) { Object.new }
  
  # Some objects may be pinned by VM internals
  result = GC.compact
  
  pinned_count = result[:considered] - result[:moved]
  pin_ratio = pinned_count.to_f / result[:considered]
  
  if pin_ratio > 0.1
    puts "High pin ratio detected: #{(pin_ratio * 100).round(1)}%"
    puts "Consider reviewing C extensions or native library usage"
  end
  
  {
    pinned_objects: pinned_count,
    pin_ratio: pin_ratio,
    movable_objects: result[:moved]
  }
end

pinning_analysis = analyze_pinned_objects

Memory pressure during compaction can cause performance degradation. Monitor system memory usage to detect when compaction occurs under constrained conditions.

require 'objspace'

def memory_pressure_check
  gc_stats = GC.stat
  heap_usage = gc_stats[:heap_allocated_pages] * gc_stats[:heap_page_length]
  live_objects = gc_stats[:heap_live_slots]
  
  pressure_indicators = {
    high_fragmentation: (heap_usage - live_objects) > (heap_usage * 0.4),
    frequent_gc: gc_stats[:major_gc_count] > 1000,
    large_heap: gc_stats[:heap_allocated_pages] > 10000
  }
  
  if pressure_indicators.any? { |_, condition| condition }
    puts "Memory pressure detected:"
    pressure_indicators.each do |indicator, present|
      puts "  #{indicator}: #{present}" if present
    end
    puts "Consider compaction to reduce pressure"
    return true
  end
  
  false
end

if memory_pressure_check
  GC.compact
end

Production Patterns

Production applications benefit from scheduled compaction during low-traffic periods. This approach minimizes user-facing performance impact while maintaining optimal memory layout.

class ProductionCompactionManager
  def initialize
    @last_compaction = Time.now
    @compaction_interval = 3600 # 1 hour
    @min_fragmentation = 0.25
  end
  
  def should_compact_now?
    return false unless low_traffic_period?
    return false if Time.now - @last_compaction < @compaction_interval
    
    current_fragmentation > @min_fragmentation
  end
  
  def compact_if_appropriate
    return unless should_compact_now?
    
    start_time = Time.now
    result = GC.compact
    duration = Time.now - start_time
    
    log_compaction_result(result, duration)
    @last_compaction = Time.now
    
    result
  end
  
  private
  
  def low_traffic_period?
    # Implement based on application patterns
    hour = Time.now.hour
    (2..6).include?(hour) # 2 AM - 6 AM
  end
  
  def current_fragmentation
    stats = GC.stat
    total_slots = stats[:heap_allocated_pages] * stats[:heap_page_length]
    used_slots = stats[:heap_live_slots]
    1.0 - (used_slots.to_f / total_slots)
  end
  
  def log_compaction_result(result, duration)
    puts "[COMPACTION] Moved #{result[:moved]} objects in #{duration.round(3)}s"
  end
end

# Integrate with application lifecycle
compaction_manager = ProductionCompactionManager.new
Thread.new do
  loop do
    compaction_manager.compact_if_appropriate
    sleep 300 # Check every 5 minutes
  end
end

Web applications can trigger compaction between request cycles to avoid impacting response times. Integration with application servers enables strategic timing.

# Rack middleware for compaction
class CompactionMiddleware
  def initialize(app, options = {})
    @app = app
    @request_count = 0
    @compact_interval = options[:interval] || 1000
  end
  
  def call(env)
    @request_count += 1
    response = @app.call(env)
    
    if should_compact?
      Thread.new { perform_background_compaction }
    end
    
    response
  end
  
  private
  
  def should_compact?
    @request_count % @compact_interval == 0
  end
  
  def perform_background_compaction
    start_time = Time.now
    result = GC.compact
    duration = Time.now - start_time
    
    Rails.logger.info "Background compaction: #{result[:moved]} objects moved in #{duration.round(3)}s"
  rescue => e
    Rails.logger.error "Compaction failed: #{e.message}"
  end
end

# Rails application setup
Rails.application.config.middleware.use CompactionMiddleware, interval: 500

Background job processing provides natural compaction opportunities between job executions. This pattern works well with systems like Sidekiq or DelayedJob.

class CompactionAwareWorker
  include Sidekiq::Worker
  
  def perform(*args)
    # Execute the actual work
    result = process_job(*args)
    
    # Compact after processing if conditions are met
    compact_if_beneficial
    
    result
  end
  
  private
  
  def compact_if_beneficial
    return unless should_compact_after_job?
    
    # Perform compaction in background to avoid blocking job queue
    Thread.new do
      begin
        compaction_result = GC.compact
        log_compaction_success(compaction_result)
      rescue => e
        log_compaction_error(e)
      end
    end
  end
  
  def should_compact_after_job?
    # Check memory fragmentation level
    stats = GC.stat
    fragmentation = calculate_fragmentation(stats)
    
    # Compact if fragmentation exceeds threshold and sufficient time has passed
    fragmentation > 0.3 && time_since_last_compaction > 300
  end
  
  def calculate_fragmentation(stats)
    total = stats[:heap_allocated_pages] * stats[:heap_page_length]
    used = stats[:heap_live_slots]
    (total - used).to_f / total
  end
  
  def time_since_last_compaction
    Time.now.to_i - (Rails.cache.read('last_compaction_time') || 0)
  end
  
  def log_compaction_success(result)
    Rails.cache.write('last_compaction_time', Time.now.to_i)
    Sidekiq.logger.info "Compacted #{result[:moved]} objects"
  end
  
  def log_compaction_error(error)
    Sidekiq.logger.error "Compaction failed: #{error.message}"
  end
end

Common Pitfalls

Excessive compaction frequency wastes CPU cycles without providing proportional benefits. Applications that compact too often see diminishing returns as objects have insufficient time to create meaningful fragmentation.

# Problematic: Compacting after every GC
class OvereagerCompactor
  def initialize
    # BAD: This will compact constantly
    ObjectSpace.define_finalizer(Object.new) { GC.compact }
  end
end

# Better: Threshold-based compaction
class ThresholdCompactor
  def initialize
    @gc_count_at_last_compaction = GC.stat[:major_gc_count]
    @min_gc_interval = 50
  end
  
  def compact_if_due
    current_gc_count = GC.stat[:major_gc_count]
    return unless current_gc_count - @gc_count_at_last_compaction > @min_gc_interval
    
    GC.compact
    @gc_count_at_last_compaction = current_gc_count
  end
end

Measuring compaction benefits incorrectly leads to wrong optimization decisions. Simply counting moved objects doesn't indicate actual memory improvements.

# Misleading measurement
def bad_compaction_measurement
  result = GC.compact
  puts "Compaction moved #{result[:moved]} objects - SUCCESS!"
  # This doesn't tell us if memory usage actually improved
end

# Proper measurement
def accurate_compaction_measurement
  before_pages = GC.stat[:heap_allocated_pages]
  before_fragmentation = calculate_fragmentation
  
  result = GC.compact
  
  after_pages = GC.stat[:heap_allocated_pages]
  after_fragmentation = calculate_fragmentation
  
  improvement = {
    pages_freed: before_pages - after_pages,
    fragmentation_reduction: before_fragmentation - after_fragmentation,
    objects_moved: result[:moved]
  }
  
  if improvement[:fragmentation_reduction] > 0.05
    puts "Meaningful compaction: #{improvement[:fragmentation_reduction] * 100}% less fragmented"
  else
    puts "Minimal compaction benefit - consider reducing frequency"
  end
end

def calculate_fragmentation
  stats = GC.stat
  total = stats[:heap_allocated_pages] * stats[:heap_page_length]
  used = stats[:heap_live_slots]
  (total - used).to_f / total
end

Ignoring compaction timing can degrade application performance. Compacting during peak usage periods blocks request processing and increases latency.

# Problematic: Synchronous compaction in request handler
def bad_request_handler(request)
  process_request(request)
  GC.compact # BLOCKS the request thread!
  send_response
end

# Better: Asynchronous compaction
def good_request_handler(request)
  process_request(request)
  
  # Compact in background if needed
  if should_compact?
    Thread.new { GC.compact }
  end
  
  send_response
end

def should_compact?
  @last_compact ||= Time.now
  return false if Time.now - @last_compact < 600 # Min 10 minutes between compactions
  
  stats = GC.stat
  fragmentation = 1.0 - (stats[:heap_live_slots].to_f / 
                        (stats[:heap_allocated_pages] * stats[:heap_page_length]))
  
  if fragmentation > 0.4
    @last_compact = Time.now
    true
  else
    false
  end
end

Expecting compaction to solve all memory problems leads to disappointment. Compaction addresses fragmentation but cannot fix memory leaks or excessive object retention.

# Compaction won't fix this memory leak
class MemoryLeaker
  @@global_cache = []
  
  def process_data(data)
    result = expensive_computation(data)
    @@global_cache << result # Objects never freed!
    
    # Compaction won't help - objects are still referenced
    GC.compact if @@global_cache.size % 1000 == 0
    
    result
  end
end

# Fix the underlying issue instead
class ProperMemoryManagement
  def initialize(cache_limit: 10000)
    @cache = {}
    @cache_limit = cache_limit
  end
  
  def process_data(data)
    cache_key = data.hash
    return @cache[cache_key] if @cache.key?(cache_key)
    
    result = expensive_computation(data)
    
    # Proper cache management
    if @cache.size >= @cache_limit
      old_key = @cache.keys.first
      @cache.delete(old_key)
    end
    
    @cache[cache_key] = result
    result
  end
end

Automatic compaction settings can conflict with application-specific memory patterns. Blindly enabling automatic compaction without understanding the workload may hurt performance.

# Monitor automatic compaction impact
class AutoCompactionMonitor
  def self.evaluate_auto_compaction
    # Test with auto compaction disabled
    GC.auto_compact = false
    baseline_metrics = run_workload_sample
    
    # Test with auto compaction enabled
    GC.auto_compact = true
    auto_compact_metrics = run_workload_sample
    
    comparison = {
      throughput_change: (auto_compact_metrics[:throughput] / baseline_metrics[:throughput] - 1) * 100,
      memory_change: (auto_compact_metrics[:memory] / baseline_metrics[:memory] - 1) * 100,
      gc_time_change: (auto_compact_metrics[:gc_time] / baseline_metrics[:gc_time] - 1) * 100
    }
    
    recommendation = if comparison[:throughput_change] < -5
      "Disable auto compaction - significant throughput loss"
    elsif comparison[:memory_change] < -10
      "Enable auto compaction - significant memory savings"
    else
      "Manual compaction may be better - mixed results"
    end
    
    { comparison: comparison, recommendation: recommendation }
  end
  
  def self.run_workload_sample
    # Implement workload representative of production
    start_time = Time.now
    start_memory = GC.stat[:heap_allocated_pages]
    
    # Simulate typical application work
    1000.times { create_typical_objects }
    
    {
      throughput: 1000 / (Time.now - start_time),
      memory: GC.stat[:heap_allocated_pages] - start_memory,
      gc_time: GC.stat[:total_time]
    }
  end
end

Reference

Core Methods

Method Parameters Returns Description
GC.compact None Hash Performs immediate compaction, returns statistics
GC.auto_compact None Boolean Returns current automatic compaction setting
GC.auto_compact= enabled (Boolean) Boolean Enables or disables automatic compaction
GC.verify_compaction_references toward: :empty (Symbol) Hash Verifies reference consistency after compaction

Compaction Statistics

The hash returned by GC.compact contains:

Key Type Description
:considered Integer Total objects evaluated for movement
:moved Integer Objects successfully relocated

GC Statistics Related to Compaction

Statistic Description
:compact_count Number of compactions performed
:heap_allocated_pages Total heap pages allocated
:heap_sorted_length Length of sorted heap page list
:heap_free_slots Available object slots in heap
:heap_live_slots Slots containing live objects
:heap_page_length Objects per heap page

Configuration Options

Setting Type Default Description
Auto compaction Boolean false Automatic compaction during major GC

Common Fragmentation Calculation

def heap_fragmentation_ratio
  stats = GC.stat
  total_slots = stats[:heap_allocated_pages] * stats[:heap_page_length]
  used_slots = stats[:heap_live_slots]
  free_slots = total_slots - used_slots
  
  free_slots.to_f / total_slots
end

Compaction Timing Guidelines

Fragmentation Level Recommended Action
< 20% No compaction needed
20-30% Consider compaction during low activity
30-50% Schedule compaction soon
> 50% Urgent compaction recommended

Performance Impact Estimates

Objects Count Typical Compaction Time
< 10,000 1-5 milliseconds
10,000-100,000 5-50 milliseconds
100,000-1,000,000 50-500 milliseconds
> 1,000,000 500+ milliseconds

Error Conditions

Error Cause Solution
NoMemoryError during compaction Insufficient heap space for object movement Free memory before compacting
High pinned object ratio Many objects locked by C extensions Review native library usage
Compaction provides no benefit Low fragmentation or recent compaction Reduce compaction frequency