一行 torch.add 究竟走过了什么路?——PyTorch 从 Python 到 CUDA 的完整调度链
本文深入剖析了PyTorch中torch.add(a,b)调用的完整执行路径,揭示了其核心调度机制。PyTorch通过七层抽象完成调用,关键组件是Dispatcher系统,它基于TensorImpl中的DispatchKeySet(64位掩码)动态决定算子执行路径。文章详细讲解了:1)PyTorch的三层架构(c10/ATen/torch);2)Tensor作为轻量级手柄的设计;3)Dispatc
如果你在 Python 里写下这一行:
c = torch.add(a, b) # a, b 是 CUDA Tensor,且 requires_grad=True
它最终会落在 GPU 上跑一段 CUDA kernel。但中间发生了什么?
很多人会含糊地说"Python 调了 C++,C++ 调了 CUDA"。这不算错,但远远不够——PyTorch 实际上经过了至少七层抽象才完成这次调用,而其中真正决定"走哪条路"的,是一个叫做 Dispatcher 的东西。
这篇文章把这一路彻底走一遍。读完你会知道:
at::Tensor为什么这么"轻",真正的数据结构在哪里- Dispatcher 凭什么能优雅地处理 CPU/CUDA/Autograd/Sparse 这些维度的组合爆炸
- 为什么在源码里
grep "add_kernel_cuda"经常什么都找不到 - Autograd 看起来很神秘,本质上其实只是 Dispatcher 的一个 key
- 自己怎么去探索、调试和验证这一切

