【问题标题】:Optimizing Long Runs of Memory Reads and Writes优化内存读取和写入的长时间运行
【发布时间】:2016-08-25 04:34:00
【问题描述】:

我有一个名为 reorder.cc 的源文件,如下所示:

void reorder(float *output, float *input) {
  output[56] = input[0];
  output[57] = input[1];
  output[58] = input[2];
  output[59] = input[3];
  output[60] = input[4];
  ...
  output[75] = input[19];
  output[76] = input[20];
  output[77] = input[21];
  output[78] = input[22];
  output[79] = input[23];
  output[80] = input[24];
  ...
  output[98] = 0;
  output[99] = 0;
  output[100] = 0;
  output[101] = 0;
  output[102] = 0;
  output[103] = 0;
  output[104] = 0;
  output[105] = input[1];
  output[106] = input[2];
  output[107] = input[3];
  output[108] = input[4];
  output[109] = input[5];
  output[110] = input[6];
  output[111] = 0; 
  ...
}

函数 reorder 有一个很长的从输入到输出缓冲区的内存移动操作列表。从输入到输出的对应关系很复杂,但通常有足够长的运行,至少 10 个浮点数可以保证是连续的。运行被从任意输入索引开始的新运行中断,或者是“0”值。

带有 (g++-6 -march=native -Ofast -S reorder.cc) 的关联程序集文件 (.S) 文件生成以下程序集:

 .file "reorder.cc"
  .text
  .p2align 4,,15
  .globl  _Z9optimizedPfS_
  .type _Z9optimizedPfS_, @function
_Z9optimizedPfS_:
.LFB0:
  .cfi_startproc
  movss (%rsi), %xmm0
  movss %xmm0, 32(%rdi)
  movss 4(%rsi), %xmm0
  movss %xmm0, 36(%rdi)
  movss 8(%rsi), %xmm0
  movss %xmm0, 40(%rdi)
  movss 12(%rsi), %xmm0
  movss %xmm0, 44(%rdi)
  movss 16(%rsi), %xmm0
  movss %xmm0, 48(%rdi)
  movss 20(%rsi), %xmm0
  movss %xmm0, 52(%rdi)
  movss 28(%rsi), %xmm0
  movss %xmm0, 60(%rdi)
  movss 32(%rsi), %xmm0
  movss %xmm0, 64(%rdi)
  movss 36(%rsi), %xmm0
  ...

... 对应于每条装配线的单个移动单个标量 (fp32) 值。我认为编译器足够智能,可以编译成更智能的指令,例如 MOVDQU(移动未对齐双四字,适用于 128 位字)运行足够长的时间?

我正在考虑手写一个简单的解析器,它需要长时间运行并自动调用 movdqu,但我发现这很乏味、笨拙且容易出错。

是否有一个特殊的编译器标志可以自动检测这些长时间运行并生成有效的指令?我注定要使用内在函数来进一步优化这段代码,还是有一个聪明的技巧可以自动为我做这个记账?

reorder.cc 大约有 100,000 条这些输入、输出对的指令,这是我正在处理的较小的测试用例。

另外,关于编译这些移动指令的 100K+ 或更多行的大型源文件有什么技巧吗? g++-6 -Ofast 在像这样的 1M 行文件上需要数小时才能用于 Macbook Pro i7 处理器。

【问题讨论】:

  • 嗯,使用 memcpy 可能是最理想的方法
  • 我用 memcpy 替换了一次运行,但它生成了 movq(64 位移动而不是 movss 的 32 位)。在深入重写之前,我仍在寻找是否有自动解决方案。
  • 如果您的算法确切地知道应该如何复制数据,您可以将长赋值拆分为几个单独的memcpymemset
  • 幸运的是,0 很容易处理。初始化时整个输出缓冲区只需 calloc() 或 memset 为 0。 memcpy 仍然生成 64 位移动。如果可能,寻找 128 或 256 位移动而不诉诸内在函数。
  • 如果这个缓冲区很大,那么可以使用calloc 来利用来自操作系统的新页面无论如何都开始归零的事实(以避免信息泄漏)。重用缓冲区时,不要在写入部分缓冲区之前memset 整个缓冲区;这可能会比使用零和新加载的数据(特别是如果它大于 L1 缓存)按顺序编写它的效率低。

标签: c++ memory assembly compiler-optimization


【解决方案1】:

一个版本,例如使用movupsmovdqu,意味着有效地并行执行赋值,如果函数的参数可能有别名,这可能是不正确的。

