Try-catch acelerando mi código?

Escribí un código para probar el impacto de try-catch, pero vi 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 consistentemente un valor 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 constantemente 0.69 ... - ¡en realidad funciona 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).

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


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

preguntado el 19 de enero de 12 a las 11:01

@Lloyd intenta obtener una respuesta a su pregunta "¡en realidad corre más rápido! ¿Pero por qué?" -

Entonces, ahora "Tragar excepciones" pasó de ser una mala práctica a una buena optimización del rendimiento: P -

¿Es esto en un contexto aritmético no verificado o verificado? -

@ taras.roshko: Si bien no deseo hacerle un flaco favor a Eric, esta no es realmente una pregunta de C #, es una pregunta del compilador JIT. La máxima dificultad es averiguar por qué el JIT x86 no usa tantos registros sin el try / catch como lo hace con el bloque try / catch. -

Dulce, si anidamos estas capturas de prueba, podemos ir aún más rápido, ¿verdad? -

6 Respuestas

El Roslyn Los ingenieros que se especializan en comprender la optimización del uso de la pila analizaron esto y me informaron que parece haber un problema en la interacción entre la forma en que el compilador de C # genera almacenes de variables locales y la forma en que el JIT El compilador registra la programación en el código x86 correspondiente. El resultado es una generación de código subóptima en las cargas y tiendas de los locales.

Por alguna razón que no nos queda clara a todos, 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 por prueba.

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

Además, estamos trabajando en mejoras para Roslyn en los algoritmos de los compiladores de C # y VB para determinar cuándo los locales se pueden convertir en "efímeros", es decir, simplemente presionarlos y colocarlos en la pila, en lugar de asignarles 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 en la asignación de registros y todo eso si le damos mejores pistas sobre cuándo los locales pueden quedar "muertos" antes.

Gracias por informarnos sobre esto y disculpas por el comportamiento extraño.

Respondido 17 Oct 16, 00:10

Siempre me he preguntado por qué el compilador de C # genera tantos locales extraños. Por ejemplo, las nuevas expresiones de inicialización de matriz siempre generan un local, pero nunca es necesario para generar un local. Si permite que el JITter produzca un código considerablemente más eficaz, tal vez el compilador de C # debería tener un poco más de cuidado al generar locales innecesarios ... timwi

@Timwi: Por supuesto. En código no optimizado, el compilador produce locales innecesarios con gran abandono porque facilitan la depuración. En el código optimizado, los temporales innecesarios deben eliminarse si es posible. Desafortunadamente, hemos tenido muchos errores a lo largo de los años en los que accidentalmente desoptimizamos el optimizador de eliminación temporal. El ingeniero mencionado anteriormente está rehaciendo completamente desde cero todo este código para Roslyn y, como resultado, deberíamos tener un comportamiento optimizado mucho mejor en el generador de código de Roslyn. - eric lippert

¿Hubo alguna vez algún movimiento sobre este tema? - Robert Harvey

Parece que Roslyn lo arregló. - Eren Ersonmez

Bueno, la forma en que estás cronometrando las cosas me parece bastante desagradable. Sería mucho más sensato medir el tiempo de todo el ciclo:

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 merced de tiempos minúsculos, aritmética de punto flotante y error acumulado.

Una vez realizado ese cambio, compruebe si la versión "sin captura" sigue siendo más lenta que la versión "sin captura".

EDITAR: Está bien, lo probé yo mismo, y estoy viendo el mismo resultado. Muy raro. Me pregunté si el intento / captura estaba deshabilitando algunas alineaciones incorrectas, pero usando [MethodImpl(MethodImplOptions.NoInlining)] en cambio no ayudó ...

Básicamente, deberá mirar el código JITted optimizado debajo de cordbg, sospecho ...

EDITAR: Algunos bits más de información:

  • Poniendo el try / catch alrededor del n++; La línea sigue mejorando el rendimiento, pero no tanto como colocarla en 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 de captura, aún es rápido
  • Si vuelve a lanzar la excepción en el bloque de captura, vuelve a ser lento
  • Si usa un bloque finalmente en lugar de un bloque de captura, vuelve a ser lento
  • Si usas un bloque finalmente al igual que un bloque de captura, es rápido

