伪共享

什么是伪共享

伪共享与缓存机制有关,该机制用于维护存储在多个 CPU 缓存中的同一个缓存行的数据一致性;其学术定义见于 [1]。考虑一个包含引用计数和一个字符串的 struct with

struct foo {
        refcount_t refcount;
        ...
        char name[16];
} ____cacheline_internodealigned_in_smp;

成员 “refcount”(A) 和 “name”(B) _共享_ 一个缓存行,如下所示

              +-----------+                     +-----------+
              |   CPU 0   |                     |   CPU 1   |
              +-----------+                     +-----------+
             /                                        |
            /                                         |
           V                                          V
       +----------------------+             +----------------------+
       | A      B             | Cache 0     | A       B            | Cache 1
       +----------------------+             +----------------------+
                           |                  |
---------------------------+------------------+-----------------------------
                           |                  |
                         +----------------------+
                         |                      |
                         +----------------------+
            Main Memory  | A       B            |
                         +----------------------+

“refcount” 会被频繁修改,而 “name” 仅在对象创建时设置一次且从不修改。当许多 CPU 同时访问 “foo” 时,“refcount” 经常被某个 CPU 递增,而其他 CPU 则在读取 “name”,由于这种 “共享”,所有这些读取的 CPU 不得不一遍又一遍地重新加载整个缓存行,尽管 “name” 从未改变过。

有许多由伪共享导致的性能退化的现实案例。其中之一是 mm_struct 结构体内部的 rw_semaphore “mmap_lock”,其缓存行布局的改变引发了性能退化,Linus 对此进行了分析,见 [2]

有害的伪共享有两个关键因素

  • 被许多 CPU 访问(共享)的全局数据

  • 在对该数据的并发访问中,至少存在一个写操作:写/写或写/读的情况。

这种共享可能来自完全不相关的内核组件,或者同一内核组件的不同代码路径。

伪共享陷阱

过去,当一个平台只有几个 CPU 时,为了使热数据成员保持缓存热态并节省缓存行/TLB,可能会刻意将它们放在同一个缓存行中,比如锁以及受其保护的数据。但对于拥有数百个 CPU 的现代大型系统来说,当锁竞争激烈时,这可能就行不通了,因为持有锁的 CPU 可能会写入数据,而其他 CPU 则忙于在锁上自旋。

回顾过去的案例,伪共享有几种频繁出现的模式

  • 锁(自旋锁/互斥锁/信号量)和受其保护的数据被刻意放在同一个缓存行中。

  • 全局数据被放在同一个缓存行中。一些内核子系统有许多体积很小(4 字节)的全局参数,它们很容易被组合在一起并放入一个缓存行。

  • 大型数据结构的数据成员在未被注意的情况下随机地靠在一起(缓存行通常为 64 字节或更大),例如 “mem_cgroup” 结构体。

接下来的“缓解”一节提供了实际的例子。

除非刻意检查,否则伪共享很容易发生;对于性能关键的工作负载,运行特定工具来检测影响性能的伪共享情况并进行相应的优化,是非常有价值的。

如何检测和分析伪共享

perf record/report/stat 被广泛用于性能调优,一旦检测到热点,就可以进一步使用 “perf-c2c” 和 “pahole” 等工具来检测和精确定位可能存在伪共享的数据结构。“addr2line” 在存在多层内联函数时解码指令指针也非常有用。

perf-c2c 可以捕获具有最多伪共享命中的缓存行、访问该缓存行的已解码函数(文件的行号)以及数据的内联偏移量。简单的命令如下

$ perf c2c record -ag sleep 3
$ perf c2c report --call-graph none -k vmlinux

在测试 will-it-scale 的 tlb_flush1 用例时运行上述命令,perf 会报告类似以下内容

Total records                     :    1658231
Locked Load/Store Operations      :      89439
Load Operations                   :     623219
Load Local HITM                   :      92117
Load Remote HITM                  :        139

#----------------------------------------------------------------------
    4        0     2374        0        0        0  0xff1100088366d880
#----------------------------------------------------------------------
  0.00%   42.29%    0.00%    0.00%    0.00%    0x8     1       1  0xffffffff81373b7b         0       231       129     5312        64  [k] __mod_lruvec_page_state    [kernel.vmlinux]  memcontrol.h:752   1
  0.00%   13.10%    0.00%    0.00%    0.00%    0x8     1       1  0xffffffff81374718         0       226        97     3551        64  [k] folio_lruvec_lock_irqsave  [kernel.vmlinux]  memcontrol.h:752   1
  0.00%   11.20%    0.00%    0.00%    0.00%    0x8     1       1  0xffffffff812c29bf         0       170       136      555        64  [k] lru_add_fn                 [kernel.vmlinux]  mm_inline.h:41     1
  0.00%    7.62%    0.00%    0.00%    0.00%    0x8     1       1  0xffffffff812c3ec5         0       175       108      632        64  [k] release_pages              [kernel.vmlinux]  mm_inline.h:41     1
  0.00%   23.29%    0.00%    0.00%    0.00%   0x10     1       1  0xffffffff81372d0a         0       234       279     1051        64  [k] __mod_memcg_lruvec_state   [kernel.vmlinux]  memcontrol.c:736   1

关于 perf-c2c 的一篇极好的介绍见 [3]

“pahole” 按缓存行粒度解码数据结构布局。用户可以将 perf-c2c 输出中的偏移量与 pahole 的解码结果进行匹配,以定位准确的数据成员。对于全局数据,用户可以在 System.map 中搜索数据地址。

可能的缓解措施

并非总是需要缓解伪共享。伪共享的缓解措施应当平衡性能收益与复杂性及空间消耗。有时,较低的性能是可以接受的,没有必要对每个很少使用的数据结构或冷代码路径进行过度优化。

随着核心数量的增加,伪共享损害性能的情况越来越频繁地出现。由于这些不良影响,各个子系统(如网络和内存管理)中已经提出了许多补丁并被合并。一些常见的缓解措施(带示例)是

  • 将热全局数据隔离在其自己的专用缓存行中,即使它只是一个 “short” 类型。缺点是会消耗更多的内存、缓存行和 TLB 条目。

  • 重新组织数据结构,将相互干扰的成员分隔到不同的缓存行。缺点之一是它可能会引入针对其他成员的新伪共享。

  • 尽可能用 “读” 替换 “写”,特别是在循环中。例如对于某些全局变量,使用比较(读)后写入来代替无条件写入。例如,使用

    if (!test_bit(XXX))
            set_bit(XXX);
    

    而不是直接使用 “set_bit(XXX);”,对于 atomic_t 数据也是如此

    if (atomic_read(XXX) == AAA)
            atomic_set(XXX, BBB);
    
  • 尽可能将热全局数据转变为 “per-cpu 数据 + 全局数据”,或者合理提高将 per-cpu 数据同步到全局数据的阈值,以减少或推迟对该全局数据的 “写” 操作。

当然,所有的缓解措施都应经过仔细验证以确保不会引发副作用。为了在编码时避免引入伪共享,最好

  • 了解缓存行边界

  • 将大多只读的字段归类到一起

  • 将同时写入的事物归类到一起

  • 将频繁读取和频繁写入的字段分隔在不同的缓存行上。

并且最好添加一条注释说明对伪共享的考量。

需要注意的是,有时即使检测并解决了严重的伪共享问题,由于热点转移到了新的地方,性能可能仍然没有明显的改善。

杂项

还有一个未决问题是,内核拥有一个可选的数据结构随机化机制,这也会打乱数据成员之间缓存行共享的情况。