【问题标题】:Questions about the performance of different implementations of strlen [closed]关于 strlen 不同实现的性能问题[关闭]
【发布时间】:2016-03-30 16:46:24
【问题描述】:

我实现了strlen()函数的不同方式,包括SSE2 assemblySSE4.2 assemblySSE2 intrinsic,我也对它们进行了一些实验,strlen() in <string.h>strlen() in glibc。但是,它们在毫秒(时间)方面的表现是出乎意料的。

我的实验环境: CentOS 7.0 + gcc 4.8.5 + Intel Xeon

以下是我的实现:

  1. strlen 使用 SSE2 程序集

    long strlen_sse2_asm(const char* src){
    long result = 0;
    asm(
        "movl %1, %%edi\n\t"
        "movl $-0x10, %%eax\n\t"
        "pxor %%xmm0, %%xmm0\n\t"
        "lloop:\n\t"
            "addl $0x10, %%eax\n\t"
            "movdqu (%%edi,%%eax), %%xmm1\n\t"
            "pcmpeqb %%xmm0, %%xmm1\n\t"
            "pmovmskb %%xmm1, %%ecx\n\t"
            "test %%ecx, %%ecx\n\t"
            "jz lloop\n\t"
    
        "bsf %%ecx, %%ecx\n\t"
        "addl %%ecx, %%eax\n\t"
        "movl %%eax, %0"
        :"=r"(result)
        :"r"(src)
        :"%eax"
        );
    return result;
    }
    

2.strlen 使用 SSE4.2 汇编

long strlen_sse4_2_asm(const char* src){
long result = 0;
asm(
    "movl %1, %%edi\n\t"
    "movl $-0x10, %%eax\n\t"
    "pxor %%xmm0, %%xmm0\n\t"
    "lloop2:\n\t"
        "addl $0x10, %%eax\n\t"
        "pcmpistri $0x08,(%%edi, %%eax), %%xmm0\n\t"
        "jnz lloop2\n\t"

        "add %%ecx, %%eax\n\t"
        "movl %%eax, %0"

    :"=r"(result)
    :"r"(src)
    :"%eax"
    );
return result;
}

3。 strlen 使用 SSE2 内在

long strlen_sse2_intrin_align(const char* src){
if (src == NULL || *src == '\0'){
    return 0;
}
const __m128i zero = _mm_setzero_si128();
const __m128i* ptr = (const __m128i*)src;

if(((size_t)ptr&0xF)!=0){
    __m128i xmm = _mm_loadu_si128(ptr);
    unsigned int mask = _mm_movemask_epi8(_mm_cmpeq_epi8(xmm,zero));
    if(mask!=0){
        return (const char*)ptr-src+(size_t)ffs(mask);
    }
    ptr = (__m128i*)(0x10+(size_t)ptr & ~0xF);
}
for (;;ptr++){
    __m128i xmm = _mm_load_si128(ptr);
    unsigned int mask = _mm_movemask_epi8(_mm_cmpeq_epi8(xmm,zero));
    if (mask!=0)
        return (const char*)ptr-src+(size_t)ffs(mask);
}

}
  1. 我也查了一下linux内核中的实现,下面是它的实现

    size_t strlen_inline_asm(const char* str){
    int d0;
    size_t res;
    asm volatile("repne\n\t"
    "scasb"
    :"=c" (res), "=&D" (d0)
    : "1" (str), "a" (0), "" (0xffffffffu)
    : "memory");
    
    return ~res-1;
    }
    

