method_timer 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: 68ce55408cf9543b2a34106b4dc700f6f7c5e49e12e3caa55a14502d06ec4bf4
4
+ data.tar.gz: 1782abd41e506211c86e0c02e6c29549354563f9a0404e7eccab10ad3b209add
5
+ SHA512:
6
+ metadata.gz: 9c760f1a4ffd0788a0c88392793ad3d3879f79f58fe3da4e3f9b85ccf79f8f61e22a17e1abc716c3a166df175ecea068bea135f71ac6d22c6fb8f9452ef75cc9
7
+ data.tar.gz: b6f0d33089cd55d60ce0def864aebe13b3c096d4dc564612507201eb3ad76ae0fd6b8464588bcead4dd517520e678752df008a65a5a45d2178200735ea2f2a60
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Konrad Jurkowski
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,296 @@
1
+ # MethodTimer
2
+
3
+ Per-method timing for Ruby code, for profiling interactively from a console. It instruments every method under a
4
+ class or namespace and reports, per method: self and total time, SQL time, GC time, call count and allocations.
5
+ Wrappers are allocation-free, and the report includes an estimate of the profiler's own overhead.
6
+
7
+ **This is a console tool: use it from an interactive console session, never from application code.** Running it
8
+ in a production console against real data is fine and a common use. Don't call it from code that serves requests or
9
+ runs jobs: instrumentation patches classes until you `uninstall`, adds a fixed cost to every
10
+ instrumented call, keeps global state and isn't thread-safe. Profile in a throwaway console session (e.g.
11
+ `rails console`), then exit.
12
+
13
+ Requires Ruby 3.4+: the wrappers forward arguments with `(...)`, which only stopped allocating in Ruby 3.4 (on
14
+ older versions every instrumented call would add objects to the `allocs` column). No runtime dependencies; SQL is
15
+ tracked automatically when ActiveSupport is loaded (ActiveRecord's `sql.active_record` notifications).
16
+
17
+ ## Installation
18
+
19
+ Add it with `require: false`, so it's installed everywhere you might open a console but never loaded when the app
20
+ boots:
21
+
22
+ ```ruby
23
+ # Gemfile
24
+ gem 'method_timer', require: false
25
+ ```
26
+
27
+ If you only profile locally, put it in the development group instead.
28
+
29
+ Then load it in the console session where you want to profile:
30
+
31
+ ```ruby
32
+ require 'method_timer'
33
+ ```
34
+
35
+ ### Loading it in every Rails console
36
+
37
+ Use Rails' `console` hook in `config/application.rb`, which runs only when a console starts (not in the server,
38
+ jobs or tests):
39
+
40
+ ```ruby
41
+ module MyApp
42
+ class Application < Rails::Application
43
+ console do
44
+ require 'method_timer' if Gem.loaded_specs.key?('method_timer')
45
+ end
46
+ end
47
+ end
48
+ ```
49
+
50
+ The `Gem.loaded_specs` guard keeps consoles starting in environments where the gem isn't installed (e.g. when it's
51
+ in the development group and you open a production console).
52
+
53
+ ## Usage
54
+
55
+ MethodTimer works with any Ruby code: plain classes and modules, Rails models and services, or classes from other
56
+ gems. Point `install` at a class or a namespace; every instance method defined there (public, protected and
57
+ private, plus methods of modules included directly into those classes) gets timed.
58
+
59
+ ### Where does the time go?
60
+
61
+ ```ruby
62
+ MethodTimer.install(MyApp::Billing) # a class or a namespace (all classes under it)
63
+ MethodTimer.run { thing_to_profile } # prints the report to stderr, returns the block's value
64
+ MethodTimer.run(rss: true) { thing_to_profile } # also samples peak process memory every second
65
+ MethodTimer.report(threshold: 0.5) # reprint, showing only methods with total >= 0.5s (default 0.001s)
66
+ MethodTimer.stats # the numbers as a hash
67
+ MethodTimer.uninstall # remove all wrappers (methods run at full speed again)
68
+ ```
69
+
70
+ `install` options:
71
+
72
+ - `strip_root: false`: keep the full namespace in labels (by default the namespace shared by all instrumented
73
+ classes is stripped).
74
+ - `silence_sql: false`: keep the ActiveRecord logger (it's silenced by default, as logging every query distorts
75
+ timings).
76
+
77
+ You can call `install` several times to time a few unrelated classes together. To profile a different piece of
78
+ code in the same session, `uninstall` first, so the previous classes stop paying the per-call cost and don't show
79
+ up in the next report. (`run` already clears the numbers from the previous run.)
80
+
81
+ ### Example: plain Ruby
82
+
83
+ A CSV export of 50,000 orders, each with 5 items:
84
+
85
+ ```ruby
86
+ require 'csv'
87
+ require 'method_timer'
88
+
89
+ module Shop
90
+ Order = Struct.new(:id, :customer, :items)
91
+ Item = Struct.new(:name, :price, :quantity)
92
+
93
+ class PriceCalculator
94
+ TAX = 0.23
95
+
96
+ def total(order) = order.items.sum { |item| line_total(item) }
97
+
98
+ private
99
+
100
+ def line_total(item) = (item.price * item.quantity * (1 + TAX)).round(2)
101
+ end
102
+
103
+ class CsvExport
104
+ def initialize(orders) = @orders = orders
105
+
106
+ def call
107
+ CSV.generate do |csv|
108
+ csv << %w[id customer total]
109
+ @orders.each { |order| csv << row(order) }
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def row(order) = [order.id, order.customer.upcase, calculator.total(order)]
116
+
117
+ def calculator = @calculator ||= PriceCalculator.new
118
+ end
119
+ end
120
+
121
+ orders = Array.new(50_000) do |i|
122
+ Shop::Order.new(i, "customer #{i}", Array.new(5) { |n| Shop::Item.new("item #{n}", 9.99, n + 1) })
123
+ end
124
+
125
+ MethodTimer.install(Shop)
126
+ MethodTimer.run { Shop::CsvExport.new(orders).call }
127
+ ```
128
+
129
+ ```
130
+ MethodTimer: instrumented 4 classes/modules under Shop (overhead 0.77µs per call).
131
+
132
+ method self(ruby) self(sql) self(gc) total calls sql calls allocs
133
+ -----------------------------------------------------------------------------------------------------------
134
+ PriceCalculator#line_total 0.36301s 0.00000s 0.00000s 0.42104s 250000 0 12
135
+ PriceCalculator#total 0.21378s 0.00000s 0.00000s 0.64071s 50000 0 9
136
+ CsvExport#row 0.15167s 0.00000s 0.00000s 0.81289s 50000 0 150014
137
+ CsvExport#call 0.11139s 0.00000s 0.00000s 0.92428s 1 0 250123
138
+ Item#quantity 0.03049s 0.00000s 0.00000s 0.03049s 250000 0 2
139
+ Item#price 0.02753s 0.00000s 0.00000s 0.02753s 250000 0 2
140
+ CsvExport#calculator 0.00823s 0.00000s 0.00000s 0.00823s 50000 0 6
141
+ Order#id 0.00656s 0.00000s 0.00000s 0.00656s 50000 0 2
142
+ Order#items 0.00590s 0.00000s 0.00000s 0.00590s 50000 0 2
143
+ Order#customer 0.00572s 0.00000s 0.00000s 0.00572s 50000 0 2
144
+ -----------------------------------------------------------------------------------------------------------
145
+ TOTAL (wall time of run block) 0.92429s
146
+ of which captured (sum of self ruby) 0.92428s
147
+ of which SQL (sum of self sql / calls) 0.00000s 0
148
+ of which GC (sum of self gc) 0.00000s
149
+ of which instrumentation overhead (estimated) 0.80849s (1050002 calls x 0.77µs, counted in callers' self)
150
+ total allocations (sum of self allocs) 400176
151
+ ```
152
+
153
+ Two things to notice:
154
+
155
+ - `Struct` accessors are real methods, so they're timed too. `allocs` shows where objects are created:
156
+ `CsvExport#row` allocates 3 per call, `PriceCalculator` almost nothing.
157
+ - The overhead line says 0.81 s of the 0.92 s is the profiler itself: a million calls to tiny methods. When that
158
+ happens, instrument only the classes you care about:
159
+
160
+ ```ruby
161
+ MethodTimer.install(Shop::CsvExport)
162
+ MethodTimer.install(Shop::PriceCalculator)
163
+ MethodTimer.run { Shop::CsvExport.new(orders).call }
164
+ # 400,002 instrumented calls instead of 1,050,002; wall time 0.42 s instead of 0.92 s
165
+ ```
166
+
167
+ ### Example: a Rails model and service
168
+
169
+ In `rails console`, SQL time and query counts are filled in from ActiveRecord's notifications:
170
+
171
+ ```ruby
172
+ MethodTimer.install(Invoice) # an ActiveRecord model
173
+ MethodTimer.install(Billing) # a namespace of service objects, e.g. Billing::MonthlyRun
174
+ MethodTimer.run { Billing::MonthlyRun.new(month: Date.current).call }
175
+ ```
176
+
177
+ `self(sql)` and `sql calls` show which method ran the queries (cached queries are skipped), so an N+1 stands out as
178
+ a method with as many `sql calls` as `calls`. Association readers are included (Rails defines them in a named
179
+ `GeneratedAssociationMethods` module), e.g. loading 3 records and calling `download.template` on each:
180
+
181
+ ```
182
+ Download::GeneratedAssociationMethods#template 3 calls 3 sql calls
183
+ ```
184
+
185
+ Attribute readers (`invoice.amount`) aren't timed: Rails defines them lazily on first use, after `install` ran.
186
+
187
+ ### Example: a class from another gem
188
+
189
+ ```ruby
190
+ MethodTimer.install(CSV) # CSV and everything under it (CSV::Writer, CSV::Parser, ...)
191
+ MethodTimer.run { CSV.generate { |csv| 1_000.times { |i| csv << [i, 'x'] } } }
192
+ # CSV::Writer#quote 2000 calls, CSV#<< 1000 calls, ...
193
+ ```
194
+
195
+ ### How long does it really take?
196
+
197
+ `bench` times a block without instrumenting anything, so it works for any code, including top-level methods and
198
+ lambdas that `install` can't reach. Use it to compare two versions:
199
+
200
+ ```ruby
201
+ titles = Array.new(200_000) { |i| "Hello World #{i}!" }
202
+
203
+ MethodTimer.bench { titles.map { |t| t.downcase.gsub(/[^a-z0-9]+/, '-').delete_suffix('-') } }
204
+ # wall 0.320s
205
+ # GC 0.085s
206
+ # SQL 0.000s (0 queries)
207
+ # allocations 2600038
208
+
209
+ MethodTimer.bench { titles.map { |t| t.downcase.tr('^a-z0-9', '-').squeeze('-').delete_suffix('-') } }
210
+ # wall 0.150s
211
+ # GC 0.043s
212
+ # SQL 0.000s (0 queries)
213
+ # allocations 1800022
214
+
215
+ MethodTimer.bench(rss: true) { thing_to_measure } # plus memory at start, peak (sampled every second) and end
216
+ ```
217
+
218
+ When comparing two versions of real code, run `bench` in a fresh process without `install`.
219
+
220
+ ### What isn't timed
221
+
222
+ - Class (singleton) methods such as `Report.build`: only instance methods are wrapped. Their time counts toward
223
+ the instance methods they call, or use `bench`.
224
+ - Top-level methods, lambdas and blocks: they aren't methods of an instrumented class.
225
+ - Methods defined after `install` (e.g. ActiveRecord attribute readers, which Rails defines on first use) and
226
+ methods in anonymous modules: `install` wraps the methods that exist when it runs.
227
+
228
+ ## Reading the report
229
+
230
+ ```
231
+ method self(ruby) self(sql) self(gc) total calls sql calls allocs
232
+ ------------------------------------------------------------------------------------------------------------
233
+ Supply#build_csv 36.52825s 21.26017s 4.77200s 86.66200s 1 27 52580175
234
+ Supply#build_row 22.22469s 0.00000s 0.09000s 24.03738s 4865224 0 4918938
235
+ ...
236
+ ------------------------------------------------------------------------------------------------------------
237
+ TOTAL (wall time of run block) 163.27384s
238
+ of which captured (sum of self ruby) 101.23146s
239
+ of which SQL (sum of self sql / calls) 49.77568s 250
240
+ of which GC (sum of self gc) 12.26600s
241
+ of which instrumentation overhead (estimated) 31.75190s (43720945 calls x 0.73µs, counted in callers' self)
242
+ total allocations (sum of self allocs) 112497459
243
+ ```
244
+
245
+ - **self(ruby)**: time spent in the method's own code, excluding instrumented callees, SQL and GC.
246
+ - **self(sql)**: SQL run directly by the method (not by instrumented callees). Cached queries are skipped.
247
+ - **self(gc)**: GC pauses that started while the method itself was running. Without this column, GC time would
248
+ land on whatever method happened to be on the stack.
249
+ - **total**: wall time including everything the method called.
250
+ - **allocs**: objects allocated directly by the method.
251
+ - **wall time of run block** = self(ruby) + self(sql) + self(gc) summed over all methods (plus anything outside
252
+ instrumented methods). It **includes the profiler's overhead**.
253
+ - **instrumentation overhead (estimated)**: every instrumented call costs a fixed amount (measured when you call
254
+ `install`), and that cost shows up in the callers' self(ruby). Subtract it from the wall time to estimate the
255
+ code's real run time. A method's `total` includes the overhead of every instrumented call beneath it, so methods
256
+ that make millions of calls look relatively more expensive than they are.
257
+
258
+ For an exact real run time, use `bench`.
259
+
260
+ ## Caveats
261
+
262
+ - `uninstall` removes the timing code, but each instrumented class keeps an empty prepended module until the
263
+ process exits (Ruby can't un-prepend). It has no per-call cost.
264
+ - Only the thread running `run` is profiled: instrumented methods called from other threads run untimed (and
265
+ their SQL isn't counted). Run one profiled block at a time.
266
+ - With `rss: true`, memory is sampled by a child `sh` process (reading `/proc/<pid>/statm` on Linux, `ps`
267
+ elsewhere), so sampling doesn't add allocations or GC to the profiled process.
268
+ - Lambdas and blocks aren't methods, so their time is counted in the method that calls them.
269
+ - The per-call overhead is about 0.4–0.7µs depending on the process. With tens of millions of calls, expect tens
270
+ of seconds of overhead; the report tells you how much.
271
+
272
+ ## Development
273
+
274
+ The code is split into modules extended onto `MethodTimer`, so they share its state and the generated wrappers
275
+ call `MethodTimer.enter`/`MethodTimer.leave` without an extra object per call:
276
+
277
+ - `lib/method_timer.rb`: `run` and `bench`
278
+ - `lib/method_timer/instrumenter.rb`: `install`, class discovery, labels, wrapper generation, overhead calibration
279
+ - `lib/method_timer/recorder.rb`: per-method stats (`enter`/`leave`, `stats`, `reset`)
280
+ - `lib/method_timer/reporter.rb`: report output
281
+ - `lib/method_timer/sql.rb`: SQL tracking via ActiveSupport notifications
282
+ - `lib/method_timer/memory.rb`: RSS sampling
283
+
284
+ Each module has its own spec under `spec/method_timer/`; `spec/method_timer_spec.rb` covers `run` and `bench` end to end.
285
+
286
+ ```sh
287
+ bundle install
288
+ bundle exec rspec
289
+ bundle exec rubocop
290
+ bundle exec rake # both
291
+ gem build method_timer.gemspec
292
+ ```
293
+
294
+ ## License
295
+
296
+ MIT, see [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MethodTimer
4
+ # Wraps every method of the instrumented classes (and their directly included modules) in a prepended module
5
+ # that calls MethodTimer.enter / MethodTimer.leave around it.
6
+ module Instrumenter
7
+ CALIBRATION_CALLS = 200_000
8
+
9
+ def install(root, silence_sql: true, strip_root: true)
10
+ @root = root
11
+ @strip_root = strip_root
12
+ ActiveRecord::Base.logger = nil if silence_sql && defined?(ActiveRecord::Base)
13
+ classes = []
14
+ classes << root if root.is_a?(Class)
15
+ each_class(root) { |k| classes << k }
16
+ sources = classes.flat_map { |k| [k, *own_included_modules(k)] }
17
+ @common_prefix = common_namespace(sources.filter_map(&:name))
18
+ already = @instrumented.size
19
+ classes.each { |k| instrument(k) }
20
+ calibrate
21
+ warn "MethodTimer: instrumented #{@instrumented.size - already} classes/modules under #{root} " \
22
+ '(overhead %.2fµs per call).' % (@overhead * 1e6)
23
+ end
24
+
25
+ # Removes every wrapper added by `install`, so instrumented methods run at full speed again and a different
26
+ # class or namespace can be installed from scratch. A module can't be un-prepended, but an emptied one adds
27
+ # no per-call cost.
28
+ def uninstall
29
+ @instrumented.each_value do |wrapper|
30
+ (wrapper.instance_methods(false) + wrapper.private_instance_methods(false)).each do |m|
31
+ wrapper.send(:remove_method, m)
32
+ end
33
+ end
34
+ count = @instrumented.size
35
+ @instrumented.clear
36
+ reset
37
+ count
38
+ end
39
+
40
+ # Longest `::`-delimited namespace shared by all names, always leaving at least the leaf segment.
41
+ def common_namespace(names)
42
+ return '' if names.empty?
43
+ parts = names.map { |n| n.split('::') }
44
+ min_len = parts.map(&:size).min
45
+ shared = (0...min_len).take_while { |i| parts.map { |p| p[i] }.uniq.size == 1 }.size
46
+ shared = min_len - 1 if shared >= min_len
47
+ parts.first.first(shared).join('::')
48
+ end
49
+
50
+ private
51
+
52
+ # Times an empty instrumented method against a plain one to estimate the fixed cost of each wrapper call.
53
+ def calibrate
54
+ plain = Class.new { def noop = nil }
55
+ timed = Class.new { def noop = nil }
56
+ wrapper = Module.new
57
+ @calibration_id ||= register('MethodTimer#calibration')
58
+ define_wrapper(wrapper, :noop, @calibration_id)
59
+ timed.prepend(wrapper)
60
+ @thread = Thread.current
61
+ @overhead = [elapsed(timed.new) - elapsed(plain.new), 0.0].max / CALIBRATION_CALLS
62
+ ensure
63
+ @thread = nil
64
+ @depth = 0
65
+ Recorder::STATS[@calibration_id] = [0.0, 0.0, 0, 0.0, 0, 0, 0] if @calibration_id
66
+ end
67
+
68
+ def elapsed(object)
69
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
+ CALIBRATION_CALLS.times { object.noop }
71
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
72
+ end
73
+
74
+ def each_class(mod, seen = {}, &blk)
75
+ return if seen[mod]
76
+ seen[mod] = true
77
+ mod.constants(false).each do |c|
78
+ val = mod.const_get(c)
79
+ next unless val.is_a?(Module) && val.name&.start_with?(mod.name.to_s)
80
+ yield(val) if val.is_a?(Class)
81
+ each_class(val, seen, &blk)
82
+ end
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ # Strips the namespace shared by all instrumented sources, so labels stay short yet unique
88
+ # (a single fixed prefix removed from already-unique names cannot collide).
89
+ def label_for(klass)
90
+ return klass.name unless @strip_root && klass.name && !@common_prefix.to_s.empty?
91
+ klass.name.sub(/\A#{Regexp.escape(@common_prefix)}::/, '')
92
+ end
93
+
94
+ def instrument(klass)
95
+ instrument_owner(klass)
96
+ own_included_modules(klass).each { |mod| instrument_owner(mod) }
97
+ end
98
+
99
+ # Prepend a timing wrapper into the method owner itself (class or module) so that each ancestor
100
+ # layer is measured independently: a method reached via `super` still records its own-owner timing.
101
+ def instrument_owner(source)
102
+ return if @instrumented[source]
103
+ wrapper = @instrumented[source] = Module.new
104
+ wrap_methods(wrapper, source, label_for(source))
105
+ source.prepend(wrapper)
106
+ end
107
+
108
+ # Modules mixed directly into klass (not inherited from its superclass, not anonymous wrappers).
109
+ def own_included_modules(klass)
110
+ inherited = klass.superclass ? klass.superclass.ancestors : []
111
+ (klass.ancestors - inherited).select { |a| a.instance_of?(Module) && a != klass && a.name }
112
+ end
113
+
114
+ def wrap_methods(wrapper, source, label)
115
+ privates = source.private_instance_methods(false)
116
+ protecteds = source.protected_instance_methods(false)
117
+ methods = source.instance_methods(false) + privates
118
+ methods.each do |m|
119
+ define_wrapper(wrapper, m, register("#{label}##{m}"))
120
+ wrapper.send(:private, m) if privates.include?(m)
121
+ wrapper.send(:protected, m) if protecteds.include?(m)
122
+ end
123
+ end
124
+
125
+ # `(...)` with a bare `super` forwards arguments without allocating, unlike `define_method { |*a, **k| }`.
126
+ def define_wrapper(wrapper, method_name, id)
127
+ wrapper.module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
128
+ def #{method_name}(...)
129
+ MethodTimer.enter
130
+ super
131
+ ensure
132
+ MethodTimer.leave(#{id})
133
+ end
134
+ RUBY
135
+ rescue SyntaxError
136
+ wrapper.define_method(method_name) do |*a, **k, &b|
137
+ MethodTimer.enter
138
+ super(*a, **k, &b)
139
+ ensure
140
+ MethodTimer.leave(id)
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MethodTimer
4
+ # Process memory (RSS) sampling for `run(rss: true)` and `bench(rss: true)`.
5
+ module Memory
6
+ RSS_INTERVAL = 1 # seconds
7
+ # Args: $1 = PID to watch, $2 = interval in seconds. Prints the peak RSS in KB on SIGTERM. POSIX sh only
8
+ # (dash, busybox ash, bash, macOS sh). Linux reads VmRSS from /proc (kB, independent of page size), other
9
+ # systems use ps. The sleep doesn't hold stdout, so the pipe closes as soon as the script exits.
10
+ RSS_SAMPLER_SCRIPT = <<~SH
11
+ peak=0
12
+ trap 'echo "$peak"; exit 0' TERM
13
+ while kill -0 "$1" 2>/dev/null; do
14
+ rss=
15
+ if [ -r "/proc/$1/status" ]; then
16
+ while read -r key value _; do
17
+ [ "$key" = "VmRSS:" ] && rss=$value
18
+ done < "/proc/$1/status"
19
+ else
20
+ rss=$(ps -o rss= -p "$1" | tr -d ' ')
21
+ fi
22
+ [ -n "$rss" ] && [ "$rss" -gt "$peak" ] && peak=$rss
23
+ sleep "$2" > /dev/null 2>&1 &
24
+ wait $!
25
+ done
26
+ echo "$peak"
27
+ SH
28
+
29
+ private
30
+
31
+ # Samples the peak RSS from a child shell process, so sampling allocates nothing in this process and can't
32
+ # skew allocs or self(gc). The child keeps the maximum and prints it (in KB) once, when stopped.
33
+ def start_rss_sampler
34
+ reader, writer = IO.pipe
35
+ pid = Process.spawn('sh', '-c', RSS_SAMPLER_SCRIPT, 'rss_sampler', Process.pid.to_s, RSS_INTERVAL.to_s,
36
+ out: writer, err: File::NULL)
37
+ writer.close
38
+ [pid, reader]
39
+ rescue SystemCallError
40
+ reader&.close
41
+ writer&.close
42
+ nil
43
+ end
44
+
45
+ # Stops the sampler and returns the peak RSS it saw, in bytes (nil if unavailable).
46
+ def stop_rss_sampler(sampler)
47
+ return unless sampler
48
+
49
+ pid, reader = sampler
50
+ Process.kill('TERM', pid)
51
+ peak_kb = reader.read.to_i
52
+ Process.wait(pid)
53
+ peak_kb.positive? ? peak_kb * 1024 : nil
54
+ rescue SystemCallError
55
+ nil
56
+ ensure
57
+ reader&.close
58
+ end
59
+
60
+ # RSS in bytes. Linux: /proc/self/statm. macOS/other: shell out to ps. Returns nil if unavailable.
61
+ def current_rss
62
+ if File.exist?('/proc/self/status')
63
+ File.read('/proc/self/status')[/^VmRSS:\s+(\d+)/, 1].to_i * 1024 # kB, independent of page size
64
+ else
65
+ `ps -o rss= -p #{Process.pid}`.to_i * 1024 # ps reports KB
66
+ end
67
+ rescue StandardError
68
+ nil
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MethodTimer
4
+ # Collects per-method stats during a run.
5
+ module Recorder
6
+ # Per-method stats, indexed by method id: [self_t, sql_t, gc_ms, total_t, calls, sql_calls, allocs]
7
+ NAMES = []
8
+ STATS = []
9
+ # Per-depth frame data; depth 0 is the code running outside instrumented methods.
10
+ START_T = [0.0]
11
+ START_A = [0]
12
+ START_G = [0]
13
+ CHILD_T = [0.0]
14
+ CHILD_A = [0]
15
+ CHILD_G = [0]
16
+ SQL_T = [0.0]
17
+ SQL_N = [0]
18
+
19
+ def reset
20
+ STATS.map! { [0.0, 0.0, 0, 0.0, 0, 0, 0] }
21
+ @depth = 0
22
+ @grand_total = 0.0
23
+ end
24
+
25
+ # { "Class#method" => { self_t:, sql_t:, gc_t:, total_t:, calls:, sql_calls:, allocs: } } for called methods
26
+ def stats
27
+ STATS.each_with_index.filter_map do |(self_t, sql_t, gc_ms, total_t, calls, sql_calls, allocs), id|
28
+ next if calls.zero?
29
+
30
+ [NAMES[id], { self_t:, sql_t:, gc_t: gc_ms / 1000.0, total_t:, calls:, sql_calls:, allocs: }]
31
+ end.to_h
32
+ end
33
+
34
+ # Called by every wrapper. Kept allocation-free (flat arrays, no blocks or splats) so that profiling
35
+ # millions of calls neither skews the allocation counts nor triggers extra GC. Frame state is global, so
36
+ # calls from any thread other than the one running `run` are ignored instead of corrupting it.
37
+ def enter
38
+ return unless Thread.current.equal?(@thread)
39
+
40
+ d = @depth += 1
41
+ CHILD_T[d] = 0.0
42
+ CHILD_A[d] = 0
43
+ CHILD_G[d] = 0
44
+ SQL_T[d] = 0.0
45
+ SQL_N[d] = 0
46
+ START_G[d] = GC.stat(:time)
47
+ START_A[d] = GC.stat(:total_allocated_objects)
48
+ START_T[d] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
+ end
50
+
51
+ def leave(id)
52
+ return unless Thread.current.equal?(@thread)
53
+
54
+ d = @depth
55
+ total = Process.clock_gettime(Process::CLOCK_MONOTONIC) - START_T[d]
56
+ allocs = GC.stat(:total_allocated_objects) - START_A[d]
57
+ gc_ms = GC.stat(:time) - START_G[d]
58
+ @depth = d - 1
59
+ record(STATS[id], d, total, allocs, gc_ms)
60
+ CHILD_T[d - 1] += total
61
+ CHILD_A[d - 1] += allocs
62
+ CHILD_G[d - 1] += gc_ms
63
+ end
64
+
65
+ private
66
+
67
+ def record(stat, depth, total, allocs, gc_ms)
68
+ stat[0] += total - CHILD_T[depth]
69
+ stat[1] += SQL_T[depth] # SQL fired directly in this method (not in instrumented children)
70
+ stat[2] += gc_ms - CHILD_G[depth] # GC pauses that started directly in this method
71
+ stat[3] += total
72
+ stat[4] += 1
73
+ stat[5] += SQL_N[depth]
74
+ stat[6] += allocs - CHILD_A[depth] # objects allocated directly in this method
75
+ end
76
+
77
+ def record_sql(start, finish)
78
+ return if @depth.zero? || !Thread.current.equal?(@thread)
79
+
80
+ SQL_T[@depth] += finish - start
81
+ SQL_N[@depth] += 1
82
+ end
83
+
84
+ def register(name)
85
+ NAMES << name.freeze
86
+ STATS << [0.0, 0.0, 0, 0.0, 0, 0, 0]
87
+ NAMES.size - 1
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MethodTimer
4
+ # Prints the `run` and `bench` reports to stderr.
5
+ module Reporter
6
+ def report(threshold: 0.001)
7
+ all = stats
8
+ @name_width = [65, *all.keys.map(&:length)].max
9
+ warn "\n%-*s %10s %10s %10s %10s %10s %10s %12s" %
10
+ [@name_width, 'method', 'self(ruby)', 'self(sql)', 'self(gc)', 'total', 'calls', 'sql calls', 'allocs']
11
+ warn '-' * (@name_width + 79)
12
+ all.sort_by { |_, v| -self_ruby(v) }.each do |name, v|
13
+ next if v[:total_t] < threshold
14
+ warn '%-*s %9.5fs %9.5fs %9.5fs %9.5fs %10d %10d %12d' %
15
+ [@name_width, name, self_ruby(v), v[:sql_t], v[:gc_t], v[:total_t], v[:calls], v[:sql_calls], v[:allocs]]
16
+ end
17
+ warn '-' * (@name_width + 79)
18
+ report_totals(all.values)
19
+ report_rss("peak (sampled every #{Memory::RSS_INTERVAL}s)") if @rss_peak
20
+ warn '-' * (@name_width + 79)
21
+ end
22
+
23
+ private
24
+
25
+ def self_ruby(stats)
26
+ stats[:self_t] - stats[:sql_t] - stats[:gc_t]
27
+ end
28
+
29
+ def report_totals(values)
30
+ w = @name_width
31
+ calls = values.sum { |v| v[:calls] }
32
+ sql_t = values.sum { |v| v[:sql_t] }
33
+ sql_calls = values.sum { |v| v[:sql_calls] }
34
+ gc_t = values.sum { |v| v[:gc_t] }
35
+ allocs = values.sum { |v| v[:allocs] }
36
+ warn '%-*s %10s %10s %10s %9.5fs' % [w, 'TOTAL (wall time of run block)', '', '', '', @grand_total]
37
+ warn '%-*s %9.5fs' % [w, ' of which captured (sum of self ruby)', values.sum { |v| self_ruby(v) }]
38
+ warn '%-*s %10s %9.5fs %10s %10s %10s %10d' % [w, ' of which SQL (sum of self sql / calls)', '', sql_t,
39
+ '', '', '', sql_calls]
40
+ warn '%-*s %10s %10s %9.5fs' % [w, ' of which GC (sum of self gc)', '', '', gc_t]
41
+ warn '%-*s %9.5fs (%d calls x %.2fµs, counted in callers\' self)' %
42
+ [w, ' of which instrumentation overhead (estimated)', calls * @overhead, calls, @overhead * 1e6]
43
+ warn '%-*s %10s %10s %10s %10s %10s %10s %12d' % [w, ' total allocations (sum of self allocs)', '', '', '', '',
44
+ '', '', allocs]
45
+ end
46
+
47
+ def report_bench((t0, gc0, alloc0), (sql_t, sql_calls), rss, sampler)
48
+ warn "\n%-20s %9.3fs" % ['wall', Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0]
49
+ warn '%-20s %9.3fs' % ['GC', (GC.stat(:time) - gc0) / 1000.0]
50
+ warn '%-20s %9.3fs (%d queries)' % ['SQL', sql_t, sql_calls]
51
+ warn '%-20s %10d' % ['allocations', GC.stat(:total_allocated_objects) - alloc0]
52
+ return unless rss
53
+
54
+ @rss_end = current_rss
55
+ @rss_peak = [@rss_start, stop_rss_sampler(sampler), @rss_end].compact.max
56
+ report_rss("peak (sampled every #{Memory::RSS_INTERVAL}s)")
57
+ end
58
+
59
+ def report_rss(peak_label = 'peak')
60
+ warn ''
61
+ warn '%-*s %10s' % [@name_width, ' RSS start', format_bytes(@rss_start)]
62
+ warn '%-*s %10s' % [@name_width, " RSS #{peak_label}", format_bytes(@rss_peak)]
63
+ warn '%-*s %10s' % [@name_width, ' RSS end', format_bytes(@rss_end)]
64
+ return unless @rss_peak && @rss_start
65
+
66
+ warn '%-*s %10s' % [@name_width, ' RSS growth (peak - start)', format_bytes(@rss_peak - @rss_start)]
67
+ end
68
+
69
+ def format_bytes(bytes)
70
+ return '-' unless bytes
71
+ value = bytes.to_f
72
+ %w[B KB MB GB].each do |unit|
73
+ return '%.1f%s' % [value, unit] if value < 1024 || unit == 'GB'
74
+ value /= 1024
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MethodTimer
4
+ # SQL tracking through ActiveRecord's `sql.active_record` notifications; a no-op without ActiveSupport.
5
+ module Sql
6
+ private
7
+
8
+ def subscribe_sql
9
+ return unless defined?(ActiveSupport::Notifications)
10
+
11
+ ActiveSupport::Notifications.subscribe('sql.active_record') do |_, start, finish, _, payload|
12
+ yield(start, finish) unless payload[:cached] || payload[:name] == 'SCHEMA'
13
+ end
14
+ end
15
+
16
+ def unsubscribe_sql(subscriber)
17
+ ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MethodTimer
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'method_timer/version'
4
+ require_relative 'method_timer/recorder'
5
+ require_relative 'method_timer/instrumenter'
6
+ require_relative 'method_timer/reporter'
7
+ require_relative 'method_timer/sql'
8
+ require_relative 'method_timer/memory'
9
+
10
+ # Times every method under a class or namespace, reporting self/total time, SQL time, GC time, allocations and
11
+ # call counts. See README.md for usage and how to read the report.
12
+ #
13
+ # The parts are modules extended onto MethodTimer, so they share its state and the generated wrappers call
14
+ # MethodTimer.enter / MethodTimer.leave directly, without an extra object per call.
15
+ module MethodTimer
16
+ extend Recorder
17
+ extend Instrumenter
18
+ extend Reporter
19
+ extend Sql
20
+ extend Memory
21
+
22
+ @depth = 0
23
+ @thread = nil
24
+ @instrumented = {}
25
+ @grand_total = 0.0
26
+ @overhead = 0.0
27
+ @root = nil
28
+ @strip_root = true
29
+ @common_prefix = ''
30
+ @name_width = 65
31
+
32
+ class << self
33
+ def run(rss: false)
34
+ reset
35
+ subscriber = subscribe_sql { |start, finish| record_sql(start, finish) }
36
+ GC.start
37
+ @rss_start = @rss_peak = rss ? current_rss : nil
38
+ sampler = start_rss_sampler if rss
39
+ @thread = Thread.current
40
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
+ yield
42
+ ensure
43
+ @grand_total = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
44
+ @thread = nil
45
+ if rss
46
+ @rss_end = current_rss
47
+ @rss_peak = [@rss_start, stop_rss_sampler(sampler), @rss_end].compact.max
48
+ end
49
+ unsubscribe_sql(subscriber)
50
+ report
51
+ end
52
+
53
+ def bench(rss: false)
54
+ sql = [0.0, 0] # [time, calls]
55
+ subscriber = subscribe_sql do |start, finish|
56
+ sql[0] += finish - start
57
+ sql[1] += 1
58
+ end
59
+ GC.start
60
+ @rss_start = @rss_peak = rss ? current_rss : nil
61
+ sampler = start_rss_sampler if rss
62
+ start = [Process.clock_gettime(Process::CLOCK_MONOTONIC), GC.stat(:time), GC.stat(:total_allocated_objects)]
63
+ yield
64
+ ensure
65
+ unsubscribe_sql(subscriber)
66
+ report_bench(start, sql, rss, sampler) if start
67
+ end
68
+ end
69
+ end
metadata ADDED
@@ -0,0 +1,53 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: method_timer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Konrad Jurkowski
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Instruments every method under a class or namespace and reports self/total
13
+ time, SQL time, GC time, allocations and call counts, with allocation-free wrappers
14
+ and an estimate of its own overhead.
15
+ email:
16
+ - konrad@jurkowski.me
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE.txt
22
+ - README.md
23
+ - lib/method_timer.rb
24
+ - lib/method_timer/instrumenter.rb
25
+ - lib/method_timer/memory.rb
26
+ - lib/method_timer/recorder.rb
27
+ - lib/method_timer/reporter.rb
28
+ - lib/method_timer/sql.rb
29
+ - lib/method_timer/version.rb
30
+ homepage: https://github.com/msx2/method_timer
31
+ licenses:
32
+ - MIT
33
+ metadata:
34
+ source_code_uri: https://github.com/msx2/method_timer
35
+ rubygems_mfa_required: 'true'
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '3.4'
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubygems_version: 3.6.9
51
+ specification_version: 4
52
+ summary: Low-overhead per-method timing for Ruby code, made for profiling from a console.
53
+ test_files: []