优化内存读取和写入的长时间运行

时间:2016-08-25 04:34:01

标签: c++ memory assembly compiler-optimization

我有一个名为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; 
  ...
}

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

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

 .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 +或更多行这些移动指令的大型源文件的任何提示?对于Macbook Pro i7处理器,g ++ - 6 -Ofast在1M行文件上花费数小时。

2 个答案:

答案 0 :(得分:6)

使用例如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

答案 1 :(得分:5)

首先,在某些其他功能中从input[]读取时,可能(或可能不)值得这样做。如果洗牌有任何形式,那可能不会太糟糕。 OTOH,无论你为这个数组提供什么,它都可能会失败预取。

您是否尝试过使用__restrict__ 告诉编译器通过一个指针进行访问是否可以通过另一个指针进行访问?如果它无法证明这一点,那么当源对它们进行交错时,它就不允许组合加载或存储。

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

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来自calloc指令膨胀的重新排序功能)。使用perf计数器查看I-cache未命中。另请参阅标记wiki以了解有关x86性能的更多信息,尤其是Agner Fog's guides

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

使用memsetmemset而不是按元素分配可能有助于您的编译时间。 如果源代码非常重复,您可以使用perl(或您选择的文本操作语言)编写内容,以便将连续的复制运行转换为rep movsd次调用。

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

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

如果你不能用clang而不是gcc编译这个目标文件,那么你应该考虑自己为它生成asm。如果这会大大减慢你的程序(并且复制那么多内存+ nuking指令缓存可能会这样做),那么一些文本处理可以将范围列表转换为设置rdi的{​​as},{{1 } {}和ecx rep movsd

按顺序读取输入可能比按顺序写输出更好。缓存未命中存储对管道的影响通常小于缓存未命中负载。 OTOH,将单个缓存行的所有商店放在一起可能是一件好事。如果这是一个重要的瓶颈,值得玩。

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

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

如果对你的程序有用,那么就地进行随机播放可能是一个好主意。由于输出包含一些零的运行,我认为它比输入长。在这种情况下,如果从数组的末尾开始,就地执行可能是最简单的。我怀疑原地洗牌不是你想要的,所以我不会对此说太多。