前置讲 P1 · 计算机体系结构入门:从指令到存储层次 · A Computer Architecture Primer

Efficient ML Inference at Scale · 预备知识 1。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

定位:为没有计算机系统背景的读者补充的前置讲,原课没有对应的课次(原课默认学过计算机体系结构)。它支撑第 1 讲(能量与 roofline)、第 8 讲(硬件引擎)和第 9 讲(硬件性能建模),也是前置讲 P2(GPU 编程模型)的基础。 依据:经典教材 J. Hennessy & D. Patterson, Computer Architecture: A Quantitative Approach(以及入门版 Computer Organization and Design)的基本框架;U. Drepper, What Every Programmer Should Know About Memory(2007);M. Horowitz 的 ISSCC 2014 主题演讲;X. Ma & D. Patterson(IEEE Computer, 2026)的内存技术数据;Gimlet Labs 关于 SRAM 芯片的博客。实测数字来自编写时的一台 Apple M4 Pro 笔记本。 配套代码 Code:code/prereq1/(numpy + 标准库,03 的延迟测量另需 C 编译器;共享模块 arch_lab.py + 5 个脚本,最慢的约 10 秒) 预计阅读时间:3 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 为什么要先学这些

第 1 讲说”decode 时 GPU 的大部分时间在等数据”、“搬数据比算贵几百倍”、“分块能提高复用”。这些话背后有一整套关于处理器怎样执行程序、数据怎样在存储层次里流动的知识。这一讲把它们从零讲起,只讲后面真正要用的部分:

本讲的概念后面在哪里用到
指令、时钟、流水线、依赖链P2(GPU 怎样隐藏停顿);第 6 讲(decode 的依赖链与投机解码)
cache、局部性、cache line、3C 缺失P2(合并访存、共享内存);第 9 讲(数据复用与能量模型);第 10–11 讲(分块内核)
延迟 vs 带宽、Little 定律P2(为什么 GPU 需要成千上万个线程);第 4 讲(通信与计算重叠)
SRAM / DRAM / HBM第 1 讲 §4.7(近存 vs 远存);第 7 讲(KV cache 的存储层次);第 8 讲
SIMD、多核、功耗与频率P2(GPU 的设计哲学);第 1 讲 §2(能量视角)

0.2 学习目标 Learning goals

  1. 解释处理器执行一段程序的过程:指令、时钟周期、CPI,以及”时间 = 指令数 × CPI ÷ 频率”。
  2. 说清楚流水线为什么提高吞吐而不缩短延迟,停顿和依赖链为什么会让硬件闲着,多个累加器(ILP)怎样解决。
  3. 描述 cache 的组织(行、组、路、替换策略),能手算一个地址落在哪一组;会用”3C”(强制、容量、冲突)分析一个访问模式为什么缺失。
  4. 用平均访存时间(AMAT)把命中率换算成时间;知道为什么顺序访问在真实机器上比模型里还快(预取)。
  5. 区分延迟和带宽,用 Little 定律算出”要跑满带宽需要多少在途请求”,并由此理解 GPU 为什么需要海量线程。
  6. 说出 SRAM、DRAM、HBM 的物理差异和取舍;会用”引脚数 × 每引脚速率”估算内存带宽。
  7. 区分三种并行(ILP、SIMD、多核),解释为什么功耗随电压平方增长导致”又宽又慢”的设计(GPU)更省电。

0.3 本讲地图 Map

§1  处理器在做什么:指令与性能方程;流水线;停顿;依赖链与 ILP;解释器的开销              ← 代码 01
§2  存储层次:内存墙;局部性;cache 的组织;3C;访问模式;矩阵与分块;AMAT              ← 代码 02
§3  在你的电脑上看见存储层次:延迟阶梯;随机访问与内存级并行;顺序带宽;TLB          ← 代码 03
§4  延迟、带宽与 Little 定律:在途请求;多线程实测;GPU 为什么要海量线程                ← 代码 04
§5  内存技术:SRAM、DRAM、HBM;带宽 = 引脚 × 速率;面积账;新方向                     ← 代码 04
§6  并行与功耗:三种并行;峰值算力的分解;SIMD 与数据类型;多核与 Amdahl;P = CV²f      ← 代码 05
§7  把这些放在一起:CPU 与 GPU 的两种设计取舍
§8–12  小结、常见误区、练习(含答案)、延伸阅读、和正课的联系

0.4 关于数字 · About the numbers

  • 模拟和公式(代码 01 演示 1–2、02、04 演示 1/3/4/5、05 演示 1/4):每次运行完全相同。
  • 墙钟测量(代码 01 演示 3–4、03、04 演示 2、05 演示 2–3):来自编写时的 Apple M4 Pro(8 个性能核 + 4 个能效核;性能核 L1 数据 cache 128 KiB,每 4 个性能核共享 16 MiB L2;cache line 128 字节)。每次运行会有几个百分点到十几个百分点的波动,你的机器会不同,只看形状和数量级。
  • 引用别人的数字(论文、规格)时注明出处。

1. 处理器在做什么 · What a processor does

代码:01_how_a_cpu_runs.py

1.1 从程序到指令

你写的 Python 或 C 代码,处理器并不能直接”看懂”。它只认识一种很简单的语言:指令(instruction)。每一种处理器都有自己的指令集(ISA,instruction set architecture),比如 x86、ARM、RISC-V,NVIDIA GPU 的 PTX/SASS。一条指令只做一件很小的事:“把内存某个地址的数读进寄存器”、“把两个寄存器相加”、“如果某个条件成立就跳到另一条指令”……

寄存器(register)是处理器内部极少量、极快的存储单元(每个核几十到几百个),运算只能在寄存器之间进行。数据要先从内存 load 进寄存器,算完再 store 回去。

拿点积 s += a[i] * b[i] 来说,编译之后的循环大致是这样(伪汇编,示意,代码 01 演示 1):

loop:
  load   f1, [a + i*4]     ; 从内存读 a[i] 到寄存器 f1
  load   f2, [b + i*4]     ; 读 b[i]
  fma    f0, f1, f2, f0    ; f0 = f1 × f2 + f0(乘加,一条指令算 2 次浮点运算)
  add    i, i, 1           ; 下标加 1
  cmp    i, n              ; 比较 i 和 n
  blt    loop              ; 如果 i < n 就跳回 loop(分支)

每个元素 6 条指令,真正”做运算”的只有 1 条 fma(fused multiply-add,乘加),其余是搬数据(load)和管理循环(add、cmp、blt)。这就是第 1 讲 §2.5 说的”管理开销”的具体样子。

1.2 时钟与性能方程

处理器由一个时钟驱动:每隔固定的时间”嘀嗒”一次,所有电路在嘀嗒时同步更新状态。4 GHz 的处理器每秒嘀嗒 40 亿次,每个时钟周期(cycle)0.25 ns。

一条指令平均要几个周期,叫 CPI(cycles per instruction);倒过来,每个周期平均完成几条指令,叫 IPC。于是有体系结构里最基本的公式(“性能铁律”,Iron Law):

时间=指令条数×CPI×1频率.\text{时间} = \text{指令条数} \times \text{CPI} \times \frac{1}{\text{频率}} .

