benchmark-swap 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 300c84291632dc4b0f85e22e83dde631f6baffeaee7d61911adbb62ecceaa288
4
+ data.tar.gz: 7c5fd3e887f3af1d60012ae3f62b6be0f04e9f425ebb4e69fdd2cc663ad6f1fd
5
+ SHA512:
6
+ metadata.gz: 12661ba83db0e3e3f861768b2f90cf096c3acd273df8c9099b7870def338d8f0698cc0cb5f9db37dcbe02a0f6052e365b592513c1bba8d19e1e21ee1aed3cd86
7
+ data.tar.gz: 29ee23f65939cf66613bc324c3acb8048c9093b59889d9652208340d2f9acbaad5691865df2ee43474a332cbb13f0cf3b447363c53f88244a327186ca2d33d3c
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - First release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Mehmet Emin INAC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # Benchmark::Swap
2
+
3
+ Compare two implementations of a method with benchmark-ips, without lifting the method out of its call chain into two standalone lambdas. That lift is slow to do and easy to get wrong when the method sits deep inside real code. `Benchmark::Swap` keeps the method where it lives and swaps its body in place.
4
+
5
+ Built for exploring performance changes inside a large Rails app from the Rails console.
6
+
7
+ ## Installation
8
+
9
+ Add this line to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "benchmark-swap"
13
+ ```
14
+
15
+ Then run `bundle install`.
16
+
17
+ Requires Ruby 3.2 or newer. Depends on `benchmark-ips`.
18
+
19
+ ## Usage
20
+
21
+ Keep the original method. Add a second implementation next to it, with the same name plus a `_perf` suffix:
22
+
23
+ ```ruby
24
+ class Pow
25
+ def initialize(number)
26
+ @number = number
27
+ end
28
+
29
+ def pow
30
+ do_pow
31
+ end
32
+
33
+ private
34
+
35
+ def do_pow
36
+ @number**2
37
+ end
38
+
39
+ def do_pow_perf
40
+ @number * @number
41
+ end
42
+ end
43
+
44
+ Benchmark.swap { Pow.new(2).pow }
45
+ ```
46
+
47
+ `Benchmark.swap` is a shortcut for `Benchmark::Swap.test`. Both take the same options.
48
+
49
+ Sample output:
50
+
51
+ ```
52
+ benchmark-swap: swapping 1 method
53
+ Pow#do_pow -> do_pow_perf
54
+
55
+ ruby 3.3.11 (2026-03-26 revision 1f2d15125a) [arm64-darwin24]
56
+ Warming up --------------------------------------
57
+ original 848.389k i/100ms
58
+ Calculating -------------------------------------
59
+ original 8.480M (± 0.3%) i/s (117.93 ns/i) - 8.484M in 1.000511s
60
+ ruby 3.3.11 (2026-03-26 revision 1f2d15125a) [arm64-darwin24]
61
+ Warming up --------------------------------------
62
+ swapped 905.345k i/100ms
63
+ Calculating -------------------------------------
64
+ swapped 9.083M (± 0.2%) i/s (110.09 ns/i) - 9.959M in 1.096372s
65
+
66
+ Comparison:
67
+ original: 8479556.9 i/s
68
+ swapped: 9083408.7 i/s - 1.07x faster
69
+ ```
70
+
71
+ Two "Warming up / Calculating" blocks and one "Comparison:" block are expected. Each side gets its own benchmark-ips run, then the two are compared with the original as the baseline.
72
+
73
+ ## How it works
74
+
75
+ 1. **Discovery.** The block runs once under a Ruby `TracePoint` on the `:call` event. It collects every method that was actually called and has a twin with the suffix defined on the same owner. Only calls from the current thread count. A method whose twin lives on a different class or module (for example the original on a parent class, the twin on the child) is skipped. Frozen owners are skipped too.
76
+ 2. **Verification.** The block runs once per side, and the two results are compared with `==`. A mismatch prints a warning, but the benchmark still runs: this step never raises. If both sides raise the same error class and message, that also counts as a match. Turn it off with `verify: false`.
77
+ 3. **Benchmark.** Each side gets its own benchmark-ips run, then `Benchmark.compare` reports the two with the original as the baseline.
78
+
79
+ All discovered twins are swapped together. There is no one-at-a-time mode in this version.
80
+
81
+ The swap itself copies the twin's `UnboundMethod` body onto the original method name with `define_method`, keeping the original body aside, and puts it back afterwards. It does not delegate through a wrapper method, so both sides run at the same call depth and the numbers describe the method bodies, not the swap. A spec proves this by comparing `caller.size` on both sides. Method visibility (public, protected, private) is preserved and restored. Originals are restored even when the block raises. When a module is prepended to the owner, the swap targets the definition the owner itself holds, so a prepended override that calls `super` keeps working and its own body is left untouched.
82
+
83
+ ## Options
84
+
85
+ | Option | Default | Description |
86
+ | --- | --- | --- |
87
+ | `suffix:` | `"_perf"` | Suffix used to find the twin method |
88
+ | `verify:` | `true` | Compare both sides once before benchmarking |
89
+ | `output:` | `$stdout` | Where the gem's own report lines go |
90
+ | anything else | | Passed to benchmark-ips, for example `time:` and `warmup:` |
91
+
92
+ The call returns a `Runner::Result` struct with `candidates`, `verification`, `original`, and `swapped`. `original` and `swapped` are benchmark-ips `Report` objects. It returns `nil` when no twin was called.
93
+
94
+ ## Caveats
95
+
96
+ - The block runs several times: once for discovery, twice for verification, then many times per benchmark side. Side effects add up, so build fresh objects inside the block instead of reusing a memoised one.
97
+ - If the block raises during discovery, the error propagates.
98
+ - Only methods defined in Ruby are found. The TracePoint `:call` event does not fire for methods implemented in C, so an `attr_reader` original, or a method from a C extension, is skipped even when a twin exists next to it. Write the original in Ruby if you want to measure it.
99
+ - While a side is being measured, the swap is visible to the whole process, not just the calling thread. Discovery only watches the current thread, but the swapped body is what every thread sees. So do not run this on a process that is serving real traffic.
100
+ - Remember to delete the `_perf` method before you commit.
101
+
102
+ ## Development
103
+
104
+ ```
105
+ bundle install
106
+ bundle exec rspec
107
+ bundle exec rubocop
108
+ ruby examples/pow.rb
109
+ ```
110
+
111
+ ## License
112
+
113
+ MIT.
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benchmark
4
+ module Swap
5
+ # A method that has a suffixed twin, plus the owner both are defined on.
6
+ Candidate = Struct.new(:owner, :name, :perf_name) do
7
+ def label
8
+ if owner.singleton_class?
9
+ "#{owner.attached_object}.#{name}"
10
+ else
11
+ "#{owner}##{name}"
12
+ end
13
+ rescue TypeError
14
+ "#{owner}##{name}"
15
+ end
16
+
17
+ def to_s
18
+ "#{label} -> #{perf_name}"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benchmark
4
+ module Swap
5
+ # Runs the block once under a TracePoint and keeps every method call that
6
+ # has a twin with the configured suffix on the same owner.
7
+ class Discovery
8
+ def initialize(suffix:)
9
+ @suffix = suffix
10
+ end
11
+
12
+ def call(&block)
13
+ seen = Set.new
14
+ found = []
15
+ thread = Thread.current
16
+
17
+ trace = TracePoint.new(:call) do |trace_point|
18
+ next unless Thread.current.equal?(thread)
19
+
20
+ owner = trace_point.defined_class
21
+ name = trace_point.method_id
22
+ key = [owner.object_id, name]
23
+
24
+ next unless seen.add?(key)
25
+
26
+ candidate = candidate_for(owner, name)
27
+
28
+ found << candidate if candidate
29
+ end
30
+
31
+ trace.enable(&block)
32
+
33
+ found
34
+ end
35
+
36
+ private
37
+
38
+ def candidate_for(owner, name)
39
+ return unless owner.is_a?(Module)
40
+ return if owner.frozen?
41
+ return if name.nil? || name.to_s.end_with?(@suffix)
42
+
43
+ perf_name = :"#{name}#{@suffix}"
44
+ return unless own_method?(owner, name) && own_method?(owner, perf_name)
45
+
46
+ Candidate.new(owner, name, perf_name)
47
+ end
48
+
49
+ def own_method?(owner, name)
50
+ owner.public_method_defined?(name, false) ||
51
+ owner.private_method_defined?(name, false) ||
52
+ owner.protected_method_defined?(name, false)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benchmark
4
+ module Swap
5
+ # Ties the three steps together:
6
+ # - find the twins
7
+ # - check both sides agree
8
+ # - benchmark each side
9
+ #
10
+ # Each side gets its own benchmark-ips run, because a single run executes
11
+ # every report block after the setup block returns, which leaves no point
12
+ # to turn the swap on between them.
13
+ class Runner
14
+ ORIGINAL_LABEL = "original"
15
+ SWAPPED_LABEL = "swapped"
16
+ PREFIX = "benchmark-swap:"
17
+
18
+ Result = Struct.new(:candidates, :verification, :original, :swapped)
19
+
20
+ def initialize(suffix:, verify:, output:, ips_options:)
21
+ @suffix = suffix
22
+ @verify = verify
23
+ @output = output
24
+ @ips_options = ips_options
25
+ end
26
+
27
+ def call(&block)
28
+ candidates = Discovery.new(suffix: @suffix).call(&block)
29
+
30
+ return nothing_found if candidates.empty?
31
+
32
+ announce(candidates)
33
+
34
+ swapper = Swapper.new(candidates)
35
+ verification = verify(swapper, &block)
36
+ original, swapped = benchmark(swapper, &block)
37
+
38
+ Result.new(candidates, verification, original, swapped)
39
+ end
40
+
41
+ private
42
+
43
+ def nothing_found
44
+ say "#{PREFIX} no *#{@suffix} twin was called by this block, nothing to compare."
45
+
46
+ nil
47
+ end
48
+
49
+ def announce(candidates)
50
+ count = candidates.size
51
+ say "#{PREFIX} swapping #{count} method#{"s" if count > 1}"
52
+ candidates.each { |candidate| say " #{candidate}" }
53
+ say ""
54
+ end
55
+
56
+ def verify(swapper, &block)
57
+ return unless @verify
58
+
59
+ outcome = Verifier.new.call(swapper, &block)
60
+ return outcome if outcome.match?
61
+
62
+ say "#{PREFIX} WARNING both sides returned a different result"
63
+ say " #{ORIGINAL_LABEL}: #{outcome.original}"
64
+ say " #{SWAPPED_LABEL}: #{outcome.swapped}"
65
+ say ""
66
+
67
+ outcome
68
+ end
69
+
70
+ def benchmark(swapper, &block)
71
+ original = ips(ORIGINAL_LABEL, &block)
72
+ swapped = swapper.swapped { ips(SWAPPED_LABEL, &block) }
73
+
74
+ Benchmark.compare(*original.entries, *swapped.entries, order: :baseline)
75
+
76
+ [original, swapped]
77
+ end
78
+
79
+ def ips(label, &block)
80
+ options = @ips_options
81
+
82
+ Benchmark.ips do |job|
83
+ job.config(**options) unless options.empty?
84
+ job.report(label, &block)
85
+ end
86
+ end
87
+
88
+ def say(line)
89
+ @output.puts(line)
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benchmark
4
+ module Swap
5
+ # Copies each twin's body onto the original method name, and puts the
6
+ # original body back afterwards.
7
+ #
8
+ # define_method with an UnboundMethod copies the body instead of
9
+ # delegating to it, so both sides of the benchmark run at the same call
10
+ # depth and the numbers describe the bodies, not the swap.
11
+ class Swapper
12
+ def initialize(candidates)
13
+ @candidates = candidates
14
+ @saved = []
15
+ end
16
+
17
+ def swapped
18
+ enable
19
+ yield
20
+ ensure
21
+ disable
22
+ end
23
+
24
+ def enable
25
+ @candidates.each do |candidate|
26
+ owner = candidate.owner
27
+ original = own_instance_method(owner, candidate.name)
28
+ visibility = visibility_of(owner, candidate.name)
29
+
30
+ define(owner, candidate.name, own_instance_method(owner, candidate.perf_name), visibility)
31
+ @saved << [owner, candidate.name, original, visibility]
32
+ end
33
+ end
34
+
35
+ def disable
36
+ while (entry = @saved.pop)
37
+ define(*entry)
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # A module prepended to the owner shadows the owner's own definition, so
44
+ # Module#instance_method can hand back a body the owner does not own.
45
+ # Saving that body and writing it back on restore would overwrite the
46
+ # real one for good, so walk down to the definition the owner itself
47
+ # holds.
48
+ def own_instance_method(owner, name)
49
+ method = owner.instance_method(name)
50
+ method = method.super_method until method.nil? || method.owner.equal?(owner)
51
+
52
+ method || raise(ArgumentError, "#{owner} does not define #{name}, it only inherits it")
53
+ end
54
+
55
+ def define(owner, name, body, visibility)
56
+ owner.send(:define_method, name, body)
57
+ owner.send(visibility, name)
58
+ end
59
+
60
+ def visibility_of(owner, name)
61
+ if owner.private_method_defined?(name, false)
62
+ :private
63
+ elsif owner.protected_method_defined?(name, false)
64
+ :protected
65
+ else
66
+ :public
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benchmark
4
+ module Swap
5
+ # Runs the block once per side and compares the outcomes. A mismatch is a
6
+ # warning, never an error: the block may return something that does not
7
+ # compare with ==, and that is the caller's call to make.
8
+ class Verifier
9
+ # An inspect of a real object graph can fill the console, and this is
10
+ # only here to show the reader what differed.
11
+ MAX_LENGTH = 200
12
+
13
+ Result = Struct.new(:value, :error) do
14
+ def self.capture
15
+ new(yield, nil)
16
+ rescue StandardError, ScriptError => e
17
+ new(nil, e)
18
+ end
19
+
20
+ def match?(other)
21
+ return value == other.value unless error || other.error
22
+
23
+ other.error.instance_of?(error.class) && other.error.message == error.message
24
+ end
25
+
26
+ def to_s
27
+ return "#{error.class}: #{error.message}" if error
28
+
29
+ text = value.inspect
30
+ text.length > MAX_LENGTH ? "#{text[0, MAX_LENGTH]}..." : text
31
+ end
32
+ end
33
+
34
+ Outcome = Struct.new(:original, :swapped) do
35
+ def match?
36
+ original.match?(swapped)
37
+ end
38
+ end
39
+
40
+ def call(swapper, &block)
41
+ original = Result.capture(&block)
42
+ swapped = swapper.swapped { Result.capture(&block) }
43
+
44
+ Outcome.new(original, swapped)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Benchmark
4
+ module Swap
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark/ips"
4
+
5
+ require_relative "swap/version"
6
+ require_relative "swap/candidate"
7
+ require_relative "swap/discovery"
8
+ require_relative "swap/swapper"
9
+ require_relative "swap/verifier"
10
+ require_relative "swap/runner"
11
+
12
+ module Benchmark
13
+ # Benchmarks a second implementation of a method where it already runs.
14
+ #
15
+ # class Pow
16
+ # def pow
17
+ # do_pow
18
+ # end
19
+ #
20
+ # private
21
+ #
22
+ # def do_pow
23
+ # @number**2
24
+ # end
25
+ #
26
+ # def do_pow_perf
27
+ # @number * @number
28
+ # end
29
+ # end
30
+ #
31
+ # Benchmark.swap { Pow.new(2).pow }
32
+ module Swap
33
+ DEFAULT_SUFFIX = "_perf"
34
+
35
+ class << self
36
+ # @param suffix [String] the twin's name suffix
37
+ # @param verify [Boolean] compare both sides once before benchmarking
38
+ # @param output [IO] where the swap report goes
39
+ # @param ips_options [Hash] passed to benchmark-ips, e.g. time:, warmup:
40
+ # @return [Runner::Result, nil] nil when no twin was called
41
+ def test(suffix: DEFAULT_SUFFIX, verify: true, output: $stdout, **ips_options, &block)
42
+ raise ArgumentError, "a block is required" unless block
43
+
44
+ Runner.new(suffix: suffix, verify: verify, output: output, ips_options: ips_options).call(&block)
45
+ end
46
+ end
47
+ end
48
+
49
+ def self.swap(...)
50
+ Swap.test(...)
51
+ end
52
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: benchmark-swap
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mehmet Emin INAC
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: benchmark-ips
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.13'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.13'
26
+ description: |
27
+ Write your alternative implementation next to the original one, with a
28
+ _perf suffix on the name. Benchmark.swap runs your block twice, once with
29
+ the original methods and once with the suffixed twins in their place, and
30
+ compares the two. No need to lift the method out of its call chain first.
31
+ email:
32
+ - mehmetemininac@gmail.com
33
+ executables: []
34
+ extensions: []
35
+ extra_rdoc_files: []
36
+ files:
37
+ - CHANGELOG.md
38
+ - LICENSE.txt
39
+ - README.md
40
+ - lib/benchmark/swap.rb
41
+ - lib/benchmark/swap/candidate.rb
42
+ - lib/benchmark/swap/discovery.rb
43
+ - lib/benchmark/swap/runner.rb
44
+ - lib/benchmark/swap/swapper.rb
45
+ - lib/benchmark/swap/verifier.rb
46
+ - lib/benchmark/swap/version.rb
47
+ homepage: https://github.com/meinac/benchmark-swap
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/meinac/benchmark-swap
52
+ changelog_uri: https://github.com/meinac/benchmark-swap/blob/master/CHANGELOG.md
53
+ rubygems_mfa_required: 'true'
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '3.2'
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 4.0.10
69
+ specification_version: 4
70
+ summary: Benchmark a second implementation of a method where it already runs.
71
+ test_files: []