第一章:CUDA入门——从GPU架构到第一个程序跑起来

大家好,我是你们这门课的主讲。做了十几年GPU计算,踩过不少坑,也积累了一些经验。今天咱们聊聊CUDA入门最核心的几个东西:GPU到底长什么样、CUDA怎么跟GPU对话、以及怎么把开发环境搭好。

说实话,很多初学者一上来就写kernel,结果性能惨不忍睹。为什么?因为你不了解GPU的脾气。好,咱们从头捋一遍。

1.1 GPU架构概述——它为什么这么快?

GPU和CPU的设计哲学完全不同。CPU是「全能选手」,什么活儿都能干,但一次干不了太多。GPU是「流水线工人」,成千上万个核心一起上,专攻并行计算。

我当年第一次看NVIDIA的架构白皮书时,最震撼的是它的层次结构。咱们以目前主流的Ampere架构(比如A100)为例,简单拆解一下:

  • SM(Streaming Multiprocessor):这是GPU的核心计算单元。一个GPU有几十到上百个SM。
  • CUDA Core:每个SM里有几十到上百个CUDA核心。这些才是真正干活的「小兵」。
  • 共享内存(Shared Memory):SM内部的高速缓存,延迟极低。我项目中很多优化都是靠它。
  • 全局内存(Global Memory):显存,容量大但慢。访问一次要几百个时钟周期。
  • 寄存器(Registers):每个线程私有的最快存储,但数量有限。

核心要点:GPU的并行能力来自于「大量线程同时执行」。一个SM可以同时管理上千个线程,靠的是硬件线程调度器——它几乎零开销地切换线程,不像CPU那样需要保存恢复上下文。

嗯,这里要注意:不是线程越多越好。每个线程能用的寄存器、共享内存都是有限的。我曾经有个项目,线程数翻了一倍,性能反而掉了一半——因为寄存器不够用,数据被挤到全局内存里去了。

GPU 架构层次结构图 GPU(如 A100) GPC(图形处理簇) GPC(图形处理簇) SM CUDA Cores x64 Shared Memory Registers SM CUDA Cores x64 Shared Memory Registers SM CUDA Cores x64 Shared Memory Registers SM CUDA Cores x64 Shared Memory Registers L2 Cache 全局内存(Global Memory / HBM)

1.2 CUDA编程模型——你怎么指挥GPU干活?

说白了,CUDA编程就是「你告诉GPU:这里有N个数据,你派M个线程去处理」。但怎么组织这M个线程,是有讲究的。

CUDA把线程组织成三层结构:

  • 线程(Thread):最小的执行单元。每个线程执行相同的kernel函数,但处理不同的数据。
  • 线程块(Block):一组线程,可以协作。同一个block内的线程可以通过共享内存通信。
  • 网格(Grid):一组block,构成整个任务。

你想想看,这就像公司组织架构:Grid是公司,Block是部门,Thread是员工。部门内部(Block内)可以开小会(共享内存),跨部门就得走邮件(全局内存),慢很多。

我的经验:Block大小一般选128或256个线程比较稳妥。太小了SM利用率低,太大了寄存器压力大。我习惯先设256,跑一下profiler看看occupancy,再调。

来看一个最简单的CUDA程序——向量加法。这玩意儿虽然简单,但麻雀虽小五脏俱全:

// 文件名:vector_add.cu
#include <stdio.h>

// GPU kernel:每个线程处理一个元素
__global__ void vecAdd(float *a, float *b, float *c, int n) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}

