remarks example cref c# performance compilation roslyn jit

example - Rendimiento C#en pequeñas funciones



remarks c# (1)

Puedo hacer que el rendimiento sea mucho mejor al cambiar una línea de código:

a = a + b + 1;

Cambiarlo a:

a = b + 1 + a;

O:

a += b + 1;

Ahora verá que NormalFunction podría ser un poco más rápido y puede "corregirlo" cambiando la firma del método Double a:

int Double( int a ) { return a * 2; }

Pensé en estos cambios porque esto es lo que era diferente entre las dos implementaciones. Después de esto, su rendimiento es muy similar, con TinyFunctions siendo un poco más lento (como se esperaba).

El segundo cambio es fácil de explicar: la implementación de NormalFunction realidad dobla un int y luego lo convierte en un double (con un código de operación fild en el nivel de código de máquina). El método Double original carga un double primero y luego lo dobla, lo que espero sea un poco más lento.

Pero eso no explica la mayor parte de la discrepancia de tiempo de ejecución. Eso se debe casi por completo al cambio de orden que hice primero. ¿Por qué? Realmente no tengo idea. La diferencia en el código de máquina se ve así:

Original Changed 01070620 push ebp 01390620 push ebp 01070621 mov ebp,esp 01390621 mov ebp,esp 01070623 push edi 01390623 push edi 01070624 push esi 01390624 push esi 01070625 push eax 01390625 push eax 01070626 fldz 01390626 fldz 01070628 xor esi,esi 01390628 xor esi,esi 0107062A mov edi,dword ptr ds:[0FE43ACh] 0139062A mov edi,dword ptr ds:[12243ACh] 01070630 test edi,edi 01390630 test edi,edi 01070632 jle 0107065A 01390632 jle 0139065A 01070634 xor edx,edx 01390634 xor edx,edx 01070636 mov ecx,dword ptr ds:[0FE43B0h] 01390636 mov ecx,dword ptr ds:[12243B0h] 0107063C test ecx,ecx 0139063C test ecx,ecx 0107063E jle 01070655 0139063E jle 01390655 01070640 mov eax,edx 01390640 mov eax,edx 01070642 add eax,eax 01390642 add eax,eax 01070644 mov dword ptr [ebp-0Ch],eax 01390644 mov dword ptr [ebp-0Ch],eax 01070647 fild dword ptr [ebp-0Ch] 01390647 fild dword ptr [ebp-0Ch] 0107064A faddp st(1),st 0139064A fld1 0107064C fld1 0139064C faddp st(1),st 0107064E faddp st(1),st 0139064E faddp st(1),st 01070650 inc edx 01390650 inc edx 01070651 cmp edx,ecx 01390651 cmp edx,ecx 01070653 jl 01070640 01390653 jl 01390640 01070655 inc esi 01390655 inc esi 01070656 cmp esi,edi 01390656 cmp esi,edi 01070658 jl 01070634 01390658 jl 01390634 0107065A pop ecx 0139065A pop ecx 0107065B pop esi 0139065B pop esi 0107065C pop edi 0139065C pop edi 0107065D pop ebp 0139065D pop ebp 0107065E ret 0139065E ret

Que es opcode-para-opcode idéntico, excepto por el orden de las operaciones de coma flotante. Eso hace una enorme diferencia en el rendimiento, pero no sé lo suficiente sobre las operaciones de punto flotante x86 para saber exactamente por qué.

Actualizar:

Con la nueva versión entera, vemos algo más curioso. En este caso, parece que el JIT está tratando de ser inteligente y aplicar una optimización porque convierte esto:

int b = 2 * i; a = a + b + 1;

En algo así como:

mov esi, eax ; b = i add esi, esi ; b += b lea ecx, [ecx + esi + 1] ; a = a + b + 1

Donde a se almacena en el registro ecx , i en eax b en esi .

Mientras que la versión de TinyFunctions se convierte en algo así como:

mov eax, edx add eax, eax inc eax add ecx, eax

Donde edx en edx , b está en eax , y a está en ecx esta vez.

