Pourquoi les opérateurs sont-ils beaucoup plus lents que les appels de méthode? (les structures ne sont plus lentes que sur les anciennes JIT)

Intro: J’écris du code haute performance en C #. Oui, je sais que C ++ me donnerait une meilleure optimisation, mais je choisis quand même d’utiliser C #. Je ne souhaite pas débattre de ce choix. J’aimerais plutôt entendre ceux qui, comme moi, essaient d’écrire du code haute performance sur le .NET Framework.

Des questions:

  • Pourquoi l’opérateur dans le code ci-dessous est-il plus lent que l’appel de méthode équivalent?
  • Pourquoi la méthode qui passe deux doubles dans le code ci-dessous est-elle plus rapide que la méthode équivalente en passant une structure qui a deux doubles? (A: les anciens JIT optimisent mal les structures)
  • Est-il possible de faire en sorte que le compilateur JIT .NET traite les structures simples aussi efficacement que les membres de la structure? (A: obtenir plus récent JIT)

Ce que je pense savoir: Le compilateur JIT .NET original ne contient rien qui implique une structure. Les structures données bizarres ne doivent être utilisées que lorsque vous avez besoin de petits types de valeur qui doivent être optimisés comme des éléments intégrés, mais vrai. Heureusement, dans .NET 3.5SP1 et .NET 2.0SP2, ils ont apporté des améliorations à l’Optimiseur JIT, notamment des améliorations de l’inline, en particulier pour les structures. (Je suppose qu’ils l’ont fait parce que sinon la nouvelle structure complexe qu’ils introduisaient aurait été horriblement performante … l’équipe de Complexe était probablement en train de battre l’équipe JIT Optimizer.) Toute documentation antérieure à .NET 3.5 SP1 est probablement pas trop pertinent pour cette question.

Ce que mes tests montrent: J’ai vérifié que JIT Optimizer était plus récent en vérifiant que le fichier C: \ Windows \ Microsoft.NET \ Framework \ v2.0.50727 \ mscorwks.dll contenait la version> = 3053 et qu’il devrait donc y avoir ces améliorations à l’optimiseur JIT. Cependant, même avec cela, ce que mes timings et regards au déassembly montrent tous les deux sont:

Le code produit par JIT pour passer une structure avec deux doubles est beaucoup moins efficace que le code qui passe directement par les deux doubles.

Le code produit par JIT pour une méthode struct passe «this» beaucoup plus efficacement que si vous transmettiez une struct en argument.

Le JIT s’intègre encore mieux si vous passez deux doubles plutôt que de passer une structure avec deux doubles, même si le multiplicateur est clairement dans une boucle.

Les timings: En fait, en regardant le désassemblage, je me rends compte que la plupart du temps, les boucles ne font qu’accéder aux données de test de la liste. La différence entre les quatre façons de faire les mêmes appels est radicalement différente si vous extrapolez le code de la boucle et l’access aux données. J’obtiens des accélérations de 5x à 20x pour faire plusEqual (double, double) au lieu de PlusEqual (Element). Et 10x à 40x pour faire PlusEqual (double, double) au lieu de operator + =. Sensationnel. Triste.

Voici un ensemble de timings:

Populating List took 320ms. The PlusEqual() method took 105ms. The 'same' += operator took 131ms. The 'same' -= operator took 139ms. The PlusEqual(double, double) method took 68ms. The do nothing loop took 66ms. The ratio of operator with constructor to method is 124%. The ratio of operator without constructor to method is 132%. The ratio of PlusEqual(double,double) to PlusEqual(Element) is 64%. If we remove the overhead time for the loop accessing the elements from the List... The ratio of operator with constructor to method is 166%. The ratio of operator without constructor to method is 187%. The ratio of PlusEqual(double,double) to PlusEqual(Element) is 5%. 

