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

Should the cache padding size of x86-64 be 128 bytes?

我找到了来自 crossbeam 的评论。

Starting from Intel's Sandy Bridge, spatial prefetcher is now pulling pairs of 64-byte cache lines at a time, so we have to align to 128 bytes rather than 64.

Sources:

我没有在 Intel 的手册中找到这句话。但是直到最近一次提交,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();
}

Compiler Explorer

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

从这个 micro bench 中我看不出在 64 字节填充上使用 128 字节填充的原因。我有没有看错?

Intel 的优化手册确实描述了 SnB-family CPU 中的 L2 空间预取器。是的,它会尝试完成 128B 对齐的 64B 线对,当第一条线被拉入时有空闲内存带宽(off-core 请求跟踪槽)。

您的微基准测试没有显示 64 字节与 128 字节分隔之间的任何显着时间差异。在没有任何 actual 错误共享(在同一 64 字节行内)的情况下,在最初的混乱之后,它很快达到一种状态,其中每个核心都对其正在修改的缓存行拥有独占所有权。这意味着没有更多的 L1d 未命中,因此没有对 L2 的请求会触发 L2 空间预取器。

不像 两对线程在相邻(或不相邻)缓存行中争用单独的 atomic<int> 变量。 或 false-sharing 与它们竞争。然后 L2 空间预取可以将竞争耦合在一起,因此所有 4 个线程都在相互竞争,而不是 2 个独立的对。基本上任何缓存行实际上在内核之间来回跳动的情况,如果不小心,L2 空间预取会使情况变得更糟。

(L2 预取器不会无限期地尝试 来完成它缓存的每条有效行的成对行;这会伤害像这样的不同内核反复接触相邻的情况行,比它有任何帮助。)

包含具有更长基准的答案;我最近没看过它,但我认为它应该演示 64 字节而不是 128 字节的破坏性干扰。不幸的是,那里的答案没有提到 L2 空间预取作为可能导致 some 的影响之一 破坏性干扰(虽然不如外层缓存中的 128 字节行大小那么多,尤其是在包含式缓存的情况下)。


性能计数器即使与您的基准测试也有差异

更多的初始混乱,我们可以使用性能计数器来衡量您的基准测试。 在我的 i7-6700k(四核 Skylake使用超线程;4c8t,运行 Linux 5.16),我改进了源代码,这样我就可以在不破坏内存访问的情况下进行优化编译,并且使用 CPP 宏,这样我就可以设置步幅(以字节为单位)编译器命令行。当我们使用相邻线路时,请注意 ~500 memory-order mis-speculation 管道核弹 (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 字节分隔相比,只有很少的机器清除

$ 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% )

大概有一些依赖于 Linux 如何将线程调度到这台 4c8t 机器上的逻辑核心。相关:

  • - 对于共享物理内核的逻辑内核而言,一行内的实际错误共享要糟糕得多,但对于相邻的行可能没有影响:每个物理内核的 L2 相同,并且两条线都将在 L1d 保持热度。

对比一行内实际false-sharing:10M机清

存储缓冲区(和存储转发)为每个 false-sharing 机器清除完成一堆增量,因此它并不像人们预期的那么糟糕。 (对于原子 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 所以我可以启用优化。我假设你在编译时禁用了优化,所以 int j 也在循环中得到 stored/reloaded。

我使用了 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();
}