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) 1 day 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) 1 day 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.