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

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

这个 让我想知道,如果当前的现代编译器曾经发出 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 必须正确实现精确的字节复制语义,即使在重叠的情况下,所以它仍然有效(但在当前 CPUs 上很慢:我认为微码只会退回到字节循环).

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

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

这可能不应该 "count" 因为我可能 不会 为除 [=165= 以外的任何用例推荐这些调整选项],所以它与内在的没什么不同。 我没有检查所有 -mtune=silvermont / -mtune=skylake / -mtune=bdver2(Bulldozer 版本 2 = Piledriver)/ 等。调整选项,但我怀疑它们中的任何一个都能实现这一点。所以这是一个不切实际的测试,因为没有人使用 -march=native 会得到这个代码生成。

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

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

有趣的事实:针对内联优化的 x86-64 SysV 调用约定 rep movs 并非巧合 (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 has a section on memcpy and memset with SIMD vectors vs. rep movs. See also http://agner.org/optimize/, and other performance links in the x86 tag wiki.

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

Intel 的 IceLake 微架构将有一个 "short rep" 功能,可以减少 rep movs / rep stos 的启动开销,使它们对小数量更有用。 (目前 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(在检查基于 CPU 设置的内部配置变量之后 - 在 glibc 初始化自身时进行检测)。我不确定它实际上会在哪个 CPU 上使用 rep movsb,如果有的话,但是支持将它用于大副本。


rep movsb 对于像这样 这样的字节循环进行计数的小代码大小和非可怕扩展可能是一个非常合理的选择,安全处理重叠的可能性不大。

微码启动开销是一个大问题,因为在当前 CPUs 上,将它用于通常很小的副本。

如果当前 CPUs 的平均副本大小可能是 8 到 16 字节,它可能比字节循环更好,and/or 不同的计数会导致分支预测错误很多。不是,但还算不错。

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

如果编译器更直接地了解它,并在使用增强型 Rep 调整 CPU 时考虑将其用于 -Os(优化代码大小而不是速度),那将会很好Movs/Stos 字节 (ERMSB) 功能。 (另请参阅 ,了解有关 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 指令和动态链接间接的实际成本).

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

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

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

IDK 如果配置文件引导的优化会在这里优化 gcc 的策略,例如当每次调用的计数很小时,支持更小/更简单的代码,因此不会达到自动矢量化代码。或者,如果代码是 "cold" 且整个程序的每个 运行 仅 运行 一次或根本没有,则更改策略。或者,如果计数通常为 16 或 24 或其他值,那么最后 n % 32 字节的标量很糟糕,因此理想情况下 PGO 会将其用于特殊情况下的更小计数。 (不过我也不太乐观。)

我可能会为此报告一个 GCC 未优化错误,关于在重叠检查后检测 memcpy 而不是将其完全留给自动向量化器。 And/or 关于将 rep movs 用于 -Os,如果有更多关于该 uarch 的信息可用,可能与 -mtune=icelake 一起使用。

很多软件只用 -O2 编译,所以 rep movs 的窥视孔而不是自动矢量化器可能会有所不同。 (但问题是这是正差还是负差)!