Skip to content

Commit 762fc42

Browse files
feat(profiling): real torch flame graphs
1 parent fa666d3 commit 762fc42

4 files changed

Lines changed: 131 additions & 16 deletions

File tree

.github/workflows/pytorch_gpu_tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ jobs:
7777
cp "$REPO_ROOT/tests/profiling/__init__.py" "$TEST_ROOT/profiling/"
7878
cp "$REPO_ROOT/tests/profiling/test_pytorch.py" "$TEST_ROOT/profiling/"
7979
cp "$REPO_ROOT/tests/profiling/simple_program_pytorch_gpu.py" "$TEST_ROOT/profiling/"
80+
cp "$REPO_ROOT/tests/profiling/simple_program_pytorch_cpu.py" "$TEST_ROOT/profiling/"
8081
cp -R "$REPO_ROOT/tests/profiling/collector" "$TEST_ROOT/profiling/"
8182
8283
cd "$ISOLATED_DIR"

ddtrace/profiling/collector/pytorch.py

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818

1919
_NANOS_PER_MICROSECOND = 1e3
2020

21+
# Safety bound on how far we walk up the cpu_parent chain when reconstructing
22+
# the operator call tree, to guard against pathological depths.
23+
_MAX_FRAMES = 128
24+
2125

2226
class _WrappedTorchProfiler(wrapt.ObjectProxy):
2327
def __init__(
@@ -112,10 +116,17 @@ def _handle_torch_trace(prof: Any) -> None:
112116
LOG.debug("Dropped events. events_limit %d, len(events): %d", events_limit, num_events)
113117
events = random.sample(events, events_limit) # nosec: used for sampling, not security
114118

115-
# Determine which attributes to use once (avoid per-event getattr checks)
119+
# Determine which attributes to use once (avoid per-event getattr checks).
120+
# We use the "self" (exclusive of children) variants of each metric so that,
121+
# once we reconstruct the operator call tree below, parent frames aggregate
122+
# to their inclusive totals without double counting their children.
116123
sample_event = events[0]
117-
use_device_time = hasattr(sample_event, "device_time")
118-
use_device_memory = hasattr(sample_event, "device_memory_usage")
124+
self_gpu_time_attr = (
125+
"self_device_time_total" if hasattr(sample_event, "self_device_time_total") else "self_cuda_time_total"
126+
)
127+
self_gpu_memory_attr = (
128+
"self_device_memory_usage" if hasattr(sample_event, "self_device_memory_usage") else "self_cuda_memory_usage"
129+
)
119130

120131
# Earlier PyTorch versions use microseconds, later versions use nanoseconds
121132
kineto_results = prof.profiler.kineto_results
@@ -136,29 +147,34 @@ def _handle_torch_trace(prof: Any) -> None:
136147
handle = ddup.SampleHandle()
137148
data_added = False
138149

139-
# cpu time sample
140-
cpu_time: int = e.cpu_time
141-
if cpu_time > 0:
150+
# Number of times this event aggregates. Each metric below is an exclusive
151+
# ("self") total across those occurrences, so we divide by count to recover
152+
# the per-occurrence value and let ddup re-multiply by count.
153+
count: int = e.count or 1
154+
155+
# cpu time sample (exclusive of children)
156+
self_cpu_time: int = e.self_cpu_time_total
157+
if self_cpu_time > 0:
142158
data_added = True
143-
handle.push_cputime(int(cpu_time * _NANOS_PER_MICROSECOND), e.count)
159+
handle.push_cputime(int(self_cpu_time / count * _NANOS_PER_MICROSECOND), count)
144160

145-
# gpu time sample
146-
gpu_time: int = e.device_time if use_device_time else e.cuda_time
147-
if gpu_time > 0:
161+
# gpu time sample (exclusive of children)
162+
self_gpu_time: int = getattr(e, self_gpu_time_attr)
163+
if self_gpu_time > 0:
148164
data_added = True
149-
handle.push_gpu_gputime(int(gpu_time * _NANOS_PER_MICROSECOND), e.count)
165+
handle.push_gpu_gputime(int(self_gpu_time / count * _NANOS_PER_MICROSECOND), count)
150166

151167
# gpu flops sample
152168
flops: int = e.flops
153169
if flops is not None and flops > 0:
154170
data_added = True
155-
handle.push_gpu_flops(flops, e.count)
171+
handle.push_gpu_flops(flops, count)
156172

157-
# GPU memory usage
158-
gpu_memory: int = e.device_memory_usage if use_device_memory else e.cuda_memory_usage
159-
if gpu_memory is not None and gpu_memory > 0:
173+
# GPU memory usage (exclusive of children)
174+
self_gpu_memory: int = getattr(e, self_gpu_memory_attr)
175+
if self_gpu_memory is not None and self_gpu_memory > 0:
160176
data_added = True
161-
handle.push_gpu_memory(gpu_memory, e.count)
177+
handle.push_gpu_memory(self_gpu_memory, count)
162178

163179
if not data_added:
164180
if empty_events_count % 1000 == 0:
@@ -169,7 +185,17 @@ def _handle_torch_trace(prof: Any) -> None:
169185
# Cache str(device_type) since we use it multiple times
170186
device_type_str = str(e.device_type)
171187

188+
# Reconstruct the operator call tree by walking up the cpu_parent chain.
189+
# Frames are pushed leaf-first (this event), then each ancestor, so that
190+
# the flame graph nests children under their parents instead of rendering
191+
# every operator as a flat leaf. Stacks go root last.
172192
handle.push_frame(e.name, "unknown-file", 0, 0)
193+
parent = getattr(e, "cpu_parent", None)
194+
depth = 0
195+
while parent is not None and depth < _MAX_FRAMES:
196+
handle.push_frame(parent.name, "unknown-file", 0, 0)
197+
parent = getattr(parent, "cpu_parent", None)
198+
depth += 1
173199
# Pushing pseudoframes for the device name ("device.CPU" or "device.CUDA")
174200
# onto the stack allows differentiation of pytorch frames from other profiling frames
175201
# in the flame graph. Note that stacks go root last, so this goes at the end.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import torch
2+
import torch.nn
3+
from torch.profiler import ProfilerActivity
4+
5+
6+
class TinyModel(torch.nn.Module):
7+
"""A tiny model whose forward pass produces a nested CPU operator tree.
8+
9+
A ``torch.nn.Linear`` forward records a parent ``aten::linear`` operator
10+
containing child operators (``aten::addmm``/``aten::matmul``/``aten::t``),
11+
which is what lets us assert that the profiler reconstructs nesting.
12+
"""
13+
14+
def __init__(self) -> None:
15+
super().__init__()
16+
self.net = torch.nn.Sequential(
17+
torch.nn.Linear(64, 64),
18+
torch.nn.ReLU(),
19+
torch.nn.Linear(64, 16),
20+
)
21+
22+
def forward(self, x: torch.Tensor) -> torch.Tensor:
23+
return self.net(x)
24+
25+
26+
def run() -> None:
27+
model = TinyModel()
28+
inputs = torch.randn(32, 64)
29+
30+
with torch.profiler.profile(
31+
activities=[ProfilerActivity.CPU],
32+
):
33+
for _ in range(5):
34+
model(inputs).sum().backward()
35+
36+
37+
if __name__ == "__main__":
38+
run()

tests/profiling/test_pytorch.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,56 @@
77
from tests.utils import call_program
88

99

10+
try:
11+
import torch # noqa: F401
12+
13+
_HAS_TORCH = True
14+
except ImportError:
15+
_HAS_TORCH = False
16+
17+
18+
@pytest.mark.skipif(not _HAS_TORCH, reason="torch is not installed")
19+
def test_call_script_pytorch_cpu(tmp_path, monkeypatch):
20+
"""The torch profiler integration should reconstruct the operator call tree.
21+
22+
Each torch event's stack is built by walking the ``cpu_parent`` chain, so a
23+
nested operator (e.g. ``aten::addmm`` under ``aten::linear``) must show up as
24+
a multi-frame stack rooted at the ``PYTORCH_DeviceType.CPU`` pseudo-frame,
25+
rather than as a flat two-frame stack.
26+
"""
27+
filename = str(tmp_path / "pprof")
28+
monkeypatch.setenv("DD_PROFILING_OUTPUT_PPROF", filename)
29+
monkeypatch.setenv("DD_PROFILING_ENABLED", "1")
30+
monkeypatch.setenv("DD_PROFILING_PYTORCH_ENABLED", "1")
31+
_, stderr, exitcode, _ = call_program(
32+
"ddtrace-run", sys.executable, os.path.join(os.path.dirname(__file__), "simple_program_pytorch_cpu.py")
33+
)
34+
assert exitcode == 0, f"Profiler exited with code {exitcode}. Stderr: {stderr}"
35+
36+
profile = pprof_utils.parse_newest_profile(filename)
37+
samples = pprof_utils.get_samples_with_value_type(profile, "cpu-time")
38+
assert len(samples) > 0, "Expected at least one cpu-time sample"
39+
40+
# Find at least one sample with a reconstructed (nested) stack: more than the
41+
# old flat two frames ([op_name, PYTORCH_DeviceType.CPU]) and rooted at the
42+
# CPU device pseudo-frame.
43+
nested_found = False
44+
for sample in samples:
45+
locations = [pprof_utils.get_location_from_id(profile, loc_id) for loc_id in sample.location_id]
46+
if len(locations) < 3:
47+
continue
48+
if locations[-1].function_name != "PYTORCH_DeviceType.CPU":
49+
continue
50+
# The frames between the leaf and the device root are the operator
51+
# ancestor chain; require at least one genuine parent operator.
52+
op_frames = [loc.function_name for loc in locations[:-1]]
53+
if len(op_frames) >= 2:
54+
nested_found = True
55+
break
56+
57+
assert nested_found, "Expected at least one cpu-time sample with a nested operator stack"
58+
59+
1060
@pytest.mark.skipif(not os.getenv("DD_PROFILING_PYTORCH_ENABLED", False), reason="Not testing pytorch GPU")
1161
def test_call_script_pytorch_gpu(tmp_path, monkeypatch):
1262
filename = str(tmp_path / "pprof")

0 commit comments

Comments
 (0)