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 does | reason string | family |
|---|---|---|
| `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.item | a 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.default | an output shape that is data |
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.
[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} 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.
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} 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.
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.
Readings
- symbolic_convert.py at 39901f2 ↗ generic_jump and the unimplemented() calls that produce most of these strings
- skipfiles.py at 39901f2 ↗ SKIP_DIRS itself, alongside the trace_rules.py that is replacing it in the same tree
- eval_frame.py explain at 39901f2 ↗ the one-line subtraction behind graph_break_count, in context