如何正确使用预取指令?

How to properly use prefetch instructions?

我正在尝试向量化一个循环,计算一个大浮点向量的点积。我正在并行计算它,利用 CPU 具有大量 XMM 寄存器的事实,如下所示:

__m128* A, B;
__m128 dot0, dot1, dot2, dot3 = _mm_set_ps1(0);
for(size_t i=0; i<1048576;i+=4) {
    dot0 = _mm_add_ps( dot0, _mm_mul_ps( A[i+0], B[i+0]);
    dot1 = _mm_add_ps( dot1, _mm_mul_ps( A[i+1], B[i+1]);
    dot2 = _mm_add_ps( dot2, _mm_mul_ps( A[i+2], B[i+2]);
    dot3 = _mm_add_ps( dot3, _mm_mul_ps( A[i+3], B[i+3]);
}
... // add dots, then shuffle/hadd result.

我听说使用预取指令可以帮助加快速度,因为它可以获取更多数据 "in background",同时对缓存中的数据执行 muls 和 adding。然而,我没有找到关于如何使用 _mm_prefetch()、何时、使用什么地址以及命中率的示例和解释。你能帮忙吗?

可能适用于像您这样的完美线性流循环的简短答案可能是:根本不要使用它们,让硬件预取器完成工作。

不过,可能 您可以使用软件预取来加快速度,如果您想尝试,这里是理论和一些细节...

基本上,您在将来某个时候需要的地址上调用 _mm_prefetch()。它在某些方面类似于从内存中加载一个值而不对其进行任何操作:都将行放入 L1 缓存 2,但是预取内在的内部正在发出特定的prefetch instructions,有一些适合预取的优点。

它以 cache-line 粒度工作1:你只需要为每个缓存行发出一个预取:更多只是一种浪费。这意味着通常,您应该尝试充分展开循环,以便每个缓存行只能发出一次预取。在 16 字节 __m128 值的情况下,这意味着至少展开 4(你已经完成了,所以你在那里很好)。

然后在当前计算之前简单地预取每个访问流 PF_DIST 距离,例如:

for(size_t i=0; i<1048576;i+=4) {
    dot0 = _mm_add_ps( dot0, _mm_mul_ps( A[i+0], B[i+0]);
    dot1 = _mm_add_ps( dot1, _mm_mul_ps( A[i+1], B[i+1]);
    dot2 = _mm_add_ps( dot2, _mm_mul_ps( A[i+2], B[i+2]);
    dot3 = _mm_add_ps( dot3, _mm_mul_ps( A[i+3], B[i+3]);
    _mm_prefetch(A + i + PF_A_DIST, HINT_A);
    _mm_prefetch(B + i + PF_B_DIST, HINT_B);
}

此处 PF_[A|B]_DIST 是在当前迭代之前预取的距离,HINT_ 是要使用的时间提示。与其尝试根据第一原理计算正确的距离值,不如通过实验4 简单地确定 PF_[A|B]_DIST 的良好值。要减少搜索 space,您可以先将它们设置为相等,因为从逻辑上讲,相似的距离可能是理想的。您可能会发现只预取两个流之一是理想的。

非常重要,理想的PF_DIST 取决于硬件配置。不仅在 CPU 模型上,而且在内存配置上,包括 multi-socket 系统的侦听模式等细节。例如,同一 CPU 系列的客户端和服务器芯片上的最佳值可能大相径庭。因此,您应该 运行 尽可能多地在您作为目标的实际硬件上进行调优实验。如果您针对各种硬件,您可以在所有硬件上进行测试,并希望找到一个对所有硬件都适用的值,或者甚至考虑 compile-time 或 运行 时间调度,具体取决于 CPU类型(并不总是足够,如上所述)或基于 运行 时间测试。现在仅仅依靠硬件预取开始听起来好多了,不是吗?

您可以使用相同的方法找到最佳 HINT,因为搜索 space 很小(只有 4 个值可以尝试)- 但在这里您应该知道不同值之间的区别提示(特别是 _MM_HINT_NTA)可能仅显示为代码中的性能差异 运行s after 此循环,因为它们会影响与此内核无关的数据量在缓存中。

您可能还会发现这种预取根本没有帮助,因为您的访问模式是完全线性的,并且可能会被 L2 流预取器很好地处理。还有一些额外的、更硬编码的东西你可以尝试或考虑:

  • 您可能会调查仅在 4K 页面边界开始处进行预取是否有帮助3。这将使您的循环结构复杂化:您可能需要一个嵌套循环来分隔 "near edge of page" 和 "deep inside the page" 情况,以便仅在页面边界附近发出预取。您还需要制作输入数组 page-aligned,否则它会变得更加复杂。
  • 你可以试试disabling some/all of the hardware prefetchers。这通常对整体性能来说很糟糕,但是在使用软件预取进行高度调整的负载上,通过消除硬件预取的干扰,您可能会看到更好的性能。选择禁用预取还为您提供了一个重要的关键工具,可以帮助您了解正在发生的事情,即使您最终启用了所有预取器也是如此。
  • 确保您使用的是大页面,因为对于像这样的大的连续块,它们是理想的。
  • 在主计算循环的开始和结束时预取存在问题:在开始时,您会错过在每个数组的开头预取所有数据(在初始 PF_DIST window),并且在循环结束时,您将预取额外的 PF_DIST beyond 数组的末尾。充其量这些浪费获取和指令带宽,但它们也可能导致(最终丢弃)页面错误,这可能会影响性能。您可以通过特殊的介绍和结尾循环来解决这些问题。

我还强烈推荐由 5 部分组成的博客 post Optimizing AMD Opteron Memory Bandwidth,它描述了一个与您的问题非常相似的问题的优化,并且详细介绍了预取(它提供了很大的提升)。现在这是完全不同的硬件(AMD Opteron),它的行为可能与更新的硬件不同(尤其是英特尔硬件,如果你正在使用它的话)- bu改进的过程是关键,作者是该领域的专家。


1 它实际上可能以 2-cache-line 粒度工作,具体取决于它与相邻缓存行预取器的交互方式。在这种情况下,您可以发出一半的预取次数:每 128 字节一次。

2 在软件预取的情况下,您还可以 select 一些其他级别的缓存,使用时间提示。

3 有一些迹象表明,即使有完美的流式加载,尽管在现代英特尔硬件中存在 "next page prefetchers",页面边界仍然是硬件的障碍软件预取可以部分缓解预取。也许是因为软件预取作为一个更强的暗示"Yes, I'm going to read into this page",或者因为软件预取工作在虚拟地址级别并且必然涉及翻译机器,而L2预取工作在物理级别

4 请注意,PF_DIST 值的 "units" 是 sizeof(__mm128),即 16 个字节,这是由于我计算的方式地址。