Skip to content

Optimize DelegateClass using ... delegation - #46

Merged
hsbt merged 1 commit into
ruby:masterfrom
byroot:use-forward-send
Dec 17, 2025
Merged

Optimize DelegateClass using ... delegation#46
hsbt merged 1 commit into
ruby:masterfrom
byroot:use-forward-send

Conversation

@byroot

Copy link
Copy Markdown
Member

By generating source code for methods that use ... delegation when possible, we can lower the overhead of delegation by half.

This could be lowered further by copying the delegated method signature, like in #16, but this would assume the delegated method signature never change, so I'm not sure if that's OK.

Then most of the remaining overhead is in calling __getobj__, but that's part of the spec, so can't be eliminated.

Results:

== no arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
baseline 3.838M i/100ms
handrolled 3.465M i/100ms
DelegateClass 979.160k i/100ms
Opt 2.028M i/100ms
Calculating -------------------------------------
baseline 64.296M (± 0.5%) i/s (15.55 ns/i) - 322.355M in 5.013724s
handrolled 57.058M (± 0.4%) i/s (17.53 ns/i) - 287.567M in 5.039966s
DelegateClass 12.118M (± 0.5%) i/s (82.52 ns/i) - 60.708M in 5.009812s
Opt 27.764M (± 0.5%) i/s (36.02 ns/i) - 139.925M in 5.039997s
Comparison:
baseline: 64296345.8 i/s
handrolled: 57058063.8 i/s - 1.13x slower
Opt: 27763713.5 i/s - 2.32x slower
DelegateClass: 12118085.0 i/s - 5.31x slower
== many arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
baseline 3.605M i/100ms
handrolled 3.275M i/100ms
DelegateClass 623.030k i/100ms
Opt 1.348M i/100ms
Calculating -------------------------------------
baseline 63.349M (± 1.6%) i/s (15.79 ns/i) - 317.272M in 5.009667s
handrolled 56.277M (± 0.2%) i/s (17.77 ns/i) - 281.623M in 5.004270s
DelegateClass 7.079M (± 4.1%) i/s (141.26 ns/i) - 35.513M in 5.026286s
Opt 17.953M (± 0.1%) i/s (55.70 ns/i) - 90.345M in 5.032248s
Comparison:
baseline: 63348844.0 i/s
handrolled: 56276912.5 i/s - 1.13x slower
Opt: 17953308.5 i/s - 3.53x slower
DelegateClass: 7079118.4 i/s - 8.95x slower

Benchmark:

require"delegate"require"bundler/inline"gemfiledogem"benchmark-ips"endclassUserattr_accessor:namedefinitialize(name)@name=nameenddefdo_something(a,b,c,d: 1):somethingendendclassHandrolledDelegatordefinitialize(user)@user=userenddefname@user.nameenddefdo_something(a,b,c,d: 1)@user.do_something(a,b,c,d: 1)endendStdlibDelegator=DelegateClass(User)defOptDelegateClass(superclass, &block)klass=Class.new(Delegator)ignores=[*::Delegator.public_api,:to_s,:inspect,:=~,:!~,:===]protected_instance_methods=superclass.protected_instance_methodsprotected_instance_methods -= ignorespublic_instance_methods=superclass.public_instance_methodspublic_instance_methods -= ignoresinstance_methods=(public_instance_methods + protected_instance_methods)normal,special=instance_methods.partition{ |m| m.match?(/\A[a-zA-Z]\w*\z/)}source=normal.mapdo |method|
"def #{method}(...); __getobj__.#{method}(...); end"endklass.module_evaldodef__getobj__# :nodoc:unlessdefined?(@delegate_dc_obj)returnyieldifblock_given?__raise__ ::ArgumentError,"not delegated"end@delegate_dc_objenddef__setobj__(obj)# :nodoc:__raise__ ::ArgumentError,"cannot delegate to self"ifself.equal?(obj)@delegate_dc_obj=objendclass_eval(source.join(";"),__FILE__,__LINE__)special.eachdo |method|
define_method(method,Delegator.delegating_block(method))endprotected(*protected_instance_methods)endklass.define_singleton_method:public_instance_methodsdo |all=true|
super(all) | superclass.public_instance_methodsendklass.define_singleton_method:protected_instance_methodsdo |all=true|
super(all) | superclass.protected_instance_methodsendklass.define_singleton_method:instance_methodsdo |all=true|
super(all) | superclass.instance_methodsendklass.define_singleton_method:public_instance_methoddo |name|
super(name)rescueNameErrorraiseunlessself.public_instance_methods.include?(name)superclass.public_instance_method(name)endklass.define_singleton_method:instance_methoddo |name|
super(name)rescueNameErrorraiseunlessself.instance_methods.include?(name)superclass.instance_method(name)endklass.module_eval(&block)ifblockreturnklassendOptStdlibDelegator=OptDelegateClass(User)direct=User.new("George")handrolled=HandrolledDelegator.new(direct)stdlib=StdlibDelegator.new(direct)opt_stdlib=OptStdlibDelegator.new(direct)puts"== no arguments =="Benchmark.ipsdo |x|
x.report("baseline"){direct.name}x.report("handrolled"){handrolled.name}x.report("DelegateClass"){stdlib.name}x.report("Opt"){opt_stdlib.name}x.compare!(order: :baseline)endputs"== many arguments =="Benchmark.ipsdo |x|
x.report("baseline"){direct.do_something(1,2,3,d: 4)}x.report("handrolled"){handrolled.do_something(1,2,3,d: 4)}x.report("DelegateClass"){stdlib.do_something(1,2,3,d: 4)}x.report("Opt"){opt_stdlib.do_something(1,2,3,d: 4)}x.compare!(order: :baseline)end

