避免错误共享以提高性能
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;
}
我用上面的代码来演示虚假共享对性能的影响。但令我惊讶的是,填充似乎根本不会加快程序的速度。有趣的是,如果a
和b
都是原子变量,会有明显的提升。有什么区别?
当 相同 缓存行中的 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
#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;
}
我用上面的代码来演示虚假共享对性能的影响。但令我惊讶的是,填充似乎根本不会加快程序的速度。有趣的是,如果a
和b
都是原子变量,会有明显的提升。有什么区别?
当 相同 缓存行中的 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