memory-profiler 1.2.0 → 1.4.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
- checksums.yaml.gz.sig +0 -0
- data/ext/memory/profiler/allocations.c +19 -69
- data/ext/memory/profiler/allocations.h +3 -7
- data/ext/memory/profiler/capture.c +281 -104
- data/ext/memory/profiler/events.c +5 -6
- data/ext/memory/profiler/events.h +4 -2
- data/lib/memory/profiler/allocations.rb +33 -0
- data/lib/memory/profiler/call_tree.rb +25 -2
- data/lib/memory/profiler/capture.rb +1 -1
- data/lib/memory/profiler/graph.rb +369 -0
- data/lib/memory/profiler/native.rb +9 -0
- data/lib/memory/profiler/sampler.rb +66 -47
- data/lib/memory/profiler/version.rb +1 -1
- data/lib/memory/profiler.rb +2 -0
- data/readme.md +17 -8
- data/releases.md +17 -0
- data.tar.gz.sig +0 -0
- metadata +4 -1
- metadata.gz.sig +0 -0
|
@@ -23,7 +23,8 @@ struct Memory_Profiler_Event {
|
|
|
23
23
|
// The class of the object:
|
|
24
24
|
VALUE klass;
|
|
25
25
|
|
|
26
|
-
// The
|
|
26
|
+
// The object_id (Integer VALUE) - NOT the raw object.
|
|
27
|
+
// We store object_id to avoid referencing objects that should be freed.
|
|
27
28
|
VALUE object;
|
|
28
29
|
};
|
|
29
30
|
|
|
@@ -32,12 +33,13 @@ struct Memory_Profiler_Events;
|
|
|
32
33
|
struct Memory_Profiler_Events* Memory_Profiler_Events_instance(void);
|
|
33
34
|
|
|
34
35
|
// Enqueue an event to the global queue.
|
|
36
|
+
// object parameter should be the object_id (Integer), not the raw object.
|
|
35
37
|
// Returns non-zero on success, zero on failure.
|
|
36
38
|
int Memory_Profiler_Events_enqueue(
|
|
37
39
|
enum Memory_Profiler_Event_Type type,
|
|
38
40
|
VALUE capture,
|
|
39
41
|
VALUE klass,
|
|
40
|
-
VALUE
|
|
42
|
+
VALUE object_id
|
|
41
43
|
);
|
|
42
44
|
|
|
43
45
|
// Process all queued events immediately (flush the queue)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "capture"
|
|
7
|
+
|
|
8
|
+
module Memory
|
|
9
|
+
module Profiler
|
|
10
|
+
# Ruby extensions to the C-defined Allocations class.
|
|
11
|
+
# The base Allocations class is defined in the C extension.
|
|
12
|
+
class Allocations
|
|
13
|
+
# Convert allocation statistics to JSON-compatible hash.
|
|
14
|
+
#
|
|
15
|
+
# @returns [Hash] Allocation statistics as a hash.
|
|
16
|
+
def as_json(...)
|
|
17
|
+
{
|
|
18
|
+
new_count: self.new_count,
|
|
19
|
+
free_count: self.free_count,
|
|
20
|
+
retained_count: self.retained_count,
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Convert allocation statistics to JSON string.
|
|
25
|
+
#
|
|
26
|
+
# @returns [String] Allocation statistics as JSON.
|
|
27
|
+
def to_json(...)
|
|
28
|
+
as_json.to_json(...)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
@@ -187,7 +187,7 @@ module Memory
|
|
|
187
187
|
# @parameter limit [Integer] Maximum number of paths to return.
|
|
188
188
|
# @parameter by [Symbol] Sort by :total or :retained count.
|
|
189
189
|
# @returns [Array(Array)] Array of [locations, total_count, retained_count].
|
|
190
|
-
def top_paths(limit
|
|
190
|
+
def top_paths(limit: 10, by: :retained)
|
|
191
191
|
paths = []
|
|
192
192
|
|
|
193
193
|
@root.each_path do |path, total_count, retained_count|
|
|
@@ -206,7 +206,7 @@ module Memory
|
|
|
206
206
|
# @parameter limit [Integer] Maximum number of hotspots to return.
|
|
207
207
|
# @parameter by [Symbol] Sort by :total or :retained count.
|
|
208
208
|
# @returns [Hash] Map of location => [total_count, retained_count].
|
|
209
|
-
def hotspots(limit
|
|
209
|
+
def hotspots(limit: 20, by: :retained)
|
|
210
210
|
frames = Hash.new{|h, k| h[k] = [0, 0]}
|
|
211
211
|
|
|
212
212
|
collect_frames(@root, frames)
|
|
@@ -245,6 +245,29 @@ module Memory
|
|
|
245
245
|
@root.prune!(limit)
|
|
246
246
|
end
|
|
247
247
|
|
|
248
|
+
# Convert call tree data to JSON-compatible hash.
|
|
249
|
+
#
|
|
250
|
+
# @returns [Hash] Call tree data as a hash.
|
|
251
|
+
def as_json(top_paths: {limit: 10}, hotspots: {limit: 20})
|
|
252
|
+
{
|
|
253
|
+
total_allocations: total_allocations,
|
|
254
|
+
retained_allocations: retained_allocations,
|
|
255
|
+
top_paths: top_paths(**top_paths).map{|path, total, retained|
|
|
256
|
+
{path: path, total_count: total, retained_count: retained}
|
|
257
|
+
},
|
|
258
|
+
hotspots: hotspots(**hotspots).transform_values{|total, retained|
|
|
259
|
+
{total_count: total, retained_count: retained}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Convert call tree data to JSON string.
|
|
265
|
+
#
|
|
266
|
+
# @returns [String] Call tree data as JSON.
|
|
267
|
+
def to_json(...)
|
|
268
|
+
as_json.to_json(...)
|
|
269
|
+
end
|
|
270
|
+
|
|
248
271
|
private
|
|
249
272
|
|
|
250
273
|
def collect_frames(node, frames)
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "objspace"
|
|
7
|
+
|
|
8
|
+
module Memory
|
|
9
|
+
module Profiler
|
|
10
|
+
class Graph
|
|
11
|
+
def initialize
|
|
12
|
+
@objects = Set.new.compare_by_identity
|
|
13
|
+
@parents = Hash.new{|hash, key| hash[key] = Set.new.compare_by_identity}.compare_by_identity
|
|
14
|
+
@internals = Set.new
|
|
15
|
+
@root = nil # Track the root of traversal for idom
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr :parents
|
|
19
|
+
|
|
20
|
+
def include?(object)
|
|
21
|
+
@parents.include?(object)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def inspect
|
|
25
|
+
"#<#{self.class.name} @objects=#{@objects.size} @parents=#{@parents.size} @names=#{@names.size}>"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def add(object)
|
|
29
|
+
@objects.add(object)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def update!(from = Object)
|
|
33
|
+
# Store the root for idom algorithm
|
|
34
|
+
@root = from
|
|
35
|
+
@parents.clear
|
|
36
|
+
@internals.clear
|
|
37
|
+
|
|
38
|
+
# If the user has provided a specific object, try to avoid traversing the root Object.
|
|
39
|
+
if from != Object
|
|
40
|
+
@parents[Object] = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Don't traverse the graph itself:
|
|
44
|
+
@parents[self] = nil
|
|
45
|
+
|
|
46
|
+
traverse!(from)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def name_for(object, limit = 8)
|
|
50
|
+
if object.is_a?(Module)
|
|
51
|
+
object.name.to_s
|
|
52
|
+
elsif object.is_a?(Object)
|
|
53
|
+
if limit > 0
|
|
54
|
+
parents = @parents[object]
|
|
55
|
+
if parent = parents&.first
|
|
56
|
+
return name_for(parent, limit - 1) + compute_edge_label(parent, object)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
return object.class.name.to_s
|
|
61
|
+
else
|
|
62
|
+
object.inspect
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Return roots using immediate dominator algorithm
|
|
67
|
+
def roots
|
|
68
|
+
return [] if @objects.empty?
|
|
69
|
+
|
|
70
|
+
# Compute immediate dominators
|
|
71
|
+
idom = compute_idom
|
|
72
|
+
|
|
73
|
+
# Count how many tracked objects each node dominates
|
|
74
|
+
dominated_counts = Hash.new(0).compare_by_identity
|
|
75
|
+
retained_by_counts = Hash.new(0).compare_by_identity
|
|
76
|
+
|
|
77
|
+
@objects.each do |object|
|
|
78
|
+
# Credit the immediate dominator
|
|
79
|
+
if dominator = idom[object]
|
|
80
|
+
dominated_counts[dominator] += 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Credit all immediate parents for retained_by
|
|
84
|
+
if parent_set = @parents[object]
|
|
85
|
+
parent_set.each do |parent|
|
|
86
|
+
retained_by_counts[parent] += 1
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Build results
|
|
92
|
+
total = @objects.size
|
|
93
|
+
results = []
|
|
94
|
+
|
|
95
|
+
dominated_counts.each do |object, count|
|
|
96
|
+
result = {
|
|
97
|
+
name: name_for(object),
|
|
98
|
+
count: count,
|
|
99
|
+
percentage: (count * 100.0) / total
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if retained_count = retained_by_counts[object]
|
|
103
|
+
result[:retained_by] = retained_count
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
results << result
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Add entries that appear ONLY in retained_by (intermediate nodes)
|
|
110
|
+
retained_by_counts.each do |object, count|
|
|
111
|
+
next if dominated_counts[object] # Already included
|
|
112
|
+
|
|
113
|
+
results << {
|
|
114
|
+
name: name_for(object),
|
|
115
|
+
count: 0,
|
|
116
|
+
percentage: 0.0,
|
|
117
|
+
retained_by: count
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Sort by count descending
|
|
122
|
+
results.sort_by!{|r| -r[:count]}
|
|
123
|
+
|
|
124
|
+
results
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
IS_A = Kernel.method(:is_a?).unbind
|
|
130
|
+
RESPOND_TO = Kernel.method(:respond_to?).unbind
|
|
131
|
+
EQUAL = Kernel.method(:equal?).unbind
|
|
132
|
+
|
|
133
|
+
def traverse!(object, parent = nil)
|
|
134
|
+
queue = Array.new
|
|
135
|
+
queue << [object, parent]
|
|
136
|
+
|
|
137
|
+
while queue.any?
|
|
138
|
+
object, parent = queue.shift
|
|
139
|
+
|
|
140
|
+
# We shouldn't use internal objects as parents.
|
|
141
|
+
unless IS_A.bind_call(object, ObjectSpace::InternalObjectWrapper)
|
|
142
|
+
parent = object
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
ObjectSpace.reachable_objects_from(object).each do |child|
|
|
146
|
+
if IS_A.bind_call(child, ObjectSpace::InternalObjectWrapper)
|
|
147
|
+
# There is no value in scanning internal objects.
|
|
148
|
+
next if child.type == :T_IMEMO
|
|
149
|
+
|
|
150
|
+
# We need to handle internal objects differently, because they don't follow the same equality/identity rules. Since we can reach the same internal object from multiple parents, we need to use an appropriate key to track it. Otherwise, the objects will not be counted on subsequent parent traversals.
|
|
151
|
+
key = [parent.object_id, child.internal_object_id]
|
|
152
|
+
if @internals.add?(key)
|
|
153
|
+
queue << [child, parent]
|
|
154
|
+
end
|
|
155
|
+
elsif parents = @parents[child] # Skip traversal if we are explicitly set to nil.
|
|
156
|
+
# If we haven't seen the object yet, add it to the queue:
|
|
157
|
+
if parents.add?(parent)
|
|
158
|
+
queue << [child, parent]
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Limit the cost of computing the edge label.
|
|
166
|
+
SEARCH_LIMIT = 1000
|
|
167
|
+
|
|
168
|
+
# Lazily compute the edge label (how parent references child)
|
|
169
|
+
# This is called on-demand for objects that appear in roots
|
|
170
|
+
def compute_edge_label(parent, object)
|
|
171
|
+
case parent
|
|
172
|
+
when Hash
|
|
173
|
+
if parent.size < SEARCH_LIMIT
|
|
174
|
+
# Use Ruby's built-in key method to find the key
|
|
175
|
+
if key = parent.key(object)
|
|
176
|
+
return "[#{key.inspect}]"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
return "[?/#{parent.size}]"
|
|
180
|
+
when Array
|
|
181
|
+
if parent.size < SEARCH_LIMIT
|
|
182
|
+
# Use Ruby's built-in index method to find the position
|
|
183
|
+
if index = parent.index(object)
|
|
184
|
+
return "[#{index}]"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
return "[?/#{parent.size}]"
|
|
188
|
+
else
|
|
189
|
+
return extract_name(parent, object)
|
|
190
|
+
end
|
|
191
|
+
rescue => error
|
|
192
|
+
# If inspection fails, fall back to class name
|
|
193
|
+
return "(#{error.class.name}: #{error.message})"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Compute immediate dominators for all nodes
|
|
197
|
+
# Returns hash mapping node => immediate dominator
|
|
198
|
+
def compute_idom
|
|
199
|
+
idom = Hash.new.compare_by_identity
|
|
200
|
+
|
|
201
|
+
# Root dominates itself
|
|
202
|
+
idom[@root] = @root if @root
|
|
203
|
+
|
|
204
|
+
# Find all roots (nodes with no parents)
|
|
205
|
+
roots = Set.new.compare_by_identity
|
|
206
|
+
roots.add(@root) if @root
|
|
207
|
+
|
|
208
|
+
# Convert to array to avoid "can't add key during iteration" errors
|
|
209
|
+
@parents.each do |node, parents|
|
|
210
|
+
if parents.nil? || parents.empty?
|
|
211
|
+
roots.add(node)
|
|
212
|
+
idom[node] = node
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Iterative dataflow analysis
|
|
217
|
+
changed = true
|
|
218
|
+
iterations = 0
|
|
219
|
+
|
|
220
|
+
while changed && iterations < 1000
|
|
221
|
+
changed = false
|
|
222
|
+
iterations += 1
|
|
223
|
+
|
|
224
|
+
# Convert to array to avoid "can't add key during iteration" errors
|
|
225
|
+
@parents.each do |node, parents|
|
|
226
|
+
# Skip roots:
|
|
227
|
+
next if roots.include?(node)
|
|
228
|
+
next if parents.nil? || parents.empty?
|
|
229
|
+
|
|
230
|
+
# Find first processed predecessor
|
|
231
|
+
new_idom = parents.find{|parent| idom[parent]}
|
|
232
|
+
next unless new_idom
|
|
233
|
+
|
|
234
|
+
# Intersect with other processed predecessors
|
|
235
|
+
parents.each do |parent|
|
|
236
|
+
next if parent.equal?(new_idom)
|
|
237
|
+
next unless idom[parent]
|
|
238
|
+
|
|
239
|
+
new_idom = intersect(new_idom, parent, idom)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Update if changed
|
|
243
|
+
if !idom[node].equal?(new_idom)
|
|
244
|
+
idom[node] = new_idom
|
|
245
|
+
changed = true
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
idom
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Find lowest common ancestor in dominator tree
|
|
254
|
+
def intersect(node1, node2, idom)
|
|
255
|
+
finger1 = node1
|
|
256
|
+
finger2 = node2
|
|
257
|
+
seen = Set.new.compare_by_identity
|
|
258
|
+
iterations = 0
|
|
259
|
+
|
|
260
|
+
until finger1.equal?(finger2)
|
|
261
|
+
return finger1 if iterations > SEARCH_LIMIT
|
|
262
|
+
iterations += 1
|
|
263
|
+
|
|
264
|
+
# Prevent infinite loops
|
|
265
|
+
return finger1 if seen.include?(finger1)
|
|
266
|
+
seen.add(finger1)
|
|
267
|
+
|
|
268
|
+
# Walk up both paths until they meet
|
|
269
|
+
depth1 = depth(finger1)
|
|
270
|
+
depth2 = depth(finger2)
|
|
271
|
+
|
|
272
|
+
if depth1 > depth2
|
|
273
|
+
break unless idom[finger1]
|
|
274
|
+
finger1 = idom[finger1]
|
|
275
|
+
elsif depth2 > depth1
|
|
276
|
+
break unless idom[finger2]
|
|
277
|
+
finger2 = idom[finger2]
|
|
278
|
+
else
|
|
279
|
+
# Same depth, walk both up
|
|
280
|
+
break unless idom[finger1] && idom[finger2]
|
|
281
|
+
finger1 = idom[finger1]
|
|
282
|
+
finger2 = idom[finger2]
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
finger1
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Compute depth of node in parent tree
|
|
290
|
+
def depth(node)
|
|
291
|
+
depth = 0
|
|
292
|
+
current = node
|
|
293
|
+
seen = Set.new.compare_by_identity
|
|
294
|
+
|
|
295
|
+
while current
|
|
296
|
+
return depth if seen.include?(current)
|
|
297
|
+
seen.add(current)
|
|
298
|
+
|
|
299
|
+
break unless @parents.key?(current)
|
|
300
|
+
parents = @parents[current]
|
|
301
|
+
break if parents.empty?
|
|
302
|
+
|
|
303
|
+
current = parents.first # TODO: Should we use the first parent?
|
|
304
|
+
depth += 1
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
depth
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def extract_name(parent, object)
|
|
311
|
+
if RESPOND_TO.bind_call(parent, :constants)
|
|
312
|
+
parent.constants.each do |constant|
|
|
313
|
+
if !parent.autoload?(constant) && parent.const_defined?(constant)
|
|
314
|
+
if EQUAL.bind_call(parent.const_get(constant), object)
|
|
315
|
+
return "::#{constant}"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
if RESPOND_TO.bind_call(parent, :instance_variables)
|
|
322
|
+
parent.instance_variables.each do |variable|
|
|
323
|
+
if EQUAL.bind_call(parent.instance_variable_get(variable), object)
|
|
324
|
+
return ".#{variable}"
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
if IS_A.bind_call(parent, Struct)
|
|
330
|
+
parent.members.each do |member|
|
|
331
|
+
if EQUAL.bind_call(parent[member], object)
|
|
332
|
+
return ".#{member}"
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
if IS_A.bind_call(parent, Fiber)
|
|
338
|
+
return "(fiber)"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
if IS_A.bind_call(parent, Thread)
|
|
342
|
+
parent.thread_variables.each do |variable|
|
|
343
|
+
if EQUAL.bind_call(parent.thread_variable_get(variable), object)
|
|
344
|
+
return "(TLS #{variable})"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
parent.keys.each do |key|
|
|
349
|
+
if EQUAL.bind_call(parent[key], object)
|
|
350
|
+
return "[#{key}]"
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
return "(thread)"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
if IS_A.bind_call(parent, Ractor)
|
|
358
|
+
return "(ractor)"
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
return "(#{parent.inspect}::???)"
|
|
362
|
+
rescue => error
|
|
363
|
+
# If inspection fails, fall back to class name
|
|
364
|
+
return "(#{error.class.name}: #{error.message})"
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
@@ -7,7 +7,9 @@ require "console"
|
|
|
7
7
|
require "objspace"
|
|
8
8
|
|
|
9
9
|
require_relative "capture"
|
|
10
|
+
require_relative "allocations"
|
|
10
11
|
require_relative "call_tree"
|
|
12
|
+
require_relative "graph"
|
|
11
13
|
|
|
12
14
|
module Memory
|
|
13
15
|
module Profiler
|
|
@@ -140,6 +142,28 @@ module Memory
|
|
|
140
142
|
def stop
|
|
141
143
|
@capture.stop
|
|
142
144
|
end
|
|
145
|
+
|
|
146
|
+
# Clear tracking data for a class.
|
|
147
|
+
def clear(klass)
|
|
148
|
+
tree = @call_trees[klass]
|
|
149
|
+
tree&.clear!
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Clear all tracking data.
|
|
153
|
+
def clear_all!
|
|
154
|
+
@call_trees.each_value(&:clear!)
|
|
155
|
+
@capture.clear
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Stop all tracking and clean up.
|
|
159
|
+
def stop!
|
|
160
|
+
@capture.stop
|
|
161
|
+
@call_trees.each_key do |klass|
|
|
162
|
+
@capture.untrack(klass)
|
|
163
|
+
end
|
|
164
|
+
@capture.clear
|
|
165
|
+
@call_trees.clear
|
|
166
|
+
end
|
|
143
167
|
|
|
144
168
|
# Run periodic sampling in a loop.
|
|
145
169
|
#
|
|
@@ -209,9 +233,9 @@ module Memory
|
|
|
209
233
|
tree = @call_trees[klass] = CallTree.new
|
|
210
234
|
|
|
211
235
|
# Register callback on allocations object:
|
|
212
|
-
# - On :newobj - returns
|
|
213
|
-
# - On :freeobj - receives
|
|
214
|
-
allocations.track do |klass, event,
|
|
236
|
+
# - On :newobj - returns data (leaf node) which C extension stores
|
|
237
|
+
# - On :freeobj - receives data back from C extension
|
|
238
|
+
allocations.track do |klass, event, data|
|
|
215
239
|
case event
|
|
216
240
|
when :newobj
|
|
217
241
|
# Capture call stack and record in tree
|
|
@@ -223,8 +247,8 @@ module Memory
|
|
|
223
247
|
end
|
|
224
248
|
# Return nil or the node - C will store whatever we return.
|
|
225
249
|
when :freeobj
|
|
226
|
-
# Decrement using the
|
|
227
|
-
|
|
250
|
+
# Decrement using the data (leaf node) passed back from then native extension:
|
|
251
|
+
data&.decrement_path!
|
|
228
252
|
end
|
|
229
253
|
rescue Exception => error
|
|
230
254
|
warn "Error in allocation tracking: #{error.message}\n#{error.backtrace.join("\n")}"
|
|
@@ -244,7 +268,7 @@ module Memory
|
|
|
244
268
|
|
|
245
269
|
# Get live object count for a class.
|
|
246
270
|
def count(klass)
|
|
247
|
-
@capture.
|
|
271
|
+
@capture.retained_count_of(klass)
|
|
248
272
|
end
|
|
249
273
|
|
|
250
274
|
# Get the call tree for a specific class.
|
|
@@ -255,54 +279,49 @@ module Memory
|
|
|
255
279
|
# Get allocation statistics for a tracked class.
|
|
256
280
|
#
|
|
257
281
|
# @parameter klass [Class] The class to get statistics for.
|
|
258
|
-
# @
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
282
|
+
# @parameter allocation_roots [Boolean] Include call tree showing where allocations occurred (default: true if available).
|
|
283
|
+
# @parameter retained_roots [Boolean] Compute object graph showing what's retaining allocations (default: true, can be slow for large graphs).
|
|
284
|
+
# @parameter roots_from [Object] Starting point for retained roots analysis (default: Object).
|
|
285
|
+
# @returns [Hash] Statistics including allocations, allocation_roots (call tree), and retained_roots (object graph).
|
|
286
|
+
def analyze(klass, allocation_roots: true, retained_roots: false, retained_objects: true)
|
|
287
|
+
call_tree_data = @call_trees[klass] if allocation_roots
|
|
288
|
+
allocations = @capture[klass]
|
|
262
289
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
top_paths: tree.top_paths(10).map{|path, total, retained|
|
|
268
|
-
{path: path, total_count: total, retained_count: retained}
|
|
269
|
-
},
|
|
270
|
-
hotspots: tree.hotspots(20).transform_values{|total, retained|
|
|
271
|
-
{total_count: total, retained_count: retained}
|
|
272
|
-
}
|
|
290
|
+
return nil unless call_tree_data or allocations
|
|
291
|
+
|
|
292
|
+
result = {
|
|
293
|
+
allocations: allocations&.as_json,
|
|
273
294
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def all_statistics
|
|
278
|
-
@call_trees.keys.each_with_object({}) do |klass, result|
|
|
279
|
-
result[klass] = statistics(klass) if tracking?(klass)
|
|
295
|
+
|
|
296
|
+
if allocation_roots && call_tree_data
|
|
297
|
+
result[:allocation_roots] = call_tree_data.as_json
|
|
280
298
|
end
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
def clear(klass)
|
|
285
|
-
tree = @call_trees[klass]
|
|
286
|
-
tree&.clear!
|
|
287
|
-
end
|
|
288
|
-
|
|
289
|
-
# Clear all tracking data.
|
|
290
|
-
def clear_all!
|
|
291
|
-
@call_trees.each_value(&:clear!)
|
|
292
|
-
@capture.clear
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
# Stop all tracking and clean up.
|
|
296
|
-
def stop!
|
|
297
|
-
@capture.stop
|
|
298
|
-
@call_trees.each_key do |klass|
|
|
299
|
-
@capture.untrack(klass)
|
|
299
|
+
|
|
300
|
+
if retained_roots
|
|
301
|
+
result[:retained_roots] = compute_roots(klass)
|
|
300
302
|
end
|
|
301
|
-
|
|
302
|
-
|
|
303
|
+
|
|
304
|
+
result
|
|
303
305
|
end
|
|
304
306
|
|
|
305
307
|
private
|
|
308
|
+
|
|
309
|
+
# Compute retaining roots for a class's allocations
|
|
310
|
+
def compute_roots(klass)
|
|
311
|
+
graph = Graph.new
|
|
312
|
+
|
|
313
|
+
# Add all tracked objects to the graph
|
|
314
|
+
# NOTE: States table is now at Capture level, so we use capture.each_object
|
|
315
|
+
@capture.each_object(klass) do |object, state|
|
|
316
|
+
graph.add(object)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Build parent relationships
|
|
320
|
+
graph.update!
|
|
321
|
+
|
|
322
|
+
# Return roots analysis
|
|
323
|
+
graph.roots
|
|
324
|
+
end
|
|
306
325
|
|
|
307
326
|
# Default filter to include all locations.
|
|
308
327
|
def default_filter
|