三项分别由三层决定:指令条数由算法和编译器决定,CPI 由微体系结构(处理器内部怎么设计)决定,频率由工艺和电路决定。想让程序变快,就是想办法压低其中一项。

现代 CPU 每个周期能同时发射好几条指令(IPC > 1,叫”超标量”,superscalar)。即便如此,代码 01 演示 1 算出:按 CPI = 0.25、4 GHz,单核标量代码做点积也只有约 5 GFLOP/s。和第 1 讲里 H100 的 989 TFLOP/s 差了五个数量级——差距从哪里补?这一讲的 §6 和 P2 会回答。

1.3 一条指令的五个步骤

教科书里最经典的处理器把一条指令分成五步:

  1. 取指(IF, instruction fetch):从指令 cache 里读出下一条指令。
  2. 译码(ID, decode):弄清楚这是什么指令、要用哪几个寄存器,并读出寄存器的值。
  3. 执行(EX, execute):在运算单元(ALU)里做加法、乘法、比较,或者算出内存地址。
  4. 访存(MEM, memory):如果是 load/store,访问数据 cache。
  5. 写回(WB, write back):把结果写进目标寄存器。

第 1 讲 Horowitz 的能量表里”一条指令 70 pJ”,主要就花在第 1、2、5 步(取指 25 pJ、寄存器 6 pJ、控制逻辑)上,而第 3 步真正的运算只要零点几到几个 pJ。

1.4 流水线:延迟不变,吞吐提高

如果一条指令走完五步才开始下一条,每条指令要 5 个周期。流水线(pipeline)的想法是:第一条指令进入第 2 步时,第二条指令就可以进入第 1 步。

用洗衣服打个比方:洗、烘、叠三步,每步 1 小时。一个人洗完烘完叠完再洗下一批,3 批衣服要 9 小时;流水作业时,第一批进烘干机的同时第二批开始洗,3 批只要 5 小时。每一批衣服从脏到叠好的时间(延迟)没变,单位时间洗完的批数(吞吐)提高了。

代码 01 演示 2:

指令条数不用流水线(周期)5 级流水线(周期)加速比
1551.00×
1005001044.81×
10,00050,00010,0045.00×

公式:不用流水线 n×kn \times k 个周期(kk 是级数);理想流水线 k+n−1k + n - 1 个周期。指令很多时,几乎每个周期完成一条,吞吐提高约 kk 倍。

延迟和吞吐是两回事——这对概念会贯穿全课:LLM 生成每个 token 有延迟(TPOT),一块 GPU 每秒产出多少 token 是吞吐;批处理提高了吞吐,但不缩短(甚至延长)每个 token 的延迟。

1.5 停顿:流水线的天敌

流水线有一个前提:每个周期都有新指令可以开始。两种情况会打破它:

  • 数据依赖(数据冒险):下一条指令要用上一条的结果,而上一条还没算完。它只能等,流水线里出现一个”气泡”。
  • 分支(控制冒险):遇到 blt loop 这样的条件跳转,在比较结果出来之前,处理器不知道下一条该取哪条指令。

代码 01 演示 2 的第二张表:10,000 条指令里 15% 是分支,如果每次都猜错、每次损失 15 个周期,CPI 从 1.0 涨到 3.25;分支预测准确率提到 95%,CPI 回到 1.11。

现代 CPU 为了减少停顿,花了大量晶体管在分支预测(猜下一条指令在哪里)、乱序执行(out-of-order:后面的指令如果不依赖前面的,就先执行)、寄存器重命名等机制上。这些都属于”控制”开销,它们让单个线程跑得很快,但很费面积和能量。§7 和 P2 会看到,GPU 走了完全相反的一条路。

1.6 依赖链与指令级并行

再看求和 s += x[i]:每一次加法都要等上一次加法的结果——这是一条贯穿整个循环的依赖链。浮点加法有几个周期的延迟(典型 3–4 个周期),所以即使硬件每个周期能做 2 次加法,单个累加器每 4 个周期也只能完成 1 次。

办法是用多个独立的累加器:s0 += x[0]; s1 += x[1]; s2 += x[2]; ...,最后再把 s0, s1, ... 加起来。几条依赖链交错进行,同时有多条加法”在路上”,这叫指令级并行(ILP, instruction-level parallelism)。代码 01 演示 3 的模型(延迟 4 周期、每周期最多 2 次加法,假设值):

累加器个数每周期完成的加法
10.25
20.50
41.00
82.00(饱和)

需要的累加器个数 = 延迟 × 吞吐 = 4 × 2 = 8。记住这个乘法——它就是 §4 的 Little 定律在处理器内部的样子。

实测(同一段放得进 cache 的数据):没有依赖链的 np.add 每个元素约 0.06 ns,内部多路累加的 x.sum() 约 0.11 ns,而必须逐个依赖的 np.cumsum(第 ii 个前缀和要等第 i−1i-1 个)约 2 ns,慢了 17 倍。(cumsum 还要写回结果,这个对比不完全是”纯依赖”的差别,但主要的差距来自依赖链。)

这对 LLM 很重要:decode 就是一条依赖链——第 t+1t+1 个 token 必须等第 tt 个 token 生成出来。单个请求内部没法并行,只能靠”同时服务很多请求”(批处理,第 1 讲 §4.2、第 6 讲 Orca)或者”先猜几个、再一起验证”(投机解码,第 6 讲)来补。

1.7 解释器的开销

最后一个实测(代码 01 演示 4):同一个 20 万维的点积,

写法每个元素相对 numpy
纯 Python for 循环约 20 ns约 800×
sum(x*y for x, y in zip(a, b))约 27 ns约 1,000×
numpy a @ b约 0.026 ns1×

Python 是解释执行的:每一步 s += a[i] * b[i] 都要查变量、检查类型、创建新的浮点数对象、回收旧对象……几十上百条机器指令只为了一次乘加。numpy 把整个循环交给编译好的 C 代码(还用上了 SIMD,§6),每个元素只要零点几条指令。

这和第 1 讲 §2.5 的”指令开销”是同一件事,只是被放大了:解释器的每一步就像一条”超级昂贵的指令”。推理引擎里同样的问题叫”CPU 开销”或”框架开销”:每启动一个 GPU 内核,Python 和驱动都要走一遍(P2 §8、第 11 讲的 megakernel)。本讲义所有代码都尽量用 numpy 的整数组运算,原因就在这里。


2. 存储层次:为什么需要 cache · The memory hierarchy

代码:arch_lab.py(Cache、Hierarchy 模拟器)和 02_cache_simulator.py

2.1 内存墙

处理器一个周期 0.25 ns,而从内存(DRAM)读一个数要 100 ns 以上——几百个周期(§3 在笔记本上实测到约 120 ns)。如果每条 load 都要等内存,处理器 99% 以上的时间都在等。几十年来处理器的速度比内存的速度涨得快得多,这个差距叫内存墙(memory wall)。第 1 讲 §5.3 说的”算力涨得比带宽快”是它在 AI 芯片上的版本。

解决办法是在处理器和内存之间放几级小而快的存储:cache(高速缓存)。离处理器越近,越小、越快、越省电:

