Skip to content

Commit 7b9a3b6

Browse files
Introduce Memory::Graph.for.
1 parent c56cedb commit 7b9a3b6

File tree

5 files changed

+501
-521
lines changed

5 files changed

+501
-521
lines changed

lib/memory/graph.rb

Lines changed: 180 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -3,139 +3,215 @@
33
# Released under the MIT License.
44
# Copyright, 2025, by Samuel Williams.
55

6-
require "set"
6+
require_relative "usage"
7+
require "json"
78

89
module Memory
910
# Tracks object traversal paths for memory analysis.
10-
#
11-
# The Graph class maintains a mapping of objects to their parent objects,
12-
# allowing you to trace the reference path from any object back to its root.
13-
class Graph
14-
def initialize
15-
@mapping = Hash.new.compare_by_identity
16-
end
17-
18-
# The internal mapping of objects to their parents.
19-
attr_reader :mapping
11+
module Graph
12+
IGNORE = Usage::IGNORE
2013

21-
# Add a parent-child relationship to the via mapping.
22-
#
23-
# @parameter child [Object] The child object.
24-
# @parameter parent [Object] The parent object that references the child.
25-
def []=(child, parent)
26-
@mapping[child] = parent
27-
end
28-
29-
# Get the parent of an object.
30-
#
31-
# @parameter child [Object] The child object.
32-
# @returns [Object | Nil] The parent object, or nil if not tracked.
33-
def [](child)
34-
@mapping[child]
35-
end
36-
37-
# Check if an object is tracked in the via mapping.
38-
#
39-
# @parameter object [Object] The object to check.
40-
# @returns [Boolean] True if the object is tracked.
41-
def key?(object)
42-
@mapping.key?(object)
43-
end
44-
45-
# Find how a parent object references a child object.
46-
#
47-
# @parameter parent [Object] The parent object.
48-
# @parameter child [Object] The child object to find.
49-
# @returns [String | Nil] A human-readable description of the reference, or nil if not found.
50-
def find_reference(parent, child)
51-
# Check instance variables:
52-
parent.instance_variables.each do |ivar|
53-
value = parent.instance_variable_get(ivar)
54-
if value.equal?(child)
55-
return ivar.to_s
56-
end
14+
# Represents a node in the object graph with usage information.
15+
class Node
16+
def initialize(object, usage = Usage.new, parent = nil, reference: nil)
17+
@object = object
18+
@usage = usage
19+
@parent = parent
20+
@children = nil
21+
22+
@reference = reference || parent&.find_reference(object)
23+
@total_usage = nil
24+
@path = nil
25+
end
26+
27+
# @attribute [Object] The object this node represents.
28+
attr_accessor :object
29+
30+
# @attribute [Usage] The memory usage of this object (not including children).
31+
attr_accessor :usage
32+
33+
# @attribute [Node | Nil] The parent node (nil for root).
34+
attr_accessor :parent
35+
36+
# @attribute [Hash(String, Node) | Nil] Child nodes reachable from this object (hash of reference => node).
37+
attr_accessor :children
38+
39+
# @attribute [String | Nil] The reference to the parent object (nil for root).
40+
attr_accessor :reference
41+
42+
# Add a child node to this node.
43+
#
44+
# @parameter child [Node] The child node to add.
45+
# @returns [self] Returns self for chaining.
46+
def add(child)
47+
@children ||= {}
48+
49+
# Use the reference as the key, or a fallback if not found:
50+
key = child.reference || "(#{@children.size})"
51+
52+
@children[key] = child
53+
54+
return self
5755
end
5856

59-
# Check array elements:
60-
if parent.is_a?(Array)
61-
parent.each_with_index do |element, index|
62-
if element.equal?(child)
63-
return "[#{index}]"
57+
# Compute total usage including all children.
58+
def total_usage
59+
unless @total_usage
60+
@total_usage = Usage.new(@usage.size, @usage.count)
61+
62+
@children&.each_value do |child|
63+
child_total = child.total_usage
64+
@total_usage.add!(child_total)
6465
end
6566
end
67+
68+
return @total_usage
6669
end
6770

