96 votes

Augmentation bizarre des performances dans un benchmark simple

Hier, j'ai trouvé un Article de Christoph Nahr intitulé "Performances des structures .NET". qui a évalué plusieurs langages (C++, C#, Java, JavaScript) pour une méthode qui ajoute deux structs ponctuels ( double tuples).

Il s'est avéré que la version C++ met environ 1000 ms à s'exécuter (1e9 itérations), alors que C# ne peut descendre en dessous de ~3000 ms sur la même machine (et se comporte encore plus mal en x64).

Pour le tester moi-même, j'ai pris le code C# (et l'ai légèrement simplifié pour n'appeler que la méthode où les paramètres sont passés par valeur), et je l'ai exécuté sur une machine i7-3610QM (3.1Ghz boost pour single core), 8GB RAM, Win8.1, utilisant .NET 4.5.2, RELEASE build 32-bit (x86 WoW64 puisque mon OS est 64-bit). Il s'agit de la version simplifiée :

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Point AddByVal(Point a, Point b)
    {
        return new Point(a.X + b.Y, a.Y + b.X);
    }

    public static void Main()
    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        Stopwatch sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", 
            a.X, a.Y, sw.ElapsedMilliseconds);
    }
}

Avec Point défini aussi simplement :

public struct Point 
{
    private readonly double _x, _y;

    public Point(double x, double y) { _x = x; _y = y; }

    public double X { get { return _x; } }

    public double Y { get { return _y; } }
}

Son exécution produit des résultats similaires à ceux de l'article :

Result: x=1000000001 y=1000000001, Time elapsed: 3159 ms

Première observation étrange

Puisque la méthode doit être inlined, je me suis demandé comment le code se comporterait si je supprimais complètement les structs et si j'inlined simplement le tout :

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    public static void Main()
    {
        // not using structs at all here
        double ax = 1, ay = 1, bx = 1, by = 1;

        Stopwatch sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
        {
            ax = ax + by;
            ay = ay + bx;
        }
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", 
            ax, ay, sw.ElapsedMilliseconds);
    }
}

Et j'ai obtenu pratiquement le même résultat (en fait 1% plus lent après plusieurs essais), ce qui signifie que le JIT-ter semble faire un bon travail d'optimisation de tous les appels de fonction :

Result: x=1000000001 y=1000000001, Time elapsed: 3200 ms

Cela signifie également que l'indice de référence ne semble mesurer aucune struct et ne semblent en fait mesurer que les performances de base double arithmétique (après que tout le reste ait été optimisé).

Les trucs bizarres

Maintenant vient la partie bizarre. Si j'ajoute simplement un autre chronomètre en dehors de la boucle (oui, j'ai réduit le problème à cette étape folle après plusieurs tentatives), le code s'exécute trois fois plus rapide :

public static void Main()
{
    var outerSw = Stopwatch.StartNew();     // <-- added

    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    outerSw.Stop();                         // <-- added
}

Result: x=1000000001 y=1000000001, Time elapsed: 961 ms

C'est ridicule ! Et ce n'est pas comme si Stopwatch me donne des résultats erronés car je peux clairement voir qu'elle se termine après une seule seconde.

Quelqu'un peut-il me dire ce qui peut se passer ici ?

(Mise à jour)

J'ai également supposé que cela avait quelque chose à voir avec le JITting, mais répéter le test plusieurs fois (sans le chronomètre extérieur) est lent dans tous les cas :

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Point AddByVal(Point a, Point b)
    {
        return new Point(a.X + b.Y, a.Y + b.X);
    }

    public static void Main()
    {
        Test();
        Test();
        Test();
    }

    private static void Test()
    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", 
           a.X, a.Y, sw.ElapsedMilliseconds);
    }
}

Sortie :

Result: x=1000000001 y=1000000001, Time elapsed: 3251 ms
Result: x=1000000001 y=1000000001, Time elapsed: 3249 ms
Result: x=1000000001 y=1000000001, Time elapsed: 3250 ms

Encore une fois, avec le chronomètre extérieur ajouté, nous obtenons le coup de pouce magique :

    public static void Main()
    {
        Test();
        Test();
        Test();
    }

    private static void Test()
    {
        var outerSw = Stopwatch.StartNew();

        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms", a.X, a.Y, sw.ElapsedMilliseconds);

        outerSw.Stop();
    }

Result: x=1000000001 y=1000000001, Time elapsed: 979 ms
Result: x=1000000001 y=1000000001, Time elapsed: 982 ms
Result: x=1000000001 y=1000000001, Time elapsed: 978 ms

(Mise à jour 2)

Voici deux méthodes dans le même programme, ce qui montre que la raison n'est pas le JITting :

public static class CSharpTest
{
    private const int ITERATIONS = 1000000000;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private static Point AddByVal(Point a, Point b)
    {
        return new Point(a.X + b.Y, a.Y + b.X);
    }

