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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +296 -0
- data/lib/method_timer/instrumenter.rb +144 -0
- data/lib/method_timer/memory.rb +71 -0
- data/lib/method_timer/recorder.rb +90 -0
- data/lib/method_timer/reporter.rb +78 -0
- data/lib/method_timer/sql.rb +20 -0
- data/lib/method_timer/version.rb +5 -0
- data/lib/method_timer.rb +69 -0
- metadata +53 -0
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
|
data/lib/method_timer.rb
ADDED
|
@@ -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: []
|