web-dev-qa-db-fra.com

Augmentation étrange des performances dans un benchmark simple

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

Il s'est avéré que la version C++ prend environ 1000 ms à exécuter (1e9 itérations), tandis que C # ne peut pas obtenir moins de ~ 3000 ms sur la même machine (et fonctionne encore pire en x64).

Pour le tester moi-même, j'ai pris le code C # (et simplifié légèrement pour appeler uniquement la méthode où les paramètres sont passés par valeur), et je l'ai exécuté sur une machine i7-3610QM (boost 3.1Ghz pour un cœur), 8 Go de RAM, Win8. 1, en utilisant .NET 4.5.2, RELEASE build 32 bits (x86 WoW64 puisque mon système d'exploitation est 64 bits). Voici 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 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; } }
}

L'exécuter produit des résultats similaires à ceux de l'article:

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

Première observation étrange

Étant donné que la méthode devrait être intégrée, je me demandais comment le code fonctionnerait si je supprimais complètement les structures et simplement alignais le tout ensemble:

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 obtenu pratiquement le même résultat (en fait 1% plus lent après plusieurs tentatives), ce qui signifie que JIT-ter semble faire du bon travail en optimisant tous les appels de fonction:

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

Cela signifie également que le test de référence ne semble pas mesurer les performances de struct et ne semble mesurer que l'arithmétique de base double (après tout le reste est optimisé).

Les trucs bizarres

Vient maintenant la partie bizarre. Si j'ajoute simplement un autre chronomètre en dehors de la boucle (oui, je l'ai réduit à 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 de mauvais résultats parce que je peux clairement voir que cela se termine après une seule seconde.

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

(Mise à jour)

Voici deux méthodes dans le même programme, ce qui montre que la raison n'est pas 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();
    }
}

Production:

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

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

(mise à jour 4)

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

Test1 on the left, Test2 on the right

Cela semble montrer que la différence pourrait être due au fait que le compilateur agit de manière amusante dans le premier cas, plutôt qu'à un double alignement de champ?

De plus, si j'ajoute deux variables (décalage total de 8 octets), j'obtiens toujours la même augmentation de vitesse - et il ne semble plus que cela soit lié au champ mention d'alignement par Hans Passant:

// 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);
}
96
Groo

La mise à jour 4 explique le problème: dans le premier cas, JIT conserve les valeurs calculées (a, b) sur la pile; dans le second cas, JIT le garde dans les registres.

En réalité, Test1 fonctionne lentement à cause du Stopwatch. J'ai écrit le benchmark minimal suivant basé sur BenchmarkDotNet :

[BenchmarkTask(platform: BenchmarkPlatform.X86)]
public class Jit_RegistersVsStack
{
    private const int IterationCount = 100001;

    [Benchmark]
    [OperationsPerInvoke(IterationCount)]
    public string WithoutStopwatch()
    {
        double a = 1, b = 1;
        for (int i = 0; i < IterationCount; i++)
        {
            // fld1  
            // faddp       st(1),st
            a = a + b;
        }
        return string.Format("{0}", a);
    }

    [Benchmark]
    [OperationsPerInvoke(IterationCount)]
    public string WithStopwatch()
    {
        double a = 1, b = 1;
        var sw = new Stopwatch();
        for (int i = 0; i < IterationCount; i++)
        {
            // fld1  
            // fadd        qword ptr [ebp-14h]
            // fstp        qword ptr [ebp-14h]
            a = a + b;
        }
        return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
    }

    [Benchmark]
    [OperationsPerInvoke(IterationCount)]
    public string WithTwoStopwatches()
    {
        var outerSw = new Stopwatch();
        double a = 1, b = 1;
        var sw = new Stopwatch();
        for (int i = 0; i < IterationCount; i++)
        {
            // fld1  
            // faddp       st(1),st
            a = a + b;
        }
        return string.Format("{0}{1}", a, sw.ElapsedMilliseconds);
    }
}

Les résultats sur mon ordinateur:

BenchmarkDotNet=v0.7.7.0
OS=Microsoft Windows NT 6.2.9200.0
Processor=Intel(R) Core(TM) i7-4702MQ CPU @ 2.20GHz, ProcessorCount=8
HostCLR=MS.NET 4.0.30319.42000, Arch=64-bit  [RyuJIT]
Type=Jit_RegistersVsStack  Mode=Throughput  Platform=X86  Jit=HostJit  .NET=HostFramework

             Method |   AvrTime |    StdDev |       op/s |
------------------- |---------- |---------- |----------- |
   WithoutStopwatch | 1.0333 ns | 0.0028 ns | 967,773.78 |
      WithStopwatch | 3.4453 ns | 0.0492 ns | 290,247.33 |
 WithTwoStopwatches | 1.0435 ns | 0.0341 ns | 958,302.81 |

