TT Lab
开始
学习 学习路径 课程

计算机组成

缓存行与伪共享 — 被邻座拖慢

在 TT Lab 中继续学习

一句话总结

缓存以 64 字节的行为单位移动,各个核心也以行为单位交接所有权,因此即使是互不相干的两个变量,只要落在同一行,它们的性能就会互相拖累。

为什么需要它

在多核系统中,多个核心可以各自把同一块内存加载到自己的缓存里。如果一个核心改了值,另一个核心却还在读旧值,程序就会出错。因此硬件运行着缓存一致性协议(MESI 系列):某个核心要修改一行数据时,必须先让其他核心的副本失效,并获得独占所有权。

这里的关键事实是,这种所有权之争的单位不是变量,而是缓存行。

工作原理

假设线程 A 拼命递增计数器 a,线程 B 拼命递增计数器 b。两者是完全不同的变量,也不需要任何锁。但如果 a 和 b 在结构体里挨着声明,落进了同一个 64 字节的缓存行,那么 A 每写一次,B 的缓存里这一行就会失效;B 每写一次,A 的那一行也会失效。从逻辑上看并不存在共享,但在硬件层面两者却不断互相抢夺。这就叫伪共享(false sharing)。

计数器 a 和 b 落在同一条 64 字节缓存行的前两格,两个核心不断使对方的行所有权失效并互相抢夺;加入填充后二者被放到不同的缓存行,争抢消失——两种情形的对比图

症状很有特点:增加线程后吞吐量不增反降,而且无论怎么查锁竞争都查不出问题,因为根本没有锁。

对策很简单:让每个核心写入的数据位于不同的缓存行。可以在结构体中加入填充,或者把数组元素按缓存行大小的整数倍对齐。

还有一个同类的陷阱,叫分裂锁(split lock)。当原子操作的操作数横跨两条缓存行时,CPU 无法走快速的一致性路径,只能锁住外部总线。这要花费 800 多个周期,期间连毫不相干的其他核心也会被停住。Linux 内核提供了检测这种情况的功能,启动参数 split_lock_detect 的默认值是 warn。如果内核日志里正在打印相关警告,那么很可能确实存在性能问题。

在现场相遇的样子

高性能队列、计数器数组和按线程划分的统计采集器是典型的受害者。尤其危险的是这样设计的数组:“每个线程只写自己的槽位,所以不需要锁”。如果槽位大小是 8 字节,八个槽位就会落进同一行,八个线程便围绕同一行争抢。

反过来,也可以利用这个原理:把会被一起读取的字段集中放在同一行,一次未命中就能全部取回。一起只读使用的值要放在一起,由不同核心写入的值则要分开。规则可以浓缩成这一句。

下一项实验要做什么

用时间来测量“按行移动”这一特性。按不同步幅遍历数组,观察每用完并丢弃一条缓存行的代价不同时,读取开销会如何变化;再按行优先和列优先两种顺序遍历同一个矩阵,确认仅靠访问顺序就能拉开几倍的差距。最后把遍历切成小块,把这部分损失挽回来。