假设你是一家装修公司的老板,刚接下一栋10000间客房的大楼照明工程。你当然不会自己拎着工具箱一间一间去装——你有一支100人的施工队(GPU),他们各拿一份你写的《施工手册》,在同一秒冲向各自的房间。每人按手册干一步,100人同时动工,效率直接翻100倍。CUDA核函数编程,就是教你怎么写这份给GPU施工队的《施工手册》:手册封面盖什么章(__global__),工人怎么认领自己的房间(线程索引),队伍怎么分组(grid/block),你站在楼下怎么喊开工(启动语法),手册里有哪些铁律(不能有返回值),以及装错了怎么验收返工(调试工具)。这篇文章,就是带你把这份手册从头到尾写明白。
掌握CUDA核函数编写,你就拿到了让GPU替你打工的钥匙。从深度学习算子优化到科学计算加速,这是AI工程师从“调包侠”迈向“性能工程师”的第一块敲门砖。
目录
可视化演示
观察粒子在马尔可夫链状态间的随机转移过程,理解转移概率矩阵与稳态分布的形成。
本节配有交互动效(进入视口后自动加载,约 3KB)。
马尔可夫链状态转移 青色节点为状态,粒子沿转移概率随机流动 橙色为高转移概率路径 P(Xₙ₊₁=j|Xₙ=i) = pᵢⱼ 观察粒子分布趋向稳态
核函数概述:让GPU替你打工
装修公司接了一个大工程:给一栋10层大楼装灯泡,每层100个房间,一共1000个灯泡。老板一个人装,得装三天三夜。但他手下有100个工人,只要写一份施工手册,让每个工人照着手册去装自己负责的房间,一上午就能干完。GPU就是这支施工队,而那份手册,就是CUDA核函数。
在CUDA里,核函数(kernel)是一个看上去很普通的C++函数,唯一的不同是它前面多了一个印章__global__。这个印章告诉编译器:“把这份函数编译成GPU工人们能执行的代码,而不是CPU的普通指令。”正因为如此,核函数运行在GPU(设备端)上,但由CPU(主机端)负责“下发”。
GPU的厉害之处在于“人多”:一个核函数被启动后,GPU会创建成百上千个线程(工人),每个线程执行同样的代码,但处理不同的数据。比如装灯泡的活,1000个灯泡对应1000个线程,每个线程装一个。为了保证每个工人知道该装哪个灯泡,我们需要给线程编号——这就是后面要讲的线程索引。
所以,核函数就是施工手册,GPU就是施工队。接下来我们要学的,就是怎么把手册写好——包括封面盖什么章、工人怎么编号、队伍怎么分组、口令怎么喊、有什么禁忌,以及出错了怎么算账。
- gridDim.x整个施工队分成的小组总数。
- blockDim.x每个小组里的工人数量。
- 相乘把小组数和每组人数乘起来,就得到工地上的工人总数,也就是GPU上并行执行的线程总数。
声明限定符:手册上的三种印章
写施工手册前,先要搞清楚这份手册给谁看。有的手册是给施工队看的,有的手册是施工现场内部传阅的,还有的是总部办公室存档的。CUDA用三种“印章”来区分代码的运行位置:__global__、__device__、__host__。
__global__是核函数的专属印章。盖了这枚章的函数在GPU上执行,但由CPU调用。它就像老板站在楼下向全队广播的施工总则——发号施令的是老板,卖力干活的是工人。
__device__盖着的函数,是工人们内部使用的工具函数,只能在核函数里被调用。__host__则是普通CPU函数,我们平时写的main函数就是一个__host__函数。还有一种组合写法:__host__ __device__,表示这个函数CPU和GPU都能跑——就像“干湿两用”的多功能工具。
盖好__global__这枚印章,你的施工手册才算是被GPU施工队认可的正式文件。漏了印章,编译器会直接报错——施工队压根不认这份手册。
__global__印章、void返回类型、函数名加参数。- \_\_global\_\_声明这个函数是GPU核函数,CPU可调用、GPU执行。
- void核函数的返回类型只能是void,不能有返回值。
- kernel\_name核函数的名字,CPU端通过这个名字来下发启动命令。
- 参数列表核函数的参数,由CPU传给GPU,按值传递。
线程层次结构:工人的工号体系
工地上有1000个工人,怎么让每个人准确找到自己该装的那个灯泡?答案是工号。CUDA把工人分成了两级组织:整个施工队叫grid(网格),施工队里又分成若干个小组叫block(线程块)。每个工人有两个编号:在小组内的编号叫threadIdx.x,所在小组的编号叫blockIdx.x。
为什么需要两级编号?因为GPU的硬件架构就是这样设计的:线程块(block)可以被调度到不同的流处理器上执行,而同一个块内的线程可以通过共享内存快速协作。就像施工队分成小队,小队内部可以快速互相借工具,不同小队之间的交流则要慢得多。
在编程时,最常用的操作是把两个编号换算成一个唯一的全局编号(gid)。假设每组4人,你是第2组的第3号工人,那么你的全局编号就是 2×4+3=11。每一位工人的全局编号都不一样,这就是他认领数据房间的“门牌号”。
别小看这套工号体系。GPU能让上千个工人并行干活,靠的就是这套“对号入座”的机制——每个工人拿着工号,找到自己的灯泡,绝不跑错房间。
- blockIdx.x当前线程所在小组的编号,从0开始数。
- blockDim.x每个小组的工人人数(小组规模)。
- blockIdx.x \times blockDim.x排在你前面所有小组的工人总数。
- threadIdx.x你在自己小组里的编号,也是从0开始数。
- 相加前面的队伍人数加上你的队内名次,得到你的全局唯一工号。
维度配置:施工队怎么分组
老板在广播里喊“开工”之前,得先想清楚:施工队分成多少组?每组多少人?这就是grid和block的维度配置。
CUDA提供了dim3类型来定义三维尺寸。处理一维数组时,只用到x方向;处理图片(二维)时,可以同时指定x和y;处理三维体素数据时,x、y、z三个方向一起用。不过入门阶段,我们几乎都只用一维x方向。
配置的核心思想是“人多力量大,但要够用”。假设有N个数据要处理,每个小组安排blockDim.x个工人,那么小组数就是 gridDim.x = ceil(N / blockDim.x)。向上取整是为了保证最后剩几个数据也有工人处理。例如N=1000,blockDim.x=128,gridDim.x=ceil(7.8125)=8,总线程数=8×128=1024,比1000稍多,多出的线程做“空转”或边界检查跳过。
分组分得好,工人不白跑。维度配置就像装修前规划楼层分工:一层楼派一组人,组里再分房间,分配得当,工程就顺。
- N需要处理的数据元素总数(灯泡数量)。
- blockDim.x每个小组的工人人数。
- N / blockDim.x理论上的小组数。
- \lceil \rceil 向上取整除不尽的余数也要有人处理,所以向上取整。
启动与限制:开工口令与手册铁律
手册写好、队伍分好,老板怎么开工?CUDA的答案是:一行特殊的函数调用。老板在CPU端写下这行“口令”,GPU施工队听到口令就立刻行动。
这行口令的语法是:kernel_name<<<grid, block>>>(参数列表)。其中<<<grid, block>>>就是启动配置——grid是小组数,block是每组人数。这行代码是异步的:CPU喊完口令后不会站在旁边等工人干完,而是继续干自己手头的其他活。
施工手册上还有几条“铁律”:核函数不能有返回值(必须void);参数按值传递,传指针时传的是显存地址;不能递归调用;不能是类的普通成员函数(可以是static成员函数);由于每条线程的栈空间和寄存器有限,核函数里也不能开太大的局部数组。
开工口令一下,工人们就冲出去了。但手册上写得很清楚:工人干完活不许跑回办公室口头汇报——这就是核函数没有返回值的底层逻辑。工人把结果直接写进现场(显存),老板要数据时再去现场取(cudaMemcpy)。
- kernel核函数名,即施工手册的名字。
- <<< grid, block >>>启动配置——grid是小组数,block是每组人数。
- (args)从CPU传给GPU的参数,按值传递。
核函数调试:监工的上岗指南
工人多了,出错也难免。可能某个工人把灯泡装在走廊,可能有人数错房间号。作为老板,你需要一套工具来定位问题。
入门阶段最直接的监工是printf。没错,核函数里可以写printf!它会打印当前线程的信息。比如printf("block %d, thread %d\n", blockIdx.x, threadIdx.x);就能看到每个线程的执行情况。但注意,GPU的printf是异步的,很可能程序跑完了输出还没刷新,这时候在核函数启动后调用cudaDeviceSynchronize()可以强制同步。
更专业的工具是cuda-gdb。先用nvcc -g -G编译,再用cuda-gdb启动程序,就能像调试普通程序一样打断点、单步执行、查看每个线程的变量值。此外,几乎每个CUDA API调用都应该检查返回值:cudaGetLastError()可以告诉你最近一次调用是否出错。
监工的职责是让问题在萌芽期暴露。核函数里的printf、CPU端对错误码的检查、cuda-gdb里的断点,就是工地上的三班监工——24小时盯着施工队,bug一冒头就揪出来。
---
- cudaError\_tCUDA的错误类型,一个枚举类型。
- err存放错误码的变量。
- cudaGetLastError()获取最近一次CUDA运行时调用的错误信息。
- 判断如果 err != cudaSuccess,就打印错误信息并退出。
AI 应用场景
场景一:深度学习中的LayerNorm算子加速
阶段:模型训练与推理优化
解决的问题:在Transformer大模型训练中,LayerNorm要对每个token的特征做均值、方差计算和归一化。用PyTorch内置实现,这三个步骤会多次读写显存,浪费带宽。手写一个融合核函数(fused kernel),让每个线程处理一个token,一次读完数据后依次完成均值、方差、归一化,最后写回,能减少2-3次显存往返。
具体案例:某AI推理团队在自研推理引擎时,用CUDA核函数重写LayerNorm,在A100上把吞吐提升了35%。他们的核函数遵循了本文讲的范式:__global__声明、线程索引计算全局id、grid/block配置、void返回、printf调试。
场景二:大数据预处理中的图像并行归一化
阶段:数据准备与特征工程
解决的问题:在训练CV模型前,需要对数百万张图片做像素归一化(减均值、除方差)。CPU单线程遍历太慢,用GPU核函数并行处理,每个线程处理一个像素(或一个通道),同时对数万张图片操作。
具体案例:一个自动驾驶数据管线团队,把图像预处理写成核函数:一个block处理一张图,block内每个线程处理图中的一个像素块,配合dim3二维索引,把预处理耗时从2小时压到10分钟。
场景三:推荐系统Embedding查询合并
阶段:在线推理服务
解决的问题:推荐模型需要查询大量用户/物品Embedding,逐个串行查询延迟高。把查询逻辑写成核函数,让GPU并行查上千个Embedding,再把结果聚合返回。
具体案例:某推荐系统团队用CUDA核函数实现批量Embedding查找,每个线程查一个ID,通过grid/block配置使查询总延迟降低80%,线上P99从5ms降到1ms。
Python 演示
class KernelSimulator: """Simulate CUDA kernel thread organization and vector add.""" def __init__(self, block_dim): self.block_dim = block_dim # threads per block def calc_grid_dim(self, n): # ceil(n / block_dim) return (n + self.block_dim - 1) // self.block_dim def global_id(self, block_idx, thread_idx): # gid = blockIdx.x * blockDim.x + threadIdx.x return block_idx * self.block_dim + thread_idx def vector_add(self, a, b): n = len(a) grid_dim = self.calc_grid_dim(n) c = [0] * n print("gridDim=%d, blockDim=%d, total threads=%d" % (grid_dim, self.block_dim, grid_dim * self.block_dim)) for bi in range(grid_dim): for ti in range(self.block_dim): gid = self.global_id(bi, ti) if gid < n: c[gid] = a[gid] + b[gid] print("block %d thread %d -> gid %d: %d + %d = %d" % (bi, ti, gid, a[gid], b[gid], c[gid])) else: print("block %d thread %d -> gid %d out of range, skip" % (bi, ti, gid)) return c if __name__ == "__main__": sim = KernelSimulator(block_dim=4) a = [1, 2, 3, 4, 5, 6, 7, 8, 9] b = [10, 20, 30, 40, 50, 60, 70, 80, 90] result = sim.vector_add(a, b) print("result:", result)
grid_dim = (n + self.block_dim - 1) // self.block_dim等价于ceil(N / blockDim),也就是向上取整算出需要开多少个block。gid = block_idx * self.block_dim + thread_idx这就是CUDA的核心索引公式:全局线程编号 gid = blockIdx.x × blockDim.x + threadIdx.x。if gid < n:边界检查。总线程数可能比数据多,超出的线程什么都不做,保证不越界访问。c[gid] = a[gid] + b[gid]每个线程处理自己责任范围内的数据,这正是GPU并行加速的微观缩影。
常见误区
误区我以前写函数都是return算结果,写核函数时也想当然地写return result,心想GPU算完直接把结果还给我多方便啊。
正解核函数不能有返回值。GPU上的百万个线程是异步执行的,CPU不可能逐个接收返回值。正确的做法是:核函数把计算结果写到一块显存数组里(相当于工人在工地记录板上写好成果),CPU稍后用cudaMemcpy把数据从显存拷回内存(老板去现场取记录板)。
为何常见:CPU函数“返回值”是最自然的传数据方式,初学者没有意识到GPU是“发射后不管”的异步执行模型。
误区我以为只要启动核函数时指定了足够的线程,GPU就会自动把数据分给每个线程处理,不需要我在代码里算索引。
正解核函数里的每一行代码,每个线程都会各自执行一遍。你必须手动通过blockIdx.x、blockDim.x、threadIdx.x计算出当前线程的全局编号,再决定处理哪份数据。而且数据量不是线程数整数倍时,还要做越界检查(if (gid < n))。就像施工手册只写了“去你的房间装灯泡”,但工人必须自己拿工号牌找房间。
为何常见:把核函数想象成一个被GPU自动展开的for循环,忽略了“每个线程独立执行同一段代码”这个并行模型。
误区GPU有几千个核心,我直接写gridDim=1000000、blockDim=1000000,岂不是秒杀一切?
正解blockDim.x有硬上限(现代GPU通常为1024),gridDim也有上限(约2^31-1)。此外,一个block能开的线程数受寄存器、共享内存等资源限制。block大小最好是32的倍数,否则会有线程束浪费。就像工地能容纳的总人数有限,消防通道也限定了每个区域不能挤太多人。
为何常见:被“GPU有上百万线程”的宣传误导,忽略了硬件架构的物理约束。
误区我以为核函数就是普通C++函数,可以malloc申请内存、可以调std::cout、可以递归计算。
正解核函数运行在GPU设备端,可用的库和API非常有限。老版本不支持malloc/new(新版本有限支持),不支持C++的iostream,不允许递归。核函数内部能安全使用的是被__device__修饰的自定义辅助函数和CUDA提供的少数内置函数。
为何常见:GPU设备端是一个独立的执行环境,和CPU内存空间、运行库都不同,初学者很难立刻建立这种“两个世界”的认知。
误区我在核函数里写了printf,运行后屏幕一片空白,是不是我的核函数压根没执行?
正解很可能是GPU的printf输出还在缓冲区里没刷出来。GPU printf是异步的,程序结束时输出可能还没写回终端。应该在启动核函数后调用cudaDeviceSynchronize()强制同步,或者在程序结尾调用cudaDeviceReset()刷出缓冲区。
为何常见:默认用CPU的printf经验来理解GPU,忽略了异步执行和缓冲区机制。
面试题
Q1. 在CUDA中,核函数(kernel)应使用哪个声明限定符?
- A.host 主机端普通函数
- B.global 核函数专用标记← 正确答案
- C.device 设备端辅助函数
- D.shared 共享内存修饰符
答案:B。__global__是核函数的专属印章,表示函数在GPU上执行、由CPU调用。__device__是GPU端工具函数,__host__是CPU端普通函数,__shared__是共享内存修饰符。
Q2. 核函数的返回类型必须是什么?
- A.int 整型返回类型
- B.void 无返回类型← 正确答案
- C.float 浮点返回类型
- D.bool 布尔返回类型
答案:B。核函数在GPU上异步执行,无法直接向CPU返回值,因此返回类型必须为void。若想拿到计算结果,需要把结果写入显存中的数组,再用cudaMemcpy拷回。
Q3. 若blockDim.x=128,blockIdx.x=3,threadIdx.x=42,全局线程编号gid是多少?
- A.gid等于384号
- B.gid等于170号
- C.gid等于426号← 正确答案
- D.gid等于512号
答案:C。gid = blockIdx.x × blockDim.x + threadIdx.x = 3×128+42 = 426。这个公式是CUDA线程索引计算的核心,每个线程靠它找到自己负责的数据。
Q4. 需要处理N=1000个元素,blockDim.x=128,gridDim.x应配置为多少?
- A.配置为8个块← 正确答案
- B.配置为7个块
- C.配置为128个块
- D.配置为1000个块
答案:A。gridDim.x = ceil(N/blockDim.x) = ceil(1000/128) = 8。向上取整的数学含义是:即使最后一个块只有少量元素,也要开一个块去处理剩余数据。
Q5. 以下哪一项是CUDA核函数的正确启动语法?
- A.kernel<<<grid, block>>>(args) 双尖括号配置← 正确答案
- B.kernel(grid, block)(args) 括号传参
- C.kernel[grid, block](args) 方括号配置
- D.kernel(grid, block, args) 普通传参
答案:A。CUDA用<<<grid, block>>>进行启动配置,把线程组织信息传给运行时系统。后面的(args)是传给核函数的实参列表。这是CUDA最标志性的语法。
Q6. 在大多数现代NVIDIA GPU上,一个block中最多允许多少个线程?
- A.最多1024个线程← 正确答案
- B.最多256个线程
- C.最多512个线程
- D.最多65535个线程
答案:A。现代NVIDIA GPU(如A100、V100)的block最大线程数为1024。配置时建议取32的倍数(一个线程束warp的大小),可以充分利用硬件。
Q7. 在核函数中使用printf打印调试信息,为什么程序结束时可能看不到输出?
- A.因为输出被缓冲并且异步执行← 正确答案
- B.因为GPU不支持print操作
- C.因为需要加图形驱动才可用
- D.因为printf必须在循环外才能输出
答案:A。GPU的printf输出是异步且带缓冲的,可能等程序结束后缓冲区才刷到终端。解决方法:在核函数启动后调用cudaDeviceSynchronize(),或者用cuda-gdb打断点观察。
Q8. 关于CUDA核函数的限制,以下哪些说法正确?
- A.核函数返回类型必须是void← 正确答案
- B.核函数参数按值传递← 正确答案
- C.核函数可以递归调用
- D.核函数必须是类的非静态成员函数
答案:AB。核函数不能有返回值、参数按值传递。GPU端栈空间有限,递归会导致栈溢出,因此核函数不允许递归。核函数不能是类的普通成员函数(老版本要求static)。所以只有A、B正确。
Q9. 以下关于CUDA内置变量的描述,哪些是正确的?
- A.threadIdx.x表示线程在块内的编号← 正确答案
- B.blockIdx.x表示块在网格中的编号← 正确答案
- C.gridDim.x表示块内的线程数量
- D.blockDim.x表示网格中的块数量
答案:AB。threadIdx是块内索引,blockIdx是网格内块的索引。gridDim是网格内有多少块,blockDim是块内有多少线程。C和D把这两个维度搞反了。
Q10. 下列哪些手段可以用于定位CUDA核函数中的错误?
- A.在核函数内使用printf打印关键变量← 正确答案
- B.使用cuda-gdb在核函数处打断点← 正确答案
- C.用cudaGetLastError检查API调用状态← 正确答案
- D.使用std::cout在核函数内输出
答案:ABC。printf调试、cuda-gdb断点调试、错误码检查是三种标准手段。std::cout在核函数中不可用(设备端不支持C++流),必须用printf。错误退出后还可用cuda-memcheck/cuda-gdb深入定位越界访问。