这页是随时翻的总图,不是一篇文章。三部分按“在哪 → 多大多快 → 怎么合起来算”排:
- 存储层次:硬件地图,一次读写经过哪些框,每个框在哪篇文章里讲过。
- 性能参数:每个框的延迟、带宽、容量,同时能挂几个请求(MLP),以及 CPU 自己想吃多快(带宽需求)。都是帮助理解的量级,不对应哪台机器。
- 估算模型:用一个式子把第 2 部分的数串起来,判断一段代码慢在“搬得多”还是“搬得慢”。
后面讲到新的硬件层,都补在这一页。往上一层,一条行情经过哪些线程,见交易系统架构。
1. 存储层次(memory hierarchy):一次访存经过哪些框
图里的蓝字是“在哪里讲过”:#N 是交易系统笔记 #N,MESI 是专题,“速查 N.N”是本页的小节。点图打开可交互的版本:点一个框看它是什么、在哪篇讲过,选一条路径(load、store、分支猜错)一步一步看怎么走,蓝字直接链到文章。
1.1 读(load):地址翻译、查缓存、行填充
图里的延迟是量级,见 2.1。
第 2 步里的 LFB(line fill buffer,行填充缓冲)是 L1D 记“哪些缺失还没回来”的一张小表:每个 L1D 缺失占一项,整条行填回来才释放。它有几项,一个核自己的 load 就最多同时挂几个缺失(2.3)。同一类结构,AMD 叫 MAB(miss address buffer),论文和教科书里统称 MSHR(miss status holding register)。
1.2 写(store):store buffer 与 RFO
写先进 store buffer,后面的指令不用等它写完。真正写进 L1D 之前,这个核要拿到这条行的独占权:别的核手里的副本都要作废(MESI),别的核收到作废通知,先排进自己的 invalidate queue。要写的行不在缓存里,还得先把整条行读回来再改,这叫 RFO(read for ownership)。所以一次写缺失也要从内存搬一整条行。改过的行被踢出时还要写回,普通写一条不在缓存里的行,总线上走两趟。流式写(non-temporal store)不读回、不进缓存,凑满 64 B 直接写到内存,只走一趟,适合写一大片、短期内没人读的数据。
图里是一次写在两个核之间走一遍:invalidate 请求经片上互连,只发给缓存里有这条行的核,谁有由 L3 旁的 snoop filter 记着;新值不会推过去,别的核要用时自己来取。对方把失效排进 invalidate queue 就回 ACK,所以它可能先看到后写的 ready、再读到旧的 data。这种读和读的乱序 x86 不允许,ARM 允许,ARM 上的 acquire 因此要用真正的指令(2.1,#2)。
1.3 文章索引:每个框在哪篇讲过
| 框 | 文章 |
|---|---|
| 核、SMT、NUMA、核上的中断 | #1 CPU 隔离与 NUMA |
| store buffer、invalidate queue、缓存行与伪共享 | #2 内存序与缓存行 |
| 流水线与依赖链、累加器个数、寄存器溢出 | #9 循环优化 |
| 缓存一致性 | MESI 专题 |
| 锁住内存、TLB shootdown | #3 内存池与分配器 |
| 多个核读写同一条行 | #4 无锁队列 · #5 SPMC 广播环 · #16 自旋锁 |
| 缺页 | #5 SPMC 广播环 |
| 对齐、布局、组相联、内存条内部、刷新、多通道 | #6 对齐、布局与几何 |
| 分支预测器、取指与推测执行 | #7 分支预测 |
| 执行单元:ALU、乘法器、除法器、SIMD 向量单元 | #12 强度削弱与算术优化 · #15 SIMD 向量化 |
| 硬件预取、软件预取、缓存预热 | #14 预取、预热与双缓冲 |
| 性能计数器(PMU)和采样:时间花在哪个函数、哪条指令 | perf 专题 |
| 框与框之间的路:延迟、带宽、ROB、LFB | 本页第 2、3 部分 |
2. 性能参数:每个框多大、多快
这里的数都是帮助理解的量级,不对应哪台机器。周期和纳秒按约 4 GHz 换算:1 ns ≈ 4 个周期。
2.1 延迟:一次要等多久
| 类别 | 一次…… | 大约多久 |
|---|---|---|
| 取数据 | 读寄存器 | 0–1 周期 |
| L1D 命中 | 约 1 ns(4–5 周期) | |
| L2 命中 | 约 3–5 ns | |
| L3 命中 | 约 10–30 ns | |
| 要的行在另一个核的缓存里 | 约 20–100 ns,伪共享也是这一笔(见 #2) | |
| 内存,地址翻译在 TLB 里命中 | 约 70–90 ns | |
| 内存,地址翻译也没命中 TLB | 约 85–120 ns | |
| 另一个插槽的内存(NUMA 远端) | 约 120–200 ns | |
| 碰上 DRAM 刷新 | 约 300–500 ns,最坏情况下多等这么久 | |
| 原子 | 原子读(任何内存序)、relaxed 或 release 写 | 约 1 ns(4–5 周期),和普通读写一样:x86 上就是 mov,ARM 上带顺序的换成 ldar、stlr,本身也不慢 |
| seq_cst 写 | 约 1–5 ns(4–20 周期):x86 上编成 xchg,和读改写一样贵;ARM 上是 stlr,和 release 写一样 | |
| acquire、release 屏障 | 0–6 ns:x86 上不生成指令,ARM 上是一条 dmb | |
无竞争的原子读改写:fetch_add、CAS、exchange(行在自己的 L1 里) | 约 2–6 ns(8–25 周期):x86 上是带 lock 的指令,ARM 上是 ldaddal、casal 这类 | |
| seq_cst 屏障 | 约 2–6 ns(8–25 周期):x86 上 GCC 编成 lock or,ARM 上是 dmb ish | |
x86 的 sfence、lfence、mfence | 1–6、4–15、30–80 周期,lfence 不算等前面指令的时间 | |
自旋等待里的一条 pause | 约 140 周期(约 35 ns),现在交易服务器上按这个估;Skylake 之前的 Intel 只有约 10 周期,所以自旋预算按时间算,不按次数;ARM 没有对应的指令,常用 yield(很多核上等于空操作)或 isb(见 #16) | |
| 无竞争的 mutex 加锁再解锁 | 约 10–25 ns:两次原子读改写;有竞争、要去睡时,见“系统”组的 futex 那行 | |
| 有竞争的原子读改写(行在别的核那里,要先抢过来) | 约 20–100 ns,抢的核越多越慢;伪共享的计数器也是这一笔 | |
| 运算 | 整数加减、位运算、移位 | 1 周期 |
| 整数乘法 | 3 周期 | |
| 浮点加减、乘法 | 3–5 周期 | |
| 浮点除法、开方 | 约 3–5 ns(10–20 周期) | |
| 整数除法、取余 | 约 3–20 ns(10–90 周期) | |
| 分支 | 猜对的分支 | 0–1 周期 |
条件传送(x86 的 cmov、ARM 的 csel) | 1 周期,但要等条件和两个值都算好 | |
| 没内联的函数调用(直接调用) | 约 1–2 ns(几个周期):call、ret 加保存寄存器;真正的代价是没内联,编译器没法跨调用优化 | |
| 一次分支猜错(冲刷流水线,见 #7) | 约 4–5 ns(15–20 周期) | |
| 虚函数调用 | 猜对时约 1–2 ns,和直接调用差不多;猜错再加一次分支猜错,vtable 不在缓存里再加一次取数据(见 虚函数) | |
| 系统 | malloc 加 free 一对(小块) | 约 10–50 ns:走线程自己的缓存;缓存里没有、要向系统要内存时进内核,到 µs 级 |
| 最简单的系统调用 | 约 0.1–0.5 µs | |
| 第一次写一页(缺页) | 约 1–2 µs | |
| 抛出并捕获一个异常 | 约 1–2 µs,栈越深越贵 | |
| 线程切换 | 约 1–5 µs,再加缓存被冲掉的代价 | |
| 抢锁失败去睡、再被叫醒(futex:从放锁到它真正跑起来) | 约 2–10 µs:两次系统调用加两次线程切换,等的核进了深的省电状态还要更久(见 #16) | |
| NVMe SSD 读 4 KiB | 10–100 µs |
内存那两行差在第一步,也就是 1.1 图里的地址翻译:TLB 里没有这一页的翻译时,硬件要先按页表一级一级查出物理地址(page walk),才能发真正的读。页表项多半在缓存里,所以只多约 15–30 ns。随机读一大片内存,几乎每次都要付这一份。换成 2 MiB 的大页,一个 TLB 项管的范围大 512 倍,这一份基本就没了。
TLB 能盖住多少,看 $\text{TLB 项数} \times \text{页大小}$:L1 dTLB 64–96 项、STLB 1500–3000 项,4 KiB 页时只盖约 6–12 MiB,2 MiB 页时盖几 GiB。另一个插槽的内存那一行,物理页落在哪个节点按首次访问(first-touch)定:谁第一次写这一页,就从谁所在的节点分配,之后不跟着线程走。只把线程绑到另一个节点,内存还留在原地。
原子操作贵在要等什么。x86 本来只允许一种乱序:后面的读越过前面还在 store buffer 里的写(#2),所以普通读写已经带 acquire、release 的语义:原子读和 relaxed、release 写就是普通 mov,acquire、release 屏障只拦编译器重排、不生成指令。ARM 允许的乱序多得多,读和读、写和写都可能换顺序,这份顺序要用指令换:带顺序的读写 ldar、stlr 本身不慢,只是前后的读写不能越过它们;屏障 dmb 要等前面的读写做完,所以 acquire、release 屏障那一行在 ARM 上不是 0。seq_cst 还要禁掉写后读:x86 只能把写换成带 lock 的 xchg;ARM 的 stlr 和后面的 ldar 本来就不会互相越过,seq_cst 写照样用 stlr,代价挪到了后面那次 ldar,要等它写完。
读改写要把行锁在自己的 L1 里做完读、改、写,还要先等 store buffer 里前面的写都进缓存,x86 带 lock 的指令和 ARM 的 ldaddal、casal 都是这样,所以前面压着还没写进去的写时,比表里更久。x86 那几个单独的屏障也不搬数据,花的同样是等:lfence 等前面的指令都执行完,前面有一次缓存未命中就要等它回来;sfence 只管写和写之间的顺序,普通写本来不乱,它主要配合流式写;手写的 mfence 还要给流式写这类操作排顺序,比 lock 指令重,GCC 编 seq_cst 屏障用的是 lock or。
有竞争时,贵的是先把行从别的核抢过来,和“要的行在另一个核的缓存里”是同一笔账。N 个核轮流对同一行做读改写,每个核两次之间要等其余 N−1 个核各搬一次,所以抢的核越多,每次越慢(2.6)。
整数除法那一行宽,是因为商只能一轮一轮地求,每轮只出几位:64 位要的轮数比 32 位多,近几年的芯片每轮出的位数多。乘法每个周期都能开始一条新的,除法器做不到,所以热路径上的除法尽量换成乘法或移位(见 #12)。
分支猜错和 L2 命中差不多贵,但藏不住:L2 没命中时,乱序执行还能接着做后面不相关的指令;猜错时,分支后面已经取进来的指令全部作废,要从取指重新开始,这十几个周期里后端没有正确的活可做。条件传送(cmov、csel)把分支换成按条件挑一个值,没有猜错这回事,代价是要等条件和两个值都算好:分支猜得准时,CPU 不等条件就往下做,分支反而可能更快;条件随机时,条件传送才划算(见 #7)。
从 L1 到内存,每往下一层慢 3–10 倍,一共差约 100 倍。到了微秒那一档,已经出了缓存层级,是操作系统在替你干活:一次缺页的时间,够 L1 命中 1000–2000 次。
2.2 带宽:每秒能运多少
| 数据在哪 | 一个核顺序读 | 整颗芯片 |
|---|---|---|
| L1D | 约 150–400 GB/s | $\text{核数} \times \text{单核}$ |
| L2 | 约 80–200 GB/s | $\text{核数} \times \text{单核}$ |
| L3 | 约 30–150 GB/s | 约 0.3–3 TB/s,随核数涨 |
| 内存 | 约 15–60 GB/s | 约 50–500 GB/s,看有几个通道(2.5) |
| PCIe 4.0 ×16 | — | 每个方向约 32 GB/s |
| NVMe SSD | — | 3–7 GB/s |
两点:
- 延迟差 100 倍,带宽只差 10 倍。从 L1 到内存,一个核的带宽从 150–400 GB/s 掉到 15–60 GB/s。两列不是同一个量的两种写法,中间差的是同时在路上的请求数(在途请求数,2.3)。
- 一个核拿不满整颗芯片。一个核能拿多少,看在途请求数和延迟(Little 定律,2.3);整颗芯片的峰值,看通道数。延迟越长、通道越多,一个核占的比例越小,约 10%–80% 都有。
- 核一多,每个核分到的变少。k 个核一起扫,每个核约 $\min(\text{单独跑},\ \text{芯片峰值} \times (70\%\text{–}90\%) / k)$。2 个通道的芯片 2–4 个核就扫满;8–12 个通道配 32–128 个核,全核一起扫每个核只有几 GB/s。加通道只抬后一项。
2.3 内存级并行(MLP):同时能有几个在路上
同时在路上、还没回来的缺失叫在途请求(outstanding request),能同时有几个,叫内存级并行(MLP,memory-level parallelism)。一个核能拿到多少带宽,由排队论里的 Little 定律给出:系统里平均有几个请求,等于每秒完成几个,乘以每个要待多久。
$$ \begin{aligned} \underbrace{L}_{\text{在途请求数}} &= \underbrace{\lambda}_{\text{每秒完成的请求数}} \times \underbrace{W}_{\text{延迟}} \\ \text{一个核的带宽} &= \lambda \times 64\ \text{B} = \frac{\text{在途请求数} \times 64\ \text{B}}{\text{延迟}} \end{aligned} $$一次缺失从发出到回来,沿路每一级都要占一项,最先满的那一级就是在途请求数的上限:
| 结构 | 大约几项 | 管什么 |
|---|---|---|
| ROB(reorder buffer,重排序缓冲) | 200–600 | 指令按程序顺序进来,乱序执行,再按原顺序退休。它管乱序执行能往后看多远:一个缺失卡在队头时,只有窗口里那些互不依赖的 load 能先发出去 |
| load queue | 70–200 | 每条 load 从进窗口到退休都占一项。load 很密的代码里,它可能比 ROB 先满 |
| L1D 的 LFB(1.1) | 12–24 | 一个核自己的 load 同时能挂几个 L1D 缺失 |
| L2 的 MSHR(未完成请求队列) | 30–60 | L2 预取器替你多发的请求挂在这里,不占 LFB |
| store buffer | 50–110 | 写先放这里,不等它进缓存 |
再往下是内存控制器:它把请求分给各个通道和 bank 并行去做(2.5)。
ROB 与指令窗口:一次内存缺失要 300–500 个周期。缺失的 load 一到 ROB 队头就退休不了,后面的指令只能跟着排队,前端却还在往队尾加,每周期 4–8 条,200–600 项的 ROB 约 100 个周期(20–30 ns)就满了。满了以后核就干等。所以能和这次缺失叠在一起的,只有它后面 200–600 条指令里那些互不依赖的 load,这一段叫指令窗口(instruction window)。按 ROB 500 项算:
| 代码 | 两次独立缺失之间隔多少条指令 | 窗口里有几个 | 先撞哪一级 |
|---|---|---|---|
链表 p = p->next | 下一个地址要等这次读回来 | 1 | 依赖链,ROB 再大也没用 |
s += a[idx[i]],a 很大、idx 打乱 | 约 5 条 | 约 100 | LFB |
| 每次查表之前先算约 150 条指令的哈希 | 约 150 条 | 约 3 | ROB |
同一个核、同一条内存,只改在途请求数,带宽能差 20–70 倍(按延迟 80 ns、一条 64 B 算):
| 访问方式 | 在途请求数 | 一个核的带宽 |
|---|---|---|
| 链表:下一个地址要等这次读回来 | 1 | 64 B ÷ 80 ns ≈ 0.8 GB/s |
| 地址互不依赖,但预取器帮不上(随机访问,或者步长每次都跨页:预取器不跨 4 KiB 页) | LFB 的 12–24 项 | 约 10–20 GB/s |
| 顺序读,预取器全力 | 约 30–70 个 | 约 15–60 GB/s(就是 2.2 的那一格) |
第三行的在途请求比 LFB 的项数多,多出来的是 L2 预取器在 L2 的 MSHR 里替你挂着的。
软件预取提前多少:提前一整次延迟发出,$D \approx \text{一次缺失的延迟} / \text{每处理一个元素的时间}$,例如 100 ns ÷ 5 ns = 20 个。太小照样等,太大则 LFB 不够用,或者行在 L1 里等太久被挤掉。硬件预取器通常不跨 4 KiB 页。
2.4 带宽需求与计算强度:CPU 想吃多快
内存带宽是不是瓶颈,还要看需求这一侧:假设数据零成本到手,循环每秒想吃掉多少字节,这叫带宽需求。它由计算强度(arithmetic intensity,每搬 1 个字节要做几次运算)决定:算得越少,吃得越快。需求和 2.2 的供给取小的那个,就是 roofline 模型。一个核、约 4 GHz:
| 循环 | 每周期吃多少 | 每秒 | 和内存比 |
|---|---|---|---|
float 求和,一个累加器,没开 -ffast-math | 每 3–4 周期 4 B:每次加法都要等上一次的结果 | 约 4–5 GB/s | 比一个核的内存带宽还低:卡在加法依赖链上,不在内存 |
int 求和,一个累加器,没有向量化 | 每周期 4 B | 16 GB/s | 只有内存那一级勉强追平 |
| 向量化(SIMD)+ 多个累加器(每周期 2 次 32 B 读) | 每周期 64 B | 约 256 GB/s | 出了 L1 就受带宽限制(memory-bound) |
| L1 读端口的上限(每周期 2–3 次读,每次 32–64 B) | 每周期 64–128 B | 250–500 GB/s | — |
| 每个元素要做 100 次以上运算 | 不到 1 B | — | 几乎永远受计算限制(compute-bound) |
表里的两个词:
- 向量化(vectorization,也叫 SIMD,single instruction multiple data,单指令多数据):一条指令同时处理一排数。AVX2 的向量寄存器宽 256 位(32 B),装得下 8 个
int或 8 个float,一条加法指令做 8 次加法。标量循环一条指令只做 1 次。一次向量读 32 B,正好装满一个寄存器,多数核每周期能发 2 次读,就是表里的“每周期 2 次 32 B 读”。向量化可以交给编译器(-O3,再用-mavx2或-march=native放开 AVX2,否则默认只用 16 B 宽的 SSE),也可以手写 intrinsics(编译器内置函数,如_mm256_add_epi32)。 - 多个累加器(multiple accumulators):
s += a[i]每一步都要等上一步的结果,这条链叫依赖链(dependency chain)。只有一个累加器时,加法一次接一次地做,读端口喂不满。把s拆成几个互不依赖的累加器,最后再合并,就有几次加法同时在执行单元里进行,这叫指令级并行(ILP,instruction-level parallelism)。要拆几个,用的是 2.3 的 Little 定律:$\text{在途加法数} = \text{每周期完成的加法数} \times \text{加法延迟}$。整数向量加法延迟约 1 周期,2 × 1 = 2 个就够。float加法延迟 3–4 周期,要 2 × 3–4 = 6–8 个。编译器会不会替你做,见下表。
GCC 对这两件事的实际处理:
| 求和 | 向量化 | 拆成多个累加器 |
|---|---|---|
int | 自己做(开 -O3) | 不做,只用 1 个 |
float,没开 -ffast-math | 不做:加法不满足结合律,换个加的顺序,结果最后几位会变 | 不做:标量加法,一条链 |
float,开了 -ffast-math | 自己做 | 不做,只用 1 个 |
向量化,int 编译器会自己做,float 要加 -ffast-math。拆累加器,GCC 两种都不会自己做(-funroll-loops 也只是把同一条依赖链多写几遍),要手写。1 个累加器够不够,看数据在哪、加法延迟多长。int 加法延迟 1 周期,每周期 1 次 32 B 读,约 128 GB/s,数据在 L3 或内存里(15–150 GB/s)基本够用,在 L1/L2 里(80–400 GB/s)会被拖慢。float 加法延迟 3–4 周期,每 3–4 周期才 1 次 32 B 读,约 32–43 GB/s,内存里(15–60 GB/s)勉强够,L1/L2/L3 里都喂不满。别的编译器(如 clang)行为不同,要对着汇编看。
“CPU 多快”不是一个数:同一个求和,写法不同,从 4 GB/s 到 250 GB/s,差 60 倍。只有吃得比送得快,带宽才是瓶颈。
2.5 DRAM:通道、行缓冲、刷新、带载延迟
| 量 | 大约多少 |
|---|---|
| 一个通道的峰值 | $\text{传输速率} \times 8\ \text{B}$:DDR4 约 20–25 GB/s,DDR5 约 40–50 GB/s |
| 整颗芯片的峰值 | $\text{通道数} \times \text{一个通道的峰值}$:2 个通道约 50–100 GB/s,8–12 个通道约 200–500 GB/s |
| 实际能跑到 | 全核一起读,峰值的 70%–90% |
| 行缓冲(row buffer) | 命中已开的行约 15 ns,行空约 30 ns,行冲突约 40 ns |
| 刷新(refresh) | 每约 7.8 µs 一次(tREFI),每次挡住约 300–500 ns(tRFC)。一次内存访问撞上的概率约 300–500 ns ÷ 7.8 µs ≈ 4%–6%,比 1% 大,所以内存访问的 P99 基本就是撞上刷新的那些 |
| 带载延迟(loaded latency) | 带宽用得越满,单次访问越慢。接近上限时,可涨到空闲时的 2 倍以上 |
2.6 缓存行乒乓(cache-line ping-pong):一条行在核之间来回
一个生产者往环里连着写,几个读者在别的核上忙着轮询同一块(#5 的实验)。没人读时,行一直在生产者自己的 L2 里,每写一条约 3 ns。来了读者,读者守在生产者下一块上,每写一块之前都要先作废读者手里的副本(MESI),每条涨到约 20–40 ns,读者越多越贵。这是全速写的吞吐;写得慢时,这一趟藏在 store buffer 后面,看不出来;大环里落在后面的读者碰不到生产者要写的行,不贵(#5 1.6)。单次跨核往返本身约 20–100 ns(2.1)。
3. 估算模型:搬几条行 × 每条多久
第 2 部分的数,单拿哪一个都说不清一段代码为什么慢。把它们串成一个式子,四个问题(3.1)各管一个因子:
$$ \text{总时间} \;\approx\; \underbrace{\text{要搬的行数}}_{\substack{\text{Q1 空间局部性} \\ \text{Q2 时间局部性}}} \;\times\; \underbrace{\text{每行摊到的时间}}_{\substack{\text{Q3 命中层级} \\ \text{Q4 MLP}}} $$每行摊到的时间倒过来,就是每秒能搬几条行。它有两层天花板,取小的那个:
$$ \text{每秒能搬的行数} \;=\; \min\Bigg(\underbrace{\frac{\text{在途请求数}}{\text{延迟}}}_{\substack{\text{单核先撞这个} \\ \text{(Little 定律)}}},\;\; \underbrace{\frac{\text{峰值带宽}}{64\ \text{B}}}_{\substack{\text{多核一起压} \\ \text{才常撞这个}}}\Bigg) $$最后再和带宽需求(2.4)取小,这一步就是 roofline:
$$ \text{实际速度} \;=\; \min\big(\,\text{带宽需求},\;\; \text{每行有用的字节} \times \text{每秒能搬的行数}\,\big) $$3.1 局部性、命中层级与 MLP:四个问题
- Q1 空间局部性(spatial locality):一条行用了多少字节。连续读
int,一条 64 B 的行喂 16 个。步长 ≥ 64 B 时,每条行只用 4 B,同样多的有用数据要搬 16 倍的行。“浪费带宽”说的就是这一问,和总线满不满无关。步长 64 B 已经最差,再大也不会更差。 - Q2 时间局部性(temporal locality):搬进来的行能不能活到下次用。活不到有四种原因,前三种是经典的 3C 分类:
- Q3 命中层级:从哪一层取。决定延迟和峰值带宽,数见 2.1、2.2。
- Q4 MLP:同时能有几个在路上。下一个地址要等这次读回来(pointer chasing:链表、树、哈希冲突链),就只有 1 个,每条行付一整次延迟。地址是算出来的,乱序执行会在 ROB 的窗口里把后面的读先发出去,最多挂满 LFB。顺序走时,预取器还能再替你多挂一批(2.3)。这就是“流水线掩盖延迟”:不是延迟变短了,是很多次等待叠在了一起。
3.2 几个例子
下面都是按一组典型数估的:内存延迟约 90 ns,一个核读内存约 35 GB/s,整颗芯片约 50 GB/s。
- 大数组顺序求和,向量化。Q1:每条行都用满。Q4:预取器全力,一个核约 35 GB/s。带宽需求约 256 GB/s。取小,约 35 GB/s,卡在内存带宽上。4 个核一起扫,撞上整颗芯片的约 50 GB/s,每个核只分到 12 GB/s 左右。
- 同一个求和,换成
float、一个累加器。带宽需求只有约 4 GB/s,比内存送得还慢。数据在 L1 还是在内存,速度几乎一样。要快,得拆依赖链(多个累加器,#9 3.3),换更快的内存没用。 - 遍历一个散在堆上的链表。Q4 只有 1,每个节点付一整次内存延迟,约 90 ns。100 万个节点约 90 ms。路很宽,只用了 0.7 GB/s。要快,只能让节点挨着放(数组、内存池),或者让它们一直留在缓存里。
- 热路径上一次查表落到内存。一次约 90 ns,按 4 GHz 是 360 个周期,够 L1 命中 90 次。热路径上每条行情只碰几条行,字节数很小,怕的是这种有依赖的缺失(Q3、Q4),不是带宽。
- 同一颗芯片上另一个核在扫回测数据。策略绑在隔离核上,热路径只碰几条行,带宽几乎不用,照样变慢,两条原因相乘。缺失变多:回测把 L3 冲掉,热路径原来在 L3 命中(约 10–30 ns)的行要去内存(约 90 ns)。每次缺失更慢:内存控制器被回测占满,热路径那几次缺失要排在它的请求后面,带载延迟可到空闲时的 2 倍以上,约 200 ns。核自己 L1/L2 里一直在用的行冲不掉(L3 是 inclusive 的芯片例外)。绑核只隔离了计算,没隔离 L3 和内存这段路。办法是把回测挪走,或者用 CAT 限它的 L3、MBA 限它的内存带宽。
3.3 常见误区
- LFB 满了,是重叠最多,不是“退化成串行”。N 项都在路上,第 N+1 个缺失等最早那一项回来、腾出位置再发。稳态下一直有 N 个在路上,每条行摊到 $\text{延迟} / N$ 的时间。真正串行的只有依赖链。所以带宽和延迟不是二选一:按 Little 定律(2.3),在途请求数不变时,一个核的带宽和延迟成反比。延迟变长(DRAM 行冲突、TLB 缺失),同样的 N 每秒搬的行就少了。
- “5 ns 一条”是吞吐,不是延迟。80 ns ÷ 16 = 5 ns 是平摊下来的,每一条行照样要走完 80 ns。
- “受带宽限制”有两层天花板,就是第 3 部分 min 里的两项。一个核先撞第一项(Little 定律),整颗芯片的峰值要多个核一起压才撞得到。说“总线打满”之前,先问是几个核。
- padding 治冲突,不治容量。每行多填一条缓存行,相差关键步长的地址就分到了不同组。可要反复用的行如果比这一层装得下的多(比如一列 2048 行,就是 2048 条行、128 KiB,而 L1D 只有 32 KiB),填了也装不下,得分块。
看任何关于访存的说法,先问它在回答四个问题里的哪一个。一段话里混了好几个,就拆开看。