3、第一个CUDA程序:编写向量加法Kernel,理解<<<>>>语法,编译与运行。

好,咱们直接上手写代码。

很多初学者学CUDA,第一个坎儿就是看不懂那个尖括号语法。我当年第一次看到 <<< >>> 的时候,心里直犯嘀咕:这玩意儿到底是啥?别急,咱们一步步来。

3.1 向量加法:CPU版本回顾

先看一个最朴素的C语言向量加法。假设有两个数组 A 和 B,长度都是 N,我们要把对应元素相加,结果存到 C 里。

// CPU版本
void vecAdd_cpu(float *A, float *B, float *C, int N) {
    for (int i = 0; i < N; i++) {
        C[i] = A[i] + B[i];
    }
}

这个循环,说白了就是挨个算。N 等于 100 万的时候,CPU 得老老实实跑 100 万次。GPU 怎么干?它把 100 万个线程同时扔上去,每个线程只算一个元素。

3.2 第一个CUDA Kernel

Kernel 就是跑在 GPU 上的函数。写法上有个标志:前面加个 __global__

// GPU版本:向量加法Kernel
__global__ void vecAdd_gpu(float *A, float *B, float *C, int N) {
    int i = threadIdx.x + blockIdx.x * blockDim.x;
    if (i < N) {
        C[i] = A[i] + B[i];
    }
}

这里有个关键点:threadIdx.xblockIdx.xblockDim.x 这三个变量。它们不是我们手动赋值的,而是CUDA运行时自动提供的。每个线程拿到的值都不一样,从而实现了「并行」。

核心理解: 每个线程执行相同的代码,但操作不同的数据。这就是 SIMT(单指令多线程)的精髓。

3.3 尖括号语法:<<< >>> 到底在传什么?

好,重点来了。调用 Kernel 的时候,写法是这样的:

vecAdd_gpu<<<gridDim, blockDim>>>(d_A, d_B, d_C, N);

尖括号里两个参数,分别指定了「网格维度」和「块维度」。我习惯这么理解:

  • blockDim:一个块里有多少个线程。比如 256 个。
  • gridDim:一个网格里有多少个块。比如 N / 256 + 1 个。

那么总线程数 = gridDim * blockDim。每个线程通过 threadIdx.x + blockIdx.x * blockDim.x 算出自己的全局索引。

我的小技巧: 刚开始写的时候,可以把 blockDim 设成 256 或 512。这两个值在大多数 GPU 上性能都不错。别一上来就搞 1024,容易出问题。

3.4 完整的主机端代码

光有 Kernel 不行,还得有主机端代码来分配显存、拷贝数据、启动 Kernel。看下面这个完整示例:

#include <stdio.h>
#include <cuda_runtime.h>

#define N 1000000

int main() {
    float *h_A, *h_B, *h_C;      // 主机端指针
    float *d_A, *d_B, *d_C;      // 设备端指针
    size_t size = N * sizeof(float);

    // 1. 分配主机内存
    h_A = (float*)malloc(size);
    h_B = (float*)malloc(size);
    h_C = (float*)malloc(size);

    // 初始化数据
    for (int i = 0; i < N; i++) {
        h_A[i] = i * 1.0f;
        h_B[i] = (N - i) * 1.0f;
    }

    // 2. 分配设备显存
    cudaMalloc(&d_A, size);
    cudaMalloc(&d_B, size);
    cudaMalloc(&d_C, size);

    // 3. 拷贝数据到GPU
    cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);

    // 4. 启动Kernel
    int blockSize = 256;
    int gridSize = (N + blockSize - 1) / blockSize;
    vecAdd_gpu<<<gridSize, blockSize>>>(d_A, d_B, d_C, N);

    // 5. 拷贝结果回CPU
    cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);

    // 6. 验证结果
    for (int i = 0; i < 10; i++) {
        printf("h_C[%d] = %f\n", i, h_C[i]);
    }

    // 7. 释放内存
    cudaFree(d_A); cudaFree(d_B); cudaFree(d_C);
    free(h_A); free(h_B); free(h_C);

    return 0;
}

注意: 千万别忘了 cudaMemcpy 把结果拷回来!我曾经见过一个同事,Kernel 跑完了,结果在显存里没拷回主机,打印出来全是 0。排查了半天才发现是忘了这一步。

3.5 编译与运行

CUDA 程序用 nvcc 编译,不是 gcc。命令很简单:

nvcc -o vecAdd vecAdd.cu
./vecAdd

如果你看到类似这样的输出:

h_C[0] = 1000000.000000
h_C[1] = 1000000.000000
...

恭喜你,第一个 CUDA 程序跑通了!

3.6 常见错误与避坑指南

嗯,这里我总结几个新手最容易踩的坑:

错误类型 现象 原因
段错误 程序崩溃 指针没分配显存就用了
结果全0 输出全是0 忘了 cudaMemcpy 拷回主机
索引越界 部分结果正确,部分错误 gridSize 算小了,有些元素没被处理
编译报错 找不到 cuda_runtime.h 环境变量没配好,或者用了 gcc 而不是 nvcc

避坑指南: 我曾经在项目里遇到过一个问题:Kernel 里忘记加 if (i < N) 这个边界检查。结果 N 不是 blockSize 的整数倍时,最后一个块多跑了几个线程,访问了非法内存。程序没崩,但结果偶尔会错。这种 bug 最难查!所以,永远记得加边界检查

3.7 小结

这一章我们干了三件事:

  • 写了一个最简单的向量加法 Kernel
  • 理解了 <<<gridDim, blockDim>>> 的含义
  • 走通了编译和运行的完整流程

说白了,CUDA 编程就是把一个 for 循环拆成无数个线程并行执行。你想想看,是不是这个理儿?下一章我们会深入聊聊线程、块、网格的更多细节,以及怎么用 cudaError_t 来捕获错误。到时候你会发现,调试 CUDA 程序其实没那么可怕。