From: czj via ruby-core Date: 2026-11-04T22:11:51+00:00 Subject: [ruby-core:127335] [Ruby Feature#22437] Speed up creation and basic operations of Struct / Data Issue #22437 has been updated by czj (Cl��ment Joubert). 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: ```ruby 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. ---------------------------------------- Feature #22437: Speed up creation and basic operations of Struct / Data https://bugs.ruby-lang.org/issues/22437#change-119767 * 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/