2443 字
12 分钟
CUDA C编程入门笔记(1)

异构计算 = CPU(控制者)+ GPU(加速者)协同工作

为任务选择适合它的架构”。

  1. 费脑子的事(逻辑、分支预测、IO读取)给 CPU。
  2. 费体力的事(大规模矩阵运算、像素处理)给 GPU。
image-20251219032707822

用GPU输出:Hello world!#

准备工作#

检查CUDA编译器是否正确安装,以及路径:

Terminal window
which nvcc

通常结果是/usr/local/cuda/bin/nvcc

检查GPU:

Terminal window
ls -l /dev/nv*

我这里的输出结果是:

Terminal window
crw-rw-rw- 1 root root 195, 0 11月 24 15:55 /dev/nvidia0
crw-rw-rw- 1 root root 195, 1 11月 24 15:55 /dev/nvidia1
crw-rw-rw- 1 root root 195, 2 11月 24 15:55 /dev/nvidia2
crw-rw-rw- 1 root root 195, 3 11月 24 15:55 /dev/nvidia3
crw-rw-rw- 1 root root 195, 255 11月 24 15:55 /dev/nvidiactl
crw-rw-rw- 1 root root 235, 0 11月 24 16:10 /dev/nvidia-uvm
crw-rw-rw- 1 root root 235, 1 11月 24 16:10 /dev/nvidia-uvm-tools
/dev/nvidia-caps:
total 0
cr-------- 1 root root 238, 1 11月 24 15:55 nvidia-cap1
cr--r--r-- 1 root root 238, 2 11月 24 15:55 nvidia-cap2

/dev/nvidia0/dev/nvidia3表示有4张独立显卡。

第一个代码#

#include <stdio.h>
__global__ void hello(void){
printf("Hello world from GPU !\n");
}
int main(){
printf("Hello world from CPU !\n");
hello<<<1,10>>> ();
cudaDeviceReset();
return 0;
}

我保存到test.cu中,然后运行nvcc test.cu -o test编译,./test来运行。运行结果:

Terminal window
$ ./test
Hello world from CPU !
Hello world from GPU !
Hello world from GPU !
Hello world from GPU !
Hello world from GPU !
Hello world from GPU !
Hello world from GPU !
Hello world from GPU !
Hello world from GPU !
Hello world from GPU !
Hello world from GPU !

可以看见,输出了一次CPU,十次GPU。

我的理解是,.cu文件,实际上是一个C程序,只是通过CUDA调用了GPU。而nvcc是一个编译器驱动,调用gcc编译CPU代码、以及NVDIA的自己的编译器来翻译GPU语言。

在CUDA编程里面,没有 __global__ 的函数,是普通C函数,比如main函数。

加上 __global__ 的函数(也叫 Kernel 核函数),由CPU来调用,通过GPU来执行。

因此前面CPU只输出一次。

int main(){
printf("Hello world from CPU !\n"); // CPU运行,只有一个主线程,所以只打印一次
// ...
}

后面的:

int main(){
printf("Hello world from CPU !\n");
hello<<<1,10>>> (); // 启动1个线程块,每个块10个线程同时跑,所以打印10次
// ...
}

三重尖括号意味着从主线程到设备端代码的调用,格式是函数名<<< 组的数量, 每组的人数 >>>(参数);

块和线程#

前面这段代码,如果运行hello<<< 1, 10 >>>hello<<< 2, 5 >>>,效果应该是一样的。

但是其实应该是不一样的。

用下面这段修改后的代码,打印出块号blockIdx.x和线程号threadIdx.x

#include <stdio.h>
__global__ void hello(void){
printf("Block %d,\t Thread %d\n", blockIdx.x, threadIdx.x);
}
int main(){
printf("Case : <<< 1, 10 >>>\n");
hello<<<1,10>>> ();
cudaDeviceSynchronize();
printf("Case : <<< 2, 5 >>>\n");
hello<<<2, 5>>> ();
cudaDeviceSynchronize();
return 0;
}

