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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -0
- data/README.md +109 -213
- data/docs/examples/benchmarking.md +95 -0
- data/docs/examples/class-and-module-methods.md +126 -0
- data/docs/examples/plain-ruby.md +243 -0
- data/docs/examples/rails-console.md +185 -0
- data/docs/examples/real-world-export.md +40 -0
- data/docs/examples/reports-as-data.md +124 -0
- data/docs/reference.md +272 -0
- data/lib/method_timer/instrumenter.rb +50 -24
- data/lib/method_timer/memory.rb +6 -0
- data/lib/method_timer/recorder.rb +13 -11
- data/lib/method_timer/reporter.rb +76 -44
- data/lib/method_timer/sql.rb +8 -2
- data/lib/method_timer/version.rb +1 -1
- data/lib/method_timer.rb +22 -10
- metadata +10 -2
|
@@ -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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
81
|
-
|
|
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(
|
|
90
|
-
return
|
|
91
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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 =
|
|
104
|
-
wrap_methods(wrapper, 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
|
-
#
|
|
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,
|
|
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("#{
|
|
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
|
data/lib/method_timer/memory.rb
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
59
|
+
gc_ns = GC.total_time - START_G[d]
|
|
58
60
|
@depth = d - 1
|
|
59
|
-
record(STATS[id], d, total, allocs,
|
|
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] +=
|
|
64
|
+
CHILD_G[d - 1] += gc_ns
|
|
63
65
|
end
|
|
64
66
|
|
|
65
67
|
private
|
|
66
68
|
|
|
67
|
-
def record(stat, depth, total, allocs,
|
|
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] +=
|
|
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(
|
|
79
|
+
def record_sql(duration)
|
|
78
80
|
return if @depth.zero? || !Thread.current.equal?(@thread)
|
|
79
81
|
|
|
80
|
-
SQL_T[@depth] +=
|
|
82
|
+
SQL_T[@depth] += duration
|
|
81
83
|
SQL_N[@depth] += 1
|
|
82
84
|
end
|
|
83
85
|
|