delegate
delegate copied to clipboard
Optimize DelegateClass using `...` delegation
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 https://github.com/ruby/delegate/pull/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"
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