第一章:踏上GPU编程之路——从Hello World到内存管理

各位同学,欢迎来到《GPU算子开发能力提升》的第一课。今天我们要聊的内容,可以说是GPU编程的“敲门砖”。你可能会想:不就是个Hello World吗?有什么好讲的?

嗯,我当年也是这么想的。直到我在一个项目里,因为cudaMalloc分配的内存大小不对,排查了整整两天……从那以后,我再也不敢小看这些基础操作了。

1.1 第一个CUDA程序:让GPU说Hello

先来看一个最经典的例子。我们让GPU打印一句话:

#include <stdio.h>

__global__ void helloFromGPU() {
    printf("Hello World from GPU!\n");
}

int main() {
    helloFromGPU<<<1, 10>>>();
    cudaDeviceSynchronize();
    return 0;
}

这段代码里,__global__ 是关键。它告诉编译器:这个函数要在GPU上运行。而 <<<1, 10>>> 是CUDA的启动配置,表示启动1个线程块,每个块里10个线程。

核心要点: __global__ 修饰的函数必须返回void,且只能由CPU调用。

我个人习惯在写第一个CUDA程序时,先确认GPU设备是否可用。你可以用 cudaGetDeviceCount() 来检查。有一次我在新服务器上跑代码,发现怎么都打印不出来,结果发现是驱动没装好……

1.2 向量加法:GPU并行计算的入门案例

Hello World只是开胃菜。真正体现GPU价值的是并行计算。我们来看向量加法:

__global__ void vecAdd(float *A, float *B, float *C, int N) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < N) {
        C[i] = A[i] + B[i];
    }
}

这里有个公式你要记住:全局线程ID = 块ID × 块大小 + 线程ID。说白了,就是每个线程负责计算一个元素。

你想想看,如果N=100万,用CPU得循环100万次。但GPU可以同时启动成千上万个线程,一次搞定。这就是并行计算的魅力。

小技巧: 启动配置时,建议让线程块大小是32的倍数(比如128、256)。因为GPU的warp大小就是32,这样能充分利用硬件资源。

1.3 内存管理:cudaMalloc / cudaMemcpy / cudaFree

GPU不能直接访问CPU的内存。你得先把数据拷贝过去。这就涉及三个核心函数:

函数 作用 注意事项
cudaMalloc 在GPU上分配内存 返回的是设备指针,不能直接在CPU上解引用
cudaMemcpy 在CPU和GPU之间拷贝数据 需要指定方向:cudaMemcpyHostToDevice 或 cudaMemcpyDeviceToHost
cudaFree 释放GPU内存 忘记释放会导致内存泄漏,严重时程序崩溃

来看一个完整的向量加法示例:

int main() {
    int N = 1000;
    size_t size = N * sizeof(float);

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

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

    // 2. 分配设备内存
    float *d_A, *d_B, *d_C;
    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. 启动核函数
    int threadsPerBlock = 256;
    int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
    vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

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

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

    return 0;
}

避坑指南: 我曾经在项目里忘记调用cudaFree,结果程序跑了几个小时突然崩溃。排查后发现是显存泄漏。记住:每次cudaMalloc都要对应一个cudaFree,就像malloc和free一样。

1.4 错误检查与调试:别让bug过夜

CUDA编程最让人头疼的是什么?是错误信息不够直观。很多时候,核函数出错了,程序直接崩溃,连个提示都没有。

所以我强烈建议你封装一个错误检查宏:

#define CHECK(call) \
    do { \
        cudaError_t err = call; \
        if (err != cudaSuccess) { \
            fprintf(stderr, "CUDA error at %s:%d - %s\n", \
                    __FILE__, __LINE__, cudaGetErrorString(err)); \
            exit(EXIT_FAILURE); \
        } \
    } while(0)

用法很简单:

CHECK(cudaMalloc(&d_A, size));
CHECK(cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice));
vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);
CHECK(cudaDeviceSynchronize());  // 检查核函数执行错误

为什么要在核函数后面加 cudaDeviceSynchronize()?因为核函数是异步执行的。CPU不会等GPU算完就继续往下走。如果你不同步,后面的错误检查可能捕获不到核函数里的问题。

调试技巧: 用 cuda-memcheck 工具可以检测越界访问、未初始化内存等问题。我每次写完核函数,都会跑一遍这个工具,能省去很多排查时间。

1.5 知识体系总览

下面这张图总结了本章的核心知识点,你可以把它当作学习路线图:

第一章:GPU编程基础 Hello World __global__ 核函数 向量加法 线程索引计算 内存管理 cudaMalloc/Free 错误检查 CHECK宏 + 调试 关键点 • __global__ 返回void • <<<grid, block>>> 启动 • 由CPU调用 • 需要同步等待 • 检查设备可用性 关键点 • 线程ID = blockIdx.x * blockDim.x + threadIdx.x • 边界检查 if (i < N) • 块大小建议32的倍数 • 异步执行特性 关键点 • cudaMalloc分配显存 • cudaMemcpy指定方向 • cudaFree释放内存 • 主机/设备指针区分 • 防止内存泄漏 关键点 • CHECK宏 • 同步检查 • cuda-memcheck • 错误码解析 • 调试工具

这张图把四个核心模块串起来了。从左到右,从最简单的Hello World开始,到向量加法理解并行,再到内存管理掌握数据流动,最后用错误检查保证代码质量。每一步都是下一章的基础。

好了,第一章的内容就到这里。记住:写CUDA代码,先保证能跑通,再追求性能。别一上来就想着优化,先把基础打牢。

想要更多资料,联系可可