Skip to content

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 是 ES: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)

r1r2 同时为 0!这就好像 读操作跑到了写操作前面执行 一样。 这就是所谓的 Total Store Order (TSO) 内存模型下允许的唯一乱序:写后读 (Store-Load) 乱序

既然 Store Buffer 导致别人看不见我的修改,那我就需要一个指令,强制清空 Store Buffer。

这就是 MFENCE (x86) 或 std::atomic 的作用。

MFENCE 的作用:

  1. 停! CPU 暂停流水线。
  2. 刷! 强制把 Store Buffer 里的所有东西都刷进 L1 Cache(这意味着要等待 MESI 完成)。
  3. 等! 直到 Store Buffer 空了,或者该写的数据都对外可见了。
  4. 走! 继续执行后面的读指令。

理解了 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 队列

  1. Core A (Producer)
  2. data = 100
  3. head = 1 (Release)。
  4. (Core A 把 data 的失效消息发给 Core B)。
  5. Core B (Consumer)
  6. 现状:Core B 的 Cache 里存着旧的 data = 0 (Shared状态)。
  7. 收信:Core B 收到了“data 失效”的消息,扔进 Invalidate Queue,还没处理。
  8. head:读到了 1(假设 head 已经同步过来了)。
  9. data
    • Core B 准备读 data
    • 它查 Cache:dataS 状态(因为失效消息还在队列里排队,没执行)。
    • 悲剧:Core B 认为 Cache 有效,直接用了旧值 0
  10. 结果:Core B 看到了新的 head,却读到了旧的 data。逻辑崩盘。

Acquire 做了什么?(硬件层面)

std::memory_order_acquire 就是用来填这个坑的。

当你执行 head.load(std::memory_order_acquire) 时,硬件(在弱内存模型架构如 ARM 上)会做以下事情:

强制刷空 Invalidate Queue (Flush Invalidate Queue)

  1. 停! 在执行这条 Load 指令之后的所有读取指令,都不许执行。
  2. 刷! 必须把 Invalidate Queue 里积压的所有“失效通知”全部处理完。
  3. 这意味着:把该标为 Invalid 的 Cache Line 统统标为 Invalid。
  4. 读! 队列空了之后,再去执行后面的读取。

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 里那个分支指令之后的所有条目全部抹掉
  • 因为没有提交,所以没有副作用