PyTorch 训练框架内核深度解析

第12章 TorchDynamo:CPython 帧拦截与图捕获

作者 杨艺韬 · 13,099 字 · 发布于 · 更新于

"The eval frame handler is the core mechanism that enables TorchDynamo to dynamically intercept, analyze and optimize PyTorch code during execution. It works by registering a custom frame evaluation function that gets called for every Python frame, allowing us to detect PyTorch operations and trigger compilation as needed."

—— torch/_dynamo/eval_frame.py 模块 docstring

本章要点

  • Dynamo 通过 PEP 523 帧评估 API 在 CPython 解释器层拦截每个 Python 函数调用:用 _PyInterpreterState.eval_frame 钩子替换默认的 _PyEval_EvalFrameDefault
  • 拦截后做的事:解析帧的字节码,用 InstructionTranslator 一条条字节码地"符号执行",把 PyTorch 算子调用记录到 FX Graph
  • Guards 是"输入假设":trace 时假定输入是 torch.float32 + cuda + shape=[B, 768],下次调用时检查 guards,命中就跑编译产物,不命中就重新 trace
  • Graph Break 是最重要的失败模式:遇到 unsupported Python 构造(如依赖 tensor 值的 if 判断、外部库调用)时 Dynamo 把这一段交给 eager 执行,把图切成两段
  • FX Graph 输出后送给 backend:默认 backend 是 Inductor,用户也可以传 aot_eager、cudagraphs 等做实验
  • 理解 Dynamo 是理解 torch.compile 一半价值:编译失败、性能不如预期、graph break 多 —— 这类问题大多要从 Dynamo 阶段查起

12.1 一个被低估的工程奇迹

@torch.compile 一行装饰器就能给模型带来可观加速,但它没有改任何用户代码。这是怎么做到的?

答案是 PEP 523:CPython 3.6 引入的"帧评估 API",允许 C 扩展替换解释器的核心函数 _PyEval_EvalFrameDefault。Dynamo 利用这个钩子,在每个函数被调用时先把它的字节码拿出来分析一遍,能编译就编译、不能就让默认解释器跑。

graph LR
    Py[Python 解释器] --> H{eval_frame 钩子<br/>有没有装?}
    H -->|否, 默认| Def[_PyEval_EvalFrameDefault<br/>正常解释执行]
    H -->|是, Dynamo 装了| Dy[Dynamo callback]
    Dy --> Cache{这个 frame 编译过吗?}
    Cache -->|是, guards 命中| Run[直接跑编译产物]
    Cache -->|否或不命中| Comp[trace + 编译]
    Comp --> Run
    style Dy fill:#fef3c7,stroke:#f59e0b,stroke-width:2px

源码集中在 torch/_dynamo/:v2.11.0 该目录约 110 个 .py 文件、近 10 万行 Python,配套的 C/C++ 扩展在 torch/csrc/dynamo/,约 1.4 万行(v2.0 起该 namespace 一直在快速增长)。本章拆它的核心机制。

12.2 入口:set_eval_frame 装钩子

打开 torch/_dynamo/eval_frame.py:torch.compile(fn) 本身只返回一个包装函数;调用这个包装函数时,Dynamo 才通过一个 C 扩展(torch/csrc/dynamo/)调 CPython 的 _PyInterpreterState_SetEvalFrameFunc,把 _PyEval_EvalFrameDefault 替换成 Dynamo 自己的 dynamo_custom_eval_frame_shim,调用结束再换回原来的函数。于是在被编译函数执行期间,它以及它调用到的 Python 函数都先经过 Dynamo。

但 Dynamo 不会编译所有函数 —— 它只对和 PyTorch 相关的 frame 感兴趣。has_tensor_in_frame(torch/_dynamo/convert_frame.py:377)扫描帧的 locals(也看帧引用的全局名里有没有 torch 模块),发现 Tensor、nn.Module 等 torch 相关对象才进入编译路径,否则直接 fall back 到默认解释。

这种"hooks all but only acts on tensor frames"是 Dynamo 与现有 Python 生态共存的关键 —— 不影响其他库的运行,只对 PyTorch 代码生效。

12.3 编译入口:convert_frame

进入编译路径后,convert_frame.py:catch_errors_wrapper 是统一入口,里面调 _compile。_compile 干几件事:

  1. 从 frame.f_code 拿到字节码 + locals + globals
  2. 统计该 code object 已有的缓存条目,判断是否超出 recompile_limit(缓存命中与否在进入 Python 回调之前已由 C 层查过,见 §12.13)
  3. 创建 InstructionTranslator 开始 trace
  4. trace 完拿到 OutputGraph + Guards
  5. 把 OutputGraph 喂给 backend(默认 Inductor)拿到编译产物
  6. 返回包着新 code object 的 GuardedCode(torch/_dynamo/types.py:67)让 CPython 后续调用直接跑

整套流程发生在用户 f(x) 第一次调用时,所以第一次 compile 慢;第二次起命中缓存,只需付 guard 检查等固定开销(量级见 §12.8)。

12.4 InstructionTranslator:符号执行字节码

核心类是 torch/_dynamo/symbolic_convert.py:5286 的 InstructionTranslator,它继承同文件 :1236 的 InstructionTranslatorBase,本质是一个字节码解释器 —— 但它不真的执行算术,而是把每个 PyTorch 算子记录到 FX Graph,普通 Python 操作正常算(直接在 trace 时算 Python 的 if/for)。

字节码层面的关键 ops(opcode 名随 Python 版本变化:3.10 的 CALL_FUNCTION / CALL_METHOD 在 3.11 起改为 PRECALL + CALL(3.12 去掉 PRECALL),BINARY_ADD / BINARY_MULTIPLY 等在 3.11 起合并为带参数的 BINARY_OP;symbolic_convert.py 对这些 opcode 都有同名 handler):

Bytecode InstructionTranslator 行为
LOAD_FAST 从 locals 取出对应 VariableTracker(包装 Python 对象的符号)
CALL_FUNCTION(3.11+ 为 CALL) 如果是 PyTorch 算子,往 FX Graph 加 node;如果是普通函数,inline trace 进去
BINARY_ADD(3.11+ 为 BINARY_OP) 同上 —— Tensor + Tensor 加节点,int + int 直接算
RETURN_VALUE trace 结束,返回 OutputGraph

每个 Python 对象在 trace 时被包装成 VariableTracker:TensorVariable(Tensor)、ConstantVariable(int/str/bool)、BuiltinVariable(内置函数)等。这套包装让 Dynamo 能区分"这个值要进 FX Graph"和"这个值在 trace 时直接消费"。

举个例子:

@torch.compile
def f(x, n):
    y = x * 2
    for i in range(n):
        y = y + i
    return y

trace 时 Dynamo 看到:

  • x * 2 → 加一个 mul 节点到 FX Graph
  • range(n) → n 是 Python int,trace 时直接展开循环
  • 循环里的 y + i → 加 add 节点(每次循环加一个)

如果 n=3,最终 FX Graph 是 mul(x, 2) → add(_, 0) → add(_, 1) → add(_, 2) → return。循环被完全 unroll。本机 CPU torch 2.11.0 用自定义 backend 打印 gm.code 实测正是这 4 个节点,同时装了一条 guard EQUALS_MATCH: L['n'] == 3;再用 n=5 调用会因这条 guard 失败而重编译,新图有 5 个 add。

12.5 Guards:输入假设的运行时校验

trace 出来的 FX Graph 只对符合 trace 假设的输入正确。比如上例 trace 时 n=3,graph 里硬编码了 3 次 add;如果下次 n=5,graph 就错了。

Guards 是 Dynamo 记录的"输入假设"列表。guards.py 里几十种 GuardBuilder(TENSOR_MATCH、SHAPE_ENV、TYPE_MATCH、ID_MATCH、EQUALS_MATCH、CONSTANT_MATCH 等)对应不同维度的假设:

# 假设的形式 (实际是 C 代码生成)
def check_guards(x, n):
    assert isinstance(x, torch.Tensor)
    assert x.dtype == torch.float32
    assert x.device == device('cuda:0')
    assert x.size() == [B, 768]   # B 可能是符号 (动态 shape)
    assert n == 3                  # EQUALS_MATCH,source 是 LocalSource('n')
    return True

每次调用编译过的函数:先跑 guards check,全过就跑编译产物,否则重新 trace(产生新的 graph + 新的 guards,存到 cache)。