层次典型大小典型延迟(编写机器上实测的数量级)实现方式
寄存器几百字节到几 KB0(就在运算单元旁边)触发器 / SRAM
L1 cache几十到一百多 KB(每核)约 1 nsSRAM
L2 cache几 MB 到几十 MB约 7–20 nsSRAM
内存(DRAM)几十到几百 GB约 100–120 nsDRAM

2.2 局部性原理

cache 之所以有用,是因为程序访问数据有规律——局部性(locality):

  • 时间局部性:刚用过的数据,很快还会再用(循环里的变量、反复使用的权重)。
  • 空间局部性:用了一个数据,很快会用它旁边的数据(顺序遍历数组)。

如果程序没有局部性(每次都访问一个全新的、离得很远的地址),cache 就帮不上忙。好消息是大多数程序都有;坏消息是有些重要的负载局部性很差,比如 decode 时每个序列读自己的 KV cache、推荐系统查大嵌入表。

2.3 cache 怎样工作

cache line(缓存行)。cache 不是一个字节一个字节地搬数据,而是一次搬一整”行”(典型 64 字节,Apple M 系列是 128 字节)。读 a[0] 时,a[0] 到 a[15](16 个 float32)一起被搬进来,后面读 a[1]……a[15] 就都命中了——这就是空间局部性的来源。

组相联(set-associative)。cache 被分成若干组(set),每组有若干路(way,即若干个可以放 cache line 的位置)。一个地址属于哪一组是固定的:

行号=⌊地址行大小⌋,组号=行号 mod 组数.\text{行号} = \left\lfloor \frac{\text{地址}}{\text{行大小}} \right\rfloor, \qquad \text{组号} = \text{行号} \bmod \text{组数} .

这一行只能放进它那一组的某一路里。组里满了再来新行,就要按替换策略踢掉一行,最常见的是 LRU(least recently used,踢掉最久没用过的)。

  • 每组只有 1 路:直接映射(direct-mapped),硬件最简单,但两个行号同组的数据会不停互相踢。
  • 只有 1 组:全相联(fully associative),任何行可以放任何位置,最灵活,但每次查找要比较所有位置,又慢又费电。
  • 实际的 cache 通常是 4–16 路组相联,是两者的折中。

例:32 KiB、64 字节行、8 路 → 32768/(64×8)=6432768 / (64 \times 8) = 64 组。地址 0x12345(十进制 74,565)的行号是 1,165,组号是 1165 mod 64=131165 \bmod 64 = 13。

写操作(store)还涉及”写回”(write-back,先只改 cache,被踢出时再写回内存)和”写分配”等策略,我们的模拟器只模拟读,够用了。

2.4 三种缺失(3C)

访问的数据不在 cache 里,叫缺失(miss),要去下一级取。缺失可以分成三类,每一类的对策不同:

  1. 强制缺失(compulsory):第一次访问这一行,任何 cache 都躲不掉。对策:更大的行(一次多搬一点)、预取。
  2. 容量缺失(capacity):工作集比 cache 大,用过的行在再用之前被挤掉了。即使是同样大小的全相联 cache 也会缺失。对策:更大的 cache,或者改变访问顺序让工作集变小(分块)。
  3. 冲突缺失(conflict):cache 总容量够,但这些行恰好挤在同一组里互相踢。全相联 cache 不会发生。对策:更高的相联度,或者填充(padding),让地址错开。

我们的模拟器 arch_lab.Cache 同时维护一个同容量的全相联 LRU cache 作对照,就能把每次缺失归到这三类之一。

2.5 访问模式与命中率

空间局部性(代码 02 演示 1,32 KiB、64 字节行、8 路,访问 4096 个 float32):

访问方式每行用到几个数命中率
顺序(跨 4 B)1693.8%
跨 8 B887.5%
跨 32 B250.0%
跨 64 B(一整行)10.0%
4 MiB 范围内随机≈10.7%

顺序访问的命中率 =1−元素大小/行大小= 1 - \text{元素大小}/\text{行大小}。跨度达到一整行后,每次访问都是新的一行:搬进来 64 字节只用了 4 字节,带宽浪费了 15/16。P2 §5 会看到,GPU 上同样的现象叫”没有合并的访存”。

时间局部性与 LRU 的悬崖(演示 2):把大小为 WW 的数组反复扫 4 遍(每行访问一次,排除空间局部性),cache 是 32 KiB 全相联:

工作集 WWLRU 命中率随机替换命中率
8–32 KiB75.0%75.0%
33 KiB0.0%70.7%
36 KiB0.0%60.1%
64 KiB0.0%16.5%

放得下时,除了第一遍的强制缺失全部命中(3/4 = 75%)。但工作集只比 cache 大 3%,LRU 的命中率就直接掉到 0:顺序循环访问时,LRU 每次恰好踢掉”马上就要再用”的那一行。随机替换反而能保住一部分。没有一种替换策略在所有访问模式下都最好——第 7 讲 KV cache 的前缀缓存会再遇到”该踢掉谁”的问题。对程序员最重要的启示是:让工作集放得进 cache,比任何替换策略都重要。

冲突缺失(演示 3):同时顺序读 12 个数组的第 ii 个元素,数组首地址相隔 32 KiB:

数组布局直接映射4 路8 路16 路全相联
相隔 32 KiB0.0%0.0%0.0%93.8%93.8%
相隔 32 KiB + 64 B(填充一行)93.8%93.8%93.8%93.8%93.8%

12 个数组一共只需要 12 行(768 字节),远小于 cache。但 32 KiB 恰好是”组数 × 行大小”的整数倍,12 个数组的第 ii 个元素全部落在同一组,8 路放不下 12 行,就不停互相踢。相联度 ≥ 12 时冲突消失;更便宜的办法是填充——每个数组错开 64 字节,它们就分散到不同的组,连直接映射都够用。2 的幂次方大小的数组在 AI 里到处都是(4096、8192……),这个问题会在 GPU 共享内存的 bank 冲突(P2 §6)里以几乎一模一样的形式再出现,解法也一样。

2.6 矩阵:存储顺序、填充与分块

一个 n×nn \times n 的矩阵在内存里是一维排开的。numpy 和 C 默认按行存储(row-major):A[i][j]A[i][j] 的地址 =base+(i⋅ld+j)×4= \text{base} + (i \cdot \text{ld} + j) \times 4,其中 ld(leading dimension,行距)通常等于 nn。按行遍历地址连续;按列遍历每一步跳过一整行。

代码 02 演示 4(float32,32 KiB、8 路 cache):

操作256×256,无填充256×256,行距 +64 B1024×1024,行距 +64 B
按行求和93.8%93.8%–
按列求和0.0%(全是冲突缺失)93.8%0.0%(全是容量缺失)
朴素转置46.9%93.8%46.9%
分块转置 16×1693.8%93.8%–
分块转置 32×3291.6%93.8%93.8%
分块转置 64×6446.9%93.8%–

这张表里有一个我写代码前没想到的结果:256×256 时,按列求和的缺失几乎全是冲突缺失,不是容量缺失。一列只涉及 256 行 × 64 B = 16 KiB,容量其实够;是 1 KiB 这个 2 的幂次方的行距,让一列的元素只落在少数几组里。给每行填充 64 字节,按列求和就回到了 15/16。

