¿Probar y acelerar mi código?

1505

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

Nota: compilé esto usando la configuración de lanzamiento 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 x86 CLR 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é 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 inttipos dentro del método Fibo en lugar de longtipos, y luego el x86 CLR fue tan rápido como el x64 CLR.


ACTUALIZACIÓN: Parece que este problema ha sido solucionado por Roslyn. Misma máquina, 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.

Eren Ersönmez
fuente
111
@Lloyd intenta obtener una respuesta a su pregunta "¡en realidad funciona más rápido! ¿Pero por qué?"
Andreas Niedermair
137
Entonces, ahora "Swallowing Exceptions" pasó de ser una mala práctica a una buena optimización del rendimiento: P
Luciano
2
¿Está esto en un contexto aritmético no verificado o verificado?
Random832
77
@ taras.roshko: Si bien no deseo perjudicar a Eric, esta no es realmente una pregunta de C #, es una pregunta del compilador JIT. La última dificultad es descubrir por qué el x86 JIT no utiliza tantos registros sin el bloque try / catch como lo hace con el bloque try / catch.
Jon Skeet
63
Dulce, así que si anidamos estas capturas de prueba, podemos ir aún más rápido, ¿verdad?
Chuck Pinkert el

Respuestas:

1053

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 de C # genera almacenes de variables locales y la forma en que el compilador JIT se registra 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 está clara para todos nosotros, la ruta problemática de generación de código se evita cuando el JITter sabe que el bloque está en una región protegida contra prueba.

Esto es bastante raro Seguiremos 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 hacer "efímeros", es decir, simplemente empujados y desplegados 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 en la asignación de registros y qué pasa si le damos mejores pistas sobre cuándo los locales pueden ser "muertos" antes.

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

Eric Lippert
fuente
8
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 generar un local. Si permite que el JITter produzca un código notablemente más eficiente, quizás el compilador de C # debería ser un poco más cuidadoso al generar locales innecesarios ...
Timwi
33
@Timwi: Absolutamente. En código no optimizado, el compilador produce locales innecesarios con gran abandono porque facilitan la depuración. En el código optimizado, se deben eliminar temporarios innecesarios si es posible. Desafortunadamente, hemos tenido muchos errores a lo largo de los años en los que accidentalmente optimizamos el optimizador de eliminación temporal. El ingeniero antes mencionado 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
24
¿Hubo alguna vez algún movimiento sobre este tema?
Robert Harvey
10
Parece que Roslyn lo arregló.
Eren Ersönmez
56
Perdiste tu oportunidad de llamarlo un "error JITter".
mbomb007
734

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

Después de haber hecho ese cambio, vea si la versión "sin captura" sigue siendo más lenta que la versión "sin captura".

EDITAR: Bien, lo he intentado yo mismo y estoy viendo el mismo resultado. Muy raro. Me preguntaba si el try / catch estaba deshabilitando algunas malas alineaciones, pero usar en su [MethodImpl(MethodImplOptions.NoInlining)]lugar no ayudó ...

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

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

  • Poner el try / catch solo alrededor de la n++;línea aún mejora el rendimiento, pero no tanto como ponerlo en todo el bloque
  • Si detecta una excepción específica ( ArgumentExceptionen mis pruebas) aún 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 usa un bloque finalmente en lugar de un bloque catch, es lento nuevamente
  • Si usa un bloque finalmente y un bloque catch, 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 cordbg en mi máquina). Todavía veo los mismos efectos de rendimiento, incluso bajo el depurador. La versión rápida usa un trybloque alrededor de todo entre las declaraciones de variables y la declaración de retorno, con solo un catch{}controlador. 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 de línea).

Código desmontado 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 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 *programa 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 en la pila. Para valores pequeños de neso 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 obliga a guardar y restaurar más registros, por lo que el JIT también los usa para el bucle ... lo que mejora el rendimiento general. No está claro si es una decisión razonable que 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 x86 CLR en este código, y bajo x64 el bloque try / catch no hace una diferencia notable.

