伪共享¶
什么是伪共享¶
伪共享与多个 CPU 缓存中存储的同一缓存行的数据一致性维护机制有关;其学术定义见[1]。考虑一个包含引用计数和一个字符串的结构体
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 频繁递增,而 ‘name’ 由其他 CPU 读取,则所有读取 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 条目。
提交 91b6d3256356 (“net: 缓存对齐 tcp_memory_allocated, tcp_sockets_allocated”)
重新组织数据结构,将相互干扰的成员分离到不同的缓存行。一个缺点是可能会引入其他成员新的伪共享。
提交 802f1d522d5f (“mm: page_counter: 重新布局结构以减少伪共享”)
尽可能用 ‘read’ 替换 ‘write’,尤其是在循环中。例如,对于某些全局变量,使用比较(读取)然后写入而不是无条件写入。例如,使用
if (!test_bit(XXX)) set_bit(XXX);
代替直接的 “set_bit(XXX);”,对于 atomic_t 数据也是如此
if (atomic_read(XXX) == AAA) atomic_set(XXX, BBB);
提交 7b1002f7cfe5 (“bcache: 修复 bcache_dev_sectors_dirty_add() 多线程 CPU 伪共享”)
提交 292648ac5cf1 (“mm: gup: 允许 FOLL_PIN 在 SMP 中扩展”)
在可能的情况下,将热全局数据转换为“per-cpu 数据 + 全局数据”,或者适当地增加将 per-cpu 数据同步到全局数据的阈值,以减少或推迟对该全局数据的“写入”。
提交 520f897a3554 (“ext4: 为 extent_status 缓存命中/未命中 使用 percpu_counters”)
提交 56f3547bfa4d (“mm: 根据 vm 过度提交策略调整 vm_committed_as_batch”)
当然,应仔细验证所有缓解措施,以避免引起副作用。为了避免在编码时引入伪共享,最好
注意缓存行边界
将大多数只读字段组合在一起
将同时写入的内容组合在一起
将频繁读取和频繁写入的字段分离在不同的缓存行上。
最好添加注释说明伪共享的考虑。
一个注意事项是,有时即使在检测到并解决了严重的伪共享之后,性能可能仍然没有明显的改善,因为热点会转移到新的位置。
其他¶
一个未解决的问题是,内核具有一个可选的数据结构随机化机制,该机制还会随机化数据成员之间缓存行共享的情况。