Sorpresa de rendimiento con tipos "as" y que aceptan valores NULL

Resuelto Jon Skeet asked hace 15 años • 10 respuestas

Estoy revisando en profundidad el capítulo 4 de C#, que trata sobre los tipos que aceptan valores NULL, y estoy agregando una sección sobre el uso del operador "as", que te permite escribir:

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

Pensé que esto era realmente interesante y que podría mejorar el rendimiento con respecto al equivalente de C# 1, usando "is" seguido de una conversión; después de todo, de esta manera solo necesitamos solicitar una verificación de tipo dinámico una vez y luego una verificación de valor simple. .

Sin embargo, este no parece ser el caso. He incluido una aplicación de prueba de muestra a continuación, que básicamente suma todos los números enteros dentro de una matriz de objetos, pero la matriz contiene muchas referencias nulas y referencias de cadenas, así como números enteros en cuadros. El punto de referencia mide el código que tendría que usar en C# 1, el código que usa el operador "as" y, solo por diversión, una solución LINQ. Para mi sorpresa, el código C# 1 es 20 veces más rápido en este caso, e incluso el código LINQ (que esperaba que fuera más lento, dados los iteradores involucrados) supera al código "as".

isinst¿Es realmente lenta la implementación de .NET para tipos que aceptan valores NULL? ¿Es el adicional unbox.anyel que causa el problema? ¿Hay otra explicación para esto? Por el momento parece que voy a tener que incluir una advertencia contra el uso de esto en situaciones sensibles al rendimiento...

Resultados:

Reparto: 10000000: 121
Como: 10000000: 2211
LINQ: 10000000: 2143

Código:

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);
    }
}
Jon Skeet avatar Oct 18 '09 02:10 Jon Skeet
Aceptado

Claramente, el código de máquina que el compilador JIT puede generar para el primer caso es mucho más eficiente. Una regla que realmente ayuda es que un objeto solo se puede descomprimir en una variable que tenga el mismo tipo que el valor encajonado. Eso permite que el compilador JIT genere código muy eficiente, no es necesario considerar conversiones de valores.

La prueba del operador es fácil, simplemente verifique si el objeto no es nulo y es del tipo esperado, solo requiere unas pocas instrucciones de código de máquina. La conversión también es sencilla: el compilador JIT conoce la ubicación de los bits de valor en el objeto y los utiliza directamente. No se produce ninguna copia ni conversión, todo el código de máquina está en línea y solo requiere alrededor de una docena de instrucciones. Esto tenía que ser realmente eficiente en .NET 1.0, cuando el boxeo era común.

¿Transmitir a int? requiere mucho más trabajo. La representación del valor del número entero encuadrado no es compatible con el diseño de la memoria de Nullable<int>. Se requiere una conversión y el código es complicado debido a posibles tipos de enumeración en cuadros. El compilador JIT genera una llamada a una función auxiliar CLR denominada JIT_Unbox_Nullable para realizar el trabajo. Esta es una función de propósito general para cualquier tipo de valor, hay mucho código para verificar los tipos. Y se copia el valor. Es difícil estimar el costo ya que este código está bloqueado dentro de mscorwks.dll, pero es probable que haya cientos de instrucciones en código de máquina.

El método de extensión Linq OfType() también utiliza el operador is y el cast. Sin embargo, esto es una conversión a un tipo genérico. El compilador JIT genera una llamada a una función auxiliar, JIT_Unbox() que puede realizar una conversión a un tipo de valor arbitrario. No tengo una buena explicación de por qué es tan lento como el elenco Nullable<int>, dado que debería ser necesario menos trabajo. Sospecho que ngen.exe podría causar problemas aquí.

Hans Passant avatar Jun 19 '2010 17:06 Hans Passant

Me parece que isinstes muy lento con los tipos que aceptan valores NULL. En el método FindSumWithCastcambié

if (o is int)

a

if (o is int?)

lo que también ralentiza significativamente la ejecución. La única diferencia en IL que puedo ver es que

isinst     [mscorlib]System.Int32

se cambia a

isinst     valuetype [mscorlib]System.Nullable`1<int32>
Dirk Vollmar avatar Oct 17 '2009 20:10 Dirk Vollmar