Ruby's embedded TypedData removes a separate payload allocation
A June implementation report explains how Ruby embeds native payloads with their objects. The allocation savings depend on ownership and compaction rules that make the mechanism an explicit opt-in.
A Ruby object can have two allocations even when it appears to be one value. The interpreter allocates the Ruby wrapper, while a separate system allocation holds a native payload. Embedded TypedData combines those pieces for suitable types, reducing allocation work while changing the lifetime assumptions native code can make about the payload's address.
Peter Zhu's June 3 implementation report describes work he and Jean Boussier introduced with Variable Width Allocation in Ruby 3.3. The article is a report on an existing implementation and its adoption. It is not an announcement that all native-backed objects suddenly changed representation in June.
I appreciate Ruby's consistent object model because its values generally expose ordinary methods regardless of their internal representation. That uniformity still depends on several storage strategies inside the interpreter. TypedData makes the difference explicit for runtime and extension developers while keeping ordinary Ruby code independent of the payload layout.
The wrapper describes how to manage native data
TypedData lets a Ruby object refer to an arbitrary piece of native data. Zhu's TypedData guide describes the configuration connecting that data to Ruby's garbage collector. The object can behave like an ordinary class instance while its implementation works with native fields instead of looking up every value through instance variables.
The configuration includes a type name and callbacks. A mark callback identifies Ruby objects reachable through the native payload. A free callback handles resource release when the wrapper is reclaimed. Flags describe supported behavior. These responsibilities exist because an arbitrary native structure cannot be interpreted correctly by the collector without information from its author.
The traditional representation stores a pointer to the external payload. That pointer allows the wrapper to reach the data but does not make the data part of the wrapper's own allocation. Allocating the wrapper and allocating its payload remain separate operations, with separate bookkeeping and access costs.
The native payload can contain data that are not themselves Ruby objects. That is part of TypedData's purpose. Its mark callback therefore needs to distinguish Ruby references requiring collector attention from ordinary native fields that have no Ruby object identity.
Ruby's own Time, Mutex, and Enumerator are examples of TypedData-backed objects named in the report. Native gems also use the mechanism. The representation is therefore relevant to ordinary programs that never define a C extension themselves, because frequently used runtime objects can rely on it.
Embedding changes the allocation boundary
The implementation pull request places the payload immediately after the object rather than allocating it separately. Variable-width object allocation provides room for the combined representation. A suitable object then needs one Ruby allocation instead of a Ruby allocation plus a system allocation.
Removing the separate allocation avoids its allocation and release work. It also removes the need to follow the external data pointer when reaching the payload. The report identifies improved locality and the elimination of that pointer as additional benefits. These effects concern the representation of eligible objects, not a general promise that Ruby eliminates native allocation everywhere.
The original pull request gives Time as a concrete example. It reports reducing the combined size from 86 bytes to 80 bytes in that implementation. This is a measured layout comparison for a particular conversion. It should not be interpreted as the exact size of every TypedData object or every platform's native structures.
The June report says more than 30 commonly used types have adopted embedding, including methods and trace points. That breadth explains why an internal allocation change can matter beyond a single niche API. It also reinforces that conversion is performed type by type, with implementation work appropriate to each payload.
Compaction makes saved payload pointers dangerous
An embedded payload moves with its Ruby object during compaction. A pointer saved elsewhere can therefore continue referring to the old location after the collector relocates the object. This differs from a wrapper pointing to a separate allocation whose address does not change merely because the wrapper moves.
The report requires that embedded data not be shared among several TypedData objects. Shared ownership would conflict with treating the payload as storage belonging to one movable wrapper. A conversion must also examine native code that retains addresses into the payload or passes them to other C APIs.
The pull-request discussion raises exactly that concern. Boussier asks about external C APIs receiving pointers to members of a structure that might move. Zhu describes the opt-in assumption and the need to avoid storing such pointers, along with the role of keeping the owning Ruby object protected where required.
These are correctness conditions, not optional tuning details. Reducing an allocation is useful only if native access still reaches live storage. A type whose external interface requires a stable independent address may not be eligible for the same representation without more substantial changes.
The flag records a representation commitment
Eligible implementations opt in through RUBY_TYPED_EMBEDDABLE. The flag does not establish safety by itself. It records that the type's implementation has been adapted to the embedded representation and its movement constraints. Applying it indiscriminately to arbitrary native structures would ignore the reason the mechanism is selective.
The June article also describes opening the facility to third-party native extensions as future work. Existing use by Ruby's own types does not mean every extension can assume the same supported adoption path at this date. The runtime's internal conversions and the public extension interface have different rollout requirements.
I find this a useful example of why native-extension performance work needs an ownership model. A smaller layout can change which addresses remain valid and who releases the data. The improvement is therefore inseparable from the rules that let the garbage collector manage the combined object correctly.
The report links allocation benchmarks for individual conversions, including Time.now and method creation. Those measurements describe the affected operations, not an application-wide speed increase. The general mechanism is concrete without extrapolating those numbers. Suitable objects avoid a separate payload allocation, while their native implementations accept that the payload belongs to a movable Ruby object.