Skip to content

Commit e19a214

Browse files
committed
Add search, peak breakdown, memory breakdown, minimap, and tests
- Search & filter: type or press / to search allocations by stack frame, with regex toggle - What's at Peak: click HWM line to see all allocations alive at peak, sorted by size with percentage bars - Memory Breakdown: detail panel tab aggregating top call sites by total bytes with click-to-highlight - Minimap: overview strip at bottom with draggable viewport synced to main chart zoom - Detail panel tabs remember last stack trace when switching back - Title derived from file path stem in save_memory_snapshot - 23 tests covering frame extraction, snapshot processing, and HTML generation using a mini snapshot fixture
1 parent 04f4413 commit e19a214

4 files changed

Lines changed: 560 additions & 6 deletions

File tree

test/data/mini_snapshot.pickle

59.3 KB
Binary file not shown.

test/test_memory_viz.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import pickle
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from transformer_nuggets.utils.memory_viz import (
7+
_extract_frames,
8+
_is_cpython_c_frame,
9+
_shorten_path,
10+
generate_memory_html,
11+
process_snapshot,
12+
)
13+
14+
DATA_DIR = Path(__file__).parent / "data"
15+
SNAPSHOT_PATH = DATA_DIR / "mini_snapshot.pickle"
16+
17+
18+
@pytest.fixture
19+
def snapshot():
20+
with open(SNAPSHOT_PATH, "rb") as f:
21+
return pickle.load(f)
22+
23+
24+
class TestExtractFrames:
25+
def test_filters_unwind_frames(self):
26+
frames = [
27+
{"filename": "??", "line": 0, "name": "torch::unwind::unwind()"},
28+
{"filename": "foo.py", "line": 10, "name": "bar"},
29+
]
30+
assert _extract_frames(frames) == ["foo.py:10 bar"]
31+
32+
def test_filters_cpython_c_frames(self):
33+
frames = [
34+
{
35+
"filename": "/usr/local/src/conda/python-3.12/Objects/call.c",
36+
"line": 0,
37+
"name": "_PyObject_MakeTPCall",
38+
},
39+
{"filename": "my_script.py", "line": 5, "name": "main"},
40+
]
41+
assert _extract_frames(frames) == ["my_script.py:5 main"]
42+
43+
def test_keeps_cpp_frames_without_filename(self):
44+
frames = [
45+
{"filename": "", "line": 0, "name": "at::native::matmul(at::Tensor const&)"},
46+
]
47+
result = _extract_frames(frames)
48+
assert len(result) == 1
49+
assert "matmul" in result[0]
50+
51+
def test_shortens_site_packages_path(self):
52+
frames = [
53+
{
54+
"filename": "/home/user/.conda/envs/dev/lib/python3.12/site-packages/torch/nn/linear.py",
55+
"line": 42,
56+
"name": "forward",
57+
},
58+
]
59+
result = _extract_frames(frames)
60+
assert result == ["torch/nn/linear.py:42 forward"]
61+
62+
63+
class TestHelpers:
64+
def test_shorten_path_site_packages(self):
65+
assert _shorten_path("/foo/site-packages/torch/nn.py") == "torch/nn.py"
66+
67+
def test_shorten_path_lib_python(self):
68+
assert _shorten_path("/foo/lib/python3.12/collections.py") == "3.12/collections.py"
69+
70+
def test_shorten_path_no_match(self):
71+
assert _shorten_path("/home/user/my_script.py") == "/home/user/my_script.py"
72+
73+
def test_is_cpython_c_frame(self):
74+
assert _is_cpython_c_frame("/usr/local/src/conda/python-3.12/call.c", "_PyObject_Call")
75+
assert _is_cpython_c_frame("eval.c", "_PyEval_EvalFrameDefault")
76+
assert not _is_cpython_c_frame("my_module.py", "forward")
77+
78+
79+
class TestProcessSnapshot:
80+
def test_returns_correct_tuple_shape(self, snapshot):
81+
timeline, allocs, stacks, categories, max_ts = process_snapshot(snapshot)
82+
assert len(timeline) > 0
83+
assert len(allocs) > 0
84+
assert len(stacks) > 0
85+
assert len(categories) == len(stacks)
86+
assert max_ts > 0
87+
88+
def test_timeline_fields(self, snapshot):
89+
timeline, *_ = process_snapshot(snapshot)
90+
entry = timeline[0]
91+
assert set(entry.keys()) == {"t", "a", "r", "h", "act", "s", "si"}
92+
93+
def test_alloc_poly_fields(self, snapshot):
94+
_, allocs, *_ = process_snapshot(snapshot)
95+
poly = allocs[0]
96+
assert "si" in poly
97+
assert "s" in poly
98+
assert "ts" in poly
99+
assert "offsets" in poly
100+
assert len(poly["ts"]) == len(poly["offsets"])
101+
assert len(poly["ts"]) >= 2
102+
103+
def test_hwm_is_max_allocated(self, snapshot):
104+
timeline, *_ = process_snapshot(snapshot)
105+
hwm = max(e["h"] for e in timeline)
106+
max_allocated = max(e["a"] for e in timeline)
107+
assert hwm == max_allocated
108+
109+
def test_allocated_never_negative(self, snapshot):
110+
timeline, *_ = process_snapshot(snapshot)
111+
assert all(e["a"] >= 0 for e in timeline)
112+
113+
def test_stack_indices_valid(self, snapshot):
114+
timeline, allocs, stacks, *_ = process_snapshot(snapshot)
115+
for e in timeline:
116+
assert 0 <= e["si"] < len(stacks)
117+
for a in allocs:
118+
assert 0 <= a["si"] < len(stacks)
119+
120+
def test_empty_device_returns_empty(self, snapshot):
121+
result = process_snapshot(snapshot, device=99)
122+
assert result == ([], [], [], [], 0)
123+
124+
def test_polygon_offsets_non_negative(self, snapshot):
125+
_, allocs, *_ = process_snapshot(snapshot)
126+
for poly in allocs:
127+
assert all(o >= 0 for o in poly["offsets"])
128+
129+
130+
class TestGenerateHTML:
131+
def test_produces_valid_html(self, snapshot):
132+
html = generate_memory_html(snapshot, title="Test")
133+
assert html.startswith("<!DOCTYPE html>")
134+
assert "</html>" in html
135+
136+
def test_no_remaining_placeholders(self, snapshot):
137+
html = generate_memory_html(snapshot, title="Test")
138+
for placeholder in ["__TITLE__", "__TIMELINE__", "__ALLOCS__", "__STACKS__", "__CATEGORIES__", "__META__"]:
139+
assert placeholder not in html
140+
141+
def test_title_appears_in_html(self, snapshot):
142+
html = generate_memory_html(snapshot, title="My Custom Title")
143+
assert "My Custom Title" in html
144+
145+
def test_d3_loaded(self, snapshot):
146+
html = generate_memory_html(snapshot, title="Test")
147+
assert "d3.v7" in html
148+
149+
def test_hwm_timestep_in_meta(self, snapshot):
150+
html = generate_memory_html(snapshot, title="Test")
151+
assert "hwm_timestep" in html
152+
153+
def test_search_input_present(self, snapshot):
154+
html = generate_memory_html(snapshot, title="Test")
155+
assert "search-input" in html
156+
157+
def test_minimap_present(self, snapshot):
158+
html = generate_memory_html(snapshot, title="Test")
159+
assert "minimap" in html

transformer_nuggets/utils/benchmark.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ def save_memory_snapshot(file_path: Path | str, viz: str = "torch"):
266266
case "d3":
267267
from transformer_nuggets.utils.memory_viz import generate_memory_html
268268

269-
html = generate_memory_html(s)
269+
html = generate_memory_html(s, title=file_path.stem)
270270
case _:
271271
raise ValueError(f"Unknown viz backend: {viz!r}, expected 'torch' or 'd3'")
272272

@@ -336,7 +336,7 @@ def oom_observer(device, alloc, device_alloc, device_free):
336336
case "d3":
337337
from transformer_nuggets.utils.memory_viz import generate_memory_html
338338

339-
html = generate_memory_html(snapshot)
339+
html = generate_memory_html(snapshot, title=f"OOM rank {rank}")
340340
case _:
341341
html = torch.cuda._memory_viz.trace_plot(snapshot) # type: ignore
342342

0 commit comments

Comments
 (0)