web-dev-qa-db-fra.com

Est-il plus efficace d'effectuer une vérification de plage en effectuant un cast sur uint au lieu de rechercher des valeurs négatives?

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;)

77
enzi

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.

55

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.

29
Jon Hanna

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.

8
xanatos

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.

8
DrKoch

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.

5
usr

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" );

    }
}
4
nsimeonov

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];
}
1
Serve Laurijssen