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

Optimizing Long Runs of Memory Reads and Writes

本文关键字:运行 内存 读取 优化      更新时间:2023-10-16

我有一个名为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,但我发现这很乏味,很难处理,而且容易出错。

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

rec是这些输入、输出对的100000条指令的数量级,这是我正在处理的较小的测试用例

此外,关于编译100K+或更多行移动指令的大型源文件,有什么技巧吗?g++-6-对于Macbook Pro i7处理器来说,Ofast在1M行文件上需要几个小时。

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

首先,在其他函数中读取input[]时,可能(也可能不)值得动态执行此操作。如果洗牌有什么样的模式,可能不会太糟糕。OTOH,它可能会在你向其提供的任何数组中击败预取。


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

restrict是C99的一个特性,它没有包含在ISO C++中,但常见的C++编译器支持__restrict____restrict作为扩展。在MSVC上对#define __restrict__ __restrict使用CPP宏,或者在不支持任何等效功能的编译器上对空字符串使用CPP宏。


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

这是gcc中一个长期存在的错误,它不善于合并加载/存储(请参阅bugzilla链接,但我想我还记得很久以前看到过另一个错误报告,比如gcc4.0之类的)。结构复制(逐成员加载/存储生成成员)通常会遇到这种情况,但这里也是同样的问题。

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

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


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

因此,在该函数返回后,程序的其余部分将在很短的时间内比正常情况下运行得慢,因为32kiB L1指令缓存和更小的uop缓存将是冷的(充满了来自臃肿的重新排序函数的mov指令)。使用性能计数器查看I缓存未命中。另请参阅x86标记wiki,以了解有关x86性能的更多信息,尤其是AgnerFog的指南。


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


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

如果有任何大的运行(如128字节或更多),那么在许多CPU上,特别是最近的Intel上,理想的asm是rep movsd(或rep movsq)。当编译时大小已知时,gcc通常将memcpy内联到rep movs,而不是调用库memcpy,您甚至可以使用-mstringop-strategy调整策略(SIMD与rep-movs)。如果没有一种模式可以让您将代码编码为循环,那么代码大小的节省可能对您来说是一个巨大的好处。

如果您的模式允许,那么可能值得复制一个更大的连续块,然后归零或将其他内容复制到几个元素中,因为rep movs具有显著的启动开销,但一旦启动并运行,性能就非常好。(Andy Glew(他在P6中实现了IvB的快速rep-movsb功能)表示,当它在英特尔CPU上存储整个缓存线时,甚至在IvB的快读movsb功能之前,就避免了所有权读取开销。)

如果你不能用clang而不是gcc编译这个对象文件,也许你应该考虑自己为它生成asm。如果这大大降低了程序的速度(复制那么多内存+清空指令缓存很可能会做到这一点),那么一些文本处理可以将范围列表转换为asm,为rep movsd设置rsirdiecx


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

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

NT加载可能是个好主意,但是如果NT提示在正常的回写内存上做任何事情,则使用IDK。它们并不是弱有序的,但有一些方法可以减少缓存污染(我的猜测请参阅该链接)。


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