为什么从多个线程使用相同的缓存行不会导致严重的减速?

Why does using the same cache-line from multiple threads not cause serious slowdown?

看看这个片段:

#include <atomic>
#include <thread>

typedef volatile unsigned char Type;
// typedef std::atomic_uchar Type;

void fn(Type *p) {
    for (int i=0; i<500000000; i++) {
        (*p)++;
    }
}

int main() {
    const int N = 4;

    std::thread thr[N];
    alignas(64) Type buffer[N*64];

    for (int i=0; i<N; i++) {
        thr[i] = std::thread(&fn, &buffer[i*1]);
    }

    for (int i=0; i<N; i++) {
        thr[i].join();
    }

}

这个小程序从四个不同的线程多次递增四个相邻字节。之前,我使用规则:不要使用来自不同线程的相同缓存行,因为缓存行共享是不好的。所以我预计四线程版本 (N=4) 比单线程版本 (N=1) 慢得多。

但是,这些是我的测量值(在 Haswell CPU 上):

所以N=4并没有慢多少。如果我使用不同的缓存行(将 *1 替换为 *64),那么 N=4 会变得更快:1.1 秒。

相同的原子访问测量(交换 typedef 处的注释),相同的缓存行:

所以 N=4 的情况要慢得多(如我所料)。如果使用不同的缓存行,那么 N=4 的性能与 N=1 相似:3.3 秒。

我不明白这些结果背后的原因。为什么我的非原子 N=4 情况没有严重放缓?四个核心在它们的缓存中有相同的内存,所以它们必须以某种方式同步它们,不是吗?他们怎么 运行 几乎完全平行?为什么只有原子案例会严重放缓?


我想我需要了解在这种情况下内存是如何更新的。一开始,没有内核的缓存中有 buffer。经过 for 次迭代(在 fn 中),所有 4 个内核的缓存行中都有 buffer,但每个内核写入不同的字节。这些缓存行如何同步(在非原子情况下)?缓存如何知道哪个 byte 是脏的?还是有其他机制来处理这种情况?为什么这种机制比原子机制便宜很多(实际上,它几乎是免费的)?

原子版本必须确保其他线程能够以顺序一致的方式读取结果。所以每次写入都有栅栏。

volatile 版本不会使任何关系对其他核心可见,因此不会尝试同步内存,因此它在其他核心上可见。对于使用 C++11 或更新版本的多线程系统,volatile 不是线程间通信的机制。

您所看到的基本上是存储缓冲区与 store-to-load forwarding 相结合的效果,尽管共享缓存行,但允许每个核心大部分独立工作。正如我们将在下面看到的,这确实是一个 奇怪的 情况,在某种程度上,更多的争用是不好的,然后 甚至更多的 争用突然使事情变得真快!

现在,按照传统的争用观点,您的代码似乎会引起高争用,因此比理想情况慢得多。然而,一旦每个内核在其写缓冲区中获得一个挂起的写操作,所有后来的读操作都可以从写缓冲区中得到满足(存储转发),并且稍后的写操作也只是进入缓冲区 即使在核心失去缓存行的所有权之后。这将大部分工作转变为完全本地操作。缓存行仍然在核心之间来回跳动,但它与核心执行路径分离,只需要偶尔实际提交存储1.

std::atomic 版本根本无法使用这种魔法,因为它必须使用 locked 操作来保持原子性和破坏存储缓冲区,因此您可以看到竞争的全部成本长延迟原子操作的成本2.

让我们尝试实际收集一些证据来证明这就是正在发生的事情。下面的所有讨论都涉及使用 volatile 强制从 buffer.

读取和写入的非 atomic 版本的基准测试

让我们首先检查程序集,以确保它符合我们的预期:

0000000000400c00 <fn(unsigned char volatile*)>:
  400c00:   ba 00 65 cd 1d          mov    edx,0x1dcd6500
  400c05:   0f 1f 00                nop    DWORD PTR [rax]
  400c08:   0f b6 07                movzx  eax,BYTE PTR [rdi]
  400c0b:   83 c0 01                add    eax,0x1
  400c0e:   83 ea 01                sub    edx,0x1
  400c11:   88 07                   mov    BYTE PTR [rdi],al
  400c13:   75 f3                   jne    400c08 <fn(unsigned char volatile*)+0x8>
  400c15:   f3 c3                   repz ret 