68-
# Check hash keys and values:
69-
if parent.is_a?(Hash)
70-
parent.each do |key, value|
71+
# Find how this node references a child object.
72+
#
73+
# @parameter child [Object] The child object to find.
74+
# @returns [String | Nil] A human-readable description of the reference, or nil if not found.
75+
def find_reference(child)
76+
# Check instance variables:
77+
@object.instance_variables.each do |ivar|
78+
value = @object.instance_variable_get(ivar)
7179
if value.equal?(child)
72-
return "[#{key.inspect}]"
80+
return ivar.to_s
7381
end
74-
if key.equal?(child)
75-
return "(key: #{key.inspect})"
82+
end
83+
84+
# Check array elements:
85+
if @object.is_a?(Array)
86+
@object.each_with_index do |element, index|
87+
if element.equal?(child)
88+
return "[#{index}]"
89+
end
90+
end
91+
end
92+
93+
# Check hash keys and values:
94+
if @object.is_a?(Hash)
95+
@object.each do |key, value|
96+
if value.equal?(child)
97+
return "[#{key.inspect}]"
98+
end
99+
if key.equal?(child)
100+
return "(key: #{key.inspect})"
101+
end
102+
end
103+
end
104+
105+
# Check struct members:
106+
if @object.is_a?(Struct)
107+
@object.each_pair do |member, value|
108+
if value.equal?(child)
109+
return ".#{member}"
110+
end
76111
end
77112
end
113+
114+
# Could not determine the reference:
115+
return nil
78116
end
79117

80-
# Check struct members:
81-
if parent.is_a?(Struct)
82-
parent.each_pair do |member, value|
83-
if value.equal?(child)
84-
return ".#{member}"
118+
# Get the path string from root to this node (cached).
119+
#
120+
# @returns [String | Nil] The formatted path string, or nil if no graph available.
121+
def path
122+
unless @path
123+
# Build object path from root to this node:
124+
object_path = []
125+
current = self
126+
127+
while current
128+
object_path.unshift(current)
129+
current = current.parent
130+
end
131+
132+
# Format the path:
133+
parts = ["#<#{object_path.first.object.class}:0x%016x>" % (object_path.first.object.object_id << 1)]
134+
135+
# Append each reference in the path:
136+
(1...object_path.size).each do |i|
137+
parent_node = object_path[i - 1]
138+
child_node = object_path[i]
139+
140+
parts << (parent_node.find_reference(child_node.object) || "<??>")
85141
end
142+
143+
@path = parts.join
86144
end
145+
146+
return @path
87147
end
88148

89-
# Could not determine the reference:
90-
return nil
149+
# Convert this node to a JSON-compatible hash.
150+
#
151+
# @parameter options [Hash] Options for JSON serialization.
152+
# @returns [Hash] A hash representation of this node.
153+
def as_json(*)
154+
{
155+
path: path,
156+
object: {
157+
class: @object.class.name,
158+
object_id: @object.object_id
159+
},
160+
usage: @usage.as_json,
161+
total_usage: total_usage.as_json,
162+
children: @children&.transform_values(&:as_json)
163+
}
164+
end
165+
166+
# Convert this node to a JSON string.
167+
#
168+
# @parameter options [Hash] Options for JSON serialization.
169+
# @returns [String] A JSON string representation of this node.
170+
def to_json(...)
171+
as_json.to_json(...)
172+
end
91173
end
92174

93-
# Construct a human-readable path from an object back to a root.
175+
# Build a graph of nodes from a root object, computing usage at each level.
94176
#
95-
# @parameter object [Object] The object to trace back from.
96-
# @parameter root [Object | Nil] The root object to trace to. If nil, traces to any root.
97-
# @returns [Array(Array(Object), Array(String))] A tuple of [object_path, reference_path].
98-
def path_to(object, root = nil)
99-
# Build the object path by following via backwards:
100-
object_path = [object]
101-
current = object
102-
103-
while @mapping.key?(current)
104-
parent = @mapping[current]
105-
object_path << parent
106-
current = parent
107-
108-
# Stop if we reached the specified root:
109-
break if root && current.equal?(root)
177+
# @parameter root [Object] The root object to start from.
178+
# @parameter depth [Integer] Maximum depth to traverse (nil for unlimited).
179+
# @parameter seen [Set] Set of already seen objects (for internal use).
180+
# @parameter ignore [Array] Array of types to ignore during traversal.
181+
# @parameter parent [Node | Nil] The parent node (for internal use).
182+
# @returns [Node] The root node with children populated.
183+
def self.for(root, depth: nil, seen: Set.new.compare_by_identity, ignore: IGNORE, parent: nil)
184+
if depth && depth <= 0
185+
# Compute shallow usage for this object and it's children:
186+
usage = Usage.of(root, seen: seen, ignore: ignore)
187+
return Node.new(root, usage, parent)
110188
end
111189

