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,126 @@
|
|
|
1
|
+
# Class methods, modules and inheritance
|
|
2
|
+
|
|
3
|
+
[README](../../README.md) · [Installation reference](../reference.md#install)
|
|
4
|
+
|
|
5
|
+
Instance methods are profiled by default. Add `singleton: true` to include class and module
|
|
6
|
+
entry points, including implementations reached through `extend`, `extend self` and
|
|
7
|
+
`module_function`.
|
|
8
|
+
|
|
9
|
+
## A complete example
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
require 'method_timer'
|
|
13
|
+
|
|
14
|
+
module BillingExample
|
|
15
|
+
module Names
|
|
16
|
+
def normalize(value) = value.strip.downcase
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Base
|
|
20
|
+
def self.call(value) = value
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Service < Base
|
|
24
|
+
include Names
|
|
25
|
+
extend Names
|
|
26
|
+
|
|
27
|
+
def self.call(value)
|
|
28
|
+
super(normalize(value))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(value) = normalize(value)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
module Functions
|
|
35
|
+
def slug(value) = value.downcase.tr(' ', '-')
|
|
36
|
+
module_function :slug
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
module Helpers
|
|
40
|
+
extend self
|
|
41
|
+
|
|
42
|
+
def call(value) = value.upcase
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
MethodTimer.install(BillingExample, singleton: true)
|
|
47
|
+
results = MethodTimer.run do
|
|
48
|
+
[
|
|
49
|
+
BillingExample::Service.call(' Invoice '),
|
|
50
|
+
BillingExample::Service.new.call(' Invoice '),
|
|
51
|
+
BillingExample::Functions.slug('Hello World'),
|
|
52
|
+
BillingExample::Helpers.call('memo')
|
|
53
|
+
]
|
|
54
|
+
end
|
|
55
|
+
MethodTimer.report(threshold: 0)
|
|
56
|
+
shared_calls = MethodTimer.stats.fetch('Names#normalize')[:calls]
|
|
57
|
+
MethodTimer.uninstall
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`results` is `["invoice", "invoice", "hello-world", "MEMO"]`. Reprinting with `threshold: 0`
|
|
61
|
+
reveals even the tiny methods that the default one-millisecond threshold may hide.
|
|
62
|
+
|
|
63
|
+
Complete stderr captured on Ruby 4.0.9 (arm64 macOS). The first table is printed by
|
|
64
|
+
`run` with the default threshold: all methods are too small to appear, but totals still
|
|
65
|
+
include them. The second is the same saved measurement printed by `report(threshold: 0)`.
|
|
66
|
+
Values below five decimal places round to `0.00000s`; that does not mean no work occurred.
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
MethodTimer: instrumented 7 classes/modules under BillingExample (overhead 0.63µs per call).
|
|
70
|
+
|
|
71
|
+
method self(ruby) self(sql) self(gc) total calls sql calls allocs
|
|
72
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
73
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
74
|
+
TOTAL (wall time of run block) 0.00002s
|
|
75
|
+
of which captured (sum of self ruby) 0.00001s
|
|
76
|
+
of which SQL (sum of self sql / calls) 0.00000s 0
|
|
77
|
+
of which GC (sum of self gc) 0.00000s
|
|
78
|
+
of which instrumentation overhead (estimated) 0.00000s (7 calls x 0.63µs, counted in callers' self)
|
|
79
|
+
total allocations (sum of self allocs) 43
|
|
80
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
method self(ruby) self(sql) self(gc) total calls sql calls allocs
|
|
83
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
84
|
+
Service.call 0.00001s 0.00000s 0.00000s 0.00001s 1 0 10
|
|
85
|
+
Names#normalize 0.00000s 0.00000s 0.00000s 0.00000s 2 0 12
|
|
86
|
+
Functions.slug 0.00000s 0.00000s 0.00000s 0.00000s 1 0 8
|
|
87
|
+
Base.call 0.00000s 0.00000s 0.00000s 0.00000s 1 0 3
|
|
88
|
+
Helpers#call 0.00000s 0.00000s 0.00000s 0.00000s 1 0 5
|
|
89
|
+
Service#call 0.00000s 0.00000s 0.00000s 0.00000s 1 0 5
|
|
90
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
91
|
+
TOTAL (wall time of run block) 0.00002s
|
|
92
|
+
of which captured (sum of self ruby) 0.00001s
|
|
93
|
+
of which SQL (sum of self sql / calls) 0.00000s 0
|
|
94
|
+
of which GC (sum of self gc) 0.00000s
|
|
95
|
+
of which instrumentation overhead (estimated) 0.00000s (7 calls x 0.63µs, counted in callers' self)
|
|
96
|
+
total allocations (sum of self allocs) 43
|
|
97
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Why the labels differ
|
|
101
|
+
|
|
102
|
+
- `Service.call` and `Service#call` are different definitions and have separate rows.
|
|
103
|
+
- `super` reaches `Base.call`, which is installed through the same namespace.
|
|
104
|
+
- `Names#normalize` is the shared owner implementation. The class reaches it through
|
|
105
|
+
`extend`; an instance reaches it through `include`. It is wrapped once, not once per receiver.
|
|
106
|
+
- `Functions.slug` is the singleton copy made by `module_function`. Its original instance
|
|
107
|
+
definition remains private.
|
|
108
|
+
- `Helpers#call` belongs to the module used by `extend self`, so it retains its `#` owner label.
|
|
109
|
+
|
|
110
|
+
Self time excludes instrumented children; inclusive total time includes them. Do not add
|
|
111
|
+
`Service.call` and `Base.call` totals to estimate the run's wall time.
|
|
112
|
+
|
|
113
|
+
## Filtering and scope
|
|
114
|
+
|
|
115
|
+
`only: [:call]` would select both instance and singleton methods named `call`, but not
|
|
116
|
+
`normalize` or `slug`. Their uninstrumented work would remain in the selected callers' self
|
|
117
|
+
time. Change an existing owner's filter only after `uninstall`.
|
|
118
|
+
|
|
119
|
+
Installing a subclass alone does not patch external parent implementations or their mixins.
|
|
120
|
+
Install those parents too, or choose a namespace containing them. Likewise, a shared module
|
|
121
|
+
retains the selection established when it was first instrumented, even if another class later
|
|
122
|
+
uses it through a different path.
|
|
123
|
+
|
|
124
|
+
Private and protected visibility, arguments, keywords and blocks are preserved.
|
|
125
|
+
Later definitions and anonymous mixins are not discovered. See the [full reference](../reference.md)
|
|
126
|
+
for installation and label rules.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Plain Ruby: finding the cost of an export
|
|
2
|
+
|
|
3
|
+
[README](../../README.md) · [API reference](../reference.md)
|
|
4
|
+
|
|
5
|
+
This larger version of the README example exports 50,000 orders with five items each.
|
|
6
|
+
It shows why many cheap calls can make instrumentation itself expensive.
|
|
7
|
+
The original CSV example is retained here. The separate
|
|
8
|
+
[historical application report](real-world-export.md) preserves the real-work excerpt too.
|
|
9
|
+
All current outputs below were captured on Ruby 4.0.9, CSV 3.3.5, arm64 macOS,
|
|
10
|
+
in one process following the code in order. Tables and footers are unabridged;
|
|
11
|
+
timings and allocations vary between runs.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
This example uses the separate `csv` gem. If you use Bundler, declare it in your application's
|
|
16
|
+
Gemfile as well as MethodTimer; Ruby 3.4+ does not make it automatically available to a bundle:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# Gemfile
|
|
20
|
+
gem 'csv'
|
|
21
|
+
gem 'method_timer', require: false
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Run `bundle install`, then paste the following into a fresh console. Nothing is written to disk;
|
|
25
|
+
the export is an in-memory string. The prices and tax are just sample data, not financial guidance.
|
|
26
|
+
|
|
27
|
+
## Profile the whole namespace
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
require 'csv'
|
|
31
|
+
require 'method_timer'
|
|
32
|
+
|
|
33
|
+
module Shop
|
|
34
|
+
Order = Struct.new(:id, :customer, :items)
|
|
35
|
+
Item = Struct.new(:name, :price, :quantity)
|
|
36
|
+
|
|
37
|
+
class PriceCalculator
|
|
38
|
+
TAX = 0.23
|
|
39
|
+
|
|
40
|
+
def total(order) = order.items.sum { |item| line_total(item) }
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def line_total(item) = (item.price * item.quantity * (1 + TAX)).round(2)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class CsvExport
|
|
48
|
+
def initialize(orders) = @orders = orders
|
|
49
|
+
|
|
50
|
+
def call
|
|
51
|
+
CSV.generate do |csv|
|
|
52
|
+
csv << %w[id customer total]
|
|
53
|
+
@orders.each { |order| csv << row(order) }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def row(order) = [order.id, order.customer.upcase, calculator.total(order)]
|
|
60
|
+
|
|
61
|
+
def calculator = @calculator ||= PriceCalculator.new
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
orders = Array.new(50_000) do |i|
|
|
66
|
+
Shop::Order.new(i, "customer #{i}", Array.new(5) { |n| Shop::Item.new("item #{n}", 9.99, n + 1) })
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
MethodTimer.install(Shop)
|
|
70
|
+
csv = MethodTimer.run { Shop::CsvExport.new(orders).call }
|
|
71
|
+
broad_calls = MethodTimer.stats.values.sum { |stats| stats[:calls] }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Data creation is outside the measured block. The export result remains in `csv`; MethodTimer
|
|
75
|
+
does not replace it with a report.
|
|
76
|
+
|
|
77
|
+
Complete stderr:
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
MethodTimer: instrumented 4 classes/modules under Shop (overhead 0.62µs per call).
|
|
81
|
+
|
|
82
|
+
method self(ruby) self(sql) self(gc) total calls sql calls allocs
|
|
83
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
84
|
+
PriceCalculator#line_total 0.28903s 0.00000s 0.00000s 0.34740s 250000 0 12
|
|
85
|
+
PriceCalculator#total 0.16870s 0.00000s 0.00000s 0.52206s 50000 0 10
|
|
86
|
+
CsvExport#row 0.11930s 0.00000s 0.00766s 0.66412s 50000 0 150014
|
|
87
|
+
CsvExport#call 0.08534s 0.00000s 0.00000s 0.74946s 1 0 250127
|
|
88
|
+
Item#price 0.03083s 0.00000s 0.00000s 0.03083s 250000 0 3
|
|
89
|
+
Item#quantity 0.02754s 0.00000s 0.00000s 0.02754s 250000 0 2
|
|
90
|
+
Order#items 0.00596s 0.00000s 0.00000s 0.00596s 50000 0 2
|
|
91
|
+
CsvExport#calculator 0.00527s 0.00000s 0.00000s 0.00527s 50000 0 7
|
|
92
|
+
Order#customer 0.00512s 0.00000s 0.00000s 0.00512s 50000 0 2
|
|
93
|
+
Order#id 0.00470s 0.00000s 0.00000s 0.00470s 50000 0 3
|
|
94
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
95
|
+
TOTAL (wall time of run block) 0.74947s
|
|
96
|
+
of which captured (sum of self ruby) 0.74180s
|
|
97
|
+
of which SQL (sum of self sql / calls) 0.00000s 0
|
|
98
|
+
of which GC (sum of self gc) 0.00766s
|
|
99
|
+
of which instrumentation overhead (estimated) 0.65463s (1050002 calls x 0.62µs, counted in callers' self)
|
|
100
|
+
total allocations (sum of self allocs) 400185
|
|
101
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`Struct` accessors are real methods, so profiling `Shop` includes them. The complete run has
|
|
105
|
+
1,050,002 instrumented calls. Check the footer's estimated overhead before treating every
|
|
106
|
+
large `self(ruby)` value as useful application work.
|
|
107
|
+
|
|
108
|
+
Sort another view without rerunning:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
MethodTimer.report(sort: :allocs, limit: 5, threshold: 0)
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Complete stderr, showing five rows but retaining totals for all 1,050,002 calls:
|
|
115
|
+
|
|
116
|
+
```text
|
|
117
|
+
method self(ruby) self(sql) self(gc) total calls sql calls allocs
|
|
118
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
119
|
+
CsvExport#call 0.08534s 0.00000s 0.00000s 0.74946s 1 0 250127
|
|
120
|
+
CsvExport#row 0.11930s 0.00000s 0.00766s 0.66412s 50000 0 150014
|
|
121
|
+
PriceCalculator#line_total 0.28903s 0.00000s 0.00000s 0.34740s 250000 0 12
|
|
122
|
+
PriceCalculator#total 0.16870s 0.00000s 0.00000s 0.52206s 50000 0 10
|
|
123
|
+
CsvExport#calculator 0.00527s 0.00000s 0.00000s 0.00527s 50000 0 7
|
|
124
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
125
|
+
TOTAL (wall time of run block) 0.74947s
|
|
126
|
+
of which captured (sum of self ruby) 0.74180s
|
|
127
|
+
of which SQL (sum of self sql / calls) 0.00000s 0
|
|
128
|
+
of which GC (sum of self gc) 0.00766s
|
|
129
|
+
of which instrumentation overhead (estimated) 0.65463s (1050002 calls x 0.62µs, counted in callers' self)
|
|
130
|
+
total allocations (sum of self allocs) 400185
|
|
131
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The price calculations create relatively few objects. The row and CSV generation paths
|
|
135
|
+
allocate arrays and strings; these are more useful places to investigate allocation volume.
|
|
136
|
+
Actual allocation totals, GC pauses and timings depend on the Ruby version and process.
|
|
137
|
+
|
|
138
|
+
## Narrow the scope before drawing conclusions
|
|
139
|
+
|
|
140
|
+
Remove the original wrappers first. Reinstalling selected classes without uninstalling would
|
|
141
|
+
leave the earlier `Struct` instrumentation active.
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
MethodTimer.uninstall
|
|
145
|
+
MethodTimer.install(Shop::CsvExport)
|
|
146
|
+
MethodTimer.install(Shop::PriceCalculator)
|
|
147
|
+
narrow_csv = MethodTimer.run { Shop::CsvExport.new(orders).call }
|
|
148
|
+
narrow_calls = MethodTimer.stats.values.sum { |stats| stats[:calls] }
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Complete stderr, including uninstalling the previous scope and installing the narrower one:
|
|
152
|
+
|
|
153
|
+
```text
|
|
154
|
+
MethodTimer: instrumented 1 classes/modules under Shop::CsvExport (overhead 0.61µs per call).
|
|
155
|
+
MethodTimer: instrumented 1 classes/modules under Shop::PriceCalculator (overhead 0.60µs per call).
|
|
156
|
+
|
|
157
|
+
method self(ruby) self(sql) self(gc) total calls sql calls allocs
|
|
158
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
159
|
+
PriceCalculator#total 0.14170s 0.00000s 0.00000s 0.18216s 50000 0 7
|
|
160
|
+
CsvExport#call 0.08192s 0.00000s 0.00000s 0.33683s 1 0 250047
|
|
161
|
+
CsvExport#row 0.06598s 0.00000s 0.00000s 0.25492s 50000 0 150010
|
|
162
|
+
PriceCalculator#line_total 0.04046s 0.00000s 0.00000s 0.04046s 250000 0 4
|
|
163
|
+
CsvExport#calculator 0.00677s 0.00000s 0.00000s 0.00677s 50000 0 3
|
|
164
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
165
|
+
TOTAL (wall time of run block) 0.33685s
|
|
166
|
+
of which captured (sum of self ruby) 0.33684s
|
|
167
|
+
of which SQL (sum of self sql / calls) 0.00000s 0
|
|
168
|
+
of which GC (sum of self gc) 0.00000s
|
|
169
|
+
of which instrumentation overhead (estimated) 0.24148s (400002 calls x 0.60µs, counted in callers' self)
|
|
170
|
+
total allocations (sum of self allocs) 400074
|
|
171
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
This performs the same export with 400,002 instrumented calls rather than 1,050,002.
|
|
175
|
+
The `Item` and `Order` rows disappear because those methods are no longer wrapped.
|
|
176
|
+
Their work still contributes to their instrumented callers.
|
|
177
|
+
|
|
178
|
+
If you only need the export's inclusive cost, select its entry point:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
MethodTimer.uninstall
|
|
182
|
+
MethodTimer.install(Shop::CsvExport, only: [:call])
|
|
183
|
+
selected_csv = MethodTimer.run { Shop::CsvExport.new(orders).call }
|
|
184
|
+
selected_calls = MethodTimer.stats.values.sum { |stats| stats[:calls] }
|
|
185
|
+
MethodTimer.uninstall
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Complete stderr:
|
|
189
|
+
|
|
190
|
+
```text
|
|
191
|
+
MethodTimer: instrumented 1 classes/modules under Shop::CsvExport (overhead 0.62µs per call).
|
|
192
|
+
|
|
193
|
+
method self(ruby) self(sql) self(gc) total calls sql calls allocs
|
|
194
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
195
|
+
CsvExport#call 0.08137s 0.00000s 0.00000s 0.08137s 1 0 400050
|
|
196
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
197
|
+
TOTAL (wall time of run block) 0.08138s
|
|
198
|
+
of which captured (sum of self ruby) 0.08137s
|
|
199
|
+
of which SQL (sum of self sql / calls) 0.00000s 0
|
|
200
|
+
of which GC (sum of self gc) 0.00000s
|
|
201
|
+
of which instrumentation overhead (estimated) 0.00000s (1 calls x 0.62µs, counted in callers' self)
|
|
202
|
+
total allocations (sum of self allocs) 400050
|
|
203
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Now there is one instrumented call. Helper work is included in `CsvExport#call`'s self time.
|
|
207
|
+
All three exports should be identical; the different scopes change observation cost, not the
|
|
208
|
+
export's business logic. To compare implementations without wrappers, use a fresh process
|
|
209
|
+
and [`bench`](benchmarking.md).
|
|
210
|
+
|
|
211
|
+
## Profiling another gem
|
|
212
|
+
|
|
213
|
+
If your application already loads CSV, you can also profile its named classes:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
MethodTimer.install(CSV)
|
|
217
|
+
MethodTimer.run { CSV.generate { |output| 1_000.times { |i| output << [i, 'x'] } } }
|
|
218
|
+
MethodTimer.uninstall
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Complete stderr. The default threshold hides methods shorter than one millisecond;
|
|
222
|
+
the footer still includes their calls and allocations:
|
|
223
|
+
|
|
224
|
+
```text
|
|
225
|
+
MethodTimer: instrumented 16 classes/modules under CSV (overhead 0.60µs per call).
|
|
226
|
+
|
|
227
|
+
method self(ruby) self(sql) self(gc) total calls sql calls allocs
|
|
228
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
229
|
+
CSV::Writer#<< 0.00245s 0.00000s 0.00000s 0.00391s 1000 0 3011
|
|
230
|
+
CSV#<< 0.00114s 0.00000s 0.00000s 0.00524s 1000 0 8
|
|
231
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
232
|
+
TOTAL (wall time of run block) 0.00585s
|
|
233
|
+
of which captured (sum of self ruby) 0.00526s
|
|
234
|
+
of which SQL (sum of self sql / calls) 0.00000s 0
|
|
235
|
+
of which GC (sum of self gc) 0.00000s
|
|
236
|
+
of which instrumentation overhead (estimated) 0.00480s (8022 calls x 0.60µs, counted in callers' self)
|
|
237
|
+
total allocations (sum of self allocs) 4151
|
|
238
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Only methods already loaded at installation time are wrapped. Warm up a lazy library first
|
|
242
|
+
if it defines classes or methods on first use, then install it. Broad instrumentation of
|
|
243
|
+
another gem can have substantial overhead, just like broad instrumentation of application code.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Rails console: finding an N+1
|
|
2
|
+
|
|
3
|
+
[README and console installation](../../README.md#installation) · [API reference](../reference.md)
|
|
4
|
+
|
|
5
|
+
Use your application's `rails console`, never code that serves requests or runs jobs.
|
|
6
|
+
The following assumes an `Invoice` model with a `customer` association. Adapt those names
|
|
7
|
+
to your application. It only reads existing records; it does not create tables or fixtures.
|
|
8
|
+
|
|
9
|
+
## Reproducing the captured reports without an application
|
|
10
|
+
|
|
11
|
+
The reports below are real captures on Ruby 4.0.9, ActiveRecord / ActiveSupport 8.1.4,
|
|
12
|
+
SQLite3 gem 2.10.2, arm64 macOS. They used 100 invoices, each with a different customer,
|
|
13
|
+
in an isolated in-memory database, not a production application.
|
|
14
|
+
|
|
15
|
+
To reproduce that dataset, start a **fresh plain Ruby process, not your application's console**,
|
|
16
|
+
with `activerecord`, `sqlite3` and `method_timer` installed. Run this setup before the profiling
|
|
17
|
+
code below. It replaces the process's ActiveRecord connection, so do not paste it into an
|
|
18
|
+
existing application session:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
require 'active_record'
|
|
22
|
+
require 'sqlite3'
|
|
23
|
+
|
|
24
|
+
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
|
|
25
|
+
ActiveRecord::Schema.define do
|
|
26
|
+
create_table(:customers) { |t| t.string :name }
|
|
27
|
+
create_table(:invoices) { |t| t.references :customer }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class Customer < ActiveRecord::Base
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class Invoice < ActiveRecord::Base
|
|
34
|
+
belongs_to :customer
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
100.times do |i|
|
|
38
|
+
Invoice.create!(customer: Customer.create!(name: "Customer #{i}"))
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
In an existing Rails console, skip this setup and use your own models and records instead.
|
|
43
|
+
Counts and timings will depend on your data and query-cache state.
|
|
44
|
+
|
|
45
|
+
## Keep queries inside the measured method
|
|
46
|
+
|
|
47
|
+
Passing a lazy relation to the export lets its initial load happen inside `call`.
|
|
48
|
+
Loading records before `run` would exclude those initial queries from the measurement.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
require 'method_timer'
|
|
52
|
+
|
|
53
|
+
class ConsoleInvoiceExport
|
|
54
|
+
def call(scope)
|
|
55
|
+
scope.map { |invoice| row(invoice) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def row(invoice)
|
|
61
|
+
[invoice.id, invoice.customer.name]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
exporter = ConsoleInvoiceExport.new
|
|
66
|
+
MethodTimer.install(ConsoleInvoiceExport)
|
|
67
|
+
MethodTimer.install(Invoice, only: [:customer])
|
|
68
|
+
rows = MethodTimer.run { exporter.call(Invoice.order(:id).limit(100)) }
|
|
69
|
+
MethodTimer.report(sort: :sql_calls, threshold: 0)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Complete profiling stderr: first the automatic `run` table, then
|
|
73
|
+
`report(sort: :sql_calls, threshold: 0)` for the same measurement:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
MethodTimer: instrumented 1 classes/modules under ConsoleInvoiceExport (overhead 0.60µs per call).
|
|
77
|
+
MethodTimer: instrumented 1 classes/modules under Invoice (overhead 0.60µs per call).
|
|
78
|
+
|
|
79
|
+
method self(ruby) self(sql) self(gc) total calls sql calls allocs
|
|
80
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
81
|
+
Invoice::GeneratedAssociationMethods#customer 0.00703s 0.00070s 0.00000s 0.00774s 100 100 13646
|
|
82
|
+
ConsoleInvoiceExport#call 0.00032s 0.00008s 0.00000s 0.00837s 1 1 1055
|
|
83
|
+
ConsoleInvoiceExport#row 0.00024s 0.00000s 0.00000s 0.00797s 100 0 214
|
|
84
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
85
|
+
TOTAL (wall time of run block) 0.01204s
|
|
86
|
+
of which captured (sum of self ruby) 0.00759s
|
|
87
|
+
of which SQL (sum of self sql / calls) 0.00078s 101
|
|
88
|
+
of which GC (sum of self gc) 0.00000s
|
|
89
|
+
of which instrumentation overhead (estimated) 0.00012s (201 calls x 0.60µs, counted in callers' self)
|
|
90
|
+
total allocations (sum of self allocs) 14915
|
|
91
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
method self(ruby) self(sql) self(gc) total calls sql calls allocs
|
|
94
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
95
|
+
Invoice::GeneratedAssociationMethods#customer 0.00703s 0.00070s 0.00000s 0.00774s 100 100 13646
|
|
96
|
+
ConsoleInvoiceExport#call 0.00032s 0.00008s 0.00000s 0.00837s 1 1 1055
|
|
97
|
+
ConsoleInvoiceExport#row 0.00024s 0.00000s 0.00000s 0.00797s 100 0 214
|
|
98
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
99
|
+
TOTAL (wall time of run block) 0.01204s
|
|
100
|
+
of which captured (sum of self ruby) 0.00759s
|
|
101
|
+
of which SQL (sum of self sql / calls) 0.00078s 101
|
|
102
|
+
of which GC (sum of self gc) 0.00000s
|
|
103
|
+
of which instrumentation overhead (estimated) 0.00012s (201 calls x 0.60µs, counted in callers' self)
|
|
104
|
+
total allocations (sum of self allocs) 14915
|
|
105
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`sql.active_record` notifications fill the SQL columns automatically. This capture recorded
|
|
109
|
+
one invoices query and 100 uncached customer queries through the named association-reader
|
|
110
|
+
module. Repeated customers, query caching, empty data or callbacks can change the numbers.
|
|
111
|
+
Cached queries and `SCHEMA` events are excluded. Wall time also includes work outside the
|
|
112
|
+
instrumented frames, such as constructing the relation before entering `call`.
|
|
113
|
+
|
|
114
|
+
Look for many SQL calls on the association reader. Time charged to an instrumented child is
|
|
115
|
+
excluded from its caller's self columns, so `ConsoleInvoiceExport#row` need not carry the SQL
|
|
116
|
+
itself for an N+1 to be present.
|
|
117
|
+
|
|
118
|
+
## Compare with eager loading
|
|
119
|
+
|
|
120
|
+
Pass a fresh relation and keep preloading inside the same export call:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
preloaded_rows = MethodTimer.run do
|
|
124
|
+
exporter.call(Invoice.includes(:customer).order(:id).limit(100))
|
|
125
|
+
end
|
|
126
|
+
MethodTimer.report(sort: :sql_calls, threshold: 0)
|
|
127
|
+
MethodTimer.uninstall
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Complete stderr, again showing the automatic table followed by the unfiltered SQL-call view:
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
method self(ruby) self(sql) self(gc) total calls sql calls allocs
|
|
134
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
135
|
+
ConsoleInvoiceExport#call 0.00289s 0.00013s 0.00000s 0.00323s 1 2 3470
|
|
136
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
137
|
+
TOTAL (wall time of run block) 0.00328s
|
|
138
|
+
of which captured (sum of self ruby) 0.00310s
|
|
139
|
+
of which SQL (sum of self sql / calls) 0.00013s 2
|
|
140
|
+
of which GC (sum of self gc) 0.00000s
|
|
141
|
+
of which instrumentation overhead (estimated) 0.00012s (201 calls x 0.60µs, counted in callers' self)
|
|
142
|
+
total allocations (sum of self allocs) 3670
|
|
143
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
method self(ruby) self(sql) self(gc) total calls sql calls allocs
|
|
146
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
147
|
+
ConsoleInvoiceExport#call 0.00289s 0.00013s 0.00000s 0.00323s 1 2 3470
|
|
148
|
+
ConsoleInvoiceExport#row 0.00015s 0.00000s 0.00000s 0.00021s 100 0 200
|
|
149
|
+
Invoice::GeneratedAssociationMethods#customer 0.00006s 0.00000s 0.00000s 0.00006s 100 0 0
|
|
150
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
151
|
+
TOTAL (wall time of run block) 0.00328s
|
|
152
|
+
of which captured (sum of self ruby) 0.00310s
|
|
153
|
+
of which SQL (sum of self sql / calls) 0.00013s 2
|
|
154
|
+
of which GC (sum of self gc) 0.00000s
|
|
155
|
+
of which instrumentation overhead (estimated) 0.00012s (201 calls x 0.60µs, counted in callers' self)
|
|
156
|
+
total allocations (sum of self allocs) 3670
|
|
157
|
+
------------------------------------------------------------------------------------------------------------------------------------------------
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The default threshold hides the now-cheap row and association methods in the first table,
|
|
161
|
+
but the second proves they still ran 100 times each. Association SQL fell to zero; both
|
|
162
|
+
remaining queries belong to `ConsoleInvoiceExport#call`.
|
|
163
|
+
|
|
164
|
+
Typically this replaces many association queries with the invoices query and a customer
|
|
165
|
+
preload query, attributed to `ConsoleInvoiceExport#call`. Association-reader SQL counts
|
|
166
|
+
should fall. Do not move `to_a` outside the profiled block and then mistake unmeasured
|
|
167
|
+
preloading work for a free optimization.
|
|
168
|
+
|
|
169
|
+
Each run replaces the saved report. To keep data for both runs, use the
|
|
170
|
+
[hash reporting workflow](reports-as-data.md) and save each returned hash before the next run.
|
|
171
|
+
|
|
172
|
+
## What to watch
|
|
173
|
+
|
|
174
|
+
- SQL logging is silenced by setting `ActiveRecord::Base.logger = nil` during installation.
|
|
175
|
+
`uninstall` restores the original logger if it is still `nil`; a replacement logger is kept.
|
|
176
|
+
Use `silence_sql: false` if you want to keep the current logger.
|
|
177
|
+
- With ActiveSupport 7.2+, GC during SQL is excluded from SQL time and appears only as GC.
|
|
178
|
+
Older versions cannot separate that overlap.
|
|
179
|
+
- Attribute readers may be defined lazily after `install`; association readers are a different,
|
|
180
|
+
named-module path and are included when present at installation time.
|
|
181
|
+
- A class-method service needs `singleton: true`. Inherited implementations need their
|
|
182
|
+
defining owner installed, not just the leaf class.
|
|
183
|
+
- Queries from other threads are not counted. GC, allocations and RSS remain process-wide.
|
|
184
|
+
- The block runs normally. Profiling does not roll back writes, so inspect application service
|
|
185
|
+
behavior before invoking it against production data. Exit the throwaway console afterward.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Historical report: a real application export
|
|
2
|
+
|
|
3
|
+
[README](../../README.md) · [CSV walkthrough](plain-ruby.md) · [Measurement semantics](../reference.md#measurement-semantics)
|
|
4
|
+
|
|
5
|
+
This report came from real application work and was included in the original README.
|
|
6
|
+
It is preserved verbatim below, not regenerated with the current release. The original
|
|
7
|
+
already omitted rows with `...`; the missing rows and the application's source code are
|
|
8
|
+
not available here. The Ruby version and environment were not recorded with this excerpt.
|
|
9
|
+
For a complete runnable export and current, unabridged reports, see the [CSV walkthrough](plain-ruby.md).
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
method self(ruby) self(sql) self(gc) total calls sql calls allocs
|
|
13
|
+
------------------------------------------------------------------------------------------------------------
|
|
14
|
+
Supply#build_csv 36.52825s 21.26017s 4.77200s 86.66200s 1 27 52580175
|
|
15
|
+
Supply#build_row 22.22469s 0.00000s 0.09000s 24.03738s 4865224 0 4918938
|
|
16
|
+
...
|
|
17
|
+
------------------------------------------------------------------------------------------------------------
|
|
18
|
+
TOTAL (wall time of run block) 163.27384s
|
|
19
|
+
of which captured (sum of self ruby) 101.23146s
|
|
20
|
+
of which SQL (sum of self sql / calls) 49.77568s 250
|
|
21
|
+
of which GC (sum of self gc) 12.26600s
|
|
22
|
+
of which instrumentation overhead (estimated) 31.75190s (43720945 calls x 0.73µs, counted in callers' self)
|
|
23
|
+
total allocations (sum of self allocs) 112497459
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What this exposed
|
|
27
|
+
|
|
28
|
+
- `Supply#build_csv` directly allocated 52,580,175 objects and spent 21.26017 seconds in
|
|
29
|
+
SQL across 27 queries. Its inclusive total also contains instrumented child work.
|
|
30
|
+
- `Supply#build_row` was called 4,865,224 times. It had no direct SQL, but created
|
|
31
|
+
4,918,938 objects: a repeated Ruby/allocation path rather than a direct-query hotspot.
|
|
32
|
+
- The full run recorded 250 SQL calls, 12.266 seconds of GC and 112,497,459 allocations.
|
|
33
|
+
These totals include the omitted methods; they cannot be reconstructed from the two visible rows.
|
|
34
|
+
- With 43,720,945 instrumented calls, the estimated profiler overhead was 31.75190 seconds.
|
|
35
|
+
That cost is included in measured time, not subtracted automatically. Narrow instrumentation
|
|
36
|
+
before drawing conclusions about tiny methods, and use `bench` in a fresh process for an
|
|
37
|
+
uninstrumented comparison.
|
|
38
|
+
|
|
39
|
+
The report is evidence of one workload, not a performance guarantee or a reproducible
|
|
40
|
+
benchmark. No missing application code, rows or measurements have been invented.
|