这套机制让 torch.compile 既能享受静态图性能、又能处理"shape 偶尔变化"的动态场景。代价是 cache 可能膨胀,所以默认配置 automatic_dynamic_shapes=True:第一次按静态 shape 编译,某个维度一旦因 shape 变化触发重编译,就把它自动标成符号维度,之后同一维度再变也不必重 trace(本机实测:x + 1 依次喂长度 2、3、4、5、6 的输入,只编译 2 次,第二次的图输入是符号 s77)。也可以用 torch.compile(dynamic=True) 或 torch._dynamo.mark_dynamic 一开始就声明动态。

12.6 Graph Break:trace 失败时的退路

不是所有 Python 代码都能 trace。Dynamo 遇到下面情况会 graph break:

  • 调用了 Dynamo 不认识的 C 扩展(如某些第三方库)
  • 控制流依赖 tensor 的具体值(if x > 0 —— 要等运行时才知道)
  • print / open / 其他 side effect 操作
  • 某些 Python 黑魔法(动态生成函数等)

graph break 不是 fatal —— Dynamo 把当前 trace 段封成一个 graph、让 break 处的代码用 eager 跑、break 之后继续 trace 第二段。结果是一个函数被切成多个 graph + 中间 eager 代码段。

@torch.compile
def f(x):
    y = x * 2                # graph 1:x * 2、y.sum() > 0
    if y.sum() > 0:          # graph break! (依赖 tensor 值)
        z = y.relu()         # graph 2:relu + 后面的 z + 1(在 if 分支的 resume 函数里)
    else:
        z = y.tanh()         # graph 3:tanh + 后面的 z + 1(在 else 分支的 resume 函数里)
    return z + 1

本机 CPU torch 2.11.0 实测:正、负输入各调一次,自定义 backend 共收到 3 张图——graph 1 输出 (gt, y),两个分支各自连同 return z + 1 编成一张图;torch._dynamo.explain 对单次调用报 1 次 graph break,原因 generic_jump TensorVariable()。每个 graph 各自编译。优化空间还在但不如"一整个 graph"激进 —— graph 之间没法做跨段融合,CUDA Graph 也只能按段分别捕获。

减少 graph break 是 torch.compile 调优的核心工作。可以用环境变量 TORCH_LOGS=graph_breaks 看哪些行触发了 break(上例打印 Graph Break Reason: Data-dependent branching,并提示改用 torch.cond)。

12.7 OutputGraph:trace 的产物

torch/_dynamo/output_graph.py:583 的 OutputGraph 类持有 trace 阶段构建的 FX Graph。它的核心字段:

  • graph: torch.fx.Graph —— FX 节点列表
  • guards(GuardsSet)—— 收集到的 guards
  • side_effects —— 副作用列表(用于安全 replay)
  • output_instructions —— 编译完成后回写到 frame 的字节码

trace 结束后 OutputGraph 调用 compiler_fn(graph_module, example_inputs) 把 FX Graph 交给 backend。default backend 名为 inductor(torch/_dynamo/backends/inductor.py),它转手调 torch._inductor.compile_fx.compile_fx(第 14 章会展开)。其他常见 backend:

  • aot_eager:只做 AOTAutograd 不上 Inductor,主要用于调试
  • cudagraphs:经 AOTAutograd 后直接用 CUDA Graph 包装,跳过 Inductor 优化
  • eager:不做编译只 trace(用于验证 trace 正确性)

backend 是可插拔的,第三方可以注册自己的:

@torch._dynamo.register_backend
def my_backend(gm, example_inputs):
    return gm.forward    # 返回 callable

国产 AI 芯片厂商接入 torch.compile 时,往往在这层注入自家编译器。第 14 章会拆 Inductor 自己怎么实现这个 backend。

12.8 一段实际 trace 的剖析

考虑这段代码:

@torch.compile
def add_relu(a, b):
    c = a + b
    return c.relu()

第一次调用 add_relu(x, y)(假设都是 cuda fp32 [4, 4])时:

  1. Dynamo 拦截 frame,看到有 Tensor → 进入 _compile
  2. cache miss,开始 trace
  3. InstructionTranslator 解析字节码(Python 3.10):LOAD_FAST a、LOAD_FAST b、BINARY_ADD、STORE_FAST c、LOAD_FAST c、LOAD_METHOD relu、CALL_METHOD、RETURN_VALUE(3.11 起开头多一条 RESUME,BINARY_ADD 变成 BINARY_OP,CALL_METHOD 变成 PRECALL + CALL;3.12 起 LOAD_METHOD 并入 LOAD_ATTR、PRECALL 取消)
  4. trace 时往 FX Graph 加 2 个节点:add(a, b) → relu(_)
  5. 收集 guards:a 是 fp32+cuda+[4,4]、b 是 fp32+cuda+[4,4]
  6. 把 FX Graph + example_inputs 送给 Inductor
  7. Inductor 编译成 Triton kernel,返回 callable
  8. Dynamo 把 callable 连同 guards 包成 GuardedCode 挂到 code object 上,下次直接跳过 trace

第二次调用 add_relu(x2, y2)(同样 dtype/device/shape):

  1. Dynamo 拦截 frame
  2. 检查 guards:x2 / y2 也是 fp32+cuda+[4,4] → 通过
  3. 直接跑缓存的编译产物(Inductor 生成的 Python wrapper 调 Triton kernel)
  4. 跳过重新 trace,也绕开了原来逐算子走 dispatcher 的路径

第二次起只剩 guard 检查和包装层的固定开销。本机 CPU 实测(backend="eager",函数体只有 x + 1):直接调用约 5 µs,编译版约 22 µs,多出的十几微秒就是这部分固定成本;函数体越大、kernel 越多,它占的比例越小 —— 省下的是重新 trace 与逐算子调度,这就是 torch.compile 的核心收益。

12.9 VariableBuilder:Python 对象 → VariableTracker

进入 trace 前要把 frame 的 locals / globals 里每个 Python 对象包装成 VariableTracker。torch/_dynamo/variables/builder.py:464 的 VariableBuilder 是这个包装器。

它按对象类型分流:

Python 对象 包装后类型 处理
torch.Tensor TensorVariable 记录 shape/dtype/device 加 guard、加 graph input
int / float ConstantVariable(首次) trace 时直接当常量参与、装 EQUALS_MATCH guard;值变化触发重编译后按 automatic dynamic 转成符号(SymNodeVariable)
nn.Module 默认(inline_inbuilt_nn_modules=True)为 UnspecializedNNModuleVariable / UnspecializedBuiltinNNModuleVariable;关掉该开关时为 NNModuleVariable 把 module 整个"内联"进 trace,递归 trace 它的 forward
list / tuple / dict ListVariable / TupleVariable / ConstDictVariable 容器递归包装每个元素
torch.dtype ConstantVariable dtype 是 trace 时常量,装 EQUALS_MATCH guard
用户函数 UserFunctionVariable 调用时 inline trace 进去
不认识的对象 UserDefinedObjectVariable 属性/调用不被支持时触发 graph break

每种 VariableTracker 实现 call_function / call_method / var_getattr 等方法 —— 描述"这个值上做某操作时 trace 怎么处理"。比如 TensorVariable.call_method('add', other) 会在 FX Graph 里加一个 add 节点。

VariableTracker 同时记录 Source:这个值是从哪里来的(如 LocalSource('x')、AttrSource(LocalSource('self'), 'weight'))。Source 用于生成 Guard —— 反向追溯出"如果下次调用,这个值在什么位置、应该是什么"。

12.10 GuardBuilder:把 trace 假设编译成 C++ check

torch/_dynamo/guards.py:1013 的 GuardBuilder 把 trace 阶段累积的 Source + 类型假设转换成 可执行的 guard 检查代码。

每个 Source 对应几条 guard:

# trace 时 x 是 cuda fp32 [B, 768] tensor (B 是符号)
# 生成的 guards (伪代码):
guard_1 = TENSOR_MATCH(x, dtype=fp32, device='cuda:0', requires_grad=False)
guard_2 = SHAPE_ENV(x.size() = [s0, 768])    # s0 是 SymInt 符号
# TENSOR_MATCH 自身已含类型检查(实际日志:check_tensor(L['x'], Tensor, DispatchKeySet(...), torch.float32, ...)),
# 所以 tensor 输入不另装 TYPE_MATCH,另有 NO_HASATTR(L['x'], '_dynamo_dynamic_indices') 等辅助 guard

GuardBuilder 把这些 guards 组装成 C++ 层的 GuardManager 树(torch/csrc/dynamo/guards.cpp,不是 Python!),下次调用时直接在 C++ 里 check,省掉了逐条回到 Python 解释器求值的开销。

// 编译生成的 check (伪代码)
bool check(PyObject* x) {
    if (!THPVariable_Check(x)) return false;
    auto t = THPVariable_Unpack(x);
    if (t.dtype() != fp32) return false;
    if (t.device() != cuda_0) return false;
    if (t.size(1) != 768) return false;
    return true;
}

