Pregunta Try-catch ¿acelera mi código?


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 consistentemente imprime 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 sistemáticamente 0,69 ... ¡en realidad funciona más rápido! ¿Pero por qué?

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

EDITAR: 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 hemos entendido por qué). Confirmé que Jon descubrió 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 x86 CLR era tan rápido como el x64 CLR.


ACTUALIZAR: Parece que este problema ha sido resuelto por Roslyn. Misma máquina, misma versión de CLR: el problema sigue siendo el mismo que el anterior cuando se compila con VS 2013, pero el problema desaparece cuando se compila con VS 2015.


1338
2018-01-19 15:10


origen


Respuestas:


Uno de los Roslyn Los ingenieros que se especializan en comprender la optimización del uso de la pila lo analizaron y me informan que parece haber un problema en la interacción entre la forma en que el compilador de C # genera las tiendas de variables locales y la forma en que 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 las tiendas de los locales.

Por alguna razón no está claro para todos nosotros, la ruta de generación de código problemático se evita cuando el JITter sabe que el bloque está en una región protegida de prueba.

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

Además, estamos trabajando en mejoras para Roslyn a los algoritmos de los compiladores C # y VB para determinar cuándo los locales pueden ser "efímeros", es decir, simplemente empujados y reventados 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 JITter podrá hacer un mejor trabajo en la asignación de registros y otras cosas si le damos mejores pistas sobre cuándo los locales pueden hacerse "muertos" antes.

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


927
2018-01-20 20:14



Bueno, la forma en que estás cronometrando las cosas me parece bastante desagradable. Sería mucho más sensato simplemente cronometrar 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 estás a merced de los tiempos pequeños, la aritmética de punto flotante y el error acumulado.

Habiendo hecho ese cambio, vea si la versión de "no captura" es aún más lenta que la versión de "captura".

EDITAR: Bien, lo he probado yo mismo, y estoy viendo el mismo resultado. Muy raro. Me preguntaba si el try / catch estaba deshabilitando alguna mala alineación, pero usando [MethodImpl(MethodImplOptions.NoInlining)]en cambio, no ayudó ...

Básicamente, necesitarás mirar el código JITted optimizado bajo cordbg, sospecho ...

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

  • Poniendo el try / catch alrededor del n++; la línea todavía mejora el rendimiento, pero no tanto como ponerlo alrededor de todo el bloque
  • Si captas 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, es lento de nuevo
  • Si usas un bloque finalmente tanto como un bloque de captura, es rápido

Extraño...

EDITAR: Bien, tenemos desmontaje ...

Esto está utilizando el compilador C # 2 y .NET 2 (32 bits) CLR, desmontando con mdbg (ya que no tengo cable en mi máquina). Todavía veo los mismos efectos de rendimiento, incluso bajo el depurador. La versión rápida usa una try bloquear todo entre las declaraciones de variables y la declaración de retorno, con solo una catch{} entrenador de animales. 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 conjunto (por lo que no es un problema de alineación).

Código desensamblado para la 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 la 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, * muestra dónde ingresó el depurador en un simple "paso hacia adentro".

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

Posiblemente el bloque try / catch efectivo más registros para ser guardados y restaurados, por lo que el JIT también los utiliza para el ciclo ... lo cual mejora el rendimiento general. No está claro si es una decisión razonable para el JIT no use tantos registros en el código "normal".

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


702
2018-01-19 15:15



Los desmontadores 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 registro para el código que contiene un bloque try-catch vs. código que no lo hace. Esto hace que realice 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 llevar al efecto opuesto, por lo que no lo consideraría una técnica de aceleración de propósito general.

Al final, es muy difícil decir qué código terminará funcionando más rápido. Algo así como la asignación de registros y los factores que influyen en ellos son detalles de implementación de 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 los métodos idénticos. Porque StructArray es un tipo de valor, obtiene su propia versión compilada del método genérico. Sin embargo, el tiempo real de ejecución es significativamente más largo que el método especializado, pero solo para x86. Para x64, los tiempos son más o menos idénticos. En otros casos, he observado diferencias para x64 también.


110
2018-01-19 18:27



Esto parece un caso de inline que salió 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 esta. El registro eax a menudo es necesario 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 los cálculos y edi: ebx para el almacenamiento.

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

Cuando la fluctuación de fase no puede encontrar suficientes registros para almacenar variables locales, entonces debe generar código para cargar y almacenarlos desde el marco de la pila. Eso ralentiza el código, previene una optimización del procesador llamada "registro de nombre", un truco de optimización del núcleo del procesador interno que usa múltiples copias de un registro y permite la ejecución súper escalar. Lo cual permite que varias instrucciones se ejecuten al mismo tiempo, incluso cuando usan el mismo registro. No tener suficientes registros es un problema común en los núcleos x86, tratado 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 que genere el código para el método en línea en el método Main (). Una optimización bastante importante que, para uno, hace que las propiedades de una clase C # sean gratuitas, dándoles el rendimiento de un campo. Evita la sobrecarga de hacer que el método llame y configure su marco de pila, guarda un par de nanosegundos.

Existen 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 blog. Una regla es que no sucederá cuando el cuerpo del método sea demasiado grande. Eso anula la ganancia de la creación, genera demasiado código que no encaja tan bien en la caché de instrucciones L1. Otra regla difícil que se aplica aquí es que un método no se incluirá cuando contenga una instrucción try / catch. El trasfondo detrás de este es un detalle de implementación de las excepciones, que se conectan al soporte integrado de Windows para SEH (Manejo de Excepción de Estructura) que se basa en el marco de la pila.

Un comportamiento del algoritmo de asignación de registros en el jitter se puede deducir jugando con este código. Parece estar al tanto de cuándo el jitter está intentando alinear un método. Una regla parece usar que solo el par de registro 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, porque 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.

Así que obtienes la versión rápida porque el jitter sabe por adelantado que el cuerpo del método contiene instrucciones try / catch. Sabe que nunca se puede incluir, por lo que usa edi: ebx para el almacenamiento de la variable larga. Obtuviste la versión lenta porque el jitter no sabía por adelantado que la línea no funcionaría. Solo se enteró después generando el código para el cuerpo del método.

El error es que no retrocedió y regenerado el código para el método. Lo cual es comprensible, dadas las limitaciones de tiempo en las que tiene que operar.

Esta ralentización no ocurre en x64 porque para uno tiene 8 registros más. Por otro, porque puede almacenar un registro largo en solo uno (como rax). Y la desaceleración no ocurre cuando utiliza int en lugar de long porque la jitter tiene mucha más flexibilidad en la selección de registros.


65
2017-08-03 10:42



Me gustaría haber puesto esto como un comentario, ya que no estoy seguro de que este sea el caso, pero según recuerdo, una afirmación try / except implica una modificación en la forma en que el mecanismo de eliminación de basura 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 que deba aclararse en este caso o que el bucle for puede constituir un cierre que el mecanismo de recolección de basura reconoce suficiente para imponer 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 lado.


18
2018-01-20 13:15