112-
# Reverse to get path from root to object:
113-
object_path.reverse!
190+
# Compute shallow usage for just this object:
191+
usage = Usage.new(ObjectSpace.memsize_of(root), 1)
114192

115-
return object_path
116-
end
117-
118-
# Format a human-readable path string.
119-
#
120-
# @parameter object [Object] The object to trace back from.
121-
# @parameter root [Object | Nil] The root object to trace to. If nil, traces to any root.
122-
# @returns [String] A formatted path string.
123-
def path(object, root = nil)
124-
object_path = path_to(object, root)
125-
126-
# Start with the root object description:
127-
parts = ["#<#{object_path.first.class}:0x%016x>" % (object_path.first.object_id << 1)]
128-
129-
# Append each reference in the path:
130-
(1...object_path.size).each do |i|
131-
parent = object_path[i - 1]
132-
child = object_path[i]
193+
# Create the node:
194+
node = Node.new(root, usage, parent)
195+
196+
# Mark this object as seen:
197+
seen.add(root)
198+
199+
# Traverse children:
200+
ObjectSpace.reachable_objects_from(root)&.each do |reachable_object|
201+
# Skip ignored types:
202+
next if ignore.any?{|type| reachable_object.is_a?(type)}
133203

134-
parts << (find_reference(parent, child) || "<??>")
204+
# Skip internal objects:
205+
next if reachable_object.is_a?(ObjectSpace::InternalObjectWrapper)
206+
207+
# Skip already seen objects:
208+
next if seen.include?(reachable_object)
209+
210+
# Recursively build child node:
211+
node.add(self.for(reachable_object, depth: depth ? depth - 1 : nil, seen: seen, ignore: ignore, parent: node))
135212
end
136213

137-
return parts.join
214+
return node
138215
end
139216
end
140217
end
141-

lib/memory/usage.rb

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,15 @@ def << allocation
3939
return self
4040
end
4141

42+
# Add another usage to this usage.
43+
# @parameter other [Usage] The usage to add.
44+
def add!(other)
45+
self.size += other.size
46+
self.count += other.count
47+
48+
return self
49+
end
50+
4251
IGNORE = [
4352
# Skip modules and symbols, they are usually "global":
4453
Module,
@@ -62,9 +71,8 @@ def << allocation
6271
#
6372
# @parameter root [Object] The root object to start traversal from.
6473
# @parameter seen [Hash(Object, Integer)] The seen objects (should be compare_by_identity).
65-
# @parameter via [Hash(Object, Object) | Nil] The traversal path. The key object was seen via the value object.
6674
# @returns [Usage] The usage of the object and all reachable objects from it.
67-
def self.of(root, seen: Set.new.compare_by_identity, ignore: IGNORE, via: nil)
75+
def self.of(root, seen: Set.new.compare_by_identity, ignore: IGNORE)
6876
count = 0
6977
size = 0
7078

@@ -88,10 +96,6 @@ def self.of(root, seen: Set.new.compare_by_identity, ignore: IGNORE, via: nil)
8896
# Skip objects we have already seen:
8997
next if seen.include?(reachable_object)
9098

91-
if via
92-
via[reachable_object] ||= object
93-
end
94-
9599
queue << reachable_object
96100
end
97101
end

releases.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Remove support for `Memory::Usage.of(..., via:)` and instead use `Memory::Graph.for` which collects more detailed usage until the specified depth, at which point it delgates to `Memory::Usage.of`. This should be more practical.
6+
37
## v0.10.0
48

59
- Add support for `Memory::Usage.of(..., via:)` for tracking reachability of objects.

0 commit comments

Comments
 (0)