    public static void Main()
    {
        Test1();
        Test2();

        Console.WriteLine();

        Test1();
        Test2();
    }

    private static void Test1()
    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms", 
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    private static void Test2()
    {
        var swOuter = Stopwatch.StartNew();

        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms", 
            a.X, a.Y, sw.ElapsedMilliseconds);

        swOuter.Stop();
    }
}

Sortie :

Test1: x=1000000001 y=1000000001, Time elapsed: 3242 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 974 ms

Test1: x=1000000001 y=1000000001, Time elapsed: 3251 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 972 ms

(Mise à jour 3)

Voici un pastebin. Vous devez l'exécuter en tant que version 32 bits sur .NET 4.x (il y a quelques vérifications dans le code pour s'en assurer).

(Solution, peut-être ?) (lire La réponse de @HansPassant pour plus de détails)

Selon @Hans, le ralentissement est dû au fait que les variables 64 bits ne sont pas toujours alignées dans les applications 32 bits. Si j'ajoute une variable 32 bits à l'une des méthodes de test, elle sera aussi rapide que la version C++ :

private static void Test3()
{
    var magical_speed_booster = "whatever";

    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    GC.KeepAlive(magical_speed_booster);
}

(Mise à jour 4)

Suite aux commentaires de @usr sur la réponse de @Hans, j'ai vérifié le désassemblage optimisé pour les deux méthodes, et ils sont assez différents :

Test1 on the left, Test2 on the right

Cela semble montrer que la différence pourrait être due à un comportement bizarre du compilateur dans le premier cas, plutôt qu'à l'alignement du double champ ?

Aussi, si j'ajoute dos (décalage total de 8 octets), j'obtiens toujours le même gain de vitesse - et il ne semble plus que ce soit lié à l'alignement des champs :

// this is still fast?
private static void Test3()
{
    var magical_speed_booster_1 = "whatever";
    var magical_speed_booster_2 = "whatever";

    {
        Point a = new Point(1, 1), b = new Point(1, 1);

        var sw = Stopwatch.StartNew();
        for (int i = 0; i < ITERATIONS; i++)
            a = AddByVal(a, b);
        sw.Stop();

        Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }

    GC.KeepAlive(magical_speed_booster_1);
    GC.KeepAlive(magical_speed_booster_2);
}

76voto

Hans Passant Points 475940

Il existe un moyen très simple de toujours obtenir la version "rapide" de votre programme. Projet > Propriétés > onglet Build, décochez l'option "Prefer 32-bit", assurez-vous que la sélection de la plate-forme cible est AnyCPU.

Vous ne préférez vraiment pas le 32 bits, qui est malheureusement toujours activé par défaut pour les projets C#. Historiquement, le jeu d'outils Visual Studio fonctionnait beaucoup mieux avec les processus 32 bits, un vieux problème que Microsoft a réduit en miettes. Il est temps de faire supprimer cette option, VS2015 en particulier a abordé les derniers véritables obstacles au code 64 bits avec une toute nouvelle gigue x64 et un support universel pour Edit+Continue.

Assez de bavardage, ce que vous avez découvert est l'importance de alignement pour les variables. Le processeur s'en soucie beaucoup. Si une variable est mal alignée en mémoire, le processeur doit effectuer un travail supplémentaire pour mélanger les octets afin de les remettre dans le bon ordre. Il y a deux problèmes distincts de mauvais alignement, l'un est celui où les octets sont toujours à l'intérieur d'une seule ligne de cache L1, ce qui coûte un cycle supplémentaire pour les déplacer dans la bonne position. Et le très mauvais problème, celui que vous avez trouvé, où une partie des octets se trouve dans une ligne de cache et une autre partie dans une autre. Cela nécessite deux accès mémoire distincts et de les coller ensemble. Trois fois plus lent.

El double y long sont les fauteurs de troubles dans un processus 32 bits. Ils ont une taille de 64 bits. Et peuvent donc être mal alignés par 4, le CLR ne peut garantir qu'un alignement de 32 bits. Ce n'est pas un problème dans un processus 64 bits, toutes les variables sont garanties alignées sur 8. C'est aussi la raison sous-jacente pour laquelle le langage C# ne peut pas leur promettre un alignement de 32 bits. atomique . Et pourquoi les tableaux de doubles sont alloués dans le Large Object Heap lorsqu'ils ont plus de 1000 éléments. Le LOH fournit une garantie d'alignement de 8. Et explique pourquoi l'ajout d'une variable locale a résolu le problème, une référence d'objet est de 4 octets, ce qui a déplacé l'allocation de la variable locale. double variable par 4, maintenant on l'aligne. Par accident.

