Performance surprise avec les types “as” et nullable

Je ne fais que 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 “as”, 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 bien, et que cela pourrait améliorer les performances par rapport à l’équivalent C # 1, en utilisant “is” suivi d’un cast – après tout, il suffit de demander une vérification dynamic du type, puis une vérification simple des valeurs .

Cela ne semble toutefois pas être le cas. J’ai inclus un exemple d’application de test ci-dessous, qui résume fondamentalement tous les entiers dans un tableau d’objects – mais le tableau contient beaucoup de références nulles et de références de chaîne, ainsi que des entiers encadrés. Le benchmark mesure le code que vous devez utiliser dans C # 1, le code utilisant l’opérateur “as” 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 pensais plus lent, étant donné les iterators impliqués) bat le code “as”.

L’implémentation .NET de isinst pour les types isinst -elle vraiment lente? Est-ce le unbox.any supplémentaire qui cause le problème? Y a-t-il une autre explication à cela? En ce moment, je pense que je devrais inclure un avertissement pour ne pas l’utiliser dans des situations sensibles à la performance …

Résultats:

Cast: 10000000: 121
Comme: 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().Sum(); sw.Stop(); Console.WriteLine("LINQ: {0} : {1}", sum, (long) sw.ElapsedMilliseconds); } } 

De toute évidence, 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 à cela est qu’un object ne peut être désencapsulé que dans une variable de 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 ne doit être envisagée.

Le test de l’opérateur is est simple, il suffit de vérifier si l’object n’est pas nul et qu’il est du type attendu, ne prend que quelques instructions de code machine. La dissortingbution est également facile, le compilateur JIT connaît l’emplacement des bits de valeur dans l’object et les utilise directement. Aucune copie ou conversion ne se produit, tout le code machine est en ligne et ne prend qu’environ 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 disposition de la mémoire de Nullable . Une conversion est nécessaire et le code est délicat à cause des types de enum possibles. Le compilateur JIT génère un appel à une fonction d’assistance CLR nommée JIT_Unbox_Nullable pour terminer le travail. Ceci est une fonction polyvalente pour tout type de valeur, beaucoup de code là 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 le cast. Ceci est cependant une dissortingbution à un type générique. Le compilateur JIT génère un appel à une fonction d’assistance, JIT_Unbox (), qui peut effectuer une conversion vers un type de valeur arbitraire. Je n’ai pas une grande explication pour expliquer pourquoi il est aussi lent que la Nullable vers Nullable , étant donné que moins de travail devrait être nécessaire. Je soupçonne que ngen.exe pourrait causer des problèmes ici.

Il me semble que le isinst est vraiment 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 en IL je peux voir est que

 isinst [mscorlib]System.Int32 