Comme on peut le voir:

  • WithoutStopwatch fonctionne rapidement (car a = a + b utilise les registres)
  • WithStopwatch fonctionne lentement (car a = a + b utilise la pile)
  • WithTwoStopwatches fonctionne à nouveau rapidement (car a = a + b utilise les registres)

Le comportement de JIT-x86 dépend d'une grande quantité de conditions différentes. Pour une raison quelconque, le premier chronomètre force JIT-x86 à utiliser la pile et le deuxième chronomètre lui permet d'utiliser à nouveau les registres.

10
AndreyAkinshin

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

Vous ne préférez vraiment pas 32 bits, malheureusement, il est 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 éliminé. Il était temps de supprimer cette option, VS2015 en particulier a adressé les derniers blocages réels au code 64 bits avec une toute nouvelle gigue x64 et un support universel pour Edit + Continue.

Assez bavardé, 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 faire un travail supplémentaire pour mélanger les octets afin de les mettre dans le bon ordre. Il y a deux problèmes de désalignement distincts, l'un est lorsque 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 plus mauvais, celui que vous avez trouvé, où une partie des octets se trouvent dans une ligne de cache et une partie dans une autre. Cela nécessite deux accès à la mémoire distincts et les coller ensemble. Trois fois plus lent.

Les types double et long sont les fauteurs de troubles dans un processus 32 bits. Ils ont une taille de 64 bits. Et peut ainsi se désaligner de 4, le CLR ne peut garantir qu'un alignement 32 bits. Ce n'est pas un problème dans un processus 64 bits, toutes les variables sont assurées d'être alignées sur 8. Aussi la raison sous-jacente pour laquelle le langage C # ne peut pas leur promettre d'être atomique. Et pourquoi les tableaux de double sont alloués dans le grand tas d'objets lorsqu'ils ont plus de 1 000 éléments. La 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, de sorte qu'elle a déplacé la variable double de 4, la maintenant alignée. Par accident.

Un compilateur C ou C++ 32 bits fait un travail supplémentaire pour garantir que double ne peut pas être désaligné. Ce n'est pas exactement un problème simple à résoudre, la pile peut être désalignée lorsqu'une fonction est entrée, étant donné que la seule garantie est qu'elle est alignée sur 4. Le prologue d'une telle fonction doit faire un travail supplémentaire pour l'aligner sur 8. La même astuce ne fonctionne pas dans un programme géré, le garbage collector se soucie beaucoup de l'emplacement exact d'une variable locale en mémoire. Nécessaire pour qu'il puisse découvrir qu'un objet du tas GC est toujours référencé. Il ne peut pas gérer correctement une telle variable se déplaçant de 4 car la pile était mal alignée lorsque la méthode a été entrée.

C'est également le problème sous-jacent avec la gigue .NET qui ne prend pas facilement en charge les instructions SIMD. Ils ont des exigences d'alignement beaucoup plus fortes, du genre que le processeur ne peut pas résoudre lui-même non plus. SSE2 nécessite un alignement de 16, AVX nécessite un alignement de 32. Impossible d'obtenir cela dans le code managé.

Enfin, notez également que cela rend la performance d'un programme C # qui s'exécute en mode 32 bits très imprévisible. Lorsque vous accédez à un double ou long qui est stocké en tant que champ dans un objet, perf peut changer radicalement lorsque le garbage collector compacte le tas. Ce qui déplace des objets en mémoire, un tel champ peut maintenant soudainement être mal aligné. Très aléatoire bien sûr, peut être un vrai coup de tête :)

Eh bien, pas de correctifs simples, mais un code 64 bits est l'avenir. Supprimez le forçage de gigue tant que Microsoft ne modifie pas le modèle de projet. Peut-être la prochaine version quand ils auront plus confiance en Ryujit.

75
Hans Passant

Restreint certains (ce qui ne semble affecter le runtime CLR 4.0 32 bits).

Remarquez l'emplacement du var f = Stopwatch.Frequency; fait toute la différence.

Lent (2700 ms):

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 (800 ms):

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);
}
5
leppie

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 en 900 Ms, comme le boîtier du chronomètre externe. Cependant, si nous supprimons la condition if (!warmup), elle s'exécutera en 3000 Ms. Ce qui est encore plus étrange, c'est que le code suivant s'exécutera également 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);
}

Remarque J'ai supprimé les références a.X Et a.Y De la sortie Console.

Je n'ai aucune idée de ce qui se passe, mais cela me sent assez bogué et ce n'est pas lié à un Stopwatch externe ou non, le problème semble un peu plus généralisé.

4
InBetween