web-dev-qa-db-fra.com

Surprise de performance avec les types "as" et nullable

Je suis en train de réviser le chapitre 4 de C # in Depth qui traite des types nullables, et j'ajoute une section sur l'utilisation de l'opérateur "en tant que", qui vous permet d'écrire:

object o = ...;
int? x = o as int?;
if (x.HasValue)
{
    ... // Use x.Value in here
}

Je pensais que c'était vraiment génial et que cela pouvait améliorer les performances par rapport à l'équivalent C # 1, en utilisant "est" suivi d'un transt - après tout, de cette façon, nous n'avons à demander qu'un contrôle de type dynamique une fois, puis un contrôle de valeur simple. .

Cela ne semble pas être le cas, cependant. J'ai inclus un exemple d'application de test ci-dessous, qui additionne en gros tous les entiers d'un tableau d'objets, mais ce tableau contient de nombreuses références nulles et des chaînes, ainsi que des entiers encadrés. Le benchmark mesure le code que vous devez utiliser en C # 1, le code utilisant l'opérateur "en tant que", et juste pour lancer une solution LINQ. À mon grand étonnement, le code C # 1 est 20 fois plus rapide dans ce cas - et même le code LINQ (que je m'attendais à être plus lent, compte tenu des itérateurs impliqués) bat le code "en".

L'implémentation .NET de isinst pour les types nullables est-elle vraiment lente? Est-ce le supplément unbox.any qui cause le problème? Y a-t-il une autre explication à cela? Pour le moment, j'ai l'impression qu'il va falloir inclure un avertissement contre l'utilisation de ce logiciel dans des situations sensibles aux performances ...

Résultats:

Cast: 10000000: 121
As: 10000000: 2211
LINQ: 10000000: 2143

Code:

using System;
using System.Diagnostics;
using System.Linq;

class Test
{
    const int Size = 30000000;

    static void Main()
    {
        object[] values = new object[Size];
        for (int i = 0; i < Size - 2; i += 3)
        {
            values[i] = null;
            values[i+1] = "";
            values[i+2] = 1;
        }

        FindSumWithCast(values);
        FindSumWithAs(values);
        FindSumWithLinq(values);
    }

    static void FindSumWithCast(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            if (o is int)
            {
                int x = (int) o;
                sum += x;
            }
        }
        sw.Stop();
        Console.WriteLine("Cast: {0} : {1}", sum, 
                          (long) sw.ElapsedMilliseconds);
    }

    static void FindSumWithAs(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;
            if (x.HasValue)
            {
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("As: {0} : {1}", sum, 
                          (long) sw.ElapsedMilliseconds);
    }

    static void FindSumWithLinq(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = values.OfType<int>().Sum();
        sw.Stop();
        Console.WriteLine("LINQ: {0} : {1}", sum, 
                          (long) sw.ElapsedMilliseconds);
    }
}
321
Jon Skeet

Clairement, le code machine que le compilateur JIT peut générer pour le premier cas est beaucoup plus efficace. Une règle qui aide vraiment, c'est qu'un objet ne peut être décompressé que dans une variable du même type que la valeur encadrée. Cela permet au compilateur JIT de générer un code très efficace, aucune conversion de valeur n’est à envisager.

Le test de l'opérateur is est simple. Il suffit de vérifier si l'objet n'est pas nul et s'il est du type attendu, il ne prend que quelques instructions de code machine. La conversion est également facile, le compilateur JIT connaît l'emplacement des bits de valeur dans l'objet et les utilise directement. Aucune copie ou conversion ne se produit, tout le code machine est en ligne et ne prend qu'une douzaine d'instructions. Cela devait être vraiment efficace dans .NET 1.0 lorsque la boxe était courante.

Casting à Int? prend beaucoup plus de travail. La représentation de la valeur de l’entier encadré n’est pas compatible avec la structure de la mémoire de Nullable<int>. Une conversion est nécessaire et le code est délicat en raison des types d’énumération en boîte possibles. Le compilateur JIT génère un appel à une fonction d'assistance CLR appelée JIT_Unbox_Nullable pour que le travail soit effectué. C'est une fonction d'usage général pour tout type de valeur, il y a beaucoup de code pour vérifier les types. Et la valeur est copiée. Difficile d’estimer le coût puisque ce code est verrouillé dans mscorwks.dll, mais des centaines d’instructions de code machine sont probables.

La méthode d'extension Linq OfType () utilise également l'opérateur is et la conversion. Il s’agit toutefois d’une conversion vers un type générique. Le compilateur JIT génère un appel à une fonction d'assistance, JIT_Unbox (), qui peut effectuer un transtypage en un type de valeur arbitraire. Je n'ai pas une bonne explication pourquoi c'est aussi lent que le casting pour Nullable<int>, étant donné que moins de travail devrait être nécessaire. Je soupçonne que ngen.exe pourrait causer des problèmes ici.

