启用优化后,为什么此代码慢6.5倍?


68

我想基准glibcstrlen功能,出于某种原因,发现它显然执行慢与GCC启用优化,我不知道为什么。

这是我的代码:

在我的机器上,它输出:

启用优化会以某种方式使它执行更长的时间。


2
请报告给gcc的bugzilla。
马克·格里斯

3
使用-fno-builtin使问题消失。因此,大概的问题在于,在这种特定情况下,GCC的内置strlen速度比库的慢。
David Schwartz

2
repnz scasb在-O1处为strlen生成。
Marc Glisse

9
@MarcGlisse它已经被归档:gcc.gnu.org/bugzilla/show_bug.cgi?
贾斯汀

4
@Damon性能注意事项也被视为gcc(以及与此相关的大多数编译器)的错误报告。如果他们决定不更改它,那很好。如果他们认为值得更改,那也很好。如果您没有遇到性能错误,那么编译器团队将不会意识到有什么需要注意的。
贾斯汀

Answers:


61

Godbolt的Compiler Explorer上测试代码可提供以下解释:

  • -O0或不最佳化,所生成的代码调用C库函数strlen;
  • -O1生成的代码处,使用一条rep scasb指令进行简单的内联扩展;
  • -O2以上版本中,生成的代码使用了更为详尽的内联扩展。

反复对代码进行基准测试,表明从一次运行到另一次运行的实质性变化,但是增加迭代次数则表明:

  • -O1代码比C库实现慢得多:32240VS3090
  • -O2代码-O1比C库代码快,但仍然慢得多:8570vs 3090

此行为特定于gccGNU libc和GNU libc。在OS / Xclang和Apple的Libc上进行的相同测试并未显示出明显的差异,这并不奇怪,因为Godbolt显示了在所有优化级别上都会clang生成对C库的调用strlen

这可能被认为是gcc / glibc中的错误,但是更广泛的基准测试可能表明,调用的开销所strlen产生的影响要比缺少用于小字符串的内联代码的性能更重要。基准测试中的字符串异常大,因此将基准测试重点放在超长字符串上可能不会产生有意义的结果。

我改进了此基准并测试了各种字符串长度。从运行在Intel(R)Core(TM)i3-2100 CPU @ 3.10GHz上且运行gcc(Debian 4.7.2-5)4.7.2的Linux上的基准来看,生成的内联代码-O1始终较慢,例如对于中等长度的字符串,它的大小大约是10倍,而对于非常短的字符串,-O2它仅比libc快一点,而对于较长的字符串,它的速度只有strlen一半。根据这些数据,strlen至少在我的特定硬件上,大多数字符串长度的GNU C库版本都非常有效。还请记住,缓存会对基准测试产生重大影响。

这是更新的代码:

这是输出:

chqrlie> gcc -std = c99 -O0 benchstrlen.c && ./a.out
平均长度0->平均时间:14.000 ns /字节,14.000 ns /通话
平均长度4->平均时间:2.364 ns /字节,13.000 ns /通话
平均长度10->平均时间:1.238 ns /字节,13.000 ns /通话
平均长度50->平均时间:0.317 ns /字节,16.000 ns /通话
平均长度100->平均时间:0.169 ns /字节,17.000 ns /通话
平均长度500->平均时间:0.074 ns /字节,37.000 ns /通话
平均长度1000->平均时间:0.068 ns /字节,68.000 ns /通话
平均长度5000->平均时间:0.064 ns /字节,318.000 ns /调用
平均长度10000->平均时间:0.062 ns /字节,622.000 ns /通话
平均长度1000000->平均时间:0.062 ns /字节,62000.000 ns /通话
chqrlie> gcc -std = c99 -O1 benchstrlen.c && ./a.out
平均长度0->平均时间:20.000 ns / byte,20.000 ns / call
平均长度4->平均时间:3.818 ns /字节,21.000 ns /通话
平均长度10->平均时间:2.190 ns /字节,23.000 ns /通话
平均长度50->平均时间:0.990 ns /字节,50.000 ns /通话
平均长度100->平均时间:0.816 ns /字节,82.000 ns /通话
平均长度500->平均时间:0.679 ns /字节,340.000 ns /通话
平均长度1000->平均时间:0.664 ns /字节,664.000 ns /通话
平均长度5000->平均时间:0.651 ns /字节,3254.000 ns /通话
平均长度10000->平均时间:0.649 ns / byte,6491.000 ns / call
平均长度1000000->平均时间:0.648 ns /字节,648000.000 ns /通话
chqrlie> gcc -std = c99 -O2 benchstrlen.c && ./a.out
平均长度0->平均时间:10.000 ns / byte,10.000 ns / call
平均长度4->平均时间:2.000 ns /字节,11.000 ns /通话
平均长度10->平均时间:1.048 ns /字节,11.000 ns /通话
平均长度50->平均时间:0.337 ns /字节,17.000 ns /通话
平均长度100->平均时间:0.299 ns /字节,30.000 ns /通话
平均长度500->平均时间:0.202 ns /字节,101.000 ns /通话
平均长度1000->平均时间:0.188 ns / byte,188.000 ns / call
平均长度5000->平均时间:0.174 ns / byte,868.000 ns / call
平均长度10000->平均时间:0.172 ns / byte,1716.000 ns / call
平均长度1000000->平均时间:0.172 ns / byte,172000.000 ns / call

