the pytorch path · 0/12
start the path

the PyTorch path · Guarded capture · lesson 02 of 4

What a guard checks

A guard is not a concept. It is a line of generated Python you can print, and the tensor one checks seven properties, of which shape is a single field.

the goal Print the guard set for any compiled function, say which entries are ambient and which came from your code, name the seven fields of the tensor guard, and derive from them five recompiles that have nothing to do with shape.

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 guard set, printed

TORCH_LOGS=guards dumps the compiled guard for every capture. For a function taking a tensor and a float, the whole set is seven lines, and reading them in order tells you where each came from. The first three are about your arguments. The next three are ambient state nobody wrote down. The last is the tensor check.

Look at what check_tensor compares. Type, dispatch key set, dtype, device, requires_grad, size, stride: seven fields for one argument. Shape is one of them, which is why the chapter's shorthand about shapes and dtypes is true and incomplete. The dispatch key set entry is why a tensor from a different device or with a different autograd state cannot reuse this entry even at identical dimensions.

Two of the ambient lines deserve names. ___compile_config_hash() pins the dynamo config in force at capture time, so flipping a config flag anywhere in the process invalidates every entry compiled under the old one. ___skip_backend_check() or ___current_backend() == ... pins the backend, which is why the same function compiled twice under two backends does not share a thing.

TORCH_LOGS=guards for one two-argument function (verified, torch 2.2.2 CPU, Python 3.11); timestamps and the log prefix trimmed, paths shortened
GUARDS:
hasattr(L['x'], '_dynamo_dynamic_indices') == False           # return torch.sin(x) * alpha  # guards.py:5 in f
___check_type_id(L['alpha'], 4570165824)                      # return torch.sin(x) * alpha  # guards.py:5 in f
L['alpha'] == 2.0                                             # return torch.sin(x) * alpha  # guards.py:5 in f
utils_device.CURRENT_DEVICE == None                           # _dynamo/output_graph.py:379 in init_ambient_guards
(___skip_backend_check() or ___current_backend() == ___lookup_backend(4643794512))  # _dynamo/output_graph.py:385 in init_ambient_guards
___compile_config_hash() == '56b03c84a72a07721b34d987281ef6a5'  # _dynamo/output_graph.py:387 in init_ambient_guards
check_tensor(L['x'], Tensor, DispatchKeySet(CPU, BackendSelect, ADInplaceOrView, AutogradCPU), torch.float32, device=None, requires_grad=False, size=[4], stride=[1])  # return torch.sin(x) * alpha  # guards.py:5 in f
§ 02

Eleven guard objects, eight printed lines

torch._dynamo.explain(f)(*args).out_guards gives the same guards as objects rather than text, and the two lists are not the same length. For the function in the table below, explain reports eleven guard objects while the compiled check for that same function prints eight predicate lines. The gap is not arbitrary. GuardBuilder.GRAD_MODE, DETERMINISTIC_ALGORITHMS and TORCH_FUNCTION_STATE are each a bare pass with the comment that they are always guarded via GlobalStateGuard(), and SHAPE_ENV emits code only when a dimension is symbolic, which is lesson four's subject.

The create_fn on each object is the builder method that wrote it, and the names are the taxonomy: TENSOR_MATCH, CONSTANT_MATCH, FUNCTION_MATCH, CLOSURE_MATCH, GRAD_MODE, DETERMINISTIC_ALGORITHMS, TORCH_FUNCTION_STATE, DEFAULT_DEVICE, BACKEND_MATCH, CONFIG_HASH_MATCH, SHAPE_ENV. The name field is the source expression: L['x'] for a local, G['scale'] for a global, empty for ambient state.

The printed set is also two predicates short of what runs. Every check function begins with ___guarded_code.valid and ___check_global_state(), which guards.py builds first and then declines to log, with the comment that reporting it would be useless because it is always the same. That second call is the one covering grad mode, deterministic algorithms and torch function state together, and it is why the next section's no_grad recompile has nothing more specific to report.

Reach for the objects when you want to enumerate what a capture depends on, and for the text when you want to know what will actually be tested per call. They answer different questions and the gap between them is a fact about dynamo, not about your program.

namecreate_fnwhat it pins
L['x']TENSOR_MATCHtype, dispatch keys, dtype, device, requires_grad, size, stride
L['flag']CONSTANT_MATCHthe exact value of a Python argument dynamo specialized on
G['scale']CONSTANT_MATCHthe value of a module-level constant the graph baked in
G['torch']FUNCTION_MATCHthe module object the calls were resolved against
GRAD_MODEwhether grad was enabled at capture
DEFAULT_DEVICEthe ambient default device
BACKEND_MATCHthe compiler backend in force
CONFIG_HASH_MATCHthe dynamo config in force
SHAPE_ENVthe symbolic-shape assumptions, when there are any
explain(f)(torch.randn(4), True).out_guards for a function closing over a global (verified, torch 2.2.2 CPU, Python 3.11)
§ 03

Five compiles, one shape

Five calls, one function, one shape and one dtype throughout, and five separate compiles come out the other end. A transposed tensor is the same shape with a different stride. A tensor with requires_grad on is the same shape with a different flag. A call inside torch.no_grad() changes ambient state rather than any argument. Rebinding the module-level float the graph baked in changes a global.

The recompile log names the failed predicate every time, and it is the fastest way to close a recompile investigation. Three of these four report the exact line: a stride mismatch with the expected value, a requires_grad mismatch, a G['scale'] == 3.0 that no longer holds. The no_grad one reports ___check_global_state(), the unprinted predicate from the previous section, which is as specific as that failure ever gets.