根据我的经验,我还添加了标准库之一并比较了它们的性能。 以下是我的main功能码:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <xmmintrin.h>
#include <x86intrin.h>
#include <emmintrin.h>
#include <time.h>
#include <unistd.h>
#include <sys/time.h>
int main()
{
    struct timeval tpstart,tpend;
    int i=0;
    for(;i<1023;i++){
            test_str[i] = 'a';
    }
    test_str[i]='\0';
    gettimeofday(&tpstart,NULL);
    for(i=0;i<10000000;i++)
            strlen(test_str);
    gettimeofday(&tpend,NULL);
    printf("strlen from stirng.h--->%lf\n",(tpend.tv_sec-tpstart.tv_sec)*1000+(tpend.tv_usec-tpstart.tv_usec)/1000.0);

    gettimeofday(&tpstart,NULL);
    for(i=0;i<10000000;i++)
            strlen_inline_asm(test_str);
    gettimeofday(&tpend,NULL);
    printf("strlen_inline_asm--->%lf\n",(tpend.tv_sec-tpstart.tv_sec)*1000+(tpend.tv_usec-tpstart.tv_usec)/1000.0);

    gettimeofday(&tpstart,NULL);
    for(i=0;i<10000000;i++)
            strlen_sse2_asm(test_str);
    gettimeofday(&tpend,NULL);
    printf("strlen_sse2_asm--->%lf\n",(tpend.tv_sec-tpstart.tv_sec)*1000+(tpend.tv_usec-tpstart.tv_usec)/1000.0);

    gettimeofday(&tpstart,NULL);
    for(i=0;i<10000000;i++)
            strlen_sse4_2_asm(test_str);
    gettimeofday(&tpend,NULL);
    printf("strlen_sse4_2_asm--->%lf\n",(tpend.tv_sec-tpstart.tv_sec)*1000+(tpend.tv_usec-tpstart.tv_usec)/1000.0);

    gettimeofday(&tpstart,NULL);
    for(i=0;i<10000000;i++)
            strlen_sse2_intrin_align(test_str);
    gettimeofday(&tpend,NULL);
    printf("strlen_sse2_intrin_align--->%lf\n",(tpend.tv_sec-tpstart.tv_sec)*1000+(tpend.tv_usec-tpstart.tv_usec)/1000.0);

    return 0;
}

结果是:(毫秒)

strlen from stirng.h--->23.518000
strlen_inline_asm--->222.311000
strlen_sse2_asm--->782.907000
strlen_sse4_2_asm--->955.960000
strlen_sse2_intrin_align--->3499.586000

