22.2 SIMD strlen() implementation

SIMD指令可能通过特殊的宏8插入到C/C++代码中。MSVC中他们被保存在intrin.h中。

Strlen()函数9的实现使用了SIMD指令,比常规的实现快了2-2.5倍。该函数将16个字符加载到一个XMM寄存器并检查是否为零

  1. size_t strlen_sse2(const char *str)
  2. {
  3. register size_t len = 0;
  4. const char *s=str;
  5. bool str_is_aligned=(((unsigned int)str)&0xFFFFFFF0) == (unsigned int)str;
  6. if (str_is_aligned==false)
  7. return strlen (str);
  8. __m128i xmm0 = _mm_setzero_si128();
  9. __m128i xmm1;
  10. int mask = 0;
  11. for (;;)
  12. {
  13. xmm1 = _mm_load_si128((__m128i *)s);
  14. xmm1 = _mm_cmpeq_epi8(xmm1, xmm0);
  15. if ((mask = _mm_movemask_epi8(xmm1)) != 0)
  16. {
  17. unsigned long pos;
  18. _BitScanForward(&pos, mask);
  19. len += (size_t)pos;
  20. break;
  21. }
  22. s += sizeof(__m128i);
  23. len += sizeof(__m128i);
  24. };
  25. return len;
  26. }

(这里的例子基于源代码).

MSVC 2010 /Ox 编译选项:

  1. _pos$75552 = -4 ; size = 4
  2. _str$ = 8 ; size = 4
  3. ?strlen_sse2@@YAIPBD@Z PROC ; strlen_sse2
  4. push ebp
  5. mov ebp, esp
  6. and esp, -16 ; fffffff0H
  7. mov eax, DWORD PTR _str$[ebp]
  8. sub esp, 12 ; 0000000cH
  9. push esi
  10. mov esi, eax
  11. and esi, -16 ; fffffff0H
  12. xor edx, edx
  13. mov ecx, eax
  14. cmp esi, eax
  15. je SHORT $LN4@strlen_sse
  16. lea edx, DWORD PTR [eax+1]
  17. npad 3
  18. $LL11@strlen_sse:
  19. mov cl, BYTE PTR [eax]
  20. inc eax
  21. test cl, cl
  22. jne SHORT $LL11@strlen_sse
  23. sub eax, edx
  24. pop esi
  25. mov esp, ebp
  26. pop ebp
  27. ret 0
  28. $LN4@strlen_sse:
  29. movdqa xmm1, XMMWORD PTR [eax]
  30. pxor xmm0, xmm0
  31. pcmpeqb xmm1, xmm0
  32. pmovmskb eax, xmm1
  33. test eax, eax
  34. jne SHORT $LN9@strlen_sse
  35. $LL3@strlen_sse:
  36. movdqa xmm1, XMMWORD PTR [ecx+16]
  37. add ecx, 16 ; 00000010H
  38. pcmpeqb xmm1, xmm0
  39. add edx, 16 ; 00000010H
  40. pmovmskb eax, xmm1
  41. test eax, eax
  42. je SHORT $LL3@strlen_sse
  43. $LN9@strlen_sse:
  44. bsf eax, eax
  45. mov ecx, eax
  46. mov DWORD PTR _pos$75552[esp+16], eax
  47. lea eax, DWORD PTR [ecx+edx]
  48. pop esi
  49. mov esp, ebp
  50. pop ebp
  51. ret 0
  52. ?strlen_sse2@@YAIPBD@Z ENDP ; strlen_sse2

首先,检查str指针,如果不是按照16字节对齐则调用常规实现。

然后使用movdqa指令加载16个字节到xmm1.这里不使用movdqu的原因是如果指针不一致则从内存中加载的数据可能会不一致。

是的,它可能会以这种方式做,如果指针对齐,使用MOVDQA加载数据,否则使用比较慢的MOVDQU。

但是我们应该注意到这样的警告:

在windowsNT操作系统但不限于该操作系统,内存页按4kb对齐。每个win32进程独占4GB虚拟内存。事实上,只有部分地址空间与真实物理内存对应,如果进程访问的内存没有对应物理内存,将触发异常。这是虚拟内存的工作方式10.

一个函数一次加载16个字节,可能会跨内存分块访问。我们考虑这样一种情况,操作系统在x008c0000分配8192(0x2000)字节,因此块字节从地质0x008c0000到0x008c1fff。 内存块之后从0x008c2000什么都没有,操作系统没有分配任何内存。访问该地址将触发异常。

假如内存块包含的最后5个字符如下:

  1. 0x008c1ff8 h
  2. 0x008c1ff9 e
  3. 0x008c1ffa l
  4. 0x008c1ffb l
  5. 0x008c1ffc o
  6. 0x008c1ffd x00
  7. 0x008c1ffe random noise
  8. 0x008c1fff random noise

正常情况下,strlen()只会读取到”hello”。

如果我们使用MOVDQU读取16个字节,将会触发异常,应该避免这种情况。

因为我们要确保16字节对齐,保证我们不会读取未分配的内存。

让我们回到函数:

  1. _mm_setzero_si128()—宏pxor xmm0, xmm0—清空XMM0寄存器。
  2. _mm_load_si128()—宏 MOVDQA, 从内存加载16个字节到XMM寄存器。
  3. _mm_cmpeq_epi8()—宏PCMPEQB,比较XMM寄存器的字节位,如果相等则为0xff否则为0

比如:

  1. XMM1: 11223344556677880000000000000000
  2. XMM0: 11ab3444007877881111111111111111

执行pcmpeqb xmm1, xmm0之后,XMM1寄存器的值为:

XMM1: ff0000ff0000ffff0000000000000000

在本例中该指令比较每一个16字节块与16字节0字节块对比,XMM0通过pxor xmm0 xmm0置零。

接下来宏_mm_movemask_epi8() —这是PMOVMSKB指令。

pmovmskb eax, xmm1

pmovmskb创建源操作数每一个字节的自高位掩码,并保存结果到目的操作数的低byte。源操作数必须为MMX寄存器,目的操作数必须为32位通用寄存器。

比如:

XMM1: 0000ff00000000000000ff0000000000

对应的EAX:

EAX=0010000000100000b

之后bsf eax,eax被执行,eax值为5,意味着第一个是1的位置是5(从0开始)。

MSVC关于这个指令的宏是:_BitScanForward.

至此,找到结尾0的位置,然后程序返回长度计数。

整个过程大致就是这样。

顺便提一下,MSVC为了优化,使用了两个并排的循环。

SSE 4.2(英特尔core i7)提供了更多的指令,这些可能更容易字符串操作。

http://www.strchr.com/strcmp_and_strlen_using_sse_4.2 # 64位化