历史
现代CPU采用缓存一致性协议,如MESI,来同步不同核心的私有L1缓存中的数据。当独立线程写入意外位于同一缓存行(通常为64或128字节)的不同内存位置时,硬件通过不断失效和转移对该行的所有权来序列化这些操作,这种现象被称为虚假共享。C++17引入了std::hardware_destructive_interference_size来公开架构的缓存行宽度,允许开发人员分离可变数据,从而确保每个线程的热变量占据不同的行,避免这种同步开销。
问题
将alignas(std::hardware_destructive_interference_size)应用于具有自动存储生命周期的变量确保对象的起始地址是特定线程的栈帧内缓存行大小的倍数。然而,这种对齐仅限于线程对内存的视图,并不能保证物理缓存行的独占占用。如果对象小于缓存行,则相邻的变量可能位于同一栈上,或者不同线程的栈上可能分配了物理地址,正好相差缓存行大小的倍数,从而映射到同一物理缓存行。因此,当另一个线程写入同一行上的其他变量时,硬件仍然会遇到一致性流量,从而使得alignas规范不足以提供隔离。
解决方案
为了确保避免虚假共享,数据必须填充以占用整个缓存行,确保没有其他数据共享物理存储,无论运行时地址布局如何。这是通过定义一个同时根据std::hardware_destructive_interference_size对齐和大小的结构来实现的。
#include <new> #include <cstddef> #include <atomic> struct alignas(std::hardware_destructive_interference_size) PaddedCounter { std::atomic<int> value; // 填充消耗缓存行的其余部分以防止共享 char padding[std::hardware_destructive_interference_size - sizeof(std::atomic<int>)]; }; // 数组确保每个元素位于不同的缓存行上 PaddedCounter thread_counters[8];
问题描述
一个低延迟市场数据处理器使用八个工作线程,每个线程在全局数组**std::atomic<int> stats[8]**中维护一个每线程的滴答计数器。每个线程在没有锁的情况下独占递增其自己的索引,但分析显示吞吐量在理论最大值的一小部分处停滞,CPU计数器显示过多的缓存一致性周期,而不是用户模式计算。调查确认,尽管原子整数在逻辑上是独立的,但它们在同一个64字节缓存行中连续打包,导致核心之间的破坏性干扰。
解决方案1:本地对齐变量
团队最初尝试在每个线程的执行函数内部声明alignas(64) std::atomic<int> local_stat,并传递指向监视线程的指针。该方法需要最少的重构,并避免全局状态。然而,由于编译器可能将其他自动变量放置在与local_stat相邻的同一缓存行中,因此被证明不可靠,而且不同线程的栈分配可能恰好相差64字节的倍数,导致已对齐的变量别名到同一物理行,并持续发生虚假共享。
解决方案2:使用原始指针的堆分配
另一种考虑的方法是通过**new std::atomic<int>**为每个计数器分配,希望堆分配器能够在遥远的内存地址中分散分配。虽然这有时减少了争用,但它引入了非确定性的性能,因为小的分配通常从连续的块中提供,而分配器元数据可能会将不同的对象放置在同一缓存行。更重要的是,这需要手动内存管理,并且没有提供对齐或填充的编译时保证。
选择的解决方案及结果
最终实现采用了上面定义的PaddedCounter结构,将实例存储在静态数组中。选择该解决方案是因为它通过编译时填充和对齐确定性地强制缓存行分离,从而消除了硬件级的争用,无论运行时内存布局如何。内存消耗从32字节增加到512字节,这对于性能提升是可以接受的。结果是吞吐量增加了十二倍,延迟方差降低,满足了亚微秒处理要求。
为什么将alignas(std::hardware_destructive_interference_size)应用于小对象无法防止与同一线程上的其他数据发生虚假共享?
alignas仅控制对象起始地址的对齐,而不是其范围。如果对象小于缓存行(例如,64字节行上的4字节整数),则该缓存行的剩余字节可以包含其他变量。当编译器将另一个变量放置在同一行上,或者当不同线程的变量映射到该物理行时,会发生虚假共享。真正的隔离需要对象通过填充占用整个行,而不仅仅是对齐到其起始位置。
什么是std::hardware_destructive_interference_size和std::hardware_constructive_interference_size之间的区别,何时将数据组合在一起以适应后者会提高性能?
std::hardware_destructive_interference_size是避免虚假共享所需的最小分离,而std::hardware_constructive_interference_size是受益于单个缓存行的空间局部性的最大数据大小。将相关的频繁访问字段(例如,一个点的x、y、z坐标)组合成一个适合于建设性大小的结构可确保它们位于同一行,从而最大化缓存命中率和预取效率,而破坏性大小则用于分离不相关的可变数据。
虚假共享如何影响使用std::atomic操作的memory_order_relaxed**,而为什么放松内存顺序无法解决性能下降?**
即使使用memory_order_relaxed,它对周围的内存操作没有施加任何顺序约束,原子写入仍然需要CPU核心获得该缓存行的独占所有权(一个阅读-所有权周期)。如果另一个线程最近修改了同一行上的不同变量,则缓存一致性协议迫使该行在核心之间跳动。此级别的硬件同步独立于C++内存模型的逻辑保证,这意味着虚假共享会造成完全的缓存未命中延迟,而不管指定的内存顺序如何。