我对此有一些疑问:

  1. 为什么string.hstrlen 这么快?我认为它的代码应该识别为strlen_inline_asm,因为我从/linux-4.2.2/arch/x86/lib/string_32.c复制了代码[http://lxr.oss.org.cn/source/arch/x86/lib/string_32.c#L164]
  2. 为什么sse2 intrinsicsse2 assembly 在性能上如此不同?
  3. 谁能帮助我如何反汇编代码,以便我可以看到编译器对静态库的函数strlen 进行了哪些转换?我用了gcc -s但是没有找到strlen from the &lt;string.h&gt;的反汇编
  4. 我认为我的代码可能不太好,如果您能帮助我改进我的代码,尤其是汇编代码,我将不胜感激。

谢谢。

【问题讨论】:

  • 我投票决定将此问题作为离题结束,因为它通过在循环中丢弃结果来对标准的纯函数进行基准测试。
  • 编译器可能会优化掉string.h strlen,因为它知道它会这样做(并且每次结果都是一样的)。您是否检查了测试循环的 asm 输出?编写衡量您实际想要衡量的内容的微基准并非易事。
  • @BecomeBetter:-O0 无法获得有用的基准测试结果。我想知道是否是这种情况,因为内在函数版本的表现有多糟糕。您是否甚至查看过使用 -O0 从内部函数生成的 asm?
  • @BecomeBetter:另外,您应该提及您正在使用的测试字符串的大小 - 如果它们是理智/现实的(例如,对于普通代码来说,最多大约 100 个字符)那么你会得到与在合理代码中从未使用过的“非常大”的字符串截然不同的结果。
  • 对这个问题的反对票和反对票是把婴儿和洗澡水一起扔出去。一些基准测试前提可能是错误的,但如果您缺乏批判性检查 SSE 实现的知识 - 您应该继续前进。这个问题有很多值得考虑和平衡的字符串长度等问题。

标签: performance gcc sse inline-assembly intrinsics


【解决方案1】:

就像我在 cmets 中所说,您最大的错误是使用 -O0 进行基准测试。我讨论了为什么使用-O0 进行测试是一个糟糕的想法in the first part of another post

基准测试应该至少使用 -O2 来完成,最好使用与构建完整项目相同的优化,如果您尝试测试哪个源可以生成最快的 asm。 p>

-O0 解释了内联 asm 比具有内在函数的 C(或常规编译的 C,对于从 glibc 借用的 C strlen 实现)快得多。

IDK -O0 仍然会优化循环,该循环会重复丢弃库 strlen 的结果,或者如果它以某种方式避免了其他一些巨大的性能缺陷。猜测在这样一个有缺陷的测试中到底发生了什么并不有趣。


我收紧了你的 SSE2 inline-asm 版本。主要是因为我最近一直在使用 gcc 内联 asm 输入/输出约束,并且想看看如果我编写它让编译器选择哪些寄存器用于临时变量并避免不需要的指令会是什么样子。

相同的内联汇编适用于 32 位和 64 位 x86 目标;请参阅为on the Godbolt compiler explorer 编译的此内容。编译成独立函数时,即使在 32 位模式下也不必保存/恢复任何寄存器:

警告:它最多可以读取字符串末尾的 15 个字节。这可能会出现段错误。有关避免这种情况的详细信息,请参阅Is it safe to read past the end of a buffer within the same page on x86 and x64?:到达对齐边界,然后使用对齐加载,因为如果向量包含至少 1 个字节的字符串数据,这总是安全的。我保持代码不变,因为讨论对齐 SSE 与 AVX 的指针的效果很有趣。对齐指针还可以避免缓存行拆分和 4k 页面拆分(这是 Skylake 之前的性能坑)。

#include <immintrin.h>

size_t strlen_sse2_asm(const char* src){

  // const char *orig_src = src; // for a pointer-increment with a "+r" (src) output operand

  size_t result = 0;
  unsigned int tmp1;
  __m128i zero = _mm_setzero_si128(), vectmp;

  // A pointer-increment may perform better than an indexed addressing mode
  asm(
    "\n.Lloop:\n\t"
        "movdqu   (%[src], %[res]), %[vectmp]\n\t"  // result reg is used as the loop counter
        "pcmpeqb  %[zerovec], %[vectmp]\n\t"
        "pmovmskb %[vectmp], %[itmp]\n\t"
        "add      $0x10, %[res]\n\t"
        "test     %[itmp], %[itmp]\n\t"
        "jz  .Lloop\n\t"

    "bsf %[itmp], %[itmp]\n\t"
    "add %q[itmp], %q[res]\n\t"   // q modifier to get quadword register.
    // (add %edx, %rax doesn't work).  But in 32bit mode, q gives a 32bit reg, so the same code works
    : [res] "+r"(result), [vectmp] "=&x" (vectmp), [itmp] "=&r" (tmp1)

    : [zerovec] "x" (zero) // There might already be a zeroed vector reg when inlining
      , [src] "r"(src)
      , [dummy] "m" (*(const char (*)[])src) // this reads the whole object, however long gcc thinks it is
    : //"memory"        // not needed because of the dummy input
    );
  return result;
  // return result + tmp1;  // doing the add outside the asm makes gcc sign or zero-extend tmp1.
  // No benefit anyway, since gcc doesn't know that tmp1 is the offset within a 16B chunk or anything.
}

注意虚拟输入,作为"memory" clobber 的替代,告诉编译器内联汇编读取src 指向的内存,以及@ 的值987654336@ 本身。 (编译器不知道 asm 做了什么;因为它只知道 asm 只是将指针与 and 或其他东西对齐,因此假设所有输入指针都被取消引用将导致错过从重新排序/组合加载和存储的优化asm。另外,这让编译器知道我们只读取内存,而不是修改它。)GCC手册uses an example with this unspecified-length array syntax"m" (*(const char (*)[])src)

它应该在内联时将寄存器压力保持在最低限度,并且不会占用任何特殊用途的寄存器(如变量计数移位所需的ecx)。

如果您可以将另一个微指令从内部循环中剔除,那么每个循环可以发出一个微指令,它会减少到 4 个微指令。实际上,5 uop 意味着每次迭代可能需要 2 个周期才能从 Intel SnB CPU 上的前端发出。 (Or 1.25 cycles on later CPUs like Haswell,如果我对整数行为有误,可能会在 SnB 上。)

使用对齐的指针将允许负载折叠到pcmpeqb 的内存操作数中。 (如果字符串开头未对齐并且结尾接近页面末尾,则也是正确性所必需的)。有趣的是,使用零向量作为pcmpeqb 的目标在理论上是可以的:您不需要在迭代之间重新归零向量,因为如果它不为零,则退出循环。它有 1 个周期的延迟,因此只有在缓存未命中延迟旧迭代时才会将零向量转换为循环携带的依赖项。不过,删除这个循环携带的依赖链在实践中可能会有所帮助,因为可以让后端在缓存未命中延迟旧迭代后赶上来更快。

AVX 完全解决了这个问题(如果字符串在页面末尾附近结束,则正确性除外)。 AVX 允许在没有先进行对齐检查的情况下折叠负载。 3 操作数非破坏性vpcmpeqb 避免将零向量变成循环携带的依赖项。 AVX2 将允许一次检查 32B。

展开对任何一种方式都有帮助,但在没有 AVX 的情况下会有更多帮助。对齐到 64B 边界什么的,然后将整个缓存行加载到四个 16B 向量中。对 POR 将它们全部组合在一起的结果进行综合检查可能会很好,因为 pmovmsk + compare-and-branch 是 2 微秒。

使用 SSE4.1 PTEST 没有帮助(与 pmovmsk / test / jnz 相比),因为它是 2 微指令并且不能像 test 那样进行宏融合。

PTEST 可以直接测试整个 16B 向量是否为全零或全一(使用 ANDNOT -> CF 部分),但如果字节元素之一为零则不能。 (所以我们无法避免pcmpeqb)。


查看 Agner Fog's guides 以优化 asm,以及 wiki 上的其他链接。大多数优化(Agner Fog 以及 Intel 和 AMD)都会特别提到优化 memcpy 和 strlen,IIRC。

【讨论】:

  • 我突然想到,这个问答会在五年前为你们赢得两颗金星。
  • @BrettHale: /me, 在风车上倾斜,因为 ... IDK 时:P
  • 没有内存破坏?此外,我不建议这些天在风车上倾斜。那些傻逼是HUGE。你最好去追龙。龙吐火,风车有律师……
  • @DavidWohlferd:很好看;重读几年前写的代码时,我没有注意到这个问题,只是发现了对齐问题。
【解决方案2】:

如果您阅读 glibc 中 strlen 函数的源代码,您可以看到该函数不是逐个字符地测试字符串,而是逐个长字地测试长字与复杂的位运算:http://www.stdlib.net/~colmmacc/strlen.c.html。我想它解释了它的速度,但它比汇编中的 rep 指令还要快这一事实确实令人惊讶。

【讨论】:

  • rep movs / rep stos 在 Intel 上速度很快。 rep scas / rep cmps 快。它们比一次一个字节的普通循环慢。
  • 我添加了 glibc 实现,与我的实验中的其他实现相比,它不是很快strlen from stirng.h---&gt;23.747000 strlen_inline_asm---&gt;221.661000 strlen_sse2_asm---&gt;797.489000 strlen_sse4_2_asm---&gt;923.677000 strlen_sse2_intrin_align---&gt;3467.068000 strlen of glib---&gt;6591.779000
  • 好吧,猜猜谜底还没有。
  • @KeylorSanchez:不是这样;使用-O0 编译 glibc 的实现会产生垃圾结果,我并不感到惊讶。
  • 基准测试应该至少使用 -O2 来完成,最好使用与构建完整项目相同的优化,如果您正在尝试测试是什么来源使最快的汇编。 -O0确实解释了这些结果:内在函数方式比内联汇编慢。 -O0 可能仍会优化库 strlen,或者它可能只是内联或其他东西。 IDK 关于这一点,但我没有兴趣猜测在这样一个有缺陷的测试中到底发生了什么。
猜你喜欢
  • 1970-01-01
  • 2012-08-01
  • 2012-11-23
  • 2011-11-18
  • 1970-01-01
  • 1970-01-01
  • 2011-05-28
  • 1970-01-01
  • 2022-09-24
相关资源
最近更新 更多