Skip to content

Commit 80d3615

Browse files
Add per-module allocation statistics to memray stats
Display the top N modules responsible for the most allocations in the memray stats reporter. For each allocation, walk the call stack to locate the nearest non-stdlib Python frame and extract its top-level module name. Aggregate allocation counts and allocated bytes by module, and include the top N results in both terminal and JSON output. Signed-off-by: Ivona Stojanovic <stojanovic.i@hotmail.com>
1 parent 4e49d28 commit 80d3615

9 files changed

Lines changed: 468 additions & 3 deletions

File tree

news/765.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add per-module allocation statistics to ``memray stats``, showing the top allocating non-stdlib modules by size and by number of allocations.

src/memray/_memray.pyx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,6 +1493,9 @@ def compute_statistics(
14931493
report_progress=False,
14941494
num_largest=5,
14951495
):
1496+
from memray.reporters.module_tools import get_module_for_stack
1497+
from memray.reporters.module_tools import get_python_path_info
1498+
14961499
cdef shared_ptr[RecordReader] reader_sp = make_shared[RecordReader](
14971500
unique_ptr[FileSource](new FileSource(file_name))
14981501
)
@@ -1512,15 +1515,27 @@ def compute_statistics(
15121515
total=total,
15131516
report_progress=report_progress,
15141517
)
1518+
1519+
path_info = get_python_path_info()
1520+
module_stats = {} # module -> [num_allocations, total_bytes]
1521+
15151522
with progress_indicator:
15161523
while True:
15171524
PyErr_CheckSignals()
15181525
ret = reader.nextRecord()
15191526
if ret == RecordResult.RecordResultAllocationRecord:
1527+
allocation = reader.getLatestAllocation()
15201528
aggregator.addAllocation(
1521-
reader.getLatestAllocation(),
1522-
reader.getLatestPythonLocationId(reader.getLatestAllocation()),
1529+
allocation,
1530+
reader.getLatestPythonLocationId(allocation),
15231531
)
1532+
1533+
if allocation.frame_index != 0:
1534+
stack = reader.Py_GetStackFrame(allocation.frame_index)
1535+
module_name = get_module_for_stack(stack, path_info)
1536+
entry = module_stats.setdefault(module_name, [0, 0])
1537+
entry[0] += 1
1538+
entry[1] += allocation.size
15241539
progress_indicator.update(1)
15251540
elif ret == RecordResult.RecordResultMemoryRecord:
15261541
pass
@@ -1554,6 +1569,22 @@ def compute_statistics(
15541569
for count_and_loc in aggregator.topLocationsByCount(num_largest)
15551570
]
15561571