如果它们没有别名,您可以使用非标准的__restrict__ 关键字。

也就是说,gcc 仍然只会向量化循环,所以重写程序如下:

// #define __restrict__ 
void reorder(float * __restrict__ output, float * __restrict__ input) {

  for (auto i = 0; i < 5; i++)
    output[i+56] = input[i];

  for (auto i = 0; i < 6; i++)
    output[i+75] = input[i+19];

  for (auto i = 0; i < 7; i++)
    output[i+98] = 0;

  for (auto i = 0; i < 6; i++)
    output[i+105] = input[i+1];

  output[111] = 0; 
}

这个,用-O2 -ftree-vectorize编译,生成:

reorder(float*, float*):
        movups  xmm0, XMMWORD PTR [rsi]
        movups  XMMWORD PTR [rdi+224], xmm0
        movss   xmm0, DWORD PTR [rsi+16]
        movss   DWORD PTR [rdi+240], xmm0
        movups  xmm0, XMMWORD PTR [rsi+76]
        movups  XMMWORD PTR [rdi+300], xmm0
        movss   xmm0, DWORD PTR [rsi+92]
        movss   DWORD PTR [rdi+316], xmm0
        movss   xmm0, DWORD PTR [rsi+96]
        movss   DWORD PTR [rdi+320], xmm0
        pxor    xmm0, xmm0
        movups  xmm1, XMMWORD PTR [rsi+4]
        movups  XMMWORD PTR [rdi+392], xmm0
        pxor    xmm0, xmm0
        movups  XMMWORD PTR [rdi+420], xmm1
        movss   DWORD PTR [rdi+408], xmm0
        movss   DWORD PTR [rdi+412], xmm0
        movss   xmm1, DWORD PTR [rsi+20]
        movss   DWORD PTR [rdi+436], xmm1
        movss   xmm1, DWORD PTR [rsi+24]
        movss   DWORD PTR [rdi+416], xmm0
        movss   DWORD PTR [rdi+440], xmm1
        movss   DWORD PTR [rdi+444], xmm0
        ret

不理想,但仍然有一些动作是用一个 insn 完成的。

https://godbolt.org/g/9aSmB1

【讨论】:

  • 好主意,我没想过尝试短循环。但是,是的,gcc 会很高兴地剥离非 4 倍数的迭代。 (不过,它不会使用 movsdmovq 一次复制 2 个浮点数。不过,clang 会。)
【解决方案2】:

首先,在其他函数中读取input[] 时,可能(或可能不)值得即时执行此操作。如果洗牌有任何模式,那可能还不错。 OTOH,无论您将此数组提供给什么,它都可能会破坏预取。


您是否尝试过使用__restrict__ 告诉编译器通过一个指针进行访问不能与通过另一个指针进行的访问进行别名?如果它自己不能证明这一点,那么当源像这样交错加载或存储时,它是不允许组合加载或存储的。

restrict 是 ISO C++ 中未包含的 C99 功能,但the common C++ compilers support __restrict__ or __restrict as an extension。对 MSVC 上的 #define __restrict__ __restrict 使用 CPP 宏,或在不支持任何等效项的编译器上使用空字符串。


gcc 在加载/存储合并方面很糟糕,但 clang 没有。

这是 gcc 中一个长期存在的错误,它在合并负载/存储方面很糟糕(请参阅bugzilla link,但我想我记得看到过更早以前的另一个错误报告,比如来自 gcc4.0 或其他东西)。结构复制(生成逐个成员的加载/存储)通常会遇到这种情况,但这里是同样的问题。

使用__restrict__,clang 能够将示例函数中的大部分加载/存储合并为 xmm 或 ymm 向量。它甚至生成最后三个元素的矢量负载和标量vpextrd!请参阅 the Godbolt compiler explorer 上的代码 + asm 输出,来自 clang++3.8 -O3 -march=haswell

使用相同的源,g++6.1 仍然完全无法合并任何内容,即使是连续的零。 (尝试在godbolt上将编译器翻转到gcc)。即使我们使用-march=haswell 编译,未对齐的向量非常便宜,它甚至在使用小的 memcpy 时也做得很差,不使用 SIMD。 :/


