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 optimisée de glibc C. (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'arbre git glibc actuel. Apparemment, il est toujours utilisé par quelques cibles de la glibc, dont MIPS. (Merci @zwol).
Sur les ISA populaires comme x86 et ARM, la glibc utilise un asm écrit à la main
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 naïve d'octet à la fois, 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 horloge cycle dans la boucle principale si les données sont chaudes dans le cache L1d sur les processeurs modernes avec une charge vectorielle 2 / horloge et un débit ALU, c'est-à-dire pour les chaînes de taille moyenne où la surcharge de démarrage ne domine pas.)
glibc utilise des astuces de liaison dynamique pour se résoudre strlen
à une version optimale pour votre CPU, donc même dans x86, il existe une version SSE2 (vecteurs 16 octets, ligne de base pour x86-64) et une 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 celle utilisant AdvSIMD , et une version pour les processeurs AArch64 où vector-> GP enregistre bloque le pipeline, donc il utilise réellement 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 est-il 6.5x plus lent avec des optimisations activées? a plus de détails sur ce qui est rapide par rapport à lent dans asm x86 strlen
avec un grand tampon et une implémentation asm simple qui pourrait être bonne pour gcc pour savoir comment s'aligner. (Certaines versions de gcc sont imprudemment en ligne, rep scasb
ce qui est très lent, ou un bithack de 4 octets à la fois comme celui-ci. La recette en ligne de GCC 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 après 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 oblige les compilateurs à créer pour une implémentation autonome non 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 toute 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).
Pourquoi c'est sûr dans le cadre de la glibc mais pas autrement.
Le facteur le plus important est que cela strlen
ne peut s'aligner sur rien d'autre. Ce n'est pas sûr pour ça; il contient UB à alias strict (lecture des char
données via un unsigned long*
). char*
est autorisé à alias autre chose mais l'inverse n'est pas vrai .
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 n'a qu'à compiler avec GCC. Apparemment, il n'est pas pris en charge pour le compiler avec clang ou ICC, même s'ils prennent en charge les extensions GNU. 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 soit en ligne au moment de la compilation, les octets en mémoire ne sont que des octets en mémoire. c'est-à-dire que l'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 les unes dans les autres.
N'oubliez pas que son strlen
comportement est défini par la norme ISO C. Ce nom de fonction fait spécifiquement partie de l'implémentation. Les compilateurs comme GCC traitent même le nom comme une fonction intégrée à moins que vous ne l'utilisiez -fno-builtin-strlen
, donc strlen("foo")
peut être une constante de temps de compilation 3
. La définition de la bibliothèque est uniquement utilisée lorsque décide gcc d'émettre effectivement un appel à elle au lieu de inline sa propre recette ou quelque chose.
Lorsque UB n'est pas visible par 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 le souhaitez , 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 la mémoire pointée.
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.-à libc.a
-d. ne participera pas à l' -flto
optimisation du temps de liaison dans le programme principal.) Construire la glibc de cette façon serait potentiellement dangereux pour les cibles qui l'utilisent réellement.c
.
En fait, comme le commente @zwol, LTO ne peut pas être utilisé lors de la construction de la glibc elle - même , en raison 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, peut-être dans le cadre de la printf
mise en œuvre)
Cela strlen
fait certaines 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 de prologue non aligné exécutera toujours 0 itérations si sizeof(long) = sizeof(char) = 1
parce que 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.
- (peut-être)
unsigned long
est de 4 ou 8 octets. Ou peut-être que cela fonctionnerait pour n'importe quelle taille 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 fait (ou faisait) partie de l'implémentation C sur les plateformes où il fonctionne, donc ça va.
L'hypothèse suivante est le potentiel C UB:
- Une charge alignée qui contient des octets valides ne peut pas être mise en défaut et est sûre tant que vous ignorez les octets en dehors de l'objet que vous voulez réellement. (Vrai dans asm sur tous les systèmes GNU et sur tous les processeurs normaux car la protection de la mémoire se produit avec une granularité de page alignée. Est-il sûr de lire après la fin d'un tampon dans la même page sur x86 et x64? Sûr en C lorsque l'UB n'est pas visible au moment de la compilation. Sans inlining, c'est le cas ici. Le compilateur ne peut pas prouver que la lecture après le premier
0
est UB; il pourrait s'agir d'un char[]
tableau C 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 s'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 l'ancienne memcpy
macro CPP non sécurisée du noyau Linux qui utilisait le casting de pointeurs vers unsigned long
( gcc, alias strict et histoires d'horreur ).
Cela 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[]
transtypage en a 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.
Écrire ceci en toute sécurité, sans UB à alias strict
L' attribut de type GCCmay_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
pour que vous puissiez toujours le faire en toute sécurité _mm_loadu_si128( (__m128i*)foo )
. (Voir «reinterpret_cast» entre le pointeur vectoriel matériel et le type correspondant est-il 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;
Un moyen portable d'exprimer une charge d'alias en ISO est avecmemcpy
lequel les compilateurs modernes savent comment s'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 chargements non alignés, car il memcpy
fonctionne comme char
un accès à la fois. Mais dans la pratique, les compilateurs modernes comprennent memcpy
très bien.
Le danger ici est que si GCC ne sait pas avec certitude qu'il char_ptr
est aligné sur un mot, 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 mot (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 utiliser
p = __builtin_assume_aligned(p, sizeof(unsigned long));
Cela n'évite pas l'UB possible lu après l'objet, mais avec le GCC actuel ce n'est pas dangereux dans la pratique.
Pourquoi une source C optimisée à la main est nécessaire: les compilateurs actuels ne sont pas assez bons
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 contre Bithack C sans fonctionnalités spécifiques à ISA.
(Je pense que nous pouvons le considérer comme une donnée strlen
suffisamment utilisée pour qu'il soit aussi rapide que possible de l'exécuter. La question devient donc de savoir si nous pouvons obtenir un code machine efficace à partir d'une source plus simple. Non, nous ne pouvons 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 d' exécuter la première itération.) Par exemple, l'autovectorisation de memcpy est possible (tampon de longueur explicite) mais pas strcpy ou strlen (chaîne de longueur implicite), étant donné le courant compilateurs.
Cela inclut les boucles de recherche ou toute autre boucle avec if()break
un compteur 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 simple / naïf strlen
comme la libc d'OpenBSD. ( Godbolt ). (De la réponse de @ Peske ).
Une libc optimisée à la main strlen
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, les masques AVX512VL + BW se comparent en un masque et / ktest
ou kortest
pourraient rendre l' strlen
hyperthreading plus convivial 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 strlen
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.