306 votes

Pourquoi le strlen de la glibc doit-il être si compliqué pour fonctionner rapidement ?

Je regardais dans le strlen code ici et je me demandais si les optimisations utilisées dans le code sont vraiment nécessaires ? Par exemple, pourquoi quelque chose comme ce qui suit 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;
}

Un code plus simple n'est-il pas meilleur et/ou plus facile à optimiser par 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 (tege@sics.se),
   with help from Dan Sahlin (dan@sics.se);
   commentary by Jim Blandy (jimb@ai.mit.edu).

   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 ?

245voto

Antti Haapala Points 11542

Vous Ne le fais pas. besoin et vous ne devrait jamais écrire du code comme ça - surtout si vous n'êtes pas un vendeur de compilateur C / bibliothèque standard. C'est du code utilisé pour implémenter strlen avec quelques bidouillages de vitesse et hypothèses très discutables (qui ne sont pas testées avec des affirmations ou mentionnées dans les commentaires) :

  • unsigned long est soit 4 soit 8 octets
  • les octets sont de 8 bits
  • un pointeur peut être converti en unsigned long long et non uintptr_t
  • on peut aligner le pointeur en vérifiant simplement que les 2 ou 3 bits d'ordre inférieur sont nuls.
  • on peut accéder à une chaîne de caractères comme unsigned long s
  • on peut lire au-delà de la fin du tableau sans aucun effet négatif.

De plus, un bon compilateur pourrait même remplacer le code écrit en tant que

size_t stupid_strlen(const char s[]) {
    size_t i;
    for (i=0; s[i] != '\0'; i++)
        ;
    return i;
}

(remarquez qu'il doit être un type compatible avec size_t ) avec une version inlined du compilateur builtin strlen ou vectoriser le code, mais il est peu probable qu'un compilateur puisse optimiser la version complexe.


Le site strlen est décrite par C11 7.24.6.3 comme :

Description

  1. Le site strlen calcule la longueur de la chaîne de caractères pointée par s.

Renvoie à

  1. Le site strlen La fonction renvoie le nombre de caractères qui précèdent le caractère nul final.

Maintenant, si la chaîne pointée par s se trouvait dans un tableau de caractères juste assez long pour contenir la chaîne de caractères et le NUL de fin de chaîne. 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, en réalité, le uniquement dans un langage C entièrement portable et conforme aux normes pour mettre en œuvre cette correctement est la façon dont elle est écrite dans votre question sauf pour les transformations triviales - vous pouvez prétendre être plus rapide en déroulant la boucle, etc. un octet à la fois.

(Comme les commentateurs l'ont souligné, lorsque la portabilité stricte est un trop lourd fardeau, tirer parti des hypothèses raisonnables ou sûres n'est pas toujours une mauvaise chose. Surtout dans le code qui est partie de une implémentation C spécifique. Mais vous devez comprendre les règles avant de savoir comment/quand vous pouvez les contourner).


Le lien strlen vérifie d'abord les octets individuellement jusqu'à ce que le pointeur pointe sur la limite d'alignement naturelle de 4 ou 8 octets de l'objet unsigned long . La norme C stipule que l'accès à un pointeur qui n'est pas correctement aligné a comportement indéfini Il faut donc absolument le faire pour que le prochain mauvais coup soit encore plus mauvais. (En pratique, sur certaines architectures de CPU autres que x86, un mot mal aligné ou un chargement de double-mot fera défaut. C est pas un langage assembleur 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 d'erreur sur les implémentations où la protection de la mémoire fonctionne en blocs alignés (par exemple, les pages de mémoire virtuelle de 4kiB).

Maintenant vient la partie sale : le code rupture la promesse et lit 4 ou 8 octets de 8 bits à la fois (un long int ), et utilise un truc binaire avec une addition non signée pour déterminer rapidement s'il y a eu tout zéro octet à l'intérieur de ces 4 ou 8 octets - il utilise un nombre spécialement conçu pour que le bit de report modifie les bits qui sont bloqués par un masque binaire. En substance, cela permet de déterminer si l'un des 4 ou 8 octets du masque est un zéro. plus rapide que de boucler sur chacun de ces octets. Enfin, il y a une boucle à la fin pour calculer dont était le premier zéro, le cas échéant, et de retourner le résultat.

Le plus gros problème est qu'en sizeof (unsigned long) - 1 fois sur sizeof (unsigned long) Dans certains cas, il lira au-delà de la fin de la chaîne - uniquement si l'octet nul se trouve dans la chaîne de caractères. dernier l'octet auquel on accède (c'est-à-dire le plus significatif en little-endian et le moins significatif en big-endian), est-il pas accéder au tableau en dehors des limites !


Le code, même s'il est utilisé pour mettre en œuvre strlen dans une bibliothèque standard C est mauvais code. Il contient plusieurs aspects définis et non définis par l'implémentation et ne doit pas être utilisé. partout au lieu de l'option fournie par le système strlen - J'ai renommé la fonction en the_strlen ici et ajouté ce qui suit main :

int main(void) {
    char buf[12];
    printf("%zu\n", the_strlen(fgets(buf, 12, stdin)));
}

Le tampon est soigneusement dimensionné de manière à pouvoir contenir exactement les 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 exécuter 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 sont arrivées.

182voto

Peter Cordes Points 1375

Il y a eu beaucoup de suppositions (légèrement ou entièrement) erronées dans les commentaires sur certains détails / le contexte de ce projet.

Vous êtes en train de regarder l'implémentation optimisée du fallback C de la glibc. (Pour les ISA qui n'ont pas d'implémentation asm écrite à la main) . Ou une ancienne version de ce code, qui se trouve toujours dans l'arbre source de la glibc. https://code.woboq.org/userspace/glibc/string/strlen.c.html est un navigateur de code basé sur l'arbre git actuel de la glibc. Apparemment, il est encore utilisé par quelques cibles de la glibc courante, dont MIPS. (Merci @zwol).

Sur les ISA populaires comme x86 et ARM, glibc utilise des asm écrits à la main.

L'intérêt de changer quoi que ce soit à ce code est donc 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/ordinateur portable/smartphone. C'est mieux qu'une boucle naïve d'octet par octet, mais même ce bithack est assez mauvais comparé à l'asm efficace pour les CPU modernes (en particulier x86 où AVX2 SIMD permet de vérifier 32 octets avec quelques instructions, permettant 32 à 64 octets par cycle d'horloge dans la boucle principale si les données sont chaudes dans le cache L1d sur les CPU modernes avec une charge vectorielle et un débit ALU de 2/clock. c'est-à-dire pour des chaînes de taille moyenne où l'overhead de démarrage ne domine pas).

La glibc utilise des astuces de liaison dynamique pour résoudre strlen à une version optimale pour votre processeur, donc même dans x86 il y a une Version SSE2 (vecteurs de 16 octets, base de référence pour x86-64) et une Version AVX2 (vecteurs de 32 octets).

Le x86 dispose d'un transfert de données efficace entre les registres vectoriels et les registres à usage général, ce qui le rend particulièrement adapté à l'utilisation de la SIMD pour accélérer les fonctions sur des chaînes de longueur implicite où le contrôle de la boucle dépend des données. pcmpeqb / pmovmskb permet de tester 16 octets distincts à la fois.

La glibc possède une version AArch64 comme celle-ci en utilisant AdvSIMD et une version pour les processeurs AArch64 où les registres vectoriels->GP bloquent le pipeline, de sorte qu'il n'y a pas d'erreur. utiliser réellement ce bithack . Mais utilise le comptage de zéros en tête pour trouver l'octet dans le registre une fois qu'il obtient un résultat, et profite de l'efficacité des accès non alignés de AArch64 après avoir vérifié le passage de page.

Voir aussi : Pourquoi ce code est-il 6,5 fois plus lent avec les optimisations activées ? a plus de détails sur ce qui est rapide ou lent dans l'asm x86 pour strlen avec un grand tampon et une implémentation asm simple qu'il serait bon que gcc sache comment mettre en ligne. (Certaines versions de gcc ont imprudemment mis en ligne rep scasb ce qui est très lent, ou un bithack de 4 octets à la fois comme ceci. Donc la recette inline-strlen de GCC doit être mise à jour ou désactivée).

L'Asm n'a pas de "comportement indéfini" à la C. il est possible d'accéder aux octets de la mémoire comme on le souhaite, et un chargement aligné qui inclut n'importe quel octet valide ne peut pas faire défaut. La protection de la mémoire se fait avec une granularité de page alignée ; les accès alignés plus étroits ne peuvent pas traverser une frontière de page. Est-il possible 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 fait créer par les compilateurs pour une implémentation non-inline autonome de cette fonction.

Lorsqu'un compilateur émet du code pour appeler une fonction non-inline inconnue, il doit supposer que cette fonction modifie toutes les variables globales et toute la mémoire sur laquelle elle pourrait éventuellement avoir un pointeur. En d'autres termes, tout, sauf les locales dont l'adresse n'a pas été échappée, doit être synchronisé en mémoire lors de l'appel. Ceci s'applique aux fonctions écrites en asm, évidemment, mais aussi aux fonctions des bibliothèques. Si vous n'activez pas l'optimisation au moment de la liaison, cela s'applique même aux unités de traduction séparées (fichiers sources).


Pourquoi c'est sûr dans le cadre de la glibc mais pas autrement.

Le facteur le plus important est que cette strlen ne peut pas s'insérer dans un autre élément. Ce n'est pas sûr pour ça ; ça contient UB à crénelage strict (lecture char données par le biais d'un unsigned long* ). char* est autorisé à aliaser toute autre chose mais l'inverse est pas vrai .

Il s'agit d'une fonction de bibliothèque pour une bibliothèque compilée en avance sur le temps (glibc). Il ne sera pas intégré dans les appelants avec une optimisation du temps de liaison. 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 doit seulement être compilée avec GCC. Apparemment, c'est non supporté pour le compiler avec clang ou ICC, même s'ils supportent les extensions GNU. GCC est un compilateur en avance sur le temps qui transforme un fichier source C en un fichier objet de code machine. Il ne s'agit pas d'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. Autrement dit, l'aliasing strict d'UB n'est pas dangereux lorsque les accès de types différents se produisent dans des fonctions différentes qui ne s'alignent pas les unes sur les autres.

Rappelez-vous que strlen Le comportement de la personne est défini par la norme ISO C. Le nom de cette fonction est précisément partie de la mise en œuvre. Les compilateurs comme GCC traitent même le nom comme une fonction intégrée, à moins que vous n'utilisiez la fonction -fno-builtin-strlen donc strlen("foo") peut être une constante de temps de compilation 3 . La définition dans la bibliothèque est uniquement utilisé lorsque gcc décide d'émettre un appel à cette fonction au lieu de mettre en ligne sa propre recette ou autre.

Quand l'UB n'est pas visible au compilateur au moment de la compilation, vous obtenez un code machine sain. Le code machine doit fonctionner pour le cas no-UB, et même si vous recherché il n'y a aucun moyen pour l'asm de détecter quels types l'appelant a utilisé pour mettre des données dans la mémoire pointée.

La glibc est compilée en une bibliothèque statique ou dynamique autonome qui ne peut pas être intégrée dans un programme avec une optimisation au moment de la liaison. Les scripts de compilation de la glibc ne créent pas de "grosses" bibliothèques statiques contenant le code machine + la représentation interne GIMPLE de gcc pour l'optimisation au moment de la liaison lors de l'intégration dans un programme. (c'est-à-dire libc.a ne participera pas à -flto l'optimisation du temps de liaison dans le programme principal). Construire la glibc de cette façon serait potentiellement dangereux. sur les cibles qui utilisent réellement cette .c .

En fait, comme le commente @zwol, LTO ne peut pas être utilisé lors de la construction de la glibc. lui-même Il n'a pas été possible d'utiliser le code de la glibc, à cause d'un code "fragile" comme celui-ci, qui pourrait se briser si l'inlining entre les fichiers sources de la glibc était possible. (Il y a quelques utilisations internes de strlen par exemple, peut-être dans le cadre de l'initiative de l'Union européenne en faveur de l'environnement. printf mise en œuvre)


Ce site 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 prologue non alignée effectuera toujours 0 itération si la boucle est ouverte. sizeof(long) = sizeof(char) = 1 car chaque pointeur est toujours aligné et p & sizeof(long)-1 est toujours égal à zéro). Mais si vous aviez un jeu de caractères non-ASCII où les caractères ont une largeur de 9 ou 12 bits, 0x8080... n'est pas le bon modèle.
  • (peut-être) unsigned long est de 4 ou 8 octets. Ou peut-être que cela fonctionnerait pour n'importe quelle taille d'octets. unsigned long jusqu'à 8, et il utilise un assert() pour vérifier cela.

Ces deux-là ne sont pas des UB possibles, c'est juste de la non-portabilité pour certaines implémentations C. Ce code est (ou était) partie de l'implémentation C sur les plateformes où elle fonctionne, donc c'est bien.

L'hypothèse suivante est le potentiel C UB :

  • Un chargement aligné qui contient des octets valides ne peut pas être défectueux. et est sûr tant que vous ignorez les octets en dehors de l'objet que vous voulez réellement. (Vrai en asm sur tous les systèmes GNU, et sur tous les processeurs normaux parce que la protection de la mémoire se fait avec une granularité de page alignée. Est-il possible de lire au-delà de 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 au-delà de la première ligne de l'équation 0 est UB ; il pourrait s'agir d'un C char[] contenant {1,2,0,3} par exemple)