Notice the shape of the output too. On the fourth recompile there are three failure lines, one per existing cache entry, because dynamo checked every entry in the line before deciding to compile. Lesson four picks that up.

five captures from one function with one shape (verified, torch 2.2.2 CPU, Python 3.11), run under TORCH_LOGS=recompiles
import torch
from torch._dynamo.utils import counters

scale = 3.0

@torch.compile(backend="eager")
def f(x):
    return x * scale

def graphs():
    return counters["stats"]["unique_graphs"]

f(torch.randn(4, 4));                       print("contiguous   ", graphs())  # 1
f(torch.randn(4, 4).t());                   print("transposed   ", graphs())  # 2
f(torch.randn(4, 4, requires_grad=True));   print("requires_grad", graphs())  # 3
with torch.no_grad():
    f(torch.randn(4, 4))
print("under no_grad", graphs())                                              # 4
scale = 4.0
f(torch.randn(4, 4));                       print("scale rebound", graphs())  # 5

# the four guard failures TORCH_LOGS=recompiles reports, in order:
#   - tensor 'L['x']' stride mismatch at index 0. expected 4, actual 1
#   - tensor 'L['x']' requires_grad mismatch. expected requires_grad=0
#   - ___check_global_state()
#   - G['scale'] == 3.0                        # return x * scale  # misses.py:8 in f
§ 04

The module you built twice

One guard kind matters more than the rest for real models. When dynamo captures an nn.Module method, it guards self by object identity: ___check_obj_id(L['self'], 4621695952). Not by class, not by parameter shapes. By address.

Two instances of one module class therefore compile twice, which is measurable in four lines and surprises people who assume the class is the cache key. An ensemble of sixteen identical members compiles sixteen times. A model rebuilt inside an evaluation loop compiles once per rebuild, forever, and the second lesson of that story is in lesson four, because identity guards are exactly why dynamo carries two cache limits instead of one.

The GYM·10 drill on the gym page runs eight of these scenarios as a guess-the-verdict exercise against counters measured on this same torch. Take it after this section rather than before: the drill asks which verdict, and this section is where the answer stops being a guess.

two instances, two graphs (verified, torch 2.2.2 CPU, Python 3.11); the guard line comes from TORCH_LOGS=guards on the same run
import torch
from torch._dynamo.utils import counters

class M(torch.nn.Module):
    def __init__(self, k):
        super().__init__()
        self.lin = torch.nn.Linear(4, 4)
        self.k = k
    def forward(self, x):
        return self.lin(x) * self.k

c1 = torch.compile(M(2.0), backend="eager")
c2 = torch.compile(M(3.0), backend="eager")
c1(torch.randn(2, 4)); print(dict(counters["stats"]))  # unique_graphs: 1
c1(torch.randn(2, 4)); print(dict(counters["stats"]))  # unique_graphs: 1
c2(torch.randn(2, 4)); print(dict(counters["stats"]))  # unique_graphs: 2

# [0/0] ___check_obj_id(L['self'], 4621695952)   # return self.lin(x) * self.k
# [0/1] ___check_obj_id(L['self'], 4443764496)   # return self.lin(x) * self.k
§ 05

The hole the guards leave

Guard coverage is a contract, and the interesting part of any contract is what it excludes. On this torch, rebinding a module-level function that dynamo inlined does not invalidate the graph that inlined it. The compiled path keeps running the old body while eager Python runs the new one, and the two disagree until something else forces a recompile.

The sequence below is five prints. Compile once with a helper that doubles. Rebind the helper to multiply by a hundred. Eager gives 100, compiled still gives 2. Then call with a new shape, which misses the tensor guard, and the fresh capture picks up the new body, after which even the old shape returns the new answer, because the recompiled graph took a dynamic dimension and now serves both.

Whether it still holds on the torch you are running is a question for your torch, so re-run the snippet before trusting either outcome. The durable lesson is the shape of the risk: a guard set is a finite list of predicates, hot-swapping code under a compiled function is outside it, and the stale window closes at the next miss rather than at the moment you changed something.

a stale graph, measured (verified, torch 2.2.2 CPU, Python 3.11); re-run this on your own version before relying on either outcome
import torch

def helper(t):
    return t * 2

@torch.compile(backend="eager")
def f(x):
    return helper(x)

x = torch.ones(3)
print("compiled once    :", f(x).tolist())          # [2.0, 2.0, 2.0]

def helper(t):                 # same name, new function object, new body
    return t * 100

print("eager now        :", helper(x).tolist())     # [100.0, 100.0, 100.0]
print("compiled still   :", f(x).tolist())          # [2.0, 2.0, 2.0]
print("after a shape miss:", f(torch.ones(5)).tolist())
# [100.0, 100.0, 100.0, 100.0, 100.0]
print("and back at 3    :", f(x).tolist())          # [100.0, 100.0, 100.0]
before you move on

Check yourself

01 Two tensors have the same shape and dtype, and the second one recompiles. Name three fields that could have differed?

Any of the seven in check_tensor: stride, requires_grad, device, dispatch key set, or the tensor type itself. Shape is one field among several, not the whole guard.

02 Why does an ensemble of sixteen identical modules compile sixteen times?

Because a captured nn.Module method guards self with ___check_obj_id, an identity check on the instance. The class and the parameter shapes are not the cache key; the object address is.

03 A guard object appears in explain().out_guards but no line for it appears under TORCH_LOGS=guards. Is that a bug?

No. Some guard kinds emit no runtime code, and SHAPE_ENV emits code only when a dimension is symbolic. The object list is what the capture depends on; the printed lines are what gets tested per call.

assigned

Readings