想象你是一位餐厅老板,要在三小时内为满堂宾客同时端出上千道菜。你当然不会自己冲进厨房一口锅一口锅地炒——你会做两件事:写下一份标准菜谱,让每个厨师都按同一套做法操作;再贴出一张排班表,明确谁在哪个灶台、负责哪道菜。老板是你手中的CPU,菜谱是核函数,厨房是GPU,而排班表上的每一位厨师,就是GPU里成千上万个并行运行的线程。你只需要在菜谱上盖一个特殊印章“``__global__`”,再附上排班格式“`<<<grid, block>>>``”,厨师们就会自动各就各位,靠着工位号算出自己该处理哪份食材。这就是CUDA编程模型的核心秘密——让一个函数同时跑在成千上万个线程里,而你真正要学的,不过是“如何写菜谱、如何排班”。
学会核函数,你就握住了GPU并行计算的入场券。无论日后做深度学习算子优化、图像处理还是科学计算,所有高性能AI落地都从这里起步。
可视化演示
展示Grid-Block-Thread层次结构、Warp内存合并访问模式及核函数启动流程,支持三模式切换交互。
本节配有交互动效(进入视口后自动加载,约 3KB)。
CUDA线程层次结构 Grid:全局执行配置包含所有线程块 Block:独立线程块可调度到任意SM Warp:32线程以SIMT方式同步执行 idx = blockIdx.x * blockDim.x + threadIdx.x 三级索引将计算任务映射到大规模并行线程
核函数声明
老板把“红烧肉怎么做、几分熟出锅”写成一张标准操作卡,盖上主厨专用印章,贴在厨房每个灶台前。这张卡在CUDA里就叫核函数(Kernel),印章上的字就是“``__global__``”。凡是盖了这枚印章的函数,就表示它的执行者不是CPU,而是GPU上每一位并行工作的“厨师线程”。
从菜谱过渡到代码:你要写的核函数与普通C函数长得很像,但有三个显著区别。第一,声明时必须以“``__global__`”开头,告诉编译器“这段代码编译后要放到GPU上去执行”;第二,返回类型必须写成“`void``”,因为成千上万个线程同时执行时,谁也没有资格向CPU返回一个单独的值,所有计算结果必须通过参数指针写回内存;第三,核函数只能由主机端代码调用,不能由另一个核函数直接调用。
打个比方:核函数声明就是“操作卡抬头+印章”。至于卡上具体写什么步骤、派多少人去执行,则由下面几节决定。
- ``
__global__`` 修饰符这是核函数的“主厨印章”,告诉编译器该函数在设备端(GPU)执行,但由主机端(CPU)发起调用。 - ``
void`` 返回类型核函数不能向调用方返回任何值,因为千万个线程的返回值无处安放;结果一律通过指针参数写回内存。 - ``
kernel_name`` 函数名这是你在主机代码中“点名启动”用的名称,调用时配合尖括号执行配置一起使用。 - 参数列表参数在每个线程中都有自己的一份拷贝,通常传入数据指针、标量值或输出缓冲区的地址。
执行配置
菜谱写好了,可老板还不能撒手。他需要再写一张排班表:菜谱要复制给几组人?每组多少人?在CUDA里,这张排班表就是调用核函数时的“执行配置”,用一对尖括号“``<<< >>>``”表达,紧跟在函数名之后。
执行配置的完整语法是“``kernel_name<<<grid_size, block_size>>>(args)`”。其中第一个数字“`grid_size`”表示启动多少个线程块(block),相当于厨房分成几个班组;第二个数字“`block_size`”表示每个班组里有多少个线程(thread),相当于每个班组几个厨师。例如“`vectorAdd<<<2, 32>>>(a, b, c, N)``”表示启动2个块、每块32个线程,总共64个线程并行工作。
回到厨房场景:排班表决定了一道菜的复制份数和人手规模。老板只需写一行“``kernel<<<2, 32>>>(...)``”,GPU就会自动安排2个班组、每班32人同时开工,完全不需要老板逐个厨师去叮嘱。
- ``
<<< >>>`` 尖括号CUDA特有的执行配置语法,紧跟在核函数名后,用于声明启动多少线程。 - ``
grid_size``(网格大小)网格中线程块的数量,相当于“分几个班组”。 - ``
block_size``(块大小)每个线程块中线程的数量,相当于“每组几位厨师”。 - 参数传递尖括号后面圆括号内的实参会传给核函数,每个线程都能读取这些参数的一份拷贝。
线程索引
排班表贴出去了,可每个厨师怎么知道自己是“第几组的第几号”呢?CUDA给每个线程发了两个身份牌:一个是``blockIdx.x`,表示这个线程所在的块是网格里的第几个块;另一个是`threadIdx.x``,表示线程在块内的第几个位置。两者都从0开始编号。
光有身份牌还不够,程序员需要把“组号+组内编号”换算成一个全局编号,才能对应到数组的下标。换算逻辑很直观:先数一数当前线程前面有几个完整的块,每个块里有``blockDim.x`个线程,加上自己在块内的`threadIdx.x``,就是全场第几个线程。
用一个具体例子:网格有2个块,每块8个线程。第1块(``blockIdx.x=1`)的第5号厨师(`threadIdx.x=5``)的全局索引就是“\(1\times8+5=13\)”。每个厨师只需记住自己两个号码,就能唯一确定全局工位。
- ``
blockIdx.x``当前线程所在块在整个网格x方向的编号,从0开始。排班表中的“第几组”。 - ``
blockDim.x``每个块在x方向包含的线程数,也就是“每组多少人”。 - ``
threadIdx.x``当前线程在块内x方向的编号,从0开始。相当于“组内第几号”。 - 组合换算先跳过前面``
blockIdx.x``个整块的人数,再加上组内序号,得到全网格的线性索引。
网格与块
中央厨房的架构不止一层:整个厨房叫“网格”(grid),里面划分成若干“块”(block),每个块里坐着若干“厨师”(thread)。更妙的是,网格和块都可以是一维、二维或三维的,分别适用于处理数组、矩阵图像或立体数据。
在CUDA中,``gridDim.x`、`gridDim.y`、`gridDim.z`表示网格在三个方向上的块数量;`blockDim.x`、`blockDim.y`、`blockDim.z``表示块在三个方向上的线程数量。把三个方向的人数乘起来,就是网格中的总线程数。
以前面的例子来说,如果启动“``<<<(2,2), (4,4)>>>``”,表示网格有2×2个块,每块4×4个线程,总共64位厨师同时开工。理解了这个嵌套结构,就能明白为什么GPU吞吐量如此惊人——它相当于把一个大厨房分成无数小班组,每个班组独立又并行地运转。
- ``
gridDim.x`/`gridDim.y`/`gridDim.z``网格在三个维度的块数量,最多三维,相当于厨房分区的坐标轴。 - ``
blockDim.x`/`blockDim.y`/`blockDim.z``每个块在三个维度的线程数量,同一块内的线程可以协作。 - 总线程数所有维度的块数和线程数相乘,得到整个厨房可以同时开工的厨师总数。
并行映射
排班表和工位号都到位了,接下来最关键的问题:每个厨师到底做哪道菜?在CUDA里,最常见的做法是“一人一道菜”:把大数组均匀切分,让每个线程通过自己的全局索引去访问对应的数组元素。
比如做向量加法“``C[i] = A[i] + B[i]`”,传统CPU写法是写一个循环依次处理每个元素;CUDA则是让第`i`号线程直接去处理第`i``个元素。由于线程们同时开工,整个循环的时间被压缩成一次“并行爆发”。
回到厨房:老板把食材切成N份摆上案台,每个厨师按工位号拿起自己那份。若食材不够分(多出来的厨师),就站在一旁待命,绝不抢占别人的案板。这就是“数据并行”的精髓——人不动,数据动。
- ``
i = blockIdx.x × blockDim.x + threadIdx.x``先用工位换算公式计算全局索引,得到当前线程要处理的数组下标。 - ``
if (i < N)`` 边界检查当数组长度不是线程总数的整数倍时,多出来的线程不干活,防止越界访问。 - ``
C[i] = A[i] + B[i]``每个线程并行执行同一条加法指令,但操作的数据各自不同,互不干扰。
编译运行
菜谱和排班表都写好了,要怎么把整套方案落地?CUDA提供了专门的编译器“``nvcc`”,它能把一份混合了CPU代码和GPU代码的`.cu``源文件,自动分离并编译成主机端可执行文件与设备端二进制代码。
运行流程分两条线:主机端(CPU)像餐厅老板一样按顺序执行自己的逻辑,遇到“``kernel<<<...>>>``”时,把核函数交给GPU异步启动,然后立即继续做自己的事;设备端(GPU)的厨师们并行执行核函数体。由于核函数启动是异步的,程序员需要主动调用错误检查函数和同步函数来确认执行结果。
整个流程就像餐厅开业的完整链条:老板(CPU)写菜单、发通知、查验收;厨房(GPU)领菜谱、按排班表并行做菜、把菜摆到出餐口。如果排班表写错或某道菜没熟,老板必须主动检查,否则顾客只能干等。
- ``
nvcc`` 编译命令一条命令同时编译主机代码和设备代码,例如“``nvcc -arch=sm_80 hello.cu -o hello``”。 - ``
cudaGetLastError()`` 错误检查核函数启动是异步的,启动后立即调用此函数捕获配置错误(如网格尺寸非法)。 - ``
cudaDeviceSynchronize()`` 同步等待阻塞主机端,直到所有设备端线程执行完毕,用于保证数据就绪。
AI 应用场景
图像滤镜与像素级并行
阶段:数据预处理与特征提取
解决的问题:一张1080p图像包含约200万个像素,CPU逐像素遍历做模糊或边缘检测要耗时几十毫秒;核函数让每个线程处理一个像素,一次并行爆发完成全部滤镜计算,延迟降到毫秒以内。
具体案例:自动驾驶摄像头采集的实时画面需要快速做白平衡和去噪。工程师写一个``apply_filter<<<grid, block>>>(image)``核函数,每个线程读取自己负责像素的RGB值,套用卷积核计算新像素值。GPU上200万个线程同时开工,一帧画面的预处理时间从20ms压到2ms。
深度学习框架中的张量算子
阶段:模型训练与推理
解决的问题:神经网络的核心是矩阵乘法和卷积,计算量动辄数十亿次浮点运算。CUDA核函数把输出矩阵的每个元素分配给一个线程组计算,实现大规模并行,大幅提升训练速度。
具体案例:PyTorch训练ResNet时,底层调用的cuDNN卷积算子就是典型的kernel:输入特征图被切分成多个Tile,每个线程块负责一个输出区域,块内线程通过``blockIdx.x`和`threadIdx.x``定位到具体的输出像素与通道。这就是为什么一张A100 GPU训练大模型比CPU快几十倍。
科学计算中的粒子模拟
阶段:物理仿真与优化
解决的问题:模拟一万个粒子的引力相互作用需要计算一亿次两两作用力,CPU串行计算无法实时完成。核函数赋予每个线程一个粒子,并行计算它与其他粒子的合力。
具体案例:天体物理团队模拟星系演化时,写``gravity_kernel<<<grid, block>>>(positions, forces, N)``,每个线程计算一个粒子所受的全部引力,把复杂度从串行的一亿次循环压缩为一次并行发射。原本需要数小时的模拟,缩短到几分钟。
Python 演示
def simulate_cuda_kernel(grid_x, grid_y, block_x, block_y): """Simulate the thread index calculation of a CUDA kernel.""" print("=" * 60) print("CUDA Kernel Thread Index Simulation") print("grid: (%d, %d), block: (%d, %d)" % (grid_x, grid_y, block_x, block_y)) print("=" * 60) block_size = block_x * block_y grid_size = grid_x * grid_y total = grid_size * block_size print("Total threads = %d * %d = %d" % (grid_size, block_size, total)) print("-" * 60) count = 0 for block_idx in range(grid_size): block_x_id = block_idx % grid_x block_y_id = block_idx // grid_x for thread_idx in range(block_size): thread_x_id = thread_idx % block_x thread_y_id = thread_idx // block_x global_x = block_x_id * block_x + thread_x_id global_y = block_y_id * block_y + thread_y_id if count < 16: print("thread#%2d: block=(%d,%d) thread=(%d,%d) -> global=(%d,%d)" % (block_idx * block_size + thread_idx, block_x_id, block_y_id, thread_x_id, thread_y_id, global_x, global_y)) count += 1 print("... total %d threads" % total) def vector_add_simulation(n, block_size): """Simulate vector add with boundary check in CUDA style.""" a = [i * 2 for i in range(n)] b = [i * 3 for i in range(n)] c = [-1] * n grid_size = (n + block_size - 1) // block_size print("\nVector Add Simulation: N=%d, block_size=%d, grid_size=%d" % (n, block_size, grid_size)) for i in range(n): bid = i // block_size tid = i % block_size gid = bid * block_size + tid if gid < n: c[gid] = a[gid] + b[gid] print("c[0] = %d + %d = %d" % (a[0], b[0], c[0])) print("c[%d] = %d + %d = %d" % (n - 1, a[n - 1], b[n - 1], c[n - 1])) if __name__ == "__main__": simulate_cuda_kernel(2, 2, 4, 4) vector_add_simulation(10, 4)
blockIdx.x`、`threadIdx.x`和`blockDim.x``如何协作。simulate_cuda_kernel(2, 2, 4, 4)模拟一个2×2网格、每块4×4线程的核函数启动,打印前16个线程的全局坐标。for block_idx in range(grid_size)遍历所有线程块,相当于GPU硬件按块调度线程。block_x_id = block_idx % grid_x从线性块编号反推出块在网格x方向的坐标,对应``blockIdx.x``。global_x = block_x_id * block_x + thread_x_id核心换算公式:组编号×组大小+组内编号,得到全局x坐标。if gid < n:边界检查,模拟CUDA中“数组长度不是线程数整数倍”时的越界保护。grid_size = (n + block_size - 1) // block_size向上取整计算需要的块数,保证所有元素都有线程覆盖。
常见误区
误区我写了一个带```__global__```的函数,以为它和普通C函数一样在CPU上逐行跑,前面的修饰符只是个装饰。
正解``__global__``明确把代码编译并部署到GPU(设备)端执行,CPU只是发起调用的一方。就像菜谱是给厨房里的厨师看的,老板只负责把菜谱递过去,并不会自己下厨。核函数体内的每条指令都被大量线程并行执行,而不是CPU串行循环。
为何常见:传统C语言里所有函数都在CPU上运行,突然引入“设备端执行”的概念,惯性思维让人误以为修饰符不影响执行位置。
误区我尝试把核函数返回类型写成```int```,想让它返回一个错误码,编译就报错了。难道不能返回```int```吗?
正解CUDA规定核函数返回类型必须是``void``,因为成千上万个线程并行执行时,没有单一接收者能收下返回值。所有结果必须通过指针参数写回内存,这就像让每道菜的厨师直接把菜端到出餐窗口,而不是端回老板办公室。
为何常见:普通函数“有入有出”的思维定式,让人忽略了并行场景下返回值的归属问题。
误区我在核函数里直接用```threadIdx.x```当作全局线程编号,结果数组越界、结果错乱。不就是编号吗,有什么区别?
正解``threadIdx.x`是块内编号(组内第几号),`blockIdx.x`是块在网格中的编号(第几组),全局索引必须用“`blockIdx.x * blockDim.x + threadIdx.x``”合成。只报工号不报组号,食堂打饭都会排错队,GPU自然也会乱套。
为何常见:初学CUDA时只盯住“线程”概念,忽略了线程是有层级结构的两级编址。
误区我担心核函数里的一整段循环会被所有线程轮流执行,那不是比单线程还慢吗?
正解核函数的每一条指令都会被所有线程同时执行(SIMT模型),循环是每个线程各自独立完成的操作序列。就像每个厨师都按照同一张菜谱同时颠勺,而不是一个厨师做完另一个才上手。面向数据并行思维,代码是“一人一份地跑”,不是“排队等着一人负责全部”。
为何常见:把“函数执行”理解为单一调用流,没有建立“同一函数体被几千个线程副本同时执行”的心智模型。
误区我只管调用```kernel<<<...>>>```,如果配置错误或越界,它会像普通函数一样自动抛异常报错。
正解核函数启动是异步的,CPU把任务交给GPU后立刻返回,运行错误不会自动传到CPU端。必须主动调用``cudaGetLastError()`检查启动错误,或用`cudaDeviceSynchronize()``等待并捕获执行错误。这就像自助餐厅不会挨桌通知“某道菜没熟”,你得自己去吧台问。
为何常见:习惯了CPU端的同步异常机制,忽略了GPU异步执行的特性。
面试题
Q1. 在CUDA中,声明核函数必须使用的修饰符是?
- A.device
- B.global← 正确答案
- C.host
- D.kernel
答案:B。``__global__`是核函数的专属签名,声明该函数在设备端执行、由主机端调用。`__device__`表示只能在设备端被其他设备函数调用,`__host__`表示普通主机函数,`__kernel__``不是CUDA关键字。
Q2. CUDA核函数的返回类型强制要求是什么?
- A.int
- B.float
- C.void← 正确答案
- D.任意类型均可以
答案:C。核函数并行执行时成千上万个线程的返回值没有接收者,因此必须用void,结果通过指针参数写回内存。就像菜品直接端到出餐口,而不是端回老板办公室。
Q3. 核函数调用 vectorAdd<<<2, 32>>>(a, b, c, N) 会启动多少个线程?
- A.2个线程
- B.32个线程
- C.64个线程← 正确答案
- D.取决于数组N的大小
答案:C。执行配置中2是网格中的块数,32是每块的线程数,总线程数=2×32=64。N只决定数据量,不决定线程总数,多出的线程通过边界检查闲置。
Q4. 一维核函数中计算线程全局索引的正确表达式是?
- A.blockIdx.x * blockDim.x + threadIdx.x← 正确答案
- B.threadIdx.x * blockDim.x + blockIdx.x
- C.blockIdx.x + blockDim.x * threadIdx.x
- D.直接用threadIdx.x即可
答案:A。全局索引=跳过前面所有块的人数+块内编号,即“组数×每组人数+组内号”。B和C交换了乘数位置导致编号错位,D忽略了块层级。
Q5. 关于gridDim和blockDim,以下说法正确的有哪些?
- A.gridDim.x表示网格中线程块的个数← 正确答案
- B.blockDim.x表示每个块中线程的个数← 正确答案
- C.gridDim.x表示网格中线程的个数
- D.blockDim.x表示每个线程中块的个数
答案:AB。gridDim是网格各维度块的数目,blockDim是块各维度线程的数目。二者相乘才是总线程数,C和D把层级关系彻底搞反了。
Q6. 核函数以<<<2, 8>>>启动(一维),块编号(blockIdx.x)的取值范围是?
- A.0到1← 正确答案
- B.0到7
- C.0到15
- D.0到8
答案:A。gridDim.x=2表示只有2个块,编号从0开始,因此blockIdx.x只能是0和1。块内线程数8只影响threadIdx.x的范围0到7,不影响块编号。
Q7. 在二维线程块中,threadIdx.x=3、threadIdx.y=2、blockDim.x=4时,线程的块内线性编号为?
- A.5
- B.11← 正确答案
- C.3
- D.8
答案:B。块内线性编号=threadIdx.y×blockDim.x+threadIdx.x=2×4+3=11。规则是“先x后y”,即x方向放满一行才换到下一行,与C语言多维数组的行优先布局一致。
Q8. 关于CUDA核函数的启动与执行,下列说法正确的有哪些?
- A.主机调用核函数后立即返回,线程在设备上异步执行← 正确答案
- B.可以调用cudaDeviceSynchronize()等待所有线程完成← 正确答案
- C.核函数内的普通变量是每个线程各自私有的副本← 正确答案
- D.核函数可以通过return语句向主机返回计算结果
答案:ABC。核函数启动是异步的,故A正确;同步函数用于阻塞等待设备完成,故B正确;线程私有变量互不干扰,故C正确;核函数返回类型必须void,D错误。这就像老板发完任务就忙别的,厨师们各自用自己案板上的调料。
Q9. 网格配置为<<<gridDim(1,2), blockDim(4,4)>>>,网格总共有多少个线程?
- A.16个线程
- B.32个线程← 正确答案
- C.8个线程
- D.64个线程
答案:B。总线程数=gridDim.x×gridDim.y×blockDim.x×blockDim.y=1×2×4×4=32。注意网格有1×2=2个块,每块有4×4=16个线程,共32个线程并行。
Q10. 关于核函数的特点,下列说法正确的有哪些?
- A.由主机端代码调用启动← 正确答案
- B.在设备端GPU上并行执行← 正确答案
- C.返回类型可以定义为int
- D.使用global修饰符声明← 正确答案
答案:ABD。核函数由CPU发起、在GPU上执行,A、B正确;声明必须用``__global__``,D正确;返回类型必须是void,C错误。选C的人把普通C函数的返回值习惯带到了并行世界。