Je suis tombé sur ce morceau de code dans .NET's Liste du code source :
// Following trick can reduce the range check by one
if ((uint) index >= (uint)_size) {
ThrowHelper.ThrowArgumentOutOfRangeException();
}
Apparemment, c'est plus efficace (?) Que if (index < 0 || index >= _size)
Je suis curieux de connaître la raison d'être de cette astuce. Une instruction de branche unique est-elle vraiment plus coûteuse que deux conversions en uint
? Ou existe-t-il une autre optimisation en cours qui rendra ce code plus rapide qu'une comparaison numérique supplémentaire?
Pour s'adresser à l'éléphant dans la pièce: oui, c'est de la micro optimisation, non je n'ai pas l'intention de l'utiliser partout dans mon code - je suis juste curieux;)
De MS Partition I , section 12.1 (Types de données pris en charge):
Les types entiers signés (int8, int16, int32, int64 et native int) et leurs types entiers non signés correspondants (unsigned int8, unsigned int16, unsigned int32, unsigned int64 et native unsigned int) ne diffèrent que par la façon dont les bits de l'entier sont interprétés. Pour les opérations dans lesquelles un entier non signé est traité différemment d'un entier signé (par exemple, dans les comparaisons ou l'arithmétique avec débordement), il existe des instructions distinctes pour traiter un entier comme non signé (par exemple, cgt.un et add.ovf.un).
C'est-à-dire que la conversion d'un int
en uint
n'est qu'une question de comptabilité - à partir de maintenant on, la valeur sur la pile/dans un registre est maintenant connue pour être un entier non signé plutôt qu'un entier.
Ainsi, les deux conversions doivent être "gratuites" une fois que le code est JITté, puis l'opération de comparaison non signée peut être effectuée.
Disons que nous avons:
public void TestIndex1(int index)
{
if(index < 0 || index >= _size)
ThrowHelper.ThrowArgumentOutOfRangeException();
}
public void TestIndex2(int index)
{
if((uint)index >= (uint)_size)
ThrowHelper.ThrowArgumentOutOfRangeException();
}
Compilons-les et examinons ILSpy:
.method public hidebysig
instance void TestIndex1 (
int32 index
) cil managed
{
IL_0000: ldarg.1
IL_0001: ldc.i4.0
IL_0002: blt.s IL_000d
IL_0004: ldarg.1
IL_0005: ldarg.0
IL_0006: ldfld int32 TempTest.TestClass::_size
IL_000b: bge.s IL_0012
IL_000d: call void TempTest.ThrowHelper::ThrowArgumentOutOfRangeException()
IL_0012: ret
}
.method public hidebysig
instance void TestIndex2 (
int32 index
) cil managed
{
IL_0000: ldarg.1
IL_0001: ldarg.0
IL_0002: ldfld int32 TempTest.TestClass::_size
IL_0007: blt.un.s IL_000e
IL_0009: call void TempTest.ThrowHelper::ThrowArgumentOutOfRangeException()
IL_000e: ret
}
Il est facile de voir que le second a moins de code, avec une branche de moins.
Vraiment, il n'y a pas de distribution du tout, il y a le choix d'utiliser blt.s
et bge.s
ou pour utiliser blt.s.un
, où ce dernier traite les entiers passés comme non signés tandis que le premier les traite comme signés.
(Remarque pour ceux qui ne connaissent pas CIL, car il s'agit d'une question C # avec une réponse CIL, bge.s
, blt.s
et blt.s.un
sont les versions "courtes" de bge
, blt
et blt.un
respectivement. blt
sort deux valeurs de la pile et des branches si la première est inférieure à la seconde quand on les considère comme des valeurs signées tandis que blt.un
affiche deux valeurs de la pile et des branches si la première est inférieure à la seconde en les considérant comme des valeurs non signées).
C'est tout à fait un micro-opt, mais il y a des moments où les micro-opt en valent la peine. Considérez en outre que, avec le reste du code dans le corps de la méthode, cela pourrait faire la différence entre quelque chose tombant dans les limites de gigue pour l'inline ou non, et s'ils prennent la peine d'avoir un assistant pour lever les exceptions hors de portée, ils sont essayant probablement de s'assurer que l'inline se produise si possible, et les 4 octets supplémentaires pourraient faire toute la différence.
En effet, il est fort probable que cette différence inline soit beaucoup plus importante que la réduction d'une branche. Il n'y a pas beaucoup de fois où sortir de votre chemin pour vous assurer que l'incrustation se produit en vaut la peine, mais une méthode de base d'une classe d'utilisation aussi lourde que List<T>
serait certainement l'un d'entre eux.
Notez que cette astuce ne fonctionnera pas si votre projet est checked
au lieu de unchecked
. Dans le meilleur des cas, il sera plus lent (car chaque distribution devra être vérifiée par rapport au débordement) (ou du moins pas plus rapide), dans le pire des cas, vous obtiendrez un OverflowException
si vous essayez de passer -1 comme index
(au lieu de votre exception).
Si vous voulez l'écrire "correctement" et d'une manière plus "fonctionnera sûrement", vous devez mettre un
unchecked
{
// test
}
tout autour du test.
En supposant _size
est un entier, privé de la liste et index
est l'argument de cette fonction dont la validité doit être testée.
En supposant en outre que _size
est toujours> = 0.
Alors le test original aurait été:
if(index < 0 || index > size) throw exception
La version optimisée
if((uint)index > (uint)_size) throw exception
a une comparaison (comme opsed à deux dans l'exemple précédent.) Parce que le transtypage réinterprète juste les bits et rend le >
en fait une comparaison non signée, aucun cycle CPU supplémentaire n'est utilisé pour cela.
Pourquoi ça marche?
Les résultats sont simples/triviaux tant que l'indice> = 0.
Si index <0, le (uint)index
le transformera en un très grand nombre:
Exemple: 0xFFFF vaut -1 comme entier, mais 65535 comme uint, donc
(uint)-1 > (uint)x
est toujours vrai si x
était positif.
Oui, c'est plus efficace. Le JIT fait la même astuce lorsque accès au tableau de vérification de plage.
La transformation et le raisonnement sont les suivants:
i >= 0 && i < array.Length
devient (uint)i < (uint)array.Length
car array.Length <= int.MaxValue
pour que array.Length
a la même valeur que (uint)array.Length
. Si i
s'avère négatif, alors (uint)i > int.MaxValue
et la vérification échoue.
Apparemment, dans la vraie vie, ce n'est pas plus rapide. Vérifiez ceci: https://dotnetfiddle.net/lZKHmn
Il s'avère que grâce à la prédiction de branche d'Intel et à l'exécution parallèle, le code le plus évident et le plus lisible fonctionne en fait plus rapidement ...
Voici le code:
using System;
using System.Diagnostics;
public class Program
{
const int MAX_ITERATIONS = 10000000;
const int MAX_SIZE = 1000;
public static void Main()
{
var timer = new Stopwatch();
Random Rand = new Random();
long InRange = 0;
long OutOfRange = 0;
timer.Start();
for ( int i = 0; i < MAX_ITERATIONS; i++ ) {
var x = Rand.Next( MAX_SIZE * 2 ) - MAX_SIZE;
if ( x < 0 || x > MAX_SIZE ) {
OutOfRange++;
} else {
InRange++;
}
}
timer.Stop();
Console.WriteLine( "Comparision 1: " + InRange + "/" + OutOfRange + ", elapsed: " + timer.ElapsedMilliseconds + "ms" );
Rand = new Random();
InRange = 0;
OutOfRange = 0;
timer.Reset();
timer.Start();
for ( int i = 0; i < MAX_ITERATIONS; i++ ) {
var x = Rand.Next( MAX_SIZE * 2 ) - MAX_SIZE;
if ( (uint) x > (uint) MAX_SIZE ) {
OutOfRange++;
} else {
InRange++;
}
}
timer.Stop();
Console.WriteLine( "Comparision 2: " + InRange + "/" + OutOfRange + ", elapsed: " + timer.ElapsedMilliseconds + "ms" );
}
}
En explorant cela sur un processeur Intel, je n'ai trouvé aucune différence dans les temps d'exécution, probablement en raison de plusieurs unités d'exécution entières.
Mais en faisant cela sur un microprocesseur en temps réel 16MHZ sans prédiction de branche ni unités d'exécution entières, il y avait des différences notables.
1 million d'itérations du code plus lent ont pris 1761 ms
int slower(char *a, long i)
{
if (i < 0 || i >= 10)
return 0;
return a[i];
}
1 million d'itérations de code plus rapide en 1635 ms
int faster(char *a, long i)
{
if ((unsigned int)i >= 10)
return 0;
return a[i];
}