Skip to content

Let DelegateClass respect the original method's arity in case of 0 - #16

Open
amatsuda wants to merge 1 commit into
ruby:masterfrom
amatsuda:DelegateClass_arity
Open

Let DelegateClass respect the original method's arity in case of 0#16
amatsuda wants to merge 1 commit into
ruby:masterfrom
amatsuda:DelegateClass_arity

Conversation

@amatsuda

Copy link
Copy Markdown
Member

Further description is at https://bugs.ruby-lang.org/issues/19287

This defines 37% faster method when it takes no argument.

Benchmark:
Warming up --------------------------------------
old 551.697k i/100ms
new 721.906k i/100ms
Calculating -------------------------------------
old 6.511M (± 0.8%) i/s - 33.102M in 5.084530s
new 8.925M (± 1.0%) i/s - 44.758M in 5.015619s

Comparison:
new: 8924652.2 i/s
old: 6510691.1 i/s - 1.37x (± 0.00) slower

This defines 37% faster method when it takes no argument.
Benchmark:
Warming up --------------------------------------
old 551.697k i/100ms
new 721.906k i/100ms
Calculating -------------------------------------
old 6.511M (± 0.8%) i/s - 33.102M in 5.084530s
new 8.925M (± 1.0%) i/s - 44.758M in 5.015619s
Comparison:
new: 8924652.2 i/s
old: 6510691.1 i/s - 1.37x (± 0.00) slower
Comment threadlib/delegate.rb
target.__send__(mid, *args, &block)
end.ruby2_keywords
def Delegator.delegating_block(mid, arity = nil) # :nodoc:
if arity != 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I was thinking if def m(**h) would also have arity 0 but it does not, that's -1 (makes sense, it's 0+ arguments).

@eregon

Copy link
Copy Markdown
Member

This would break if the target method is later redefined with non-0 arity and called with 1+ arguments.
I'm not sure how much that matters though.

byroot added a commit to byroot/delegate that referenced this pull request Nov 15, 2025
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
```
byroot added a commit to byroot/delegate that referenced this pull request Nov 15, 2025
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
```
byroot added a commit to byroot/delegate that referenced this pull request Nov 18, 2025
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
```
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.

2 participants

@amatsuda@eregon