第20讲 · CPU、GPU 和 SIMT 编程模型

2026 南京大学《操作系统原理》第二十讲 · 课堂实录重构

主讲 蒋炎岩(Yanyan Jiang)
视频 https://www.bilibili.com/video/BV1df536aEMk/
课程主页 https://jyywiki.cn/OS/2026/
本讲讲义 https://jyywiki.cn/OS/2026/lect20.md
讲义 / 幻灯片作者 蒋炎岩,https://jyywiki.cn/
讲义 / 幻灯片许可 CC BY-NC 4.0
图片来源 书中插图为课堂演示与讲义渲染图;演示类截图为视频画面

对应讲次:第20讲(南京大学 操作系统原理)

制作模型:deepseek-flash(coding-agent / pi;据 ~/.pi/agent/settings.json)

编者说明:本书由课堂录像的语音字幕重构而成,术语、章节骨架与参考链接已按课程官方讲义(lect20.md)校准。讲师的口语风格、类比与案例被尽量保留;编辑性的补充一律以「编者说明」或「编者拓展」标出。


引子:是时候推翻 CPU 的统治了

(参考时间: 00:00)

回顾前面学的:计算图(理解并行计算的关键)、轻量化线程(generator/coroutine、非阻塞 I/O、goroutine)、异步编程(事件驱动、Promise、async/await)——这些都是 CPU 上的并行/并发模型。

「是时候推翻 CPU 在计算这件事上的统治了。」


一、CPU 内部的并行

1.1 「顺序执行」只是一个精心维护的假象

(参考时间: 00:01)

CPU 就是那个「无情地执行指令的机器」(rv32ima_step)。但——顺序执行只是一个精心维护的假象,每个程序其实都是并行程序。

为什么?因为逻辑门天生是并行的(信号从左到右流过电路时,所有门同时工作):

怎么证明你的 CPU 一个周期能执行超过一条指令? 讲师给了个「开放题」思路:从 /proc/cpuinfo 知道 CPU 型号与频率 → 写汇编构造不同依赖关系的指令序列 → 用 perf stat 统计 IPC(Instructions Per Cycle)。

BogoMIPS:`/proc/cpuinfo` 里那个「每秒钟几百万次空循环」的估算值

📷 BogoMIPS:`/proc/cpuinfo` 里那个「每秒钟几百万次空循环」的估算值
回到原视频 (00:08:00)

cpu-ilp:用 `perf stat` 测量不同指令序列的 IPC

📷 cpu-ilp:用 `perf stat` 测量不同指令序列的 IPC
回到原视频 (00:12:30)

编者拓展|BogoMIPS 与 delay:早期(或固件/内核刚启动、精确计时器还不可用时)需要实现 delay(20ms)。答案:假设 CPU 频率固定,就用 while (loops--) cpu_relax(); 计数。由此得出 BogoMIPS(Bogus MIPS)——一个估算值。有意思的是,它常常大于 CPU 的主频(比如 2GHz 的 CPU 得到 5000),这本身就说明一个时钟周期能执行多条指令。

1.2 功耗墙:跑得越快,浪费得越多

(参考时间: 00:15)

为了让指令「一二三」能同时执行,CPU 付出了巨大的功耗:那个队列、数据流分析器、重命名……电路的每一次翻转都要发热,而这些能量没有参与实际计算——「你收 100 块,99 块都交税了」。

热功耗公式:

$$\text{Power} = C V^2 f$$

功耗墙:P = C V² f——纵有更大的电路,热功耗限制了性能上限

📷 功耗墙:P = C V² f——纵有更大的电路,热功耗限制了性能上限
回到原视频 (00:16:00)

面对功耗墙,两条路:

  1. 让一条指令处理更多数据 → SIMD(调度一条指令的能量大致是定数,如果一条指令能做更多事,调度的代价就被分摊了);
  2. 用更多更简单的处理器 → 多处理器、异构多处理器(ARM big.LITTLE,Intel P-cores/E-cores)。

1.3 SIMD:分摊指令调度的开销

(参考时间: 00:24)

