避免错误共享以提高性能

avoiding false sharing to improve performance

#include <iostream>
#include <future>
#include <chrono>

using namespace std;
using namespace std::chrono;

int a = 0;
int padding[16]; // avoid false sharing
int b = 0;

promise<void> p;
shared_future<void> sf = p.get_future().share();

void func(shared_future<void> sf, int &data)
{
  sf.get();

  auto t1 = steady_clock::now();
  while (data < 1'000'000'000)
    ++data;
  auto t2 = steady_clock::now();

  cout << duration<double, ratio<1, 1>>(t2 - t1).count() << endl;
}

int main()
{
  thread th1(func, sf, ref(a)), th2(func, sf, ref(b));
  p.set_value();
  th1.join();
  th2.join();

  return 0;
}

我用上面的代码来演示虚假共享对性能的影响。但令我惊讶的是,填充似乎根本不会加快程序的速度。有趣的是,如果ab都是原子变量,会有明显的提升。有什么区别?

相同 缓存行中的 2 个原子变量由不同的线程通过读-修改-写 (RMW) 操作递增时,最好检测到虚假共享。 为此,每个 CPU 都必须在增量操作期间刷新存储缓冲区并锁定缓存行,即:

  • 锁定缓存行
  • 从一级缓存读取值到寄存器
  • 增加寄存器内的值
  • 写回 L1 缓存
  • 解锁缓存行

单个缓存行在 CPU 之间不断跳动的效果是显而易见的,即使使用了完整的编译器优化。 强制两个变量位于不同的缓存行(通过添加填充数据)可能会显着提高性能,因为每个 CPU 都可以完全访问自己的缓存行。 锁定高速缓存行仍然是必要的,但不要浪费时间获取对高速缓存行的读写访问权。

如果两个变量都是普通整数,情况就不同了,因为递增整数涉及普通加载和存储(即不是原子 RMW 操作)。
在没有填充的情况下,缓存行在内核之间跳动的影响可能仍然很明显,但规模要小得多,因为不再涉及缓存行锁定。 如果您使用完全优化进行编译,整个 while 循环可能会被单个增量替换,并且不会再有任何区别。

在我的 4 核 X86 上,我得到以下数字:

atomic int, no padding, no optimization: real 57.960s, user 114.495s

atomic int, padding, no optimization: real 10.514s, user 20.793s

atomic int, no padding, full optimization: real 55.732s, user 110.178s

atomic int, padding, full optimization: real 8.712s, user 17.214s

int, no padding, no optimization: real 2.206s, user 4.348s

int, padding, no optimization: real 1.951s, user 3.853s

int, no padding, full optimization: real 0.002s, user 0.000s

int, padding, full optimization: real 0.002s, user 0.000s