输出结果:

Terminal window
$ ./test
Case : <<< 1, 10 >>>
Block 0, Thread 0
Block 0, Thread 1
Block 0, Thread 2
Block 0, Thread 3
Block 0, Thread 4
Block 0, Thread 5
Block 0, Thread 6
Block 0, Thread 7
Block 0, Thread 8
Block 0, Thread 9
Case : <<< 2, 5 >>>
Block 0, Thread 0
Block 0, Thread 1
Block 0, Thread 2
Block 0, Thread 3
Block 0, Thread 4
Block 1, Thread 0
Block 1, Thread 1
Block 1, Thread 2
Block 1, Thread 3
Block 1, Thread 4

为什么要分块?这看上去运行hello<<< 1, 10 >>>hello<<< 2, 5 >>>结果并没有差很多,都是多个同时运行。

应该是应该硬件限制,每个块的线程有上限。

而且同一个block内部,有共享内存。

可以提升可扩展性。让程序可以在不同的CPU上都能跑。

同步与重置#

cudaDeviceReset()cudaDeviceSynchronize()的问题。

理论上,cudaDeviceReset是全部清空上下文,重新初始化整个环境。cudaDeviceSynchronize()是等前面的GPU干完了,不会清空上下文。

cudaDeviceSynchronize()意思是,等前面调用的GPU代码都干完了。如果没有调用这个,前面没有干完,CPU也会继续往下。因为CPU调用核函数是非阻塞的。

在前面运行这段代码的时候:

#include <stdio.h>
__global__ void hello(void){
printf("Block %d,\t Thread %d\n", blockIdx.x, threadIdx.x);
}
int main(){
printf("Case : <<< 1, 10 >>>\n");
hello<<<1,10>>> ();
cudaDeviceSynchronize();
printf("Case : <<< 2, 5 >>>\n");
hello<<<2, 5>>> ();
cudaDeviceSynchronize();
return 0;
}

显然可以注意到,第一次打印出来Case : <<< 1, 10 >>>后,要等一会儿(很明显),才能看见后续的一堆东西打印出来,而且是立即全部打印出来,第二次的Case : <<< 2, 5 >>>不需要等待。

第一次等待,应该是因为有一个冷启动。

如果把cudaDeviceSynchronize()全部换成cudaDeviceReset(),理论上应该是有区别的!毕竟说过了后者会清空上下文,但是前面这段运行起来,体感上没有区别,但是我感觉应该是有区别的。

用下面的代码测试一下时间:

#include <stdio.h>
#include <time.h>
__global__ void hello(void){
// printf("Block %d,\t Thread %d\n", blockIdx.x, threadIdx.x);
}
int main(){
int N = 100;
clock_t start, end;
double cost_time;
hello<<<1, 1>>>();
cudaDeviceSynchronize();
printf("Test Synchronize: \n");
start=clock();
for (int i=0;i<N;i++){
hello<<<1,1>>>();
cudaDeviceSynchronize();
}
end=clock();
cost_time=((double)(end-start))/CLOCKS_PER_SEC;
printf("Synchronize cost %.4f seconds\n", cost_time);
printf("Test Reset: \n");
start=clock();
for (int i=0;i<N;i++){
hello<<<1,1>>>();
cudaDeviceReset();
}
end=clock();
cost_time=((double)(end-start))/CLOCKS_PER_SEC;
printf("Reset cost %.4f seconds\n", cost_time);
return 0;
}

测试结果:

Terminal window
~$ ./test
Test Synchronize:
Synchronize cost 0.0006 seconds
Test Reset:
Reset cost 11.5860 seconds

感觉还是很明显的。Reset慢很多。

CUDA编程结构#

Host (主机) = CPU + 内存。负责逻辑控制、读取文件、指挥 GPU 。

Device (设备) = GPU + 显存。专门负责并行的繁重计算。

