En répondant à une autre question Stack Overflow ( celle-ci ), je suis tombé sur un sous-problème intéressant. Quel est le moyen le plus rapide pour trier un tableau de 6 entiers?
Comme la question est de très bas niveau:
- nous ne pouvons pas supposer que les bibliothèques sont disponibles (et l'appel lui-même a son coût), seul C
- pour éviter de vider le pipeline d'instructions (qui a un coût très élevé), nous devons probablement minimiser les branches, les sauts et tout autre type de rupture de flux de contrôle (comme ceux cachés derrière les points de séquence dans
&&
ou||
). - l'espace est limité et la minimisation des registres et de l'utilisation de la mémoire est un problème, idéalement en place, le tri est probablement le meilleur.
Vraiment, 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. Je l'appelle le code «Zening» tel qu'il est utilisé dans le titre du livre Zen of Code Optimization de Michael Abrash et ses suites .
Quant à savoir pourquoi il est intéressant, il existe plusieurs couches:
- l'exemple est simple et facile à comprendre et à mesurer, pas beaucoup de compétences C impliquées
- 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 ma mise en œuvre 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 qui peut être trouvée ici . Les tests réels utilisés sont un peu moins naïfs que ceux montré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 / compilateurs cibles. (OK les gars, mettez-le dans les réponses, je vais attribuer +1 à chaque contributeur d'un nouvel ensemble 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 bibliothèque qsort: 689,38
- Implémentation naïve (tri par insertion): 285,70
- Tri d'insertion (Daniel Stutzbach): 142,12
- Tri par insertion déroulé: 125,47
- Ordre de classement: 102,26
- Ordre de classement avec registres: 58.03
- Réseaux de tri (Daniel Stutzbach): 111,68
- Réseaux de tri (Paul R): 66,36
- Réseaux de tri 12 avec échange rapide: 58,86
- Réseaux de tri 12 réorganisés Échange: 53,74
- Réseaux de tri 12 échange simple réorganisé: 31,54
- Réseau de tri réorganisé avec échange rapide: 31,54
- Réseau de tri réorganisé avec échange rapide V2: 33,63
- Tri des bulles en ligne (Paolo Bonzini): 48,85
- Tri par insertion déroulée (Paolo Bonzini): 75,30
Linux 64 bits, gcc 4.6.1 64 bits, Intel Core 2 Duo E8400, -O1
- Appel direct à la fonction de bibliothèque qsort: 705.93
- Implémentation naïve (tri par insertion): 135.60
- Tri d'insertion (Daniel Stutzbach): 142,11
- Tri par insertion déroulé: 126,75
- Ordre de classement: 46,42
- Ordre de classement avec registres: 43,58
- Réseaux de tri (Daniel Stutzbach): 115,57
- Réseaux de tri (Paul R): 64,44
- Réseaux de tri 12 avec échange rapide: 61,98
- Réseaux de tri 12 réorganisés Échange: 54,67
- Réseaux de tri 12 échange simple réorganisé: 31,54
- Réseau de tri réorganisé avec échange rapide: 31,24
- Réseau de tri réorganisé avec échange rapide V2: 33.07
- Tri des bulles en ligne (Paolo Bonzini): 45,79
- Tri par insertion déroulée (Paolo Bonzini): 80,15
J'ai inclus à la fois les résultats -O1 et -O2 car é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 demandais si l'effet principal n'était pas obtenu en évitant la boucle externe. Je l'ai essayé par tri d'insertion déroulée 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'ici. Le code réel que j'ai utilisé pour tester est ici . Je ne sais pas encore pourquoi elle est presque deux fois plus rapide que l'autre implémentation du réseau de tri. Passage de paramètre? Rapide max?
Réseaux de tri 12 SWAP avec échange rapide
Comme suggéré par Daniel Stutzbach, j'ai combiné son réseau de tri à 12 swaps avec un swap rapide sans branche (le code est ici ). Il est en effet plus rapide, le meilleur jusqu'à présent avec une petite marge (environ 5%) comme on pourrait s'y attendre en utilisant 1 swap de moins.
Il est également intéressant de noter que le swap sans branche semble être beaucoup (4 fois) moins efficace que le simple utilisant if sur l'architecture PPC.
Appeler la bibliothèque qsort
Pour donner un autre point de référence, j'ai également essayé, comme suggéré, d'appeler simplement la bibliothèque qsort (le code est ici ). Comme prévu, il est beaucoup plus lent: 10 à 30 fois plus lent ... comme cela est devenu évident avec la nouvelle suite de tests, le principal problème semble être la charge initiale de la bibliothèque après le premier appel, et elle ne se compare pas si mal aux autres version. C'est juste entre 3 et 20 fois plus lent sur mon Linux. Sur certaines architectures utilisées pour les tests par d'autres, cela semble même être plus rapide (je suis vraiment surpris par celle-ci, 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, calculer directement sa position finale. Ceci est efficace car le calcul de l'ordre de classement n'a pas besoin de branchement. L'inconvénient de cette méthode est qu'elle prend 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 de performance sont très surprenants (et intéressants). Sur mon architecture de référence avec OS 32 bits et Intel Core2 Quad E8300, le nombre de cycles était légèrement inférieur à 1000 (comme les réseaux de tri avec swap de branchement). Mais lorsqu'elle a été compilée et exécutée sur ma boîte 64 bits (Intel Core2 Duo), elle s'est beaucoup mieux comportée: elle est devenue la plus rapide jusqu'à présent. J'ai finalement découvert la vraie raison. Ma boîte 32 bits utilise gcc 4.4.1 et ma boîte 64 bits gcc 4.4.
mise à jour :
Comme les chiffres publiés ci-dessus le montrent, cet effet a été encore amélioré par les versions ultérieures de gcc et Rank Order est devenu deux fois plus rapide que toute autre alternative.
Réseaux de tri 12 avec échange réorganisé
L'incroyable efficacité de la proposition Rex Kerr avec gcc 4.4.3 m'a fait me demander: comment un programme avec 3 fois plus de mémoire peut-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, permettant une meilleure utilisation du planificateur d'instructions superscalaire du x86. Cela m'a donné une idée: réorganiser les swaps pour minimiser les dépendances de lecture après écriture. Plus simplement: lorsque vous le faites, SWAP(1, 2); SWAP(0, 2);
vous devez attendre la fin du premier échange avant d'effectuer le second car les deux ont accès à une cellule de mémoire commune. Lorsque vous le faites, 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 fonctionnent environ 10% plus rapidement.
Tri des réseaux 12 avec échange simple
Un an après la publication originale, Steinar H. Gunderson a suggéré de ne pas essayer de déjouer le compilateur et de garder le code d'échange simple. C'est en effet une bonne idée car le code résultant est environ 40% plus rapide! Il a également proposé un échange optimisé à la main en utilisant le code d'assemblage en ligne x86 qui peut encore épargner quelques cycles supplémentaires. Le plus surprenant (il dit des volumes sur la psychologie du programmeur) est qu'il y a un an, aucun des utilisateurs n'avait essayé cette version de swap. Le code que je testais est ici . D'autres ont suggéré d'autres façons d'écrire un échange rapide en C, mais il donne les mêmes performances que le simple 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 nous croyons que notre ensemble de tests (et, oui, il est assez médiocre, son simple avantage est d'être court, simple et facile à comprendre ce que nous mesurons), le nombre moyen de cycles du code résultant pour une sorte est inférieur à 40 cycles ( 6 tests sont exécutés). Cela met chaque swap à une moyenne de 4 cycles. J'appelle ça incroyablement rapide. D'autres améliorations possibles?
__asm__ volatile (".byte 0x0f, 0x31; shlq $32, %%rdx; orq %%rdx, %0" : "=a" (x) : : "rdx");
due au fait que rdtsc place la réponse dans EDX: EAX tandis que GCC l'attend dans un seul registre 64 bits. Vous pouvez voir le bogue en compilant à -O3. Voir également ci-dessous mon commentaire à Paul R sur un SWAP plus rapide.
CMP EAX, EBX; SBB EAX, EAX
mettra 0 ou 0xFFFFFFFF EAX
selon qu'il EAX
est respectivement plus grand ou plus petit que EBX
. SBB
est "soustraire avec emprunter", la contrepartie de ADC
("ajouter avec reporter"); le bit d'état auquel vous faites référence est le bit de retenue. Là encore, je me souviens de cela ADC
et SBB
avait une latence et un débit terribles sur le Pentium 4 par rapport à ADD
et SUB
, et était encore deux fois plus lent sur les processeurs Core. Depuis le 80386, il existe également des instructions de SETcc
stockage CMOVcc
conditionnel et de déplacement conditionnel, mais elles sont également lentes.
x-y
etx+y
ne provoquera pas de débordement ou de débordement?