c# - unity - when use try catch




¿Trata-captura acelerando mi código? (4)

Bueno, la forma en que estás sincronizando las cosas me parece bastante desagradable. Sería mucho más sensato simplemente cronometrar todo el bucle:

var stopwatch = Stopwatch.StartNew();
for (int i = 1; i < 100000000; i++)
{
    Fibo(100);
}
stopwatch.Stop();
Console.WriteLine("Elapsed time: {0}", stopwatch.Elapsed);

De esa manera, no estará a la merced de los tiempos pequeños, la aritmética de punto flotante y el error acumulado.

Después de haber realizado ese cambio, vea si la versión "non-catch" es aún más lenta que la versión "catch".

EDIT: Está bien, lo he intentado yo mismo, y estoy viendo el mismo resultado. Muy raro. Me pregunté si el try / catch estaba deshabilitando algunos inline mal, pero usar [MethodImpl(MethodImplOptions.NoInlining)] lugar no ayudó ...

Básicamente, tendrá que ver el código JITted optimizado en cordbg, sospecho ...

EDITAR: Un poco más de información:

  • Poniendo el try / catch alrededor del n++; La línea aún mejora el rendimiento, pero no tanto como ponerlo alrededor de todo el bloque.
  • Si detecta una excepción específica ( ArgumentException en mis pruebas) todavía es rápido
  • Si imprime la excepción en el bloque catch, sigue siendo rápido.
  • Si vuelves a lanzar la excepción en el bloque catch, vuelve a ser lento.
  • Si usas un bloque finally en lugar de un bloque catch, vuelve a ser lento
  • Si usas un bloque finally y un bloque catch, es rápido

Extraño...

EDIT: Está bien, tenemos desmontaje ...

Esto es usar el compilador C # 2 y .NET 2 (32 bits) CLR, desensamblando con mdbg (ya que no tengo cordbg en mi máquina). Sigo viendo los mismos efectos de rendimiento, incluso debajo del depurador. La versión rápida usa un bloque try alrededor de todo entre las declaraciones de variables y la declaración de retorno, con solo un controlador catch{} . Obviamente, la versión lenta es la misma, excepto sin el try / catch. El código de llamada (es decir, Principal) es el mismo en ambos casos, y tiene la misma representación de ensamblaje (por lo que no es un problema en línea).

Código desensamblado para versión rápida:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        edi
 [0004] push        esi
 [0005] push        ebx
 [0006] sub         esp,1Ch
 [0009] xor         eax,eax
 [000b] mov         dword ptr [ebp-20h],eax
 [000e] mov         dword ptr [ebp-1Ch],eax
 [0011] mov         dword ptr [ebp-18h],eax
 [0014] mov         dword ptr [ebp-14h],eax
 [0017] xor         eax,eax
 [0019] mov         dword ptr [ebp-18h],eax
*[001c] mov         esi,1
 [0021] xor         edi,edi
 [0023] mov         dword ptr [ebp-28h],1
 [002a] mov         dword ptr [ebp-24h],0
 [0031] inc         ecx
 [0032] mov         ebx,2
 [0037] cmp         ecx,2
 [003a] jle         00000024
 [003c] mov         eax,esi
 [003e] mov         edx,edi
 [0040] mov         esi,dword ptr [ebp-28h]
 [0043] mov         edi,dword ptr [ebp-24h]
 [0046] add         eax,dword ptr [ebp-28h]
 [0049] adc         edx,dword ptr [ebp-24h]
 [004c] mov         dword ptr [ebp-28h],eax
 [004f] mov         dword ptr [ebp-24h],edx
 [0052] inc         ebx
 [0053] cmp         ebx,ecx
 [0055] jl          FFFFFFE7
 [0057] jmp         00000007
 [0059] call        64571ACB
 [005e] mov         eax,dword ptr [ebp-28h]
 [0061] mov         edx,dword ptr [ebp-24h]
 [0064] lea         esp,[ebp-0Ch]
 [0067] pop         ebx
 [0068] pop         esi
 [0069] pop         edi
 [006a] pop         ebp
 [006b] ret