SIMD(Single Instruction, Multiple Data):把一个大操作数分成几个小操作数。1997 年的 Intel MMX(MultiMedia eXtension)——一个很有年代感的名字:

MMX:64 位寄存器当成 4×int16,一条指令操作多个数据

📷 MMX:64 位寄存器当成 4×int16,一条指令操作多个数据
回到原视频 (00:26:00)

编者拓展|你其实一直在用「SIMD 思想」: - Bitset:(arr[x / 8] >> (x % 8)) & 1——把一个 32 位整数当成 32 个 bit 用; - lowbit:x & -x == x & (~x + 1); - popcount():用「掩码 + 移位 + 加法」把 32 个 bit 当成 32 个 1-bit 数并行统计。

讲师现场讲了经典的 popcount 位运算技巧(x & 0x55555555 取奇数位、x & 0xaaaaaaaa 取偶数位、对齐相加、递归汇聚)。不过他也提醒:这种「聪明」写法在现代处理器上性能反而差(对动态流水线不友好)——查表(把 32 位拆成 4 个字节,查预先算好的表)反而更快。

popcount:用位运算把「32 个数」当成 SIMD 并行统计

📷 popcount:用位运算把「32 个数」当成 SIMD 并行统计
回到原视频 (00:29:00)

寄存器越长,调度代价被摊薄得越多:%mm(64) → %xmm(128) → %ymm(256) → %zmm(512, AVX-512)。(mm 的「多媒体」含义已过时,但名字被永远保留下来;AVX-512 已经撞上热密度极限,一开就可能降频。)它还支持更多数据类型(int8/16/32、float32/64)、更多运算(Shuffle:c[i]=a[b[i]];FMA:a*b+c 一条指令完成)。

MMX → SSE → AVX → AVX-512:寄存器越来越宽,直到撞上热密度极限

📷 MMX → SSE → AVX → AVX-512:寄存器越来越宽,直到撞上热密度极限
回到原视频 (00:34:30)

但 SIMD 没完全解决问题:它依然在 CPU 里被调度,要参与缓存和动态流水线,会和其他指令抢缓存、抢功耗。

编者拓展|VLIW(Very Long Instruction Word):一个激进但失败的尝试——完全消灭动态流水线,把「打包指令 + 静态调度」全交给编译器。Intel 的 IA64 就是 VLIW,失败了(当年指令级并行和先进制程的收益很大、又没有迁移成本)。「但在大模型时代,它可能又要复活了——因为它是真正能让指令调度代价接近零的方案,只是当年编译器技术还跟不上。」


二、改变人类命运的另一条时间线:GPU

2.1 游戏机的「性能危机」

(参考时间: 00:39)

1972 年的 Magnavox Odyssey——世界上第一台发售的游戏主机:没有声音,画面只是光点(只能玩 Pong)。

1983 年的 NES(任天堂红白机)面临一个计算危机:

核心想法:把「要算什么」和「怎么画」分开——CPU 只做「场景描述」这个数据结构,由专用硬件把数据结构变成图形。

Magnavox Odyssey 与 NES:娱乐需求驱动了图形硬件的发展

📷 Magnavox Odyssey 与 NES:娱乐需求驱动了图形硬件的发展
回到原视频 (00:42:00)

2.2 NES 的「场景描述」与 PPU

(参考时间: 00:45)

NES Sprite 属性:调色板 / 优先级 / 水平垂直翻转

📷 NES Sprite 属性:调色板 / 优先级 / 水平垂直翻转
回到原视频 (00:50:00)

NES 的 PPU:Tile ROM + Tile Map + Attribute Table + Sprite

📷 NES 的 PPU:Tile ROM + Tile Map + Attribute Table + Sprite
回到原视频 (00:46:30)

PPU 中的「静态渲染管线」:对每一个像素做的事情都是一样的——依次算出背景像素、Sprite 像素的颜色,再合成(这些概念一直用到今天的 z-buffer)。这就是早期的 fixed-function pipeline GPU:每个 GPU「线程」都在做完全相同的动作。