但填充救不了真正的容量问题:1024×1024 的矩阵一列要 1024 行 × 64 B = 64 KiB,比 cache 大,填充后按列求和、朴素转置仍然几乎全是容量缺失。这时只能改变访问顺序:分块(tiling)。分块转置每次只处理 t×tt \times t 的一小块,读 AA 的 tt 行、写 BB 的 tt 行,一共 2t2t 个 cache line,放得进 cache,于是两边都能把一行用完再走:32×32 的块一次只碰 64 行(4 KiB),命中率回到 15/16。

块的大小有讲究:太小,每行用不完;太大,工作集放不下或者互相冲突(无填充时 64×64 又掉回 46.9%)。“把大问题切成放得进快速存储的小块”——第 1 讲 §3.9 的分块矩阵乘法、第 9 讲的循环分块、第 10 讲的 GPU 内核、第 11 讲的 FlashAttention,都是这一个想法的变体。

2.7 平均访存时间 AMAT

命中率要换算成时间才有意义。两级 cache 的平均访存时间(average memory access time):

AMAT=tL1+mL1×(tL2+mL2×tmem),\text{AMAT} = t_{L1} + m_{L1} \times \big( t_{L2} + m_{L2} \times t_\text{mem} \big),

其中 mL1m_{L1} 是 L1 的缺失率,mL2m_{L2} 是到达 L2 的访问里的缺失率(“局部缺失率”)。代码 02 演示 5(L1 32 KiB / 1 ns、L2 1 MiB / 8 ns、内存 120 ns,延迟取整自 §3 的实测数量级):

访问模式L1 缺失率L2 局部缺失率AMAT
顺序访问6.2%100%9.0 ns
随机,16 KiB 范围(放得进 L1)0.4%100%1.55 ns
随机,512 KiB 范围(放得进 L2)93.8%14.5%24.9 ns
随机,64 MiB 范围99.9%98.7%127 ns
  • 随机访问一大片内存时,AMAT 接近内存延迟本身,是 L1 的上百倍;工作集放得进 L2 时,L2 接住了大部分 L1 缺失。
  • 顺序访问在这个模型里也要 9 ns:每 16 次访问有 1 次要一路走到内存(1 + 8 + 120 ns),平均到 16 次上。这个模型没有预取。真实 CPU 有硬件预取器(prefetcher):看到顺序访问,就提前把后面的行搬进来。§3 的实测会显示,真实机器上顺序访问的代价比这个模型低得多。

3. 在你的电脑上看见存储层次 · Measuring your machine

代码:03_measure_memory_hierarchy.py(约 10 秒)。全部是墙钟测量,数字来自编写时的 Apple M4 Pro。

3.1 指针追逐:测出”一次访问到底要等多久”

要测延迟,必须让每次访问都依赖上一次:在一块内存里放一串指针,连成一个随机顺序的环,然后反复执行 p = *p(读出的值就是下一个地址)。上一次读没完成,下一次读就没法开始,于是”总时间 ÷ 步数”就是一次访问的延迟。随机顺序是为了让预取器猜不到下一个地址。

这个测量用一个 30 行的 C 小程序来做(脚本会自动编译;没有 C 编译器就跳过),因为 Python 每一步的解释开销约 20 ns(§1.7),会把 L1、L2 的差别完全盖住。结果:

工作集每次访问的延迟
16–128 KiB0.75 ns
256 KiB – 2 MiB6.5–6.8 ns
4–8 MiB8–10 ns
16 MiB19 ns
32 MiB72 ns
64–256 MiB109–121 ns

一级一级的台阶,和操作系统报告的 cache 大小(性能核 L1D 128 KiB、L2 16 MiB)对得上:128 KiB 以内全在 L1(约 3 个时钟周期);超出 L1 进入 L2(约 7 ns);超出 16 MiB 的 L2 以后,经过芯片上还有的一级共享缓存(这里没有细测),最终到内存约 120 ns——比 L1 慢约 160 倍。

拐点不是尖角:cache 是组相联的,别的程序也在用 cache,工作集接近容量时缺失是逐渐出现的。最右边几个点还会受 TLB 缺失影响(§3.4)。

3.2 随机访问:吞吐比延迟好得多

现在换一种方式:用 numpy 一次给出几百万个随机下标 a[idx]。访问之间没有依赖,处理器可以不等前一个完成就发出下一个。结果(工作集 256 MiB 时):平均每 4.1 ns 完成一次随机访问,而一次访问的延迟是 121 ns。

延迟每次访问的平均间隔=1214.1≈30.\frac{\text{延迟}}{\text{每次访问的平均间隔}} = \frac{121}{4.1} \approx 30 .

也就是说,平均约有 30 个访问同时在路上。这叫内存级并行(memory-level parallelism, MLP):每个请求的延迟没变,但很多请求重叠起来,吞吐就上去了。

由此可以把”慢”分成两种:

  1. 依赖造成的慢:下一步要等上一步(指针追逐、链表、树的遍历、LLM 的 decode)。只能靠缩短延迟。
  2. 单个请求慢、但请求之间独立:可以靠同时发很多请求来掩盖。GPU 把这种做到了极致(P2 §4、§7)。

3.3 顺序带宽:几乎是平的

最后反复复制一个大小为 WW 的数组(单线程):复制带宽在工作集放得进 cache 时最高约 190 GB/s,到 256 MiB(只能走内存)时约 130 GB/s——只差 1.5 倍,而延迟差了 160 倍。

差别来自可预测性:顺序访问的下一个地址完全可以预测,预取器提前很多行把数据搬进来,很多请求同时在路上,延迟被藏到了带宽后面。剩下的限制只是”每秒能搬多少字节”。(最小的工作集反而偏慢:每次 np.copyto 调用有零点几微秒的固定开销,数组太小时这部分占了大头。)

这一组对比是整门课的缩影:能变成大块、顺序、可并行的访问,就受带宽限制;变不成的,就受延迟限制。 GPU、HBM、分块、批处理,都是在把问题往前一种情况推。

3.4 虚拟内存与 TLB(简述)

程序里看到的地址是虚拟地址,操作系统把它们按页(几 KB 到几 MB 一页)映射到物理内存。每次访存都要把虚拟地址翻译成物理地址,翻译结果缓存在一个叫 TLB(translation lookaside buffer)的小 cache 里。随机访问跨越几百 MB 时,TLB 也放不下,翻译本身就要去内存里查页表——这是 §3.1 最右边几个点延迟继续上升的原因之一。第 7 讲 PagedAttention 借用的正是”分页”这个操作系统的思想(只不过是给 KV cache 分页)。


4. 延迟、带宽与 Little 定律 · Latency, bandwidth, and Little’s law

代码:04_latency_bandwidth_little.py 演示 1–2

4.1 两个不同的量

  • 延迟(latency):一个请求从发出到拿到结果要多久。单位是秒。
  • 带宽(bandwidth):单位时间里最多能传多少数据。单位是字节/秒。

用高速公路打比方:延迟是”一辆车从 A 开到 B 要多久”(取决于距离和车速),带宽是”每小时最多能有多少辆车到达 B”(取决于车道数和车速)。加车道能提高带宽,但不能缩短任何一辆车的行程。

4.2 Little 定律

排队论里有一个极其简单又极其有用的定律(Little, 1961):

L=λ×W,L = \lambda \times W ,

