Como não fazer o strlen mais rápido e encontrar uma falha na Comunidade do Visual Studio 2019

Fui solicitado por um artigo sobre o uso da instrução "estranha" de contagem de pop-up nos processadores modernos . Não se trata de contar o número de unidades, mas de detectar um sinal do final de uma linha C (uma linha terminada em nulo ).
Uma sequência terminada em nulo é uma maneira de representar sequências de caracteres em linguagens de programação, nas quais, em vez de introduzir um tipo especial de sequência, é usada uma matriz de caracteres e o final da sequência é o primeiro caractere nulo especial encontrado (NUL do código ASCII, com um valor 0).

Para determinar a duração desse termo, uma função padrão é aplicada.

size_t __cdecl strlen(char const* str) 

O algoritmo de operação pode ser descrito em C como:

 size_t strlen_algo(const char* str) { size_t length = 0; while (*str++) length++; return length; } 

Vamos ver no que o compilador da comunidade do MS Visual Studio 2019 (versão x86) o transforma:

 08811F7h: mov al,byte ptr [ecx] inc ecx test al,al jne main+0D7h (08811F7h) 

Ou seja, um byte é carregado da memória e comparado com zero. O mesmo código é substituído por chamadas strlen se você criar o projeto no Release, o algoritmo está correto, mas a velocidade, ao que me parece, não é suficiente. O que acontece se você compilar o código com uma chamada para strlen padrão no Debug? - A função da biblioteca strlen será chamada, como esperado, mas escrita por uma pessoa manualmente no assembler.

Spoiler de código da função strlen padrão no MS Visual Studio
 ;strlen.asm - contains strlen() routine ; ; Copyright (c) Microsoft Corporation. All rights reserved. ; ;Purpose: ; strlen returns the length of a null-terminated string, ; not including the null byte itself. ; ;******************************************************************************* .xlist include cruntime.inc .list page ;*** ;strlen - return the length of a null-terminated string ; ;Purpose: ; Finds the length in bytes of the given string, not including ; the final null character. ; ; Algorithm: ; int strlen (const char * str) ; { ; int length = 0; ; ; while( *str++ ) ; ++length; ; ; return( length ); ; } ; ;Entry: ; const char * str - string whose length is to be computed ; ;Exit: ; EAX = length of the string "str", exclusive of the final null byte ; ;Uses: ; EAX, ECX, EDX ; ;Exceptions: ; ;******************************************************************************* CODESEG public strlen strlen proc \ buf:ptr byte OPTION PROLOGUE:NONE, EPILOGUE:NONE .FPO ( 0, 1, 0, 0, 0, 0 ) string equ [esp + 4] mov ecx,string ; ecx -> string test ecx,3 ; test if string is aligned on 32 bits je short main_loop str_misaligned: ; simple byte loop until string is aligned mov al,byte ptr [ecx] add ecx,1 test al,al je short byte_3 test ecx,3 jne short str_misaligned add eax,dword ptr 0 ; 5 byte nop to align label below align 16 ; should be redundant main_loop: mov eax,dword ptr [ecx] ; read 4 bytes mov edx,7efefeffh add edx,eax xor eax,-1 xor eax,edx add ecx,4 test eax,81010100h je short main_loop ; found zero byte in the loop mov eax,[ecx - 4] test al,al ; is it byte 0 je short byte_0 test ah,ah ; is it byte 1 je short byte_1 test eax,00ff0000h ; is it byte 2 je short byte_2 test eax,0ff000000h ; is it byte 3 je short byte_3 jmp short main_loop ; taken if bits 24-30 are clear and bit ; 31 is set byte_3: lea eax,[ecx - 1] mov ecx,string sub eax,ecx ret byte_2: lea eax,[ecx - 2] mov ecx,string sub eax,ecx ret byte_1: lea eax,[ecx - 3] mov ecx,string sub eax,ecx ret byte_0: lea eax,[ecx - 4] mov ecx,string sub eax,ecx ret strlen endp end 


Tabela 1 - tempo de execução do benchmark strlen em segundos (comunidade MS VS 2019, versão C ++ cl: 19.22.27905)

Bloco grande, 1KBloco grande, 1K, * chamada strlenBloco pequeno, 10 elementosBloco pequeno, 10 elementos, * chamada de strlen
Depuração, x867,257,253.063.06
Lançamento, x869.03.90,150,12
Depuração, x646.06.03.4.3.4.
Lançamento, x648,52.30,150,11

* force o compilador a chamar a função strlen library

Assim, podemos concluir que a substituição da comparação de bytes pelo compilador MS é ineficiente mesmo em pequenas seqüências de caracteres e, em grandes, o Debug está à frente do Release!

