duel-retained 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: 731c988c83f260d7d2b1f72f8bcaea04a3c2353d95eef6018acbf5107b6bc8f6
4
+ data.tar.gz: 0e482b0d4bbff58e2c459807da6378ce62e5874d38d6db7ce7e943ff2139448e
5
+ SHA512:
6
+ metadata.gz: 4de0db7edeaadcb3bc5848249b87e54f7746c82be8153eb84ebdb1022c0bac4af822b6d638756e930a376444d9aafdcba68e097f887e85f7f10a1243ba6ef943
7
+ data.tar.gz: 18f36afd00357c1d3250f5fd1dabc40efd8cba87aa6591c9c455d958ccd2b3b47ff01c64da61b369c1c8d6fb9c0f3631bffbc546da09893e17971c119202d574
data/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/2.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-10-24
11
+
12
+ The first release. duel-retained is a Duel plugin, open source under
13
+ the MIT license. It needs Duel 0.1 and Ruby 3.4 or later, and measures
14
+ on CRuby alone (on JRuby and TruffleRuby it leaves itself out).
15
+
16
+ ### Added
17
+
18
+ - `c.use :retained`: the `retained_objects` metric (CRuby), the
19
+ objects each benchmark's iterations leave alive after a full garbage
20
+ collection, with Duel's allowance of 16 a sample (for what's still on
21
+ the machine stack when it stops) and tolerance of one per two
22
+ iterations, both changed with `c.measure("retained_objects", ...)`.
23
+ It measures straight through `untimed` blocks. Where a sample retained
24
+ more than it's allowed (as `c.measure` leaves its allowance and
25
+ tolerance), the regression shows what, an iteration: the
26
+ objects' classes and where they were made, the three most (from
27
+ allocation tracing during the sample).
28
+ - `retained_objects` is in objects (`unit: %w[object objects]`), so
29
+ reports read "1 object → 2 objects an iteration" (the summary line,
30
+ "retained_objects 3→5531", leaves out the unit its name ends in).
31
+ - Mutation tested: mutant kills every change to the plugin's code, in
32
+ CI.
33
+ - RBS signatures in sig/, checked with Steep.
34
+
35
+ [Unreleased]: https://github.com/sferik/duel-ruby/compare/duel-retained-v0.1.0...HEAD
36
+ [0.1.0]: https://github.com/sferik/duel-ruby/releases/tag/duel-retained-v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Erik Berlin
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # duel-retained
2
+
3
+ [![Duel](https://raw.githubusercontent.com/sferik/duel-ruby/main/assets/badge.svg)](https://github.com/sferik/duel-ruby)
4
+ [![duel-retained](https://github.com/sferik/duel-ruby/actions/workflows/plugin-retained.yml/badge.svg)](https://github.com/sferik/duel-ruby/actions/workflows/plugin-retained.yml)
5
+ [![Gem Version](https://badge.fury.io/rb/duel-retained.svg)](https://rubygems.org/gems/duel-retained)
6
+
7
+ A [Duel](https://github.com/sferik/duel-ruby) plugin: every benchmark is
8
+ judged on what its iterations leave alive too, the Ruby objects still
9
+ there after a full garbage collection.
10
+
11
+ | Metric | What it is | A regression |
12
+ |---|---|---|
13
+ | `retained_objects` | the objects a sample's iterations left alive, beyond a few a sample (CRuby) | one more per two iterations |
14
+
15
+ So a leak, a cache that grows with every call, or memoization keyed by
16
+ input that never repeats, fails, though it allocates no more and takes
17
+ no longer than before. Allocations count what a benchmark makes, and CPU
18
+ time what it costs now, but neither sees what it keeps. Duel's `peak_memory`
19
+ does, coarsely: resident memory, in fresh workers, judged by the
20
+ megabyte. This counts the objects, exactly.
21
+
22
+ ## Installation
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ group :development do
27
+ gem "duel-retained"
28
+ end
29
+ ```
30
+
31
+ > [!NOTE]
32
+ > It measures on CRuby. On JRuby and TruffleRuby, whose garbage collectors
33
+ > can't be held off, or their live objects counted, it leaves itself out:
34
+ > benchmarks run, and are judged, as without it.
35
+
36
+ ## Usage
37
+
38
+ ```ruby
39
+ # .duel.rb
40
+ Duel.configure { |c| c.use :retained }
41
+ ```
42
+
43
+ Then a benchmark whose code keeps what it's given fails:
44
+
45
+ ```ruby
46
+ # lib/my_formatter.rb, the candidate's: memoized, and never forgets
47
+ FORMATTED = {}
48
+ def self.format(value) = FORMATTED[value] ||= value.round(2).to_s.rjust(10)
49
+
50
+ # duel/formatter_duel.rb
51
+ duel("format: a number") { MyFormatter.format(rand) }
52
+ ```
53
+
54
+ ```
55
+ ! format: a number 0.4s n=5528 ... retained_objects 3→5531 allocations 11056
56
+ REGRESSION format: a number: worse retained_objects in every measurement
57
+ retained_objects: 0.0005427 objects → 1.001 objects an iteration
58
+ retained_objects (candidate): an iteration: 1 String (lib/my_formatter.rb:3)
59
+ ```
60
+
61
+ What it retained beyond what's let through is shown by class, and where
62
+ it was made (the three most): see [What it retained](#what-it-retained).
63
+
64
+ Install it after any other plugin's metrics: those added after it are
65
+ read inside its measurement, and what they keep of their readings counts
66
+ toward its allowance.
67
+
68
+ A sample may leave 16 objects alive before any counts against it (its
69
+ allowance), and beyond that it's one more per two iterations (its
70
+ tolerance) that's a regression: see [below](#what-it-cant-help-counting).
71
+ Duel's `c.measure` changes either, or leaves the metric out:
72
+
73
+ ```ruby
74
+ Duel.configure do |c|
75
+ c.use :retained
76
+ c.measure("retained_objects", allowance: 32, tolerance: 1) # or skip: true
77
+ end
78
+ ```
79
+
80
+ ## How it measures
81
+
82
+ Duel holds the garbage collector off during a sample, so that when it
83
+ runs isn't up to chance. `GC.start` still collects (CRuby leaves the
84
+ collector disabled after): before each sample it counts in, the plugin
85
+ collects all the garbage there is, a full collection twice (what a
86
+ finalizer, or a dynamic symbol, keeps alive goes in the second), and
87
+ counts the live objects (`GC.stat(:heap_live_slots)`). After the sample,
88
+ it does it again. What's left is what the sample's iterations kept.
89
+
90
+ That's outside the built-in metrics, so none of it is in a benchmark's
91
+ allocations, instructions or CPU time. And only in the samples Duel
92
+ counts in (three of each version's a benchmark, and as many again for
93
+ each time it measures one again before failing), not the timed ones. A
94
+ full collection takes about half a millisecond with 30,000 live objects,
95
+ and some 15 with a million: four a sample, so a fraction of a second a
96
+ benchmark, for even a large application.
97
+
98
+ What's measured is the count itself, every object more after the sample
99
+ than before it (or fewer, for one that let go of more than it kept), and
100
+ Duel judges it: a candidate that leaves more alive than the baseline, by
101
+ more than the allowance a sample and the tolerance an iteration, fails.
102
+
103
+ ### What it can't help counting
104
+
105
+ Not every object a sample leaves alive is the benchmark's. Duel's own
106
+ record of the metrics inside this one is. And so is what's still on the
107
+ machine stack when the sample stops: the last iteration's result (or the
108
+ block's, given n) is no longer referenced, but CRuby scans the stack
109
+ conservatively, so a stale reference to it there, or to anything it
110
+ holds, keeps it alive through the collection. Nothing Duel or the plugin
111
+ can do from Ruby clears it: running the iterations on a stack of their
112
+ own (a Fiber) would, but it would change what `Thread.current[]` means to
113
+ the benchmark. They're the same few from sample to sample, but not
114
+ always to the object, so a sample is allowed 16 before any is counted.
115
+ On top of that, it's one more per two iterations, not one more, that's a
116
+ regression. So for a benchmark that runs thousands of iterations a
117
+ sample, as most do, one object kept an iteration fails. For one that
118
+ runs a few (a slow one, or one with `only: :count`, which runs four),
119
+ it takes more an iteration.
120
+
121
+ What a benchmark makes the first time and keeps (a constant it
122
+ memoizes, a method cache) isn't a leak: Duel runs each benchmark many
123
+ times before it counts, and none of that is counted.
124
+
125
+ Benchmarks with `only: :timing` aren't counted at all, so they aren't
126
+ judged on this either.
127
+
128
+ ### Untimed blocks
129
+
130
+ Duel pauses every metric around an `untimed` block, and this one would
131
+ collect the garbage, twice, on each: in a benchmark with an `untimed`
132
+ block in every iteration, that's thousands of collections a sample. So
133
+ it doesn't pause: it measures the whole sample, and what an `untimed`
134
+ block makes counts only if it's still alive when the sample is over.
135
+ What it usually makes, the input a benchmark works on, isn't.
136
+
137
+ ## What it retained
138
+
139
+ Where a sample retained more than Duel lets through (its allowance, and
140
+ its tolerance for its iterations, as `c.measure` leaves them), the
141
+ plugin says what, under the regression: the objects' classes, and where
142
+ they were made, the three most, an iteration (`1 String
143
+ (lib/my_formatter.rb:3)`), of the sample whose count was judged, on
144
+ each version that did. Files are from the working directory, the
145
+ project's, so the baseline's show their whole path, in its checkout
146
+ next to the project. Objects Duel makes for itself aren't shown.
147
+
148
+ It knows from Ruby's allocation tracing (`objspace`'s
149
+ `ObjectSpace.trace_object_allocations`), on during each sample it counts
150
+ in: that makes each allocation some four times slower, in samples that
151
+ aren't timed. Telling what's left apart walks every live object (some
152
+ twenty times as long as a full collection), so it's done only for a
153
+ sample that retained more than Duel lets through. A benchmark that
154
+ traces allocations itself, or clears the trace, shares the one Ruby
155
+ has.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Duel
4
+ module Retained
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "duel"
4
+ require "objspace"
5
+ require_relative "retained/version"
6
+
7
+ module Duel
8
+ # What each benchmark leaves alive: the Ruby objects still there after
9
+ # a full garbage collection that weren't before it (CRuby).
10
+ #
11
+ # - retained_objects: the objects a sample's iterations left alive
12
+ # (counted; beyond ALLOWANCE a sample, one more per two iterations is
13
+ # a regression)
14
+ #
15
+ # # .duel.rb
16
+ # Duel.configure { |c| c.use :retained }
17
+ #
18
+ # So a leak, a cache that grows with every call, or memoization keyed
19
+ # by input that never repeats, fails, though it allocates no more and
20
+ # takes no longer than before.
21
+ #
22
+ # Duel holds the garbage collector off during a sample, but GC.start
23
+ # still collects: the metric collects, twice (what a finalizer or a
24
+ # dynamic symbol keeps alive goes in the second), and counts the live
25
+ # objects before the sample and again after it. It's outside the
26
+ # built-in metrics, so none of that is in allocations or CPU time, and
27
+ # only in the samples Duel counts in (a few a benchmark), not the timed
28
+ # ones. When a sample retained more than Duel lets through, the metric
29
+ # says what (Objects#details), by class and where it was made, under
30
+ # the regression: "an iteration: 1 String (lib/my_cache.rb:12)".
31
+ module Retained
32
+ Duel.plugin(:retained, self)
33
+
34
+ # The retained objects a sample is allowed (Duel's allowance) before
35
+ # any is counted against it: Duel's own record of the metrics inside
36
+ # this one, and what's still on the machine stack when the sample
37
+ # stops (the last iteration's result, or the block's, given n),
38
+ # which are the same few from sample to sample, but not always to the
39
+ # object.
40
+ ALLOWANCE = 16
41
+
42
+ # Retained objects per iteration that still aren't a regression.
43
+ TOLERANCE = 0.5
44
+
45
+ # The kinds of object (by class, and where they were made) a sample
46
+ # that retained more than its allowance shows, most first.
47
+ SHOWN = 3
48
+
49
+ # Configures Duel for the plugin (c.use :retained): the metric
50
+ # (CRuby only: elsewhere the garbage collector can't be held off or
51
+ # its live objects counted). Returns the metric.
52
+ def self.configure(config) = config.metric(Objects)
53
+
54
+ # Collects all the garbage there is: a full collection (GC.start's
55
+ # own: a full mark, and an immediate sweep), twice (the first leaves
56
+ # what has a finalizer to run, and a dynamic symbol's string, for the
57
+ # second). It collects though the garbage collector is disabled, and
58
+ # leaves it as it was.
59
+ def self.collect = 2.times { GC.start }
60
+
61
+ # The objects alive after collecting the garbage.
62
+ def self.live
63
+ collect
64
+ GC.stat(:heap_live_slots)
65
+ end
66
+
67
+ # Where Duel's own files are: what it makes during a sample (its
68
+ # record of the metrics inside this one) is in the allowance, and
69
+ # isn't shown.
70
+ # (Duel's metric.rb, written in Ruby: it has a source location.)
71
+ metric_rb, = Metric.instance_method(:details).source_location #: [String, Integer]
72
+ DUEL = File.join(File.dirname(metric_rb), "")
73
+
74
+ # What each of the objects that a sample made (and Objects traced)
75
+ # and left alive is, and where it was made: {[class, "file:line"] =>
76
+ # how many}, most first, but Duel's own. A walk of every live object.
77
+ def self.traced
78
+ counts = Hash.new(0) #: Hash[[String, String], Integer]
79
+ class_of = Kernel.instance_method(:class)
80
+ here = File.join(Dir.pwd, "")
81
+ ObjectSpace.each_object do |object|
82
+ next unless ObjectSpace.allocation_generation(object)
83
+
84
+ file = ObjectSpace.allocation_sourcefile(object)
85
+ next if file.to_s.start_with?(DUEL)
86
+
87
+ klass = class_of.bind_call(object)
88
+ counts[[klass.name || klass.inspect, site(file, ObjectSpace.allocation_sourceline(object), here)]] += 1
89
+ end
90
+ counts.sort_by { |(klass, site), count| [-count, klass, site] }.to_h
91
+ end
92
+
93
+ # Where an object was made, from its +file+ and +line+ (nil where it
94
+ # wasn't made in Ruby code): the file from +here+ (a directory, with
95
+ # a trailing slash) where it's under it.
96
+ def self.site(file, line, here) = file ? "#{file.delete_prefix(here)}:#{line}" : "?"
97
+
98
+ # The metric: the objects alive after the sample less those before
99
+ # it (fewer, when the sample let go of more than it kept). Duel takes
100
+ # the allowance and the tolerance into account when it compares them.
101
+ class Objects < Metric
102
+ def self.measures = {"retained_objects" => {unit: %w[object objects], allowance: ALLOWANCE, tolerance: TOLERANCE}}
103
+
104
+ # mutant:disable -- mutant runs on CRuby, where this is always true
105
+ def self.available? = RUBY_ENGINE == "ruby"
106
+
107
+ # It traces where objects are made (ObjectSpace's allocation
108
+ # tracing) during the sample, for #details: what it retained, it
109
+ # can tell apart from the rest.
110
+ def start_sample(sample)
111
+ @n = sample.n
112
+ ObjectSpace.trace_object_allocations_clear
113
+ ObjectSpace.trace_object_allocations_start
114
+ @live = Retained.live
115
+ end
116
+
117
+ def stop_sample
118
+ @retained = Retained.live - @live
119
+ ensure
120
+ ObjectSpace.trace_object_allocations_stop
121
+ end
122
+
123
+ # It goes on through an untimed block: stopping and starting would
124
+ # collect the garbage, twice, on each, for every untimed block (an
125
+ # iteration's, maybe). So it measures the whole sample, and what an
126
+ # untimed block made (the input the benchmark works on, usually)
127
+ # counts only if it's still alive when the sample is over.
128
+ def pause_sample
129
+ end
130
+
131
+ def resume_sample(_sample)
132
+ end
133
+
134
+ # What the sample retained, an iteration, by class and where it was
135
+ # made (the SHOWN most), when it retained more than Duel lets through
136
+ # (its allowance a sample, and its tolerance an iteration, as
137
+ # configured): only then is it worth walking every live object for
138
+ # (Retained.traced). Nothing for one that retained less. Duel asks
139
+ # once, after the sample: the trace is cleared either way.
140
+ def details
141
+ return if within?(@retained, @n)
142
+
143
+ kinds = Retained.traced.first(SHOWN).map { |(klass, site), count| format("%.4g %s (%s)", count.fdiv(@n), klass, site) }
144
+ "an iteration: #{kinds.join(", ")}" unless kinds.empty? # (none it can see: internal objects, or a trace cleared)
145
+ ensure
146
+ ObjectSpace.trace_object_allocations_clear
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,62 @@
1
+ module Duel
2
+ # What each benchmark leaves alive: the Ruby objects still there after
3
+ # a full garbage collection that weren't before it (retained_objects).
4
+ module Retained
5
+ VERSION: String
6
+
7
+ # The retained objects a sample is allowed (Duel's allowance) before
8
+ # any is counted against it.
9
+ ALLOWANCE: Integer
10
+
11
+ # Retained objects per iteration that still aren't a regression.
12
+ TOLERANCE: Float
13
+
14
+ # The kinds of object a sample that retained more than its allowance
15
+ # shows, most first.
16
+ SHOWN: Integer
17
+
18
+ # Configures Duel for the plugin (c.use :retained): the metric (CRuby
19
+ # only). Returns it.
20
+ def self.configure: (Config config) -> singleton(Metric)
21
+
22
+ # Collects all the garbage there is (a full collection, twice).
23
+ def self.collect: () -> void
24
+
25
+ # The objects alive after collecting the garbage.
26
+ def self.live: () -> Integer
27
+
28
+ # What each of the objects a sample made and left alive is, and where
29
+ # it was made: {[class, "file:line"] => how many}, most first.
30
+ def self.traced: () -> Hash[[String, String], Integer]
31
+
32
+ # Where Duel's own files are: what it makes isn't shown.
33
+ DUEL: String
34
+
35
+ # Where an object was made, from its +file+ and +line+ (nil where it
36
+ # wasn't made in Ruby code).
37
+ def self.site: (String? file, Integer? line, String here) -> String
38
+
39
+ # The metric: the objects alive after the sample less those before
40
+ # it.
41
+ class Objects < Metric
42
+ @live: Integer
43
+ @n: Integer
44
+ @retained: Integer
45
+
46
+ # It traces where objects are made during the sample, for #details.
47
+ def start_sample: (Sample sample) -> void
48
+
49
+ def stop_sample: () -> Integer
50
+
51
+ # It goes on through an untimed block: it measures the whole sample.
52
+ def pause_sample: () -> nil
53
+
54
+ def resume_sample: (Sample _sample) -> nil
55
+
56
+ # What the sample retained, an iteration, by class and where it was
57
+ # made, when it retained more than its allowance and tolerance let
58
+ # through.
59
+ def details: () -> String?
60
+ end
61
+ end
62
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: duel-retained
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Erik Berlin
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: duel
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ description: 'A Duel plugin: every benchmark is judged on the Ruby objects its iterations
27
+ leave alive after a full garbage collection, so a leak, or a cache that grows with
28
+ every call, fails.'
29
+ email:
30
+ - sferik@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE
37
+ - README.md
38
+ - lib/duel/retained.rb
39
+ - lib/duel/retained/version.rb
40
+ - sig/duel/retained.rbs
41
+ homepage: https://github.com/sferik/duel-ruby/tree/main/plugins/retained
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ source_code_uri: https://github.com/sferik/duel-ruby/tree/main/plugins/retained
46
+ changelog_uri: https://github.com/sferik/duel-ruby/blob/main/plugins/retained/CHANGELOG.md
47
+ bug_tracker_uri: https://github.com/sferik/duel-ruby/issues
48
+ rubygems_mfa_required: 'true'
49
+ rdoc_options: []
50
+ require_paths:
51
+ - lib
52
+ required_ruby_version: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '3.4'
57
+ required_rubygems_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ requirements: []
63
+ rubygems_version: 4.0.23
64
+ specification_version: 4
65
+ summary: 'What each Duel benchmark leaves alive: objects that outlive the garbage
66
+ collector'
67
+ test_files: []