Extraño...

EDITAR: Está bien, tenemos desmontaje ...

Esto está usando el compilador C # 2 y .NET 2 (32 bits) CLR, desensamblado con mdbg (ya que no tengo cordbg en mi máquina). Sigo viendo los mismos efectos de rendimiento, incluso bajo el depurador. La versión rápida usa un try bloquear todo entre las declaraciones de variables y la declaración de retorno, con solo un catch{} manipulador. Obviamente, la versión lenta es la misma excepto sin el try / catch. El código de llamada (es decir, Main) es el mismo en ambos casos y tiene la misma representación de ensamblado (por lo que no es un problema de inserción).

Código desmontado 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 desmontado 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".

EDITAR: Bien, 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 de pila. Para pequeños valores de n posiblemente sea más rápido, pero cuando el bucle ocupa la mayor parte del tiempo, es más lento.

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

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

Respondido el 19 de enero de 12 a las 19:01

@GordonSimpson, pero en el caso de que solo se detecte una excepción específica, todas las demás excepciones no se detectarán, por lo que cualquier sobrecarga involucrada en su hipótesis de no intentarlo aún sería necesaria. - jon hanna

Parece una diferencia en la asignación de registros. La versión rápida logra usar esi,edi para uno de los largos en lugar de la pila. Usa ebx como el contador, donde la versión lenta usa esi. - jeffrey sax

@JeffreySax: No es solo que se utilizan registros pero cuántos. La versión lenta usa más espacio de pila, tocando menos registros. No tengo idea de por qué ... - jon skeet

¿Cómo se tratan los marcos de excepción CLR en términos de registros y pila? ¿La creación de uno podría haber liberado un registro para su uso de alguna manera? - Random832

IIRC x64 tiene más registros disponibles que x86. La aceleración que vio sería coherente con el intento / captura que obliga al uso de registros adicionales en x86. - Dan está jugando a la luz del fuego

Los desmontajes 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 de registros para el código que contiene un bloque try-catch frente al código que no lo tiene. Esto hace que haga diferentes elecciones de asignación de registros. En este caso, esto favorece el código con el bloque try-catch. Un código diferente puede producir el efecto contrario, 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 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 un código más rápido de manera confiable.

Por ejemplo, considere los dos métodos siguientes. 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;
}

Uno es una versión genérica del otro. Reemplazando el tipo genérico con StructArray haría que los métodos fueran idénticos. Porque 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 del método especializado, pero solo para x86. Para x64, los tiempos son prácticamente idénticos. En otros casos, también he observado diferencias para x64.

Respondido el 19 de enero de 12 a las 22:01

Dicho esto ... ¿puede forzar diferentes opciones de asignación de registros sin usar un Try / Catch? ¿O como prueba de esta hipótesis o como un intento general de ajustar la velocidad? - Werner CD

Hay varias razones por las que este caso específico puede ser diferente. Quizás sea el try-catch. Tal vez sea el hecho de que las variables se reutilizan en un ámbito interno. Cualquiera que sea la razón específica, es un detalle de implementación con el que no puede contar para que se conserve, incluso si se llama exactamente al mismo código en un programa diferente. - jeffrey sax

@WernerCD Diría que el hecho de que C y C ++ tienen una palabra clave para sugerir lo que (A) es ignorado por muchos compiladores modernos y (B) se decidió no poner en C #, sugiere que esto no es algo que nosotros ' Veremos de una manera más directa. - jon hanna

@WernerCD - Solo si escribe el ensamblaje usted mismo - naranjaperro

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

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

Cuando el jitter no puede encontrar suficientes registros para almacenar variables locales, debe generar 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 de optimización del núcleo del procesador interno que utiliza múltiples copias de un registro y permite una ejecución superescalar. Lo que permite que varias instrucciones se ejecuten simultáneamente, incluso cuando usan 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á alinear su método Fibo (). En otras palabras, no realice 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, crea las propiedades de una clase C # de forma gratuita, dándoles el rendimiento de un campo. Evita la sobrecarga de realizar la llamada al método y configurar su marco de pila, ahorra un par de nanosegundos.