204
Hans Passant

Il me semble que le isinst est vraiment très lent sur les types nullables. Dans la méthode FindSumWithCast j'ai changé

if (o is int)

à

if (o is int?)

ce qui ralentit également considérablement l'exécution. La seule différence dans IL que je peux voir est que

isinst     [mscorlib]System.Int32

se change en

isinst     valuetype [mscorlib]System.Nullable`1<int32>
26
Dirk Vollmar

Cela a commencé par être un commentaire sur l'excellente réponse de Hans Passant, mais cela a pris trop de temps, je veux donc ajouter quelques éléments ici:

Tout d'abord, l'opérateur C # as émettra une instruction isinstIL (l'opérateur is en fera de même). (Une autre instruction intéressante est castclass, émise lorsque vous effectuez une diffusion directe et que le compilateur sait que la vérification à l'exécution ne peut pas être omise.)

Voici ce que fait isinst ( ECMA 335 Partition III, 4.6 ):

Format: isinst typeTok

typeTok est un jeton de métadonnées (un typeref, typedef ou typespec), indiquant la classe souhaitée.

Si typeTok est un type de valeur non nullable ou un type de paramètre générique, il est interprété comme "encadré" typeTok.

Si typeTok est un type nullable, Nullable<T>, il est interprété comme "encadré" T

Le plus important:

Si le type actuel (et non le type suivi par le vérificateur) de obj est vérificateur-assignable-à le type typeTok alors isinst réussit et = obj (comme résultat) est renvoyé sous forme inchangée pendant que la vérification en indique le type typeTok. Contrairement aux coercitions (§1.6) et aux conversions (§3.27), isinst ne change jamais le type réel d'un objet et préserve l'identité de l'objet (voir Partition I).

Donc, le tueur à la performance n'est pas isinst dans ce cas, mais le unbox.any. Cela ne ressortait pas clairement de la réponse de Hans, qui ne regardait que le code JITed. En général, le compilateur C # émettra un unbox.any après un isinst T? _ (mais l'omettra si vous le faites isinst T, lorsque T est un type de référence).

Pourquoi ça fait ça? isinst T? n’a jamais eu l’effet qui aurait été évident, c’est-à-dire que vous récupérerez un T?. Au lieu de cela, toutes ces instructions garantissent que vous avez un "boxed T" qui peut être déballé à T?. Pour obtenir un réel T?, nous devons encore déballer notre "boxed T" à T?, raison pour laquelle le compilateur émet un unbox.any après isinst. Si vous y réfléchissez, cela a du sens car le "format de boîte" pour T? n'est qu'un "boxed T" et en faisant castclass et isinst effectuer la décompression serait incohérent.

Sauvegarde de la découverte de Hans avec des informations provenant du standard , voici ce qui se passe:

(ECMA 335 Partition III, 4.33): unbox.any

Appliqué à la forme encadrée d'un type de valeur, le unbox.anyinstruction extrait la valeur contenue dans obj (de type O). (Cela équivaut à unbox suivi de ldobj.) Lorsqu'il est appliqué à un type de référence, le unbox.anyinstruction a le même effet que castclasstypeTok.

(ECMA 335 Partition III, 4.32): unbox

Généralement, unbox calcule simplement l'adresse du type de valeur déjà présent à l'intérieur de l'objet encadré. Cette approche n'est pas possible lorsque les types de valeur nullable ont été désencaissés. Car Nullable<T> Les valeurs sont converties en Ts en boîte lors de l’utilisation de la boîte, une implémentation doit souvent fabriquer un nouveau Nullable<T> sur le tas et calculez l'adresse du nouvel objet alloué.

22
Johannes Rudolph

Fait intéressant, j’ai transmis des commentaires sur le support des opérateurs via dynamic étant un ordre de grandeur plus lent pour Nullable<T> (semblable à ce test précoce ) - Je soupçonne pour des raisons très similaires.

Tu dois aimer Nullable<T>. Une autre option amusante est que même si le JIT repère (et supprime) null pour les structures non nullables, il le bloque pour Nullable<T>:

using System;
using System.Diagnostics;
static class Program {
    static void Main() { 
        // JIT
        TestUnrestricted<int>(1,5);
        TestUnrestricted<string>("abc",5);
        TestUnrestricted<int?>(1,5);
        TestNullable<int>(1, 5);

        const int LOOP = 100000000;
        Console.WriteLine(TestUnrestricted<int>(1, LOOP));
        Console.WriteLine(TestUnrestricted<string>("abc", LOOP));
        Console.WriteLine(TestUnrestricted<int?>(1, LOOP));
        Console.WriteLine(TestNullable<int>(1, LOOP));

    }
    static long TestUnrestricted<T>(T x, int loop) {
        Stopwatch watch = Stopwatch.StartNew();
        int count = 0;
        for (int i = 0; i < loop; i++) {
            if (x != null) count++;
        }
        watch.Stop();
        return watch.ElapsedMilliseconds;
    }
    static long TestNullable<T>(T? x, int loop) where T : struct {
        Stopwatch watch = Stopwatch.StartNew();
        int count = 0;
        for (int i = 0; i < loop; i++) {
            if (x != null) count++;
        }
        watch.Stop();
        return watch.ElapsedMilliseconds;
    }
}
19
Marc Gravell

Ceci est le résultat de FindSumWithAsAndHas ci-dessus: alt text

Ceci est le résultat de FindSumWithCast: alt text

Résultats:

  • En utilisant as, il vérifie d’abord si un objet est une instance de Int32; sous le capot, il utilise isinst Int32 (ce qui est similaire au code écrit à la main: if (o est int)). Et en utilisant as, il décompactera également l’objet de manière inconditionnelle. Et c’est un vrai tueur de performance que d’appeler une propriété (c’est toujours une fonction cachée), IL_0027

  • En utilisant cast, vous testez d'abord si l'objet est un intif (o is int); sous le capot, cela utilise isinst Int32. S'il s'agit d'une instance de int, vous pouvez alors décompresser la valeur en toute sécurité, IL_002D

En termes simples, c’est le pseudo-code de l’utilisation de l’approche as:

int? x;

(x.HasValue, x.Value) = (o isinst Int32, o unbox Int32)

if (x.HasValue)
    sum += x.Value;    

Et voici le pseudo-code de l'utilisation de l'approche cast:

if (o isinst Int32)
    sum += (o unbox Int32)

Ainsi, la distribution ((int)a[i], La syntaxe ressemble à une distribution, mais en réalité, elle est unboxing, la distribution et la déballage partagent la même syntaxe, la prochaine fois que je serai pédant avec la bonne terminologie), l'approche est vraiment plus rapide, Nécessaire uniquement pour décompresser une valeur lorsqu'un objet est décidément un int. On ne peut pas dire la même chose en utilisant une approche as.

12
Michael Buen

Profiler plus loin:

using System;
using System.Diagnostics;

class Program
{
    const int Size = 30000000;

    static void Main(string[] args)
    {
        object[] values = new object[Size];
        for (int i = 0; i < Size - 2; i += 3)
        {
            values[i] = null;
            values[i + 1] = "";
            values[i + 2] = 1;
        }

        FindSumWithIsThenCast(values);

        FindSumWithAsThenHasThenValue(values);
        FindSumWithAsThenHasThenCast(values);

        FindSumWithManualAs(values);
        FindSumWithAsThenManualHasThenValue(values);



        Console.ReadLine();
    }

    static void FindSumWithIsThenCast(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            if (o is int)
            {
                int x = (int)o;
                sum += x;
            }
        }
        sw.Stop();
        Console.WriteLine("Is then Cast: {0} : {1}", sum,
                            (long)sw.ElapsedMilliseconds);
    }

    static void FindSumWithAsThenHasThenValue(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;

            if (x.HasValue)
            {
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("As then Has then Value: {0} : {1}", sum,
                            (long)sw.ElapsedMilliseconds);
    }

    static void FindSumWithAsThenHasThenCast(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;

            if (x.HasValue)
            {
                sum += (int)o;
            }
        }
        sw.Stop();
        Console.WriteLine("As then Has then Cast: {0} : {1}", sum,
                            (long)sw.ElapsedMilliseconds);
    }

    static void FindSumWithManualAs(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            bool hasValue = o is int;
            int x = hasValue ? (int)o : 0;

            if (hasValue)
            {
                sum += x;
            }
        }
        sw.Stop();
        Console.WriteLine("Manual As: {0} : {1}", sum,
                            (long)sw.ElapsedMilliseconds);
    }

    static void FindSumWithAsThenManualHasThenValue(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;

            if (o is int)
            {
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("As then Manual Has then Value: {0} : {1}", sum,
                            (long)sw.ElapsedMilliseconds);
    }

}

Sortie:

Is then Cast: 10000000 : 303
As then Has then Value: 10000000 : 3524
As then Has then Cast: 10000000 : 3272
Manual As: 10000000 : 395
As then Manual Has then Value: 10000000 : 3282

Que pouvons-nous déduire de ces chiffres?

  • Tout d'abord, l'approche is-then-cast est nettement plus rapide que l'approche as. 303 vs 3524
  • Deuxièmement, .Value est légèrement plus lent que le casting. 3524 vs 3272
  • Troisièmement, .HasValue est légèrement plus lent que l’utilisation de manual a (c’est-à-dire is). 3524 vs 3282
  • Quatrièmement, faire une comparaison Apple à Apple (c’est-à-dire l’assignation de HasValue simulée et la conversion de valeur simulée se produisent ensemble) entre simulé en tant que et en tant que réel, nous pouvons voir - simulé en tant que est toujours nettement plus rapide que réel en tant que. 395 vs 3524
  • Enfin, sur la base des première et quatrième conclusions, il y a un problème avec as implémentation ^ _ ^
9
Michael Buen

Afin de garder cette réponse à jour, il est utile de mentionner que l'essentiel de la discussion sur cette page est maintenant sans objet avec C # 7.1 et . NET 4.7 qui prend en charge une syntaxe fine qui produit également le meilleur code IL.

Exemple original du PO ...

object o = ...;
int? x = o as int?;
if (x.HasValue)
{
    // ...use x.Value in here
}

devient simplement ...

if (o is int x)
{
    // ...use x in here
}

J'ai constaté qu'une utilisation courante de la nouvelle syntaxe est lorsque vous écrivez un type de valeur .NET (c'est-à-dire struct in C # ) qui implémente IEquatable<MyStruct> (Comme la plupart devraient le faire). Après avoir implémenté la méthode Equals(MyStruct other) fortement typée, vous pouvez désormais rediriger gracieusement la Equals(Object obj) non typée _ (annulée de (héritée de Object) vers elle comme suit:

public override bool Equals(Object obj) => obj is MyStruct o && Equals(o);


Annexe: Le Release build [~ # ~] il [~ # ~] les codes pour les deux premiers exemples de fonctions indiqués ci-dessus dans cette réponse (respectivement) sont donnés ici. Bien que le code IL de la nouvelle syntaxe soit effectivement inférieur de 1 octet, il gagne généralement gros en effectuant zéro appel (contre deux) et en évitant l’opération unbox lorsque cela est possible.

// static void test1(Object o, ref int y)
// {
//     int? x = o as int?;
//     if (x.HasValue)
//         y = x.Value;
// }

[0] valuetype [mscorlib]Nullable`1<int32> x
        ldarg.0
        isinst [mscorlib]Nullable`1<int32>
        unbox.any [mscorlib]Nullable`1<int32>
        stloc.0
        ldloca.s x
        call instance bool [mscorlib]Nullable`1<int32>::get_HasValue()
        brfalse.s L_001e
        ldarg.1
        ldloca.s x
        call instance !0 [mscorlib]Nullable`1<int32>::get_Value()
        stind.i4
