一次内存访问大约 100 ns,运回一条 64 字节的缓存行。假如一次只能有一个请求在路上,一个核从内存读数据的速度就是:
$$ \frac{64\ \text{B}}{100\ \text{ns}} = 0.64\ \text{GB/s} $$可是写一个最普通的循环,把一个几 GB 的数组从头加到尾,一个核能跑到 15–60 GB/s,是这个数的 20–90 倍。每一次访问并没有变快,还是 100 ns 左右。多出来的速度从哪来?
反过来,同样是内存里的数据,沿着一条链表往下走,每一步真的要付满 100 ns,0.64 GB/s 一点不差。同一块内存、同一个核,两段代码差了两个数量级。
要讲清楚这件事,得先把两个量分开:延迟(latency)和带宽(bandwidth)。平时说“内存慢”,可能指这个,也可能指那个。这一篇先把两个量立起来,再看是什么把它们连在一起。
1. 两个量:延迟与带宽
1.1 延迟与带宽:框与框之间的路
前几篇讲的都是硬件地图上的框:L1D、L2、L3、内存控制器、内存条(见硬件地图)。延迟和带宽讲的是框与框之间的那段路:
- 延迟:从核发出一次 load,到数据回到寄存器,中间走的时间。走得越远越久:L1 命中只在核内,内存访问要一路走到内存条再回来。
- 带宽:这段路上单位时间能运回多少字节,单位 GB/s。
拿高速公路打比方:延迟是一辆车从 A 开到 B 要多久,带宽是 B 端每小时到达多少辆车。加车道不会让单辆车开得更快,只是让同一时间路上能跑更多车。
1.2 各层的典型数字:两列变差的倍数不一样
按约 4 GHz 算,都是帮助建立直觉的范围:
| 层 | 一次访问的延迟 | 一个核能拿到的带宽(顺序读) |
|---|---|---|
| L1D | 约 1 ns(4–5 周期) | 约 150–400 GB/s(每周期读 2–3 次,每次 32–64 B) |
| L2 | 约 3–5 ns | 约 80–200 GB/s |
| L3 | 约 10–30 ns | 约 30–150 GB/s |
| 内存 | 约 70–120 ns | 约 15–60 GB/s。整颗芯片看通道数,2 个通道约 50–100 GB/s |
两列都随层级变差,但变差的倍数不一样:从 L1 到内存,延迟差约 100 倍,一个核的带宽只差约 10 倍。光这一点就说明,它们不是同一个量的两种写法。
还有一个前提:这里的“一次访问”永远是一整条缓存行,x86 上是 64 B。哪怕只要 1 个字节,路上运的也是 64 B。
1.3 Little 定律:带宽 ≠ 1/延迟
回到开头那个数。0.64 GB/s 是“一次只有一个请求在路上”时的带宽,实际的 15–60 GB/s 比它多出 20–90 倍,多出来的倍数就是同时在路上的请求数(在途请求数,outstanding requests)。一个核能同时挂几个请求,叫内存级并行(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} $$反过来用:一个核顺序读到 35 GB/s、延迟 80 ns 时,
$$ 35\ \text{GB/s} \times 80\ \text{ns} = 2800\ \text{B} \approx 44\ \text{条缓存行} $$也就是平均有 44 条行同时在路上。带宽高,不是因为每个请求变快了,而是车道多、车多,每一辆车照样要开完全程。
1.3.1 内存级并行(MLP):谁让请求同时在路上
三样东西:
- 乱序执行:后面的 load 不等前面的 load 回来,就先发出去(依赖链见 #9 第 3 节)。它能往后看多远,由 ROB 决定,见 1.3.2。
- LFB(line fill buffer,行填充缓冲;AMD 叫 MAB,论文里叫 MSHR):L1D 用来记“哪些缺失还没回来”的小表,一个核 12–24 项。核自己发出的缺失,最多同时挂这么多。
- 硬件预取器:看出你在顺序走,它自己提前发请求,不占你的指令。L2 的预取器把请求挂在 L2 自己的未完成请求队列(L2 的 MSHR,30–60 项)里,所以能比 LFB 再多挂一批。
上面反推出的 44 条比 LFB 的 12–24 项多,多出来的就是 L2 预取器替你挂的。一个核的在途请求数,大致有三档:
| 访问方式 | 在途请求数 | 一个核的带宽(延迟按 80 ns) |
|---|---|---|
| 下一个地址要等这次读回来(链表) | 1 | 64 B ÷ 80 ns ≈ 0.8 GB/s |
| 地址互不依赖,但预取器帮不上(随机访问,或者步长每次都跨页) | LFB 的 12–24 项 | 约 10–20 GB/s |
| 顺序读,预取器全力 | 约 30–70 个 | 约 15–60 GB/s |
1.3.2 ROB 与指令窗口:乱序执行能往后看多远
ROB(reorder buffer,重排序缓冲)是乱序执行的账本。在硬件地图上,它在核最上面那个框里(取指 → 译码 → 乱序执行 → 退休),管“乱序执行 → 退休”这一段。它不在数据走的那条路上:它不存数据,存的是还没退休的指令。但一个核能一口气往路上发几个访存,要看它。
x = a[i]; // ① 缺失,要去内存,约 80 ns
y = b[j]; // ② 和 ① 无关,也缺失
s += x; // ③ 要等 ①
t = y * 2; // ④ 要等 ②
- 进:按程序顺序。译码出来的指令依次排进 ROB 的队尾,一条占一项(准确说是一个微操作)。
- 做:谁的输入先备齐谁先做。② 不用等 ①,地址一算出来就发出去,于是 ① ② 两个缺失同时在路上。③ 等 ① 的值,④ 等 ② 的值。
- 退休:只能从队头按顺序来。退休就是结果正式生效、腾出这一项。② 就算先回来,也要等 ① 退休了才轮到它。
为什么非按顺序退休不可?没退休的结果都还是草稿。分支猜错、出了异常,把队头之后的草稿整段扔掉就行,程序看上去仍然是一条一条按顺序执行的。
麻烦在于一次内存缺失太长,要 300–500 个周期。缺失的 load 一到 ROB 队头就退休不了,后面的指令只能跟着排队,前端却还在往队尾加,每周期 4–8 条,200–600 项的 ROB 大约 100 个周期(20–30 ns)就满了。满了以后新指令进不来,核就干等剩下的 50–100 ns。所以能和这次缺失叠在一起的,只有它后面 200–600 条指令里那些互不依赖的 load。这一段叫指令窗口(instruction window)。按 ROB 500 项算:
| 代码 | 两次独立缺失之间隔多少条指令 | 窗口里有几个 | 先撞哪一级 |
|---|---|---|---|
链表 p = p->next | 下一个地址要等这次读回来 | 1 | 依赖链,ROB 再大也没用 |
s += a[idx[i]],a 很大、idx 打乱 | 约 5 条 | 约 100 | LFB(12–24 项) |
| 每次查表之前先算约 150 条指令的哈希 | 约 150 条 | 约 3 | ROB |
合起来(只算核自己发的 load,不算预取器):
$$ \text{同时在路上的缺失} \;\approx\; \min\Bigg(\underbrace{\frac{\text{ROB 项数}}{\text{两次独立缺失之间的指令数}}}_{\substack{\text{窗口里能找到几个} \\ \text{(依赖链时只有 1 个)}}},\;\; \underbrace{\text{LFB 项数}}_{\text{最多同时挂几个}}\Bigg) $$严格说,窗口是 ROB、load queue、物理寄存器这几样里最先满的那个,ROB 只是名义上限。load queue 只有 70–200 项,load 很密的循环里,它会比 ROB 先满。硬件预取器不受这个式子管:它的请求由 L1、L2 自己发,不占 ROB 和 load queue,所以顺序读能挂到 30–70 个。
1.3.3 channel、rank、bank:车道、共用的车道、备货的窗口
到了内存条,这段路上也有自己的并行。DRAM 的结构在 #6 的 3.4 讲过,这里只看每一层管的是哪个量:
- channel(通道)主要管带宽。 每个通道是一组独立的数据线,能同时各传各的。通道数就是车道数:双通道比单通道总带宽约翻倍,单次访问的延迟基本不变。
- rank 不加带宽。 同一通道上的几个 rank 共用那组数据线,同一时刻只能有一个在传,换 rank 传还要空出几个周期。它的作用是让一个通道上能挂更多的 bank。
- bank 两边都管。 带宽这一侧:不同 bank 可以各自开行、各自备数据,让 DRAM 内部同时处理多个请求,这就是“在途请求数”在内存条里的那一段。延迟这一侧:每个 bank 的行缓冲决定单次访问快慢,命中已经打开的行约 15 ns,要先关旧行再开新行约 40 ns。
channel 决定有几条车道,bank 决定仓库里有几个窗口能同时备货,行缓冲决定一个窗口给一辆车备货要多久。
1.4 依赖链:下一次读的地址要不要等这一次
判断一段代码受延迟限制还是受带宽限制,只问一句:下一次读的地址,要不要等上一次读回来才知道?
1.4.1 地址是算出来的:受带宽限制
long long s = 0;
for (int i = 0; i < n; ++i)
s += a[i]; // 第 i+1 个地址是算出来的:&a[0] + 4(i+1),不用等 a[i] 回来
CPU 可以连续发出很多个 load,让一堆缺失同时在路上。瓶颈是“路上最多能挂几个、每秒最多运回多少字节”,也就是带宽。
#6 3.2 按列走矩阵的实验就是这种情况。下标 m[i * row_ints + j] 也是算出来的,和读到的值无关。所以那里测到的 0.81 ns/次是吞吐(总时间 ÷ 访问次数),不是一次 L2 访问的延迟(约 5 ns)。5 ÷ 0.81 ≈ 6,平均有六七个缺失同时在路上,正是 Little 定律。
1.4.2 地址是读回来的(pointer chasing):受延迟限制
struct Node { int value; Node* next; };
for (Node* p = head; p != nullptr; p = p->next) // 下一个地址 = 这次读回来的 p->next
s += p->value;
p->next 没回来之前,下一次 load 连地址都不知道,发不出去。路上永远只有一辆车,每一步付满一次延迟。这就是 pointer chasing,#6 讲去规范化时的那条依赖链。节点不在缓存里时,每步约 100 ns,带宽只用到 0.64 GB/s:路很宽,但只有一辆车。
常见的受延迟限制的结构:链表、std::map(红黑树,每层一个指针)、哈希表的冲突链、多级查表(先查合约,再查账户,再查风控参数)。
1.4.3 带宽测法与延迟测法:同一块内存差一两个数量级
- 测带宽:顺序扫一个大数组,让预取和乱序全力工作,算 GB/s。
- 测延迟:把数组做成一个随机排列的环,每次
k = q[k],下一个地址依赖这次读到的值,预取猜不到,乱序也帮不上,算 ns/次。
同一块内存,两种测法的数字可以差一两个数量级。看别人报的内存数字,先问:这是哪种测法?
1.5 带宽需求与计算强度:CPU 想吃多快
上面说的都是供给这一侧:每一层每秒能送来多少字节。带宽是不是瓶颈,还要看需求这一侧:假设数据零成本到手,循环每秒想吃掉多少字节,这叫带宽需求。它由计算强度(arithmetic intensity,每搬 1 个字节要做几次运算)决定:算得越少,吃得越快。两边取小:
$$ \text{实际速度} \;=\; \min\big(\,\text{带宽需求},\;\; \text{数据所在那一层能送的速度}\,\big) $$这就是 roofline 模型的核心。一个核、约 4 GHz:
| 循环 | 带宽需求 | 从哪一层开始受带宽限制 |
|---|---|---|
int 求和 s += a[i],不向量化、一个累加器,每周期 1 次加法 | 4 B/周期 × 4 GHz = 16 GB/s | 只有内存那一级勉强追平。在 L1/L2/L3 里,瓶颈是那条加法依赖链,不是带宽 |
| SIMD 向量化 + 多个累加器(AVX2,每周期 2 次 32 B 读) | 64 B/周期 × 4 GHz = 256 GB/s | 出了 L1 就受限:L2 的 80–200 GB/s 已经喂不饱 |
| 每个元素要做 100 次以上运算 | 每周期不到 1 B | 几乎永远受计算限制(compute-bound),数据在内存里也一样 |
算得越少、越向量化,越早撞上带宽。同一个求和,向量化之后快了 16 倍,但数据一出 L1 就被带宽拦住。标量版自己就慢,带宽只在最底层才追上它。这也是“数组越大越慢”的曲线呈台阶的原因:数组每掉出一层缓存,供给侧就换成更低的那一档,只要它低于需求侧,速度就跟着掉一级。
#6 的实测也对得上:按行走约 0.05 ns 读一个 4 B 的 int,4 B ÷ 0.05 ns = 80 GB/s(矩阵在 L2 里)。标量循环做不到这么快,说明编译器把它向量化了。它停在 80 GB/s 左右,正落在一个核读 L2 的带宽范围里(80–200 GB/s)。
HFT 热路径正好反过来:每条行情只碰几条缓存行,带宽需求很小,带宽几乎从不成为瓶颈,怕的是 1.4.2 那种延迟。带宽成瓶颈的是批量处理:行情回放、回测、日志落盘、大块拷贝。
1.5.1 向量化(SIMD)与多个累加器:谁来做
- 向量化(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)。要拆几个,用的还是 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)行为不同,要对着汇编看。
1.6 总图:总时间 = 要搬的行数 × 每行摊到的时间
“要搬多少”和“搬得多快”不放进同一个式子,讨论很容易打转:一会儿说瓶颈是带宽,一会儿又说“退化成串行、暴露延迟”。合起来是:
$$ \text{总时间} \;\approx\; \underbrace{\text{要搬的行数}}_{\substack{\text{Q1 一条行用了多少字节} \\ \text{Q2 行能不能活到下次用}}} \;\times\; \underbrace{\text{每行摊到的时间}}_{\substack{\text{Q3 从哪一层取} \\ \text{Q4 同时几个在路上}}} $$每行摊到的时间倒过来,就是每秒能搬几条行。它有两层天花板,取小的那个:
$$ \text{每秒能搬的行数} \;=\; \min\Bigg(\underbrace{\frac{\text{在路上的个数}}{\text{延迟}}}_{\substack{\text{单核先撞这个} \\ \text{(Little 定律)}}},\;\; \underbrace{\frac{\text{峰值带宽}}{64\ \text{B}}}_{\substack{\text{多核一起压} \\ \text{才常撞这个}}}\Bigg) $$最后再和 1.5 的带宽需求取小:
$$ \text{实际速度} \;=\; \min\big(\,\text{带宽需求},\;\; \text{每行有用的字节} \times \text{每秒能搬的行数}\,\big) $$1.6.1 四个问题:局部性、命中层级、MLP
- Q1 一条行用了多少字节(空间局部性)。 连续读
int,一条 64 B 的行喂 16 个。步长 ≥ 64 B 时每条行只用 4 B,同样多的有用数据要搬 16 倍的行。“浪费带宽”说的就是这一问,和总线满不满无关。步长 64 B 已经让这一问最差,再大也不会更差。 - Q2 搬进来的行能不能活到下次用(时间局部性)。 活不到的原因只有三种:冷缺失(第一次碰,谁也躲不掉)、容量缺失(要反复用的行比这一层装得下的多)、冲突缺失(地址挤进同一组,#6 3.1 的关键步长)。padding 只治冲突。
- Q3 从哪一层取。 决定延迟和峰值带宽,就是 1.2 的那张表。
- Q4 同时能有几个在路上。 下一个地址要等这次读回来(1.4.2),只有 1 个,每行付一整次延迟。地址是算出来的(1.4.1),乱序执行把后面的读先发出去,每个缺失占一项 LFB,最多 N 个(12–24),每行摊到 $\text{延迟} / N$。预取器还能替你再多挂一批。这就是常说的“流水线掩盖延迟”:不是延迟变短了,是很多次等待叠在了一起。能往后看多远受 ROB 限制,能挂多少受 LFB 限制,依赖链一个也提前不了。
看任何关于访存的说法,先问它在回答哪一问。一段话里混了好几问,就拆开看。
1.6.2 三个容易混的地方
- LFB 满了不是“退化成串行”。 满,恰恰是重叠最多的时候:N 项都在路上,第 N+1 个缺失等最早那一项回来、腾出位置再发,稳态一直有 N 个在路上,每行摊到 $\text{延迟} / N$(比如 80 ns、16 项,每 5 ns 一条)。真正串行的只有依赖链。所以带宽和延迟不是二选一:一个核的带宽本身就是 $\text{在途请求数} \times 64\ \text{B} / \text{延迟}$,延迟变长(DRAM 行冲突、TLB 缺失),同样的 N 每秒搬的行就变少。
- “受带宽限制”有两层天花板。 一个核先撞的通常是 $\text{在途请求数} / \text{延迟}$(一个核读内存约 15–60 GB/s),整颗芯片的总线峰值要多核一起压才常撞到(2 个通道就有 50–100 GB/s)。多核一起压,读也只能到峰值的 70%–90%:刷新、读写切换、bank 冲突都要占时间。另外,“每行摊到 5 ns”是吞吐的倒数,不是延迟,每一条照样走完 80 ns,把它叫“等效时延”正是混淆的来源。
- padding 治冲突,不治容量。 #6 的
int m[64][1040]只有 64 行,按列走只碰 64 条行,错开组以后全放得进 L1D(512 个位置),第 1–15 列都在 L1 命中。换成 2048 行,一列就是 2048 条行(128 KiB):padding 让它们平均分到 64 组,但每组 32 条抢 8 格,走完第 0 列,L1D 里只剩最后约 512 行,第 1 列从第 0 行开始就在 L1 缺失、在 L2 命中。要在 L1 命中还得分块:一次只走一段装得进 L1 的行,把这一段的 16 列走完再换下一段。
1.7 带载延迟(loaded latency):带宽用满,延迟上涨
路上车越多,越接近车道的上限,就开始排队。内存控制器也一样:整颗芯片的带宽用得越满,单次访问的延迟越高,接近上限时可以涨到空闲时的 2 倍以上。这叫带载延迟(loaded latency)。
这对 HFT 的意义:
- 热路径是“收一条行情 → 查订单簿 → 决策 → 发单”,一串有依赖的访问,数据量很小,怕的是延迟。
- 但内存控制器和 L3 是全核共享的。同一颗芯片上别的进程在狂扫内存(回测、日志落盘、行情回放),带宽被吃满,热路径的每次缺失都要排队。绑核和隔离核只隔离了计算,没隔离这段路(#1)。
- 所以生产机上,要么把这类吃带宽的任务挪走,要么用硬件的分配功能限制它们:Intel 的 CAT 把 L3 分份,MBA 限制每个核的内存带宽。
2. 带宽这一侧:容量、写、多核
把一块 1 GiB 的内存清零,用最普通的循环:
void plain_zero(int* dst, size_t n) {
for (size_t i = 0; i < n; ++i) dst[i] = 0;
}
代码里一个读都没有,可内存总线上走了 2 GiB,一半是读。再看同一个核:数组 16 KiB 时每秒能读几百 GB,数组 256 MiB 时只剩几十 GB。还有,一颗 64 核、峰值约 300 GB/s 的芯片,一个核单独扫内存只能拿到零头。这三件事都在回答第 1 部分总图里的两个量:要搬多少行,路有多宽。
2.1 工作集(working set):数据装得进哪一层
工作集是一段代码在一段时间里反复碰的那些字节。工作集装得进哪一层,反复访问就在哪一层命中,速度就按那一层算。
反复顺序求和一个 int 数组,数组从 16 KiB 一路加到 256 MiB,每个大小算一次每秒读了多少字节:
// a 有 n 个 int,n × 4 B 就是工作集;反复扫 reps 遍
for (int rep = 0; rep < reps; ++rep)
for (size_t i = 0; i < n; ++i) s += a[i];
在 L1D 32 KiB、L2 512 KiB、L3 16 MiB 的机器上,曲线是 4 级台阶(按速查页的范围估):
| 工作集 | 在哪层命中 | 一个核顺序读 |
|---|---|---|
| ≤ 约 32 KiB | L1D | 约 150–400 GB/s |
| 约 32 KiB – 512 KiB | L2 | 约 80–200 GB/s |
| 约 512 KiB – 16 MiB | L3 | 约 30–150 GB/s |
| > 约 16 MiB | 内存 | 约 15–60 GB/s |
实测一条(Ryzen 5 5600GT,一个核,GCC 16.2.0 编译,
-O3 -march=native,4 个向量累加器,每个大小测 5 次取最快;测的是这段循环本身,不是硬件峰值):
- 四层平台的数都落在示意图给的范围里:L1D 约 280 GB/s(267–289),L2 约 145 GB/s(144–146),L3 约 116 GB/s(114–117),内存约 26 GB/s(25.9–27.5)。
- 拐点和虚线的关系:L1D→L2 正好切在 32 KiB 虚线上(32 KiB 还是 279 GB/s,下一个点 39 KiB 就掉到 146 GB/s)。L2→L3 的斜坡从约 300 KiB 开始,比 512 KiB 虚线早,虚线处已滑过大半(121 GB/s),约 1 MiB 落到 L3 平台。L3→内存的斜坡约 9 MiB 就开始,比 16 MiB 虚线略早,16 MiB 处约 47 GB/s、才掉到一半;victim cache 没有让拐点整体右移,偏晚的是斜坡的结束——32 MiB 处还有约 33 GB/s,约 48 MiB 才完全贴到内存平台的 26 GB/s,脱离 L3 是渐进的。
- L3 那段尾巴为什么拖到约 48 MiB:假如 L3 严格按 LRU 换行,顺序扫一个比 L3 大的数组,每条行在被再次用到之前就被踢掉,命中率直接掉到 0,曲线应该在 16 MiB 附近陡降。实测是慢慢降下去的,说明 L3 的替换策略不是纯 LRU,每遍都有一部分行碰巧留下来。数组越大,留下的比例越小,大致按 $\text{L3 容量} / \text{工作集}$ 衰减:16 MiB 处还剩一半左右,到约 48 MiB 才贴到内存那一级。
每过一个容量,反复访问的行就从“这一层装得下”变成容量缺失,要去下一层取。读这条曲线要注意 4 点:
- 拐点比标称容量早,而且是斜坡,不是直角。别的数据也占着位置:栈、代码、页表项。组相联也不是完美的 LRU:L2、L3 按物理地址选组,操作系统给的物理页是零散的,有的组先满,整体还没装满就开始踢。L3 还是全核共享的。
- 有的芯片拐点反而晚。L3 做成 L2 的 victim cache 时(L2 踢出的行才进 L3,L2 里有的 L3 不再存一份),能装的总量约是 L2 + L3。
- 顺序读的台阶比随机读的矮。这张图量的就是各层的带宽,不是实验做得不够:顺序读时预取器提前搬、很多请求同时在路上,下一层的延迟大半被藏住,每秒能搬多少字节由各层的带宽决定,L1 到内存一共差约 10 倍。想看延迟,要换一个实验:随机 pointer chasing,下一个地址等这次读回来,没有预取、一次一个,每步付满一整次延迟,L1 到内存一共差约 100 倍。两条曲线回答的是两个问题。
- 随机访问还有一级 TLB 的台阶:工作集超过 TLB 能覆盖的几 MiB,每次多一次查页表。
热路径的数据结构(订单簿、持仓、参数表)要压到 L1/L2 装得下,才谈得上每次几纳秒。#6 的紧凑布局,省的不只是行数,还让工作集往上挪一层。
2.2 写缺失与 RFO:写一条行,总线上走两趟
写一个变量,先进 store buffer。真正写进 L1D 之前,核要拿到这条行的独占权(MESI 的 I→M)。行不在缓存里时,要先把整条行从内存读回来,再在里面改,这叫 RFO(read for ownership)。原因是缓存只按整条 64 B 行管理:你只写了 4 B,另外 60 B 也得是对的。写缺失也先把行分配进缓存,这种做法叫写分配(write-allocate)。
改过的行是脏的,被踢出缓存时要写回内存(write-back)。所以普通的写,每条行在总线上走两趟:
$$ \text{总线流量} \;=\; \underbrace{64\ \text{B}}_{\text{RFO 读回}} \;+\; \underbrace{64\ \text{B}}_{\text{踢出时写回}} $$- 开头清零 1 GiB(远大于 L3),总线上走 2 GiB。按“写了多少字节 ÷ 时间”算的写带宽,只有总线流量的一半。
memcpy1 GiB:读源 1 GiB,RFO 目的 1 GiB,写回 1 GiB,一共 3 GiB,有用的只有 2 GiB。
store buffer 只藏得住写的延迟,后面的指令不用等它。它只有 50–110 项,写得很密时会满,满了核就停下来,这时速度受的是 RFO 的带宽。
RFO 的带宽大概多少,看行在哪:
$$ \text{一个核写内存的有用带宽} \;\approx\; \frac{\text{在途 RFO 数} \times 64\ \text{B}}{\text{延迟}}, \qquad \text{整颗芯片的有用写带宽} \;\lesssim\; \frac{\text{芯片峰值} \times (70\%\text{–}90\%)}{2} $$- 行已经在 L1/L2 里、而且是自己独占的:不用 RFO,速度按写端口算。每周期 1–2 次 32 B 的写,4 GHz 下约 130–250 GB/s。热路径反复写同几条行,就是这种情况。
- 一个核往远大于 L3 的数组写:卡在同时在路上的 RFO 有几个。RFO 和读缺失一样占 LFB,L2 预取器也会替顺序写提前发 RFO,所以算法和读一样,代入数是约 10–40 GB/s 的有用写带宽,通常比同一个核的顺序读低一些:写回也要占路,store buffer 又只能按顺序把写交出去。
- 多核一起写:撞的是整颗芯片的总线。每写一条行,总线上走两趟(RFO + 写回),所以有用的写带宽最多约峰值的一半,再算上读写切换的空等,只有峰值的 35%–45%。2 个通道、峰值 50–100 GB/s 的芯片,全核一起写,有用的约 20–40 GB/s。
这些是拿读那一侧的数算的。流式写把总线上的两趟变成一趟,整颗芯片的有用写带宽能回到峰值的 70%–90%。
读和写还有一点不对称:DRAM 的数据线是读写共用的,从读切到写、再切回读,都要空等几个周期,所以读写混在一起的流能跑到的峰值比例比纯读低。部分 CPU 对 rep stosb / rep movsb 这类整块的字符串指令做了优化:知道整条行都会被覆盖,就不做 RFO。glibc 的大块 memset / memcpy 用的就是它们,或者下一节的流式写。
2.3 流式写(non-temporal store):不读回、不进缓存
流式写(non-temporal store,NT store)是另一种写指令(x86 的 movnti、movntdq,intrinsic 是 _mm_stream_si128 等),non-temporal 的意思是“短期内不会再用”。它不读回,也不进缓存:同一条行的写先在写合并缓冲(WC buffer,write-combining buffer;Intel 上和 LFB 共用那 12–24 项)里凑,凑满 64 B 就整条直接写到内存。总线上只走一趟,memcpy 从 3 趟变成 2 趟。
#include <immintrin.h>
// dst 按 16 B 对齐,bytes 是 64 的倍数(整条行写满)
void stream_zero(void* dst, size_t bytes) {
__m128i z = _mm_setzero_si128();
auto* p = static_cast<__m128i*>(dst);
for (size_t i = 0; i < bytes / 16; ++i) _mm_stream_si128(p + i, z);
_mm_sfence(); // 流式写是弱序的:发布“写完了”之前要先 sfence
}
三个坑:
- 要整条行连续写满。只写了半条,WC 缓冲被迫提前送出,变成几次部分写,比普通写还慢。
- 它是弱序的。x86 的普通写彼此按顺序可见(#2 的 TSO),流式写是例外。写完一批数据再写“已就绪”标志,中间要
_mm_sfence(),否则别的核可能先看到标志、后看到数据。 - 数据落在内存,不在缓存。缓存里原有的这条行也被作废。马上要读它的人(你自己或别的核),要付一次完整的内存缺失,约 80–120 ns。
所以它适合“写一大片、短期内没人读”的数据:超过 L3 的大块拷贝、往大文件缓冲里写日志。glibc 的 memcpy 在大小超过一个约为 L3 量级的阈值后,会自动换成流式写。反过来,核间的 SPSC 队列(#4、#5)写进去马上被另一个核读,用流式写等于把数据先推到内存,再让消费者从内存拉回来。
2.4 多核共享带宽:两层天花板各归谁
L3、内存控制器、通道都是全核共享的。第 1 部分总图里的两层天花板,放到多核上分清各归谁:
$$ \text{一个核单独跑} \;\approx\; \frac{\text{在途请求数} \times 64\ \text{B}}{\text{延迟}}, \qquad \text{k 个核一起跑,每个核} \;\approx\; \min\Big(\text{单独跑},\;\; \frac{\text{芯片峰值} \times (70\%\text{–}90\%)}{k}\Big) $$- 一个核单独跑,撞的是第一层。在路上 40 个、延迟 100 ns:40 × 64 B ÷ 100 ns ≈ 25.6 GB/s,和芯片有几个通道无关。
- 核一多,撞的是第二层。2 个通道、峰值约 50–100 GB/s 的芯片,2–4 个核一起扫就满了。再加核,总量不涨,每个核分到的变少,延迟还往上涨(1.7 的带载延迟)。
- 通道多的芯片,也是核多的芯片。8–12 个通道、约 200–500 GB/s,配 32–128 个核,全核一起扫,每个核只分到几 GB/s,比一个核单独跑还少。这类芯片片上的路更长,内存延迟往往也更长,一个核单独跑也只占峰值的一小部分。
- 加通道只抬第二层。一个核单独跑时,加通道几乎没用。要抬第一层,只能让更多请求同时在路上(预取、去掉依赖链),或者缩短延迟。锁高核心频率(#1)也抬不了:DRAM 那段的纳秒数不随核心频率变,核越快,一次缺失折成的周期反而越多。
热路径吃的带宽很少,它在意的是第二层被别人撞满以后的排队。回测、行情回放、日志这类吃带宽的任务,放到别的机器或别的 NUMA 节点,或者用 MBA 限住它们。
3. 延迟这一侧:一次缺失的账、TLB、预取、NUMA
同一个哈希表查找,在一台机器上一次约 90 ns,换一台双插槽服务器、内存是另一个线程初始化的,变成约 180 ns;表从 4 KiB 页换到 2 MiB 页,又少了 20 ns 左右。代码一行没改,变的都是“这一次读要走多远的路”。这一部分把一次缺失的延迟按段拆开,再看 TLB、预取和 NUMA 各自在这笔账上加了什么、藏了什么。
3.1 一次缺失逐段记账:80–120 ns 花在哪
在速查页的硬件地图上就是“读(load)的路径”那四步:翻译地址、逐级查缓存、内存控制器去内存条取、整条行原路返回。把一次走到内存的读按段拆开(各段是帮助理解的拆法,不同芯片差别大):
$$ \text{一次读的延迟} \;\approx\; \underbrace{t_{\text{TLB}}}_{\substack{\text{命中 0} \\ \text{没命中 15–30 ns}}} + \underbrace{t_{\text{L1}} + t_{\text{L2}} + t_{\text{L3}}}_{\substack{\text{逐级查、都没命中} \\ \text{约 15–35 ns}}} + \underbrace{t_{\text{到控制器}}}_{\substack{\text{片上网络} \\ \text{约 10–30 ns}}} + \underbrace{t_{\text{DRAM}}}_{\substack{\text{行命中 / 行空 / 行冲突} \\ \text{约 15 / 30 / 40 ns}}} + \underbrace{t_{\text{返回}}}_{\substack{\text{原路回来、填各级} \\ \text{约 10–20 ns}}} $$TLB 命中时加起来约 80–120 ns。要会讲的 3 点:
- DRAM 本身只占约 1/3。内存条上的 CAS 延迟换算成纳秒,DDR3 到 DDR5 一直在约 13–16 ns,代际升级抬的是带宽,不是延迟。所以换更快的内存条,一次缺失的延迟几乎不变。
- 缓存越多级、片子越大,“没命中”本身越贵。每一级都要先查完、确认没有,才往下一级问,L3 的切片和片上网络越大,路越长。核多的芯片,内存延迟往往也更长(2.4 说的“一个核单独跑也拿不了多少”就是这个原因)。
- 在这 80–120 ns 之上,还有 4 种额外的等:TLB 没命中(3.2)、地址在另一个插槽(3.4)、撞上 DRAM 刷新(最坏多等约 300–500 ns,#6)、总线被别人占满(1.7 的带载延迟,可到 2 倍以上)。热路径的 P99 大多是这几种叠出来的。
3.2 TLB 与大页:地址翻译这一步
这是“读的路径”的第 1 步。前置先讲清楚:程序用的是虚拟地址,内存条认的是物理地址。操作系统按页(默认 4 KiB)管理映射:虚拟地址的低 12 位是页内偏移,翻译前后不变;高位是页号,要查页表才知道它对应哪个物理页。x86-64 的页表有 4 级,查一次要依次读 4 个页表项。每次读内存都这样查就太慢了,所以 CPU 把最近用过的翻译缓存起来,这就是 TLB(translation lookaside buffer,地址翻译缓存)。
TLB 也分两级,每一项管一页:
| TLB | 大约几项 | 4 KiB 页时能覆盖 |
|---|---|---|
| L1 dTLB | 64–96 | 约 256–384 KiB |
| L2 TLB(STLB) | 1500–3000 | 约 6–12 MiB |
工作集超过 STLB 能覆盖的范围,又是随机访问,几乎每次都要查页表,这叫 page walk。例如随机读一个 1 GiB 的数组:1 GiB ÷ 4 KiB = 262,144 页,STLB 只有 1500–3000 项,命中的机会约 1%。
page walk 只多约 15–30 ns,不是再多 4 次内存访问,原因有两个:页表项本身也是普通数据,常用的那些行就在 L1/L2/L3 里;CPU 还有页遍历缓存(page-walk cache),把上面几级页表项缓存住,通常只需要读最后一级。页表项也都不在缓存里时(工作集极大、极随机),一次 walk 要多付一次以上的内存访问,延迟能翻倍。
大页同时从两头下手:
- 一项管 2 MiB,覆盖范围大 512 倍。同一个 STLB 能盖住几 GiB,1 GiB 的数组只要 512 项,放得下,page walk 基本消失。
- 页表少走一级:2 MiB 页的翻译停在第 3 级,walk 本身也更短。
这也是 2.1 说的那一级 TLB 台阶:随机访问的延迟曲线,在工作集超过几 MiB 时多掉一级。热路径的大表(订单簿、id 映射)放在大页上,就是为了去掉这一级。
3.3 硬件预取与软件预取:藏延迟,但只在一页之内
硬件预取器看你的访问规律,提前把后面要用的行发出去,不占你的指令。它不让一次缺失变短,而是让缺失在你用到之前就开始,于是同时在路上的请求更多(1.3 说顺序读能有 30–70 个在路上,靠的就是它)。常见的有两类:
- L1 预取器:下一条行(next-line),或者按同一条 load 指令的固定步长(IP-stride)往前推。
- L2 流预取器(streamer):认出一段升序或降序的流,往前跑 10–30 条行,请求挂在 L2 的 MSHR 里,不占 LFB。
它通常不跨 4 KiB 页。L2 以下的缓存按物理地址工作,预取器手里只有物理地址。虚拟地址上连续的下一页,物理上可能在任何地方,要知道它在哪就得先翻译,而 L2 预取器没有 TLB 可查。所以一条流走到页边界就断了,下一页开头要重新被认出来,前几次访问又是普通缺失。步长很大(比如每次跨 8 KiB)时,每一步都跨页,预取器完全帮不上,这就是#6里按列走大矩阵慢的原因之一。(部分新芯片在 2 MiB 大页里能跨 4 KiB 边界,看实现。)
预取器猜不到的两种访问:pointer chasing(下一个地址要等这次读回来,谁也提前不了)和随机访问(没有规律)。后者如果你自己提前知道地址,就可以用软件预取:
// 一批订单 id,逐个查哈希表里的槽位;提前 D 个把槽位预取进来
void lookup_batch(const uint64_t* ids, size_t n, const Slot* slots, uint64_t mask, size_t D) {
for (size_t i = 0; i < n; ++i) {
if (i + D < n) __builtin_prefetch(&slots[hash(ids[i + D]) & mask]);
process(slots[hash(ids[i]) & mask]);
}
}
提前多少个,还是 Little 定律:预取要提前一整次延迟发出,
$$ D \;\approx\; \frac{\text{一次缺失的延迟}}{\text{每处理一个元素的时间}} $$- 延迟 100 ns、每个元素处理 5 ns:D ≈ 20。
- D 太小:数据还在路上就要用,照样等,只是等得短一点。
- D 太大:同时在路上的预取太多,占满 LFB 反而挡住真正的缺失;或者行到了 L1 却等太久,被别的数据挤掉,白搬一趟。
热路径上更常见的做法是把这种“批量查表”整理出来:一条行情要查好几张表时,先把几个地址都算出来、一起预取,再依次用,让几次缺失叠在一起。
3.4 NUMA 远端:同一台机器,两种内存距离
这是硬件地图上插槽和内存控制器那一层。多插槽的服务器上,每个插槽有自己的内存控制器和内存条,构成一个 NUMA 节点。读本节点的内存约 80–120 ns;读另一个插槽的内存,要先走插槽之间的互连,约 120–200 ns,互连的带宽也比本地内存低。
物理页落在哪个节点,Linux 默认按首次访问(first-touch)决定:哪个线程第一次写这一页,就从那个线程所在的节点分配。这里有一个常见的坑:
- 初始化线程在节点 0 上把订单簿、行情缓冲全部
memset一遍,物理页全落在节点 0。 - 策略线程被绑在节点 1 的隔离核上,热路径每次缺失都是远端访问,平白多出 40–100 ns。
- 只绑线程不会把内存搬过去。要么让绑在目标节点上的线程自己做首次写入,要么启动时用
numactl --cpunodebind=1 --membind=1(或mbind/ libnuma)把内存指定到节点 1。
还有一个运行时的坑:Linux 的自动 NUMA 平衡(kernel.numa_balancing)会周期性地把页标成不可访问,借缺页统计谁在用,再把页迁过去。对热路径来说,这意味着随机冒出来的缺页和 TLB shootdown,低延迟机器上通常关掉(#1)。
应用
1.5.1 讲的多个累加器,在实际优化里对应“B1 串行累加”这张卡片:在 perf 里看到什么、怎么改、怎么验证。
- 信号:annotate 上一条
vaddss/vfmadd占大头(样本常记在它的下一条指令上,要往前看一条);IPC ≪ 1,缓存缺失却很低,这个组合是关键。- 原因:每次
+=都要等上一次的结果,速度卡在浮点加的延迟(4–5 周期)上,而不是吞吐(约 0.5 周期),差 8–10 倍。- 修法:拆成多个独立累加器。纯 C 拆 4 个约快 4 倍;SSE2 向量累加快 4–8 倍;AVX2 向量加 4 个累加器快 8–15 倍。
- 累加器个数:$\text{延迟} \div \text{吞吐} = 4 \div 0.5 = 8$。一般 4 个够,紧循环用 8 个。
- 尾巴:不满一组的尾巴也要用多累加器,否则小输入又退回串行。
- 前提:运算满足结合律(加、乘、min、max)。结果会差一个舍入误差;要求逐位一致就不能用。
- 验证:输出在舍入误差内一致,IPC 至少翻倍。
- 原理:#9 循环优化 3.3;累加器个数的公式见 #9 3.4;编译器替不替你拆,见内存延迟与带宽 1.5.1;实测见 perf 专题 4.6。