memory-profiler 1.0.3 → 1.1.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.
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ module Memory
7
+ module Profiler
8
+ # Efficient tree structure for tracking allocation call paths.
9
+ # Each node represents a frame in the call stack, with counts of how many
10
+ # allocations occurred at this point in the call path.
11
+ class CallTree
12
+ # Represents a node in the call tree.
13
+ #
14
+ # Each node tracks how many allocations occurred at a specific point in a call path.
15
+ # Nodes form a tree structure where each path from root to leaf represents a unique
16
+ # call stack that led to allocations.
17
+ #
18
+ # Nodes now track both total allocations and currently retained (live) allocations.
19
+ class Node
20
+ # Create a new call tree node.
21
+ #
22
+ # @parameter location [Thread::Backtrace::Location] The source location for this frame.
23
+ # @parameter parent [Node] The parent node in the tree.
24
+ def initialize(location = nil, parent = nil)
25
+ @location = location
26
+ @parent = parent
27
+ @total_count = 0 # Total allocations (never decrements)
28
+ @retained_count = 0 # Current live objects (decrements on free)
29
+ @children = nil
30
+ end
31
+
32
+ # @attribute [Thread::Backtrace::Location] The location of the call.
33
+ attr_reader :location, :parent, :children
34
+ attr_accessor :total_count, :retained_count
35
+
36
+ # Increment both total and retained counts up the entire path to root.
37
+ def increment_path!
38
+ current = self
39
+ while current
40
+ current.total_count += 1
41
+ current.retained_count += 1
42
+ current = current.parent
43
+ end
44
+ end
45
+
46
+ # Decrement retained count up the entire path to root.
47
+ def decrement_path!
48
+ current = self
49
+ while current
50
+ current.retained_count -= 1
51
+ current = current.parent
52
+ end
53
+ end
54
+
55
+ # Check if this node is a leaf (end of a call path).
56
+ #
57
+ # @returns [Boolean] True if this node has no children.
58
+ def leaf?
59
+ @children.nil?
60
+ end
61
+
62
+ # Find or create a child node for the given location.
63
+ #
64
+ # @parameter location [Thread::Backtrace::Location] The frame location for the child node.
65
+ # @returns [Node] The child node for this location.
66
+ def find_or_create_child(location)
67
+ @children ||= {}
68
+ @children[location.to_s] ||= Node.new(location, self)
69
+ end
70
+
71
+ # Iterate over child nodes.
72
+ #
73
+ # @yields {|child| ...} If a block is given, yields each child node.
74
+ def each_child(&block)
75
+ @children&.each_value(&block)
76
+ end
77
+
78
+ # Enumerate all paths from this node to leaves with their counts
79
+ #
80
+ # @parameter prefix [Array] The path prefix (nodes traversed so far).
81
+ # @yields {|path, total_count, retained_count| ...} For each leaf path.
82
+ def each_path(prefix = [], &block)
83
+ current = prefix + [self]
84
+
85
+ if leaf?
86
+ yield current, @total_count, @retained_count
87
+ end
88
+
89
+ @children&.each_value do |child|
90
+ child.each_path(current, &block)
91
+ end
92
+ end
93
+ end
94
+
95
+ # Create a new call tree for tracking allocation paths.
96
+ def initialize
97
+ @root = Node.new
98
+ end
99
+
100
+ # Record an allocation with the given caller locations.
101
+ #
102
+ # @parameter caller_locations [Array<Thread::Backtrace::Location>] The call stack.
103
+ # @returns [Node] The leaf node representing this allocation path.
104
+ def record(caller_locations)
105
+ return nil if caller_locations.empty?
106
+
107
+ current = @root
108
+
109
+ # Build tree path from root to leaf:
110
+ caller_locations.each do |location|
111
+ current = current.find_or_create_child(location)
112
+ end
113
+
114
+ # Increment counts for entire path (from leaf back to root):
115
+ current.increment_path!
116
+
117
+ # Return leaf node for object tracking:
118
+ current
119
+ end
120
+
121
+ # Get the top N call paths by allocation count.
122
+ #
123
+ # @parameter limit [Integer] Maximum number of paths to return.
124
+ # @parameter by [Symbol] Sort by :total or :retained count.
125
+ # @returns [Array<Array>] Array of [locations, total_count, retained_count].
126
+ def top_paths(limit = 10, by: :retained)
127
+ paths = []
128
+
129
+ @root.each_path do |path, total_count, retained_count|
130
+ # Filter out root node (has nil location) and map to location strings
131
+ locations = path.select(&:location).map {|node| node.location.to_s}
132
+ paths << [locations, total_count, retained_count] unless locations.empty?
133
+ end
134
+
135
+ # Sort by the requested metric (default: retained, since that's what matters for leaks)
136
+ sort_index = (by == :total) ? 1 : 2
137
+ paths.sort_by {|path_data| -path_data[sort_index]}.first(limit)
138
+ end
139
+
140
+ # Get hotspot locations (individual frames with highest counts).
141
+ #
142
+ # @parameter limit [Integer] Maximum number of hotspots to return.
143
+ # @parameter by [Symbol] Sort by :total or :retained count.
144
+ # @returns [Hash] Map of location => [total_count, retained_count].
145
+ def hotspots(limit = 20, by: :retained)
146
+ frames = Hash.new {|h, k| h[k] = [0, 0]}
147
+
148
+ collect_frames(@root, frames)
149
+
150
+ # Sort by the requested metric
151
+ sort_index = (by == :total) ? 0 : 1
152
+ frames.sort_by {|_, counts| -counts[sort_index]}.first(limit).to_h
153
+ end
154
+
155
+ # Total number of allocations tracked.
156
+ #
157
+ # @returns [Integer] Total allocation count.
158
+ def total_allocations
159
+ @root.total_count
160
+ end
161
+
162
+ # Number of currently retained (live) allocations.
163
+ #
164
+ # @returns [Integer] Retained allocation count.
165
+ def retained_allocations
166
+ @root.retained_count
167
+ end
168
+
169
+ # Clear all tracking data
170
+ def clear!
171
+ @root = Node.new
172
+ end
173
+
174
+ private
175
+
176
+ def collect_frames(node, frames)
177
+ # Skip root node (has no location)
178
+ if node.location
179
+ location_str = node.location.to_s
180
+ frames[location_str][0] += node.total_count
181
+ frames[location_str][1] += node.retained_count
182
+ end
183
+
184
+ node.each_child {|child| collect_frames(child, frames)}
185
+ end
186
+ end
187
+ end
188
+ end
189
+
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require "Memory_Profiler"
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require_relative "capture"
7
+ require_relative "call_tree"
8
+
9
+ module Memory
10
+ module Profiler
11
+ # Periodic sampler for monitoring memory growth over time.
12
+ #
13
+ # Samples class allocation counts at regular intervals and detects potential memory leaks
14
+ # by tracking when counts increase beyond a threshold. When a class exceeds the increases
15
+ # threshold, automatically enables detailed call path tracking for diagnosis.
16
+ class Sampler
17
+ # Tracks memory growth for a specific class.
18
+ #
19
+ # Records allocation counts over time and detects sustained growth patterns
20
+ # that indicate potential memory leaks.
21
+ class Sample
22
+ # Create a new sample profiler for a class.
23
+ #
24
+ # @parameter target [Class] The class being sampled.
25
+ # @parameter size [Integer] Initial object count.
26
+ # @parameter threshold [Integer] Minimum increase to consider significant.
27
+ def initialize(target, size = 0, threshold: 1000)
28
+ @target = target
29
+ @current_size = size
30
+ @maximum_observed_size = size
31
+ @threshold = threshold
32
+
33
+ @sample_count = 0
34
+ @increases = 0
35
+ end
36
+
37
+ attr_reader :target, :current_size, :maximum_observed_size, :threshold, :sample_count, :increases
38
+
39
+ # Record a new sample measurement.
40
+ #
41
+ # @parameter size [Integer] Current object count for this class.
42
+ # @returns [Boolean] True if count increased significantly.
43
+ def sample!(size)
44
+ @sample_count += 1
45
+ @current_size = size
46
+
47
+ # @maximum_observed_count ratchets up in units of at least @threshold counts.
48
+ # When it does, we bump @increases to track a potential memory leak.
49
+ if @maximum_observed_size
50
+ delta = @current_size - @maximum_observed_size
51
+ if delta > @threshold
52
+ @maximum_observed_size = size
53
+ @increases += 1
54
+
55
+ return true
56
+ end
57
+ else
58
+ @maximum_observed_size = size
59
+ end
60
+
61
+ return false
62
+ end
63
+
64
+ # Convert sample data to JSON-compatible hash.
65
+ #
66
+ # @returns [Hash] Sample data as a hash.
67
+ def as_json(...)
68
+ {
69
+ target: @target.name || "(anonymous class)",
70
+ current_size: @current_size,
71
+ maximum_observed_size: @maximum_observed_size,
72
+ increases: @increases,
73
+ sample_count: @sample_count,
74
+ threshold: @threshold,
75
+ }
76
+ end
77
+
78
+ # Convert sample data to JSON string.
79
+ #
80
+ # @returns [String] Sample data as JSON.
81
+ def to_json(...)
82
+ as_json.to_json(...)
83
+ end
84
+ end
85
+
86
+ attr_reader :depth
87
+
88
+ # Create a new memory sampler.
89
+ #
90
+ # @parameter depth [Integer] Number of stack frames to capture for call path analysis.
91
+ # @parameter filter [Proc] Optional filter to exclude frames from call paths.
92
+ # @parameter increases_threshold [Integer] Number of increases before enabling detailed tracking.
93
+ def initialize(depth: 10, filter: nil, increases_threshold: 10)
94
+ @depth = depth
95
+ @filter = filter || default_filter
96
+ @increases_threshold = increases_threshold
97
+ @capture = Capture.new
98
+ @call_trees = {}
99
+ @samples = {}
100
+ end
101
+
102
+ # Start capturing allocations.
103
+ def start
104
+ @capture.start
105
+ end
106
+
107
+ # Stop capturing allocations.
108
+ def stop
109
+ @capture.stop
110
+ end
111
+
112
+ # Run periodic sampling in a loop.
113
+ #
114
+ # Samples allocation counts at the specified interval and reports when
115
+ # classes show sustained memory growth. Automatically tracks ALL classes
116
+ # that allocate objects - no need to specify them upfront.
117
+ #
118
+ # @parameter interval [Numeric] Seconds between samples.
119
+ # @yields {|sample| ...} Called when a class shows significant growth.
120
+ def run(interval: 60, &block)
121
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
122
+
123
+ while true
124
+ sample!(&block)
125
+
126
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
127
+ delta = interval - (now - start_time)
128
+ sleep(delta) if delta > 0
129
+ start_time = now
130
+ end
131
+ end
132
+
133
+ # Take a single sample of memory usage for all tracked classes.
134
+ #
135
+ # @yields {|sample| ...} Called when a class shows significant growth.
136
+ def sample!
137
+ @capture.each do |klass, allocations|
138
+ count = allocations.retained_count
139
+ sample = @samples[klass] ||= Sample.new(klass, count)
140
+
141
+ if sample.sample!(count)
142
+ # Check if we should enable detailed tracking
143
+ if sample.increases >= @increases_threshold && !@call_trees.key?(klass)
144
+ track_with_analysis_internal(klass, allocations)
145
+ end
146
+
147
+ # Notify about growth if block given
148
+ yield sample if block_given?
149
+ end
150
+ end
151
+ end
152
+
153
+ # Internal: Enable tracking with analysis using allocations object
154
+ private def track_with_analysis_internal(klass, allocations)
155
+ tree = @call_trees[klass] = CallTree.new
156
+ depth = @depth
157
+ filter = @filter
158
+
159
+ # Register callback on allocations object with new signature:
160
+ # - On :newobj - returns state (leaf node) which C extension stores
161
+ # - On :freeobj - receives state back from C extension
162
+ allocations.track do |klass, event, state|
163
+ case event
164
+ when :newobj
165
+ # Capture call stack and record in tree
166
+ locations = caller_locations(1, depth)
167
+ filtered = locations.select(&filter)
168
+ unless filtered.empty?
169
+ # Record returns the leaf node - return it so C can store it
170
+ tree.record(filtered)
171
+ end
172
+ # Return nil or the node - C will store whatever we return
173
+ when :freeobj
174
+ # Decrement using the state (leaf node) passed back from C
175
+ if state
176
+ state.decrement_path!
177
+ end
178
+ end
179
+ rescue Exception => error
180
+ warn "Error in track_with_analysis_internal: #{error.message}\n#{error.backtrace.join("\n")}"
181
+ end
182
+ end
183
+
184
+ # Start tracking allocations for a class (count only).
185
+ def track(klass)
186
+ return if @capture.tracking?(klass)
187
+
188
+ @capture.track(klass)
189
+ end
190
+
191
+ # Start tracking with call path analysis.
192
+ #
193
+ # @parameter klass [Class] The class to track with detailed analysis.
194
+ def track_with_analysis(klass)
195
+ # Track the class if not already tracked
196
+ unless @capture.tracking?(klass)
197
+ @capture.track(klass)
198
+ end
199
+
200
+ # Enable analysis by setting callback on the allocations object
201
+ @capture.each do |tracked_klass, allocations|
202
+ if tracked_klass == klass
203
+ track_with_analysis_internal(klass, allocations)
204
+ break
205
+ end
206
+ end
207
+ end
208
+
209
+ # Stop tracking a specific class.
210
+ def untrack(klass)
211
+ @capture.untrack(klass)
212
+ @call_trees.delete(klass)
213
+ end
214
+
215
+ # Check if a class is being tracked.
216
+ def tracking?(klass)
217
+ @capture.tracking?(klass)
218
+ end
219
+
220
+ # Get live object count for a class.
221
+ def count(klass)
222
+ @capture.count_for(klass)
223
+ end
224
+
225
+ # Get the call tree for a specific class.
226
+ def call_tree(klass)
227
+ @call_trees[klass]
228
+ end
229
+
230
+ # Get allocation statistics for a tracked class.
231
+ #
232
+ # @parameter klass [Class] The class to get statistics for.
233
+ # @returns [Hash] Statistics including total, retained, paths, and hotspots.
234
+ def statistics(klass)
235
+ tree = @call_trees[klass]
236
+ return nil unless tree
237
+
238
+ {
239
+ live_count: @capture.count_for(klass),
240
+ total_allocations: tree.total_allocations,
241
+ retained_allocations: tree.retained_allocations,
242
+ top_paths: tree.top_paths(10).map {|path, total, retained|
243
+ { path: path, total_count: total, retained_count: retained }
244
+ },
245
+ hotspots: tree.hotspots(20).transform_values {|total, retained|
246
+ { total_count: total, retained_count: retained }
247
+ }
248
+ }
249
+ end
250
+
251
+ # Get statistics for all tracked classes.
252
+ def all_statistics
253
+ @call_trees.keys.each_with_object({}) do |klass, result|
254
+ result[klass] = statistics(klass) if tracking?(klass)
255
+ end
256
+ end
257
+
258
+ # Clear tracking data for a class.
259
+ def clear(klass)
260
+ tree = @call_trees[klass]
261
+ tree&.clear!
262
+ end
263
+
264
+ # Clear all tracking data.
265
+ def clear_all!
266
+ @call_trees.each_value(&:clear!)
267
+ @capture.clear
268
+ end
269
+
270
+ # Stop all tracking and clean up.
271
+ def stop!
272
+ @capture.stop
273
+ @call_trees.each_key do |klass|
274
+ @capture.untrack(klass)
275
+ end
276
+ @capture.clear
277
+ @call_trees.clear
278
+ end
279
+
280
+ private
281
+
282
+ def default_filter
283
+ ->(location) {path = location.path
284
+ !path.include?("/gems/") &&
285
+ !path.include?("/ruby/") &&
286
+ !path.start_with?("(eval)")
287
+ }
288
+ end
289
+ end
290
+ end
291
+ end
292
+
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ # @namespace
7
+ module Memory
8
+ # @namespace
9
+ module Profiler
10
+ VERSION = "1.1.0"
11
+ end
12
+ end
13
+
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require_relative "profiler/version"
7
+ require_relative "profiler/call_tree"
8
+ require_relative "profiler/capture"
9
+ require_relative "profiler/sampler"
data/license.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright, 2025, by Samuel Williams.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/readme.md ADDED
@@ -0,0 +1,45 @@
1
+ # Memory::Profiler
2
+
3
+ Efficient memory allocation tracking focused on **retained objects only**. Automatically tracks allocations and cleans up when objects are freed, giving you precise data on memory leaks.
4
+
5
+ [![Development Status](https://github.com/socketry/memory-profiler/workflows/Test/badge.svg)](https://github.com/socketry/memory-profiler/actions?workflow=Test)
6
+
7
+ ## Features
8
+
9
+ - **Retained Objects Only**: Uses `RUBY_INTERNAL_EVENT_NEWOBJ` and `RUBY_INTERNAL_EVENT_FREEOBJ` to automatically track only objects that survive GC.
10
+ - **O(1) Live Counts**: Maintains per-class counters updated on alloc/free - no heap enumeration needed\!
11
+ - **Tree-Based Analysis**: Deduplicates common call paths using an efficient tree structure.
12
+ - **Native C Extension**: **Required** - uses Ruby internal events not available in pure Ruby.
13
+ - **Configurable Depth**: Control how deep to capture call stacks.
14
+
15
+ ## Usage
16
+
17
+ Please see the [project documentation](https://socketry.github.io/memory-profiler/) for more details.
18
+
19
+ - [Getting Started](https://socketry.github.io/memory-profiler/guides/getting-started/index) - This guide explains how to use `memory-profiler` to detect and diagnose memory leaks in Ruby applications.
20
+
21
+ ## Releases
22
+
23
+ Please see the [project releases](https://socketry.github.io/memory-profiler/releases/index) for all releases.
24
+
25
+ ### v0.1.0
26
+
27
+ - Initial implementation.
28
+
29
+ ## Contributing
30
+
31
+ We welcome contributions to this project.
32
+
33
+ 1. Fork it.
34
+ 2. Create your feature branch (`git checkout -b my-new-feature`).
35
+ 3. Commit your changes (`git commit -am 'Add some feature'`).
36
+ 4. Push to the branch (`git push origin my-new-feature`).
37
+ 5. Create new Pull Request.
38
+
39
+ ### Developer Certificate of Origin
40
+
41
+ In order to protect users of this project, we require all contributors to comply with the [Developer Certificate of Origin](https://developercertificate.org/). This ensures that all contributions are properly licensed and attributed.
42
+
43
+ ### Community Guidelines
44
+
45
+ This project is best served by a collaborative and respectful environment. Treat each other professionally, respect differing viewpoints, and engage constructively. Harassment, discrimination, or harmful behavior is not tolerated. Communicate clearly, listen actively, and support one another. If any issues arise, please inform the project maintainers.
data/releases.md ADDED
@@ -0,0 +1,5 @@
1
+ # Releases
2
+
3
+ ## v0.1.0
4
+
5
+ - Initial implementation.
data.tar.gz.sig ADDED
@@ -0,0 +1 @@
1
+ Ps#(�����{L��+n]-Sd,�9�u"��@.��~�+"��<�S��ύ���v�{\��Wf�;V%UK�Ϗe���� .v���Py�k���xcF �(zZb�e�Lnd-Z�����2�6V���:=��ܑM\�~Kp��wGQa���l�1�F*l�`��A/��j���r|���p�wD��0�n�_��H�{� 7]����{^c�a�)}�9å�J����a@o&��oi�k��d�����&sI�t���k�i�U�R�-�,�X�3���R��勖8����#�s:�����I�QٟM�ݼ�3=զy8~I%����M�e� G�KH� �iH�`�����=ZnV#��>�\엝�YCu���� wOv#�f!�rgmz/�K