系统里平均”在里面的东西”的数量 LL = 到达速率 λ\lambda × 每个东西平均停留的时间 WW。它几乎不需要任何假设(只要系统是稳定的)。

放到内存上:要维持带宽 β\beta,每个请求要等延迟 ℓ\ell 才回来,那么任意时刻必须有

在途字节=β×ℓ\text{在途字节} = \beta \times \ell

的请求已经发出、还没回来。发得不够多,带宽就上不去——不管内存本身有多快。 §1.6 的”需要的累加器个数 = 延迟 × 吞吐”、§3.2 的”约 30 个访问同时在路上”,都是这个定律。

代码 04 演示 1(延迟都是数量级假设,见脚本注释):

系统带宽延迟(假设)需要在途的字节折合 128 字节的请求数
一个 CPU 核(单线程复制)130 GB/s120 ns15.6 KB122
一块 H100 的 HBM3.35 TB/s500 ns1.68 MB13,086
 其中每个 SM(÷ 132)25.4 GB/s500 ns12.7 KB99
一块 Blackwell Ultra 的 HBM8 TB/s500 ns4.0 MB31,250

4.3 实测:线程越多,在途请求越多

一个 CPU 核能同时追踪的缺失请求数是有上限的。代码 04 演示 2 用多个线程分段复制一个大数组(numpy 复制大数组时会释放 Python 的全局解释器锁 GIL,所以多线程是真并行):

线程数复制带宽
1约 133 GB/s
2约 200 GB/s
4约 239 GB/s
8–12约 230–236 GB/s(饱和)

线程越多,同时在路上的请求越多,带宽越高,直到内存系统本身的上限。单个核拿不满整个芯片的内存带宽,在 CPU 上很常见。

4.4 GPU 为什么需要成千上万个线程

回到 §4.2 的表:一块 H100 要跑满 HBM 带宽,同时要有约 1.7 MB 的请求在路上,摊到每个 SM 约 99 个 128 字节的请求。GPU 的一个 warp(32 个线程一组,P2 §4)一次读 128 字节(每个线程 4 字节);而一个 SM 最多只能同时驻留 64 个 warp。所以要么每个 warp 一次发好几个读请求,要么每个线程一次读 16 字节(“向量化读取”)。

GPU 不靠单个核的”聪明”(乱序执行、预取)去凑出这么多在途请求,而是靠海量线程:一个 warp 在等数据,就切换到另一个 warp 去执行,切换几乎不花时间(P2 §4.4)。这就是 GPU”吞吐导向”设计的核心——下一讲的主题。

而且趋势在加剧:带宽一代比一代高,延迟基本不变,需要的在途量越来越大(Blackwell Ultra 约 4 MB)。“只涨带宽、不降延迟”对软件的并行度要求越来越高。


5. 内存技术:SRAM、DRAM、HBM · Memory technologies

代码:04_latency_bandwidth_little.py 演示 3–5

5.1 SRAM:快、近、贵

SRAM(static RAM)每个比特用 6 个晶体管组成一个双稳态电路,只要通电就一直保持,读取约 1 ns、不破坏数据。它和计算电路用同一种工艺做在同一块芯片上,所以可以紧挨着运算单元。寄存器、各级 cache、GPU 的共享内存、TPU 的 VMEM、Groq/Cerebras 的片上存储都是 SRAM。

缺点是大:6 个晶体管一个比特,密度远低于 DRAM;而且芯片面积给了 SRAM 就不能给运算单元(零和)。

5.2 DRAM:密、慢、要刷新

DRAM(dynamic RAM)每个比特只用 1 个晶体管加 1 个电容:电容里有电荷是 1,没有是 0。密度高得多,但:

  • 电容会漏电,必须定期刷新(重新写一遍);
  • 读取会破坏电荷,读完要写回;
  • 需要专门的工艺,只能做在计算芯片外面,信号要走更长的路。

DRAM 内部按行组织:读一个数时,先把它所在的一整行(几 KB)读到这个 bank 的行缓冲(row buffer)里,再从行缓冲取出需要的几个字节。下一次访问如果在同一行(row hit)就很快;换一行(row miss)就要先关旧行、再开新行,慢好几倍也更耗电。代码 04 演示 5 的玩具模型(8 KB 行、16 个 bank):顺序读的行命中率 99.2%,跨 4 KB 读 50%,1 GB 范围内随机读 0%。DRAM 自己也”喜欢”顺序访问——cache line、预取器、行缓冲,层层都在奖励局部性。

5.3 DDR、LPDDR、HBM:带宽 = 位宽 × 速率

内存的带宽由一个简单的乘法决定:

带宽=接口位宽(引脚数)×每根引脚每秒传几比特÷8.\text{带宽} = \text{接口位宽(引脚数)} \times \text{每根引脚每秒传几比特} \div 8 .
  • DDR(电脑和服务器的内存条):一条通道 64 位宽,DDR5-6400 每根引脚 6.4 Gb/s → 51.2 GB/s。
  • LPDDR(手机、笔记本的低功耗内存):带宽和 DDR 相近,功耗低得多。Apple M 系列的”统一内存”就是 LPDDR。
  • HBM(high bandwidth memory,高带宽内存):把多片 DRAM 芯片垂直堆叠起来(8、12、16 层),通过硅通孔连在一起,再和计算芯片一起放在同一个封装里、用硅中介层(interposer)连接。因为距离极短,可以拉出 1024 根(HBM4 是 2048 根)数据线。

Ma & Patterson(2026)表 1 的各代 HBM(代码 04 演示 3):

代际年份每引脚 Gb/s引脚数每堆栈带宽每堆栈最大容量
HBM20131.01024128 GB/s4 GiB
HBM220162.41024307 GB/s8 GiB
HBM320226.41024819 GB/s24 GiB
HBM3E20239.810241,254 GB/s48 GiB
HBM420268.020482,048 GB/s64 GiB

一块 GPU 的总带宽 = 堆栈数 × 每堆栈带宽。从公开的总带宽和堆栈数反推每根引脚的速率:H100(5 个 HBM3 堆栈)约 5.2 Gb/s,低于 HBM3 的上限;Blackwell Ultra(8 个 HBM3E 堆栈)约 7.8 Gb/s;Rubin 的 22 TB/s(8 个 HBM4 堆栈、每个 2048 根引脚)反推出约 10.7 Gb/s,高于表里 HBM4 的 8 Gb/s——表里是 JEDEC 标准的数字,Rubin 用的是速率更高的 HBM4。

HBM 的秘诀是”宽”而不是”快”:每根引脚的速率和 DDR 差不多,引脚数却多了十几倍。代价是贵:堆叠和封装都很难,Ma & Patterson 指出 2023–2025 年 HBM 每 GB 和每 GB/s 的价格都上涨了约 1.35 倍,而普通 DDR 在下降。

几种内存的粗略对比(Ma & Patterson 表 3,单个模块 / 堆栈):

类型容量带宽功耗GB/s 每瓦GB 每瓦
HBM4-6400 堆栈48 GB1,638 GB/s40 W411.2
DDR5-6400 模块64 GB51 GB/s12 W45.3
LPDDR5-6400 模块16 GB51 GB/s3 W175.3
HBF 堆栈(高带宽闪存,研究方向)512 GB1,638 GB/s(读)80 W206.4

