为什么启用GCC优化后,此代码大量使用strlen会慢6.5倍?

73

我因为某些原因想要测试 glibcstrlen 函数,结果发现启用 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

不知何故,启用优化会导致执行时间变长。


4
使用-fno-builtin选项可以解决问题。因此,可以推测问题出在这个特定实例中,GCC内置的strlen函数比库函数更慢。 - David Schwartz
2
在 -O1 优化级别下,它会为 strlen 生成 repnz scasb - Marc Glisse
9
已经提交了:https://gcc.gnu.org/bugzilla/show_bug.cgi?id=88809。 - Justin
5
性能考虑也被视为gcc(以及大多数编译器)的错误报告。如果他们决定不改变它,那很好。如果他们决定值得改变,那也很好。如果您不提交性能错误报告,编译器团队将意识不到需要关注的问题。 - Justin
2
@Justin:是的,但我的观点是这并不是一个性能缺陷。小字符串(10-100个字符)比大字符串(兆字节)更常见,并且在循环中更频繁地进行strlen操作。对于小字符串,调用标准库会产生巨大的影响。因此,编译器正在做正确的事情。它不可能知道字符串有一百万个字符长(如果知道,它也会将该值作为常量放入其中)。"to go"(继续前进)的假设是该字符串不会太长。 - Damon
显示剩余5条评论
2个回答

67

Godbolt的编译器资源管理器 上测试你的代码,可以得到如下结论:

  • -O0 或没有优化时,生成的代码调用 C 库函数 strlen
  • -O1 时,生成的代码使用一个简单的内联展开,使用了一个 rep scasb 指令;
  • -O2 及以上,生成的代码使用一个更为复杂的内联展开。

通过反复基准测试你的代码,会发现运行时间存在较大变化,但增加迭代次数后可以看出:

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

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

这可能被认为是 gcc/glibc 中的一个 bug,但更详细的基准测试可能会显示调用 strlen 的开销比小字符串的内联代码性能差异更为重要。你基准测试中使用的字符串较大,所以将基准测试集中在极长字符串上可能不会得到有意义的结果。

我改进了这个基准测试,并测试了各种字符串长度。从在运行 Debian 4.7.2-5 上的 Linux 并配备 Intel(R) Core(TM) i3-2100 CPU @ 3.10GHz 上的 gcc(版本为 4.7.2)上的基准测试中可以看出,由 -O1 生成的内联代码总是慢得多,对于中等长度的字符串,慢了最多达到 10 倍;而对于很短的字符串,-O2 只比 libc 的 strlen 稍微快一些,对于较长的字符串只有 libc 的一半速度。从这些数据中可以看出,GNU C 库版本的 strlen 对于大多数字符串长度来说相当高效,至少在我的具体硬件上是这样。还要记住缓存对基准测试的测量有重大影响。

这是更新后的代码:

#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] = '\0';
            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
平均长度       0 -> 平均时间:14.000 纳秒/字节,14.000 纳秒/调用
平均长度       4 -> 平均时间:2.364 纳秒/字节,13.000 纳秒/调用
平均长度      10 -> 平均时间:1.238 纳秒/字节,13.000 纳秒/调用
平均长度      50 -> 平均时间:0.317 纳秒/字节,16.000 纳秒/调用
平均长度     100 -> 平均时间:0.169 纳秒/字节,17.000 纳秒/调用
平均长度     500 -> 平均时间:0.074 纳秒/字节,37.000 纳秒/调用
平均长度    1000 -> 平均时间:0.068 纳秒/字节,68.000 纳秒/调用
平均长度    5000 -> 平均时间:0.064 纳秒/字节,318.000 纳秒/调用
平均长度   10000 -> 平均时间:0.062 纳秒/字节,622.000 纳秒/调用
平均长度 1000000 -> 平均时间:0.062 纳秒/字节,62000.000 纳秒/调用
chqrlie> gcc -std=c99 -O1 benchstrlen.c && ./a.out
平均长度       0 -> 平均时间:20.000 纳秒/字节,20.000 纳秒/调用
平均长度       4 -> 平均时间:3.818 纳秒/字节,21.000 纳秒/调用
平均长度      10 -> 平均时间:2.190 纳秒/字节,23.000 纳秒/调用
平均长度      50 -> 平均时间:0.990 纳秒/字节,50.000 纳秒/调用
平均长度     100 -> 平均时间:0.816 纳秒/字节,82.000 纳秒/调用
平均长度     500 -> 平均时间:0.679 纳秒/字节,340.000 纳秒/调用
平均长度    1000 -> 平均时间:0.664 纳秒/字节,664.000 纳秒/调用
平均长度    5000 -> 平均时间:0.651 纳秒/字节,3254.000 纳秒/调用
平均长度   10000 -> 平均时间:0.649 纳秒/字节,6491.000 纳秒/调用
平均长度 1000000 -> 平均时间:0.648 纳秒/字节,648000.000 纳秒/调用
chqrlie> gcc -std=c99 -O2 benchstrlen.c && ./a.out
平均长度       0 -> 平均时间:10.000 纳秒/字节,10.000 纳秒/调用
平均长度       4 -> 平均时间:2.000 纳秒/字节,11.000 纳秒/调用
平均长度      10 -> 平均时间:1.048 纳秒/字节,11.000 纳秒/调用
平均长度      50 -> 平均时间:0.337 纳秒/字节,17.000 纳秒/调用
平均长度     100 -> 平均时间:0.299 纳秒/字节,30.000 纳秒/调用
平均长度     500 -> 平均时间:

