缓存行与伪共享 — 被邻座拖慢
一句话总结
缓存以 64 字节的行为单位移动,各个核心也以行为单位交接所有权,因此即使是互不相干的两个变量,只要落在同一行,它们的性能就会互相拖累。
为什么需要它
在多核系统中,多个核心可以各自把同一块内存加载到自己的缓存里。如果一个核心改了值,另一个核心却还在读旧值,程序就会出错。因此硬件运行着缓存一致性协议(MESI 系列):某个核心要修改一行数据时,必须先让其他核心的副本失效,并获得独占所有权。
这里的关键事实是,这种所有权之争的单位不是变量,而是缓存行。
工作原理
假设线程 A 拼命递增计数器 a,线程 B 拼命递增计数器 b。两者是完全不同的变量,也不需要任何锁。但如果 a 和 b 在结构体里挨着声明,落进了同一个 64 字节的缓存行,那么 A 每写一次,B 的缓存里这一行就会失效;B 每写一次,A 的那一行也会失效。从逻辑上看并不存在共享,但在硬件层面两者却不断互相抢夺。这就叫伪共享(false sharing)。
症状很有特点:增加线程后吞吐量不增反降,而且无论怎么查锁竞争都查不出问题,因为根本没有锁。
对策很简单:让每个核心写入的数据位于不同的缓存行。可以在结构体中加入填充,或者把数组元素按缓存行大小的整数倍对齐。
还有一个同类的陷阱,叫分裂锁(split lock)。当原子操作的操作数横跨两条缓存行时,CPU 无法走快速的一致性路径,只能锁住外部总线。这要花费 800 多个周期,期间连毫不相干的其他核心也会被停住。Linux 内核提供了检测这种情况的功能,启动参数 split_lock_detect 的默认值是 warn。如果内核日志里正在打印相关警告,那么很可能确实存在性能问题。
在现场相遇的样子
高性能队列、计数器数组和按线程划分的统计采集器是典型的受害者。尤其危险的是这样设计的数组:“每个线程只写自己的槽位,所以不需要锁”。如果槽位大小是 8 字节,八个槽位就会落进同一行,八个线程便围绕同一行争抢。
反过来,也可以利用这个原理:把会被一起读取的字段集中放在同一行,一次未命中就能全部取回。一起只读使用的值要放在一起,由不同核心写入的值则要分开。规则可以浓缩成这一句。
下一项实验要做什么
用时间来测量“按行移动”这一特性。按不同步幅遍历数组,观察每用完并丢弃一条缓存行的代价不同时,读取开销会如何变化;再按行优先和列优先两种顺序遍历同一个矩阵,确认仅靠访问顺序就能拉开几倍的差距。最后把遍历切成小块,把这部分损失挽回来。