x86-64的缓存填充大小应该设置为128字节吗?

13

我在 crossbeam 的评论中发现了以下内容。

从英特尔的 Sandy Bridge 开始,空间预取器现在每次拉取一对 64 字节的缓存行,因此我们必须对齐到 128 字节而不是 64 字节。

来源:

我没有在英特尔的手册中找到这句话。但直到最新的提交,folly 仍然使用 128 字节的填充,这使我相信这个说法。所以我开始编写代码来观察这个行为。以下是我的代码。

#include <thread>

int counter[1024]{};

void update(int idx) {
    for (int j = 0; j < 100000000; j++) ++counter[idx];
}

int main() {
    std::thread t1(update, 0);
    std::thread t2(update, 1);
    std::thread t3(update, 2);
    std::thread t4(update, 3);
    t1.join();
    t2.join();
    t3.join();
    t4.join();
}

编译器资源浏览器

我的 CPU 是 Ryzen 3700X。当索引为 0123 时,需要大约 1.2 秒才能完成。当索引为 0163248 时,需要大约 200 毫秒才能完成。当索引为 0326496 时,需要大约 200 毫秒才能完成,与之前完全相同。我还在 Intel 机器上进行了测试,结果类似。

从这个微型基准测试中,我看不出使用 128 字节补齐而不是 64 字节补齐的原因。请问我理解错了吗?


2
英特尔的优化手册描述了SnB系列CPU中的L2空间预取器。是的,当第一行被拉入时,它会尝试完成128B对齐的64B行的配对,当有多余的内存带宽(离核请求跟踪插槽)时。 - Peter Cordes
@PeterCordes 谢谢!在什么情况下,128字节的填充会胜过64字节的填充? - QuarticCat
@QuarticCat,一些AMD GPU OpenCL驱动程序在主机缓冲区的4096对齐方面表现更佳,但这可能会改变。我猜这与VRAM和RAM之间的DMA单元有关。我不知道DMA是否在同一主板上的两个CPU之间使用(而不是它们的内部链接),但当我有2个GPU时,更高的对齐方式可以通过仅使用1个线程来利用更高百分比的RAM带宽。 - huseyin tugrul buyukisik
它说的是空间预取器。您的实验旨在测量错误共享,与预取无关! - user253751
我的CPU是Ryzen 3700X。它不是英特尔而是AMD,因此x86-64处理器的建议并不相关。 - Karl Knechtel
显示剩余4条评论
1个回答

17

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                #    3.958 CPUs utilized            ( +-  0.12% )
                 0      context-switches          #    0.000 /sec                   
                 0      cpu-migrations            #    0.000 /sec                   
               126      page-faults               #  224.752 /sec                     ( +-  0.35% )
     2,180,391,747      cycles                    #    3.889 GHz                      ( +-  0.12% )
     2,003,039,378      instructions              #    0.92  insn per cycle           ( +-  0.00% )
     1,604,118,661      uops_issued.any           #    2.861 G/sec                    ( +-  0.00% )
     2,003,739,959      uops_executed.thread      #    3.574 G/sec                    ( +-  0.00% )
               494      machine_clears.memory_ordering #  881.172 /sec                     ( +-  9.00% )

          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                #    3.957 CPUs utilized            ( +-  0.13% )
                 0      context-switches          #    0.000 /sec                   
                 0      cpu-migrations            #    0.000 /sec                   
               124      page-faults               #  221.203 /sec                     ( +-  0.16% )
     2,180,048,243      cycles                    #    3.889 GHz                      ( +-  0.13% )
     2,003,038,553      instructions              #    0.92  insn per cycle           ( +-  0.00% )
     1,604,084,990      uops_issued.any           #    2.862 G/sec                    ( +-  0.00% )
     2,003,707,895      uops_executed.thread      #    3.574 G/sec                    ( +-  0.00% )
                22      machine_clears.memory_ordering #   39.246 /sec                     ( +-  9.68% )

          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                #    3.835 CPUs utilized            ( +-  0.42% )
                 0      context-switches          #    0.000 /sec                   
                 0      cpu-migrations            #    0.000 /sec                   
               122      page-faults               #  152.953 /sec                     ( +-  0.22% )
     3,152,973,230      cycles                    #    3.953 GHz                      ( +-  0.42% )
     2,003,038,681      instructions              #    0.65  insn per cycle           ( +-  0.00% )
     2,868,628,070      uops_issued.any           #    3.596 G/sec                    ( +-  0.41% )
     2,934,059,729      uops_executed.thread      #    3.678 G/sec                    ( +-  0.30% )
        10,810,169      machine_clears.memory_ordering #   13.553 M/sec                    ( +-  0.90% )

           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();
}

1
根据您的指示,我创建了另一个基准测试。在英特尔上,它确实显示出64字节和128字节之间的显着差异,但在AMD上没有。我注意到在两个平台上,结果有时会有很大的变化。我认为这与调度程序有关。 - QuarticCat
1
@QuarticCat:有趣,感谢您确认我的猜测,如果使用相邻行,Intel CPU之间的真正共享将成为一个问题。而有趣的是,对于AMD来说并非如此。顺便说一句,可以通过MSR启用/禁用HW预取器,因此甚至可以验证在Intel上禁用L2空间预取器(或所有L2预取)是否消除了性能损失。(例如正确使用Skylake中的MSR禁用硬件预取 / L2 HW预取器真的有用吗? - Peter Cordes
@PeterCordes https://www.mail-archive.com/gcc-patches@gcc.gnu.org/msg265387.html。我认为空间预取器实际上何时会导致乒乓效应并不完全清楚。据我回忆,我之前运行的基准测试也非常模棱两可,并且在基于英特尔微架构的硬件内部也存在很大差异。 - Noah

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接