Skip to content

Use ... and anonymous & for argument forwarding#18

Merged
wt-l00 merged 2 commits into
masterfrom
fix_allocations
May 25, 2026
Merged

Use ... and anonymous & for argument forwarding#18
wt-l00 merged 2 commits into
masterfrom
fix_allocations

Conversation

@wt-l00

@wt-l00 wt-l00 commented May 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replace *args, **kwargs, &block forwarding with Ruby 3.2+ ... / anonymous *, **, & to avoid the per-call Array / Hash / Proc allocations from repacking arguments.
  • Covers the forwarding paths in Association, Persistence, RelationProxy, CurrentShardTracker, LogSubscriber, and Octoball.using.
  • In RelationProxy#method_missing, the dynamically generated methods inspect the block via if mblock, so the block stays named (&mblock) while * / ** are anonymized.

Motivation

ShardedPersistence and RelationProxy forward through super on hot paths, so the per-call argument repacking adds up. ... lets Ruby pass the original argument objects through without allocating new ones.

Benchmark

case i/s (old → new) speedup memory (old → new) mem reduction
update_columns(name: 'a', age: 1) (kwargs) 4.44 M → 5.93 M 1.34× 400 B → 200 B 2.00×
find(1) { |v| v } (positional + block) 5.15 M → 6.98 M 1.35× 240 B → 40 B 6.00×
update_columns() (no args) 5.23 M → 7.58 M 1.45× 400 B → 200 B 2.00×

bench/forwarding.rb compares the forwarding overhead between the old *args, **kwargs, &block style and the new ...
style. Both are defined in the same process and prepended onto a stub base class.

Environment: Ruby 4.0.5 (PRISM) on arm64-darwin25.

Benchmark script (bench/forwarding.rb)
# frozen_string_literal: true

# Microbenchmark comparing the forwarding overhead between
# `*args, **kwargs, &block` and `...`. Both the old and new
# implementations are defined in the same process, so no git checkout
# is required to compare them.
#
# Run: mise exec ruby@4.0.5 -- ruby bench/forwarding.rb

require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem 'benchmark-ips'
  gem 'benchmark-memory'
end

# --- Base class that Octoball prepends onto -----------------------------------
class Base
  # Equivalent to AR's Persistence#update_columns: takes keyword args.
  def update_columns(*_args, **_kwargs, &_block)
    nil
  end

  # Equivalent to AR's collection methods: receives a block and yields it.
  def find(*args, &block)
    block ? block.call(args.first) : args.first
  end
end

# --- Old implementation: forward via *args, **kwargs, &block ------------------
module ShardedOld
  def update_columns(*args, **kwargs, &block)
    super(*args, **kwargs, &block)
  end

  def find(*args, **kwargs, &block)
    super(*args, **kwargs, &block)
  end
end

class TargetOld < Base
  prepend ShardedOld
end

# --- New implementation: receive with (...) and forward via implicit super ----
module ShardedNew
  def update_columns(...)
    super
  end

  def find(...)
    super
  end
end

class TargetNew < Base
  prepend ShardedNew
end

old = TargetOld.new
new = TargetNew.new
puts "ruby: #{RUBY_DESCRIPTION}"

# --- Keyword argument forwarding ---------------------------------------------
puts "\n=== ips: update_columns(name: 'a', age: 1) ==="
Benchmark.ips do |x|
  x.report('old (*args, **kw, &b)') { old.update_columns(name: 'a', age: 1) }
  x.report('new (...)') { new.update_columns(name: 'a', age: 1) }
  x.compare!
end

puts "\n=== memory: update_columns(name: 'a', age: 1) ==="
Benchmark.memory do |x|
  x.report('old (*args, **kw, &b)') { old.update_columns(name: 'a', age: 1) }
  x.report('new (...)') { new.update_columns(name: 'a', age: 1) }
  x.compare!
end

# --- Call with a block (e.g. AR collection methods) ---------------------------
puts "\n=== ips: find(1) { |v| v } ==="
Benchmark.ips do |x|
  x.report('old (*args, **kw, &b)') { old.find(1) { |v| v } }
  x.report('new (...)') { new.find(1) { |v| v } }
  x.compare!
end

puts "\n=== memory: find(1) { |v| v } ==="
Benchmark.memory do |x|
  x.report('old (*args, **kw, &b)') { old.find(1) { |v| v } }
  x.report('new (...)') { new.find(1) { |v| v } }
  x.compare!
end

# --- No-argument call (e.g. Octoball's reload) --------------------------------
puts "\n=== ips: update_columns() ==="
Benchmark.ips do |x|
  x.report('old (*args, **kw, &b)') { old.update_columns }
  x.report('new (...)') { new.update_columns }
  x.compare!
end

puts "\n=== memory: update_columns() ==="
Benchmark.memory do |x|
  x.report('old (*args, **kw, &b)') { old.update_columns }
  x.report('new (...)') { new.update_columns }
  x.compare!
end
Raw output
ruby: ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [arm64-darwin25]

=== ips: update_columns(name: 'a', age: 1) ===
old (*args, **kw, &b)      4.436M (± 2.6%) i/s  (225.44 ns/i)
            new (...)      5.925M (± 5.5%) i/s  (168.76 ns/i)

Comparison:
            new (...):  5925469.2 i/s
old (*args, **kw, &b):  4435677.6 i/s - 1.34x  slower

=== memory: update_columns(name: 'a', age: 1) ===
old (*args, **kw, &b): 400 memsize, 4 objects
            new (...): 200 memsize, 2 objects
new (...) allocates 2.00x less

=== ips: find(1) { |v| v } ===
old (*args, **kw, &b)      5.150M (± 1.9%) i/s  (194.17 ns/i)
            new (...)      6.977M (± 1.8%) i/s  (143.33 ns/i)
new is 1.35x faster

=== memory: find(1) { |v| v } ===
old (*args, **kw, &b): 240 memsize, 3 objects
            new (...):  40 memsize, 1 object
new (...) allocates 6.00x less

=== ips: update_columns() ===
old (*args, **kw, &b)      5.227M (± 2.5%) i/s  (191.30 ns/i)
            new (...)      7.583M (± 2.6%) i/s  (131.87 ns/i)
new is 1.45x faster

=== memory: update_columns() ===
old (*args, **kw, &b): 400 memsize, 4 objects
            new (...): 200 memsize, 2 objects
new (...) allocates 2.00x less

wt-l00 added 2 commits May 22, 2026 22:57
Replace explicit *args, **kwargs, &block parameters with ... to avoid
the Array / Hash / Proc allocations from repacking arguments on every
forwarded call.
- RelationProxy#method_missing: positional/keyword splats are unused
  in the generated methods, so use anonymous `*, **` (block stays
  named because it is inspected by `if mblock`).
- LogSubscriber#debug and Octoball.using: use anonymous `&` for the
  block.
@wt-l00 wt-l00 force-pushed the fix_allocations branch from 5784833 to d4308d7 Compare May 22, 2026 14:12

@fumihumi fumihumi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍

@wt-l00 wt-l00 merged commit a032df7 into master May 25, 2026
12 checks passed
@wt-l00 wt-l00 deleted the fix_allocations branch May 25, 2026 08:13
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