如果有任何类型的模式,利用reorder() 函数中的模式来节省代码大小将有很大帮助。即使加载/存储合并为 SIMD 向量,您仍然会破坏 uop 缓存和 L1 指令缓存。代码获取将与数据加载/存储竞争 L2 带宽。一旦你的数组索引对于 8 位位移来说太大了,每条指令的大小就会变得更大。 (操作码字节 + ModRM 字节 + disp32)。如果它不会合并,那么 gcc 没有将这些移动优化为 32 位 mov 指令(1 个操作码字节)而不是 movss (3 opcode bytes),这太糟糕了

所以在这个函数返回后,你的程序的其余部分将在很短的时间内运行得比正常速度慢,因为 32kiB L1 指令缓存和更小的 uop 缓存会变冷(充满来自臃肿的 mov 指令重新排序功能)。使用性能计数器查看 I-cache 未命中。另请参阅 标签 wiki 以了解有关 x86 性能的更多信息,尤其是 Agner Fog's guides


正如您在 cmets 中所建议的,calloc 是在您需要新的输出缓冲区时避免归零部分的好方法。它确实利用了来自操作系统的新页面无论如何都开始归零的事实(以避免信息泄漏)。不过,重用现有缓冲区比释放它并分配一个新缓冲区要好,因为旧缓冲区在缓存和/或 TLB 中仍然很热。而且至少页面仍然是连线的,而不是在你第一次触摸它们时出现故障。


使用 memcpymemset 而不是每个元素的分配可能会有助于您的编译时间。 如果源代码非常重复,您可以用 perl(或您选择的文本操作语言)编写一些东西,将连续复制运行转换为 memset 调用。

如果有任何大型运行(例如 128 字节或更多),理想的 asm 是许多 CPU 上的 rep movsd(或 rep movsq),尤其是最近的 Intel。 gcc 通常将 memcpy 内联到 rep movs 而不是在编译时知道大小时调用库 memcpy,您甚至可以 tune the strategy (SIMD vs. rep movs) with -mstringop-strategy。如果没有可让您将其编码为循环的模式,则代码大小的节省可能对您有很大的好处。

如果您的模式允许,可能值得复制一个更大的连续块,然后返回零或将其他内容复制到几个元素中,因为rep movs 具有显着的启动开销,但一旦启动并运行,性能非常好。 (即使在 IvB 的快速 rep movsb 功能according to Andy Glew (who implemented it in P6) 之前,当它在 Intel CPU 上存储整个缓存行时,避免读取所有权开销。)

如果你不能只用 clang 而不是 gcc 编译这个目标文件,也许你应该考虑自己为它生成 asm。如果这会显着降低您的程序速度(并且复制那么多内存 + 核对指令缓存可能会很好地做到这一点),那么一些文本处理可以将范围列表转换为设置 rsirdiecxrep movsd


按顺序读取输入可能比按顺序写入输出提供更好的性能。缓存未命中存储对管道的影响通常小于缓存未命中加载。 OTOH,一起为单个缓存行执行所有存储可能是一件好事。如果这是一个重大瓶颈,值得一试。

如果您确实使用了内在函数,那么如果您的数组非常大,那么对于覆盖整个高速缓存行(64B 大小,64B 对齐)的连续运行部分可能值得使用 NT 存储。或者也许使用 NT 存储按顺序进行存储?

NT 加载可能是个好主意,但IDK if the NT hint does anything at all on normal write-back memory。它们不是弱排序的,但有一些方法可以减少缓存污染(请参阅该链接以了解我的猜测)。


如果对您的程序有用的话,就地进行 shuffle 可能是个好主意。由于输出包括一些零运行,我假设它比输入长。在这种情况下,如果从数组的末尾开始,就地执行可能是最简单的。我怀疑就地洗牌不是你想要的,所以我不会多说。

【讨论】:

  • 感谢您对彼得的扩展回复,阅读您的帖子我学到了很多。您能否扩展有关 L1 指令缓存和 i-cache 压力的部分?我熟悉这些术语,但我想了解更多信息。
  • @CarlodelMundo:好的。 IDK 你多久运行一次这个函数,或者在这之间运行了多少其他代码。乱序执行依赖于能够提前获取正在执行的内容,因此每次跳转/调用冷代码块都会导致执行中的重大停顿。数据缓存未命中延迟可以通过乱序执行部分隐藏,但 I-cache 未命中会使 CPU 不知道下一步该做什么。
猜你喜欢
  • 1970-01-01
  • 2019-10-09
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-05-25
  • 1970-01-01
  • 2020-10-01
  • 1970-01-01
相关资源
最近更新 更多