1572+
top_modules_by_count = sorted(
1573+
module_stats.items(), key=lambda x: x[1][0], reverse=True
1574+
)[:num_largest]
1575+
top_allocations_by_module_by_count = [
1576+
(name, count, total_bytes)
1577+
for name, (count, total_bytes) in top_modules_by_count
1578+
]
1579+
1580+
top_modules_by_size = sorted(
1581+
module_stats.items(), key=lambda x: x[1][1], reverse=True
1582+
)[:num_largest]
1583+
top_allocations_by_module = [
1584+
(name, count, total_bytes)
1585+
for name, (count, total_bytes) in top_modules_by_size
1586+
]
1587+
15571588
# And we're done!
15581589
cdef uint64_t peak_memory = aggregator.peakBytesAllocated()
15591590
return Stats(
@@ -1565,6 +1596,8 @@ def compute_statistics(
15651596
allocation_count_by_allocator=allocation_count_by_allocator,
15661597
top_locations_by_size=top_locations_by_size,
15671598
top_locations_by_count=top_locations_by_count,
1599+
top_allocations_by_module=top_allocations_by_module,
1600+
top_allocations_by_module_by_count=top_allocations_by_module_by_count,
15681601
)
15691602

15701603

src/memray/_stats.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
from dataclasses import dataclass
2+
from dataclasses import field
3+
from typing import List
4+
from typing import Tuple
25

36
from ._metadata import Metadata
47

@@ -13,3 +16,7 @@ class Stats:
1316
allocation_count_by_allocator: dict
1417
top_locations_by_size: list
1518
top_locations_by_count: list
19+
top_allocations_by_module: List[Tuple[str, int, int]] = field(default_factory=list)
20+
top_allocations_by_module_by_count: List[Tuple[str, int, int]] = field(
21+
default_factory=list
22+
)

src/memray/_stats.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ class Stats:
1313
allocation_count_by_allocator: dict[str, int]
1414
top_locations_by_size: list[tuple[PythonStackElement, int]]
1515
top_locations_by_count: list[tuple[PythonStackElement, int]]
16+
top_allocations_by_module: list[tuple[str, int, int]]
17+
top_allocations_by_module_by_count: list[tuple[str, int, int]]
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Utilities for extracting module names from file paths."""
2+
3+
import site
4+
import sys
5+
import sysconfig
6+
from pathlib import Path
7+
from typing import Iterable
8+
from typing import List
9+
from typing import Optional
10+
from typing import Tuple
11+
12+
from typing_extensions import TypedDict
13+
14+
15+
class PathInfo(TypedDict):
16+
stdlib: Optional[Path]
17+
site_packages: List[Path]
18+
sys_path: List[Path]
19+
20+
21+
def get_python_path_info() -> PathInfo:
22+
"""Get information about Python's search paths.
23+
24+
Returns:
25+
dict: Dictionary containing stdlib path, site-packages paths, and sys.path entries.
26+
"""
27+
libdest = sysconfig.get_config_var("LIBDEST")
28+
stdlib: Optional[Path] = Path(libdest) if libdest else None
29+
30+
# Get site-packages directories
31+
site_packages: List[Path] = [Path(p) for p in site.getsitepackages()]
32+
33+
# Get user site-packages
34+
user_site = site.getusersitepackages()
35+
if Path(user_site).exists():
36+
site_packages.append(Path(user_site))
37+
38+
return {
39+
"stdlib": stdlib,
40+
"site_packages": site_packages,
41+
"sys_path": [Path(p) for p in sys.path if p],
42+
}
43+
44+
45+
def _is_relative_to(path: Path, base: Path) -> bool:
46+
try:
47+
path.relative_to(base)
48+
return True
49+
except ValueError:
50+
return False
51+
52+
53+
def extract_module_name_and_type(filename: str, path_info: PathInfo) -> Tuple[str, str]:
54+
"""Extract Python module name and type from file path.
55+
56+
Returns:
57+
tuple: (module_name, module_type) where module_type is one of:
58+
'stdlib', 'site-packages', 'project', or 'unknown'
59+
"""
60+
if not filename:
61+
return ("unknown", "unknown")
62+
63+
if filename.startswith("<frozen "):
64+
return (filename[len("<frozen ") : -1], "stdlib")
65+
66+
file_path = Path(filename)
67+
68+
for site_pkg in path_info["site_packages"]:
69+
if _is_relative_to(file_path, site_pkg):
70+
return (_path_to_module(file_path.relative_to(site_pkg)), "site-packages")
71+
72+
if path_info["stdlib"] and _is_relative_to(file_path, path_info["stdlib"]):
73+
return (_path_to_module(file_path.relative_to(path_info["stdlib"])), "stdlib")
74+
75+
for path_entry in path_info["sys_path"]:
76+
if _is_relative_to(file_path, path_entry):
77+
return (_path_to_module(file_path.relative_to(path_entry)), "project")
78+
79+
# Fallback: use just the filename, not the full absolute path
80+
return (_path_to_module(Path(file_path.name)), "unknown")
81+
82+
83+
def _path_to_module(path: Path) -> str:
84+
if path.is_absolute():
85+
raise ValueError(f"Expected a relative path, got: {path}")
86+
87+
if path.name == "__init__.py":
88+
path = path.parent
89+
elif path.suffix == ".py":
90+
path = path.with_suffix("")
91+
92+
return ".".join(path.parts)
93+
94+
95+
def get_module_for_stack(
96+
stack: Iterable[Tuple[str, str, int]],
97+
path_info: PathInfo,
98+
) -> str:
99+
"""Find the top-level module of the closest non-stdlib frame in a stack.
100+
101+
Walks frames from leaf to root, returning the first non-stdlib module's
102+
top-level package name. Returns "__main__" if every frame is stdlib
103+
or the stack is empty.
104+
"""
105+
for frame in stack:
106+
module_name, module_type = extract_module_name_and_type(frame[1], path_info)
107+
if module_type != "stdlib":
108+
return module_name.split(".")[0]
109+
return "__main__"

src/memray/reporters/stats.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,25 @@ def _render_to_terminal(self, histogram_params: Dict[str, int]) -> None:
162162
for location, count in self._get_top_allocations_by_count():
163163
print(f"\t- {self._format_location(location)} -> {count}")
164164

165+
for modules, sort_label in [
166+
(self._stats.top_allocations_by_module, "by size"),
167+
(
168+
self._stats.top_allocations_by_module_by_count,
169+
"by number of allocations",
170+
),
171+
]:
172+
if not modules:
173+
continue
174+
print()
175+
rich.print(
176+
f"🥇 [bold]Top {self.num_largest} allocating modules ({sort_label}):[/]"
177+
)
178+
for module_name, num_allocs, total_bytes in modules:
179+
print(
180+
f"\t- {module_name}: {size_fmt(total_bytes)} total"
181+
f" | {num_allocs} allocations"
182+
)
183+
165184
def _render_to_json(self, histogram_params: Dict[str, int], out_path: Path) -> None:
166185
alloc_size_hist = describe_histogram_databins(
167186
get_histogram_databins(
@@ -189,6 +208,26 @@ def _render_to_json(self, histogram_params: Dict[str, int], out_path: Path) -> N
189208
{"location": self._format_location(location), "count": count}
190209
for location, count in self._get_top_allocations_by_count()
191210
],
211+
"top_allocations_by_module": [
212+
{
213+
"module": module_name,
214+
"num_allocations": num_allocs,
215+
"total_bytes": total_bytes,
216+
}
217+
for module_name, num_allocs, total_bytes in (
218+
self._stats.top_allocations_by_module
219+
)
220+
],
221+
"top_allocations_by_module_by_count": [
222+
{
223+
"module": module_name,
224+
"num_allocations": num_allocs,
225+
"total_bytes": total_bytes,
226+
}
227+
for module_name, num_allocs, total_bytes in (
228+
self._stats.top_allocations_by_module_by_count
229+
)
230+
],
192231
"metadata": metadata,
193232
}
194233

tests/integration/test_tracking.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,3 +1797,46 @@ def test_temporary_allocations_when_filling_vector_without_preallocating_small_b
17971797
assert record.n_allocations == 1
17981798
assert record.allocator == AllocatorType.MALLOC
17991799
assert record.size == 2 << 10
1800+
1801+
1802+
@pytest.mark.parametrize(["allocator_func", "allocator_type"], ALLOCATORS)
1803+
def test_module_stats_only_counts_allocations(allocator_func, allocator_type, tmp_path):
1804+
# GIVEN
1805+
output = tmp_path / "test.bin"
1806+
allocator = MemoryAllocator()
1807+
1808+
# WHEN
1809+
with Tracker(output):
1810+
res = getattr(allocator, allocator_func)(1234)
1811+
if res:
1812+
allocator.free()
1813+
1814+
if not res:
1815+
pytest.skip(f"Allocator {allocator_func} not supported on this platform")
1816+
1817+
# THEN
1818+
stats = compute_statistics(str(output), num_largest=5)
1819+
total_module_allocs = sum(count for _, count, _ in stats.top_allocations_by_module)
1820+
assert total_module_allocs <= stats.total_num_allocations
1821+
1822+
1823+
def test_module_stats_attributes_to_non_stdlib(tmp_path):
1824+
# GIVEN
1825+
output = tmp_path / "test.bin"
1826+
1827+
def tracking_function():
1828+
return [{str(k): k for k in range(50)} for _ in range(1000)]
1829+
1830+
# WHEN
1831+
with Tracker(output, trace_python_allocators=True):
1832+
tracking_function()
1833+
1834+
# THEN
1835+
stats = compute_statistics(str(output), num_largest=5)
1836+
module_names = [name for name, _, _ in stats.top_allocations_by_module]
1837+
assert len(module_names) > 0
1838+
assert "stdlib" not in module_names
1839+
for module_name, count, total_bytes in stats.top_allocations_by_module:
1840+
assert isinstance(module_name, str)
1841+
assert count > 0
1842+
assert total_bytes > 0

0 commit comments

Comments
 (0)