Un compilateur C ou C++ 32 bits fait un travail supplémentaire pour s'assurer que double ne peuvent pas être désalignés. Ce n'est pas exactement un problème simple à résoudre, la pile peut être mal alignée lors de l'entrée d'une fonction, étant donné que la seule garantie est qu'elle soit alignée sur 4. Le prologue d'une telle fonction doit faire un travail supplémentaire pour qu'elle soit alignée sur 8. La même astuce ne fonctionne pas dans un programme géré, le ramasseur d'ordures se soucie beaucoup de l'emplacement exact d'une variable locale dans la mémoire. C'est nécessaire pour qu'il puisse découvrir qu'un objet dans le tas GC est toujours référencé. Il ne peut pas gérer correctement une telle variable déplacée de 4 parce que la pile était mal alignée lors de l'entrée de la méthode.

C'est également le problème sous-jacent aux jitters .NET qui ne supportent pas facilement les instructions SIMD. Elles ont des exigences d'alignement beaucoup plus fortes, le genre que le processeur ne peut pas non plus résoudre par lui-même. SSE2 exige un alignement de 16, AVX exige un alignement de 32. Impossible d'obtenir cela en code géré.

Enfin, notez également que cela rend très imprévisible la perforation d'un programme C# qui s'exécute en mode 32 bits. Lorsque vous accédez à un double o long qui est stocké comme un champ dans un objet alors la perf peut changer drastiquement quand le garbage collector compacte le heap. En déplaçant les objets dans la mémoire, un tel champ peut soudainement être mal aligné. C'est très aléatoire, bien sûr, mais cela peut être assez déconcertant :)

Eh bien, il n'y a pas de solution simple mais une, le code 64 bits est l'avenir. Supprimez le forçage de la gigue tant que Microsoft ne change pas le modèle de projet. Peut-être la prochaine version quand ils se sentiront plus en confiance avec Ryujit.

5voto

leppie Points 67289

Je l'ai un peu réduit (il semble que cela n'affecte que le runtime 32-bit CLR 4.0).

Remarquez le placement de la var f = Stopwatch.Frequency; fait toute la différence.

Lent (2700ms) :

static void Test1()
{
  Point a = new Point(1, 1), b = new Point(1, 1);
  var f = Stopwatch.Frequency;

  var sw = Stopwatch.StartNew();
  for (int i = 0; i < ITERATIONS; i++)
    a = AddByVal(a, b);
  sw.Stop();

  Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
      a.X, a.Y, sw.ElapsedMilliseconds);
}

Rapide (800ms) :

static void Test1()
{
  var f = Stopwatch.Frequency;
  Point a = new Point(1, 1), b = new Point(1, 1);

  var sw = Stopwatch.StartNew();
  for (int i = 0; i < ITERATIONS; i++)
    a = AddByVal(a, b);
  sw.Stop();

  Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
      a.X, a.Y, sw.ElapsedMilliseconds);
}

4voto

InBetween Points 6162

Il semble y avoir un bug dans le Jitter car le comportement est encore plus bizarre. Considérez le code suivant :

public static void Main()
{
    Test1(true);
    Test1(false);
    Console.ReadLine();
}

public static void Test1(bool warmup)
{
    Point a = new Point(1, 1), b = new Point(1, 1);

    Stopwatch sw = Stopwatch.StartNew();
    for (int i = 0; i < ITERATIONS; i++)
        a = AddByVal(a, b);
    sw.Stop();

    if (!warmup)
    {
        Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
            a.X, a.Y, sw.ElapsedMilliseconds);
    }
}

Cela fonctionnera dans 900 ms, comme le boîtier extérieur du chronomètre. Cependant, si nous supprimons le if (!warmup) il fonctionnera en 3000 ms. Ce qui est encore plus étrange, c'est que le code suivant s'exécutera aussi en 900 ms :

public static void Test1()
{
    Point a = new Point(1, 1), b = new Point(1, 1);

    Stopwatch sw = Stopwatch.StartNew();
    for (int i = 0; i < ITERATIONS; i++)
        a = AddByVal(a, b);
    sw.Stop();

    Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
        0, 0, sw.ElapsedMilliseconds);
}

Notez que j'ai supprimé a.X y a.Y les références de la Console sortie.

Je n'ai aucune idée de ce qui se passe, mais cela me semble assez buggé et ce n'est pas lié au fait d'avoir un extérieur. Stopwatch ou non, le problème semble un peu plus généralisé.

1voto

Macke Points 13474

Le JIT:ing de la classe StopWatch prend probablement un certain temps et la mesure du temps commence avant que cela ne soit fait.

Avec les langages JIT, exécutez toujours votre test entier plusieurs fois pour chauffer le JIT et charger toutes les classes et autres. Un seul benchmark main() sera assez lent.

UPDATE

Donc, ce n'est pas le JIT.

En regardant le source la seule chose que fait stopwatch est d'utiliser SafeNativeMethods.

Peut-être qu'il y a un chargement/déchargement de la bibliothèque, qui est mis en cache tant qu'il y a des instances StopWatch actives ?

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X