Hay varias reglas que determinan exactamente cuándo se puede insertar un método. No están exactamente documentados, pero se han mencionado en publicaciones de blogs. Una regla es que no sucederá cuando el cuerpo del método sea demasiado grande. Eso anula la ganancia de la inserción, genera demasiado código que no encaja tan bien en la caché de instrucciones L1. Otra regla estricta que se aplica aquí es que un método no estará en línea cuando contenga una declaración try / catch. El trasfondo detrás de eso es un detalle de implementación de excepciones, que se suma al soporte integrado de Windows para SEH (Manejo de excepciones de estructura) que se basa en marcos de pila.

Un comportamiento del algoritmo de asignación de registros en el jitter se puede inferir jugando con este código. Parece ser consciente de cuándo el jitter está intentando incorporar un método. Una regla que parece usar es que solo el par de registros edx: eax se puede usar para código en línea que tiene variables locales de tipo long. Pero no edi: ebx. Sin duda, debido a que eso 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.

Entonces obtienes la versión rápida porque el jitter sabe de antemano que el cuerpo del método contiene declaraciones try / catch. Sabe que nunca se puede insertar en línea, por lo que usa edi: ebx para almacenar la variable larga. Obtuviste la versión lenta porque el jitter no sabía desde el principio que el inlining no funcionaría. Solo se enteró después de generando el código para el cuerpo del método.

La falla entonces es que no regresó y regenerado el código del 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 uno tiene 8 registros más. Por otro, porque puede almacenar un long en un solo registro (como rax). Y la ralentización no ocurre cuando usa int en lugar de long porque el jitter tiene mucha más flexibilidad para elegir registros.

Respondido 03 ago 12, 11:08

Habría puesto esto como un comentario, ya que realmente no estoy seguro de que sea probable que sea el caso, pero según recuerdo, una declaración try / except no implica una modificación en la forma en que el mecanismo de eliminación de basura de el compilador funciona, ya que borra las asignaciones de memoria de objetos de forma recursiva fuera de la pila. Es posible que no haya un objeto para limpiar en este caso o el bucle for puede constituir un cierre que el mecanismo de recolección de basura reconoce lo suficiente como para hacer cumplir un método de recolección diferente. Probablemente no, pero pensé que valía la pena mencionarlo ya que no lo había visto discutido en ningún otro lugar.

Respondido el 20 de enero de 12 a las 13:01

¡9 años después y el error sigue ahí! Puedes verlo fácilmente con:

   static void Main( string[] args )
    {
      int hundredMillion = 1000000;
      DateTime start = DateTime.Now;
      double sqrt;
      for (int i=0; i < hundredMillion; i++)
      {
        sqrt = Math.Sqrt( DateTime.Now.ToOADate() );
      }
      DateTime end = DateTime.Now;

      double sqrtMs = (end - start).TotalMilliseconds;

      Console.WriteLine( "Elapsed milliseconds: " + sqrtMs );

      DateTime start2 = DateTime.Now;

      double sqrt2;
      for (int i = 0; i < hundredMillion; i++)
      {
        try
        {
          sqrt2 = Math.Sqrt( DateTime.Now.ToOADate() );
        }
        catch (Exception e)
        {
          int br = 0;
        }
      }
      DateTime end2 = DateTime.Now;

      double sqrtMsTryCatch = (end2 - start2).TotalMilliseconds;

      Console.WriteLine( "Elapsed milliseconds: " + sqrtMsTryCatch );

      Console.WriteLine( "ratio is " + sqrtMsTryCatch / sqrtMs );

      Console.ReadLine();
    }

La proporción es menor a uno en mi máquina, ejecutando la última versión de MSVS 2019, .NET 4.6.1

respondido 27 nov., 20:14

Ejecuté este código en .NET 5.0. En x86, tarda entre 530-535 ms sin un try-catch y entre un 1-3% menos con try-catch. Al compilar x64, se necesitan entre 218-222 ms sin try-catch y entre un 11-13% menos con try-catch. Qué curioso. - foxita

No es la respuesta que estás buscando? Examinar otras preguntas etiquetadas or haz tu propia pregunta.