the pytorch path · 0/12
start the path

the PyTorch path · Guarded capture · lesson 03 of 4

Anatomy of a break

Every graph break carries a reason string, and the strings sort into four families with four different fixes. The count the survey hands you is a subtraction, which is why it sometimes reports a break that never happened and sometimes misses one that did.

the goal Given a compiled function, name which of the four break families each break belongs to from its reason string, predict the number of frames and guard sets a break costs, and say why explain and the counters can disagree.

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

Nine repros, four families

Reason strings are dynamo telling you which internal handler gave up. Nine one-line repros produce nine distinct strings, and sorting them by what would fix them gives four families rather than nine special cases.

In the first family a tensor value is being forced into Python, whether by a branch on a comparison, a bool(), an .item(), or id() on a tensor. None of those can be answered without running the graph, so dynamo runs what it has. The second family is a callable with no handler, which is where print and os.getpid() land; dynamo knows exactly what the object is and has no rule for tracing it.

The third family is a callee inside a skipped directory. logging.getLogger and torch.nn.Parameter both report skipfiles.SKIP_DIRS by name, which is a policy list rather than a limitation: dynamo declines to trace into those trees. The name is version-specific, and a trace_rules.py already sits beside skipfiles.py in this same 2.2.2 tree, so re-read the string on whatever torch you run. The fourth family is one op, something whose output shape is data, with aten.nonzero.default as the canonical case.

Four families, four fixes: move the value out of Python, teach dynamo the call, hoist the skipped callee, or accept a dynamic output shape.
what the line doesreason stringfamily
`if x.sum() > 0`generic_jump TensorVariable()a tensor value forced into Python
`bool(x.sum())`call_function BuiltinVariable(bool) [TensorVariable()] {}a tensor value forced into Python
`x.max().item()`Tensor.itema tensor value forced into Python
`id(y)`call_id with args (TensorVariable(),)a tensor value forced into Python
`print()`call_function BuiltinVariable(print) [] {}a callable with no handler
`os.getpid()`call_method UserDefinedObjectVariable(getpid) __call__ [] {}a callable with no handler
`logging.getLogger("p")`'skip function getLogger in file .../logging/__init__.py'', skipped according skipfiles.SKIP_DIRS'a callee in a skipped directory
`torch.nn.Parameter(...)`'skip function Parameter in file .../torch/nn/parameter.py'a callee in a skipped directory
`torch.nonzero(x)`dynamic shape operator: aten.nonzero.defaultan output shape that is data
nine repros, each run through torch._dynamo.explain (verified, torch 2.2.2 CPU, Python 3.11); reason strings verbatim, absolute paths shortened
§ 02

The cost is frames, not fusion

Two prints in one function, and the accounting comes out at three of everything: three frames, three graphs, three guard sets. The guard sets are the part worth staring at, because each one guards a different local. The original frame guards L['x'], the first resume frame guards L['y'], the second guards L['z'].

Which means the intermediate tensors flowing across a break are now cache keys. A break in the middle of a model turns an internal activation into something whose stride and requires_grad get checked on every call, and into something a later shape change can invalidate independently of the input that caused it.

One extra line shows up at each break site in the guard dump: not ___needs_nopython(). The break is recorded in the guard itself, which is how the same cached code refuses to run when the function is later called under fullgraph=True.

two breaks in one function (verified, torch 2.2.2 CPU, Python 3.11); guard lines trimmed to the tensor check, counters printed at the end
[0/0_1] check_tensor(L['x'], Tensor, DispatchKeySet(CPU, BackendSelect, ADInplaceOrView, AutogradCPU), torch.float32, device=None, requires_grad=False, size=[4], stride=[1])  # y = torch.sin(x)  # frames.py:6 in f
[0/0_1] not ___needs_nopython()                                       # print()  # frames.py:7 in f
[1/0_1] check_tensor(L['y'], ... size=[4], stride=[1])  # z = y * 2  # frames.py:8 in resume_in_f
[1/0_1] not ___needs_nopython()                                       # print()  # frames.py:9 in resume_in_f
[2/0]   check_tensor(L['z'], ... size=[4], stride=[1])  # return z + 1  # frames.py:10 in resume_in_f

frames     : {'total': 3, 'ok': 3}
stats      : {'calls_captured': 3, 'unique_graphs': 3}
graph_break: {'call_function BuiltinVariable(print) [] {}': 2}
§ 03

The count that subtracts

explain(...).graph_break_count is not a count of breaks. In torch/_dynamo/eval_frame.py it is one line, graph_break_count = graph_count - 1, where graph_count is the number of graphs the accumulating backend actually received. Empty graphs never reach a backend, so any break with nothing captured before it is invisible to the subtraction.

Two measurements make the gap concrete. A function whose first statement is print() compiles two frames and reports zero breaks, because the leading segment held no ops. A function with no tensor math at all reports minus one. Neither number is wrong for what it measures; they are wrong for the question people ask them.

counters["graph_break"] is the counted instrument: a dict keyed by reason string, incremented once per break as it happens. Two prints give a value of 2 under one key. Use the counter dict when you want to know how many and why, and use explain when you want the graphs and the guards it also returns.

the two instruments disagreeing, on purpose (verified, torch 2.2.2 CPU, Python 3.11)
import torch
import torch._dynamo as dynamo
from torch._dynamo.utils import counters

def print_first(x):
    print()
    return x + 1

def no_tensors(x):
    try:
        raise ValueError("no")
    except ValueError:
        return 1

print(dynamo.explain(print_first)(torch.randn(4)).graph_break_count)   # 0
print(dynamo.explain(no_tensors)(torch.randn(4)).graph_break_count)    # -1

dynamo.reset(); counters.clear()
torch.compile(print_first, backend="eager")(torch.randn(4))
print(dict(counters["frames"]))        # {'total': 2, 'ok': 2}
print(dict(counters["graph_break"]))   # {'call_function BuiltinVariable(print) [] {}': 1}
§ 04

The same conditions, as errors

fullgraph=True does not change what dynamo can trace. It changes what happens when it cannot, and the two exception types it raises are worth telling apart. A data-dependent branch raises torch._dynamo.exc.UserError with the message about dynamic control flow and a pointer at functorch.experimental.control_flow.cond. A print raises torch._dynamo.exc.Unsupported carrying the same reason string the counter would have recorded.

The split is a rough map of intent. UserError says the program as written has no whole-graph meaning and someone has to choose a different formulation. Unsupported says dynamo has no rule for this, which may be true only of this version.

Chapter 7 covers the other tool that refuses instead of splitting, torch.export, and the difference in contract between the two is the mastery item on that chapter rather than this one. What belongs here is the mechanism: the refusal is the same trace, with the break site turned into a raise.

before you move on

Check yourself

01 A break reports skipfiles.SKIP_DIRS in its reason. What kind of fix does that point at?

A structural one: the callee lives in a directory dynamo refuses to trace into, so hoist that call out of the compiled region rather than trying to make it traceable.

02 Why can explain report zero breaks for a function that clearly broke?

Because graph_break_count is graph_count minus one, and empty segments never reach the backend. A break before any tensor op leaves one graph and reports zero.

03 What do the guard sets look like after two breaks in one function?

Three of them, one per frame, each guarding the locals live at its own entry point. The intermediates crossing the breaks become cache keys with their own stride and requires_grad checks.

assigned

Readings