您所看到的基本上是存储缓冲区与store-to-load forwarding 相结合的效果,尽管共享缓存行,但允许每个内核大部分独立工作。正如我们将在下面看到的,这确实是一个奇怪的情况,在某种程度上,更多的争用是不好的,然后甚至更多的争用突然让事情变得非常快!
现在,按照传统的争用观点,您的代码似乎是高争用的,因此比理想的要慢得多。然而,发生的情况是,一旦每个核心在其写入缓冲区中获得一个待处理的写入,所有以后的读取都可以从写入缓冲区(存储转发)中得到满足,并且以后的写入也只会进入缓冲区 即使在核心失去了缓存行的所有权之后。这将大部分工作变成了完全本地化的操作。缓存线仍在内核之间来回跳动,但它与内核执行路径解耦,只需要不时地实际提交存储1。
std::atomic 版本根本无法使用这种魔法,因为它必须使用 locked 操作来保持原子性并击败存储缓冲区,因此您可以看到争用的全部成本和 em> 长延迟原子操作的成本2。
让我们试着收集一些证据来证明这就是正在发生的事情。下面的所有讨论都涉及非atomic 版本的基准测试,它使用volatile 强制从buffer 进行读写。
让我们首先检查程序集,以确保它符合我们的预期:
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 通过分解sub 和jne 错过了优化,从而抑制了宏融合,但总的来说还可以,并且存储转发延迟无论如何都会限制循环。
接下来,我们来看看 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。这主要反映了存储到加载转发的成本,加上一个循环的增量,这是一个携带的依赖链,因为相同的位置被重复更新(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 倍,达到约 6,000 万。尽管如此,与 20 亿负载(以及 20 亿商店 - 未显示,但我们知道它们就在那里)相比,这个数字仍然不算多。对于每个 未命中,这意味着 ~33 次加载和 ~33 次存储。这也意味着每次未命中之间有 250 个周期。
这并不真正适合高速缓存线在内核之间不规则弹跳的模型,一旦一个内核获得该线,另一个内核就需要它。我们知道,共享 L2 的内核之间的线路可能会在 20-50 个周期内反弹,因此每 250 个周期发生一次未命中的比率似乎很低。
两个假设
对于上述行为,我想到了几个想法:
-
也许这个芯片中使用的MESI协议变种是“智能”的,并且可以识别出多个核心之间的一条线路是热的,但是每次一个核心获得锁定并且线路花费更多时间时只完成少量工作在 L1 和 L2 之间移动而不是实际满足某些核心的负载和存储。有鉴于此,一致性协议中的一些智能组件决定为每条线路强制执行某种最小“拥有时间”:在一个核心获得线路后,即使另一个核心要求它也会保持 N 个周期(其他核心只需要等待)。
这将有助于平衡缓存行 ping-pong 与实际工作的开销,代价是“公平”和其他内核的响应能力,有点像不公平锁和公平锁之间的权衡,并抵消效果described here,一致性协议越快、越公平,一些(通常是合成的)循环可能执行得越差。
现在我从来没有听说过这样的事情(之前的链接显示,至少在 Sandy-Bridge 时代,事情正朝着相反的方向发展),但它肯定是 可能!
-
所描述的存储缓冲区效果实际上正在发生,因此大多数操作几乎可以在本地完成。
一些测试
让我们尝试通过一些修改来区分两种情况。
读写不同的字节
显而易见的方法是更改 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]。
现在正如我上面提到的,由于循环携带的load->add->store->load... 依赖关系,即使在最好的单线程情况下,原始(相同位置)循环实际上运行相当缓慢,每次迭代大约 5.5 个周期。这个新代码打破了这个链条!负载不再依赖于存储,因此我们几乎可以并行执行所有内容,我希望这个循环每次迭代运行大约 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 倍,这很好地解释了性能下降,并且与当存储到加载转发无法隐藏争用时,未命中将增加很多的想法一致。
增加商店之间的距离
另一种方法是增加存储和后续加载之间的时间/指令距离。我们可以通过在fn() 方法中增加SPAN 连续位置来做到这一点,而不是总是相同的位置。例如,如果 SPAN 为 4,则连续增加 4 个位置,例如:
for (long i=0; i<500000000 / 4; i++) {
p[0]++;
p[1]++;
p[2]++;
p[3]++;
}
请注意,我们仍然总共增加 5 亿个位置,只是将增量分散到 4 个字节中。直觉上,您会期望整体性能会提高,因为您现在拥有长度为 1/SPAN 的 SPAN 并行依赖项,因此在上述情况下,您可能期望性能提高 4 倍,因为 4 条并行链可以以大约 4 倍的速度运行总吞吐量的倍数。
对于 1 线程和 3 线程4,对于从 1 到 20 的 SPAN 值,这是我们实际得到的时间(以周期为单位):
最初,您会发现单线程和多线程情况下的性能都显着提高;从SPAN 增加一到二和三接近于两种情况下完美并行情况下的理论预期值。
单线程情况的渐近线比单位置写入快约 4.25 倍:此时存储转发延迟不是瓶颈,其他瓶颈已经接管(最大 IPC 和存储端口争用,大部分)。
然而,多线程的情况非常不同!一旦你达到大约 7 的SPAN,性能就会迅速变差,比SPAN=1 的情况差大约 2.5 倍,与SPAN=5 的最佳性能相比差了近 10 倍。发生的情况是 store-to-load 转发停止发生,因为 store 和后续加载在时间/周期上相距足够远,以至于 store 已退休到 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]++;
}
}
}
为从 1 到 20 的所有 SPAN 值运行 perf 的 bash 脚本:
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 将所有sub 和jne 融合在一起,那么它每次迭代运行1.1 个周期(仍然比我预期的1.0 差)。我会使用-march=haswell 而不是-march=native,但我不会回去更改所有数字。
4 结果也适用于 4 个线程:但我只有 4 个内核,而且我在后台运行 Firefox 之类的东西,所以少用 1 个内核可以使测量的噪音小很多.以周期为单位测量时间也很有帮助。
5在此 CPU 架构上,在存储数据准备好之前负载到达的存储转发似乎在 4 和 5 个周期之间交替,平均为 4.5 个周期。