the pytorch path · 0/12
start the path

the PyTorch path · Performance · lesson 02 of 3

Reading one real trace

Capture five steps of a small training loop and the biggest row in the table is not an operator. It is the annotation you wrapped the step in, holding six hundred microseconds that belong to no kernel at all.

the goal Read a torch.profiler table of your own training step: separate self time from total time, name the host-side gap and say what is in it, find the allocation that dominates the step from the shape-grouped view, and pair a backward node with the forward op that produced it.

mastery work · this chapter0/3
  1. go →
  2. go →
manual items are your word; auto items complete from your streaks, labs, and can-you ticks · stored in your browser only
§ 01

The schedule decides what the table contains

Profiling every step of a run would record the noisy first steps along with the steady ones, so torch.profiler.schedule divides the steps into phases and the profiler only keeps one of them. Ask for wait=2, warmup=2, active=5, repeat=1 and the schedule is a plain function of the step index that you can call yourself before you profile anything.

Printing it for ten steps shows the shape directly: two NONE, two WARMUP, four RECORD, one RECORD_AND_SAVE, then NONE for anything past the single cycle. The last active step carries the save because that is where on_trace_ready fires.

The counterpart in your loop is prof.step(), one call per iteration, which is what advances that index. Forget it and the schedule never moves off its first phase. The proof that the schedule worked is in the table's call counts: ten steps ran, the train_step row says 5.

run it (verified, torch 2.2.2 CPU): the phase for each of ten steps, then what survived into the table
import torch
import torch.nn as nn
from torch.profiler import ProfilerActivity, profile, record_function, schedule

torch.manual_seed(0)
model = nn.Sequential(nn.Linear(512, 512), nn.ReLU(), nn.Linear(512, 10))
opt = torch.optim.SGD(model.parameters(), lr=0.1)
x, y = torch.randn(64, 512), torch.randint(0, 10, (64,))
loss_fn = nn.CrossEntropyLoss()

def step():
    opt.zero_grad(set_to_none=True)
    loss_fn(model(x), y).backward()
    opt.step()

sched = schedule(wait=2, warmup=2, active=5, repeat=1)
print("phase per step:", [sched(i).name for i in range(10)])

with profile(activities=[ProfilerActivity.CPU], schedule=sched,
             record_shapes=True, profile_memory=True) as prof:
    for _ in range(10):
        with record_function("train_step"):
            step()
        prof.step()

rows = {e.key: e.count for e in prof.key_averages()}
print("train_step rows kept:", rows["train_step"], "of 10 steps run")

# ---- stdout ----
# phase per step: ['NONE', 'NONE', 'WARMUP', 'WARMUP', 'RECORD', 'RECORD', 'RECORD', 'RECORD', 'RECORD_AND_SAVE', 'NONE']
# train_step rows kept: 5 of 10 steps run
§ 02

The largest row in the table is not an op

Sort that capture by self CPU time and train_step is at the top with 31.20 percent, 3.094 milliseconds across five steps. Nothing in that row is an operator. Self time is what a region spent outside all of its children, so the annotation's self time is exactly the part of the step that ran between ATen calls: Python, module __call__, the autograd engine deciding what to run next, the optimizer's bookkeeping before it dispatches anything.

That is the host-side gap the chapter asks you to name, and here it is 0.619 milliseconds of a step whose whole recorded cost is 1.942. Across five runs of the same file the share stayed between 28.1 and 31.6 percent while the milliseconds moved by half. On an accelerator that same third of the step is time the device spends idle unless the host runs far enough ahead, which is why it is worth extracting rather than eyeballing.

The total column reads the opposite way and the two must not be confused. The row for autograd::engine::evaluate_function: AddmmBackward0 shows 1.56 percent self against 21.00 percent total, because the total includes the backward node and the aten::mm calls under it. Self answers where the time went; total answers what a subtree cost. Sorting by the wrong one is how a wrapper ends up looking like the bottleneck.

Read the counts as a check on your own model of the step. aten::addmm fires 10 times over 5 steps, which is the two forward linears. aten::mm fires 15, three per step, and the shape-grouped view names them: the second linear needs a gradient for its input and one for its weight, the first linear needs only the weight one, because the batch it was handed is a leaf that requires no gradient.

verbatim stdout from the capture above (torch 2.2.2 CPU, 15 August 2026), printed with key_averages().table(sort_by="self_cpu_time_total", row_limit=10); one run of five, and the call counts and byte figures were identical in all five
-------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  
                                                   Name    Self CPU %      Self CPU   CPU total %     CPU total  CPU time avg       CPU Mem  Self CPU Mem    # of Calls  