int main() {
    int n = 1 << 20;  // 1048576个元素
    size_t bytes = n * sizeof(float);

    // 1. 分配主机内存
    float *h_a = (float*)malloc(bytes);
    float *h_b = (float*)malloc(bytes);
    float *h_c = (float*)malloc(bytes);

    // 初始化数据
    for (int i = 0; i < n; i++) {
        h_a[i] = 1.0f;
        h_b[i] = 2.0f;
    }

    // 2. 分配设备内存
    float *d_a, *d_b, *d_c;
    cudaMalloc(&d_a, bytes);
    cudaMalloc(&d_b, bytes);
    cudaMalloc(&d_c, bytes);

    // 3. 把数据从CPU拷贝到GPU
    cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, h_b, bytes, cudaMemcpyHostToDevice);

    // 4. 启动kernel
    int blockSize = 256;
    int gridSize = (n + blockSize - 1) / blockSize;
    vecAdd<<<gridSize, blockSize>>>(d_a, d_b, d_c, n);

    // 5. 把结果拷回CPU
    cudaMemcpy(h_c, d_c, bytes, cudaMemcpyDeviceToHost);

    // 验证结果
    printf("c[0] = %f\n", h_c[0]);  // 应该输出 3.0

    // 6. 清理
    cudaFree(d_a);
    cudaFree(d_b);
    cudaFree(d_c);
    free(h_a);
    free(h_b);
    free(h_c);

    return 0;
}

这段代码里,有几点我想强调一下:

  1. __global__ 关键字告诉编译器,这个函数跑在GPU上,从CPU调用。
  2. threadIdx、blockIdx、blockDim 是CUDA内置变量,帮你算当前线程的全局ID。
  3. cudaMalloc和cudaMemcpy 是显式内存管理——GPU不会自动帮你搬数据。

避坑指南:我曾经犯过一个低级错误——在kernel里忘了做边界检查(if (idx < n))。当数据量不是blockSize的整数倍时,最后一个block会有多余的线程访问越界内存。结果程序偶尔崩溃,偶尔出乱数,查了我整整一天。所以,永远记得做边界检查。

1.3 CUDA开发环境搭建——磨刀不误砍柴工

好,理论说完了,咱们动手搭环境。我建议按这个顺序来:

第一步:安装NVIDIA驱动

驱动是GPU和操作系统之间的桥梁。去NVIDIA官网下载对应你显卡型号的最新驱动。装完后在命令行敲 nvidia-smi,如果能看到显卡信息,就说明驱动装好了。

我个人习惯用Linux(Ubuntu)做开发,Windows也行,但有些profiling工具在Linux下更顺手。

第二步:安装CUDA Toolkit

CUDA Toolkit包含了编译器(nvcc)、库(如cuBLAS、cuFFT)和调试工具。我建议用runfile安装,这样你能控制安装路径。装完后设置环境变量:

export PATH=/usr/local/cuda-12.0/bin:$PATH
export LD_LIBRARY_PATH=/usr/local/cuda-12.0/lib64:$LD_LIBRARY_PATH

验证安装:nvcc --version,看到版本号就对了。

第三步:安装Nsight工具

Nsight是NVIDIA的性能分析神器。我强烈建议你从第一天就开始用它。Nsight Systems做宏观分析,Nsight Compute做微观kernel分析。

装好之后,跑一下刚才的向量加法,用Nsight看看:

nsys profile -o vec_add_report ./vector_add
ncu --target-processes all ./vector_add

你会看到kernel执行时间、内存带宽利用率、occupancy等指标。嗯,这些数字以后会是你优化路上的好朋友。

组件 版本建议 验证命令
NVIDIA驱动 最新稳定版(≥525) nvidia-smi
CUDA Toolkit 12.x(与驱动兼容) nvcc --version
Nsight Systems 2024.x nsys --version
Nsight Compute 2024.x ncu --version

小技巧:如果你在Windows上开发,可以用Visual Studio + Nsight插件。但我个人更推荐Linux + 命令行 + Makefile,调试和profiling更灵活。当然,这纯属个人习惯,你用着顺手就好。

小结

这一章咱们把GPU架构、CUDA编程模型和环境搭建都过了一遍。你想想看,其实核心就三件事:理解硬件层次、学会组织线程、搭好开发环境。后面的章节,我们会在这个基础上,一步步深入——从内存优化到算子融合,从profiling到性能调优。

好,第一章就到这里。记住:别急着写复杂代码,先把环境玩熟,把最简单的vector add跑通、跑明白。我曾经带过一个新人,上来就写卷积算子,结果环境都没配好,折腾了两周。嗯,咱们不犯这个错。

课后练习:

  1. 把上面的向量加法代码敲一遍,编译运行通过。
  2. 用nvidia-smi查看你的GPU型号、显存大小、驱动版本。
  3. 用Nsight Systems抓一次profile,看看kernel执行时间。

想要更多资料,联系可可