Skip to content

Conversation

@amatsuda
Copy link
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
target.__send__(mid, *args, &block)
end.ruby2_keywords
def Delegator.delegating_block(mid, arity = nil) # :nodoc:
if arity != 0
Copy link
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
Member

eregon commented Dec 30, 2022

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 free to 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