搞 PyTorch 做训练或者部署,时间长了基本都会碰到一个绕不开的问题——现有算子不够用。要么是某个核心算子跑得太慢,要么是想把好几个操作融合进一次 GPU Kernel,要么是某个反向传播逻辑在 autograd 里绕来绕去,既难维护又费显存。这时候,你就需要用自定义 C++/CUDA 算子来解决问题。说白了,PyTorch 并没有限制你只能用它的内置算子,它提供了完整的扩展机制,允许你把 Python 层、C++ 层、甚至 CUDA Kernel 层全部打通。这套能力,是每个做模型优化、推理部署、特殊算子开发的人迟早都要啃下的硬骨头。
这篇文章我会从环境搭建、C++ 扩展、CUDA Kernel、反向传播,到编译调试和性能对照,一条线走下来。不会只给你看能跑通的代码,还会解释每一步为什么这么做,有哪些文档里不会写的坑。不管你是第一次写自定义算子,还是已经写过但总被编译问题卡住,这篇内容应该都能让你省下不少试错时间。
1. 为什么需要自定义 C++/CUDA 算子
1.1 一段代码执行时,到底发生了什么
很多人写 PyTorch 代码时,会觉得 Python 调用算子是“直接让 GPU 干活”。其实不是。你执行一行output = torch.relu(x)的时候,背后经历了 Python 解释器调用、PyTorch 的 C++ dispatcher 分派、ATen 算子选择、kernel launch 等多个环节。GPU 真正算的时间往往很短,但数据在 Python 和 C++ 之间来回传递、算子分发、内存分配这些开销却非常可观。
可以拿“点外卖”来类比:你每调用一个算子,就相当于下一单外卖。外卖本身可能 5 分钟就送到了,但下单、沟通、等骑手这些过程加起来可能花掉半小时。如果同一件事你能在一家店一次点齐,效率自然高得多。自定义 C++/CUDA 算子做的事情,就是把原本要多次下订单的活合并成一次,甚至把后厨一起搬到 GPU 里。
1.2 什么场景必须自己写算子
不是所有项目都需要自定义算子,但以下三类场景,几乎只有这条路能走:
- 性能瓶颈在算子组合。比如模型里连续做了
x * scale + bias、clamp、relu,每一步都是一个独立的 kernel,都要启动、读显存、写显存。把这些操作合并成一个 kernel,访存次数可以少一半以上,整体延迟下降非常明显。 - 现有算子无法表达的算法。比如某些特殊的量化逻辑、非标准的池化、自定义的索引变换,用基础算子拼起来要么复杂度太高,要么根本拼不出来。
- 大量使用算子对硬件性能的挑战。当你的算法本身计算密度很高、访存模式又很特殊(比如小矩阵的批量运算、稀疏数据的 gather/scatter),直接依赖现成算子往往压不满硬件性能,必须针对算子做循环展开、Shared Memory 优化、Warp 级协作这些底层优化。
这三类场景里,最容易被忽视的是第二种。很多人在 Python 里用torch.where、torch.gather硬凑,代码又长又难调试。其实花半天时间写一个 C++ 扩展,逻辑会清晰得多,速度还更快。
这里我把三种实现方式放在一起做个对比:
| 实现方式 | 开发成本 | 性能潜力 | 适用场景 |
|---|---|---|---|
| 纯 PyTorch 算子组合 | 低 | 中低 | 逻辑简单、不追求极致性能 |
| C++ 扩展(无 CUDA) | 中 | 中 | 融合 CPU 上的多个操作、逻辑复杂 |
| C++/CUDA 扩展 | 高 | 高 | GPU 上的高性能融合算子 |
对于大多数真实项目,CUDA 算子是最终目标,但你最好先走一遍 C++ 扩展的流程。为什么?因为编译链路、绑定方式、Tensor 操作都是同一套,先学会在 CPU 上把逻辑写对,再移植到 CUDA Kernel,调试成本能低一个数量级。
2. 环境准备与工具链搭建
2.1 版本匹配:PyTorch、CUDA、编译器三者关系
自定义算子最容易翻车的不是代码,而是环境。这里有一个基本规则:你用来编译算子的 CUDA 版本,应该尽量接近 PyTorch 本身编译时的 CUDA 版本。为什么?PyTorch 的 c10::cuda 运行时、缓存分配器、流管理这些逻辑,都是基于特定 CUDA runtime 编译出来的。如果你用 CUDA 12.2 编译一个扩展,去加载一个用 CUDA 11.8 编译的 PyTorch,一般不会立刻崩,但遇到跨版本兼容问题会特别难排查。
安装 PyTorch 时的选择也影响很大。以常见的两个版本组合为例:
| PyTorch 版本 | 适配 CUDA 版本 | 编译扩展需要的 nvcc |
|---|---|---|
| 2.0.x | CUDA 11.7 / 11.8 | 11.7+ |
| 2.1.x | CUDA 11.8 / 12.1 | 11.8 或 12.1 |
我的建议是:开发机装哪个 CUDA Toolkit 就选对应的 PyTorch 安装包。最好直接通过官方源安装对应版本,比如pip install torch --index-url https://download.pytorch.org/whl/cu118,装完之后用torch.version.cuda确认。
编译器也要跟着平台走。Linux 上最常见的是 g++,Windows 上必须是 MSVC(Visual Studio 2015-2022 里选择“使用 C++ 的桌面开发”工作负载)。很多人在 Windows 上装完 PyTorch 之后发现torch.utils.cpp_extension.load一直报错,绝大多数情况就是 MSVC 没装,或者装完没打开 VS 的 x64 终端。
2.2 快速检查环境是否就绪
准备环境不需要手动折腾太多,重点是用命令列出关键变量。下面这组命令是我每次新环境必跑的:
python -c "import torch; print(torch.__version__)" python -c "import torch; print(torch.version.cuda)" python -c "import torch; print(torch.cuda.is_available())" nvcc --version如果你在 WSL2 里做开发,一定要确认 Windows 侧的 NVIDIA 驱动处于较新版本,并且 WSL 内能看到/usr/lib/wsl/lib下的 CUDA 运行时。WSL2 里跑 GPU 算子是完全可以的,唯一的坑是路径解析和CUDA_HOME的设置,通常需要显式指定。具体命令在不同发行版上略有差异,但不复杂,在 WSL2 里装 CUDA Toolkit 时顺手确认一下nvidia-smi能输出信息就成功了大半。
我最初在 WSL2 里折腾的时候,最尴尬的是一登录就看到 WSL 里没装 nvcc,但 PyTorch 明明能识别 GPU。原因就是 PyTorch 通过驱动加载 CUDA runtime,而编译扩展还需要完整的 Toolkit。所以记得补装 nvcc,并且把/usr/local/cuda/bin加进PATH。
2.3 编译后端:Ninja 和 setuptools
PyTorch 扩展默认使用 Ninja 做增量编译,Windows 上要是没装 Ninja,build_ext --inplace基本必挂。Linux 上我建议直接用 pip 装好:
pip install ninjaNinja 的好处是增量编译非常快。你改了一个.cu文件,重新python setup.py build_ext --inplace时,只有对应的目标文件会重建,其他文件会跳过。这对 CUDA 算子开发来说很重要,因为.cu单次编译经常要几十秒甚至更久。
Windows 上还要注意,Ninja 需要配合 MSVC 的cl.exe环境变量使用,建议直接在“Developer Command Prompt for VS”里跑编译命令,不要把 VS 的cl.exe目录手动加进 PATH,容易和其他编译器冲突。
3. C++ 扩展:第一个自定义算子全流程
3.1 torch/extension.h 和 ATen 基础
C++ 扩展的入口就是#include <torch/extension.h>,这个头文件把 pybind11、ATen、c10 的核心接口全部拉进来了。ATen 是 PyTorch 的张量库,里面最核心的类型是at::Tensor。你在 Python 里拿到的 Tensor,在 C++ 里就是at::Tensor。
上手阶段需要掌握几个常用 API:
tensor.sizes()或者tensor.size(i):取形状,返回数组。tensor.data_ptr<float>():拿到底层数据指针,写入或读取时都要用到。tensor.scalar_type():判断数据类型,写算子时最好显式检查,免得 float 和 double 混用。tensor.device():查看张量在 CPU 还是 GPU 上。
还有个细节值得提:at::Tensor不止是一块数据,它携带了 autograd 元信息(比如是否 requires_grad)。在 C++ 扩展里你一般处理的是原始数据,反向传播的逻辑可以放在 Python 侧,也可以直接在你的 C++ 函数里自定义。
3.2 写一个带绑定的算子
我从一个非常实用的例子开始:实现“截断后加偏置”的算子clip_add。它的语义是:对输入 x,先 clip 到 [min, max],然后加上一个标量 bias。用 Python 组合是torch.clamp(x, min, max) + bias,但这会产生一个中间张量,多一次显存读写。自定义算子可以一步完成。
C++ 实现:
#include <torch/extension.h> at::Tensor clip_add_cpu( const at::Tensor& x, double min_val, double max_val, double bias) { TORCH_CHECK(x.scalar_type() == at::kFloat, "Input must be float tensor"); auto y = torch::empty_like(x); const float* x_ptr = x.data_ptr<float>(); float* y_ptr = y.data_ptr<float>(); int64_t n = x.numel(); for (int64_t i = 0; i < n; ++i) { float v = x_ptr[i]; if (v < min_val) v = (float)min_val; if (v > max_val) v = (float)max_val; y_ptr[i] = v + (float)bias; } return y; } PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { m.def("clip_add", &clip_add_cpu, "Clip and add bias (CPU)"); }注意PYBIND11_MODULE的宏,TORCH_EXTENSION_NAME会被 setup.py 里的名称替换。这种方法的好处是,编译出来的模块名和你在 setup.py 里定义的名字自动一致,不需要手动在函数名和模块名之间来回倒。
绑定时m.def的第三个参数是函数说明文档,这个会体现在help(module.func)里,写清楚对团队维护很有帮助。
3.3 setup.py 与 import 加载
编译这一步用torch.utils.cpp_extension.CppExtension最省事。在项目根目录放一个setup.py:
from setuptools import setup from torch.utils.cpp_extension import CppExtension, BuildExtension setup( name="custom_ops", ext_modules=[ CppExtension( name="custom_ops_cpp", sources=["clip_add.cpp"], ) ], cmdclass={"build_ext": BuildExtension.with_options(no_python_abi_suffix=True)}, )sources里是 C++ 源文件列表。no_python_abi_suffix=True是我个人的偏好,原因是在一些环境里,abi 后缀会让生成的.so文件名带上一串乱七八糟的 Python 版本标记,手动 import 时容易对不上号。
编译命令:
python setup.py build_ext --inplace完成后,同目录会出现custom_ops_cpp.so(Linux)或者.pyd(Windows)。然后在 Python 里直接 import:
import torch import custom_ops_cpp x = torch.tensor([1.0, 5.0, -3.0, 8.0]) y = custom_ops_cpp.clip_add(x, 0.0, 6.0, 1.0) print(y) # tensor([2., 6., 1., 7.])到这里为止,你已经拥有了一个能跑通全链路的自定义算子。虽然它还没有用到 CUDA,但整个流程和后面要讲的 GPU 版完全一致,只是把CppExtension换成CUDAExtension,把.cpp换成.cu而已。
这里有个容易忽略的点:torch::empty_like(x)分配了和 x 相同形状、相同 device、相同 dtype 的张量。它不会确保数据为 0,所以你必须显式把所有元素都写一遍。我见过不少新手在这里用empty_like后漏掉了部分路径的赋值,导致输出张量里有随机垃圾值。
4. CUDA 算子:真正压榨 GPU 性能
4.1 CUDA Kernel 基本结构
C++ 扩展解决的是“逻辑复杂”和“代码清晰”的问题,但真正让性能质变的,是 CUDA Kernel。CUDA 代码写在.cu文件里,基本结构是三段式:
- 一个
__global__函数,也就是 Kernel,定义每个 CPU 线程如何被 GPU 的多个线程执行。 - 一个 C++ 风格的入口函数,负责启动 Kernel。
- 在
.cpp里做 pybind11 绑定,入口函数声明放在头文件里。
以最简单的逐元素操作来说,Kernel 的写法通常是:
__global__ void clip_add_kernel( const float* x, float* y, float min_val, float max_val, float bias, int64_t n) { int64_t idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx < n) { float v = x[idx]; v = fminf(fmaxf(v, min_val), max_val); y[idx] = v + bias; } }启动 Kernel 时,你需要告诉 GPU 要用多少线程块、每个块多少个线程:
void clip_add_cuda(const at::Tensor& x, double min_val, double max_val, double bias, at::Tensor& y) { int64_t n = x.numel(); const float* x_ptr = x.data_ptr<float>(); float* y_ptr = y.data_ptr<float>(); int threads = 256; int64_t blocks = (n + threads - 1) / threads; clip_add_kernel<<<blocks, threads>>>(x_ptr, y_ptr, (float)min_val, (float)max_val, (float)bias, n); }threads=256是个默认值,并不是所有情况最优。跑更重的 Kernel 时,可以考虑 128 或者 512 并配合 occupancy 计算。但逐元素算子 256 是很稳的起始点,具体调优要看 profiling 结果。
4.2 融合算子:为什么一次 Kernel 比三次快
知道了基本写法,我们来看一个真正体现实战价值的融合算子。假设模型里有这样一段逻辑:
y = clamp(x * scale, min_val, max_val) + bias
用原生 PyTorch,大概要 4 次 Kernel 启动:乘法、clamp、加法(乘法结果要写进去再读出来)。用融合算子,一次启动就能完成:
__global__ void fused_scale_clamp_add_kernel( const float* x, float* y, float scale, float min_val, float max_val, float bias, int64_t n) { int64_t idx = blockIdx.x * blockDim.x + threadIdx.x; if (idx < n) { float v = x[idx] * scale; v = fminf(fmaxf(v, min_val), max_val); y[idx] = v + bias; } }这看起来很简单,但性能上的一次 Kernel vs 多次 Kernel 差异,在 GPU 上非常明显。每次 Kernel 启动有固定开销,虽然只有几微秒,但如果你在循环里调用,几微秒会被放大成千上万倍。更重要的是,多次 Kernel 会把中间张量写回显存、再读出来,显存带宽在这种操作上会被大量浪费。融合算子通过减少 HBM 访问次数,往往能带来数倍延迟改善。
我在实际项目中用类似思路融合过一个包含 7 个连续操作的序列,端到端推理延迟降到了原来的 60% 左右。这个收益不是某一个 Kernel 跑得更快,而是省掉了大量中间结果的读写。
4.3 文件组织和 C++/CUDA 分工
真实项目的文件不会只有一个.cu,一般这样组织:
custom_ops/ ├── setup.py ├── clip_add.cpp # 入口,负责设备分发和 pybind11 绑定 ├── clip_add.cu # CUDA Kernel 实现 └── clip_add.h # 声明 C++ 侧的函数.cpp里做的是“设备分派”,也就是根据输入张量的 device 决定走 CPU 还是 GPU:
at::Tensor clip_add(const at::Tensor& x, double min_val, double max_val, double bias) { auto y = torch::empty_like(x); if (x.is_cuda()) { clip_add_cuda(x, min_val, max_val, bias, y); } else { clip_add_cpu(x, min_val, max_val, bias, y); } return y; }这么做的好处是,你在 Python 层不需要关心 device,代码同一套接口,CPU/GPU 自动切换。开发阶段 CPU 版能帮你快速验证逻辑,CUDA 版再压性能,配合TORCH_CHECK还能做各种形状和类型检查。
4.4 容易踩的坑:设备、类型、和指针
写 CUDA 扩展时,最常见的 bug 集中在以下几点:
- 没有为空张量分配显存空间。一定要用
torch::empty_like或at::zeros先建好输出张量,再往里写数据,不要在 Kernel 里直接cudaMalloc。 - 类型不匹配。变量是
float,但你data_ptr<double>,数据会完全错乱。严谨的做法是在入口处加TORCH_CHECK(x.scalar_type() == at::kFloat)。 - grid 尺寸超限。
blocks = (n + threads - 1) / threads这个计算本身没问题,但当 n 很大(比如超过 20 亿)时,int装不下,要用int64_t来算 gridDim 上限,然后用循环处理。 - 忘记流同步。在测性能和做多次 Kernel 串联时,要确保使用默认流并且正确同步。开发调试可以用
cudaDeviceSynchronize(),正式代码建议通过 PyTorch 的流管理来避免额外同步开销。
另外有个细节:不要在 CUDA 入口里用std::cout打印调试信息,看不到任何输出。真需要调试,先写 CPU 版打日志,或者在 Python 侧把 Tensor 拿去生成numpy数组核对结果。
5. 前向传播只是开始:反向传播与自动微分
5.1 torch.autograd.Function 封装
自定义算子不只是前向函数,如果要在训练中使用,还要接入反向传播。这里用torch.autograd.Function封装。基本原则是:
- 定义一个继承
torch.autograd.Function的类,实现forward和backward两个静态方法。 forward里调用你前面写好的 C++/CUDA 算子。- 需要用于反向计算的值,用
ctx.save_for_backward保存。
以clip_add为例,反向求导的公式很好推:clip 操作在超出区间时梯度为 0,区间内梯度为 1。所以反向相当于:
grad_x = grad_output * mask
其中mask表示 x 在 clip 区间内。为了得到这个 mask,forward 阶段需要把原始 x 保存下来。
Python 侧封装:
import torch class ClipAddFunction(torch.autograd.Function): @staticmethod def forward(ctx, x, min_val, max_val, bias): ctx.save_for_backward(x) ctx.min_val = min_val ctx.max_val = max_val return custom_ops_cpp.clip_add(x, min_val, max_val, bias) @staticmethod def backward(ctx, grad_output): (x,) = ctx.saved_tensors grad_x = torch.where((x >= ctx.min_val) & (x <= ctx.max_val), grad_output, torch.zeros_like(x)) return grad_x, None, None, None注意backward的返回值要和forward的输入一一对应。这里min_val、max_val、bias都不是可学习参数,所以返回None,而grad_x返回给x。
这种写法的好处是灵活,完全控制了反传逻辑,但你也要为每一个ctx保存的张量付出显存代价。保存的 tensor 越多,训练显存占用越高,所以要只保存真正需要的信息,不要顺手把中间结果都存下来。
5.2 更复杂的反向:融合算子怎么拆
很多人会纠结:融合算子在前向合并了多个操作,反向该怎么做?其实不用怕,反向公式依然很简单。比如y = clip(x * scale, min_val, max_val) + bias,对 x 的梯度是:
grad_x = grad_output * scale * mask
其中 mask 为 1 表示x * scale在区间内,否则为 0。你只需要在前向阶段保存一个x * scale的副本,或者保存 x 和 scale 在反向时重新计算。保存x加一个标量 scale 显然比保存整个中间张量省显存。
如果你写的算子组合特别复杂,比如涉及多个输入的乘法、除法、softmax,那就认真推一遍梯度公式,用torch.autograd.gradcheck验证。下面这段代码可以检查前向和反向是否正确:
from torch.autograd import gradcheck from torch.autograd.gradcheck import gradgradcheck x = torch.randn(4, 4, dtype=torch.double, requires_grad=True) assert gradcheck(ClipAddFunction.apply, (x, 0.0, 1.0, 0.5), eps=1e-6, atol=1e-4)gradcheck的原理是数值微分离散近似和你的反向传播梯度做对比,误差在容差内就算通过。跑gradcheck时必须用double类型,否则数值误差会大到你根本无法判断是公式错了还是精度问题。
5.3 反向传播的显存优化:不保存中间结果
前向保存中间张量虽然方便,但在大模型场景中是不可忽视的开销。一个技巧是在反向时重新计算前向的中间值,这在算子本身很便宜时(比如逐元素操作)特别划算。我们的clip_add反向只需要知道 x,那就在 backward 时直接从ctx.saved_tensors拿即可,这不会造成大的负担。但如果中间结果是个巨大的 feature map,重新计算一次可能比显存占用更划算。
我再强调一个和 PyTorch 版本相关的点:在 PyTorch 2.x 里,torch.autograd.Function的backward推荐返回和forward输入数量相同的梯度,即使是标量参数也要显式返回None。如果漏返回或者错位,PyTorch 会报错提醒,但报错信息往往不够直观,最好在开发初期写个最小测试把参数顺序理清楚。
6. 编译调试与性能对照
6.1 编译报错与排查清单
自定义算子开发中,编译问题往往比算子逻辑问题更让人头疼。我把这些年踩过的坑整理成了一张速查表:
| 现象 | 原因 | 解决方案 |
|---|---|---|
RuntimeError: Ninja is required | 没有安装 Ninja | pip install ninja |
g++: error: unrecognized command line option '-std=c++14' | g++ 版本太老 | 升级 g++,或使用 clang |
fatal error: torch/extension.h: No such file or directory | 编译环境中没有 PyTorch 头文件路径 | 确认在虚拟环境里执行 setup.py |
undefined symbol: _ZN2at5Tensor... | Python 和 PyTorch ABi 不匹配 | 重新安装匹配版本的 PyTorch |
MSB8040: Spectre-mitigated libraries are required | VS 缺少 Spectre 库 | 在 VS Installer 里勾选对应组件 |
nvcc fatal: Unsupported gpu architecture | CUDA 版本和 GPU 算力不匹配 | 在 setup.py 里通过extra_compile_args指定arch=compute_80,code=sm_80等 |
Windows 上编译后 import 报DLL load failed | MSVC 运行时缺失 | 安装对应的 Microsoft Visual C++ Redistributable |
第六类Unsupported gpu architecture在 Windows 上比较常见。你刚装好的 CUDA 12.x 默认编译目标里可能不包含老显卡的算力。解决办法是在CUDAExtension里加参数:
from torch.utils.cpp_extension import CUDAExtension CUDAExtension( name="custom_ops_cuda", sources=["clip_add.cpp", "clip_add.cu"], extra_compile_args={ "cxx": ["-O3"], "nvcc": ["-O3", "-arch=compute_80", "-code=sm_80"] } )compute_80对应 RTX 30 系列及 A100 等 Ampere 架构,sm_80是实际执行的 SASS 代码。如果你是 RTX 40 系列(Ada 架构),可以用compute_89, code=sm_89。最简单的方法是先用torch.cuda.get_device_capability()查你的 GPU 算力,再决定参数。
6.2 性能对比:别凭感觉判断
写完算子,验证性能要有数据支撑。写算子的好处是能直接控制 Kernel 启动和访存,但收益不够明显时,你得用 profiling 找到真正的瓶颈。我用torch.cuda.Event做简单计时,和原生算子组合做对照:
start_event = torch.cuda.Event(enable_timing=True) end_event = torch.cuda.Event(enable_timing=True) # 预热 for _ in range(10): y = fused_scale_clamp_add(x, 2.0, 0.0, 6.0, 1.0) torch.cuda.synchronize() start_event.record() for _ in range(100): y = fused_scale_clamp_add(x, 2.0, 0.0, 6.0, 1.0) end_event.record() torch.cuda.synchronize() print("fused avg time:", start_event.elapsed_time(end_event) / 100, "ms")对照时,不要在一个循环里混跑原生和自定义算子,会让后续 kernel 的启动顺序干扰计时。我的流程是先单独测原生组合,再单独测自定义算子,每轮循环之间加torch.cuda.synchronize(),这样测出来的时间更接近真实单独调用的情况。
6.3 常见问题与排查技巧实录
除了编译阶段,运行阶段的问题也不少。我举几个印象最深的例子:
第一个:输入张量在 GPU 上的数据没同步。有一次我写了自定义算子,输入是从numpy转过去的 CPU 张量,直接调用is_cuda()判断为 false 后走了 CPU 分支,结果数据对不上。排查时加了一条print(x.device)才发现问题。所以入口函数的设备分派一定不能省。
第二个:共享内存越界导致随机错误。我在写一个带有 Block 内规约的 Kernel 时,声明了一个固定大小的共享内存数组,但实际 block 线程数设得比数组大,直接导致了随机崩溃。这种问题不容易复现,最好用cuda-memcheck或者 Compute Sanitizer 工具跑一遍,会直接提示越界地址。
第三个:PyTorch 扩展在 fork 之后调用出问题。如果启动了一批 DataLoader worker,而 worker 里调用了自定义算子,部分环境会碰到 CUDA context 初始化顺序的问题。最简单的规避办法是避免在fork之后首次触发 CUDA,或者在模型初始化阶段先做一次热身调用。
还有一个非常实用的技巧:开发阶段先用小规模输入验证逻辑,再用大规模数据测性能。逻辑错误在小规模下更容易从数值上发现;性能优化必须用接近真实 shape 的数据跑,否则你看到的“优化”很可能是显存带宽没压满的假象。
7. 写在最后的一些体会
自定义 C++/CUDA 算子这条路,入门门槛主要在三处:环境、编译、反向传播公式。环境问题看似繁琐,其实只要记住“PyTorch、CUDA、编译器三者的版本要对齐”;编译问题用速查表基本能解决;反向传播则是把神经网络里的链式法则老老实实推一遍。
以我个人的实际体验来说,一旦你把第一个 CPU 版算子跑通,后面写 CUDA 版和融合算子就顺理成章。每次写新的算子时,我都会从最小实现开始,先打印 Python 输出做对比,再逐步加优化。还有一个小技巧:遇到难调的 Kernel,先在网上搜同类的 open source 实现,看别人的 grid-stride loop 和 Shared Memory 怎么组织的,很多时候比自己闭门造车高效得多。
写自定义算子不只是为了性能,它也能让你更深刻地理解 PyTorch 底层到底怎么调度算子、张量内存怎么布局、CUDA 上如何高效访存。这笔时间投入,越到后面越觉得值得。