一、回顾:并发编程模型
我们先回顾一下之前学过的内容。我们学了线程库,在线程基础上可以加上互斥锁、信号量、条件变量等互斥与同步操作,从而控制并发程序的执行。控制并发执行的本质,是我们脑子里有一张计算图:有些部分可以并行,有些部分必须顺序执行;能并行的,就应该尽可能地并发跑起来。
为了让并行更容易实现,一个思路是把线程做得尽可能轻量。我们从 Python 的 Generator,到协程,再到 Go 语言的 Goroutine——用起来和线程一样,却像协程一样轻量。另一条路是在编程语言层面处理并发:JavaScript 里有基于事件的并发模型,以及用 Promise API 描述计算图,通过 Promise.all 等待多个异步操作完成。
以上讨论都是 CPU 上的并行与并发模型。今天我们要讲的,是如何超越 CPU 在计算领域的统治地位。
二、CPU 的本质与指令级并行
2.1 CPU 是什么
CPU 就是我们在第一堂课介绍的计算模型:有一个状态(CPU State),每一步执行一条指令(如 RV32I 的一小步)。但顺序执行只是 CPU 精心维护的假象。实际上,逻辑门天生是并行的——当信号从左往右流过电路时,所有逻辑门同时工作。
没有任何物理原因阻止我们一次从内存取出 64 位甚至 128 位数据,或者同时发射两条指令并判断它们之间是否存在数据依赖。如果没有依赖,就可以同时写入两个寄存器。这就是**指令级并行(ILP)**的基础。
2.2 乱序执行与动态流水线
现代 CPU 内部有一条很长的指令队列。每个时钟周期,CPU 可以向队列中填入三四条甚至更多指令。CPU 内部有一个类似编译器的数据流分析器,负责分析哪些指令之间存在数据依赖、哪些可以乱序执行。只要分支预测准确、指令执行速度能够跟上,CPU 就能始终保持极高的吞吐率。
如何在真实系统上观察到这一现象?你可以读取 /proc/cpuinfo 获取 CPU 的频率信息,再用汇编写出若干无依赖的指令序列,借助 Linux 的 perf 工具统计 instructions 和 cycles 两个计数器,从而计算出实际的 IPC(每周期执行指令数)。
/proc/cpuinfo 里有一个字段叫 Bogomips,它是 Linux 内核在早期启动时、精确计时器尚不可用的情况下,通过执行固定循环来粗略估算处理器速度(单位:百万次循环/秒)的值。在现代 Intel 处理器上,这个值通常可达三四千甚至更高,并且往往大于处理器的实际频率,这在一定程度上印证了处理器确实能在单个时钟周期内执行超过一条指令。
2.3 复杂度与功耗的代价
动态流水线调度需要大量电路:一个 1024 项的循环队列、维护数据依赖关系的分析器,这些都要消耗晶体管。逻辑门的每一次翻转都会产生功耗,而动态调度电路翻转所消耗的能量,往往远超真正用于计算的那部分。
芯片的热功耗由三个因素决定:电容(与制程相关)、频率,以及电压的平方。1995 年到 2005 年间,Intel 每隔一年发布更先进的制程(TikTok 战略),不断缩小晶体管、降低电压,CPU 性能才得以持续提升。但这条路终有尽头——一旦撞上功耗墙(封装散热能力的极限),再复杂的单核设计也无法继续堆叠。
三、超越单核:多核与 big.LITTLE
面对功耗墙,芯片设计者面临一道选择题:同样的面积和功耗预算,是放一个动态调度能力极强的大核,还是放多个更简单的小核?大核单线程性能强,但"调度税"高;小核"税"低,但单核性能弱,需要靠数量取胜。
Intel 大约在 2005 年推出第一款双核处理器,标志着性能增长方式从"提升单核"转向"增加核数"。2011 年前后,随着移动互联网兴起,ARM 推出了 big.LITTLE 架构:在同一块芯片上同时放大核与小核,甚至还有超小核。系统空闲时在能效极高的小核上运行,需要性能时切换到大核,从而在性能与功耗之间取得平衡。
四、SIMD:单指令多数据
4.1 思路:平摊指令开销
还有另一条路:让一条指令处理更多的数据。动态流水线调度的单位是指令,如果一条指令只做一件很小的事(如 RISC-V 指令集的设计原则),那么 bookkeeping 这条指令的代价就显得很大——相当于"收入 100 元却交了 99 元的税"。
解决办法是引入 SIMD(Single Instruction Multiple Data,单指令多数据):用一条指令同时操作多个数据,把指令的调度开销平摊到多份计算上。
4.2 MMX → SSE → AVX → AVX-512
1997 年,Intel 为 32 位处理器引入了 MMX(Multimedia Extension):新增一组 64 位寄存器,每个可视为 4 个 16 位整数,并增加相应指令集,支持对这 4 个数字同时做加法等运算(饱和运算而非溢出)。随后经历了:
- SSE:128 位寄存器,称为 XMM(eXtended Multimedia);
这些寄存器名称带有鲜明的时代烙印:MMX 取自"多媒体扩展",但"多媒体"这个词今天已几乎无人提及,寄存器名却被永久保留了下来。AVX-512 已接近物理极限——某些 Intel 处理器在开启 AVX-512 后,整个封装的功耗会触及上限,被迫降频。
4.3 程序员视角下的 SIMD
你们在实现位集(bitset)数据结构时,其实已经用过类似的思想:用一个 32 位整数表示 32 个 1-bit 元素,一次操作就能处理 32 个集合成员。popcount(统计二进制中 1 的个数)也是经典例子:
- 分治法(bitwise trick):逻辑上很巧妙,但因大量计算间存在依赖关系,对动态流水线不友好;
- 查表法:将 32 位整数拆成四个 8 位段,预先计算好每段的 popcount,然后并行做 4 次 load 加 4 次加法,缓存命中时几乎是并行完成的,性能更好。
SIMD 的优势在于:依然是单线程程序,但一条指令能完成原来四条、八条甚至更多指令的工作,且这条指令在乱序执行队列中只占一个槽位。
4.4 SIMD 的局限
SIMD 指令依然参与 CPU 微架构的每一个环节:寄存器的 load/store 经过同一套缓存,动态流水线调度的功耗依然存在,且最终会与其他指令竞争缓存和功耗预算。这并非"接近零税"的方案,而只是把税分摊了。
4.5 VLIW 的尝试与失败
历史上还有一次激进尝试:VLIW(Very Long Instruction Word,超长指令字)。其思路是:把所有指令调度的工作交给编译器完成,CPU 本身不做动态调度,每条指令固定为若干字节并打包多个操作,由编译器保证包内指令之间没有数据依赖。
Intel 的 IA-64 就采用了 VLIW,但最终失败,因为当时制程红利和动态流水线的收益已经足够,而 VLIW 又没有向前兼容性。不过在大语言模型时代,这种让编译器承担全部调度工作的思路或许会复活,因为今天的编译器技术(包括 AI 辅助编译)已经远比当年强大。
五、GPU 的起源:从游戏主机到图形处理器
5.1 早期游戏主机的图形问题
1972 年,MagnumVox 推出了世界上第一款商业游戏主机 Odyssey,画面仅为光点,只能玩乒乓球之类的游戏。到 1983 年,Nintendo Entertainment System(NES/FC)发售,1986 年《塞尔达传说》第一代推出,画面已十分精美。
但 NES 使用的是 MOS 6502 CPU,IPC 约为 0.43,几乎没有动态流水线,load 指令要等内存控制器返回才能继续。屏幕有 61,000 个像素,64 种颜色,游戏运行在 60 fps。用不足一万条指令渲染六万个像素,这是如何做到的?
5.2 场景描述 + 专用硬件渲染
关键在于将"描述要画什么"与"如何画"分离:
- CPU 只负责生成一个场景描述数据结构(
struct gpu_state),描述背景、精灵(sprite)的位置和贴图; - 专用的图形处理硬件(PPU)负责把这个数据结构逐行扫描,生成每个像素的颜色值。
NES 的贴图系统极为节省:每个 8×8 的小贴块只有 4 种颜色(2 bit/像素),仅几个字节。游戏中著名的绿衣路易吉与红衣马里奥使用完全相同的贴块,只是调色板不同。水平翻转、垂直翻转等属性位,让同一张贴图能表示多种朝向,蘑菇行走动画就是靠两帧贴图加镜像翻转实现的。全屏闪光动画则通过切换全局调色板来实现,而不是改变任何贴块数据。
图形渲染本质上是一个 embarrassingly parallel(令人尴尬地可并行) 的问题:对每个像素的计算相互独立,适合大规模并行。PPU 利用电路天然并行的特性,逐行逐块地流水线处理每一行像素。
5.3 从固定管线到可编程管线
随着 CPU 和 GPU 性能不断提升,图形描述方式逐渐演进:从固定大小的贴块,到带有仿射变换的位图(可缩放、旋转、拉伸),再到完整的三维世界(以顶点和三角面片描述物体,用 4×4 矩阵做齐次坐标变换实现平移、旋转、透视投影)。
三维渲染管线中,最终所有变换都是线性的,可以用一次 4×4 矩阵乘法表达:平移、旋转、缩放、透视投影,一应俱全。
当图形处理器的功能越来越复杂(矩阵乘法、贴图采样、光照计算……),它已经非常接近一个通用处理器,只是并行度更高。于是,可编程 Shader 出现了:
- Vertex Shader(顶点着色器):对每个顶点执行一段程序,可修改顶点坐标,实现水面波纹、毛发飘动等效果,完全不占用 CPU;
- Fragment/Pixel Shader(片段/像素着色器):顶点处理完成后,对每个像素执行后处理程序,实现调色、蒙版、法线贴图(Normal Map)等效果。
法线贴图是一个经典例子:不需要增加几何细节(三角面片),只需为每个像素存储一个法线向量,在 Shader 中根据光线与法线的夹角(内积)计算光照强度,就能在平面上"骗"出立体凹凸的视觉效果,远景几乎不穿帮。
5.4 通用 GPU 计算的萌芽
约 2001 年,研究者开始尝试用可编程 Shader 做科学计算——把矩阵乘法"伪装"成图片处理问题:矩阵当作纹理,用 Pixel Shader 计算外积(outer product),再将多张结果图像叠加(另一个 Pixel Shader 操作)得到最终乘积。这种方式虽然迂回,却在某些情况下真的比 CPU 快。这一探索预示了今天用 GPU 做高性能计算的时代。
Shader 程序的本质是:对一大批同类数据,执行同一段代码。这和操作系统第一堂课讲并发时的出发点完全一样:
ounter(lineounter(lineounter(lineounter(line// 伪代码for_each pixel in screen: // embarrassingly parallel color = f(row, column) screen[row * 1920 + column] = color
如果 GPU 能支持为每个像素 spawn 一个轻量级线程,这不就是我们想要的并行计算模型吗?
六、CUDA:单指令多线程(SIMT)
6.1 CUDA 编程模型
CUDA 的核心思想就是上面那段伪代码的实现:允许用户在 GPU 上 spawn 海量线程(例如 1920×1080 = 约 200 万个),每个线程执行相同的 kernel 函数,但携带不同的参数(如行号、列号)。
初次接触 CUDA 代码时,__device__、<<<gridDim, blockDim>>> 等语法会让人感到陌生,但其背后的逻辑并不神秘:你只是想创建一大堆运行相同代码的线程,让它们各自算自己像素的颜色。
问题在于:如果每个线程占用 1 KB 内存,创建 200 万个线程根本不可行。CUDA 的解决方案是在软件模型与硬件资源之间引入一层高效调度。
6.2 线程束(Warp):共享一个 PC
CUDA 的关键设计是 Warp(线程束):将 32 个线程捆绑为一组,共享同一个 Program Counter(PC)。这意味着:
- 每 32 个线程只需要一个取指/译码单元,大幅节省电路;
- ALU 和寄存器(每个线程私有)依然保留,因为没有寄存器就无法计算;
- 译码器等"税"被 32 个线程均摊,每条指令的实际开销极低。
32 个线程携带不同参数(如 (row=3, col=0)、(row=3, col=1)…(row=3, col=31)),执行相同指令(如 store),但访问的内存地址连续:
ounter(linescreen[3*1920 + 0], screen[3*1920 + 1], ..., screen[3*1920 + 31]
这 32 次 store 自然合并为一次连续的 128 位(或更宽的)内存写操作,对内存控制器极为友好——与 AVX-512 的宽寄存器 store 异曲同工,但在编程模型层面更自然。
6.3 延迟隐藏:多 Warp 交替执行
一个 Streaming Multiprocessor(SM,流式多处理器)可以同时管理多个 Warp。当某个 Warp 遇到全局内存访问(高延迟)时,SM 不用动态流水线来填充等待,而是直接切换到另一个就绪的 Warp 执行,以此隐藏内存延迟。这是 GPU 与 CPU 处理延迟方式的根本区别:CPU 靠乱序执行和缓存,GPU 靠海量线程轮转。
6.4 分支的代价:条件禁用
SIMT 模型的一大限制是分支:32 个线程共享一个 PC,怎么执行 if-else?
CUDA 编译器的做法是:把 if A else B 编译成顺序执行两段代码,但在每段代码前加入一条 predicate(条件)指令,将条件不满足的线程禁用(disable),禁用的线程在该段代码期间不产生副作用。也就是说,if 分支不满足的线程在执行 then 块时被屏蔽,反之亦然。
这意味着 Warp 内的分支会导致两段代码都实际执行,总耗时是两段之和而非之一,严重影响性能。在 CUDA 程序的汇编(PTX/SASS)中,你几乎看不到传统的条件跳转分支,这正是 SIMT 架构的体现。
6.5 性能的关键:内存访问模式
CUDA 程序的性能对内存访问模式极为敏感:
- 合并访问(coalesced access):同一 Warp 内 32 个线程访问连续内存地址,合并为一次宽内存操作,性能最佳;
- 非合并访问:32 个地址分散,内存控制器需逐个处理,性能急剧下降。
稍微改变一下数组索引的计算方式,就可能让性能从最优跌到最差,这也是为什么 CUDA 程序"很难写好"。
七、总结:从 CPU 到 GPU 的演进脉络
| | |
|---|
| | |
| | |
| | |
| | |
| | 海量轻量线程,共享 PC 均摊译码开销,以线程切换隐藏延迟 |
GPU 的设计哲学可以归结为一句话:让每一个逻辑门的翻转都参与真正的计算,把"税"降到接近零。 从 NES 的 PPU 到今天的 NVIDIA CUDA GPU,再到驱动 AI 时代的张量计算核心(Tensor Core),这条演进脉络一脉相承。