C++/CLI y C#: Asombrosas diferencias en el optimizador de Código (II)
En mi anterior entrada demostraba la poca calidad del optimizador de C# frente a la del de C++/CLI. Comentarios vertidos en dicha entrada me han puesto en duda sobre si el jitter era capaz de realizar las optimizaciones oportunas o no sobre el código anteriormente citado. Lo cierto es que por mi experiencia práctica, la respuesta es que no, que por muy moderno, listo y guapo que fuera, como mucho cambiaría el orden de las instrucciones para mejorar los pipelines y el predictor de salto del procesador (aparte de, evidentemente, traducir el pseudoensamblador MSIL en código máquina del procesador que se trate), pero que desde luego no era capaz de reordenar bucles y comerse código muerto.
La explicación lógica es bastante sencilla desde mi modo de ver: mientras que el compilador tiene acceso al código fuente y puede ver qué está haciendo el programador, el jitter (cualquier jitter en realidad) no lo tiene, y podría ser muy peligroso cambiar el orden de un bucle ya que podría tener efectos laterales no deseados.
A ello se añade el hecho de que es un elemento de tiempo de ejecución, y cuanto menos tarde a realizar sus tareas, mejor.
Pero dejémonos de rollos y vayamos a lo práctico. Tomemos el código siguiente (que aparece en la anterior entrada y que en la solución suminstrada se llama TestCS2):
using System;
using System.Collections.Generic;
using System.Text;
namespace TestCS1
{
class Program
{
static void CallPrint()
{
//Console.WriteLine("CallPrint");
}
static void DoWork()
{
for (int i = 0; i < 1000; i++)
CallPrint();
}
static void Main(string[] args)
{
DoWork();
Console.WriteLine("Hello World");
}
}
}
En la anterior entrada se demostró que el compilador de C# no eliminaba todo el código muerto que hay en la llamada a DoWork() y que el de C++/CLI sí lo hacía. Luego surgió el tema de que el jitter sería capaz de terminar la pobre acción del compilador. Para ello, pongamos un punto de interrupción en la llamada a DoWork() dentro de main(), lancemos el programa y esperemos a que se pare en el punto de interrupción:
Ahora nos vamos al menú "Debug", "Windows" y seleccionemos la ventana "Disassembly", o pulsemos directamente ALT-F8:
En el punto de interrupción vemos que hay una llamada de subrutina en código nativo a una dirección absoluta: call dword ptr ds:[001D9774h].
Pulsemos F11 o Step Into:
Vaya, el bucle muerto sigue ahí, con todas sus instrucciones. Pero sigamos ejecutando paso a paso hasta llegar al call que se puede ver en la línea 0x00000016, y luego volvamos a pulsar F11:
¡Hasta la llamada a la rutina de código muerto! ¡No ha sido capaz ni de eliminar un salto a algo que simplemente retorna sin hacer nada…
Demostramos así que el jitter ni elimina el código muerto, ni nada de nada.
Y no nos hemos fijado ni en la serie de nop que rellenan todo el código, ni en esos extraños call 79885AAE (que me imagino qué son), ni la extraña forma que tiene el bucle…
Solamente, para echar más leña al fuego, veamos cómo queda el bucle del primer programa realizado en C++/CLI (el que hace la llamada a WtiteLine() pero que el optimizador mete todo dentro de main():
La llama a WriteLine() es el call de la línea 0x00000020.
Ahora comparemos ese bucle con el que ha realizado el jitter para el programa hecho en C#.
Para aquellos que no lo tengan muy claro, el bucle lo forman las líneas que van desde 0x00000015 a 0x00000026, es decir, traer desde memoria el valor de la cadena, llamar a WriteLine(), decrementar el valor de 1000 y saltar si no es cero.
La única diferencia entre este código y el de un teórico programa en C++ que hiciera lo mismo es la línea que trae el puntero a la cadena, que estaría fuera del bucle, pero como estamos en .NET y nunca tendremos la certeza de dónde está realmente la cadena, tenemos que traerla no sea que el recolector de basura la haya movido.