Intel的优化手册确实描述了SnB系列CPU中的L2空间预取器。是的,当第一行被拉入时,如果有多余的内存带宽(离核心请求跟踪插槽),它会尝试完成128字节对齐的64字节行。
在没有任何实际虚假共享(在同一64字节行内)的情况下,您的微基准测试未显示64与128字节分离之间的任何显着时间差异。经过一些初始混乱后,它很快达到这样一个状态:每个核心都拥有其正在修改的高速缓存行的独占所有权。这意味着没有进一步的L1d miss,因此也没有请求到L2,这将触发L2空间预取器。
与例如争夺相邻(或不相邻)缓存行中单独的atomic<int>
变量的两对线程的情况不同。 或与它们虚假共享。然后,L2空间预取可以将争用耦合在一起,因此所有4个线程都在争用而不是2个独立的线程对。基本上,任何实际上的高速缓存行在核心之间来回跳动的情况下,如果不小心,L2空间预取都会使情况变得更糟。
(L2预取器不会无限期地继续尝试完成其缓存的每个有效行的线对;这将比帮助任何东西更加损害像这样的情况,其中不同的核心正在重复触摸相邻的行。)
理解std::hardware_destructive_interference_size和std::hardware_constructive_interference_size包括一个具有较长基准测试的答案;我最近没有看过它,但我认为它应该演示64字节的破坏性干扰,但不是128。不幸的是,那里的答案没有提到L2空间预取作为可能导致一些破坏性干扰的影响(尤其是外层高速缓存中的128字节行大小,特别是如果它是包容性高速缓存)。
即使在您的基准测试中,性能计数器也会显示差异
对于您的基准测试,我们可以使用性能计数器测量更多的初始混乱。 在我的i7-6700k(四核Skylake带有超线程; 4c8t,运行Linux 5.16)上,我改进了源代码,使其可以在不破坏内存访问的情况下进行编译,并使用CPP宏,以便可以从编译器命令行设置步幅(以字节为单位)。请注意,当我们使用相邻行时,会出现约500个内存顺序误判流水线nukes (machine_clears.memory_ordering
)。实际数字相当可变,从200到850,但总体时间几乎没有影响。
相邻行,500+-300机器清除
$ g++ -DSIZE=64 -pthread -O2 false-share.cpp && perf stat --all-user -etask-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,uops_issued.any,uops_executed.thread,machine_clears.memory_ordering -r25 ./a.out
Performance counter stats for './a.out' (25 runs):
560.22 msec task-clock
0 context-switches
0 cpu-migrations
126 page-faults
2,180,391,747 cycles
2,003,039,378 instructions
1,604,118,661 uops_issued.any
2,003,739,959 uops_executed.thread
494 machine_clears.memory_ordering
0.141534 +- 0.000342 seconds time elapsed ( +- 0.24% )
使用128字节分隔符的VS,只有极少数机器会清除
$ g++ -DSIZE=128 -pthread -O2 false-share.cpp && perf stat --all-user -etask-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,uops_issued.any,uops_executed.thread,machine_clears.memory_ordering -r25 ./a.out
Performance counter stats for './a.out' (25 runs):
560.01 msec task-clock
0 context-switches
0 cpu-migrations
124 page-faults
2,180,048,243 cycles
2,003,038,553 instructions
1,604,084,990 uops_issued.any
2,003,707,895 uops_executed.thread
22 machine_clears.memory_ordering
0.141506 +- 0.000342 seconds time elapsed ( +- 0.24% )
在这台4核8线程的机器上,Linux如何调度线程到逻辑核心可能存在一些依赖关系。相关:生产者和消费者共享内存位置,超线程兄弟姐妹与非超线程兄弟姐妹之间的延迟和吞吐成本是什么? - 在同一行中实际错误共享对于共享一个物理核心的逻辑核心来说更糟糕,但对于相邻的行,可能没有影响:L2对于每个物理核心都是相同的,并且两行数据将保持在L1d缓存中。
为什么要清除因其他逻辑处理器引起的内存顺序冲突的流水线?
与同一行内的实际错误共享相比:1000万次机器清除
每个错误共享的机器清除都会使存储缓存区(和存储转发)执行一堆递增操作,所以情况并不像人们预期的那样糟糕。(如果使用原子RMW,如std::atomic<int>
fetch_add
,则情况将更加严重,因为每个递增操作都需要直接访问L1d缓存。)为什么错误共享仍然会影响非原子变量,但比原子变量受影响要少得多?
$ g++ -DSIZE=4 -pthread -O2 false-share.cpp && perf stat --all-user -etask-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,uops_issued.any,uops_executed.thread,machine_clears.memory_ordering -r25 ./a.out
Performance counter stats for './a.out' (25 runs):
809.98 msec task-clock
0 context-switches
0 cpu-migrations
122 page-faults
3,152,973,230 cycles
2,003,038,681 instructions
2,868,628,070 uops_issued.any
2,934,059,729 uops_executed.thread
10,810,169 machine_clears.memory_ordering
0.21123 +- 0.00124 seconds time elapsed ( +- 0.59% )
改进基准测试-对齐数组和使用volatile以允许优化
我使用了volatile
,以便我可以启用优化。我假设您是在禁用优化的情况下编译的,因此int j
也被存储/重新加载到循环中。
我还使用了alignas(128) counter[]
,以确保数组的开头位于两个128字节行中,而不是分布在三个行中。
#include <thread>
alignas(128) volatile int counter[1024]{};
void update(int idx) {
for (int j = 0; j < 100000000; j++) ++counter[idx];
}
static const int stride = SIZE/sizeof(counter[0]);
int main() {
std::thread t1(update, 0*stride);
std::thread t2(update, 1*stride);
std::thread t3(update, 2*stride);
std::thread t4(update, 3*stride);
t1.join();
t2.join();
t3.join();
t4.join();
}