这两者通过 PCI-Express 总线连接,但它们的内存是物理隔离的。这意味着,CPU 里的变量,GPU 没法直接读;GPU 算好的结果,CPU 也没法直接拿。

NOTE

可以把 CPU 上的变量名加前缀 h_ (host),GPU 上的变量名加前缀 d_ (device)。

因为内存不互通,所以我们往往需要:

  1. H->D:把数据从 CPU 内存(Host)拷贝到 GPU 显存(Device)。
  2. 核函数执行:CPU 发令,GPU 开始并行计算。(这是异步的,CPU 发完令可以去干别的,也可以等 GPU 算完。)
  3. D->H:把算好的结果从 GPU 显存拷贝回 CPU 内存。

在GPU分配空间,用的是cudaMalloc函数:

cudaError_t cudaMalloc (void** devPtr, size_t size)

这个是在GPU显存上,申请一个size字节的连续内存,返回的是是否分配成功,然后分配的GPU显存的指针,会被存在devPtr指向的地址中。

主机和设备之间的数据传输,用的是cudaMemcpy函数:

cudaError_t cudaMemcpy (void* dst, const void* src, size_t count, cudaMemcpyKind kind)

src这个地址开始,复制count字节到dst指向的地址。

最后的kind,用来表示复制方向,有四种:

  1. cudaMemcpyHostToHost(疑似是为了对称的,主机搬到主机,和memcpy没啥区别)
  2. cudaMemcpyHostToDevice
  3. cudaMemcpyDeviceToHost
  4. cudaMemcpyDeviceToDevice

然后cudaError_t是一个枚举类型,成功就是cudaSuccess,失败就是cudaErrorMemoryAllocation

可以用这个函数,把错误代码转成可以读的错误消息:

char* cudaGetErrorString (cudaError_t error)

向量相加#

下面是一个简单的向量相加代码。

#include <stdio.h>
__global__ void kernel_add(int *d_a, int *d_b, int *d_c, int n)
{
int idx = threadIdx.x + blockIdx.x * blockDim.x;
if (idx < n)
d_c[idx] = d_a[idx] + d_b[idx];
}
int main()
{
int n = 1000;
int h_a[n + 5], h_b[n + 5], h_c[n + 5];
for (int i = 0; i < n; i++)
h_a[i] = i, h_b[i] = i;
size_t sz = n * (sizeof(int));
int *d_a, *d_b, *d_c;
cudaMalloc(&d_a, sz);
cudaMalloc(&d_b, sz);
cudaMalloc(&d_c, sz);
cudaMemcpy(d_a, h_a, sz, cudaMemcpyHostToDevice);
cudaMemcpy(d_b, h_b, sz, cudaMemcpyHostToDevice);
int block_size = 256; // 每个块256个线程
int block_cnt = (n + block_size - 1) / block_size;
// up(n/block_size)
kernel_add<<<block_cnt, block_size>>>(d_a, d_b, d_c, n);
cudaMemcpy(h_c, d_c, sz, cudaMemcpyDeviceToHost);
printf("result: \n");
for (int i = 0; i < 4; i++)
printf("%d\t+\t%d\t=\t%d\n", h_a[i], h_b[i], h_c[i]);
printf("...\n");
for (int i = n - 4; i < n; i++)
printf("%d\t+\t%d\t=\t%d\n", h_a[i], h_b[i], h_c[i]);
cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_c);
return 0;
}

运行:

Terminal window
nvcc test.cu -o test
./test

结果:

Terminal window
result:
0 + 0 = 0
1 + 1 = 2
2 + 2 = 4
3 + 3 = 6
...
996 + 996 = 1992
997 + 997 = 1994
998 + 998 = 1996
999 + 999 = 1998

这里,似乎加上(void **)强转比较好,但是我编译没有报错:

cudaMalloc(&d_a, sz);
cudaMalloc(&d_b, sz);
cudaMalloc(&d_c, sz);

加上:

cudaMalloc((void**)&d_a, size);
// ...

