method_timer 0.1.0 → 0.2.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.
@@ -0,0 +1,124 @@
1
+ # Sorting reports and keeping results as data
2
+
3
+ [README](../../README.md) · [Report reference and schema](../reference.md#report)
4
+
5
+ Use hash mode when another piece of console code needs the numbers. Formatting is global,
6
+ but measurement methods still return the block's value.
7
+
8
+ ## Measure without printing a table
9
+
10
+ ```ruby
11
+ require 'method_timer'
12
+
13
+ class ReportWork
14
+ def wait = sleep(0.003)
15
+
16
+ def allocate
17
+ Array.new(1_000) { Object.new }
18
+ end
19
+ end
20
+
21
+ MethodTimer.format = :hash
22
+ MethodTimer.install(ReportWork)
23
+ work = ReportWork.new
24
+
25
+ value = MethodTimer.run do
26
+ work.wait
27
+ work.allocate
28
+ :done
29
+ end
30
+
31
+ first = MethodTimer.report(sort: :allocs, limit: 1, threshold: 0)
32
+ require 'pp'
33
+ pp first
34
+ ```
35
+
36
+ `value` is still `:done`. Complete captured output on Ruby 4.0.9 (arm64 macOS), including
37
+ the installation notice on stderr and `pp first` on stdout. `run` itself prints no report:
38
+
39
+ ```text
40
+ MethodTimer: instrumented 1 classes/modules under ReportWork (overhead 0.60µs per call).
41
+ {methods:
42
+ {"ReportWork#allocate" =>
43
+ {self_t: 7.100001676008105e-05,
44
+ sql_t: 0.0,
45
+ gc_t: 0.0,
46
+ total_t: 7.100001676008105e-05,
47
+ calls: 1,
48
+ sql_calls: 0,
49
+ allocs: 1007}},
50
+ totals:
51
+ {wall_t: 0.003851999994367361,
52
+ ruby_t: 0.003842000034637749,
53
+ sql_t: 0.0,
54
+ gc_t: 0.0,
55
+ calls: 2,
56
+ sql_calls: 0,
57
+ allocs: 1012,
58
+ overhead_t: 1.1989999999059364e-06},
59
+ rss: nil}
60
+ ```
61
+
62
+ `first` contains:
63
+
64
+ - `methods`: one selected row, `ReportWork#allocate`, with raw numeric method metrics.
65
+ - `totals`: both calls, including the hidden `wait` method.
66
+ - `rss`: `nil`, because this run did not request memory sampling.
67
+
68
+ Use `threshold: 0` for all called methods, including those too fast for the default threshold.
69
+ All times are unrounded seconds; memory readings are bytes.
70
+
71
+ ## Render the same saved measurement
72
+
73
+ ```ruby
74
+ MethodTimer.format = :table
75
+ MethodTimer.report(sort: :allocs, limit: 1, threshold: 0)
76
+ ```
77
+
78
+ Complete stderr from that saved measurement, without rerunning the workload:
79
+
80
+ ```text
81
+ method self(ruby) self(sql) self(gc) total calls sql calls allocs
82
+ ------------------------------------------------------------------------------------------------------------------------------------------------
83
+ ReportWork#allocate 0.00007s 0.00000s 0.00000s 0.00007s 1 0 1007
84
+ ------------------------------------------------------------------------------------------------------------------------------------------------
85
+ TOTAL (wall time of run block) 0.00385s
86
+ of which captured (sum of self ruby) 0.00384s
87
+ of which SQL (sum of self sql / calls) 0.00000s 0
88
+ of which GC (sum of self gc) 0.00000s
89
+ of which instrumentation overhead (estimated) 0.00000s (2 calls x 0.60µs, counted in callers' self)
90
+ total allocations (sum of self allocs) 1012
91
+ ------------------------------------------------------------------------------------------------------------------------------------------------
92
+ ```
93
+
94
+ No work is run again. The table uses exactly the same timing and allocation data as the hash.
95
+ Sort keys are `:self`, `:total`, `:sql`, `:gc`, `:calls`, `:sql_calls` and `:allocs`, all
96
+ descending with a method-name tie breaker. Filtering and limiting never reduce the totals.
97
+
98
+ Do not sort on `self_t` to imitate the table's `self(ruby)` column: the raw `self_t` metric
99
+ includes SQL and GC. The `:self` sort subtracts both, and `totals[:ruby_t]` already excludes them.
100
+
101
+ ## The next measurement replaces the saved one
102
+
103
+ ```ruby
104
+ MethodTimer.format = :hash
105
+ MethodTimer.bench { 100.times { Object.new } }
106
+ latest = MethodTimer.report
107
+
108
+ first[:totals][:calls] # => 2, still the original profile
109
+ latest[:methods] # => {}
110
+
111
+ MethodTimer.uninstall
112
+ MethodTimer.format = :table
113
+ ```
114
+
115
+ The benchmark replaces the gem's internal snapshot, but does not mutate `first`.
116
+ It has only block-level totals: `wall_t`, `sql_t`, `gc_t`, `sql_calls` and `allocs`.
117
+ You may retain returned hashes yourself; MethodTimer does not keep a history.
118
+
119
+ Returned hashes are detached, so changing their nested metrics does not affect later reports.
120
+ `reset` and `uninstall` clear saved measurements but do not reset the configured format.
121
+ The explicit final assignment above leaves the console ready to print tables again.
122
+
123
+ For two comparable profiles, capture a report after each run rather than inspecting live
124
+ `stats` after a later benchmark or reset. See the [reference](../reference.md#stats-reset-and-uninstall).
data/docs/reference.md ADDED
@@ -0,0 +1,272 @@
1
+ # API reference
2
+
3
+ [README](../README.md) · [Examples](../README.md#examples-and-reference)
4
+
5
+ MethodTimer is a console profiler with global configuration and one saved measurement.
6
+ It requires Ruby 3.4+ and has no runtime dependencies. The Ruby version floor keeps the
7
+ argument-forwarding wrappers allocation-free.
8
+
9
+ ## `install`
10
+
11
+ Start with just the namespace you want to investigate; no options are required:
12
+
13
+ ```ruby
14
+ MethodTimer.install(MyApp::Billing)
15
+ ```
16
+
17
+ The root is a class or namespace. Installation discovers classes under it and wraps existing
18
+ instance methods at their defining owners, including named modules mixed directly into those
19
+ classes. It does not automatically instrument external superclasses.
20
+
21
+ For example, the call above covers instance methods of `MyApp::Billing::MonthlyRun`,
22
+ `MyApp::Billing::InvoiceExport` and other classes already defined under that namespace.
23
+ It does not include their class methods by default. The namespace and classes must already
24
+ exist in your console; see the [complete CSV example](examples/plain-ruby.md) for a runnable setup.
25
+
26
+ ### Common ways to install
27
+
28
+ These are alternative scopes, using your application's existing constants. Call
29
+ `MethodTimer.uninstall` before trying a different scope so earlier wrappers do not stay active.
30
+
31
+ **One class, including its private helpers:**
32
+
33
+ ```ruby
34
+ MethodTimer.install(MyApp::Billing::MonthlyRun)
35
+ ```
36
+
37
+ **Several specific classes instead of the whole namespace:**
38
+
39
+ ```ruby
40
+ MethodTimer.install(MyApp::Billing::MonthlyRun)
41
+ MethodTimer.install(MyApp::Billing::InvoiceExport)
42
+ ```
43
+
44
+ Both installs remain active together; one `MethodTimer.uninstall` removes both.
45
+
46
+ **Only entry points, without wrapping every helper:**
47
+
48
+ ```ruby
49
+ MethodTimer.install(MyApp::Billing, only: [:call])
50
+ ```
51
+
52
+ Helpers still execute, but their work belongs to the selected caller rather than separate rows.
53
+
54
+ **Class and module methods as well as instance methods:**
55
+
56
+ ```ruby
57
+ MethodTimer.install(MyApp::Billing, singleton: true)
58
+ ```
59
+
60
+ To select only methods named `call` on both paths, combine `singleton: true` with `only: [:call]`.
61
+ See the [class and module example](examples/class-and-module-methods.md) for owner labels and inheritance.
62
+
63
+ **Full namespace labels and unchanged SQL logging:**
64
+
65
+ ```ruby
66
+ MethodTimer.install(MyApp::Billing, strip_root: false, silence_sql: false)
67
+ ```
68
+
69
+ This keeps labels such as `MyApp::Billing::MonthlyRun#call` and leaves the current
70
+ ActiveRecord logger untouched.
71
+
72
+ ### Options and behavior
73
+
74
+ | Option | Default | Meaning |
75
+ | --- | --- | --- |
76
+ | `only:` | `nil` | Array of method-name symbols; `nil` selects all, `[]` selects none. |
77
+ | `singleton:` | `false` | Also wrap singleton methods on classes and modules, and named modules they extend. |
78
+ | `strip_root:` | `true` | Remove the common namespace from method labels. |
79
+ | `silence_sql:` | `true` | Set `ActiveRecord::Base.logger` to `nil`, if ActiveRecord is loaded. |
80
+
81
+ `only:` applies to public, protected and private methods, including singleton methods.
82
+ Missing names are skipped. Unselected methods do not get wrappers, so their time belongs
83
+ to any selected caller rather than a separate row. Other `only:` types raise `ArgumentError`.
84
+
85
+ Direct instance methods use `Service#call`; direct singleton methods use `Service.call`.
86
+ `module_function` copies use `Tools.call`. Named mixin implementations keep their
87
+ `Mixin#call` owner label, including `extend self`.
88
+
89
+ A mixin shared by `include` and `extend` is instrumented once, with shared call counts.
90
+ `super` is measured separately at each installed owner it reaches. See the
91
+ [class and module walkthrough](examples/class-and-module-methods.md).
92
+
93
+ You can install unrelated roots together. Existing owners keep their first selection:
94
+ uninstall before changing that filter. A later install with `singleton: true` can add
95
+ previously uninstrumented singleton owners without duplicating instance wrappers.
96
+ Discovery reads constants and can trigger autoloading.
97
+
98
+ The original SQL logger is saved across repeated installs. `uninstall` restores it only
99
+ if the current logger is still `nil`, leaving a replacement logger untouched.
100
+
101
+ ## `run`
102
+
103
+ ```ruby
104
+ result = MethodTimer.run(rss: true) { thing_to_profile }
105
+ ```
106
+
107
+ Resets previous counters, forces GC before timing, then profiles the block on its current
108
+ thread. Returns the block's value. Existing instrumentation stays installed between runs.
109
+
110
+ - With the default `format = :table`, prints a method report to stderr on exit.
111
+ - With `format = :hash`, saves the measurement without rendering a report.
112
+ - `rss: false` is the default. `rss: true` additionally samples process memory.
113
+ - Exceptions propagate normally. Measurements for a block that raises are still saved,
114
+ and table mode still attempts to print them.
115
+
116
+ The wall time includes the block and its instrumentation overhead, but excludes initial
117
+ GC, final RSS collection, SQL unsubscription and report output.
118
+
119
+ ## `bench`
120
+
121
+ ```ruby
122
+ result = MethodTimer.bench(rss: true) { thing_to_measure }
123
+ ```
124
+
125
+ Measures wall time, GC time, same-thread SQL and process-wide allocations without enabling
126
+ per-method recording. Returns the block's value. It resets prior counters and replaces
127
+ the saved measurement; its method map is empty.
128
+
129
+ Like `run`, it forces GC before measurement, supports `rss:`, prints only in table mode,
130
+ and preserves exceptions. End counters are captured before cleanup and rendering.
131
+ For comparisons, use a fresh process without installed wrappers; already-installed wrappers
132
+ still perform their inactive thread check during `bench`.
133
+
134
+ ## `format`
135
+
136
+ ```ruby
137
+ MethodTimer.format = :hash
138
+ MethodTimer.run { thing_to_profile }
139
+ data = MethodTimer.report
140
+
141
+ MethodTimer.format = :table
142
+ MethodTimer.report
143
+ ```
144
+
145
+ Only `:table` and `:hash` are accepted; other values raise `ArgumentError`.
146
+ Set the format before measuring. It controls rendering, not collection or block return values.
147
+ It may be changed afterward to render the same saved measurement differently.
148
+ Installation notices are independent of the report format.
149
+
150
+ ## `report`
151
+
152
+ ```ruby
153
+ MethodTimer.report(sort: :allocs, limit: 20, threshold: 0)
154
+ ```
155
+
156
+ Renders the last `run` or `bench`, not a new measurement. In table mode it prints to stderr;
157
+ in hash mode it returns the structure below without printing.
158
+
159
+ | Option | Default | Meaning |
160
+ | --- | --- | --- |
161
+ | `sort:` | `:self` | Sort by self Ruby time, total time, SQL, GC, calls, SQL calls or allocations. |
162
+ | `limit:` | `nil` | At most this many methods; a non-negative integer or `nil` for all. |
163
+ | `threshold:` | `0.001` | Hide methods with total time below this many seconds. |
164
+
165
+ Sort keys: `:self`, `:total`, `:sql`, `:gc`, `:calls`, `:sql_calls`, `:allocs`.
166
+ All sort descending; equal values use the method name. Unknown sort keys and invalid limits
167
+ raise `ArgumentError` before anything is printed.
168
+
169
+ The threshold is applied before the limit. `limit: 0` leaves only totals.
170
+ Selection affects both formats' method rows, never their totals. It has no effect on
171
+ collection overhead. Pass `threshold: 0` when the hash should include tiny methods too.
172
+
173
+ ### Hash structure
174
+
175
+ This is an actual returned hash from the [reports-as-data example](examples/reports-as-data.md),
176
+ captured on Ruby 4.0.9. The example also shows its table rendering from the same measurement:
177
+
178
+ ```ruby
179
+ {
180
+ methods: {
181
+ 'ReportWork#allocate' => {
182
+ self_t: 7.100001676008105e-05, sql_t: 0.0, gc_t: 0.0,
183
+ total_t: 7.100001676008105e-05, calls: 1, sql_calls: 0, allocs: 1007
184
+ }
185
+ },
186
+ totals: {
187
+ wall_t: 0.003851999994367361, ruby_t: 0.003842000034637749, sql_t: 0.0, gc_t: 0.0,
188
+ calls: 2, sql_calls: 0, allocs: 1012, overhead_t: 1.1989999999059364e-06
189
+ },
190
+ rss: nil
191
+ }
192
+ ```
193
+
194
+ - All times are unrounded seconds; RSS values are bytes.
195
+ - Per-method `self_t` includes SQL and GC. Self Ruby time is `self_t - sql_t - gc_t`.
196
+ - `total_t` includes instrumented callees; `calls`, SQL and allocations on a row do not.
197
+ - Profile totals sum every captured method's self metrics and counts, even when rows are hidden.
198
+ `wall_t` is the whole block's wall time; `overhead_t` is the estimated wrapper overhead.
199
+ - Benchmark totals have only `wall_t`, `sql_t`, `gc_t`, `sql_calls` and `allocs`; `methods` is `{}`.
200
+ - `rss` is `nil` without `rss: true`; otherwise it has `:start`, `:peak` and `:end` byte readings.
201
+ Unavailable requested readings can be `nil` within the hash.
202
+
203
+ Only one internal measurement is kept. New measurements replace it; returned hashes are
204
+ detached and may be edited without changing the saved data. Labels and overhead calibration
205
+ are captured at measurement end, so installing another root does not change an old report.
206
+ Before a measurement, or after `reset` / `uninstall`, reporting yields an empty profile.
207
+
208
+ ## `stats`, `reset` and `uninstall`
209
+
210
+ - `stats` returns the live, unfiltered per-method counters, using the same fields as
211
+ `report[:methods]`. Its labels reflect the current installation's common namespace.
212
+ It does not include wall time or RSS. `bench`, `reset` and `uninstall` clear these counters.
213
+ - `reset` clears counters and the saved measurement, but keeps wrappers installed.
214
+ - `uninstall` removes every added method wrapper, clears measurements and restores the SQL
215
+ logger as described above. It returns the number of instrumented owners cleared, including
216
+ mixins and singleton classes. Ruby leaves the emptied prepended modules in the ancestor chain.
217
+ - Both reset operations keep the configured `format`.
218
+
219
+ ## Measurement semantics
220
+
221
+ | Report column | Meaning |
222
+ | --- | --- |
223
+ | `self(ruby)` | Own wall time minus instrumented callees, SQL and GC. |
224
+ | `self(sql)` | SQL attributed directly to this frame, not instrumented children. |
225
+ | `self(gc)` | GC attributed directly to this frame, not instrumented children. |
226
+ | `total` | Inclusive wall time, including called methods. |
227
+ | `calls` | Completed wrapper invocations, including those unwinding an exception. |
228
+ | `sql calls` | Uncached, non-schema `sql.active_record` notifications. |
229
+ | `allocs` | Allocations attributed directly to this frame, not instrumented children. |
230
+
231
+ `run` attributes SQL only while an instrumented method is active; `bench` counts SQL anywhere
232
+ in its block. Both ignore SQL from other threads. Timings use monotonic clocks.
233
+ With ActiveSupport 7.2+, GC inside a SQL event is excluded from SQL time and counted only as
234
+ GC. Older versions expose no per-event GC duration, so SQL and GC can overlap and self Ruby
235
+ time can consequently be negative.
236
+
237
+ Wall time includes uninstrumented code and all profiler overhead. The sum of the self columns
238
+ describes captured work, not necessarily the whole block: wrapper bookkeeping and work outside
239
+ timed frames also take time. Never sum the inclusive `total` column to estimate overall time.
240
+
241
+ The overhead estimate is calibrated during installation. It is not automatically subtracted,
242
+ and is not an exact correction for individual methods. Narrow the instrumented scope if this
243
+ estimate dominates the result; use `bench` for an uninstrumented comparison.
244
+
245
+ RSS is the process's resident memory, not object counts or retained Ruby heap size.
246
+ A child `sh` process samples it every second, using `/proc` on Linux or `ps` elsewhere.
247
+ Sampling adds no allocation or GC work to the measured Ruby process, but may miss short-lived
248
+ memory spikes. Start and end readings are included when calculating the reported peak.
249
+
250
+ ## Limitations
251
+
252
+ - Console use only; global state is not safe for overlapping measurements.
253
+ - Only the measuring thread's methods are recorded. GC, allocations and RSS remain process-wide.
254
+ - Installation is a snapshot of existing methods; later definitions are not wrapped.
255
+ Rails attribute readers may be generated lazily after installation.
256
+ - Anonymous mixins are skipped. External inherited owners must be installed explicitly.
257
+ - Lambdas and blocks are not separate method rows; their work belongs to the calling frame.
258
+ - Profiling does not sandbox the block, roll back writes, or make destructive code safe.
259
+
260
+ ## Development
261
+
262
+ The modules are extended onto `MethodTimer` and share its state:
263
+
264
+ - `lib/method_timer.rb`: block measurement, saved snapshot and format default.
265
+ - `instrumenter.rb`: discovery, owner labels, wrappers and overhead calibration.
266
+ - `recorder.rb`: per-depth arrays, self / inclusive counters, reset and raw stats.
267
+ - `reporter.rb`: sorting, selection, hash assembly and table rendering.
268
+ - `sql.rb`: optional ActiveSupport subscriptions.
269
+ - `memory.rb`: RSS reads and child-process sampling.
270
+
271
+ Specs live under `spec/`; `spec/method_timer_spec.rb` exercises block APIs end to end.
272
+ Wrappers must stay allocation-free while recording, not just while inactive.
@@ -1,22 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MethodTimer
4
- # Wraps every method of the instrumented classes (and their directly included modules) in a prepended module
4
+ # Wraps selected methods at their class, module or singleton-class owner in a prepended module
5
5
  # that calls MethodTimer.enter / MethodTimer.leave around it.
6
6
  module Instrumenter
7
7
  CALIBRATION_CALLS = 200_000
8
8
 
9
- def install(root, silence_sql: true, strip_root: true)
9
+ def install(root, silence_sql: true, strip_root: true, only: nil, singleton: false)
10
+ valid_only = only.nil? || (only.is_a?(Array) && only.all?(Symbol))
11
+ raise ArgumentError, 'only must be an array of symbols or nil' unless valid_only
10
12
  @root = root
11
13
  @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))
14
+ if silence_sql && defined?(ActiveRecord::Base)
15
+ # A saved nil logger must stay distinct from no saved logger.
16
+ @saved_sql_logger ||= [ActiveRecord::Base.logger]
17
+ ActiveRecord::Base.logger = nil
18
+ end
19
+ targets = [root]
20
+ each_module(root) { |mod| targets << mod }
21
+ targets.select! { |mod| mod.is_a?(Class) } unless singleton
18
22
  already = @instrumented.size
19
- classes.each { |k| instrument(k) }
23
+ targets.each { |target| instrument(target, only, singleton) }
24
+ @common_prefix = common_namespace(@instrumented.keys.filter_map { |source| owner_name(source) })
20
25
  calibrate
21
26
  warn "MethodTimer: instrumented #{@instrumented.size - already} classes/modules under #{root} " \
22
27
  '(overhead %.2fµs per call).' % (@overhead * 1e6)
@@ -28,11 +33,20 @@ module MethodTimer
28
33
  def uninstall
29
34
  @instrumented.each_value do |wrapper|
30
35
  (wrapper.instance_methods(false) + wrapper.private_instance_methods(false)).each do |m|
36
+ verbosity = $VERBOSE
37
+ # Ruby warns even when removing only our prepended constructor wrapper.
38
+ $VERBOSE = nil if m == :initialize
31
39
  wrapper.send(:remove_method, m)
40
+ ensure
41
+ $VERBOSE = verbosity if m == :initialize
32
42
  end
33
43
  end
34
44
  count = @instrumented.size
35
45
  @instrumented.clear
46
+ if @saved_sql_logger && defined?(ActiveRecord::Base) && ActiveRecord::Base.logger.nil?
47
+ ActiveRecord::Base.logger = @saved_sql_logger.first
48
+ end
49
+ @saved_sql_logger = nil
36
50
  reset
37
51
  count
38
52
  end
@@ -71,14 +85,14 @@ module MethodTimer
71
85
  Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
72
86
  end
73
87
 
74
- def each_class(mod, seen = {}, &blk)
88
+ def each_module(mod, seen = {}, &blk)
75
89
  return if seen[mod]
76
90
  seen[mod] = true
77
91
  mod.constants(false).each do |c|
78
92
  val = mod.const_get(c)
79
93
  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)