按每瓦带宽,HBM 比 DDR5 高约 10 倍;按每瓦容量,DDR5 反而更好。没有”最好”的内存,只有”适合哪种数据”:频繁读的权重和 KV cache 放 HBM,大而冷的数据放 DDR 或闪存。第 7 讲的 Mooncake 就是把 KV cache 分层放在 HBM、CPU 内存和 SSD 上。

5.4 SRAM 的面积账

第 1 讲 §4.7 说 SRAM 芯片”快但小”。小到什么程度?用 Gimlet 博客给出的各芯片 SRAM 密度,算一算把 Llama-3.1-70B 的 FP8 权重(70.55 GB)全放进 SRAM 要多大面积(代码 04 演示 4):

芯片工艺SRAM 密度放下 70B 需要的面积≈ 几个光罩极限(858 mm²)大小的芯片
B200 的 L2 cache4 nm0.08 MB/mm²约 90 万 mm²1,044
Groq LPU v114 nm0.32 MB/mm²约 22 万 mm²259
d-Matrix Corsair6 nm0.64 MB/mm²约 11 万 mm²128
Cerebras WSE-3(整片晶圆)5 nm0.95 MB/mm²约 7.4 万 mm²86

即使是 SRAM 密度最高的整片晶圆,也要约 1.6 片晶圆的面积。而一个 12 层的 HBM3E 堆栈就有 36 GB。这就是为什么”把模型全放进 SRAM”的芯片需要很多颗一起用,而 GPU 用 HBM 放全部权重、只在片上放”正在算的那一小块”。Ma & Patterson 也指出:Cerebras 和 Groq 十年前用满片 SRAM 是合理的,但模型很快超过了片上容量,两家后来都加了外部 DRAM。


6. 并行与功耗 · Parallelism and power

代码:05_parallelism_and_power.py

6.1 三种并行

Dennard scaling 结束以后(第 1 讲 §2.3),单核频率不再上涨,性能只能靠并行。并行有三个层次:

  1. 指令级并行(ILP):一个核里同时有多条独立的指令在执行(流水线、超标量、乱序执行;§1)。
  2. 数据级并行(DLP):一条指令同时处理多个数据——SIMD(single instruction, multiple data)。一个 128 位的 SIMD 寄存器能装 4 个 float32 或 16 个 int8,一条加法指令就同时做 4 次或 16 次加法。
  3. 线程级并行(TLP):多个核(或多个线程)各自执行不同的指令流。

GPU 把第 2、3 种做到了极致,并且用一种叫 SIMT 的方式把它们合在了一起(P2)。

6.2 峰值算力的分解

任何处理器的峰值算力都可以拆成一个乘法(代码 05 演示 1):

峰值=核(SM)数×每核的运算单元数×每单元每周期的运算数×频率.\text{峰值} = \text{核(SM)数} \times \text{每核的运算单元数} \times \text{每单元每周期的运算数} \times \text{频率} .
处理器并行单元每单元每周期 FLOP频率峰值
假想 CPU(8 核,SIMD FP32)8 核 × 4 条流水线 × 4 lane2(乘加)4.0 GHz1.0 TFLOP/s
H100 CUDA core(FP32)132 SM × 128 单元21.98 GHz66.9 TFLOP/s
H100 Tensor Core(BF16)132 SM × 4 个≈1024反推约 1.83 GHz989 TFLOP/s

(假想 CPU 的参数是有代表性的假设;H100 的 SM 结构来自 CUDA 编程指南,“每个 Tensor Core 每周期约 1024 FLOP”是 How to Scale Your Model 第 12 章的估计,NVIDIA 没有公开这个数。)

H100 的频率只有 CPU 的一半,FP32 峰值却高出 65 倍——靠的是 1.7 万个并行的 lane;Tensor Core 再高 15 倍——每个单元每周期做的不是一次乘加,而是一个小矩阵乘法。

6.3 SIMD 与数据类型

对 5 种数据类型做逐元素加法(代码 05 演示 2,实测):

类型字节在 cache 里:每元素在内存里:每元素
float648约 0.12 ns约 0.14 ns
float324约 0.085 ns约 0.073 ns
int81约 0.035 ns约 0.018–0.02 ns
float162约 1.7 ns约 1.7 ns

三个结论:

  1. 数据在内存里时,时间只看字节数:int8 比 float32 快约 3.4–4 倍,接近字节数之比。这就是低精度在 memory-bound 阶段(decode)最直接的好处。
  2. 数据在 cache 里时,类型越窄,一条 SIMD 指令处理的元素越多:int8 比 float32 快约 2.4 倍。
  3. float16 反而慢 20 倍:numpy 在这台机器上没有用原生的半精度算术,每个元素都要先转换成 float32 再转回来。数值格式再小,硬件和软件不支持就没用——第 3 讲的 FP8、NVFP4 能加速,前提是 Tensor Core 原生支持它们。

6.4 多核与 Amdahl 定律

多核能加速的前提,是瓶颈在”核”上。代码 05 演示 3 用 kk 个线程分段处理一个大数组:

线程数exp(计算密集)加速比复制(带宽密集)加速比
2约 2.0×约 1.3–1.4×
4约 3.9×约 1.6–1.9×
8约 5–6×约 1.8–1.9×
12约 6×约 1.8–1.9×

(这两列在不同运行之间波动明显,8 线程的 exp 在几次运行里从 5.0 到 5.9 倍不等。)

  • 计算密集的任务,4 个线程以内接近线性。之后变差的原因这里没有拆开测,可能是多核同时满载时频率降低、8 个性能核分属两个簇、线程被调度到较慢的能效核上、线程池本身的开销——真实系统里”加核不线性”几乎总是好几个原因同时存在。
  • 带宽密集的复制,加到 4 个线程以后就不再变快:瓶颈是共享的内存系统,不是核。这和 decode 在 GPU 上的处境一样:加算力没用,要加带宽。

Amdahl 定律:如果一个任务里比例为 ss 的部分只能串行,其余部分完美并行到 nn 份,加速比是

S(n)=1s+(1−s)/n  ≤  1s.S(n) = \frac{1}{s + (1 - s)/n} \;\le\; \frac{1}{s} .

哪怕只有 5% 不能并行,12 个核最多 7.7 倍,无穷多个核最多 20 倍。在 GPU 的尺度上(上万个 lane),这个定律更加残酷:只要有千分之一的串行部分,16,896 个 lane 最多只有约 944 倍。P2 §8 会把它和”内核启动开销”联系起来。

6.5 功耗:为什么”又宽又慢”更省电

第 1 讲 §2.2 的动态功耗公式 P=CV2fP = C V^2 f 还有后半段故事:频率越高,需要的电压也越高(晶体管要在更短的时间里翻转完成,需要更大的电流)。用一个示意的线性关系(1 GHz 时 0.6 V,4 GHz 时 1.1 V;代码 05 演示 4):

频率电压相对功耗 V2fV^2 f每次运算的相对能量 V2V^2
1.0 GHz0.60 V0.360.36
2.0 GHz0.77 V1.180.59
4.0 GHz1.10 V4.841.21

频率 ×4,功耗 ×13:每次运算的能量随电压的平方上涨。反过来,同样的吞吐用更多、更慢的核来做:

