一、CUDA编程模型回顾:从GPU架构到程序执行

各位同学好,我是你们的老朋友。今天咱们来聊聊CUDA编程模型的基础。说实话,这部分内容看似简单,但很多人在实际项目中栽跟头,往往就是基础没打牢。我个人习惯把这一章叫做「地基」,地基不稳,后面写再多代码也是空中楼阁。

1.1 GPU架构概览:它到底强在哪?

先问大家一个问题:为什么GPU能比CPU快那么多?

你想想看,CPU就像一位全能博士,什么都会,但一次只能处理几件事。GPU呢?它更像是一群小学生,每个人只会简单的加减法,但胜在人多——成千上万个计算单元同时干活。

具体来说,NVIDIA的GPU架构有几个关键点:

  • SM(Streaming Multiprocessor):核心计算单元,每个SM包含多个CUDA Core
  • Warp:32个线程为一组,这是GPU调度和执行的最小单位
  • 大量寄存器:每个SM有数万个寄存器,但注意这是共享资源

核心认知:GPU的强项是「大量并行、简单计算」。如果你让GPU做复杂的逻辑分支判断,那效率反而会下降。我在项目中遇到过有人拿GPU做字符串解析,结果性能还不如CPU——这就是典型的用错场景。

下面这张图是我自己画的,帮你理清GPU的硬件层次与软件层次的对应关系:

CUDA软件层次与GPU硬件层次对应关系 软件层次 Grid → Block → Thread 硬件层次 GPU → SM → CUDA Core 一个Grid对应整个GPU,一个Block对应一个SM,一个Warp(32线程)是调度基本单位 内存层次结构 Global Memory 大容量·高延迟 Shared Memory 小容量·低延迟 Local Memory 线程私有·可能慢 Register 最快·数量有限 访问速度:Register > Shared > Global 容量大小:Global > Shared > Register 图1:CUDA软件与硬件层次对应关系及内存层次

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

CUDA把线程组织成三层结构,这个设计非常巧妙。我刚开始学的时候觉得这有点多余,后来才发现——没有这个层次,你根本没法高效利用GPU。

  • Thread(线程):最基础的计算单元,每个线程执行相同的kernel函数
  • Block(线程块):一组线程的集合,共享Shared Memory,可以同步
  • Grid(网格):所有Block的集合,一个kernel启动对应一个Grid

我的经验:Block大小一般设为128或256比较稳妥。太小了SM利用率低,太大了寄存器不够用。我曾经在项目里设了1024的Block大小,结果每个线程能用的寄存器少得可怜,性能反而下降——这就是典型的「贪多嚼不烂」。

来看一个简单的代码示例,感受一下线程索引的计算:

// 一个简单的向量加法kernel
__global__ void vecAdd(float* A, float* B, float* C, int N) {
    // 计算全局线程ID
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    
    // 边界检查
    if (idx < N) {
        C[idx] = A[idx] + B[idx];
    }
}

这里 blockIdx.x 是Block在Grid中的索引,threadIdx.x 是Thread在Block中的索引,blockDim.x 是Block的维度大小。三者配合,就能唯一定位一个线程。

1.3 内存层次结构:用好每一级存储

GPU的内存层次,说白了就是「速度越快的越少,容量越大的越慢」。这个道理和CPU的缓存类似,但GPU的层次更分明。

内存类型 作用域 延迟(大约) 容量 典型用途
Register 单个线程 ~1 cycle 每个SM约64K~256K 局部变量、中间结果
Shared Memory 单个Block ~5 cycles 每个SM 48KB~164KB Block内数据共享、归约
Global Memory 所有线程 ~400 cycles 几GB~几十GB 主数据存储
Local Memory 单个线程 ~400 cycles 同Global 寄存器溢出时使用

避坑指南:我曾经在优化一个矩阵乘法的kernel时,发现性能始终上不去。查了半天,原来是编译器把一些变量放到了Local Memory里——因为寄存器不够用了。Local Memory虽然对线程是私有的,但它实际上存在Global Memory里,延迟高得吓人。解决办法?减少每个线程的变量数量,或者调整Block大小。

1.4 CUDA程序编译与执行流程

CUDA程序的编译流程,和普通C程序不太一样。它需要经过两阶段编译:

  1. 设备端编译:.cu文件中的kernel代码被编译成PTX(中间表示)
  2. 主机端编译:主机代码被C/C++编译器处理
  3. 链接:最终生成可执行文件,运行时由驱动将PTX编译成机器码

执行流程就更直观了:

// 典型执行流程
1. 分配GPU内存:cudaMalloc()
2. 数据从CPU拷贝到GPU:cudaMemcpy(..., cudaMemcpyHostToDevice)
3. 启动kernel:kernelName<<<grid, block>>>(args)
4. 数据从GPU拷贝回CPU:cudaMemcpy(..., cudaMemcpyDeviceToHost)
5. 释放GPU内存:cudaFree()

关键点:kernel的启动是异步的!也就是说,<<<>>> 调用后CPU会立即返回,不会等GPU算完。如果你紧接着就要用结果,必须加 cudaDeviceSynchronize() 同步。我见过太多新手因为这个bug,拿到的数据全是错的。

嗯,到这里第一章的核心内容就差不多了。记住:GPU架构决定了你怎么写代码,内存层次决定了你的性能上限,编译流程决定了你调试的方向。把这些基础吃透了,后面咱们讲优化技巧时,你才能理解「为什么这么改能快」。

想要更多资料,联系可可