L_001e: ret
// static void test2(Object o, ref int y)
// {
//     if (o is int x)
//         y = x;
// }

[0] int32 x,
[1] object obj2
        ldarg.0
        stloc.1
        ldloc.1
        isinst int32
        ldnull
        cgt.un
        dup
        brtrue.s L_0011
        ldc.i4.0
        br.s L_0017
L_0011: ldloc.1
        unbox.any int32
L_0017: stloc.0
        brfalse.s L_001d
        ldarg.1
        ldloc.0
        stind.i4
L_001d: ret

Pour des tests supplémentaires qui corroborent ma remarque sur les performances de la nouvelle syntaxe C # 7 dépassant les options précédemment disponibles, voir ici (en particulier, exemple 'D').

9
Glenn Slayden

J'ai essayé la construction de vérification de type exacte

typeof(int) == item.GetType(), qui fonctionne aussi vite que la version item is int, et retourne toujours le nombre (en italique: même si vous écrivez un Nullable<int> dans le tableau, vous devrez utiliser typeof(int)). Vous avez également besoin d'un chèque supplémentaire de null != item Ici.

Toutefois

typeof(int?) == item.GetType() reste rapide (contrairement à item is int?), mais renvoie toujours false.

Le typeof-construct est à mes yeux le moyen le plus rapide pour la vérification du type exact, car il utilise le RuntimeTypeHandle. Puisque les types exacts dans ce cas ne correspondent pas à nullable, mon hypothèse est que is/as Doit faire plus de poids lourd ici pour s’assurer qu’il s’agit bien d’une instance de type Nullable.

