我已经以不同的方式实施了strlen()
功能,包括SSE2 assembly
,SSE4.2 assembly
和SSE2 intrinsic
,我还使用strlen() in <string.h>
对它们进行了一些实验和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 <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
我对此有一些疑问:
strlen
的{{1}}如此之快?我认为其代码应该标识为string.h
,因为我复制了strlen_inline_asm
[http://lxr.oss.org.cn/source/arch/x86/lib/string_32.c#L164] /linux-4.2.2/arch/x86/lib/string_32.c
和sse2 intrinsic
的表现如此不同?sse2 assembly
的内容吗?我使用strlen
但未找到gcc -s
感谢。
答案 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,以及x86 wiki上的其他链接。大多数优化(Agner Fog,以及Intel和AMD)都会提到优化memcpy和strlen,特别是IIRC。
答案 1 :(得分:0)
如果你在glibc中读取了strlen函数的来源,你可以看到该函数没有通过char测试字符串char,而是通过具有复杂按位运算的longword测试长字:http://www.stdlib.net/~colmmacc/strlen.c.html。我想它解释了它的速度,但它比装配中的指令更快的事实确实非常令人惊讶。