Grande différence de performance (26 fois plus rapide) lors de la compilation pour 32 et 64 bits

J’essayais de mesurer la différence d’utilisation d’un for et d’un foreach lors de l’access aux listes de types de valeurs et de types de référence.

J’ai utilisé le cours suivant pour faire le profilage.

 public static class Benchmarker { public static void Profile(ssortingng description, int iterations, Action func) { Console.Write(description); // Warm up func(); Stopwatch watch = new Stopwatch(); // Clean up GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); watch.Start(); for (int i = 0; i < iterations; i++) { func(); } watch.Stop(); Console.WriteLine(" average time: {0} ms", watch.Elapsed.TotalMilliseconds / iterations); } } 

J’ai utilisé le double pour mon type de valeur. Et j’ai créé cette «fausse classe» pour tester les types de référence:

 class DoubleWrapper { public double Value { get; set; } public DoubleWrapper(double value) { Value = value; } } 

Enfin, j’ai exécuté ce code et comparé les différences de temps.

 static void Main(ssortingng[] args) { int size = 1000000; int iterationCount = 100; var valueList = new List(size); for (int i = 0; i < size; i++) valueList.Add(i); var refList = new List(size); for (int i = 0; i  { double result = 0; for (int i = 0; i  { double result = 0; foreach (var v in valueList) { var temp = v; result *= temp; result += temp; result /= temp; result -= temp; } dummy = result; }); Benchmarker.Profile("refList for: ", iterationCount, () => { double result = 0; for (int i = 0; i  { double result = 0; foreach (var v in refList) { unchecked { var temp = v.Value; result *= temp; result += temp; result /= temp; result -= temp; } } dummy = result; }); SafeExit(); } 

J’ai sélectionné Any CPU options Release et Any CPU , exécuté le programme et obtenu les temps suivants:

 valueList for: average time: 483,967938 ms valueList foreach: average time: 477,873079 ms refList for: average time: 490,524197 ms refList foreach: average time: 485,659557 ms Done! 

Ensuite, j’ai sélectionné les options Release et x64, exécuté le programme et obtenu les temps suivants:

 valueList for: average time: 16,720209 ms valueList foreach: average time: 15,953483 ms refList for: average time: 19,381077 ms refList foreach: average time: 18,636781 ms Done! 

Pourquoi la version x64 bit est-elle tellement plus rapide? Je m’attendais à une différence, mais pas quelque chose d’aussi gros.

Je n’ai pas access à d’autres ordinateurs. Pourriez-vous s’il vous plaît lancer ceci sur vos machines et me dire les résultats? J’utilise Visual Studio 2015 et j’ai un processeur Intel Core i7 930.

Voici la méthode SafeExit() , vous pouvez donc comstackr / exécuter vous-même:

 private static void SafeExit() { Console.WriteLine("Done!"); Console.ReadLine(); System.Environment.Exit(1); } 

Comme demandé, en utilisant double? au lieu de mon DoubleWrapper :

N’importe quel processeur

 valueList for: average time: 482,98116 ms valueList foreach: average time: 478,837701 ms refList for: average time: 491,075915 ms refList foreach: average time: 483,206072 ms Done! 

x64

 valueList for: average time: 16,393947 ms valueList foreach: average time: 15,87007 ms refList for: average time: 18,267736 ms refList foreach: average time: 16,496038 ms Done! 

Last but not least: créer un profil x86 me donne presque les mêmes résultats en utilisant Any CPU .

Je peux le reproduire sur 4.5.2. Pas de RyuJIT ici. Les désassemblages x86 et x64 semblent raisonnables. Les vérifications de scope et ainsi de suite sont les mêmes. La même structure de base. Aucune boucle se déroulant.

x86 utilise un ensemble d’instructions de flottement différent. Les performances de ces instructions semblent être comparables aux instructions x64, à l’ exception de la division :

  1. Les instructions flottantes 32 bits x87 utilisent une précision de 10 octets en interne.
  2. La division de précision étendue est super lente.

L’opération de division rend la version 32 bits extrêmement lente. Décommenter la division égalise la performance dans une large mesure (32 bits vers le bas de 430 ms à 3,25 ms).

Peter Cordes souligne que les latences d’instructions des deux unités à virgule flottante ne sont pas si différentes. Peut-être que certains des résultats intermédiaires sont des nombres dénormalisés ou NaN. Celles-ci peuvent déclencher un chemin lent dans l’une des unités. Ou, peut-être les valeurs divergent-elles entre les deux implémentations à cause de la précision de flottement de 10 octets par rapport à 8 octets.

Peter Cordes souligne également que tous les résultats intermédiaires sont NaN … Suppression de ce problème ( valueList.Add(i + 1) pour qu’aucun diviseur ne soit égal à zéro) égalise principalement les résultats. Apparemment, le code 32 bits n’aime pas du tout les opérandes NaN. Imprimons des valeurs intermédiaires: if (i % 1000 == 0) Console.WriteLine(result); . Cela confirme que les données sont maintenant saines.

Lors de l’parsing comparative, vous devez évaluer une charge de travail réaliste. Mais qui aurait pu penser qu’une division innocente pourrait gâcher votre benchmark?!

Essayez simplement de faire la sum des chiffres pour obtenir un meilleur repère.

Division et modulo sont toujours très lents. Si vous modifiez le code du Dictionary BCL pour ne pas utiliser simplement l’opérateur modulo pour calculer les performances de l’index du compartiment, vous pouvez améliorer les performances. C’est comme cela que la division est lente.

Voici le code 32 bits:

entrer la description de l'image ici

Code 64 bits (même structure, division rapide):

entrer la description de l'image ici

Ceci n’est pas vectorisé malgré les instructions SSE utilisées.

valueList[i] = i , à partir de i=0 , la première itération de la boucle fait 0.0 / 0.0 . Ainsi, toutes les opérations de votre benchmark se font avec NaN .

Comme @usr l’a montré dans la sortie de désassemblage , la version 32 bits utilisait la virgule flottante x87, tandis que la version 64 bits utilisait la virgule flottante SSE.

Je ne suis pas un expert de la performance avec NaN , ou la différence entre x87 et SSE pour cela, mais je pense que cela explique la différence de 26x perf. Je parie que vos résultats seront beaucoup plus proches entre 32 et 64 bits si vous initialisez valueList[i] = i+1 . (mise à jour: usr a confirmé que cela rendait les performances de 32 et 64 bits assez proches.)

La division est très lente par rapport aux autres opérations. Voir mes commentaires sur la réponse de @ usr. Voir aussi http://agner.org/optimize/ pour des tonnes d’excellents articles sur le matériel et l’optimisation de asm et de C / C ++, dont certains concernent le C #. Il a des tables d’instructions de latence et de débit pour la plupart des instructions pour tous les processeurs x86 récents.

Cependant, 10B x87 fdiv n’est pas beaucoup plus lent que le divsd double précision 8B de SSE2, pour les valeurs normales. IDK sur les différences de perf avec les NaN, les infinis ou les dénormaux.

Ils ont des contrôles différents pour ce qui se passe avec les NaN et autres exceptions FPU. Le mot de contrôle FPU x87 est distinct du registre de contrôle d’arrondi / exception SSE (MXCSR). Si x87 obtient une exception CPU pour chaque division, mais que SSE ne l’est pas, cela explique facilement le facteur 26. Ou peut-être y a-t-il une différence de performance aussi importante lors de la manipulation de NaN. Le matériel n’est pas optimisé pour la diffusion via NaN après NaN .

IDK si les contrôles SSE pour éviter les ralentissements avec les dénormaux entreront en jeu ici, car je pense que le result sera NaN tout le temps. IDK si C # définit l’indicateur de dénormalisation-zéro dans le MXCSR, ou l’indicateur flush-to-zero (qui écrit les zéros en premier lieu, au lieu de traiter les dénormaux comme zéro lors de la relecture).

J’ai trouvé un article Intel sur les contrôles à virgule flottante SSE, en le contrastant avec le mot de contrôle FPU x87. Il n’y a pas grand chose à dire sur NaN , cependant. Cela se termine par ceci:

Conclusion

Pour éviter les problèmes de sérialisation et de performance dus aux nombres dénormalisés et sous-débordants, utilisez les instructions SSE et SSE2 pour définir les modes Flush-to-Zero et Denormals-Are-Zero dans le matériel pour optimiser les performances des applications à virgule flottante.

IDK si cela aide à diviser par zéro.

pour vs foreach

Il pourrait être intéressant de tester un corps de boucle dont le débit est limité, plutôt que de se limiter à une seule chaîne de dépendance à boucle. En fait, tout le travail dépend des résultats antérieurs; Il n’y a rien à faire en parallèle pour le processeur (à part le contrôle de la charge du tableau suivant pendant l’exécution de la chaîne mul / div).

Vous pourriez voir plus de différence entre les méthodes si le “travail réel” occupait plus de ressources d’exécution du processeur. En outre, sur Intel avant Sandybridge, il y a une grande différence entre un assembly en boucle dans le buffer de boucle 28uop ou non. Vous obtenez des instructions pour décoder les goulots d’étranglement, sinon, esp. lorsque la longueur moyenne de l’instruction est plus longue (ce qui se produit avec SSE). Les instructions qui décodent à plus d’un UOP limitent également le débit du décodeur, à moins qu’elles n’apparaissent dans un modèle intéressant pour les décodeurs (par exemple, 2-1-1). Ainsi, une boucle avec plus d’instructions sur les overheads de boucle peut faire la différence entre un assembly en boucle dans le cache UOP à 28 entrées ou pas, ce qui est très important sur Nehalem et parfois utile sur Sandybridge.