memory-profiler 1.4.0 → 1.5.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.
@@ -1,369 +0,0 @@
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
-