94
+ yield(val)
95
+ each_module(val, seen, &blk)
82
96
  end
83
97
  rescue StandardError
84
98
  nil
@@ -86,37 +100,49 @@ module MethodTimer
86
100
 
87
101
  # Strips the namespace shared by all instrumented sources, so labels stay short yet unique
88
102
  # (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)}::/, '')
103
+ def label_for(name)
104
+ return name unless @strip_root && !@common_prefix.to_s.empty?
105
+ name.delete_prefix("#{@common_prefix}::")
92
106
  end
93
107
 
94
- def instrument(klass)
95
- instrument_owner(klass)
96
- own_included_modules(klass).each { |mod| instrument_owner(mod) }
108
+ def owner_name(source)
109
+ source.singleton_class? ? source.attached_object.name : source.name
110
+ end
111
+
112
+ def instrument(target, only, singleton)
113
+ sources = target.is_a?(Class) ? [target] : []
114
+ sources << target.singleton_class if singleton
115
+ sources.each do |source|
116
+ instrument_owner(source, only)
117
+ own_included_modules(source).each { |mod| instrument_owner(mod, only) }
118
+ end
97
119
  end
98
120
 
99
121
  # Prepend a timing wrapper into the method owner itself (class or module) so that each ancestor
100
122
  # layer is measured independently: a method reached via `super` still records its own-owner timing.
