Overview
YJIT (Yet another Ruby JIT) compiles Ruby bytecode to native machine code at runtime, requiring careful memory management for compiled code storage, cache maintenance, and garbage collection coordination. YJIT allocates memory for three primary areas: the code cache storing compiled machine instructions, metadata structures tracking compilation state, and runtime data supporting execution.
The code cache represents YJIT's largest memory allocation, defaulting to 128MB on 64-bit systems and 64MB on 32-bit systems. This cache stores compiled machine code alongside associated metadata including basic block boundaries, type assumptions, and jump targets. When the cache fills, YJIT employs eviction strategies to reclaim space for new compilations.
YJIT integrates with Ruby's garbage collector through write barriers and object tracking mechanisms. Compiled code references Ruby objects, requiring coordination during GC cycles to maintain memory safety and update object references after compaction.
# Enable YJIT with default memory settings
RubyVM::YJIT.enable
# Check current memory usage
stats = RubyVM::YJIT.runtime_stats
puts "Code cache size: #{stats[:code_gc_count]} MB"
puts "Compiled blocks: #{stats[:compiled_iseq_count]}"
Memory management becomes critical in long-running applications where YJIT must balance compilation overhead against performance gains while preventing memory exhaustion.
# Configure YJIT memory limits at startup
export RUBY_YJIT_CODE_GC_SIZE=256
ruby --yjit script.rb
Basic Usage
YJIT memory management operates automatically once enabled, but several configuration options control memory allocation behavior. The --yjit-code-gc-size
option sets the code cache size in MB, while runtime statistics provide visibility into memory usage patterns.
# Enable YJIT with memory monitoring
RubyVM::YJIT.enable
def memory_intensive_method
1000.times.map { |i| "string_#{i}" * 100 }
end
# Execute code to trigger compilation
5.times { memory_intensive_method }
# Check memory statistics
stats = RubyVM::YJIT.runtime_stats
puts "Allocated bytes: #{stats[:total_allocated]}"
puts "Freed bytes: #{stats[:total_freed]}"
puts "Peak RSS: #{stats[:peak_mem_usage]}"
The code cache fills as YJIT compiles methods. When approaching capacity, YJIT triggers garbage collection of compiled code, removing least recently used entries and associated metadata.
# Monitor code cache pressure
class YJITMonitor
def self.check_cache_usage
stats = RubyVM::YJIT.runtime_stats
cache_size = stats[:code_gc_count] || 0
total_allocated = stats[:total_allocated] || 0
if cache_size > 0
usage_ratio = total_allocated.to_f / (cache_size * 1024 * 1024)
puts "Cache usage: #{(usage_ratio * 100).round(2)}%"
if usage_ratio > 0.8
puts "Warning: Code cache approaching capacity"
end
end
end
end
YJITMonitor.check_cache_usage
Memory allocation tracking helps identify methods causing excessive compilation overhead. Methods with complex control flow or frequent deoptimization consume more cache space.
# Track per-method compilation costs
def track_compilation_memory
before_stats = RubyVM::YJIT.runtime_stats
# Method with multiple branches increases memory usage
def complex_method(input)
case input
when String then input.upcase
when Integer then input * 2
when Array then input.compact
else input.to_s
end
end
# Trigger compilation with different types
complex_method("test")
complex_method(42)
complex_method([1, nil, 3])
complex_method({key: "value"})
after_stats = RubyVM::YJIT.runtime_stats
memory_increase = (after_stats[:total_allocated] || 0) - (before_stats[:total_allocated] || 0)
puts "Memory allocated for compilation: #{memory_increase} bytes"
end
track_compilation_memory
Advanced Usage
Advanced YJIT memory management involves tuning cache sizes, controlling compilation thresholds, and implementing custom memory monitoring strategies. The compilation threshold determines when YJIT compiles methods, affecting memory allocation patterns.
# Advanced memory configuration with environment variables
ENV['RUBY_YJIT_CODE_GC_SIZE'] = '512' # 512MB code cache
ENV['RUBY_YJIT_COMPILE_THRESHOLD'] = '30' # Compile after 30 calls
# Custom memory pressure detection
class AdvancedYJITMonitor
def initialize(warning_threshold: 0.75, critical_threshold: 0.9)
@warning_threshold = warning_threshold
@critical_threshold = critical_threshold
@last_check = Time.now
@stats_history = []
end
def monitor_memory_trends
current_stats = RubyVM::YJIT.runtime_stats
timestamp = Time.now
@stats_history << {
timestamp: timestamp,
allocated: current_stats[:total_allocated] || 0,
freed: current_stats[:total_freed] || 0,
code_gc_count: current_stats[:code_gc_count] || 0
}
# Keep only last 100 measurements
@stats_history = @stats_history.last(100)
analyze_memory_pressure if @stats_history.length > 10
end
private
def analyze_memory_pressure
recent_stats = @stats_history.last(10)
allocation_trend = calculate_allocation_trend(recent_stats)
if allocation_trend > @critical_threshold
handle_critical_pressure
elsif allocation_trend > @warning_threshold
handle_warning_pressure
end
end
def calculate_allocation_trend(stats)
return 0 if stats.length < 2
first_allocated = stats.first[:allocated]
last_allocated = stats.last[:allocated]
time_span = stats.last[:timestamp] - stats.first[:timestamp]
return 0 if time_span <= 0
(last_allocated - first_allocated) / time_span.to_f
end
def handle_critical_pressure
puts "CRITICAL: YJIT memory pressure detected"
# Force garbage collection of compiled code
GC.start
end
def handle_warning_pressure
puts "WARNING: YJIT memory allocation increasing"
end
end
Fine-grained control over compilation targets reduces memory usage by preventing compilation of infrequently executed methods. The --yjit-verify-ctx
option enables context verification, trading memory for runtime safety.
# Selective compilation strategy
module SelectiveCompilation
def self.setup_conditional_compilation
# Monitor method call frequencies
@call_counts = Hash.new(0)
@compile_threshold = 50
# Patch method definition to track calls
Module.prepend(CallTracker)
end
module CallTracker
def define_method(name, &block)
super(name) do |*args, **kwargs|
SelectiveCompilation.record_call(self.class, name)
instance_exec(*args, **kwargs, &block)
end
end
end
def self.record_call(klass, method_name)
key = "#{klass}##{method_name}"
@call_counts[key] += 1
if @call_counts[key] == @compile_threshold
# Method reached compilation threshold
puts "Compiling #{key} after #{@compile_threshold} calls"
end
end
def self.compilation_candidates
@call_counts.select { |_, count| count >= @compile_threshold }
end
end
Memory-mapped code regions provide alternative allocation strategies for specific deployment environments. Large applications benefit from partitioning compiled code across multiple memory regions.
# Memory region management for large applications
class CodeRegionManager
def initialize(region_size_mb: 64, max_regions: 8)
@region_size = region_size_mb * 1024 * 1024
@max_regions = max_regions
@current_region = 0
@regions = []
allocate_initial_region
end
def allocate_initial_region
puts "Allocating initial code region: #{@region_size / 1024 / 1024}MB"
@regions << create_region(@region_size)
end
def monitor_region_usage
stats = RubyVM::YJIT.runtime_stats
total_allocated = stats[:total_allocated] || 0
if total_allocated > (@current_region + 1) * @region_size * 0.8
if @current_region < @max_regions - 1
allocate_new_region
else
trigger_region_cleanup
end
end
end
private
def allocate_new_region
@current_region += 1
puts "Allocating new code region #{@current_region}"
@regions << create_region(@region_size)
end
def create_region(size)
{ size: size, allocated_at: Time.now }
end
def trigger_region_cleanup
puts "Maximum regions reached, triggering cleanup"
# Implementation would involve YJIT API extensions
end
end
Performance & Memory
YJIT memory management directly impacts application performance through compilation overhead, cache hit rates, and garbage collection interactions. Measuring these interactions requires comprehensive monitoring of both YJIT-specific metrics and general memory usage patterns.
Code cache sizing significantly affects performance characteristics. Undersized caches cause frequent evictions and recompilations, while oversized caches waste memory without performance benefits. The optimal size depends on application working set size and method complexity.
# Benchmark different cache sizes
class CacheSizeBenchmark
def initialize
@test_methods = generate_test_methods
@cache_sizes = [64, 128, 256, 512] # MB
end
def run_benchmark
@cache_sizes.each do |size|
puts "\n=== Testing #{size}MB cache ==="
# Restart with new cache size would require process restart
# This shows measurement approach
before_memory = measure_memory_usage
before_time = Time.now
# Execute workload
execute_workload
after_time = Time.now
after_memory = measure_memory_usage
stats = RubyVM::YJIT.runtime_stats
puts "Execution time: #{after_time - before_time}s"
puts "Memory increase: #{after_memory - before_memory}MB"
puts "Compilation ratio: #{stats[:ratio_in_yjit] || 0}%"
puts "Code GC events: #{stats[:code_gc_count] || 0}"
end
end
private
def generate_test_methods
methods = []
20.times do |i|
method_name = :"test_method_#{i}"
define_singleton_method(method_name) do |input|
# Create different execution paths
result = case input % 4
when 0 then input * 2 + rand(100)
when 1 then input.to_s.reverse.to_i
when 2 then [input, input * 2, input * 3].sum
else Math.sqrt(input).round
end
# Add some string operations
result.to_s.upcase.length + input
end
methods << method_name
end
methods
end
def execute_workload
1000.times do |iteration|
@test_methods.each do |method_name|
10.times { send(method_name, iteration) }
end
end
end
def measure_memory_usage
GC.start
`ps -o rss= -p #{Process.pid}`.to_i / 1024 # MB
end
end
benchmark = CacheSizeBenchmark.new
benchmark.run_benchmark
Memory allocation patterns reveal optimization opportunities. Methods causing frequent deoptimizations consume disproportionate cache space and benefit from refactoring to reduce type variations.
# Memory allocation profiling
class AllocationProfiler
def initialize
@method_profiles = Hash.new { |h, k| h[k] = { calls: 0, allocations: 0 } }
end
def profile_method(object, method_name)
original_method = object.method(method_name)
object.define_singleton_method(method_name) do |*args, **kwargs|
before_stats = RubyVM::YJIT.runtime_stats
before_allocated = before_stats[:total_allocated] || 0
result = original_method.call(*args, **kwargs)
after_stats = RubyVM::YJIT.runtime_stats
after_allocated = after_stats[:total_allocated] || 0
profile = @method_profiles[method_name]
profile[:calls] += 1
profile[:allocations] += (after_allocated - before_allocated)
result
end
end
def analyze_hot_methods
sorted_methods = @method_profiles.sort_by { |_, profile|
-profile[:allocations]
}
puts "Top memory-allocating methods:"
sorted_methods.first(10).each do |method_name, profile|
avg_allocation = profile[:allocations] / profile[:calls].to_f
puts "#{method_name}: #{profile[:allocations]} bytes total, " \
"#{avg_allocation.round(2)} bytes/call"
end
end
def detect_allocation_anomalies
@method_profiles.each do |method_name, profile|
avg_allocation = profile[:allocations] / profile[:calls].to_f
if avg_allocation > 10_000 # 10KB per call threshold
puts "WARNING: #{method_name} allocates #{avg_allocation.round(2)} bytes per call"
puts "Consider optimizing for better cache efficiency"
end
end
end
end
Garbage collection interaction patterns affect overall memory performance. YJIT's write barriers and object reference tracking introduce overhead during GC cycles, particularly in applications with high allocation rates.
# GC interaction analysis
class GCInteractionAnalyzer
def initialize
@gc_stats = []
@yjit_stats = []
end
def start_monitoring
@monitoring = true
Thread.new do
while @monitoring
record_stats
sleep(0.1) # Sample every 100ms
end
end
end
def stop_monitoring
@monitoring = false
analyze_interaction_patterns
end
private
def record_stats
gc_stat = GC.stat
yjit_stat = RubyVM::YJIT.runtime_stats
@gc_stats << {
timestamp: Time.now,
major_gc_count: gc_stat[:major_gc_count],
minor_gc_count: gc_stat[:minor_gc_count],
heap_allocated_pages: gc_stat[:heap_allocated_pages],
heap_live_slots: gc_stat[:heap_live_slots]
}
@yjit_stats << {
timestamp: Time.now,
total_allocated: yjit_stat[:total_allocated] || 0,
code_gc_count: yjit_stat[:code_gc_count] || 0,
invalidation_count: yjit_stat[:invalidation_count] || 0
}
end
def analyze_interaction_patterns
gc_events = detect_gc_events
gc_events.each do |event|
yjit_impact = measure_yjit_impact_during_gc(event)
if yjit_impact[:significant]
puts "GC event at #{event[:timestamp]} significantly impacted YJIT:"
puts " Invalidations: #{yjit_impact[:invalidations]}"
puts " Code GC triggered: #{yjit_impact[:code_gc]}"
end
end
end
def detect_gc_events
events = []
previous_major = 0
@gc_stats.each do |stat|
if stat[:major_gc_count] > previous_major
events << {
timestamp: stat[:timestamp],
type: :major,
heap_pages: stat[:heap_allocated_pages]
}
previous_major = stat[:major_gc_count]
end
end
events
end
def measure_yjit_impact_during_gc(gc_event)
# Find YJIT stats around GC event time
event_time = gc_event[:timestamp]
before_stats = @yjit_stats.reverse.find { |s| s[:timestamp] < event_time }
after_stats = @yjit_stats.find { |s| s[:timestamp] > event_time }
return { significant: false } unless before_stats && after_stats
invalidation_increase = after_stats[:invalidation_count] - before_stats[:invalidation_count]
code_gc_increase = after_stats[:code_gc_count] - before_stats[:code_gc_count]
{
significant: invalidation_increase > 0 || code_gc_increase > 0,
invalidations: invalidation_increase,
code_gc: code_gc_increase > 0
}
end
end
Production Patterns
Production YJIT deployments require systematic memory management strategies addressing varying workload patterns, resource constraints, and operational monitoring requirements. Applications serving different request types benefit from workload-aware compilation strategies.
Web applications experience request pattern variations that affect YJIT memory usage. High-traffic endpoints compiled early in the application lifecycle may dominate cache usage, while occasional endpoints remain uncompiled. Load balancing across multiple processes distributes compilation overhead.
# Production memory management strategy
class ProductionYJITManager
def initialize(config = {})
@config = {
max_cache_size_mb: config[:max_cache_size_mb] || 256,
warning_threshold: config[:warning_threshold] || 0.8,
monitoring_interval: config[:monitoring_interval] || 30,
log_file: config[:log_file] || '/var/log/yjit_memory.log'
}.freeze
@logger = setup_logger
@monitoring_active = false
end
def start_production_monitoring
return if @monitoring_active
@monitoring_active = true
@logger.info("Starting YJIT memory monitoring")
# Background monitoring thread
@monitor_thread = Thread.new { monitoring_loop }
# Signal handlers for graceful shutdown
setup_signal_handlers
# Periodic memory reports
setup_periodic_reporting
end
def stop_monitoring
@monitoring_active = false
@monitor_thread&.join(5) # Wait up to 5 seconds
@logger.info("YJIT memory monitoring stopped")
end
private
def monitoring_loop
while @monitoring_active
begin
check_memory_health
sleep(@config[:monitoring_interval])
rescue => e
@logger.error("Monitoring error: #{e.message}")
sleep(60) # Back off on errors
end
end
end
def check_memory_health
stats = RubyVM::YJIT.runtime_stats
return unless stats
memory_metrics = calculate_memory_metrics(stats)
if memory_metrics[:cache_utilization] > @config[:warning_threshold]
handle_high_memory_usage(memory_metrics)
end
log_memory_status(memory_metrics)
end
def calculate_memory_metrics(stats)
total_allocated = stats[:total_allocated] || 0
max_cache_bytes = @config[:max_cache_size_mb] * 1024 * 1024
{
total_allocated_mb: total_allocated / 1024.0 / 1024.0,
cache_utilization: total_allocated.to_f / max_cache_bytes,
compiled_methods: stats[:compiled_iseq_count] || 0,
invalidation_count: stats[:invalidation_count] || 0,
code_gc_count: stats[:code_gc_count] || 0
}
end
def handle_high_memory_usage(metrics)
@logger.warn("High YJIT memory usage detected:")
@logger.warn(" Cache utilization: #{(metrics[:cache_utilization] * 100).round(1)}%")
@logger.warn(" Total allocated: #{metrics[:total_allocated_mb].round(1)}MB")
# Consider triggering code GC or alerting operations team
if metrics[:cache_utilization] > 0.95
@logger.error("Critical YJIT memory usage - consider process restart")
# In production, this might trigger an alert to operations
send_critical_alert(metrics)
end
end
def send_critical_alert(metrics)
# Production implementation would integrate with monitoring systems
# like Datadog, New Relic, or custom alerting
puts "CRITICAL ALERT: YJIT memory usage at #{(metrics[:cache_utilization] * 100).round(1)}%"
end
def setup_logger
require 'logger'
Logger.new(@config[:log_file], 'daily').tap do |logger|
logger.level = Logger::INFO
end
end
def setup_signal_handlers
trap('USR1') { log_detailed_stats }
trap('USR2') { force_memory_cleanup }
end
def log_detailed_stats
stats = RubyVM::YJIT.runtime_stats
@logger.info("Detailed YJIT stats: #{stats}")
end
def force_memory_cleanup
@logger.info("Forcing memory cleanup")
GC.start
end
def setup_periodic_reporting
# Daily memory usage reports
Thread.new do
while @monitoring_active
sleep(24 * 60 * 60) # 24 hours
generate_daily_report if @monitoring_active
end
end
end
def generate_daily_report
stats = RubyVM::YJIT.runtime_stats
return unless stats
report = <<~REPORT
Daily YJIT Memory Report
========================
Date: #{Time.now.strftime('%Y-%m-%d')}
Total Allocated: #{((stats[:total_allocated] || 0) / 1024.0 / 1024.0).round(2)}MB
Compiled Methods: #{stats[:compiled_iseq_count] || 0}
Code GC Events: #{stats[:code_gc_count] || 0}
Invalidations: #{stats[:invalidation_count] || 0}
Compilation Ratio: #{stats[:ratio_in_yjit] || 0}%
REPORT
@logger.info(report)
end
end
# Initialize in production
production_manager = ProductionYJITManager.new(
max_cache_size_mb: ENV['YJIT_MAX_CACHE_MB']&.to_i || 256,
warning_threshold: 0.85,
monitoring_interval: 60
)
production_manager.start_production_monitoring
Container environments require memory limit awareness to prevent YJIT cache sizes from exceeding available memory. Dynamic cache sizing based on container memory limits prevents out-of-memory conditions.
# Container-aware memory management
class ContainerYJITManager
def initialize
@container_memory_limit = detect_container_memory_limit
@recommended_cache_size = calculate_optimal_cache_size
configure_yjit_for_container
end
def self.setup_for_container
new.tap(&:apply_container_optimizations)
end
private
def detect_container_memory_limit
# Check cgroup memory limit
cgroup_limit = read_cgroup_memory_limit
return cgroup_limit if cgroup_limit && cgroup_limit > 0
# Fallback to system memory
system_memory = `grep MemTotal /proc/meminfo`.split[1].to_i * 1024
puts "Using system memory: #{system_memory / 1024 / 1024}MB"
system_memory
end
def read_cgroup_memory_limit
limit_file = '/sys/fs/cgroup/memory/memory.limit_in_bytes'
return nil unless File.exist?(limit_file)
limit = File.read(limit_file).strip.to_i
# cgroups v1 uses a very large number for unlimited
limit > (1 << 50) ? nil : limit
rescue
nil
end
def calculate_optimal_cache_size
return 64 unless @container_memory_limit
memory_mb = @container_memory_limit / 1024 / 1024
# Reserve memory for Ruby heap, gems, and system overhead
case memory_mb
when 0..512 then 32 # Small containers
when 513..1024 then 64 # Medium containers
when 1025..2048 then 128 # Large containers
when 2049..4096 then 256 # XL containers
else 512 # XXL containers
end
end
def configure_yjit_for_container
# Set environment variable for child processes
ENV['RUBY_YJIT_CODE_GC_SIZE'] = @recommended_cache_size.to_s
puts "Container memory: #{@container_memory_limit / 1024 / 1024}MB"
puts "YJIT cache size: #{@recommended_cache_size}MB"
end
def apply_container_optimizations
# Monitor memory pressure in containers
setup_memory_pressure_monitoring
# Configure compilation thresholds for container workloads
configure_compilation_strategy
end
def setup_memory_pressure_monitoring
Thread.new do
loop do
check_container_memory_pressure
sleep(30)
end
end
end
def check_container_memory_pressure
return unless @container_memory_limit
current_usage = get_current_memory_usage
pressure_ratio = current_usage.to_f / @container_memory_limit
if pressure_ratio > 0.9
puts "High memory pressure: #{(pressure_ratio * 100).round(1)}%"
consider_cache_reduction
end
end
def get_current_memory_usage
`ps -o rss= -p #{Process.pid}`.to_i * 1024 # Convert KB to bytes
rescue
0
end
def consider_cache_reduction
# In extreme cases, could trigger YJIT code GC
# or temporarily reduce compilation aggressiveness
puts "Consider reducing YJIT cache size or restarting process"
end
def configure_compilation_strategy
# Lower compilation threshold in containers to reduce memory pressure
ENV['RUBY_YJIT_COMPILE_THRESHOLD'] = '50'
end
end
# Auto-configure for container environment
ContainerYJITManager.setup_for_container if ENV['CONTAINER_ENV']
Common Pitfalls
YJIT memory management contains several subtle behaviors that cause unexpected memory growth, performance degradation, or compilation failures. Understanding these pitfalls prevents common deployment issues and performance problems.
The most frequent pitfall involves cache size misconfiguration. Developers often set cache sizes too small for their application's working set, causing excessive code garbage collection and recompilation overhead. Conversely, oversized caches waste memory without providing performance benefits.
# Pitfall: Cache size too small for working set
class CacheSizePitfallDemo
def demonstrate_undersized_cache
# Simulating an app with many method variants
puts "=== Demonstrating undersized cache pitfall ==="
# Set artificially small cache for demonstration
# In practice, this would be set via environment variables
puts "Simulating 8MB cache for large application"
# Track recompilation events
compilation_events = []
# Define many methods that will compete for cache space
50.times do |i|
method_name = :"method_#{i}"
define_singleton_method(method_name) do |input|
# Each method has complex logic requiring significant cache space
case input % 5
when 0 then fibonacci_recursive(input % 20)
when 1 then string_manipulations(input)
when 2 then array_operations(input)
when 3 then hash_operations(input)
else mathematical_operations(input)
end
end
end
# Exercise all methods to trigger compilations
initial_stats = RubyVM::YJIT.runtime_stats
3.times do |round|
puts "Execution round #{round + 1}"
50.times do |method_index|
method_name = :"method_#{method_index}"
20.times { |i| send(method_name, i) }
end
current_stats = RubyVM::YJIT.runtime_stats
if current_stats[:code_gc_count] && initial_stats[:code_gc_count]
new_gc_events = current_stats[:code_gc_count] - initial_stats[:code_gc_count]
if new_gc_events > 0
puts " Code GC events this round: #{new_gc_events}"
puts " Performance likely degraded due to recompilation"
end
end
end
end
private
def fibonacci_recursive(n)
return n if n <= 1
fibonacci_recursive(n-1) + fibonacci_recursive(n-2)
end
def string_manipulations(input)
str = "base_string_#{input}"
str.upcase.reverse.downcase.capitalize
end
def array_operations(input)
array = (0..input).to_a
array.map(&:to_s).select { |s| s.length.even? }.join(',')
end
def hash_operations(input)
hash = { key: input, value: input * 2 }
hash.transform_keys(&:to_s).transform_values { |v| v + 1 }
end
def mathematical_operations(input)
Math.sqrt(input) + Math.sin(input) + Math.cos(input)
end
end
# CacheSizePitfallDemo.new.demonstrate_undersized_cache
Memory leaks occur when YJIT retains references to objects that should be garbage collected. This happens particularly with dynamic method definitions and metaprogramming that creates compilation contexts referencing temporary objects.
# Pitfall: Memory leaks from dynamic method generation
class MemoryLeakDemo
def initialize
@dynamic_method_count = 0
@leaked_objects = []
end
def demonstrate_metaprogramming_leak
puts "=== Demonstrating metaprogramming memory leak ==="
initial_memory = measure_memory
initial_stats = RubyVM::YJIT.runtime_stats
# This pattern creates memory leaks in YJIT
100.times do |i|
create_leaky_dynamic_method(i)
end
# Force compilation of dynamic methods
exercise_dynamic_methods
final_memory = measure_memory
final_stats = RubyVM::YJIT.runtime_stats
memory_increase = final_memory - initial_memory
yjit_allocation = (final_stats[:total_allocated] || 0) - (initial_stats[:total_allocated] || 0)
puts "Memory increase: #{memory_increase}KB"
puts "YJIT allocation increase: #{yjit_allocation} bytes"
puts "Dynamic methods created: #{@dynamic_method_count}"
puts "Potential leaked objects: #{@leaked_objects.length}"
if memory_increase > 1000 # More than 1MB increase
puts "WARNING: Significant memory increase detected"
puts "This pattern may cause memory leaks in production"
end
end
def create_leaky_dynamic_method(index)
# This creates a closure that captures the current context
# YJIT may retain references to these objects
captured_data = generate_large_data_structure(index)
@leaked_objects << captured_data # Simulating retained reference
method_name = :"dynamic_method_#{index}"
# The closure captures captured_data, potentially preventing GC
define_singleton_method(method_name) do
# Method uses captured data, creating reference in compiled code
captured_data[:computed_value] + index
end
@dynamic_method_count += 1
end
def generate_large_data_structure(index)
{
id: index,
data: Array.new(1000) { |i| "string_#{index}_#{i}" },
metadata: {
created_at: Time.now,
description: "Large data structure #{index}",
tags: Array.new(50) { |i| "tag_#{i}" }
},
computed_value: index * 42
}
end
def exercise_dynamic_methods
@dynamic_method_count.times do |i|
method_name = :"dynamic_method_#{i}"
10.times { send(method_name) }
end
end
def measure_memory
GC.start
`ps -o rss= -p #{Process.pid}`.to_i # KB
rescue
0
end
def demonstrate_proper_cleanup
puts "\n=== Demonstrating proper cleanup ==="
initial_memory = measure_memory
# Proper pattern: avoid capturing large objects in closures
100.times do |i|
create_clean_dynamic_method(i)
end
exercise_clean_methods
final_memory = measure_memory
memory_increase = final_memory - initial_memory
puts "Clean pattern memory increase: #{memory_increase}KB"
puts "This should be significantly less than the leaky pattern"
end
def create_clean_dynamic_method(index)
# Don't capture large objects - pass minimal data
value = index * 42 # Simple value, not large structure
method_name = :"clean_method_#{index}"
define_singleton_method(method_name) do
value + 1 # Uses simple value, not large structure
end
end
def exercise_clean_methods
100.times do |i|
method_name = :"clean_method_#{i}"
10.times { send(method_name) }
end
end
end
# MemoryLeakDemo.new.demonstrate_metaprogramming_leak
# MemoryLeakDemo.new.demonstrate_proper_cleanup
Type assumption invalidations cause unexpected memory usage spikes when YJIT repeatedly recompiles methods due to changing object types. Methods that handle multiple types unpredictably consume excessive cache space.
# Pitfall: Type assumption invalidations
class TypeInvalidationDemo
def demonstrate_type_invalidation_pitfall
puts "=== Demonstrating type invalidation pitfall ==="
# Method that will be compiled with initial type assumptions
def polymorphic_method(input)
input.to_s.length + input.hash.abs
end
# First phase: establish type assumptions with consistent types
puts "Phase 1: Establishing type assumptions with String inputs"
before_stats = RubyVM::YJIT.runtime_stats
100.times { |i| polymorphic_method("string_#{i}") }
after_phase1_stats = RubyVM::YJIT.runtime_stats
phase1_allocations = (after_phase1_stats[:total_allocated] || 0) - (before_stats[:total_allocated] || 0)
puts "Memory allocated in phase 1: #{phase1_allocations} bytes"
puts "Invalidations: #{after_phase1_stats[:invalidation_count] || 0}"
# Second phase: violate type assumptions with different types
puts "\nPhase 2: Violating assumptions with mixed types"
mixed_inputs = [
42, # Integer
3.14, # Float
[1, 2, 3], # Array
{key: 'value'}, # Hash
:symbol, # Symbol
true, # Boolean
nil # NilClass
]
# This will cause repeated invalidations and recompilations
50.times do |round|
mixed_inputs.each { |input| polymorphic_method(input) }
end
after_phase2_stats = RubyVM::YJIT.runtime_stats
phase2_allocations = (after_phase2_stats[:total_allocated] || 0) - (after_phase1_stats[:total_allocated] || 0)
invalidation_increase = (after_phase2_stats[:invalidation_count] || 0) - (after_phase1_stats[:invalidation_count] || 0)
puts "Memory allocated in phase 2: #{phase2_allocations} bytes"
puts "Additional invalidations: #{invalidation_increase}"
if phase2_allocations > phase1_allocations * 3
puts "WARNING: Phase 2 used significantly more memory due to invalidations"
puts "Consider refactoring to handle types more predictably"
end
demonstrate_type_stable_alternative
end
def demonstrate_type_stable_alternative
puts "\n=== Demonstrating type-stable alternative ==="
# Better pattern: separate methods for different types
def handle_string(input)
input.length + input.hash.abs
end
def handle_numeric(input)
input.to_s.length + input.hash.abs
end
def handle_collection(input)
input.to_s.length + input.hash.abs
end
def handle_other(input)
input.to_s.length + input.hash.abs
end
# Type-dispatch method
def type_stable_method(input)
case input
when String then handle_string(input)
when Numeric then handle_numeric(input)
when Array, Hash then handle_collection(input)
else handle_other(input)
end
end
before_stats = RubyVM::YJIT.runtime_stats
# Same mixed inputs, but handled through type-stable dispatch
mixed_inputs = [42, 3.14, [1, 2, 3], {key: 'value'}, :symbol, true, nil]
50.times do |round|
mixed_inputs.each { |input| type_stable_method(input) }
end
after_stats = RubyVM::YJIT.runtime_stats
stable_allocations = (after_stats[:total_allocated] || 0) - (before_stats[:total_allocated] || 0)
puts "Type-stable pattern memory usage: #{stable_allocations} bytes"
puts "This should be more consistent and efficient"
end
end
# TypeInvalidationDemo.new.demonstrate_type_invalidation_pitfall
Reference
YJIT Memory Configuration
Environment Variable | Default | Description |
---|---|---|
RUBY_YJIT_CODE_GC_SIZE |
128 (64-bit), 64 (32-bit) | Code cache size in MB |
RUBY_YJIT_COMPILE_THRESHOLD |
10 | Method calls before compilation |
RUBY_YJIT_COLD_THRESHOLD |
200000 | Calls before considering method cold |
RUBY_YJIT_MAX_VERSIONS |
4 | Maximum compiled versions per method |
Runtime Statistics Methods
Method | Returns | Description |
---|---|---|
RubyVM::YJIT.runtime_stats |
Hash |
Complete runtime statistics |
RubyVM::YJIT.enabled? |
Boolean |
YJIT compilation status |
RubyVM::YJIT.reset_stats! |
nil |
Reset runtime statistics |
Key Statistics Fields
Field | Type | Description |
---|---|---|
:total_allocated |
Integer |
Total bytes allocated by YJIT |
:total_freed |
Integer |
Total bytes freed by code GC |
:code_gc_count |
Integer |
Number of code GC events |
:compiled_iseq_count |
Integer |
Number of compiled instruction sequences |
:invalidation_count |
Integer |
Type assumption invalidations |
:ratio_in_yjit |
Float |
Percentage of execution in compiled code |
Memory Monitoring Commands
# Basic memory check
stats = RubyVM::YJIT.runtime_stats
puts "Allocated: #{stats[:total_allocated]} bytes"
# Cache utilization check
cache_size_bytes = ENV['RUBY_YJIT_CODE_GC_SIZE'].to_i * 1024 * 1024
utilization = stats[:total_allocated].to_f / cache_size_bytes
puts "Cache utilization: #{(utilization * 100).round(1)}%"
# Compilation ratio
puts "Compiled execution: #{stats[:ratio_in_yjit]}%"
Common Error Patterns
Error Pattern | Cause | Solution |
---|---|---|
High invalidation count | Type instability | Use type-stable method dispatch |
Excessive code GC events | Undersized cache | Increase RUBY_YJIT_CODE_GC_SIZE |
Memory growth without GC | Reference retention | Avoid capturing large objects in closures |
Low compilation ratio | High compile threshold | Decrease RUBY_YJIT_COMPILE_THRESHOLD |
Production Monitoring Checklist
- Set cache size based on container memory limits
- Monitor invalidation rates for type stability issues
- Track compilation ratios for performance optimization
- Set up alerts for excessive code GC events
- Log daily memory usage reports
- Monitor GC interaction patterns
- Configure compilation thresholds for workload patterns
- Implement graceful degradation for memory pressure