C++ guard check 的耗时在微秒量级(TORCH_LOGS=guards 会打印一行 Guard eval latency = ... us,那是编译时先刷 CPU cache 再测一次的冷启动值,本机 CPU 上 §12.4 那个例子约 36 µs,稳态更低),远小于重新 trace 一次,这是 Dynamo 第二次起调用开销很小的原因。

guards 也有失败处理:guard 失败时 Dynamo 不直接 fallback eager,而是重新 trace 一份(产生新的 graph + 新的 guards),缓存里就有 N 个候选 graph、运行时按 guard 命中选一个。

12.11 cache 的层次结构

Dynamo 不是"每个函数一个 graph",而是每个函数 N 个 graph(按不同 guards 命中):

function `f`'s code object → guarded code 列表:
  [0]: graph_A + guards_A    ← 第一次 trace 出来的
  [1]: graph_B + guards_B    ← shape 变了重新 trace
  [2]: graph_C + guards_C    ← 又一个新 shape
  ...

每次调用 f(x),Dynamo 沿这条链表逐个检查 guards,第一个 pass 的就跑对应 graph;全部不命中就再 trace 一份。链表默认按 LRU 排列(torch/csrc/dynamo/extra_state.cpp 的 use_lru 默认 true):新条目插到表头,命中的条目也挪到表头,所以上图的顺序只是示意 —— TORCH_LOGS=recompiles 列出的失败 guard 也是按 0/7, 0/6, …, 0/0 从新到旧排的。

torch/_dynamo/cache_size.py 控制 cache 大小:

  • recompile_limit 默认 8(旧名 cache_size_limit 仍可用,是它的 alias):单个 code object 最多缓存 8 个条目(有 ID_MATCH guard 时按"守护同一对象的条目数"计)
  • 超过后不驱逐旧条目,而是不再为这个 frame 编译新版本(见 §12.25)
  • accumulated_recompile_limit(旧名 accumulated_cache_size_limit,默认 256):单个 code object 编译次数的总保险上限

torch._dynamo.config.recompile_limit = 64 可以放宽,但太大会让"同一个函数有几十个版本"消耗内存。如果你看到日志警告 torch._dynamo hit config.recompile_limit (8),意味着输入在不断变化(每次 shape 或常量都不一样)—— 应该查 TORCH_LOGS=recompiles 给出的原因,必要时用 mark_dynamic 让一个 graph 处理多 shape,而非缓存几十份。

12.12 OutputGraph 的"compile + 字节码回写"

trace 完成后,OutputGraph 不只是返回 FX Graph 给 backend —— 它还要生成新的字节码让 CPython 在后续调用时直接跑编译产物。

torch/_dynamo/output_graph.py:1605 的 compile_subgraph:

  1. 把 trace 出来的 FX Graph 包装成 torch.fx.GraphModule
  2. 调 compiler_fn(gm, example_inputs)(默认是 Inductor)拿到 callable
  3. 用 install_global 一族方法把 callable 注册成 frame 的全局变量(实测名字形如 __compiled_fn_1_<uuid>)
  4. 生成"调用 callable 的字节码序列",存到 self.output_instructions

torch/_dynamo/bytecode_transformation.py:1593 的 transform_code_object 把 output_instructions 拼装成新的 code object,PyTorch 在 eval_frame 钩子里返回这个新 code object 让 CPython 执行 —— 从此用户的函数被透明替换。

简化后的回写字节码大致(Python 3.10 opcode):

LOAD_GLOBAL  __compiled_fn_1_<uuid>    # 取出 Inductor 编译好的 callable
LOAD_FAST    x                         # 取参数 x
LOAD_FAST    y                         # 取参数 y
CALL_FUNCTION 2                        # 调用编译产物
RETURN_VALUE                           # 返回

注意 guards 不在这段字节码里:它们挂在 cache entry 上,由 C 层在执行字节码之前检查(§12.11、§12.13),命中哪个条目就执行哪个条目的新 code object。实际回写字节码还要处理返回值打包、还原栈与局部变量、graph break 后调用 resume 函数等,本机 Python 3.10 实测 z = x + y; return z * 2 这样的两行函数就有三十多条指令(TORCH_LOGS=bytecode 可同时看到 ORIGINAL BYTECODE 与 MODIFIED BYTECODE)。但用户原始函数体被整体替换 —— 没有 graph break 时,CPython 不再解释执行原始 Python 代码,直接调编译产物。

这是"@torch.compile 装饰器一行就能加速"的最后一块拼图:guards + bytecode rewrite 一起让"判断 + 跳到编译产物"成为新 frame 的全部工作。

12.13 PEP 523 frame eval 钩子的精确机制

§12.2 提到 Dynamo 通过 PEP 523 拦截 CPython。具体看 torch/csrc/dynamo/eval_frame.c:

// :218 安装钩子
_PyInterpreterState_SetEvalFrameFunc(
    tstate->interp,
    &dynamo_custom_eval_frame_shim    // Dynamo 自家的 frame evaluator
);

// :227 卸载钩子
_PyInterpreterState_SetEvalFrameFunc(tstate->interp, previous_eval_frame);

机制:CPython 解释器在每个函数调用前查 _PyInterpreterState->eval_frame,调它评估帧。默认是 _PyEval_EvalFrameDefault(标准解释器)。Dynamo 用 _PyInterpreterState_SetEvalFrameFunc 把这个指针换成自己的 dynamo_custom_eval_frame_shim。

之后每个 Python 函数调用都先经过 Dynamo。shim 内部判断:

  • C 实现的内置函数(如 print)根本不产生 Python frame,不经过钩子;标准库等被 torch/_dynamo/trace_rules.py 判为跳过的 frame(如 json.loads)→ 调 default eval(不编译)
  • frame 含 tensor + 是用户代码 → 进 trace + compile 路径
  • frame 已编译过 + guards 命中 → 直接跑编译产物

