Voici un morceau de code C ++ qui montre un comportement très particulier. Pour une raison étrange, le tri des données miraculeusement rend le code presque six fois plus rapide:
#include <algorithm>
#include <ctime>
#include <iostream>
int main()
{
// Generate data
const unsigned arraySize = 32768;
int data[arraySize];
for (unsigned c = 0; c < arraySize; ++c)
data[c] = std::rand() % 256;
// !!! With this, the next loop runs faster.
std::sort(data, data + arraySize);
// Test
clock_t start = clock();
long long sum = 0;
for (unsigned i = 0; i < 100000; ++i)
{
// Primary loop
for (unsigned c = 0; c < arraySize; ++c)
{
if (data[c] >= 128)
sum += data[c];
}
}
double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
std::cout << elapsedTime << std::endl;
std::cout << "sum = " << sum << std::endl;
}
- Sans
std::sort(data, data + arraySize);
, le code s'exécute en 11,54 secondes. - Avec les données triées, le code s'exécute en 1,93 secondes.
Au départ, je pensais que cela pourrait être juste une anomalie de langage ou de compilateur, j'ai donc essayé Java:
import java.util.Arrays;
import java.util.Random;
public class Main
{
public static void main(String[] args)
{
// Generate data
int arraySize = 32768;
int data[] = new int[arraySize];
Random rnd = new Random(0);
for (int c = 0; c < arraySize; ++c)
data[c] = rnd.nextInt() % 256;
// !!! With this, the next loop runs faster
Arrays.sort(data);
// Test
long start = System.nanoTime();
long sum = 0;
for (int i = 0; i < 100000; ++i)
{
// Primary loop
for (int c = 0; c < arraySize; ++c)
{
if (data[c] >= 128)
sum += data[c];
}
}
System.out.println((System.nanoTime() - start) / 1000000000.0);
System.out.println("sum = " + sum);
}
}
Avec un résultat similaire mais moins extrême.
Ma première pensée a été que le tri amène les données dans le cache, mais j'ai pensé à quel point c'était stupide parce que le tableau venait d'être généré.
- Que se passe-t-il?
- Pourquoi le traitement d'un tableau trié est-il plus rapide que le traitement d'un tableau non trié?
Le code résume certains termes indépendants, donc l'ordre ne devrait pas avoir d'importance.
java
c++
performance
optimization
branch-prediction
GManNickG
la source
la source
Réponses:
Vous êtes victime d'un échec de prédiction de branche .
Qu'est-ce que la prédiction de branche?
Considérons une jonction ferroviaire:
Image par Mecanismo, via Wikimedia Commons. Utilisé sous la licence CC-By-SA 3.0 .
Maintenant, pour les besoins de l'argument, supposons que cela remonte aux années 1800 - avant les communications longue distance ou radio.
Vous êtes l'opérateur d'un carrefour et vous entendez arriver un train. Vous n'avez aucune idée de la direction à prendre. Vous arrêtez le train pour demander au conducteur dans quelle direction il veut. Et puis vous réglez le commutateur de manière appropriée.
Les trains sont lourds et ont beaucoup d'inertie. Ils mettent donc une éternité à démarrer et à ralentir.
Y a-t-il une meilleure façon? Vous devinez dans quelle direction le train ira!
Si vous devinez à chaque fois , le train n'aura jamais à s'arrêter.
Si vous vous trompez trop souvent , le train passera beaucoup de temps à s'arrêter, à reculer et à redémarrer.
Considérons une instruction if: au niveau du processeur, il s'agit d'une instruction de branchement:
Vous êtes un processeur et vous voyez une branche. Vous n'avez aucune idée de la direction que cela prendra. Que faire? Vous arrêtez l'exécution et attendez que les instructions précédentes soient terminées. Ensuite, vous continuez sur le bon chemin.
Les processeurs modernes sont compliqués et ont de longs pipelines. Ils mettent donc une éternité à «s'échauffer» et à «ralentir».
Y a-t-il une meilleure façon? Vous devinez dans quelle direction ira la succursale!
Si vous devinez à chaque fois , l'exécution ne devra jamais s'arrêter.
Si vous vous trompez trop souvent , vous passez beaucoup de temps à caler, à reculer et à redémarrer.
Ceci est une prédiction de branche. J'avoue que ce n'est pas la meilleure analogie car le train pourrait simplement signaler la direction avec un drapeau. Mais dans les ordinateurs, le processeur ne sait pas dans quelle direction ira une branche jusqu'au dernier moment.
Alors, comment devineriez-vous stratégiquement pour minimiser le nombre de fois que le train doit reculer et descendre l'autre chemin? Vous regardez l'histoire passée! Si le train part à 99% du temps, alors vous devinez parti. S'il alterne, alors vous alternez vos suppositions. Si cela va dans un sens toutes les trois fois, vous devinez la même chose ...
En d'autres termes, vous essayez d'identifier un modèle et de le suivre. C'est plus ou moins comment fonctionnent les prédicteurs de branche.
La plupart des applications ont des branches bien comportées. Ainsi, les prédicteurs de branche modernes atteindront généralement des taux de réussite supérieurs à 90%. Mais face à des branches imprévisibles sans schémas reconnaissables, les prédicteurs de branche sont pratiquement inutiles.
Pour en savoir plus: article "Predicteur de branche" sur Wikipédia .
Comme laissé entendre ci-dessus, le coupable est cette instruction if:
Notez que les données sont réparties uniformément entre 0 et 255. Lorsque les données sont triées, à peu près la première moitié des itérations n'entrera pas dans l'instruction if. Après cela, ils entreront tous dans l'instruction if.
Ceci est très convivial pour le prédicteur de branche car la branche va dans le même sens plusieurs fois de suite. Même un simple compteur saturant prédira correctement la branche, à l'exception des quelques itérations après avoir changé de direction.
Visualisation rapide:
Cependant, lorsque les données sont complètement aléatoires, le prédicteur de branche est rendu inutile, car il ne peut pas prédire des données aléatoires. Ainsi, il y aura probablement environ 50% d'erreurs de prédiction (pas mieux que des suppositions aléatoires).
Alors, que peut-on faire?
Si le compilateur n'est pas en mesure d'optimiser la branche dans un mouvement conditionnel, vous pouvez essayer quelques hacks si vous êtes prêt à sacrifier la lisibilité pour les performances.
Remplacer:
avec:
Cela élimine la branche et la remplace par quelques opérations au niveau du bit.
(Notez que ce hack n'est pas strictement équivalent à l'instruction if d'origine. Mais dans ce cas, il est valide pour toutes les valeurs d'entrée de
data[]
.)Repères: Core i7 920 @ 3,5 GHz
C ++ - Visual Studio 2010 - Version x64
Java - NetBeans 7.1.1 JDK 7 - x64
Observations:
Une règle générale consiste à éviter la ramification dépendante des données dans les boucles critiques (comme dans cet exemple).
Mise à jour:
GCC 4.6.1 avec
-O3
ou-ftree-vectorize
sur x64 est capable de générer un déplacement conditionnel. Il n'y a donc aucune différence entre les données triées et non triées - les deux sont rapides.(Ou un peu rapide: pour le cas déjà trié,
cmov
peut être plus lent, surtout si GCC le place sur le chemin critique plutôt que justeadd
, en particulier sur Intel avant Broadwell où lacmov
latence est à 2 cycles: l' indicateur d'optimisation gcc -O3 rend le code plus lent que -O2 )VC ++ 2010 est incapable de générer des mouvements conditionnels pour cette branche même sous
/Ox
.Intel C ++ Compiler (ICC) 11 fait quelque chose de miraculeux. Il échange les deux boucles , hissant ainsi la branche imprévisible à la boucle externe. Ainsi, non seulement il est immunisé contre les erreurs de prévision, mais il est également deux fois plus rapide que ce que VC ++ et GCC peuvent générer! En d'autres termes, ICC a profité de la boucle de test pour battre la référence ...
Si vous donnez au compilateur Intel le code sans branche, il le vectorise juste à droite ... et est aussi rapide qu'avec la branche (avec l'échange de boucle).
Cela montre que même les compilateurs modernes matures peuvent varier considérablement dans leur capacité à optimiser le code ...
la source
Prédiction de branche.
Avec un tableau trié, la condition
data[c] >= 128
est d'abordfalse
pour une séquence de valeurs, puis devienttrue
pour toutes les valeurs ultérieures. C'est facile à prévoir. Avec un tableau non trié, vous payez les frais de branchement.la source
La raison pour laquelle les performances s'améliorent considérablement lorsque les données sont triées est que la pénalité de prédiction de branche est supprimée, comme expliqué magnifiquement dans la réponse de Mysticial .
Maintenant, si nous regardons le code
nous pouvons constater que le sens de cette
if... else...
branche particulière est d'ajouter quelque chose quand une condition est remplie. Ce type de branche peut être facilement transformé en une instruction de déplacement conditionnel , qui serait compilée en une instruction de déplacement conditionnel:,cmovl
dans unx86
système. La branche et donc la pénalité de prédiction de branche potentielle sont supprimées.Dans
C
, ainsiC++
, l'instruction, qui compilerait directement (sans aucune optimisation) dans l'instruction de déplacement conditionnel dansx86
, est l'opérateur ternaire... ? ... : ...
. Nous réécrivons donc la déclaration ci-dessus en une déclaration équivalente:Tout en maintenant la lisibilité, nous pouvons vérifier le facteur d'accélération.
Sur un Intel Core i7 -2600K @ 3,4 GHz et le mode de sortie de Visual Studio 2010, la référence est (format copié depuis Mysticial):
x86
x64
Le résultat est robuste dans plusieurs tests. Nous obtenons une grande accélération lorsque le résultat de la branche est imprévisible, mais nous souffrons un peu lorsqu'il est prévisible. En fait, lors de l'utilisation d'un déplacement conditionnel, les performances sont les mêmes quel que soit le modèle de données.
Examinons maintenant de plus près en examinant l'
x86
assemblage qu'ils génèrent. Pour simplifier, nous utilisons deux fonctionsmax1
etmax2
.max1
utilise la branche conditionnelleif... else ...
:max2
utilise l'opérateur ternaire... ? ... : ...
:Sur une machine x86-64,
GCC -S
génère l'assembly ci-dessous.max2
utilise beaucoup moins de code en raison de l'utilisation de l'instructioncmovge
. Mais le vrai gain est quemax2
n'implique pas de sauts de branchejmp
, ce qui entraînerait une pénalité de performance importante si le résultat prévu n'est pas correct.Alors pourquoi un mouvement conditionnel fonctionne-t-il mieux?
Dans un
x86
processeur typique , l'exécution d'une instruction est divisée en plusieurs étapes. En gros, nous avons différents matériels pour faire face à différentes étapes. Il n'est donc pas nécessaire d'attendre la fin d'une instruction pour en commencer une nouvelle. C'est ce qu'on appelle le pipelining .Dans un cas de branche, l'instruction suivante est déterminée par la précédente, donc nous ne pouvons pas faire de pipelining. Nous devons attendre ou prévoir.
Dans un cas de déplacement conditionnel, l'instruction de déplacement conditionnel d'exécution est divisée en plusieurs étapes, mais les étapes antérieures aiment
Fetch
etDecode
ne dépendent pas du résultat de l'instruction précédente; seules les dernières étapes ont besoin du résultat. Ainsi, nous attendons une fraction du temps d'exécution d'une instruction. C'est pourquoi la version à déplacement conditionnel est plus lente que la branche lorsque la prédiction est facile.Le livre Computer Systems: A Programmer's Perspective, deuxième édition explique cela en détail. Vous pouvez consulter la section 3.6.6 pour les instructions de déplacement conditionnel , l'intégralité du chapitre 4 pour l' architecture du processeur et la section 5.11.2 pour un traitement spécial pour les pénalités de prédiction de branche et de mauvaise prévision .
Parfois, certains compilateurs modernes peuvent optimiser notre code en assembleur avec de meilleures performances, parfois certains compilateurs ne le peuvent pas (le code en question utilise le compilateur natif de Visual Studio). Connaître la différence de performances entre la branche et le mouvement conditionnel en cas d'imprévisibilité peut nous aider à écrire du code avec de meilleures performances lorsque le scénario devient si complexe que le compilateur ne peut pas les optimiser automatiquement.
la source
-O0
exemple trompeur et pour montrer la différence d' asm optimisé sur vos deux tests.Si vous êtes curieux de voir encore plus d'optimisations qui peuvent être apportées à ce code, considérez ceci:
En commençant par la boucle d'origine:
Avec l'échange de boucle, nous pouvons changer cette boucle en toute sécurité en:
Ensuite, vous pouvez voir que le
if
conditionnel est constant tout au long de l'exécution de lai
boucle, vous pouvez donc hisser laif
sortie:Ensuite, vous voyez que la boucle intérieure peut être réduite en une seule expression, en supposant que le modèle à virgule flottante le permet (
/fp:fast
est levé, par exemple)Celui-ci est 100 000 fois plus rapide qu'auparavant.
la source
i
une unité = 1e5. Cela ne fait aucune différence pour le résultat final, mais je voulais juste remettre les pendules à l'heure car c'est une page tellement fréquentée.if
à ce stade pourrait être convertie en:sum += (data[j] >= 128) ? data[j] * 100000 : 0;
ce que le compilateur peut réduirecmovge
ou équivalent.Certains d'entre nous seraient sans doute intéressés par des moyens d'identifier le code problématique pour le prédicteur de branche du CPU. L'outil Valgrind
cachegrind
dispose d'un simulateur de prédicteur de branche, activé en utilisant l'--branch-sim=yes
indicateur. L'exécuter sur les exemples de cette question, avec le nombre de boucles externes réduit à 10000 et compilé avecg++
, donne les résultats suivants:Trié:
Non trié:
En descendant dans la sortie ligne par ligne produite par
cg_annotate
nous voyons pour la boucle en question:Trié:
Non trié:
Cela vous permet d'identifier facilement la ligne problématique - dans la version non triée, la
if (data[c] >= 128)
ligne provoque 164 050 007 branches conditionnelles mal prédites (Bcm
) sous le modèle de prédicteur de branche de cachegrind, alors qu'elle ne cause que 10 006 dans la version triée.Alternativement, sous Linux, vous pouvez utiliser le sous-système des compteurs de performances pour accomplir la même tâche, mais avec des performances natives à l'aide de compteurs CPU.
Trié:
Non trié:
Il peut également faire des annotations de code source avec démontage.
Voir le didacticiel sur les performances pour plus de détails.
la source
data[c] >= 128
(qui a un taux de 50% comme vous le suggérez) et une pour la condition de bouclec < arraySize
qui a ~ 0% de taux de manque .Je viens de lire cette question et ses réponses, et je sens qu'il manque une réponse.
Une méthode courante pour éliminer la prédiction de branche que j'ai trouvée particulièrement efficace dans les langages gérés est une recherche de table au lieu d'utiliser une branche (bien que je ne l'ai pas testée dans ce cas).
Cette approche fonctionne en général si:
Contexte et pourquoi
Du point de vue du processeur, votre mémoire est lente. Pour compenser la différence de vitesse, deux caches sont intégrés à votre processeur (cache L1 / L2). Imaginez donc que vous faites vos bons calculs et comprenez que vous avez besoin d'un morceau de mémoire. Le processeur obtient son opération de «chargement» et charge le morceau de mémoire dans le cache - puis utilise le cache pour effectuer le reste des calculs. La mémoire étant relativement lente, cette «charge» ralentira votre programme.
Comme la prédiction de branche, celle-ci a été optimisée dans les processeurs Pentium: le processeur prédit qu'il doit charger une donnée et tente de la charger dans le cache avant que l'opération n'atteigne réellement le cache. Comme nous l'avons déjà vu, la prédiction de branche va parfois horriblement mal - dans le pire des cas, vous devez revenir en arrière et attendre une charge de mémoire, ce qui prendra une éternité ( en d'autres termes: l'échec de la prédiction de branche est mauvais, une mémoire charger après l'échec d'une prédiction de branche est tout simplement horrible! ).
Heureusement pour nous, si le modèle d'accès à la mémoire est prévisible, le processeur le chargera dans son cache rapide et tout va bien.
La première chose que nous devons savoir est ce qui est petit ? Bien que plus petit soit généralement meilleur, une règle de base est de s'en tenir aux tables de recherche dont la taille est <= 4096 octets. Comme limite supérieure: si votre table de recherche est supérieure à 64 Ko, cela vaut probablement la peine d'être reconsidéré.
Construire une table
Nous avons donc compris que nous pouvons créer une petite table. La prochaine chose à faire est de mettre en place une fonction de recherche. Les fonctions de recherche sont généralement de petites fonctions qui utilisent un couple d'opérations entières de base (et, ou, xor, shift, add, remove et peut-être multiplier). Vous voulez que votre entrée soit traduite par la fonction de recherche en une sorte de «clé unique» dans votre table, qui vous donne alors simplement la réponse de tout le travail que vous vouliez qu'elle fasse.
Dans ce cas:> = 128 signifie que nous pouvons conserver la valeur, <128 signifie que nous nous en débarrassons. La façon la plus simple de le faire est d'utiliser un 'ET': si nous le gardons, nous le faisons avec 7FFFFFFF; si nous voulons nous en débarrasser, nous ET avec 0. Notez également que 128 est une puissance de 2 - donc nous pouvons aller de l'avant et faire un tableau de 32768/128 entiers et le remplir avec un zéro et beaucoup de 7FFFFFFFF.
Langues gérées
Vous vous demandez peut-être pourquoi cela fonctionne bien dans les langues gérées. Après tout, les langages gérés vérifient les limites des tableaux avec une branche pour vous assurer de ne pas gâcher ...
Eh bien, pas exactement ... :-)
Il y a eu pas mal de travail sur l'élimination de cette branche pour les langues gérées. Par exemple:
Dans ce cas, il est évident pour le compilateur que la condition aux limites ne sera jamais atteinte. Au moins le compilateur Microsoft JIT (mais je pense que Java fait des choses similaires) le remarquera et supprimera complètement la vérification. WOW, cela signifie pas de branche. De même, il traitera d'autres cas évidents.
Si vous rencontrez des problèmes avec les recherches dans les langues gérées - la clé est d'ajouter un
& 0x[something]FFF
à votre fonction de recherche pour rendre la vérification des limites prévisible - et regardez-la aller plus vite.Le résultat de cette affaire
la source
sum += lookup[data[j]]
oùlookup
est un tableau avec 256 entrées, les premiers étant zéro et les derniers étant égal à l'indice?Comme les données sont réparties entre 0 et 255 lorsque le tableau est trié, environ la première moitié des itérations n'entrera pas dans l'
if
énoncé-(l'if
instruction est partagée ci-dessous).La question est: qu'est-ce qui fait que l'instruction ci-dessus ne s'exécute pas dans certains cas comme dans le cas de données triées? Voici le "prédicteur de branche". Un prédicteur de branche est un circuit numérique qui essaie de deviner dans quelle direction
if-then-else
ira une branche (par exemple une structure) avant que cela ne soit sûr. Le prédicteur de branche a pour but d'améliorer le flux dans le pipeline d'instructions. Les prédicteurs de branche jouent un rôle essentiel dans l'obtention de performances efficaces élevées!Faisons quelques repères pour mieux le comprendre
Les performances d'une
if
instruction dépendent du fait que sa condition présente un modèle prévisible. Si la condition est toujours vraie ou toujours fausse, la logique de prédiction de branchement dans le processeur reprendra le motif. En revanche, si le modèle est imprévisible, leif
déclaration sera beaucoup plus chère.Mesurons les performances de cette boucle avec différentes conditions:
Voici les timings de la boucle avec différents modèles vrai-faux:
Un « mauvais » vrai-faux motif peut rendre une
if
déclaration jusqu'à six fois plus lente qu'un « bon » » motif! Bien sûr, quel modèle est bon et lequel est mauvais dépend des instructions exactes générées par le compilateur et du processeur spécifique.Il n'y a donc aucun doute sur l'impact de la prédiction de branche sur les performances!
la source
Une façon d'éviter les erreurs de prédiction de branche consiste à créer une table de recherche et à l'indexer à l'aide des données. Stefan de Bruijn en a parlé dans sa réponse.
Mais dans ce cas, nous savons que les valeurs sont dans la plage [0, 255] et nous ne nous soucions que des valeurs> = 128. Cela signifie que nous pouvons facilement extraire un seul bit qui nous dira si nous voulons une valeur ou non: en décalant les données à droite 7 bits, nous nous retrouvons avec un bit 0 ou 1 bit, et nous voulons seulement ajouter la valeur lorsque nous avons un bit. Appelons ce bit le "bit de décision".
En utilisant la valeur 0/1 du bit de décision comme index dans un tableau, nous pouvons créer un code qui sera tout aussi rapide que les données soient triées ou non. Notre code ajoutera toujours une valeur, mais lorsque le bit de décision est 0, nous ajouterons la valeur quelque part qui nous importe peu. Voici le code:
Ce code gaspille la moitié des ajouts mais n'a jamais d'échec de prédiction de branche. C'est extrêmement plus rapide sur des données aléatoires que la version avec une instruction if réelle.
Mais dans mes tests, une table de recherche explicite était légèrement plus rapide que cela, probablement parce que l'indexation dans une table de recherche était légèrement plus rapide que le décalage de bits. Cela montre comment mon code s'installe et utilise la table de recherche (appelée sans
lut
ambiguïté pour "LookUp Table" dans le code). Voici le code C ++:Dans ce cas, la table de recherche n'était que de 256 octets, elle s'intègre donc bien dans un cache et tout était rapide. Cette technique ne fonctionnerait pas bien si les données étaient des valeurs 24 bits et nous n'en voulions que la moitié ... la table de recherche serait beaucoup trop grande pour être pratique. D'autre part, nous pouvons combiner les deux techniques présentées ci-dessus: d'abord décaler les bits, puis indexer une table de recherche. Pour une valeur de 24 bits que nous ne voulons que la moitié supérieure, nous pourrions potentiellement déplacer les données vers la droite de 12 bits et se retrouver avec une valeur de 12 bits pour un index de table. Un index de table de 12 bits implique une table de 4096 valeurs, ce qui pourrait être pratique.
La technique d'indexation dans un tableau, au lieu d'utiliser une
if
instruction, peut être utilisée pour décider du pointeur à utiliser. J'ai vu une bibliothèque qui implémentait des arbres binaires, et au lieu d'avoir deux pointeurs nommés (pLeft
etpRight
ou autre) avait un tableau de pointeurs de longueur 2 et utilisé la technique du "bit de décision" pour décider lequel suivre. Par exemple, au lieu de:cette bibliothèque ferait quelque chose comme:
Voici un lien vers ce code: Red Black Trees , Eternally Confuzzled
la source
data[c]>>7
- ce qui est également évoqué ici); J'ai intentionnellement omis cette solution, mais vous avez bien sûr raison. Juste une petite note: la règle générale pour les tables de recherche est que si elle tient dans 4 Ko (en raison de la mise en cache), cela fonctionnera - de préférence, rendez la table aussi petite que possible. Pour les langages gérés, je pousserais cela à 64 Ko, pour les langages de bas niveau comme C ++ et C, je reconsidérerais probablement (c'est juste mon expérience). Depuistypeof(int) = 4
, j'essaierais de m'en tenir à 10 bits maximum.sizeof(int) == 4
? Ce serait vrai pour 32 bits. Mon téléphone portable de deux ans a un cache L1 de 32 Ko, donc même une table de recherche 4K pourrait fonctionner, surtout si les valeurs de recherche étaient un octet au lieu d'un entier.j
méthode égale à 0 ou 1, pourquoi ne multipliez-vous pas simplement votre valeurj
avant de l'ajouter plutôt que d'utiliser l'indexation du tableau (éventuellement à multiplier par1-j
plutôt quej
)int c = data[j]; sum += c & -(c >> 7);
ne nécessiter aucune multiplication.Dans le cas trié, vous pouvez faire mieux que de vous fier à une prédiction de branche réussie ou à une astuce de comparaison sans branche: supprimez complètement la branche.
En effet, le tableau est partitionné dans une zone contiguë avec
data < 128
et une autre avecdata >= 128
. Vous devriez donc trouver le point de partition avec une recherche dichotomique (en utilisant desLg(arraySize) = 15
comparaisons), puis faire une accumulation directe à partir de ce point.Quelque chose comme (décoché)
ou, légèrement plus obscurci
Une approche encore plus rapide, qui donne une solution approximative à la fois triée ou non triée, est la suivante:
sum= 3137536;
(en supposant une distribution vraiment uniforme, 16384 échantillons avec la valeur attendue 191,5) :-)la source
sum= 3137536
- intelligent. Ce n'est évidemment pas le but de la question. La question est clairement d'expliquer des caractéristiques de performance surprenantes. Je suis enclin à dire que l'ajout de fairestd::partition
au lieu destd::sort
est précieux. Bien que la vraie question ne se limite pas à la référence synthétique donnée.Le comportement ci-dessus se produit en raison de la prédiction de branche.
Pour comprendre la prédiction de branche, il faut d'abord comprendre le pipeline d'instructions :
Toute instruction est divisée en une séquence d'étapes afin que différentes étapes puissent être exécutées simultanément en parallèle. Cette technique est connue sous le nom de pipeline d'instructions et est utilisée pour augmenter le débit dans les processeurs modernes. Pour mieux comprendre cela, veuillez consulter cet exemple sur Wikipedia .
Généralement, les processeurs modernes ont des pipelines assez longs, mais pour plus de facilité, considérons ces 4 étapes uniquement.
Pipeline en 4 étapes en général pour 2 instructions.
Revenant à la question ci-dessus, considérons les instructions suivantes:
Sans prédiction de branche, les événements suivants se produiraient:
Pour exécuter l'instruction B ou l'instruction C, le processeur devra attendre que l'instruction A n'atteigne pas l'étape EX dans le pipeline, car la décision d'aller à l'instruction B ou à l'instruction C dépend du résultat de l'instruction A. Ainsi, le pipeline ressemblera à ceci.
quand si la condition retourne vraie:
Quand si la condition retourne false:
En raison de l'attente du résultat de l'instruction A, le nombre total de cycles CPU dépensés dans le cas ci-dessus (sans prédiction de branche; pour vrai et faux) est de 7.
Alors, quelle est la prédiction de branche?
Le prédicteur de branche essaiera de deviner dans quelle direction une branche (une structure si-alors-autre) ira avant que cela ne soit sûr. Il n'attendra pas que l'instruction A atteigne l'étape EX du pipeline, mais il devinera la décision et ira à cette instruction (B ou C dans le cas de notre exemple).
En cas de supposition correcte, le pipeline ressemble à ceci:
S'il est détecté ultérieurement que la supposition était erronée, les instructions partiellement exécutées sont ignorées et le pipeline recommence avec la branche correcte, ce qui entraîne un retard. Le temps perdu en cas de mauvaise prédiction de branche est égal au nombre d'étapes dans le pipeline de l'étape de récupération à l'étape d'exécution. Les microprocesseurs modernes ont tendance à avoir des pipelines assez longs, de sorte que le retard de mauvaise prévision se situe entre 10 et 20 cycles d'horloge. Plus le pipeline est long, plus le besoin d'un bon prédicteur de branche est grand .
Dans le code de l'OP, la première fois que le conditionnel, le prédicteur de branche n'a aucune information pour baser la prédiction, donc la première fois il choisira au hasard l'instruction suivante. Plus tard dans la boucle for, il peut baser la prédiction sur l'historique. Pour un tableau trié par ordre croissant, il existe trois possibilités:
Supposons que le prédicteur assume toujours la vraie branche lors de la première exécution.
Donc dans le premier cas, il prendra toujours la vraie branche puisque historiquement toutes ses prédictions sont correctes. Dans le 2ème cas, au départ, il prédira mal, mais après quelques itérations, il prédira correctement. Dans le 3ème cas, il prédira initialement correctement jusqu'à ce que les éléments soient inférieurs à 128. Après quoi il échouera pendant un certain temps et se corrigera lui-même lorsqu'il verra un échec de prédiction de branche dans l'histoire.
Dans tous ces cas, l'échec sera trop peu nombreux et, par conséquent, il faudra seulement quelques fois ignorer les instructions partiellement exécutées et recommencer avec la bonne branche, ce qui entraînera moins de cycles CPU.
Mais dans le cas d'un tableau aléatoire non trié, la prédiction devra ignorer les instructions partiellement exécutées et recommencer avec la bonne branche la plupart du temps et entraîner plus de cycles CPU par rapport au tableau trié.
la source
Une réponse officielle serait de
Vous pouvez également voir sur ce joli diagramme pourquoi le prédicteur de branche est confus.
Chaque élément du code d'origine est une valeur aléatoire
donc le prédicteur changera de côté comme le
std::rand()
coup.D'un autre côté, une fois qu'il est trié, le prédicteur passera d'abord dans un état de fortement non pris et lorsque les valeurs passeront à la valeur élevée, le prédicteur changera en trois passages de fortement non pris à fortement pris.
la source
Dans la même ligne (je pense que cela n'a été mis en évidence par aucune réponse), il est bon de mentionner que parfois (spécialement dans les logiciels où les performances sont importantes, comme dans le noyau Linux), vous pouvez trouver des instructions if comme les suivantes:
ou similaire:
Les deux
likely()
etunlikely()
sont en fait des macros qui sont définies en utilisant quelque chose comme les GCC__builtin_expect
pour aider le compilateur à insérer le code de prédiction pour favoriser la condition en tenant compte des informations fournies par l'utilisateur. GCC prend en charge d'autres modules internes qui pourraient modifier le comportement du programme en cours d'exécution ou émettre des instructions de bas niveau comme la suppression du cache, etc. Consultez cette documentation qui passe par les modules internes disponibles de GCC.Normalement, ce type d'optimisations se trouve principalement dans les applications en temps réel ou les systèmes embarqués où le temps d'exécution est important et critique. Par exemple, si vous recherchez une condition d'erreur qui ne se produit que 1/10000000 fois, alors pourquoi ne pas en informer le compilateur? De cette façon, par défaut, la prédiction de branche supposerait que la condition est fausse.
la source
Les opérations booléennes fréquemment utilisées en C ++ produisent de nombreuses branches dans le programme compilé. Si ces branches se trouvent à l'intérieur de boucles et sont difficiles à prévoir, elles peuvent ralentir considérablement l'exécution. Les variables booléennes sont stockées sous forme d'entiers 8 bits avec la valeur
0
pourfalse
et1
pourtrue
.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
ou1
, mais les opérateurs qui ont des booléens en sortie ne peuvent produire aucune autre valeur que0
ou1
. Cela rend les opérations avec des variables booléennes en entrée moins efficaces que nécessaire. Prenons l'exemple:Ceci est généralement implémenté par le compilateur de la manière suivante:
Ce code est loin d'être optimal. Les succursales peuvent prendre beaucoup de temps en cas de mauvaises prévisions. 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
et1
. 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. Le code ci-dessus peut être optimisé sia
etb
a été initialisé aux valeurs valides ou si elles proviennent d'opérateurs qui produisent sortie booléenne. Le code optimisé ressemble à ceci:char
est utilisé à la place debool
afin de permettre d'utiliser les opérateurs au niveau du bit (&
et|
) au lieu des opérateurs booléens (&&
et||
). Les opérateurs au niveau du bit sont des instructions uniques qui ne prennent qu'un seul cycle d'horloge. L'opérateur OR (|
) fonctionne même sia
etb
a d'autres valeurs que0
ou1
. L'opérateur ET (&
) et l'opérateur OU EXCLUSIF (^
) peuvent donner des résultats incohérents si les opérandes ont d'autres valeurs que0
et1
.~
ne peut pas être utilisé pour NOT. Au lieu de cela, vous pouvez faire un booléen NOT sur une variable qui est connue pour être0
ou en le1
faisant XOR avec1
:peut être optimisé pour:
a && b
ne peut pas être remplacé para & b
ifb
est une expression qui ne doit pas être évaluée sia
isfalse
(&&
n'évaluera pasb
,&
sera). De même,a || b
ne peut pas être remplacé para | b
ifb
est une expression qui ne doit pas être évaluée sia
istrue
.L'utilisation d'opérateurs au niveau du bit est plus avantageuse si les opérandes sont des variables que si les opérandes sont des comparaisons:
est optimal dans la plupart des cas (sauf si vous vous attendez à ce que l'
&&
expression génère de nombreuses erreurs de prédiction de branche).la source
Ça c'est sûr!...
La prédiction de branche rend la logique plus lente, à cause de la commutation qui se produit dans votre code! C'est comme si vous allez dans une rue droite ou une rue avec beaucoup de tournants, c'est sûr que la ligne droite se fera plus vite! ...
Si le tableau est trié, votre condition est fausse à la première étape
data[c] >= 128
:, devient alors une vraie valeur pour tout le chemin jusqu'au bout de la rue. C'est ainsi que vous arrivez plus rapidement à la fin de la logique. D'autre part, en utilisant un tableau non trié, vous avez besoin de beaucoup de tournage et de traitement qui rendent votre code plus lent à coup sûr ...Regardez l'image que j'ai créée pour vous ci-dessous. Quelle rue va finir plus vite?
Donc, par programme, la prédiction de branche ralentit le processus ...
Enfin, il est bon de savoir que nous avons deux types de prédictions de branche qui affecteront chacune votre code différemment:
1. Statique
2. Dynamique
la source
Cette question a déjà reçu d'excellentes réponses à plusieurs reprises. Je voudrais quand même attirer l'attention du groupe sur une autre analyse intéressante.
Récemment, cet exemple (modifié très légèrement) a également été utilisé comme moyen de montrer comment un morceau de code peut être profilé dans le programme lui-même sous Windows. En cours de route, l'auteur montre également comment utiliser les résultats pour déterminer où le code passe la plupart de son temps dans le cas trié et non trié. Enfin, l'article montre également comment utiliser une fonctionnalité peu connue de la couche d'abstraction matérielle (HAL) pour déterminer à quel point une mauvaise prédiction de branche se produit dans le cas non trié.
Le lien est ici: http://www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm
la source
When the input is unsorted, all the rest of the loop takes substantial time. But with sorted input, the processor is somehow able to spend not just less time in the body of the loop, meaning the buckets at offsets 0x18 and 0x1C, but vanishingly little time on the mechanism of looping.
auteur essaie de discuter du profilage dans le contexte du code publié ici et dans le processus en essayant d'expliquer pourquoi le cas trié est tellement plus rapide.Comme ce qui a déjà été mentionné par d'autres, ce qui se cache derrière le mystère est Branch Predictor .
Je n'essaye pas d'ajouter quelque chose mais d'expliquer le concept d'une autre manière. Il y a une introduction concise sur le wiki qui contient du texte et un diagramme. J'aime bien l'explication ci-dessous qui utilise un diagramme pour élaborer intuitivement le prédicteur de branche.
Sur la base du scénario décrit, j'ai écrit une démo d'animation pour montrer comment les instructions sont exécutées dans un pipeline dans différentes situations.
L'exemple contient trois instructions et la première est une instruction de saut conditionnel. Les deux dernières instructions peuvent entrer dans le pipeline jusqu'à l'exécution de l'instruction de saut conditionnel.
Il faudra 9 cycles d'horloge pour terminer 3 instructions.
Il faudra 7 cycles d'horloge pour terminer 3 instructions.
Il faudra 9 cycles d'horloge pour terminer 3 instructions.
Comme vous pouvez le voir, il semble que nous n'ayons aucune raison de ne pas utiliser Branch Predictor.
C'est une démo assez simple qui clarifie la partie très basique de Branch Predictor. Si ces gifs sont ennuyeux, n'hésitez pas à les supprimer de la réponse et les visiteurs peuvent également obtenir le code source de démonstration en direct de BranchPredictorDemo
la source
if()
bloc peut s'exécuter avant que la condition de branchement ne soit connue. Ou pour une boucle de recherche commestrlen
oumemchr
, les interactions peuvent se chevaucher. Si vous deviez attendre que le résultat de correspondance ou non soit connu avant d'exécuter l'une des prochaines itérations, vous goulot d'étranglement sur la charge du cache + latence ALU au lieu du débit.Gain de prédiction de branche!
Il est important de comprendre qu'une mauvaise prédiction de branche ne ralentit pas les programmes. Le coût d'une prédiction manquée est comme si la prédiction de branche n'existait pas et que vous attendiez l'évaluation de l'expression pour décider du code à exécuter (plus d'explications dans le paragraphe suivant).
Chaque fois qu'il y a une instruction
if-else
\switch
, l'expression doit être évaluée pour déterminer quel bloc doit être exécuté. Dans le code assembleur généré par le compilateur, des instructions de branchement conditionnel sont insérées.Une instruction de branchement peut amener un ordinateur à commencer à exécuter une séquence d'instructions différente et ainsi s'écarter de son comportement par défaut d'exécution des instructions dans l'ordre (c'est-à-dire si l'expression est fausse, le programme saute le code du
if
bloc) en fonction d'une condition, qui est l'évaluation de l'expression dans notre cas.Cela étant dit, le compilateur essaie de prédire le résultat avant qu'il ne soit réellement évalué. Il récupérera les instructions du
if
bloc, et si l'expression s'avère vraie, alors c'est merveilleux! Nous avons gagné du temps pour l'évaluer et progressé dans le code; sinon, nous exécutons le mauvais code, le pipeline est vidé et le bloc correct est exécuté.Visualisation:
Disons que vous devez choisir l'itinéraire 1 ou l'itinéraire 2. En attendant que votre partenaire vérifie la carte, vous vous êtes arrêté à ## et avez attendu, ou vous pouvez simplement choisir l'itinéraire1 et si vous avez de la chance (l'itinéraire 1 est le bon itinéraire), alors super, vous n'avez pas eu à attendre que votre partenaire vérifie la carte (vous avez économisé le temps qu'il lui aurait fallu pour vérifier la carte), sinon vous reviendrez simplement en arrière.
Alors que le rinçage des pipelines est super rapide, prendre ce pari en vaut la peine de nos jours. Prédire des données triées ou des données qui changent lentement est toujours plus facile et meilleur que de prédire des changements rapides.
la source
Sur ARM, aucune branche n'est nécessaire, car chaque instruction a un champ de condition de 4 bits, qui teste (à un coût nul) l'une des 16 conditions différentes qui peuvent survenir dans le registre d'état du processeur, et si la condition sur une instruction est false, l'instruction est ignorée. Cela élimine le besoin de branches courtes et il n'y aurait pas de prédiction de branche pour cet algorithme. Par conséquent, la version triée de cet algorithme s'exécuterait plus lentement que la version non triée sur ARM, en raison de la surcharge supplémentaire de tri.
La boucle interne de cet algorithme ressemblerait à ce qui suit dans le langage d'assemblage ARM:
Mais cela fait en fait partie d'une image plus grande:
CMP
Les opcodes mettent toujours à jour les bits d'état dans le registre d'état du processeur (PSR), car c'est leur objectif, mais la plupart des autres instructions ne touchent pas le PSR sauf si vous ajoutez unS
suffixe facultatif à l'instruction, spécifiant que le PSR doit être mis à jour en fonction de la résultat de l'instruction. Tout comme le suffixe de condition 4 bits, être capable d'exécuter des instructions sans affecter le PSR est un mécanisme qui réduit le besoin de branches sur ARM, et facilite également la répartition hors service au niveau matériel , car après avoir effectué une opération X qui met à jour les bits d'état, par la suite (ou en parallèle), vous pouvez effectuer un tas d'autres travaux qui ne devraient explicitement pas affecter les bits d'état, puis vous pouvez tester l'état des bits d'état définis précédemment par X.Le champ de test de condition et le champ facultatif "bit d'état défini" peuvent être combinés, par exemple:
ADD R1, R2, R3
fonctionneR1 = R2 + R3
sans mettre à jour aucun bit d'état.ADDGE R1, R2, R3
effectue la même opération que si une instruction précédente qui a affecté les bits d'état a entraîné une condition supérieure à ou égale.ADDS R1, R2, R3
effectue l'addition puis met à jour lesN
,Z
,C
etV
drapeaux dans le statut du processeur en fonction de registre si le résultat est négatif, zéro, Adoptée (pour l' addition non signé), ou Débordés (pour plus signé).ADDSGE R1, R2, R3
effectue l'ajout uniquement si leGE
test est vrai, puis met à jour les bits d'état en fonction du résultat de l'addition.La plupart des architectures de processeur n'ont pas cette capacité de spécifier si les bits d'état doivent être mis à jour pour une opération donnée, ce qui peut nécessiter l'écriture de code supplémentaire pour enregistrer et restaurer ultérieurement les bits d'état, ou peut nécessiter des branches supplémentaires, ou peut limiter la sortie du processeur de l'efficacité d'exécution des ordres: l'un des effets secondaires de la plupart des architectures de jeux d'instructions CPU mettant à jour de force les bits d'état après la plupart des instructions est qu'il est beaucoup plus difficile de déterminer quelles instructions peuvent être exécutées en parallèle sans interférer les unes avec les autres. La mise à jour des bits d'état a des effets secondaires, a donc un effet de linéarisation sur le code.La capacité d'ARM de mélanger et de faire correspondre les tests de condition sans branche sur n'importe quelle instruction avec la possibilité de mettre à jour ou de ne pas mettre à jour les bits d'état après qu'une instruction soit extrêmement puissante, pour les programmeurs et les compilateurs en langage assembleur, et produit un code très efficace.
Si vous vous êtes déjà demandé pourquoi ARM a connu un succès si phénoménal, l'efficacité brillante et l'interaction de ces deux mécanismes sont une grande partie de l'histoire, car ils sont l'une des plus grandes sources d'efficacité de l'architecture ARM. La brillance des concepteurs originaux de l'ARM ISA en 1983, Steve Furber et Roger (maintenant Sophie) Wilson, ne peut pas être surestimée.
la source
R2 = data + arraySize
, puis commencez parR1 = -arraySize
. Le bas de la boucle devientadds r1, r1, #1
/bnz inner_loop
. Les compilateurs n'utilisent pas cette optimisation pour une raison quelconque: / Mais de toute façon, l'exécution prédite de l'add n'est pas fondamentalement différente dans ce cas de ce que vous pouvez faire avec du code sans branche sur d'autres ISA, comme x86cmov
. Bien que ce ne soit pas aussi agréable: l' indicateur d'optimisation gcc -O3 rend le code plus lent que -O2cmov
avec un opérande source de mémoire. La plupart des ISA, y compris AArch64, n'ont que des opérations de sélection ALU. La prédication ARM peut donc être puissante, et utilisable plus efficacement que le code sans branche sur la plupart des ISA.)Il s'agit de prédiction de branche. Qu'Est-ce que c'est?
Un prédicteur de branche est l'une des anciennes techniques d'amélioration des performances qui trouve toujours sa pertinence dans les architectures modernes. Bien que les techniques de prédiction simples fournissent une recherche rapide et une efficacité énergétique, elles souffrent d'un taux d'erreurs de prédiction élevé.
D'un autre côté, les prédictions de branchement complexes - basées sur des neurones ou des variantes de prédiction de branche à deux niveaux - offrent une meilleure précision de prédiction, mais elles consomment plus de puissance et la complexité augmente de façon exponentielle.
De plus, dans les techniques de prédiction complexes, le temps nécessaire pour prédire les branches est lui-même très élevé - allant de 2 à 5 cycles - ce qui est comparable au temps d'exécution des branches réelles.
La prédiction de branche est essentiellement un problème d'optimisation (minimisation) où l'accent est mis sur la réalisation du taux de défaillance le plus bas possible, une faible consommation d'énergie et une faible complexité avec des ressources minimales.
Il existe en réalité trois types de branches différentes:
Branches conditionnelles de transfert - en fonction d'une condition d'exécution, le PC (compteur de programmes) est modifié pour pointer vers une adresse de transfert dans le flux d'instructions.
Branches conditionnelles arrière - le PC est modifié pour pointer vers l'arrière dans le flux d'instructions. La branche est basée sur une condition, telle que la ramification vers l'arrière au début d'une boucle de programme lorsqu'un test à la fin de la boucle indique que la boucle doit être exécutée à nouveau.
Branches inconditionnelles - cela inclut les sauts, les appels de procédure et les retours qui n'ont aucune condition spécifique. Par exemple, une instruction de saut inconditionnelle peut être codée en langage assembleur comme simplement "jmp", et le flux d'instructions doit être immédiatement dirigé vers l'emplacement cible pointé par l'instruction de saut, tandis qu'un saut conditionnel qui peut être codé comme "jmpne" redirigerait le flux d'instructions uniquement si le résultat d'une comparaison de deux valeurs dans une précédente instruction "comparer" montre que les valeurs ne sont pas égales. (Le schéma d'adressage segmenté utilisé par l'architecture x86 ajoute une complexité supplémentaire, car les sauts peuvent être "proches" (dans un segment) ou "éloignés" (en dehors du segment). Chaque type a des effets différents sur les algorithmes de prédiction de branche.)
Prédiction de branche statique / dynamique : la prédiction de branche statique est utilisée par le microprocesseur la première fois qu'une branche conditionnelle est rencontrée, et la prédiction de branche dynamique est utilisée pour les exécutions successives du code de branche conditionnelle.
Références:
Prédicteur de branche
Une démonstration de l'auto-profilage
Examen des prévisions de succursales
Prédiction de branche
la source
Outre le fait que la prédiction de branche peut vous ralentir, un tableau trié présente un autre avantage:
Vous pouvez avoir une condition d'arrêt au lieu de simplement vérifier la valeur, de cette façon, vous bouclez uniquement sur les données pertinentes et ignorez le reste.
La prédiction de branche ne manquera qu'une seule fois.
la source
Les tableaux triés sont traités plus rapidement qu'un tableau non trié, en raison d'un phénomène appelé prédiction de branche.
Le prédicteur de branche est un circuit numérique (en architecture informatique) essayant de prédire dans quelle direction ira une branche, améliorant le flux dans le pipeline d'instructions. Le circuit / ordinateur prédit l'étape suivante et l'exécute.
Faire une mauvaise prédiction conduit à revenir à l'étape précédente et à exécuter une autre prédiction. En supposant que la prédiction est correcte, le code passera à l'étape suivante. Une mauvaise prédiction entraîne la répétition de la même étape, jusqu'à ce qu'une prédiction correcte se produise.
La réponse à votre question est très simple.
Dans un tableau non trié, l'ordinateur fait plusieurs prédictions, ce qui augmente le risque d'erreurs. Alors que, dans un tableau trié, l'ordinateur fait moins de prédictions, ce qui réduit le risque d'erreurs. Faire plus de prédictions demande plus de temps.
Tableau trié: route droite ____________________________________________________________________________________ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT
Réseau non trié: route incurvée
Prédiction de branche: deviner / prédire quelle route est droite et la suivre sans vérifier
Bien que les deux routes atteignent la même destination, la route droite est plus courte et l'autre plus longue. Si alors vous choisissez l'autre par erreur, il n'y a pas de retour en arrière, et vous perdrez donc du temps supplémentaire si vous choisissez la route la plus longue. C'est semblable à ce qui se passe dans l'ordinateur, et j'espère que cela vous a aidé à mieux comprendre.
Je veux également citer @Simon_Weaver dans les commentaires:
la source
J'ai essayé le même code avec MATLAB 2011b avec mon MacBook Pro (Intel i7, 64 bits, 2,4 GHz) pour le code MATLAB suivant:
Les résultats pour le code MATLAB ci-dessus sont les suivants:
Les résultats du code C comme dans @GManNickG j'obtiennent:
Sur cette base, il semble que MATLAB soit presque 175 fois plus lent que l'implémentation C sans tri et 350 fois plus lent avec tri. En d'autres termes, l'effet (de la prédiction de branchement) est 1,46x pour l'implémentation MATLAB et 2,7x pour l'implémentation C.
la source
L'hypothèse des autres réponses selon laquelle il faut trier les données n'est pas correcte.
Le code suivant ne trie pas l'intégralité du tableau, mais uniquement des segments de 200 éléments, et s'exécute ainsi le plus rapidement.
Le fait de trier uniquement les sections d'éléments k termine le prétraitement en temps linéaire
O(n)
, plutôt qu'enO(n.log(n))
temps nécessaire pour trier l'ensemble du tableau.Cela "prouve" également que cela n'a rien à voir avec un problème algorithmique tel que l'ordre de tri, et c'est en effet une prédiction de branche.
la source
pcmpgtb
pour trouver des éléments avec leur bit élevé, puis ET pour mettre à zéro des éléments plus petits). Passer du temps à trier des morceaux serait plus lent. Une version sans branche aurait des performances indépendantes des données, prouvant également que le coût provenait d'une mauvaise prédiction de branche. Ou utilisez simplement des compteurs de performance pour observer cela directement, comme Skylakeint_misc.clear_resteer_cycles
ouint_misc.recovery_cycles
pour compter les cycles inactifs frontaux des erreurs de prévisionRéponse de Bjarne Stroustrup à cette question:
Cela ressemble à une question d'entrevue. Est-ce vrai? Comment saurais tu? C'est une mauvaise idée de répondre aux questions sur l'efficacité sans d'abord faire quelques mesures, il est donc important de savoir comment mesurer.
J'ai donc essayé avec un vecteur d'un million d'entiers et obtenu:
J'ai couru ça plusieurs fois pour être sûr. Oui, le phénomène est réel. Mon code clé était:
Au moins, le phénomène est réel avec ce compilateur, cette bibliothèque standard et ces paramètres d'optimisation. Différentes implémentations peuvent donner et donnent des réponses différentes. En fait, quelqu'un a fait une étude plus systématique (une recherche rapide sur le Web le trouvera) et la plupart des implémentations montrent cet effet.
L'une des raisons est la prédiction de branche: l'opération clé dans l'algorithme de tri est
“if(v[i] < pivot]) …”
ou équivalente. Pour une séquence triée, ce test est toujours vrai alors que, pour une séquence aléatoire, la branche choisie varie de façon aléatoire.Une autre raison est que lorsque le vecteur est déjà trié, nous n'avons jamais besoin de déplacer les éléments à leur position correcte. L'effet de ces petits détails est le facteur de cinq ou six que nous avons vu.
Quicksort (et le tri en général) est une étude complexe qui a attiré certains des plus grands esprits de l'informatique. Une bonne fonction de tri résulte à la fois du choix d'un bon algorithme et de l'attention portée aux performances matérielles dans sa mise en œuvre.
Si vous voulez écrire du code efficace, vous devez en savoir un peu sur l'architecture de la machine.
la source
Cette question est enracinée dans les modèles de prédiction de branche sur les processeurs. Je recommanderais de lire ce document:
Augmentation du taux de récupération des instructions via la prédiction de branches multiples et un cache d'adresses de branche
Lorsque vous avez trié des éléments, IR ne pouvait pas être dérangé pour récupérer toutes les instructions du processeur, encore et encore, il les récupère du cache.
la source
Une façon d'éviter les erreurs de prédiction de branche consiste à créer une table de recherche et à l'indexer à l'aide des données. Stefan de Bruijn en a parlé dans sa réponse.
Mais dans ce cas, nous savons que les valeurs sont dans la plage [0, 255] et nous ne nous soucions que des valeurs> = 128. Cela signifie que nous pouvons facilement extraire un seul bit qui nous dira si nous voulons une valeur ou non: en décalant les données à droite 7 bits, nous nous retrouvons avec un bit 0 ou 1 bit, et nous voulons seulement ajouter la valeur lorsque nous avons un bit. Appelons ce bit le "bit de décision".
En utilisant la valeur 0/1 du bit de décision comme index dans un tableau, nous pouvons créer un code qui sera tout aussi rapide que les données soient triées ou non. Notre code ajoutera toujours une valeur, mais lorsque le bit de décision est 0, nous ajouterons la valeur quelque part qui nous importe peu. Voici le code:
// Test
Ce code gaspille la moitié des ajouts mais n'a jamais d'échec de prédiction de branche. C'est extrêmement plus rapide sur des données aléatoires que la version avec une instruction if réelle.
Mais dans mes tests, une table de recherche explicite était légèrement plus rapide que cela, probablement parce que l'indexation dans une table de recherche était légèrement plus rapide que le décalage de bits. Cela montre comment mon code s'installe et utilise la table de recherche (appelée sans ambiguïté lut pour "LookUp Table" dans le code). Voici le code C ++:
// Déclarez puis remplissez la table de recherche
Dans ce cas, la table de recherche n'était que de 256 octets, elle s'intègre donc bien dans un cache et tout était rapide. Cette technique ne fonctionnerait pas bien si les données étaient des valeurs 24 bits et nous n'en voulions que la moitié ... la table de recherche serait beaucoup trop grande pour être pratique. D'autre part, nous pouvons combiner les deux techniques présentées ci-dessus: d'abord décaler les bits, puis indexer une table de recherche. Pour une valeur de 24 bits que nous ne voulons que la demi-valeur supérieure, nous pourrions potentiellement déplacer les données vers la droite de 12 bits et se retrouver avec une valeur de 12 bits pour un index de table. Un index de table de 12 bits implique une table de 4096 valeurs, ce qui pourrait être pratique.
La technique d'indexation dans un tableau, au lieu d'utiliser une instruction if, peut être utilisée pour décider du pointeur à utiliser. J'ai vu une bibliothèque qui implémentait des arbres binaires et au lieu d'avoir deux pointeurs nommés (pLeft et pRight ou autre) avait un tableau de pointeurs de longueur 2 et utilisait la technique du "bit de décision" pour décider lequel suivre. Par exemple, au lieu de:
c'est une bonne solution peut-être que cela fonctionnera
la source
mask = tmp < 128 : 0 : -1UL;
/total += tmp & mask;