很简单:一个包含字节加载的五指令循环、加载字节的递增、字节存储,最后是循环递增和条件跳转回到顶部。在这里,gcc 错过了通过分解 subjne 来进行的优化,抑制了宏融合,但总的来说它没问题,存储转发延迟在任何情况下都会限制循环。

接下来,让我们看看L1D未命中的次数。每次内核需要写入被偷走的行时,它都会遭受 L1D 未命中,我们可以用 perf 来衡量。一、单线程(N=1)案例:

$ perf stat -e task-clock,cycles,instructions,L1-dcache-loads,L1-dcache-load-misses ./cache-line-increment 

 Performance counter stats for './cache-line-increment':

       1070.188749      task-clock (msec)         #    0.998 CPUs utilized          
     2,775,874,257      cycles                    #    2.594 GHz                    
     2,504,256,018      instructions              #    0.90  insn per cycle         
       501,139,187      L1-dcache-loads           #  468.272 M/sec                  
            69,351      L1-dcache-load-misses     #    0.01% of all L1-dcache hits  

       1.072119673 seconds time elapsed

这与我们的预期大致相同:L1D 未命中基本上为零(占总数的 0.01%,可能主要来自中断和循环外的其他代码),以及超过 500,000,000 次命中(几乎与循环迭代次数完全匹配) .另请注意,我们可以轻松计算每次迭代的周期数:大约 5.55。这主要反映了store-to-load转发的开销,加上增量的一个周期,这是一个携带的依赖链,因为同一个位置被重复更新(而volatile意味着它不能被提升到一个寄存器).

我们来看看N=4案例:

$ perf stat -e task-clock,cycles,instructions,L1-dcache-loads,L1-dcache-load-misses ./cache-line-increment 

 Performance counter stats for './cache-line-increment':

       5920.758885      task-clock (msec)         #    3.773 CPUs utilized          
    15,356,014,570      cycles                    #    2.594 GHz                    
    10,012,249,418      instructions              #    0.65  insn per cycle         
     2,003,487,964      L1-dcache-loads           #  338.384 M/sec                  
        61,450,818      L1-dcache-load-misses     #    3.07% of all L1-dcache hits  

       1.569040529 seconds time elapsed

正如预期的那样,L1 负载从 5 亿跃升至 20 亿,因为有 4 个线程,每个线程执行 5 亿负载。 L1D 未命中 的数量也跃升了约 1,000 倍,达到约 6000 万。尽管如此,与 20 亿个负载(和 20 亿个商店 - 未显示,但我们知道它们在那里)相比,这个数字并不算多。对于 每个 失误,这是 ~33 次加载和 ~33 次存储。这也意味着每次未命中之间有 250 个周期。

这并不完全符合高速缓存行在核心之间不规则地跳动的模型,一旦一个核心获得该行,另一个核心就需要它。我们知道线路在共享一个 L2 的内核之间反弹大约 20-50 个周期,因此每 250 个周期一次未命中的比率似乎很低。

两个假设

关于上述行为的一些想法spring:

  • 可能是这个芯片使用的MESI协议变种比较“聪明”,能识别出几个核心之间一条线很热,但是每次只有一个核心在做少量的工作锁和线花费更多的时间在 L1 和 L2 之间移动,而不是实际满足某些内核的加载和存储。鉴于此,一致性协议中的一些智能组件决定为每条线路强制执行某种最小“拥有时间”:在一个核心获得线路后,它将保留它 N 个周期,即使另一个核心要求(其他核心只需要等待)。

    这将有助于平衡高速缓存行乒乓球与实际工作的开销,代价是“公平”和其他核心的响应能力,有点像不公平锁和公平锁之间的权衡,并且抵消效果 described here,其中一致性协议越快、越公平,一些(通常是合成的)循环可能执行得越差。

    现在我从来没有听说过这样的事情(而且紧接在前的 link 表明至少在桑迪-布里奇时代,事情正朝着相反的方向发展方向),但肯定是可能!

  • 所描述的存储缓冲效应实际发生,因此大多数操作几乎可以在本地完成。

一些测试

让我们尝试通过一些修改来区分两种情况。

