web-dev-qa-db-fra.com

Grande différence de performances (26x plus rapide) lors de la compilation sur 32 et 64 bits

J'essayais de mesurer la différence d'utiliser un for et un foreach lors de l'accès aux listes de types de valeur et de types de référence.

J'ai utilisé la classe suivante pour faire le profilage.

public static class Benchmarker
{
    public static void Profile(string 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é 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(string[] args)
{
    int size = 1000000;
    int iterationCount = 100;

    var valueList = new List<double>(size);
    for (int i = 0; i < size; i++) 
        valueList.Add(i);

    var refList = new List<DoubleWrapper>(size);
    for (int i = 0; i < size; i++) 
        refList.Add(new DoubleWrapper(i));

    double dummy;

    Benchmarker.Profile("valueList for: ", iterationCount, () =>
    {
        double result = 0;
        for (int i = 0; i < valueList.Count; i++)
        {
             unchecked
             {
                 var temp = valueList[i];
                 result *= temp;
                 result += temp;
                 result /= temp;
                 result -= temp;
             }
        }
        dummy = result;
    });

    Benchmarker.Profile("valueList foreach: ", iterationCount, () =>
    {
        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 < refList.Count; i++)
        {
            unchecked
            {
                var temp = refList[i].Value;
                result *= temp;
                result += temp;
                result /= temp;
                result -= temp;
            }
        }
        dummy = result;
    });

    Benchmarker.Profile("refList foreach: ", iterationCount, () =>
    {
        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é les options Release et Any CPU, Exécuté le programme et obtenu les heures suivantes:

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 heures suivantes:

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 bits est-elle tellement plus rapide? Je m'attendais à une différence, mais pas à quelque chose d'aussi gros.

Je n'ai pas accès à d'autres ordinateurs. Pourriez-vous s'il vous plaît exécuter cela sur vos machines et me dire les résultats? J'utilise Visual Studio 2015 et j'ai un Intel Core i7 930.

Voici la méthode SafeExit(), vous pouvez donc compiler/exécuter par 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 CPU

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!

Dernier point mais non le moindre: la création d'un profil x86 Me donne presque les mêmes résultats que l'utilisation de Any CPU.

78
Trauer

Je peux reproduire cela sur 4.5.2. Pas de RyuJIT ici. Les démontages x86 et x64 semblent raisonnables. Les contrôles de portée et ainsi de suite sont les mêmes. La même structure de base. Pas de déroulement de boucle.

x86 utilise un ensemble différent d'instructions flottantes. Les performances de ces instructions semblent comparables aux instructions x64 à l'exception de la division :

  1. Les instructions float 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. La mise en commentaire de la division égalise les performances dans une large mesure (32 bits de 430 ms à 3,25 ms).

Peter Cordes souligne que les latences d'instruction 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. Ceux-ci pourraient déclencher un chemin lent dans l'une des unités. Ou, peut-être que les valeurs divergent entre les deux implémentations en raison de la précision flottante de 10 octets contre 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 nul) égalise principalement Les resultats. Apparemment, le code 32 bits n'aime pas du tout les opérandes NaN. Imprimons quelques valeurs intermédiaires: if (i % 1000 == 0) Console.WriteLine(result);. Cela confirme que les données sont maintenant saines.

Lors de l'analyse comparative, vous devez évaluer une charge de travail réaliste. Mais qui aurait pensé qu'une division innocente peut gâcher votre référence?!

Essayez simplement de additionner les chiffres pour obtenir une meilleure référence.

La division et le modulo sont toujours très lents. Si vous modifiez le code BCL Dictionary pour simplement ne pas utiliser l'opérateur modulo pour calculer les performances mesurables de l'index de compartiment, cela s'améliore. C'est ainsi que la division est lente.

Voici le code 32 bits:

enter image description here

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

enter image description here

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

87
usr

valueList[i] = i, a partir de i=0, donc la première itération de boucle 0.0 / 0.0. Ainsi, chaque opération dans votre benchmark complet se fait avec NaNs.

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

Je ne suis pas un expert des performances avec NaNs, ni de la différence entre x87 et SSE pour cela, mais je pense que cela explique la différence de perf 26x. Je parie que vos résultats être un beaucoup plus proche entre 32 et 64 bits si vous initialisez valueList[i] = i+1. (mise à jour: usr a confirmé que cela rendait les performances 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 de bonnes choses sur le matériel et l'optimisation de asm et C/C++, dont certaines sont pertinentes pour C #. Il dispose de 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 la double précision 8B de SSE2 divsd, pour les valeurs normales. IDK sur les différences de performances avec les NaN, les infinis ou les dénormals.

Cependant, ils ont des contrôles différents pour ce qui se passe avec les NaN et d'autres exceptions FPU. x87 FPU control Word est distinct du SSE rounding/exception control register (MXCSR). Si x87 obtient une exception CPU pour chaque division, mais SSE ne l'est pas, cela explique facilement le facteur de 26. Ou peut-être qu'il y a juste une différence de performances aussi grande lors de la manipulation des NaN. Le matériel est pas optimisé pour le barattage NaN après NaN.

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

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

Conclusion

Pour éviter les problèmes de sérialisation et de performances dus aux dénormals et aux numéros de débordement, utilisez les instructions SSE et SSE2 pour définir les modes Flush-to-Zero et Denormals-Are-Zero dans le matériel pour permettre des performances optimales pour 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 d'être une seule chaîne de dépendance portée par une boucle. En l'état, tout le travail dépend des résultats antérieurs; il n'y a rien à faire en parallèle par le CPU (à part vérifier les limites de la prochaine charge de la baie pendant que la chaîne mul/div est en cours d'exécution).

Vous pourriez voir plus de différence entre les méthodes si le "travail réel" occupait plus des ressources d'exécution du processeur. En outre, sur Intel pré-Sandybridge, il y a une grande différence entre un ajustement de boucle dans le tampon de boucle 28uop ou non. Vous obtenez des goulots d'étranglement de décodage des instructions sinon, en particulier. lorsque la durée moyenne des instructions est plus longue (ce qui se produit avec SSE). Les instructions qui décodent à plus d'un uop limiteront également le débit du décodeur, à moins qu'elles ne viennent dans un modèle qui soit agréable pour les décodeurs (par exemple 2-1-1). Ainsi, une boucle avec plus d'instructions de surcharge de boucle peut faire la différence entre un ajustement de boucle dans le cache uop à 28 entrées ou non, ce qui est un gros problème sur Nehalem, et parfois utile sur Sandybridge et plus tard.

31
Peter Cordes

Nous avons l'observation que 99,9% de toutes les opérations en virgule flottante impliqueront des NaN, ce qui est au moins très inhabituel (trouvé par Peter Cordes en premier). Nous avons une autre expérience par usr, qui a révélé que la suppression des instructions de division fait disparaître presque complètement la différence de temps.

Le fait est cependant que les NaN ne sont générés que parce que la toute première division calcule 0,0/0,0 qui donne le NaN initial. Si les divisions ne sont pas effectuées, le résultat sera toujours 0,0 et nous calculerons toujours 0,0 * temp -> 0,0, 0,0 + temp -> temp, temp - temp = 0,0. Ainsi, la suppression de la division n'a pas seulement supprimé les divisions, mais a également supprimé les NaN. Je m'attendrais à ce que les NaN soient en fait le problème, et qu'une implémentation gère les NaN très lentement, tandis que l'autre n'a pas le problème.

Il serait intéressant de démarrer la boucle à i = 1 et de mesurer à nouveau. Les quatre opérations résultent * temp, + temp,/temp, - temp s'ajoutent effectivement (1 - temp) donc nous n'aurions pas de nombres inhabituels (0, infini, NaN) pour la plupart des opérations.

Le seul problème pourrait être que la division donne toujours un résultat entier, et certaines implémentations de division ont des raccourcis lorsque le résultat correct n'utilise pas beaucoup de bits. Par exemple, la division de 310,0/31,0 donne 10,0 comme les quatre premiers bits avec un reste de 0,0, et certaines implémentations peuvent arrêter d'évaluer les 50 bits restants tandis que d'autres ne le peuvent pas. S'il y a une différence significative, alors démarrer la boucle avec result = 1.0/3.0 ferait une différence.

1
gnasher729