C++/CLI y C#: Asombrosas diferencias en el optimizador de código

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.

11 comentarios sobre “C++/CLI y C#: Asombrosas diferencias en el optimizador de código”

  1. Impresionante fiera. A ver si alguien más versado que yo en compiladores, CLI y demás puede rebatirte un poco, que se ha quedado el compilador de C# a la altura del betún jaja 🙂

  2. Hola, Rafa,

    Irrefutable el hecho de q la optimización de código en C++/CLI va mucho más allá q la de C#. Posibles razones que veo:

    1. Los del equipo de C++/CLI tienen toda la experiencia del mundo en esos temas gracias a C++/Win32. Los de C# no.
    2. Los compiladores de C# y VB son parte de la plataforma, por aquello de que las aplicacoines ASP.NET necesitan compilación «on the fly». Eso hace q no se puedan dar demasiados lujos a la hora de optimizar código, lo importante es generarlo rápido aunque no sea muy allá. A lo mejor eso hasta les ha hecho pensar q no vale la pena romperse tanto la cabeza en la optimización 🙂
    3. Tengo entendido que el futuro backend de todos los compiladores de la plataforma será Phoenix (en research.microsoft.com), que está precisamente basado en el compilador de C++. Tal vez tengamos que esperar hasta ese momento para ver q tus ejemplos de C# y C++/CLI se equiparen.

    Ahora bien, con la frase «el C# es lento en su ejecución y carece de un buen número de características esenciales» creo q te has pasado un poco. Lento o rápido son términos relativos, y para aplicaciones y sitios Web corporativos C# en general da la talla perfectamente. Para otras cosas, por supuesto, es mejor elegir C++. En cuanto a que le faltan características, creo q tiene todas las q hacen falta (con LINQ y compañía ya será la hostia)… Otra cosa es que el resultado de su compilación demore 3 ó 33 ciclos de reloj.

    Slds – Octavio

  3. Octavio, lo curioso del tema es que el codigo en C# se ejecutó más rápido que el código en C++/CLI… porque la llamada a WriteLine costó unos 400 milisegundos en C# y más de 500 en C++/CLI. Visto el ILASM, no hay diferencias en la llamada a la rutina. Por eso no he puesto nada sobre rendimientos, porque tiene que haber algo oculto cuando hice las mediciones. Ya pondré algo más sobre el tema.

    Te tengo que contradecir en lo de que es lento: sí que es lento. Me explico. Imagina una jerarquía de clases grande y profunda. Imagina cuánto código eliminable podría haber ahí. El compilador de C# es tan literal que ejecutará todo ese código, mientras que el de C++/CLI, no.

    Para una aplicación de bases de datos quizá no tengan importancia esos 3 ó 33 ciclos, pero en una de escritorio, sí (y más aún de las del tipo que hago yo).

    Respecto a lo Phoenix, algo he leído por ahí en los blogs de C++/CLI, y respecto a lo de ASP.NET me estás dando la razón: o es una locura hacer esa compilación al vuelo, o que hagan dos compiladores. Además, imagina un server de producción: si es capaz de procesar 1 petición http por segundo más, imagina la variación del rendimiento. Por ello hay algo mal en el fondo del .NET y del C#, y lo seguiré diciendo y manteniendo.

  4. El código de C# se optimiza en ejecución por el jitter, compararlos en compilación no tiene ningún valor porque uno está optmizado y el otro no.

    Si quieres compararlos, tiene que ser corriendo. Aún así debería salir mejor el C++ casi siempre porque en compilación puedes optimizar mucho más que en ejecución. Pero claro, usa reflection en C++… 😉

    Un saludo!

    Vicente

  5. Pablo, la instrucción NOP se suele colocar en firmwares para alinear las instrucciones antes o después de un salto para que el micro no ande con ajustes, y para hacer pequeños retardos (por ejemplo, cuando cambias el chip-select de un espacio de direcciones el micro tarda x ciclos máquina en realizar la operación, el retardo es tan corto que lo mejor es calcular el número de NOP a poner y listo). Relamente no sé por qué lo hacen, pero por ejemplo los micros arm saltan a la siguiente instrucción al salto (es decir, tu haces un jmp, cuando el micro llega a la instrucción la pasa, y después de ejecutar la siguiente, entonces salta). Supongo que podría ser por algo parecido.

    Vicente, una cosa está clara: cuanta menos faena le des al jitter mejor, ¿no? Menos tardará, irá más suelto, etc etc, etc. Y casi me apostaría un café a que lo único que hace el jitter es reordenar las instrucciones para aprovechar los pipelines y los predictores de salto (y posiblemente analizar qué instrucciones tardan menos en la máquina destino). Mucha inteligencia debería de tener un jitter para saber reordenar el bucle de menor a mayor y ponerlo de mayor a menor.

    De todos modos, tengo previsto hacer también pruebas de rendimiento… pero son muchas cosas las que tengo previsto hacer… así que al final no sé qué haré, ni cuándo ni como…

  6. El jitter hace muchas cosas 😉 Básicamente debería hacer lo mismo que el compilador, pero su lista de optimizaciones está mucho más reducida (por ejemplo, el inline de las funciones que cumplan ciertos requisitos).

    Ahora mismo el compilador de C# solo se encarga de eliminar código muerto y optimizar bifurcaciones. Todo el resto del trabajo se lo come el jitter. Y además si piensas en C# y no en C++ tiene su sentido, ya que en C# es probable que el propio programa genere código al vuelo. Si te basas en el compilador ese código no se optimizaría nada.

    Otra ventaja de optimizar el jitter es que el jitter lo comparten todos los lenguajes .NET, con lo cual en vez de mejorar 200 compiladores, mejoras solo 1 jitter 🙂

    Leete la especificación y verás la lista de optimizaciones que realiza el jitter en tiempo de ejecución, está bastante interesante 🙂

    Un saludo!

    Vicente

  7. Se me olvidaba, otra ventaja es que el jitter se basa en la máquina sobre la que corre, con lo cual puede optimizar específicamente para la arquitectura en concreto donde está funcionando (y con el compilador te tocaría recompilar).

    Un saludo!

    Vicente

  8. Hola Rafa,

    Primeramente, me he quedado impresionado por los conocimientos que tienes. Y por eso, te quiero hacer unas preguntas.

    Soy analista/programador se algo de c#, pero hace 3 meses ví que existia c++/cli. Me gusto mucho. Pero me hago la siguiente pregunta. ¿Quel será el futuro de c++/cli en España?. ¿Las empresas cuando empezaran a desarrollar en c++/cli?. En infojobs, solo he visto una oferta que lo piden.

    Es que tengo la sensación como que lo mismo estoy perdiendo el tiempo, y que luego todas las empresas siguen con c++(MFC) y c#. ¿es una apuesta arriesgada gastar tiempo en c++/cli?

    Gracias.

  9. Felix,

    No creo que en España desarrollen muchos en C++/CLI, de hecho el C++/CLI se está quedando como el «lenguaje ensamblador del .NET», algo así como ahora es el C respecto a Windows y C++.

    Pero no se desarrolla en C++/CLI porque tampoco se desarrolla mucho en MFC y Win32, en principio porque no hay empresas con el perfil adecuado… Ten en cuenta que en general se trabaja con bases de datos etc, y ciertamente el C++ y el C++/CLI son malos lenguajes para ello.

    Pero mi perfil es el de programador de sistemas para Windows mezclado con firmware: el C++/CLI es la solución ideal, no es un juguete de lenguaje (para mi trabajo, evidentemente) y te permite acelerar un montón el tiempo de desarrollo sin perder contacto con el bajo nivel y sin tener que hacer florituras para unar código en C++…

    Y ciertamente, sí que es, para un programador a secas, una apuesta arriesgada gastar tiempo con el C++/CLI.

  10. Hola Rafa,

    Muchas gracias por la respuesta. He programado con c++,mfc,c#,wpf,opengl,…pero noto o intuyo que las mfc se van a ir quedando obsoletas dejando pasaso a los Forms, y claro, ¿como se puedo programar para usar Forms? o c# o c++/cli, claro, 10 años aprox programando con c++ y 1 año con c#, pues cuando descubri c++/cli pues..como que me tira mas…en fin..me arriesgaré, ya con el tiempo lo sabremos…;-).

    ah! ¿sabes algun foro bueno sobre c++/cli?¿los de microsoft?

    Un saludo.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *