假设你是一家装修公司的老板,刚接下一栋10000间客房的大楼照明工程。你当然不会自己拎着工具箱一间一间去装——你有一支100人的施工队(GPU),他们各拿一份你写的《施工手册》,在同一秒冲向各自的房间。每人按手册干一步,100人同时动工,效率直接翻100倍。CUDA核函数编程,就是教你怎么写这份给GPU施工队的《施工手册》:手册封面盖什么章(__global__),工人怎么认领自己的房间(线程索引),队伍怎么分组(grid/block),你站在楼下怎么喊开工(启动语法),手册里有哪些铁律(不能有返回值),以及装错了怎么验收返工(调试工具)。这篇文章,就是带你把这份手册从头到尾写明白。

岗位关联

掌握CUDA核函数编写,你就拿到了让GPU替你打工的钥匙。从深度学习算子优化到科学计算加速,这是AI工程师从“调包侠”迈向“性能工程师”的第一块敲门砖。

目录
  1. 可视化演示
  2. 核函数概述:让GPU替你打工
  3. 声明限定符:手册上的三种印章
  4. 线程层次结构:工人的工号体系
  5. 维度配置:施工队怎么分组
  6. 启动与限制:开工口令与手册铁律
  7. 核函数调试:监工的上岗指南
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

观察粒子在马尔可夫链状态间的随机转移过程,理解转移概率矩阵与稳态分布的形成。

