关于strlen的不同实现的性能的问题

时间:2015-12-24 08:08:01

标签: performance gcc sse inline-assembly intrinsics

我已经以不同的方式实施了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. 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;
      }
      
    2. 根据我的经验,我还添加了一个标准库并比较了它们的性能。 以下是我的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;
      }
      

      结果是:(ms)

      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. 为什么strlen的{​​{1}}如此之快?我认为其代码应该标识为string.h,因为我复制了strlen_inline_asm [http://lxr.oss.org.cn/source/arch/x86/lib/string_32.c#L164]
      2. 中的代码
      3. 为什么/linux-4.2.2/arch/x86/lib/string_32.csse2 intrinsic的表现如此不同?
      4. 有人可以帮我解决如何反汇编代码,以便我可以看到编译器转换了静态库函数sse2 assembly的内容吗?我使用strlen但未找到gcc -s
      5. 的反汇编
      6. 我认为我的代码可能不太好,如果你能帮助我改进我的代码,特别是装配代码,我将不胜感激。
      7. 感谢。

2 个答案:

答案 0 :(得分:5)

正如我在评论中所说的,您最大的错误是使用-O0进行基准测试。我确切地讨论了使用-O0进行测试的原因in the first part of another post

基准测试应该至少使用-O2,最好使用与完整项目相同的优化,如果你正在尝试测试测试哪个源是最快的asm。

-O0解释内联asm比使用内部函数的C更快(或者常规编译的C,对于从glibc借来的C strlen实现)。

IDK -O0仍会优化远离循环,反复丢弃库strlen的结果,或者它是否以某种方式避免了其他一些巨大的性能陷阱。猜测在这样一个有缺陷的测试中究竟发生了什么并不是很有趣。

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

相同的内联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的替代,告诉编译器内联asm读取src指向的内存,以及src本身的价值。 (编译器不知道asm是做什么的;因为它知道asm只是将指针与and或者其他东西对齐,所以假设所有输入指针都被解除引用会导致错误的重新排序/组合负载和整个asm。这也让编译器知道我们只读取内存,而不是修改它。)GCC手册uses an example with this unspecified-length array syntax "m" (*(const char (*)[])src)

在内联时应将寄存器压力保持在最小值,并且不会占用任何专用寄存器(如变量计数移位所需的ecx)。

如果你可以将另一个uop从内环中剃掉,那么每个周期就可以发出4个uop。实际上,5 uops意味着每次迭代可能需要2个周期才能从英特尔SnB CPU上的前端发出。 (Or 1.25 cycles on later CPUs like Haswell,如果我对整数行为的错误,也许在SnB上。)

使用对齐的指针可以将负载折叠到pcmpeqb 的内存操作数中。 (如果字符串start未对齐且结尾靠近页面末尾,则必须具有正确性)。有趣的是,使用零向量作为pcmpeqb的目的地在理论上是可以的:你不需要在迭代之间重新归零向量,因为如果循环是非零,则退出循环。它具有1个周期的延迟,因此当缓存未命中延迟旧迭代时,将零向量转换为循环携带的依赖性只是一个问题。但是,删除这个循环传递的依赖关系链可能有助于实践,通过让缓存未命中延迟旧迭代后追赶后端变得更快。

AVX完全解决了问题(如果字符串在页面末尾附近,则正确性除外)。即使没有首先进行对齐检查,AVX也可以折叠负载。 3操作数非破坏性vpcmpeqb避免将零向量转换为循环携带依赖性。 AVX2允许立即检查32B。

展开将有助于两种方式,但在没有AVX的情况下可以提供更多帮助。对齐64B边界或其他东西,然后将整个缓存行加载到四个16B向量中。综合检查POR所有结果可能会很好,因为pmovmsk + compare-and-branch是2 uops。

使用SSE4.1 PTEST没有帮助(与pmovmsk / test / jnz相比),因为它是2 uops并且无法宏观融合方式test可以。

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

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

答案 1 :(得分:0)

如果你在glibc中读取了strlen函数的来源,你可以看到该函数没有通过char测试字符串char,而是通过具有复杂按位运算的longword测试长字:http://www.stdlib.net/~colmmacc/strlen.c.html。我想它解释了它的速度,但它比装配中的指令更快的事实确实非常令人惊讶。