
November 20, 2024
In Ruby 3.2, a significant internal change was introduced to optimize object access: object shapes. This feature addresses some critical performance challenges and limitations in the language. In this article, we’ll explore how object shapes work, their implications, and the performance impact on commonly used idioms like memoization.

The Problem with Accessing Instance Variables
Ruby is a highly dynamic language, meaning even simple operations, such as accessing an instance variable, require multiple internal steps. In older versions of Ruby (pre-3.2), instance variables were stored as references in an array, and Ruby used a Hash to map variable names to array indices.
Consider a simple class User:
class User
def initialize(name, admin)
@name = name
@admin = admin
end
def admin?
@admin
end
end
user = User.new("John", true)
In this case, Ruby creates an object with a slot layout to store the object’s flags, class reference, and instance variables:
+--------+----------+---------+---------+---------+ | flags | class | ivar0 | ivar1 | ivar2 | +--------+----------+---------+---------+---------+ | 0x01 | User | "John" | true | UNDEF | +--------+----------+---------+---------+---------+
However, the operation of accessing an instance variable, like @admin, is not straightforward. In Ruby 3.1 and earlier, it involved looking up the variable name in a Hash stored in each class, which was time-consuming.
🚀 Need Expert Ruby on Rails Developers to Elevate Your Project?

Optimizing with Inline Caches

To address this performance hit, Ruby used inline caches. These are small memory regions within the virtual machine (VM) bytecode that cache the result of frequently executed computations, such as the index of an instance variable. This allows the VM to skip the hash lookup and directly access the variable on subsequent calls.
In the ideal case, inline caches greatly speed up variable access. However, when polymorphism comes into play (i.e., when objects of different classes share the same method names but differ in their implementations), the performance benefits of inline caches degrade. The cache must be invalidated whenever the class of the object changes, leading to slower performance.
Introducing Object Shapes
To address the limitations of inline caches in polymorphic scenarios, Ruby 3.2 introduced object shapes. This concept, borrowed from Smalltalk, represents a more efficient way to manage object layouts.
Object shapes are tree-like structures where each node represents an instance variable. For example:
class Person
def initialize(name)
@name = name
end
end
class Employee < Person
end
class Customer < Person
def initialize(name)
super
@balance = 0
end
end
In this example, objects of Employee and Person will share the same shape for the @name variable. However, an instance of Customer will have a different shape due to the additional @balance variable.
How Object Shapes Improve Performance
The key benefit of object shapes is that objects with the same shape (i.e., the same set of instance variables in the same order) can share a common shape ID. This allows Ruby to bypass polymorphism checks and use a cached index directly for instance variable access.
Ruby now uses the shape ID as a cache key, which drastically reduces the lookup overhead. For example, in Ruby 3.2, accessing an instance variable in polymorphic cases performs just as well as accessing it in stable classes, which wasn’t the case in Ruby 3.1.
Memoization and Its Impact on Object Shapes
While object shapes offer great improvements, they do not solve every performance problem. A common pattern in Ruby is memoization:
def dynamic_property @dynamic_property ||= something_slow_to_compute end
Memoization stores the result of a computation in an instance variable to avoid repeated calculations. However, memoization can conflict with the benefits of object shapes. When new instance variables are added dynamically (e.g., memoized variables), it increases the number of possible shapes an object can have.
For example, with one memoized variable, a class can have 2 possible shapes. With two memoized variables, the number of shapes doubles to 4. This growth follows a factorial pattern: with n memoized variables, there can be 1 + n! possible shapes. This explosion of shapes causes the inline cache hit rate to degrade, leading to slower performance.
Benchmarking Memoization with Object Shapes

To demonstrate the impact of memoization on performance with object shapes, consider the following benchmark:
class UnstableShape
attr_reader :name
def initialize(name)
@name = name
end
def first_name
@first_name ||= @name.split.first
end
def upcased_name
@upcased_name ||= @name.upcase
end
end
# Benchmarking the performance of stable and unstable shapes
require 'benchmark/ips'
Benchmark.ips do |x|
x.report("stable shape") do
stable_1.first_name
stable_1.upcased_name
end
x.report("unstable shape") do
unstable_1.first_name
unstable_1.upcased_name
end
x.compare!(order: :baseline)
end
In this benchmark, we compare the performance of instance variable access in objects with stable and unstable shapes. While stable shapes perform at approximately 15 million iterations per second, objects with unstable shapes (due to memoization) are 16% slower, with a performance of 13.2 million iterations per second.
Conclusion

Ruby 3.2’s introduction of object shapes brings significant improvements to the performance of object attribute access, especially in polymorphic scenarios. However, developers should be cautious when using memoization, as it can lead to a factorial increase in the number of object shapes, negatively affecting performance. By understanding the interplay between memoization and object shapes, developers can make more informed decisions on how to optimize their Ruby applications for both performance and maintainability.