se change à

 isinst valuetype [mscorlib]System.Nullable`1 

Cela a commencé par un commentaire à l’excellente réponse de Hans Passant, mais cela a pris trop de temps, donc je veux append quelques éléments ici:

Tout d’abord, l’opérateur C # as émettra une instruction IL isinst (de même que l’opérateur is ). (Une autre instruction intéressante est castclass , émise lorsque vous effectuez une dissortingbution directe et que le compilateur sait que la vérification de l’exécution ne peut pas être validée.)

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

Format: isinst typeTok

typeTok est un jeton de métadonnées ( 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 un typeTok «encadré».

Si typeTok est un type Nullable , Nullable , il est interprété comme «encadré» T

Plus important encore:

Si le type actuel (pas le type suivi du vérificateur) de obj est vérifiable-assignable-au type typeTok, alors isinst réussit et obj (en tant que résultat ) est retourné inchangé tandis que la vérification suit son type en tant que typeTok . Contrairement aux coercitions (§1.6) et aux conversions (§3.27), isinst ne modifie jamais le type réel d’un object et conserve l’identité de l’object (voir la partition I).

Donc, le tueur de performance n’est pas dans ce cas, mais le unbox.any supplémentaire. La réponse de Hans n’était pas claire, car il ne regardait que le code JIT. En général, le compilateur C # émettra un unbox.any après un isinst T? (mais l’omettra au cas où vous le isinst T , quand T est un type de référence).

Pourquoi ça fait ça? isinst T? jamais l’effet qui aurait été évident, c’est-à-dire que vous récupérez un T? . Au lieu de cela, toutes ces instructions garantissent que vous avez un "boxed T" qui peut être unboxed à T? . Pour obtenir un T? réel T? , nous devons encore déballer notre "boxed T" à T? , c’est pourquoi le compilateur émet un unbox.any après isinst . Si vous y réfléchissez, cela a du sens parce que le “format de boîte” pour T? est juste un "boxed T" et faire castclass et isinst performer le unbox serait incohérent.

En rappelant la découverte de Hans avec des informations de la norme , la voici:

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

Appliquée à la forme encadrée d’un type de valeur, l’instruction unbox.any extrait la valeur contenue dans obj (de type O ). (Cela équivaut à unbox suivi de ldobj .) Lorsqu’elle est appliquée à un type de référence, l’instruction unbox.any a le même effet que castclass typeTok.

(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’object encadré. Cette approche n’est pas possible lors du déballage des types de valeur nullable. Étant Nullable que les Nullable sont converties en boîtes Ts lors de l’opération Box, une implémentation doit souvent fabriquer un nouveau Nullable sur le tas et calculer l’adresse pour l’object nouvellement alloué.

Fait intéressant, j’ai transmis des commentaires sur le soutien de l’opérateur via dynamic soit un ordre de grandeur plus lent pour Nullable (similaire à ce test précoce ) – je soupçonne pour des raisons très similaires.

Je dois aimer Nullable . Un autre aspect amusant est que même si JIT détecte (et supprime) null pour les structures non nullables, il le remplace par Nullable :

 using System; using System.Diagnostics; static class Program { static void Main() { // JIT TestUnressortingcted(1,5); TestUnressortingcted("abc",5); TestUnressortingcted(1,5); TestNullable(1, 5); const int LOOP = 100000000; Console.WriteLine(TestUnressortingcted(1, LOOP)); Console.WriteLine(TestUnressortingcted("abc", LOOP)); Console.WriteLine(TestUnressortingcted(1, LOOP)); Console.WriteLine(TestNullable(1, LOOP)); } static long TestUnressortingcted(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? 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; } } 

Ceci est le résultat de FindSumWithAsAndHas ci-dessus: alt text http://soffr.miximages.com/c%23/www.freeimagehosting.net

Ceci est le résultat de FindSumWithCast: alt text http://soffr.miximages.com/c%23/www.freeimagehosting.net

Résultats:

  • En utilisant as , il teste d’abord si un object est une instance de Int32; sous le capot, il utilise isinst Int32 (qui est similaire au code manuscrit: if (o is int)). Et en utilisant as , il débouche également inconditionnellement sur l’object. Et c’est un vrai tueur de performance d’appeler une propriété (c’est toujours une fonction sous le capot), IL_0027

  • En utilisant cast, vous testez d’abord si object est un int if (o is int) ; sous le capot, c’est en utilisant le isinst Int32 . Si c’est une instance de int, alors vous pouvez annuler la valeur de la valeur, IL_002D

En termes simples, il s’agit du pseudo-code d’utilisation as approche:

 int? x; (x.HasValue, x.Value) = (o isinst Int32, o unbox Int32) if (x.HasValue) sum += x.Value; 

Et c’est le pseudo-code d’utilisation de l’approche cast:

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

Donc, la dissortingbution ( (int)a[i] , bien que la syntaxe ressemble à un casting, mais en fait unboxing, cast et unboxing partagent la même syntaxe, la prochaine fois je serai pédant avec la bonne terminologie) il suffit de décomprimer une valeur lorsqu’un object est décidément un int . On ne peut pas dire la même chose en utilisant une approche as .

Profilage plus loin:

 using System; using System.Diagnostics; class Program { const int Size = 30000000; static void Main(ssortingng[] 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 par jetons est beaucoup plus rapide que l’approche. 303 vs 3524
  • Deuxièmement, la valeur est légèrement plus lente que la diffusion. 3524 contre 3272
  • Troisièmement, .HasValue est légèrement plus lent que l'utilisation de la commande manuelle (c.-à-d. L'utilisation de is ). 3524 contre 3282
  • Quasortingèmement, faire une comparaison pomme à pomme (c’est-à-dire à la fois l’assignation de HasValue simulée et la conversion de valeur simulée se produit ensemble) entre une approche simulée et une approche réelle , nous pouvons voir 395 contre 3524
  • Enfin, sur la base de la première et de la quasortingème conclusion, quelque chose ne va pas avec la mise en œuvre ^ _ ^

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 un nouvel object à chaque fois, ce qui n’explique pas complètement le problème, mais peut y consortingbuer.

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

typeof(int) == item.GetType() , qui fonctionne aussi vite que l’ item is int version item is int et retourne toujours le nombre (accentuation: même si vous avez écrit un Nullable dans le tableau, vous devez utiliser typeof(int) ). Vous avez également besoin d’une vérification supplémentaire de null != item Ici.

toutefois

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

Le typeof-construct est à mes yeux le moyen le plus rapide de vérifier le type exact , car il utilise le RuntimeTypeHandle. Étant donné que les types exacts dans ce cas ne correspondent pas à nullable, je suppose que cela est is/as doit faire beaucoup plus de bruit en s’assurant qu’il s’agit bien d’une instance de type Nullable.

Et honnêtement: qu’est-ce que vous is Nullable plus HasValue vous acheter? Rien. Vous pouvez toujours accéder 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 de type renverra false.

 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().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 a été effectué dans VS, le débogage de configuration, à l’aide de VS2009, en utilisant Core i7 (machine de développement de la société).

Ce qui suit a été fait sur ma machine en utilisant Core 2 Duo, en utilisant 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 

Afin de garder cette réponse à jour, il convient de mentionner que la plupart des discussions sur cette page sont maintenant sans object avec C # 7.1 et .NET 4.7 qui supportent une syntaxe fine qui produit également le meilleur code IL.

L’exemple original de l’OP …

 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 trouvé qu’une utilisation courante de la nouvelle syntaxe est lorsque vous écrivez un type de valeur .NET (c’est struct à-dire struct en C # ) qui implémente IEquatable (comme la plupart le devraient). Après avoir implémenté la méthode Equals(MyStruct other) fortement typée, vous pouvez maintenant redirect avec élégance la substitution Equals(Object obj) non typée (héritée de Object ) comme suit:

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


Annexe: Le code IL de la version Release pour les deux premiers exemples de fonctions présentés ci-dessus dans cette réponse (respectivement) est donné ici. Bien que le code IL de la nouvelle syntaxe soit en fait plus petit de 1 octet, il gagne généralement gros en ne faisant aucun appel (vs deux) et en évitant l’opération de 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 x ldarg.0 isinst [mscorlib]Nullable`1 unbox.any [mscorlib]Nullable`1 stloc.0 ldloca.sx call instance bool [mscorlib]Nullable`1::get_HasValue() brfalse.s L_001e ldarg.1 ldloca.sx call instance !0 [mscorlib]Nullable`1::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 d’autres tests qui corroborent ma remarque concernant les performances de la nouvelle syntaxe C # 7 surpassant les options précédemment disponibles, voir ici (en particulier, l’exemple ‘D’).