Skip to content

Commit ed8ed15

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

File tree

5 files changed

+374
-531
lines changed

5 files changed

+374
-531
lines changed

lib/memory/graph.rb

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

6-
require "set"
6+
require_relative "usage"
77

88
module Memory
99
# 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
20-
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
10+
module Graph
11+
IGNORE = Usage::IGNORE
4412

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
13+
# Represents a node in the object graph with usage information.
14+
class Node
15+
def initialize(object, usage: nil, parent: nil)
16+
@object = object
17+
@usage = usage
18+
@total_usage = nil
19+
20+
@children = []
21+
@parent = parent
22+
@path = nil
5723
end
5824

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}]"
25+
# The object this node represents.
26+
attr_accessor :object
27+
28+
# The memory usage of this object (not including children).
29+
attr_accessor :usage
30+
31+
# Child nodes reachable from this object.
32+
attr_accessor :children
33+
34+
# The parent node (nil for root).
35+
attr_accessor :parent
36+
37+
# Compute total usage including all children.
38+
def total_usage
39+
unless @total_usage
40+
@total_usage = Usage.new(@usage.size, @usage.count)
41+
42+
@children.each do |child|
43+
child_total = child.total_usage
44+
@total_usage.add!(child_total)
6445
end
6546
end
47+
48+
return @total_usage
6649
end
6750

68-
# Check hash keys and values:
69-
if parent.is_a?(Hash)
70-
parent.each do |key, value|
51+
# Find how this node references a child object.
52+
#
53+
# @parameter child [Object] The child object to find.
54+
# @returns [String | Nil] A human-readable description of the reference, or nil if not found.
55+
def find_reference(child)
56+
# Check instance variables:
57+
@object.instance_variables.each do |ivar|
58+
value = @object.instance_variable_get(ivar)
7159
if value.equal?(child)
72-
return "[#{key.inspect}]"
60+
return ivar.to_s
7361
end
74-
if key.equal?(child)
75-
return "(key: #{key.inspect})"
62+
end
63+
64+
# Check array elements:
65+
if @object.is_a?(Array)
66+
@object.each_with_index do |element, index|
67+
if element.equal?(child)
68+
return "[#{index}]"
69+
end
7670
end
7771
end
78-
end
79-
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}"
72+
73+
# Check hash keys and values:
74+
if @object.is_a?(Hash)
75+
@object.each do |key, value|
76+
if value.equal?(child)
77+
return "[#{key.inspect}]"
78+
end
79+
if key.equal?(child)
80+
return "(key: #{key.inspect})"
81+
end
82+
end
83+
end
84+
85+
# Check struct members:
86+
if @object.is_a?(Struct)
87+
@object.each_pair do |member, value|
88+
if value.equal?(child)
89+
return ".#{member}"
90+
end
8591
end
8692
end
93+
94+
# Could not determine the reference:
95+
return nil
8796
end
8897

89-
# Could not determine the reference:
90-
return nil
98+
# Get the path string from root to this node (cached).
99+
#
100+
# @returns [String | Nil] The formatted path string, or nil if no graph available.
101+
def path
102+
return @path if @path
103+
104+
# Build object path from root to this node:
105+
object_path = []
106+
current = self
107+
108+
while current
109+
object_path.unshift(current)
110+
current = current.parent
111+
end
112+
113+
# Format the path:
114+
parts = ["#<#{object_path.first.object.class}:0x%016x>" % (object_path.first.object.object_id << 1)]
115+
116+
# Append each reference in the path:
117+
(1...object_path.size).each do |i|
118+
parent_node = object_path[i - 1]
119+
child_node = object_path[i]
120+
121+
parts << (parent_node.find_reference(child_node.object) || "<??>")
122+
end
123+
124+
return @path = parts.join
125+
end
91126
end
92127

93-
# Construct a human-readable path from an object back to a root.
128+
# Build a graph of nodes from a root object, computing usage at each level.
94129
#
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)
130+
# @parameter root [Object] The root object to start from.
131+
# @parameter depth [Integer] Maximum depth to traverse (nil for unlimited).
132+
# @parameter seen [Set] Set of already seen objects (for internal use).
133+
# @parameter ignore [Array] Array of types to ignore during traversal.
134+
# @parameter parent [Node | Nil] The parent node (for internal use).
135+
# @returns [Node] The root node with children populated.
136+
def self.for(root, depth: nil, seen: Set.new.compare_by_identity, ignore: IGNORE, parent: nil)
137+
if depth && depth <= 0
138+
# Compute shallow usage for this object and it's children:
139+
usage = Usage.of(root, seen: seen, ignore: ignore)
140+
return Node.new(root, usage: usage, parent: parent)
110141
end
111142

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

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)
146+
# Create the node:
147+
node = Node.new(root, usage: usage, parent: parent)
125148

126-
# Start with the root object description:
127-
parts = ["#<#{object_path.first.class}:0x%016x>" % (object_path.first.object_id << 1)]
149+
# Mark this object as seen:
150+
seen.add(root)
128151

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]
152+
# Traverse children:
153+
ObjectSpace.reachable_objects_from(root)&.each do |reachable_object|
154+
# Skip ignored types:
155+
next if ignore.any?{|type| reachable_object.is_a?(type)}
156+
157+
# Skip internal objects:
158+
next if reachable_object.is_a?(ObjectSpace::InternalObjectWrapper)
133159

134-
parts << (find_reference(parent, child) || "<??>")
160+
# Skip already seen objects:
161+
next if seen.include?(reachable_object)
162+
163+
# Recursively build child node:
164+
node.children << self.for(reachable_object, depth: depth ? depth - 1 : nil, seen: seen, ignore: ignore, parent: node)
135165
end
136166

137-
return parts.join
167+
return node
138168
end
139169
end
140170
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 sohuld 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)