为什么在启用 GCC 优化的情况下,这段代码使用 strlen 的速度要慢 6.5 倍?
Why is this code using strlen heavily 6.5x slower with GCC optimizations enabled?
出于某种原因,我想对 glibc
的 strlen
函数进行基准测试,发现它在 GCC 中启用优化后显然执行 很多 慢,我不知道为什么。
这是我的代码:
#include <time.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
int main() {
char *s = calloc(1 << 20, 1);
memset(s, 65, 1000000);
clock_t start = clock();
for (int i = 0; i < 128; ++i) {
s[strlen(s)] = 'A';
}
clock_t end = clock();
printf("%lld\n", (long long)(end - start));
return 0;
}
在我的机器上输出:
$ gcc test.c && ./a.out
13336
$ gcc -O1 test.c && ./a.out
199004
$ gcc -O2 test.c && ./a.out
83415
$ gcc -O3 test.c && ./a.out
83415
不知何故,启用优化会导致其执行时间更长。
在 Godbolt's Compiler Explorer 上测试您的代码提供了以下解释:
- 在
-O0
或没有优化,生成的代码调用 C 库函数 strlen
;
- 在
-O1
生成的代码使用 rep scasb
指令进行简单的内联扩展;
- 在
-O2
及以上,生成的代码使用更精细的内联扩展。
反复对您的代码进行基准测试显示出一个 运行 另一个代码的重大差异,但增加迭代次数表明:
-O1
代码比 C 库实现慢得多:32240
vs 3090
-O2
代码比 -O1
快,但仍然比 C 库代码慢很多:8570
vs 3090
.
此行为特定于 gcc
和 GNU libc。使用 clang
和 Apple 的 Libc 在 OS/X 上进行的相同测试没有显示出显着差异,这不足为奇,因为 Godbolt 显示 clang
生成对 C 库的调用 strlen
在所有优化级别。
这可以被认为是 gcc/glibc 中的一个错误,但更广泛的基准测试可能表明调用 strlen
的开销比小字符串的内联代码的性能不足具有更重要的影响.基准测试中的字符串异常大,因此将基准测试重点放在超长字符串上可能不会给出有意义的结果。
我改进了这个基准并测试了各种字符串长度。它出现在 linux 与 gcc (Debian 4.7.2-5) 4.7.2 运行 在 Intel(R) Core(TM) i3-2100 CPU @ 3.10GHz 上的基准测试中-O1
生成的内联代码总是比较慢,对于中等长度的字符串,速度是 10 的一个因子,而 -O2
只比libc strlen
用于非常短的字符串,而对于较长的字符串则速度减半。根据这些数据,strlen
的 GNU C 库版本对于大多数字符串长度来说都非常有效,至少在我的特定硬件上是这样。还要记住缓存对基准测量有重大影响。
这是更新后的代码:
#include <stdlib.h>
#include <string.h>
#include <time.h>
void benchmark(int repeat, int minlen, int maxlen) {
char *s = malloc(maxlen + 1);
memset(s, 'A', minlen);
long long bytes = 0, calls = 0;
clock_t clk = clock();
for (int n = 0; n < repeat; n++) {
for (int i = minlen; i < maxlen; ++i) {
bytes += i + 1;
calls += 1;
s[i] = '[=10=]';
s[strlen(s)] = 'A';
}
}
clk = clock() - clk;
free(s);
double avglen = (minlen + maxlen - 1) / 2.0;
double ns = (double)clk * 1e9 / CLOCKS_PER_SEC;
printf("average length %7.0f -> avg time: %7.3f ns/byte, %7.3f ns/call\n",
avglen, ns / bytes, ns / calls);
}
int main() {
benchmark(10000000, 0, 1);
benchmark(1000000, 0, 10);
benchmark(1000000, 5, 15);
benchmark(100000, 0, 100);
benchmark(100000, 50, 150);
benchmark(10000, 0, 1000);
benchmark(10000, 500, 1500);
benchmark(1000, 0, 10000);
benchmark(1000, 5000, 15000);
benchmark(100, 1000000 - 50, 1000000 + 50);
return 0;
}
这是输出:
chqrlie> gcc -std=c99 -O0 benchstrlen.c && ./a.out
average length 0 -> avg time: 14.000 ns/byte, 14.000 ns/call
average length 4 -> avg time: 2.364 ns/byte, 13.000 ns/call
average length 10 -> avg time: 1.238 ns/byte, 13.000 ns/call
average length 50 -> avg time: 0.317 ns/byte, 16.000 ns/call
average length 100 -> avg time: 0.169 ns/byte, 17.000 ns/call
average length 500 -> avg time: 0.074 ns/byte, 37.000 ns/call
average length 1000 -> avg time: 0.068 ns/byte, 68.000 ns/call
average length 5000 -> avg time: 0.064 ns/byte, 318.000 ns/call
average length 10000 -> avg time: 0.062 ns/byte, 622.000 ns/call
average length 1000000 -> avg time: 0.062 ns/byte, 62000.000 ns/call
chqrlie> gcc -std=c99 -O1 benchstrlen.c && ./a.out
average length 0 -> avg time: 20.000 ns/byte, 20.000 ns/call
average length 4 -> avg time: 3.818 ns/byte, 21.000 ns/call
average length 10 -> avg time: 2.190 ns/byte, 23.000 ns/call
average length 50 -> avg time: 0.990 ns/byte, 50.000 ns/call
average length 100 -> avg time: 0.816 ns/byte, 82.000 ns/call
average length 500 -> avg time: 0.679 ns/byte, 340.000 ns/call
average length 1000 -> avg time: 0.664 ns/byte, 664.000 ns/call
average length 5000 -> avg time: 0.651 ns/byte, 3254.000 ns/call
average length 10000 -> avg time: 0.649 ns/byte, 6491.000 ns/call
average length 1000000 -> avg time: 0.648 ns/byte, 648000.000 ns/call
chqrlie> gcc -std=c99 -O2 benchstrlen.c && ./a.out
average length 0 -> avg time: 10.000 ns/byte, 10.000 ns/call
average length 4 -> avg time: 2.000 ns/byte, 11.000 ns/call
average length 10 -> avg time: 1.048 ns/byte, 11.000 ns/call
average length 50 -> avg time: 0.337 ns/byte, 17.000 ns/call
average length 100 -> avg time: 0.299 ns/byte, 30.000 ns/call
average length 500 -> avg time: 0.202 ns/byte, 101.000 ns/call
average length 1000 -> avg time: 0.188 ns/byte, 188.000 ns/call
average length 5000 -> avg time: 0.174 ns/byte, 868.000 ns/call
average length 10000 -> avg time: 0.172 ns/byte, 1716.000 ns/call
average length 1000000 -> avg time: 0.172 ns/byte, 172000.000 ns/call
GCC 的内联 strlen
模式比 SSE2 pcmpeqb
/ pmovmskb
和 bsf
慢得多,因为 16 -来自 calloc
的字节对齐。这种“优化”其实是一种悲观。
我的简单 hand-written 循环利用了 16 字节对齐,比 gcc -O3
内联大缓冲区快 5 倍,短字符串快 2 倍。 (并且比为短字符串调用 strlen 更快)。我在 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=88809 中添加了一条评论,建议 gcc 应该在 -O2 / -O3 中内联什么。 (如果我们只知道 4 字节对齐开始,建议增加到 16 字节。)
当 gcc 知道它有 4 字节对齐 缓冲区(gua运行由 calloc
提供)时,它选择内联 strlen
作为 4-byte-at-a-time 标量 bithack 使用 GP 整数寄存器(-O2
和更高)。
(一次读取 4 个字节只有在我们知道我们不能进入不包含任何字符串字节的页面时才是安全的,因此可能会被取消映射。(TL:DR是的,在 asm 中是这样,因此编译器可以发出执行此操作的代码,即使在 C 源代码中这样做是 UB。libc strlen
实现也利用了这一点。请参阅我的回答以获取指向 glibc [=14 的链接=] 以及它如何 运行 对于大字符串如此之快的总结。)
在 -O1
,gcc always(即使没有已知对齐方式)选择内联 strlen
为 repnz scasb
,这非常慢(在现代英特尔CPUs 上每个时钟周期大约 1 个字节)。不幸的是,“快速字符串”仅适用于 rep stos
和 rep movs
,不适用于 repz
/repnz
指令。他们的微代码一次只是简单的 1 个字节,但他们仍然有一些启动开销。 (https://agner.org/optimize/)
(例如,我们可以通过将 s
存储/重新加载到 volatile void *tmp
来“隐藏”来自编译器的指针来测试这一点。gcc 必须对指针值做出零假设从 volatile
读回,破坏任何对齐信息。)
GCC 确实有一些 x86 tuning options,比如 -mstringop-strategy=libcall
vs. unrolled_loop
vs. rep_byte
用于一般的内联字符串操作(不仅仅是 strlen;memcmp
将是另一个可以用 rep 或循环完成的主要任务)。我这里没有检查这些有什么作用。
另一个选项的文档也描述了当前的行为。即使在我们希望在未对齐的指针上使用它的情况下,我们也可以获得此内联(使用 alignment-handling 的额外代码)。 (这曾经是一个真正的性能胜利,特别是对于小字符串,在与机器可以做的相比内联循环不是垃圾的目标上。)
-minline-all-stringops
By default GCC inlines string operations only when the destination is known to be aligned to least a 4-byte boundary. This enables more inlining and increases code size, but may improve performance of code that depends on fast memcpy, strlen, and memset for short lengths.
GCC 也有 per-function attributes 你显然可以用来控制它,比如 __attribute__((no-inline-all-stringops)) void foo() { ... }
,但我还没有玩过它。 (这与 inline-all 相反。它 并不 意味着内联 none,它只是在已知 4 字节对齐时返回到仅内联。)
gcc 的两种内联 strlen
策略都无法利用 16 字节对齐,并且对 x86-64 非常不利
除非 small-string 情况 非常 常见,做一个 4 字节的块,然后对齐的 8 字节块的速度大约是 4 字节的两倍.
并且 4 字节策略的清理速度比在包含零字节的双字中查找字节所需的清理速度慢得多。它通过查找设置了高位的字节来检测这一点,因此它应该屏蔽掉其他位并使用 bsf
(bit-scan forward)。在现代 CPUs(Intel 和 Ryzen)上有 3 个周期延迟。或者编译器可以使用 rep bsf
,因此在支持 BMI1 的 CPU 上 运行 与 tzcnt
一样,这在 AMD 上更有效。 bsf
和 tzcnt
对 non-zero 输入给出相同的结果。
GCC 的 4 字节循环看起来像是从纯 C 或一些 target-independent 逻辑编译而来,没有利用位扫描。在使用 BMI1 为 x86 编译时,gcc 确实使用 andn
对其进行了优化,但每个周期仍然少于 4 个字节。
SSE2 pcmpeqb
+ bsf
对于短输入和长输入 多 更好 。 x86-64 gua运行证明 SSE2 可用,x86-64 系统 V 有 alignof(maxalign_t) = 16
所以 calloc
总是 return 至少 16 字节对齐的指针.
我为 strlen
块写了一个替代品来测试性能
正如预期的那样,它在 Skylake 上一次处理 16 个字节而不是 4 个字节快了大约 4 倍。
(我使用 -O3
将原始源代码编译为 asm,然后编辑 asm 以查看使用此 strlen
内联扩展策略的性能应该如何。我还将其移植到内联C 源代码中的 asm;see that version on 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 , %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)
请注意,我将 strlen 清理的一部分优化为存储寻址模式:我用 -16
位移校正了超调,这只是找到字符串的结尾,而不是实际计算长度然后像 GCC 一样在内联其 4-byte-at-a-time 循环后已经在做索引。
获取实际字符串length(而不是指向末尾的指针),您将减去 rdx-start,然后添加 rax-16
(可能使用 LEA 添加 2 个寄存器 + 一个常量,但是 3 分量 LEA 有更多的延迟。)
使用 AVX 允许在一条指令中加载+比较而不破坏置零寄存器,整个循环只有 4 微指令,低于 5。(test/jz macro-fuses 在英特尔和英特尔上都变成了一个微指令和 AMD。vpcmpeqb
和 non-indexed memory-source 可以在整个管道中保持它 micro-fused,所以它只有 1 fused-domain front-end.)
的 uop
(请注意,将 128 位 AVX 与 SSE 混合 不会 即使在 Haswell 上也会导致停顿,只要您一开始就处于 clean-upper 状态。所以我没有费心将其他指令更改为 AVX,只有一个重要的指令。似乎有一些小的影响 pxor
实际上比 更好 vpxor
在我的桌面上,虽然,对于 AVX 循环体。它似乎有点可重复,但它很奇怪,因为没有 code-size 差异,因此没有对齐差异。)
pmovmskb
是一条 single-uop 指令。它在 Intel 和 Ryzen 上有 3 个周期的延迟(在 Bulldozer-family 上更糟)。对于短字符串,通过 SIMD 单元并返回整数的过程是从输入内存字节到 store-address 就绪的延迟的关键路径依赖链的重要部分。但是只有 SIMD 有 packed-integer 比较,所以标量必须做更多的工作。
对于 very-small 字符串的情况(比如 0 到 3 个字节),使用纯标量(尤其是 Bulldozer-family)可能会稍微降低延迟,但是让从 0 到 15 字节的所有字符串都采用相同的 b运行ch 路径(循环 b运行ch 从未采用)对大多数人来说非常好 short-strings use-cases .
当我们知道我们有 16 字节对齐时,对于所有最多 15 字节的字符串来说似乎是一个不错的选择。比较有预见性的b运行ching很好。 (请注意,在循环时,pmovmskb
延迟仅影响我们检测到 b运行ch 错误预测以跳出循环的速度;b运行ch 预测 + 推测执行隐藏了延迟每次迭代中的独立 pmovmskb。
如果我们希望更长的字符串很常见,我们可以展开一点,但此时您应该只调用 libc 函数,以便它可以在 运行 时间可用时分派给 AVX2。展开到超过 1 个向量会使清理变得复杂,从而损害简单的情况。
在我的 i7-6700k Skylake 机器上,最大睿频频率为 4.2GHz(和 energy_performance_preference
= 性能),在 Arch Linux 上使用 gcc8.2,我得到了一些一致的基准计时,因为我的 CPU 在 memset 期间时钟速度上升。但也许并不总是最大涡轮增压; Skylake 的硬件电源管理在 memory-bound 时降频。 perf stat
显示我通常在 4.0GHz 左右正确 运行 对此进行平均 stdout 输出并查看 stderr 上的性能摘要。
perf stat -r 100 ./a.out | awk '{sum+= } END{print sum/100;}'
我最终将我的 asm 复制到 GNU C inline-asm 语句中,so I could put the code on the Godbolt compiler explorer。
对于大字符串,与问题中的长度相同:~4GHz Skylake 上的时间
- ~62100
clock_t
time units: -O1
rep scas: (clock()
有点过时了,不过我没费心去改。)
- ~15900
clock_t
时间单位:-O3
gcc 4 字节循环策略:平均 100 运行s = . (或者 andn
的 -march=native
可能是 ~15800)
- ~1880
clock_t
时间单位:-O3
使用 glibc strlen
函数调用,使用 AVX2
- ~3190
clock_t
时间单位:(AVX1 128 位向量,4 uop 循环)hand-written 内联 asm 即 gcc could/should 内联。
- ~3230
clock_t
时间单位:(SSE2 5 uop 循环) hand-written inline asm that gcc could/should inline.
我的 hand-written asm 应该也非常适合短字符串,因为它不需要特别 b运行ch。已知对齐 非常 对 strlen 有好处,而 libc 不能利用它。
如果我们预计大字符串很少见,那么在这种情况下比 libc 慢 1.7 倍。 1M 字节的长度意味着它不会在我的 CPU 上的 L2 (256k) 或 L1d 缓存 (32k) 中保持热度,因此即使在 L3 缓存上出现瓶颈,libc 版本也更快。 (可能展开的循环和 256 位向量不会以每字节那么多的 uops 阻塞 ROB,因此 OoO exec 可以看得更远并获得更多的内存并行性,尤其是在页面边界处。)
但 L3 缓存带宽可能是阻止 4-uop 版本从 运行ning 以每个时钟 1 次迭代的瓶颈,因此我们看到 AVX 为我们在循环中节省了一个 uop 带来的好处较少。对于 L1d 缓存中的热数据,每次迭代我们应该得到 1.25 个周期,而不是 1 个。
但是一个好的 AVX2 实现可以在每个周期读取多达 64 个字节(2x 32 字节负载)使用 vpminub
在检查零并返回查找它们的位置之前组合对。这个和 libc 之间的差距对于 ~2k 到 ~30 kiB 左右的大小打开得更大,因此在 L1d 中保持热度。
一些read-only长度为1000的测试表明glibcstrlen
对于 L1d 缓存中热的中等大小字符串,确实比我的循环快 4 倍 。这足以让 AVX2 加速到大的展开循环,但仍然很容易适合 L1d 缓存。 (Read-only 避免 store-forwarding 停顿,因此我们可以进行多次迭代)
如果你的字符串那么大,你应该使用 explicit-length 字符串而不需要 strlen
,所以内联一个简单的循环似乎仍然是一个合理的策略,只要它是实际上 对于短字符串来说很好 而对于中等(比如 300 字节)和非常长(> 缓存大小)的字符串来说不是总垃圾。
用这个对小字符串进行基准测试:
我 运行 在尝试获得我预期的结果时遇到了一些奇怪的事情:
我尝试s[31] = 0
在每次迭代前对字符串进行运行分类(允许较短的常量长度)。但后来我的 SSE2 版本几乎与 GCC 版本的速度相同。 Store-forwarding 停顿是瓶颈! 字节存储后接更宽的负载使得 store-forwarding 采用慢速路径将存储缓冲区中的字节与 L1d 缓存中的字节合并.这个额外的延迟是通过字符串的最后 4 字节或 16 字节块的 loop-carried 深度链的一部分,以计算下一次迭代的存储索引。
GCC 较慢的 4-byte-at-a-time 代码可以通过处理该延迟阴影中较早的 4 字节块来跟上。 (Out-of-order 执行非常棒:慢速代码有时不会影响程序的整体速度)。
我最终通过制作一个 read-only 版本解决了这个问题,并使用内联汇编来阻止编译器将 strlen
提升到循环之外。
但是 store-forwarding 是使用 16 字节加载的潜在问题。如果其他 C 变量存储在数组末尾之后,我们可能会遇到 SF 停顿,因为从数组末尾加载比使用较窄的存储更远。对于 recently-copied 数据,如果它是用 16 字节或更宽的对齐存储复制的,我们就没问题,但是用于小副本的 glibc memcpy 会覆盖整个对象的 2 倍重叠加载,从对象的开始和结束。然后它存储两者,再次重叠,免费处理 memmove src overlaps dst 情况。因此,只是 memcpyied 的短字符串的第二个 16 字节或 8 字节块可能会给我们一个 SF 停顿来读取最后一个块。 (具有输出数据依赖性的那个。)
只是 运行 速度变慢,这样你就不会在它准备好之前到达终点,这通常不是很好,所以这里没有很好的解决方案。我认为 大多数 的时候你不会去 strlen 缓冲区你只是 写 , 通常你会strlen
您只是在阅读输入,因此 store-forwarding 停顿不是问题。如果其他东西只是写了它,那么高效代码有望不会丢弃长度并调用需要重新计算它的函数。
其他我还没有完全弄清楚的怪事:
代码对齐使 read-only、大小=1000 (s[1000] = 0;
) 的差异因子为 2。但是 inner-most 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+= } 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
注意 b运行ch 绝对未命中 non-zero,而快速版本几乎完全为零。发出的 uops 比快速版本高得多:它可能在 long 时间的每个 b运行ch 未命中上推测错误的路径。
可能内层和外层loop-branches互相混淆了,或者没有。
指令数几乎相同,只是在内循环之前的外循环中的一些 NOP 不同。但 IPC 有很大不同:没有问题,快速版本 运行 整个程序平均每个时钟有 4.82 条指令。 (其中大部分在 inner-most 循环中 运行 每个周期 5 条指令,多亏了 test/jz 将 macro-fuses 2 条指令放入 1 uop。)并注意 uops_executed 比 uops_issued 高得多:这意味着 micro-fusion 可以很好地通过 front-end 瓶颈获得更多微指令。
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+= } 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
我认为这只是 b运行ch 预测,而不是其他 front-end 问题。 test/branch 指令不会跨越会阻止 macro-fusion.
的边界拆分
将 .p2align 5
更改为 .p2align 4
会反转它们:-UHIDE_ALIGNMENT
变慢。
This Godbolt binary link 重现我在 Arch Linux 上使用 gcc8.2.1 看到的相同填充对于这两种情况:2x 11 字节 nopw
+ 3 字节 nop
在快速情况下的外循环内。它还具有我在本地使用的确切来源。
短strlen read-only micro-benchmarks:
使用选定的东西进行测试,因此它不会受到 b运行ch 错误预测或 store-forwarding 的影响,并且可以重复测试相同的短长度以获得足够的迭代以获得有意义的数据。
strlen=33
,因此终止符靠近第 3 个 16 字节向量的开头。 (让我的版本看起来尽可能糟糕他是 4 字节版本。)-DREAD_ONLY
和 i<1280000
作为 outer-loop 重复循环。
- 1933 clock_t:我的 asm:良好且一致的 best-case 时间(不嘈杂/在 re-running 平均值时反弹。)与较长的 strlen 不同,等于 perf with/without
-DHIDE_ALIGNMENT
。使用更短的模式,循环 b运行ch 更容易预测。 (strlen=33,不是 1000)。
- 3220 clock_t:gcc -O3 调用 glibc
strlen
。 (-DHIDE_ALIGNMENT
)
- 6100 clock_t: gcc -O3 4 字节循环
- 37200 clock_t: gcc -O1 repz scasb
所以对于短字符串,我的简单内联循环 比 库函数调用 strlen
必须通过 PLT(调用 + jmp [mem]
), 然后 运行 strlen 不能依赖于对齐的启动开销。
branch-mispredicts 可以忽略不计,例如所有带 strlen(s)=33
的版本的 0.05%。 repz scasb 版本有 0.46%,但这是在较少的总 b运行ches 中。没有内循环来累积许多正确预测的 b运行ches.
使用 b运行ch 预测器和 code-cache 热,repz scasb
比调用 glibc strlen
33 字节差 10 倍以上string. 在 strlen
可能会 b运行ch 错过甚至错过 code-cache 并停滞的实际用例中,它会更糟糕,但是 straight-line repz scasb
不会。但是 10x 已经很大了,而且这是一个相当短的字符串。
出于某种原因,我想对 glibc
的 strlen
函数进行基准测试,发现它在 GCC 中启用优化后显然执行 很多 慢,我不知道为什么。
这是我的代码:
#include <time.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
int main() {
char *s = calloc(1 << 20, 1);
memset(s, 65, 1000000);
clock_t start = clock();
for (int i = 0; i < 128; ++i) {
s[strlen(s)] = 'A';
}
clock_t end = clock();
printf("%lld\n", (long long)(end - start));
return 0;
}
在我的机器上输出:
$ gcc test.c && ./a.out
13336
$ gcc -O1 test.c && ./a.out
199004
$ gcc -O2 test.c && ./a.out
83415
$ gcc -O3 test.c && ./a.out
83415
不知何故,启用优化会导致其执行时间更长。
在 Godbolt's Compiler Explorer 上测试您的代码提供了以下解释:
- 在
-O0
或没有优化,生成的代码调用 C 库函数strlen
; - 在
-O1
生成的代码使用rep scasb
指令进行简单的内联扩展; - 在
-O2
及以上,生成的代码使用更精细的内联扩展。
反复对您的代码进行基准测试显示出一个 运行 另一个代码的重大差异,但增加迭代次数表明:
-O1
代码比 C 库实现慢得多:32240
vs3090
-O2
代码比-O1
快,但仍然比 C 库代码慢很多:8570
vs3090
.
此行为特定于 gcc
和 GNU libc。使用 clang
和 Apple 的 Libc 在 OS/X 上进行的相同测试没有显示出显着差异,这不足为奇,因为 Godbolt 显示 clang
生成对 C 库的调用 strlen
在所有优化级别。
这可以被认为是 gcc/glibc 中的一个错误,但更广泛的基准测试可能表明调用 strlen
的开销比小字符串的内联代码的性能不足具有更重要的影响.基准测试中的字符串异常大,因此将基准测试重点放在超长字符串上可能不会给出有意义的结果。
我改进了这个基准并测试了各种字符串长度。它出现在 linux 与 gcc (Debian 4.7.2-5) 4.7.2 运行 在 Intel(R) Core(TM) i3-2100 CPU @ 3.10GHz 上的基准测试中-O1
生成的内联代码总是比较慢,对于中等长度的字符串,速度是 10 的一个因子,而 -O2
只比libc strlen
用于非常短的字符串,而对于较长的字符串则速度减半。根据这些数据,strlen
的 GNU C 库版本对于大多数字符串长度来说都非常有效,至少在我的特定硬件上是这样。还要记住缓存对基准测量有重大影响。
这是更新后的代码:
#include <stdlib.h>
#include <string.h>
#include <time.h>
void benchmark(int repeat, int minlen, int maxlen) {
char *s = malloc(maxlen + 1);
memset(s, 'A', minlen);
long long bytes = 0, calls = 0;
clock_t clk = clock();
for (int n = 0; n < repeat; n++) {
for (int i = minlen; i < maxlen; ++i) {
bytes += i + 1;
calls += 1;
s[i] = '[=10=]';
s[strlen(s)] = 'A';
}
}
clk = clock() - clk;
free(s);
double avglen = (minlen + maxlen - 1) / 2.0;
double ns = (double)clk * 1e9 / CLOCKS_PER_SEC;
printf("average length %7.0f -> avg time: %7.3f ns/byte, %7.3f ns/call\n",
avglen, ns / bytes, ns / calls);
}
int main() {
benchmark(10000000, 0, 1);
benchmark(1000000, 0, 10);
benchmark(1000000, 5, 15);
benchmark(100000, 0, 100);
benchmark(100000, 50, 150);
benchmark(10000, 0, 1000);
benchmark(10000, 500, 1500);
benchmark(1000, 0, 10000);
benchmark(1000, 5000, 15000);
benchmark(100, 1000000 - 50, 1000000 + 50);
return 0;
}
这是输出:
chqrlie> gcc -std=c99 -O0 benchstrlen.c && ./a.out average length 0 -> avg time: 14.000 ns/byte, 14.000 ns/call average length 4 -> avg time: 2.364 ns/byte, 13.000 ns/call average length 10 -> avg time: 1.238 ns/byte, 13.000 ns/call average length 50 -> avg time: 0.317 ns/byte, 16.000 ns/call average length 100 -> avg time: 0.169 ns/byte, 17.000 ns/call average length 500 -> avg time: 0.074 ns/byte, 37.000 ns/call average length 1000 -> avg time: 0.068 ns/byte, 68.000 ns/call average length 5000 -> avg time: 0.064 ns/byte, 318.000 ns/call average length 10000 -> avg time: 0.062 ns/byte, 622.000 ns/call average length 1000000 -> avg time: 0.062 ns/byte, 62000.000 ns/call chqrlie> gcc -std=c99 -O1 benchstrlen.c && ./a.out average length 0 -> avg time: 20.000 ns/byte, 20.000 ns/call average length 4 -> avg time: 3.818 ns/byte, 21.000 ns/call average length 10 -> avg time: 2.190 ns/byte, 23.000 ns/call average length 50 -> avg time: 0.990 ns/byte, 50.000 ns/call average length 100 -> avg time: 0.816 ns/byte, 82.000 ns/call average length 500 -> avg time: 0.679 ns/byte, 340.000 ns/call average length 1000 -> avg time: 0.664 ns/byte, 664.000 ns/call average length 5000 -> avg time: 0.651 ns/byte, 3254.000 ns/call average length 10000 -> avg time: 0.649 ns/byte, 6491.000 ns/call average length 1000000 -> avg time: 0.648 ns/byte, 648000.000 ns/call chqrlie> gcc -std=c99 -O2 benchstrlen.c && ./a.out average length 0 -> avg time: 10.000 ns/byte, 10.000 ns/call average length 4 -> avg time: 2.000 ns/byte, 11.000 ns/call average length 10 -> avg time: 1.048 ns/byte, 11.000 ns/call average length 50 -> avg time: 0.337 ns/byte, 17.000 ns/call average length 100 -> avg time: 0.299 ns/byte, 30.000 ns/call average length 500 -> avg time: 0.202 ns/byte, 101.000 ns/call average length 1000 -> avg time: 0.188 ns/byte, 188.000 ns/call average length 5000 -> avg time: 0.174 ns/byte, 868.000 ns/call average length 10000 -> avg time: 0.172 ns/byte, 1716.000 ns/call average length 1000000 -> avg time: 0.172 ns/byte, 172000.000 ns/call
GCC 的内联 strlen
模式比 SSE2 pcmpeqb
/ pmovmskb
和 bsf
慢得多,因为 16 -来自 calloc
的字节对齐。这种“优化”其实是一种悲观。
我的简单 hand-written 循环利用了 16 字节对齐,比 gcc -O3
内联大缓冲区快 5 倍,短字符串快 2 倍。 (并且比为短字符串调用 strlen 更快)。我在 https://gcc.gnu.org/bugzilla/show_bug.cgi?id=88809 中添加了一条评论,建议 gcc 应该在 -O2 / -O3 中内联什么。 (如果我们只知道 4 字节对齐开始,建议增加到 16 字节。)
当 gcc 知道它有 4 字节对齐 缓冲区(gua运行由 calloc
提供)时,它选择内联 strlen
作为 4-byte-at-a-time 标量 bithack 使用 GP 整数寄存器(-O2
和更高)。
(一次读取 4 个字节只有在我们知道我们不能进入不包含任何字符串字节的页面时才是安全的,因此可能会被取消映射。strlen
实现也利用了这一点。请参阅我的回答以获取指向 glibc [=14 的链接=] 以及它如何 运行 对于大字符串如此之快的总结。)
在 -O1
,gcc always(即使没有已知对齐方式)选择内联 strlen
为 repnz scasb
,这非常慢(在现代英特尔CPUs 上每个时钟周期大约 1 个字节)。不幸的是,“快速字符串”仅适用于 rep stos
和 rep movs
,不适用于 repz
/repnz
指令。他们的微代码一次只是简单的 1 个字节,但他们仍然有一些启动开销。 (https://agner.org/optimize/)
(例如,我们可以通过将 s
存储/重新加载到 volatile void *tmp
来“隐藏”来自编译器的指针来测试这一点。gcc 必须对指针值做出零假设从 volatile
读回,破坏任何对齐信息。)
GCC 确实有一些 x86 tuning options,比如 -mstringop-strategy=libcall
vs. unrolled_loop
vs. rep_byte
用于一般的内联字符串操作(不仅仅是 strlen;memcmp
将是另一个可以用 rep 或循环完成的主要任务)。我这里没有检查这些有什么作用。
另一个选项的文档也描述了当前的行为。即使在我们希望在未对齐的指针上使用它的情况下,我们也可以获得此内联(使用 alignment-handling 的额外代码)。 (这曾经是一个真正的性能胜利,特别是对于小字符串,在与机器可以做的相比内联循环不是垃圾的目标上。)
-minline-all-stringops
By default GCC inlines string operations only when the destination is known to be aligned to least a 4-byte boundary. This enables more inlining and increases code size, but may improve performance of code that depends on fast memcpy, strlen, and memset for short lengths.
GCC 也有 per-function attributes 你显然可以用来控制它,比如 __attribute__((no-inline-all-stringops)) void foo() { ... }
,但我还没有玩过它。 (这与 inline-all 相反。它 并不 意味着内联 none,它只是在已知 4 字节对齐时返回到仅内联。)
gcc 的两种内联 strlen
策略都无法利用 16 字节对齐,并且对 x86-64 非常不利
除非 small-string 情况 非常 常见,做一个 4 字节的块,然后对齐的 8 字节块的速度大约是 4 字节的两倍.
并且 4 字节策略的清理速度比在包含零字节的双字中查找字节所需的清理速度慢得多。它通过查找设置了高位的字节来检测这一点,因此它应该屏蔽掉其他位并使用 bsf
(bit-scan forward)。在现代 CPUs(Intel 和 Ryzen)上有 3 个周期延迟。或者编译器可以使用 rep bsf
,因此在支持 BMI1 的 CPU 上 运行 与 tzcnt
一样,这在 AMD 上更有效。 bsf
和 tzcnt
对 non-zero 输入给出相同的结果。
GCC 的 4 字节循环看起来像是从纯 C 或一些 target-independent 逻辑编译而来,没有利用位扫描。在使用 BMI1 为 x86 编译时,gcc 确实使用 andn
对其进行了优化,但每个周期仍然少于 4 个字节。
SSE2 pcmpeqb
+ bsf
对于短输入和长输入 多 更好 。 x86-64 gua运行证明 SSE2 可用,x86-64 系统 V 有 alignof(maxalign_t) = 16
所以 calloc
总是 return 至少 16 字节对齐的指针.
我为 strlen
块写了一个替代品来测试性能
正如预期的那样,它在 Skylake 上一次处理 16 个字节而不是 4 个字节快了大约 4 倍。
(我使用 -O3
将原始源代码编译为 asm,然后编辑 asm 以查看使用此 strlen
内联扩展策略的性能应该如何。我还将其移植到内联C 源代码中的 asm;see that version on 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 , %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)
请注意,我将 strlen 清理的一部分优化为存储寻址模式:我用 -16
位移校正了超调,这只是找到字符串的结尾,而不是实际计算长度然后像 GCC 一样在内联其 4-byte-at-a-time 循环后已经在做索引。
获取实际字符串length(而不是指向末尾的指针),您将减去 rdx-start,然后添加 rax-16
(可能使用 LEA 添加 2 个寄存器 + 一个常量,但是 3 分量 LEA 有更多的延迟。)
使用 AVX 允许在一条指令中加载+比较而不破坏置零寄存器,整个循环只有 4 微指令,低于 5。(test/jz macro-fuses 在英特尔和英特尔上都变成了一个微指令和 AMD。vpcmpeqb
和 non-indexed memory-source 可以在整个管道中保持它 micro-fused,所以它只有 1 fused-domain front-end.)
(请注意,将 128 位 AVX 与 SSE 混合 不会 即使在 Haswell 上也会导致停顿,只要您一开始就处于 clean-upper 状态。所以我没有费心将其他指令更改为 AVX,只有一个重要的指令。似乎有一些小的影响 pxor
实际上比 更好 vpxor
在我的桌面上,虽然,对于 AVX 循环体。它似乎有点可重复,但它很奇怪,因为没有 code-size 差异,因此没有对齐差异。)
pmovmskb
是一条 single-uop 指令。它在 Intel 和 Ryzen 上有 3 个周期的延迟(在 Bulldozer-family 上更糟)。对于短字符串,通过 SIMD 单元并返回整数的过程是从输入内存字节到 store-address 就绪的延迟的关键路径依赖链的重要部分。但是只有 SIMD 有 packed-integer 比较,所以标量必须做更多的工作。
对于 very-small 字符串的情况(比如 0 到 3 个字节),使用纯标量(尤其是 Bulldozer-family)可能会稍微降低延迟,但是让从 0 到 15 字节的所有字符串都采用相同的 b运行ch 路径(循环 b运行ch 从未采用)对大多数人来说非常好 short-strings use-cases .
当我们知道我们有 16 字节对齐时,对于所有最多 15 字节的字符串来说似乎是一个不错的选择。比较有预见性的b运行ching很好。 (请注意,在循环时,pmovmskb
延迟仅影响我们检测到 b运行ch 错误预测以跳出循环的速度;b运行ch 预测 + 推测执行隐藏了延迟每次迭代中的独立 pmovmskb。
如果我们希望更长的字符串很常见,我们可以展开一点,但此时您应该只调用 libc 函数,以便它可以在 运行 时间可用时分派给 AVX2。展开到超过 1 个向量会使清理变得复杂,从而损害简单的情况。
在我的 i7-6700k Skylake 机器上,最大睿频频率为 4.2GHz(和 energy_performance_preference
= 性能),在 Arch Linux 上使用 gcc8.2,我得到了一些一致的基准计时,因为我的 CPU 在 memset 期间时钟速度上升。但也许并不总是最大涡轮增压; Skylake 的硬件电源管理在 memory-bound 时降频。 perf stat
显示我通常在 4.0GHz 左右正确 运行 对此进行平均 stdout 输出并查看 stderr 上的性能摘要。
perf stat -r 100 ./a.out | awk '{sum+= } END{print sum/100;}'
我最终将我的 asm 复制到 GNU C inline-asm 语句中,so I could put the code on the Godbolt compiler explorer。
对于大字符串,与问题中的长度相同:~4GHz Skylake 上的时间
- ~62100
clock_t
time units:-O1
rep scas: (clock()
有点过时了,不过我没费心去改。) - ~15900
clock_t
时间单位:-O3
gcc 4 字节循环策略:平均 100 运行s = . (或者andn
的-march=native
可能是 ~15800) - ~1880
clock_t
时间单位:-O3
使用 glibcstrlen
函数调用,使用 AVX2 - ~3190
clock_t
时间单位:(AVX1 128 位向量,4 uop 循环)hand-written 内联 asm 即 gcc could/should 内联。 - ~3230
clock_t
时间单位:(SSE2 5 uop 循环) hand-written inline asm that gcc could/should inline.
我的 hand-written asm 应该也非常适合短字符串,因为它不需要特别 b运行ch。已知对齐 非常 对 strlen 有好处,而 libc 不能利用它。
如果我们预计大字符串很少见,那么在这种情况下比 libc 慢 1.7 倍。 1M 字节的长度意味着它不会在我的 CPU 上的 L2 (256k) 或 L1d 缓存 (32k) 中保持热度,因此即使在 L3 缓存上出现瓶颈,libc 版本也更快。 (可能展开的循环和 256 位向量不会以每字节那么多的 uops 阻塞 ROB,因此 OoO exec 可以看得更远并获得更多的内存并行性,尤其是在页面边界处。)
但 L3 缓存带宽可能是阻止 4-uop 版本从 运行ning 以每个时钟 1 次迭代的瓶颈,因此我们看到 AVX 为我们在循环中节省了一个 uop 带来的好处较少。对于 L1d 缓存中的热数据,每次迭代我们应该得到 1.25 个周期,而不是 1 个。
但是一个好的 AVX2 实现可以在每个周期读取多达 64 个字节(2x 32 字节负载)使用 vpminub
在检查零并返回查找它们的位置之前组合对。这个和 libc 之间的差距对于 ~2k 到 ~30 kiB 左右的大小打开得更大,因此在 L1d 中保持热度。
一些read-only长度为1000的测试表明glibcstrlen
对于 L1d 缓存中热的中等大小字符串,确实比我的循环快 4 倍 。这足以让 AVX2 加速到大的展开循环,但仍然很容易适合 L1d 缓存。 (Read-only 避免 store-forwarding 停顿,因此我们可以进行多次迭代)
如果你的字符串那么大,你应该使用 explicit-length 字符串而不需要 strlen
,所以内联一个简单的循环似乎仍然是一个合理的策略,只要它是实际上 对于短字符串来说很好 而对于中等(比如 300 字节)和非常长(> 缓存大小)的字符串来说不是总垃圾。
用这个对小字符串进行基准测试:
我 运行 在尝试获得我预期的结果时遇到了一些奇怪的事情:
我尝试s[31] = 0
在每次迭代前对字符串进行运行分类(允许较短的常量长度)。但后来我的 SSE2 版本几乎与 GCC 版本的速度相同。 Store-forwarding 停顿是瓶颈! 字节存储后接更宽的负载使得 store-forwarding 采用慢速路径将存储缓冲区中的字节与 L1d 缓存中的字节合并.这个额外的延迟是通过字符串的最后 4 字节或 16 字节块的 loop-carried 深度链的一部分,以计算下一次迭代的存储索引。
GCC 较慢的 4-byte-at-a-time 代码可以通过处理该延迟阴影中较早的 4 字节块来跟上。 (Out-of-order 执行非常棒:慢速代码有时不会影响程序的整体速度)。
我最终通过制作一个 read-only 版本解决了这个问题,并使用内联汇编来阻止编译器将 strlen
提升到循环之外。
但是 store-forwarding 是使用 16 字节加载的潜在问题。如果其他 C 变量存储在数组末尾之后,我们可能会遇到 SF 停顿,因为从数组末尾加载比使用较窄的存储更远。对于 recently-copied 数据,如果它是用 16 字节或更宽的对齐存储复制的,我们就没问题,但是用于小副本的 glibc memcpy 会覆盖整个对象的 2 倍重叠加载,从对象的开始和结束。然后它存储两者,再次重叠,免费处理 memmove src overlaps dst 情况。因此,只是 memcpyied 的短字符串的第二个 16 字节或 8 字节块可能会给我们一个 SF 停顿来读取最后一个块。 (具有输出数据依赖性的那个。)
只是 运行 速度变慢,这样你就不会在它准备好之前到达终点,这通常不是很好,所以这里没有很好的解决方案。我认为 大多数 的时候你不会去 strlen 缓冲区你只是 写 , 通常你会strlen
您只是在阅读输入,因此 store-forwarding 停顿不是问题。如果其他东西只是写了它,那么高效代码有望不会丢弃长度并调用需要重新计算它的函数。
其他我还没有完全弄清楚的怪事:
代码对齐使 read-only、大小=1000 (s[1000] = 0;
) 的差异因子为 2。但是 inner-most 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+= } 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
注意 b运行ch 绝对未命中 non-zero,而快速版本几乎完全为零。发出的 uops 比快速版本高得多:它可能在 long 时间的每个 b运行ch 未命中上推测错误的路径。
可能内层和外层loop-branches互相混淆了,或者没有。
指令数几乎相同,只是在内循环之前的外循环中的一些 NOP 不同。但 IPC 有很大不同:没有问题,快速版本 运行 整个程序平均每个时钟有 4.82 条指令。 (其中大部分在 inner-most 循环中 运行 每个周期 5 条指令,多亏了 test/jz 将 macro-fuses 2 条指令放入 1 uop。)并注意 uops_executed 比 uops_issued 高得多:这意味着 micro-fusion 可以很好地通过 front-end 瓶颈获得更多微指令。
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+= } 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
我认为这只是 b运行ch 预测,而不是其他 front-end 问题。 test/branch 指令不会跨越会阻止 macro-fusion.
的边界拆分将 .p2align 5
更改为 .p2align 4
会反转它们:-UHIDE_ALIGNMENT
变慢。
This Godbolt binary link 重现我在 Arch Linux 上使用 gcc8.2.1 看到的相同填充对于这两种情况:2x 11 字节 nopw
+ 3 字节 nop
在快速情况下的外循环内。它还具有我在本地使用的确切来源。
短strlen read-only micro-benchmarks:
使用选定的东西进行测试,因此它不会受到 b运行ch 错误预测或 store-forwarding 的影响,并且可以重复测试相同的短长度以获得足够的迭代以获得有意义的数据。
strlen=33
,因此终止符靠近第 3 个 16 字节向量的开头。 (让我的版本看起来尽可能糟糕他是 4 字节版本。)-DREAD_ONLY
和 i<1280000
作为 outer-loop 重复循环。
- 1933 clock_t:我的 asm:良好且一致的 best-case 时间(不嘈杂/在 re-running 平均值时反弹。)与较长的 strlen 不同,等于 perf with/without
-DHIDE_ALIGNMENT
。使用更短的模式,循环 b运行ch 更容易预测。 (strlen=33,不是 1000)。 - 3220 clock_t:gcc -O3 调用 glibc
strlen
。 (-DHIDE_ALIGNMENT
) - 6100 clock_t: gcc -O3 4 字节循环
- 37200 clock_t: gcc -O1 repz scasb
所以对于短字符串,我的简单内联循环 比 库函数调用 strlen
必须通过 PLT(调用 + jmp [mem]
), 然后 运行 strlen 不能依赖于对齐的启动开销。
branch-mispredicts 可以忽略不计,例如所有带 strlen(s)=33
的版本的 0.05%。 repz scasb 版本有 0.46%,但这是在较少的总 b运行ches 中。没有内循环来累积许多正确预测的 b运行ches.
使用 b运行ch 预测器和 code-cache 热,repz scasb
比调用 glibc strlen
33 字节差 10 倍以上string. 在 strlen
可能会 b运行ch 错过甚至错过 code-cache 并停滞的实际用例中,它会更糟糕,但是 straight-line repz scasb
不会。但是 10x 已经很大了,而且这是一个相当短的字符串。