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始终 (即使没有已知的对齐方式)也选择内联strlen
为repnz scasb
,这非常慢(现代Intel CPU上每个时钟周期大约1个字节)。不幸的是,“快速字符串”仅适用于rep stos
和rep movs
,不适用于repz
/repnz
指令。它们的微代码一次仅是1个字节,但是它们仍然有一些启动开销。(https://agner.org/optimize/)
(例如,我们可以通过存储/重新加载s
到a来“隐藏”来自编译器的指针来进行测试volatile void *tmp
。gcc必须对从a读取的指针值进行零假设volatile
,破坏任何对齐信息。)
GCC确实有一些x86调整选项,例如-mstringop-strategy=libcall
vs. unrolled_loop
vs. 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。在使用andn
BMI1为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上查看该版本。)
# at this point gcc has `s` in RDX, `i` in ECX
pxor %xmm0, %xmm0 # zeroed vector to compare against
.p2align 4
.Lstrlen16: # do {
#ifdef __AVX__
vpcmpeqb (%rdx), %xmm0, %xmm1
#else
movdqa (%rdx), %xmm1
pcmpeqb %xmm0, %xmm1 # xmm1 = -1 where there was a 0 in memory
#endif
add $16, %rdx # ptr++
pmovmskb %xmm1, %eax # extract high bit of each byte to a 16-bit mask
test %eax, %eax
jz .Lstrlen16 # }while(mask==0);
# RDX points at the 16-byte chunk *after* the one containing the terminator
# EAX = bit-mask of the 0 bytes, and is known to be non-zero
bsf %eax, %eax # EAX = bit-index of the lowest set bit
movb $'A', -16(%rdx, %rax)
请注意,我将部分清理工作优化为商店寻址模式:我用-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附近。
perf stat -r 100 ./a.out | awk '{sum+= $1} END{print sum/100;}'
我最终将我的asm复制到GNU C inline-asm语句中,因此可以将代码放在Godbolt编译器资源管理器上。
对于大字符串,长度与问题相同:〜4GHz Skylake上的时间
- 〜62100
clock_t
个时间单位:-O1
代表中汽南方:(clock()
有点过时了,但我没有理会改变它。)
- 〜15900
clock_t
时间单位:-O3
gcc 4字节循环策略:100次运行的平均值=。(也许〜15800用-march=native
了andn
)
- 〜1880
clock_t
时间单位:使用AVX2使用-O3
glibcstrlen
函数调用
- 〜3190
clock_t
时间单位:(AVX1 128位矢量,4 UOP环)手写联汇编该GCC可以/应内嵌。
- 〜3230
clock_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倍!
# slow version, with *no* extra HIDE_ALIGNMENT function call before the loop.
# using my hand-written asm, AVX version.
i<1280000 read-only at strlen(s)=1000 so strlen time dominates the total runtime (not startup overhead)
.p2align 5 in the asm inner loop. (32-byte code alignment with NOP padding)
gcc -DUSE_ASM -DREAD_ONLY -DHIDE_ALIGNMENT -march=native -O3 -g strlen-microbench.c &&
time taskset -c 3 perf stat -etask-clock,context-switches,cpu-migrations,page-faults,cycles,branches,branch-misses,instructions,uops_issued.any,uops_executed.thread -r 100 ./a.out |
awk '{sum+= $1} END{print sum/100;}'
Performance counter stats for './a.out' (100 runs):
40.92 msec task-clock # 0.996 CPUs utilized ( +- 0.20% )
2 context-switches # 0.052 K/sec ( +- 3.31% )
0 cpu-migrations # 0.000 K/sec
313 page-faults # 0.008 M/sec ( +- 0.05% )
168,103,223 cycles # 4.108 GHz ( +- 0.20% )
82,293,840 branches # 2011.269 M/sec ( +- 0.00% )
1,845,647 branch-misses # 2.24% of all branches ( +- 0.74% )
412,769,788 instructions # 2.46 insn per cycle ( +- 0.00% )
466,515,986 uops_issued.any # 11401.694 M/sec ( +- 0.22% )
487,011,558 uops_executed.thread # 11902.607 M/sec ( +- 0.13% )
0.0410624 +- 0.0000837 seconds time elapsed ( +- 0.20% )
40326.5 (clock_t)
real 0m4.301s
user 0m4.050s
sys 0m0.224s
注意分支肯定会丢失非零值,而快速版本的错误几乎是零。而且,发出的uops比快速版本要高得多:它可能长时间在每个分支未命中的错误路径上进行推测。
内外分支分支可能彼此混叠,也可能不混叠。
指令计数几乎相同,只是内循环之前的外循环中的某些NOP有所不同。但是IPC却有很大的不同:没有问题,快速版本在整个程序中平均每个时钟运行4.82条指令。(由于在test / jz中将2条指令宏融合到1个uop中,因此大多数操作位于最内层的循环中,每个周期运行5条指令。)并且请注意,uops_exected远远高于uops_issueed:这意味着微融合可以很好地解决前端瓶颈问题。
fast version, same read-only strlen(s)=1000 repeated 1280000 times
gcc -DUSE_ASM -DREAD_ONLY -UHIDE_ALIGNMENT -march=native -O3 -g strlen-microbench.c &&
time taskset -c 3 perf stat -etask-clock,context-switches,cpu-migrations,page-faults,cycles,branches,branch-misses,instructions,uops_issued.any,uops_executed.thread -r 100 ./a.out |
awk '{sum+= $1} END{print sum/100;}'
Performance counter stats for './a.out' (100 runs):
21.06 msec task-clock # 0.994 CPUs utilized ( +- 0.10% )
1 context-switches # 0.056 K/sec ( +- 5.30% )
0 cpu-migrations # 0.000 K/sec
313 page-faults # 0.015 M/sec ( +- 0.04% )
86,239,943 cycles # 4.094 GHz ( +- 0.02% )
82,285,261 branches # 3906.682 M/sec ( +- 0.00% )
17,645 branch-misses # 0.02% of all branches ( +- 0.15% )
415,286,425 instructions # 4.82 insn per cycle ( +- 0.00% )
335,057,379 uops_issued.any # 15907.619 M/sec ( +- 0.00% )
409,255,762 uops_executed.thread # 19430.358 M/sec ( +- 0.00% )
0.0211944 +- 0.0000221 seconds time elapsed ( +- 0.10% )
20504 (clock_t)
real 0m2.309s
user 0m2.085s
sys 0m0.203s
我认为这只是分支预测,而不是其他前端问题。测试/分支指令不会越过边界,以防止宏融合。
改变.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 -O3
strlen
。(-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 scasb
比strlen
为33字节的字符串调用glibc糟糕十倍。 在实际用例中strlen
,分支丢失或什至在代码缓存和停顿中丢失的情况会更糟,但直线repz scasb
不会。但是10倍是巨大的,那是一个相当短的字符串。