速查

CPU 与内存:地图、数字、怎么估算

一次访存经过哪些框,每个框多大、多快,以及怎么把这些数合起来,估一段代码的访存时间。

本页目录

这页是随时翻的总图,不是一篇文章。三部分按“在哪 → 多大多快 → 怎么合起来算”排:

  1. 存储层次:硬件地图,一次读写经过哪些框,每个框在哪篇文章里讲过。
  2. 性能参数:每个框的延迟、带宽、容量,同时能挂几个请求(MLP),以及 CPU 自己想吃多快(带宽需求)。都是帮助理解的量级,不对应哪台机器。
  3. 估算模型:用一个式子把第 2 部分的数串起来,判断一段代码慢在“搬得多”还是“搬得慢”。

后面讲到新的硬件层,都补在这一页。往上一层,一条行情经过哪些线程,见交易系统架构。

1. 存储层次(memory hierarchy):一次访存经过哪些框

硬件地图:从前端、后端和 store buffer,经 L1、L2、L3、内存控制器到内存条,附访问延迟量级表和一个地址的各段

图里的蓝字是“在哪里讲过”:#N 是交易系统笔记 #N,MESI 是专题,“速查 N.N”是本页的小节。点图打开可交互的版本:点一个框看它是什么、在哪篇讲过,选一条路径(load、store、分支猜错)一步一步看怎么走,蓝字直接链到文章。

1.1 读(load):地址翻译、查缓存、行填充

读(load)的路径。第 1 步:地址拆成页号和组号,TLB 翻译和 L1D 选组同时进行。第 2 步:L1D 比对标签,没命中先在 LFB(填充缓冲)登记一项,再问 L2、L3。第 3 步:都没有就交给内存控制器,按通道、bank、行去内存条取。第 4 步:整条 64 B 行原路返回,沿路放进各级缓存。图中标了每一步的累计延迟。

图里的延迟是量级,见 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)的路径,两个核。核 0 执行 data = 42,写先进 store buffer;invalidate 请求经片上互连,snoop filter 只把它发给有副本的核 1;核 1 先放进 invalidate queue,马上回 ACK;核 0 收齐 ACK 才把 42 写进 L1D,接着写 ready。核 1 从核 0 取回 ready = 1,再读 data 时失效还没处理,读到旧值 0。下方说明:这是读和读乱序,x86 不允许,ARM 允许,修法是 ready 用 atomic,写 release、读 acquire。

写先进 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、mfence1–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 KiB10–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 queue70–200每条 load 从进窗口到退休都占一项。load 很密的代码里,它可能比 ROB 先满
L1D 的 LFB(1.1)12–24一个核自己的 load 同时能挂几个 L1D 缺失
L2 的 MSHR(未完成请求队列)30–60L2 预取器替你多发的请求挂在这里,不占 LFB
store buffer50–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 条约 100LFB
每次查表之前先算约 150 条指令的哈希约 150 条约 3ROB

同一个核、同一条内存,只改在途请求数,带宽能差 20–70 倍(按延迟 80 ns、一条 64 B 算):

访问方式在途请求数一个核的带宽
链表:下一个地址要等这次读回来164 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 B16 GB/s只有内存那一级勉强追平
向量化(SIMD)+ 多个累加器(每周期 2 次 32 B 读)每周期 64 B约 256 GB/s出了 L1 就受带宽限制(memory-bound)
L1 读端口的上限(每周期 2–3 次读,每次 32–64 B)每周期 64–128 B250–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 分类:
    • 冷缺失(compulsory miss):第一次碰,谁也躲不掉。
    • 容量缺失(capacity miss):要反复用的行比这一层装得下的多。治法是分块:一次只处理一段装得进缓存的数据。
    • 冲突缺失(conflict miss):地址挤进同一组,#6 的关键步长。padding(填充)治的就是这一种,也只治这一种。
    • 一致性缺失(coherence miss):别的核写了这条行,你手里的副本被作废(2.6)。只有多核才有。
  • Q3 命中层级:从哪一层取。决定延迟和峰值带宽,数见 2.1、2.2。
  • Q4 MLP:同时能有几个在路上。下一个地址要等这次读回来(pointer chasing:链表、树、哈希冲突链),就只有 1 个,每条行付一整次延迟。地址是算出来的,乱序执行会在 ROB 的窗口里把后面的读先发出去,最多挂满 LFB。顺序走时,预取器还能再替你多挂一批(2.3)。这就是“流水线掩盖延迟”:不是延迟变短了,是很多次等待叠在了一起。

3.2 几个例子

下面都是按一组典型数估的:内存延迟约 90 ns,一个核读内存约 35 GB/s,整颗芯片约 50 GB/s。

  1. 大数组顺序求和,向量化。Q1:每条行都用满。Q4:预取器全力,一个核约 35 GB/s。带宽需求约 256 GB/s。取小,约 35 GB/s,卡在内存带宽上。4 个核一起扫,撞上整颗芯片的约 50 GB/s,每个核只分到 12 GB/s 左右。
  2. 同一个求和,换成 float、一个累加器。带宽需求只有约 4 GB/s,比内存送得还慢。数据在 L1 还是在内存,速度几乎一样。要快,得拆依赖链(多个累加器,#9 3.3),换更快的内存没用。
  3. 遍历一个散在堆上的链表。Q4 只有 1,每个节点付一整次内存延迟,约 90 ns。100 万个节点约 90 ms。路很宽,只用了 0.7 GB/s。要快,只能让节点挨着放(数组、内存池),或者让它们一直留在缓存里。
  4. 热路径上一次查表落到内存。一次约 90 ns,按 4 GHz 是 360 个周期,够 L1 命中 90 次。热路径上每条行情只碰几条行,字节数很小,怕的是这种有依赖的缺失(Q3、Q4),不是带宽。
  5. 同一颗芯片上另一个核在扫回测数据。策略绑在隔离核上,热路径只碰几条行,带宽几乎不用,照样变慢,两条原因相乘。缺失变多:回测把 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),填了也装不下,得分块。

看任何关于访存的说法,先问它在回答四个问题里的哪一个。一段话里混了好几个,就拆开看。