J'avais une fonction qui ressemblait à ceci (ne montrant que la partie importante):
double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY) {
...
for(std::size_t i=std::max(0,-shift);i<max;i++) {
if ((curr[i] < 479) && (l[i + shift] < 479)) {
nontopOverlap++;
}
...
}
...
}
Écrit comme ceci, la fonction a pris ~ 34ms sur ma machine. Après avoir changé la condition pour bool multiplier (en donnant au code l'apparence suivante):
double CompareShifted(const std::vector<uint16_t>& l, const std::vector<uint16_t> &curr, int shift, int shiftY) {
...
for(std::size_t i=std::max(0,-shift);i<max;i++) {
if ((curr[i] < 479) * (l[i + shift] < 479)) {
nontopOverlap++;
}
...
}
...
}
le temps d'exécution a diminué à ~ 19 ms.
Le compilateur utilisé était GCC 5.4.0 avec -O3 et après avoir vérifié le code asm généré à l'aide de godbolt.org, j'ai découvert que le premier exemple générait un saut, contrairement au second. J'ai décidé d'essayer GCC 6.2.0, qui génère également une instruction de saut lors de l'utilisation du premier exemple, mais GCC 7 ne semble plus en générer.
Découvrir cette façon d’accélérer le code était plutôt macabre et a pris un certain temps. Pourquoi le compilateur se comporte-t-il de cette façon? Est-ce prévu et est-ce quelque chose que les programmeurs devraient rechercher? Y a-t-il d'autres choses semblables à cela?
EDIT: lien vers godbolt https://godbolt.org/g/5lKPF
L'opérateur logique AND (&&
) utilise l'évaluation de court-circuit, ce qui signifie que le deuxième test n'est effectué que si la première comparaison est vraie. C'est souvent exactement la sémantique dont vous avez besoin. Par exemple, considérons le code suivant:
if ((p != nullptr) && (p->first > 0))
Vous devez vous assurer que le pointeur est non nul avant de le déréférencer. Si cela n'était pas une évaluation de court-circuit, vous auriez un comportement indéfini car vous déréférenceriez un pointeur nul.
Il est également possible que l’évaluation des courts-circuits apporte un gain de performance dans les cas où l’évaluation des conditions est un processus coûteux. Par exemple:
if ((DoLengthyCheck1(p) && (DoLengthyCheck2(p))
Si DoLengthyCheck1
échoue, il est inutile d'appeler DoLengthyCheck2
.
Cependant, dans le binaire résultant, une opération de court-circuit a souvent pour résultat deux branches, car il s’agit du moyen le plus simple pour le compilateur de préserver cette sémantique. (C’est pourquoi, de l’autre côté de la médaille, une évaluation de court-circuit peut parfois inhiber le potentiel d’optimisation .) Vous pouvez le voir en regardant le partie pertinente du code objet générée pour votre instruction if
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 (instructions cmp
) suivies chacune d'un saut/branche conditionnel distinct (ja
ou 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. C’est le cas de la quasi-totalité des processeurs x86, de l’humble 8088 (dont les temps de récupération lents et de la file d’attente de pré-extraction extrêmement réduite [comparable à un cache d’instructions], combinée à une absence totale de prédiction de branche, signifiait que les branches prises nécessitaient le vidage du cache ) aux implémentations modernes (dont les longs pipelines rendent les branches mal prédites aussi coûteuses). Notez la petite mise en garde que j'ai glissé là. Les processeurs modernes depuis le Pentium Pro sont dotés de moteurs de prédiction de branche avancés conçus pour minimiser le coût des branches. Si la direction de la succursale peut être correctement prédite, le coût est minime. La plupart du temps, cela fonctionne bien, mais si vous tombez 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à où vous vous trouvez, puisque vous dites que votre tableau n'est pas trié.
Vous dites que les tests ont confirmé que le remplacement du &&
avec un *
rend le code sensiblement plus rapide. La raison en est évidente lorsque nous comparons la partie pertinente du code de l'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 de penser que cela pourrait ê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
) en cours ici, mais chacune d’elles est précédée d’un xor
et suivie d’un setbe
. Le XOR n'est qu'une astuce standard pour effacer un registre. Le setbe
est une instruction x86 qui définit un bit en fonction de la valeur d'un drapeau et est souvent utilisée pour implémenter code sans branche. Ici, setbe
est l'inverse de ja
. Il définit le registre de destination sur 1 si la comparaison est inférieure ou égale (étant donné que le registre a été mis à zéro, il sera 0 sinon), alors que ja
est ramifié si la comparaison est supérieure, une fois ces deux valeurs obtenues dans le r15b
et r14b
_ registres, ils sont multipliés ensemble en utilisant imul
. La multiplication était traditionnellement une opération relativement lente, mais elle est extrêmement rapide sur les processeurs modernes, et ce sera particulièrement rapide, car elle ne multiplie que les valeurs de deux octets.
Vous auriez tout aussi bien pu remplacer la multiplication par l'opérateur AND au niveau du bit (&
), qui ne fait pas d’évaluation de court-circuit. Cela rend le code beaucoup plus clair et constitue un motif généralement reconnu par les compilateurs. 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 pas de raison technique pour laquelle il a dû émettre le code de cette manière, mais pour une raison quelconque, son heuristique interne lui dit que c'est plus rapide. Il serait probablement plus rapide si le prédicteur de branche était de votre côté, mais il serait 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'utilisent parfois pour générer le même code que celui que vous auriez recherché manuellement. Je vois régulièrement Clang traduire &&
expressions dans le même code qui aurait été émis si j'aurais utilisé &
. Ce qui suit est la sortie pertinente de GCC 6.2 avec votre code en utilisant le &&
opérateur:
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 à quel point astucieux ceci ! Il utilise des conditions signées (jg
et setle
) par opposition à des conditions non signées (ja
et setbe
), mais ce n'est pas important. Vous pouvez voir que la comparaison avec la première condition est toujours identique à celle de l'ancienne version et qu'elle utilise la même instruction setCC
pour générer du code sans branche pour la deuxième condition, mais elle est devenue beaucoup plus efficace. dans comment il fait l'incrément. Au lieu de faire une deuxième comparaison redondante pour définir les indicateurs pour une opération sbb
, il utilise la connaissance selon laquelle r14d
sera 1 ou 0 pour simplement ajouter inconditionnellement cette valeur à nontopOverlap
. Si r14d
est égal à 0, l’ajout est donc nul; sinon, il ajoute 1, exactement comme il est censé le faire.
GCC 6.2 produit réellement plus de code lorsque vous utilisez le court-circuitage &&
_ opérateur que le bit &
opérateur:
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 le jeu conditionnel sont toujours là, mais maintenant, il retourne à la manière moins intelligente d’incrémenter nontopOverlap
. Ceci est une leçon importante sur la raison pour laquelle vous devez faire attention lorsque vous essayez de sur-compiler votre compilateur!
Mais si vous pouvez prouver avec des points de repère que le code de branchement est en réalité plus lent, alors il peut être rentable d'essayer de sur-habiller votre compilateur. Vous devez simplement le faire avec une inspection minutieuse du désassemblage et soyez 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’ya pas d’instruction if
ici, 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 paraître très familier. Les deux comparaisons sont effectuées sans branche, les résultats intermédiaires sont and
ed ensemble, puis ce résultat (qui sera 0 ou 1) est add
ed à nontopOverlap
. Si vous voulez du code sans succursale, cela vous permettra pratiquement de l'obtenir.
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 en tant que code original. 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 des méthodes heuristiques 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 voir cette situation est que le code de branchement présente la meilleure meilleure performance . Si la prédiction de branche réussit, le fait de sauter des opérations inutiles se traduira par une durée d'exécution légèrement plus rapide. Cependant, le code sans branche a la meilleure performance dans le pire des cas . Si la prédiction de branche échoue, l'exécution de quelques instructions supplémentaires afin d'éviter une branche sera définitivement 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 ce qui est de savoir si les programmeurs doivent faire attention à cela, la réponse est presque certainement non, sauf dans certaines boucles critiques que vous essayez d'accélérer via des micro-optimisations. Ensuite, vous vous asseyez avec le démontage et trouvez les moyens de le modifier. Et, comme je l’ai déjà dit, soyez prêt à revoir ces décisions lorsque vous mettrez à jour une version plus récente du compilateur, car cela peut faire quelque chose de stupide avec votre code compliqué ou peut-être avoir suffisamment modifié son système heuristique d'optimisation pour pouvoir revenir en arrière. à utiliser votre code d'origine. Commentez bien!
Une chose importante à noter est que
(curr[i] < 479) && (l[i + shift] < 479)
et
(curr[i] < 479) * (l[i + shift] < 479)
ne sont pas sémantiquement équivalents! En particulier, si vous avez un jour la situation où:
0 <= i
Et i < curr.size()
sont tous deux vraiscurr[i] < 479
Est fauxi + shift < 0
Ou i + shift >= l.size()
est vraialors l'expression (curr[i] < 479) && (l[i + shift] < 479)
est garantie d'être une valeur booléenne bien définie. Par exemple, cela ne cause pas d'erreur de segmentation.
Cependant, dans ces circonstances, l'expression (curr[i] < 479) * (l[i + shift] < 479)
Est comportement non défini; il est est autorisé à causer une erreur de segmentation.
Cela signifie que, par exemple, pour l'extrait de code d'origine, le compilateur ne peut pas écrire une boucle effectuant les deux comparaisons et effectuant une opération and
, à moins que le compilateur puisse également prouver que l[i + shift]
ne jamais causer de segfault dans une situation à ne pas faire.
En bref, le code d'origine offre moins d'opportunités d'optimisation que ces dernières. (bien sûr, que le compilateur reconnaisse ou non l'opportunité est une question totalement différente)
Vous pouvez réparer la version originale en faisant plutôt
bool t1 = (curr[i] < 479);
bool t2 = (l[i + shift] < 479);
if (t1 && t2) {
// ...
L'opérateur &&
Implémente l'évaluation des courts-circuits. Cela signifie que le deuxième opérande n'est évalué que si le premier est évalué à true
. Cela entraîne certainement un saut dans ce cas.
Vous pouvez créer un petit exemple pour montrer ceci:
#include <iostream>
bool f(int);
bool g(int);
void test(int x, int y)
{
if ( f(x) && g(x) )
{
std::cout << "ok";
}
}
La sortie de l'assembleur peut être trouvée ici .
Vous pouvez voir que le code généré appelle d'abord f(x)
, puis vérifie la sortie et passe à l'évaluation de g(x)
alors qu'il s'agissait de true
. Sinon, il quitte la fonction.
L'utilisation de la multiplication "booléenne" force l'évaluation des deux opérandes à chaque fois et ne nécessite donc pas de saut.
En fonction des données, le saut peut provoquer un ralentissement, car il perturbe le pipeline de la CPU et d'autres tâches, telles que l'exécution spéculative. Normalement, la prédiction de branche est utile, mais si vos données sont aléatoires, vous ne pouvez pas en prédire beaucoup.