delegate icon indicating copy to clipboard operation
delegate copied to clipboard

Optimize DelegateClass using `...` delegation

Open byroot opened this issue 1 month ago • 0 comments

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

byroot avatar Nov 15 '25 10:11 byroot