00 > 一个线程块,一个线程
这一课会运行一个最简单的 CUDA 程序。它的核函数只用一个线程块、一个线程,完全没有并行。这样在内容变复杂之前,你就能先看到第一行输出。后面每一课都会在这个程序的基础上做一点小改动。
[!NOTE] 本页所有输出都来自一块 NVIDIA L40S,环境是 CUDA 13.0 和 Ubuntu 24。
GPU 和 CPU 的区别
在 CPU 上,一个函数只在一个核心上运行一次。在 GPU 上,一个核函数会并行运行很多次。核函数就是在 GPU 上运行的函数,它的每一份运行中的副本叫作一个线程。运行多少个线程由两个数决定,一个是线程块的数量,另一个是每个线程块里的线程数。
比如 2 个线程块、每块 3 个线程,一共会启动 2 x 3 = 6 个线程。这 6 个线程运行的是同一份核函数代码。
__global__ 是什么意思
__global__ void printIDs() { ... }
__global__ 把一个函数标记为 GPU 核函数。编译器会为 GPU 而不是 CPU 编译它。它由 CPU 调用,却在 GPU 上运行。
[!NOTE] 另外还有两个限定符。
__device__函数在 GPU 上运行,只能由 GPU 代码调用。__host__就是普通的 CPU 函数,只能在 CPU 上调用。
启动配置 <<<blocks, threads>>>
printIDs<<<1, 1>>>();
// ^ ^
// blocks -+ +- threads per block
<<<...>>> 这种写法叫作执行配置,写在函数名和参数列表之间。第一个数是线程块的数量,第二个数是每个线程块里的线程数。<<<1, 1>>> 表示一个线程块,里面只有一个线程。线程总数是 1 x 1 = 1。
线程、线程块、网格
每次启动核函数,都会形成三个层级。
- 线程是最小的单位。一个线程运行核函数的一份副本。
- 线程块是在同一个 SM 上运行的一组线程。SM 是 GPU 内部众多小处理器中的一个。同一个线程块里的线程可以共享内存。
- 网格是一次核函数启动中的全部线程块。启动一次,就有一个网格。
[!NOTE] 一块 GPU 有很多个 SM。这些课使用的 L40S 有 142 个。一个线程块永远不会被拆到两个 SM 上,但不同的线程块可以同时在不同的 SM 上运行。第 02 课会用到这一点。
blockIdx.x 和 threadIdx.x
printf("Block ID: %d Thread ID: %d", blockIdx.x, threadIdx.x);
blockIdx.x 是当前线程所在线程块的编号,threadIdx.x 是当前线程在所在线程块里的编号,两者都从 0 开始。它们都有 .x、.y 和 .z 三个分量,因为网格和线程块可以是 1D、2D 或 3D 的。处理一维的工作时,只用 .x 就够了。在 <<<1, 1>>> 下,两者始终为 0。
头文件
cuda_runtime.h是 CUDA 运行时 API。它声明了cudaDeviceSynchronize()和各种错误检查函数。stdio.h是标准 C 头文件,printf需要它。device_launch_parameters.h的作用是,在使用 MSVC 或某些 IDE 时,让编辑器认出blockIdx、threadIdx、blockDim和gridDim。nvcc不需要它,但加上也没有坏处。
cudaDeviceSynchronize()
核函数的启动是异步的。CPU 启动核函数后,会马上执行下一行。如果没有 cudaDeviceSynchronize(),main() 会直接返回,GPU 还没来得及打印,程序就已经退出了。这个函数让 CPU 一直等到 GPU 上的工作全部完成。
[!WARNING] 如果忘了写
cudaDeviceSynchronize(),程序照样能编译,运行时也不会报错,只是什么都不打印。
代码
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#include <stdio.h>
__global__ void printIDs()
{
printf("\nBlock ID: %d === Thread ID: %d", blockIdx.x, threadIdx.x);
}
int main()
{
printIDs<<<1, 1>>>();
cudaDeviceSynchronize();
return 0;
}
- 三行
#include引入了上面介绍的头文件。 printIDs是核函数。每个线程打印自己的线程块编号和线程编号。字符串开头的\n让每条输出单独占一行。printIDs<<<1, 1>>>();用一个线程块、一个线程启动核函数。cudaDeviceSynchronize();等待 GPU 执行完毕,这样打印的内容才能在程序结束前显示出来。
代码逐步讲解
按照从空文件开始写代码的顺序,一步一步看这个程序。
1-3 cpu头文件。 先写三行#include,因为后面的代码都要用到它们声明的名字。cuda_runtime.h提供cudaDeviceSynchronize(),stdio.h提供printf。少了stdio.h,核函数里的printf就无法通过编译。5-6,8 gpu空的核函数。 先搭核函数的框架,再写函数体,包括__global__、返回类型void、函数名,以及一对空的花括号。核函数必须返回void,因为没有调用者在等它的返回值。如果漏掉__global__,编译器会把它当成普通的 CPU 函数来编译,后面启动核函数的那一行就会编译失败。7 gpu核函数的函数体。 加一行printf,打印blockIdx.x和threadIdx.x。这一行会在 GPU 上的每个线程里各运行一次。每个%d依次由字符串后面列出的值填入。10-11,14-15 cpumain 函数。 先写好main、它的花括号和return 0;,再填中间的部分。这是在 CPU 上运行的普通 C 代码。12 cpu启动核函数。 先写核函数名,接着写<<<1, 1>>>,最后是参数列表()。规则是线程块数量在前,每个线程块的线程数在后。printIDs虽然不接收任何参数,空的()也不能省。13 cpu等待 GPU。 启动会立即返回,所以要紧接着加上cudaDeviceSynchronize();。这是新手最常犯的第一个错误。少了它,程序照样能编译、能运行,却什么也不打印。
编译和运行
第一条命令把代码编译成程序,第二条命令运行它。
nvcc -o first_kernel first_kernel.cu
./first_kernel
nvcc是 CUDA 编译器,它会把文件里的 CPU 部分和 GPU 部分一起编译。-o first_kernel把程序命名为first_kernel。不加这个选项,程序名默认是a.out。first_kernel.cu是包含上面代码的源文件。CUDA 源文件以.cu结尾。./first_kernel运行程序。./告诉 shell 在当前目录里找这个程序。
输出
Block ID: 0 === Thread ID: 0
- 只有一行输出,因为只有一个线程,而每个线程只打印一次。
- 两个编号都是 0,因为唯一的线程块和唯一的线程,编号都是 0。
- 每次运行的输出都相同,因为只有一个线程,不存在和其他线程抢先后的问题。
自己动手写
从零写一个只有一个线程的核函数,让它从 GPU 向你问好。
- 用下面的框架创建文件
hello.cu。 - 写出核函数
hello,用blockIdx.x和threadIdx.x打印出Hello from block 0, thread 0。 - 用一个线程块、一个线程启动它,并让 CPU 等它完成。
#include "cuda_runtime.h"
#include <stdio.h>
// TODO: write the kernel hello() that prints its block ID and thread ID
int main()
{
// TODO: launch hello with 1 block of 1 thread
// TODO: wait for the GPU to finish
return 0;
}
提示
核函数以 __global__ void 开头。启动写作 hello<<<1, 1>>>();,等待写作 cudaDeviceSynchronize();。
答案
#include "cuda_runtime.h"
#include <stdio.h>
__global__ void hello()
{
printf("Hello from block %d, thread %d\n", blockIdx.x, threadIdx.x);
}
int main()
{
hello<<<1, 1>>>();
cudaDeviceSynchronize();
return 0;
}
用 nvcc -o hello hello.cu 编译,再用 ./hello 运行。你应该会看到一行输出,也就是 Hello from block 0, thread 0。
术语表
- CUDA(Compute Unified Device Architecture,统一计算设备架构):NVIDIA 的平台,让你在 GPU 上运行自己的代码。
- GPU(Graphics Processing Unit,图形处理器):拥有成千上万个小核心、用来运行核函数的处理器。
- CPU(Central Processing Unit,中央处理器):主处理器,负责运行
main()并启动核函数。 - 核函数(kernel):在 GPU 上运行的函数。你只写一次,GPU 就会在很多线程上同时运行它。
- 线程(thread):最小的执行单位。一个线程就是核函数的一份正在运行的副本,有自己的编号。
- 线程块(block):一组在同一个 SM 上运行的线程。它们可以通过共享内存共享数据。
- 网格(grid):一次核函数调用所启动的全部线程块。
- SM(Streaming Multiprocessor,流式多处理器):GPU 内部的处理器之一。线程块在 SM 上运行。
__global__:告诉编译器这个函数是 GPU 核函数,由 CPU 调用,在 GPU 上运行。blockIdx.x:当前线程所在线程块的编号,从 0 开始。threadIdx.x:当前线程在线程块里的编号,从 0 开始。cudaDeviceSynchronize():让 CPU 一直等到 GPU 完成所有工作。- 异步(asynchronous):CPU 不等待,向 GPU 发出命令后就立刻往下执行。
- API(Application Programming Interface,应用程序编程接口):一个库对外提供的一组函数,这里指 CUDA 运行时函数。
- 3D(three-dimensional,三维):有
x、y、z三个尺寸的形状。1D 只用x,2D 用x和y。 - 编译器(compiler):把源代码变成机器能运行的程序的工具。CUDA 的编译器是
nvcc。 - 执行配置(execution configuration):核函数启动里的
<<<blocks, threads>>>部分,决定运行多少个线程。 - MSVC(Microsoft Visual C++):微软在 Windows 上的 C 和 C++ 编译器。
- IDE(Integrated Development Environment,集成开发环境):带有构建和调试工具的编辑器,比如 Visual Studio。