后端工程师写惯了服务,很容易把“性能优化”自动翻译成“并发度 + 缓存 + 分布式”。遇到一台机器处理得太慢,第一反应是加线程、上缓存、拆微服务。但如果把同样的思路直接搬到 GPU 上,大概率会写出一个跑得比 CPU 还慢的程序。原因很简单:CPU 和 GPU 的根本区别不在“核数”多几十倍,而在于两者的硬件设计目标是反着来的。
这篇是系列第二篇,专门讲硬件层。目标是给纯后端工程师建立一套能用的 GPU 硬件心智模型。读完你不会立刻写出顶尖 kernel,但你能看懂 GPU 程序为什么长这样,为什么某些数据布局会血亏,以及为什么“多开线程”在 GPU 上并不是万金油。更重要的是,当你面对 profiling 报告里“带宽受限”“分支发散”“occupancy 不够”这些词时,脑子里能立刻出现对应的硬件画面,而不是把它们当成黑盒符号。
先放一个总纲:我们一共要建立三个模型。
- 第一,GPU 是吞吐优先的机器,不是延迟优先的机器;
- 第二,GPU 的执行单元是“成组”的,线程按 warp 一起前进,数据按 block 共享;
- 第三,GPU 的性能瓶颈通常不在计算本身,而在数据搬运,衡量标准是“计算强度”。
把这三件事想清楚,后面学什么框架都快。
1. 放下 CPU 心智模型:GPU 是“吞吐优先”的机器
1.1 CPU 和 GPU 的路线分歧
CPU 是过去几十年为“单线程快速执行”反复优化的产物。它花了大量晶体管在乱序执行、分支预测、多级缓存上,目的只有一个:让一个线程把一条复杂的指令流跑得足够快。哪怕这一代服务器 CPU 已经做到几十核、上百线程,单核的极致优化依然是它的核心卖点。
GPU 的路线完全相反。它把晶体管更多地堆在“执行单元”上,而不是控制逻辑和缓存上。它不在单个线程的延迟上较劲,而是试图让几十万个线程同时在机器里推进。代价是什么?单线程执行效率其实一般,但如果问题能被拆成海量同构的小任务,总吞吐量会把 CPU 甩开几个数量级。
后端人熟悉的类比:CPU 像一个全能主厨,单锅出菜,讲究一道菜从头到尾的火候控制;GPU 更像一个中央厨房,里面有几千个切菜工,每个人只会两个动作——切和装盘。单看一个人,他远不如主厨灵活;但来了三百桌宴席的订单,中央厨房可以把每桌的菜全部同时切完,主厨却只能在灶台前一盘一盘地炒。重点不是单个线程多强,而是单位时间内处理了多少同类型任务。
所以碰到 GPU 时,首先要问的不是“它比 CPU 快多少”,而是“我的任务是不是同构批量任务”。如果你手里是一个强依赖的长链路,每一步都要等上一步的结果,那 GPU 几乎发挥不出优势;如果你的场景是“同一套公式套在百万条数据上”,那这就是 GPU 的主场。
1.2 为什么后端工程师需要这个硬件视角
你可能说:我平时不写 GPU 程序,就是调接口、管服务,需要懂这么多吗?
需要。原因很实际:后端越往上走,一定会碰到 GPU 相关的资源。
举例:向量检索、大规模推荐、图像识别、语音转写、模型推理。这些东西通常不直接暴露 kernel 给你,但它们跑在 GPU 上。如果你把 GPU 理解成“一台超大核数的 CPU”,那你对成本的估算基本是错的。你以为瓶颈在显存、在带宽、在批次大小,但实际上你没搞懂为什么调整 batch size 会影响吞吐,为什么小模型在 GPU 上反而跑不过 CPU,为什么同一个模型换了一版数据 layout 后性能忽上忽下。
这些现象背后都是硬件心智模型的问题。不需要成为驱动开发者,但至少要能画出“数据从哪来、在哪儿算、结果怎么回去”这条链路。后端工程师最大的优势本来就是系统视角,把硬件层补上之后,你对性能的敏感度会完全不同。
2. GPU 硬件架构:三个记牢的硬件概念
2.1 从 SM 到 warp:真正的执行单位
先来一组名词:SM、执行单元、warp。
SM(Streaming Multiprocessor,流式多处理器)可以理解成 GPU 内部的一个“计算小组”。一块 GPU 由若干个 SM 组成,每个 SM 里面又有若干条执行通道。线程并不是直接挂在 SM 上跑的,GPU 会做一道非常关键的动作:把线程捆成一组,这一组叫 warp。
在常见的 GPU 架构里,一个 warp 固定是 32 个线程。这 32 个线程什么时候启动、什么时候执行下一条指令,是绑在一起的。不是“一个线程发一条指令”,而是“32 个线程同时执行同一条指令”。
我用军训队列类比:队列里每个人是独立个体,有自己的身高、站位(对应每个线程自己的数据),但教官喊“向右转”,所有人必须同时做同一个动作。指令是同一个,动作序列是同一个,差别只在每个人执行这个动作的细节位置不同。
这就是 SIMT(单指令多线程)的底层感觉。它和 CPU 的多线程完全不同:CPU 多线程是每个核各跑各的指令流,彼此之间基本不共享“下一步做什么”;GPU 的 warp 里,所有线程被一根指挥棒套在一起,必须齐步走。
这个模型带来的第一个关键结论:如果 warp 里 32 个线程遇到一个 if-else 分支,且它们分成两拨走不同方向,硬件只能先执行 if 这一拨、再执行 else 那一拨,总体耗时是两段加起来。这种被称为“warp divergence”的现象,是 GPU 性能杀手之一,后续章节会详细展开。
2.2 grid / block / thread:三层线程组织
写 GPU 程序时,线程不是无组织的几十万个散兵。它们按三层结构组织:thread、block、grid。
- thread:最细粒度的执行单位,对应一个逻辑线程;
- block:一组线程的集合,通常几十到几百个线程;
- grid:一组 block 的集合,对应一次完整的 kernel 启动。
运行时,一个 block 会被整体调度到某个 SM 上。SM 上可能同时跑多个 block,但一个 block 不会被拆到两个 SM 上。这样设计是有意为之:block 内部的线程需要协作,协作必须发生在同一块物理区域内。
后端人可以把 block 理解成一个“任务容器”,SM 是“执行任务的机器”。容器内部的所有线程可以通过共享内存交换数据,也可以做同步;容器与容器之间则不能直接同步,默认是相互独立的关系。这个限制极其重要。如果你设计算法时假设 block A 里的线程要等 block B 里的线程算完某个中间结果,你的思路就是错的。跨 block 协作只能通过多次 kernel 启动或者显式的全局同步点来做,代价很大。
把这块想通,你就理解为什么很多 GPU 算法都写成分而治之:先在 block 内部完成局部聚合,再用一次新的 kernel 做跨 block 聚合。这就是分布式系统里常见的“单机内合并,集群间汇总”的翻版,只不过把“机器”换成了“SM”。
2.3 存储层级:寄存器、shared memory、global memory
后端工程师脑子里都有一张存储草图:寄存器、L1 缓存、L2 缓存、主存、磁盘。GPU 也有类似层级,但多了一个让后端人不太习惯的角色:可以被程序员手动控制的共享内存(shared memory)。
我整理了这张对照表:
| 存储类型 | 容量大小 | 读写延迟 | 可见范围 | 后端类比 |
|---|---|---|---|---|
| 寄存器 | 极小 | 最快 | 仅当前线程 | CPU 寄存器 |
| shared memory | 通常几十到几百 KB/SM | 远低于 global | 同一 block 内所有线程可见 | 手动控制的 L1 缓存 |
| local memory | 受限于线程栈 | 与 global 相当 | 仅当前线程,物理在显存 | 栈溢出后被换到内存的局部变量 |
| global memory | 最大,即显存容量 | 延迟高 | 所有线程可见 | 大内存池,但不在 CPU 旁 |
| constant / texture | 特殊只读缓存 | 视命中而定 | 所有线程可读 | 只读配置表、只读大表 |
这里最值得后端人注意的是 shared memory。平时我们写 CPU 代码时,缓存是硬件自动管理的,程序员根本不用管。GPU 则给了你一块快得多的片上存储,但它不是自动的,你必须显式声明,把数据从 global memory 搬进 shared memory,再用完搬回去。
这听起来麻烦,但它是 GPU 性能的核心玩法。想象你在做一个矩阵操作,好几个线程都要反复读矩阵里的同一个小块。如果每次直接去 global memory 读,慢;如果先把小块搬进 shared memory,再让 block 内所有线程从 shared memory 里读,快好几个数量级。这个“掏钱搬数据,省下反复读”的思路,和我们在后端做的“预热缓存”本质上是一回事,只是 GPU 把控制权交给了程序员。
3. 看清性能:SIMT、内存带宽和计算强度
3.1 warp divergence:一个 if 可能让一半线程等着
后端写业务代码,if-else 是呼吸一样自然的事。但在 GPU 上,每个 if 都可能变成性能坑。
回到 warp 模型。一个 warp 里的 32 个线程共享指令流。如果这 32 个线程进入了同一个 if 分支,没问题,继续齐步走。但如果其中 16 个满足条件、16 个不满足,硬件只能分两次走:第一次让满足条件的 16 个线程执行 if 分支,第二次让另外 16 个执行 else 分支。从消耗的周期看,这个过程比没有分支的程序多了一倍时间。
后端类比:你有一个 32 线程的线程池,每次池子里所有线程只能执行同一行代码。如果业务逻辑里有分流,你相当于把所有线程切成两批,一批先跑完,另一批再跑。吞吐自然下降。
所以 GPU 编程里有一句经验:尽量让同一个 warp 里的线程走同样的路径。常见的做法是“先按 warp 对齐,再处理尾巴”,或者把分支判断挪到 block 级别,而不是让线程之间出现发散的路径。当然,不是所有分支都能避免,但你要有这个意识:数据分布越均匀、越规整,分支发散越少,性能越稳。
3.2 内存带宽:真正稀缺的资源不是乘法器
接着聊带宽。后端人对带宽的直觉通常是网络带宽、磁盘 IO 带宽。GPU 场景下,你必须把“显存带宽”也纳入成本模型。
数字放在这里比较直观:一块常见的入门级 GPU,显存带宽可能达到几百 GB/s;高端板卡能做到每秒数 TB 的读写带宽。作为对比,普通服务器 CPU 访问主存的带宽通常只有几十 GB/s 到几百 GB/s。听起来 GPU 在数据搬运上好像完胜?但注意,GPU 的访存延迟反而可能比 CPU 高不少,而且它的容量有限、路径特殊。
更关键的是:很多 GPU kernel 做得非常小。比如每个线程只读取 4 字节数据,做两次浮点运算,再把结果写回去。从算数角度看,这个 kernel 的“计算量”近乎为零,但它的每次操作都要消耗一次显存访问。最后实际跑起来,计算单元大部分时间在空转等待数据。
我常给后端同学算一笔账:假设你处理一张一千万行的表,每个线程只读一行里的 4 字节列,做完一个简单运算再写回。数据搬运接近 80MB 起步,而真正需要的加法和乘法可能只有一千万次。这时候决定性能的是带宽,不是浮点算力。你优化 kernel 内部多加两个没用到的乘加,反而可能帮不上忙。
所以拿到一个 GPU 任务,第一步不是问“有多少计算”,而是问“要搬多少数据”。如果数据搬运量远大于计算量,你真正该优化的是访存,比如改成向量化读取、使用 shared memory 做数据复用、调整数据布局让访问更连续。我在后面会用一个转置案例解释什么叫“连续访问”。
3.3 计算强度:用两个数字判断优化方向
“要搬多少数据”和“有多少计算”之比,在 GPU 领域叫计算强度(Arithmetic Intensity,AI)。定义很简单:
AI = 运算量 / 数据搬运量
单位通常写成 FLOP/byte,意思是每搬一个字节,配套做多少次浮点运算。
每一代 GPU 硬件都有自己的平衡点:当 AI 高于平衡点时,性能上限在计算单元;当 AI 低于平衡点时,性能上限在带宽。前者叫 compute bound,后者叫 memory bound。
后端人可以把计算强度想象成“请求处理时间与网络传输时间的比值”。如果一个请求要 1 秒处理,但网络往返要 3 秒,你无论怎么优化业务逻辑都不会让整体变快多少,因为瓶颈在网络时延。反过来,如果你能把数据压缩、批量拉取,减少网络往返次数,性能立刻起飞。GPU 里同理:低计算强度任务的命脉是减少搬运;高计算强度任务则要关注 SM 上的计算利用率。
这里直接给一个可以“抄作业”的判断思路:
- 如果你的 kernel 是逐元素操作,每个输出只依赖很少的输入,大概率是 memory bound;
- 如果你的 kernel 是矩阵乘法、卷积、FFT 这类多个数据被反复使用的任务,大概率是 compute bound;
- 如果是 reduce、scan、转置这类需要跨数据协作的任务,则需要仔细设计,很可能还受制于带宽和同步。
下一节用三个具体例子把这些概念串起来。
4. 从心智模型到写代码:三个可以直接抄的 mini 案例
4.1 向量加:把 for 循环展开成线程网格
先看最简单的逐元素操作:两个数组相加。CPU 上是这样:
for (int i = 0; i < n; i++) { c[i] = a[i] + b[i]; }GPU 上,这段循环被“摊”到大量线程上。常见写法示意如下:
__global__ void vec_add(const float* a, const float* b, float* c, int n) { int i = blockIdx.x * blockDim.x + threadIdx.x; if (i < n) { c[i] = a[i] + b[i]; } }启动时你会告诉运行时:开多少个 block、每个 block 多少个线程。比如一个 block 开 256 个线程,总数据量 n=100 万,那么大概需要 3907 个 block,再处理一下边界。代码本身不难,难的是理解为什么 GPU 能跑得比 CPU 快。
关键在硬件映射:每个线程处理一个 i,block 被调度到 SM 上,SM 把 block 里的线程按 32 个一组拆成 warp 去执行。因为相邻线程读的 a[i] 和 b[i] 在内存里也是相邻的,所以这些访问可以被硬件合并成大片连续读——这就是“合并访问”的好处。把内存里连续的数据交给相邻线程去读,比让每个线程跳着读要快得多。
这个例子还暴露了 GPU 程序的一个共同点:线程数量必须覆盖数据量。但线程数也不是越大越好,具体选择受寄存器数量、shared memory 容量、block 大小限制,单纯堆线程会适得其反。
4.2 矩阵转置:shared memory 和同步的实战
第二个例子是矩阵转置。后端工程师不一定天天写矩阵,但转置是一个非常典型的“跨数据协作”操作,能很好地展示 shared memory 的用法。
朴素思路是:每个线程读一个元素,写到输出位置。这会遇到一个问题:访问模式在输入里是连续的,在输出里就不连续了,或者反过来。无论哪种,总有一侧的访存效率很差。
更好的做法是:让一个 block 负责矩阵的一个小分块(tile)。先用协作方式把分块从 global memory 读进 shared memory,然后 block 内同步,再从 shared memory 读出,写回转置后的位置。示意代码:
#define BLOCK 16 __global__ void transpose_tile(const float* in, float* out, int rows, int cols) { __shared__ float tile[BLOCK][BLOCK + 1]; int x = blockIdx.x * BLOCK + threadIdx.x; int y = blockIdx.y * BLOCK + threadIdx.y; if (x < cols && y < rows) { tile[threadIdx.y][threadIdx.x] = in[y * cols + x]; } __syncthreads(); int x_out = blockIdx.y * BLOCK + threadIdx.y; int y_out = blockIdx.x * BLOCK + threadIdx.x; if (x_out < rows && y_out < cols) { out[y_out * rows + x_out] = tile[threadIdx.x][threadIdx.y]; } }先看__shared__:这个声明让线程块里的所有线程共用一块高速存储。第一次读时,block 内线程把输入 tile 搬进 shared memory;__syncthreads()保证所有线程都完成搬运后,才允许任何线程继续;接着再从 shared memory 里取数写回输出。
这中间有两个后端人很熟悉的概念:共享内存和 barrier。后端写多线程程序时,用共享队列或共享 Map 时同样担心“还没写完就被读了”,所以需要锁或屏障。GPU 的__syncthreads()就是 block 内所有线程的屏障。但注意,它只同步一个 block 内的线程,不能同步不同 block,这一点我在前面强调过,实际编码时很容易踩。
另外注意到tile[BLOCK][BLOCK + 1]而不是[BLOCK][BLOCK]。这多出来的一列是有讲究的:shared memory 是按 bank 组织的,如果二维数组的每一行恰好对齐到 bank 边界,多线程访问相邻行时可能会撞到同一个 bank,造成所谓的 bank conflict。加 1 列偏移可以让相邻线程访问的行错开,减少冲突。这是个很细的优化点,但知道以后再看别人代码,你不会一脸懵。
4.3 规约(reduce):GPU 版的分治聚合
第三个例子是规约:把一个大数组求和,得到一个总结果。这是后端极其熟悉的操作,比如统计报表里的求和、平均、最大值。
CPU 上直接一个循环累加。GPU 上把它拆成两阶段:
第一,每个 block 处理数组的一个分片,block 内线程逐步把分片归约成一个值。这个过程可以复用 shared memory 协作,每次让一半线程读取并合并结果,直到只剩一个数。第二,把每个 block 的局部结果写回一个较小的数组,再启动一个 kernel(或者由单个 block)做最终归约。
用后端语言理解:第一阶段是 Map,第二阶段是 Shuffle/Reduce。GPU 没有天然提供跨 block 的聚合通道,所以跨 block 之间的聚合必须用“结果数组 + 再次启动”的方式硬造出来。这正是理解 grid 和 block 层级的意义:知道“跨 block 不能同步”这个约束,才会自然地想到这种分阶段归约设计。
规约看起来简单,但它的性能往往不取决于计算,而取决于每一步访存是否合并连续。比如让相邻线程读取相邻数据,归约时用 shared memory 做两两相加,能大幅减小访问全局内存的次数。你跑一次 profiling 就会看到:即便只是求和,带宽也常常是瓶颈。
5. 后端工程师最容易掉进的三个坑
5.1 把 GPU 当成“巨型线程池”
很多人第一次写 GPU 程序,脑子里还是后端线程池那套:“既然 GPU 有几万个执行单元,那我就创建几十万个线程,每个线程做一点点事。”
听上去没问题,但 GPU 线程不是免费的。每个线程有自己的寄存器状态和程序状态,开太多线程会让每个线程分到的资源变少,甚至出现寄存器溢出,被迫把数据写进 local memory。local memory 物理上在显存里,访问一次慢得吓人,性能断崖式下跌。
实际经验是:线程数量要和数据量匹配,block 大小也要符合 SM 的资源上限。不要盲目“拉满”,要根据 occupancy 情况调整。调试时可以用工具看每个线程用了多少寄存器、每个 SM 能驻留多少 block。这些数字会直观地告诉你为什么线程太多反而更慢。
5.2 只优化计算,忽略数据搬运链路
后端同学有个天然直觉:性能慢就优化算法,减少 CPU 计算。但在 GPU 上,数据搬运往往才是大头。
我曾经处理过一个看似慢的 kernel,单看计算逻辑非常简单,怎么优化都压不下去。后来把流程拆开才发现,大部分时间花在启动前的显存分配和把数据从主机端拷贝到设备端、再拷回去上。GPU 计算只占了一小部分。
这种问题的排查思路很简单:分阶段测时间。把程序切分成“主机端到设备端传输、kernel 执行、设备端回传”三段,分别计时,立刻就能看到瓶颈在哪。如果内核执行时间很短,但传输时间很长,那该优化的不是 kernel,而是整个数据管道:考虑数据留驻显存、使用异步传输、减少小对象拷贝。后端处理网络请求时也一样,大量小包传输比一个大包更费神,GPU 传输一模一样。
5.3 忽视 warp 同步和分支发散
第三个高频坑是不了解 warp 的行为。有人写代码时假设所有线程都是完全独立的,结果某段分支逻辑让一个 warp 里的线程走两条路,性能直接打了个折扣;还有人没有正确使用同步,多个线程同时读写 shared memory 同一个位置,产生难以复现的随机错误。
排查这类问题,我建议不要只盯着正确性,还要看 profiling 指标。如果分支发散很严重,重新设计数据结构,让同一 warp 内的数据尽量保持相似属性。比如数组按类别排序后再处理,把相同类别的数据聚到一起,可以让 warp 路径更统一。
把常见问题整理成表,方便以后自查:
| 现象 | 常见原因 | 优先排查方向 |
|---|---|---|
| GPU 跑得不如 CPU 快 | 数据搬运占比太大,任务粒度太小 | 测传输时间,调整 batch size |
| kernel 内加线程数反而变慢 | 寄存器溢出,occupancy 下降 | 查看寄存器占用和 local memory |
| 相同的逻辑,运行不稳定 | shared memory 并发读写未同步 | 检查屏障和跨线程可见性 |
| 分支多,性能下降明显 | warp divergence | 调整数据布局,减少 intra-warp 分叉 |
| 相邻线程访问不连续地址 | 未做合并访问 | 改变索引映射,做成连续读写 |
后端工程师有一个优势:你懂系统、懂数据流、懂拆分和聚合。GPU 只是把很多“分布式系统设计经验”搬到了单块芯片内部:block 是节点,shared memory 是本地磁盘,global memory 是远端存储,跨 block 通信靠重新发起任务。想通这一层,GPU 就不再是神秘黑盒,而是一个延迟高、吞吐惊人的特殊节点。最后再分享一个我自己的实操体会:入门阶段不要急着记 API,先把三个概念刻在脑子里——warp 怎么走、数据存在哪、瓶颈是不是带宽。把任何 kernel 都拿这三点去套,配合一次内核计时实验,你很快就能准确预判一段 GPU 代码的好坏。