Day 14 - VariableTracker and Source: Every Python Value Through Dynamo's Eyes

August 6, 2026 (2w ago)

Every value entering tracing is wrapped in a VariableTracker: values with a Source go through VariableBuilder and get guards; values born mid-trace go through SourcelessBuilder

The previous post kept vaguely calling the things on the symbolic stack "stand-ins". This post makes them concrete. Under torch/_dynamo/variables/, Dynamo prepares a wrapper class for every kind of Python value tracing can meet, collectively VariableTracker. This type system is Dynamo's complete answer to the question "how dynamic is Python".

Why wrap at all

While translating bytecode, Dynamo holds no real values, yet an instruction's behavior depends on the value's type: the same call instruction means wildly different things depending on whether the callee is torch.sin or a function you wrote. So every value needs a stand-in that knows what it is, so a handler can ask it: what happens when you are multiplied? when you are called? when an attribute is read from you?

Those questions are exactly the VariableTracker interface: call_function(), call_method(), as_proxy() (hand out the proxy used in the graph), as_python_constant() (if I am really a constant, surrender the actual value), reconstruct() (how to rebuild me in the rewritten bytecode). Each subclass answers in its own way.

The family

A few members you will hit most often; their behavioral differences are Dynamo's behavior in miniature:

Python valuewrapped askey behavior
tensorTensorVariablecarries an FX proxy; operations add graph nodes
int, str, NoneConstantVariableabsorbed into the graph as a constant, not an input
list, tupleListVariable etc.elements wrapped individually, ops simulated symbolically
dictConstDictVariablesame; key lookups resolved at translation time
your functionUserFunctionVariableinlined on call, translation continues inside
nn.ModuleNNModuleVariableattribute chains tracked, parameters become graph inputs
other objectsUserDefinedObjectVariabletracked attribute by attribute, as far as possible

Two behaviors deserve a closer look.

ConstantVariable: a Python int gets baked into the graph. Your function received n=3; the graph will not have an input named n. Instead x * n becomes the node x * 3 directly. The graph is easier to optimize (constants fold, kernels specialize); the price is that this graph only holds for n=3. Who pays that price? The bookkeeping is done by the next post's guards.

UserFunctionVariable: a call is not a break point, it is an entrance. When Dynamo meets a call to a function you wrote, it does not break the graph; it opens an InliningInstructionTranslator (a subclass of the machine from the previous post), dives into the callee's bytecode, keeps translating, and splices the result back onto the original stack. The whole call chain is flattened into one graph. Which functions to dive into and which to skip (numpy internals, say) is decided by the lists in trace_rules.py.

There is also a performance role: LazyVariableTracker. Wrapping itself has a cost, and a value never touched is not worth expanding, so it first gets a lazy shell and only materializes when actually used. That is where compile time gets saved.

Source: where did this value come from

VariableTracker has one more key field, source, answering a different question: how to fetch this value at runtime.

cfg = Config()          # a global object, cfg.scale == 2
 
def f(x):
    return x * cfg.scale

When tracing cfg.scale, Dynamo records its origin as a chain: AttrSource(GlobalSource('cfg'), 'scale'), printed as G['cfg'].scale. A function argument is a LocalSource, printed as L['x']. There are only two kinds of roots (locals, globals); everything else chains on with AttrSource, GetItemSource, and so on, so any value's access path can be expressed.

The chain has two uses, matching Dynamo's two outputs:

Two factories: with or without a Source

The wrapping entrance is VariableTracker.build(tx, value, source=...), which routes to two factories by whether a source exists:

One sentence for this split: guards watch the boundary between the graph and the outside world, not the graph's interior.

Next post

The price of baking cfg.scale into a constant is the premise "G['cfg'].scale must still be 2". This post showed where premises grow from; the next (Day 15) looks at what they look like and how they are checked: the kinds of guards, how to read each line TORCH_LOGS=guards prints, and how, to keep the per-call check fast, Dynamo compiles the whole guard set into a C++ tree.