2
可以,但是C库中的手动优化版本可能更大,并且内联起来更加复杂。我最近没有对此进行研究,但是过去曾经混合使用复杂的平台特定宏<string.h>和gcc代码生成器中的硬编码优化。英特尔目标肯定还有改进的空间。
chqrlie

1
@Brendan:平均字符串长度在一个应用程序与另一个应用程序之间相差很大,并且平均值不如各种长度的统计分布重要。如果strlen是给定应用程序的瓶颈,那么它的代码很可能会保留在指令缓存中...总的来说-O1,由于REP SCASB硬件性能不佳,糟糕的是为生成的代码。这在很大程度上取决于CPU版本。优化是做出有效的折衷,而不是达到完美。
chqrlie

1
@chqrlie:我在这里要强调的问题是人们以“实践中的实际不现实”场景为基准,然后根据不现实的结果做出错误的假设,然后根据这些错误的假设优化代码(例如,在库中)。如果使用strlen() is a bottleneck (e.g. because the strings actually are large) anyone that cares about performance will keep track of string lengths themselves and will not use strlen(); and (for people that care about performance) strlen()`仅在字符串太小而无法跟踪其长度时才使用。
布伦丹

4
@chqrlie:我还要说这部分是更大问题的征兆-库中的代码无法针对任何特定情况进行优化,因此在某些情况下必须是“非最佳”的。要解决此问题,如果有一个strlen_small()和一个单独的strlen_large(),那就很好了,但是没有。
布伦丹

1
@AndrewHenle:通常,您知道您的字符串很小,甚至更多时候您知道它们通常很小,并且想要针对这种情况进行优化。没有人建议strlen_small()大型字符串会失败,只是如果字符串确实很大,它可能不会加速到很高的速度。
彼得·科德斯

31

GCC的内联strlen模式比SSE2 pcmpeqb/慢得多pmovmskb,并且bsf考虑到的16字节对齐方式calloc。这种“优化”实际上是一种悲观。

我使用16字节对齐方式的简单手写循环比-O3大型缓冲区的gcc内联速度快5倍,对于短字符串则快2倍。(并且比为短字符串调用strlen更快)。我在https://gcc.gnu.org/bugzilla/show_bug.cgi?id=88809上添加了一条注释,以建议在gcc可以插入时在-O2 / -O3处插入什么内容。(如果我们只知道4字节对齐,建议增加到16字节。)


当gcc知道缓冲区具有4字节对齐方式(由保证calloc)时,它选择strlen使用GP整数寄存器(-O2及更高版本)内联为一次4字节标量bithack 。

(仅当我们知道无法跨入不包含任何字符串字节的页面并且因此可能未映射时,一次读取4个字节才是安全的。 在同一个缓冲区中读完缓冲区是否安全?在x86和x64上的页面?(TL:DR是的,在asm中是这样,所以即使在C源代码中是UB,编译器也可以发出执行此操作的代码。libcstrlen实现也可以利用该代码。有关链接,请参见我的回答到glibc strlen,以及它对于大型字符串如何快速运行的摘要。)

-O1,gcc始终 (即使没有已知的对齐方式)也选择内联strlenrepnz scasb,这非常慢(现代Intel CPU上每个时钟周期大约1个字节)。不幸的是,“快速字符串”仅适用于rep stosrep movs,不适用于repz/repnz指令。它们的微代码一次仅是1个字节,但是它们仍然有一些启动开销。(https://agner.org/optimize/

(例如,我们可以通过存储/重新加载s到a来“隐藏”来自编译器的指针来进行测试volatile void *tmp。gcc必须对从a读取的指针值进行零假设volatile,破坏任何对齐信息。)


GCC确实有一些x86调整选项,例如-mstringop-strategy=libcallvs. unrolled_loopvs. rep_byte,通常用于内联字符串操作(不仅仅是strlen;memcmp这将是可以通过rep或循环完成的另一个主要调整)。我还没有检查这些在这里有什么作用。

另一个选项的文档还描述了当前的行为。即使在我们需要未对齐指针的情况下,我们也可以获得这种内联(带有用于对齐处理的额外代码)。(这曾经是一个实际的性能胜利,尤其是对于小的字符串而言,在与计算机可以执行的操作相比,内联循环不会浪费的目标上)

-minline-all-stringops
默认情况下,仅当已知目标与至少4个字节的边界对齐时,GCC才会内联字符串操作。这可以实现更多的内联并增加代码大小,但是可以提高依赖于短长度的快速memcpy,strlen和memset的代码性能。

GCC还具有按功能显示的功能,您显然可以使用它来控制它,例如__attribute__((no-inline-all-stringops)) void foo() { ... },但是我还没有使用它。(这与inline-all相反。这并不意味着没有inline,只有在知道4字节对齐时才返回到inline。)


gcc的两种内联strlen策略都无法利用16字节对齐的优势,对于x86-64来说非常糟糕

除非小字符串的情况非常普遍,否则只做一个4字节的块,那么对齐的8字节的块的速度大约是4字节的两倍。

4字节策略的清理速度比在包含零字节的dword中查找字节所需的清理速度要慢得多。它通过查找设置了高位的字节来检测到此情况,因此它应仅屏蔽其他位并使用bsf(正向扫描)。在现代CPU(Intel和Ryzen)上具有3个周期的延迟。或者编译器可以使用rep bsf它,使其tzcnt在支持BMI1的CPU上运行,这在AMD上更为有效。 bsf并且tzcnt给出相同的结果为非零输入。

GCC的4字节循环看起来像是用纯C或某些与目标无关的逻辑编译的,没有利用bitcan。在使用andnBMI1为x86进行编译时,gcc确实使用了优化功能,但每个周期仍少于4个字节。

SSE2 pcmpeqb+bsf是非常多的好短期和长期的投入。X86-64保证SSE2是可用的,和X86-64系统V已经alignof(maxalign_t) = 16这样calloc将始终返回至少16字节对齐的指针。


我写了一个替换strlen块来测试性能

正如预期的那样,Skylake一次处理16个字节而不是4个字节的速度快了大约4倍。

(我使用asm编译了原始源代码-O3,然后编辑了asm来查看这种内联扩展策略应具有的性能strlen。我还将其移植到C源代码中的内联asm上;请在Godbolt上查看该版本。)

请注意,我将部分清理工作优化为商店寻址模式:我用-16位移来纠正过冲,这只是查找字符串的末尾,而不是实际计算长度,然后像GCC一样进行索引内联其每次4字节的循环。

要获得实际的字符串长度(而不是指向末尾的指针),您需要减去rdx-start然后添加rax-16(也许使用LEA来添加2个寄存器+一个常量,但是3分量LEA会有更大的延迟。)

与AVX以允许负载+在一个指令而不破坏归零寄存器比较,整个循环只有4微指令,从5(试验/ JZ宏熔断器分解成在Intel和AMD的一个微指令。 vpcmpeqb非索引存储器-source可以使它在整个管道中保持微融合,因此前端只有1个融合域uop。)

(请注意,将128位AVX与SSE混合使用,即使在Haswell上也不会造成停顿,只要您处于清除状态即可。因此,我不必为将其他指令更改为AVX而烦恼要紧,似乎有一些轻微的效果,即pxor实际上是略不是vpxor我的桌面上,但是,对于一个AVX循环体。它似乎有些重复,但它的怪异,因为没有代码大小的区别,因此没有对准差异。)

pmovmskb是单联指令。它在Intel和Ryzen上有3个周期的延迟(在Bulldozer系列上更糟)。对于短字符串,从SIMD单元返回整数的行程是关键路径依赖关系链的重要组成部分,对于从输入存储字节到准备就绪的存储地址的等待时间而言。但是只有SIMD具有压缩整数比较,因此标量将不得不做更多的工作。

对于非常小的字符串(例如0到3个字节),通过使用纯标量(尤其是Bulldozer系列),可能会在这种情况下实现稍低的延迟,但是将所有0到15个字节的字符串都作为对于大多数短字符串用例,相同的分支路径(永不采取循环分支)非常好

当我们知道16字节对齐时,对所有不超过15个字节的字符串都非常好,这似乎是一个不错的选择。更可预测的分支非常好。(请注意,在循环时,pmovmskb等待时间只会影响我们检测分支错误预测脱离循环的速度;分支预测+投机执行会隐藏每次迭代中独立pmovmskb的等待时间。

如果我们期望更长的字符串是通用的,我们可以展开一点,但是那时候您应该只调用libc函数,以便它可以在运行时分发给AVX2。展开到1个以上的向量会使清理复杂化,从而伤害了简单的案例。


在我的机器i7-6700k Skylake上,最大Turbo速度为4.2GHz(并且energy_performance_preference=性能),在Arch Linux上使用gcc8.2,我得到了一些一致的基准时序,因为我的CPU时钟速度在内存设置期间有所提高。但也许并非总是如此。内存受限时,Skylake的硬件电源管理会降频。 perf stat显示了运行此命令以平均stdout输出的平均值并查看stderr的性能摘要时,我通常在4.0GHz附近。

我最终将我的asm复制到GNU C inline-asm语句中,因此可以将代码放在Godbolt编译器资源管理器上

对于大字符串,长度与问题相同:〜4GHz Skylake上的时间

  • 〜62100clock_t个时间单位:-O1代表中汽南方:(clock()有点过时了,但我没有理会改变它。)
  • 〜15900clock_t时间单位:-O3gcc 4字节循环策略:100次运行的平均值=。(也许〜15800用-march=nativeandn
  • 〜1880clock_t时间单位:使用AVX2使用-O3glibcstrlen函数调用
  • 〜3190clock_t时间单位:(AVX1 128位矢量,4 UOP环)手写联汇编该GCC可以/应内嵌。
  • 〜3230clock_t时间单位:(SSE2 5 uop循环)gcc可以/应该内联的手写内联asm。

我的手写asm也适合短字符串,因为它不需要专门分支。已知的对齐方式对于strlen非常有用,而libc无法利用它。

如果我们认为大型字符串很少见,那么在这种情况下,它比libc慢1.7倍。1M字节的长度意味着它不会在我的CPU的L2(256k)或L1d缓存(32k)中保持高温,因此即使瓶颈在L3缓存中,libc版本也会更快。(可能是展开的循环和256位向量不会使ROB阻塞,因为每字节只有uops,因此OoO exec可以看到更远的距离并获得更多的内存并行性,尤其是在页面边界处。)

但是L3缓存带宽可能是阻止4-uop版本以每个时钟1次迭代运行的瓶颈,因此,我们发现AVX在为我们节省循环uu方面的好处较少。在L1d缓存中有热数据的情况下,我们每次迭代应获得1.25个周期,而不是1。

但是一个好的AVX2实现可以vpminub在检查零并返回查找它们的位置之前使用组合对读取每个周期最多读取64个字节(2x 32字节负载)。此库与libc之间的间隙打开的范围更大,范围从〜2k到〜30 kiB左右,以便在L1d中保持高温。

对于length = 1000的一些只读测试表明,strlen对于L1d高速缓存中的中等大小的字符串,glibc实际上比我的循环快大约4倍。它足够大,足以使AVX2加速到大的展开循环,但仍很容易装入L1d缓存中。(只读,避免存储转发停顿,因此我们可以进行多次迭代)

如果你的字符串是很大的,你应该使用显式长度的字符串,而不是需要给strlen所有,因此内联一个简单的循环仍然似乎是一个合理的策略,只要它实际上是于短字符串,而不是垃圾总量的介质( (例如300字节)和非常长的字符串(>缓存大小)。


使用以下方法对小字符串进行基准测试:

我在尝试获得预期的结果时遇到了一些奇怪的事情:

我尝试s[31] = 0在每次迭代之前截断字符串(允许较短的常量长度)。但是后来我的SSE2版本几乎与GCC版本的速度相同。 商店摊位是瓶颈! 字节存储再加上更大的负载使存储转发采用慢速路径,该路径将存储缓冲区中的字节与L1d高速缓存中的字节合并在一起。这种额外的延迟是循环传输的dep链的一部分,该链通过字符串的最后4个字节或16个字节块来计算下一次迭代的存储索引。

GCC较慢的一次4字节代码可以通过在该延迟的阴影下处理较早的4字节块来跟上。(乱序执行非常棒:缓慢的代码有时不会影响程序的整体速度)。

我最终通过制作一个只读版本并使用内联asm阻止编译器strlen退出循环来解决了该问题。

但是在使用16字节加载时,存储转发是一个潜在的问题。如果其他C变量存储在数组末尾之后,则可能会导致SF停顿,这是因为与较窄的存储区相比,数组末尾的加载量更大。对于最近复制的数据,如果使用16字节或更宽的对齐存储区进行复制,则很好,但是对于小副本,glibc memcpy会从对象的开始和结束进行2倍的覆盖整个对象的重叠负载。然后,它再次存储这两个重叠的内容,并免费处理memmove src重叠的内容。因此,刚刚存储的短字符串的第2个16字节或8字节块可能会给我们一个SF停顿,以读取最后一个块。(与输出具有数据依赖性的那个。)

只是运行速度较慢,这样一来您就无法做好准备,这通常是不好的,因此这里没有很好的解决方案。我认为在大多数情况下,您不会浪费刚刚的缓冲区,通常strlen,您只会读到只读取的输入,因此存储转发停顿不是问题。如果有其他东西刚刚写出来,那么高效的代码希望不会丢掉这个长度,而是调用一个需要重新计算它的函数。


我还没有完全弄清楚的其他怪异现象:

代码对齐对于只读(大小= 1000(s[1000] = 0;))而言,相差2倍。但最里面的asm循环本身与.p2align 4或对齐.p2align 5。增加循环对齐会使其降低2倍!

注意分支肯定会丢失非零值,而快速版本的错误几乎是零。而且,发出的uops比快速版本要高得多:它可能长时间在每个分支未命中的错误路径上进行推测。

内外分支分支可能彼此混叠,也可能不混叠。

指令计数几乎相同,只是内循环之前的外循环中的某些NOP有所不同。但是IPC却有很大的不同:没有问题,快速版本在整个程序中平均每个时钟运行4.82条指令。(由于在test / jz中将2条指令宏融合到1个uop中,因此大多数操作位于最内层的循环中,每个周期运行5条指令。)并且请注意,uops_exected远远高于uops_issueed:这意味着微融合可以很好地解决前端瓶颈问题。

我认为这只是分支预测,而不是其他前端问题。测试/分支指令不会越过边界,以防止宏融合。

改变.p2align 5.p2align 4扭转它们:-UHIDE_ALIGNMENT变得缓慢。

对于这两种情况,此Godbolt二进制链接都可以在Arch Linux上重现我在gcc8.2.1上看到的相同填充:2x 11字节nopw+nop外循环内的3字节用于快速情况。它还具有我在本地使用的确切来源。


简短的只读只读微基准测试:

使用选定的东西进行测试,因此不会受到分支错误预测或存储转发的影响,并且可以重复测试相同的短长度,以进行足够的迭代以获取有意义的数据。

strlen=33,因此终止符位于第三个16字节向量的开头附近。(使我的版本看起来比4字节版本更糟糕。) -DREAD_ONLY,并i<1280000作为外循环重复循环。

  • 1933 clock_t:我的asm:很好且一致的最佳情况时间(重新运行平均值时不吵闹/弹跳。)与/不带perf-DHIDE_ALIGNMENT一样,等效perf ,与更长的strlen不同。使用更短的模式,可以更容易地预测循环分支。(strlen = 33,而不是1000)。
  • 3220 clock_t:gcc -O3strlen。(-DHIDE_ALIGNMENT
  • 6100 clock_t:gcc -O3 4字节循环
  • 37200 clock_t:gcc -O1 repz scasb

因此,对于短字符串,我的简单内联循环击败strlen必须通过PLT(调用+ jmp [mem])进行的库函数调用,然后运行了不依赖对齐的strlen的启动开销。

分支错误预测可以忽略不计,对于带有的所有版本,错误率均为0.05%strlen(s)=33。repz scasb版本有0.46%,但是这是因为分支总数较少。没有内部循环可以容纳许多正确预测的分支。

分支预测变量和代码缓存很热,repz scasbstrlen为33字节的字符串调用glibc糟糕十倍。 在实际用例中strlen,分支丢失或什至在代码缓存和停顿中丢失的情况会更糟,但直线repz scasb不会。但是10倍是巨大的,那是一个相当短的字符串。


还相关:为什么glibc的复杂性需要如此复杂才能快速运行?有关glibc的C和x86-asm strlen的更多信息。
彼得·科德斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.