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?
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 bool
s à 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 bool
s 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.
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.
bool
avec des opérateurs au niveau du bit aide MSVC et ICCDans 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 test
s 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 bool
s à 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
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
.
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.