错误检查宏#

如果我们把n改成10000,然后后面增加进程数:

int block_size = 2048; // 每个块256个线程 ,改成2048
int block_cnt = (n + block_size - 1) / block_size;

这样运行是直接错的:

Terminal window
result:
0 + 0 = 0
1 + 1 = 0
2 + 2 = 0
3 + 3 = 0
...
9996 + 9996 = 0
9997 + 9997 = 0
9998 + 9998 = 0
9999 + 9999 = 0

因为一个block最多就1024个线程。

但是在不知道这个问题的时候,可能不太好找到问题。

可以加一个这样的:

#define CHECK(call) \
{ \
const cudaError_t error = call; \
if (error != cudaSuccess) \
{ \
printf("Error: %s:%d, ", __FILE__, __LINE__); \
printf("code:%d, reason: %s\n", error, cudaGetErrorString(error)); \
exit(1); \
} \
}

然后代码改成下面这样:

#include <stdio.h>
#define CHECK(call) \
{ \
const cudaError_t error = call; \
if (error != cudaSuccess) \
{ \
printf("Error: %s:%d, ", __FILE__, __LINE__); \
printf("code:%d, reason: %s\n", error, cudaGetErrorString(error)); \
exit(1); \
} \
}
__global__ void kernel_add(int *d_a, int *d_b, int *d_c, int n)
{
int idx = threadIdx.x + blockIdx.x * blockDim.x;
if (idx < n)
d_c[idx] = d_a[idx] + d_b[idx];
}
int main()
{
int n = 10000;
int h_a[n + 5], h_b[n + 5], h_c[n + 5];
for (int i = 0; i < n; i++)
h_a[i] = i, h_b[i] = i;
size_t sz = n * (sizeof(int));
int *d_a, *d_b, *d_c;
CHECK(cudaMalloc((void**)&d_a, sz));
CHECK(cudaMalloc((void**)&d_b, sz));
CHECK(cudaMalloc((void**)&d_c, sz));
CHECK(cudaMemcpy(d_a, h_a, sz, cudaMemcpyHostToDevice));
CHECK(cudaMemcpy(d_b, h_b, sz, cudaMemcpyHostToDevice));
int block_size = 2048; // 每个块256个线程
int block_cnt = (n + block_size - 1) / block_size;
// up(n/block_size)
kernel_add<<<block_cnt, block_size>>>(d_a, d_b, d_c, n);
CHECK(cudaGetLastError());
CHECK(cudaMemcpy(h_c, d_c, sz, cudaMemcpyDeviceToHost));
printf("result: \n");
for (int i = 0; i < 4; i++)
printf("%d\t+\t%d\t=\t%d\n", h_a[i], h_b[i], h_c[i]);
printf("...\n");
for (int i = n - 4; i < n; i++)
printf("%d\t+\t%d\t=\t%d\n", h_a[i], h_b[i], h_c[i]);
CHECK(cudaFree(d_a));
CHECK(cudaFree(d_b));
CHECK(cudaFree(d_c));
return 0;
}

就可以看见报错了:

Terminal window
$ ./test
Error: test.cu:44, code:9, reason: invalid configuration argument

同步问题#

前面说了,核函数启动是异步的。

但是我并没有调用cudaDeviceSynchronize(),那么,会导致printf在GPU算完之前就输出吗?

不会。

因为后面有个:

cudaMemcpy(h_c, d_c, sz, cudaMemcpyDeviceToHost);

这个函数以同步方式执行,因为在 cudaMemcpy 函数返回以及传输操作完成之前主机应用程序是阻塞的

在运行这个函数的时候,CUDA会先检查GPU是否在忙碌,只有前面都忙完了,才会拷贝数据。

CUDA C编程入门笔记(1)
https://fuwari.vercel.app/posts/course/cuda-c/cuda-1/
作者
wegret
发布于
2026-01-03
许可协议
CC BY-NC-SA 4.0