Introduction
Shape problems are invisible at the Ruby source level — your code works correctly, just slower. Detecting shape mismatches requires specific tools and techniques to surface what the VM is doing under the hood.
Key Concepts
RubyVM.debug_get_shape: A debug API that returns the shape descriptor of an object, allowing you to compare shapes between instances.- Shape depth limit: Ruby imposes a maximum depth for the shape tree. Objects that exceed this limit (by dynamically creating too many unique ivars) fall back to a slower hash-based storage.
- YJIT side exits: When YJIT encounters an unexpected shape at a cached ivar access site, it takes a "side exit" back to the interpreter, negating the JIT speedup.
Real World Context
A major e-commerce platform discovered that their product catalog objects had 47 different shapes due to optional attributes added by different middleware layers. After unifying initialization order, ivar access throughput improved by 22% and YJIT side exit count dropped by 60%.
Deep Dive
The primary debugging tool is RubyVM.debug_get_shape, which lets you compare two objects:
rubydef same_shape?(a, b) RubyVM.debug_get_shape(a) == RubyVM.debug_get_shape(b) end class User attr_accessor :name, :age end u1 = User.new.tap { |u| u.name = "A"; u.age = 1 } u2 = User.new.tap { |u| u.age = 2; u.name = "B" } # Different order! same_shape?(u1, u2) # => false!
Even though both users have @name and @age, the different assignment order creates different shapes. The fix is to use a constructor:
rubyclass User attr_accessor :name, :age def initialize(name, age) @name = name # Always first @age = age # Always second end end u1 = User.new("A", 1) u2 = User.new("B", 2) same_shape?(u1, u2) # => true
You can also monitor YJIT for shape-related deoptimization:
rubystats = RubyVM::YJIT.runtime_stats puts stats[:side_exit_count] # High numbers may indicate shape issues puts stats[:total_exits]
The shape depth limit is important to know about. When you dynamically create ivars, each unique name adds a level to the shape tree:
ruby# BAD — each key creates a new shape transition data.each { |k, v| instance_variable_set("@#{k}", v) } # If data has 100 keys, this creates a chain 100 levels deep # Exceeding the limit causes fallback to hash-based storage # GOOD — store dynamic data in a Hash ivar def initialize(data) @data = data # One shape transition, regardless of data size end
Common Pitfalls
- Relying on
debug_get_shapein production — This method is for debugging only. It may not be available in all Ruby builds and adds overhead. Use it in tests and development, not in production code. - Ignoring the shape limit for serialization — Libraries that deserialize JSON into ivars via
instance_variable_setcan easily hit the shape depth limit with large payloads.
Best Practices
- Add shape assertions to your test suite — Write a test that creates instances via different code paths and asserts they share the same shape (or at least the same
instance_variableslist). - Monitor YJIT side exits after deploys — A spike in
side_exit_countafter a deploy can indicate that new code introduced shape polymorphism.
Summary
- Use
RubyVM.debug_get_shapeto verify that instances share the same shape. - Dynamic ivar creation via
instance_variable_setrisks hitting the shape depth limit. - Monitor YJIT
side_exit_countto detect shape-related deoptimization in production.
Code Examples
# BAD: Dynamic ivar creation hits shape depth limit
class DynamicModel
def initialize(attrs)
attrs.each { |k, v| instance_variable_set("@#{k}", v) }
end
end
# DynamicModel.new(a: 1, b: 2, ..., z: 26) -> 26-deep shape chain
# GOOD: Store dynamic attributes in a hash
class SafeModel
def initialize(attrs)
@attributes = attrs # Single shape transition
end
def [](key)
@attributes[key]
end
end