当前C++编译器是否发出过"rep movsb/w/d"?

Does any of current C++ compilers ever emit "rep movsb/w/d"?

本文关键字:rep movsb 编译器 C++ 是否 当前      更新时间:2023-10-16

这个问题让我怀疑,当前的现代编译器是否会发出REP MOVSB/W/D指令。

基于这一讨论,使用REP MOVSB/W/D似乎对当前的CPU有益。

但无论我如何尝试,我都无法使当前的任何编译器(GCC 8、Clang 7、MSVC 2017和ICC 18)发出此指令。

对于这个简单的代码,发出REP MOVSB:可能是合理的

void fn(char *dst, const char *src, int l) {
for (int i=0; i<l; i++) {
dst[i] = src[i];
}
}

但是编译器会发出一个未优化的简单字节复制循环,或者一个巨大的展开循环(基本上是内联的memmove)。有编译器使用此指令吗?

GCC有x86调优选项来控制字符串操作策略以及何时内联与库调用。(请参见https://gcc.gnu.org/onlinedocs/gcc/x86-Options.html)。-mmemcpy-strategy=strategy alg:max_size:dest_align三元组,但暴力方式为-mstringop-strategy=rep_byte

我不得不使用__restrict让gcc识别memcpy模式,而不是在重叠检查/回退到哑字节循环后进行正常的自动向量化。(有趣的事实:即使使用-mno-sse,gcc-O3也会使用整数寄存器的全宽自动向量化。因此,如果使用-Os(针对大小进行优化)或-O2(小于完全优化)进行编译,则只会得到一个哑字节循环)。

请注意,如果src和dst与dst > src重叠,则结果为而不是memmove。相反,您将得到一个长度为dst-src的重复图案。即使在重叠的情况下,rep movsb也必须正确地实现精确的字节复制语义,因此它仍然有效(但在当前CPU上速度较慢:我认为微码只会回到字节循环)。

gcc只能通过识别memcpy模式,然后选择将memcpy内联为rep movsb来获得rep movsb它不会直接从字节复制循环到rep movsb,这就是为什么可能的混叠会破坏优化。(不过,当别名分析无法证明它是memcpy或memmove时,-Os可能会考虑在具有快速rep movsb的CPU上直接使用rep movs。)

void fn(char *__restrict dst, const char *__restrict src, int l) {
for (int i=0; i<l; i++) {
dst[i] = src[i];
}
}

这可能不应该"计数",因为除了"让编译器使用rep movs"之外,我可能不会为任何用例推荐这些调优选项,所以它与内在的没有太大区别我没有检查所有的-mtune=silvermont/-mtune=skylake/-mtune=bdver2(推土机版本2=Piledriver)等调整选项,但我怀疑其中任何一个都能启用。所以这是一个不切实际的测试,因为使用-march=native的人都不会得到这个代码。

但是上面的C在Godbolt编译器资源管理器上使用gcc8.1-xc -O3 -Wall -mstringop-strategy=rep_byte -minline-all-stringops编译到x86-64 System V:的asm

fn:
test    edx, edx
jle     .L1               # rep movs treats the counter as unsigned, but the source uses signed
sub     edx, 1            # what the heck, gcc?  mov ecx,edx would be too easy?
lea     ecx, [rdx+1]
rep movsb                 # dst=rdi and src=rsi
.L1:                              # matching the calling convention
ret

有趣的事实:为内联rep movs而优化的x86-64 SysV调用约定并非巧合(为什么Windows64使用与x86-64上所有其他操作系统不同的调用约定?)。我认为gcc在设计调用约定时更喜欢这样,所以它保存了指令。

rep_8byte做了一堆设置来处理不是8的倍数的计数,也许还有对齐,我没有仔细看。

我也没有检查其他编译器。


如果没有对齐保证,内嵌rep movsb将是一个糟糕的选择,所以编译器默认情况下不这样做是好的。(只要他们做得更好。)英特尔的优化手册中有一节是关于memcpy和带有SIMD矢量的memset与rep movs的比较。另请参阅http://agner.org/optimize/,以及x86标记wiki中的其他性能链接。

(我怀疑,如果你使用dst=__builtin_assume_aligned(dst, 64);或任何其他与编译器通信对齐的方式,gcc是否会做任何不同的事情。例如,在一些数组上使用alignas(64)。)

英特尔的IceLake微体系结构将具有"短周期"功能,这可能会减少rep movs/rep stos的启动开销,使其对小计数更有用。(目前rep字符串微码有显著的启动开销:REP做什么设置?)


memmove/memcpy策略:

顺便说一句,glibc的memcpy对对重叠不敏感的小输入使用了一种非常好的策略:两个加载->两个可能重叠的存储,用于最多2个寄存器范围的拷贝。例如,这意味着从4..7字节开始的任何输入都以相同的方式分支。

Glibc的asm来源对该策略有一个很好的描述:https://code.woboq.org/userspace/glibc/sysdeps/x86_64/multiarch/memmove-vec-unaligned-erms.S.html#19.

对于大型输入,它使用SSE XMM寄存器、AVX YMM寄存器或rep movsb(在glibc初始化自身时,检查了基于CPU检测设置的内部配置变量之后)。我不确定它实际会在哪些CPU上使用rep movsb(如果有的话),但支持将其用于大型拷贝。


rep movsb可能是一个非常合理的选择,适用于小代码大小和像这样的字节循环计数不可怕的缩放,并且可以安全地处理不太可能的重叠情况。

不过,对于当前CPU上通常较小的副本,使用微代码启动开销是一个大问题。

如果当前CPU上的平均拷贝大小可能为8到16个字节,和/或不同的计数会导致分支预测失误,那么它可能比字节循环更好。这不是,但不那么糟糕。

如果在不进行自动向量化的情况下进行编译,那么将字节循环转换为rep movsb的最后一搏窥视孔优化可能是个好主意(或者对于像MSVC这样的编译器,即使在完全优化的情况下也会进行字节循环。)

如果编译器更直接地了解它,并在为具有增强型Rep-Movs/Stos-Byte(ERMSB)功能的CPU进行调优时考虑将其用于-Os(针对代码大小而非速度进行优化),那将是一件很好的事情。(另请参阅Enhanced REP MOVSB for memcpy,了解关于x86内存带宽单线程与所有内核、避免RFO的NT存储以及使用避免RFO缓存协议的rep movs的许多好东西…)

在较旧的CPU上,rep movsb不适合大拷贝,因此推荐的策略是rep movsdmovsq,对最后几个计数进行特殊处理。(假设您要使用rep movs,例如在内核代码中,您不能触摸SIMD矢量寄存器。)

对于L1d或L2高速缓存中热的中等大小的副本,使用整数寄存器的-mno-sse自动矢量化比rep movs差得多,因此gcc在检查重叠后肯定应该使用rep movsbrep movsq,而不是qword复制循环,除非它期望小输入(如64字节)是常见的。


字节循环的唯一优点是代码大小小;它几乎是桶的底部;像glibc这样的智能策略对于较小但未知的副本大小会更好。但是,这太多的代码无法内联,并且函数调用确实有一些成本(溢出调用阻塞寄存器和阻塞红色区域,加上call/ret指令和动态间接链接的实际成本)。

尤其是在不经常运行的"冷"函数中(因此您不想在它上花费大量代码大小,从而增加程序的I-cache占用空间、TLB位置、要从磁盘加载的页面等)。如果手工编写asm,您通常会对预期的大小分布有更多的了解,并且能够通过回退到其他内容来内联快速路径。

请记住,编译器将对一个程序中可能存在的多个循环做出决定,而大多数程序中的大多数代码都在热循环之外。它不应该让他们都膨胀这就是为什么gcc默认为-fno-unroll-loops,除非启用了配置文件导向优化。(不过,自动矢量化是在-O3启用的,它可以为像这样的一些小循环创建大量的代码。gcc在循环序言/尾声上花费大量的代码大小,但在实际循环上花费很少,这很愚蠢;据它所知,每次外部代码运行时,循环将运行数百万次迭代。)

不幸的是,gcc的自动向量化代码并不是非常高效或紧凑。对于16字节SSE情况(完全展开15字节副本),它在循环清理代码上花费了大量代码大小。对于32字节的AVX向量,我们得到一个汇总字节循环来处理剩余元素。(对于17字节的副本,与1个XMM矢量+1字节或glibc风格的重叠16字节副本相比,这是非常糟糕的)。对于gcc7和更早的版本,它执行相同的完全展开操作,直到对齐边界作为循环序言,因此它的膨胀是原来的两倍。

IDK if profile guided优化将优化gcc的策略,例如,当每次调用的计数很小时,倾向于更小/更简单的代码,因此无法实现自动矢量化代码。或者,如果代码是"冷"的,并且在整个程序的每次运行中只运行一次或根本不运行,则更改策略。或者,如果计数通常是16或24左右,那么最后一个n % 32字节的标量就很糟糕,所以理想情况下PGO会将其设置为特殊情况下的较小计数。(但我并不太乐观。)

我可能会报告一个GCC遗漏的优化错误,关于在重叠检查后检测memcpy,而不是将其完全留给自动矢量器。和/或关于使用rep movs代替-Os,如果有更多关于该uarch的信息可用,可以使用-mtune=icelake

许多软件都是用-O2编译的,所以rep movs的窥视孔而不是自动矢量器可能会有所不同。(但问题是这是一个积极的还是消极的差异)!