我已经实现了strlen()
以不同的方式,包括功能SSE2 assembly
,SSE4.2 assembly
并且SSE2 intrinsic
,我也产生了一些实验,请用strlen() in
和strlen() in glibc
。但是,以毫秒(时间)为单位的性能是出乎意料的。
我的实验环境:
CentOS 7.0 + gcc 4.8.5 + Intel Xeon
以下是我的实现:
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); } }
我还查找了在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#include #include #include #include #include #include #include #include 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
我对此有一些疑问:
为什么strlen
的string.h
是如此之快?我认为应该识别其代码,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]复制了代码
为什么sse2 intrinsic
和sse2 assembly
在性能上有如此大的不同?
有人可以帮助我如何反汇编代码,以便我可以看到strlen
静态库的功能被编译器转换了吗?我用过gcc -s
但没发现拆卸strlen from the
我认为我的代码可能不太好,如果您能帮助我改进我的代码,尤其是汇编代码,我将不胜感激。
谢谢。
就像我在评论中所说的那样,您最大的错误是使用进行基准测试-O0
。在另一篇文章的第一部分中,我确切地讨论了为什么使用with -O0
进行测试是一个糟糕的主意。
基准测试至少应使用-O2进行,如果要尝试测试哪种来源构成最快的asm,最好采用与整个项目所用的优化方式相同的优化方式。
-O0
解释了使用内在函数(或常规编译的C,对于从glibc借来的C strlen实现),内联汇编要比C快得多。
IDK -O0
仍将优化离开循环,该循环将丢弃重复出现的库结果,或者以某种方式避免其他严重的性能陷阱。猜测在这样一个有缺陷的测试中到底发生了什么并不有趣。
我收紧了您的SSE2 inline-asm版本。主要是因为我最近一直在使用gcc内联asm输入/输出约束,并且想看看如果我编写它来让编译器选择用于临时的寄存器并避免不必要的指令会是什么样子。
相同的嵌入式asm可用于32位和64位x86目标。在Godbolt编译器资源管理器中可以同时看到这两个编译器。编译为独立功能时,即使在32位模式下,也不必保存/恢复任何寄存器:
警告:它最多可以读取字符串的末尾15个字节。这可能会导致段错误。请参阅在x86和x64的同一页面中读取缓冲区的末尾是否安全?有关避免这种情况的详细信息:到达对齐边界,然后使用对齐的载荷,因为如果向量包含至少1个字节的字符串数据,这始终是安全的。我将代码保持不变,因为讨论SSE与AVX对齐指针的效果很有趣。对齐指针还可以避免缓存行拆分和4k页面拆分(这是Skylake之前的性能隐患)。
#includesize_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"
告诉编译器,内联asm读取所指向的内存src
以及其src
自身的值。(编译器不知道asm的功能;因为它知道asm只是将一个指针与之对齐and
,所以假设所有输入指针都被取消引用将导致在asm中重新排序/合并加载和存储的优化遗漏。此外,这还使编译器知道我们仅读取内存,而不修改内存。)GCC手册使用了一个示例,其中使用了这种未指定长度的数组语法 "m" (*(const char (*)[])src)
进行内联时,它应将寄存器压力保持在最低水平,并且不要占用任何特殊用途的寄存器(如ecx
可变计数移位所需的寄存器)。
如果您可以从内部循环中删除另一个uop,则每个周期可以发出4 uop。实际上,在Intel SnB CPU上,5微秒意味着每次迭代可能需要2个周期才能从前端发出。(或者在以后的CPU(例如Haswell)上,或者在SnB 上为1.25个周期,如果我对整数行为的理解是错误的。)
使用对齐的指针将使负载折叠为的内存操作数pcmpeqb
。(如果字符串开头未对齐且结尾接近页面结尾,则对于正确性也是必需的)。有趣的是,将零向量用作目标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微秒,并且无法以这种方式test
进行宏融合。
PTEST
可以直接测试整个16B向量为全零或全一(使用ANDNOT-> CF部分),但是如果字节元素之一为零,则无法测试。(因此我们无法避免pcmpeqb
)。
看看Agner Fog的优化asm 指南,以及x86 Wiki 上的其他链接。大多数优化(Agner Fog,Intel和AMD)都会提到优化memcpy,特别是IIRC。