Kernel Dev 训练营/ 提示与讲解
C1 提示与讲解 · Track C · CUDA C++ · 对照 solutions/c1_elementwise.cu 逐行读

C1 答疑:逐行读懂 c1_elementwise.cu

把读 C1 参考解时卡住的 8 个问题讲透:TORCH_CHECK 和 at::kFloat、blockDim 从哪来、at/c10/torch、launch 配置、__device__ __forceinline__、grid-stride loop、block 数上限、bias[i % cols]。每节都有图、类比、算例和源码链接。

怎么读这一页

每一节对应一个你问过的问题,开头标出它在 drillsc1_elementwise.cu 里的行号(点灰色小标签可以跳到 GitHub 上的那一行)。图里的节点可以悬停,相连的边会高亮。PyTorch 的链接都钉在固定 commit 上,行号不会漂移。

① TORCH_CHECK 在干什么?at::kFloat 从哪来?

你问的是 第 13–16 行:TORCH_CHECK((t).scalar_type() == at::kFloat, #t " must be float32") · drillsc1_elementwise.cu:13–16

一句话:在 kernel 启动之前,确认输入张量满足 kernel 的假设;不满足就抛异常,Python 那边看到一个写清楚原因的 RuntimeError。

这三行在文件里是一个宏的一部分,三项检查一起做:

#define CHECK_F32_CUDA(t)                                                   \
  TORCH_CHECK((t).is_cuda(), #t " must be a CUDA tensor");                  \
  TORCH_CHECK((t).is_contiguous(), #t " must be contiguous");               \
  TORCH_CHECK((t).scalar_type() == at::kFloat, #t " must be float32")
检查 为什么 kernel 需要它 不检查会怎样
is_cuda() kernel 只能读显存 把 CPU 内存地址交给 GPU:非法地址错误
is_contiguous() kernel 用 x[i] 按连续下标读 转置后的视图会被按错误顺序读,结果错但不报错
scalar_type() == at::kFloat kernel 写的是 data_ptr<float>(),按 4 字节 float 解读 fp16 张量的字节被当成 float 读:静默的垃圾值
TORCH_CHECK 失败时,错误怎么一路回到 Python悬停节点查看连接
TORCH_CHECK 和 assert 不同:release 构建里照样生效,而且错误能完整地传回 Python,消息里还带着 C++ 的文件名和行号。

拆开这一行

CHECK_F32_CUDA(bias);
// 预处理之后,第三行变成:
TORCH_CHECK((bias).scalar_type() == at::kFloat, "bias" " must be float32");
//                                              ^^^^^^ #t     ^^^^^^^^^^^^^^^^ 自动拼接

at::kFloat 的来历

kFloat 不是某个文件里手写的 const,而是用宏批量生成的,再经过两次 using namespace 才能写成 at::kFloat:

at::kFloat 是怎么来的悬停节点查看连接
三个名字指向同一个常量。枚举在 torch/headeronly/core/ScalarType.h,k 前缀常量在 c10/core/ScalarType.h,at 导入 c10 在 torch/headeronly/macros/Macros.h,torch 导入 at 在 torch/types.h。

对应源码(点开就是那一行):

  1. 枚举本体:pytorchenum class ScalarType;Float 是第 6 号,见 pytorch_(float, Float) /* 6 */
  2. 生成 k 前缀常量:pytorchDEFINE_CONSTANT → constexpr kFloat。k##name 是宏的拼接操作,k 和 Float 拼成 kFloat
  3. at 导入 c10:pytorchnamespace at { using namespace c10; }
  4. torch 导入 at:pytorchnamespace torch { using namespace at; }

C++ 常量和 Python dtype 的对应:

C++ Python 字节
at::kFloat torch.float32 4
at::kHalf torch.float16 2
at::kBFloat16 torch.bfloat16 2
at::kDouble torch.float64 8
at::kInt / at::kLong torch.int32 / torch.int64 4 / 8
类比 · 登机口查票

CHECK_F32_CUDA 是登机口的工作人员:设备(是不是这趟航班)、连续性(座位号是不是连号)、dtype(是不是这个舱位的票)。查票只花一秒,但如果不查,乘客会被送到错误的城市,而且没人告诉他(kernel 静默地算出垃圾值)。#t 就是工作人员在广播里念出名字:“bias 旅客的票不对”,而不是只说“有人的票不对”。

自测:传一个 fp16 张量进来,报错信息是什么?

a must be float32(如果是第一个参数 a)。前两项检查都通过了(在 CUDA 上,也是连续的),第三项失败,#t 展开成 "a"。Python 端看到的是 RuntimeError: a must be float32。

② blockDim.x 从哪来?为什么点不进去?

你问的是 kernel 里的 blockDim.x:它也是 torch 头文件带进来的吗?为什么在编辑器里 click 不到定义?· drillsc1_elementwise.cu:27

不是 torch 带来的。 blockDim、threadIdx、blockIdx、gridDim、warpSize 是 CUDA 语言自带的内置变量,由 nvcc 提供,你不需要 #include 任何东西。

内置变量 含义 本文件里等于 读的是哪个 PTX 特殊寄存器
threadIdx.x 我在 block 里排第几 0 … 255 %tid.x
blockIdx.x 我的 block 在 grid 里排第几 0 … blocks−1 %ctaid.x
blockDim.x 每个 block 有几个线程 256 %ntid.x
gridDim.x grid 有几个 block blocks %nctaid.x
blockDim.x 的一生:从 <<<>>> 到寄存器悬停节点查看连接
头文件里的 extern const 只是让编译器“认识这个名字”,它没有 C++ 实现:nvcc 把 blockDim.x 直接翻译成一条读特殊寄存器的指令,值由你 <<<>>> 里传的 256 决定。

细节:

为什么在 Mac 上点不进去

编辑器为什么找不到 blockDim悬停节点查看连接
两层原因:NVIDIA 从 CUDA 10.2 起不再支持 macOS,本机根本没有这些头文件;编辑器默认也不会像 nvcc 那样做隐式 include。

解决办法:在仓库根目录运行一次 ./setup_ide.sh(之后在 VS Code 里 Reload Window)。它会:

在这台 Mac 上验证过:blockDim 跳到 clang 的 llvm__clang_cuda_builtin_vars.h(本机是 /Library/Developer/CommandLineTools/usr/lib/clang/16/include/ 下的同一个文件),TORCH_CHECK、at::kFloat、getCurrentCUDAStream、C10_CUDA_KERNEL_LAUNCH_CHECK、atomicAdd 都能跳到 torch / CUDA 头文件里的定义。在 Linux GPU 机器上用 Remote-SSH 打开项目也可以,那里有完整的 CUDA toolkit。

类比 · 工牌上印好的“本组人数”

blockDim.x 不是你声明的变量,而像是公司发给每个员工的工牌上印着的一行字:“本组共 256 人”。组建团队的时候(kernel 启动),人事部(硬件)就把数字印上去了,员工只能读,不能改。你在代码里“找它的定义”,就像在公司规章里找“你工牌上的 256 是哪一条规定的”:答案不在规章里,而在组建团队的那张申请单(<<<blocks, 256>>>)上。

③ at 是什么?at / c10 / torch 什么关系?

你问的是 at::kFloat、at::cuda::getCurrentCUDAStream() 里的 at · drillsc1_elementwise.cu:44

at 是 ATen(“A Tensor Library”)的命名空间:PyTorch 底层的 C++ 张量库。PyTorch 的 C++ 部分分三层:

PyTorch C++ 的三层命名空间悬停节点查看连接
箭头是“导入”:上层能直接用下层的名字。所以 torch::Tensor 就是 at::Tensor,at::kFloat 就是 c10::kFloat,at::cuda::getCurrentCUDAStream 就是 c10::cuda::getCurrentCUDAStream。
命名空间 名字来源 放什么 本文件里用到的
c10 “Caffe2” + “ATen” 的谐音(ca-ten → c-10) 最底层的基础设施 TORCH_CHECK、C10_CUDA_KERNEL_LAUNCH_CHECK、kFloat 的真身
at ATen 张量和算子 at::kFloat、at::cuda::getCurrentCUDAStream、at::cuda::getCurrentDeviceProperties
torch C++ 前端 面向用户的 API torch::Tensor、torch::empty_like

源码:pytorchat 导入 c10、at::cuda 导入 c10::cuda · pytorchtorch 导入 at(附长注释解释为什么) · 你 include 的 pytorchtorch/extension.h 把这一切都带进来。

类比 · 一栋楼的三层

c10 是地下室的配电和水管(类型、设备、报错机制),at 是中间的工厂车间(张量和算子都在这里生产),torch 是一楼的门店(给顾客用的 nn、optim)。using namespace 相当于在楼上开了一个通往楼下的门:站在 at 层就能直接拿到 c10 的东西,所以 at::kFloat 和 c10::kFloat 是同一件货。写 kernel 的人习惯写 at::,因为我们干的就是车间里的活。

④ vector_add:threads = 256 和 ceil 除法

你问的是 第 41–42 行:threads = 256 和 blocks = (n + threads - 1) / threads · drillsc1_elementwise.cu:33–49

先看整个 host 函数做了什么。它跑在 CPU 上,自己不做加法,只负责把 GPU 上的活安排好:

vector_add 这个 host 函数的流程悬停节点查看连接
kernel 启动是异步的:<<<>>> 只是把任务放进 stream 的队列,CPU 马上往下走。return c 的时候 GPU 可能还在算,但 c 后续的使用会排在同一个 stream 上,所以拿到的一定是算完的结果。

核心想法:一个线程负责一个元素

CUDA 不让你直接说“给我 n 个线程”,只能说“给我 blocks 个 block,每个 block threads 个线程”。所以要把 n 个元素按 256 个一组切开,算出要几组。

threads = 256:每个 block 的线程数。

blocks = (n + threads - 1) / threads:向上取整的整数除法。

整数除法 n / threads 是向下取整,会漏掉尾巴。先加上 threads - 1 再除,就变成 ⌈n / threads⌉。

n n / 256(向下) (n + 255) / 256(向上) 启动的线程数 空转的线程
256 1 1 256 0
257 1,漏 1 个 2 512 255
1000 3,漏 232 个 4 1024 24
1 0,什么都没做 1 256 255

n = 1000 时,4 个 block 怎么覆盖这 1000 个元素:

block 0i = 0 … 255
block 1i = 256 … 511
block 2i = 512 … 767
block 3i = 768 … 999
✗24

block 3 的最后 24 个线程(i = 1000 … 1023)没有对应的元素,被 if (i < n) 挡掉,什么都不做。

所以 blocks 的计算和 kernel 里的边界检查是配套的:前者宁可多发线程也不能漏元素,后者把多发的线程挡掉。

// kernel 里(第 27–29 行)
int64_t i = (int64_t)blockIdx.x * blockDim.x + threadIdx.x;   // 全局编号
if (i < n) c[i] = a[i] + b[i];                                // 多出来的线程直接跳过
// 例:block 3 的第 231 号线程:i = 3 * 256 + 231 = 999 < 1000,干活
//     block 3 的第 232 号线程:i = 3 * 256 + 232 = 1000,不满足,跳过

为什么到处都是 int64_t? n 可能超过 2³¹(约 21 亿)。blockIdx.x * blockDim.x 两个都是 32 位无符号数,相乘会在 2³² 处回绕,所以第 27 行先把 blockIdx.x 转成 int64_t 再乘。

剩下几行

类比 · 坐大巴

1000 个人去春游,每辆大巴 256 个座位。1000 / 256 = 3.9,向下取整订 3 辆车会落下 232 个人,所以必须订 4 辆(向上取整)。第 4 辆车有 24 个空座:这就是 if (i < n),没有乘客的座位什么都不做。为什么座位数是 256 而不是 250?因为大巴按 32 座一排造(warp),订 250 座的车,最后一排照样占着一整排的位置。

自测:n = 513,blocks 是多少?会有几个线程空转?

(513 + 255) / 256 = 768 / 256 = 3。启动 3 × 256 = 768 个线程,768 − 513 = 255 个空转。

⑤ __device__ __forceinline__ 是什么意思?

你问的是 第 54 行:__device__ __forceinline__ float gelu_tanh(float x) · drillsc1_elementwise.cu:54–58

两个修饰词各管一件事:__device__ 说“这个函数在 GPU 上运行,给 kernel 调用”;__forceinline__ 说“别生成函数调用,把函数体直接贴到调用处”。

__device__:执行空间

本文件里三种函数分别跑在哪悬停节点查看连接
CPU 不能直接调 __device__ 函数,kernel 也不能用普通调用语法去调另一个 __global__。__global__ 必须返回 void,__device__ 可以有返回值(这里返回 float)。
限定符 在哪运行 谁能调用 例子
__global__ GPU CPU 用 <<<>>> 启动 bias_gelu_kernel(第 63 行)
__device__ GPU 只有 GPU 代码 gelu_tanh(第 54 行)
__host__(不写就是它) CPU CPU bias_gelu(第 75 行)

如果把 __device__ 删掉,gelu_tanh 就成了普通 CPU 函数,kernel 里调用它会直接编译报错。

__forceinline__:强制内联

内联之后,第 71 行在编译器眼里相当于:

float v = x[i] + bias[i % cols];
y[i] = 0.5f * v * (1.0f + tanhf(0.7978845608028654f * (v + 0.044715f * v * v * v)));

为什么在 GPU 上这很重要:

nvcc 默认就会积极内联小的 __device__ 函数,__forceinline__ 是把“大概率内联”变成“一定内联”,也写明了作者的意图。反过来的 __noinline__ 用于很大的函数,避免代码膨胀。文档:Execution Space Specifiers · Inline Specifiers。

对照 PyTorch 自己的实现:F.gelu(approximate="tanh") 的 CUDA kernel 用的是同一个公式,kBeta 就是这里的 k0 = sqrt(2/π),kKappa 就是 k1 = 0.044715:pytorchActivationGeluKernel.cu · tanh GELU。

类比 · 抄菜谱 vs 打电话问

厨师(kernel)做菜做到一半需要调一个酱汁(gelu_tanh)。不内联就像每做一份都放下手里的活,打电话问酱汁大师傅怎么调,听完再回来接着做(保存现场、跳转、返回)。内联就是把酱汁的配方直接抄进自己的菜谱里,一气呵成。配方只有三行时,打电话的时间比调酱汁还长,当然应该抄进来。__device__ 则说明这本菜谱只在后厨(GPU)用,前台(CPU)拿到也看不懂。

⑥ grid-stride loop:一个线程处理好几个元素

你问的是 第 68–72 行的 for 循环 · drillsc1_elementwise.cu:68–72

const int64_t stride = (int64_t)blockDim.x * gridDim.x;      // 整个 grid 的线程总数
for (int64_t i = (int64_t)blockIdx.x * blockDim.x + threadIdx.x; i < total; i += stride) {
  y[i] = gelu_tanh(x[i] + bias[i % cols]);
}

和 vector_add 的区别:vector_add 启动“刚好够”的线程,每个线程只做一次;这里 block 数有上限(见第 ⑦ 节),线程可能比元素少,所以每个线程循环:做完一个元素,往后跳 stride 个再做一个。

小数字走一遍:2 个 block × 4 个线程,total = 20

stride = 4 × 2 = 8。颜色表示 block,格子里写的是元素下标和处理它的线程(全局编号 g):

第 1 轮
0g0
1g1
2g2
3g3
4g4
5g5
6g6
7g7
第 2 轮
8g0
9g1
10g2
11g3
12g4
13g5
14g6
15g7
第 3 轮
16g0
17g1
18g2
19g3
20退出
21退出
22退出
23退出

block 0(g0–g3)block 1(g4–g7)i ≥ total,循环结束

整个 grid 像一把梳子,每轮整体往前平移 stride 个元素,直到扫完数组。

为什么跨步,而不是每个线程分一段连续的?

另一种直觉是“线程 0 做 0–2,线程 1 做 3–5……”。这样性能很差,原因是合并访存(coalescing)。

同一个 warp、同一条 load 指令,两种分法悬停节点查看连接
显存按 32 字节的 sector 搬运。32 个相邻的 float 正好是连续的 128 字节,一次就搬完;如果 32 个线程各读一个分散的地址,每个 sector 只用到 4 字节。

关键点:grid-stride 是在同一个线程的不同轮次之间跨步;同一轮里,相邻线程读的永远是相邻地址。延伸阅读:NVIDIA blog · Grid-Stride Loops · Programming Guide · Coalesced Global Memory Access。

类比 · 发牌,而不是切牌

8 个人打牌,荷官发 20 张牌:一人一张、转一圈,再一人一张……这就是 grid-stride:第 0 个人拿到第 0、8、16 张。另一种做法是把牌切成 8 叠,每人拿一叠连续的,这就是“每线程一段”。 为什么发牌更快?想象牌在一条传送带上,每一轮 8 个人同时伸手,正好拿走传送带上挨着的 8 张,一次抓取就够了(合并访存)。切牌的话,8 个人同时伸手去抓 8 个相隔很远的位置,传送带要停 8 次。

⑦ block 数上限 sms × 32 在哪里?为什么要限?

你问的是“为什么要限制 block 数(sms × 32),我没看到”:它在 host 函数 bias_gelu 里,第 84–88 行 · drillsc1_elementwise.cu:84–88

const int threads = 256;
const int sms = at::cuda::getCurrentDeviceProperties()->multiProcessorCount;
const int64_t blocks = std::min<int64_t>((total + threads - 1) / threads, (int64_t)sms * 32);

kernel 里看不到,是因为 kernel 不决定启动多少个 block。block 数是 host 端在 <<<blocks, threads>>> 里传进去的,kernel 只能通过 gridDim.x 读到它。

block 数怎么算出来,又怎么变成 kernel 里的 stride悬停节点查看连接
host 端封顶 block 数,kernel 端必须用 grid-stride 循环把剩下的元素扫完。两边必须配套:如果 kernel 还是 vector_add 那种只做一次的写法,超出线程总数的元素就没人处理了。

用 RTX 5090 算几个例子

sms × 32 = 170 × 32 = 5440 个 block,stride = 5440 × 256 = 1,392,640。

total ceil(total / 256) blocks(取 min) 每个线程最多循环几轮
100,000 391 391 1(和 vector_add 完全一样)
1,000,000 3,907 3,907 1
10,000,000 39,063 5,440(封顶) 8
1,000,000,000 3,906,250 5,440(封顶) 719

(轮数 = ⌈total / stride⌉,例如 10⁷ / 1,392,640 ≈ 7.2,所以最多 8 轮。)

为什么要封顶

类比 · 按桌子雇服务员,不按客人数

餐厅里来了 10,000 位客人。你不会雇 10,000 个服务员,每人服务一位就下班(每个 block 只干一点活),光是招人和交接就累死。合理的做法是按桌子数(SM 数)雇人,比如每张桌子配 32 个服务员,每个服务员轮流服务很多位客人(grid-stride 循环)。客人很少的时候,来几位就派几位服务员,和原来没区别。

⑧ bias[i % cols]:扁平下标怎么找到列号

你问的是 第 70–71 行:y[i] = gelu_tanh(x[i] + bias[i % cols]); · drillsc1_elementwise.cu:70–71

这一行等价于 PyTorch 的:

y = F.gelu(x + bias, approximate="tanh")   # x: [rows, cols], bias: [cols]

关键是 x + bias 的广播:bias 只有一行,要加到 x 的每一行上。可 kernel 里没有“行”和“列”,只有一个扁平下标 i,所以要用 i % cols 算出当前元素在哪一列。

第一步:二维张量在显存里是一条一维数组

设 rows = 3,cols = 4。颜色表示列:

逻辑上的二维 x(3 行 × 4 列)和 bias(4 个数):

列 0
列 1
列 2
列 3
行 0
x00
x01
x02
x03
行 1
x10
x11
x12
x13
行 2
x20
x21
x22
x23
bias
b0
b1
b2
b3

显存里实际的样子(行优先:先存完第 0 行,再存第 1 行……):

i
0
1
2
3
4
5
6
7
8
9
10
11
x[i]
x00
x01
x02
x03
x10
x11
x12
x13
x20
x21
x22
x23
i % 4
0
1
2
3
0
1
2
3
0
1
2
3
bias[i%4]
b0
b1
b2
b3
b0
b1
b2
b3
b0
b1
b2
b3

x[i] 和 bias[i % 4] 的颜色每一格都对得上:每到新的一行,i % cols 就回到 0,bias 从头再读一遍。这就是广播。

第二步:从 i 反推行号和列号

每行有 cols 个元素,所以行号 = i / cols(前面有几个完整的行),列号 = i % cols(在当前行里排第几)。

i i / 4(行) i % 4(列) 元素 加的 bias
0 0 0 x00 b0
3 0 3 x03 b3
4 1 0 x10 b0,换行后列号回到 0
6 1 2 x12 b2
11 2 3 x23 b3

只需要列号,所以代码里没有算行号。

第三步:这一行按顺序做了什么,以及“融合”省在哪

  1. 读 x[i](显存)
  2. 读 bias[i % cols](几乎总是命中缓存,见下)
  3. 相加,结果在寄存器里
  4. gelu_tanh,结果还在寄存器里
  5. 写 y[i](显存)
未融合 vs 融合:数据进出显存几次悬停节点查看连接
逐元素运算的瓶颈是显存带宽,不是算力。显存流量减半,速度就接近翻倍。中间结果 t 从来没有写进显存,这就是 fusion 省下的东西。

为什么注释说 bias “stays in L1/L2”: x 的每个元素只读一次,实打实地消耗显存带宽;bias 很小(cols = 4096 时只有 16 KB),却被每一行重复读一遍。第一次读之后它就留在缓存里(L2 有几十 MB),之后几乎全部命中,不占显存带宽。所以这个 kernel 的显存流量就是读 x 一次加写 y 一次。

一个小代价: % 是 64 位整数取模,GPU 上要用几十条指令模拟。这个 kernel 受访存限制,取模的时间通常被访存延迟掩盖掉。想进一步优化,可以改成二维 grid,让 block 直接按行列索引,就不需要取模了。

前提:x 必须是连续的。 “列号 = i % cols”只在 x 真的是紧密的行优先布局时成立。传一个 x.t() 进来,逻辑形状没变,内存顺序却变了,算出的列号就错了,而且不会报错。这正是第 ① 节里 is_contiguous() 检查存在的原因。

类比 · 日历和每周课表

把一年的日子从 0 开始编号:第 i 天。不管过了多少周,“今天星期几”就是 i % 7。bias 就像一张每周固定的课表:周一体育、周二美术……第 i 天上什么课 = 课表[i % 7]。x 是 365 天的日历(按周一行排开,行优先),你不需要知道今天是第几周(行号),只需要知道星期几(列号),就能查到当天的课。

自测:cols = 4096,i = 10,000,这个元素在哪一行哪一列?加的是 bias 的第几个?

行 = 10000 / 4096 = 2,列 = 10000 % 4096 = 10000 − 8192 = 1808。所以是第 2 行第 1808 列,加的是 bias[1808]。

源码与资料索引

本仓库:drillssolutions/c1_elementwise.cu · drillsmine/c1_elementwise.cu(你写的) · drillscontent/c1.md(题目页)

PyTorch(钉在固定 commit)

CUDA 内置变量(clang 的开源实现):llvmblockDim → %ntid · llvmblockDim 变量

文档