_PyEval_RequestCodeExtraIndex(:758;Python 3.12+ 下在 :754 被 #define 成 CPython 的 PyUnstable_Eval_RequestCodeExtraIndex)申请一个 code object 的"额外字段",Dynamo 用它存"这个 frame 的编译缓存"。CPython 看到 code object 时通过 extra_index 取回缓存 —— 这是 Dynamo 实现"per-code-object 缓存"的底层。

整套机制让 Dynamo 无需修改 CPython 源码就能拦截字节码执行。PEP 523 是 2016 年加入 CPython 的扩展点,Dynamo 是它的典型使用者。理解这条机制让你看 eval_frame.c 几百行 C 代码不困惑。

12.14 InstructionTranslator 的核心循环

torch/_dynamo/symbolic_convert.py:1236 的 InstructionTranslatorBase 是字节码解释器。核心是 run()(:1787)里的循环,一条条调 step()(:1416):

# run() 的主循环
while self.step():
    pass

# step() 的核心:按 opcode 查 dispatch_table 派发 handler
def step(self) -> bool:
    ...
    self.current_instruction = inst = self.instructions[ip]
    self.instruction_pointer = ip + 1
    ...
    try:
        self.dispatch_table[inst.opcode](self, inst)
        return not self.output.should_exit
    # (此处省略 TensorifyScalarRestartAnalysis / ObservedException 两个 except 分支)
    except (ReturnValueOp, YieldValueOp):
        return False
    except (Unsupported, StepUnsupported) as e:
        ...   # → graph break 或(fullgraph 下)向上抛

每条字节码对应一个与 opname 同名的 handler 方法(没有 handler 的 opcode 查到的是 _missing,调用时报 Unsupported 触发 graph break)。dispatch_table 不是 dict 而是按 opcode 数值索引的 list(:1134 处 [dispatch_table.get(i) for i in range(2**8)],长度 256),由 __init_subclass__ 在每个子类上自动装配 —— 查表就是一次数组下标访问。

部分 handler 例子(opcode 名以 Python 3.10 为准):

Opcode Handler 行为
LOAD_FAST 从 local symtable 取出 VariableTracker
STORE_FAST 把 VariableTracker 存到 local symtable
BINARY_ADD 定义为 stack_op(operator.add):弹出两个操作数、按 operator.add 调用对应 VariableTracker,可能加 fx node(3.11+ 的 BINARY_OP 按参数查 _binary_op_lookup 转到同一批 handler)
CALL_FUNCTION 找出 callable 的 VariableTracker,inline trace 进去(如果是用户函数)或加 fx node(如果是 PyTorch op)
LOAD_ATTR 处理对象属性访问,可能触发 nn.Module 的 _modules / _parameters dict 查找
RETURN_VALUE 终止 trace,传 OutputGraph 给 backend
POP_JUMP_IF_FALSE 控制流分支(generic_jump):如果条件依赖 tensor 值 → graph break

整套字节码 dispatch 让 Dynamo 像"模拟 CPython 解释器"一样运行用户代码,不真做计算(tensor 操作转 fx node、Python 操作模拟运行)。这是 trace 阶段的核心工程实现。

12.15 GuardManager:guards 的高效组织

§12.5 + §12.10 讲了 guards 的概念与编译。具体管理 guards 的是 GuardManagerWrapper(torch/_dynamo/guards.py:265):

graph TB
    Code[code object → guard 列表]
    Code --> Mgr[GuardManager]
    Mgr --> M1[guards by source]
    M1 --> S1[LocalSource: x → TENSOR_MATCH]
    M1 --> S2[LocalSource: y → TENSOR_MATCH]
    M1 --> S3[GlobalSource: model → TYPE_MATCH]
    M1 --> S4[AttrSource: model.weight → TENSOR_MATCH]
    Mgr --> CC[C++ guard 树<br/>RootGuardManager → accessor → leaf guard]
    CC --> Run[运行时调用]

GuardManager 按 source 组织 guards(每个 Python 表达式一组,TORCH_LOGS=guards 打出的 TREE_GUARD_MANAGER 就是这棵树)—— 让 check 时能短路:第一个 source 的 guard 失败就立即返回 false,不查后面的。

检查顺序也不是固定的:C++ 的 GuardManager 给每个子节点记 fail_count,某次检查在非首个子节点上失败后,按失败次数把子节点重新排序(torch/csrc/dynamo/guards.cpp 的 check_accessors_nopybind),常失败的 guard 下次先查,让"不命中"的判断更快做出。

12.16 Symbolic shape:SymInt / SymFloat 的传递

第 6 章 §6.6.2 提过 SymInt 在 ATen layer 的存在。Dynamo 这层处理符号 shape 的具体方式:

trace 时如果 input.shape[0] 被标记为 dynamic(mark_dynamic,或 automatic dynamic 在重编译时自动标记),Dynamo 创建一个符号 SymInt(日志里形如 create_symbol s77 = 3 for L['x'].size()[0])—— 表示一个未知值。后续每个对这个 dim 的算子调用,输出的 shape 表达式自动变成符号:

input.shape = (s0, 768)
hidden = input @ weight             # weight: (768, 768);hidden.shape = (s0, 768) — s0 仍是符号
norm = hidden / hidden.norm(dim=1, keepdim=True)  # norm.shape = (s0, 768)

torch.fx.experimental.symbolic_shapes.ShapeEnv 维护所有符号变量的关系。每次新引入的符号 + 假设(如 s0 > 0、s0 % 8 == 0)都被记录。fx graph 生成时所有 shape 表达式是 SymInt,Inductor 拿到后能用它做 dynamic codegen(第 14 章 §14.21)。

如果 trace 中有"shape 决定控制流"的代码(如 if x.shape[0] > 100),Dynamo 会创建一个 shape guard:s0 > 100 必须为真才能复用此编译。下次调用 s0 < 100 → guard 失败 → 重 trace 走 else 分支、新 graph 缓存。

12.17 SideEffects:副作用的精确跟踪

torch/_dynamo/side_effects.py:89 的 SideEffects 类管理 trace 期间的"副作用列表":

  • 全局变量赋值(global_var = ...)
  • 类属性 mutation(self.cache = ...)
  • 容器修改(my_list.append(...))

(print 不在其列:v2.11 默认遇到 print 直接 graph break,本机实测报 Dynamo does not know how to trace builtin operator print;只有登记进 torch._dynamo.config.reorderable_logging_functions 的日志函数才会被挪到图外执行。)

trace 完成后这些副作用要在编译产物里正确 replay —— 否则用户原本期望的修改没生效。SideEffects 把它们记录下来,编译产物在合适时机调用。

例子:

@torch.compile
def f(x):
    x.foo = "bar"     # side effect: 设置属性
    return x + 1

Dynamo trace 出 fx graph 只是 x + 1,但额外记录"设置 x.foo = bar"这个 side effect。生成的字节码在 fx graph 调用之后回放这个 side effect(compile_subgraph 的 docstring 顺序:调用编译子图 → 应用副作用 → 还原栈与局部变量)。用户看到的语义与原始代码一致:本机实测该函数 0 次 graph break,调用后输入 tensor 上确实有 foo == "bar"。

这套机制让 Dynamo 能 trace 含副作用的代码,不仅仅纯函数。是 Dynamo 比 JAX trace 更强大的关键 —— JAX 要求纯函数,PyTorch 允许副作用。

12.18 Inlining 决策:哪些函数被 trace 进去

CALL_FUNCTION handler 决定调用一个函数时是 inline trace 还是 当不透明 op:

  • 用户写的 Python 函数 → inline(Dynamo 进入函数继续 trace)
  • PyTorch 内置算子(如 torch.add) → 加 fx node 不 inline
  • Dynamo 不认识的 C 扩展函数(如第三方 pybind 函数) → 触发 graph break(NumPy 是例外:np.array(x)、np.sin(x.numpy()) 这类调用会被翻译成 torch 算子,本机实测 0 次 graph break)
  • @torch._dynamo.allow_in_graph 装饰的函数(第 22 章 §22.10)→ 当不透明 op 加进 graph
  • @torch._dynamo.disable 装饰的函数 → graph break

inline trace 让用户代码的 helper 函数也被编译。但inline 太深会让 trace 时间爆炸(编译大型 model 时常见)。Dynamo 有 inline_inbuilt_nn_modules 等 flag 控制 inline 策略。

实战:Llama 训练里 transformer_block.forward 被 inline → attention.forward 被 inline → attention.QKV_projection 被 inline → ... 整个模型的 forward 被展开成一个超大 fx graph。这是为什么大模型首次编译明显更慢。

12.19 Dynamo × nn.Module 的协作

第 9 章讲了 nn.Module 的 _parameters / _modules / getattr 兜底。Dynamo trace 到 model.linear.weight 时怎么处理?

机制:

  1. trace 看到 LOAD_ATTR linear
  2. linear 不在 model.__dict__ 里,本该落到 nn.Module.__getattr__;Dynamo 并不真去调用它,而是自己模拟这段逻辑,到 _modules 里取出 Linear 子模块(默认模式走 torch/_dynamo/variables/user_defined.py 的 manually_trace_nn_module_getattr,关掉 inline_inbuilt_nn_modules 时走 torch/_dynamo/variables/nn_module.py 里 NNModuleVariable.var_getattr 按 __dict__ → _modules → _parameters → _buffers 的顺序查)
  3. Dynamo 把它包装成 nn.Module 对应的 VariableTracker(§12.9)
  4. 继续 trace LOAD_ATTR weight
  5. 按第 9 章 §9.4 __getattr__ 的同样规则,从 _parameters['weight'] 取出 → 返回 Tensor
  6. Dynamo 包装成 TensorVariable + 加 guard(确保下次调用时 weight 仍是同样 dtype/shape/device)

这种"对 nn.Module 特殊处理"让 Dynamo 能正确 trace 任意 PyTorch model。NNModuleVariable 内部实现了对 _modules / _parameters / _buffers 的特殊知识,访问时自动包装下层 tensor。

12.20 cache invalidation:什么时候 cache 失效

§12.11 讲了 cache 大小限制。具体什么操作让 cache 失效(强制重 trace)?

  • 替换 model 的参数(如 model.linear.weight = nn.Parameter(...))→ 只有新参数的 dtype / shape 等与 guard 记录的不同才重编译。默认 inline_inbuilt_nn_modules=True 下参数是 graph 的输入,本机实测换成同形状同 dtype 的新 Parameter 不触发重编译,换成 float64 则报 dtype mismatch. expected Float, actual Double 并重编译
  • 改 hyperparameter(如 dropout 概率所在的全局变量从 0.5 改成 0.3)→ 该值被装了 G['p'] == 0.5 这类 guard,涉及它的 graph 不再命中、重编译
  • 改编译配置(如 torch._inductor.config.max_autotune)→ Dynamo 的 guard 不检查它,本机实测(backend="eager")改完后已有条目照样命中;想让新配置生效要 torch._dynamo.reset() 后重新编译
  • 进程重启 / PyTorch 版本升级 → Dynamo 的缓存挂在进程内存里的 code object 上,本来就不跨进程,重启后都要重新 trace

torch._dynamo.reset() 手动清掉所有 cache(debug 时常用)。生产代码里频繁触发 cache 失效会让训练吞吐崩盘 —— 监控 TORCH_LOGS=recompiles 输出能发现哪段代码频繁重 trace。

12.21 Dynamo trace 的性能开销

编译一次的时间按数量级分摊在四段上(本机无 GPU,无法给出实测值;实际耗时可用 TORCH_LOGS=dynamo 或 torch._dynamo.utils.compile_times() 在自己机器上量):

阶段 量级
Dynamo trace + guards 通常是整条链路里最小的一段
AOTAutograd trace(含 decomposition / partition) 与 Dynamo trace 同量级或更大
Inductor lowering + scheduling 与上面同量级
Triton kernel 编译(每个 kernel 一次 LLVM/PTX 编译) 通常是最大头,且随 kernel 数线性增长

trace 本身不算长,但对每个未见过 shape 都要 trace 一次。生产代码里 cache 命中率决定整体性能 —— 命中时只多一次 C++ guard check,不命中时整条编译链路要重跑。

mark_dynamic 让一个 graph 处理多 shape,避免每个 shape 都 trace。fullgraph=True 强制不允许 graph break,逼用户写 trace-friendly 代码。这些 flag 是优化 cache 命中率的关键。

12.22 graph break 的常见场景与避免

实战导致 graph break 的代码模式("本机实测"指 CPU torch 2.11.0、Python 3.10、默认配置):

模式 例子 避免方法
依赖 tensor 值的 if if x.sum() > 0: ... 重构成 torch.where / torch.cond
dict 动态 key d[x.item()] = ... 避免 .item() / 用 fixed key
print print(x) 移到 trace 之外
用户调 .item() 把 tensor 转 Python 标量 避免在 trace 内调,或开 capture_scalar_outputs
不认识的 C 扩展调用 如 json.dumps 内部的 C 编码器 移到 trace 之外,或包成 custom op

反过来,一些常被误认为会 break 的写法在 v2.11 并不 break:局部 list 的 lst.append(x)、np.array(x) / x.numpy() 这类 NumPy 调用、普通 try/except,本机实测都是 0 次 graph break。

@torch.compile(fullgraph=True) 让以上场景直接报错而非默默 break,能强制用户写出 break-free 代码。生产代码追求性能时建议 fullgraph。

12.23 Dynamo × DDP / FSDP

第 17 章 §17.19 + 第 18 章 §18.19 讲过 DDP / FSDP 与 compile 的协作。具体到 Dynamo 这层:

  • DDP wrap 后的 model:VariableBuilder 把 DDP 包装成 UnspecializedNNModuleVariable,trace 进 inner module。allreduce 不进 graph,仍由 DDP 的 reducer 在反向 hook 里发起;为了让通信与反向计算重叠,默认 optimize_ddp=True("ddp_optimizer")会由 torch/_dynamo/backends/distributed.py 的 DDPOptimizer 按 DDP bucket 边界把 graph 切成几段分别编译
  • FSDP-2 wrap 后的 model:参数是 DTensor;但默认 skip_fsdp_hooks=True,FSDP-2 负责 all-gather / reshard 的 hook 被 torch._dynamo.disable() 包住、在图外以 eager 执行。只有设成 False(源码注释称为 Traceable FSDP2)时,collective 才会以 functional collectives(第 16 章 §16.12)的形式进 graph,给 Inductor 做 compute + comm 调度的机会

以上都是默认行为,用户不需要做特殊配置。

历史上 FSDP-1 trace 频繁 graph break(FlatParameter 的复杂 view 让 Dynamo 困惑),FSDP-2 重新设计后与 compile 配合顺畅得多(FSDP-2 自 v2.6 起从 torch.distributed.fsdp 公开导出,见专栏首页)。

12.24 ContinuationFrame:graph break 后的恢复执行

graph break 不是简单"停下",Dynamo 要让函数继续从 break 处往下跑。这通过 continuation frame 实现。

机制:

  1. trace 跑到 break 处停下,已 trace 的部分编成 graph_A
  2. Dynamo 生成新字节码:调 graph_A → 用 eager 跑 break 那一行 → 创建一个 continuation function 接管剩余字节码
  3. continuation function 是个新的函数,包含 break 之后的所有字节码
  4. CPython 调 continuation function 时再次进 Dynamo(PEP 523 hook)→ 可能再 trace 一段、又 break 一次、又生成新 continuation ...

最终一个含 N 个 graph break 的函数被切成 N+1 个编译产物 + N 段 eager 代码 + N 个 continuation。整套递归直到所有代码都被 trace 或 eager 跑过。

这套机制的实现在 torch/_dynamo/resume_execution.py:ContinueExecutionCache(:299)按 (code, 恢复偏移, 栈状态) 缓存生成出来的 resume code object,symbolic_convert.py 里的 create_call_resume_at 负责在 break 处插入"调用 resume 函数"的字节码,生成的函数名形如 torch_dynamo_resume_in_<原函数名>_at_<行号>(本机实测:§12.6 那种写法、函数名为 g、break 在源文件第 9 行时,生成的是 torch_dynamo_resume_in_g_at_9)。理解它让你看到"含 graph break 的 compiled function"性能不如纯 graph —— 多次 trace + 多次 dispatch 累积开销。这是 fullgraph=True 强制无 break 的工程理由。

12.25 Dynamo cache 失效的恢复路径

recompile limit 触发后,Dynamo 不会"删旧 cache",而是不再为这个 frame 编译新版本。逻辑(本机实测 dynamic=False 下依次喂长度 1…9 的输入):

缓存了 8 个条目 → 第 9 种 shape 不命中任何条目
→ 日志警告: "torch._dynamo hit config.recompile_limit (8)"
           (附 function 名与 last reason: tensor 'x' size mismatch at index 0. expected 8, actual 9)
→ 该 frame 的执行策略被置为 RUN_ONLY:已有条目照常检查、命中照跑编译产物;
  不命中的调用直接走 eager,不再 trace

所以"看到这条警告"意味着 该函数的新输入不再享受 compile 加速。torch._dynamo.config.recompile_limit = 32(增大上限)或 torch._dynamo.reset()(清掉 cache 重新编)是解法;若希望超限直接报错,可开 fail_on_recompile_limit_hit,fullgraph=True 时超限也会直接报错。

实战监控:长跑训练 TORCH_LOGS=recompiles 持续输出说明有问题,要么开 dynamic shape 让一个 graph 处理多 shape、要么排查为什么每次输入都看起来不同(如不必要的 dtype 变化)。

12.26 Dynamo 错误诊断 logs 完整列表

TORCH_LOGS= 支持多个标签同时开(逗号分隔):

标签 输出内容
dynamo Dynamo 整体流程(trace 开始 / 结束、cache 命中 / 失败)
recompiles 每次重 trace 的原因(哪个 guard 失败、shape 变了什么)
graph_breaks 每次 graph break 的位置 + 原因(哪条字节码不支持)
bytecode 每个被编译 frame 的原始字节码(ORIGINAL BYTECODE)与改写后字节码(MODIFIED BYTECODE)
trace_bytecode 符号执行过程:每条 opcode 执行前的虚拟栈(§12.28)
output_code Inductor 生成的代码(GPU 上含 Triton kernel,与 §14.31 联动)
aot_graphs AOTAutograd 输出的 graph
guards 每个 guard 的具体内容
+dynamo / all 前缀 + 把某个 log 提到 DEBUG 级;all 打开全部,info 量爆炸

调试 compile 问题的标准三件套:TORCH_LOGS=dynamo,graph_breaks,recompiles。看完输出大多能定位是哪个层的问题。

12.27 Dynamo 历史:从 LazyTensor 到 PEP 523

PyTorch 在到达 Dynamo 之前试过几条 trace 路径:

  • torch.jit.trace(v1.0):用 example input 跑一遍记录算子序列。问题:不能处理控制流(每次 example input 跑出来的可能不一样)
  • torch.jit.script(v1.0):用类型注解 + 自家 IR 静态 trace。问题:用户得改代码 / 加注解,迁移成本高
  • LazyTensor(实验性):每个算子调用先记录、用到时再触发计算。问题:性能差、调试难
  • TorchDynamo(v2.0):PEP 523 字节码拦截 + Just-In-Time trace + cache。当前赢家

理解这条历史让你看到 PyTorch 团队在 trace 路径上的多次尝试。Dynamo 是几年探索后落定的方案 —— 既不要求用户改代码(vs torchscript)、又能处理控制流(vs torch.jit.trace)、又有合理性能(vs LazyTensor)。

12.28 一个具体 trace 过程的逐字节码追踪

最深入的方式:开 TORCH_LOGS=trace_bytecode 看 trace 一个简单函数(TORCH_LOGS=bytecode 打的是改写前后的整段字节码,不是逐条 trace 过程):

@torch.compile
def f(x, y):
    z = x + y
    return z * 2

本机 CPU torch 2.11.0、Python 3.10、backend="eager" 的实际输出(去掉时间戳前缀;每行是"opcode、参数、执行这条指令之前的虚拟栈"):

[0/0] [__trace_bytecode] TRACE LOAD_FAST x []
[0/0] [__trace_bytecode] TRACE LOAD_FAST y [LazyVariableTracker(unrealized: <class 'torch.Tensor'>)]
[0/0] [__trace_bytecode] TRACE BINARY_ADD None [LazyVariableTracker(unrealized: <class 'torch.Tensor'>), LazyVariableTracker(unrealized: <class 'torch.Tensor'>)]
[0/0] [__trace_bytecode] TRACE STORE_FAST z [TensorVariable()]
[0/0] [__trace_bytecode] TRACE LOAD_FAST z []
[0/0] [__trace_bytecode] TRACE LOAD_CONST 2 [TensorVariable()]
[0/0] [__trace_bytecode] TRACE BINARY_MULTIPLY None [TensorVariable(), ConstantVariable(int: 2)]
[0/0] [__trace_bytecode] TRACE RETURN_VALUE None [TensorVariable()]

几个细节:输入先以 LazyVariableTracker 入栈,真正用到(BINARY_ADD)时才经 VariableBuilder 包装成 TensorVariable;BINARY_ADD 执行后栈顶变成代表 add 节点的 TensorVariable;最终 OutputGraph 含 add + mul 两个节点。Python 3.11+ 上同一函数的 opcode 会变成 RESUME、BINARY_OP 等。开 trace_bytecode log 学一个函数的 trace 过程,是最直观理解 InstructionTranslator 工作方式的方法。

12.29 v2.x Dynamo 的演进

时间线:

  • 2022-10:TorchDynamo 从独立仓库 pytorch/torchdynamo 合入主仓,落到 torch/_dynamo/(commit c7c09722ad,PR #86461 "Move TorchDynamo into PyTorch core";首个含它的正式发布是 v2.0.0,此前没有 release 带 torch._dynamo)
  • v2.0.0 (2023-03-09):torch.compile 公开发布,Dynamo 成为默认 trace 方式
  • v2.1.0 (2023-09-21):torch/_dynamo/compiled_autograd.py 首次随 release 发布;torch.export 路径成型
  • v2.2.0 (2024-01-10):dynamic shape 支持大幅完善
  • v2.4.0 (2024-07-09):inline_inbuilt_nn_modules 引入(当时默认关闭,v2.5.0 起开源版默认打开),与 FSDP-2 / DTensor / export 深度集成
  • v2.11.0 (2026-03-20):本专栏基准版本,稳定 + 性能持续优化

理解这条演进让你知道哪些功能是 v2.x 哪个版本引入的。Dynamo 仍在快速演进中。

12.30 ConvertFrame:把帧转成 GuardedCode 的总调度

torch/_dynamo/convert_frame.py 是 Dynamo 的总调度入口(catch_errors_wrapper 在 :2322)。dynamo_custom_eval_frame_shim 决定要 trace 时,最终调到 _compile():

graph TB
    Shim[dynamo_custom_eval_frame_shim<br/>C 层] --> CF[convert_frame.py<br/>_compile]
    CF --> IT[InstructionTranslator<br/>字节码 trace]
    IT --> OG[OutputGraph<br/>fx graph + side effects]
    OG --> BK[backend<br/>aot_autograd / inductor]
    BK --> CC[CompiledFn<br/>编译产物]
    CF --> GG[GuardManager<br/>组装 guards]
    GG --> GC[GuardedCode<br/>guards + bytecode + CompiledFn]
    GC --> Cache[CacheEntry<br/>挂到 code object 的 extra slot]
    style CF fill:#fef3c7,stroke:#f59e0b
    style GC fill:#dbeafe,stroke:#3b82f6

关键步骤(精简版):

  1. 入口校验:跳过库代码、被 @disable 装饰的函数、recursion 太深的 frame
  2. InstructionTranslator 实例化:把 frame 的 co_code、f_locals、f_globals 包成 trace 上下文
  3. 跑 trace 主循环:run() 里 while self.step() 逐条执行,直到 RETURN_VALUE 或 graph break
  4. OutputGraph.compile_subgraph:把累积的 fx node 整理成可调用的 fx Graph
  5. 调 backend:默认 backend inductor 调 torch._inductor.compile_fx.compile_fx,后者再经 aot_autograd(torch/_dynamo/backends/common.py:134)挂上正反向编译器
  6. 重写 frame 的 bytecode:原始字节码替换为"call CompiledFn → 回放副作用 → return"(guard 检查不在字节码里,由 C 层在执行前完成)
  7. 包成 GuardedCode:guards + 新 bytecode + 编译产物绑在一起,存进 cache

这个 7 步流程是 Dynamo 全部价值的实现。卡在哪一步可以从 TORCH_LOGS=dynamo 的输出看出来:它打印 Step 1: torchdynamo start tracing / done tracing、Step 2: calling compiler function / done compiler function 等阶段标记;各阶段耗时可用 torch._dynamo.utils.compile_times() 查看。debug compile 慢时这是第一手信息。

12.31 OutputGraph:fx graph + 副作用打包器

torch/_dynamo/output_graph.py 的 OutputGraph 类负责"把 trace 期间发生的所有事打包成可消费的产物"。它管理:

  • fx Graph 节点:每条记录的算子调用
  • graphargs:trace 时引用到的 input tensor / global / closure 变量
  • side effects:§12.17 提到的全局赋值、属性 mutation 等
  • guards:trace 期间累积的所有假设
  • example value:每个 fx node 的形状/dtype(给 Inductor 后续做 shape inference)

最关键的方法 compile_subgraph:trace 完成后把这堆东西整理成"输入 → 算子调用 → 输出"的标准 fx Graph,附带一段"side effects replay 字节码"。这一步是 trace 阶段到 backend 阶段的接口。

为什么不直接把 trace 时的 fx Graph 原样交给 backend?因为 trace 期间只是一边符号执行一边往图里追加节点(节点本就按执行顺序排列),图的边界还没定:哪些栈上值和局部变量需要作为图输出、哪些 graph 输入其实没被用到,都要到这一步才知道。compile_subgraph 据此确定输出、删掉未用到的输入(remove_unused_graphargs),再生成"调用编译产物 → 回放副作用 → 还原栈与局部变量"的字节码。这个职责切分让 Dynamo 与 backend 解耦 —— Inductor 不需要知道 trace 时怎么"模拟 CPython",只看到最终干净 graph。

12.32 torch.export:Dynamo 的非编译用法

除了 @torch.compile,Dynamo 还服务另一条路径:torch.export(v2.1 起提供)。区别:

维度 torch.compile torch.export
目的 runtime 加速 导出可序列化 graph(部署到 mobile / 推理引擎)
graph break 允许(退回 eager) 不允许(直接报错)
guards 失败时重 trace 保存为 ExportedProgram 的输入约束
输出 callable function ExportedProgram(含 graph + signature + 约束)
落盘 不直接落盘(Inductor 有独立的编译缓存目录) torch.export.save 存成 .pt2

要注意 v2.11 的 torch.export.export 默认 strict=False:此时它不经过 Dynamo,而是在 Python 运行时里直接执行程序来抓图(docstring:"trace the program through Python runtime");只有传 strict=True 才用 TorchDynamo trace,以 fullgraph 方式抓整图、不允许 graph break。两种模式产出的 IR 与序列化格式相同,最后都 wrap 成 ExportedProgram,可以保存到 .pt2 文件、给 ExecuTorch / TensorRT / ONNX 转换器消费。

这条路径是"PyTorch 模型部署到非 Python 环境"的官方推荐方式(替代 v1 时代的 torchscript)。strict 模式下,Dynamo 既是 runtime 编译器(@torch.compile)又是 ahead-of-time export 工具,底层共用同一套 trace 逻辑。

12.33 自定义 backend:register_backend 接口

Dynamo 的 backend 是可插拔的。torch._dynamo.register_backend 允许第三方注册自家编译器:

from torch._dynamo import register_backend

@register_backend
def my_backend(gm: torch.fx.GraphModule, example_inputs):
    # gm: trace 出的 fx GraphModule
    # example_inputs: 第一次调用时的输入(用来推 shape / dtype)
    print("got graph:", gm.graph)
    return gm.forward    # 必须返回一个 callable

@torch.compile(backend="my_backend")
def f(x): return x + 1

PyTorch 自带的 backend:

backend 用途
inductor(默认) 全栈编译:GPU 上生成 Triton kernel,CPU 上生成 C++ 代码
aot_eager 只跑 AOTAutograd、不跑 Inductor lowering,graph 用 eager 跑
aot_eager_decomp_partition 加上 decomposition 与 partition
cudagraphs 经 AOTAutograd 后直接 wrap 成 CUDA Graph
eager 完全不编译,只 trace 出 graph 验证 trace 正确性
tvm、openxla 等 仓库内置的注册入口,实际编译依赖对应的第三方库(v2.11 的 torch/_dynamo/backends/onnxrt.py、torch/_dynamo/backends/tensorrt.py 里的注册代码整段被注释掉,本机 list_backends() 里已没有 onnxrt / tensorrt)

实战:硬件厂商可以在自家插件包里用同样的方式注册 backend,让用户 torch.compile(backend="<注册名>") 就能跑加速版。这套机制让 Dynamo 既服务通用 GPU(Inductor),又支持长尾硬件平台 —— trace 与编译解耦的工程价值。

调试 trace 行为最方便的方法:用 aot_eager backend,跳过 Inductor,让 trace 错误第一时间暴露。生产追求性能用 inductor。

12.34 VariableBuilder:第一次见到对象时怎么包装

torch/_dynamo/variables/builder.py 的 VariableBuilder 负责"把 trace 看到的 Python 对象转成 VariableTracker"。这是 Dynamo 处理"未知输入"的入口。

第一次看到一个对象(如 model)时的逻辑:

graph TB
    Obj[Python 对象] --> T{type 是什么?}
    T -->|torch.Tensor| Tv[TensorVariable<br/>记录 dtype/shape, 加 TENSOR_MATCH guard]
    T -->|nn.Module| Mv[NNModuleVariable<br/>把 _parameters/_modules 也包装]
    T -->|int/float/str| Cv[ConstantVariable<br/>加 EQUALS_MATCH guard]
    T -->|list/tuple| Lv[ListVariable<br/>递归包装每个元素]
    T -->|dict| Dv[ConstDictVariable<br/>递归包装 key/value]
    T -->|callable| Fv[UserFunctionVariable<br/>记录 closure / globals]
    T -->|其他 / 不认识的对象| Br[UserDefinedObjectVariable 等<br/>遇到不支持的操作时 graph break]
    style Br fill:#fee2e2,stroke:#ef4444

每种 VariableTracker 子类有自己的"var_getattr / call_method / call_function"实现,决定后续 trace 时怎么处理。VariableBuilder 用一个上千行的 _wrap 方法(torch/_dynamo/variables/builder.py:718 起)dispatch:先查类型注册表、再做兜底匹配,不认识的对象包成 UserDefinedObjectVariable,对它做不支持的操作时才触发 graph break。

为什么这么细?Dynamo 必须在 trace 期间"假装执行"用户代码,但又不能真去 call C 扩展(如 numpy)。VariableTracker 是这个"假装"的载体 —— 包装后的对象响应所有访问都是 Dynamo 的可控行为。理解 VariableBuilder 让你看 variables/ 目录下两百多个 VariableTracker 子类不再迷失。

12.35 几个关键 config flag

torch._dynamo.config 暴露了大量调优开关。生产用得最多的几个:

flag 默认 作用
cache_size_limit 8 单 frame 最多缓存条目数,超了新输入就 fallback eager(v2.11 中是 recompile_limit 的 alias)
accumulated_cache_size_limit 256 单个 code object 的编译次数总上限(v2.11 中是 accumulated_recompile_limit 的 alias)
recompile_limit 8 单 frame 最多 recompile 次数
suppress_errors False(可由 TORCHDYNAMO_SUPPRESS_ERRORS 环境变量置位) 是否吞掉 Dynamo 错误转 fallback eager
verbose False(由 TORCHDYNAMO_VERBOSE 环境变量置位) 打印详细 trace 流程
dynamic_shapes True 遗留开关,源码注释为 "legacy config, does nothing now!",改它不起作用
assume_static_by_default True 只给 mark_dynamic 标记过的维度分配符号,其余维度先按 static 处理
automatic_dynamic_shapes True(可由 TORCH_DYNAMO_AUTOMATIC_DYNAMIC_SHAPES 环境变量关闭) 第一次按全 static 编译;某维度因 shape 变化导致 guard 失败时,重编译时把它标成 dynamic
inline_inbuilt_nn_modules True(受 justknob pytorch/compiler:inline_inbuilt_nn_modules 控制) inline trace 进 nn.Module 的 forward
capture_scalar_outputs False tensor.item() 是否当 unbacked SymInt 而非 graph break

suppress_errors 在生产里争议大:开了让代码总能跑(不会因 trace bug 崩溃),但隐藏了优化机会。线下调试推荐关,线上推荐开。

assume_static_by_default + automatic_dynamic_shapes 是 v2.x 的默认策略:避免一上来就按 dynamic shape 编译(dynamic shape 下 codegen 要处理符号表达式,编译与运行通常都比纯 static 版本更贵)。第一次 batch size 假定 static、第二次发现 batch size 变了再转 dynamic 重 trace 一次 —— 之后 batch size 再变也不用重编译(§12.5 的实测:5 种长度只编译 2 次)。

12.36 Compiled Autograd:让反向也被 Dynamo trace

Compiled Autograd(torch/_dynamo/compiled_autograd.py,首次随 v2.1.0 发布,此后逐版本完善)让 Dynamo 不只 trace forward,反向计算也能被它接管。

默认 @torch.compile 的反向是怎么来的,以及它的局限:

forward 被 compile → Dynamo 抓 fx graph → AOTAutograd 在编译期同时推出反向图(第 13 章)→ Inductor 分别编译
backward 运行时    → 编译好的反向图被包成一个 autograd 节点,仍由 autograd engine 调度(Engine 调度见第 8 章 §8.7)

也就是说反向本来就会被编译,但只覆盖 Dynamo 抓到的那些 forward 图:每段图的反向各自独立编译,graph break 越多、反向被切得越碎;图外的 eager 部分、tensor hook、AccumulateGrad 等仍由 engine 逐节点执行,段与段之间没机会做 fusion。

Compiled Autograd 的做法:

graph LR
    F[forward 执行] --> G[autograd 记录 grad_fn 链]
    G --> CA[Compiled Autograd<br/>把整条 grad_fn 链转成 fx graph]
    CA --> Dy[Dynamo 第二次 trace<br/>把 fx graph 当成函数 trace]
    Dy --> Ind[Inductor<br/>编译反向 graph]
    style CA fill:#fef3c7,stroke:#f59e0b
    style Dy fill:#dbeafe,stroke:#3b82f6

具体:autograd engine 调用每个 grad_fn 时,不是真去执行,而是把它的元信息累积到 fx graph。整个 backward 链 trace 完后再交给 Dynamo 二次处理(装上 guards)、再 send Inductor。

启用:

import torch._dynamo
torch._dynamo.config.compiled_autograd = True

收益来自把整条 grad_fn 链交给 Inductor 做 fusion(具体幅度取决于模型与 kernel 结构,需要在自己的机器上量)。Compiled Autograd 是"第二条 trace 路径" —— 不是替代 forward 编译,而是补充。理解它让你看到 PyTorch 编译战略的完整版图:forward 通过字节码 trace;backward 默认由 AOTAutograd 从 forward 图推出,开启 Compiled Autograd 后改为在运行时从 autograd graph 抓取;两条路径都流向 Inductor。

12.37 inline_inbuilt_nn_modules 的权衡

inline_inbuilt_nn_modules 在 v2.4 引入(当时默认 False),v2.5 起开源版默认 True。名字里的 "inbuilt" 指 torch.nn 自带的模块(nn.Linear、nn.Conv2d 等):打开后,这些模块也和用户自定义模块一样被 inline trace 进去,模块的参数、buffer 作为 graph 输入传入,而不是作为常量属性烧进图里。

本机 CPU torch 2.11.0 实测:一个 Linear → relu → Linear 的小模块建 3 个实例、分别 torch.compile 后各调用一次——

  • 开(默认):3 个实例共用 1 张图;图里是 torch._C._nn.linear(x, weight, bias),weight / bias 是 forward 的参数
  • 关:编出 3 张图(每个实例一张,靠 ID_MATCH 之类的 guard 区分实例);两个 Linear 在图里是 self.L__s___l1(x) 这样的 call_module 节点

两种模式都是一张完整的图,不存在"关掉就切成三段"的问题;差别在于:

  • 开的好处:同一段代码被多个模块实例(比如 Transformer 的各层)复用时,不必每个实例重编译一次,更不容易撞上 recompile_limit;Linear 内部的计算直接展开进图,backend 看到的是普通算子
  • 开的代价:参数和 buffer 都变成图输入,图的输入列表随参数张量个数增长(上例是 4 个参数张量加 x 共 5 个输入,关掉时只有 x 1 个)

torch._dynamo.config.inline_inbuilt_nn_modules = False 显式关(排查"开了之后才出现的问题"时可以用来对比)。在开源版里它的默认值只变过一次:v2.4 默认 False,v2.5 起默认 True;v2.7 起改用带 justknob(pytorch/compiler:inline_inbuilt_nn_modules)的 Config 定义,默认仍为 True。

12.38 Stack Reconstruction:graph break 时怎么还原 Python 栈

graph break 在字节码任意位置发生,但 Dynamo 不能"丢掉栈状态" —— eager fallback 的代码需要看到与 break 时一致的 local 变量、操作数栈、异常处理 frame。torch/_dynamo/codegen.py 的 stack reconstruction 干这个活。

机制:trace 期间 Dynamo 记录每个时刻的"虚拟栈"(VariableTracker 列表)。break 触发时,需要把虚拟栈重建到真实 Python 栈上 —— 通过生成一段字节码,把每个 VariableTracker 对应的真实对象 LOAD_FAST / LOAD_GLOBAL 推上去。

# 假设 trace 期间虚拟栈是 [TensorVariable(x), ConstantVariable(2)]
# 现在要 graph break,生成的恢复字节码:
LOAD_FAST    x         # push 真实 tensor x
LOAD_CONST   2         # push 常数 2
# 现在真实栈与虚拟栈一致,CPython 默认 eval 接管后续字节码

复杂场景:栈上的对象是中间结果(如 LOAD_FAST x; CALL torch.relu 后栈顶是 relu(x) 还没存到 local),需要先把它存到 graph 输出里、然后 LOAD 出来。这套生成由 OutputGraph + codegen 协作完成。

理解 stack reconstruction 让你看明白"graph break 不只是停下",而是精心编排的状态迁移。出错时常见症状是"break 后变量 undefined"或"操作数栈高度不对" —— 这往往是 codegen 这层的 bug,从 TORCH_LOGS=bytecode 可看到生成的 break 字节码与预期不符。

12.39 Dynamo 自身异常 vs 用户代码异常

trace 过程中可能抛两类异常,要区分对待:

  • Dynamo 自身的"不支持"(Unsupported):表明 trace 不能处理某段代码。默认转成 graph break;fullgraph=True 时直接 raise
  • 用户代码本身的错误(如矩阵乘 shape 不匹配):trace 时 Dynamo 用 fake tensor 跑真实算子的 shape 推导,错误在这时暴露,需要抛给用户

convert_frame.py 的 try/except 框架做这个分发:捕获 Unsupported 后看 fullgraph 决定是 break 还是 raise;用户代码在 fake tensor 上出错时包成 TorchRuntimeError 抛出(本机实测 x @ w 喂 [3, 4] 和 [5, 6]:torch._dynamo.exc.TorchRuntimeError: RuntimeError when making fake tensor call,后面附原始的 a and b must have same reduction dim 信息);BackendCompilerFailed 则专指 backend 编译函数自己抛出的异常(错误信息以 backend='...' raised: 开头)。

特殊案例:用户代码里的 try: ... except: ...。v2.11 的 Dynamo 能 trace 普通的异常处理结构:Python 3.10 及以前是 SETUP_FINALLY 等块栈指令,3.11 起改为"零开销异常"的异常表加 PUSH_EXC_INFO 等指令,symbolic_convert.py 对两套都有 handler。本机实测(Python 3.10)try: y = x + 1 / except RuntimeError: ... 以及在 try 里 raise ValueError 再 except ValueError 的写法,都是 0 次 graph break。真正要当心的是要看到实际数值才知道会不会抛异常的写法:trace 时算子只在 fake tensor 上跑 shape 推导,不会触发这类运行时错误。这类逻辑建议把 try/except 移到 compile 包装外面,内部代码改成 torch.where 这类纯算子判断。

12.40 Source 类:guards 怎么"指向"被守护的值

每个 guard 必须知道"我守护的是哪个 Python 表达式"。torch/_dynamo/source.py 的 Source 类层次表达这个:

Source 子类 表示
LocalSource("x") x(local 变量)
GlobalSource("model") model(global)
AttrSource(LocalSource("x"), "shape") x.shape
GetItemSource(LocalSource("lst"), 0) lst[0]
DictGetItemSource(LocalSource("d"), "key") d["key"]
NNModuleSource(...) 包在另一个 Source 外面,标明"这是个 nn.Module"(如 model.layer1),与 AttrSource 递归组合出 model.layer1.weight

每个 VariableTracker 持有一个 Source,guards 编译成 C++ 时把 Source 转成对应的访问器(如 AttrSource(x, "shape") 对应 GetAttrGuardAccessor,内部调 PyObject_GetAttr(x, "shape"),见 torch/csrc/dynamo/guards.cpp)。这种"用对象树表达 Python 表达式"让 guards 检查是纯 C 代码 —— 不进 Python interpreter 就能验证。

理解 Source 让你看到 guards 检查为什么能这么便宜 —— 整个检查链都在 C 层执行,不用回到 Python 解释器。这是 v2.x guard 性能大幅改善的关键工程改造。Source 还服务另一个目的:debug 输出时把"哪个 guard 失败"翻译回可读 Python 表达式,让 TORCH_LOGS=recompiles 的输出对人类友好(如本机实测打印的 tensor 'x' size mismatch at index 0. expected 2, actual 3,而不是无法定位的 byte offset)。

12.41 几条工程经验

实战 Dynamo:

1. TORCH_LOGS=dynamo,graph_breaks 是诊断 compile 问题的第一武器:能看到每次 trace、每个 graph break 的原因

2. 第一次 compile 慢是正常的:模型越大越慢。线上服务前要 warm up(先跑几个 batch 让 cache 命中)

3. 避免在 compiled 函数里写复杂 Python 逻辑:能放外面就放外面。依赖 tensor 值的 if、.item()、print 等越多,graph break 越多;依赖 Python 值的 for / if 会被展开或特化,值一变就重编译

4. dynamic shape 用 mark_dynamic:torch._dynamo.mark_dynamic(x, 0) 告诉 Dynamo "x 的 dim 0 是符号",避免每个 batch size 重新 trace

5. 用 fullgraph=True 强制不要 graph break:@torch.compile(fullgraph=True) 时 Dynamo 遇到不能 trace 的代码直接报错而不是退回 eager。这能强制你写 trace-friendly 的代码

6. cache 体积在长跑训练里要监控:每多一个缓存条目就多一份编译产物常驻内存,重编译频繁时会越积越多。torch._dynamo.reset() 清掉所有 cache

7. 跨进程缓存:Inductor 的编译产物会落盘(目录可用 TORCHINDUCTOR_CACHE_DIR 指定),下次进程启动时命中可跳过 Inductor 编译;但 Dynamo 的 trace 仍要重做(它的缓存只在进程内存里)。注意 mode='reduce-overhead' 是开 CUDA Graphs 降低 launch 开销,与缓存无关

12.42 跨专栏关联

  • 《Rust 编译器与运行时揭秘》专栏第 7 章 trait 分发:Rust trait 在编译期决定,PyTorch Dynamo 在运行期决定。前者零开销但不灵活,后者有 trace 开销但能处理动态形状
  • 《vLLM 推理内核深度解析》专栏第 8 章 模型 runner:vLLM 也用 torch.compile 加速 forward,理解 Dynamo 的 graph break 机制能帮你调出最高吞吐

12.43 设计启示

Dynamo 的几个核心思想可迁移:

第一:编译是可选的、按 frame 粒度的:不是整个程序编译,而是对实际被调用、且与 torch 相关的 frame 编译。让 compile 不破坏其他代码

第二:trace 时假设 + 运行时校验:guards 模式让"乐观编译"能在动态场景安全工作。这套思想在 V8、HotSpot、PyPy 都用

第三:graph break 而非 hard fail:不能 trace 的代码不报错,让程序仍然能跑,只是少一些优化。可用性远大于性能损失

第四:backend 可插拔:trace 与编译解耦,让多家硬件厂商能在 trace 之上接自家编译器

下一章拆 AOTAutograd —— Dynamo 拿到的只是 forward graph,AOTAutograd 把它配上反向、function化、partition 成正反向子图,再送给 Inductor。