Et honnêtement: qu'est-ce que votre is Nullable<xxx> plus HasValue Vous achète? Rien. Vous pouvez toujours aller directement au type sous-jacent (valeur) (dans ce cas). Vous obtenez soit la valeur, soit "non, pas une instance du type que vous demandiez". Même si vous avez écrit (int?)null Dans le tableau, la vérification du type renverra false.

8
dalo

Je n'ai pas le temps de l'essayer, mais vous voudrez peut-être avoir:

foreach (object o in values)
        {
            int? x = o as int?;

comme

int? x;
foreach (object o in values)
        {
            x = o as int?;

Vous créez à chaque fois un nouvel objet, ce qui n'explique pas complètement le problème, mais peut y contribuer.

8
James Black
using System;
using System.Diagnostics;
using System.Linq;

class Test
{
    const int Size = 30000000;

    static void Main()
    {
        object[] values = new object[Size];
        for (int i = 0; i < Size - 2; i += 3)
        {
            values[i] = null;
            values[i + 1] = "";
            values[i + 2] = 1;
        }

        FindSumWithCast(values);
        FindSumWithAsAndHas(values);
        FindSumWithAsAndIs(values);


        FindSumWithIsThenAs(values);
        FindSumWithIsThenConvert(values);

        FindSumWithLinq(values);



        Console.ReadLine();
    }

    static void FindSumWithCast(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            if (o is int)
            {
                int x = (int)o;
                sum += x;
            }
        }
        sw.Stop();
        Console.WriteLine("Cast: {0} : {1}", sum,
                          (long)sw.ElapsedMilliseconds);
    }

    static void FindSumWithAsAndHas(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;
            if (x.HasValue)
            {
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("As and Has: {0} : {1}", sum,
                          (long)sw.ElapsedMilliseconds);
    }


    static void FindSumWithAsAndIs(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {
            int? x = o as int?;
            if (o is int)
            {
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("As and Is: {0} : {1}", sum,
                          (long)sw.ElapsedMilliseconds);
    }







    static void FindSumWithIsThenAs(object[] values)
    {
        // Apple-to-Apple comparison with Cast routine above.
        // Using the similar steps in Cast routine above,
        // the AS here cannot be slower than Linq.



        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {

            if (o is int)
            {
                int? x = o as int?;
                sum += x.Value;
            }
        }
        sw.Stop();
        Console.WriteLine("Is then As: {0} : {1}", sum,
                          (long)sw.ElapsedMilliseconds);
    }

    static void FindSumWithIsThenConvert(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = 0;
        foreach (object o in values)
        {            
            if (o is int)
            {
                int x = Convert.ToInt32(o);
                sum += x;
            }
        }
        sw.Stop();
        Console.WriteLine("Is then Convert: {0} : {1}", sum,
                          (long)sw.ElapsedMilliseconds);
    }



    static void FindSumWithLinq(object[] values)
    {
        Stopwatch sw = Stopwatch.StartNew();
        int sum = values.OfType<int>().Sum();
        sw.Stop();
        Console.WriteLine("LINQ: {0} : {1}", sum,
                          (long)sw.ElapsedMilliseconds);
    }
}

Les sorties:

Cast: 10000000 : 456
As and Has: 10000000 : 2103
As and Is: 10000000 : 2029
Is then As: 10000000 : 1376
Is then Convert: 10000000 : 566
LINQ: 10000000 : 1811

[EDIT: 2010-06-19]

Remarque: Le test précédent avait été effectué dans VS, débogage de la configuration, à l'aide de VS2009, à l'aide de Core i7 (machine de développement de la société).

Les opérations suivantes ont été effectuées sur ma machine avec Core 2 Duo, avec VS2010.

Inside VS, Configuration: Debug

Cast: 10000000 : 309
As and Has: 10000000 : 3322
As and Is: 10000000 : 3249
Is then As: 10000000 : 1926
Is then Convert: 10000000 : 410
LINQ: 10000000 : 2018




Outside VS, Configuration: Debug

Cast: 10000000 : 303
As and Has: 10000000 : 3314
As and Is: 10000000 : 3230
Is then As: 10000000 : 1942
Is then Convert: 10000000 : 418
LINQ: 10000000 : 1944




Inside VS, Configuration: Release

Cast: 10000000 : 305
As and Has: 10000000 : 3327
As and Is: 10000000 : 3265
Is then As: 10000000 : 1942
Is then Convert: 10000000 : 414
LINQ: 10000000 : 1932




Outside VS, Configuration: Release

Cast: 10000000 : 301
As and Has: 10000000 : 3274
As and Is: 10000000 : 3240
Is then As: 10000000 : 1904
Is then Convert: 10000000 : 414
LINQ: 10000000 : 1936
7
Michael Buen