101
- def instrument_owner(source)
123
+ def instrument_owner(source, only)
102
124
  return if @instrumented[source]
103
- wrapper = @instrumented[source] = Module.new
104
- wrap_methods(wrapper, source, label_for(source))
125
+ wrapper = Module.new
126
+ wrapped_methods = wrap_methods(wrapper, source, only)
127
+ return if wrapped_methods.empty? && (only || source.singleton_class?)
128
+ @instrumented[source] = wrapper
105
129
  source.prepend(wrapper)
106
130
  end
107
131
 
108
- # Modules mixed directly into klass (not inherited from its superclass, not anonymous wrappers).
132
+ # Named modules mixed into a class or singleton class, excluding inherited and anonymous layers.
109
133
  def own_included_modules(klass)
110
134
  inherited = klass.superclass ? klass.superclass.ancestors : []
111
135
  (klass.ancestors - inherited).select { |a| a.instance_of?(Module) && a != klass && a.name }
112
136
  end
113
137
 
114
- def wrap_methods(wrapper, source, label)
138
+ def wrap_methods(wrapper, source, only)
115
139
  privates = source.private_instance_methods(false)
116
140
  protecteds = source.protected_instance_methods(false)
117
141
  methods = source.instance_methods(false) + privates
142
+ methods &= only if only
143
+ prefix = "#{owner_name(source)}#{source.singleton_class? ? '.' : '#'}"
118
144
  methods.each do |m|
