PyTorch 训练框架内核深度解析
第6章 ATen 算子库与代码生成
第6章 ATen 算子库与代码生成
“Code generation is what lets PyTorch ship 3000 operators with consistent quality without 3000 humans writing repetitive boilerplate.”
—— Edward Yang, “PyTorch dev podcast: code generation”
本章要点
native_functions.yaml(16172 行)+derivatives.yaml(3253 行)= 整个 ATen 算子库的”宪法”:声明 schema、后端 dispatch、autograd 规则torchgen/(Python,约 25000 行)是 PyTorch 的代码生成器:编译时读这两份 YAML,吐出数十万行 C++ 与 Python 包装代码structured: True+structured_delegate是 ATen 算子的现代统一框架:把 broadcast / dtype 提升 / output 分配 / 调度桩等共性逻辑代码生成出来- ufunc 内层循环让一个
return self + alpha * other自动展开成 fp32/fp16/bf16/int 等 N 套 CPU SIMD + CUDA kernel - AT_DISPATCH_ 宏家族* 是手写算子时把”对所有 dtype 展开 if-else”的工具,与代码生成器配合让”运行期类型选择”零开销
- 代码生成的真正价值不只是省手写时间,更在于保证 3000+ 算子的注册口径完全一致(Python 包装、autograd 钩子、dispatch 表、序列化、JIT trace 等十几条衍生路径都对得上)
6.1 问题:3000 个算子背后的人力黑洞
第 5 章我们看到 PyTorch 注册算子要做的事:
- 写 schema(输入输出类型)
- 写每个 backend 的实现(CPU / CUDA / MPS / XPU / …)
- 写 autograd 反向规则
- 注册到 dispatcher
- 生成 Python 包装(让
torch.add能调用) - 生成 C++ 包装(让
at::add能调用) - 生成
Tensor::add方法 - 生成
aten::addschema 字符串(给 JIT / TorchScript / FX) - 生成 functionalization 规则
- 生成 vmap 规则
- 生成 PyTorch RPC 序列化
- ……
每个算子都要做以上 10+ 件事。PyTorch 注册了 3000+ 个算子。如果靠手写,工作量是 3 万次重复劳动 —— 每次新增一个 dispatch key(如某国产芯片)都要改 3000 个文件。
更糟糕的是,每条衍生路径之间必须保持一致:
- Python 端
torch.add(a, b, alpha=2)与 C++ 端at::add(a, b, 2)必须接收相同参数顺序 loss.backward()触发的反向必须与前向接收的张量类型/形状对得上torch.jit.trace序列化的 IR 节点必须包含完整 schema 信息- functionalize 规则必须知道哪些参数是 inplace 的
人工维护这种一致性几乎不可能。任何一处疏忽都会让用户看到”前向能跑反向报错""Python 能调 C++ 不能调”等怪异 bug。这就是为什么 PyTorch 必须用代码生成 —— 不只是省人力,更是用机器生成保证一致性。
PyTorch 团队的解法是 代码生成:把所有重复部分用一份 YAML 声明,让 Python 脚本在编译时生成 C++ 代码。这一章拆这个体系。
flowchart TB
Y1[native_functions.yaml<br/>16172 行]
Y2[derivatives.yaml<br/>3253 行]
Y3[tags.yaml]
Y1 --> TG[torchgen<br/>Python 生成器<br/>约 25000 行]
Y2 --> TG
Y3 --> TG
TG --> O1["build/aten/src/ATen/Operators_*.cpp<br/>(数十个文件,几十万行)"]
TG --> O2[RegisterCPU.cpp / RegisterCUDA.cpp / ...]
TG --> O3[Functions.h / NativeFunctions.h]
TG --> O4[VariableType_*.cpp<br/>autograd 包装]
TG --> O5[python_torch_functions_*.cpp<br/>Python 端绑定]
TG --> O6[python_variable_methods.cpp<br/>Tensor 方法]
TG --> O7[FunctionalInverses.cpp<br/>functionalization]
style TG fill:#fef3c7,stroke:#f59e0b,stroke-width:3px
style Y1 fill:#dbeafe,stroke:#3b82f6
style Y2 fill:#dbeafe,stroke:#3b82f6
6.2 native_functions.yaml 的语法
打开 aten/src/ATen/native/native_functions.yaml,第一感觉是 庞大 —— 16172 行,全是 YAML 条目。每个条目对应一个 ATen 算子。让我们看一个真实的例子(mm —— 矩阵乘):
- func: mm(Tensor self, Tensor mat2) -> Tensor
structured_delegate: mm.out
variants: function, method
dispatch:
SparseCPU, SparseCUDA, SparseMPS: _sparse_mm
SparseCsrCPU, SparseCsrCUDA, SparseCsrMeta: _sparse_csr_mm
tags: core
- func: mm.out(Tensor self, Tensor mat2, *, Tensor(a!) out) -> Tensor(a!)
structured: True
dispatch:
CPU: mm_out_cpu
CUDA: mm_out_cuda
MTIA: mm_out_mtia
MPS: mm_out_mps
XPU: mm_out_xpu
SparseCPU, SparseCUDA, SparseMPS: _sparse_mm_out
——aten/src/ATen/native/native_functions.yaml 中 mm 与 mm.out 的真实条目
每个字段的语义:
func:算子签名。仿 TorchScript IR 类型语法structured_delegate:把实现委托给另一个算子(典型是out=版本)structured: True:这是结构化算子,使用统一的 broadcast / dtype 提升 / output 分配框架variants: function, method:生成at::mm(a, b)(function)和Tensor::mm(b)(method)两种 C++ APIdispatch:哪个 dispatch key 调用哪个 C++ 实现tags: core:分类标签,用于子集筛选(如 mobile build 只保留 core 标签)
6.2.1 schema 类型语法
func 字段里的类型是仿 TorchScript IR 的语法,而不是 C++ 语法:
| YAML | C++ | 含义 |
|---|---|---|
Tensor | const Tensor& | 张量(不可空) |
Tensor? | const std::optional<Tensor>& | 可选张量 |
Tensor(a!) | Tensor& | 可变引用,参数 a 可以被修改 |
Tensor[] | TensorList | 张量列表 |
int | int64_t | 整数 |
int[] | IntArrayRef | 整数数组 |
Scalar | const Scalar& | 标量(任意 dtype) |
bool | bool | 布尔 |
* | (kwargs 分隔符) | * 之后的参数必须用关键字传 |
Tensor(a!) 是 ATen 特有的”别名”标记,告诉系统这是 inplace 修改。它在 functionalization、autograd 等多处影响代码生成。
6.2.2 dispatch 表的写法
dispatch 字段的左边是 dispatch key 列表,右边是 C++ 函数名。多个 key 可以共用一个实现:
dispatch:
CPU, CUDA, MPS: my_kernel # 三个 key 共用 my_kernel
Sparse*: my_sparse_kernel # 通配符
如果不写 dispatch 字段,算子默认走 CompositeImplicitAutograd —— 也就是用其他算子组合实现的算子,autograd 自动正确(第 5 章 §5.2.2 提过)。这种”组合算子”通常代码非常短,例如 torch.where 内部就是 cond * a + (1 - cond) * b。
6.2.3 schema 中的”出参”与”别名规则”
PyTorch schema 有一套精细的”别名规则”用于功能化(functionalization)。看几个例子:
- func: foo(Tensor self) -> Tensor
# foo 接收 self,返回新张量。无别名
- func: foo_(Tensor(a!) self) -> Tensor(a!)
# foo_ 是 inplace 版本:self 被修改,返回的也是 self(同一个内存)
- func: foo.out(Tensor self, *, Tensor(a!) out) -> Tensor(a!)
# foo.out 是 out= 版本:把结果写到 out,返回 out
- func: as_strided(Tensor(a) self, int[] size, int[] stride) -> Tensor(a)
# 创建 view: 返回的张量与 self 共享内存(无 ! 表示只读共享)
(a) 是”只读视图”标记(共享 storage 但不能 inplace 改),(a!) 是”可变别名”(共享并可能修改)。这套语法让 functionalization pass(第 13 章 AOTAutograd)能在编译期把所有 inplace 改写成纯函数式 —— 不需要运行期分析就能知道哪些张量参数是潜在的”修改源”。
理解这套语法是给 PyTorch 提交”自定义算子 PR”的基本功。新手最常见错误就是把 out 参数标成 Tensor 而不是 Tensor(a!),导致 inplace 路径在 functionalize 时被错误优化。
6.3 derivatives.yaml:反向规则的声明
打开 tools/autograd/derivatives.yaml,这是一份纯数学声明,长这样:
- name: mm(Tensor self, Tensor mat2) -> Tensor
self: mm_mat1_backward(grad, mat2, self.sym_sizes(), self.sym_strides(), self.layout(), 1)
mat2: mm_mat2_backward(grad, self, mat2.sym_sizes(), mat2.sym_strides(), mat2.layout(), 1)
result: at::mm(self_t, mat2_p) + at::mm(self_p, mat2_t)
——tools/autograd/derivatives.yaml 中 mm 的真实条目
字段含义:
name:与native_functions.yaml中的 schema 完全一致self、mat2:每个输入参数的反向梯度表达式(被 torchgen 翻成 C++ 代码)result:前向 jacobian-vector product 表达式,用于 forward-mode AD(forward grad)
注意几个特殊符号:
| 符号 | 含义 |
|---|---|
grad | 输出梯度(backward 输入) |
self_t / mat2_t | 输入参数的”切线”(forward AD) |
self_p / mat2_p | 输入参数的”基础值”(forward AD) |
result_ | 前向输出(保存以备反向用) |
代码生成器把这些表达式翻成 C++ 类(如 MmBackward0),第 7 章 autograd 章会展开。
注意 result 字段对 forward grad 的特殊语义:它返回的是输出的切线 (output tangent),输入是 *_p(基础值)和 *_t(输入切线)。如果你看不懂这套数学,没关系 —— 99% 的 PyTorch 算子作者只写 backward 字段(self、mat2 等),forward grad 由 PyTorch 团队的几个核心维护者集中处理。
6.3.1 反向规则与前向函数的对应关系
每个 derivatives.yaml 条目都必须对应一个 native_functions.yaml 里存在的算子。如果你写了一个新算子但没写反向规则,默认这个算子不能反向(dispatcher 命中 Autograd key 时报错”…does not have a derivative”)。
如果某算子是 composite(用其他可微算子组合实现),可以完全不写 derivatives 条目 —— autograd 通过子算子的反向自动推导。
如果某算子明确”不需要反向”(如 RNG、随机采样),在 native_functions.yaml 里加 tags: nondifferentiable 标记,避免代码生成器抱怨缺反向。
6.3.2 derivatives.yaml 表达式的”语言”
derivatives.yaml 里的表达式 mm_mat1_backward(grad, mat2, ...) 不是任意 C++,而是一门特定子语言。它的特征:
- 所有变量都是 schema 里的参数名(
self、mat2等) - 函数调用是
at::xxx(...)或者一些 helper(maybe_multiply、mm_mat1_backward等) - 支持
?:三目、属性访问(self.sym_sizes())、转at::调用 - 不支持 if/for —— 控制流要写在 helper 函数里
如果反向规则非常简单(像 add 那样 grad),可以直接写表达式;如果复杂(如 softmax、layer_norm),就在 tools/autograd/FunctionsManual.cpp 里写 helper 函数,YAML 里只写一行调用。这种”声明式入口 + 实现式后端”分离让反向规则一眼读起来就是”数学公式”。
6.3.3 forward grad 的特殊性
注意 derivatives.yaml 里的 result 字段 —— 它不是反向 (backward) 而是 前向 AD (forward grad)。这是 PyTorch 较新的能力(v1.10+),允许用户做 jvp(Jacobian-vector product)而不是 vjp(vector-Jacobian product)。
forward grad 在大多数算子上是反向规则的”对偶”:如果反向是 mat1.grad += grad @ mat2.T,前向就是 result.grad = self.grad @ mat2。derivatives.yaml 在同一个条目里把两套规则一起写,让 torchgen 一次生成两套 C++ 代码(MmBackward0、MmJvp 等)。
对绝大多数 PyTorch 用户来说 forward grad 不常用(vjp 已经够用),但对二阶导数、Hessian 计算等场景必不可少。第 7 章 autograd 那章会展开。
6.4 structured 算子:现代算子的统一框架
把目光回到 mm.out 那个条目:structured: True。这是 PyTorch 算子的现代框架,统一处理算子的”前奏”。
传统手写算子的 boilerplate(伪代码):
Tensor mm_out_cpu(const Tensor& self, const Tensor& mat2, Tensor& out) {
// 1. shape 检查与广播
TORCH_CHECK(self.dim() == 2 && mat2.dim() == 2);
TORCH_CHECK(self.size(1) == mat2.size(0));
auto out_shape = {self.size(0), mat2.size(1)};
// 2. dtype 检查与提升
auto common_dtype = at::result_type(self, mat2);
TORCH_CHECK(common_dtype == kFloat || common_dtype == kDouble || ...);
// 3. 分配输出
out.resize_(out_shape);
if (out.dtype() != common_dtype) out = out.to(common_dtype);
// 4. 真正的计算
// ... CPU GEMM kernel ...
return out;
}
每个算子都要写这一坨。structured 框架把它自动生成:
graph TB
YAML["native_functions.yaml<br/>mm.out: structured True"]
YAML --> Gen["torchgen 解析"]
Gen --> Meta["生成 meta 函数<br/>shape 推导 + dtype 检查 + 分配 out"]
Gen --> Impl["生成 impl wrapper<br/>调用用户写的 mm_kernel"]
User["用户只写: mm_kernel<br/>纯计算, 无 boilerplate"] -.被 wrapper 调.-> Impl
Meta --> Final["完整算子<br/>meta + impl 自动注册到 dispatcher"]
Impl --> Final
style YAML fill:#fef3c7
style Gen fill:#dbeafe
style User fill:#dcfce7
style Final fill:#fce7f3
structured 框架的核心:把 boilerplate(shape 检查 / dtype 推导 / 分配输出)从用户代码中剥离,让用户只写”真正的计算”——这与 §6.5 的 ufunc 思路一致,都是”代码生成抽象掉重复”。
// 代码生成器吐出的简化版 (基于 native_functions.yaml 的 structured: True)
struct structured_mm_out final : public TensorIteratorBase {
void meta(const Tensor& self, const Tensor& mat2);
void impl(const Tensor& self, const Tensor& mat2, const Tensor& out);
};
// meta 函数:算 shape、dtype、分配 out(torchgen 生成)
void structured_mm_out::meta(const Tensor& self, const Tensor& mat2) {
set_output_raw_strided(0, {self.size(0), mat2.size(1)},
{}, self.options());
}
// impl 函数:用户只需写这一段(CPU 在 mm_out_cpu, CUDA 在 mm_out_cuda)
TORCH_IMPL_FUNC(mm_out_cpu)(const Tensor& self, const Tensor& mat2, const Tensor& out) {
at::native::mm_kernel(self, mat2, out);
}
——开发者只写 impl,meta、shape 检查、dtype 提升、out 分配全部代码生成。这在大量算子上节省了海量重复代码。
6.4.1 structured_delegate 的转发机制
回到 mm(无 out):
- func: mm(Tensor self, Tensor mat2) -> Tensor
structured_delegate: mm.out
这告诉 torchgen:mm 自己不实现,分配一个 out 张量后调 mm.out。生成的代码大致是:
// 生成的 mm 实现
Tensor mm(const Tensor& self, const Tensor& mat2) {
Tensor result;
at::mm_outf(self, mat2, result); // 实际调用 mm.out
return result;
}
这种”无 out 版本委托给 out 版本”模式让所有算子的”无 out / 有 out”两个变体共享同一份核心实现,消除一半重复。
6.4.2 structured 框架与 inplace 三态
每个 ATen 算子在 structured 框架下有三个变体:
mm(a, b) -> result—— 函数式:返回新张量mm.out(a, b, *, out)—— out= 版本:写入用户给的 outmm_(a, b)—— inplace:修改 a 自身(如果可行)
这三个变体共享同一个 meta(计算 shape)和 impl(真正的 kernel)。代码生成器自动把 mm 转发到 mm.out、把 mm_ 转发到 mm.out。用户只需写一份 impl,三个 API 自动具备。
不是所有算子都有这三个变体。mm 没有 mm_ 因为矩阵乘的输出形状与输入不同(不能 inplace)。但加减乘除这种”形状不变”的算子三态都有。native_functions.yaml 通过分别声明三个 schema 让代码生成器知道每个算子有哪些变体。
6.4.2.5 meta 函数与 FakeTensor 的关系
structured 框架里 meta 函数的存在不只是工程整洁,它是 FakeTensor / torch.compile 的关键依赖。
FakeTensor 的语义是”假执行” —— 只算 shape / dtype / stride,不真做数值计算。它怎么知道每个算子的输出 shape?答案就是 调 meta 函数。torch.compile 在 trace 阶段把每个 ATen 调用 redirect 到对应的 meta,meta 函数算出输出 shape 后返回一个 FakeTensor,trace 继续往下走。
这就是为什么 §6.12 那条”meta 里不能有计算”的红线如此关键 —— 一旦 meta 错误地做了真计算,FakeTensor 的”零成本 shape 推导”就垮了。第 12-14 章 torch.compile 编译器栈会反复看到 meta 函数的影子。
6.4.3 结构化算子的演进
structured 框架不是 PyTorch 一开始就有的。早期所有算子都是手写完整流水线,重复严重。2020-2021 年 Edward Yang 主导了 “structured kernels” 提案(PR #51277 系列),把所有 ATen 算子向 structured 迁移。今天 v2.11 大约 70%+ 的算子已经迁移完成,剩下的一些”特殊算子”(如 RNG、量化、复杂的 reduce)暂未迁移。
理解这个迁移历史,你看源码时能区分”老式手写算子”和”现代 structured 算子”两种风格。前者多在 aten/src/ATen/native/ 的老文件里,后者在新文件或 *_native.cpp 后缀里。
6.5 ufunc 内层循环:一个公式生成几十个 kernel
第 1 章 §1.4 介绍过 add 的真正数学藏在 aten/src/ATen/native/ufunc/add.h:
// aten/src/ATen/native/ufunc/add.h:14
template <typename T>
C10_HOST_DEVICE C10_ALWAYS_INLINE T add(T self, T other, T alpha) {
return self + alpha * other;
}
native_functions.yaml 里 add.out 条目带这一段:
- func: add.out(Tensor self, Tensor other, *, Scalar alpha=1, Tensor(a!) out) -> Tensor(a!)
structured: True
structured_inherits: TensorIteratorBase
ufunc_inner_loop:
Generic: add (AllAndComplex, BFloat16, Half, ComplexHalf)
ScalarOnly: add (Bool)
ufunc_inner_loop 字段告诉代码生成器:用 ufunc::add 这个内层函数,给它生成 SIMD 向量化的 CPU kernel + CUDA kernel。代码生成器会自动展开为:
// 生成的 (简化) CPU SIMD kernel
void add_kernel_cpu(TensorIteratorBase& iter, const Scalar& alpha) {
AT_DISPATCH_ALL_TYPES_AND2(kBFloat16, kHalf, iter.common_dtype(), "add", [&]() {
auto alpha_val = alpha.to<scalar_t>();
cpu_kernel_vec(iter,
[=](scalar_t a, scalar_t b) -> scalar_t {
return ufunc::add(a, b, alpha_val);
},
[=](Vectorized<scalar_t> a, Vectorized<scalar_t> b) {
return ufunc::add(a, b, Vectorized<scalar_t>(alpha_val));
});
});
}
// 生成的 CUDA kernel
void add_kernel_cuda(TensorIteratorBase& iter, const Scalar& alpha) {
AT_DISPATCH_ALL_TYPES_AND2(kBFloat16, kHalf, iter.common_dtype(), "add", [&]() {
gpu_kernel_with_scalars(iter,
[=]GPU_LAMBDA(scalar_t a, scalar_t b) -> scalar_t {
return ufunc::add(a, b, alpha.to<scalar_t>());
});
});
}
一个 10 行的内层公式 → 几十个 dtype × 后端的 kernel。这是 PyTorch 处理”逐元素操作”的统一机制 —— 数学公式与 SIMD 化、CUDA 化、dtype 展开完全解耦。
6.5.1 ufunc 与 NumPy 的渊源
ufunc 这个名字直接借自 NumPy —— “universal function”。NumPy 的 ufunc 也是”对每个元素应用同一函数”的逐元素抽象,由 NumPy 自家代码生成器生成各 dtype 的 SIMD 实现。
PyTorch 的 ufunc 与 NumPy 同名同源,但实现细节有差异:
- NumPy 用 C 模板生成;PyTorch 用 C++ 模板 + Python codegen
- PyTorch 的 ufunc 同时支持 CPU SIMD 与 CUDA,NumPy 只有 CPU
- PyTorch 把 ufunc 与 TensorIterator 紧密集成,NumPy 用自家的
nditer
但思想完全一致:让用户写一行数学公式,机器把它扩展成几十个特化版本。这是数值计算库通用的工程模式。
6.5.1.5 ufunc 与 vec::Vectorized
在 ufunc 内层循环代码里你会反复看到 Vectorized<T> 这个类型 —— PyTorch 自家的 SIMD 抽象(aten/src/ATen/cpu/vec/)。它对每个 dtype 提供 Vectorized<float>、Vectorized<bfloat16> 等模板特化,每个特化在 x86 上用 AVX2/AVX512、ARM 上用 NEON、Apple Silicon 上用 Accelerate 实现。
ufunc 函数同时支持标量版本和 Vectorized 版本:
template <typename T>
T add(T self, T other, T alpha) { return self + alpha * other; } // 标量
template <typename T>
Vectorized<T> add(Vectorized<T> self, Vectorized<T> other, Vectorized<T> alpha) {
return vec::fmadd(other, alpha, self); // 向量化版,用 FMA 指令
}
cpu_kernel_vec 在循环内部对对齐+整 chunk 部分用向量版,对剩余的尾部元素用标量版。这种”vector + scalar tail”是 SIMD 编程的标准模式。
Vectorized<T> 还提供完整的数学 API(abs、min、max、log、exp、sin、cos、…),让用户写 ufunc 不需要自己处理 SIMD 细节。这是 PyTorch CPU 性能在数十种 dtype 上保持一致的根本工具。
6.5.2 哪些算子能 ufunc 化
不是所有算子都能写成 ufunc。要求是:
- 逐元素:每个输出元素只依赖对应位置的输入元素(最多再加广播)
- 无内部状态:没有累加、归约、依赖前一个输出
- 数学公式可向量化:能在 SIMD 寄存器里并行算
加减乘除、比较运算、激活函数(ReLU / Sigmoid / Tanh)、绝大多数 element-wise 数学函数(exp / log / sin / cos)都符合。但 matmul、conv、softmax、layer_norm、reduce 都不行 —— 它们涉及跨元素的依赖。这类算子要手写 kernel + AT_DISPATCH_* 宏来处理 dtype 展开。
6.6 AT_DISPATCH_* 宏家族:手写算子的”开关”
不是所有算子都能 ufunc。复杂的算子(如卷积、attention、归约)需要手写。但手写算子也需要”对所有 dtype 展开”—— 这就是 AT_DISPATCH_* 宏的用武之地。
AT_DISPATCH_ALL_TYPES_AND2(kBFloat16, kHalf, iter.common_dtype(), "my_op", [&]() {
using scalar_t = ...; // 在这个 lambda 里 scalar_t 是具体 dtype
// 写算子实现
});
宏展开后是一个巨大的 switch:
switch (iter.common_dtype()) {
case kFloat: { using scalar_t = float; /* lambda 体 */ break; }
case kDouble: { using scalar_t = double; /* lambda 体 */ break; }
case kBFloat16: { using scalar_t = at::BFloat16; /* lambda 体 */ break; }
case kHalf: { using scalar_t = at::Half; /* lambda 体 */ break; }
case kInt: { using scalar_t = int32_t; /* lambda 体 */ break; }
case kLong: { using scalar_t = int64_t; /* lambda 体 */ break; }
/* ... */
}
每个 case 分支编译时实例化一份 lambda 副本,对应那个 dtype 的特化代码。最终二进制里有 N 份针对不同 dtype 编译过的 kernel —— 运行时一次 switch 跳到正确版本。
AT_DISPATCH_* 有几十个变体:AT_DISPATCH_ALL_TYPES、AT_DISPATCH_FLOATING_TYPES、AT_DISPATCH_INTEGRAL_TYPES 等等。它们的区别就是 switch case 覆盖的 dtype 集合不同。这套宏让”运行期 dtype 选择”零开销 —— 编译期把所有可能性展开成静态分支。
6.6.1 dispatch 宏的二进制膨胀代价
AT_DISPATCH_* 的代价是 二进制膨胀:每个用了这套宏的算子实现,编译产出 N 份代码(每个 dtype 一份)。如果一个算子用 AT_DISPATCH_ALL_TYPES_AND4(...),可能展开 12-15 份特化。乘上几百个算子,整个 libtorch 的体积有相当一部分来自这种”特化爆炸”。
PyTorch 在移动端有 C10_MOBILE 编译宏,会让 dispatch 宏只展开少数几个 dtype(如 fp32),减小体积。这是为什么 PyTorch Lite(移动端)的 .so 比桌面版小几倍 —— 不是少了功能,而是少了 dtype 特化。
6.6.2 SymInt:符号 shape 的代码生成支持
PyTorch 2.0+ 引入 SymInt —— 一个能表示”具体整数”或”符号变量”的类型。它由 torch.compile / Dynamo 在 trace 阶段使用,让算子能接受 dynamic shapes 而不立即具体化。
代码生成器对每个算子自动生成 SymInt 版本。在 native_functions.yaml 里你会看到很多算子有 SymInt[] 参数(如 view(Tensor self, SymInt[] size)),torchgen 据此生成两套 C++ 实现:一套接收普通 int64_t、一套接收 c10::SymInt。运行期普通调用走前者;trace 阶段走后者,让符号 shape 一路传到 Inductor。
第 14 章 TorchInductor 章会展开 SymInt 怎么让 dynamic shape 编译成为可能。
6.7 torchgen 工作流
把视角拉高,看 torchgen 是怎么把 YAML 变成 C++ 的。打开 torchgen/gen.py:3067(主入口)和 torchgen/api/ 目录的多个文件,整体流程:
flowchart LR
Y1[native_functions.yaml]
Y2[derivatives.yaml]
Y3[tags.yaml]
Y1 --> Parse[gen.py: parse_native_yaml<br/>解析为 NativeFunction 对象]
Y2 --> Parse
Y3 --> Parse
Parse --> Model[torchgen/model.py<br/>Python 中间表示<br/>NativeFunction / FunctionSchema]
Model --> CG1[gen_cpp.py<br/>生成 Tensor 方法 / at::xxx]
Model --> CG2[gen_dispatch.py<br/>生成 Operators_*.cpp]
Model --> CG3[gen_register_*.py<br/>生成 RegisterCPU/CUDA/...]
Model --> CG4[gen_autograd_*.py<br/>生成 VariableType / Functions]
Model --> CG5[gen_python_*.py<br/>生成 Python 包装]
Model --> CG6[gen_functionalization_type.py<br/>functionalize 规则]
Model --> CG7[gen_vmap_plumbing.py<br/>vmap 规则]
style Model fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
torchgen 使用 Jinja-like 模板 生成代码(自家轻量级 code_template.py)。整个生成过程在 PyTorch 编译时由 CMake/setup.py 触发,产物落到 build/aten/src/ATen/ 与 torch/csrc/autograd/generated/。
6.7.0 几个 gen_*.py 的分工
torchgen 里的几个核心生成模块 :
| 文件 | 职责 |
|---|---|
torchgen/gen.py | 主入口,编排所有生成步骤 |
torchgen/api/cpp.py | C++ API 签名生成(at::add、Tensor::add) |
torchgen/api/dispatcher.py | dispatcher 调用约定(boxed / unboxed signatures) |
torchgen/api/native.py | native 层签名(实现侧) |
torchgen/api/structured.py | structured 算子的 meta + impl 框架 |
torchgen/api/python.py (1550 行) | Python 端绑定生成 (THPVariable_xxx) |
torchgen/api/ufunc.py | ufunc 内层循环展开 |
torchgen/api/translate.py | 不同 binding 集合之间的翻译 |
torchgen/api/functionalization.py | functionalize 规则生成 |
torchgen/api/lazy.py | Lazy Tensor backend 支持 |
torchgen/dest/ | 各种”目标”的输出(Python wrappers、CPU register、CUDA register、autograd 等) |
每个文件都有清晰职责。理解这套划分让你看 PyTorch 编译产物时能快速定位”这段生成代码出自哪里”。
6.7.1 一个具体跟踪:mm 在 torchgen 里的生命周期
跟踪 mm 算子从 YAML 到二进制:
- 解析:
parse_native_yaml读native_functions.yaml,把mm与mm.out解析成两个NativeFunction对象 - dispatch 表合并:把
dispatch字段展开到 dispatchTable,包括 alias key(CompositeExplicitAutograd → 所有 backend) - Operators_*.cpp:生成
at::_ops::mm::call入口,里面调Dispatcher::singleton().findSchemaOrThrow(...).typed<...>().call(...)(第 1 章 §1.2 见过) - RegisterCPU.cpp:生成
TORCH_LIBRARY_IMPL(aten, CPU, m) { m.impl("mm.out", &at::native::structured_mm_out_cpu_functional::wrapper); } - TensorBody.h:生成
Tensor::mm(const Tensor& mat2) const { return at::_ops::mm::call(*this, mat2); } - VariableType_4.cpp:生成
VariableType::mm的反向包装,使用 derivatives.yaml 翻译出的MmBackward0节点 - python_torch_functions_2.cpp:生成
THPVariable_mmPython 函数,能从 Python 端调用
每一步都有专门的 gen_*.py 文件负责。整个 torchgen 大约 25000 行 Python,但它的产出是数十万行 C++ —— 代码膨胀比约 1:10。
6.7.2 torchgen 模板系统
torchgen 不依赖 Jinja2 等外部库 —— 它有自己的轻量级模板(torchgen/code_template.py,仅 100 行)。模板语法形如:
TEMPLATE = CodeTemplate("""
Tensor ${name}(${arguments}) {
${body}
}
""")
result = TEMPLATE.substitute(
name="my_op",
arguments="const Tensor& self",
body="return self.add(1);"
)
风格与 Python 的 string.Template 类似,但支持嵌套替换和列表展开。PyTorch 团队选择不依赖 Jinja2 是为了 减少 build-time 依赖——torchgen 是 PyTorch 编译过程的”临界路径”,每多一个 Python 包就多一个潜在的安装问题。
6.7.2.5 一个有趣的设计:translate
torchgen/api/translate.py(437 行)是 torchgen 里一个特别巧妙的模块。它的工作是把”一种 binding 集合”翻成”另一种”。
举例:dispatcher 调用约定的参数列表((const Tensor&, const Tensor&, const Scalar&))和 native 调用约定((const Tensor& self, const Tensor& mat2, const Scalar& alpha))类型一样但语义不同(前者无名、后者有名)。生成 RegisterCPU.cpp 里的 wrapper 函数时,需要把 dispatcher 收到的位置参数转成 native 函数的命名参数 —— translate.py 就是这道题的引擎。
它内部维护一组”binding 转换规则”(如 Tensor → const Tensor&、int[] → IntArrayRef),自动推断怎么把一组类型签名”翻”成另一组。这套机制让 PyTorch 能在 dispatcher / native / Python / functionalize 等多种约定之间自如转换,开发者不用手写每一对的转换代码。
6.7.3 torchgen 的 Python 中间表示
torchgen/model.py 定义了一组 dataclass 表示算子的”AST”:
@dataclass(frozen=True)
class NativeFunction:
namespace: str # "aten"
func: FunctionSchema # 解析后的 schema
dispatch: dict[DispatchKey, str]
structured: bool
structured_delegate: OperatorName | None
variants: set[Variant]
tags: set[str]
...
YAML 解析完后,每个算子是一个 NativeFunction 实例。后续所有代码生成函数都接收 NativeFunction 列表,遍历产出代码字符串。这种”先解析成模型对象,再多 pass 生成”的设计与编译器后端的 IR 思想完全一致。第 14 章 TorchInductor 章会再看到这种 IR-Lowering 模式。
6.8 一个真实例子:从 YAML 到二进制的完整链
以 add 为例,把整条链路画出来:
flowchart TB
subgraph Source["源码 (静态)"]
Y1["native_functions.yaml<br/>add.Tensor / add.out"]
Y2["derivatives.yaml<br/>add 反向规则"]
Y3["aten/src/ATen/native/ufunc/add.h<br/>self + alpha*other"]
Y4["aten/src/ATen/native/BinaryOps.cpp<br/>structured impl"]
end
subgraph Codegen["编译期 torchgen"]
TG[torchgen/gen.py 跑]
end
subgraph Generated["生成的 C++ (build/)"]
G1[Operators_4.cpp<br/>at::_ops::add::call]
G2[RegisterCPU.cpp<br/>m.impl 注册]
G3[RegisterCUDA.cpp]
G4[VariableType_4.cpp<br/>autograd 包装]
G5[python_torch_functions_2.cpp<br/>Python 绑定]
G6[UfuncCPUKernel_add.cpp<br/>SIMD 展开]
G7[UfuncCUDAKernel_add.cu<br/>CUDA 展开]
end
subgraph Binary["编译后 (libtorch.so)"]
B1[at::add 符号]
B2[Tensor::add 符号]
B3[VariableType::add 符号]
B4[THPVariable_add 符号]
B5[各 dtype × 后端的 kernel]
end
Y1 --> TG
Y2 --> TG
TG --> G1
TG --> G2
TG --> G3
TG --> G4
TG --> G5
Y3 --> G6
Y3 --> G7
Y4 -.手写,直接编译.-> Binary
G1 --> B1
G1 --> B2
G4 --> B3
G5 --> B4
G6 --> B5
G7 --> B5
style TG fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
这条链上的关键观察:
- 静态 YAML → 自动膨胀到二进制:3 行 YAML → 数千行 C++ → 数万行汇编
- 手写部分极少:开发者只需写”算子的 impl + 反向公式”两段,其余全是模板/生成
- 跨语言一致性自动保证:Python
torch.add、C++at::add、Tensor::add永远拥有完全一致的签名 —— 因为它们都从同一个 schema 派生 - 二进制可观察:调试时可以
nm libtorch.so | grep add看到生成代码产生的所有符号
6.8.1 编译时间的代价
代码生成虽然减少手写,但有一个常被忽略的代价:编译时间。PyTorch 全量编译要 1-2 小时,其中很大一部分是编译这些”机器生成的几十万行 C++“。具体痛点:
Operators_*.cpp一个文件展开后可能 5000+ 行,每个文件编译 5-10 秒- 模板展开导致的 SFINAE 计算让编译器单文件吃 1-2 GB 内存
- 一次性 link 所有生成的
.o时,linker 是另一个瓶颈
PyTorch 团队为了缓解,做了几个工程优化:
- 拆分生成文件:
Operators_0.cpp到Operators_4.cpp把 3000 算子分成 5 块,让 make 能并行编 AT_PER_OPERATOR_HEADERS宏:每个算子单独一个 .h 文件,用户代码可以”只 include 自己用到的”,减少 amplification- 预编译头:常用 c10 / aten 头文件做成 PCH
这些优化让全量编译从 v1.0 时代的 4-5 小时压到现在的 1-2 小时。但对国内大多数 PyTorch 改造工程来说,“编译一次喝杯咖啡”仍是常态。
6.9 添加一个新算子:实战流程
把上面所有抽象落到工程实战。要在 PyTorch 主仓加一个新算子(比如 my_clamp),完整流程:
1. 在 native_functions.yaml 加 schema:
- func: my_clamp(Tensor self, Scalar min, Scalar max) -> Tensor
variants: function, method
dispatch:
CPU: my_clamp_cpu
CUDA: my_clamp_cuda
tags: pointwise
2. 在 aten/src/ATen/native/MyClamp.cpp 写 CPU impl:
Tensor my_clamp_cpu(const Tensor& self, const Scalar& min, const Scalar& max) {
// 真正的 CPU 实现
}
3. 在 aten/src/ATen/native/cuda/MyClamp.cu 写 CUDA impl:
Tensor my_clamp_cuda(const Tensor& self, const Scalar& min, const Scalar& max) {
// 真正的 CUDA 实现
}
4. 在 derivatives.yaml 加反向规则(如果可微):
- name: my_clamp(Tensor self, Scalar min, Scalar max) -> Tensor
self: clamp_backward(grad, self, min, max)
5. 重新跑构建:python setup.py develop。torchgen 自动检测 YAML 变化,重新生成所有派生文件
6. 在 Python 端使用:torch.my_clamp(t, -1, 1) 就能调
注意 6 步里只有 2、3、4 是手写(impl + 反向公式),其余全部由代码生成器 / 构建系统接管。整个流程通常半天能搞定一个简单算子。绝大多数代码由生成器代劳,开发者只写”算子语义”。这是 PyTorch 工程化的最重要范本之一。
6.9.1 第三方库的 torch.library API
如果你不想改 PyTorch 主仓(大部分场景),可以用 torch.library Python API 在自己的项目里注册算子:
import torch
# 在 Python 端定义算子
@torch.library.custom_op("mylib::my_clamp", mutates_args=())
def my_clamp(x: torch.Tensor, min: float, max: float) -> torch.Tensor:
return torch.clamp(x, min, max)
@my_clamp.register_fake
def _(x, min, max):
# FakeTensor / shape 推导版本(用于 torch.compile)
return torch.empty_like(x)
这套 API(v2.4+ 稳定)让用户不需要 C++、不需要修改 PyTorch 源码就能注册新算子,同时还能与 torch.compile 完美兼容。它的底层实现仍然是调用 dispatcher 的 registerImpl 注册接口,但把繁琐的 schema 解析、注册流程封装成 Python 函数。
如果你的需求是”在不改 PyTorch 主仓的前提下加一个算子”,这是最佳路径。第 22 章自定义算子章会详细演示。
6.9.2 国产芯片厂商怎么用这套机制
国产 AI 芯片厂商(华为昇腾、寒武纪、壁仞、海光等)接入 PyTorch 时,几乎都走同一条路:
- 声明自己的 dispatch key:通过 PyTorch 的 PrivateUse1 机制注册一个新的 backend key
- 跑 codegen:torchgen 提供
gen_backend_stubs.py,生成”我应该实现哪些算子”的 stub 列表 - 逐个实现:把 stub 列表里的算子一个个用自家硬件 SDK 实现
- 作为独立 wheel 发布:用户
pip install torch_npu/pip install torch_mlu等就能在自家芯片上跑 PyTorch
这套流程让国产芯片不需要 fork PyTorch 主仓就能完整接入。gen_backend_stubs.py 是这条路上的关键 —— 它读一份”我要支持的算子列表” YAML,吐出对应的 C++ stub 模板,厂商只填实现细节。
理解这条机制,你就能解释为什么国产芯片接入 PyTorch 速度比想象的快 —— 代码生成器消除了 90% 的胶水代码工作。
gen_backend_stubs.py 的工作流大致是:读厂商提供的”目标 backend YAML”(声明它支持哪些算子、是否支持 autograd 等),自动生成对应的 Register{Backend}.cpp 注册表与 stub 头文件。厂商团队拿到这些 stub 后只需要逐个填实现 —— 类型签名、dispatcher 注册、autograd 包装全自动。这条”以 codegen 降低生态准入门槛”的设计是 PyTorch 在国内 AI 芯片生态里站稳脚跟的关键工程支撑。
6.10 横向对比:其他框架怎么做
| 框架 | 算子注册方式 | 优劣 |
|---|---|---|
| PyTorch | YAML + Python codegen | 自动化高、跨语言一致;调试需要看生成代码 |
| TensorFlow | C++ REGISTER_OP 宏 + Bazel codegen | 编译时检查严格;YAML 缺失,schema 不集中 |
| JAX | XLA HLO + Python primitives | 算子直接是 HLO,很多算子是 Python 组合实现 |
| MindSpore | Python OpDef + 自动生成 | 与 PyTorch 类似但更晚成熟 |
| OneFlow | 自有 IR + 手写 op_def | 编译期优化更激进,但生态小 |
PyTorch 的 YAML 路线被 MindSpore、PaddlePaddle 等国内框架借鉴,因为它在”数千算子的工程一致性”问题上确实领先。
值得多说一句的是 JAX 的对比。JAX 选择了不同的路线:算子是 Python 一等公民,所谓 “primitive” 加上一组规则(abstract eval、impl、jvp、transpose)。这种纯 Python 设计让自定义算子门槛极低(写几个 Python 函数就行),但代价是性能完全依赖 XLA 编译器 —— 没编译就没法跑。PyTorch 的 codegen 路线让 eager 模式能立即跑,编译只是 可选优化。两条路线没有绝对优劣,体现了 “动态优先” 与 “静态编译优先” 两种 ML 框架哲学的差异。
6.10.5 历史回顾:从 C++ 模板到 YAML codegen
PyTorch 早期(pre-1.0 / Torch7 时代)算子注册全部是手写 C++ 模板。每加一个算子要改 5-10 个文件,每加一个 dtype 要改几十处宏。这种状态在 2017-2018 年算子数量爆炸增长时变得不可维持。
2018-2019 Edward Yang、Will Feng、Sebastian Messmer 等人主导了从手写模板向 YAML codegen 的渐进迁移。早期的 codegen 用的是 Python 2 风格的脚本,慢慢演进成今天的 torchgen 包(Python 3.10+ 类型化 dataclass + 模块化)。这次迁移是 PyTorch 工程化历史上最重要的转折之一 —— 没有它,PyTorch 不可能维持每月一个 minor 版本、每个版本几百个 PR 的开发节奏。
理解这条历史,你看 PyTorch 旧代码(如 torch/csrc/autograd/VariableTypeUtils.cpp、各种 *_legacy.cpp)就能识别”这是迁移前的手写代码”。它们大部分还在,但都标了 legacy、deprecated,逐步在被新生成代码替换。
6.11 跨书关联
- 《Serde 元编程》全书:Serde 用 Rust 派生宏(procedural macro)做”序列化代码生成”,与 ATen 用 YAML + Python 做”算子代码生成”是同一思想的不同实现。Rust 的派生宏在编译期一次过;PyTorch 的 torchgen 是 Python 脚本独立先跑、产物再编译。各有取舍:派生宏类型安全、torchgen 调试友好
- 《Rust 编译器之路》第 X 章 内置 trait 与代码生成:Rust 编译器内部对一些 trait(如
Clone、Default)的派生也是代码生成,思想与 ATen 一致 - 《MCP 协议剖析》第 X 章 Server 注册:MCP 也用 schema-driven 注册(JSON schema → method handler),但量级远小于 PyTorch(每个 server 几十个 method vs 3000+ 算子)
- 《vLLM 内核探秘》第 17 章 API 服务器:vLLM OpenAI 兼容 API 的 schema 也是 schema-driven 自动生成绑定 —— 与 ATen 思想一致,规模一个量级以下
6.12 几条踩坑提示
实战添加新算子时常见的坑:
1. schema 写错了类型:Tensor 与 Tensor(a!) 一字之差,functionalization 规则完全不同。如果你想写”修改输入”的算子,必须用 (a!) 标记
2. 忘了 derivatives.yaml:新算子默认不可反向。loss.backward() 时报 “does not have a derivative implemented” 是这个错的标志
3. dispatch 表漏了 backend:只写了 CPU 没写 CUDA,CUDA 张量上调用会报 “no kernel found”。最少要为 CPU + CUDA 两个 backend 写 impl,或注册一个 fallback
4. 改了 YAML 但没重编:torchgen 在 build 时跑,如果你只改 .yaml 不重新构建,生成的代码不会变。python setup.py develop 强制重跑
5. struct kernel 的 meta 与 impl 签名不一致:structured_delegate 模式下 meta 函数和 impl 函数的签名必须严格匹配 schema,否则编译报错
6. structured impl 误把计算放到 meta:meta 函数只能算 shape / dtype / 分配 out,绝不能做实际数值计算。FakeTensor / torch.compile 在 trace 阶段只跑 meta、不跑 impl —— 如果 meta 里有数值计算,trace 出来的图会错
7. derivatives.yaml 引用了未保存的张量:反向规则只能引用前向保留的张量。如果你的反向需要前向中间值(如 softmax 的输出),要在 native_functions.yaml 里通过 output_differentiability 或者特殊保存机制让前向把它存下来 —— 否则反向会拿到无效数据
这些坑在 PyTorch 官方的 aten/src/ATen/native/README.md 与 tools/codegen/README.md 里有详尽指南,新增算子前建议通读。
6.12.5 调试技巧:怎么找生成的代码
新手最常迷惑的一件事:源码里 grep 不到 at::add 的实现。原因是它在 build/aten/src/ATen/Operators_*.cpp 里 —— 生成的代码不在 git 仓库里。
调试时的标准操作:
# 1. 定位生成代码所在文件
find build/ -name "Operators_*.cpp" -exec grep -l "::add::call" {} \;
# 2. 在生成的文件里找具体注册语句
grep -A5 "at::_ops::add" build/aten/src/ATen/Operators_4.cpp
# 3. 找你的算子最终落到哪个 RegisterXxx.cpp
grep -rn "TORCH_LIBRARY_IMPL.*aten.*CPU" build/aten/src/ATen/RegisterCPU.cpp | head
如果你想跟踪”我的 YAML 改动产出了什么 C++ 代码”,最快的办法是改一行 YAML、跑构建、对比 build/ 目录的 diff。这是给 PyTorch 提交 codegen 相关 PR 的标配技能。
6.13 几条通用启示
读完本章,把代码生成思想抽象成可迁移的几条规则:
第一:在工程化框架里,schema 是真理。一旦你设计的系统涉及多语言绑定、序列化、自动化文档,把 schema 用一个权威源(YAML / JSON / proto)声明,所有派生路径都从它生成。这避免”手维护 N 个一致性”的灾难。
第二:生成代码而不是写库。当你发现自己在写”看似相同但参数不同”的 N 段代码,停下来 —— 把它声明化、用脚本生成。一个 1000 行的 codegen 比 10000 行的”近重复”库更易维护。
第三:保留逃生口(escape hatch)。PyTorch 允许某些算子用手写而不是 structured,是因为现实总有”你的框架没考虑到”的边角情况。codegen 框架要能优雅退路。
第四:模板小、生成器逻辑大。torchgen 的模板字符串很短,复杂度都在 Python 端的”分析 + 决策”逻辑里。这是因为模板系统本身是脆弱的(缩进、换行、字符串拼接),而 Python 逻辑可调试可重构。
第五:生成代码不入 git,但保留可观察性。PyTorch 把 build/aten/src/ATen/Operators_*.cpp 这类生成产物放到 .gitignore,避免每次改 YAML 都引发巨大 diff。但生成产物在编译产物里完整保留,开发者能 grep / 调试。这种”瞬态产物可观察”是 codegen 工程化的关键 trade-off。
第六:用 schema 替代手写文档。Python torch.add 的 docstring 在某种程度上也由 schema 推导。当 schema 是单一真理源,连文档都能自动一致。这是文档准确性的强保证。
把这些启示用到你自己的项目,能省下大量看似”必要”的重复劳动。
6.14 一个建议练习:从 schema 反推生成代码
要真正吃透本章,可以试一道反向题:从一段 PyTorch 文档里的算子接口反推它的 native_functions.yaml schema。
例如 torch.nn.functional.linear(input, weight, bias=None):
- 三个 Tensor 参数,第三个可选(→
Tensor?) - 返回单个 Tensor
- 推测的 schema:
- func: linear(Tensor input, Tensor weight, Tensor? bias=None) -> Tensor
python_module: nn
variants: function
dispatch:
CompositeImplicitAutograd: linear
实际打开 aten/src/ATen/native/native_functions.yaml 搜 linear,对照差异。这个练习能把”YAML schema 语法”内化成肌肉记忆。
完成几次这种反推,你就能给 PyTorch 提交自定义算子 PR 了 —— 源码里有 16172 行真实例子可以学。
6.15 一个反思:为什么 PyTorch 不用 protobuf
读到这里有人会问 —— 既然要用 schema 驱动,为什么不用更标准的 protobuf / FlatBuffers / Cap’n Proto?这些工具有更成熟的代码生成器。
PyTorch 团队公开讨论过这个问题,结论是:
- YAML 比 protobuf 可读得多:算子作者每天打开 schema 文件,YAML 的零样板比 proto 的 message + field number 更友好
- PyTorch 的 schema 不需要跨语言序列化:proto 的核心价值是网络传输,PyTorch schema 只在编译期被消费
- 自家 codegen 比 protoc 灵活:torchgen 能生成 Python 端的 docstring、autograd 反向、functionalize 规则等”非协议”产出,proto 做不到
这是一条”专用工具胜过通用工具”的工程决策。如果你的项目 schema 主要给本进程用、不需要网络传输,自家轻量级 YAML + Python codegen 通常比 protobuf 更合适。
下一章拆 Autograd —— 这一章我们看到的 derivatives.yaml 怎么变成真正的反向计算引擎。
下一章拆 Autograd —— 这一章我们看到的 derivatives.yaml 怎么变成真正的反向计算引擎。