Código desensamblado para versión lenta:

 [0000] push        ebp
 [0001] mov         ebp,esp
 [0003] push        esi
 [0004] sub         esp,18h
*[0007] mov         dword ptr [ebp-14h],1
 [000e] mov         dword ptr [ebp-10h],0
 [0015] mov         dword ptr [ebp-1Ch],1
 [001c] mov         dword ptr [ebp-18h],0
 [0023] inc         ecx
 [0024] mov         esi,2
 [0029] cmp         ecx,2
 [002c] jle         00000031
 [002e] mov         eax,dword ptr [ebp-14h]
 [0031] mov         edx,dword ptr [ebp-10h]
 [0034] mov         dword ptr [ebp-0Ch],eax
 [0037] mov         dword ptr [ebp-8],edx
 [003a] mov         eax,dword ptr [ebp-1Ch]
 [003d] mov         edx,dword ptr [ebp-18h]
 [0040] mov         dword ptr [ebp-14h],eax
 [0043] mov         dword ptr [ebp-10h],edx
 [0046] mov         eax,dword ptr [ebp-0Ch]
 [0049] mov         edx,dword ptr [ebp-8]
 [004c] add         eax,dword ptr [ebp-1Ch]
 [004f] adc         edx,dword ptr [ebp-18h]
 [0052] mov         dword ptr [ebp-1Ch],eax
 [0055] mov         dword ptr [ebp-18h],edx
 [0058] inc         esi
 [0059] cmp         esi,ecx
 [005b] jl          FFFFFFD3
 [005d] mov         eax,dword ptr [ebp-1Ch]
 [0060] mov         edx,dword ptr [ebp-18h]
 [0063] lea         esp,[ebp-4]
 [0066] pop         esi
 [0067] pop         ebp
 [0068] ret

En cada caso, el * muestra dónde ingresó el depurador en un simple "paso a paso".

EDIT: Bueno, ahora he revisado el código y creo que puedo ver cómo funciona cada versión ... y creo que la versión más lenta es más lenta porque usa menos registros y más espacio en la pila. Para valores pequeños de n es posiblemente más rápido, pero cuando el bucle ocupa la mayor parte del tiempo, es más lento.

Posiblemente el bloque try / catch obliga a guardar y restaurar más registros, por lo que el JIT también los utiliza para el bucle ... lo que mejora el rendimiento en general. No está claro si es una decisión razonable que el JIT no utilice tantos registros en el código "normal".

EDIT: Acabo de probar esto en mi máquina x64. El x64 CLR es mucho más rápido (alrededor de 3-4 veces más rápido) que el x86 CLR en este código, y bajo x64 el bloque try / catch no hace una diferencia notable.

Escribí un código para probar el impacto de try-catch, pero viendo algunos resultados sorprendentes.

static void Main(string[] args)
{
    Thread.CurrentThread.Priority = ThreadPriority.Highest;
    Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.RealTime;

    long start = 0, stop = 0, elapsed = 0;
    double avg = 0.0;

    long temp = Fibo(1);

    for (int i = 1; i < 100000000; i++)
    {
        start = Stopwatch.GetTimestamp();
        temp = Fibo(100);
        stop = Stopwatch.GetTimestamp();

        elapsed = stop - start;
        avg = avg + ((double)elapsed - avg) / i;
    }

    Console.WriteLine("Elapsed: " + avg);
    Console.ReadKey();
}

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    for (int i = 1; i < n; i++)
    {
        n1 = n2;
        n2 = fibo;
        fibo = n1 + n2;
    }

    return fibo;
}

En mi computadora, esto imprime constantemente un valor de alrededor de 0.96 ...

Cuando envuelvo el bucle for dentro de Fibo () con un bloque try-catch como este:

static long Fibo(int n)
{
    long n1 = 0, n2 = 1, fibo = 0;
    n++;

    try
    {
        for (int i = 1; i < n; i++)
        {
            n1 = n2;
            n2 = fibo;
            fibo = n1 + n2;
        }
    }
    catch {}

    return fibo;
}

Ahora imprime de manera consistente 0.69 ... - ¡en realidad corre más rápido! ¿Pero por qué?

Nota: Compilé esto usando la configuración de la versión y ejecuté directamente el archivo EXE (fuera de Visual Studio).

EDITAR: el excelente análisis de Jon Skeet muestra que try-catch está causando de alguna manera que el CLR x86 use los registros de la CPU de una manera más favorable en este caso específico (y creo que todavía tenemos que entender por qué). Confirmé el hallazgo de Jon de que x64 CLR no tiene esta diferencia y que fue más rápido que el CLR x86. También probé el uso de tipos int dentro del método Fibo en lugar de long tipos long , y luego el CLR x86 fue tan rápido como el CLR x64.

ACTUALIZACIÓN: Parece que este problema ha sido solucionado por Roslyn. La misma máquina, la misma versión CLR: el problema sigue siendo el anterior cuando se compila con VS 2013, pero el problema desaparece cuando se compila con VS 2015.


Esto parece un caso de alineación ido mal. En un núcleo x86, el jitter tiene el registro ebx, edx, esi y edi disponible para el almacenamiento general de variables locales. El registro ecx está disponible en un método estático, no tiene que almacenar esto . El registro eax a menudo es necesario para los cálculos. Pero estos son registros de 32 bits, para variables de tipo long debe usar un par de registros. Que son edx: eax para cálculos y edi: ebx para almacenamiento.

Que es lo que se destaca en el desmontaje para la versión lenta, ni edi ni ebx.

Cuando el jitter no puede encontrar suficientes registros para almacenar variables locales, debe generar un código para cargarlos y almacenarlos desde el marco de la pila. Eso ralentiza el código, evita una optimización del procesador denominada "cambio de nombre de registro", un truco interno de optimización del núcleo del procesador que utiliza varias copias de un registro y permite una ejecución súper escalar. Lo que permite que varias instrucciones se ejecuten simultáneamente, incluso cuando utilizan el mismo registro. No tener suficientes registros es un problema común en los núcleos x86, abordado en x64 que tiene 8 registros adicionales (r9 a r15).

El jitter hará todo lo posible para aplicar otra optimización de generación de código, intentará integrar su método Fibo (). En otras palabras, no haga una llamada al método sino genere el código para el método en línea en el método Main (). Optimización bastante importante que, por un lado, hace que las propiedades de una clase C # sean gratuitas, dándoles el rendimiento de un campo. Evita la sobrecarga de hacer la llamada al método y configurar su marco de pila, ahorra un par de nanosegundos.

Hay varias reglas que determinan exactamente cuándo un método puede ser incorporado. No están documentados exactamente, pero han sido mencionados en publicaciones de blog. Una regla es que no sucederá cuando el cuerpo del método sea demasiado grande. Eso derrota la ganancia de la alineación, genera demasiado código que no encaja tan bien en el caché de instrucciones L1. Otra regla difícil que se aplica aquí es que un método no estará en línea cuando contenga una declaración try / catch. El fondo detrás de eso es un detalle de implementación de excepciones, que se combinan con el soporte integrado de Windows para SEH (Structure Exception Handling), que se basa en el apilado de fotogramas.

Un comportamiento del algoritmo de asignación de registro en la fluctuación de fase puede inferirse de jugar con este código. Parece ser consciente de cuándo el jitter está intentando alinear un método. Parece que una regla utiliza que solo el par de registros edx: eax se puede usar para el código en línea que tiene variables locales de tipo long. Pero no edi: ebx. Sin duda, ya que sería demasiado perjudicial para la generación de código para el método de llamada, tanto edi como ebx son registros de almacenamiento importantes.

