Feature #22437
openSpeed up creation and basic operations of Struct / Data
Description
Summary¶
Struct and Data are slower to create and to operate on than a hand-written class that does the same job.
Struct / Data are usually chosen because people expect them to be lightweight value objects, yet once they sit on a hot path they turn out to be slow, and the code ends up being rewritten as a plain class. I think the standard value objects should be chosen for their original reasons, such as readability and immutability, rather than avoided for performance.
This ticket proposes to narrow that gap.
Approach¶
The experimental branch below is the result of asking Claude Opus 5.5 to speed things up under the following constraints:
- Do not change any interface
- Do not add any fields
- Do not change how stack traces look
- Do not change any observable behavior
So I do not intend to merge this branch as is. I will review the changes one by one, pick only the ones that are realistic and maintainable, and submit them as small PRs.
Summary of the changes in the experimental branch¶
- Hidden ivar lookups (
__members__etc.) are cached in six file-static slots instruct.c(48 bytes per process), keyed by shape id. Struct#initialize- Reads the member count from self.
- Walks the keyword Hash without rb_ensure.
- Instance creation of Data classes (
Point.new, wherePoint = Data.define(...))- Stores positional and keyword arguments directly.
- Allocates the object already frozen.
Data#with: copies the existing values directly.Data#==,#eql?and Hash key lookup- Skip the recursion guard. A Data's members are fixed at creation, so the guard has nothing to protect.
- Struct keeps the guard, because a member's method can modify and freeze the struct and then re-enter the same computation.
to_h,deconstruct_keys: fill the Hash directly.inspect: reserves the buffer up front and stringifies built-in types directly.
Benchmarks¶
master d71692d49f (2026-10-12) vs. the experimental branch, on x86_64 Linux.
| No JIT | YJIT | ZJIT | |
|---|---|---|---|
| Creation, Struct (geometric mean) | 1.48x | 1.60x | 1.32x |
| Creation, Data (geometric mean) | 3.90x | 4.74x | 4.26x |
| Basic operations (geometric mean) | 2.37x | 3.28x | 2.87x |
"Basic operations" are five frequently used methods: Data#with, Struct#==, Hash lookup with a Struct key, Struct#to_h and Struct#inspect.
Selected cases (no JIT, ns/op; S = Struct.new(:a, :b, :c), D = Data.define(:a, :b, :c)):
D.new(1, 2, 3): 214 → 39S.new(1, 2, 3): 51 → 34Data#with: 334 → 86inspect: 449 → 166- Hash lookup with a Data key: 250 → 85
ruby/ruby-bench (ms; no JIT / YJIT / ZJIT):
- struct-new-no-escape: 72→47 / 36→24 / 17→12
- The same benchmark rewritten with Data: 180→60 / 150→29 / 139→24
Compatibility checks (on the experimental branch)¶
- 47 checks comparing observable behavior against master, run with no JIT / YJIT / ZJIT: all identical. They cover backtraces, TracePoint, overridden methods, re-entry and frozen state.
- All of test/ruby in 4 modes: the same set of failures as master.
- ruby/spec core/struct, data, hash, marshal, plus language/pattern_matching and keyword_arguments in 3 modes: 0 failures.
Links¶
- Experimental branch: https://github.com/riseshia/ruby/compare/d71692d49f...experiment/struct-data-core
- Benchmark and check scripts: https://github.com/riseshia/ruby/tree/experiment/struct-new-observable-probe
Note: the ISEQ initialize idea¶
If incompatibilities were acceptable, another idea is to have Struct.new generate an ISEQ initialize for each class it defines, so that it gets the same optimizations as a plain class's initialize.
I have not tested how far this could go, but a rough PoC showed the following incompatibilities:
- Reflection on initialize changes:
- owner changes from Struct to the generated class.
- arity is no longer
-1, and parameters is no longer[[:rest]]. - source_location is no longer nil.
:initializeappears inprivate_instance_methods(false).
- The TracePoint event changes from
:c_callto:call. - When an exception is raised inside initialize, the backtrace frame changes from
Struct#initializeto the generated method.
Whether these incompatibilities are acceptable, and whether the speedup would be large enough to justify them, is left for further discussion.
Updated by mame (Yusuke Endoh) 24 days ago
Thank you for working on this.
As I understand it, experiment/struct-data-core speeds up Struct/Data without breaking compatibility. It will need careful review, but I have no objection to the direction.
Personally, I'm much more interested in the ISEQ approach described in the Note. For construction, I think it is the better approach: JIT optimizations should apply straightforwardly, and it can avoid creating a Hash for keyword arguments (at least for Data and keyword_init: true Structs; the default Struct, which accepts both positional and keyword arguments, needs some thought).
As far as I can tell, all the listed incompatibilities are minor and acceptable. Of course, we should reconsider if trying it reveals an impact on well-known libraries.
I think the biggest concern with this approach is how to build the ISEQ. Ideally, it would not go through the parser, yet the code that builds the ISEQ would be short and maintainable. I wonder if that's possible.
Updated by byroot (Jean Boussier) 23 days ago
It would be easier to discuss the implementation if you opened a pull request (even as draft).
For instance I have several concerns about hidden_ivar_get:
- Not using atomics (technically tearing isn't a concern for pointer sized values on most platforms but still)
- Pretty sure the logic to pack a shape_id and an attr_index in a u64 already exist elsewhere, it should be refactored.
Updated by shia (Sangyong Sim) 20 days ago
First pr is here. Read the member count from self in Struct#initialize
Updated by Eregon (Benoit Daloze) 19 days ago
· Edited
I think the approach to define an initialize per subclass is the most promising, and where there is the most performance to gain.
TruffleRuby already generates an initialize per Struct subclass (with Ruby code) in some cases:
https://github.com/truffleruby/truffleruby/blob/b60b0d04dd11454012d0e8a6588514410ffacdcb/src/main/ruby/truffleruby/core/struct.rb#L461-L464
One limitation of using Ruby code for this is then the members need to be valid keyword arguments & local variable names.
Generating ISEQ might be able to avoid that, but might be significantly harder to read and maintain.
I have always wanted to do that and that's why during Data's design I argued all methods should be defined on the subclass.
Alas, this was apparently ignored, and so it may cause compatibility issues, because e.g. people define their own initialize in the subclass.
One thing to reduce compatibility issues is to define those methods in a module included in the subclass (but not all).
Updated by Eregon (Benoit Daloze) 19 days ago
And BTW the base Data#initialize needs to be kept because Psych relies on that to create Data instances (https://github.com/ruby/psych/pull/765).
That one would remain rather slow, but is used more rarely so matters less.
Updated by shia (Sangyong Sim) 17 days ago
Eregon, Thanks for your comments. I would like to go that way too if possible. But as you said, generating ISEQs needs a careful look at whether the code stays maintainable, and at other side effects. So for now, I will keep working on the parts that can be optimized simply and without major compatibility problems, and look into the ISEQ approach along the way.
I didn't know TruffleRuby already does this for Struct in some cases. Thanks for the pointer, I will use it as a reference. And noted about keeping the base Data#initialize for Psych.
Updated by shia (Sangyong Sim) 17 days ago
2nd pr is here. Store the members directly in Data.new with positional arguments
Updated by alanwu (Alan Wu) 16 days ago
IMO using an ivar for __members__ was an implementation limitation from back when we didn't have variable width GC allocations. Now that we can have it as a struct field on the T_CLASS directly, caching the read of that ivar seems especially backwards. Now that classes are always initialized thanks to #22376, it's easier than ever to do it properly.
Updated by byroot (Jean Boussier) 16 days ago
Now that we can have it as a struct field on the T_CLASS directly
Storing it after the RClass is a good idea indeed, but then we'd need some mechanism for marking/updating it (not impossible but might be similarly inelegant/backwards?). I suppose we could just store the number of extra VALUE to mark somewhere in RClass.
As for the ivar access, there are other parts of core that rely on hidden ivars, having a way to cache those accesses cleanly would be valuable overall.
Updated by byroot (Jean Boussier) 16 days ago
Now that we can have it as a struct field on the T_CLASS directly
Out of curiosity I took a stab at it: (draft) https://github.com/ruby/ruby/pull/19500
Storing extra VALUE after the RClass is indeed not too complicated, however it needs quite a bit of a refactoring because right now various paths define the struct with rb_define_class_id_under and similar.
Also I realized many of these paths define an empty class, store it as a constant and then only later set the members and other data. The window is pretty small, but I'm pretty sure you could end up in a rb_bug if you were to try to allocate such struct from another ractor before it has been fully initialized.
Which reinforce the idea that all these paths should be refactored to fully create and initialized the T_CLASS and later register it as a new constant instead of using rb_define_class_id_under and friends.
Updated by shia (Sangyong Sim) 11 days ago
3rd PR is here. Track the given members of Data#initialize without copying them
Before digging into where to store the members, I'm going to look into the ISEQ approach first.
Updated by czj (Clément Joubert) 3 days ago
Thank you for this work! Here's a data point from a real library, as Austin Ziegler (diff-lcs maintainer) suggested. diff-lcs 2.0 made its change events Data classes with a validating initialize that calls super, the pattern the Data docs show:
Plain = Data.define(:action, :position, :element)
Validated = Data.define(:action, :position, :element) do
def initialize(action:, position:, element:)
raise ArgumentError unless %w[= - + !].include?(action)
raise ArgumentError unless position.is_a?(Integer)
super
end
end
Ruby 4.0.9 vs master a2ce41e083 (with #19417, #19481 and #19581), arm64 macOS, YJIT, ns per new("=", 1, "a"), best of 5 × 1M, next to a hand-written frozen class with the same API:
| 4.0.9 | master | frozen class | |
|---|---|---|---|
Plain.new |
243 ns, 3 objects | 65 ns, 1 object | 50 ns, 1 object |
Validated.new |
326 ns, 4 objects | 289 ns, 4 objects | 56 ns, 1 object |
Without JIT: Plain.new 223 → 82 ns, Validated.new 347 → 353 ns, versus 77 and 125 ns for the classes.
So plain Data is now almost as cheap as a class, which is great, but as soon as initialize is overridden nothing changes yet: the positional arguments still become a keyword Hash for the custom initialize, and super passes it on again. In diff-lcs this is the whole difference: sdiff over 700 revisions of rails/rails' activerecord/lib takes 104 ms on 4.0.9 and 100 ms on master with Data, and 29 ms on master with frozen classes.
That seems like the case where a per-class initialize, or avoiding the keyword Hash as mame suggested, would matter most, since overriding initialize is the documented way to validate or set defaults. Would it make sense to keep this case in mind for the ISEQ approach? I'm happy to rerun the numbers on any branch.
Updated by byroot (Jean Boussier) 3 days ago
Custom Data#initialize is very hard to make fast because of its design. See [Bug #19278].
There might be some small possible improvements, but as long as arguments have to be trasformed it will always remain costly.