119
- define_wrapper(wrapper, m, register("#{label}##{m}"))
145
+ define_wrapper(wrapper, m, register("#{prefix}#{m}"))
120
146
  wrapper.send(:private, m) if privates.include?(m)
121
147
  wrapper.send(:protected, m) if protecteds.include?(m)
122
148
  end
@@ -28,6 +28,12 @@ module MethodTimer
28
28
 
29
29
  private
30
30
 
31
+ def finish_rss(sampler)
32
+ @rss_end = current_rss
33
+ @rss_peak = [@rss_start, stop_rss_sampler(sampler), @rss_end].compact.max
34
+ { start: @rss_start, peak: @rss_peak, end: @rss_end }
35
+ end
36
+
31
37
  # Samples the peak RSS from a child shell process, so sampling allocates nothing in this process and can't
32
38
  # skew allocs or self(gc). The child keeps the maximum and prints it (in KB) once, when stopped.
33
39
  def start_rss_sampler
@@ -3,7 +3,7 @@
3
3
  module MethodTimer
4
4
  # Collects per-method stats during a run.
5
5
  module Recorder
6
- # Per-method stats, indexed by method id: [self_t, sql_t, gc_ms, total_t, calls, sql_calls, allocs]
6
+ # Per-method stats, indexed by method id: [self_t, sql_t, gc_ns, total_t, calls, sql_calls, allocs]
7
7
  NAMES = []