// 逐行扫描
for (int row = 0; row < lines; row++) {
    #pragma omp parallel for
    for (int i = 0; i < 64; i++) {
        struct sprite *s = &SPRAM[i];
        if (s->y <= row && row < s->y + 8) { /* 绘制 sprite */ }
    }
    display_row(row);
}
PPU 逐行扫描 + Sprite 匹配:本质上就是 z-buffer 的前身

📷 PPU 逐行扫描 + Sprite 匹配:本质上就是 z-buffer 的前身
回到原视频 (00:52:30)

编者说明:讲师指出,这个「把计算固化进电路」的想法在历史上反复出现——「矿卡」就是同一个思路(把哈希计算固化进电路)。「如果你在 1983 年意识到这一点,到比特币热潮时你就会发大财。」

2.3 从 2D 到 3D,再到「可编程着色器」

(参考时间: 00:55)

2D 贴图硬贴出 3D:缺少深度信息导致 affine 贴图失真

📷 2D 贴图硬贴出 3D:缺少深度信息导致 affine 贴图失真
回到原视频 (00:58:00)

编者拓展|齐次坐标为何巧妙:二维里你无法用一个 2×2 矩阵表示平移(只能旋转/缩放),但把点写成 $(x,y,1)$、放进三维空间,就能用一个 3×3 矩阵把平移也写成线性变换——「你就可以用乘一个矩阵的方式实现三维世界里的一切变化」。

从「数据结构」到「编程语言」:可编程着色器(Shader)

// Fragment shader 干的事:为每一个像素去修改它的颜色值
// 你可以调亮/调暗、做 mask、做法线贴图……
Shader:用「套娃」方式在程序里嵌入 GLSL,跳过完整编译流程作用于像素

📷 Shader:用「套娃」方式在程序里嵌入 GLSL,跳过完整编译流程作用于像素
回到原视频 (01:05:00)

OpenGL Shader:一个字符串形式的程序,被编译后作用到每一个像素

📷 OpenGL Shader:一个字符串形式的程序,被编译后作用到每一个像素
回到原视频 (01:11:00)

2.4 GPGPU:用图形硬件做通用计算

(参考时间: 01:11)

「Shader Program 就是:为一大堆东西执行同一段代码。」——「绕了一圈,又回到《操作系统》讲并发的第一课了!」

2001 年的论文 Fast matrix multiplies using graphics hardware:把科学计算问题变成图片处理问题——用外积分解 $AB = \sum_k A_{,k} B_{k,}$,把矩阵变成「图片」,送到显卡上运算,再用 pixel shader 做「图片叠加」。

2006 年 GeForce 8800(首个 CUDA GPU, G80):128 cores(对比当时 Intel 桌面 CPU 2C4T),首个支持 C 的 GPU,首个使用标量线程处理器的 GPU(程序员不必再手动管理向量寄存器)。RTX 5080 已经有 10,752 个 CUDA Cores——「这是怎么做到的?它们也(可以)是 shared memory 啊!」


三、SIMT:CUDA 的编程模型

3.1 你早就学过 CUDA 了

(参考时间: 01:15)

CUDA 的编程模型,本质上就是在 GPU 上启动海量轻量级线程:

__device__ int *screen;

void T_kernel(int row, int col) {
    int color = f(row, col);        // 任意 C/C++ 代码
    screen[row * 1920 + col] = color;
}

for (int row = 0; row < 1080; ++row)
    for (int col = 0; col < 1920; ++col)
        spawn(T_kernel, row, col);   // 在 GPU 上启动 2,073,600 个线程

「恭喜!你发明了 CUDA(的 programming model)。」——你第一次看到 CUDA 代码里那些 __device__、三个尖括号 <<<...>>> 会觉得害怕,其实一点也不可怕:你只是想创建一大堆线程,它们运行同样的代码。(这也是为什么图形/游戏里每个像素都运行同样的代码。)

CUDA kernel:为每一个像素 spawn 一个线程,写回显存

📷 CUDA kernel:为每一个像素 spawn 一个线程,写回显存
回到原视频 (01:16:00)

3.2 SIMT:一个 PC 管一束线程

(参考时间: 01:18)

