Je remarque que les gens utilisent souvent des constructions comme celle-ci:
var length = array.Length; for (int i = 0; i < length; i++) {
Ils pensent qu'avoir un appel à Array.Length à chaque itération fera que CLR prendra plus de temps pour exécuter le code. Pour l'éviter, ils stockent la valeur de longueur dans une variable locale.
Voyons (une fois pour toutes!) Si c'est une chose viable ou utiliser une variable temporaire est une perte de temps.
Pour commencer, examinons ces méthodes C #:
public int WithoutVariable() { int sum = 0; for (int i = 0; i < array.Length; i++) { sum += array[i]; } return sum; } public int WithVariable() { int sum = 0; int length = array.Length; for (int i = 0; i < length; i++) { sum += array[i]; } return sum; }
Voici à quoi il ressemble après avoir été traité par le compilateur JIT (pour .NET Framework 4.7.2 sous LegacyJIT-x86):
Il est trivial de remarquer qu'ils ont exactement le même nombre d'instructions assembleur - 15. Même la logique de ces instructions est presque la même. Il y a une légère différence dans l'ordre d'initialisation des variables et des comparaisons sur la poursuite du cycle. On peut noter que dans les deux cas la longueur du tableau est enregistrée deux fois avant le cycle:
- Pour vérifier 0 (arrayLength)
- Dans la variable temporaire pour vérifier la condition du cycle (arrayLength2).
Il s'avère que les deux méthodes se compileront dans le même code exact, mais la première est écrite plus rapidement, même s'il n'y a aucun avantage en termes de temps d'exécution.
Le code assembleur ci-dessus m'a amené à quelques réflexions et j'ai décidé de vérifier quelques méthodes supplémentaires:
public int WithoutVariable() { int sum = 0; for(int i = 0; i < array.Length; i++) { sum += array[i] + array.Length; } return sum; } public int WithVariable() { int sum = 0; int length = array.Length; for(int i = 0; i < length; i++) { sum += array[i] + length; } return sum; }
L'élément actuel et la longueur du tableau sont ajoutés, mais dans le premier cas, la longueur du tableau est demandée à chaque fois, et dans le second cas, elle est enregistrée une fois dans une variable locale. Regardons le code assembleur de ces méthodes:
Encore une fois, le nombre d'instructions est le même, ainsi que (presque) les instructions elles-mêmes. La seule différence est l'ordre d'initialisation des variables et la condition de contrôle pour la poursuite du cycle. Vous pouvez noter que dans le calcul de la somme, seule la première longueur du tableau est prise en compte. Il est évident que ceci:
int arrayLength2 = localRefToArray . Longueur ;
mov edi , dword ptr [ edx + 4 ]
si ( i> = arrayLength2 ), lancez new IndexOutOfRangeException ( ) ;
cmp ecx edi
jae 04b12d39
dans les quatre méthodes est une vérification des limites du tableau en ligne et elle est exécutée pour chaque élément du tableau.
Nous pouvons déjà faire la première conclusion: utiliser une variable supplémentaire pour essayer d'accélérer le cycle est une perte de temps, car le compilateur le fera quand même pour vous. La seule raison de stocker un tableau de longueur dans une variable est de rendre le code plus lisible.
ForEach est une autre situation entièrement. Considérez les trois méthodes suivantes:
public int ForEachWithoutLength() { int sum = 0; foreach (int i in array) { sum += i; } return sum; } public int ForEachWithLengthWithoutLocalVariable() { int sum = 0; foreach (int i in array) { sum += i + array.Length; } return sum; } public int ForEachWithLengthWithLocalVariable() { int sum = 0; int length = array.Length; foreach (int i in array) { sum += i + length; } return sum; }
Et voici le code après JIT:
ForEachWithoutLength (); int sum = 0;
xor esi , esi
; int [] localRefToArray = this.array;
mov ecx , dword ptr [ ecx + 4 ]
; int i = 0;
xor edx , edx
; int arrayLength = localRefToArray.Length;
mov edi , dword ptr [ ecx + 4 ]
; if (arrayLength == 0) goto exit;
test edi edi
sortie jle
; int t = tableau [i];
boucle :
mov eax , dword ptr [ ecx + edx * 4 + 8 ]
; somme + = i;
ajouter esi , eax
; i ++;
inc edx
; if (i <arrayLength) goto loop
cmp edi edx
boucle jg
; somme de retour;
sortie :
mov eax , esi
ForEachWithLengthWithoutLocalVariable (); int sum = 0;
xor esi , esi
; int [] localRefToArray = this.array;
mov ecx , dword ptr [ ecx + 4 ]
; int i = 0;
xor edx , edx
; int arrayLength = localRefToArray.Length;
mov edi , dword ptr [ ecx + 4 ]
; if (arrayLength == 0) goto exit
test edi edi
sortie jle
; int t = tableau [i];
boucle :
mov eax , dword ptr [ ecx + edx * 4 + 8 ]
; somme + = i;
ajouter esi , eax
; sum + = localRefToArray.Length;
ajouter esi , dword ptr [ ecx + 4 ]
; i ++;
inc edx
; if (i <arrayLength) goto loop
cmp edi edx
boucle jg
; somme de retour;
sortie :
mov eax , esi
ForEachWithLengthWithLocalVariable (); int sum = 0;
xor esi , esi
; int [] localRefToArray = this.array;
mov edx , dword ptr [ ecx + 4 ]
; int length = localRefToArray.Length;
mov ebx , dword ptr [ edx + 4 ]
; int i = 0;
xor ecx , ecx
; int arrayLength = localRefToArray.Length;
mov edi , dword ptr [ edx + 4 ]
; if (arrayLength == 0) goto exit;
test edi edi
sortie jle
; int t = tableau [i];
boucle :
mov eax , dword ptr [ edx + ecx * 4 + 8 ]
; somme + = i;
ajouter esi , eax
; somme + = longueur;
ajouter esi , ebx
; i ++;
inc ecx
; if (i <arrayLength) goto loop
cmp edi ecx
boucle jg
; somme de retour;
sortie :
mov eax , esi
La première chose qui me vient à l'esprit est qu'il faut moins d'instructions d'assembleur que
pour le cycle (par exemple, pour la sommation d'éléments simples, il a fallu 12 instructions pour
foreach , mais 15 pour
for ).
Dans l'ensemble, voici les résultats du
test de référence
vs vs foreach pour les matrices à 1 million d'éléments:
sum+=array[i];
Et pour
sum+=array[i] + array.Length;
ForEach parcourt le tableau beaucoup plus rapidement que
pour . Pourquoi? Pour le savoir, nous devons comparer le code après JIT:
Comparaison des trois options foreach Regardons ForEachWithoutLength. La longueur du tableau n'est demandée qu'une seule fois et il n'y a aucune vérification des limites du tableau. Cela se produit car le cycle ForEach restreint d'abord la modification de la collection à l'intérieur du cycle, et le second ne sortira jamais de la collection. Pour cette raison, JIT peut se permettre de supprimer les limites du tableau de contrôle.
Examinons maintenant attentivement ForEachWithLengthWIthoutLocalVariable. Il n'y a qu'une seule partie étrange, où la somme + + longueur arrive non pas à la longueur de tableau de variables locales précédemment enregistrée, mais à une nouvelle que l'application demande à la mémoire à chaque fois. Cela signifie qu'il y aura N + 1 demandes de mémoire pour la longueur du tableau, où N est une longueur du tableau.
Et maintenant, nous arrivons à ForEachWithLengthWithLocalVariable. Le code y est exactement le même que dans l'exemple précédent, à l'exception de la gestion de la longueur du tableau. Le compilateur a de nouveau généré une variable locale arrayLength qui est utilisée pour vérifier si le tableau est vide, mais le compilateur a quand même honnêtement enregistré notre longueur de variable locale indiquée, et c'est ce qui est utilisé dans la somme à l'intérieur du cycle. Il s'avère que cette méthode ne demande la longueur du tableau à la mémoire que deux fois. La différence est très difficile à remarquer dans le monde réel.
Dans tous les cas, le code assembleur s'est avéré si simple car les méthodes elles-mêmes sont simples. Si les méthodes avaient plus de paramètres, cela devrait fonctionner avec la pile, les variables pourraient obtenir des magasins en dehors des registres, il y aurait eu plus de vérifications, mais la logique principale resterait la même: l'introduction d'une variable locale pour la longueur du tableau n'est que utile pour rendre le code plus lisible. Il s'est également avéré que
Foreach parcourt souvent le réseau plus rapidement que
For .