Project

General

Profile

Actions

Feature #22437

open

Speed up creation and basic operations of Struct / Data

Feature #22437: Speed up creation and basic operations of Struct / Data

Added by shia (Sangyong Sim) about 10 hours ago.

Status:
Open
Assignee:
-
Target version:
-
[ruby-core:127102]

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 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

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.

No data to display

Actions

Also available in: PDF Atom