第三章:GPU核心概念速览——从CPU到GPU的思维转变

好,我们正式开始接触GPU的核心概念。

说实话,很多做CPU出身的朋友,第一次看GPU架构都会觉得别扭。我也是这样。当年我在做并行计算加速项目时,拿着CPU的多线程思维去写CUDA代码,结果性能惨不忍睹。后来我才明白——CPU和GPU的思维方式,从根本上就是两码事

3.1 CPU vs GPU:两种哲学

先看一张对比表,这是我个人习惯的总结方式:

对比维度 CPU GPU
设计目标 低延迟,单线程快 高吞吐,大量线程并行
核心数量 几个到几十个 几百到几千个
缓存策略 大缓存,复杂分支预测 小缓存,简单控制逻辑
适合任务 串行、分支密集 数据并行、计算密集
线程切换代价 高(上下文切换) 极低(硬件调度)

你想想看,CPU的每个核心都像个全能选手——能跑操作系统、能处理中断、能做复杂分支预测。但GPU不一样,它把大量晶体管都堆在了计算单元上。说白了,GPU就是用更多的简单核心,去碾压那些可以并行的计算任务

核心思维转变: 别想着让一个线程跑得飞快,而是让一万个线程同时跑,哪怕每个都慢一点,总吞吐量也碾压CPU。

3.2 SIMT模型初探

GPU最核心的执行模型,叫SIMT(Single Instruction, Multiple Threads)。

嗯,这个名字有点绕。我换个说法:一条指令,驱动一堆线程

举个例子。假设你要给一万个像素做颜色变换:

// CPU做法:循环遍历
for (int i = 0; i < 10000; i++) {
    pixels[i] = transform(pixels[i]);
}

// GPU做法:每个线程处理一个像素
__global__ void transform_kernel(Pixel* pixels) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    pixels[idx] = transform(pixels[idx]);
}

CPU是一个接一个地处理。GPU呢?它让所有线程同时执行transform这个函数。但注意——所有线程执行的是同一段代码,只是处理的数据不同。这就是SIMT的精髓。

我个人经验: 写GPU代码时,我习惯先问自己一个问题——"这段逻辑,所有线程的执行路径是否一致?"如果答案是"不一定",那就要小心了。分支发散会严重拖慢性能。

3.3 线程与线程束(Warp)

好,接下来是GPU里最重要的概念之一——线程束(Warp)

你可能会想:GPU有几千个线程,难道CPU一个一个去调度?当然不是。GPU把线程分组管理,这个组就叫线程束。在NVIDIA的架构里,一个线程束固定包含32个线程

为什么是32?这跟硬件设计有关。我记得当年看Fermi架构的白皮书时,里面提到32这个数字是经过大量权衡的——太小了硬件利用率不够,太大了分支发散代价太高。

线程束的执行规则很简单:

  • 同一线程束内的线程,执行同一条指令
  • 如果线程出现分支(if-else),部分线程会被"屏蔽"
  • 线程束是硬件调度的基本单位

举个例子:

__global__ void kernel(float* data, int n) {
    int idx = threadIdx.x;
    if (idx < n) {
        data[idx] = data[idx] * 2.0f;
    } else {
        data[idx] = 0.0f;
    }
}

假设线程束里有32个线程,但只有前20个满足idx < n。那么这32个线程会先执行if分支——但只有前20个真正干活,后12个被"屏蔽"。然后反过来,后12个执行else分支,前20个被屏蔽。

避坑指南: 我曾经在一个图像处理项目里,因为没注意线程束边界,导致分支严重发散。32个线程里只有4个走同一个分支,性能直接掉了70%。从那以后,我写分支逻辑时都会刻意对齐到32的倍数。

3.4 从线程到线程束的映射

你可能会问:我写的代码明明是操作单个线程,硬件怎么知道哪些线程属于同一个线程束?

答案是:按线程ID连续分组。在CUDA里,threadIdx.x从0开始,0-31是第一个线程束,32-63是第二个,以此类推。

所以,当你写这样的代码时:

int tid = threadIdx.x + blockIdx.x * blockDim.x;
if (tid % 2 == 0) {
    // 偶数线程
} else {
    // 奇数线程
}

嗯,这就有问题了。因为在一个线程束里,0和1是相邻的线程,但它们走了不同的分支。这就是典型的线程束内分支发散

优化建议: 尽量让分支条件基于threadIdx.x / 32,而不是threadIdx.x % 2。前者保证整个线程束走同一路径,后者则会让线程束内一半线程被屏蔽。

3.5 思维转变总结

从CPU到GPU,你需要记住三件事:

  1. 别想着优化单线程性能——GPU的优势在于同时跑成千上万个线程
  2. 小心分支发散——同一线程束内的线程,尽量走同一执行路径
  3. 理解线程束是基本调度单位——你的代码最终是以32个线程为一组执行的

我个人觉得,最难转变的就是第二条。很多从CPU转过来的开发者,习惯了用if-else做精细控制,到了GPU上反而成了性能杀手。嗯,这需要一点时间适应。

下一章,我们会深入GPU的内存层次结构。你会发现,内存访问模式对性能的影响,比计算本身还要大。到时候我会分享一个我踩过的坑——因为没注意内存合并访问,导致带宽利用率不到10%。

课后思考: 如果你有一个数组,需要对其中的每个元素做平方运算。用CPU循环和用GPU每个线程处理一个元素,哪种方式更快?为什么?