方案总相对功耗
1 个核 × 4 GHz100%
2 个核 × 2 GHz49%
4 个核 × 1 GHz30%

**前提是任务能并行。**这就是 GPU 的设计哲学:上万个运算单元、跑在 2 GHz 左右,而不是几个跑在 5 GHz 的复杂核。LLM 的矩阵乘法有海量的并行度,正好吃得下这种设计。它也是第 1 讲”性能 = 能效 × 功率”的另一面:功耗预算固定时,降低电压和频率、换来更多并行单元,是提高总性能的主要手段。

(这个模型只算了动态功耗。漏电(静态功耗)很大时,“跑快一点然后关掉”有时反而更省,叫 race to idle。)

6.6 异构核

手机和笔记本芯片里常有两种核:性能核(大、快、耗电)和能效核(小、慢、省电)。操作系统把要紧的任务放到性能核上,后台任务放到能效核上。§6.4 的实测里,线程数超过性能核数之后,有些线程就跑到了能效核上。这是”异构”在一块芯片内部的版本;第 1 讲 §6 讲的是它在数据中心尺度上的版本——不同的阶段放到不同种类的芯片上。


7. 把这些放在一起:两种设计取舍 · Two design philosophies

到这里,可以把 CPU 和 GPU 看成对同一组物理限制做出的两种不同回答:

问题CPU 的回答(延迟导向)GPU 的回答(吞吐导向)
内存慢(几百个周期)大 cache + 预取,尽量让每次访问命中海量线程:一个在等,就换另一个
依赖和分支造成停顿分支预测、乱序执行,让一个线程尽快跑完不费力减少停顿,靠别的 warp 填补
指令开销每条指令做一点事,靠复杂的控制提速一条指令驱动 32 个线程(SIMT),Tensor Core 一条指令做一个矩阵乘法
功耗少量高频核(4–5 GHz)大量低频单元(约 2 GHz)
芯片面积花在哪控制逻辑和 cache运算单元和寄存器
擅长串行、分支多、不规则的程序数据并行、规整、海量的计算

前者让单个任务尽可能快,后者让大量任务的总产出尽可能高。LLM 推理(尤其是批处理之后)是典型的后者。下一讲(P2)专门讲 GPU 怎样执行程序、程序员要怎样写才能用好它。


8. 小结 · Summary

  1. 处理器执行指令:时间 = 指令数 × CPI ÷ 频率。流水线提高吞吐但不缩短延迟;数据依赖和分支会造成停顿。依赖链让硬件闲着,多个独立的累加器(ILP)能填满它——需要的并行度 = 延迟 × 吞吐。
  2. 存储层次:寄存器 → L1 → L2 → DRAM,越远越大、越慢(实测 0.75 ns → 120 ns,差约 160 倍)。cache 以行为单位、按组相联组织,靠时间局部性和空间局部性起作用。缺失分三类:强制、容量、冲突;对策分别是更大的行/预取、分块、填充/更高相联度。
  3. AMAT 把命中率换算成时间;缺失率的变化会被内存延迟放大。真实机器上顺序访问因为预取几乎不受延迟影响(顺序带宽只差 1.5 倍),随机且依赖的访问则把延迟全部暴露。
  4. 延迟 ≠ 带宽。Little 定律:在途字节 = 带宽 × 延迟。要跑满带宽,就得同时发出足够多的请求——CPU 靠乱序和预取(一个核拿不满整个芯片的带宽),GPU 靠海量线程(一块 H100 要约 1.7 MB 在途)。
  5. 内存技术:SRAM 快、近、密度低;DRAM 密、慢、要刷新,内部有行缓冲;HBM 靠堆叠和超宽接口换来带宽,但贵。带宽 = 引脚数 × 每引脚速率。把大模型全放进 SRAM 要上百个芯片的面积。
  6. 并行与功耗:三种并行(ILP、SIMD、多核);低精度数据类型在内存受限时按字节数加速,但需要硬件原生支持;带宽密集的任务加核没用;Amdahl 定律限制了串行部分;功耗随电压平方增长,所以”又宽又慢”更省电——这就是 GPU 的设计哲学。

9. 常见误区 · Common pitfalls

  1. “频率越高的处理器越快。” 时间 = 指令数 × CPI ÷ 频率,三项都重要;而且频率提高带来的功耗增长远快于性能增长。
  2. “流水线让每条指令变快了。” 流水线提高的是吞吐,单条指令的延迟不变(甚至略长)。
  3. “cache 越大越好。” 越大越慢、越费电(1 MB SRAM 读一次的能量是 8 KB 的 10 倍)。所以才有多级 cache。
  4. “缺失就是因为 cache 太小。” 很多缺失是冲突缺失,cache 容量明明够;2 的幂次方的跨度是常见元凶,填充一点就能解决(§2.5、§2.6)。
  5. “LRU 总是最好的替换策略。” 对循环扫描一个略大于 cache 的数组,LRU 命中率是 0,随机替换反而更好。
  6. “带宽高就意味着延迟低。” 两者是不同的量。HBM 带宽极高,延迟却比 CPU 内存还长;高带宽要靠大量请求同时在途才拿得到。
  7. “多开线程总能加速。” 只有瓶颈在”核”上时才行;带宽密集的任务加到几个线程就饱和了。
  8. “换成更小的数据类型一定更快。” 只有在硬件和软件原生支持时才行(numpy 的 float16 反而慢 20 倍)。
  9. “SRAM 比 DRAM 好,把东西都放 SRAM 就行。” SRAM 密度低一个数量级以上,大模型放不下;没有”最好”的存储,只有适合哪种数据的存储。

10. 练习 · Exercises

带 💻 的题可以用本讲的模块验证。参考答案在每题后面。

练习 1(性能方程)。一个程序有 10910^9 条指令,CPI = 0.5,频率 4 GHz,要跑多久?如果把 CPI 降到 0.4 和把频率提到 5 GHz,哪个更快?

参考答案

109×0.5/(4×109)=0.12510^9 \times 0.5 / (4 \times 10^9) = 0.125 s。CPI 0.4:0.1 s;频率 5 GHz:0.1 s。一样快——但按 §6.5,提频率的功耗代价要大得多。

练习 2(流水线与停顿)💻。一个 10 级流水线执行 10610^6 条指令,其中 10% 是分支,分支预测失败率 20%,每次失败损失 15 个周期。总共多少周期?CPI 是多少?

参考答案

停顿 =106×0.1×0.2×15=300,000= 10^6 \times 0.1 \times 0.2 \times 15 = 300{,}000 周期。pipeline_cycles(10**6, 10, 300000) → 1,300,009 周期,CPI ≈ 1.30。分支预测的准确率直接决定了 CPI。

练习 3(地址映射)💻。一个 64 KiB、128 字节行、4 路组相联的 cache 有多少组?地址 0x12345 落在哪一行、哪一组、行内偏移多少?

参考答案

组数 =65536/(128×4)=128= 65536 / (128 \times 4) = 128。0x12345 = 74,565;行号 =⌊74565/128⌋=582= \lfloor 74565 / 128 \rfloor = 582;组号 =582 mod 128=70= 582 \bmod 128 = 70;行内偏移 =74565 mod 128=69= 74565 \bmod 128 = 69。

