前两周写了 TorchInductor 代码生成(一),聊了整体链路和 Fusion 的基本思路。后台收到不少反馈,问题最集中的是:同样一个模型,为什么 GPU 上跑的是 Triton,CPU 上跑的是 C++?这两个内核到底是怎么“生”出来的?这篇就把 CppKernel 和 TritonKernel 放到一起拆开讲。现在 AI 辅助代码生成确实火,从 PLC 代码生成到 Simulink 模型自动转 C,到处都是“代码生成”这四个字,但 TorchInductor 干的是另一件事——它不追求生成代码“长得像人写的”,只追求一件事:跑得快。这篇适合两类人,一类是想深入 torch.compile 内部机制的源码党,另一类是编译后性能不对劲、想搞清楚内核长什么样再动手优化的工程党。
1. 先看全局:TorchInductor 的内核生成链路
1.1 从 FX 图到 Kernel 的三步走
很多朋友第一次看 TorchInductor 源码都有点懵,因为它的入口不在inductor目录里,而是从torch.compile一路进来的。实际要经过三个阶段才轮到内核生成。
第一阶段是前端捕获。torch.compile(model)会把你的模型转成一个 FX Graph,这个图经过 AOTAutograd 之后,每个节点都是一个 aten 算子,或者更准确地说,是被 decomposition 之后的底层算子。比如你写了一个output = x + 1,在图上就是一个add节点,和你脑子里想的那个add差不多,但它已经脱离了 Python 对象,成为一个可被编译器操作的中间表示。
第二阶段是 lowering。Inductor 拿到 FX Graph 之后,把每一个 aten 算子翻译成自己的 IR。这一步非常关键,因为 IR 的粒度直接决定了后面能做多少优化。常见的 IR 节点类型有 Pointwise(逐元素),Reduction(归约),ExternKernel(调用外部库,比如矩阵乘法会落到 cublas 或者 oneDNN),TemplateBuffer(卷积这种走模板的 kernel)。每个 IR 节点会记录它的 shape、stride、dtype,以及它依赖哪些 buffer,这些信息在后面生成代码时都会用到。
第三阶段才是内核生成。Scheduler 会做融合决策,把可以合并的 IR 节点分组。比如x + 1、relu、* 2这三个操作,如果它们都是逐元素且 shape 相同,那大概率会被融合进同一个 kernel,中间结果不落内存。融合完成后,每个 kernel 被丢给后端的 CodeGen 类:CPU 走CppScheduling,产出 CppKernel,编译成extern "C"函数;GPU 走TritonScheduling,产出 TritonKernel,编译成 Triton 的 kernel handle。真正意义上的“代码生成”,发生在这一步。
1.2 CppKernel 和 TritonKernel 其实是同一套抽象的两个方言
我读源码时有个很深的感受:TorchInductor 不是“两套引擎”,而是“一套抽象,两种方言”。
在代码生成这一层,两种后端共享了大量底层概念。最核心的有四个:Kernel对象负责整个函数的管理,包括函数签名、参数列表、临时 buffer 的分配;RangeTree或者说循环嵌套结构,描述多维 tensor 的遍历方式;CSE是公共子表达式消除,生成大量tmpN临时变量来避免重复计算;Buffer则负责输入、输出和中间结果的生命周期管理。
这两个后端只是把同一套抽象“翻译”成不同的目标语言。CPU 端翻译成“向量 + 线程”的 C++,GPU 端翻译成“block + thread + tile”的 Triton。所以你会发现,如果你会读其中一种生成代码,再去看另一种,很多结构都能对得上。这也是为什么这篇要把两者并排讲——先建立起这种“同构”的认知,后面看源码会事半功倍。
2. CppKernel:CPU 上怎么把 IR 翻译成可编译的 C++
2.1 一个 Pointwise 节点的 CppKernel 长什么样
先看一个最简单的例子。假设要生成长度为 1024 的 tensor 做平方运算out = x * x的内核。Inductor 生成的 C++ 大约长这样(我做了简化,保留了核心结构):
extern "C" void kernel(void* in_ptr0, void* out_ptr0) { #pragma omp parallel for for (long i0 = 0; i0 < 1024; i0 += 16) { auto tmp0 = at::vec::Vectorized<float>::loadu(in_ptr0 + i0); auto tmp1 = tmp0 * tmp0; tmp1.store(out_ptr0 + i0); } }这段代码有四个信息量很大的细节。
第一,没有用std::transform这种高层封装,而是裸循环。因为编译器后端需要完全控制循环结构和步长,才能做向量化。你原来写的 PyTorch 代码,本质上只是描述了 op 之间的逻辑关系,Inductor 要做的是把它翻译成一个可以精确控制性能的循环。
第二,at::vec::Vectorized<float>是 PyTorch 自带的 SIMD 抽象。它会在编译时根据目标机器的指令集自动选择实现:AVX2 下是 256-bit,能装 8 个 float;AVX512 下是 512-bit,装 16 个 float。也就是说,这段生成代码和上面的步长 16 不是硬编码的,而是根据向量宽度推断出来的。
第三,#pragma omp parallel for是 OpenMP 并行。CPU 上的并行是“多线程 + 向量化”两层一起上,循环步长 16 意味着一个线程一次处理 16 个 float,多个线程同时跑这段循环。
第四,这里用的是loadu而不是load。这两个的区别是对齐。load要求地址 16 字节对齐,loadu允许任意地址。Inductor 在生成代码时会分析每个 buffer 的对齐属性,如果可以证明对齐,就用load,否则退化成loadu。别看只是一个小函数名的差别,在大量数据上,对齐访问和不齐访问的性能差距很明显。
2.2 向量化是 CPU 内核的灵魂
CppKernel 的性能很大程度上取决于向量化的质量,这也是它和手写循环最大的区别。Inductor 在主循环生成这一块做了不少工作,流程大致是:
- 判断循环涉及的 buffer 是否连续,stride 是否为 1;
- 判断是否能对齐,也就是 base address 是否满足 vector 的对齐要求;
- 计算向量宽度,默认是 128-bit,但会根据 dtype 和指令集调整;
- 生成向量化主循环,处理“整段”数据;
- 生成尾部循环,把剩下不足一个向量宽度的元素用标量处理。
这里有一个新手特别容易忽略的点:dtype 不同,向量宽度也不同。float 在 AVX2 下是 8 个元素,double 是 4 个,uint8_t走的是完全不同的路径——Inductor 通常不会直接把 8-bit 类型塞进Vectorized<uint8_t>,因为整数 SIMD 指令对宽度和溢出有额外要求,它可能做 4 倍宽的处理或者直接走标量。所以你在生成代码里看到某些类型没有向量化,先别急着怀疑 Inductor,先看看是不是类型本身就不适合当前指令集。
CSE 也会在生成代码里大量出现。你去看任意一个 CppKernel 生成结果,会看到一串auto tmp0 = ...; auto tmp1 = ...;这种临时变量。这些 tmp 编号不是随机的,CSE 会维护一个表达式表,如果某个表达式之前已经算过,就直接复用,避免同一份数据被反复从内存加载。比如:
auto tmp0 = in_ptr0[i0]; auto tmp1 = tmp0 * tmp0; auto tmp2 = tmp1 + tmp0; out_ptr0[i0] = tmp2;这里tmp0被用了两次,第一次是加载,第二次是在加法里。如果没有 CSE,生成代码可能会把in_ptr0[i0]重新加载一遍,那就白白浪费一次内存访问。在 CPU 上,内存访问往往是比计算更贵的操作,所以 CSE 的价值非常大。
2.3 多线程与归约:CPU 上处理 Reduction 的细节
Reduction 节点在 CppKernel 里的处理比 Pointwise 复杂不少。一个最简单的求和 kernel,生成出来大概长这样:
#pragma omp parallel for reduction(+:tmp_acc) for (long i0 = 0; i0 < 1024; i0 += 8) { auto tmp0 = at::vec::Vectorized<float>::loadu(in_ptr0 + i0); tmp_acc = tmp_acc + tmp0; } float tmp1 = tmp_acc.reduce(); out_ptr0[0] = tmp1;但实际生成的代码不会这么干净,因为 Inductor 要同时处理向量化和多线程两个维度。它通常会把Vectorized累加结果拆成标量再合并,并且用 OpenMP 的reduction子句做跨线程归约。如果归约轴不是最后一个维度,还要处理 stride 的问题,甚至可能生成一个临时 buffer 来做转置。
还有一类值得单独说:Welford 算法。在 LayerNorm、BatchNorm 这类算子里,需要计算均值和方差。朴素求和的数值稳定性在 FP32 下不够好,Inductor 的 CPU 后端会生成带 Welford 累加的向量化代码,用在线更新的方式同时维护均值和方差。这个细节在混合精度训练里特别重要,如果被朴素求和替代,可能出现梯度异常或者 loss 抖动,而且很难排查。
多线程方面有个很实际的坑。CppKernel 的并行默认靠 OpenMP,如果你的主进程已经用了 DataLoader 的多进程加载,再叠一层 OpenMP 线程,很容易出现线程爆炸。最典型的表现是 CPU 占用打满,但吞吐反而下降。我的做法是在启动脚本里固定OMP_NUM_THREADS,或者调用torch.set_num_threads()限制线程数,让生成代码里的#pragma omp parallel也能被全局约束。
3. TritonKernel:GPU 上按 tile 来组织计算
3.1 Triton 的执行模型决定了代码长什么样
Triton 的编程模型类似于 CUDA,但抽象更高。你写的是一个 kernel 函数,但它运行在 block 级别,每个 block 由一个tl.program_id标识。在 TorchInductor 生成的 Triton 代码里,一个 kernel 的 grid 大小通常由 tensor 的元素总数除以 XBLOCK 得到,而 XBLOCK 是一个编译期常量(tl.constexpr),它决定每个 block 一次性处理多少个元素。
一个 elementwise 乘法 kernel 的生成代码大概是这样的:
@triton.jit def triton_kernel(in_ptr0, out_ptr0, xnumel, XBLOCK: tl.constexpr): xoffset = tl.program_id(0).to(tl.int64) * XBLOCK xindex = xoffset + tl.arange(0, XBLOCK) xmask = xindex < xnumel tmp0 = tl.load(in_ptr0 + xindex, mask=xmask) tmp1 = tmp0 * tmp0 tl.store(out_ptr0 + xindex, tmp1, mask=xmask)这里最核心的抽象是tl.arange(0, XBLOCK)。它生成一个 block 内连续的索引集合,后续所有操作都是在这个“索引集合”上向量化进行的。mask则用于边界处理,因为最后一个 block 的元素数量可能不足 XBLOCK,如果直接访问会越界。
注意xnumel是运行时传入的动态值,而 XBLOCK 是编译期常量。这意味着 Triton 在编译时会针对 XBLOCK 做大量优化,比如循环展开、寄存器分配、内存访问对齐等。如果你需要调整性能,XBLOCK 是一个重要的旋钮。
3.2 Pointwise 与 Broadcasting 的 Triton 映射
Pointwise 节点在 Triton 端的生成逻辑和 CppKernel 类似,也是用tmpN临时变量串起来。但由于 Triton 是 tile 级别的计算,广播的处理方式和 C++ 差别很大。
举个例子,如果你有两个 shape 不同的 tensor 做加法,比如(1024,) + (1,),在 C++ 后端会生成一个标量加法的循环,而 Triton 后端会先把(1,)那个 tensor 广播成 tile 的形状,再做向量化加法。Inductor 的生成代码里会出现tl.broadcast_to或者直接在表达式层面完成广播。
还有一个细节是索引计算。如果输入 tensor 不是连续内存,Triton 生成的索引会带上 stride 乘法,比如x0 * stride + x1。这类 strided 访问在 GPU 上性能差异很大,尤其是访问转置矩阵时。如果你在生成的 Triton 代码里看到大量 stride 乘法,就要意识到内存访问可能不太友好。我经常在项目里做的一步操作,就是把不连续的 tensor 提前contiguous()一下,往往性能立刻改善。
mask 的开销也要留意。当xnumel不是 XBLOCK 的整数倍时,每个tl.load和tl.store都要计算 mask,额外的比较和选择指令会增加 kernel 的开销。如果你发现模型里有大量形状不规整的 tensor,这是个潜在的优化点——有时候只需要在模型层面把 shape pad 成 8/16 的倍数,就能减少很多 mask 计算。
3.3 Reduction 与 Persistent Kernel
Reduction 在 Triton 端有好几种处理方式,取决于归约维度的大小。
最简单的情况是归约维度能放进一个 block,生成代码直接调tl.sum或tl.max:
tmp0 = tl.load(in_ptr0 + xindex, mask=xmask) tmp1 = tl.sum(tmp0, axis=0)当归约维度特别大,比如对几百万个元素求和时,Inductor 会采用 split reduction:把输入切成多个 tile,每个 block 做部分归约,再用第二个 kernel 把部分结果合并,或者用原子操作写入全局输出。
persistent reduction 是另一种策略,它让一个 kernel 只启动固定数量的 block,每个 block 循环处理多个 chunk,最后通过tl.atomic_add累加。这样做的好处是减少 kernel 启动次数,把 launch overhead 压到最低。Inductor 会根据归约规模和 block 数,在这些策略之间做选择。
num_warps和num_stages是 TritonKernel 里绕不开的参数。num_warps决定每个 block 的线程数,直接关系到并行度;num_stages决定软件流水线的级数,影响对延迟的隐藏能力。这两个参数相互制约:num_warps开太大,每个线程的寄存器就少了;num_stages开太大,寄存器压力飙升,甚至可能爆显存。Inductor 默认会根据 shape 估算一组参数,但不见得是最优的,所以 GPU 调试时经常要手动调整这一组数值。
4. 两个后端的核心差异与选型逻辑
4.1 一张表对比要点
| 维度 | CppKernel | TritonKernel |
|---|---|---|
| 执行模型 | SIMD 向量化 + OpenMP 多线程 | SIMT:block + thread,按 tile 组织 |
| 循环表达 | 显式 for 循环,步长 = 向量宽度 | tile 索引空间,tl.arange+ mask |
| 归约方式 | OpenMP reduction 子句 + 向量累加 | block 内tl.sum/tl.max,跨 block 用 split 或 atomic |
| 内存访问 | 手动对齐,load/loadu | 无手动对齐,mask 处理边界 |
| 编译产物 | C++ 源码 -> 编译成 so/o | Triton 源码 -> PTX/cubin -> 加载 |
| 调试手段 | 看 C++ 文件、gdb、perf | 看 Triton 源码、Nsight |
| 典型场景 | CPU 推理/训练 | NVIDIA GPU 推理/训练 |
这张表是简化视角。实际上 TorchInductor 在 CPU 上也有实验性的 Triton 路径,C++ 后端也有一堆针对 oneDNN 的模板 kernel。但初学者先把握住这条主线,足以应对绝大多数场景了。
4.2 为什么说代码生成不是“套模板”
我经常看到一种误解,觉得 TorchInductor 就是“背了几十个模板,然后往里填参数”。实际上它做的事情要深得多,CSE、死代码消除、buffer 复用、kernel fusion 都是真实发生的优化。
举一个很简单的例子。你写x = a + b; y = x * 2; z = y + a,如果x和y这两个中间变量没有被其他节点使用,Inductor 在生成代码时可能直接把它合并成z = (a + b) * 2 + a,连中间 buffer 都不分配。这种穿针引线的优化是模板引擎做不到的。
再一个是跨 kernel 的 fusion。同一个计算图里,前一个 kernel 的输出可以在寄存器级别直接传给下一个 kernel,而不是写回全局内存再读出来。CppKernel 和 TritonKernel 都做得不错,但两侧的融合策略不一样:CPU 端看重 SIMD 宽度和循环顺序的匹配,GPU 端看重 tile 大小和 block 数量之间的平衡。理解了这一点,你在做性能调优时就有了方向感——先在模型侧消除不必要的中间节点,再考虑调整 config。
5. 实操:让 TorchInductor 把内核“打开”给你看
5.1 快速开启 debug 输出的方法
这一节直接给实际可操作的办法。想看 TorchInductor 生成了什么代码,最常用的是两个办法。
第一个是环境变量。在运行脚本前设置:
TORCHINDUCTOR_DEBUG=1 python your_script.py这会把很多中间信息直接打印出来,包括每个 kernel 生成的 C++ 或 Triton 源码。缺点是信息量很大,终端会被刷屏。我建议配合TORCHINDUCTOR_TRACE=1使用,它会把编译的各阶段 dump 写到特定目录,方便慢慢翻。
第二个是在代码里直接看:
import torch from torch._inductor import config config.debug = True def model(x): return (x * 2 + 1).relu() compiled = torch.compile(model) x = torch.randn(1000) compiled(x)config.debug = True后,每次内核编译都会在编译经过时把生成的源码打印出来。或者把config.trace.enabled = True打开,设置config.trace.output_dir = "/tmp/inductor_trace",生成代码会以文件形式保留。这个姿势在 notebook 里特别舒服,不用翻终端。
5.2 常见问题速查表
以下是我实际调试中遇到过的典型问题,整理成速查表。
CPU 端
| 现象 | 原因 | 解决 |
|---|---|---|
fatal error: 'omp.h' file not found | 编译器路径里没有 OpenMP 头文件,conda 环境常见 | 安装libgomp,或用系统 gcc,重设CXX环境变量 |
Illegal instruction (core dumped) | 生成代码用了机器不支持的 SIMD 指令,比如 AVX512 kernel 跑在只支持 AVX2 的 CPU 上 | 用ATEN_CPU_CAPABILITY=avx2限制指令集,或换目标机器重新编译 |
| OpenMP 线程数失控,CPU 占用异常 | 进程中已有多个线程池,OpenMP 叠加导致线程爆炸 | 固定OMP_NUM_THREADS,或torch.set_num_threads()限制 |
GPU 端
| 现象 | 原因 | 解决 |
|---|---|---|
| 首次跑模型卡住很久,之后变快 | Triton kernel 首次编译并缓存,模型层数多时编译时间可达分钟级 | 设置TRITON_CACHE_DIR持久缓存,上线前做一次 warmup |
CUDA error: invalid device function | 编译目标架构与实际 GPU 不匹配 | 确认torch.cuda.get_device_capability,设置TORCH_CUDA_ARCH_LIST重新编译 PyTorch |
out of resource | kernel 请求的寄存器/共享内存超限 | 降低num_warps或num_stages,更新 Triton 版本 |
| mask 导致性能异常 | shape 不是 2 的幂,大量 tail block 产生额外 mask 计算 | 尽量让xnumel是 8 或 16 的倍数,检查 XBLOCK 是否过大 |
5.3 控制内核生成的常用开关
最后列几个我实际用过的config开关。这些在torch/_inductor/config.py里都有定义,不同 PyTorch 版本细节可能变化,用之前先print(config)确认。
torch._inductor.config.debug = True:打印每个 kernel 的源码,调试首选。torch._inductor.config.max_fusion_size:限制一个 kernel 里最多融合多少节点。融合太多会导致单个 kernel 过大、编译时间过长,这个参数可以限制它。torch._inductor.config.triton.autotune = True:让 Triton 自动搜索num_warps等参数。GPU 场景值得开,但会增加编译时间。torch._inductor.config.triton.cudagraphs = True:启用 CUDA Graph,降低小 kernel 的 launch 开销,GPU 场景收益很明显。- 还可以关注
cpp后端的 profiling 开关,生成带计时信息的 CPU kernel,方便定位热点。
这些开关要慎用,尤其是max_fusion_size,改小了能降编译时间,但可能损失性能,需要在项目里实际测。
说实话,TorchInductor 生成的代码,门槛没有想象中高。一旦你认真看过几个典型的 CppKernel 和 TritonKernel,后面再遇到性能问题,脑子里会浮现出大致的代码形状,然后不由自主想到:这里是不是该加一个contiguous(),那里是不是该把num_warps降一档。这种直觉比任何 benchmark 脚本都值钱。
我现在的固定调试套路是:先开 debug 把 kernel 拉出来看,再决定是改模型代码还是改 config。绝大多数时候问题出在模型侧——stride 不够友好、shape 太怪、或者根本不需要 fusion。改 config 是最后的手段,因为它影响面太大。另外提醒一句,Triton cache 和 C++ cache 是两个独立的缓存,清理时要分开处理,只清一个而另一个还在用旧代码,这种坑我踩过不止一次。如果非要说有什么经验值得分享,那就是:先看懂生成的代码,再动手优化,比什么都强。