Así que obtienes la versión rápida porque el jitter sabe desde el principio que el cuerpo del método contiene declaraciones try / catch. Sabe que nunca se puede alinear, por lo tanto, utiliza edi: ebx para el almacenamiento de la variable larga. Obtuviste la versión lenta porque la inquietud no sabía desde el principio que la incorporación no funcionaría. Solo se descubrió después de generar el código para el cuerpo del método.

La falla entonces es que no regresó y volvió a generar el código para el método. Lo cual es comprensible, dadas las limitaciones de tiempo en las que tiene que operar.

Esta desaceleración no ocurre en x64 porque, para una, tiene 8 registros más. Por otro porque puede almacenar un largo en un solo registro (como rax). Y la desaceleración no se produce cuando se usa int en lugar de largo, ya que la fluctuación de fase tiene mucha más flexibilidad para seleccionar registros.


Los desensamblajes de Jon muestran que la diferencia entre las dos versiones es que la versión rápida usa un par de registros ( esi,edi ) para almacenar una de las variables locales donde la versión lenta no lo hace.

El compilador JIT hace diferentes suposiciones con respecto al uso del registro para el código que contiene un bloque try-catch versus un código que no lo contiene. Esto hace que haga diferentes selecciones de asignación de registros. En este caso, esto favorece el código con el bloque try-catch. Un código diferente puede llevar al efecto opuesto, por lo que no consideraría esto como una técnica de aceleración de propósito general.

Al final, es muy difícil saber qué código terminará ejecutándose más rápido. Algo así como la asignación de registros y los factores que influyen en ella son detalles de implementación de tan bajo nivel que no veo cómo una técnica específica podría producir de manera confiable un código más rápido.

Por ejemplo, considere los siguientes dos métodos. Fueron adaptados de un ejemplo de la vida real:

interface IIndexed { int this[int index] { get; set; } }
struct StructArray : IIndexed { 
    public int[] Array;
    public int this[int index] {
        get { return Array[index]; }
        set { Array[index] = value; }
    }
}

static int Generic<T>(int length, T a, T b) where T : IIndexed {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}
static int Specialized(int length, StructArray a, StructArray b) {
    int sum = 0;
    for (int i = 0; i < length; i++)
        sum += a[i] * b[i];
    return sum;
}

Una es una versión genérica de la otra. Reemplazar el tipo genérico con StructArray haría que los métodos sean idénticos. Debido a que StructArray es un tipo de valor, obtiene su propia versión compilada del método genérico. Sin embargo, el tiempo de ejecución real es significativamente más largo que el de los métodos especializados, pero solo para x86. Para x64, los tiempos son prácticamente idénticos. En otros casos, también he observado diferencias para x64.


Uno de los ingenieros de Roslyn que se especializa en comprender la optimización del uso de la pila echó un vistazo a esto y me informa que parece haber un problema en la interacción entre la forma en que el compilador C # genera almacenes de variables locales y la forma en que se registra el compilador JIT Programación en el correspondiente código x86. El resultado es una generación de código subóptima en las cargas y almacenes de los locales.

Por alguna razón poco clara para todos nosotros, la ruta de generación de código problemática se evita cuando el JITter sabe que el bloque está en una región protegida contra intentos.

Esto es bastante raro. Seguiremos con el equipo de JITter y veremos si podemos obtener un error para que puedan solucionarlo.

Además, estamos trabajando en mejoras para los algoritmos de los compiladores de Roslyn a C # y VB para determinar cuándo los locales se pueden convertir en "efímeros", es decir, solo deben empujarse y colocarse en la pila, en lugar de asignar una ubicación específica en la pila para La duración de la activación. Creemos que el JITter podrá hacer un mejor trabajo de asignación de registros y todo eso si le damos mejores pistas sobre cuándo los locales pueden ser "muertos" antes.

Gracias por traer esto a nuestra atención y disculpas por el extraño comportamiento.





performance-testing