Skip to content

Commit c2a6f2f

Browse files
Introduce Memory::Graph.
1 parent ed2b905 commit c2a6f2f

File tree

6 files changed

+663
-34
lines changed

6 files changed

+663
-34
lines changed

lib/memory.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
require_relative "memory/cache"
1212
require_relative "memory/report"
1313
require_relative "memory/sampler"
14+
require_relative "memory/usage"
15+
require_relative "memory/graph"
1416

1517
# Memory profiler for Ruby applications.
1618
# Provides tools to track and analyze memory allocations and retention.

lib/memory/graph.rb

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require "set"
7+
8+
module Memory
9+
# 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
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
57+
end
58+
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}]"
64+
end
65+
end
66+
end
67+
68+
# Check hash keys and values:
69+
if parent.is_a?(Hash)
70+
parent.each do |key, value|
71+
if value.equal?(child)
72+
return "[#{key.inspect}]"
73+
end
74+
if key.equal?(child)
75+
return "(key: #{key.inspect})"
76+
end
77+
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}"
85+
end
86+
end
87+
end
88+
89+
# Could not determine the reference:
90+
return nil
91+
end
92+
93+
# Construct a human-readable path from an object back to a root.
94+
#
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)
110+
end
111+
112+
# Reverse to get path from root to object:
113+
object_path.reverse!
114+
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]
133+
134+
parts << (find_reference(parent, child) || "<??>")
135+
end
136+
137+
return parts.join
138+
end
139+
end
140+
end
141+

lib/memory/usage.rb

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -57,33 +57,42 @@ def << allocation
5757
]
5858

5959
# Compute the usage of an object and all reachable objects from it.
60+
#
61+
# The root is always visited even if it is in `seen`.
62+
#
6063
# @parameter root [Object] The root object to start traversal from.
64+
# @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.
6166
# @returns [Usage] The usage of the object and all reachable objects from it.
62-
def self.of(root, seen: Set.new.compare_by_identity, ignore: IGNORE)
67+
def self.of(root, seen: Set.new.compare_by_identity, ignore: IGNORE, via: nil)
6368
count = 0
6469
size = 0
6570

6671
queue = [root]
67-
while queue.any?
68-
object = queue.shift
69-
70-
# Skip ignored types:
71-
next if ignore.any?{|type| object.is_a?(type)}
72-
73-
# Skip internal objects - they don't behave correctly when added to `seen` and create unbounded recursion:
74-
next if object.is_a?(ObjectSpace::InternalObjectWrapper)
75-
76-
# Skip objects we have already seen:
77-
next if seen.include?(object)
78-
79-
# Add the object to the seen set and update the count and size:
72+
while object = queue.shift
73+
# Add the object to the seen set:
8074
seen.add(object)
75+
76+
# Update the count and size:
8177
count += 1
8278
size += ObjectSpace.memsize_of(object)
8379

8480
# Add the object's reachable objects to the queue:
85-
if reachable_objects = ObjectSpace.reachable_objects_from(object)
86-
queue.concat(reachable_objects)
81+
ObjectSpace.reachable_objects_from(object)&.each do |reachable_object|
82+
# Skip ignored types:
83+
next if ignore.any?{|type| reachable_object.is_a?(type)}
84+
85+
# Skip internal objects - they don't behave correctly when added to `seen` and create unbounded recursion:
86+
next if reachable_object.is_a?(ObjectSpace::InternalObjectWrapper)
87+
88+
# Skip objects we have already seen:
89+
next if seen.include?(reachable_object)
90+
91+
if via
92+
via[reachable_object] ||= object
93+
end
94+
95+
queue << reachable_object
8796
end
8897
end
8998

releases.md

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

3+
## Unreleased
4+
5+
- Add support for `Memory::Usage.of(..., via:)` for tracking reachability of objects.
6+
- Introduce `Memory::Graph` for computing paths between parent/child objects.
7+
38
## v0.9.0
49

510
- Explicit `ignore:` and `seen:` parameters for `Memory::Usage.of` to allow customization of ignored types and tracking of seen objects.

0 commit comments

Comments
 (0)