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.
No data to display