Skip to content

Commit d992e78

Browse files
feat(profiling): real torch flame graphs
1 parent 6d742a8 commit d992e78

4 files changed

Lines changed: 155 additions & 21 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: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,18 @@
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+
25+
# Frames require a file name, but GPU frames are not from a Python file.
26+
# We use the following as a placeholder.
27+
_FILE_PLACEHOLDER = "<native>"
28+
29+
# We use a frame to group GPU frames under a device.
30+
# This frame also needs a file name, none can really make sense.
31+
_DEVICE_FRAME_FILE_NAME = "<torch>"
32+
2133

2234
class _WrappedTorchProfiler(wrapt.ObjectProxy):
2335
def __init__(
@@ -112,10 +124,23 @@ def _handle_torch_trace(prof: Any) -> None:
112124
LOG.debug("Dropped events. events_limit %d, len(events): %d", events_limit, num_events)
113125
events = random.sample(events, events_limit) # nosec: used for sampling, not security
114126

115-
# Determine which attributes to use once (avoid per-event getattr checks)
127+
# Determine which attributes to use once (avoid per-event getattr checks).
128+
# For CPU operators we use the "self" (exclusive of children) variants so that,
129+
# once we reconstruct the operator call tree below, parent frames aggregate to
130+
# their inclusive totals without double counting their children. CUDA device
131+
# events are leaves and report 0 for the "self" device metrics (they are async),
132+
# so for those we use the event's own totals instead.
116133
sample_event = events[0]
117-
use_device_time = hasattr(sample_event, "device_time")
118-
use_device_memory = hasattr(sample_event, "device_memory_usage")
134+
self_gpu_time_attr = (
135+
"self_device_time_total" if hasattr(sample_event, "self_device_time_total") else "self_cuda_time_total"
136+
)
137+
total_gpu_time_attr = "device_time_total" if hasattr(sample_event, "device_time_total") else "cuda_time_total"
138+
self_gpu_memory_attr = (
139+
"self_device_memory_usage" if hasattr(sample_event, "self_device_memory_usage") else "self_cuda_memory_usage"
140+
)
141+
total_gpu_memory_attr = (
142+
"device_memory_usage" if hasattr(sample_event, "device_memory_usage") else "cuda_memory_usage"
143+
)
119144

120145
# Earlier PyTorch versions use microseconds, later versions use nanoseconds
121146
kineto_results = prof.profiler.kineto_results
@@ -136,44 +161,62 @@ def _handle_torch_trace(prof: Any) -> None:
136161
handle = ddup.SampleHandle()
137162
data_added = False
138163

139-
# cpu time sample
140-
cpu_time: int = e.cpu_time
141-
if cpu_time > 0:
164+
# Number of times this event aggregates. The time metrics below are
165+
# totals across those occurrences, so we divide by count to recover the
166+
# per-occurrence value and let ddup re-multiply by count.
167+
count: int = e.count or 1
168+
169+
# Cache str(device_type) since we use it multiple times. CPU operators get
170+
# the call tree reconstructed and use exclusive ("self") metrics; CUDA
171+
# device events are leaves and use their full totals.
172+
device_type_str = str(e.device_type)
173+
is_cpu = device_type_str.startswith("DeviceType.CPU")
174+
175+
# cpu time sample (exclusive of children)
176+
self_cpu_time: int = e.self_cpu_time_total
177+
if self_cpu_time > 0:
142178
data_added = True
143-
handle.push_cputime(int(cpu_time * _NANOS_PER_MICROSECOND), e.count)
179+
handle.push_cputime(int(self_cpu_time / count * _NANOS_PER_MICROSECOND), count)
144180

145-
# gpu time sample
146-
gpu_time: int = e.device_time if use_device_time else e.cuda_time
181+
# gpu time sample: exclusive for CPU operators, full device time for leaves
182+
gpu_time: int = getattr(e, self_gpu_time_attr) if is_cpu else getattr(e, total_gpu_time_attr)
147183
if gpu_time > 0:
148184
data_added = True
149-
handle.push_gpu_gputime(int(gpu_time * _NANOS_PER_MICROSECOND), e.count)
185+
handle.push_gpu_gputime(int(gpu_time / count * _NANOS_PER_MICROSECOND), count)
150186

151187
# gpu flops sample
152188
flops: int = e.flops
153189
if flops is not None and flops > 0:
154190
data_added = True
155-
handle.push_gpu_flops(flops, e.count)
191+
handle.push_gpu_flops(flops, count)
156192

157-
# GPU memory usage
158-
gpu_memory: int = e.device_memory_usage if use_device_memory else e.cuda_memory_usage
193+
# GPU memory usage: exclusive for CPU operators, full usage for leaves
194+
gpu_memory: int = getattr(e, self_gpu_memory_attr) if is_cpu else getattr(e, total_gpu_memory_attr)
159195
if gpu_memory is not None and gpu_memory > 0:
160196
data_added = True
161-
handle.push_gpu_memory(gpu_memory, e.count)
197+
handle.push_gpu_memory(gpu_memory, count)
162198

163199
if not data_added:
164200
if empty_events_count % 1000 == 0:
165201
LOG.debug("%d events with no data to record: %s", empty_events_count, e)
166202
empty_events_count += 1
167203
continue
168204

169-
# Cache str(device_type) since we use it multiple times
170-
device_type_str = str(e.device_type)
171-
172-
handle.push_frame(e.name, "unknown-file", 0, 0)
205+
# Reconstruct the operator call tree by walking up the cpu_parent chain.
206+
# Frames are pushed leaf-first (this event), then each ancestor, so that
207+
# the flame graph nests children under their parents instead of rendering
208+
# every operator as a flat leaf. Stacks go root last.
209+
handle.push_frame(e.name, _FILE_PLACEHOLDER, 0, 0)
210+
parent = getattr(e, "cpu_parent", None)
211+
depth = 0
212+
while parent is not None and depth < _MAX_FRAMES:
213+
handle.push_frame(parent.name, _FILE_PLACEHOLDER, 0, 0)
214+
parent = getattr(parent, "cpu_parent", None)
215+
depth += 1
173216
# Pushing pseudoframes for the device name ("device.CPU" or "device.CUDA")
174217
# onto the stack allows differentiation of pytorch frames from other profiling frames
175218
# in the flame graph. Note that stacks go root last, so this goes at the end.
176-
handle.push_frame(f"PYTORCH_{device_type_str}", "unknown-file", 0, 0)
219+
handle.push_frame(f"PYTORCH_{device_type_str}", _DEVICE_FRAME_FILE_NAME, 0, 0)
177220
handle.push_gpu_device_name(f"cuda {e.device_index}")
178221

179222
# Get thread info from cache or compute and cache it
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: 54 additions & 2 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")
@@ -24,16 +74,18 @@ def test_call_script_pytorch_gpu(tmp_path, monkeypatch):
2474
print("number of gpu time samples: ", len(samples))
2575
print("first sample: ", samples[0])
2676

77+
from ddtrace.profiling.collector.pytorch import _FILE_PLACEHOLDER
78+
2779
expected_sample = pprof_utils.StackEvent(
2880
locations=[
2981
pprof_utils.StackLocation(
3082
function_name="Memset (Device)",
31-
filename="unknown-file",
83+
filename=_FILE_PLACEHOLDER,
3284
line_no=0,
3385
),
3486
pprof_utils.StackLocation(
3587
function_name="PYTORCH_DeviceType.CUDA",
36-
filename="unknown-file",
88+
filename=_FILE_PLACEHOLDER,
3789
line_no=0,
3890
),
3991
],

0 commit comments

Comments
 (0)