Comment ne pas faire le strlen le plus rapide et trouver une faille dans la communauté Visual Studio 2019

J'ai été invité par un article sur l'utilisation de l'instruction popcount «étrange» dans les processeurs modernes . Il ne s'agit pas de compter le nombre d'unités, mais de détecter un signe de la fin d'une ligne C (une ligne terminée par un caractère nul ).
Une chaîne terminée par un caractère nul est un moyen de représenter des chaînes dans des langages de programmation, dans lequel au lieu d'introduire un type de chaîne spécial, un tableau de caractères est utilisé, et la fin de la chaîne est le premier caractère nul spécial rencontré (NUL à partir du code ASCII, avec une valeur de 0).

Pour déterminer la durée d'un tel terme, une fonction standard

size_t __cdecl strlen(char const* str) 

Dont l'algorithme de fonctionnement peut être décrit en C comme:

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

Voyons ce que le compilateur de communauté MS Visual Studio 2019 (Release, x86) en fait:

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

Autrement dit, un octet est chargé à partir de la mémoire et comparé à zéro. Le même code est substitué aux appels strlen si vous générez le projet dans Release, l'algorithme est correct, mais la vitesse, il me semble, n'est pas suffisante. Que se passe-t-il si vous compilez le code avec un appel à strlen standard dans Debug? - La fonction de bibliothèque strlen sera appelée, comme prévu, mais écrite manuellement par une personne sur l'assembleur.

Spoiler de code de la fonction strlen standard dans 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 


Tableau 1 - durée d'exécution du benchmark strlen en secondes (communauté MS VS 2019, version C ++ cl: 19.22.27905)

Gros bloc, 1KBig block, 1K, * appel strlenPetit bloc, 10 élémentsPetit bloc, 10 éléments, * appel strlen
Débogage, x867.257.253.063.06
Version, x869.03.90,150,12
Débogage, x646.06.03.43.4
Version, x648.52.30,150,11

* forcer le compilateur à appeler la fonction de bibliothèque strlen

Ainsi, nous pouvons conclure que la substitution de la comparaison d'octets par le compilateur MS VS est inefficace même sur de petites chaînes, et sur de grandes chaînes, Debug est en avance sur Release!

Code de banc
 #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); 
compile
Déboguer dans: appeler la fonction de bibliothèque strlen;
Release in: comparaison d'octets.

Si vous le commentez et l'écrivez

 len_result += strlen_func(str_for_test); 



 auto strlen_func = strlen; 

nous forcerons le compilateur à toujours appeler la fonction de bibliothèque.

En raison de quoi l'accélération de la fonction de bibliothèque a été obtenue avant la comparaison d'octets de 2,3 fois (pour Release, x86, 1k)?

En raison de la comparaison non pas d'un octet, mais immédiatement de 4. Toute la magie ici:

 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 

Est-il possible de faire plus rapidement en utilisant les instructions vectorielles des processeurs modernes? Essayons.

En utilisant le guide Intel Intrinsics , nous trouvons le _mm_cmpistri SSE4.2 intrinsèque, conçu uniquement pour travailler avec des chaînes. Deux vecteurs de longueur 128 bits et un masque d'opérations sont fournis à l'entrée. Comme masque, nous utilisons: _SIDD_UBYTE_OPS = 0 - type de données, _SIDD_CMP_EQUAL_EACH = 8 - opération d'alignement d'octets, et nous comparerons avec un vecteur zéro. La valeur renvoyée sera le nombre de premiers éléments inégaux par paire (c'est-à-dire que si l'élément correspond de gauche à droite, le décompte s'arrête, je serai heureux si quelqu'un confirme le comportement).

 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; 

Code

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

Sert à égaliser l'adresse de la ligne chargée, l'adresse est nécessaire en multiples de 16 pour que la plupart des instructions SSE fonctionnent. Pour l'instruction pcmpistri que nous utilisons, l'alignement n'est strictement pas nécessaire, une exception d'accès ne sera pas levée.
Intrinsèque
 __m128i data = _mm_load_si128((__m128i*)(str + length)); if ((res = _mm_cmpistri(z128, data, _SIDD_CMP_EQUAL_EACH)) != 16) 

Compilé en:
 pcmpistri xmm1,xmmword ptr [eax+edx],8 

Cependant, l'alignement sur 16 est également utile dans notre cas, il donne une petite augmentation de la vitesse, et nous sommes donc sûrs qu'un cycle de lecture de 16 octets n'ira pas sur une page potentiellement non allouée ( page de mémoire 4K ).

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

"Obtient" la taille de la chaîne si sa fin est détectée (car je ne suis pas complètement sûr de l'algorithme de fonctionnement _mm_cmpistri).
supprimé après un commentaire picul , ce qui a donné une augmentation sur les petites lignes.

Avons-nous fait le strlen le plus rapide? - Malheureusement, non, les gars de https://www.strchr.com/sse2_optimised_strlen ont fait encore plus rapidement et sans utiliser SSE4.2.

Tableau 2 - durée d'exécution du benchmark strlen en secondes (Release)
Nombre de caractèresComparaison d'octets MSMme 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

Conclusions:

Il me semble que MS a toujours besoin d'appeler la bibliothèque strlen, et non de faire des comparaisons d'octets de substitution.

UPD
Test x64 ajouté.
Dernière boucle supprimée dans strlen_SSE4

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


All Articles