Je suis intéressé par l'efficacité des algorithmes de bas niveau dans .net. Je voudrais nous permettre de choisir d'écrire plus de notre code en C # plutôt qu'en C++ à l'avenir, mais un obstacle est la vérification des limites dans .net qui se produit avec le bouclage et l'accès aléatoire aux tableaux.
Un exemple motivant est une fonction qui calcule la somme des produits des éléments correspondants dans deux tableaux (il s'agit du produit scalaire de deux vecteurs).
static void SumProduct(double[] X, double[] Y)
{
double sum = 0;
int length = X.Length;
if (length != Y.Length)
throw new ArgumentException("X and Y must be same size");
for (int i = 0; i < length; i++) // Check X.Length instead? See below
sum += X[i] * Y[i];
}
D'après ce que je peux dire, et je ne connais pas suffisamment IL ou x86 pour vérifier, le compilateur n'optimisera pas la vérification des limites de X
etY
. Suis-je dans l'erreur et/ou existe-t-il un moyen d'écrire mon code pour permettre au compilateur de m'aider?
Plus de détails
Il existe de nombreux arguments d'efficacité pour et contre l'utilisation de langages particuliers, notamment qu'il vaut mieux se concentrer sur le coût algorithmique du "grand O" plutôt que sur la constante de proportionnalité, et les langages de niveau supérieur vous y aident. Au sujet de la vérification des limites dans .net, le meilleur article que j'ai trouvé est Elimination de la vérification des limites du tableau dans le CLR sur MSDN (également référencé dans un réponse de débordement de pile sur l'importance d’optimisation).
Cela date de 2009, donc je me demande si les choses ont changé de manière significative depuis lors. De plus, l'article révèle de réelles subtilités qui m'auraient rattrapé, c'est pour cette seule raison que je serais heureux de recevoir des conseils d'experts.
Par exemple, il semble que dans mon code ci-dessus, je ferais mieux d'écrire i< X.Length
Plutôt que i < length
. De plus, j'avais également supposé naïvement que pour un algorithme avec un seul tableau, l'écriture d'une boucle foreach
ferait mieux de déclarer votre intention au compilateur et lui donnerait la meilleure chance d'optimiser la vérification des limites.
Selon l'article MSDN, SumForBAD
, ci-dessous, que je pensais sûr d'être optimisé, ne le serait pas. Alors que SumFor
serait directement optimisé, et SumForEach
serait également optimisé, mais pas trivialement (et pourrait ne pas être optimisé du tout si le tableau était passé dans une fonction en tant que IEnumerable<int>
)?
static double SumForBAD(double[] X)
{
double sum = 0;
int length = X.Length; // better to use i < X.length in loop
for (int i = 0; i < length; i++)
sum += X[i];
return sum;
}
static double SumFor(double[] X)
{
double sum = 0;
for (int i = 0; i < X.Length; i++)
sum += X[i];
return sum;
}
static double SumForEach(double[] X)
{
double sum = 0;
foreach (int element in X)
sum += element;
return sum;
}
J'ai fait une enquête basée sur la réponse de doug65536. En C++, j'ai comparé les temps d'un SumProduct qui fait une vérification des limites
for(int i=0; i<n; ++i) sum += v1[i]*v2[i];
contre une autre version qui fait deux vérifications de limites
for(int i=0; i<n1 && i <n2; ++i) sum += v1[i]*v2[i];
J'ai trouvé que la deuxième version était plus lente, mais seulement d'environ 3,5% (Visual Studio 2010, build optimisé, options par défaut). Cependant, il m'est venu à l'esprit qu'en C #, il pouvait y avoir trois vérifications de limites. Un explicite (i < length
Dans la fonction static void SumProduct(double[] X, double[] Y)
au début de cette question) et deux implicites (X[i]
Et Y[i]
). J'ai donc testé une troisième fonction C++, avec trois vérifications de limites
for(int i=0; i<n1 && i <n2 && i <n3; ++i) sum += v1[i]*v2[i];
Cela est arrivé 35% plus lentement que le premier, ce qui mérite d'être pris en compte. J'ai fait un peu plus d'enquête dans cette question, Pourquoi l'ajout d'une boucle d'enregistrement supplémentaire fait-il une grande différence sur certaines machines et une petite différence sur d'autres? . Fait intéressant, il semble que le coût de la vérification des limites varie considérablement sur différentes machines.
La vérification des limites n'aura pas d'importance car:
La vérification des limites consiste en une paire d'instructions cmp
/jae
, qui est fusionnée en une seule micro-op sur les architectures CPU modernes (le terme est "fusion macro-op"). La comparaison et la branche sont très hautement optimisées.
La vérification des limites est une branche à terme, qui sera statistiquement prédite comme étant non prise, ce qui réduira également le coût. La branche ne sera jamais prise. (Si jamais elle est prise, une exception sera de toute façon levée, donc le coût de l'erreur de prévision devient tout à fait hors de propos)
Dès qu'il y a un retard de mémoire, l'exécution spéculative mettra en file d'attente de nombreuses itérations de la boucle, de sorte que le coût de décodage de la paire d'instructions supplémentaires disparaît presque.
L'accès à la mémoire sera probablement votre goulot d'étranglement, donc les micro-optimisations d'effet comme la suppression des vérifications de limites disparaîtront.
La gigue 64 bits fait un bon travail d'éliminer les contrôles de limites (au moins dans les scénarios simples). J'ai ajouté return sum;
à la fin de votre méthode, puis compilé le programme à l'aide de Visual Studio 2010 en mode Release. Dans le démontage ci-dessous (que j'ai annoté avec une traduction C #), notez que:
X
, même si votre code compare i
à length
au lieu de X.Length
. Il s'agit d'une amélioration par rapport au comportement décrit dans l'article.Y.Length >= X.Length
.Démontage
; Register assignments:
; rcx := i
; rdx := X
; r8 := Y
; r9 := X.Length ("length" in your code, "XLength" below)
; r10 := Y.Length ("YLength" below)
; r11 := X.Length - 1 ("XLengthMinus1" below)
; xmm1 := sum
; (Prologue)
00000000 Push rbx
00000001 Push rdi
00000002 sub rsp,28h
; (Store arguments X and Y in rdx and r8)
00000006 mov r8,rdx ; Y
00000009 mov rdx,rcx ; X
; int XLength = X.Length;
0000000c mov r9,qword ptr [rdx+8]
; int XLengthMinus1 = XLength - 1;
00000010 movsxd rax,r9d
00000013 lea r11,[rax-1]
; int YLength = Y.Length;
00000017 mov r10,qword ptr [r8+8]
; if (XLength != YLength)
; throw new ArgumentException("X and Y must be same size");
0000001b cmp r9d,r10d
0000001e jne 0000000000000060
; double sum = 0;
00000020 xorpd xmm1,xmm1
; if (XLength > 0)
; {
00000024 test r9d,r9d
00000027 jle 0000000000000054
; int i = 0;
00000029 xor ecx,ecx
0000002b xor eax,eax
; if (XLengthMinus1 >= YLength)
; throw new IndexOutOfRangeException();
0000002d cmp r11,r10
00000030 jae 0000000000000096
; do
; {
; sum += X[i] * Y[i];
00000032 movsd xmm0,mmword ptr [rdx+rax+10h]
00000038 mulsd xmm0,mmword ptr [r8+rax+10h]
0000003f addsd xmm0,xmm1
00000043 movapd xmm1,xmm0
; i++;
00000047 inc ecx
00000049 add rax,8
; }
; while (i < XLength);
0000004f cmp ecx,r9d
00000052 jl 0000000000000032
; }
; return sum;
00000054 movapd xmm0,xmm1
; (Epilogue)
00000058 add rsp,28h
0000005c pop rdi
0000005d pop rbx
0000005e ret
00000060 ...
00000096 ...
La gigue 32 bits, malheureusement, n'est pas aussi intelligente. Dans le démontage ci-dessous, notez que:
X
, même si votre code compare i
à length
au lieu de X.Length
. Encore une fois, il s'agit d'une amélioration par rapport au comportement décrit dans l'article.Y
.Démontage
; Register assignments:
; eax := i
; ecx := X
; edx := Y
; esi := X.Length ("length" in your code, "XLength" below)
; (Prologue)
00000000 Push ebp
00000001 mov ebp,esp
00000003 Push esi
; double sum = 0;
00000004 fldz
; int XLength = X.Length;
00000006 mov esi,dword ptr [ecx+4]
; if (XLength != Y.Length)
; throw new ArgumentException("X and Y must be same size");
00000009 cmp dword ptr [edx+4],esi
0000000c je 00000012
0000000e fstp st(0)
00000010 jmp 0000002F
; int i = 0;
00000012 xor eax,eax
; if (XLength > 0)
; {
00000014 test esi,esi
00000016 jle 0000002C
; do
; {
; double temp = X[i];
00000018 fld qword ptr [ecx+eax*8+8]
; if (i >= Y.Length)
; throw new IndexOutOfRangeException();
0000001c cmp eax,dword ptr [edx+4]
0000001f jae 0000005A
; sum += temp * Y[i];
00000021 fmul qword ptr [edx+eax*8+8]
00000025 faddp st(1),st
; i++;
00000027 inc eax
; while (i < XLength);
00000028 cmp eax,esi
0000002a jl 00000018
; }
; return sum;
0000002c pop esi
0000002d pop ebp
0000002e ret
0000002f ...
0000005a ...
La gigue s'est améliorée depuis 2009, et la gigue 64 bits peut générer du code plus efficace que la gigue 32 bits.
Si nécessaire, cependant, vous pouvez toujours contourner complètement les vérifications des limites du tableau en utilisant du code et des pointeurs dangereux (comme le souligne svick). Cette technique est utilisée par certains codes critiques pour les performances dans la bibliothèque de classes de base.
Une façon de vous assurer que la vérification des limites n'est pas effectuée consiste à utiliser des pointeurs, ce que vous pouvez faire en C # en mode non sécurisé (cela vous oblige à définir un indicateur dans les propriétés du projet):
private static unsafe double SumProductPointer(double[] X, double[] Y)
{
double sum = 0;
int length = X.Length;
if (length != Y.Length)
throw new ArgumentException("X and Y must be same size");
fixed (double* xp = X, yp = Y)
{
for (int i = 0; i < length; i++)
sum += xp[i] * yp[i];
}
return sum;
}
J'ai essayé de mesurer votre méthode d'origine, votre méthode avec le X.Length
changer et mon code à l'aide de pointeurs, compilés à la fois en x86 et x64 sous .Net 4.5. Plus précisément, j'ai essayé de calculer la méthode pour des vecteurs de longueur 10 000 et j'ai exécuté la méthode 10 000 fois.
Les résultats sont à peu près conformes à la réponse de Michael Liu: il n'y a pas de différence mesurable entre les trois méthodes, ce qui signifie que la vérification des limites n'est pas effectuée ou que son effet sur les performances est insignifiant. Il y avait cependant une différence mesurable entre x86 et x64: x64 était environ 34% plus lent.
Code complet que j'ai utilisé:
static void Main()
{
var random = new Random(42);
double[] x = Enumerable.Range(0, 10000).Select(_ => random.NextDouble()).ToArray();
double[] y = Enumerable.Range(0, 10000).Select(_ => random.NextDouble()).ToArray();
// make sure JIT doesn't affect the results
SumProduct(x, y);
SumProductLength(x, y);
SumProductPointer(x, y);
var stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < 10000; i++)
{
SumProduct(x, y);
}
Console.WriteLine(stopwatch.ElapsedMilliseconds);
stopwatch.Restart();
for (int i = 0; i < 10000; i++)
{
SumProductLength(x, y);
}
Console.WriteLine(stopwatch.ElapsedMilliseconds);
stopwatch.Restart();
for (int i = 0; i < 10000; i++)
{
SumProductPointer(x, y);
}
Console.WriteLine(stopwatch.ElapsedMilliseconds);
}
private static double SumProduct(double[] X, double[] Y)
{
double sum = 0;
int length = X.Length;
if (length != Y.Length)
throw new ArgumentException("X and Y must be same size");
for (int i = 0; i < length; i++)
sum += X[i] * Y[i];
return sum;
}
private static double SumProductLength(double[] X, double[] Y)
{
double sum = 0;
if (X.Length != Y.Length)
throw new ArgumentException("X and Y must be same size");
for (int i = 0; i < X.Length; i++)
sum += X[i] * Y[i];
return sum;
}
private static unsafe double SumProductPointer(double[] X, double[] Y)
{
double sum = 0;
int length = X.Length;
if (length != Y.Length)
throw new ArgumentException("X and Y must be same size");
fixed (double* xp = X, yp = Y)
{
for (int i = 0; i < length; i++)
sum += xp[i] * yp[i];
}
return sum;
}
Tout d'abord, je voudrais remercier tous ceux qui se sont exprimés dans ce post, de l'OP original aux gars qui ont fourni des explications extrêmement détaillées et perspicaces. J'ai vraiment, vraiment apprécié lire les réponses existantes. Puisqu'il y a déjà beaucoup de théorie sur comment et pourquoi les boucles fonctionnent comme elles le font, je voudrais proposer quelques mesures empiriques (selon une certaine définition faisant autorité):
Conclusions:
.Length
propriété.unsafe fixed
n'est pas plus rapide que la boucle For normale.Code de référence:
using System;
using System.Diagnostics;
using System.Runtime;
namespace demo
{
class MainClass
{
static bool ByForArrayLength (byte[] data)
{
for (int i = 0; i < data.Length; i++)
if (data [i] != 0)
return false;
return true;
}
static bool ByForLocalLength (byte[] data)
{
int len = data.Length;
for (int i = 0; i < len; i++)
if (data [i] != 0)
return false;
return true;
}
static unsafe bool ByForUnsafe (byte[] data)
{
fixed (byte* datap = data)
{
int len = data.Length;
for (int i = 0; i < len; i++)
if (datap [i] != 0)
return false;
return true;
}
}
static bool ByForeach (byte[] data)
{
foreach (byte b in data)
if (b != 0)
return false;
return true;
}
static void Measure (Action work, string description)
{
GCSettings.LatencyMode = GCLatencyMode.LowLatency;
var watch = Stopwatch.StartNew ();
work.Invoke ();
Console.WriteLine ("{0,-40}: {1} ms", description, watch.Elapsed.TotalMilliseconds);
}
public static void Main (string[] args)
{
byte[] data = new byte[256 * 1024 * 1024];
Measure (() => ByForArrayLength (data), "For with .Length property");
Measure (() => ByForLocalLength (data), "For with local variable");
Measure (() => ByForUnsafe (data), "For with local variable and GC-pinning");
Measure (() => ByForeach (data), "Foreach loop");
}
}
}
Résultats: (utilise le runtime Mono)
$ mcs Program.cs -optimize -unsafe
For with .Length property : 440,9208 ms
For with local variable : 333,2252 ms
For with local variable and GC-pinning : 330,2205 ms
Foreach loop : 280,5205 ms