memory-profiler 1.1.5 → 1.1.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45474399b764a755546268feed4710ae9c647a8265cd933be82e35b1f390e820
4
- data.tar.gz: 47d263fc3fa31b8013d7eded37f22e74b8912b3029d6b5d9051f3a35e1b4aa6a
3
+ metadata.gz: ea6e765ad50aed3c3cf538d5c1a44f102f1bbe5251d5fd9c0995dd9d6890282a
4
+ data.tar.gz: dac87b8b0bd69f57ce3a290ef84f6eae56192ab422e554f861a2cb89b3cfa290
5
5
  SHA512:
6
- metadata.gz: d4afc54d3841e0517e966ca0e5015320cacdc747a0ed1b2004b3b5ed043345139e3d7125ee79a4ff8bc3781f04d88399f2373553fe870143adf92b7b745fe97c
7
- data.tar.gz: e99bbeda7a53e23c30e90981711f88651fd7efd453d86ba9748b11485dd2fcad6562038b91390a1f692694508e71c0aa4221ec25f2ebe61663835f667973292e
6
+ metadata.gz: cc91cd05c5cf0a2680fefe8745d8be48f7d85c0e7f358326da2ee0b58a07b01363fd9238b3b00bc9f17d5df8f216da726ebbf6dfdd77242685753ebf285183e9
7
+ data.tar.gz: df0498c707e90556d7df5319aa22775c1912f5961c5ea4f8d60f5314a9bdc8920ac60f367ef1d29e912789974857a3275af3e680a2ad2447651366e53d5685df
checksums.yaml.gz.sig CHANGED
Binary file
@@ -52,7 +52,10 @@ struct Memory_Profiler_Capture {
52
52
  // class => VALUE (wrapped Memory_Profiler_Capture_Allocations).
53
53
  st_table *tracked_classes;
54
54
 
55
- // Is tracking enabled (via start/stop):
55
+ // Master switch - is tracking active? (set by start/stop)
56
+ int running;
57
+
58
+ // Internal - should we queue callbacks? (temporarily disabled during queue processing)
56
59
  int enabled;
57
60
 
58
61
  // Queue for new objects (processed via postponed job):
@@ -300,20 +303,23 @@ static void Memory_Profiler_Capture_newobj_handler(VALUE self, struct Memory_Pro
300
303
  if (st_lookup(capture->tracked_classes, (st_data_t)klass, &allocations_data)) {
301
304
  VALUE allocations = (VALUE)allocations_data;
302
305
  struct Memory_Profiler_Capture_Allocations *record = Memory_Profiler_Allocations_get(allocations);
306
+
307
+ // Always track counts (even during queue processing)
303
308
  record->new_count++;
304
309
 
305
- // If we have a callback, queue the newobj for later processing
306
- if (!NIL_P(record->callback)) {
310
+ // Only queue for callback if tracking is enabled (prevents infinite recursion)
311
+ if (capture->enabled && !NIL_P(record->callback)) {
307
312
  // Push a new item onto the queue (returns pointer to write to)
308
313
  // NOTE: realloc is safe during allocation (doesn't trigger Ruby allocation)
309
314
  struct Memory_Profiler_Newobj_Queue_Item *newobj = Memory_Profiler_Queue_push(&capture->newobj_queue);
310
315
  if (newobj) {
311
316
  if (DEBUG_EVENT_QUEUES) fprintf(stderr, "Queued newobj, queue size now: %zu/%zu\n",
312
317
  capture->newobj_queue.count, capture->newobj_queue.capacity);
313
- // Write directly to the allocated space
314
- newobj->klass = klass;
315
- newobj->allocations = allocations;
316
- newobj->object = object;
318
+
319
+ // Write VALUEs with write barriers (combines write + GC notification)
320
+ RB_OBJ_WRITE(self, &newobj->klass, klass);
321
+ RB_OBJ_WRITE(self, &newobj->allocations, allocations);
322
+ RB_OBJ_WRITE(self, &newobj->object, object);
317
323
 
318
324
  // Trigger postponed job to process the queue
319
325
  if (DEBUG_EVENT_QUEUES) fprintf(stderr, "Triggering postponed job to process queues\n");
@@ -350,10 +356,13 @@ static void Memory_Profiler_Capture_freeobj_handler(VALUE self, struct Memory_Pr
350
356
  if (st_lookup(capture->tracked_classes, (st_data_t)klass, &allocations_data)) {
351
357
  VALUE allocations = (VALUE)allocations_data;
352
358
  struct Memory_Profiler_Capture_Allocations *record = Memory_Profiler_Allocations_get(allocations);
359
+
360
+ // Always track counts (even during queue processing)
353
361
  record->free_count++;
354
362
 
355
- // If we have a callback and detailed tracking, queue the freeobj for later processing
356
- if (!NIL_P(record->callback) && record->object_states) {
363
+ // Only queue for callback if tracking is enabled and we have state
364
+ // Note: If NEWOBJ didn't queue (enabled=0), there's no state, so this naturally skips
365
+ if (capture->enabled && !NIL_P(record->callback) && record->object_states) {
357
366
  if (DEBUG_STATE) fprintf(stderr, "Memory_Profiler_Capture_freeobj_handler: Looking up state for object: %p\n", (void *)object);
358
367
 
359
368
  // Look up state stored during NEWOBJ
@@ -364,14 +373,16 @@ static void Memory_Profiler_Capture_freeobj_handler(VALUE self, struct Memory_Pr
364
373
 
365
374
  // Push a new item onto the queue (returns pointer to write to)
366
375
  // NOTE: realloc is safe during GC (doesn't trigger Ruby allocation)
367
- struct Memory_Profiler_Freeobj_Queue_Item *freed = Memory_Profiler_Queue_push(&capture->freeobj_queue);
368
- if (freed) {
376
+ struct Memory_Profiler_Freeobj_Queue_Item *freeobj = Memory_Profiler_Queue_push(&capture->freeobj_queue);
377
+ if (freeobj) {
369
378
  if (DEBUG_EVENT_QUEUES) fprintf(stderr, "Queued freed object, queue size now: %zu/%zu\n",
370
379
  capture->freeobj_queue.count, capture->freeobj_queue.capacity);
371
- // Write directly to the allocated space
372
- freed->klass = klass;
373
- freed->allocations = allocations;
374
- freed->state = state;
380
+
381
+ // Write VALUEs with write barriers (combines write + GC notification)
382
+ // Note: We're during GC/FREEOBJ, but write barriers should be safe
383
+ RB_OBJ_WRITE(self, &freeobj->klass, klass);
384
+ RB_OBJ_WRITE(self, &freeobj->allocations, allocations);
385
+ RB_OBJ_WRITE(self, &freeobj->state, state);
375
386
 
376
387
  // Trigger postponed job to process both queues after GC
377
388
  if (DEBUG_EVENT_QUEUES) fprintf(stderr, "Triggering postponed job to process queues after GC\n");
@@ -428,8 +439,6 @@ static void Memory_Profiler_Capture_event_callback(VALUE data, void *ptr) {
428
439
  struct Memory_Profiler_Capture *capture;
429
440
  TypedData_Get_Struct(data, struct Memory_Profiler_Capture, &Memory_Profiler_Capture_type, capture);
430
441
 
431
- if (!capture->enabled) return;
432
-
433
442
  VALUE object = rb_tracearg_object(trace_arg);
434
443
 
435
444
  // We don't want to track internal non-Object allocations:
@@ -473,6 +482,8 @@ static VALUE Memory_Profiler_Capture_alloc(VALUE klass) {
473
482
  rb_raise(rb_eRuntimeError, "Failed to initialize hash table");
474
483
  }
475
484
 
485
+ // Initialize state flags - not running, callbacks disabled
486
+ capture->running = 0;
476
487
  capture->enabled = 0;
477
488
 
478
489
  // Initialize both queues
@@ -504,7 +515,7 @@ static VALUE Memory_Profiler_Capture_start(VALUE self) {
504
515
  struct Memory_Profiler_Capture *capture;
505
516
  TypedData_Get_Struct(self, struct Memory_Profiler_Capture, &Memory_Profiler_Capture_type, capture);
506
517
 
507
- if (capture->enabled) return Qfalse;
518
+ if (capture->running) return Qfalse;
508
519
 
509
520
  // Add event hook for NEWOBJ and FREEOBJ with RAW_ARG to get trace_arg
510
521
  rb_add_event_hook2(
@@ -514,6 +525,8 @@ static VALUE Memory_Profiler_Capture_start(VALUE self) {
514
525
  RUBY_EVENT_HOOK_FLAG_SAFE | RUBY_EVENT_HOOK_FLAG_RAW_ARG
515
526
  );
516
527
 
528
+ // Set both flags - we're now running and callbacks are enabled
529
+ capture->running = 1;
517
530
  capture->enabled = 1;
518
531
 
519
532
  return Qtrue;
@@ -524,11 +537,16 @@ static VALUE Memory_Profiler_Capture_stop(VALUE self) {
524
537
  struct Memory_Profiler_Capture *capture;
525
538
  TypedData_Get_Struct(self, struct Memory_Profiler_Capture, &Memory_Profiler_Capture_type, capture);
526
539
 
527
- if (!capture->enabled) return Qfalse;
540
+ if (!capture->running) return Qfalse;
528
541
 
529
- // Remove event hook using same data (self) we registered with
542
+ // Remove event hook using same data (self) we registered with. No more events will be queued after this point:
530
543
  rb_remove_event_hook_with_data((rb_event_hook_func_t)Memory_Profiler_Capture_event_callback, self);
531
544
 
545
+ // Flush any pending queued events before stopping. This ensures all callbacks are invoked and object_states is properly maintained.
546
+ Memory_Profiler_Capture_process_queues((void *)self);
547
+
548
+ // Clear both flags - we're no longer running and callbacks are disabled
549
+ capture->running = 0;
532
550
  capture->enabled = 0;
533
551
 
534
552
  return Qtrue;
@@ -52,6 +52,63 @@ module Memory
52
52
  end
53
53
  end
54
54
 
55
+ # Prune this node's children, keeping only the top N by retained count.
56
+ # Prunes current level first, then recursively prunes retained children (top-down).
57
+ #
58
+ # @parameter limit [Integer] Number of children to keep.
59
+ # @returns [Integer] Total number of nodes pruned (discarded).
60
+ def prune!(limit)
61
+ return 0 if @children.nil?
62
+
63
+ pruned_count = 0
64
+
65
+ # Prune at this level first - keep only top N children by retained count
66
+ if @children.size > limit
67
+ sorted = @children.sort_by do |_location, child|
68
+ -child.retained_count # Sort descending
69
+ end
70
+
71
+ # Detach and count discarded subtrees before we discard them:
72
+ discarded = sorted.drop(limit)
73
+ discarded.each do |_location, child|
74
+ # detach! breaks references to aid GC and returns node count
75
+ pruned_count += child.detach!
76
+ end
77
+
78
+ @children = sorted.first(limit).to_h
79
+ end
80
+
81
+ # Now recursively prune the retained children (avoid pruning nodes we just discarded)
82
+ @children.each_value {|child| pruned_count += child.prune!(limit)}
83
+
84
+ # Clean up if we ended up with no children
85
+ @children = nil if @children.empty?
86
+
87
+ pruned_count
88
+ end
89
+
90
+ # Detach this node from the tree, breaking parent/child relationships.
91
+ # This helps GC collect pruned nodes that might be retained in object_states.
92
+ #
93
+ # Recursively detaches all descendants and returns total nodes detached.
94
+ #
95
+ # @returns [Integer] Number of nodes detached (including self).
96
+ def detach!
97
+ count = 1 # Self
98
+
99
+ # Recursively detach all children first and sum their counts
100
+ if @children
101
+ @children.each_value {|child| count += child.detach!}
102
+ end
103
+
104
+ # Break all references
105
+ @parent = nil
106
+ @children = nil
107
+ @location = nil
108
+
109
+ return count
110
+ end
111
+
55
112
  # Check if this node is a leaf (end of a call path).
56
113
  #
57
114
  # @returns [Boolean] True if this node has no children.
@@ -95,8 +152,12 @@ module Memory
95
152
  # Create a new call tree for tracking allocation paths.
96
153
  def initialize
97
154
  @root = Node.new
155
+ @insertion_count = 0
98
156
  end
99
157
 
158
+ # @attribute [Integer] Number of insertions (allocations) recorded in this tree.
159
+ attr_accessor :insertion_count
160
+
100
161
  # Record an allocation with the given caller locations.
101
162
  #
102
163
  # @parameter caller_locations [Array<Thread::Backtrace::Location>] The call stack.
@@ -114,6 +175,9 @@ module Memory
114
175
  # Increment counts for entire path (from leaf back to root):
115
176
  current.increment_path!
116
177
 
178
+ # Track total insertions
179
+ @insertion_count += 1
180
+
117
181
  # Return leaf node for object tracking:
118
182
  current
119
183
  end
@@ -122,7 +186,7 @@ module Memory
122
186
  #
123
187
  # @parameter limit [Integer] Maximum number of paths to return.
124
188
  # @parameter by [Symbol] Sort by :total or :retained count.
125
- # @returns [Array<Array>] Array of [locations, total_count, retained_count].
189
+ # @returns [Array(Array)] Array of [locations, total_count, retained_count].
126
190
  def top_paths(limit = 10, by: :retained)
127
191
  paths = []
128
192
 
@@ -169,6 +233,16 @@ module Memory
169
233
  # Clear all tracking data
170
234
  def clear!
171
235
  @root = Node.new
236
+ @insertion_count = 0
237
+ end
238
+
239
+ # Prune the tree to keep only the top N children at each level.
240
+ # This controls memory usage by removing low-retained branches.
241
+ #
242
+ # @parameter limit [Integer] Number of children to keep per node (default: 5).
243
+ # @returns [Integer] Total number of nodes pruned (discarded).
244
+ def prune!(limit = 5)
245
+ @root.prune!(limit)
172
246
  end
173
247
 
174
248
  private
@@ -3,6 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2025, by Samuel Williams.
5
5
 
6
+ require "console"
7
+
6
8
  require_relative "capture"
7
9
  require_relative "call_tree"
8
10
 
@@ -90,10 +92,14 @@ module Memory
90
92
  # @parameter depth [Integer] Number of stack frames to capture for call path analysis.
91
93
  # @parameter filter [Proc] Optional filter to exclude frames from call paths.
92
94
  # @parameter increases_threshold [Integer] Number of increases before enabling detailed tracking.
93
- def initialize(depth: 10, filter: nil, increases_threshold: 10)
95
+ # @parameter prune_limit [Integer] Keep only top N children per node during pruning (default: 5).
96
+ # @parameter prune_threshold [Integer] Number of insertions before auto-pruning (nil = no auto-pruning).
97
+ def initialize(depth: 4, filter: nil, increases_threshold: 10, prune_limit: 5, prune_threshold: nil)
94
98
  @depth = depth
95
99
  @filter = filter || default_filter
96
100
  @increases_threshold = increases_threshold
101
+ @prune_limit = prune_limit
102
+ @prune_threshold = prune_threshold
97
103
  @capture = Capture.new
98
104
  @call_trees = {}
99
105
  @samples = {}
@@ -148,6 +154,9 @@ module Memory
148
154
  yield sample if block_given?
149
155
  end
150
156
  end
157
+
158
+ # Prune call trees to control memory usage
159
+ prune_call_trees!
151
160
  end
152
161
 
153
162
  # Start tracking with call path analysis.
@@ -256,11 +265,37 @@ module Memory
256
265
  @call_trees.clear
257
266
  end
258
267
 
259
- private
268
+ private
260
269
 
261
270
  def default_filter
262
271
  ->(location) {!location.path.match?(%r{/(gems|ruby)/|\A\(eval\)})}
263
272
  end
273
+
274
+ def prune_call_trees!
275
+ return if @prune_threshold.nil?
276
+
277
+ @call_trees.each do |klass, tree|
278
+ # Only prune if insertions exceed threshold:
279
+ insertions = tree.insertion_count
280
+ next if insertions < @prune_threshold
281
+
282
+ # Prune the tree
283
+ pruned_count = tree.prune!(@prune_limit)
284
+
285
+ # Reset insertion counter after pruning
286
+ tree.insertion_count = 0
287
+
288
+ # Log pruning activity for visibility
289
+ if pruned_count > 0 && defined?(Console)
290
+ Console.debug(klass, "Pruned call tree:",
291
+ pruned_nodes: pruned_count,
292
+ insertions_since_last_prune: insertions,
293
+ total: tree.total_allocations,
294
+ retained: tree.retained_allocations
295
+ )
296
+ end
297
+ end
298
+ end
264
299
  end
265
300
  end
266
301
  end
@@ -7,7 +7,7 @@
7
7
  module Memory
8
8
  # @namespace
9
9
  module Profiler
10
- VERSION = "1.1.5"
10
+ VERSION = "1.1.6"
11
11
  end
12
12
  end
13
13
 
data/readme.md CHANGED
@@ -22,6 +22,12 @@ 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.1.6
26
+
27
+ - Write barriers all the things.
28
+ - Better state handling and object increment/decrement counting.
29
+ - Better call tree handling - including support for `prune!`.
30
+
25
31
  ### v1.1.5
26
32
 
27
33
  - Use queue for `newobj` too to avoid invoking user code during object allocation.
data/releases.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Releases
2
2
 
3
+ ## v1.1.6
4
+
5
+ - Write barriers all the things.
6
+ - Better state handling and object increment/decrement counting.
7
+ - Better call tree handling - including support for `prune!`.
8
+
3
9
  ## v1.1.5
4
10
 
5
11
  - Use queue for `newobj` too to avoid invoking user code during object allocation.
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.1.5
4
+ version: 1.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
metadata.gz.sig CHANGED
Binary file