From: "shia (Sangyong Sim) via ruby-core" Date: 2026-10-14T13:05:14+00:00 Subject: [ruby-core:127102] [Ruby Feature#22437] Speed up creation and basic operations of Struct / Data Issue #22437 has been reported by shia (Sangyong Sim). ---------------------------------------- Feature #22437: Speed up creation and basic operations of Struct / Data https://bugs.ruby-lang.org/issues/22437 * Author: shia (Sangyong Sim) * Status: Open ---------------------------------------- ## 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 in `struct.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`, where `Point = 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 ��� 39 - `S.new(1, 2, 3)`: 51 ��� 34 - `Data#with`: 334 ��� 86 - `inspect`: 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. - `:initialize` appears in `private_instance_methods(false)`. - The TracePoint event changes from `:c_call` to `:call`. - When an exception is raised inside initialize, the backtrace frame changes from `Struct#initialize` to the generated method. Whether these incompatibilities are acceptable, and whether the speedup would be large enough to justify them, is left for further discussion. -- https://bugs.ruby-lang.org/ ______________________________________________ ruby-core mailing list -- ruby-core@ml.ruby-lang.org To unsubscribe send an email to ruby-core-leave@ml.ruby-lang.org ruby-core info -- https://ml.ruby-lang.org/mailman3/lists/ruby-core.ml.ruby-lang.org/