Supongo que para nuestra arquitectura de CPU, este "truco" de LEA (que se explica aquí ) termina siendo más lento que el uso de la ALU propiamente dicha. Todavía es posible cambiar el código para que el rendimiento entre los dos se alinee:

int b = 2 * i + 1; a += b;

Esto termina forzando al enfoque de función NormalFunction a convertirse en mov, add, inc, add como aparece en el enfoque de TinyFunctions .

Uno de mis compañeros de trabajo ha estado leyendo Clean Code por Robert C Martin y llegó a la sección sobre el uso de muchas funciones pequeñas en lugar de menos funciones grandes. Esto condujo a un debate sobre la consecuencia de rendimiento de esta metodología. Entonces, escribimos un programa rápido para evaluar el rendimiento y los resultados nos confunden.

Para empezar, aquí está la versión normal de la función.

static double NormalFunction() { double a = 0; for (int j = 0; j < s_OuterLoopCount; ++j) { for (int i = 0; i < s_InnerLoopCount; ++i) { double b = i * 2; a = a + b + 1; } } return a; }

Aquí está la versión que hice que rompe la funcionalidad en pequeñas funciones.

static double TinyFunctions() { double a = 0; for (int i = 0; i < s_OuterLoopCount; i++) { a = Loop(a); } return a; } static double Loop(double a) { for (int i = 0; i < s_InnerLoopCount; i++) { double b = Double(i); a = Add(a, Add(b, 1)); } return a; } static double Double(double a) { return a * 2; } static double Add(double a, double b) { return a + b; }

Utilizo la clase de cronómetro para cronometrar las funciones y cuando lo ejecuté en depuración obtuve los siguientes resultados.

s_OuterLoopCount = 10000; s_InnerLoopCount = 10000; NormalFunction Time = 377 ms; TinyFunctions Time = 1322 ms;

Estos resultados tienen sentido para mí especialmente en la depuración ya que hay una sobrecarga adicional en las llamadas a funciones. Es cuando lo ejecuto en versión que obtengo los siguientes resultados.

s_OuterLoopCount = 10000; s_InnerLoopCount = 10000; NormalFunction Time = 173 ms; TinyFunctions Time = 98 ms;

Estos resultados me confunden, incluso si el compilador optimizaba las TinyFunctions al alinear todas las llamadas a funciones, ¿cómo podría hacerlo ~ 57% más rápido?

Hemos intentado mover declaraciones variables en NormalFunctions y básicamente no tiene efecto en el tiempo de ejecución.

Esperaba que alguien supiera qué estaba pasando y si el compilador puede optimizar TinyFunctions tan bien, ¿por qué no puede aplicar optimizaciones similares a NormalFunction?

Al mirar a nuestro alrededor encontramos que alguien mencionó que tener las funciones descompuestas permite que el JIT optimice mejor qué incluir en los registros, pero NormalFunctions solo tiene 4 variables, por lo que me resulta difícil de creer que explique la enorme diferencia de rendimiento.

Estaría agradecido por cualquier información que alguien pueda proporcionar.

Actualización 1 Como se señala a continuación por Kyle, cambiar el orden de las operaciones hizo una gran diferencia en el rendimiento de NormalFunction.

static double NormalFunction() { double a = 0; for (int j = 0; j < s_OuterLoopCount; ++j) { for (int i = 0; i < s_InnerLoopCount; ++i) { double b = i * 2; a = b + 1 + a; } } return a; }

Aquí están los resultados con esta configuración.

s_OuterLoopCount = 10000; s_InnerLoopCount = 10000; NormalFunction Time = 91 ms; TinyFunctions Time = 102 ms;

Esto es más de lo que esperaba, pero aún deja la pregunta de por qué el orden de las operaciones puede tener un ~ 56% de rendimiento.

Además, luego lo intenté con operaciones enteras y volvimos a no tener ningún sentido.

s_OuterLoopCount = 10000; s_InnerLoopCount = 10000; NormalFunction Time = 87 ms; TinyFunctions Time = 52 ms;

Y esto no cambia independientemente del orden de las operaciones.