一、超越 CPU:计算模型的演进与并行机制回顾
并发机制回顾
我们先回顾之前学过的内容。我们学习了线程库 pthread,并在此基础上加入互斥锁、信号量、条件变量等同步原语,从而控制并发程序的执行。
并发控制的本质,是在脑海中构建一张计算图:某些部分可以并行,某些部分不能。不可并行的部分必须顺序执行,可并行的部分则应尽可能同时执行。
之前讲过的各种机制,都是在线程之上实现各种各样的计算图。要让计算图高效运行并不简单,一个思路是让线程尽可能轻量化——从 Python 的生成器(generator),到协程,再到 Go 语言的 goroutine。
另一个途径是在编程语言层面做文章。编程语言发展时期的主流答案是基于事件的并发(Event-Based Concurrency),例如 JavaScript 中的 Promise API——可以创建若干个 HTTP 请求,再用 Promise.all 等待它们全部完成,由此描述一张计算图。
在今天之前,所有讨论都聚焦于 CPU 上的并行与并发模型。今天,我们要讨论如何推翻 CPU 在计算这件事上的统治地位。
CPU 的计算模型
首先回顾什么是 CPU。在第一堂课上,我们讲过 CPU 的计算模型:一个单文件执行的状态机,每次执行一条 RV32I/M/A 指令,向前走一小步。
但上节课提到,顺序执行只是 CPU 精心维护的一个假象。程序中 x = 1、y = 2 这样两条语句,编译成指令后在 CPU 里执行时,其实是并行的。原因在于:逻辑门天生是可以并行的。
在数字逻辑电路课的 logic sim 中,可以直观感受到——当信号从左向右传播时,所有逻辑门同时翻转。逻辑门构成的系统天生就是并行的。
超标量执行与动态流水线
既然如此,没有什么能阻止我们一次从内存取出 128 bit,而不只是 32 bit——电路层面完全支持这样设计。类似地,如果同时取出两条指令,就可以将它们分别送到两份译码电路,再用一个电路判断二者是否存在数据依赖。如果可以并行执行,便同时写入两个寄存器——你们实现过双口寄存器(dual-port register),这在电路上是可行的。
现代 CPU 内部有一条很长的指令队列,每个时钟周期固定压入三到四条甚至更多指令。进入队列后,CPU 内部的数据流分析器负责分析指令间的依赖关系:哪条指令必须等待另一条的结果,哪条指令存在潜在副作用。
只要分支预测准确,且指令执行速度能跟上(不发生 cache miss 等长时间等待),CPU 就能保持非常高的执行吞吐量。
IPC 的量化观测
期末考试有一类综合性题目:"如何证明你的处理器在一个时钟周期内确实可以执行超过一条指令?"解题思路是:先从 /proc/cpuinfo 获取 CPU 主频,再用 Linux 的 perf 工具统计程序执行的指令数和周期数,最终计算出 IPC(Instructions Per Cycle)。
/proc/cpuinfo 中有一个字段叫 bogomips。这个值来源于 Linux 在早期没有精确计时器的情况下,用一个空循环估算 CPU 速度的方法——算出每秒能执行多少百万条指令(Million Instructions Per Second)。在 Intel 处理器上,这个值一般在几千以上,而且往往大于 CPU 主频的数值,这正说明处理器每时钟周期可以执行多条指令。
为验证指令级并行(ILP),可以让 AI 编写 benchmark 脚本,分两种情况对比:其一是不断将同一寄存器(如 W0)加一,由于每条指令都依赖前一条的结果,无法并行,IPC 接近 1;其二是对不同寄存器(R0、R1、R2、R3)分别加一,指令间无依赖,可以并行执行。
实测结果(在树莓派上)显示:有依赖的程序也能跑到约 1.5 IPC;混合多种指令时,甚至可以达到每时钟周期执行三条指令。这节课不是体系结构课,不深入展开,但指令级并行是真实可观测的。
动态调度的代价
一个加法只需要一个超前进位加法器,电路非常简单。但如果要在 CPU 里塞入一个 1024 项的指令队列,就需要支持 push/pop 的循环 buffer、每项的有效位、队列头指针,以及一个分析任意两条指令间数据依赖的分析器——理论上复杂度为 O(N²)。这些逻辑门的面积和翻转功耗,远大于一条加法指令本身消耗的电路。
逻辑门只要从 0 变 1 或从 1 变 0,就会产生热量。整个动态调度电路的功耗,比实际执行一条指令(例如把 1 写入寄存器)要大得多——为了让指令执行得更快,付出了大量功耗,而这部分功耗没有参与到实际计算中,只是为了在时间轴上加速而浪费掉的。
1995 年到 2005 年,Intel 通过 Tick-Tock 策略——每隔一年改进微架构、再隔一年缩小制程——让工艺从 100 多纳米降到十几纳米。晶体管变小后电容和工作电压都下降,功耗公式 P ∝ C · f · V² 中各项减小,从而在不超过散热极限的前提下,可以不断增大动态调度队列,推高单核性能。
但这条路终有尽头。处理器封装的散热能力约为 100 W。一旦制程无法继续缩小,C 和 V² 基本固定,就无法再增大队列或引入更复杂的调度逻辑——撞上了"功耗墙"(Power Wall)。
各种"墙"与"税"
计算机系统里有很多"墙",代表各种极限:功耗墙(散热上限)、频率墙(频率不能再提升)、内存墙(cache 与内存之间的带宽成为瓶颈)、I/O 墙等。
在 systems 领域还有一个比喻叫"税":你得到一份好处,就必须额外付出一些代价。例如在淘宝下单,真正需要的是 HTTP 请求本身,但为了让这个请求从本机传到服务器,需要逐层经过 TCP、IP、以太网协议,每层都在 packet 头尾添加数据——这些就是"税"。
面对功耗墙,核心诉求变成了:如何提升 performance per watt?理想情况是让每一次逻辑门翻转都参与实际计算,而不是浪费在调度开销(bookkeeping)上。
出路一:多核
在相同功耗预算内,可以放两个大核(动态调度复杂、单核性能强,但浪费多),也可以放四个、八个乃至更多的小核(调度简单、单核性能弱,但数量多、整体并行度高)。
Intel 在 2005 年前后推出第一款双核处理器,正是因为单核动态调度这条路已接近极限,只能靠增加核心数量来维持摩尔定律。随后,ARM 在 2011 年前后提出 big.LITTLE 架构,将大核与小核混合集成在同一块芯片上——系统空闲时只运行高能效的小核,需要性能时才启动大核。
出路二:SIMD(单指令多数据)
动态调度的调度单位是指令。RISC-V 的每条指令只做非常小的一件事,bookkeeping 的代价却是固定的——这就存在"税率过高"的问题。如果能让一条指令处理更多的数据,"税"就被平摊了。
SIMD(Single Instruction, Multiple Data)的做法正是如此。1997 年,Intel 推出 MMX(Multimedia Extension),为 32 位机增加了一组 64 位的 MM 寄存器,可以当作 4 个 16 位整数使用,并配套一组打包运算指令——用一条指令对这 4 个整数同时做加法,采用饱和算术(溢出时保留最大值,不产生跨边界进位)。那个时代随处理器附赠的光盘里,MMX 的 killer application 是一段带实时光照的 3D 渲染画面,在当时极为惊艳。
这种思想并不陌生:实现 bit set 时,你们也曾用一个 32 位整数同时表示 32 个 bit,本质上与此相同。类似地,用位掩码实现 pop count(统计二进制串中 1 的个数)时,通过分治方式(0x5555/0xAAAA 掩码逐层折叠)可以在少量指令内完成,但在现代处理器上实际性能不佳——更快的方式是查表:把 32 位整数拆成 4 个 8 位整数,预先打表,再并行加载、求和。
Intel 此后一路拓展寄存器宽度:MMX(64 bit,MM) → SSE(128 bit,XMM) → AVX(256 bit,YMM) → AVX-512(512 bit,ZMM)。这套命名历史遗留了"multimedia"的痕迹,前缀已无法延续,只好用 X/Y/Z 来区分各代。在 /proc/cpuinfo 中可以看到 sse、avx 等标志位,记录了历代 SIMD 扩展的支持情况。
ZMM 是当前极限。AVX-512 全速运行时,整个封装的功耗已接近散热上限,处理器会被迫降频。SIMD 历代还新增了更多数据类型(float32、float64 完整的 IEEE 754 浮点数)和更丰富的运算,包括 shuffle、多次内存 load,以及一条指令完成 A×B+C 的 FMA(Fused Multiply-Add)。
SIMD 的好处是不改变编程模型:依然写单线程程序,但一条指令并行处理多份数据。其局限在于:SIMD 寄存器依然共享 cache、参与动态流水线调度,动态调度的功耗没有消除,且 SIMD 指令会与普通指令竞争缓存和带宽。因此 SIMD 不是终极方案。
历史插曲:VLIW 的激进尝试
人类还做过一次激进尝试:VLIW(Very Long Instruction Word,超长指令字)。其思路是把动态调度从硬件移交给编译器——每条指令是一个"大包"(例如 256 字节),编译器保证包内所有子指令无数据依赖,硬件只需直接执行,无需任何依赖检测电路,译码器极为简单。编译器同时负责插入 NOP 以避免流水线冲突。
Intel 为此设计了 IA-64 指令集,但最终以失败告终——那个时代超标量加先进制程带来的收益仍然可观,且迁移成本极高,没有市场买单。
但我认为在大模型时代,VLIW 的思想有可能复活。它在原理上能将调度代价降到接近零;当年编译器技术跟不上,今天或许已经具备条件。
终极方向:让 CPU 变简单,数量变多
存活下来的方案是:把复杂的 CPU 不断简化,同等功耗下塞入更多个。如果每个 CPU 都是你们实验课实现的那种最简单的单周期 CPU,几乎没有动态调度电路,那么每一次逻辑门翻转都用在真正的计算上,浪费极少。
CUDA 的做法更激进:让多个执行单元共享同一个取指译码单元——只有 ALU 和寄存器组需要每个执行单元各自独立,取指译码的开销降到极低。这个想法直接引出了人工智能时代的核心主角——GPU。
二、GPU 的起源:早期游戏主机的图形渲染思路
游戏产业的起点
GPU 的故事要从另一条时间线讲起。1972 年,Magnavox Odyssey 问世,这是世界上第一款商业游戏主机,已有手柄+主机的雏形;画面只有光点,能玩的游戏仅限乒乓球——控制挡板上下移动。
这个产业就此起步。1983 年,任天堂推出跨时代产品 NES(Nintendo Entertainment System);1986 年第一代《塞尔达传说》发售,游戏已拥有精美图形、平滑动画和交互界面。漂亮界面的背后,隐藏着一场计算危机。
图形渲染是极易并行的问题
画图的逻辑并不复杂:对屏幕上每个像素,计算函数 f(x, y) 得到颜色,然后渲染。这是一个 embarrassingly parallel(极易并行)的问题——每个像素之间没有任何依赖,完全可以同时计算,就像 Mandelbrot Set 一样,可以为每个点各启动一个线程。
但 1983 年的 NES 搭载的是 MOS 6502 CPU,IPC 约 0.43,没有动态流水线,load 指令还会产生约 3 个周期的停顿。它却能在 61000 个像素、64 种颜色的屏幕上稳定运行在 60 fps——远胜今天 Switch 上很多只跑 30 fps 的游戏。这是怎么做到的?
答案是:把"要画什么"与"怎么画"分离。CPU 只维护一个轻量的"场景描述"数据结构,由专用硬件(PPU)读取并并行地将其转化为像素。
NES 的场景描述模型
NES 的背景由 8×8 像素的小贴块(tile)按规则排列组成。每个贴块只需 2 bit(4 种颜色)× 64 像素 = 16 字节存储,可以配置一个调色板(2 种自定义颜色 + 透明色 + 背景色)。Mario(红衣)和 Luigi(绿衣)共享完全相同的贴块,只是调色板不同——这是节省存储空间的经典技巧。
CPU 告诉 PPU 实际显示区域在哪里,PPU 就能实现卷轴滚动:只需移动显示窗口,在边缘处交换贴块,便可制造出横向滚动的效果。这也是为什么早期游戏只能向右走、不能回头——后来开发者用各种编程技巧突破了这一限制。
前景层由最多 64 个"精灵"(sprite)组成,每个精灵可放置在屏幕上任意 (x, y) 位置,并指定贴块编号、调色板,以及一个 8 bit 属性字段(含优先级/Z 轴顺序、水平翻转、垂直翻转各 1~2 bit)。
开发者利用翻转属性玩出了很多花样:画一个圆只需一个贴块,通过水平/垂直翻转得到四个象限;Super Mario Bros 中蘑菇的行走动画,仅用两帧贴块(一长腿一短腿)配合镜像翻转与缓慢前移,就制造出行走的视觉错觉。全屏淡出动画则通过切换全黑调色板实现,屏幕内容本身完全不变。
PPU 的并行渲染逻辑
PPU 内部有一个逐行扫描的硬件计数器(line),顺序处理每一行像素。对每个像素,先查背景贴块得到背景颜色(一次加法即可),再遍历所有精灵,找到覆盖该像素且优先级最高的精灵,取其颜色——类似今天的 Z-buffer 算法。若没有精灵覆盖,则显示背景色。
由于电路层面的逻辑门是并行的,PPU 可以同时处理一行中的多个像素(通过 chunk 计数器分块并行计算)。整个渲染逻辑固化在电路中,不占用任何 CPU 资源。
这正是早期 GPU 的核心思想:把确定性的、极度并行的图形生成逻辑固化为专用电路。这与比特币矿机将哈希算法固化到 ASIC 中是完全相同的逻辑——只要算法固定,就能用电路换取极致的并行效率。
从固定电路到可编程位图
游戏产业的发展推动了图形处理器的演进。随着 CPU 和 GPU 算力同步提升,场景描述不再局限于固定的 8×8 贴块——开发者开始使用任意大小的位图(bitmap),对其进行缩放、裁剪、旋转等线性变换,再拼合到画布上。
这种基于"位图 + 变换"的 2D 图形引擎同样是 massively parallel 的:对每个像素,迭代所有位图,找到最上层覆盖该像素的图层,取其颜色。Game Boy Advance 时代的一些游戏,甚至用 2D 贴图的挤压与拉伸模拟出了 3D 透视效果;但由于缺少深度信息,旋转角度一大就会穿帮——你无法区分"把贴图挤进去"和"因透视近大远小"这两种情况。
从 2D 到真正的 3D 的过渡是自然的。三维世界中的旋转和缩放本质上都是线性变换,可以用矩阵乘法表达。将三维坐标扩展为四维齐次坐标(末位补 1)后,平移也变成了线性变换,整个三维世界的全部变换——包括相机投影——都可以用一个 4×4 矩阵来表达。事实上,只要掌握线性代数,即使在高中阶段也可以实现一个简单的 3D 引擎。
Shader:从数据结构到可编程渲染管线
随着图形处理器越来越接近通用处理器,固定的渲染流程逐渐无法满足需求,于是诞生了 Shader——图形渲染管线中的可编程阶段。
Vertex Shader(顶点着色器) 是一段程序,GPU 处理每个顶点时都会执行它,可修改顶点的坐标、颜色等属性。其 killer application 是水面波纹与毛发摆动:对每个顶点,根据当前位置和时间代入波动方程即可修改坐标——与 Mandelbrot Set 逐像素计算的结构完全相同——在 GPU 上并行执行,完全不占用 CPU 资源。
Pixel/Fragment Shader(像素着色器) 在顶点处理完成、光栅化之后,对每个像素执行后处理程序,输入为 (x, y, RGB),可修改颜色值。这类似于 Lightroom 中调整曝光或色调——所有对照片的数值型处理,本质上都是对每个像素的函数变换,例如 R = R * 0.9, G = G * 0.9。
法线贴图(Normal Map) 是 Pixel Shader 的经典应用:真实模拟砖墙的凹凸需要大量三角形,显存无法承受。替代方案是为每个像素预存一个虚假的表面法线,在 Pixel Shader 中计算光线方向与法线的内积,从而模拟真实光照强度。效果是:平面几何体呈现出立体质感的打光效果,远看几乎不穿帮,近看才会露馅(因为法线无法遮挡真实的几何遮挡关系)。
Shader 是一种受限的编程语言:不支持系统调用,但支持计算、条件分支、load/store(纹理采样)等操作。OpenGL 教程中经典的"渐变色三角形",本质上就是通过 Fragment Shader 实现的。Shader 程序的核心结构是 forEach——对每个顶点或每个像素,并行执行同一段代码。
GPGPU 的萌芽
既然 Shader 是"对大量同类对象并行执行同一段代码",这不正是操作系统第一节课讲的多线程模型吗?
2001 年前后,研究者开始将可编程 Shader 用于通用科学计算:把矩阵乘法分解为行向量与列向量外积的求和,每次外积结果对应一张 bitmap,多张 bitmap 的叠加(用 Pixel Shader 逐像素相加)就完成了矩阵乘法。虽然从计算机科学家的视角看这条路径很迂回,但它真正利用了 GPU 的并行能力——当 GPU 比 CPU 快得多时,即使绕路也是有收益的。这篇论文预示了今天用 GPU 做高性能计算的方向。
CUDA 编程模型
回顾 Shader 的本质,可以自然地得出 CUDA 的编程模型。以计算 Mandelbrot Set 为例:在显存中分配一块 1920×1080 的数组,为每个像素 spawn 一个线程,每个线程接收 (row, col) 参数,计算颜色后写回对应位置,约 200 万个线程并行执行。这就是 CUDA——你只是想让 OS 第一节课学过的线程模型运行在 GPU 上,内存也分配在显存中,仅此而已。
第一次看到 CUDA 代码时,__device__、<<<grid, block>>> 等语法会让人感到陌生,但概念上毫不神奇:你只是在创建大量执行相同 kernel 函数的线程。
SIMT:共享 PC 的线程束(Warp)
问题在于:就算线程极其轻量,每个 1 KB,200 万个线程也需要 2 GB 的栈空间——不可行。
CUDA 的解决方案是 SIMT(Single Instruction, Multiple Threads,单指令多线程)。每个执行单元都需要 ALU、寄存器和程序计数器(PC);PC 决定了"下一条指令执行什么",从而要求取指和译码电路——而译码逻辑(32 bit 指令 → 大量 if-else 分支逻辑 → 每条线占一个触发器)会消耗大量电路。
CUDA 的做法是:将 32 个线程捆绑为一个 warp(线程束),共享同一个 PC。32 个线程同时执行同一条指令,但各自持有独立的寄存器(存储不同的数据)。只需一套取指译码电路服务 32 个线程,开销被 32 倍分摊,"税率"大幅降低。
以写像素为例:warp 中的线程 30、31、32 执行同一条 screen[row * 1920 + col] = color,row * 1920 部分在所有线程中相同,row * 1920 + col 则分别为 3×1920+0、3×1920+1、3×1920+2。所有线程执行同一条 store 指令,但写入连续的内存地址,内存控制器可以将其合并为一次 32×4 = 128 bit 的宽写操作——与 SIMD/AVX-512 的效果相同,只是通过线程模型自然达成的。
SM 的多 Warp 调度与延迟隐藏
如果内存访问地址不连续(非合并访问),就无法生成宽写操作,内存控制器只能逐字写入,性能骤降。因此 CUDA 程序中数据的布局方式极为关键,索引计算稍有改变就可能造成巨大的性能差异。
SM(Streaming Multiprocessor,流式多处理器)可以同时管理多个 warp。当某个 warp 遇到全局内存访问等高延迟操作时,SM 会切换到另一个就绪的 warp 继续执行,以隐藏延迟——这种方式不需要复杂的乱序调度电路,而是用海量线程来覆盖延迟,这与 VLIW 思想中"把调度交给外部"的哲学有内在的联系。CUDA 因此鼓励你创建数百万个线程,让 GPU 内部的高效调度机制来管理它们。
SIMT 的代价:分支发散
SIMT 不是没有代价。一个 warp 只有一个 PC,不能让 32 个线程同时走不同的分支。CUDA 将 if A else B 编译为:先计算条件,disable 条件不满足的线程,顺序执行 A 分支;再 disable 条件满足的线程,顺序执行 B 分支;最后全部恢复。两个分支都会被执行,不满足条件的线程只是被屏蔽。这意味着 warp 内分支越多,实际利用率越低——CUDA 程序的汇编(PTX)中看不到传统的 if-else 跳转结构,这一点可以让 AI 帮你解读验证。
综上,CUDA 的编程模型通过 SIMT 将调度开销降至接近零:每个 warp 的 32 个线程共享取指译码电路,ALU 和寄存器各自独立,大量逻辑门的翻转直接用于实际计算。感兴趣的同学可以在有 GPU 的环境中运行 Mandelbrot Set 的 CUDA 版本,与 CPU 版本对比,感受并行计算的威力。