From b2293d4e50069b0e4c4776fa7dacfc405427e1a1 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sat, 15 Nov 2025 10:11:35 +0000 Subject: [PATCH] Optimize DelegateClass using `...` delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ```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 ``` --- lib/delegate.rb | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/delegate.rb b/lib/delegate.rb index 838de67..bf8925d 100644 --- a/lib/delegate.rb +++ b/lib/delegate.rb @@ -399,6 +399,14 @@ def DelegateClass(superclass, &block) 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) @@ -407,18 +415,21 @@ def __getobj__ # :nodoc: end @delegate_dc_obj end + def __setobj__(obj) # :nodoc: __raise__ ::ArgumentError, "cannot delegate to self" if self.equal?(obj) @delegate_dc_obj = obj end - protected_instance_methods.each do |method| - define_method(method, Delegator.delegating_block(method)) - protected method - end - public_instance_methods.each do |method| + + 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