读取和写入不同的字节

显而易见的方法是更改​​ fn() 工作函数,以便线程仍然在同一缓存行上竞争,但存储转发无法启动。

我们只从位置 x 读取然后写入位置 x + 1 怎么样?我们会给每个线程两个连续的位置(即 thr[i] = std::thread(&fn, &buffer[i*2])),所以每个线程都在两个私有字节上运行。修改后的 fn() 看起来像:

for (int i=0; i<500000000; i++)
    unsigned char temp = p[0];
    p[1] = temp + 1;
}

核心循环与之前的几乎相同:

  400d78:   0f b6 07                movzx  eax,BYTE PTR [rdi]
  400d7b:   83 c0 01                add    eax,0x1
  400d7e:   83 ea 01                sub    edx,0x1
  400d81:   88 47 01                mov    BYTE PTR [rdi+0x1],al
  400d84:   75 f2                   jne    400d78

唯一改变的是我们写入 [rdi+0x1] 而不是 [rdi]

现在,正如我上面提到的,即使在最好的单线程情况下,原始(相同位置)循环实际上 运行ning 相当缓慢,每次迭代大约 5.5 个周期,因为循环 -携带 load->add->store->load... 依赖。这个新代码打破了这个链条!负载不再依赖于存储,所以我们几乎可以并行执行所有事情,我希望这个循环达到 运行 每次迭代大约 1.25 个周期(5 条指令/CPU 宽度为 4)。

这是单线程的情况:

$ perf stat -e task-clock,cycles,instructions,L1-dcache-loads,L1-dcache-load-misses ./cache-line-increment 

 Performance counter stats for './cache-line-increment':

        318.722631      task-clock (msec)         #    0.989 CPUs utilized          
       826,349,333      cycles                    #    2.593 GHz                    
     2,503,706,989      instructions              #    3.03  insn per cycle         
       500,973,018      L1-dcache-loads           # 1571.815 M/sec                  
            63,507      L1-dcache-load-misses     #    0.01% of all L1-dcache hits                 

       0.322146774 seconds time elapsed

因此每次迭代大约 1.65 个周期3,大约 三倍 比递增相同位置快。

4个线程怎么样?

$ perf stat -e task-clock,cycles,instructions,L1-dcache-loads,L1-dcache-load-misses ./cache-line-increment 

 Performance counter stats for './cache-line-increment':

      22299.699256      task-clock (msec)         #    3.469 CPUs utilized          
    57,834,005,721      cycles                    #    2.593 GHz                    
    10,038,366,836      instructions              #    0.17  insn per cycle         
     2,011,160,602      L1-dcache-loads           #   90.188 M/sec                  
       237,664,926      L1-dcache-load-misses     #   11.82% of all L1-dcache hits  


       6.428730614 seconds time elapsed

所以它比相同位置的情况 大约 4 倍。现在,它不仅比单线程情况慢了一点,而且慢了大约 20 倍。这就是您一直在寻找的争论!现在 L1D 未命中数也增加了 4 倍,很好地解释了性能下降,并且与当存储到加载转发无法隐藏争用时未命中数会增加很多的想法一致。

增加商店之间的距离

另一种方法是增加 time/instructions 商店和后续负载之间的距离。我们可以通过在 fn() 方法中递增 SPAN 个连续位置来做到这一点,而不是总是在相同的位置。例如,如果 SPAN 为 4,则连续递增 4 个位置,如:

for (long i=0; i<500000000 / 4; i++) {
    p[0]++;
    p[1]++;
    p[2]++;
    p[3]++;
}

请注意,我们仍然总共增加 5 亿个位置,只是将增量分散到 4 个字节中。直觉上你会期望整体性能提高,因为你现在有 SPAN 长度为 1/SPAN 的并行依赖,所以在上面的例子中你可能期望性能提高 4 倍,因为 4 个并行链可以以总吞吐量的 4 倍左右的速度进行。

这是 1 个线程和 3 个线程 4 的实际时间(以周期衡量),对于从 1 到 20 的 SPAN 值:

最初,您会看到单线程和多线程情况下的性能都有显着提高;从 SPAN 一到二和三的增加接近于两种情况下完美并行情况下的理论预期。