Jon Skeet
fuente
44
@GordonSimpson, pero en el caso de que solo se detecte una excepción específica, no se detectarán todas las demás excepciones, por lo que toda la sobrecarga involucrada en su hipótesis de no intentarlo aún sería necesaria.
Jon Hanna
45
Parece una diferencia en la asignación de registros. La versión rápida se las arregla para usar esi,edipara uno de los largos en lugar de la pila. Se usa ebxcomo contador, donde se usa la versión lenta esi.
Jeffrey Sax
13
@JeffreySax: no se trata solo de los registros que se usan, sino de cuántos. La versión lenta usa más espacio de pila, tocando menos registros. No tengo idea de por qué ...
Jon Skeet
2
¿Cómo se tratan los marcos de excepción CLR en términos de registros y pila? ¿Podría configurar uno haber liberado un registro para usar de alguna manera?
Random832
44
IIRC x64 tiene más registros disponibles que x86. La aceleración que vio sería coherente con el intento de captura / forzar el uso de registro adicional bajo x86.
Dan Is Fiddling By Firelight
116

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 vs.código que no lo hace. Esto hace que tome diferentes decisiones de asignación de registros. En este caso, esto favorece el código con el bloque try-catch. Un código diferente puede conducir al 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 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 ninguna 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;
}

Uno es una versión genérica del otro. Reemplazar el tipo genérico con StructArrayharía que los métodos sean idénticos. Debido a que StructArrayes 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 bastante idénticos. En otros casos, también he observado diferencias para x64.

Jeffrey Sax
fuente
66
Dicho esto ... ¿puede forzar diferentes opciones de asignación de registros sin usar un Try / Catch? ¿Ya sea como prueba de esta hipótesis o como un intento general de ajustar la velocidad?
WernerCD
1
Existen varias razones por las cuales este caso específico puede ser diferente. Tal vez sea el try-catch. Tal vez sea el hecho de que las variables se reutilizan en un ámbito interno. Cualquiera sea la razón específica, es un detalle de implementación con el que no puede contar para preservarse, incluso si se llama exactamente el mismo código en un programa diferente.
Jeffrey Sax
44
@WernerCD Diría que el hecho de que C y C ++ tengan una palabra clave para sugerir lo que (A) es ignorado por muchos compiladores modernos y (B) se decidió no incluir en C #, sugiere que esto no es algo que nosotros " Lo veré de una manera más directa.
Jon Hanna
2
@WernerCD - Solo si escribe el ensamblaje usted mismo
OrangeDog
72

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 esto . El registro eax a menudo es necesario para los cálculos. Pero estos son registros de 32 bits, para las variables de tipo largo debe usar un par de registros. Cuáles son edx: eax para cálculos y edi: ebx para almacenamiento.

Que es lo que se destaca en el desmontaje de 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 cargar y almacenarlas desde el marco de la pila. Eso ralentiza el código, impide una optimización del procesador llamada "cambio de nombre del registro", un truco interno de optimización del núcleo del procesador que utiliza múltiples copias de un registro y permite la ejecución súper escalar. 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á incorporar 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 (). 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 realizar la llamada al método y configurar su marco de pila, ahorra un par de nanosegundos.

Existen varias reglas que determinan exactamente cuándo se puede incorporar un método. No están documentados exactamente, pero se han mencionado en publicaciones de blog. Una regla es que no sucederá cuando el cuerpo del método sea demasiado grande. Eso vence la ganancia de la alineació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 estará en línea cuando contenga una declaración try / catch. El trasfondo detrás de ese es un detalle de implementación de excepciones, se complementan con el soporte integrado de Windows para SEH (Estructura de manejo de excepciones) que se basa en el marco de la pila.

Se puede inferir un comportamiento del algoritmo de asignación de registros en el jitter al jugar con este código. Parece ser consciente de cuándo el jitter está tratando de incorporar un método. Una regla que parece usar es que solo el par de registro edx: eax puede usarse para código en línea que tiene variables locales de tipo largo. 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.

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 puede estar en línea, 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 alineación no funcionaría. Solo se descubrió después de generar el código para el cuerpo del método.

La falla es que no volvió 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 ralentización no ocurre en x64 porque para uno tiene 8 registros más. Por otro, porque puede almacenar un largo en un solo registro (como rax). Y la desaceleración no ocurre cuando usas int en lugar de mucho tiempo porque el jitter tiene mucha más flexibilidad en la selección de registros.

Hans Passant
fuente
21

Hubiera puesto esto como un comentario, ya que realmente no estoy seguro de que este sea el caso, pero, según recuerdo, una declaración de prueba / excepción no 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 en este caso no se elimine un objeto o que el bucle for pueda 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.

Miller el gorila
fuente