-------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  
                                             train_step        31.20%       3.094ms        97.92%       9.710ms       1.942ms       1.02 Mb    -652.54 Kb             5  
                                            aten::addmm        16.13%       1.599ms        17.44%       1.729ms     172.900us     652.50 Kb     652.50 Kb            10  
                                               aten::mm        14.70%       1.458ms        14.70%       1.458ms      97.200us       5.72 Mb       5.72 Mb            15  
                                Optimizer.step#SGD.step         7.95%     788.000us        11.88%       1.178ms     235.600us           0 b           0 b             5  
                                             aten::add_         3.93%     390.000us         3.93%     390.000us      19.500us           0 b           0 b            20  
                      Optimizer.zero_grad#SGD.zero_grad         2.63%     261.000us         2.63%     261.000us      52.200us      -4.09 Mb      -4.09 Mb             5  
                                          ProfilerStep*         2.08%     206.000us       100.00%       9.916ms       1.983ms       1.02 Mb           0 b             5  
                                                aten::t         1.94%     192.000us         3.52%     349.000us       7.756us           0 b           0 b            45  
                                              aten::sum         1.63%     162.000us         1.78%     177.000us      17.700us      10.20 Kb      10.20 Kb            10  
    autograd::engine::evaluate_function: AddmmBackward0         1.56%     155.000us        21.00%       2.082ms     208.200us       5.10 Mb    -652.50 Kb            10  
-------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  
Self CPU time total: 9.916ms
§ 03

What each flag costs, and one that will not run here

Recording is not free, and the honest way to size the cost is with the instrument from lesson one rather than with the profiler's own totals. Timing the same step through Timer, inside and outside a profile() block, put the ratio at 1.29, 1.18 and 1.21 across three rounds of one script. Call it a fifth to a quarter, on this step, on this machine.

That is small enough to trust the proportions in the table and too large to quote the absolute milliseconds anywhere. The five profiled captures reported 1.638 to 2.377 milliseconds per step, while Timer put the same unwatched function at 1.420 to 1.619. Take shares of time from the profiler and take the time itself from a benchmark.

record_shapes and profile_memory earned their place in this capture, and neither one moved the ratio measurably. with_stack is the flag that would attribute every op to the Python line that made it, and on this pair of versions it refuses. A single profiled optimizer step raises an internal assert from the Python stack replay, reproducibly, at n equal to 1 as well as 50, while the same flag over a plain forward and backward with no optimizer succeeds. Treat it as a version fact about torch 2.2.2 with CPython 3.12, not as a statement about the flag.

run it (verified, torch 2.2.2 CPU, CPython 3.12.0): the observer cost through Timer, and the flag that refuses; the CI build path inside the assert message is elided, nothing else is
$ python3 observer.py       # Timer around the same step, three rounds, one script
run 1  plain  1.420 ms   under profile()  1.838 ms   ratio  1.29x
run 2  plain  1.427 ms   under profile()  1.682 ms   ratio  1.18x
run 3  plain  1.619 ms   under profile()  1.959 ms   ratio  1.21x

$ python3 with_stack.py     # one optimizer step under with_stack=True
python 3.12.0 | torch 2.2.2
n=1: RuntimeError: !stack.empty() INTERNAL ASSERT FAILED at ".../torch/csrc/autograd/profiler_python.cpp":969, please report a bug to PyTorch. Python replay stack is empty.
n=5: RuntimeError: !stack.empty() INTERNAL ASSERT FAILED at ".../torch/csrc/autograd/profiler_python.cpp":969, please report a bug to PyTorch. Python replay stack is empty.
n=50: RuntimeError: !stack.empty() INTERNAL ASSERT FAILED at ".../torch/csrc/autograd/profiler_python.cpp":969, please report a bug to PyTorch. Python replay stack is empty.
§ 04

The biggest allocation in the step has a shape

With record_shapes=True the same capture can be grouped by input shape, and with profile_memory=True it carries bytes. Together they answer a question the flat table cannot: which single call allocated the most, and for which operand.

The winner in this step is an aten::mm on [[512, 64], [64, 512]], 5.00 Mb over five steps, one megabyte per step. Read the shapes and it names itself. That is the transposed activation times the incoming gradient, which is the weight gradient of the first linear, and it is larger than the activations because the weight is 512 by 512 while the batch is only 64 rows.

Two rows carry negative memory and they are not errors. Optimizer.zero_grad#SGD.zero_grad shows -4.09 Mb self, because set_to_none=True drops the gradient tensors and the profiler accounts the release to whoever released it. The train_step annotation nets out at -652.54 Kb for the same reason: over a whole step, this loop frees slightly more than it keeps.

None of these bytes are device bytes. The columns say CPU Mem, they come from the CPU allocator, and the third lesson is about why the CUDA equivalents on this machine answer zero rather than refusing.

