Lo que son las cosas. Esta pretendía ser una larga, sesuda y razonada entrada sobre las diferencias entre el optimizador de código del C# y el de C++/CLI, pero se ha quedado en apenas una nonada, ya que con el primer ejemplo tumbo de raíz el tema. Es decir, la cosa queda demostrada y bien demostrada.
Todo el asunto viene por una entrada en el grupo de C# en la que se me discutía que el lenguaje de C# puede equipararse a C++/CLI e incluso a C++. Comparados con éstos dos últimos, el C# no es más que una m*riconada de lenguaje, lenta en su ejecución y que carece de un buen número de características esenciales. Aparte está el hecho de que las partes que van mal dentro del Visual Studio (léase el diseñador visual de fichas, y los asistentes esos para bases de datos, están hechos por los chicos de C#, mientras que el resto del IDE sigue estando construido en C++). Y quizás sea ese el motivo por el cual C++/CLI se comporta tan mal dentro del IDE, ya que el propio lenguaje es una gozada, y ya mayoría de sus bugs no están en el propio compilador, sino en la parte del .NET (que por cierto, también está hecha en C#).
Bueno, volviendo al tema que nos ocupa.
Observemos el siguiente código (el de la izquierda está escrito en C++/CLI, el de la derecha en C#). La estructura es lo más parecida que puede ser en ambos casos.
// TestCpp1.cpp : main project file.
#include "stdafx.h"
using namespace System;
ref class Program
{
public:
static void CallPrint(void)
{
Console::WriteLine("CallPrint");
}
static void DoWork(void)
{
for(int i=0;i<1000;i++)
CallPrint();
}
};
int main(array<System::String ^> ^args)
{
Program::DoWork();
Console::WriteLine(L"Hello World");
return 0;
}
|
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");
}
}
}
|
Para los perezosos diremos que main() llama a un método estático de una clase cuyo nombre es DoWork(), que a su vez llama 1000 veces mediante un bucle a otro método estático que simplemente saca por pantalla una cadena.
La solución está disponible para quien quiera bajársela, así también se ahorra esfuerzo de picar código. La única observación a realizar es que hemos cambiado la construcción de Debug a Release para que entren ambos optimizadores. Tampoco se han tocado las opciones del proyecto (recordemos que el optimizado del C++/CLI por defecto es suave, y que todavía podemos subir dicho nivel, pero realmente no hace falta).
Ahora veamos el código generado por el programa realizado en C#:
Arriba del todo tenemos el listado de métodos que nos da el Ildasm. Aparte del constructor observamos los tres métodos citados. La siguiente imagen muestra el método main(), que en la línea IL_0000 hace una llamada al método DoWork(). Debajo tenemos dicho método. Vemos perfectamente el bucle, que ocupa 21 bytes y consta de 11 instrucciones. También podemos ver la llamada al método CallPrint(), que simplemente carga la cadena y llama a su vez a WriteLine().
Veamos ahora el programa en C++/CLI:
De la misma forma que antes, arriba mostramos parte del listado (recordemos que C++/CLI es bastante más complejo que C# y necesita y carga por defecto más cosas). Pero, cosa curiosa, sólo tenemos el método main(), ni está el CallPrint() ni el DoWork().
Estudiemos ese main(). La primera instrucción es una carga del valor hexadecimal 0x3e8 ¡que es 1000 en decimal! Luego vemos que carga la cadena a imprimir que debería estar en el método CallPrint(). El siguiente paso es hacer una llamada a WriteLine(). Y luego viene todo el código de mantenimiento del bucle. Al final del todo se imprime el Hello World.
Demoledor, ¿no? Nos hemos saltado dos llamadas a métodos, una de ellas dentro de un bucle de 1000 vueltas, es decir, nos hemos ahorrado mil llamadas al método CallPrint(), lo que supone un enorme ahorro de tiempo de proceso. También nos hemos saltado la llamada a DoWork() que evidentemente no hacía falta.
Pero sigamos mirando. Veamos el detalle de ambos bucles. En el de C#, se toma una variable que se va incrementando y al final se compara con el valor tope, 1000. En el de C++/CLI, pese a que el bucle original era de cero a mil, el funcionamiento es justo el contrario: se carga una variable con el valor de 1000 y se va decrementando hasta que llegue a cero, momento en el cual la instrucción btt.un.s no salta y la ejecución del bucle se detiene. ¿Qué es más caro, cargar una variable en un registro, comparar otro registro con ella y saltar, o simplemente saltar si un registro no es cero? En ensamblador de Intel, la parte de comparación conforme la hace C# son al menos dos instrucciones, en la forma en que lo hace el de C++/CLI, una solo, y posiblemente de un solo ciclo máquina.
La única cosa que queda por demostrar es que el optimizador interno del .NET fuera capaz de equiparar ambos códigos MSIL con el mismo ensamblador para Intel, pero dudo mucho que sea capaz de invertir el bucle de cero a mil, y menos aún saltarse todas esas llamadas a métodos que realmente no son necesarias.
Pero demos todavía un paso más. Retiremos el WriteLine() que hay dentro de CallPrint() y recompilemos ambos códigos (he creado dos nuevos proyectos llamados «TestCpp2» y «TestCS2»).
Primero el resultado en C#:
Y ahora el generado por el compilador de C++/CLI:
Impresionante, ¿no? Se ha comido todo el código inútil.