当前的C ++编译器是否会发出“ rep movsb / w / d”?

时间:2018-07-01 09:00:39

标签: c++ assembly x86 compiler-optimization micro-optimization

这个question让我怀疑,当前的现代编译器是否会发出df <- structure(list(ID = 1:17, a = c(0L, 0L, 1L, 1L, 1L, 1L, 0L, 1L, 1L, 0L, 1L, 0L, 0L, 1L, 1L, 1L, 1L), b = c(0L, 0L, 1L, 2L, 3L, 4L, 0L, 1L, 2L, 0L, 1L, 0L, 0L, 1L, 2L, 3L, 4L)), .Names = c("ID", "a", "b"), class = "data.frame", row.names = c(NA, -17L)) 指令。

基于此discussion,看来在当前CPU上使用REP MOVSB/W/D可能会有所帮助。

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

对于这个简单的代码,发出REP MOVSB/W/D是合理的:

REP MOVSB

但是,编译器会发出未优化的简单字节复制循环或巨大的展开循环(基本上是内联的void fn(char *dst, const char *src, int l) { for (int i=0; i<l; i++) { dst[i] = src[i]; } } )。是否有任何编译器使用此指令?

1 个答案:

答案 0 :(得分:3)

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重叠,则结果为 not memmove。相反,您会得到一个长度为{dst-src的重复模式。 rep movsb即使在出现重叠的情况下也必须正确实现确切的字节复制语义,因此它仍然有效(但在当前CPU上速度很慢:我认为微码会退回到字节循环)。

gcc只能通过识别rep movsb模式然后选择以memcpy内联memcpy的方式进入rep movsb它不是直接从字节开始的将循环复制到rep movsb,这就是为什么可能的别名使优化失败的原因。 (不过,-Os考虑直接使用rep movs可能会很有趣,但是,当别名分析无法证明它是memcpy或memmove时,在具有快速rep movsb的CPU上。)

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(Bulldozer版本2 =打桩机)等调整选项,但是我怀疑其中任何一个都能做到这一点。因此,这是不切实际的测试,因为没有人使用-march=native会获得此代码源。

但是上面的C在Godbolt编译器资源管理器上将with 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调用约定不是巧合(Why does Windows64 use a different calling convention from all other OSes on x86-64?)。我认为gcc赞成在设计调用约定时使用它,从而节省了指令。

rep_8byte进行了 bunch 的设置,以处理不是8的倍数的计数,也许是对齐的,我没有仔细看。

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


没有对齐保证,内联rep movsb是一个糟糕的选择,因此,编译器默认情况下不执行此操作是件好事。 (只要它们做得更好。) Intel's optimization manual中有一节介绍了SIMD向量与rep movs相比的memcpy和memset。另请参见http://agner.org/optimize/the x86 tag wiki中的其他性能链接。

(我怀疑,如果您使用dst=__builtin_assume_aligned(dst, 64);或其他任何方式将对齐方式传达给编译器,gcc会做任何不同的事情,例如在某些数组上使用alignas(64)。)

英特尔的IceLake微体系结构将具有“短重复”功能,该功能可以减少rep movs / rep stos的启动开销,从而使它们在小批量生产时更加有用。 (当前rep字符串微代码的启动开销很大:What setup does REP do?


记忆/记忆策略:

顺便说一句,glibc的memcpy对不敏感的小输入使用了一种很好的策略:两个负载->两个可能重叠的存储,最多可复制2个寄存器。例如,这意味着来自4..7字节的任何输入都以相同的方式分支。

Glibc的汇编源代码中有一个很好的评论,描述了该策略: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字节(ERMSB)进行CPU调优时考虑将其用于-Os(对代码大小进行优化,而不是对速度进行优化),那将是一件好事。特征。 (有关x86内存带宽单线程与所有内核,避免RFO的NT存储以及使用避免RFO的高速缓存协议的rep movs的更多知识,请参阅Enhanced REP MOVSB for memcpy。)

在较旧的CPU上,rep movsb不适用于大型副本,因此推荐的策略是rep movsdmovsq,并且对最后几个计数进行特殊处理。 (假设您将要完全使用rep movs,例如,在无法触摸SIMD向量寄存器的内核代码中。)

对于在L1d或L2高速缓存中很热的中型副本,使用整数寄存器的-mno-sse自动向量化要比rep movs差很多,因此gcc一定要使用rep movsb或{{ 1}}后检查重叠,而不是qword复制循环,除非它期望小输入(如64字节)是常见的。


字节循环的唯一优点是代码尺寸小;它几乎是桶的底部;对于较小但未知的副本大小,像glibc这样的智能策略会更好。但这太多了以至于无法内联的代码,并且函数调用的确要付出一些代价(堆满调用密集寄存器并破坏红色区域,再加上rep movsq / call指令的实际成本以及动态链接间接)。

尤其是在不经常运行的“冷”功能中(因此,您不想在上面花费很多代码大小,从而增加了程序的I缓存占用空间,TLB局部性以及要从磁盘加载的页面等)。如果用手工编写asm,通常会对预期的大小分布有更多了解,并且能够内联快速路径并回退到其他路径。

请记住,编译器将在一个程序中可能的许多循环上做出决定,并且大多数程序中的大多数代码都在热循环之外。 这就是为什么gcc默认为ret的原因,除非启用了配置文件引导的优化。 (不过,自动矢量化已在-fno-unroll-loops上启用,并且可以为诸如此类的一些小循环创建大量代码。gcc在循环序言/结尾中花费大量代码大小是很愚蠢的,但是在实际的循环中只有很少的一部分;尽管如此,它知道每次运行外部代码时,循环将运行数百万次迭代。)

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

IDK(如果配置文件引导的优化会在此处优化gcc的策略),例如当每次调用的计数都较小时,倾向于使用较小/较简单的代码,因此将无法实现自动向量化的代码。如果代码是“冷的”并且在整个程序的每次运行中仅运行一次或根本不运行,则更改策略。或者,如果计数通常为16或24左右,则最后-O3个字节的标量会很糟糕,因此理想情况下,PGO会将其设为特殊情况下较小的计数。 (但我不太乐观。)

我可能会为此报告一个GCC缺少优化的错误,该错误涉及在重叠检查之后检测memcpy,而不是将其纯粹留给自动矢量化程序处理。和/或关于将n % 32用于rep movs,如果有更多关于该uarch的信息,也许与-Os一起使用。

许多软件仅使用-mtune=icelake进行编译,因此-O2的窥视孔(除了自动矢量化程序之外)可能会有所作为。 (但问题是这是正的还是负的差异)!