2

伪共享(False Sharing)简介

 2 years ago
source link: https://lotabout.me/2022/False-Sharing-Introduction/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Table of Contents

如果你阅读过 Java 的 Striped64 源码(没看过的可以看看博主的 这篇文章),可能遇到过 @Contended 注解。如果你经常看 C 语言的代码,也可能遇到过在结构体加 padding 的情形。它们都是为了提高缓存的性能,解决伪共享(False Sharing)的问题。

缓存行(cache line)

首先需要知道一个概念:cache line(缓存行)。缓存从内存加载数据时,并不是只加载我们请求的那部分,而是会多加载一些,例如我们想访问一个 int,只有 4 字节,但缓存会一次性加载 64 字段(不同机器不同)。缓存每次处理的这一“块”数据,就叫 cache line。

为什么缓存要多加载数据呢?为了利用空间局部性(space locality)来提高性能 [1]。相当于是缓存做了猜想,后续地址的数据,通常就是接下来马上要访问的数据,提前加载能提高性能。

缓存一致性与 MESI 协议

现代 CPU 体系中,一般每个核都单独配备了自己的(L1)缓存,为了保证多个 CPU 在读写缓存时保证整体数据的一致性,通常需要使用缓存一致性协议,MESI 就是其中一种(可以参考博主的这篇文章)。

MESI 协议可以简单理解为“踢人协议”,如果一个 CPU 写数据到缓存里,则需要“踢”掉其它缓存里的副本。

MESI-invalidate.svg

上图中,第 ⑦ 步就是“踢人”的操作。同时要注意如果 CPU 对缓存只做“读”操作,缓存也是需要同步的,如上图的第 ⑤ 步,只是它的开销更小。

伪共享(False Sharing)

缓存一致性说的是一个 cache line 在不同缓存间的同步操作。那如果一个 cache line 上存了两个变量,并且两个变量分别被不同的线程写入呢?

MESI-false-sharing.svg

可以看到,虽然 CPU A 和 CPU B 各自在写自己关心的变量 xy,但由于它们存在于同一个 cache line,每次写入都会造成另一个 CPU 的缓存失效。造成严重的性能问题。

常见场景与解法

我们看到伪共享的发生有两个条件:

  1. 两个变量在同一个缓存行里。通常是一个类/结构体的两个 field,或是同一个数组的相邻元素
  2. 不同线程同时对两个地址读写[2]。因此通常发生在高并发的场景下。

由于 #2 条件多线程处理一般是业务要求,解法通常是打破 #1 条件:加 padding,让一个cache line 里只保留一个变量[3]。例如 int 只占 4 字节,可以在后面加 15个没用的 int 变量,撑满 64 字节[4]。而 Java 专门提供了@Contended[5] 来简化这种情形。

False Sharing 的影响有多大?

JMH 有一个测 False Sharing 的 Benchmark ,在我的机器上(20c、Java 11)运行结果[6]如下:

Benchmark                          Mode  Cnt      Score     Error   Units
BenchmarkRunner.baseline thrpt 25 10145.377 ± 240.354 ops/us
BenchmarkRunner.baseline:reader thrpt 25 1305.421 ± 120.908 ops/us
BenchmarkRunner.baseline:writer thrpt 25 8839.956 ± 211.739 ops/us
BenchmarkRunner.contended thrpt 25 11329.372 ± 105.857 ops/us
BenchmarkRunner.contended:reader thrpt 25 2845.015 ± 48.006 ops/us
BenchmarkRunner.contended:writer thrpt 25 8484.357 ± 112.052 ops/us
BenchmarkRunner.hierarchy thrpt 25 11373.481 ± 39.691 ops/us
BenchmarkRunner.hierarchy:reader thrpt 25 2885.091 ± 56.386 ops/us
BenchmarkRunner.hierarchy:writer thrpt 25 8488.389 ± 78.959 ops/us
BenchmarkRunner.padded thrpt 25 11338.519 ± 49.043 ops/us
BenchmarkRunner.padded:reader thrpt 25 2868.776 ± 51.762 ops/us
BenchmarkRunner.padded:writer thrpt 25 8469.743 ± 78.427 ops/us
BenchmarkRunner.sparse thrpt 25 9288.740 ± 63.073 ops/us
BenchmarkRunner.sparse:reader thrpt 25 2582.364 ± 26.045 ops/us
BenchmarkRunner.sparse:writer thrpt 25 6706.376 ± 77.000 ops/us