本节配有交互动效(进入视口后自动加载,约 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就是施工队。接下来我们要学的,就是怎么把手册写好——包括封面盖什么章、工人怎么编号、队伍怎么分组、口令怎么喊、有什么禁忌,以及出错了怎么算账。

Nthreads=gridDim.x×blockDim.xN_{\text{threads}} = \text{gridDim.x} \times \text{blockDim.x}
这个公式像一个“点名册”:整个工地一共多少个工人,等于施工队的小组数(gridDim.x)乘以每个小组的人数(blockDim.x)。
  1. gridDim.x整个施工队分成的小组总数。
    gridDim.x\text{gridDim.x}
  2. blockDim.x每个小组里的工人数量。
    blockDim.x\text{blockDim.x}
  3. 相乘把小组数和每组人数乘起来,就得到工地上的工人总数,也就是GPU上并行执行的线程总数。
    Nthreads=gridDim.x×blockDim.xN_{\text{threads}} = \text{gridDim.x} \times \text{blockDim.x}

声明限定符:手册上的三种印章

写施工手册前,先要搞清楚这份手册给谁看。有的手册是给施工队看的,有的手册是施工现场内部传阅的,还有的是总部办公室存档的。CUDA用三种“印章”来区分代码的运行位置:__global__、__device__、__host__。

__global__是核函数的专属印章。盖了这枚章的函数在GPU上执行,但由CPU调用。它就像老板站在楼下向全队广播的施工总则——发号施令的是老板,卖力干活的是工人。

__device__盖着的函数,是工人们内部使用的工具函数,只能在核函数里被调用。__host__则是普通CPU函数,我们平时写的main函数就是一个__host__函数。还有一种组合写法:__host__ __device__,表示这个函数CPU和GPU都能跑——就像“干湿两用”的多功能工具。

盖好__global__这枚印章,你的施工手册才算是被GPU施工队认可的正式文件。漏了印章,编译器会直接报错——施工队压根不认这份手册。

__global__ void kernel_name(参数列表)\texttt{\_\_global\_\_ void kernel\_name}(\text{参数列表})
这是核函数的“封面”格式。写CUDA核函数时,开头永远是这三个要素:__global__印章、void返回类型、函数名加参数。
  1. \_\_global\_\_声明这个函数是GPU核函数,CPU可调用、GPU执行。
    __global__\texttt{\_\_global\_\_}
  2. void核函数的返回类型只能是void,不能有返回值。
    void\texttt{void}
  3. kernel\_name核函数的名字,CPU端通过这个名字来下发启动命令。
    kernel_name\texttt{kernel\_name}
  4. 参数列表核函数的参数,由CPU传给GPU,按值传递。
    参数列表\text{参数列表}

线程层次结构:工人的工号体系

工地上有1000个工人,怎么让每个人准确找到自己该装的那个灯泡?答案是工号。CUDA把工人分成了两级组织:整个施工队叫grid(网格),施工队里又分成若干个小组叫block(线程块)。每个工人有两个编号:在小组内的编号叫threadIdx.x,所在小组的编号叫blockIdx.x。

为什么需要两级编号?因为GPU的硬件架构就是这样设计的:线程块(block)可以被调度到不同的流处理器上执行,而同一个块内的线程可以通过共享内存快速协作。就像施工队分成小队,小队内部可以快速互相借工具,不同小队之间的交流则要慢得多。

在编程时,最常用的操作是把两个编号换算成一个唯一的全局编号(gid)。假设每组4人,你是第2组的第3号工人,那么你的全局编号就是 2×4+3=11。每一位工人的全局编号都不一样,这就是他认领数据房间的“门牌号”。

别小看这套工号体系。GPU能让上千个工人并行干活,靠的就是这套“对号入座”的机制——每个工人拿着工号,找到自己的灯泡,绝不跑错房间。

gid=blockIdx.x×blockDim.x+threadIdx.x\text{gid} = \text{blockIdx.x} \times \text{blockDim.x} + \text{threadIdx.x}
全局编号 = 你前面小组的总人数 + 你在组内的编号。读作“排在你前面的所有队伍的人数,加上你在队伍里的名次”。
  1. blockIdx.x当前线程所在小组的编号,从0开始数。
    blockIdx.x\text{blockIdx.x}
  2. blockDim.x每个小组的工人人数(小组规模)。
    blockDim.x\text{blockDim.x}
  3. blockIdx.x \times blockDim.x排在你前面所有小组的工人总数。
    blockIdx.x×blockDim.x\text{blockIdx.x} \times \text{blockDim.x}
  4. threadIdx.x你在自己小组里的编号,也是从0开始数。
    threadIdx.x\text{threadIdx.x}
  5. 相加前面的队伍人数加上你的队内名次,得到你的全局唯一工号。
    blockIdx.x×blockDim.x+threadIdx.x\text{blockIdx.x} \times \text{blockDim.x} + \text{threadIdx.x}

维度配置:施工队怎么分组

老板在广播里喊“开工”之前,得先想清楚:施工队分成多少组?每组多少人?这就是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稍多,多出的线程做“空转”或边界检查跳过。

分组分得好,工人不白跑。维度配置就像装修前规划楼层分工:一层楼派一组人,组里再分房间,分配得当,工程就顺。

gridDim.x=⌈NblockDim.x⌉\text{gridDim.x} = \left\lceil \frac{N}{\text{blockDim.x}} \right\rceil
需要的小组数 = 数据总量 ÷ 每组人数,然后向上取整。宁可多开几个小组空转,也不能漏掉任何一个灯泡。
  1. N需要处理的数据元素总数(灯泡数量)。
    NN
  2. blockDim.x每个小组的工人人数。
    blockDim.x\text{blockDim.x}
  3. N / blockDim.x理论上的小组数。
    NblockDim.x\frac{N}{\text{blockDim.x}}
  4. \lceil \rceil 向上取整除不尽的余数也要有人处理,所以向上取整。
    ⌈NblockDim.x⌉\left\lceil \frac{N}{\text{blockDim.x}} \right\rceil

启动与限制:开工口令与手册铁律

手册写好、队伍分好,老板怎么开工?CUDA的答案是:一行特殊的函数调用。老板在CPU端写下这行“口令”,GPU施工队听到口令就立刻行动。

这行口令的语法是:kernel_name<<<grid, block>>>(参数列表)。其中<<<grid, block>>>就是启动配置——grid是小组数,block是每组人数。这行代码是异步的:CPU喊完口令后不会站在旁边等工人干完,而是继续干自己手头的其他活。

施工手册上还有几条“铁律”:核函数不能有返回值(必须void);参数按值传递,传指针时传的是显存地址;不能递归调用;不能是类的普通成员函数(可以是static成员函数);由于每条线程的栈空间和寄存器有限,核函数里也不能开太大的局部数组。

开工口令一下,工人们就冲出去了。但手册上写得很清楚:工人干完活不许跑回办公室口头汇报——这就是核函数没有返回值的底层逻辑。工人把结果直接写进现场(显存),老板要数据时再去现场取(cudaMemcpy)。

kernel <<< grid, block >>> (args)\text{kernel}\ \text{<<< grid, block >>>}\ (\text{args})
启动语法三要素:核函数名、双尖括号里的组织配置、参数列表。这是CUDA最标志性的一行代码。
  1. kernel核函数名,即施工手册的名字。
    kernel\text{kernel}
  2. <<< grid, block >>>启动配置——grid是小组数,block是每组人数。
    <<< grid, block >>>\text{<<< grid, block >>>}
  3. (args)从CPU传给GPU的参数,按值传递。
    args\text{args}

核函数调试:监工的上岗指南

工人多了,出错也难免。可能某个工人把灯泡装在走廊,可能有人数错房间号。作为老板,你需要一套工具来定位问题。

入门阶段最直接的监工是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_t err=cudaGetLastError();\text{cudaError\_t}\ \text{err} = \text{cudaGetLastError();}
这行代码是“监工记录本”:把最近一次CUDA调用的错误状态抄下来,和cudaSuccess比对,就能判断有没有出岔子。
  1. cudaError\_tCUDA的错误类型,一个枚举类型。
    cudaError_t\text{cudaError\_t}
  2. err存放错误码的变量。
    err\text{err}
  3. cudaGetLastError()获取最近一次CUDA运行时调用的错误信息。
    cudaGetLastError()\text{cudaGetLastError()}
  4. 判断如果 err != cudaSuccess,就打印错误信息并退出。
    err≠cudaSuccess\text{err} \neq \text{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 演示

python36 行
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)
目的:用Python模拟CUDA核函数中“每个线程根据索引处理一个数组元素”的执行逻辑,直观验证grid/block配置与全局编号计算。
  1. grid_dim = (n + self.block_dim - 1) // self.block_dim等价于 ceil(N / blockDim),也就是向上取整算出需要开多少个block。
  2. gid = block_idx * self.block_dim + thread_idx这就是CUDA的核心索引公式:全局线程编号 gid = blockIdx.x × blockDim.x + threadIdx.x。
  3. if gid < n:边界检查。总线程数可能比数据多,超出的线程什么都不做,保证不越界访问。
  4. 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)应使用哪个声明限定符?

