第一章:CUDA编程基础

各位同学,今天我们来聊聊CUDA编程的入门内容。说实话,我刚开始接触GPU编程时,也被那一堆概念搞得头晕。但别担心,咱们一步步来。

1.1 CUDA编程模型:Host与Device

CUDA的编程模型,说白了就是「CPU+GPU」协同工作。CPU这边叫Host,GPU那边叫Device。我习惯把Host想象成项目经理,Device就是干活的主力团队。

Host负责逻辑控制、串行任务,比如数据准备、任务调度。Device负责大规模并行计算,比如矩阵乘法、图像处理。两者通过PCIe总线通信,但要注意——数据传输是有开销的。

核心要点:Host端代码在CPU上运行,Device端代码在GPU上运行。用__global__修饰的函数叫kernel,它在Device上执行,但由Host调用。

我在项目中遇到过一个问题:有个同事把大量数据频繁在Host和Device之间拷贝,结果性能还不如纯CPU版本。嗯,这里要注意——尽量减少数据传输,能一次传完就别分多次。

1.2 线程层次结构:Grid、Block、Thread

GPU的并行执行单元分三层:Grid(网格)、Block(线程块)、Thread(线程)。你想想看,这就像军队编制:Grid是集团军,Block是师,Thread是士兵。

  • Thread:最小的执行单元,每个线程执行相同的kernel代码,但处理不同的数据
  • Block:一组线程的集合,同一Block内的线程可以共享数据(通过Shared Memory)
  • Grid:一组Block的集合,一个kernel对应一个Grid

为什么要有这种层次结构?因为GPU硬件有物理限制。每个SM(流多处理器)能承载的线程数和Block数都是有限的。我刚开始写CUDA时,总想把所有线程塞到一个Block里,结果发现性能反而下降——因为资源不够用了。

个人经验:Block大小一般设为128或256的倍数,这样能充分利用SM的warp调度能力。我习惯用256,兼容性最好。

线程的索引方式也很重要。CUDA提供了内置变量:threadIdx、blockIdx、blockDim、gridDim。举个例子:

// 获取全局线程ID
int tid = blockIdx.x * blockDim.x + threadIdx.x;

这段代码我写了不下千遍,但每次写都要确认一下维度对不对。你想想看,如果维度搞错了,数据就全乱套了。

1.3 内存层次结构:Global、Shared、Register

GPU的内存层次,是性能调优的关键。我见过太多人只盯着计算优化,却忽略了内存访问模式。其实,很多时候瓶颈在内存,不在计算。

内存类型 位置 访问速度 生命周期 作用域
Global Memory 显存 慢(~400周期) 程序运行期间 所有线程
Shared Memory 芯片内 快(~5周期) Block生命周期 同一Block内
Register 寄存器 极快(~1周期) 线程生命周期 单个线程

Global Memory是最大的,但也是最慢的。Shared Memory是片上的,速度快得多,但容量有限(通常几十KB)。Register最快,但每个线程能用的数量有限(一般255个左右)。

避坑指南:我曾经在项目里过度使用Shared Memory,导致每个Block能承载的线程数大幅减少,最终性能反而下降。记住:Shared Memory是稀缺资源,要精打细算。

还有一个重要的概念——内存合并访问。当多个线程访问连续的Global Memory地址时,硬件会自动合并成一次大的内存事务。我建议你尽量让线程访问连续的数据,这样能充分利用内存带宽。

1.4 第一个CUDA程序:向量加法

好了,理论说完了,咱们写个实际的程序。向量加法是CUDA的「Hello World」,虽然简单,但包含了所有核心要素。

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

// 定义kernel函数
__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];
    }
}

int main() {
    int N = 1 << 20;  // 约100万个元素
    size_t size = N * sizeof(float);

    // 1. 在Host端分配内存
    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. 在Device端分配内存
    float *d_A, *d_B, *d_C;
    cudaMalloc(&d_A, size);
    cudaMalloc(&d_B, size);
    cudaMalloc(&d_C, size);

    // 3. 将数据从Host拷贝到Device
    cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
    cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);

    // 4. 配置kernel执行参数
    int threadsPerBlock = 256;
    int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;

    // 5. 调用kernel
    vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

    // 6. 将结果从Device拷贝回Host
    cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);

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

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

    return 0;
}

这段代码有8个步骤,我建议你记住这个流程:分配Host内存 → 分配Device内存 → 数据拷贝到Device → 配置kernel参数 → 调用kernel → 结果拷回Host → 验证 → 释放内存。

小技巧:每次调用CUDA API后,最好检查一下返回值。我习惯写一个CHECK(cudaError)宏,这样出错了能立刻定位。别问我为什么——因为我在调试时吃过太多亏了。

关于kernel配置,blocksPerGrid的计算方式很关键。用(N + threadsPerBlock - 1) / threadsPerBlock可以保证覆盖所有元素,即使N不是threadsPerBlock的整数倍。kernel内部的if (i < N)检查就是处理边界情况的。

知识体系总览

下面这张图展示了本章的核心知识结构,我画了很久,希望能帮你理清思路:

CUDA编程基础 - 知识体系 编程模型 Host (CPU) ↕ PCIe 通信 Device (GPU) __global__ kernel 线程层次 Grid (网格) ├─ Block (线程块) │ ├─ Thread (线程) │ ├─ Thread │ └─ Thread threadIdx, blockIdx 内存层次 Global (全局) ~400周期, 大容量 Shared (共享) ~5周期, 几十KB Register (寄存器) ~1周期, 每个线程255个 第一个CUDA程序:向量加法 ① 分配Host内存 ② 分配Device内存 ③ 数据拷贝到Device ④ 配置kernel参数 ⑤ 调用kernel ⑥ 结果拷回Host ⑦ 验证结果 ⑧ 释放内存

这张图把三个核心概念串起来了。编程模型是骨架,线程层次是肌肉,内存层次是血液。第一个程序就是让它们协同工作的实战演练。

好了,第一章的内容就到这里。记住:多写代码,多调试,多思考。CUDA编程没有捷径,但只要你理解了这些基础,后面的路就好走了。

可可精品资料库