在英特尔 x86 中读取缓存行与部分缓存行



这个问题可能没有决定性的答案,但要寻找这方面的一般建议。 让我知道这是否是题外话。 如果我的代码从不在当前 CPU 的 L1 缓存中的缓存行读取并读取到远程缓存,则假设该对象来自刚刚写入它的远程线程,因此缓存行处于修改模式。 读取整个缓存行与仅读取部分缓存行是否有任何增量成本? 或者这样的东西可以完全并行化吗?

例如,给定以下代码(假设foo()谎言是其他转换单元并且对优化器不透明,则不涉及 LTO)

struct alignas(std::hardware_destructive_interference_size) Cacheline {
std::array<std::uint8_t, std::hardware_constructive_interference_size> bytes;
};
void foo(std::uint8_t byte);

这之间是否有任何预期的性能差异

void bar(Cacheline& remote) {
foo(remote.bytes[0]);
}

而这个

void bar(Cacheline& remote) {
for (auto& byte : remote.bytes) {
foo(byte);
}
}

或者这很可能影响不大? 在读取完成之前,整个缓存行是否转移到当前处理器? 或者 CPU 是否可以并行化读取和远程缓存行提取(在这种情况下,等待整个缓存行传输可能会产生影响)?


对于某些上下文:我知道数据块可以被设计为适合缓存行(压缩可能不会像缓存未命中那样占用那么多的 CPU 时间),或者可以对其进行压缩以适应缓存行并尽可能紧凑, 因此,遥控器无需读取整个缓存行即可完成。 这两种方法将涉及实质上不同的代码。 只是想弄清楚我应该先尝试哪一个以及这里的一般建议是什么。

如果需要从缓存行读取任何字节,内核必须在 MESI 共享状态下抓取整个缓存行。 在 Haswell 和更高版本上,L2 和 L1d 缓存之间的数据路径宽度为 64 字节(https://www.realworldtech.com/haswell-cpu/5/),因此从字面上看,整条线路在同一时间、同一时钟周期内到达。 仅读取低 2 字节与高字节和低字节或字节 0 和字节 32 没有任何好处。

在早期的CPU上基本上也是如此;线路仍然作为一个整体发送,并且以2到8个时钟周期的突发到达。 (AMD 多插槽 K10 在通过 HyperTransport 在不同插槽上的内核之间发送线路时,甚至可能会产生跨越 8 字节边界的撕裂,因此它允许在发送或接收线路的周期之间进行缓存读取和/或写入。

(让负载在需要的字节到达时启动在 CPU 体系结构术语中称为"提前重启"。 一个相关的技巧是"关键字优先",其中从DRAM读取时,使用触发它的需求负载所需的字开始突发。 在数据路径与缓存行一样宽或接近缓存行的现代 x86 CPU 中,这些都不是重要因素,其中一行可能在 2 个周期内到达。 作为缓存行请求的一部分,可能不值得发送行内单词,即使请求不仅来自硬件伪装。

同一行的多个缓存未命中加载不会占用额外的内存并行资源。即使是按顺序排列的 CPU,通常也不会停止,直到某些东西尝试使用尚未准备好的加载结果。 乱序执行绝对可以在等待传入缓存行时继续执行并完成其他工作。 例如,在英特尔 CPU 上,线路的 L1d 未命中会分配一个线路填充缓冲区 (LFB) 来等待来自 L2 的传入线路。 但是,进一步加载到在该行到达之前执行的同一缓存行只是将其加载缓冲区条目指向已分配以等待该行的 LFB,因此它不会降低您以后将多个未完成缓存未命中(未命中)的能力。


任何不跨越缓存行边界的单个加载都具有与任何其他加载相同的成本,无论是 1 字节还是 32 字节。 使用 AVX512 时为 64 字节。 我能想到的几个例外是:

  • Nehalem 之前未对齐的 16 字节加载:movdqu解码为额外的 uop,即使地址对齐。
  • SnB/IvB 32 字节 AVX 负载在同一负载端口中为 16 字节的一半需要 2 个周期。
  • AMD 可能会因负载未对齐而跨越 16 字节或 32 字节边界而受到一些处罚。
  • Zen2 之前的 AMD CPU 将 256 位(32 字节)AVX/AVX2 操作分成两半 128 位,因此任何大小的相同成本规则在 AMD 上最多仅适用于 16 字节。 或者在某些非常旧的 CPU 上最多 8 字节,这些 CPU 将 128 位矢量分成两半,如奔腾-M 或山猫。
  • 整数负载的负载使用延迟可能比 SIMD 矢量负载低 1 或 2 个周期。 但是您谈论的是执行更多加载的增量成本,因此没有新地址可供等待。 (大概只是来自同一基本寄存器的不同即时位移。 或者一些便宜的计算。

我忽略了使用 512 位指令甚至在某些 CPU 上使用 256 位指令来减少涡轮增压时钟的影响。


一旦您支付了缓存未命中的成本,该行的其余部分在 L1d 缓存中很热,直到其他线程想要写入它并且他们的 RFO(所有权读取)使您的行无效。

调用非内联函数 64 次而不是一次显然更昂贵,但我认为这只是你想问的一个不好的例子。 也许一个更好的例子是两个int负载与两个__m128i负载?

缓存未命中并不是唯一需要时间的事情,尽管它们很容易占据主导地位。 但是,一个 call+ret 至少需要 4 个时钟周期(https://agner.org/optimize/Haswell 的指令表显示每个 call/ret 每 2 个时钟吞吐量都有一个,我认为这是正确的),因此在缓存行的 64 字节上循环和调用函数 64 次至少需要 256 个时钟周期。 这可能比某些 CPU 上的内核间延迟更长。 如果它能够使用 SIMD 进行内联和自动矢量化,那么缓存未命中之外的增量成本将大大降低,具体取决于它的作用。

以L1d 为单位的负载非常便宜,例如每个时钟吞吐量 2 个。 作为ALU指令的内存操作数的负载(而不是需要单独的mov)可以作为与ALU指令相同的uop的一部分进行解码,因此甚至不需要额外的前端带宽。


使用始终填充缓存行的更易于解码的格式可能是您的用例的胜利。除非这意味着循环更多次。 当我说更容易解码时,我的意思是计算中的步骤更少,而不是看起来更简单的源代码(比如运行 64 次迭代的简单循环)。

最新更新