memory-profiler 1.3.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.
@@ -23,7 +23,8 @@ struct Memory_Profiler_Event {
23
23
  // The class of the object:
24
24
  VALUE klass;
25
25
 
26
- // The object itself (for NEWOBJ and FREEOBJ):
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 object
42
+ VALUE object_id
41
43
  );
42
44
 
43
45
  // Process all queued events immediately (flush the queue)
@@ -3,4 +3,4 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2025, by Samuel Williams.
5
5
 
6
- require "Memory_Profiler"
6
+ require_relative "native"
@@ -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
+
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ # Load objspace first so InternalObjectWrapper is available
7
+ require "objspace"
8
+
9
+ require "Memory_Profiler"
@@ -9,6 +9,7 @@ require "objspace"
9
9
  require_relative "capture"
10
10
  require_relative "allocations"
11
11
  require_relative "call_tree"
12
+ require_relative "graph"
12
13
 
13
14
  module Memory
14
15
  module Profiler
@@ -141,6 +142,28 @@ module Memory
141
142
  def stop
142
143
  @capture.stop
143
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
144
167
 
145
168
  # Run periodic sampling in a loop.
146
169
  #
@@ -210,9 +233,9 @@ module Memory
210
233
  tree = @call_trees[klass] = CallTree.new
211
234
 
212
235
  # Register callback on allocations object:
213
- # - On :newobj - returns state (leaf node) which C extension stores
214
- # - On :freeobj - receives state back from C extension
215
- allocations.track do |klass, event, state|
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|
216
239
  case event
217
240
  when :newobj
218
241
  # Capture call stack and record in tree
@@ -224,8 +247,8 @@ module Memory
224
247
  end
225
248
  # Return nil or the node - C will store whatever we return.
226
249
  when :freeobj
227
- # Decrement using the state (leaf node) passed back from then native extension:
228
- state&.decrement_path!
250
+ # Decrement using the data (leaf node) passed back from then native extension:
251
+ data&.decrement_path!
229
252
  end
230
253
  rescue Exception => error
231
254
  warn "Error in allocation tracking: #{error.message}\n#{error.backtrace.join("\n")}"
@@ -256,45 +279,49 @@ module Memory
256
279
  # Get allocation statistics for a tracked class.
257
280
  #
258
281
  # @parameter klass [Class] The class to get statistics for.
259
- # @returns [Hash] Statistics including total, retained, paths, and hotspots.
260
- def analyze(klass)
261
- call_tree = @call_trees[klass]
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
262
288
  allocations = @capture[klass]
263
289
 
264
- return nil unless call_tree or allocations
290
+ return nil unless call_tree_data or allocations
265
291
 
266
- {
292
+ result = {
267
293
  allocations: allocations&.as_json,
268
- call_tree: call_tree&.as_json
269
294
  }
270
- end
271
-
272
- # @deprecated Use {analyze} instead.
273
- alias statistics analyze
274
-
275
- # Clear tracking data for a class.
276
- def clear(klass)
277
- tree = @call_trees[klass]
278
- tree&.clear!
279
- end
280
-
281
- # Clear all tracking data.
282
- def clear_all!
283
- @call_trees.each_value(&:clear!)
284
- @capture.clear
285
- end
286
-
287
- # Stop all tracking and clean up.
288
- def stop!
289
- @capture.stop
290
- @call_trees.each_key do |klass|
291
- @capture.untrack(klass)
295
+
296
+ if allocation_roots && call_tree_data
297
+ result[:allocation_roots] = call_tree_data.as_json
292
298
  end
293
- @capture.clear
294
- @call_trees.clear
299
+
300
+ if retained_roots
301
+ result[:retained_roots] = compute_roots(klass)
302
+ end
303
+
304
+ result
295
305
  end
296
306
 
297
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
298
325
 
299
326
  # Default filter to include all locations.
300
327
  def default_filter
@@ -7,7 +7,7 @@
7
7
  module Memory
8
8
  # @namespace
9
9
  module Profiler
10
- VERSION = "1.3.0"
10
+ VERSION = "1.4.0"
11
11
  end
12
12
  end
13
13
 
@@ -8,3 +8,4 @@ require_relative "profiler/call_tree"
8
8
  require_relative "profiler/capture"
9
9
  require_relative "profiler/allocations"
10
10
  require_relative "profiler/sampler"
11
+ require_relative "profiler/graph"
data/readme.md CHANGED
@@ -22,6 +22,11 @@ Please see the [project documentation](https://socketry.github.io/memory-profile
22
22
 
23
23
  Please see the [project releases](https://socketry.github.io/memory-profiler/releases/index) for all releases.
24
24
 
25
+ ### v1.4.0
26
+
27
+ - Implement [Cooper-Harvey-Kennedy](https://www.cs.tufts.edu/~nr/cs257/archive/keith-cooper/dom14.pdf) algorithm for finding root objects in memory leaks.
28
+ - Rework capture to track objects by `object_id` exclusively.
29
+
25
30
  ### v1.3.0
26
31
 
27
32
  - **Breaking**: Renamed `Capture#count_for` to `Capture#retained_count_of` for better clarity and consistency.
@@ -75,10 +80,6 @@ Please see the [project releases](https://socketry.github.io/memory-profiler/rel
75
80
 
76
81
  - More write barriers...
77
82
 
78
- ### v1.1.8
79
-
80
- - Use single global queue for event handling to avoid incorrect ordering.
81
-
82
83
  ## Contributing
83
84
 
84
85
  We welcome contributions to this project.
data/releases.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Releases
2
2
 
3
+ ## v1.4.0
4
+
5
+ - Implement [Cooper-Harvey-Kennedy](https://www.cs.tufts.edu/~nr/cs257/archive/keith-cooper/dom14.pdf) algorithm for finding root objects in memory leaks.
6
+ - Rework capture to track objects by `object_id` exclusively.
7
+
3
8
  ## v1.3.0
4
9
 
5
10
  - **Breaking**: Renamed `Capture#count_for` to `Capture#retained_count_of` for better clarity and consistency.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: memory-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -59,6 +59,8 @@ files:
59
59
  - lib/memory/profiler/allocations.rb
60
60
  - lib/memory/profiler/call_tree.rb
61
61
  - lib/memory/profiler/capture.rb
62
+ - lib/memory/profiler/graph.rb
63
+ - lib/memory/profiler/native.rb
62
64
  - lib/memory/profiler/sampler.rb
63
65
  - lib/memory/profiler/version.rb
64
66
  - license.md
metadata.gz.sig CHANGED
Binary file