8
8
  STATS = []
9
9
  # Per-depth frame data; depth 0 is the code running outside instrumented methods.
@@ -19,15 +19,17 @@ module MethodTimer
19
19
  def reset
20
20
  STATS.map! { [0.0, 0.0, 0, 0.0, 0, 0, 0] }
21
21
  @depth = 0
22
+ @last_measurement = nil
22
23
  @grand_total = 0.0
23
24
  end
24
25
 
25
26
  # { "Class#method" => { self_t:, sql_t:, gc_t:, total_t:, calls:, sql_calls:, allocs: } } for called methods
26
27
  def stats
27
- STATS.each_with_index.filter_map do |(self_t, sql_t, gc_ms, total_t, calls, sql_calls, allocs), id|
28
+ STATS.each_with_index.filter_map do |(self_t, sql_t, gc_ns, total_t, calls, sql_calls, allocs), id|
28
29
  next if calls.zero?
29
30
 
30
- [NAMES[id], { self_t:, sql_t:, gc_t: gc_ms / 1000.0, total_t:, calls:, sql_calls:, allocs: }]
31
+ gc_t = gc_ns / 1_000_000_000.0
32
+ [label_for(NAMES[id]), { self_t:, sql_t:, gc_t:, total_t:, calls:, sql_calls:, allocs: }]
31
33
  end.to_h