Código do banco
 #include <iostream> #include <string> #include <vector> #include <chrono> #include <nmmintrin.h> inline size_t strlen_algo(const char* str) { size_t length = 0; while (*str++) length++; return length; } inline size_t strlen_sse4(const char* str) { size_t length = 0; int res = 0; //align to 16 bytes while (((size_t(str+length)) & (sizeof(__m128i) - 1)) != 0) { if (str[length] == 0) return length; length++; } __m128i z128 = _mm_setzero_si128(); for(; ; length += 16) { __m128i data = _mm_load_si128((__m128i*)(str + length)); if ((res = _mm_cmpistri(z128, data, _SIDD_CMP_EQUAL_EACH)) != 16) break; } /*while (str[length]) length++;*/ return length + res; } #define _DISABLE_ASM_BSF //https://www.strchr.com/sse2_optimised_strlen #ifndef WORDS_BIGENDIAN #if 0 #elif 0 #else static inline int count_bits_to_0(unsigned int x) // counting trailing zeroes, by Nazo, post: 2009/07/20 03:40 { // this is current winner for speed static const unsigned char table[256] = { 7, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 5, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 6, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 5, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 7, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 5, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 6, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 5, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, 4, 0, 1, 0, 2, 0, 1, 0, 3, 0, 1, 0, 2, 0, 1, 0, }; if ((unsigned char)x) return table[(unsigned char)x]; return table[x >> 8] + 8; // t[x / 256] + 8 } #endif #else #if 0 static inline int count_bits_to_0(unsigned int x) // counting trailing zeroes { register int i = 0; if (!(x & (1 << 15))) i++; else return i; if (!(x & (1 << 14))) i++; else return i; if (!(x & (1 << 13))) i++; else return i; if (!(x & (1 << 12))) i++; else return i; if (!(x & (1 << 11))) i++; else return i; if (!(x & (1 << 10))) i++; else return i; if (!(x & (1 << 9))) i++; else return i; if (!(x & (1 << 8))) i++; else return i; if (!(x & (1 << 7))) i++; else return i; if (!(x & (1 << 6))) i++; else return i; if (!(x & (1 << 5))) i++; else return i; if (!(x & (1 << 4))) i++; else return i; if (!(x & (1 << 3))) i++; else return i; if (!(x & (1 << 2))) i++; else return i; if (!(x & (1 << 1))) i++; else return i; if (!(x & (1 << 0))) i++; return i; } #else static inline int count_bits_to_0(unsigned int x) // counting trailing zeroes { // http://www.hackersdelight.org/: nlz1() shortened for 16-bit mask register int n = 0; if (x <= 0x000000FFU) { n = n + 8; x = x << 8; } if (x <= 0x00000FFFU) { n = n + 4; x = x << 4; } if (x <= 0x00003FFFU) { n = n + 2; x = x << 2; } if (x <= 0x00007FFFU) { n = n + 1; } return n; } #endif #endif size_t strlen2(const char* str) { register size_t len = 0; // align to 16 bytes while ((((intptr_t)str) & (sizeof(__m128i) - 1)) != 0) { if (*str++ == 0) return len; ++len; } // search for 0 __m128i xmm0 = _mm_setzero_si128(); __m128i xmm1; int mask = 0; for (;;) { xmm1 = _mm_load_si128((__m128i*)str); xmm1 = _mm_cmpeq_epi8(xmm1, xmm0); if ((mask = _mm_movemask_epi8(xmm1)) != 0) { // got 0 somewhere within 16 bytes in xmm1, or within 16 bits in mask // find index of first set bit #ifndef _DISABLE_ASM_BSF // define it to disable ASM #if (_MSC_VER >= 1300) // make sure <intrin.h> is included unsigned long pos; _BitScanForward(&pos, mask); len += (size_t)pos; #elif defined(_MSC_VER) // earlier MSVC's do not have _BitScanForward, use inline asm __asm bsf edx, mask; edx = bsf(mask) __asm add edx, len; edx += len __asm mov len, edx; len = edx #elif ((__GNUC__ >= 4) || ((__GNUC__ == 3) && (__GNUC_MINOR__ >= 4))) // modern GCC has built-in __builtin_ctz len += __builtin_ctz(mask); #elif defined(__GNUC__) // older GCC shall use inline asm unsigned int pos; asm("bsf %1, %0" : "=r" (pos) : "rm" (mask)); len += (size_t)pos; #else // none of choices exist, use local BSF implementation len += count_bits_to_0(mask); #endif #else len += count_bits_to_0(mask); #endif break; } str += sizeof(__m128i); len += sizeof(__m128i); } return len; } int main() { std::vector<std::string> vstr; const int str_num = 1024; const int str_size = 1024; size_t len_result = 0; srand(0); for (int i = 0; i < str_num; i++) { std::string str1; for (int j = 0; j < str_size; j++) { str1.push_back('0' + rand() % 78); } vstr.push_back(std::move(str1)); } auto strlen_func = strlen; //auto strlen_func = strlen_algo; //auto strlen_func = strlen_sse4; //auto strlen_func = strlen2; auto time_std = std::chrono::steady_clock::now(); for (int k = 0; k < 10*1000; k++) { for (int i = 0; i < str_num; i++) { const char* str_for_test = vstr[i].c_str(); len_result += strlen_func(str_for_test); //len_result += strlen(str_for_test); } for (int i = 0; i < str_num; i++) { const char* str_for_test = vstr[i].c_str(); len_result -= strlen_func(str_for_test); //len_result -= strlen(str_for_test); } } auto finish = std::chrono::steady_clock::now(); double elapsed_seconds = std::chrono::duration_cast<std::chrono::duration<double>>(finish - time_std).count(); std::cout << "\n" << len_result; std::cout << "\n\nTime: " << elapsed_seconds; return 0; } 