单选题 · easy

  • A.host 主机端普通函数
  • B.global 核函数专用标记← 正确答案
  • C.device 设备端辅助函数
  • D.shared 共享内存修饰符

答案:B。__global__是核函数的专属印章,表示函数在GPU上执行、由CPU调用。__device__是GPU端工具函数,__host__是CPU端普通函数,__shared__是共享内存修饰符。

Q2. 核函数的返回类型必须是什么?

单选题 · easy

  • A.int 整型返回类型
  • B.void 无返回类型← 正确答案
  • C.float 浮点返回类型
  • D.bool 布尔返回类型

答案:B。核函数在GPU上异步执行,无法直接向CPU返回值,因此返回类型必须为void。若想拿到计算结果,需要把结果写入显存中的数组,再用cudaMemcpy拷回。

Q3. 若blockDim.x=128,blockIdx.x=3,threadIdx.x=42,全局线程编号gid是多少?

单选题 · easy

  • 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应配置为多少?

单选题 · mid

  • A.配置为8个块← 正确答案
  • B.配置为7个块
  • C.配置为128个块
  • D.配置为1000个块

答案:A。gridDim.x = ceil(N/blockDim.x) = ceil(1000/128) = 8。向上取整的数学含义是:即使最后一个块只有少量元素,也要开一个块去处理剩余数据。

Q5. 以下哪一项是CUDA核函数的正确启动语法?

单选题 · mid

  • 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中最多允许多少个线程?

单选题 · mid

  • A.最多1024个线程← 正确答案
  • B.最多256个线程
  • C.最多512个线程
  • D.最多65535个线程

答案:A。现代NVIDIA GPU(如A100、V100)的block最大线程数为1024。配置时建议取32的倍数(一个线程束warp的大小),可以充分利用硬件。

Q7. 在核函数中使用printf打印调试信息,为什么程序结束时可能看不到输出?

单选题 · hard

  • A.因为输出被缓冲并且异步执行← 正确答案
  • B.因为GPU不支持print操作
  • C.因为需要加图形驱动才可用
  • D.因为printf必须在循环外才能输出

答案:A。GPU的printf输出是异步且带缓冲的,可能等程序结束后缓冲区才刷到终端。解决方法:在核函数启动后调用cudaDeviceSynchronize(),或者用cuda-gdb打断点观察。

Q8. 关于CUDA核函数的限制,以下哪些说法正确?

多选题 · mid

  • A.核函数返回类型必须是void← 正确答案
  • B.核函数参数按值传递← 正确答案
  • C.核函数可以递归调用
  • D.核函数必须是类的非静态成员函数

答案:AB。核函数不能有返回值、参数按值传递。GPU端栈空间有限,递归会导致栈溢出,因此核函数不允许递归。核函数不能是类的普通成员函数(老版本要求static)。所以只有A、B正确。

Q9. 以下关于CUDA内置变量的描述,哪些是正确的?

多选题 · hard

  • A.threadIdx.x表示线程在块内的编号← 正确答案
  • B.blockIdx.x表示块在网格中的编号← 正确答案
  • C.gridDim.x表示块内的线程数量
  • D.blockDim.x表示网格中的块数量

答案:AB。threadIdx是块内索引,blockIdx是网格内块的索引。gridDim是网格内有多少块,blockDim是块内有多少线程。C和D把这两个维度搞反了。

Q10. 下列哪些手段可以用于定位CUDA核函数中的错误?

多选题 · hard

  • A.在核函数内使用printf打印关键变量← 正确答案
  • B.使用cuda-gdb在核函数处打断点← 正确答案
  • C.用cudaGetLastError检查API调用状态← 正确答案
  • D.使用std::cout在核函数内输出

答案:ABC。printf调试、cuda-gdb断点调试、错误码检查是三种标准手段。std::cout在核函数中不可用(设备端不支持C++流),必须用printf。错误退出后还可用cuda-memcheck/cuda-gdb深入定位越界访问。