@byroot
byroot requested a review from hsbtNovember 15, 2025 10:15
byroot added a commit to byroot/rails that referenced this pull request Nov 15, 2025
Fix: rails#56167
I opened ruby/delegate#46 upstream to improve
`ClassDelegator` but it has a bunch of backward compatibility concerns
that limits how much it can be optimized.
For internal use cases, we can define our own private class delegator
and optimize it much further.
```
== no arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
DelegateClass 1.014M i/100ms
Opt 3.273M i/100ms
Calculating -------------------------------------
DelegateClass 12.322M (± 0.8%) i/s (81.15 ns/i) - 61.838M in 5.018661s
Opt 56.866M (± 0.4%) i/s (17.59 ns/i) - 284.716M in 5.006894s
Comparison:
DelegateClass: 12322457.6 i/s
Opt: 56865721.7 i/s - 4.61x faster
== many arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
DelegateClass 555.419k i/100ms
Opt 3.214M i/100ms
Calculating -------------------------------------
DelegateClass 6.256M (± 0.6%) i/s (159.85 ns/i) - 31.659M in 5.060874s
Opt 53.902M (± 0.0%) i/s (18.55 ns/i) - 269.936M in 5.007857s
Comparison:
DelegateClass: 6255815.2 i/s
Opt: 53902461.2 i/s - 8.62x faster
== optional arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
DelegateClass 826.558k i/100ms
Opt 1.645M i/100ms
Calculating -------------------------------------
DelegateClass 9.821M (± 0.8%) i/s (101.82 ns/i) - 49.593M in 5.049895s
Opt 21.177M (± 0.8%) i/s (47.22 ns/i) - 106.952M in 5.050788s
Comparison:
DelegateClass: 9821329.7 i/s
Opt: 21176752.0 i/s - 2.16x faster
```
Benchmark:
```ruby
require "delegate"
require "bundler/inline"
gemfile do
gem "benchmark-ips"
gem "rails", path: "."
end
class User
attr_accessor :name
def initialize(name)
@name = name
end
def do_something(a, b, c, d:)
:something
end
def opt_args(a=1, b: 2)
:something
end
end
StdlibDelegator = DelegateClass(User)
ASDelegator = ActiveSupport::Delegation::DelegateClass(User)
direct = User.new("George")
stdlib = StdlibDelegator.new(direct)
opt = ASDelegator.new(direct)
puts "== no arguments =="
Benchmark.ips do |x|
x.report("DelegateClass") { stdlib.name }
x.report("Opt") { opt.name }
x.compare!(order: :baseline)
end
puts "== many arguments =="
Benchmark.ips do |x|
x.report("DelegateClass") { stdlib.do_something(1, 2, 3, d: 4) }
x.report("Opt") { opt.do_something(1, 2, 3, d: 4) }
x.compare!(order: :baseline)
end
puts "== optional arguments =="
Benchmark.ips do |x|
x.report("DelegateClass") { stdlib.opt_args }
x.report("Opt") { opt.opt_args }
x.compare!(order: :baseline)
end
```
byroot added a commit to byroot/rails that referenced this pull request Nov 15, 2025
Fix: rails#56167
I opened ruby/delegate#46 upstream to improve
`ClassDelegator` but it has a bunch of backward compatibility concerns
that limits how much it can be optimized.
For internal use cases, we can define our own private class delegator
and optimize it much further.
```
== no arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
DelegateClass 1.014M i/100ms
Opt 3.273M i/100ms
Calculating -------------------------------------
DelegateClass 12.322M (± 0.8%) i/s (81.15 ns/i) - 61.838M in 5.018661s
Opt 56.866M (± 0.4%) i/s (17.59 ns/i) - 284.716M in 5.006894s
Comparison:
DelegateClass: 12322457.6 i/s
Opt: 56865721.7 i/s - 4.61x faster
== many arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
DelegateClass 555.419k i/100ms
Opt 3.214M i/100ms
Calculating -------------------------------------
DelegateClass 6.256M (± 0.6%) i/s (159.85 ns/i) - 31.659M in 5.060874s
Opt 53.902M (± 0.0%) i/s (18.55 ns/i) - 269.936M in 5.007857s
Comparison:
DelegateClass: 6255815.2 i/s
Opt: 53902461.2 i/s - 8.62x faster
== optional arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
DelegateClass 826.558k i/100ms
Opt 1.645M i/100ms
Calculating -------------------------------------
DelegateClass 9.821M (± 0.8%) i/s (101.82 ns/i) - 49.593M in 5.049895s
Opt 21.177M (± 0.8%) i/s (47.22 ns/i) - 106.952M in 5.050788s
Comparison:
DelegateClass: 9821329.7 i/s
Opt: 21176752.0 i/s - 2.16x faster
```
Benchmark:
```ruby
require "delegate"
require "bundler/inline"
gemfile do
gem "benchmark-ips"
gem "rails", path: "."
end
class User
attr_accessor :name
def initialize(name)
@name = name
end
def do_something(a, b, c, d:)
:something
end
def opt_args(a=1, b: 2)
:something
end
end
StdlibDelegator = DelegateClass(User)
ASDelegator = ActiveSupport::Delegation::DelegateClass(User)
direct = User.new("George")
stdlib = StdlibDelegator.new(direct)
opt = ASDelegator.new(direct)
puts "== no arguments =="
Benchmark.ips do |x|
x.report("DelegateClass") { stdlib.name }
x.report("Opt") { opt.name }
x.compare!(order: :baseline)
end
puts "== many arguments =="
Benchmark.ips do |x|
x.report("DelegateClass") { stdlib.do_something(1, 2, 3, d: 4) }
x.report("Opt") { opt.do_something(1, 2, 3, d: 4) }
x.compare!(order: :baseline)
end
puts "== optional arguments =="
Benchmark.ips do |x|
x.report("DelegateClass") { stdlib.opt_args }
x.report("Opt") { opt.opt_args }
x.compare!(order: :baseline)
end
```
Comment threadlib/delegate.rb Outdated
public_instance_methods -= ignores

