Je regardais le code strlen
ici et je me demandais si les optimisations utilisées dans le code étaient vraiment nécessaires? Par exemple, pourquoi quelque chose comme le suivant ne fonctionnerait-il pas aussi bien ou mieux?
unsigned long strlen(char s[]) {
unsigned long i;
for (i = 0; s[i] != '\0'; i++)
continue;
return i;
}
Le code plus simple n'est-il pas meilleur et/ou plus facile à optimiser pour le compilateur?
Le code de strlen
sur la page derrière le lien ressemble à ceci:
/* Copyright (C) 1991, 1993, 1997, 2000, 2003 Free Software Foundation, Inc. This file is part of the GNU C Library. Written by Torbjorn Granlund ([email protected]), with help from Dan Sahlin ([email protected]); commentary by Jim Blandy ([email protected]). The GNU C Library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. The GNU C Library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with the GNU C Library; if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA. */ #include <string.h> #include <stdlib.h> #undef strlen /* Return the length of the null-terminated string STR. Scan for the null terminator quickly by testing four bytes at a time. */ size_t strlen (str) const char *str; { const char *char_ptr; const unsigned long int *longword_ptr; unsigned long int longword, magic_bits, himagic, lomagic; /* Handle the first few characters by reading one character at a time. Do this until CHAR_PTR is aligned on a longword boundary. */ for (char_ptr = str; ((unsigned long int) char_ptr & (sizeof (longword) - 1)) != 0; ++char_ptr) if (*char_ptr == '\0') return char_ptr - str; /* All these elucidatory comments refer to 4-byte longwords, but the theory applies equally well to 8-byte longwords. */ longword_ptr = (unsigned long int *) char_ptr; /* Bits 31, 24, 16, and 8 of this number are zero. Call these bits the "holes." Note that there is a hole just to the left of each byte, with an extra at the end: bits: 01111110 11111110 11111110 11111111 bytes: AAAAAAAA BBBBBBBB CCCCCCCC DDDDDDDD The 1-bits make sure that carries propagate to the next 0-bit. The 0-bits provide holes for carries to fall into. */ magic_bits = 0x7efefeffL; himagic = 0x80808080L; lomagic = 0x01010101L; if (sizeof (longword) > 4) { /* 64-bit version of the magic. */ /* Do the shift in two steps to avoid a warning if long has 32 bits. */ magic_bits = ((0x7efefefeL << 16) << 16) | 0xfefefeffL; himagic = ((himagic << 16) << 16) | himagic; lomagic = ((lomagic << 16) << 16) | lomagic; } if (sizeof (longword) > 8) abort (); /* Instead of the traditional loop which tests each character, we will test a longword at a time. The tricky part is testing if *any of the four* bytes in the longword in question are zero. */ for (;;) { /* We tentatively exit the loop if adding MAGIC_BITS to LONGWORD fails to change any of the hole bits of LONGWORD. 1) Is this safe? Will it catch all the zero bytes? Suppose there is a byte with all zeros. Any carry bits propagating from its left will fall into the hole at its least significant bit and stop. Since there will be no carry from its most significant bit, the LSB of the byte to the left will be unchanged, and the zero will be detected. 2) Is this worthwhile? Will it ignore everything except zero bytes? Suppose every byte of LONGWORD has a bit set somewhere. There will be a carry into bit 8. If bit 8 is set, this will carry into bit 16. If bit 8 is clear, one of bits 9-15 must be set, so there will be a carry into bit 16. Similarly, there will be a carry into bit 24. If one of bits 24-30 is set, there will be a carry into bit 31, so all of the hole bits will be changed. The one misfire occurs when bits 24-30 are clear and bit 31 is set; in this case, the hole at bit 31 is not changed. If we had access to the processor carry flag, we could close this loophole by putting the fourth hole at bit 32! So it ignores everything except 128's, when they're aligned properly. */ longword = *longword_ptr++; if ( #if 0 /* Add MAGIC_BITS to LONGWORD. */ (((longword + magic_bits) /* Set those bits that were unchanged by the addition. */ ^ ~longword) /* Look at only the hole bits. If any of the hole bits are unchanged, most likely one of the bytes was a zero. */ & ~magic_bits) #else ((longword - lomagic) & himagic) #endif != 0) { /* Which of the bytes was the zero? If none of them were, it was a misfire; continue the search. */ const char *cp = (const char *) (longword_ptr - 1); if (cp[0] == 0) return cp - str; if (cp[1] == 0) return cp - str + 1; if (cp[2] == 0) return cp - str + 2; if (cp[3] == 0) return cp - str + 3; if (sizeof (longword) > 4) { if (cp[4] == 0) return cp - str + 4; if (cp[5] == 0) return cp - str + 5; if (cp[6] == 0) return cp - str + 6; if (cp[7] == 0) return cp - str + 7; } } } } libc_hidden_builtin_def (strlen)
Pourquoi cette version fonctionne-t-elle rapidement?
Ne fait-il pas beaucoup de travail inutile?
Vous n'avez pas besoin et vous ne devriez jamais écrire du code comme ça - surtout si vous n'êtes pas un compilateur C/fournisseur de bibliothèque standard. C'est du code utilisé pour implémenter strlen
avec des hacks et hypothèses de vitesse très discutables (qui ne sont pas testés avec des assertions ou mentionnés dans les commentaires):
unsigned long
Est de 4 ou 8 octetsunsigned long long
et non en uintptr_t
unsigned long
sDe plus, un bon compilateur pourrait même remplacer le code écrit comme
size_t stupid_strlen(const char s[]) {
size_t i;
for (i=0; s[i] != '\0'; i++)
;
return i;
}
(notez qu'il doit s'agir d'un type compatible avec size_t
) avec une version en ligne du compilateur intégré strlen
, ou vectorisez le code; mais il est peu probable qu'un compilateur soit en mesure d'optimiser la version complexe.
La fonction strlen
est décrite par C11 7.24.6. comme:
Description
- La fonction
strlen
calcule la longueur de la chaîne pointée par s.Renvoie
- La fonction
strlen
renvoie le nombre de caractères qui précèdent le caractère nul final.
Maintenant, si la chaîne pointée par s
était dans un tableau de caractères juste assez long pour contenir la chaîne et la terminaison NUL, le comportement sera indéfini si nous accédons à la chaîne après le terminateur nul, par exemple dans
char *str = "hello world"; // or
char array[] = "hello world";
Donc vraiment la manière seulement en C entièrement portable/conforme aux normes pour l'implémenter correctement est la façon dont il est écrit votre question , à l'exception des transformations triviales - vous pouvez faire semblant d'être plus rapide en déroulant la boucle, etc., mais cela doit encore être fait un byte à la fois.
(Comme l'ont souligné les commentateurs, lorsque la portabilité stricte est trop lourde, tirer parti d'hypothèses raisonnables ou sûres n'est pas toujours une mauvaise chose. Surtout dans le code qui est une partie de un spécifique Implémentation C. Mais vous devez comprendre les règles avant de savoir comment/quand vous pouvez les plier.)
L'implémentation strlen
liée vérifie d'abord les octets individuellement jusqu'à ce que le pointeur pointe vers la limite d'alignement naturelle de 4 ou 8 octets du unsigned long
. La norme C dit que l'accès à un pointeur qui n'est pas correctement aligné a un comportement indéfini , donc cela doit absolument être fait pour que le prochain truc sale soit pair plus sale. (En pratique sur certaines architectures de CPU autres que x86, un chargement de mot ou de double mot mal aligné fera défaut. C est pas un langage d'assemblage portable, mais ce code l'utilise de cette façon). C'est aussi ce qui permet de lire au-delà de la fin d'un objet sans risque de défaut sur les implémentations où la protection de la mémoire fonctionne en blocs alignés (ex: pages de mémoire virtuelle de 4 Ko).
Vient maintenant la partie sale: le code casse la promesse et lit 4 ou 8 octets de 8 bits à la fois (un long int
), Et utilise une astuce avec ajout non signé pour déterminer rapidement s'il y avait n'importe quel zéro octet dans ces 4 ou 8 octets - il utilise un nombre spécialement conçu pour que le bit de report change les bits capturés par un masque de bits. Essentiellement, cela permettrait de déterminer si l'un des 4 ou 8 octets du masque est censé être des zéros plus rapide que de parcourir chacun de ces octets. Enfin, il y a une boucle à la fin pour comprendre lequel octet était le premier zéro, le cas échéant, et pour retourner le résultat.
Le plus gros problème est que dans sizeof (unsigned long) - 1
fois sur sizeof (unsigned long)
cas, il lira après la fin de la chaîne - uniquement si l'octet nul est dans le dernier octet accédé (c'est-à-dire en petit-boutien le plus significatif, et en gros-boutien le moins significatif), est-ce pas accéder au tableau hors limites!
Le code, même s'il est utilisé pour implémenter strlen
dans une bibliothèque standard C est mauvais code. Il comporte plusieurs aspects définis et non définis par l'implémentation et ne doit pas être utilisé n'importe où au lieu du strlen
- I fourni par le système renommé la fonction en the_strlen
ici et ajouté le main
suivant:
int main(void) {
char buf[12];
printf("%zu\n", the_strlen(fgets(buf, 12, stdin)));
}
Le tampon est soigneusement dimensionné afin qu'il puisse contenir exactement la chaîne hello world
Et le terminateur. Cependant, sur mon processeur 64 bits, le unsigned long
Est de 8 octets, donc l'accès à cette dernière partie dépasserait ce tampon.
Si je compile maintenant avec -fsanitize=undefined
Et -fsanitize=address
Et que j'exécute le programme résultant, j'obtiens:
% ./a.out
hello world
=================================================================
==8355==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffffe63a3f8 at pc 0x55fbec46ab6c bp 0x7ffffe63a350 sp 0x7ffffe63a340
READ of size 8 at 0x7ffffe63a3f8 thread T0
#0 0x55fbec46ab6b in the_strlen (.../a.out+0x1b6b)
#1 0x55fbec46b139 in main (.../a.out+0x2139)
#2 0x7f4f0848fb96 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21b96)
#3 0x55fbec46a949 in _start (.../a.out+0x1949)
Address 0x7ffffe63a3f8 is located in stack of thread T0 at offset 40 in frame
#0 0x55fbec46b07c in main (.../a.out+0x207c)
This frame has 1 object(s):
[32, 44) 'buf' <== Memory access at offset 40 partially overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism or swapcontext
(longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (.../a.out+0x1b6b) in the_strlen
Shadow bytes around the buggy address:
0x10007fcbf420: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf430: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf440: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf450: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf460: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10007fcbf470: 00 00 00 00 00 00 00 00 00 00 f1 f1 f1 f1 00[04]
0x10007fcbf480: f2 f2 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf490: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf4a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf4b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x10007fcbf4c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==8355==ABORTING
c'est-à-dire que de mauvaises choses se sont produites.
Il y a eu beaucoup de suppositions erronées (légèrement ou entièrement) dans les commentaires à propos de certains détails/antécédents pour cela.
Vous regardez l'implémentation optimisée de secours C optimisée de la glibc. (Pour les ISA qui n'ont pas d'implémentation asm manuscrite) . Ou une ancienne version de ce code, qui est toujours dans l'arborescence des sources de la glibc. https://code.woboq.org/userspace/glibc/string/strlen.c.html est un navigateur de code basé sur l'arborescence git glibc actuelle. Apparemment, il est toujours utilisé par quelques cibles de la glibc, dont MIPS. (Merci @zwol).
Ainsi, l'incitation à changer quoi que ce soit à propos de ce code est plus faible que vous ne le pensez.
Ce code bithack ( https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord ) n'est pas ce qui fonctionne réellement sur votre serveur/ordinateur de bureau/ordinateur portable/smartphone. C'est mieux qu'une boucle d'octet à la fois naïve, mais même ce bithack est assez mauvais par rapport à un asm efficace pour les processeurs modernes (en particulier x86 où AVX2 SIMD permet de vérifier 32 octets avec quelques instructions, autorisant 32 à 64 octets par cycle d'horloge dans la boucle principale si les données sont chaudes dans le cache L1d sur les processeurs modernes avec une charge vectorielle de 2 heures et un débit ALU, c'est-à-dire pour des chaînes de taille moyenne où les frais généraux de démarrage ne dominent pas.)
la glibc utilise des astuces de liaison dynamique pour résoudre strlen
en une version optimale pour votre CPU, donc même dans x86 il y a version SSE2 (vecteurs de 16 octets, ligne de base pour x86-64) et un version AVX2 (vecteurs 32 octets).
x86 offre un transfert de données efficace entre les registres vectoriels et les registres à usage général, ce qui le rend particulièrement utile pour utiliser SIMD pour accélérer les fonctions sur les chaînes de longueur implicite où le contrôle de boucle dépend des données. pcmpeqb
/pmovmskb
permet de tester 16 octets distincts à la fois.
glibc a une version AArch64 comme ça en utilisant AdvSIMD , et une version pour les processeurs AArch64 où vector-> GP enregistre bloque le pipeline, donc il tilise en fait ce bithack . Mais utilise count-leader-zeros pour trouver l'octet dans le registre une fois qu'il obtient un coup, et profite des accès non alignés efficaces d'AArch64 après avoir vérifié le croisement de page.
Également lié: Pourquoi ce code 6.5x est-il plus lent avec des optimisations activées? a plus de détails sur ce qui est rapide par rapport à lent dans x86 asm pour strlen
avec un grand tampon et une implémentation asm simple cela pourrait être bon pour gcc de savoir comment se connecter. (Certaines versions de gcc intègrent imprudemment rep scasb
, Ce qui est très lent, ou un bithack de 4 octets à la fois comme celui-ci. La recette de GCC en ligne doit donc être mise à jour ou désactivée.)
Asm n'a pas de "comportement indéfini" de style C ; il est sûr d'accéder aux octets en mémoire comme vous le souhaitez, et une charge alignée qui inclut tous les octets valides ne peut pas faire défaut. La protection de la mémoire se produit avec la granularité des pages alignées; les accès alignés plus étroits que cela ne peuvent pas traverser une limite de page. Est-il sûr de lire au-delà de la fin d'un tampon dans la même page sur x86 et x64? Le même raisonnement s'applique au code machine que ce hack C obtient des compilateurs pour créer pour un non autonome -implémentation en ligne de cette fonction.
Lorsqu'un compilateur émet du code pour appeler une fonction non en ligne inconnue, il doit supposer que la fonction modifie toutes les variables globales et la mémoire vers laquelle il peut éventuellement avoir un pointeur. c'est-à-dire que tout sauf les locaux qui n'ont pas eu leur adresse d'échappement doivent être synchronisés en mémoire pendant l'appel. Cela s'applique aux fonctions écrites en asm, évidemment, mais aussi aux fonctions de bibliothèque. Si vous n'activez pas l'optimisation du temps de liaison, elle s'applique même à des unités de traduction distinctes (fichiers source).
Le facteur le plus important est que ce strlen
ne peut pas s'aligner sur autre chose. Ce n'est pas sûr pour ça; il contient UB à alias strict (lecture des données char
via un unsigned long*
). char*
Est autorisé à alias n'importe quoi d'autre mais l'inverse est pas true .
Il s'agit d'une fonction de bibliothèque pour une bibliothèque compilée à l'avance (glibc). Il ne sera pas intégré à l'optimisation du temps de liaison dans les appelants. Cela signifie qu'il suffit de compiler en code machine sûr pour une version autonome de strlen
. Il n'a pas besoin d'être portable/sûr C.
La bibliothèque GNU C ne doit être compilée qu'avec GCC. Apparemment, c'est non pris en charge pour la compiler avec clang ou ICC, même si elles prennent en charge GNU extensions. GCC est un compilateur avancé qui transforme un fichier source C en un fichier objet de code machine. Pas un interprète, donc à moins qu'il ne s'aligne au moment de la compilation, les octets en mémoire ne sont que des octets en mémoire. UB à alias strict n'est pas dangereux lorsque les accès avec différents types se produisent dans différentes fonctions qui ne s'alignent pas.
N'oubliez pas que le comportement de strlen
est défini par la norme ISO C. Ce nom de fonction est spécifiquement une partie de l'implémentation. Les compilateurs comme GCC traitent même le nom comme une fonction intégrée à moins que vous n'utilisiez -fno-builtin-strlen
, Donc strlen("foo")
peut être une constante de compilation 3
. La définition dans la bibliothèque est seulement utilisée lorsque gcc décide de lui envoyer un appel au lieu d'inclure sa propre recette ou quelque chose.
Lorsque UB n'est pas visible pour le compilateur au moment de la compilation, vous obtenez un code machine sain. Le code machine doit fonctionner pour le cas sans UB, et même si vous voulait à, il n'y a aucun moyen pour l'asm de détecter les types que l'appelant a utilisés pour mettre des données dans le pointé vers Mémoire.
Glibc est compilé dans une bibliothèque statique ou dynamique autonome qui ne peut pas s'aligner avec l'optimisation du temps de liaison. Les scripts de construction de glibc ne créent pas de bibliothèques statiques "grosses" contenant du code machine + une représentation interne gcc GIMPLE pour une optimisation du temps de liaison lors de l'intégration dans un programme. (c'est-à-dire que libc.a
ne participera pas à l'optimisation du temps de liaison de -flto
dans le programme principal.) Construire la glibc de cette façon serait potentiellement dangereux sur les cibles qui utilisent réellement ceci .c
.
En fait, comme le commente @zwol, LTO ne peut pas être utilisé lors de la construction de glibc lui-même, à cause d'un code "fragile" comme celui-ci qui pourrait se casser s'il était possible d'aligner entre les fichiers source de la glibc. (Il existe des utilisations internes de strlen
, par exemple dans le cadre de l'implémentation de printf
)
Ce strlen
fait quelques hypothèses:
CHAR_BIT
Est un multiple de 8 . Vrai sur tous les systèmes GNU. POSIX 2001 garantit même CHAR_BIT == 8
. (Cela semble sûr pour les systèmes avec CHAR_BIT= 16
Ou 32
, Comme certains DSP) ; la boucle non alignée-prologue exécutera toujours 0 itérations si sizeof(long) = sizeof(char) = 1
car chaque pointeur est toujours aligné et p & sizeof(long)-1
est toujours nul.) Mais si vous aviez un jeu de caractères non ASCII où les caractères sont 9 ou 12 bits de large, 0x8080...
est le mauvais modèle.unsigned long
est de 4 ou 8 octets. Ou peut-être que cela fonctionnerait pour n'importe quelle taille de unsigned long
Jusqu'à 8, et il utilise un assert()
pour vérifier cela.Ces deux ne sont pas possibles UB, ils sont simplement non portables vers certaines implémentations C. Ce code est (ou était) une partie de l'implémentation C sur les plates-formes où il fonctionne, donc ça va.
L'hypothèse suivante est le potentiel C UB:
0
est UB; il peut s'agir d'un tableau C char[]
contenant {1,2,0,3}
par exemple)Ce dernier point est ce qui permet de lire en toute sécurité après la fin d'un objet C ici. C'est à peu près sûr même en alignant avec les compilateurs actuels parce que je pense qu'ils ne traitent pas actuellement qu'impliquer un chemin d'exécution est inaccessible. Mais de toute façon, l'aliasing strict est déjà un incontournable si jamais vous laissez cela en ligne.
Ensuite, vous auriez des problèmes comme le vieux noyau non sécurisé du noyau Linux memcpy
macro CPP qui utilisait la conversion de pointeur en unsigned long
( gcc, strict- aliasing, et histoires d'horreur ).
Ce strlen
remonte à l'époque où l'on pouvait s'en tirer avec des trucs comme ça en général ; il était à peu près sûr sans la mise en garde "seulement quand il n'est pas inclus" avant GCC3.
UB qui n'est visible que lorsque vous regardez à travers les limites des appels/ret ne peut pas nous blesser. (par exemple, appeler ceci sur un char buf[]
au lieu d'un tableau de unsigned long[]
transtypé en un const char*
). Une fois que le code machine est gravé dans la pierre, il ne s'agit que d'octets en mémoire. Un appel de fonction non en ligne doit supposer que l'appelé lit tout/tout la mémoire.
Le attribut de type GCC may_alias
donne à un type le même traitement d'alias-n'importe quoi que char*
. (Suggéré par @KonradBorowsk). Les en-têtes GCC l'utilisent actuellement pour les types de vecteurs SIMD x86 comme __m128i
Afin que vous puissiez toujours faire en toute sécurité _mm_loadu_si128( (__m128i*)foo )
. (Voir Est-ce que `reinterpret_cast` entre le pointeur de vecteur matériel et le type correspondant est un comportement non défini? pour plus de détails sur ce que cela signifie et ne signifie pas.)
strlen(const char *char_ptr)
{
typedef unsigned long __attribute__((may_alias)) aliasing_ulong;
aliasing_ulong *longword_ptr = (aliasing_ulong *)char_ptr;
for (;;) {
unsigned long ulong = *longword_ptr++; // can safely alias anything
...
}
}
Vous pouvez également utiliser aligned(1)
pour exprimer un type avec alignof(T) = 1
.typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;
Une façon portable d'exprimer une charge d'alias en ISO est avec memcpy
, que les compilateurs modernes savent comment aligner en tant qu'instruction de chargement unique . par exemple.
unsigned long longword;
memcpy(&longword, char_ptr, sizeof(longword));
char_ptr += sizeof(longword);
Cela fonctionne également pour les charges non alignées car memcpy
fonctionne comme si par char
à la fois. Mais dans la pratique, les compilateurs modernes comprennent très bien memcpy
.
Le danger ici est que si GCC ne fait pas - savoir sûr que char_ptr
Est aligné sur Word, il ne l'inline pas sur certaines plates-formes qui pourraient ne pas prendre en charge les charges non alignées dans asm. par exemple. MIPS avant MIPS64r6, ou ARM plus ancien. Si vous receviez un appel de fonction réel à memcpy
juste pour charger un Word (et le laisser dans une autre mémoire), ce serait un désastre. GCC peut parfois voir quand le code aligne un pointeur. Ou après la boucle char-at-a-time qui atteint une limite ulong que vous pouvez utiliserp = __builtin_assume_aligned(p, sizeof(unsigned long));
Cela n'évite pas l'UB possible de lecture après l'objet, mais avec le GCC actuel ce n'est pas dangereux dans la pratique.
L'asm optimisé à la main peut être encore meilleur lorsque vous voulez chaque dernière baisse de performances pour une fonction de bibliothèque standard largement utilisée. Surtout pour quelque chose comme memcpy
, mais aussi strlen
. Dans ce cas, il ne serait pas beaucoup plus facile d'utiliser C avec des intrinsèques x86 pour tirer parti de SSE2.
Mais ici, nous ne parlons que d'une version naïve vs bithack C sans fonctionnalités spécifiques à ISA.
(Je pense que nous pouvons considérer que strlen
est suffisamment utilisé pour qu'il soit exécuté aussi rapidement que possible. La question devient donc de savoir si nous pouvons obtenir un code machine efficace à partir d'une source plus simple. Non, nous ne peut pas.)
GCC et clang actuels ne sont pas capables de vectoriser automatiquement les boucles dont le nombre d'itérations n'est pas connu avant la première itération . (par exemple, il doit être possible de vérifier si la boucle exécutera au moins 16 itérations avant en exécutant la première itération.) l'autovectorisation de memcpy est possible (tampon de longueur explicite) mais pas strcpy ou strlen (chaîne de longueur implicite), compte tenu des compilateurs actuels.
Cela inclut les boucles de recherche, ou toute autre boucle avec un if()break
dépendant des données ainsi qu'un compteur.
ICC (le compilateur d'Intel pour x86) peut vectoriser automatiquement certaines boucles de recherche, mais ne crée toujours qu'un asm naïf octet à la fois pour un C strlen
simple/naïf comme la libc d'OpenBSD. ( Godbolt ). (De @ réponse de Peske ).
Une libc strlen
optimisée à la main est nécessaire pour les performances avec les compilateurs actuels . Aller 1 octet à la fois (avec le déroulement peut-être 2 octets par cycle sur les CPU superscalaires larges) est pathétique lorsque la mémoire principale peut suivre environ 8 octets par cycle, et le cache L1d peut fournir 16 à 64 par cycle. (2 charges de 32 octets par cycle sur les processeurs x86 grand public modernes depuis Haswell et Ryzen. Sans compter l'AVX512 qui peut réduire les vitesses d'horloge uniquement pour l'utilisation de vecteurs 512 bits; c'est pourquoi la glibc n'est probablement pas pressée d'ajouter une version AVX512 . Bien qu'avec les vecteurs 256 bits, AVX512VL + BW masqué se compare en masque et ktest
ou kortest
pourrait rendre strlen
plus hyperthreading en réduisant son uops/itération.)
J'inclus non-x86 ici, c'est les "16 octets". par exemple. la plupart des processeurs AArch64 peuvent faire au moins cela, je pense, et certains certainement plus. Et certains ont un débit d'exécution suffisant pour que strlen
puisse suivre cette bande passante de charge.
Bien sûr, les programmes qui fonctionnent avec de grandes chaînes doivent généralement garder une trace des longueurs pour éviter d'avoir à refaire très souvent la recherche de la longueur des chaînes C de longueur implicite. Mais les performances de courte à moyenne longueur bénéficient toujours d'implémentations écrites à la main, et je suis sûr que certains programmes finissent par utiliser strlen sur des chaînes de longueur moyenne.
Cela est expliqué dans les commentaires du fichier que vous avez lié:
27 /* Return the length of the null-terminated string STR. Scan for
28 the null terminator quickly by testing four bytes at a time. */
et:
73 /* Instead of the traditional loop which tests each character,
74 we will test a longword at a time. The tricky part is testing
75 if *any of the four* bytes in the longword in question are zero. */
En C, il est possible de raisonner en détail sur l'efficacité.
Il est moins efficace d'itérer des caractères individuels à la recherche d'une valeur nulle que de tester plus d'un octet à la fois, comme le fait ce code.
La complexité supplémentaire vient du fait de devoir s'assurer que la chaîne sous test est alignée au bon endroit pour commencer à tester plus d'un octet à la fois (le long d'une limite de mot long, comme décrit dans les commentaires), et de devoir s'assurer que les hypothèses sur les tailles des types de données ne sont pas violés lorsque le code est utilisé.
Dans la plupart (mais pas tous) du développement logiciel moderne, cette attention aux détails d'efficacité n'est pas nécessaire, ou ne vaut pas le coût d'une complexité de code supplémentaire.
Un endroit où il est logique de prêter attention à l'efficacité comme celui-ci est dans les bibliothèques standard, comme l'exemple que vous avez lié.
Si vous voulez en savoir plus sur les limites de Word, voir cette question , et cette excellente page wikipedia
En plus des bonnes réponses ici, je tiens à souligner que le code lié dans la question est pour l'implémentation GNU de strlen
.
implémentation OpenBSD de strlen
est très similaire au code proposé dans la question. La complexité d'une implémentation est déterminée par l'auteur.
...
#include <string.h>
size_t
strlen(const char *str)
{
const char *s;
for (s = str; *s; ++s)
;
return (s - str);
}
DEF_STRONG(strlen);
[~ # ~] edit [~ # ~] : Le code OpenBSD que j'ai lié ci-dessus semble être une implémentation de secours pour les ISA qui n'en ont pas propre implémentation asm. Il existe différentes implémentations de strlen
selon l'architecture. Le code pour AMD64 strlen
, par exemple, est asm. Semblable aux commentaires de PeterCordes/ réponse soulignant que les implémentations non-fallback GNU sont également asm.
En bref, il s'agit d'une optimisation des performances que la bibliothèque standard peut faire en sachant avec quel compilateur elle est compilée - vous ne devriez pas écrire de code comme celui-ci, sauf si vous écrivez une bibliothèque standard et peut dépendre d'un compilateur spécifique. Plus précisément, il traite le nombre d'alignement d'octets en même temps - 4 sur les plates-formes 32 bits, 8 sur les plates-formes 64 bits. Cela signifie qu'il peut être 4 ou 8 fois plus rapide que l'itération d'octets naïfs.
Pour expliquer comment cela fonctionne, considérez l'image suivante. Supposons ici la plate-forme 32 bits (alignement sur 4 octets).
Disons que la lettre "H" de "Hello, world!" une chaîne a été fournie comme argument pour strlen
. Parce que le CPU aime avoir des choses alignées en mémoire (idéalement, address % sizeof(size_t) == 0
), les octets avant l'alignement sont traités octet par octet, en utilisant une méthode lente.
Ensuite, pour chaque bloc de taille d'alignement, en calculant (longbits - 0x01010101) & 0x80808080 != 0
il vérifie si l'un des octets d'un entier est nul. Ce calcul a un faux positif quand au moins un des octets est supérieur à 0x80
, mais le plus souvent cela devrait fonctionner. Si ce n'est pas le cas (comme dans la zone jaune), la longueur est augmentée par la taille de l'alignement.
Si l'un des octets d'un entier s'avère être nul (ou 0x81
), la chaîne est vérifiée octet par octet pour déterminer la position de zéro.
Cela peut rendre un accès hors limites, mais comme il se trouve dans un alignement, il est plus probable qu'improbable d'être correct, les unités de mappage de mémoire n'ont généralement pas une précision au niveau des octets.
Vous voulez que le code soit correct, maintenable et rapide. Ces facteurs ont une importance différente:
"correct" est absolument essentiel.
"maintenable" dépend de combien vous allez maintenir le code: strlen est une fonction de bibliothèque C standard depuis plus de 40 ans. Ça ne va pas changer. La maintenabilité est donc tout à fait sans importance - pour cette fonction.
"Rapide": Dans de nombreuses applications, strcpy, strlen etc. utilisent une quantité importante de temps d'exécution. Pour obtenir le même gain de vitesse global que cette implémentation compliquée, mais pas très compliquée de strlen en améliorant le compilateur, il faudrait des efforts héroïques.
Être rapide a un autre avantage: lorsque les programmeurs découvrent que l'appel à "strlen" est la méthode la plus rapide, ils peuvent mesurer le nombre d'octets dans une chaîne, ils ne sont plus tentés d'écrire leur propre code pour accélérer les choses.
Donc, pour strlen, la vitesse est beaucoup plus importante et la maintenabilité beaucoup moins importante que pour la plupart des codes que vous écrirez jamais.
Pourquoi cela doit-il être si compliqué? Supposons que vous ayez une chaîne de 1 000 octets. L'implémentation simple examinera 1 000 octets. Une implémentation actuelle examinerait probablement des mots de 64 bits à la fois, ce qui signifie 125 mots de 64 bits ou de huit octets. Il pourrait même utiliser des instructions vectorielles examinant disons 32 octets à la fois, ce qui serait encore plus compliqué et encore plus rapide. L'utilisation d'instructions vectorielles conduit à un code un peu plus compliqué mais assez simple, vérifier si l'un des huit octets dans un mot 64 bits est nul nécessite quelques astuces intelligentes. Ainsi, pour les chaînes moyennes à longues, ce code devrait être environ quatre fois plus rapide. Pour une fonction aussi importante que strlen, cela vaut la peine d'écrire une fonction plus complexe.
PS. Le code n'est pas très portable. Mais cela fait partie de la bibliothèque Standard C, qui fait partie de l'implémentation - elle n'a pas besoin d'être portable.
PPS. Quelqu'un a publié un exemple où un outil de débogage s'est plaint d'accéder aux octets après la fin d'une chaîne. Une implémentation peut être conçue qui garantit ce qui suit: Si p est un pointeur valide vers un octet, alors tout accès à un octet dans le même bloc aligné qui serait un comportement indéfini selon la norme C, retournera une valeur non spécifiée.
PPPS. Intel a ajouté des instructions à ses processeurs ultérieurs qui forment un bloc de construction pour la fonction strstr () (recherche d'une sous-chaîne dans une chaîne). Leur description est ahurissante, mais ils peuvent rendre cette fonction particulière probablement 100 fois plus rapide. (Fondamentalement, étant donné un tableau a contenant "Hello, world!" Et un tableau b commençant par 16 octets "HelloHelloHelloH" et contenant plus d'octets, il comprend que la chaîne a n'apparaît pas dans b plus tôt qu'à partir de l'index 15) .
En bref: la vérification d'une chaîne octet par octet sera potentiellement lente sur les architectures qui peuvent extraire de plus grandes quantités de données à la fois.
Si la vérification de la terminaison nulle peut être effectuée sur une base 32 ou 64 bits, elle réduit le nombre de vérifications que le compilateur doit effectuer. C'est ce que le code lié tente de faire, avec un système spécifique à l'esprit. Ils font des hypothèses sur l'adressage, l'alignement, l'utilisation du cache, les configurations de compilateur non standard, etc., etc.
La lecture octet par octet comme dans votre exemple serait une approche judicieuse sur un processeur 8 bits, ou lors de l'écriture d'une bibliothèque portable écrite en standard C.
Regarder les bibliothèques C standard pour savoir comment écrire du code rapide/bon n'est pas une bonne idée, car il sera non portable et reposera sur des hypothèses non standard ou un comportement mal défini. Si vous êtes débutant, la lecture d'un tel code sera probablement plus nocive qu'éducative.