「为什么会有 GPU?」 回到那个「疯狂想法」:如果每个 CPU 只保留绝对必要的部件(ALU、寄存器),把能被共享的(译码器、取指、PC)只放一份,那交的税就变少了。

SIMT(Single Instruction, Multiple Threads):

一条 store 指令 → 生成「非常长」的连续 store
screen[3*1920 + 0] = color_0;
screen[3*1920 + 1] = color_1;
...

Memory coalescing(内存合并)就神奇地发生了——「这一个 thread warp 好像做了一个 128-byte 的 store」。而一个 NVIDIA Stream Multiprocessor 可以保存多个(32 个)warps,当一组线程 load/store stall(比如 cache miss)时,就切换到另一组——以隐藏延迟。

graph TD
    SM["Stream Multiprocessor"] --> W["Thread Warp 0(32 线程,共享 PC)"]
    SM --> W2["Thread Warp 1"]
    SM --> W3["…(共 32 个 warp)"]
    W -->|"load/store stall"| SW["切换到另一个 warp"]
    SW --> W2
SIMT:一束线程共享一个 PC,一条 store 变成一段连续的 128-byte store

📷 SIMT:一束线程共享一个 PC,一条 store 变成一段连续的 128-byte store
回到原视频 (01:22:00)

3.3 SIMT 的代价:分支与内存合并

(参考时间: 01:26)

这也意味着 CUDA 程序很难写:

SIMT 的分支:没有真正的 if/else,而是「disable 不满足条件的线程」线性执行

📷 SIMT 的分支:没有真正的 if/else,而是「disable 不满足条件的线程」线性执行
回到原视频 (01:29:00)

编者拓展|CUDA 的收益:渲染 25600×25600 的 Mandelbrot Set——Ryzen 5 9600X:25.1s @ 65W;RTX 4060Ti:6.1s @ 42W(而且这已经是对 CUDA「不太友好」的程序:不确定的 while 循环,最慢的那个线程会拖慢整个 thread warp)。「经过编译器优化,GPU 上的汇编比你想象的短,你可以直接阅读它——但 GPU 并不擅长执行数据中心里的业务逻辑。」


总结


附:官方参考与延伸阅读

本讲的讲义见课程主页,以下链接按书中出现顺序整理:

主题 链接
RISC-V 处理器模拟器(mini-rv32ima) https://jyywiki.cn/OS/demos/intro/mini-rv32ima
指令级并行(cpu-ilp) https://jyywiki.cn/OS/demos/concurrency/cpu-ilp
OpenGL Shader(gl-shader) https://jyywiki.cn/OS/demos/concurrency/gl-shader
CUDA 实现的 Mandelbrot Set(mandelbrot-cu) https://jyywiki.cn/OS/demos/concurrency/mandelbrot-cu
MMX 手册 https://jyywiki.cn/OS/manuals/mmx.pdf
Larson & McAllister, Fast Matrix Multiplies Using Graphics Hardware(SC'01) https://dl.acm.org/doi/pdf/10.1145/582034.582089
NVIDIA Fermi 白皮书 https://www.nvidia.com/content/PDF/fermi_white_papers/NVIDIA_Fermi_Compute_Architecture_Whitepaper.pdf
Game Boy Advance: V-Rally 3 https://www.bilibili.com/video/BV1bT4y1g75x/
OSTEP:Operating Systems: Three Easy Pieces https://pages.cs.wisc.edu/~remzi/OSTEP/
perf-stat(1) 手册 https://man7.org/linux/man-pages/man1/perf-stat.1.html
CUDA C++ Programming Guide https://docs.nvidia.com/cuda/cuda-c-programming-guide/

阅读材料:如有 GPU 设备,可动手跑一跑 mandelbrot-cu,对比 CPU 版本;并让 AI 帮你解释 SIMT 模型生成的 GPU 汇编。

版权说明:本讲讲义与幻灯片作者为蒋炎岩,采用 Creative Commons BY-NC 4.0 许可,来源于 https://jyywiki.cn/OS/2026/。视频画面与字幕版权归原作者所有。本书为课堂内容的学习性重构。