32
34
  end
33
35
 
@@ -43,7 +45,7 @@ module MethodTimer
43
45
  CHILD_G[d] = 0
44
46
  SQL_T[d] = 0.0
45
47
  SQL_N[d] = 0
46
- START_G[d] = GC.stat(:time)
48
+ START_G[d] = GC.total_time
47
49
  START_A[d] = GC.stat(:total_allocated_objects)
48
50
  START_T[d] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
49
51
  end
@@ -54,30 +56,30 @@ module MethodTimer
54
56
  d = @depth
55
57
  total = Process.clock_gettime(Process::CLOCK_MONOTONIC) - START_T[d]
56
58
  allocs = GC.stat(:total_allocated_objects) - START_A[d]
57
- gc_ms = GC.stat(:time) - START_G[d]
59
+ gc_ns = GC.total_time - START_G[d]
58
60
  @depth = d - 1
59
- record(STATS[id], d, total, allocs, gc_ms)
61
+ record(STATS[id], d, total, allocs, gc_ns)
60
62
  CHILD_T[d - 1] += total
61
63
  CHILD_A[d - 1] += allocs
62
- CHILD_G[d - 1] += gc_ms
64
+ CHILD_G[d - 1] += gc_ns
63
65
  end
64
66
 
65
67
  private
66
68
 
67
- def record(stat, depth, total, allocs, gc_ms)
69
+ def record(stat, depth, total, allocs, gc_ns)
68
70
  stat[0] += total - CHILD_T[depth]
69
71
  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
72
+ stat[2] += gc_ns - CHILD_G[depth] # GC pauses that started directly in this method
71
73
  stat[3] += total
72
74
  stat[4] += 1
73
75
  stat[5] += SQL_N[depth]
74
76
  stat[6] += allocs - CHILD_A[depth] # objects allocated directly in this method
75
77
  end
76
78
 
77
- def record_sql(start, finish)
79
+ def record_sql(duration)
78
80
  return if @depth.zero? || !Thread.current.equal?(@thread)
79
81
 
80
- SQL_T[@depth] += finish - start
82
+ SQL_T[@depth] += duration
81
83
  SQL_N[@depth] += 1
82
84
  end
83
85