the same capture again, printed with key_averages(group_by_input_shape=True).table(sort_by="self_cpu_memory_usage", row_limit=6); verbatim
-------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------------------------------------  
                                                   Name    Self CPU %      Self CPU   CPU total %     CPU total  CPU time avg       CPU Mem  Self CPU Mem    # of Calls                                Input Shapes  
-------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------------------------------------  
                                               aten::mm        12.08%       1.198ms        12.08%       1.198ms     239.600us       5.00 Mb       5.00 Mb             5                      [[512, 64], [64, 512]]  
                                            aten::addmm        13.89%       1.377ms        14.83%       1.471ms     294.200us     640.00 Kb     640.00 Kb             5      [[512], [64, 512], [512, 512], [], []]  
                                        aten::clamp_min         0.91%      90.000us         0.91%      90.000us      18.000us     640.00 Kb     640.00 Kb             5                             [[64, 512], []]  
                                               aten::mm         1.04%     103.000us         1.04%     103.000us      20.600us     640.00 Kb     640.00 Kb             5                       [[64, 10], [10, 512]]  
                               aten::threshold_backward         1.11%     110.000us         1.11%     110.000us      22.000us     640.00 Kb     640.00 Kb             5                  [[64, 512], [64, 512], []]  
                                               aten::mm         1.58%     157.000us         1.58%     157.000us      31.400us     100.00 Kb     100.00 Kb             5                       [[10, 64], [64, 512]]  
-------------------------------------------------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------  ------------------------------------------  
Self CPU time total: 9.916ms
§ 05

Every backward event carries a forward sequence number

Export the capture with export_chrome_trace and it is plain JSON, so you can check what landed without opening a viewer. This one holds 784 events over five steps: 540 operator events, 20 annotations, 146 memory events from profile_memory, and 70 events in a category called fwdbwd.

Those 70 are flow events, and they exist to answer the question a flat table cannot. Every operator event carries a Sequence number in its args, and the backward node that consumes it carries the same one. Sequence 29 holds aten::addmm with Fwd thread id 0 and AddmmBackward0 with Fwd thread id 1, which is the pairing drawn as an arrow when the trace is opened in Perfetto.

That integer is not new here. It is the same _sequence_nr() the autograd lesson at /pytorch/autograd/the-node-behind-grad-fn reads off a graph node, seen from the other end: the tape assigns it when the forward op runs, and the profiler stamps it on both events so a trace can be walked back into your own code.

A backward row you cannot attribute is a forward op you have not looked up yet.
run it (verified, torch 2.2.2 CPU): the tail of the same script, reading the trace it just exported
# the tail of the capture script from the first section, same process
import collections
import json

prof.export_chrome_trace("trace.json")
ev = json.load(open("trace.json"))["traceEvents"]
print("trace events:", len(ev), "| by category:", dict(collections.Counter(e.get("cat") for e in ev)))

pairs = collections.defaultdict(list)
for e in ev:
    if e.get("cat") == "cpu_op" and e.get("args", {}).get("Sequence number", -1) >= 0:
        pairs[e["args"]["Sequence number"]].append((e["name"], e["args"]["Fwd thread id"]))
for seq in sorted(pairs)[:3]:
    print("seq", seq, pairs[seq])

# ---- stdout ----
# trace events: 784 | by category: {'user_annotation': 20, 'cpu_op': 540, 'fwdbwd': 70, 'cpu_instant_event': 146, None: 7, 'Trace': 1}
# seq 28 [('aten::linear', 0), ('aten::t', 0), ('autograd::engine::evaluate_function: TBackward0', 1), ('TBackward0', 1)]
# seq 29 [('aten::addmm', 0), ('autograd::engine::evaluate_function: AddmmBackward0', 1), ('AddmmBackward0', 1)]
# seq 30 [('aten::relu', 0), ('autograd::engine::evaluate_function: ReluBackward0', 1), ('ReluBackward0', 1)]
before you move on

Check yourself

01 The top row of your profile is a record_function annotation, not an operator. What is in it?

Host work that ran between ATen calls: Python, module __call__, the autograd engine, optimizer bookkeeping. Self time excludes children, so that row is the host-side gap. In this capture it was 3.094 ms over five steps, 31 percent of the recorded step, and it stayed between 28 and 32 percent across five runs.

02 Why should you take shares of time from the profiler but not milliseconds?

Because recording costs a fifth to a quarter of this step: Timer put the ratio at 1.18 to 1.29, and the profiled captures reported 1.638 to 2.377 ms per step against an unwatched median of 1.420 to 1.619. Proportions survive that inflation, absolute numbers do not.

03 A backward row dominates your trace and you cannot tell which layer it came from. What do you look up?

The Sequence number in the event args. The forward op and its backward node share it, so sequence 29 pairs aten::addmm with AddmmBackward0 here. It is the same _sequence_nr the autograd lesson reads off a graph node, stamped onto both trace events.

assigned

Readings