Commit 5c8ab6c
committed
Optimize DelegateClass using
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
```... delegation1 parent c8beb7f commit 5c8ab6c
1 file changed
+16
-5
lines changed| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
399 | 399 | | |
400 | 400 | | |
401 | 401 | | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
402 | 410 | | |
403 | 411 | | |
404 | 412 | | |
| |||
407 | 415 | | |
408 | 416 | | |
409 | 417 | | |
| 418 | + | |
410 | 419 | | |
411 | 420 | | |
412 | 421 | | |
413 | 422 | | |
414 | | - | |
415 | | - | |
416 | | - | |
417 | | - | |
418 | | - | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
419 | 427 | | |
420 | 428 | | |
| 429 | + | |
| 430 | + | |
421 | 431 | | |
| 432 | + | |
422 | 433 | | |
423 | 434 | | |
424 | 435 | | |
| |||
0 commit comments