Répondant à une autre question de Stack Overflow ( celui-ci ) Je suis tombé sur un sous-problème intéressant. Quel est le moyen le plus rapide de trier un tableau de 6 entiers ?
Comme la question est de très bas niveau :
- on ne peut pas supposer que les bibliothèques sont disponibles (et l'appel lui-même a un coût), seul le C simple est disponible.
- afin d'éviter de vider le pipeline d'instructions (qui possède un très coût élevé), nous devrions probablement minimiser les branches, les sauts et tout autre type de rupture du flux de contrôle (comme ceux qui se cachent derrière les points de séquence dans && ou ||).
- Si l'espace est limité et qu'il faut minimiser l'utilisation des registres et de la mémoire, l'idéal est probablement de trier sur place.
En réalité, cette question est une sorte de Golf où le but n'est pas de minimiser la longueur de la source mais le temps d'exécution. J'appelle cela le code "Zening", comme utilisé dans le titre du livre. Optimisation du code Zen par Michael Abrash et son séquelles .
Quant à savoir pourquoi c'est intéressant, il y a plusieurs niveaux :
- l'exemple est simple et facile à comprendre et à mesurer, il n'implique pas beaucoup de compétences C
- il montre les effets du choix d'un bon algorithme pour le problème, mais aussi les effets du compilateur et du matériel sous-jacent.
Voici mon implémentation de référence (naïve, non optimisée) et mon jeu de test.
#include <stdio.h>
static __inline__ int sort6(int * d){
char j, i, imin;
int tmp;
for (j = 0 ; j < 5 ; j++){
imin = j;
for (i = j + 1; i < 6 ; i++){
if (d[i] < d[imin]){
imin = i;
}
}
tmp = d[j];
d[j] = d[imin];
d[imin] = tmp;
}
}
static __inline__ unsigned long long rdtsc(void)
{
unsigned long long int x;
__asm__ volatile (".byte 0x0f, 0x31" : "=A" (x));
return x;
}
int main(int argc, char ** argv){
int i;
int d[6][5] = {
{1, 2, 3, 4, 5, 6},
{6, 5, 4, 3, 2, 1},
{100, 2, 300, 4, 500, 6},
{100, 2, 3, 4, 500, 6},
{1, 200, 3, 4, 5, 600},
{1, 1, 2, 1, 2, 1}
};
unsigned long long cycles = rdtsc();
for (i = 0; i < 6 ; i++){
sort6(d[i]);
/*
* printf("d%d : %d %d %d %d %d %d\n", i,
* d[i][0], d[i][6], d[i][7],
* d[i][8], d[i][9], d[i][10]);
*/
}
cycles = rdtsc() - cycles;
printf("Time is %d\n", (unsigned)cycles);
}
Résultats bruts
Comme le nombre de variantes devient important, je les ai toutes rassemblées dans une suite de tests que l'on peut trouver à l'adresse suivante ici . Les tests réels utilisés sont un peu moins naïfs que ceux présentés ci-dessus, grâce à Kevin Stock. Vous pouvez le compiler et l'exécuter dans votre propre environnement. Je suis assez intéressé par le comportement sur différentes architectures cibles et compilateurs. (OK les gars, mettez-le dans les réponses, je vais +1 chaque contributeur d'un nouveau jeu de résultats).
J'ai donné la réponse à Daniel Stutzbach (pour le golf) il y a un an car il était à l'origine de la solution la plus rapide à l'époque (réseaux de tri).
Linux 64 bits, gcc 4.6.1 64 bits, Intel Core 2 Duo E8400, -O2
- Appel direct à la fonction de la bibliothèque qsort : 689.38
- Implémentation naïve (tri par insertion) : 285.70
- Tri d'insertion (Daniel Stutzbach) : 142.12
- Insertion Sort Unrolled : 125.47
- Ordre de classement : 102.26
- Ordre de classement avec les registres : 58.03
- Réseaux de tri (Daniel Stutzbach) : 111.68
- Réseaux de triage (Paul R) : 66.36
- Tri des réseaux 12 avec Fast Swap : 58.86
- Tri des réseaux 12 réordonnés Swap : 53.74
- Réseaux de triage 12 réordonnés Simple Swap : 31.54
- Réseau de triage réordonné avec échange rapide : 31.54
- Réseau de tri réordonné avec échange rapide V2 : 33.63
- Tri à bulles incluses (Paolo Bonzini) : 48.85
- Unrolled Insertion Sort (Paolo Bonzini) : 75.30
Linux 64 bits, gcc 4.6.1 64 bits, Intel Core 2 Duo E8400, -O1
- Appel direct à la fonction de la bibliothèque qsort : 705.93
- Implémentation naïve (tri par insertion) : 135.60
- Tri d'insertion (Daniel Stutzbach) : 142.11
- Insertion Sort Unrolled : 126.75
- Ordre de classement : 46.42
- Ordre de classement avec les registres : 43.58
- Réseaux de tri (Daniel Stutzbach) : 115.57
- Réseaux de triage (Paul R) : 64.44
- Tri des réseaux 12 avec Fast Swap : 61.98
- Tri des réseaux 12 réordonnés Swap : 54.67
- Réseaux de triage 12 réordonnés Simple Swap : 31.54
- Réseau de triage réordonné avec échange rapide : 31.24
- Réseau de tri réordonné avec échange rapide V2 : 33.07
- Tri à bulles en ligne (Paolo Bonzini) : 45.79
- Unrolled Insertion Sort (Paolo Bonzini) : 80.15
J'ai inclus les résultats de -O1 et -O2 parce que, étonnamment, pour plusieurs programmes, O2 est moins efficace que O1. Je me demande quelle optimisation spécifique a cet effet ?
Commentaires sur les solutions proposées
Tri par insertion (Daniel Stutzbach)
Comme prévu, minimiser les branches est en effet une bonne idée.
Réseaux de tri (Daniel Stutzbach)
Mieux que le tri par insertion. Je me suis demandé si l'effet principal n'était pas d'éviter la boucle externe. J'ai fait un essai en déroulant le tri d'insertion pour vérifier et en effet nous obtenons à peu près les mêmes chiffres (le code est ici ).
Réseaux de tri (Paul R)
Le meilleur jusqu'à présent. Le code réel que j'ai utilisé pour tester est ici . Nous ne savons pas encore pourquoi il est presque deux fois plus rapide que l'autre mise en œuvre du réseau de tri. Passage de paramètres ? Maximisation rapide ?
Tri des réseaux 12 SWAP avec Fast Swap
Comme suggéré par Daniel Stutzbach, j'ai combiné son réseau de tri de 12 swaps avec un swap rapide sans branche (le code est le suivant ici ). Il est effectivement plus rapide, le meilleur jusqu'à présent avec une petite marge (environ 5%) comme on pouvait s'y attendre en utilisant un swap de moins.
Il est également intéressant de noter que le swap sans branche semble être beaucoup (4 fois) moins efficace que le swap simple utilisant if sur l'architecture PPC.
Appel de la bibliothèque qsort
Pour donner un autre point de référence, j'ai également essayé, comme suggéré, d'appeler la bibliothèque qsort (le code est le suivant ici ). Comme prévu, il est beaucoup plus lent : 10 à 30 fois plus lent... comme il est devenu évident avec la nouvelle suite de tests, le problème principal semble être le chargement initial de la bibliothèque après le premier appel, et il ne se compare pas si mal avec les autres versions. Il est juste entre 3 et 20 fois plus lent sur mon Linux. Sur certaines architectures utilisées par d'autres pour les tests, il semble même être plus rapide (je suis vraiment surpris par cela, car la bibliothèque qsort utilise une API plus complexe).
Ordre de classement
Rex Kerr a proposé une autre méthode complètement différente : pour chaque élément du tableau, on calcule directement sa position finale. Cette méthode est efficace car le calcul de l'ordre de classement ne nécessite pas de branche. L'inconvénient de cette méthode est qu'elle nécessite trois fois la quantité de mémoire du tableau (une copie du tableau et des variables pour stocker les ordres de classement). Les résultats des performances sont très surprenants (et intéressants). Sur mon architecture de référence avec un système d'exploitation 32 bits et un Intel Core2 Quad E8300, le nombre de cycles était légèrement inférieur à 1000 (comme des réseaux de tri avec swap de branchement). Mais lorsqu'il a été compilé et exécuté sur ma machine 64 bits (Intel Core2 Duo), il s'est beaucoup mieux comporté : il est devenu le plus rapide à ce jour. J'ai finalement trouvé la vraie raison. Ma boîte 32 bits utilise gcc 4.4.1 et ma boîte 64 bits gcc 4.4.3 et la dernière semble bien meilleure pour optimiser ce code particulier (il y avait très peu de différence pour les autres propositions).
Tri des réseaux 12 avec Swap réordonné
L'efficacité étonnante de la proposition de Rex Kerr avec gcc 4.4.3 m'a fait me demander : comment un programme utilisant 3 fois plus de mémoire pouvait-il être plus rapide que les réseaux de tri sans branche ? Mon hypothèse était qu'il avait moins de dépendances du type lecture après écriture, ce qui permettait une meilleure utilisation de l'ordonnanceur d'instructions superscalaires du x86. Cela m'a donné une idée : réorganiser les swaps pour minimiser les dépendances de type lecture après écriture. Plus simplement : lorsque vous faites SWAP(1, 2); SWAP(0, 2);
vous devez attendre que le premier échange soit terminé avant d'effectuer le second, car tous deux accèdent à une cellule de mémoire commune. Lorsque vous effectuez SWAP(1, 2); SWAP(4, 5);
le processeur peut exécuter les deux en parallèle. Je l'ai essayé et cela fonctionne comme prévu, les réseaux de tri s'exécutent environ 10% plus vite.
Tri des réseaux 12 avec Simple Swap
Un an après le post original, Steinar H. Gunderson a suggéré que nous ne devrions pas essayer d'être plus malins que le compilateur et garder le code swap simple. C'est en effet une bonne idée car le code résultant est environ 40% plus rapide ! Il a également proposé un swap optimisé à la main en utilisant du code d'assemblage en ligne x86 qui peut encore épargner quelques cycles supplémentaires. Le plus surprenant (cela en dit long sur la psychologie des programmeurs) est qu'il y a un an, aucun d'entre nous n'a essayé cette version du swap. Le code que j'ai utilisé pour tester est ici . D'autres ont suggéré d'autres façons d'écrire un swap rapide en C, mais il donne les mêmes performances que le simple swap avec un compilateur décent.
Le "meilleur" code est maintenant le suivant :
static inline void sort6_sorting_network_simple_swap(int * d){
#define min(x, y) (x<y?x:y)
#define max(x, y) (x<y?y:x)
#define SWAP(x,y) { const int a = min(d[x], d[y]); const int b = max(d[x], d[y]); d[x] = a; d[y] = b;}
SWAP(1, 2);
SWAP(4, 5);
SWAP(0, 2);
SWAP(3, 5);
SWAP(0, 1);
SWAP(3, 4);
SWAP(1, 4);
SWAP(0, 3);
SWAP(2, 5);
SWAP(1, 3);
SWAP(2, 4);
SWAP(2, 3);
#undef SWAP
#undef min
#undef max
}
Si l'on en croit notre jeu de tests (et, oui, il est assez pauvre, son seul avantage est d'être court, simple et facile à comprendre ce que nous mesurons), le nombre moyen de cycles du code résultant pour un tri est inférieur à 40 cycles (6 tests sont exécutés). Cela met chaque échange à une moyenne de 4 cycles. J'appelle cela incroyablement rapide. D'autres améliorations sont-elles possibles ?
0 votes
Il ne s'agit pas vraiment d'un golf si l'on ne peut pas noter objectivement les réponses. Vous devez donc spécifier une architecture particulière (et préciser si elle sera notée sur la base du cas moyen ou du cas le plus défavorable).
0 votes
Ints sur un GPU... pouvez-vous utiliser des floats à la place ? Vous disposez alors de fonctions min/max. (Au moins, GLSL ne supporte pas min/max pour les ints.) De plus, il est probablement plus rapide d'utiliser deux fonctions
vec3
ou des types similaires au lieu d'un tableau, de sorte que vous pouvez utiliser le swizzling pour trier.0 votes
@Thomas : oui, vous avez raison, il serait plus logique d'utiliser un tableau de flottants. Le type de contenu du vecteur n'est pas vraiment pertinent pour le problème, si ce n'est qu'il s'agit d'une sorte de type "intégré" contenu dans un registre.
0 votes
@caf : Je vais utiliser un timer de référence qui lit le registre de cycle, comme celui-ci fit.vutbr.cz/~peringer/SIMLIB/doc/html/rdtsc_8h-source.html . Dans le contexte du GPU, je sais que c'est de la triche, mais cela permet de conserver des règles simples pour le golf.
2 votes
Avez-vous des contraintes sur les ints ? Par exemple, peut-on supposer que pour tout 2 x,y
x-y
yx+y
ne provoquera pas de dépassement de capacité ?0 votes
Dans le contexte je n'ai pas d'information sur l'int (vous pouvez imaginer qu'il s'agit de pixels arbitraires codant des couleurs), donc non, désolé que vous ne puissiez pas épargner un peu pour le tour de swap ;-)
3 votes
Vous devriez essayer de combiner mon réseau de tri à 12 swaps avec la fonction de swap sans branche de Paul. Sa solution passe tous les paramètres comme des éléments séparés sur la pile au lieu d'un pointeur unique vers un tableau. Cela pourrait également faire la différence.
0 votes
@kriss : En tant que non-C, je suis assez fasciné par ce post. Je me demande juste ce que
-O1
,-O2
etc. Sont-ils des niveaux d'optimisation du compilateur ?0 votes
@Zaid : oui, ce sont les ensembles d'optimisations prédéfinis de gcc (vous pouvez aussi définir les caractéristiques d'optimisation individuellement). Vous pouvez voir ici gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html Quelles sont ces optimisations ? D'autres compilateurs ont des drapeaux d'optimisations similaires.
0 votes
Excellente analyse, et bien vu de réorganiser les échanges. D'après la page à laquelle j'ai fait un lien et qui génère les macros SWAP, il semble que vous puissiez obtenir un ordre optimal avec l'option "Afficher le réseau en utilisant des caractères de texte" au lieu de l'option "Créer un ensemble de macros SWAP". Je vais envoyer un courriel à l'auteur pour voir s'il peut obtenir les macros SWAP dans l'ordre optimal.
2 votes
Notez que l'implémentation correcte de rdtsc sur 64-bit est
__asm__ volatile (".byte 0x0f, 0x31; shlq $32, %%rdx; orq %%rdx, %0" : "=a" (x) : : "rdx");
parce que rdtsc met la réponse dans EDX:EAX alors que GCC l'attend dans un seul registre de 64 bits. Vous pouvez voir le bogue en compilant à -O3. Voir aussi ci-dessous mon commentaire à Paul R à propos d'un SWAP plus rapide.0 votes
Quelques conseils - 6 boucles ne sont pas suffisantes pour toutes les plateformes, vous devriez également utiliser un cas de test puissance de deux pour permettre la modulation par puissance de deux avec et répéter plusieurs fois sans polluer les résultats avec des opérations de modulation potentiellement coûteuses. Voici quelques résultats pour une plateforme que je ne pense pas pouvoir nommer pour cause de NDA, mais qui utilise une architecture PPC :
0 votes
Appel direct à la fonction de la bibliothèque qsort : 101 Implémentation naïve (tri par insertion) : 299 Tri par insertion (Daniel Stutzbach) : 108 Insertion Sort Unrolled : 51 Réseaux de tri (Daniel Stutzbach) : 26 Réseaux de tri (Paul R) : 85 Tri des réseaux 12 avec Fast Swap : 117 Tri des réseaux 12 réordonnés Swap : 116 Ordre de classement : 56
1 votes
J'ai un problème avec les réseaux de triage 12 avec code d'échange rapide : Regarde les fonctions min & max : Il y a un élément commun qui contient une branche.
1 votes
Peut-être que le compilateur est assez intelligent pour le faire pour vous, mais une petite accélération dans le SWAP min/max semble être disponible en réutilisant la valeur xor de min/max, comme ceci :
int s = (x^y) & -(x<y); int t = y^s; y = x^s; x = t;
(Remplacez {x,y} par d[{x,y}] ; je voulais que le bout de code reste lisible).0 votes
@Loren, il n'y a pas de branche de l'informatique
x<y
il s'agit simplement d'une opération binaire, tout comme + ou *. Si c'était dans unif
par exemple, cela pourrait provoquer un branchement, mais ce n'est pas le cas, ce qui est la beauté de cette implémentation du swap - elle nous donne un comportement conditionnel sans saut.3 votes
@Tyler : Comment l'implémenter au niveau de l'assemblage sans branche ?
0 votes
@Loren, c'est juste une instruction cmp qui active un bit d'état.
0 votes
Tu fais Sélection Tri et non le tri par insertion. Sinon, excellente analyse, avec des preuves convaincantes. L'optimisation du swap-order est très intéressante.
0 votes
@Loren, je pense que vous dites "le CPU doit avoir un comportement différent en fonction de si x<y ou non" ce qui est vrai, mais le CPU prend des décisions tout le temps sans modifier le flux de contrôle -- par exemple il doit décider pendant l'addition si un certain bit de sortie est 1 ou 0 en fonction des bits d'entrée. Le mot "branchement" signifie spécifiquement un saut vers un autre ensemble d'instructions, de sorte que le flux de contrôle est modifié. La différence est importante, car la réduction du nombre de branchements permet à l'unité centrale d'anticiper les instructions futures et de commencer à les traiter avant la fin de la dernière instruction, ce que l'on appelle le pipelining.
0 votes
Quel est l'intérêt de minimiser la vitesse d'exécution ?
0 votes
@Tyler : Je suppose que mon assemblage est trop rouillé car je ne me souviens d'aucun moyen d'obtenir ce bit d'état dans un registre sans passer par beaucoup de cerceaux (pousser les drapeaux, pop AX, masquer et puis shift. 4 cycles minimum {cela fait un LONGUEUR depuis que j'ai regardé les cycles utilisés, je pense que ce sont toutes des opérations à un cycle de nos jours} et un blocage mémoire). Le seul bit que je me rappelle avoir pu utiliser directement est l'addition avec report.
4 votes
@Loren :
CMP EAX, EBX; SBB EAX, EAX
mettra soit 0 soit 0xFFFFFFFF dansEAX
selon queEAX
est plus grand ou plus petit queEBX
respectivement.SBB
est "soustraire avec emprunt", la contrepartie deADC
("add with carry") ; le bit d'état auquel vous faites référence es le bit de report. Mais encore une fois, je me souviens queADC
ySBB
avait une latence et un débit terribles sur le Pentium 4 par rapport au Pentium 5.ADD
ySUB
et étaient encore deux fois plus lents sur les processeurs Core. Depuis le 80386, il existe égalementSETcc
magasin conditionnel etCMOVcc
les instructions de déplacement conditionnel, mais elles sont aussi lentes.0 votes
@bandi : aussi inutile que de minimiser la longueur des sources, comme je l'ai dit c'est un jeu, une sorte de golf. Mais je crois que le processus a ses avantages. Il nous apprend des choses sur le type de code qui est efficace ou non à plusieurs niveaux : algorithmique (généralement les plus grands avantages), mais aussi au niveau du compilateur et du matériel. Il donne également des points de référence quant au type de performance que l'on peut attendre d'un programme C (si nécessaire).
0 votes
@Loren : Les Pentium 2 et plus récents ont des mouvements conditionnels rapides, voir le code assembleur de Steinar Gunderson.
0 votes
@kriss Je pense que ce que vous avez dit est vrai pour le cas de maximisation de la vitesse, ce qui est tout à fait compréhensible. Ce que je ne comprends pas, c'est pourquoi vous voulez la minimiser ? Pourquoi voulez-vous écrire un programme lent ?
0 votes
@bandi : mauvais mot, ma faute, je veux minimiser l'exécution. temps et non la vitesse ;-). Merci d'avoir repéré la faute de frappe.
0 votes
"Zening", j'aime ça. Je ne sais pas trop où aller avec ce mot, mais je vais essayer de l'utiliser.
0 votes
Les liens vers copypastecode.com sont rompus ; il semble maintenant s'agir d'un site publicitaire douteux.
0 votes
@Olaf : en effet copypastecode.com a été fermé il y a deux ans ( !) Dès que j'aurai du temps disponible, je remettrai le contenu sur un site web fonctionnel (probablement github). Merci pour l'avertissement.
0 votes
Cette question n'a en fait pas grand-chose à voir avec le langage C, mais plutôt avec l'assemblage, car il s'agit d'inspecter le code machine qui est produit. De plus, vous avez utilisé
__asm__
ce qui n'est pas vraiment simple C .0 votes
@seb 11 : certains posters ont utilisé l'asm pour des réponses, je l'ai utilisé pour accéder aux timers matériels sur x86 pour des mesures précises mais c'est marginal. Il n'y a pas plus d'assemblage ici que dans la plupart des implémentations typiques de la libc. Soutiendriez-vous que l'utilisation de la libc fait que ce n'est pas du C pur ? La plupart des réponses sont du C pur. Mais je ne suis pas d'accord avec vous, savoir comment le langage est compilé sous le capot (et ce qui pourrait être compilé efficacement sur une cible ou une autre) est une partie très importante de la connaissance du C. Bien sûr, personne n'est obligé de lire cette question ou ses réponses. Peu importe si vous ne maîtrisez pas ce niveau de C.
1 votes
Pour le C++, j'ai récemment écrit une classe modélisée pour générer des réseaux de Bose-Nelson au moment de la compilation . Avec les optimisations, les performances sont équivalentes aux réponses les plus rapides codées à la main ici.
0 votes
Hm... vous spécifiez que le langage doit être C, mais vous dites que le code sera exécuté sur un GPU. Comment exécute-t-on un code C sur un GPU ?
0 votes
@kriss : Aussi, je vois que vous avez supprimé la mention "sera exécuté sur un GPU". Pourtant, le
gpgpu
reste. Ne voulez-vous plus que la question porte sur le code GPU ?0 votes
@Stefan Monov : toujours intéressé, mais je pense que le code serait très différent sur un GPU. Désormais, cela devrait probablement être une autre question. Comme je suis paresseux, je ne l'ai pas ouverte. Donc si vous avez une bonne réponse qui fonctionne sur GPU, n'hésitez pas à la donner.
0 votes
Pour exécuter du code C sur un GPU, vous pouvez utiliser CUDA ou OpenCL. Cela pose quelques restrictions, mais il s'agit toujours de code C et il bénéficie du GPU. À propos, si vous disposez d'un GPU, le simple fait de trier 6 nombres serait probablement un gaspillage d'énergie.