【问题标题】:Reading a cacheline vs parts of a cacheline in Intel x86在 Intel x86 中读取缓存线与缓存线的一部分
【发布时间】:2019-02-21 05:52:06
【问题描述】:

这个问题可能没有决定性的答案,但正在寻找这方面的一般建议。让我知道这是否是题外话。如果我的代码从不在当前 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 时间),或者它可以压缩以适应缓存线并尽可能紧凑,因此远程可以在不读取整个缓存线的情况下完成。两种方法都将涉及截然不同的代码。只是想弄清楚我应该首先尝试哪个以及这里的一般建议是什么。

【问题讨论】:

    标签: c++ performance caching assembly x86


    【解决方案1】:

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

    在早期的 CPU 上基本上也是如此;行仍然作为一个整体发送,并以大约 2 到 8 个时钟周期的突发到达。 (AMD 多插槽 K10 甚至可以在通过 HyperTransport 在不同插槽上的内核之间发送一条线路时创建 tearing across 8-byte boundaries,因此它允许在发送或接收线路的周期之间发生缓存读取和/或写入。)

    (在它需要的字节到达时让负载开始被称为“提前重启”in CPU-architecture terminology。一个相关的技巧是“关键字优先”,其中从 DRAM 读取以需求负载所需的字开始突发触发它。在现代 x86 CPU 中,这些都不是一个重要因素,其数据路径与缓存线一样宽,或者接近它,一条线可能在 2 个周期内到达。可能不值得发送一个字内行缓存行请求的一部分,即使在请求不仅仅来自硬件预置的情况下。)

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


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

    • 在 Nehalem 之前加载未对齐的 16 字节:movdqu 解码为额外的微指令,即使地址 对齐。
    • SnB/IvB 32 字节 AVX 加载在 16 字节半的同一加载端口中需要 2 个周期。
    • AMD 可能会因负载未对齐而跨越 16 字节或 32 字节边界而受到处罚。
    • Zen2 之前的 AMD CPU 将 256 位(32 字节)的 AVX/AVX2 操作分成两个 128 位的一半,因此对于任何大小的相同成本规则仅适用于 AMD 上的 16 字节。或者在一些非常老的 CPU 上最多 8 个字节,这些 CPU 将 128 位向量分成两半,例如 Pentium-M 或 Bobcat。
    • 整数负载的负载使用延迟可能比 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 指令相同的微指令的一部分进行解码,因此甚至不需要额外的前端带宽。


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

    【讨论】:

    • 感谢您提供的信息丰富的答案,我仍在阅读,明天可能会有更多问题,但现在,关于 TL;DR 最后 - 你说的这个 是什么意思除非这意味着循环更多次
    • 我的意思是如果调用foo() 8 次或调用foo() 64 次之间的区别,越少越好。就像我在一个明显更贵的问题中提到的你的奇怪例子一样。
    • 也许一个更好的例子是两个 int 负载与两个 __m128i 负载你能扩展这部分吗?
    • @Curious:mov eax, [rdi] / add eax, [rdi+4] 的成本与movdqa xmm0, [rdi] / pavgb xmm0, [rdi] 的成本差不多。相同数量的加载 + ALU 指令,只是更宽。而不是您比较 1 个单字节负载与 64 个单字节负载的示例。
    猜你喜欢
    • 1970-01-01
    • 2019-01-05
    • 2018-02-14
    • 2019-06-10
    • 2010-10-17
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多