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 int
tipos dentro del método Fibo en lugar de long
tipos, 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.
Respuestas:
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.
fuente
Bueno, la forma en que estás cronometrando las cosas me parece bastante desagradable. Sería mucho más sensato cronometrar todo el ciclo:
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:
n++;
línea aún mejora el rendimiento, pero no tanto como ponerlo en todo el bloqueArgumentException
en mis pruebas) aún es rápidoExtrañ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
try
bloque alrededor de todo entre las declaraciones de variables y la declaración de retorno, con solo uncatch{}
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:
Código desmontado para versión lenta:
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
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 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.
fuente
esi,edi
para uno de los largos en lugar de la pila. Se usaebx
como contador, donde se usa la versión lentaesi
.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:
Uno es una versión genérica del otro. Reemplazar el tipo genérico con
StructArray
haría que los métodos sean idénticos. Debido a queStructArray
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 bastante idénticos. En otros casos, también he observado diferencias para x64.fuente
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.
fuente
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.
fuente