单线程情况达到比单位置写入快约 4.25 倍的渐近线:此时存储转发延迟不是瓶颈,其他瓶颈已经接管(最大 IPC 和存储端口主要是争论)。

但是,多线程的情况非常不同!一旦你的 SPAN 达到 7 左右,性能就会迅速变差,比 SPAN=1 的情况差大约 2.5 倍,比 SPAN=5 的最佳性能差近 10 倍。发生的事情是存储到负载转发停止发生,因为存储和后续负载在 time/cycles 中相距足够远,以至于存储已经退休到 L1,因此负载实际上必须获得线路并参与 MESI。

还绘制了 L1D 未命中,如上所述,它表示内核之间的“缓存行传输”。单线程情况基本上为零,并且与性能无关。然而,多线程情况下的性能几乎完全跟踪缓存未命中。使用 2 到 6 范围内的 SPAN 值,存储转发仍在工作,按比例减少未命中。显然,核心能够在每次缓存行传输之间“缓冲”更多存储,因为核心循环更快。

另一种思考方式是,在有争议的情况下,L1D 未命中基本上每单位时间不变(这是有道理的,因为它们基本上与 L1->L2->L1 延迟相关,加上一些一致性协议开销),因此在缓存行传输之间可以做的工作越多越好。

这是多跨度案例的代码:

void fn(Type *p) {
    for (long i=0; i<500000000 / SPAN; i++) {
        for (int j = 0; j < SPAN; j++) {
            p[j]++;
        }
    }
}

bash 脚本到 运行 perf 的所有 SPAN 值从 1 到 20:

PERF_ARGS=${1:--x, -r10}

for span in {1..20}; do
    g++ -std=c++11 -g -O2 -march=native -DSPAN=$span  cache-line-increment.cpp  -lpthread -o cache-line-increment
    perf stat ${PERF_ARGS} -e cycles,L1-dcache-loads,L1-dcache-load-misses,machine_clears.count,machine_clears.memory_ordering ./cache-line-increment
done

最后,将结果“转置”为正确的 CSV:

FILE=result1.csv; for metric in cycles L1-dcache-loads L1-dcache-load-misses; do { echo $metric; grep $metric $FILE | cut -f1 -d,; } > ${metric}.tmp; done && paste -d, *.tmp

最终测试

您可以进行最后一项测试,以证明每个核心都在私下有效地完成其大部分工作:使用线程在同一位置工作的基准测试版本(这不会改变性能特征)检查最终计数器值的总和(您需要 int 个计数器而不是 char)。如果一切都是原子的,那么总和为 20 亿,在非原子的情况下,总和与该值的接近程度是粗略衡量核心绕过线路的频率。如果核心几乎完全私密地工作,那么价值将接近 5 亿而不是 20 亿,我想这就是你会发现的(一个相当接近 5 亿的价值)。

通过一些更巧妙的递增,您甚至可以让每个线程跟踪它们递增的值来自上一次递增的频率,而不是另一个线程递增的值(例如,通过使用一些值来存储线程标识符).通过更聪明的测试,您实际上可以重建缓存行在核心之间移动的方式(是否存在模式,例如,核心 A 是否更喜欢移交给核心 B?)以及哪些核心对最终值的贡献最大,等等

这就是练习:)。


1 最重要的是,如果英特尔有一个合并存储缓冲区,其中与较早存储完全重叠的较晚存储会杀死较早存储,它只需要提交 一个值到L1(最新的商店)每次得到线。

2 你不能在这里真正分开这两种效果,但我们稍后会通过击败存储到加载转发来做到这一点。

3有点超出我的预期,可能调度不好导致端口压力大。如果 gcc 只是将所有 subjne 融合,它 运行s 每次迭代 1.1 个周期(仍然比我预期的 1.0 差)。它会做到这一点,我使用 -march=haswell 而不是 -march=native,但我不会返回并更改所有数字。

4 结果也适用于 4 个线程:但我只有 4 个内核,而且我 运行 在后台使用 Firefox 之类的东西,所以使用减少 1 个核心可使测量噪声降低很多。测量周期中的时间也有很大帮助。

5 在这个 CPU 架构上,负载在存储数据准备好之前到达的存储转发似乎在 4 到 5 个周期之间交替,平均4.5 个周期。