baseline、contended 及 padded 吞吐上的差别大概 10%(网上一些文章差异在 2 倍、 3 倍,和我的结果出入这么大的原因还没找到)。我们再用 perf 对比 cache misses:

---------------------------- Baseline ------------------------------------
1,012,415.83 msec task-clock # 19.258 CPUs utilized
39,279 context-switches # 0.039 K/sec
1,813 cpu-migrations # 0.002 K/sec
82,632 page-faults # 0.082 K/sec
4,855,609,024,017 cycles # 4.796 GHz (50.03%)
4,682,761,843,004 instructions # 0.96 insn per cycle (62.52%)
685,213,954,685 branches # 676.811 M/sec (62.53%)
160,820,719 branch-misses # 0.02% of all branches (62.51%)
2,551,078,768,362 L1-dcache-loads # 2519.793 M/sec (62.47%)
23,872,018,958 L1-dcache-load-misses # 0.94% of all L1-dcache hits (62.47%)
12,410,125,606 LLC-loads # 12.258 M/sec (49.98%)
2,810,037 LLC-load-misses # 0.02% of all LL-cache hits (50.01%)

---------------------------- Contended -----------------------------------
1,011,118.07 msec task-clock # 19.219 CPUs utilized
38,773 context-switches # 0.038 K/sec
1,830 cpu-migrations # 0.002 K/sec
82,441 page-faults # 0.082 K/sec
4,849,385,107,175 cycles # 4.796 GHz (49.99%)
6,794,835,895,672 instructions # 1.40 insn per cycle (62.48%)
1,006,635,368,787 branches # 995.567 M/sec (62.49%)
147,370,270 branch-misses # 0.01% of all branches (62.49%)
3,585,031,069,557 L1-dcache-loads # 3545.611 M/sec (62.50%)
615,324,166 L1-dcache-load-misses # 0.02% of all L1-dcache hits (62.51%)
167,043,519 LLC-loads # 0.165 M/sec (50.01%)
2,434,845 LLC-load-misses # 1.46% of all LL-cache hits (50.01%)

对比其中的 L1-dcache-load-misses,可以看出,加了 @Contended 的 cache miss 只有 baseline 的 3%

缓存的加载写入以 cache line 为单位,典型的大小为 64B。为了保证多 CPU 下缓存数据的一致性,需要使用一些缓存一致性协议,MESI 是其中的一个经典协议,写入缓存行时会“踢掉”其它 CPU 上的缓存。如果两个变量在同一个 cache line 中,且多线程频繁读写这两个变量,会导致多 CPU “互踢”对方的 cache line,导致性能下降。在博主的机器上 False Sharing 实测大概慢 10%,而 cache miss 大概是正常的 33 倍。


  1. 注意一般 C 语言里 padding 还有另一个作用,将数据按“字”来对齐地址,这也有助于提高性能,但缓存的视角主要还是在局部性上

  2. 两个线程同时写入的情况比较明显;一写一读也有问题;两个线程都是读则没有问题

  3. 采用 padding 的方式其实不一定靠谱,因为编译器优化有可能会把没用的字段去掉

  4. 64 这个数字并不是固定的,有些机器会设置为 128 字节, Linux 下可以执行 getconf LEVEL1_DCACHE_LINESIZE 来查看 cache line 大小, MacOS 下执行 sysctl hw.cachelinesize

  5. Java 8 中通过 @sun.misc.Contended 引用, Java 9 及之后,通过 @jdk.internal.vm.annotation.Contended 引用,但需要额外 export 一些包

  6. 需要在启动参数上加上 -XX:-RestrictContended,用户代码里加的 @Contended 才能生效。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK