web-dev-qa-db-fra.com

Valeurs booléennes de 8 bits dans les compilateurs. Les opérations sur eux sont-elles inefficaces?

Je lis " Optimisation des logiciels en C++ " d'Agner Fog (spécifique aux processeurs x86 pour Intel, AMD et VIA) et il indique à la page 34

Les variables booléennes sont stockées sous forme d'entiers de 8 bits avec la valeur 0 pour faux et 1 pour vrai. Les variables booléennes sont surdéterminées dans le sens où tous les opérateurs qui ont des variables booléennes en entrée vérifient si les entrées ont une autre valeur que 0 ou 1, mais les opérateurs qui ont des booléens en sortie ne peuvent produire aucune autre valeur que 0 ou 1. Cela rend les opérations avec Variables booléennes en entrée moins efficaces que nécessaire.

Est-ce toujours vrai aujourd'hui et sur quels compilateurs? Pouvez-vous donner un exemple? L'auteur déclare

Les opérations booléennes peuvent être rendues beaucoup plus efficaces si l'on sait avec certitude que les opérandes n'ont pas d'autres valeurs que 0 et 1. La raison pour laquelle le compilateur ne fait pas une telle hypothèse est que les variables peuvent avoir d'autres valeurs si elles sont non initialisé ou provenir de sources inconnues.

Est-ce à dire que si je prends un pointeur de fonction bool(*)() par exemple et que je l'appelle, alors les opérations sur celui-ci produisent du code inefficace? Ou est-ce le cas lorsque j'accède à un booléen en déréférençant un pointeur ou en lisant une référence et que j'opère ensuite dessus?

46

TL: DR : les compilateurs actuels ont encore bool optimisations manquées lorsqu'ils font des choses comme
(a&&b) ? x : y. Mais la raison pour laquelle est pas qu'ils ne supposent pas 0/1, ils sont juste nul.

De nombreuses utilisations de bool sont pour les fonctions locales ou en ligne, donc la booléenne à un 0/1 Peut optimiser loin et se ramifier (ou cmov ou autre) sur la condition d'origine. Ne vous inquiétez que d'optimiser les entrées/sorties bool quand elles doivent être passées/renvoyées sur quelque chose qui n'est pas en ligne, ou vraiment stocké en mémoire.

Directive d'optimisation possible : combinez bools à partir de sources externes (fonction args/mémoire) avec des opérateurs au niveau du bit, comme a&b. MSVC et ICC font mieux avec cela. IDK si c'est encore pire pour les bools locaux. Attention, a&b N'est équivalent qu'à a&&b Pour bool, pas pour les types entiers. 2 && 1 Est vrai, mais 2 & 1 Vaut 0, ce qui est faux. Bitwise OR n'a pas ce problème.

IDK si cette directive fera un jour mal aux sections locales qui ont été définies à partir d'une comparaison au sein de la fonction (ou dans quelque chose qui est en ligne). Par exemple. cela pourrait amener le compilateur à réellement faire des booléens entiers au lieu d'utiliser simplement les résultats de comparaison directement lorsque cela est possible. Notez également que cela ne semble pas aider avec gcc et clang actuels.


Oui, les implémentations C++ sur x86 stockent bool dans un octet qui est toujours 0 ou 1 (au moins à travers les limites des appels de fonction où le compilateur doit respecter la convention ABI/appel qui l'exige.)

Les compilateurs en profitent parfois, par exemple pour bool-> int la conversion même gcc 4.4 s'étend simplement à 32 bits (movzx eax, dil). Clang et MSVC le font aussi. Les règles C et C++ nécessitent que cette conversion produise 0 ou 1, donc ce comportement n'est sûr que si c'est toujours sûr de supposer qu'une fonction arg ou variable globale bool a un 0 ou 1 valeur.

Même les anciens compilateurs en profitaient généralement pour bool-> int, mais pas dans d'autres cas. Ainsi, Agner se trompe sur la raison quand il dit:

La raison pour laquelle le compilateur ne fait pas une telle hypothèse est que les variables peuvent avoir d'autres valeurs si elles ne sont pas initialisées ou proviennent de sources inconnues.


MSVC CL19 crée du code qui suppose que les arguments de la fonction bool sont 0 ou 1, donc l'ABI Windows x86-64 doit garantir cela.

Dans le x86-64 System V ABI (utilisé par tout autre que Windows), le journal des modifications pour la révision 0.98 dit "Spécifiez que _Bool (Aka bool) est booléenisé à l'appelant. " Je pense qu'avant même ce changement, les compilateurs l'assumaient, mais cela documente simplement ce sur quoi les compilateurs s'appuyaient déjà. La langue actuelle dans l'ABI SysV x86-64 est:

3.1.2 Représentation des données

Les booléens, lorsqu'ils sont stockés dans un objet mémoire, sont stockés en tant qu'objets à octet unique dont la valeur est toujours 0 (faux) ou 1 (vrai). Lorsqu'ils sont stockés dans des registres entiers (sauf pour passer comme arguments), les 8 octets du registre sont significatifs; toute valeur non nulle est considérée comme vraie.

La deuxième phrase est absurde: l'ABI n'a aucun intérêt à dire aux compilateurs comment stocker des choses dans des registres à l'intérieur d'une fonction, uniquement aux frontières entre différentes unités de compilation (arguments mémoire/fonction et valeurs de retour). J'ai signalé ce défaut ABI il y a quelque temps sur la page github où il est mainten .

3.2.3 Passage de paramètres :

Lorsqu'une valeur de type _Bool Est retournée ou passée dans un registre ou sur la pile, le bit 0 contient la valeur de vérité et les bits 1 à 7 doivent être nuls16.

(note de bas de page 16): les autres bits ne sont pas spécifiés, par conséquent le côté consommateur de ces valeurs peut compter sur 0 ou 1 lorsqu'ils sont tronqués à 8 bits.

La langue de l'i386 System V ABI est la même, IIRC.


Tout compilateur qui suppose 0/1 pour une chose (par exemple la conversion en int) mais ne parvient pas à en profiter dans d'autres cas a une optimisation manquée . Malheureusement, ces optimisations manquées existent toujours, bien qu'elles soient plus rares que lorsque Agner a écrit ce paragraphe sur les compilateurs toujours re-booleanizing.

(Source + asm sur Explorateur du compilateur Godbolt pour gcc4.6/4.7 et clang/MSVC. Voir aussi Matt Godbolt's CppCon2017 talk Qu'est-ce que mon compilateur a fait pour moi récemment? Déboulonner le couvercle du compilateur )

bool logical_or(bool a, bool b) { return a||b; }

 # gcc4.6.4 -O3 for the x86-64 System V ABI
    test    dil, dil            # test a against itself (for non-zero)
    mov     eax, 1
    cmove   eax, esi            # return   a ? 1 : b;
    ret

Donc même gcc4.6 n'a pas re-booléenisé b, mais il a raté l'optimisation que gcc4.7 fait: (et clang et les compilateurs ultérieurs comme montré dans les autres réponses):

    # gcc4.7 -O3 to present: looks ideal to me.
    mov     eax, esi
    or      eax, edi
    ret

(or dil, sil/mov eax, edi De Clang est idiot: il est garanti de provoquer un blocage de registre partiel sur Nehalem ou Intel antérieur lors de la lecture de edi après avoir écrit dil, et il a une taille de code pire que d'avoir besoin d'un préfixe REX pour utiliser la partie low-8 d'edi. ​​Un meilleur choix pourrait être or dil,sil/movzx eax, dil si vous voulez éviter lecture tous les registres 32 bits au cas où votre appelant aurait laissé des registres passant avec des registres partiels "sales".)

MSVC émet ce code qui vérifie a puis b séparément, ne réussissant pas à profiter de tout , et même en utilisant xor al,al Au lieu de xor eax,eax. Il a donc une fausse dépendance sur l'ancienne valeur de eax sur la plupart des CPU ( y compris Haswell/Skylake, qui ne renomme pas les regs partiels bas-8 séparément du registre entier, seulement AH/BH /... ). C'est tout simplement stupide. La seule raison d'utiliser jamais xor al,al Est lorsque vous souhaitez explicitement conserver les octets supérieurs.

logical_or PROC                     ; x86-64 MSVC CL19
    test     cl, cl                 ; Windows ABI passes args in ecx, edx
    jne      SHORT $LN3@logical_or
    test     dl, dl
    jne      SHORT $LN3@logical_or
    xor      al, al                 ; missed peephole: xor eax,eax is strictly better
    ret      0
$LN3@logical_or:
    mov      al, 1
    ret      0
logical_or ENDP

ICC18 ne profite pas non plus de la nature connue des entrées 0/1, il utilise simplement une instruction or pour définir des drapeaux en fonction du bit OR des deux entrées et setcc pour produire un 0/1.

logical_or(bool, bool):             # ICC18
    xor       eax, eax                                      #4.42
    movzx     edi, dil                                      #4.33
    movzx     esi, sil                                      #4.33
    or        edi, esi                                      #4.42
    setne     al                                            #4.42
    ret                                                     #4.42

ICC émet le même code même pour bool bitwise_or(bool a, bool b) { return a|b; }. Il promeut en int (avec movzx), et utilise or pour définir des drapeaux en fonction de l'opérateur OR au niveau du bit. C'est stupide par rapport à or dil,sil/setne al.

Pour bitwise_or, MSVC utilise simplement une instruction or (après movzx sur chaque entrée), mais de toute façon ne re-booleanise pas.


Optimisations manquées dans gcc/clang actuel:

Seul ICC/MSVC faisait du code stupide avec la fonction simple ci-dessus, mais cette fonction donne toujours des problèmes avec gcc et clang:

int select(bool a, bool b, int x, int y) {
    return (a&&b) ? x : y;
}

Source + asm sur l'explorateur du compilateur Godbolt (Même source, différents compilateurs sélectionnés par rapport à la dernière fois).

Semble assez simple; vous espérez qu'un compilateur intelligent le fasse sans branche avec un test/cmov. L'instruction test de x86 définit les indicateurs selon un ET au niveau du bit. Il s'agit d'une instruction AND qui n'écrit pas réellement la destination. (Tout comme cmp est un sub qui n'écrit pas la destination).

# hand-written implementation that no compilers come close to making
select:
    mov     eax, edx      # retval = x
    test    edi, esi      # ZF =  ((a & b) == 0)
    cmovz   eax, ecx      # conditional move: return y if ZF is set
    ret

Mais même les versions quotidiennes de gcc et clang sur l'explorateur du compilateur Godbolt rendent le code beaucoup plus compliqué, vérifiant chaque booléen séparément. Ils savent comment optimiser bool ab = a&&b; Si vous retournez ab, mais même l'écrire de cette façon (avec une variable booléenne distincte pour contenir le résultat) ne parvient pas à les tenir à la main pour créer du code ça ne craint pas.

Notez que test same,same Est exactement équivalent à cmp reg, 0 , et est plus petit, c'est donc ce que les compilateurs utilisent.

La version de Clang est strictement pire que ma version manuscrite. (Notez qu'il requiert que l'appelant étende à zéro les arguments bool à 32 bits, comme il le fait pour les types entiers étroits en tant que partie non officielle de l'ABI qu'il et gcc implémentent, mais seulement clang dépend de ).

select:  # clang 6.0 trunk 317877 nightly build on Godbolt
    test    esi, esi
    cmove   edx, ecx         # x = b ? y : x
    test    edi, edi
    cmove   edx, ecx         # x = a ? y : x
    mov     eax, edx         # return x
    ret

gcc 8.0.0 20171110 crée tous les soirs du code ramifié pour cela, similaire à ce que font les anciennes versions de gcc.

select(bool, bool, int, int):   # gcc 8.0.0-pre   20171110
    test    dil, dil
    mov     eax, edx          ; compiling with -mtune=intel or -mtune=haswell would keep test/jcc together for macro-fusion.
    je      .L8
    test    sil, sil
    je      .L8
    rep ret
.L8:
    mov     eax, ecx
    ret

MSVC x86-64 CL19 crée un code ramifié très similaire. Il cible la convention d'appel Windows, où les arguments entiers sont en rcx, rdx, r8, r9.

select PROC
        test     cl, cl         ; a
        je       SHORT $LN3@select
        mov      eax, r8d       ; retval = x
        test     dl, dl         ; b
        jne      SHORT $LN4@select
$LN3@select:
        mov      eax, r9d       ; retval = y
$LN4@select:
        ret      0              ; 0 means rsp += 0 after popping the return address, not C return 0.
                                ; MSVC doesn't emit the `ret imm16` opcode here, so IDK why they put an explicit 0 as an operand.
select ENDP

ICC18 crée également du code branché, mais avec les deux instructions mov après les branches.

select(bool, bool, int, int):
        test      dil, dil                                      #8.13
        je        ..B4.4        # Prob 50%                      #8.13
        test      sil, sil                                      #8.16
        jne       ..B4.5        # Prob 50%                      #8.16
..B4.4:                         # Preds ..B4.2 ..B4.1
        mov       edx, ecx                                      #8.13
..B4.5:                         # Preds ..B4.2 ..B4.4
        mov       eax, edx                                      #8.13
        ret                                                     #8.13

Essayer d'aider le compilateur en utilisant

int select2(bool a, bool b, int x, int y) {
    bool ab = a&&b;
    return (ab) ? x : y;
}

conduit MSVC à créer un code hilarant :

;; MSVC CL19  -Ox  = full optimization
select2 PROC
    test     cl, cl
    je       SHORT $LN3@select2
    test     dl, dl
    je       SHORT $LN3@select2
    mov      al, 1              ; ab = 1

    test     al, al             ;; and then test/cmov on an immediate constant!!!
    cmovne   r9d, r8d
    mov      eax, r9d
    ret      0
$LN3@select2:
    xor      al, al            ;; ab = 0

    test     al, al            ;; and then test/cmov on another path with known-constant condition.
    cmovne   r9d, r8d
    mov      eax, r9d
    ret      0
select2 ENDP

C'est uniquement avec MSVC (et ICC18 a la même optimisation manquée de test/cmov sur un registre qui vient d'être réglé sur une constante).

gcc et clang comme d'habitude ne rendent pas le code aussi mauvais que MSVC; ils font le même asm qu'ils font pour select(), ce qui n'est toujours pas bon mais au moins essayer de les aider ne fait pas empirer comme avec MSVC.


Combiner bool avec des opérateurs au niveau du bit aide MSVC et ICC

Dans mes tests très limités, | Et & Semblent mieux fonctionner que || Et && Pour MSVC et ICC. Regardez la sortie du compilateur pour votre propre code avec vos options de compilation + compilation pour voir ce qui se passe.

int select_bitand(bool a, bool b, int x, int y) {
    return (a&b) ? x : y;
}

Gcc se branche toujours séparément sur des tests séparés des deux entrées, même code que les autres versions de select. clang fait toujours deux test/cmov identiques, comme pour les autres versions sources.

MSVC intervient et s'optimise correctement, battant tous les autres compilateurs (au moins dans la définition autonome):

select_bitand PROC            ;; MSVC
    test     cl, dl           ;; ZF =  !(a & b)
    cmovne   r9d, r8d
    mov      eax, r9d         ;; could have done the mov to eax in parallel with the test, off the critical path, but close enough.
    ret      0

ICC18 gaspille deux instructions movzx étendant zéro les bools à int, mais crée ensuite le même code que MSVC

select_bitand:          ## ICC18
    movzx     edi, dil                                      #16.49
    movzx     esi, sil                                      #16.49
    test      edi, esi                                      #17.15
    cmovne    ecx, edx                                      #17.15
    mov       eax, ecx                                      #17.15
    ret                                                     #17.15
68
Peter Cordes

Je pense que ce n'est pas le cas.

Tout d'abord, ce raisonnement est totalement inacceptable:

La raison pour laquelle le compilateur ne fait pas une telle hypothèse est que les variables peuvent avoir d'autres valeurs si elles ne sont pas initialisées ou proviennent de sources inconnues.

Vérifions un peu de code (compilé avec clang 6, mais GCC 7 et MSVC 2017 produisent un code similaire).

booléen ou:

bool fn(bool a, bool b) {
    return a||b;
}

0000000000000000 <fn(bool, bool)>:
   0:   40 08 f7                or     dil,sil
   3:   40 88 f8                mov    al,dil
   6:   c3                      ret    

Comme on peut le voir, aucune vérification 0/1 ici, simple or.

Convertir bool en int:

int fn(bool a) {
    return a;
}

0000000000000000 <fn(bool)>:
   0:   40 0f b6 c7             movzx  eax,dil
   4:   c3                      ret    

Encore une fois, pas de chèque, simple mouvement.

Convertissez char en bool:

bool fn(char a) {
    return a;
}

0000000000000000 <fn(char)>:
   0:   40 84 ff                test   dil,dil
   3:   0f 95 c0                setne  al
   6:   c3                      ret    

Ici, char est vérifié s'il s'agit de 0 ou non, et la valeur bool définie sur 0 ou 1 en conséquence.

Je pense donc qu'il est sûr de dire que le compilateur utilise bool d'une certaine manière, il contient toujours un 0/1. Il ne vérifie jamais sa validité.

À propos de l'efficacité: je pense que bool est optimal. Le seul cas que je peux imaginer, où cette approche n'est pas optimale est la conversion de char-> bool. Cette opération pourrait être un simple mouvement, si la valeur booléenne n'était pas limitée à 0/1. Pour toutes les autres opérations, l'approche actuelle est tout aussi bonne, voire meilleure.


EDIT: Peter Cordes a mentionné ABI. Voici le texte pertinent du System V ABI pour AMD64 (le texte pour i386 est similaire):

Les booléens, lorsqu'ils sont stockés dans un objet mémoire, sont stockés sous la forme d'objets à octet unique dont la valeur est toujours 0 (faux) ou 1 (vrai). Lorsqu'ils sont stockés dans des registres entiers (sauf pour passer comme arguments), les 8 octets du registre sont significatifs; toute valeur non nulle est considérée comme vraie

Ainsi, pour les plates-formes qui suivent SysV ABI, nous pouvons être sûrs qu'un bool a une valeur 0/1.

J'ai cherché le document ABI pour MSVC, mais malheureusement je n'ai rien trouvé sur bool.

7
geza

J'ai compilé ce qui suit avec clang ++ -O3 -S

bool andbool(bool a, bool b)
{
    return a && b;
}

bool andint(int a, int b)
{
    return a && b;
}

Le .s le fichier contient:

andbool(bool, bool):                           # @andbool(bool, bool)
    andb    %sil, %dil
    movl    %edi, %eax
    retq

andint(int, int):                            # @andint(int, int)
    testl   %edi, %edi
    setne   %cl
    testl   %esi, %esi
    setne   %al
    andb    %cl, %al
    retq

C'est clairement la version bool qui fait moins.

0
Tony Delroy