练习 4(空间局部性)。cache line 128 字节,顺序访问 float64 数组的命中率是多少?顺序访问 int8 数组呢?

参考答案

1−8/128=93.75%1 - 8/128 = 93.75\%;1−1/128≈99.2%1 - 1/128 \approx 99.2\%。(前提是没有预取;有预取时连”每行第一次”的缺失也可能被提前藏掉。)

练习 5(3C 分析)💻。512×512 的 float32 矩阵按列求和,cache 是 32 KiB、64 字节行、8 路。预测命中率和缺失类型;再把行距改成 528 个元素(每行填充 64 字节)。用 Cache 和 matrix_trace(512, 512, "col", ld=...) 验证。

参考答案

行距 2 KiB(2 的幂),一列的 512 个元素只落在 2 个组里(2048/64=322048/64 = 32,32⋅i mod 6432 \cdot i \bmod 64 只有 0 和 32 两个值),每组 8 路远远不够:命中率 0%,全是冲突缺失(245,760 次)加 16,384 次强制缺失。填充后一列的 512 行均匀分布到 64 组 × 8 路 = 512 个位置,正好放下,下一列全部命中:93.75%,没有冲突缺失。

练习 6(AMAT)。L1 1 ns、缺失率 3%;L2 10 ns、局部缺失率 30%;内存 100 ns。AMAT 是多少?如果 L2 的局部缺失率变成 60% 呢?

参考答案

1+0.03×(10+0.3×100)=2.21 + 0.03 \times (10 + 0.3 \times 100) = 2.2 ns。60% 时 1+0.03×(10+60)=3.11 + 0.03 \times (10 + 60) = 3.1 ns。

练习 7(Little 定律)。要从延迟 100 ns 的内存拿到 100 GB/s,需要多少字节在途?如果一个核最多同时追踪 48 个 64 字节的缺失请求,它最多能拿到多少带宽?

参考答案

100×109×100×10−9=10,000100 \times 10^9 \times 100 \times 10^{-9} = 10{,}000 字节,约 157 个 64 字节的请求。48 个请求:48×64/100 ns≈30.748 \times 64 / 100\text{ ns} \approx 30.7 GB/s。所以单核拿不满整个内存系统的带宽,需要多核(或者更多的在途请求)。

练习 8(HBM 带宽)。一块 GPU 有 8 个 HBM4 堆栈,每个 2048 根引脚,每根 8 Gb/s。总带宽是多少?Blackwell Ultra 用 8 个 HBM3E 堆栈得到 8 TB/s,每根引脚跑多快?

参考答案

8×2048×8/8=16,3848 \times 2048 \times 8 / 8 = 16{,}384 GB/s ≈ 16.4 TB/s。Blackwell Ultra:8×1012×8/(8×1024)≈7.88 \times 10^{12} \times 8 / (8 \times 1024) \approx 7.8 Gb/s。

练习 9(功耗)💻。用 05_parallelism_and_power.py 的电压模型,把频率从 4 GHz 降到 3 GHz:吞吐降到多少?功耗降到多少?每次运算的能量降到多少?

参考答案

吞吐 75%;功耗 2.61/4.84≈54%2.61 / 4.84 \approx 54\%;每次运算的能量 0.871/1.21≈72%0.871 / 1.21 \approx 72\%。少 25% 的性能换来省 46% 的功耗——这就是数据中心常把 GPU 锁在比最高频率低一点的地方运行的原因之一。

练习 10(Amdahl)。一个程序在 GPU 上有 0.1% 的部分只能串行(比如每一步要回到 CPU 做一点调度),其余部分完美并行到 H100 的 16,896 个 FP32 lane。最多加速多少倍?

参考答案

1/(0.001+0.999/16896)≈9441 / (0.001 + 0.999 / 16896) \approx 944 倍,只有 lane 数的 5.6%。要让大规模并行硬件发挥作用,串行部分必须小到万分之一以下——这是 P2 §8 讨论”内核启动开销”的动机。

练习 11(思考题)。§3 测到:随机访问一大片内存,有依赖时每次 121 ns,没有依赖时平均每次 4.1 ns。下面三种程序各更接近哪一种?(a) 遍历一个链表;(b) 根据一个下标数组从大表里取数(推荐系统的嵌入查找);(c) LLM decode 时生成一个 token 序列。

参考答案

(a) 有依赖:每个节点的地址存在上一个节点里,接近 121 ns/次。(b) 没有依赖:所有下标事先知道,可以同时发出,接近 4 ns/次(GPU 上还能更好)。(c) token 之间有依赖(第 t+1t+1 个要等第 tt 个),但每一步内部是大量独立的访问(读全部权重、读 KV cache)——所以每一步受带宽限制,步与步之间受延迟限制。这正是 decode 的双重困境:每一步要把带宽用满,步数又压缩不了。


11. 延伸阅读 · Further reading

  • J. Hennessy and D. Patterson, Computer Architecture: A Quantitative Approach(第 6 版,2017)。第 1 章(性能与功耗)、第 2 章(存储层次)、第 3 章(指令级并行)、第 4 章(数据级并行:SIMD 与 GPU)。入门可以先读同作者的 Computer Organization and Design。
  • U. Drepper, What Every Programmer Should Know About Memory(2007)。DRAM、cache、TLB 的工作原理和对程序员的影响,讲得非常细。
  • M. Horowitz, Computing’s Energy Problem (and what we can do about it), ISSCC 2014。(第 1 讲 §2)
  • J. Little, A Proof for the Queuing Formula: L = λW, Operations Research, 1961。
  • X. Ma and D. Patterson, Challenges and Research Directions for Large Language Model Inference Hardware, IEEE Computer, 2026。HBM 的代际数据、内存价格趋势、HBF。
  • Gimlet Labs, The emerging role of SRAM-centric chips in AI inference(2026)。SRAM 与 HBM 的物理差异、各芯片的 SRAM 密度。
  • 本仓库 CS349D 前置讲 P3(操作系统基础):虚拟内存、分页、缓存淘汰策略的更多细节(和第 7 讲 PagedAttention 直接相关)。

12. 和正课的联系 · Where this shows up

  • 第 1 讲:§2 的能量表就是本讲 §2–§5 的”能量版”:越远的存储越贵;§3 的 roofline 是本讲 §4”带宽”概念的应用;§4.7 的”主存读遍/秒”和近存 vs 远存来自 §5。
  • P2(GPU 编程模型):本讲 §4.4(海量线程隐藏延迟)、§2.5(跨度与合并访存)、§2.5 的冲突缺失(共享内存 bank 冲突)、§6.4(Amdahl 与启动开销)都会在 GPU 上重演。
  • 第 7 讲(KV cache 管理):分页(§3.4)、替换策略(§2.5)、分层存储(§5.3)。
  • 第 8 讲(硬件引擎):GPU 的 SM、TPU 的脉动阵列、存储层次的具体实现。
  • 第 9 讲(硬件性能建模):把”每一级存储被访问了多少次 × 每次多少能量”系统化,用循环分块(§2.6)去最小化它。
  • 第 10–11 讲(内核):分块、预取(双缓冲)、Little 定律(每个 SM 要多少在途请求)都会写进代码里。
← 返回课程目录