一、三层架构:c10 / ATen / torch
读 PyTorch 源码之前必须先认清这三个目录,它们职责完全不同:
| 目录 | 定位 | 关键内容 |
|---|---|---|
c10/ |
最底层基础设施 | TensorImpl、Storage、Device、DispatchKey |
aten/src/ATen/ |
张量算子库 | 算子实现、TensorIterator、代码生成产物 |
torch/ |
上层框架 | Python 绑定、Autograd 引擎、nn、torch.compile |
记住这个分层很重要:当一个 Python 调用进来,它自上而下穿过 torch → ATen → c10,最后再调起 CUDA Runtime。每一层都有自己的关注点,互不耦合。
二、Tensor 的真身:一个"轻量手柄"
Python 里的 torch.Tensor 看起来像是个 numpy array,但底层是这样一条引用链:
Python: x (THPVariable 对象)
│ cdata 字段
▼
C++ at::Tensor ← 这只是一个 intrusive_ptr,几乎没分量
│ impl_
▼
TensorImpl ← 元数据全在这里
├── Storage (data_ptr, 设备无关的内存句柄)
├── sizes_strides (形状)
├── dtype
└── DispatchKeySet ★ 决定算子走哪条路!
at::Tensor 本身只持有一个引用计数指针,可以随便按值传递。所有重要的状态都在 TensorImpl 上,而其中最关键的字段——也是这篇文章的主角——是 key_set_:一个 64-bit 的 bitmask。
一个普通的 CPU Tensor,它的
key_set里有CPUbit。 一个 CUDA Tensor,里面有CUDAbit。 如果还带梯度,再加一个AutogradCUDAbit。 如果它被某个__torch_dispatch__包住,再加一个Pythonbit。
这个 bitmask 完全决定了 Dispatcher 把这次调用送去哪个 kernel。
三、Dispatcher:PyTorch 的中枢神经
打开 aten/src/ATen/core/dispatch/Dispatcher.h,最热的那条路径其实只有几行:
template <class Return, class... Args>
inline Return Dispatcher::call(
const TypedOperatorHandle<Return(Args...)>& op,
Args... args) const {
// 1. 从所有 Tensor 参数里聚合 DispatchKeySet(按位 OR)
auto dispatchKeySet = op.operatorDef_->op.dispatchKeyExtractor()
.template getDispatchKeySetUnboxed<Args...>(args...);
// 2. 在 op 的 dispatch table 里查表,取最高优先级 key 对应的 kernel
const KernelFunction& kernel = op.operatorDef_->op.lookup(dispatchKeySet);
// 3. 直接调用(unboxed 快路径)
return kernel.template call<Return, Args...>(op, dispatchKeySet, args...);
}
三步:聚合 key → 查表 → 调用。整个 PyTorch 几千个算子、几十种后端,调度逻辑就压缩在这么几行里。
为什么能这么干净?因为它把"决策"和"实现"彻底解耦了:
- 决策靠
DispatchKeySet:调用现场动态决定走哪条路 - 实现靠各路 kernel 在静态初始化时往全局注册表里注册自己
注册的语法是这样:
TORCH_LIBRARY_IMPL(aten, CUDA, m) {
m.impl("add.Tensor", TORCH_FN(at::native::add_cuda));
}
每个 .cu / .cpp 文件里都散落着这种宏。它们在二进制加载时把自己挂到全局 Dispatcher 上,运行时按需查找。这是一种**开放注册(open registration)**模式——你想加一个新后端(比如 XPU、MPS、自定义加速器),不需要改任何核心代码,只要往里注册新 kernel 即可。
四、DispatchKey:64 位决定一切
c10/core/DispatchKey.h 定义了这个 enum,按优先级从高到低大致是:
FuncTorchDynamicLayerFrontMode ← torch.func 函数变换的最外层
Python ← __torch_dispatch__ 的入口
Autograd / AutogradCUDA ← 梯度跟踪
CUDA ← GPU 原生 kernel
CPU ← CPU 原生 kernel
CompositeImplicitAutograd ← 跨设备复合实现
DispatchKeySet 的核心操作只有一个:highestPriorityTypeId(),取出最高位作为本次调度的目标。
这里有个非常巧妙的设计——优先级排序本身就编码了语义:
- Python 拦截器(如果存在)总是最先看到调用
- 然后是 Autograd 层,负责构图
- 最后才是真正的设备 kernel
这就解释了 Autograd 是怎么"无感插入"的:它不是装饰器,也不是猴子补丁,它就是一个比 CUDA 优先级高的 dispatch key。一个带 requires_grad=True 的 CUDA Tensor,它的 key_set 里既有 AutogradCUDA 又有 CUDA,Dispatcher 第一次查表先命中 AutogradCUDA,跑完构图逻辑后通过 redispatch 把这个 key 临时排除,重新查表,这次就命中 CUDA 了。
第一次 dispatch:
key_set = {AutogradCUDA, CUDA, ...}
→ highest = AutogradCUDA → 跑 VariableType kernel(保存反向所需的输入)
VariableType kernel 内部 redispatch:
key_set = {AutogradCUDA, CUDA, ...} & ~AutogradKeys
= {CUDA, ...}
→ highest = CUDA → 跑真正的 CUDA kernel
整个 Autograd 的"魔法",归结起来就是一个临时位运算。
五、算子注册的真相:为什么 grep 不到 add 的实现
新人读 PyTorch 源码时常常困惑:
我想看
torch.add的 CUDA 实现,grep "add_kernel_cuda"、grep "REGISTER_DISPATCH(add_stub",怎么源码里找不到?
答案是:它根本没在源码里手写。
PyTorch 有一个非常重要的事实:大量代码是构建时由 torchgen 自动生成的。生成的产物在 build/aten/src/ATen/ 下,包括 RegisterCUDA.cpp、Operators_*.cpp 等等。不构建一次,这些文件不存在;不知道这件事,就永远找不到 add 的真身。
生成的入口在哪?aten/src/ATen/native/native_functions.yaml——一个 1.8 万行的 YAML 文件,每个算子都在里面声明 schema 和分派规则。
以 add.out 为例(这是 add 的真正实现入口,add.Tensor 通过 structured_delegate 委托给它):
- func: add.out(Tensor self, Tensor other, *, Scalar alpha=1, Tensor(a!) out) -> Tensor(a!)
device_check: NoCheck
structured: True
structured_inherits: TensorIteratorBase
ufunc_inner_loop:
Generic: add (AllAndComplex, BFloat16, Half, ComplexHalf)
ScalarOnly: add (Bool)
dispatch:
SparseCPU, SparseMeta: add_out_sparse_cpu
SparseCUDA: add_out_sparse_cuda
MPS: add_out_mps
tags: pointwise
注意一个关键点:dispatch 块里没有 CPU、没有 CUDA。为什么?因为 ufunc_inner_loop 这个声明告诉 codegen:
"这是个 elementwise 算子,CPU 和 CUDA 的代码你自己生成,向量化和 kernel launch 都不用我管。"
开发者实际手写的代码只有两段:
① META 函数——只算形状和 dtype,不碰数据(BinaryOps.cpp):
TORCH_META_FUNC2(add, Tensor)(
const Tensor& self, const Tensor& other, const Scalar& alpha) {
build_borrowing_binary_op(maybe_get_output(), self, other);
native::alpha_check(dtype(), alpha);
}
② elementwise 内层逻辑——一行数学公式:
template <typename scalar_t>
struct add_functor {
scalar_t operator()(scalar_t a, scalar_t b, scalar_t alpha) {
return a + alpha * b; // 就这一行
}
};
剩下的 codegen 全包了:
- CPU 路径:套上
cpu_kernel_vec,吐出 AVX2 / AVX-512 向量化代码 - CUDA 路径:套上
gpu_kernel,生成 CUDA kernel launch - 注册:在
RegisterCPU.cpp/RegisterCUDA.cpp里完成TORCH_LIBRARY_IMPL注册
所以 add 的全部"实现",开发者写了大约 10 行代码。对于卷积、attention 这类复杂算子,才需要老老实实手写完整的 TORCH_IMPL_FUNC 和 CUDA kernel 文件。
这套机制是 PyTorch 能稳定支持几千个算子 × 十几种后端 × dtype 组合的根本原因——把样板代码外包给编译期。
六、完整链路:c = torch.add(a, b) 的七层旅行
把所有东西串起来,假设 a、b 是 CUDA Tensor 且 requires_grad=True:
① Python 层
c = torch.add(a, b)
↓
② Python ↔ C++ 绑定 (生成代码)
torch/csrc/autograd/python_torch_functions.cpp
THPVariable_add() —— 解包 PyObject 拿到 at::Tensor
↓
③ 算子入口 (生成代码)
build/aten/src/ATen/Operators_*.cpp
at::add() —— 查到 op handle,进入 Dispatcher
↓
④ Dispatcher.call()
aten/src/ATen/core/dispatch/Dispatcher.h
聚合参数 key_set = {AutogradCUDA, CUDA, ...}
最高位 → AutogradCUDA → 查到 VariableType kernel
↓
⑤ Autograd kernel (生成代码)
- 保存 self/other 到 AddBackward0 节点
- AutoDispatchBelowADInplaceOrView 守护
- redispatch (排除 Autograd key)
↓
⑥ 再次进入 Dispatcher
key_set = {CUDA, ...}
最高位 → CUDA → 查到 CUDA kernel
↓
⑦ CUDA kernel (生成 + 手写 lambda)
gpu_kernel(iter, [] __device__ (scalar_t a, scalar_t b) {
return a + alpha * b;
});
↓
⑧ GPU 硬件执行
整个流程里,Dispatcher 出场了两次。第一次走 Autograd(构图),第二次走 CUDA(计算)。没有什么神秘的"反向钩子",只有一次 key 的临时屏蔽和重新分发。
七、自己动手探索
最后给几个亲手验证的入口,比读多少文章都管用:
1. 打开 Dispatcher 调度跟踪
TORCH_SHOW_DISPATCH_TRACE=1 python -c "
import torch
a = torch.ones(2).cuda().requires_grad_(True)
b = torch.ones(2).cuda()
c = a + b
"
你会看到调度链一层一层地打印出来,AutogradCUDA → CUDA 看得清清楚楚。
2. 查看一个 Tensor 的 DispatchKeySet
import torch
x = torch.randn(3, device='cuda', requires_grad=True)
print(torch._C._dispatch_key_set(x))
# DispatchKeySet(CUDA, AutogradCUDA, ADInplaceOrView, ...)
3. 用 __torch_dispatch__ 拦截任意算子
from torch.utils._python_dispatch import TorchDispatchMode
class Tracer(TorchDispatchMode):
def __torch_dispatch__(self, func, types, args=(), kwargs=None):
print(f"→ {func}")
return func(*args, **(kwargs or {}))
with Tracer():
a = torch.randn(3, device='cuda')
b = torch.randn(3, device='cuda')
c = a + b
# 输出: → aten.randn.default
# → aten.randn.default
# → aten.add.Tensor
这就是为什么 PyTorch 在 Python 这个 DispatchKey 上专门挂了一个 fallback——它让 Python 用户拥有了拦截整个框架任何算子的能力,构成了 torch.compile、functorch、量化、各种自定义后端的底层基础。
写在最后
如果说要总结 PyTorch 的核心设计哲学,我会说是这两条:
- 用 DispatchKey 的优先级编码层语义。Autograd、Python 拦截、Vmap、Functionalize 都不是特殊机制,它们都是 dispatch key 而已。一个统一的 Dispatcher 就把这些维度全部解耦了。
- 用 codegen 外包样板,让人类只写"非平凡的部分"。
add的 yaml + 10 行 C++ 能生成出 CPU 向量化、CUDA kernel launch、dispatcher 注册、Python binding、Autograd 反向骨架的全套代码。
理解了这两点,你再去读 PyTorch 任何一个角落——量化、稀疏、MPS 后端、torch.compile——都会发现是同一套机制的不同应用。
PyTorch 不是"用 C++ 包装的 numpy",它是一个以 Dispatcher 为中心的、可扩展的张量计算调度系统。Python 只是它最常见的前端,CUDA 只是它最常见的后端。
更多推荐


所有评论(0)