CPU
Cache
缓存行 (Cache Line)
缓存行是 CPU 缓存(L1、L2、L3)与主内存 (RAM) 之间数据交换的最小单位,通常大小为 64 字节。
利用空间局部性 (Spatial Locality)。当 CPU 请求一个字节的数据时,OS 知道程序很可能接下来会访问周围的字节。因此,CPU 会一口气把整个 64 字节的缓存行都从 RAM/L3 搬到 L1/L2 中。
缓存块的结构
缓存(Cache)本质上是一个高速的查找表。它被组织成一系列缓存块 (Cache Block)
| 组成部分 | 描述 |
|---|---|
| 数据区 (Data) | 存储实际的 64 字节 数据(即缓存行)。 |
| 有效位 (Valid Bit) | 类似于页表中的有效位,用于标记这个缓存块中存储的数据是否有效或已初始化。 |
| 标记 (Tag) | 存储一个地址片段,用于识别这个缓存块中的数据来自 RAM 中的哪个地址。 |
分组相联映射 (Set-Associative Mapping)
一个完整的 Cache Entry(缓存条目)在硬件里的布局大致如下(以 64位地址、64字节缓存行的一般实现为例):
| 区域 | MESI 状态位 | 标签 (Tag) | 替换策略位 (LRU) | 数据块 (Data Block) |
|---|---|---|---|---|
| 大小 | 2 bits | ~40 bits | ~2-4 bits | 64 Bytes (512 bits) |
| 功能 | 记录 M/E/S/I | 记录内存地址 | 决定谁被踢出 | 实际存储的数据 |
| 位置 | Tag RAM | Tag RAM | Tag RAM | Data RAM |
当你执行 MOV RAX, [0x12345678] 时,CPU 拿到的这个 64 位地址,会被硬件逻辑这把“刀”咔嚓切成三段:
地址=[ Tag (标记) ∣ Index (索引) ∣ Offset (偏移量) ]
CPU 提取地址中间的 Index 位。 假设 Index 是 000010 (十进制 2)。 硬件直接根据这个数字,电路选通第 2 号组 (Set 2)。
这是硬件最厉害的地方。 在这个组里,有 8 个缓存行,每个行都有一个 Tag RAM。 CPU 会把地址里的 Tag 部分,同时与这 8 个 Tag 进行比对(并行比较器)。
根据 Offset (最后 6 位),从这 64 字节中抠出你要的那 4 个字节 (int)。
速度
在一颗 ~3 GHz 的现代 CPU 上,大概是这样:Gist+1
- CPU 一个时钟周期:≈ 0.3–0.4 ns
- L1 Cache 命中:≈ 0.5–1 ns(大约 3–4 个周期)
- L2 Cache 命中:≈ 3–5 ns(大约 10–15 个周期)
- L3 Cache 命中:≈ 10–20 ns(几十个周期)
- 主内存 DRAM:≈ 80–150 ns(两百多甚至几百个周期)
你可以先记一条粗略比例:
L1 : L2 : L3 : 内存 ≈ 1 : 10 : 30 : 100
MESI
针对同一条 cache line,每个核心自己的缓存里都有一个状态:
- M (Modified):我这份是最新的,只在我这里,并且比内存新(脏)。
- E (Exclusive):我这份是最新的,只在我这里,而且和内存一致(干净)。
- S (Shared):我这份是最新的,可能多个核都有,而且和内存一致(干净)。
- I (Invalid):我这里没有有效副本。
读(Read)
- 我若是 M/E/S:直接读,不发总线事务(命中)。
- 如果状态是 I (Cache Miss):
- Core A 向总线广播:“谁有
x?我要读!” - 情况 1:别人都没有 (Snoop Miss) -> 从内存读。状态变为 E (Exclusive)。
- 情况 2:别人 (Core B) 有 (Snoop Hit) ->
- 如果 Core B 是 E 或 S:Core B 说“我有”,大家一起变成 S (Shared)。
- 如果 Core B 是 M (最麻烦):Core B 必须先把它修改的数据写回内存 (Flush),变成 S。然后 Core A 再去读,变成 S。
写(Write)
-
我若是 M:直接写。
-
我若是 E:直接升级成 M(不需要通知别人,因为别人没有)。
-
我若是 S:关键点:Core A 必须在总线上大喊:“我要改了!你们都把手里的
x销毁!” (发送 RFO - Request For Ownership 信号)。
其他核心监听到后,把状态设为 I (Invalid)。
Core A 获得独占权,修改数据,状态变为 M。
- 我若是 I:Core A 发送 RFO (读并独占) 信号。
如果别人有 M,别人要先写回内存。
Core A 拿到数据后,修改它,状态变为 M。
Atomic操作速度
我们假设 CPU 缓存命中(L1 Cache Hit),且没有极端的竞争(Contention):
| 梯队 | 操作类型 | C++ 示例 | 汇编指令 (x86) | 延迟 (Cycles) | 评价 |
|---|---|---|---|---|---|
| T1 | 纯加载/存储 (Relaxed) | load(relaxed) store(relaxed) |
MOV |
~1 | 极快,等同普通变量 |
| T2 | 发布/获取 (Acquire/Release) | load(acquire) store(release) |
MOV (x86) LDAR/STLR (ARM) |
~1 - 3 | 在 x86 上几乎免费 |
| T3 | 顺序一致性 (Seq_Cst) | store(seq_cst) |
MOV + MFENCE |
~10 - 20 | 需要强屏障 |
| T4 | 读改写 (RMW) | fetch_add exchange |
LOCK XADD LOCK XCHG |
~20 - 50 | 锁缓存,重操作 |
| T5 | CAS (比较并交换) | compare_exchange |
LOCK CMPXCHG |
~20+ (失败会重试) | 最复杂,可能循环 |
Store Buffer
Store Buffer(写缓冲)是 CPU 微架构中最关键的性能组件之一. 40 到 70 个条目 (Entries)
场景:CPU Core A 想执行 x = 1。 现状:x 在 Core A 的缓存中是 S (Shared) 状态,或者干脆是 I (Invalid)。 必须做的动作:Core A 必须向总线发送 RFO (Request For Ownership),等待 Core B、Core C... 响应并确认“已失效”。
矛盾点:
- CPU 执行一条指令只要 < 1ns。
- 发送 RFO 并等待总线响应(特别是如果还要去内存取数据)可能需要 10ns ~ 100ns。
如果 CPU 傻傻地等着 RFO 完成才能继续下一条指令,性能会暴跌 100 倍。
解决方案: CPU 说:“我不等了。我把 x = 1 这个动作写到一个临时小本子上,然后我就假装写完了,继续干后面的活。硬件电路你帮我在后台处理 MESI,等拿到独占权(M 状态)了,再真正把小本子上的内容写进 Cache。”
这个“临时小本子”,就是 Store Buffer。
Store Buffer 位于 CPU 核心 (Core) 和 私有 L1 Cache 之间。
- 它是私有的:每个 Core 都有自己独立的 Store Buffer。
内存乱序 (Store-Load Reordering)
Store Buffer 对单个核心是完美的,但对多核心来说,它制造了一个严重的幻觉。
这就是大名鼎鼎的 Store-Load 重排。
经典案例 (Dekker/Peterson 算法失效):
初始状态:x = 0, y = 0。 Core A 和 Core B 同时执行以下代码:
| Core A | Core B |
|---|---|
x = 1 (Store) |
y = 1 (Store) |
r1 = y (Load) |
r2 = x (Load) |
r1 和 r2 同时为 0!这就好像 读操作跑到了写操作前面执行 一样。 这就是所谓的 Total Store Order (TSO) 内存模型下允许的唯一乱序:写后读 (Store-Load) 乱序。
既然 Store Buffer 导致别人看不见我的修改,那我就需要一个指令,强制清空 Store Buffer。
这就是 MFENCE (x86) 或 std::atomic 的作用。
MFENCE 的作用:
- 停! CPU 暂停流水线。
- 刷! 强制把 Store Buffer 里的所有东西都刷进 L1 Cache(这意味着要等待 MESI 完成)。
- 等! 直到 Store Buffer 空了,或者该写的数据都对外可见了。
- 走! 继续执行后面的读指令。
理解了 Store Buffer,你就理解了 C++ std::memory_order 的本质:
| C++ 内存序 | 对 Store Buffer 的影响 | 性能 |
|---|---|---|
| Relaxed | 完全不管。写进 Buffer 就走人。别人什么时候看到?随缘。 | 最快 (T1) |
| Release | 部分限制。保证我之前的读写操作,在我把这个值扔进 Buffer 之前都完成了。(主要是编译器层面的重排限制,x86 硬件天然保证 FIFO 写)。 | 快 (T2) |
| Seq_Cst | 暴力清空。强制 Drain Store Buffer (MFENCE / LOCK)。必须等 Buffer 空了才能继续。 |
慢 (T3) |
Invalidate Queue(失效队列)
回顾一下 MESI 协议: 当 Core A 要修改数据(Store)时,它必须发消息给 Core B:“把你的缓存行设为 I (Invalid)”。 Core B 收到后,必须把 Cache Line 标为 Invalid,然后给 Core A 回复“收到,已作废”。
矛盾点: Core B 正在忙着疯狂运算,突然来了一个“失效消息”。如果 Core B 必须立刻放下手里的活,去操作 Cache 标记为 I,再回复消息,这会严重打断 Core B 的流水线。
解决方案: Core B 说:“好了好了,别催。你的‘失效通知’我收到了,我把它扔进我的一个待办列表 (Invalidate Queue) 里,我马上就给你回‘已作废’。但我实际稍后有空了再去真的标记 Cache。”
这就是 Invalidate Queue:它是 Core B 为了敷衍 Core A,让自己不被打断而设计的缓冲
场景:SPSC 队列
- Core A (Producer):
- 写
data = 100。 - 写
head = 1(Release)。 - (Core A 把
data的失效消息发给 Core B)。 - Core B (Consumer):
- 现状:Core B 的 Cache 里存着旧的
data = 0(Shared状态)。 - 收信:Core B 收到了“
data失效”的消息,扔进 Invalidate Queue,还没处理。 - 读
head:读到了 1(假设head已经同步过来了)。 - 读
data:- Core B 准备读
data。 - 它查 Cache:
data是 S 状态(因为失效消息还在队列里排队,没执行)。 - 悲剧:Core B 认为 Cache 有效,直接用了旧值 0。
- Core B 准备读
- 结果:Core B 看到了新的
head,却读到了旧的data。逻辑崩盘。
Acquire 做了什么?(硬件层面)
std::memory_order_acquire 就是用来填这个坑的。
当你执行 head.load(std::memory_order_acquire) 时,硬件(在弱内存模型架构如 ARM 上)会做以下事情:
强制刷空 Invalidate Queue (Flush Invalidate Queue)
- 停! 在执行这条 Load 指令之后的所有读取指令,都不许执行。
- 刷! 必须把 Invalidate Queue 里积压的所有“失效通知”全部处理完。
- 这意味着:把该标为 Invalid 的 Cache Line 统统标为 Invalid。
- 读! 队列空了之后,再去执行后面的读取。
x86 的特殊性 (又来了)
你可能会问:“我在 x86 上用 acquire,性能会变差吗?”
x86 架构(包括 Intel 和 AMD)非常强壮。它在硬件设计上保证了 Total Store Order (TSO)。
TSO 承诺:所有的 Load 操作,天然就是带有 Acquire 语义的。
Invalidate Queue (嗅探缓冲):这一点最关键!
- Load 单元会去“偷看”Invalidate Queue。
- 如果发现队列里有一个针对该地址的“作废通知”还没处理。
- CPU 会立刻认为 Cache 里的数据已经失效了,哪怕 Cache 的 Tag 还没来得及变。
- CPU 会强制从内存(或者 L3)重新拉取最新的数据。
ROB
重排序缓冲区 (Re-order Buffer, 简称 ROB) 是现代高性能 CPU 中实现乱序执行 (Out-of-Order Execution) 的核心组件。
它的核心作用只有一句话:让指令“乱序执行”,但是“顺序提交”。
第一步:发射 (Dispatch/Issue) —— "领号"
- 当指令从解码器出来时,CPU 会按程序顺序给它在 ROB 里分配一个位置(Entry)。
- 比如指令 A 领到了 ROB#1,指令 B 领到了 ROB#2。
- 此时它们是有序的。
第二步:执行 (Execute) —— "乱序做菜"
- 指令进入运算单元(ALU/FPU)。
- 关键点:谁的数据准备好了,谁先算。
- 指令 B (加法) 瞬间算完了,它把结果写回到 ROB#2 这个格子里(或者通过重命名指向物理寄存器),标记为“完成”。
- 指令 A (除法) 还在算,ROB#1 空着。
- 此时它们是乱序的。
第三步:提交/退休 (Commit/Retire) —— "按序上菜"
- CPU 的提交单元只盯着 ROB 的队头 (Head) 看。
- 检查 ROB#1 (Head):还没算完?那就卡住,所有后面的指令(包括已经算完的 ROB#2)都不能走。
- 等待:直到 ROB#1 算完了,没有异常,预测正确。
- 提交:
- 把 ROB#1 的结果真正写入架构寄存器 (Architectural Register)(程序员能看到的那个寄存器)。
- 如果是 Store 指令,允许 Store Buffer 把数据刷出去。
- 从 ROB 里删除 #1。
- 轮到 ROB#2:发现早就标为“完成”了,立刻提交。
B. 投机执行与分支预测 (Speculative Execution)
遇到了 if (x > 0)。CPU 猜 x > 0,于是狂跑里面的代码。
- 这些代码产生的结果都暂时存在 ROB 里。
- 后来算出
x <= 0,预测失败了。 - CPU 只要把 ROB 里那个分支指令之后的所有条目全部抹掉。
- 因为没有提交,所以没有副作用。