2
可以这样做,但是C库中手动优化的版本可能更大、更复杂,难以内联。我最近没有研究过这个问题,但在<string.h>中有一些复杂的平台特定宏和gcc代码生成器中硬编码的优化混合存在。在Intel目标上肯定还有改进的空间。 - chqrlie
1
@Brendan:平均字符串长度因应用程序而异,平均值不如各种长度的统计分布重要。如果 strlen 是某个应用程序的瓶颈,那么它的代码很可能会留在指令缓存中...总的来说,我认为 -O1 生成的代码很糟糕,这是由于 REP SCASB 在我的硬件上性能不佳所致。这高度依赖于 CPU 版本。优化是关于做出有效妥协,而不是达到完美。 - chqrlie
1
@chqrlie:我想要强调的问题是,人们在“实际上极不现实”的场景下进行基准测试,然后根据不切实际的结果做出错误的假设,然后根据这些错误的假设优化代码(例如在库中)。如果 strlen() 是一个瓶颈(例如因为字符串实际上很大),那么任何关心性能的人都会自己跟踪字符串长度,而不会使用 strlen();对于那些关心性能的人来说,strlen() 主要只用于字符串太小而无需跟踪其长度的情况。 - Brendan
4
@chqrlie说,我也认为这部分是更大问题的症状-库中的代码无法针对特定情况进行优化,因此在某些情况下必须是"非最优的"。解决这个问题的方法是有一个名为strlen_small()和另一个名为strlen_large()的函数,但是实际上并没有这样的函数。 - Brendan
1
这是一个重大的优化遗漏,因为SSE2是x86-64的基线,而一个简单的紧凑内联循环可能至少可以达到库版本已知对齐情况下大输入速度的一半,并且对于小输入来说可能更好。(AVX2 / AVX512版本需要32字节/ 64字节对齐才能避免可能读取未映射页面,但我们没有这个功能。) - Peter Cordes
显示剩余16条评论

40

GCC内联的strlen模式比使用SSE2 pcmpeqb / pmovmskbbsfcalloc获得的16字节对齐要慢得多。这种“优化”实际上是一种恶化。

我的简单手写循环利用16字节对齐,对于大缓冲区比gcc -O3内联快5倍,对于短字符串快约2倍(比调用strlen函数更快)。我已经在https://gcc.gnu.org/bugzilla/show_bug.cgi?id=88809添加了注释,建议gcc在能够时应该内联这种方式,以-O2 / -O3运行。(如果我们只知道4字节对齐的起始位置,则建议逐步增加到16字节对齐。)