C'est ce dernier point qui permet de lire sans risque la fin d'un objet C ici. C'est à peu près sûr même en inlining avec les compilateurs actuels parce que je pense qu'ils ne traitent pas actuellement ce qui implique qu'un chemin d'exécution est inaccessible. Mais de toute façon, l'aliasing strict est déjà un obstacle si vous laissez cela en ligne.

Alors vous auriez des problèmes comme l'ancien système non sécurisé du noyau Linux. memcpy macro CPP qui utilisait l'encodage par pointeur pour unsigned long ( gcc, strict-aliasing, et histoires d'horreur ). (Linux moderne compile avec -fno-strict-aliasing au lieu de faire attention à may_alias attributs.)

Ce site strlen date de l'époque où l'on pouvait s'en tirer avec ce genre de choses en général. Avant GCC3, c'était pratiquement sans danger, même sans l'avertissement "seulement quand on ne fait pas d'inlining".


L'UB qui n'est visible que lorsque l'on regarde au-delà des limites de l'appel et du retour ne peut pas nous faire de mal. (par exemple, appeler ceci sur un char buf[] plutôt que sur un tableau de unsigned long[] à un const char* ). Une fois que le code machine est figé, il ne s'agit plus que de traiter des octets en mémoire. Un appel de fonction non inline doit supposer que le destinataire lit tout ou partie de la mémoire.


Écrire ceci en toute sécurité, sans anticrénelage strict UB

Le site Attribut de type GCC may_alias donne à un type le même traitement d'alias que celui accordé à char* . (Proposé par @KonradBorowsk). Les en-têtes de GCC l'utilisent actuellement pour les types vectoriels SIMD x86 tels que __m128i afin que vous puissiez toujours faire en toute sécurité _mm_loadu_si128( (__m128i*)foo ) . (Voir Le `reinterpret_cast` entre le pointeur vectoriel SIMD 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;

  // handle unaligned startup somehow, e.g. check for page crossing then check an unaligned word
  // else check single bytes until an alignment boundary.
  aliasing_ulong *longword_ptr = (aliasing_ulong *)char_ptr;

  for (;;) {
     // alignment still required, but can safely alias anything including a char[]
     unsigned long ulong = *longword_ptr++;

     ...
  }
}

Vous pouvez utiliser aligned(1) pour exprimer un type avec alignof(T) = 1 .
typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong; . Cela pourrait être utile pour la partie non alignée de strlen, si vous ne faites pas simplement un caractère à la fois jusqu'à la première limite d'alignement. (La boucle principale doit être alignée pour que vous ne soyez pas en faute si le terminateur est juste avant une page non alignée).

Une manière portable d'exprimer une charge d'aliasing en ISO est avec memcpy ce que les compilateurs modernes savent faire en tant qu'instruction de chargement unique, comme 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 travaille en tant que tel par char -à la fois. Mais en pratique, les compilateurs modernes comprennent memcpy très bien.

Le danger ici est que si le GCC ne connaître à coup sûr que char_ptr est aligné sur un mot, il ne le mettra pas en ligne sur certaines plates-formes qui ne supportent pas les chargements non alignés en asm. Par exemple, MIPS avant MIPS64r6, ou les anciens ARM. Si vous avez 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 vous pourriez utiliser
p = __builtin_assume_aligned(p, sizeof(unsigned long));

Cela n'évite pas le possible UB read-past-the-object, mais avec la GCC actuelle, ce n'est pas dangereux en pratique.


Pourquoi le code source C optimisé à la main est nécessaire : les compilateurs actuels ne sont pas assez performants

L'asm optimisé manuellement peut être encore meilleur lorsque vous voulez obtenir la moindre performance pour une fonction de bibliothèque standard largement utilisée. En particulier pour quelque chose comme memcpy mais aussi strlen . Dans ce cas, il ne serait pas beaucoup plus facile d'utiliser le C avec des intrinsèques x86 pour profiter de SSE2.

Mais ici, nous ne parlons que d'une version C naïve vs. bithack sans aucune caractéristique spécifique à l'ISA.

(Je pense que l'on peut considérer comme acquis que strlen est suffisamment utilisé pour qu'il soit important de le faire fonctionner aussi rapidement que possible. La question est donc de savoir si nous pouvons obtenir un code machine efficace à partir d'une source plus simple. Non, nous ne le pouvons pas).

Les GCC et clang actuels ne sont pas capables d'auto-vectoriser 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 que la boucle effectuera au moins 16 itérations). avant Exécuter la première itération.) Par exemple, memcpy autovectorisé est possible (tampon de longueur explicite) mais pas strcpy ou strlen (chaîne de longueur implicite), étant donné les compilateurs actuels.

Cela inclut les boucles de recherche, ou toute autre boucle dont le fonctionnement dépend des données. if()break ainsi qu'un compteur.

ICC (le compilateur d'Intel pour x86) peut auto-vectoriser certaines boucles de recherche, mais ne fait toujours qu'un asm naïf par octet 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 d'un octet à la fois (avec un déroulement de peut-être 2 octets par cycle sur les CPU superscalaires larges) est pathétique quand la mémoire principale peut suivre avec environ 8 octets par cycle, et le cache L1d peut fournir 16 à 64 par cycle. (2 charges de 32 octets par cycle sur les CPU x86 modernes depuis Haswell et Ryzen. Sans compter AVX512 qui peut réduire les vitesses d'horloge juste pour l'utilisation de vecteurs de 512 bits ; c'est pourquoi la glibc n'est probablement pas pressée d'ajouter une version AVX512. Bien qu'avec des vecteurs de 256 bits, AVX512VL + BW masquent la comparaison dans un masque et ktest ou kortest pourrait faire strlen plus favorable à l'hyperthreading en réduisant ses uops / itération).

J'inclus ici les non-x86, c'est-à-dire les "16 octets". Par exemple, la plupart des CPU AArch64 peuvent faire au moins cela, je pense, et certains certainement plus. Et certains ont un débit d'exécution suffisant pour strlen pour suivre cette bande passante.

Bien sûr, les programmes qui travaillent avec de grandes chaînes de caractères devraient généralement garder la trace des longueurs pour éviter d'avoir à refaire très souvent la recherche de la longueur des chaînes de caractères C à longueur implicite. Mais les performances des chaînes de longueur courte à moyenne bénéficient toujours des implémentations écrites à la main, et je suis sûr que certains programmes finissent par utiliser strlen sur des chaînes de longueur moyenne.

66voto

Timothy Jones Points 10760

Cela est expliqué dans les commentaires du fichier que vous avez mis en lien :

 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 à travers les caractères individuels à la recherche d'un null que de tester plus d'un octet à la fois, comme le fait ce code.

La complexité supplémentaire vient de la nécessité de s'assurer que la chaîne testée est alignée au bon endroit pour commencer à tester plus d'un octet à la fois (le long d'une frontière de mot long, comme décrit dans les commentaires), et de la nécessité de s'assurer que les hypothèses sur les tailles des types de données ne sont pas violées lorsque le code est utilisé.

Sur le plus Dans la plupart des cas (mais pas tous) de développement de logiciels modernes, cette attention aux détails de l'efficacité n'est pas nécessaire, ou ne vaut pas le coût de la complexité supplémentaire du code.

Un endroit où il est judicieux de prêter attention à l'efficacité de ce genre est dans les bibliothèques standard, comme l'exemple que vous avez lié.


Si vous voulez en savoir plus sur les limites des mots, voir cette question et cette excellente page wikipedia


Je pense aussi que cette réponse ci-dessus est une discussion beaucoup plus claire et détaillée.

40voto

Peschke Points 503

En plus des excellentes réponses données ici, je tiens à souligner que le code lié à la question concerne l'implémentation de GNU de la fonction strlen .

Le site 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 repli pour les ISAs qui n'ont pas leur propre implémentation asm. Il existe différentes implémentations de strlen selon l'architecture. Le code pour amd64 strlen par exemple, est asm. Similaire aux commentaires de PeterCordes/ réponse en soulignant que les implémentations GNU nonallback sont également asm.

37voto

xfix Points 2890

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 du code comme celui-ci, à moins que vous n'écriviez une bibliothèque standard et que vous puissiez dépendre d'un compilateur spécifique. Plus précisément, il s'agit de traiter l'alignement du nombre d'octets en même temps - 4 sur les plateformes 32 bits, 8 sur les plateformes 64 bits. Cela signifie qu'il peut être 4 ou 8 fois plus rapide que l'itération naïve d'octets.

Pour expliquer comment cela fonctionne, considérez l'image suivante. Supposons qu'il s'agisse d'une plate-forme 32 bits (alignement de 4 octets).

Disons que la lettre "H" de la chaîne "Hello, world !" a été fournie en tant qu'argument de la commande 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 la méthode lente.

Ensuite, pour chaque morceau de la taille d'un alignement, en calculant (longbits - 0x01010101) & 0x80808080 != 0 il vérifie si l'un des octets d'un nombre entier est égal à zéro. Ce calcul a un faux positif lorsqu'au moins un des octets est supérieur à 0x80 mais le plus souvent, cela devrait fonctionner. Si ce n'est pas le cas (comme c'est le cas dans la zone jaune), la longueur est augmentée de la taille de l'alignement.

Si l'un des octets d'un nombre entier s'avère être zéro (ou 0x81 ), la chaîne est alors vérifiée octet par octet pour déterminer la position du zéro.

Il peut s'agir d'un accès hors limites, mais comme il se situe dans un alignement, il est plus probable qu'il soit correct, car les unités de mappage de la mémoire n'ont généralement pas la précision d'un octet.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X