Le code:

 namespace OperatorVsMethod { public struct Element { public double Left; public double Right; public Element(double left, double right) { this.Left = left; this.Right = right; } public static Element operator +(Element x, Element y) { return new Element(x.Left + y.Left, x.Right + y.Right); } public static Element operator -(Element x, Element y) { x.Left += y.Left; x.Right += y.Right; return x; } ///  /// Like the += operator; but faster. ///  public void PlusEqual(Element that) { this.Left += that.Left; this.Right += that.Right; } ///  /// Like the += operator; but faster. ///  public void PlusEqual(double thatLeft, double thatRight) { this.Left += thatLeft; this.Right += thatRight; } } [TestClass] public class UnitTest1 { [TestMethod] public void TestMethod1() { Stopwatch stopwatch = new Stopwatch(); // Populate a List of Elements to multiply together int seedSize = 4; List doubles = new List(seedSize); doubles.Add(2.5d); doubles.Add(100000d); doubles.Add(-0.5d); doubles.Add(-100002d); int size = 2500000 * seedSize; List elts = new List(size); stopwatch.Reset(); stopwatch.Start(); for (int ii = 0; ii < size; ++ii) { int di = ii % seedSize; double d = doubles[di]; elts.Add(new Element(d, d)); } stopwatch.Stop(); long populateMS = stopwatch.ElapsedMilliseconds; // Measure speed of += operator (calls ctor) Element operatorCtorResult = new Element(1d, 1d); stopwatch.Reset(); stopwatch.Start(); for (int ii = 0; ii < size; ++ii) { operatorCtorResult += elts[ii]; } stopwatch.Stop(); long operatorCtorMS = stopwatch.ElapsedMilliseconds; // Measure speed of -= operator (+= without ctor) Element operatorNoCtorResult = new Element(1d, 1d); stopwatch.Reset(); stopwatch.Start(); for (int ii = 0; ii < size; ++ii) { operatorNoCtorResult -= elts[ii]; } stopwatch.Stop(); long operatorNoCtorMS = stopwatch.ElapsedMilliseconds; // Measure speed of PlusEqual(Element) method Element plusEqualResult = new Element(1d, 1d); stopwatch.Reset(); stopwatch.Start(); for (int ii = 0; ii < size; ++ii) { plusEqualResult.PlusEqual(elts[ii]); } stopwatch.Stop(); long plusEqualMS = stopwatch.ElapsedMilliseconds; // Measure speed of PlusEqual(double, double) method Element plusEqualDDResult = new Element(1d, 1d); stopwatch.Reset(); stopwatch.Start(); for (int ii = 0; ii < size; ++ii) { Element elt = elts[ii]; plusEqualDDResult.PlusEqual(elt.Left, elt.Right); } stopwatch.Stop(); long plusEqualDDMS = stopwatch.ElapsedMilliseconds; // Measure speed of doing nothing but accessing the Element Element doNothingResult = new Element(1d, 1d); stopwatch.Reset(); stopwatch.Start(); for (int ii = 0; ii < size; ++ii) { Element elt = elts[ii]; double left = elt.Left; double right = elt.Right; } stopwatch.Stop(); long doNothingMS = stopwatch.ElapsedMilliseconds; // Report results Assert.AreEqual(1d, operatorCtorResult.Left, "The operator += did not compute the right result!"); Assert.AreEqual(1d, operatorNoCtorResult.Left, "The operator += did not compute the right result!"); Assert.AreEqual(1d, plusEqualResult.Left, "The operator += did not compute the right result!"); Assert.AreEqual(1d, plusEqualDDResult.Left, "The operator += did not compute the right result!"); Assert.AreEqual(1d, doNothingResult.Left, "The operator += did not compute the right result!"); // Report speeds Console.WriteLine("Populating List took {0}ms.", populateMS); Console.WriteLine("The PlusEqual() method took {0}ms.", plusEqualMS); Console.WriteLine("The 'same' += operator took {0}ms.", operatorCtorMS); Console.WriteLine("The 'same' -= operator took {0}ms.", operatorNoCtorMS); Console.WriteLine("The PlusEqual(double, double) method took {0}ms.", plusEqualDDMS); Console.WriteLine("The do nothing loop took {0}ms.", doNothingMS); // Compare speeds long percentageRatio = 100L * operatorCtorMS / plusEqualMS; Console.WriteLine("The ratio of operator with constructor to method is {0}%.", percentageRatio); percentageRatio = 100L * operatorNoCtorMS / plusEqualMS; Console.WriteLine("The ratio of operator without constructor to method is {0}%.", percentageRatio); percentageRatio = 100L * plusEqualDDMS / plusEqualMS; Console.WriteLine("The ratio of PlusEqual(double,double) to PlusEqual(Element) is {0}%.", percentageRatio); operatorCtorMS -= doNothingMS; operatorNoCtorMS -= doNothingMS; plusEqualMS -= doNothingMS; plusEqualDDMS -= doNothingMS; Console.WriteLine("If we remove the overhead time for the loop accessing the elements from the List..."); percentageRatio = 100L * operatorCtorMS / plusEqualMS; Console.WriteLine("The ratio of operator with constructor to method is {0}%.", percentageRatio); percentageRatio = 100L * operatorNoCtorMS / plusEqualMS; Console.WriteLine("The ratio of operator without constructor to method is {0}%.", percentageRatio); percentageRatio = 100L * plusEqualDDMS / plusEqualMS; Console.WriteLine("The ratio of PlusEqual(double,double) to PlusEqual(Element) is {0}%.", percentageRatio); } } } 

L’IL: (ou quelque chose de ce qui précède est compilé dans)

 public void PlusEqual(Element that) { 00000000 push ebp 00000001 mov ebp,esp 00000003 push edi 00000004 push esi 00000005 push ebx 00000006 sub esp,30h 00000009 xor eax,eax 0000000b mov dword ptr [ebp-10h],eax 0000000e xor eax,eax 00000010 mov dword ptr [ebp-1Ch],eax 00000013 mov dword ptr [ebp-3Ch],ecx 00000016 cmp dword ptr ds:[04C87B7Ch],0 0000001d je 00000024 0000001f call 753081B1 00000024 nop this.Left += that.Left; 00000025 mov eax,dword ptr [ebp-3Ch] 00000028 fld qword ptr [ebp+8] 0000002b fadd qword ptr [eax] 0000002d fstp qword ptr [eax] this.Right += that.Right; 0000002f mov eax,dword ptr [ebp-3Ch] 00000032 fld qword ptr [ebp+10h] 00000035 fadd qword ptr [eax+8] 00000038 fstp qword ptr [eax+8] } 0000003b nop 0000003c lea esp,[ebp-0Ch] 0000003f pop ebx 00000040 pop esi 00000041 pop edi 00000042 pop ebp 00000043 ret 10h public void PlusEqual(double thatLeft, double thatRight) { 00000000 push ebp 00000001 mov ebp,esp 00000003 push edi 00000004 push esi 00000005 push ebx 00000006 sub esp,30h 00000009 xor eax,eax 0000000b mov dword ptr [ebp-10h],eax 0000000e xor eax,eax 00000010 mov dword ptr [ebp-1Ch],eax 00000013 mov dword ptr [ebp-3Ch],ecx 00000016 cmp dword ptr ds:[04C87B7Ch],0 0000001d je 00000024 0000001f call 75308159 00000024 nop this.Left += thatLeft; 00000025 mov eax,dword ptr [ebp-3Ch] 00000028 fld qword ptr [ebp+10h] 0000002b fadd qword ptr [eax] 0000002d fstp qword ptr [eax] this.Right += thatRight; 0000002f mov eax,dword ptr [ebp-3Ch] 00000032 fld qword ptr [ebp+8] 00000035 fadd qword ptr [eax+8] 00000038 fstp qword ptr [eax+8] } 0000003b nop 0000003c lea esp,[ebp-0Ch] 0000003f pop ebx 00000040 pop esi 00000041 pop edi 00000042 pop ebp 00000043 ret 10h 

Je reçois des résultats très différents, beaucoup moins dramatiques. Mais n’a pas utilisé le coureur de test, j’ai collé le code dans une application en mode console. Le résultat de 5% est ~ 87% en mode 32 bits, ~ 100% en mode 64 bits lorsque je l’essaie.

L’alignement est critique sur les doubles, le runtime .NET ne peut que promettre un alignement de 4 sur une machine 32 bits. Il me semble que le lanceur de test lance les méthodes de test avec une adresse de stack alignée sur 4 au lieu de 8. La pénalité de désalignement devient très importante lorsque le double traverse une limite de ligne de cache.

J’ai de la difficulté à reproduire vos résultats.

J’ai pris ton code:

  • en a fait une application console autonome
  • construit une version optimisée (release)
  • augmentation du facteur “taille” de 2,5 M à 10 M
  • l’a exécuté depuis la ligne de commande (en dehors de l’EDI)

Quand je l’ai fait, j’ai eu les timings suivants qui sont très différents des vôtres. Pour éviter tout doute, je posterai exactement le code que j’ai utilisé.

Voici mes horaires

 Populating List took 527ms. The PlusEqual() method took 450ms. The 'same' += operator took 386ms. The 'same' -= operator took 446ms. The PlusEqual(double, double) method took 413ms. The do nothing loop took 229ms. The ratio of operator with constructor to method is 85%. The ratio of operator without constructor to method is 99%. The ratio of PlusEqual(double,double) to PlusEqual(Element) is 91%. If we remove the overhead time for the loop accessing the elements from the List... The ratio of operator with constructor to method is 71%. The ratio of operator without constructor to method is 98%. The ratio of PlusEqual(double,double) to PlusEqual(Element) is 83%. 

Et ce sont mes modifications à votre code:

 namespace OperatorVsMethod { public struct Element { public double Left; public double Right; public Element(double left, double right) { this.Left = left; this.Right = right; } public static Element operator +(Element x, Element y) { return new Element(x.Left + y.Left, x.Right + y.Right); } public static Element operator -(Element x, Element y) { x.Left += y.Left; x.Right += y.Right; return x; } ///  /// Like the += operator; but faster. ///  public void PlusEqual(Element that) { this.Left += that.Left; this.Right += that.Right; } ///  /// Like the += operator; but faster. ///  public void PlusEqual(double thatLeft, double thatRight) { this.Left += thatLeft; this.Right += thatRight; } } public class UnitTest1 { public static void Main() { Stopwatch stopwatch = new Stopwatch(); // Populate a List of Elements to multiply together int seedSize = 4; List doubles = new List(seedSize); doubles.Add(2.5d); doubles.Add(100000d); doubles.Add(-0.5d); doubles.Add(-100002d); int size = 10000000 * seedSize; List elts = new List(size); stopwatch.Reset(); stopwatch.Start(); for (int ii = 0; ii < size; ++ii) { int di = ii % seedSize; double d = doubles[di]; elts.Add(new Element(d, d)); } stopwatch.Stop(); long populateMS = stopwatch.ElapsedMilliseconds; // Measure speed of += operator (calls ctor) Element operatorCtorResult = new Element(1d, 1d); stopwatch.Reset(); stopwatch.Start(); for (int ii = 0; ii < size; ++ii) { operatorCtorResult += elts[ii]; } stopwatch.Stop(); long operatorCtorMS = stopwatch.ElapsedMilliseconds; // Measure speed of -= operator (+= without ctor) Element operatorNoCtorResult = new Element(1d, 1d); stopwatch.Reset(); stopwatch.Start(); for (int ii = 0; ii < size; ++ii) { operatorNoCtorResult -= elts[ii]; } stopwatch.Stop(); long operatorNoCtorMS = stopwatch.ElapsedMilliseconds; // Measure speed of PlusEqual(Element) method Element plusEqualResult = new Element(1d, 1d); stopwatch.Reset(); stopwatch.Start(); for (int ii = 0; ii < size; ++ii) { plusEqualResult.PlusEqual(elts[ii]); } stopwatch.Stop(); long plusEqualMS = stopwatch.ElapsedMilliseconds; // Measure speed of PlusEqual(double, double) method Element plusEqualDDResult = new Element(1d, 1d); stopwatch.Reset(); stopwatch.Start(); for (int ii = 0; ii < size; ++ii) { Element elt = elts[ii]; plusEqualDDResult.PlusEqual(elt.Left, elt.Right); } stopwatch.Stop(); long plusEqualDDMS = stopwatch.ElapsedMilliseconds; // Measure speed of doing nothing but accessing the Element Element doNothingResult = new Element(1d, 1d); stopwatch.Reset(); stopwatch.Start(); for (int ii = 0; ii < size; ++ii) { Element elt = elts[ii]; double left = elt.Left; double right = elt.Right; } stopwatch.Stop(); long doNothingMS = stopwatch.ElapsedMilliseconds; // Report speeds Console.WriteLine("Populating List took {0}ms.", populateMS); Console.WriteLine("The PlusEqual() method took {0}ms.", plusEqualMS); Console.WriteLine("The 'same' += operator took {0}ms.", operatorCtorMS); Console.WriteLine("The 'same' -= operator took {0}ms.", operatorNoCtorMS); Console.WriteLine("The PlusEqual(double, double) method took {0}ms.", plusEqualDDMS); Console.WriteLine("The do nothing loop took {0}ms.", doNothingMS); // Compare speeds long percentageRatio = 100L * operatorCtorMS / plusEqualMS; Console.WriteLine("The ratio of operator with constructor to method is {0}%.", percentageRatio); percentageRatio = 100L * operatorNoCtorMS / plusEqualMS; Console.WriteLine("The ratio of operator without constructor to method is {0}%.", percentageRatio); percentageRatio = 100L * plusEqualDDMS / plusEqualMS; Console.WriteLine("The ratio of PlusEqual(double,double) to PlusEqual(Element) is {0}%.", percentageRatio); operatorCtorMS -= doNothingMS; operatorNoCtorMS -= doNothingMS; plusEqualMS -= doNothingMS; plusEqualDDMS -= doNothingMS; Console.WriteLine("If we remove the overhead time for the loop accessing the elements from the List..."); percentageRatio = 100L * operatorCtorMS / plusEqualMS; Console.WriteLine("The ratio of operator with constructor to method is {0}%.", percentageRatio); percentageRatio = 100L * operatorNoCtorMS / plusEqualMS; Console.WriteLine("The ratio of operator without constructor to method is {0}%.", percentageRatio); percentageRatio = 100L * plusEqualDDMS / plusEqualMS; Console.WriteLine("The ratio of PlusEqual(double,double) to PlusEqual(Element) is {0}%.", percentageRatio); } } } 

Exécution de .NET 4.0 ici. J’ai compilé avec “Any CPU”, en ciblant .NET 4.0 en mode release. L’exécution était à partir de la ligne de commande. Il a fonctionné en mode 64 bits. Mes horaires sont un peu différents.

 Populating List took 442ms. The PlusEqual() method took 115ms. The 'same' += operator took 201ms. The 'same' -= operator took 200ms. The PlusEqual(double, double) method took 129ms. The do nothing loop took 93ms. The ratio of operator with constructor to method is 174%. The ratio of operator without constructor to method is 173%. The ratio of PlusEqual(double,double) to PlusEqual(Element) is 112%. If we remove the overhead time for the loop accessing the elements from the List ... The ratio of operator with constructor to method is 490%. The ratio of operator without constructor to method is 486%. The ratio of PlusEqual(double,double) to PlusEqual(Element) is 163%. 

En particulier, PlusEqual(Element) est légèrement plus rapide que PlusEqual(double, double) .

Quel que soit le problème dans .NET 3.5, il ne semble pas exister dans .NET 4.0.

Comme @Corey Kosak, je viens de lancer ce code dans VS 2010 Express en tant que simple application console en mode Release. Je reçois des chiffres très différents. Mais j’ai aussi Fx4.5 donc ce ne sont peut-être pas les résultats pour un Fx4.0 propre.

 Populating List took 435ms. The PlusEqual() method took 109ms. The 'same' += operator took 217ms. The 'same' -= operator took 157ms. The PlusEqual(double, double) method took 118ms. The do nothing loop took 79ms. The ratio of operator with constructor to method is 199%. The ratio of operator without constructor to method is 144%. The ratio of PlusEqual(double,double) to PlusEqual(Element) is 108%. If we remove the overhead time for the loop accessing the elements from the List ... The ratio of operator with constructor to method is 460%. The ratio of operator without constructor to method is 260%. The ratio of PlusEqual(double,double) to PlusEqual(Element) is 130%. 

Edit: et maintenant à partir de la ligne de commande. Cela fait une différence, et moins de variation dans les chiffres.

Vous ne savez pas si cela est pertinent, mais voici les chiffres pour .NET 4.0 64 bits sur Windows 7 64 bits. Ma version de mscorwks.dll est 2.0.50727.5446. Je viens de coller le code dans LINQPad et l’ai exécuté à partir de là. Voici le résultat:

 Populating List took 496ms. The PlusEqual() method took 189ms. The 'same' += operator took 295ms. The 'same' -= operator took 358ms. The PlusEqual(double, double) method took 148ms. The do nothing loop took 103ms. The ratio of operator with constructor to method is 156%. The ratio of operator without constructor to method is 189%. The ratio of PlusEqual(double,double) to PlusEqual(Element) is 78%. If we remove the overhead time for the loop accessing the elements from the List ... The ratio of operator with constructor to method is 223%. The ratio of operator without constructor to method is 296%. The ratio of PlusEqual(double,double) to PlusEqual(Element) is 52%. 

En plus des différences de compilateur JIT mentionnées dans d’autres réponses, une autre différence entre un appel de méthode struct et un opérateur struct est qu’un appel de méthode struct passe this tant que paramètre ref (et peut être écrit pour accepter d’autres parameters en tant que parameters ref ) , alors qu’un opérateur struct transmettra tous les opérandes par valeur. Le coût de transmission d’une structure de n’importe quelle taille en tant que paramètre ref est fixe, quelle que soit la taille de la structure, tandis que le coût du passage de structures plus grandes est proportionnel à la taille de la structure. Il n’y a rien de mal à utiliser de grandes structures (même des centaines d’octets) si l’on peut éviter de les copier inutilement ; Bien qu’il soit souvent possible d’éviter des copies inutiles lors de l’utilisation de méthodes, elles ne peuvent pas être évitées lors de l’utilisation d’opérateurs.

J’imagine que lorsque vous accédez à des membres de la structure, il s’agit d’une opération supplémentaire pour accéder au membre, le pointeur + décalage.

Peut-être au lieu de List, vous devez utiliser double [] avec des décalages et des incréments d’index “bien connus”?