当gcc知道缓冲区具有4字节对齐(由calloc保证)时,它选择使用GP整数寄存器作为4字节一次的标量bithack内联strlen(-O2及更高版本)。 (每次读取4个字节仅在我们知道我们不会跨越不包含任何字符串字节的页面并因此可能未映射的页面时才是安全的。 在x86和x64上在同一页内超出缓冲区的末尾进行阅读是否安全?(TL:DR是的,在汇编中是这样的,因此编译器可以发出即使在C源中这样做也是UB的代码。 libc strlen实现也利用了这一点。请参阅我的答案以获取有关glibc strlen的链接和如何快速运行大型字符串的摘要。)
-O1优化级别下,即使没有已知的对齐方式,gcc也始终选择将strlen内联为repnz scasb,这非常慢(现代Intel CPU上每个时钟周期约为1字节)。不幸的是,“快速字符串”仅适用于rep stosrep movs,而不是repz/repnz指令。它们的微码只是简单的一次一个字节,但仍然有一些启动开销。(https://agner.org/optimize/
(我们可以通过将s存储/重新加载到volatile void *tmp中来“隐藏”指针,例如进行测试。由于gcc必须对从volatile读回的指针值做出零假设,因此会破坏任何对齐信息。)
GCC具有一些x86调优选项,例如-mstringop-strategy = libcallunrolled_looprep_byte,用于总体内联字符串操作(不仅仅是strlen; memcmp也是另一个可以使用rep或循环完成的重要操作)。我没有检查它们对此的影响。
另一个选项的文档还描述了当前行为。即使在我们想要在非对齐指针上进行操作的情况下,我们也可以获得此内联(带有额外的代码以处理对齐)。 (��内联循环与机器能够执行的内容相比无用的目标中,这曾经是实际的性能提升,特别是对于小字符串,在其中行行内循环不是垃圾时。)
引用块: -minline-all-stringops
默认情况下,GCC仅在已知目标至少对齐到4字节边界时才内联字符串操作。 这会增加更多的内联,并增加代码大小,但可能会改善依赖于快速memcpy,strlen和memset的短长度的代码的性能。
GCC还有每个函数属性,您显然可以使用它来控制此操作,例如__attribute__((no-inline-all-stringops)) void foo() { ... },但我还没有尝试过。 (这相当于inline-all的相反。 它不意味着内联无,而是回到仅在知道4字节对齐时才进行内联的情况。)

gcc的内联strlen策略都不能利用16字节对齐,并且在x86-64上表现很差

除非小字符串情况非常普遍,否则先处理一个4字节块,然后是对齐的8字节块的速度大约是4字节的两倍。

4字节策略的清理比查找包含零字节的双字中的字节所需的时间慢得多。它通过查找高位设置的字节来检测这一点,因此应只屏蔽其他位并使用bsf(位扫描前向)。在现代CPU(Intel和Ryzen)上,它的延迟为3个时钟周期。或者编译器可以使用rep bsf,以便在支持BMI1的CPU上运行为tzcnt,这在AMD上更加高效。bsftzcnt对于非零输入给出相同的结果。

gcc的4字节循环看起来像是从纯C编译而成的,或者是一些与目标无关的逻辑,没有利用bitscan。当使用BMI1编译x86时,gcc确实使用andn进行了优化,但仍然不到每个周期4字节。

SSE2 pcmpeqb + bsf 对于短输入和长输入都更加优秀。x86-64保证SSE2可用,并且x86-64 System V的alignof(maxalign_t) = 16,所以calloc将始终返回至少16字节对齐的指针。


我为strlen块编写了一个替代品来测试性能

预期在Skylake上以每次16个字节的速度运行,比以每次4个字节的速度快约4倍。

我使用-O3选项将原始源代码编译为汇编代码,然后编辑了汇编代码以查看采用此策略对strlen进行内联扩展的性能应该如何。我还将其移植到C源代码中的内联汇编语言;请参见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)

请注意,我将strlen清理的一部分优化为存储寻址模式:我通过-16位移量校正了过头,这仅仅是查找字符串的结尾,而不是像GCC在内联其每次循环处理4个字节后已经做的那样计算长度然后进行索引。
要获取实际字符串长度(而不是指向结束处的指针),您需要减去rdx-start,然后添加rax-16(可以使用LEA添加2个寄存器和一个常量,但3组件LEA具有更多延迟)。
使用AVX可以允许在不破坏零寄存器的情况下在一个指令中进行加载和比较,整个循环只需要4个uops,从5个uops降至4个uops。(test/jz宏融合成一个uop,无论是Intel还是AMD。vpcmpeqb与非索引内存源可以保持微码融合,因此它只是前端的1个融合域uop。)
(请注意,在干净的起始状态下,即使在Haswell上混合128位AVX和SSE也不会导致停顿。 因此,我没有费心将其他指令更改为AVX,只改变了相关的指令。 在我的桌面上似乎有一些微小的效果,即在AVX循环体中,pxor 实际上略微比vpxor更好。 它似乎有些可重复性,但这很奇怪,因为没有代码大小差异,因此没有对齐差异。)

pmovmskb是一种单UOP指令。在Intel和Ryzen上,它的延迟为3个周期(在Bulldozer系列上更差)。对于短字符串,从SIMD单元到整数的行程是从输入内存字节到存储地址准备就绪的延迟关键路径依赖链的重要部分。但只有SIMD具有打包的整数比较,因此标量必须做更多的工作。

对于非常小的字符串情况(如0到3个字节),使用纯标量可能可以在该情况下实现稍低的延迟(特别是在Bulldozer系列上),但是让所有0到15个字节的字符串采用相同的分支路径(循环分支永不采取)对于大多数短字符串用例非常好。

当我们知道有16字节的对齐时,非常适合所有长度不超过15个字节的字符串。更可预测的分支非常好。(请注意,在循环时,pmovmskb延迟仅影响我们能够快速检测分支错误预测以跳出循环的时间;分支预测+推测执行隐藏了每次迭代中独立的pmovmskb的延迟。

如果我们预计较长的字符串很常见,我们可以展开一些,但此时您应该调用libc函数,以便在运行时调度到AVX2。展开到超过1个向量会使清理复杂化,从而影响简单情况。


在我的机器上,i7-6700k Skylake最大睿频速度为4.2GHz(且energy_performance_preference = performance),运行Arch Linux上的gcc8.2时,我得到了相对一致的基准测试时间,因为在memset期间我的CPU时钟速度会加速。但也许不总是达到最大睿频速度;当受限于内存带宽时,Skylake的硬件功率管理会下调时钟速度。perf stat显示,当运行此命令以平均stdout输出并查看stderr上的性能摘要时,我通常能获得约4.0 GHz的速度。
perf stat -r 100 ./a.out | awk '{sum+= $1}  END{print sum/100;}'

我最终将我的汇编代码复制到了GNU C内联汇编语句中,这样我就能在Godbolt编译器资源管理器上放置代码

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

  • ~62100 clock_t 时间单位:-O1 rep scas: (clock()有点过时,但我没有费心去改变它。)
  • ~15900 clock_t 时间单位:-O3 gcc 4字节循环策略:100次运行的平均值=。(或者使用-march=native进行andn,可能是~15800)
  • ~1880 clock_t 时间单位:-O3使用AVX2的glibc strlen函数调用
  • ~3190 clock_t 时间单位:(AVX1 128位向量,4个uop循环)手写内联汇编,gcc可以/应该内联。
  • ~3230 clock_t 时间单位:(SSE2 5 uop循环)手写内联汇编,gcc可以/应该内联。

我的手写汇编对于短字符串也非常好,因为它不需要特别分支。已知的对于strlen的对齐非常好,而libc无法利用它。

如果我们认为大字符串很少出现,那么对于这种情况,速度比libc慢1.7倍。 1M字节的长度意味着它不会在我的CPU的L2(256k)或L1d缓存(32k)中保持热度,因此即使在L3缓存上受到瓶颈的情况下,libc版本也更快。(可能是展开的循环和256位向量不会用太多uop每个字节堆积ROB,所以OoO exec可以看得更远并获得更多的内存并行性,特别是在页面边界处。)
但是L3缓存带宽可能是一个瓶颈,阻止4-uop版本以每个时钟周期1次迭代运行,因此我们从AVX中节省一个uop在循环中看到的好处较小。数据在L1d缓存中热后,我们应该每次迭代花费1.25个周期而不是1个周期。
但是,一个良好的AVX2实现可以每个周期读取高达64个字节(2x 32字节负载),使用vpminub在检查零之前组合成对,并返回找到它们的位置。这与libc之间的差距在大小约为2k到30 kiB左右的大小上扩大,这些大小在L1d中保持热度。
一些只读测试,长度为1000,表明glibc strlen对于在L1d缓存中热的中等大小字符串确实比我的循环快大约4倍。这足够大,可以让AVX2升级到大型展开的循环,但仍然轻松适合L1d缓存。(只读避免存储转发停顿,因此我们可以进行多次迭代)
如果你的字符串很大,应该使用显式长度的字符串,而不需要完全依赖strlen函数。因此,内联一个简单的循环仍然是一个合理的策略,只要它对于短字符串是真正有效的,并且对于中等长度(如300字节)和非常长的(>缓存大小)字符串不会完全失败。{{}}

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

我在尝试获得期望结果时遇到了一些奇怪的问题:

我尝试使用s[31] = 0来在每次迭代之前截断字符串(允许短常量长度)。但是,我的SSE2版本的速度几乎与GCC的版本相同。存储转发停顿成为了瓶颈!一个字节存储后跟着更宽的加载会使存储转发采用从存储缓冲区合并字节和L1d高速缓存中的字节的慢路径。这个额外的延迟是通过最后4字节或16字节块的循环传递依赖链的一部分,以计算下一次迭代的存储索引。

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

我最终通过创建只读版本,并使用内联汇编来阻止编译器将strlen提升出循环来解决了这个问题。

但是,使用16字节加载存在store-forwarding潜在问题。如果其他C变量存储在数组末尾之后,我们可能会因为超出较窄的存储器而导致SF停顿。对于最近复制的数据,如果使用16字节或更宽的对齐存储,则不会出现问题。但是,glibc memcpy用于小型复制时会进行2x重叠加载以覆盖整个对象,从对象的起始和结束处开始。然后,它再次进行重叠存储,免费处理memmove src重叠dst的情况。因此,刚刚复制的短字符串的第二个16字节或8字节块可能会给我们一个SF停顿,以便读取最后一个块(具有输出的数据依赖性的块)。
通常来说,只是为了等待准备好而使速度变慢并不是一个好的解决方案,所以这里没有很好的解决方案。我认为大多数情况下,您不会对刚刚写入的缓冲区进行strlen,通常您只会读取输入进行strlen,因此store-forwarding停顿不是问题。如果其他东西刚刚写入它,那么高效的代码希望不会丢弃长度并调用需要重新计算长度的函数。

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

针对只读,大小为1000(s[1000] = 0;),代码对齐会使性能差异达到2倍。但是最内层的汇编循环本身与.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比快速版本高得多:对于每一个这些分支缺失,它可能会沿着错误的路径进行长时间的推测。
可能内部和外部循环分支彼此别名,也可能没有。
指令计数几乎相同,只是在内部循环之前的外部循环中有一些NOPs不同。但IPC差异很大:没有问题,快速版本平均每个时钟运行4.82条指令,整个程序。(其中大部分是最内层循环,每个周期运行5条指令,由于测试/jz将2条指令宏合成1个uop)请注意,uops_executed比uops_issued高得多:这意味着微聚变正在很好地工作,使更多的uop通过前端瓶颈。
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 和一个 3 字节的 nop。 它还具有我当地使用的确切源代码。


短strlen只读微基准测试:

经过选择,测试内容不会受到分支错误预测或存储转发的影响,并且可以重复测试相同的短长度足够的迭代次数以获取有意义的数据。

strlen=33,因此终止符接近第3个16字节向量的开头。 (-DREAD_ONLY), 并使用i<1280000作为外循环重复循环。

  • 1933 clock_t: 我的汇编代码: 最佳情况下的时间表现出色(平均值重新运行时不会出现噪声/反弹)。与较长的strlen不同,与/无-DHIDE_ALIGNMENT具有相等的性能。这么短的模式更容易预测循环分支。(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

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

在所有版本中,strlen(s)= 33的分支误判几乎可以忽略不计,例如0.05%。 repz scasb版本为0.46%,但总分支较少。没有内部循环来积累许多正确预测的分支。

使用分支预测器和代码缓存热点,对于长度为33字节的字符串,与调用glibc strlen相比,repz scasb的性能要差10倍以上。 在实际使用情况下,strlen可能会出现分支误判,甚至在代码缓存中失效而停顿,但直线repz scasb不会。但是10倍的性能差距很大,而这只是一个相当短的字符串。


还有相关的内容:为什么glibc的strlen需要如此复杂才能快速运行?更多关于glibc的C和x86-asm strlen的信息。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接