算子的 真理之源
Day 08 知道有 Dispatcher,Day 09 知道有 Autograd, 但真正的"加法"代码到底写在哪里? 今天进入算子实现层: c10 是核心抽象,ATen 是算子库, native_functions.yaml 是所有 op 的契约, TORCH_LIBRARY 是把 CUDA kernel 接入 Dispatcher 的钩子。 拆完这套,你就有了"给 PyTorch 加一个新算子"的能力。
思维导图
分层 — c10 / ATen / torch / Python
"PyTorch" 不是一个库,而是至少四层的同心圆。 不同层有不同的职责、不同的依赖、不同的开发者群体。 读源码先认这四层,后面再钻细节就不会迷路。
四层职责对比
| 层 | 目录 | 核心内容 | 不包含 |
|---|---|---|---|
| c10 | c10/ | Tensor、Storage、Device、DispatchKey、Allocator、引用计数 | 任何算子实现 |
| ATen | aten/src/ATen/ | native_functions.yaml、所有 op 的 CPU/CUDA kernel、TensorIterator | Python 绑定、Autograd 自动微分 |
| torch/csrc | torch/csrc/ | pybind11 绑定、Autograd 引擎、JIT、Dynamo、Inductor | 用户面 API(nn / optim 等) |
| torch/ | torch/ | nn.Module、Optimizer、distributed、profiler | 核心数据结构、kernel |
native/ 的子目录就是 backend
ATen 真正放算子代码的地方是 aten/src/ATen/native/。
它的子目录名就是该算子针对哪种 backend 写的——
这种"按目录组织 backend"的模式让你很容易定位 kernel 文件。
| 子目录 | 对应 DispatchKey | 放什么 |
|---|---|---|
native/ | (顶层入口) | backend-agnostic 实现 / composite ops / yaml 配置 |
native/cpu/ | CPU | 向量化的 CPU 实现(SIMD intrinsics) |
native/cuda/ | CUDA | CUDA C++ / .cu kernel |
native/mkldnn/ | MkldnnCPU | OneDNN 加速的 CPU 算子 |
native/sparse/ | SparseCPU/CUDA | 稀疏张量专用实现 |
native/mps/ | MPS | Apple Metal Performance Shaders kernel |
如何找一个 op 的实现
固定流程:① 全局 grep native_functions.yaml 找到 op 的条目;② 看 dispatch: 段下对应 backend 的 kernel 名;③ 用 kernel 名 grep native/<backend>/。10 秒定位任意算子实现,比看源码索引快得多。
严格的依赖方向
箭头永远从下指上:c10 不依赖 ATen,ATen 不依赖 torch/csrc,torch/csrc 不依赖 torch/(Python)。反过来全部依赖。这就是为什么 c10 可以独立编译给嵌入式设备用(LibTorch 移动版)——它根本不知道 Python 存在。
native_functions.yaml 与代码生成管线
PyTorch 有 数千个算子 × 几十种 backend × 多种 dtype,
手写绑定不现实。
所有这些样板都由代码生成器从一个 yaml 文件自动产出——
理解了这条管线,你就知道源码里 aten/src/ATen/Functions.h 为什么会有上万行,
也知道编译 PyTorch 时为什么会跑很久。
一条真实的 yaml 入口
# aten/src/ATen/native/native_functions.yaml 节选(简化) - func: add.Tensor(Tensor self, Tensor other, *, Scalar alpha=1) -> Tensor device_check: NoCheck structured_delegate: add.out variants: function, method dispatch: SparseCPU, SparseCUDA: add_sparse MkldnnCPU: mkldnn_add ZeroTensor: add_zerotensor tags: pointwise # 同一个 op 的 .out 变体 —— "结构化 kernel" 的真正实现入口 - func: add.out(Tensor self, Tensor other, *, Scalar alpha=1, Tensor(a!) out) -> Tensor(a!) device_check: NoCheck structured: True # 声明这是 structured kernel structured_inherits: TensorIteratorBase ufunc_inner_loop: Generic: add (AllAndComplex, BFloat16, Half, ComplexHalf) ScalarOnly: add (Bool) dispatch: SparseCPU: add_out_sparse_cpu SparseCUDA: add_out_sparse_cuda MkldnnCPU: mkldnn_add_out
每个字段的含义
| 字段 | 作用 |
|---|---|
func: | 算子类型签名(自定义 IDL 语法),决定 Python/C++ 调用形式 |
variants: | function(torch.add(a,b))/ method(a.add(b)),逗号分隔多个 |
dispatch: | 每个 DispatchKey 对应的 kernel 函数名,未列出的 backend 走 fallback |
device_check: | 是否检查所有 Tensor 在同一设备,NoCheck 即跳过(用于 scalar) |
structured: | 是否使用现代 structured kernel 模式,启用后大量样板自动生成 |
structured_delegate: | 把 .Tensor 变体的实现"委托"给 .out 变体——避免重复代码 |
tags: | 元信息(pointwise/view/inplace_view),编译器和 Inductor 会用 |
代码生成管线
Structured Kernels — 现代 op 的标准模式
新加的 op 几乎都写成 structured kernel:把"形状推断 + 输出分配 + 实际计算"分离,
实际计算用 TensorIterator 复用所有 broadcast / type promotion / contiguity 处理。
开发者只需写 ~10 行的核心循环。
// aten/src/ATen/native/BinaryOps.cpp · structured kernel 的"meta" // 由 yaml 中 structured_delegate / structured: True 触发生成 TORCH_META_FUNC2(add, Tensor)( const Tensor& self, const Tensor& other, const Scalar& alpha) { build_borrowing_binary_op(maybe_get_output(), self, other); } // 实际的 CPU kernel —— 由 ufunc 装饰器生成向量化循环 // aten/src/ATen/native/cpu/BinaryOpsKernel.cpp void add_kernel(TensorIteratorBase& iter, const Scalar& alpha_scalar) { AT_DISPATCH_ALL_TYPES_AND2(kHalf, kBFloat16, iter.dtype(), "add_cpu", [&]() { auto alpha = alpha_scalar.to<scalar_t>(); cpu_kernel_vec(iter, [=](scalar_t a, scalar_t b) -> scalar_t { return a + alpha * b; }, [=](Vectorized<scalar_t> a, Vectorized<scalar_t> b) { return vec::fmadd(b, Vectorized<scalar_t>(alpha), a); }); }); }
"meta" 函数干什么
structured kernel 把 op 拆成两阶段:meta 函数只计算输出 shape 和 dtype(不做计算),由它准备好输出 Tensor,再调具体 backend kernel 填充数据。这种分离让 dry-run/FakeTensor 极其方便——只跑 meta 就能预测整个网络的所有中间形状。
dtype 宏的 zoo
PyTorch 有一组 AT_DISPATCH_* 宏,根据 dtype 选择具体模板实例。比如 AT_DISPATCH_ALL_TYPES_AND2(kHalf, kBFloat16, ...) 意为"所有标量类型加上 fp16 / bf16"。读源码遇到这串宏就知道,它在生成几十个特化版本。
TensorIterator 是隐藏王者
所有 pointwise / reduction op 都用 TensorIterator 统一处理 broadcast、stride 合并、向量化、并行 chunking。它本身就是个小型编译器——能自动决定"3 个张量哪些维度可以 fuse 成一个内循环"。Day 14 算子融合再展开。
functional / .out / inplace 三胞胎
大多数 op 有三个变体:add(返回新张量)、add_(就地修改 self)、add.out(写入指定 out)。structured 模式让三者共享一份计算逻辑,在 yaml 里通过 structured_delegate 连接。
TORCH_LIBRARY — 把 kernel 接入 Dispatcher
上面讲的是 PyTorch 内部 op 的"官方通道"(yaml + codegen)。 但你写自己的 op 时不会改 yaml —— 改了要重编译整个 PyTorch,工程量太大。 TORCH_LIBRARY / TORCH_LIBRARY_IMPL 提供了 第二条通道:在外部 C++ 文件里声明 schema、注册 kernel, 运行时挂进 Dispatcher。所有自定义算子都走这条路。
三种典型注册模式
| 宏 | 用途 | 示例 |
|---|---|---|
TORCH_LIBRARY(ns, m) | 定义一个全新的命名空间和它包含的 op schema | m.def("my_add(Tensor a, Tensor b) -> Tensor") |
TORCH_LIBRARY_IMPL(ns, key, m) | 给某个 backend(key)注册具体实现 | m.impl("my_add", my_add_cuda) |
TORCH_LIBRARY_FRAGMENT(ns, m) | 给已存在的命名空间(包括 aten)追加新 op | 给 PyTorch 自带 op 加新 backend 实现 |
关键的 schema 语法
// 类型签名遵循 IDL —— 不是 C++,也不是 Python "my_add(Tensor a, Tensor b) -> Tensor" // 最普通 "my_add(Tensor a, Tensor b, *, Scalar alpha=1) -> Tensor" // 默认参数 + kwargs-only "my_add_(Tensor(a!) self, Tensor b) -> Tensor(a!)" // inplace,用 ! 标 mutable alias "my_add.out(Tensor a, Tensor b, *, Tensor(a!) out) -> Tensor(a!)" // .out 变体 "my_split(Tensor a, int chunks) -> Tensor[]" // 返回 list // (a!) 是别名注解: // (a) — 表示这个张量是输入 a 的视图(共享 Storage,read-only alias) // (a!) — 同上,且会被修改(write alias),用于 inplace / out 变体 // 不写 — 完全独立的新张量
为什么要 schema
Dispatcher 必须知道每个 op 的输入数量、类型、可变性才能正确路由、生成 Autograd 包装、做 functionalization。schema 是这份契约的唯一来源,所以即使外部 op 也要先 m.def 一次。
(a!) 不是装饰,是关键
别小看 (a!) ——它告诉 functionalization pass 这个张量是就地修改。漏写会导致 torch.compile / functorch 出错(它们以为函数纯,实际有副作用)。所有 inplace op 必须正确标 alias。
注册 Autograd 行为
除了给具体 backend 注册,还可以给 Autograd key 注册——典型做法是用 torch::autograd::Function 写一个 forward + backward 的 C++ 类,再在 TORCH_LIBRARY_IMPL(myops, Autograd, m) 里把它注册进去。下一节会演示。
新硬件也用这套
NVIDIA 之外的厂商(AMD ROCm、Intel XPU、Apple MPS、华为昇腾 等)给 PyTorch 加 backend 全靠 TORCH_LIBRARY_IMPL(aten, <新 Key>, m)。新增一种硬件 = 注册一组 kernel,不动核心代码——这就是 Dispatcher 设计的胜利。
动手实践 — 写一个 my_add CUDA op
理论看完,亲手写一个完整的自定义算子:CUDA kernel + C++ 调度 + Python 绑定 + Autograd 支持。
不到 100 行代码,用 torch.utils.cpp_extension.load_inline
可以不写 setup.py、直接在 notebook 里热编译,
30 秒拿到一个能跑的算子。
步骤 1 — 写 CUDA kernel
// my_add_kernel.cu (在下一段代码里以字符串形式传入) #include <torch/extension.h> #include <cuda_runtime.h> template <typename scalar_t> __global__ void my_add_kernel( const scalar_t* __restrict__ a, const scalar_t* __restrict__ b, scalar_t* __restrict__ out, int n) { int i = blockIdx.x * blockDim.x + threadIdx.x; if (i < n) out[i] = a[i] + b[i]; } // C++ 入口:校验形状、launch kernel、返回结果 torch::Tensor my_add_cuda(const torch::Tensor& a, const torch::Tensor& b) { TORCH_CHECK(a.is_cuda() && b.is_cuda(), "inputs must be CUDA"); TORCH_CHECK(a.sizes() == b.sizes(), "shape mismatch"); auto a_c = a.contiguous(); auto b_c = b.contiguous(); auto out = torch::empty_like(a_c); int n = a_c.numel(); int threads = 256; int blocks = (n + threads - 1) / threads; AT_DISPATCH_FLOATING_TYPES(a_c.scalar_type(), "my_add_cuda", [&] { my_add_kernel<scalar_t><<<blocks, threads>>>( a_c.data_ptr<scalar_t>(), b_c.data_ptr<scalar_t>(), out.data_ptr<scalar_t>(), n); }); return out; } // 注册到 Dispatcher —— 两个宏 TORCH_LIBRARY(myops, m) { m.def("my_add(Tensor a, Tensor b) -> Tensor"); } TORCH_LIBRARY_IMPL(myops, CUDA, m) { m.impl("my_add", my_add_cuda); }
步骤 2 — 用 load_inline 热编译
import torch from torch.utils.cpp_extension import load_inline cuda_src = open("my_add_kernel.cu").read() cpp_src = "" # 没有额外 .cpp,全部代码在 .cu 文件里 myops = load_inline( name="myops", cpp_sources=cpp_src, cuda_sources=cuda_src, is_python_module=False, # 走 TORCH_LIBRARY,不走 pybind verbose=True, # 看 nvcc 输出 ) # 现在算子已经挂在 torch.ops.myops 下 a = torch.randn(1024, device="cuda") b = torch.randn(1024, device="cuda") c = torch.ops.myops.my_add(a, b) print(torch.allclose(c, a + b)) # True ✓
步骤 3 — 加上 Autograd 支持
上面注册的是无梯度版本。如果想让 my_add 能被 backward() 反向传播,
最简单的方法是在 Python 端用 torch.autograd.Function 包一层——
不必写 C++ Autograd 类。
class MyAddFunction(torch.autograd.Function): @staticmethod def forward(ctx, a, b): ctx.save_for_backward(a, b) # 反向需要的输入 return torch.ops.myops.my_add(a, b) @staticmethod def backward(ctx, grad_output): # 加法的局部梯度 = 1,直接把上游梯度传下去 return grad_output, grad_output # 包装成 Python 函数 def my_add(a, b): return MyAddFunction.apply(a, b) # --- 测试 backward 是否正常 --- a = torch.randn(3, device="cuda", requires_grad=True) b = torch.randn(3, device="cuda", requires_grad=True) y = my_add(a, b).sum() y.backward() print(a.grad, b.grad) # tensor([1., 1., 1.], device='cuda:0') # tensor([1., 1., 1.], device='cuda:0') # ✓ 与 PyTorch 内置 add 完全一致
三种构建方式对比
| 方法 | 需要 | 编译时机 | 适用 |
|---|---|---|---|
load_inline | 源码字符串 | 运行时 | 实验 / notebook / 快速原型 |
load (sources=) | .cu/.cpp 文件 | 运行时 | 多文件项目,不想写 setup.py |
setup.py + BuildExtension | 完整 Python 包结构 | 预编译 | 发布给别人 pip 安装(如 flash-attn) |
flash-attn 就是这样写的
Tri Dao 的 flash-attention 仓库本质就是个超级版本的"自定义算子":CUDA kernel(几千行)+ setup.py + Python 包装。结构和你刚写的 my_add 一样,只是 kernel 复杂度差几个数量级。看懂 my_add 之后,flash-attn 源码不再是天书。
给 torch.compile 友好的算子
想让自定义算子被 torch.compile 追踪,还要注册一个 meta kernel(只返回形状/dtype,不分配数据)。模板:TORCH_LIBRARY_IMPL(myops, Meta, m)。Day 13 学 Inductor 时再深入。
常见编译错误
① 找不到 torch/extension.h → conda 环境没装 torch dev headers,确认 torch.utils.cpp_extension.CUDA_HOME 指向真实 CUDA。
② nvcc -gencode 不识别 → 设 TORCH_CUDA_ARCH_LIST="8.0 9.0"(对应 A100/H100)。
③ linking error → 多半是 ABI 不一致,用 verbose=True 看实际命令。
今天的交付物
把 my_add 三步全跑通,保存 my_add_kernel.cu 和测试脚本。加分项:写个 my_addmm(矩阵乘加),复用 Day 05 的 tiled GEMM kernel——你将得到一个跑得通、可微分、能被 PyTorch 模型直接调用的自定义算子。
常见疑问
Q1
c10 和 ATen 都在 aten/src/ATen/ 下,怎么区分?
+
不在同一个目录。c10 在仓库根的 c10/ 目录,完全独立的库;ATen 在 aten/src/ATen/。
关系:ATen 依赖 c10,反过来不行。c10 提供 Tensor / Storage / DispatchKey 这些核心数据结构和 Dispatcher 框架,但不含任何 op 实现;ATen 用 c10 的 Tensor 写出所有算子。
命名来历:c10 是 "Caffe2 + ATen" 的合并产物(2018 年 PyTorch 和 Caffe2 合并时把共享的核心抽象抽出来叫 c10);ATen 取自 "A Tensor library",一个独立的 C++ 张量库,后被 PyTorch 收编。
Q2
为什么 add 在 yaml 里要写成两条(add.Tensor 和 add.out)?
+
因为 PyTorch 的一个 op 通常有三个变体:
(1) functional(add):返回新张量,最常用;
(2) inplace(add_):就地修改 self;
(3) out(add.out):写入用户提供的输出张量。
三者计算逻辑相同,只是结果去向不同。structured kernel 模式让一份核心逻辑由这三个变体共享——具体做法是把真正的算子定义为 .out 变体,functional 和 inplace 通过 structured_delegate: add.out 自动委托过去。少写 2/3 的代码,且保证三者行为完全一致。
Q3 改了 native_functions.yaml,要不要从头编译 PyTorch? +
要重新跑 codegen + 编译受影响的文件,但不是从零。增量编译机制如下:
(1) tools/setup_helpers/generate_code.py 检测 yaml 变化,重新生成 Functions.h、RegisterXXX.cpp 等;
(2) 编译系统按依赖图编译被影响的目标文件;通常只重编几十到几百个文件,而非全量(~3000)。
实际开发体验:第一次编译 PyTorch 几十分钟到 1 小时,后续改一个 op 通常 1–3 分钟。ccache 能进一步加速。这就是为什么开发自定义算子绝大多数情况下用 cpp_extension——它不动 PyTorch 源码,只编你自己那几行,几秒到几十秒。
Q4 TORCH_LIBRARY_IMPL 注册到 Autograd key 和 Python 端 torch.autograd.Function,有什么区别? +
两种途径殊途同归,选哪个看场景。
C++ Autograd 注册(TORCH_LIBRARY_IMPL(ns, Autograd, m)):性能最优,完全在 C++ 端构图;但要写 C++ 类(torch::autograd::Function)+ 实现 forward/backward,代码量大,调试也烦。用于:对外发布、需要 trace 进 torch.compile 的算子。
Python torch.autograd.Function:简单太多,几行 Python 就能加梯度;代价是 forward 时会走一次 Python ↔ C++ 边界。用于:实验、原型、个人项目——开发体验吊打前者。
实际经验:先用 Python 跑通,确认数学没错,有性能或部署需求再迁到 C++。
Q5
为什么很多 op 的 yaml 里有 tags: pointwise?这些 tag 重要吗?
+
非常重要,但只对编译器/调度器可见,对用户透明。
tags: 是 op 的元属性,告诉下游"这个 op 是什么类型的运算"。常见 tag:
• pointwise:逐元素运算,可以和其他 pointwise op 融合(Inductor 的核心优化);
• view:零拷贝视图(transpose / slice),不分配新内存;
• inplace_view:就地修改且改变 shape;
• nondeterministic_seeded:有随机性,需要 seed 管理。
实际作用:torch.compile 看见 pointwise tag 就考虑算子融合,看见 view tag 就跳过实际数据移动。正确的 tag 直接影响编译后的性能——这就是 PyTorch op 元数据系统的价值。
复盘问题
- 画一张 PyTorch 分层图(c10 / ATen / torch/csrc / torch),标出每层目录路径和不能跨层引用的依赖方向。
- 用
grep在 PyTorch 源码里找到at::relu的 CUDA 实现文件路径——演练"从 yaml 到 kernel"的检索流程。 - 解释为什么 structured kernel 比传统写法节省代码,并举例说明 meta 函数与 kernel 函数的分工。
- 用
cpp_extension.load_inline实现一个my_relu(forward+backward),与 PyTorch 内置 ReLU 对比梯度。 - 设想给 PyTorch 加一个名为 NPU 的新硬件,你需要分别在 c10 / ATen / torch/csrc 哪些位置做改动?哪些可以完全不动?
今日检查清单
- 能用一句话说清 c10、ATen、torch/csrc、torch 四层各自的职责
- 能解释为什么所有依赖方向都从上指下,以及为什么 c10 能独立编译
- 能读懂一条 native_functions.yaml 入口(func / variants / dispatch 等字段)
- 理解 structured kernel 的 meta + kernel 分离设计
- 能区分 TORCH_LIBRARY / TORCH_LIBRARY_IMPL / TORCH_LIBRARY_FRAGMENT 三个宏
- 能解释 schema 中
(a!)alias 注解的含义和重要性 - 用 load_inline 成功编译并运行 my_add CUDA op,结果与
a + b一致 - 给 my_add 加上
torch.autograd.Function包装,backward 梯度通过验证
推荐阅读
PyTorch Custom C++ and CUDA Extensions 教程
官方 tutorial,从最小 load_inline 到完整 setup.py 包发布全流程。今天 hands-on 部分的标准参考。
aten/src/ATen/native/README.md
ATen native 目录自己的 README,详细解释 native_functions.yaml 每个字段、structured kernel 的写法,以及 codegen 的工作原理。是开发者文档,但写得清晰。
ezyang — Let's talk about the PyTorch dispatcher
Day 08 已推荐,本日重点看后半部分关于 op registration 和 codegen 的细节。配合 native_functions.yaml 一起读效果最好。
flash-attention 仓库
真实世界自定义 CUDA op 的标杆。重点看 csrc/flash_attn/ 下的 kernel 注册方式和 setup.py,验证今天学的所有概念。
PyTorch DevCon — Custom Ops
PyTorch 团队历年开发者大会演讲,讲自定义算子的演进(从 pybind11 到 TORCH_LIBRARY 再到 torch.library)、与 torch.compile 的集成。
Day 11 预告
显存管理 — caching allocator · OOM 排查 · 碎片化
到现在我们一直把显存当无限大,真实训练中"CUDA out of memory"是日常。Day 11 拆开 PyTorch 的 caching allocator:为什么 empty_cache() 释放不掉某些 block?expandable_segments 怎么帮你减少碎片?显存峰值定位的标准工作流是什么?这是 Phase 2 训大模型前的必修课。