String
 len_result += strlen(str_for_test); 
compila
Depurar: chamando a função de biblioteca strlen;
Lançamento em: comparação de bytes.

Se você comentar e escrever

 len_result += strlen_func(str_for_test); 

onde

 auto strlen_func = strlen; 

forçaremos o compilador a sempre chamar a função de biblioteca.

Devido a que a aceleração da função de biblioteca foi alcançada antes da comparação de bytes em 2,3 vezes (para Release, x86, 1k)?

Devido à comparação, não por um byte, mas imediatamente por 4. Toda a mágica aqui:

 main_loop: mov eax,dword ptr [ecx] ; read 4 bytes mov edx,7efefeffh add edx,eax xor eax,-1 xor eax,edx add ecx,4 test eax,81010100h je short main_loop 

É possível fazer mais rápido usando instruções vetoriais de processadores modernos? Vamos tentar.

Usando o guia Intel Intrinsics , encontramos o _mm_cmpistri SSE4.2 intrínseco, projetado apenas para trabalhar com strings. Dois vetores de 128 bits de comprimento e uma máscara de operações são alimentados na entrada. Como máscara, usamos: _SIDD_UBYTE_OPS = 0 - tipo de dados, _SIDD_CMP_EQUAL_EACH = 8 - operação de alinhamento de bytes, e compararemos com um vetor zero. O valor retornado será o número de primeiros elementos desiguais em pares (ou seja, se o elemento corresponder da esquerda para a direita, a contagem será interrompida, ficarei feliz se alguém confirmar o comportamento).

 size_t length = 0; int res = 0; //align to 16 bytes while (((size_t(str+length)) & (sizeof(__m128i) - 1)) != 0) { if (str[length] == 0) return length; length++; } __m128i z128 = _mm_setzero_si128(); for(; ; length += 16) { __m128i data = _mm_load_si128((__m128i*)(str + length)); if ((res = _mm_cmpistri(z128, data, _SIDD_CMP_EQUAL_EACH)) != 16) break; } /*while (str[length]) length++;*/ return length + res; 

Código

 while (((size_t(str+length)) & (sizeof(__m128i) - 1)) != 0) { if (str[length] == 0) return length; length++; } 

Serve para equalizar o endereço da linha carregada, o endereço é necessário em múltiplos de 16 para que a maioria das instruções SSE funcione. Para a instrução pcmpistri usada por nós, o alinhamento não é estritamente necessário, uma exceção de acesso não será lançada.
Intrínseco
 __m128i data = _mm_load_si128((__m128i*)(str + length)); if ((res = _mm_cmpistri(z128, data, _SIDD_CMP_EQUAL_EACH)) != 16) 

Compilado em:
 pcmpistri xmm1,xmmword ptr [eax+edx],8 

No entanto, o alinhamento com 16 também é útil no nosso caso, pois fornece um pequeno aumento na velocidade e, portanto, temos certeza de que um ciclo de leitura de 16 bytes não irá para uma página potencialmente não alocada ( página de memória 4K ).

Ciclo:
 while (str[length]) length++; 

"Obtém" o tamanho da string se seu fim for detectado (já que não tenho certeza absoluta sobre o algoritmo de operação _mm_cmpistri).
excluído após o comentário picul , que deu um aumento em pequenas linhas.

Fizemos o strlen mais rápido? - Infelizmente, não, os caras de https://www.strchr.com/sse2_optimised_strlen fizeram ainda mais rápido e sem usar o SSE4.2.

Tabela 2 - Tempo de execução do benchmark strlen em segundos (versão)
Número de caracteresComparação de bytes do MSMs strlenSSE 4.2SSE 2
10, x860,150,120,120,125
1K, x869.03.91,651,42
10, x640,150,110,080,1
1K, x648,52.31.61,32

Conclusões:

Parece-me que o MS sempre precisa chamar strlen de biblioteca e não fazer comparações de bytes de substituição.

UPD
Teste x64 adicionado.
Removido o último loop no strlen_SSE4

Source: https://habr.com/ru/post/pt467699/


All Articles