L'opérateur logique AND ( &&
) utilise une évaluation de court-circuit, ce qui signifie que le deuxième test n'est effectué que si la première comparaison est évaluée à vrai. C'est souvent exactement la sémantique dont vous avez besoin. Par exemple, considérez le code suivant:
if ((p != nullptr) && (p->first > 0))
Vous devez vous assurer que le pointeur n'est pas nul avant de le déréférencer. S'il ne s'agissait pas d' une évaluation de court-circuit, vous auriez un comportement non défini car vous déréférenceriez un pointeur nul.
Il est également possible que l'évaluation des courts-circuits donne un gain de performance dans les cas où l'évaluation des conditions est un processus coûteux. Par exemple:
if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))
En cas d' DoLengthyCheck1
échec, il ne sert à rien d'appeler DoLengthyCheck2
.
Cependant, dans le binaire résultant, une opération de court-circuit se traduit souvent par deux branches, car c'est le moyen le plus simple pour le compilateur de conserver cette sémantique. (C'est pourquoi, de l'autre côté de la pièce, l'évaluation de court-circuit peut parfois inhiber le potentiel d'optimisation.) Vous pouvez le voir en regardant la partie pertinente du code objet généré pour votre if
déclaration par GCC 5.4:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13w, 478 ; (curr[i] < 479)
ja .L5
cmp ax, 478 ; (l[i + shift] < 479)
ja .L5
add r8d, 1 ; nontopOverlap++
Vous voyez ici les deux comparaisons ( cmp
instructions) ici, chacune suivie d'un saut / branchement conditionnel séparé ( ja
ou d'un saut si ci-dessus).
En règle générale, les branches sont lentes et doivent donc être évitées dans les boucles serrées. Cela a été vrai sur pratiquement tous les processeurs x86, à partir de l'humble 8088 (dont les temps de récupération lents et la file d'attente de prélecture extrêmement petite [comparable à un cache d'instructions], combinés à une absence totale de prédiction de branche, signifiaient que les branches prises nécessitaient le vidage du cache ) aux implémentations modernes (dont les longs pipelines rendent les branches mal prévues tout aussi chères). Notez la petite mise en garde que j'ai glissée là-dedans. Les processeurs modernes depuis le Pentium Pro disposent de moteurs de prédiction de branche avancés conçus pour minimiser le coût des branches. Si la direction de la branche peut être correctement prédite, le coût est minime. La plupart du temps, cela fonctionne bien, mais si vous vous retrouvez dans des cas pathologiques où le prédicteur de branche n'est pas de votre côté,votre code peut devenir extrêmement lent . C'est probablement là que vous êtes ici, puisque vous dites que votre tableau n'est pas trié.
Vous dites que les benchmarks ont confirmé que le remplacement du &&
par un *
rend le code nettement plus rapide. La raison en est évidente lorsque nous comparons la partie pertinente du code objet:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
xor r15d, r15d ; (curr[i] < 479)
cmp r13w, 478
setbe r15b
xor r14d, r14d ; (l[i + shift] < 479)
cmp ax, 478
setbe r14b
imul r14d, r15d ; meld results of the two comparisons
cmp r14d, 1 ; nontopOverlap++
sbb r8d, -1
Il est un peu contre-intuitif que cela puisse être plus rapide, car il y a plus d' instructions ici, mais c'est ainsi que l'optimisation fonctionne parfois. Vous voyez les mêmes comparaisons ( cmp
) effectuées ici, mais maintenant, chacune est précédée d'un xor
et suivi d'un setbe
. Le XOR est juste une astuce standard pour effacer un registre. Il setbe
s'agit d'une instruction x86 qui définit un bit en fonction de la valeur d'un indicateur et est souvent utilisée pour implémenter du code sans branche. Ici, setbe
est l'inverse de ja
. Il met son registre de destination à 1 si la comparaison était inférieure ou égale (puisque le registre a été pré-mis à zéro, il sera à 0 dans le cas contraire), tandis que ja
ramifié si la comparaison était au-dessus. Une fois que ces deux valeurs ont été obtenues en r15b
etr14b
registres, ils sont multipliés ensemble en utilisant imul
. La multiplication était traditionnellement une opération relativement lente, mais elle est sacrément rapide sur les processeurs modernes, et ce sera particulièrement rapide, car elle ne multiplie que deux valeurs de la taille d'un octet.
Vous pourriez tout aussi facilement avoir remplacé la multiplication par l'opérateur binaire AND ( &
), qui ne fait pas d'évaluation de court-circuit. Cela rend le code beaucoup plus clair et constitue un modèle que les compilateurs reconnaissent généralement. Mais lorsque vous faites cela avec votre code et que vous le compilez avec GCC 5.4, il continue à émettre la première branche:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13w, 478 ; (curr[i] < 479)
ja .L4
cmp ax, 478 ; (l[i + shift] < 479)
setbe r14b
cmp r14d, 1 ; nontopOverlap++
sbb r8d, -1
Il n'y a aucune raison technique pour qu'il émette le code de cette façon, mais pour une raison quelconque, ses heuristiques internes lui disent que c'est plus rapide. Ce serait probablement plus rapide si le prédicteur de branche était de votre côté, mais ce sera probablement plus lent si la prédiction de branche échoue plus souvent qu'elle ne réussit.
Les nouvelles générations du compilateur (et d'autres compilateurs, comme Clang) connaissent cette règle et l'utiliseront parfois pour générer le même code que vous auriez recherché en optimisant manuellement. Je vois régulièrement Clang traduire des &&
expressions dans le même code qui aurait été émis si j'avais utilisé &
. Voici la sortie pertinente de GCC 6.2 avec votre code en utilisant l' &&
opérateur normal :
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13d, 478 ; (curr[i] < 479)
jg .L7
xor r14d, r14d ; (l[i + shift] < 479)
cmp eax, 478
setle r14b
add esi, r14d ; nontopOverlap++
Notez comment intelligent c'est! Il utilise des conditions signées ( et ) par opposition à des conditions non signées ( et ), mais ce n'est pas important. Vous pouvez voir qu'il fait toujours la comparaison et la branche pour la première condition comme l'ancienne version, et utilise la même instruction pour générer du code sans branche pour la deuxième condition, mais il est devenu beaucoup plus efficace dans la façon dont il incrémente . Au lieu de faire une deuxième comparaison redondante pour définir les indicateurs d'une opération, il utilise la connaissance qui sera 1 ou 0 pour simplement ajouter cette valeur sans condition . Si est 0, l'addition est un non-op; sinon, il ajoute 1, exactement comme il est censé le faire.jg
setle
ja
setbe
setCC
sbb
r14d
nontopOverlap
r14d
GCC 6.2 produit en fait un code plus efficace lorsque vous utilisez l' &&
opérateur de court-circuit que l'opérateur au niveau du bit &
:
movzx r13d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r13d, 478 ; (curr[i] < 479)
jg .L6
cmp eax, 478 ; (l[i + shift] < 479)
setle r14b
cmp r14b, 1 ; nontopOverlap++
sbb esi, -1
La branche et l'ensemble conditionnel sont toujours là, mais maintenant il revient à la manière moins intelligente d'incrémenter nontopOverlap
. C'est une leçon importante sur les raisons pour lesquelles vous devez faire attention lorsque vous essayez de surpasser votre compilateur!
Mais si vous pouvez prouver avec des benchmarks que le code de branchement est en fait plus lent, alors il peut être payant d'essayer de surpasser votre compilateur. Il vous suffit de le faire en inspectant soigneusement le démontage et d'être prêt à réévaluer vos décisions lorsque vous effectuez une mise à niveau vers une version ultérieure du compilateur. Par exemple, le code que vous avez pourrait être réécrit comme suit:
nontopOverlap += ((curr[i] < 479) & (l[i + shift] < 479));
Il n'y a aucune if
déclaration ici du tout, et la grande majorité des compilateurs ne penseront jamais à émettre du code de branchement pour cela. GCC ne fait pas exception; toutes les versions génèrent quelque chose qui ressemble à ce qui suit:
movzx r14d, WORD PTR [rbp+rcx*2]
movzx eax, WORD PTR [rbx+rcx*2]
cmp r14d, 478 ; (curr[i] < 479)
setle r15b
xor r13d, r13d ; (l[i + shift] < 479)
cmp eax, 478
setle r13b
and r13d, r15d ; meld results of the two comparisons
add esi, r13d ; nontopOverlap++
Si vous avez suivi les exemples précédents, cela devrait vous sembler très familier. Les deux comparaisons sont effectuées sans branche, les résultats intermédiaires sont and
édités ensemble, puis ce résultat (qui sera soit 0 soit 1) est add
édité à nontopOverlap
. Si vous voulez du code sans branche, cela garantira pratiquement que vous l'obteniez.
GCC 7 est devenu encore plus intelligent. Il génère maintenant un code pratiquement identique (à l'exception d'un léger réarrangement des instructions) pour l'astuce ci-dessus que le code d'origine. Donc, la réponse à votre question, "Pourquoi le compilateur se comporte-t-il de cette façon?" , c'est probablement parce qu'ils ne sont pas parfaits! Ils essaient d'utiliser l'heuristique pour générer le code le plus optimal possible, mais ils ne prennent pas toujours les meilleures décisions. Mais au moins, ils peuvent devenir plus intelligents avec le temps!
Une façon de regarder cette situation est que le code de branchement a le meilleur meilleur cas la performance. Si la prédiction de branche réussit, le fait de sauter des opérations inutiles entraînera une durée d'exécution légèrement plus rapide. Cependant, le code sans branche a les meilleures performances dans le pire des cas . Si la prédiction de branche échoue, exécuter quelques instructions supplémentaires nécessaires pour éviter une branche sera certainement plus rapide qu'une branche mal prédite. Même les compilateurs les plus intelligents et les plus intelligents auront du mal à faire ce choix.
Et pour votre question de savoir si c'est quelque chose que les programmeurs doivent surveiller, la réponse est presque certainement non, sauf dans certaines boucles chaudes que vous essayez d'accélérer via des micro-optimisations. Ensuite, vous vous asseyez avec le démontage et trouvez des moyens de le peaufiner. Et, comme je l'ai déjà dit, soyez prêt à revoir ces décisions lorsque vous mettez à jour vers une version plus récente du compilateur, car il peut soit faire quelque chose de stupide avec votre code délicat, soit avoir suffisamment changé son heuristique d'optimisation pour que vous puissiez revenir en arrière à utiliser votre code d'origine. Commentez attentivement!