instance_methods = (public_instance_methods + protected_instance_methods)
normal, special = instance_methods.partition { |m| m.match?(/\A[a-zA-Z]\w*!?\z/) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
normal,special=instance_methods.partition{ |m| m.match?(/\A[a-zA-Z]\w*!?\z/)}
normal,special=instance_methods.partition{ |m| m.match?(/\A[a-zA-Z]\w*[!?]?\z/)}

Il also saw that in the rails patch you used \w+ rather than \w*, IDK if there is a good reason for that

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in the rails patch you used \w+ rather than \w*, IDK if there is a good reason for that

It's a mistake, \w* is correct.

By generating source code for methods that use `...` delegation when possible,
we can lower the overhead of delegation by half.
This could be lowered further by copying the delegated method signature,
like in ruby#16, but this would assume
the delegated method signature never change, so I'm not sure if that's OK.
Then most of the remaining overhead is in calling `__getobj__`, but that's
part of the spec, so can't be eliminated.
Results:
```
== no arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
baseline 3.838M i/100ms
handrolled 3.465M i/100ms
DelegateClass 979.160k i/100ms
Opt 2.028M i/100ms
Calculating -------------------------------------
baseline 64.296M (± 0.5%) i/s (15.55 ns/i) - 322.355M in 5.013724s
handrolled 57.058M (± 0.4%) i/s (17.53 ns/i) - 287.567M in 5.039966s
DelegateClass 12.118M (± 0.5%) i/s (82.52 ns/i) - 60.708M in 5.009812s
Opt 27.764M (± 0.5%) i/s (36.02 ns/i) - 139.925M in 5.039997s
Comparison:
baseline: 64296345.8 i/s
handrolled: 57058063.8 i/s - 1.13x slower
Opt: 27763713.5 i/s - 2.32x slower
DelegateClass: 12118085.0 i/s - 5.31x slower
== many arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
baseline 3.605M i/100ms
handrolled 3.275M i/100ms
DelegateClass 623.030k i/100ms
Opt 1.348M i/100ms
Calculating -------------------------------------
baseline 63.349M (± 1.6%) i/s (15.79 ns/i) - 317.272M in 5.009667s
handrolled 56.277M (± 0.2%) i/s (17.77 ns/i) - 281.623M in 5.004270s
DelegateClass 7.079M (± 4.1%) i/s (141.26 ns/i) - 35.513M in 5.026286s
Opt 17.953M (± 0.1%) i/s (55.70 ns/i) - 90.345M in 5.032248s
Comparison:
baseline: 63348844.0 i/s
handrolled: 56276912.5 i/s - 1.13x slower
Opt: 17953308.5 i/s - 3.53x slower
DelegateClass: 7079118.4 i/s - 8.95x slower
```
Benchmark:
```ruby
require "delegate"
require "bundler/inline"
gemfile do
gem "benchmark-ips"
end
class User
attr_accessor :name
def initialize(name)
@name = name
end
def do_something(a, b, c, d: 1)
:something
end
end
class HandrolledDelegator
def initialize(user)
@user = user
end
def name
@user.name
end
def do_something(a, b, c, d: 1)
@user.do_something(a, b, c, d: 1)
end
end
StdlibDelegator = DelegateClass(User)
def OptDelegateClass(superclass, &block)
klass = Class.new(Delegator)
ignores = [*::Delegator.public_api, :to_s, :inspect, :=~, :!~, :===]
protected_instance_methods = superclass.protected_instance_methods
protected_instance_methods -= ignores
public_instance_methods = superclass.public_instance_methods
public_instance_methods -= ignores
instance_methods = (public_instance_methods + protected_instance_methods)
normal, special = instance_methods.partition { |m| m.match?(/\A[a-zA-Z]\w*\z/) }
source = normal.map do |method|
"def #{method}(...); __getobj__.#{method}(...); end"
end
klass.module_eval do
def __getobj__ # :nodoc:
unless defined?(@delegate_dc_obj)
return yield if block_given?
__raise__ ::ArgumentError, "not delegated"
end
@delegate_dc_obj
end
def __setobj__(obj) # :nodoc:
__raise__ ::ArgumentError, "cannot delegate to self" if self.equal?(obj)
@delegate_dc_obj = obj
end
class_eval(source.join(";"), __FILE__, __LINE__)
special.each do |method|
define_method(method, Delegator.delegating_block(method))
end
protected(*protected_instance_methods)
end
klass.define_singleton_method :public_instance_methods do |all=true|
super(all) | superclass.public_instance_methods
end
klass.define_singleton_method :protected_instance_methods do |all=true|
super(all) | superclass.protected_instance_methods
end
klass.define_singleton_method :instance_methods do |all=true|
super(all) | superclass.instance_methods
end
klass.define_singleton_method :public_instance_method do |name|
super(name)
rescue NameError
raise unless self.public_instance_methods.include?(name)
superclass.public_instance_method(name)
end
klass.define_singleton_method :instance_method do |name|
super(name)
rescue NameError
raise unless self.instance_methods.include?(name)
superclass.instance_method(name)
end
klass.module_eval(&block) if block
return klass
end
OptStdlibDelegator = OptDelegateClass(User)
direct = User.new("George")
handrolled = HandrolledDelegator.new(direct)
stdlib = StdlibDelegator.new(direct)
opt_stdlib = OptStdlibDelegator.new(direct)
puts "== no arguments =="
Benchmark.ips do |x|
x.report("baseline") { direct.name }
x.report("handrolled") { handrolled.name }
x.report("DelegateClass") { stdlib.name }
x.report("Opt") { opt_stdlib.name }
x.compare!(order: :baseline)
end
puts "== many arguments =="
Benchmark.ips do |x|
x.report("baseline") { direct.do_something(1, 2, 3, d: 4) }
x.report("handrolled") { handrolled.do_something(1, 2, 3, d: 4) }
x.report("DelegateClass") { stdlib.do_something(1, 2, 3, d: 4) }
x.report("Opt") { opt_stdlib.do_something(1, 2, 3, d: 4) }
x.compare!(order: :baseline)
end
```
@hsbt
hsbt merged commit 2420b69 into ruby:masterDec 17, 2025
13 checks passed
hsbt added a commit that referenced this pull request Dec 17, 2025
hsbt added a commit that referenced this pull request Dec 17, 2025
hsbt added a commit that referenced this pull request Dec 17, 2025
This reverts commit fc2bd04.
Co-authored-by: Jean Boussier <byroot@ruby-lang.org>
byroot added a commit that referenced this pull request Dec 17, 2025
This reverts commit fc2bd04.
Co-authored-by: Jean Boussier <byroot@ruby-lang.org>
byroot added a commit that referenced this pull request Dec 17, 2025
This reverts commit fc2bd04.
Co-authored-by: Jean Boussier <byroot@ruby-lang.org>
hsbt added a commit that referenced this pull request Dec 18, 2025
Reapply "Merge pull request #46 from byroot/use-forward-send"
hsbt added a commit to luke-gruber/delegate that referenced this pull request Dec 18, 2025
This reverts commit fc2bd04.
Co-authored-by: Jean Boussier <byroot@ruby-lang.org>
drymar pushed a commit to drymar/rails that referenced this pull request Jan 5, 2026
Fix: rails#56167
I opened ruby/delegate#46 upstream to improve
`ClassDelegator` but it has a bunch of backward compatibility concerns
that limits how much it can be optimized.
For internal use cases, we can define our own private class delegator
and optimize it much further.
```
== no arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
DelegateClass 1.014M i/100ms
Opt 3.273M i/100ms
Calculating -------------------------------------
DelegateClass 12.322M (± 0.8%) i/s (81.15 ns/i) - 61.838M in 5.018661s
Opt 56.866M (± 0.4%) i/s (17.59 ns/i) - 284.716M in 5.006894s
Comparison:
DelegateClass: 12322457.6 i/s
Opt: 56865721.7 i/s - 4.61x faster
== many arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
DelegateClass 555.419k i/100ms
Opt 3.214M i/100ms
Calculating -------------------------------------
DelegateClass 6.256M (± 0.6%) i/s (159.85 ns/i) - 31.659M in 5.060874s
Opt 53.902M (± 0.0%) i/s (18.55 ns/i) - 269.936M in 5.007857s
Comparison:
DelegateClass: 6255815.2 i/s
Opt: 53902461.2 i/s - 8.62x faster
== optional arguments ==
ruby 3.4.6 (2025-09-16 revision dbd83256b1) +YJIT +PRISM [arm64-darwin24]
Warming up --------------------------------------
DelegateClass 826.558k i/100ms
Opt 1.645M i/100ms
Calculating -------------------------------------
DelegateClass 9.821M (± 0.8%) i/s (101.82 ns/i) - 49.593M in 5.049895s
Opt 21.177M (± 0.8%) i/s (47.22 ns/i) - 106.952M in 5.050788s
Comparison:
DelegateClass: 9821329.7 i/s
Opt: 21176752.0 i/s - 2.16x faster
```
Benchmark:
```ruby
require "delegate"
require "bundler/inline"
gemfile do
gem "benchmark-ips"
gem "rails", path: "."
end
class User
attr_accessor :name
def initialize(name)
@name = name
end
def do_something(a, b, c, d:)
:something
end
def opt_args(a=1, b: 2)
:something
end
end
StdlibDelegator = DelegateClass(User)
ASDelegator = ActiveSupport::Delegation::DelegateClass(User)
direct = User.new("George")
stdlib = StdlibDelegator.new(direct)
opt = ASDelegator.new(direct)
puts "== no arguments =="
Benchmark.ips do |x|
x.report("DelegateClass") { stdlib.name }
x.report("Opt") { opt.name }
x.compare!(order: :baseline)
end
puts "== many arguments =="
Benchmark.ips do |x|
x.report("DelegateClass") { stdlib.do_something(1, 2, 3, d: 4) }
x.report("Opt") { opt.do_something(1, 2, 3, d: 4) }
x.compare!(order: :baseline)
end
puts "== optional arguments =="
Benchmark.ips do |x|
x.report("DelegateClass") { stdlib.opt_args }
x.report("Opt") { opt.opt_args }
x.compare!(order: :baseline)
end
```
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@byroot@hsbt@BuonOmo