C # vs C: gran diferencia de rendimiento

94

Estoy encontrando enormes diferencias de rendimiento entre código similar en C anc C #.

El código C es:

#include <stdio.h>
#include <time.h>
#include <math.h>

main()
{
    int i;
    double root;

    clock_t start = clock();
    for (i = 0 ; i <= 100000000; i++){
        root = sqrt(i);
    }
    printf("Time elapsed: %f\n", ((double)clock() - start) / CLOCKS_PER_SEC);   

}

Y la C # (aplicación de consola) es:

using System;
using System.Collections.Generic;
using System.Text;

namespace ConsoleApplication2
{
    class Program
    {
        static void Main(string[] args)
        {
            DateTime startTime = DateTime.Now;
            double root;
            for (int i = 0; i <= 100000000; i++)
            {
                root = Math.Sqrt(i);
            }
            TimeSpan runTime = DateTime.Now - startTime;
            Console.WriteLine("Time elapsed: " + Convert.ToString(runTime.TotalMilliseconds/1000));
        }
    }
}

Con el código anterior, el C # se completa en 0.328125 segundos (versión de lanzamiento) y el C tarda 11.14 segundos en ejecutarse.

La c se está compilando en un ejecutable de Windows usando mingw.

Siempre he asumido que C / C ++ era más rápido o al menos comparable a C # .net. ¿Qué está causando exactamente que el C funcione 30 veces más lento?

EDITAR: Parece que el optimizador de C # estaba eliminando la raíz porque no se estaba utilizando. Cambié la asignación de raíz a root + = e imprimí el total al final. También compilé el C usando cl.exe con el indicador / O2 configurado para la velocidad máxima.

Los resultados son ahora: 3,75 segundos para C 2,61 segundos para C #

La C todavía está tardando más, pero esto es aceptable

Juan
fuente
18
Le sugiero que use un StopWatch en lugar de solo un DateTime.
Alex Fort
2
¿Qué banderas del compilador? ¿Ambos se compilan con optimizaciones habilitadas?
jalf
2
¿Qué pasa cuando usa -ffast-math con el compilador C ++?
Dan McClain
10
¡Qué pregunta tan fascinante!
Robert S.
4
Quizás la función sqrt de C no sea tan buena como esta en C #. Entonces no sería un problema con C, pero con la biblioteca adjunta. Pruebe algunos cálculos sin funciones matemáticas.
klew

Respuestas:

61

Como nunca usa 'root', es posible que el compilador haya eliminado la llamada para optimizar su método.

Puede intentar acumular los valores de la raíz cuadrada en un acumulador, imprimirlo al final del método y ver qué está pasando.

Editar: vea la respuesta de Jalf a continuación

Brann
fuente
1
Un poco de experimentación sugiere que este no es el caso. Se genera el código para el bucle, aunque quizás el tiempo de ejecución sea lo suficientemente inteligente como para omitirlo. Incluso acumulando, C # todavía le gana a los pantalones de C.
Dana
3
Parece que el problema está al otro lado. C # se comporta razonablemente en todos los casos. Su código C aparentemente está compilado sin optimizaciones
jalf
2
Muchos de ustedes están perdiendo el punto aquí. He estado leyendo muchos casos similares en los que c # supera a c / c ++ y siempre la refutación es emplear alguna optimización de nivel experto. El 99% de los programadores no tienen los conocimientos necesarios para utilizar estas técnicas de optimización solo para que su código se ejecute un poco más rápido que el código c #. Los casos de uso para c / c ++ se están reduciendo.
167

Debes comparar versiones de depuración. Acabo de compilar tu código C y obtuve

Time elapsed: 0.000000

Si no habilita las optimizaciones, cualquier evaluación comparativa que haga no tendrá ningún valor. (Y si habilita las optimizaciones, el ciclo se optimiza. Por lo tanto, su código de evaluación comparativa también tiene fallas. Debe forzarlo a ejecutar el ciclo, generalmente resumiendo el resultado o similar e imprimiéndolo al final).

Parece que lo que está midiendo es básicamente "qué compilador inserta la mayor sobrecarga de depuración". Y resulta que la respuesta es C. Pero eso no nos dice qué programa es más rápido. Porque cuando quieres velocidad, habilitas optimizaciones.

Por cierto, a la larga se ahorrará muchos dolores de cabeza si abandona la noción de que los idiomas son "más rápidos" que los demás. C # no tiene más velocidad que el inglés.

Hay ciertas cosas en el lenguaje C que serían eficientes incluso en un compilador ingenuo que no optimiza, y hay otras que dependen en gran medida de un compilador para optimizar todo. Y, por supuesto, lo mismo ocurre con C # o cualquier otro idioma.

La velocidad de ejecución está determinada por:

  • la plataforma en la que está ejecutando (sistema operativo, hardware, otro software que se ejecuta en el sistema)
  • el compilador
  • tu código fuente

Un buen compilador de C # producirá un código eficiente. Un compilador de C incorrecto generará código lento. ¿Qué pasa con un compilador C que generó código C #, que luego podría ejecutar a través de un compilador C #? ¿Qué tan rápido funcionaría eso? Los idiomas no tienen velocidad. Tu código lo hace.

jalf
fuente
Mucha más lectura interesante aquí: blogs.msdn.com/ricom/archive/2005/05/10/416151.aspx
Daniel Earwicker
18
Buena respuesta, pero no estoy de acuerdo con la velocidad del lenguaje, al menos en analogía: se ha descubierto que el galés es un idioma más lento que la mayoría debido a la alta frecuencia de las vocales largas. Además, las personas recuerdan mejor las palabras (y listas de palabras) si son más rápidas para decirlas. web.missouri.edu/~cowann/docs/articles/before%201993/… en.wikipedia.org/wiki/Vowel_length en.wikipedia.org/wiki/Welsh_language
exceptionerror
1
¿No depende eso de lo que estés diciendo en galés? Me parece poco probable que todo sea ​​más lento.
jalf
5
++ Hola chicos, no se desvíen aquí. Si el mismo programa se ejecuta más rápido en un idioma que en otro, es porque se genera un código ensamblador diferente. En este ejemplo en particular, el 99% o más del tiempo pasará a flotar i, y sqrteso es lo que se está midiendo.
Mike Dunlavey
116

Lo haré breve, ya está marcado como respondido. C # tiene la gran ventaja de tener un modelo de punto flotante bien definido. Eso simplemente coincide con el modo de operación nativo de las instrucciones FPU y SSE establecidas en procesadores x86 y x64. No es casualidad. JITter compila Math.Sqrt () en algunas instrucciones en línea.

Native C / C ++ está cargado de años de compatibilidad con versiones anteriores. Las opciones de compilación / fp: precisa, / fp: rápida y / fp: estricta son las más visibles. En consecuencia, debe llamar a una función CRT que implemente sqrt () y verifique las opciones de punto flotante seleccionadas para ajustar el resultado. Eso es lento.

Hans Passant
fuente
66
Esta es una extraña convicción entre los programadores de C ++, parecen pensar que el código máquina generado por C # es de alguna manera diferente del código máquina generado por un compilador nativo. Solo hay un tipo. Independientemente del conmutador del compilador gcc que utilice o del ensamblaje en línea que escriba, solo hay una instrucción FSQRT. No siempre es más rápido porque lo generó un idioma nativo, a la CPU no le importa.
Hans Passant
16
Eso es lo que resuelve pre-jitting con ngen.exe. Estamos hablando de C #, no de Java.
Hans Passant
20
@ user877329 - ¿en serio? Guau.
Andras Zoltan
7
No, el jitter x64 usa SSE. Math.Sqrt () se traduce a la instrucción del código de máquina sqrtsd.
Hans Passant
6
Aunque técnicamente no es una diferencia entre lenguajes, .net JITter realiza optimizaciones bastante limitadas en comparación con un compilador típico de C / C ++. Una de las mayores limitaciones es la falta de compatibilidad con SIMD, lo que hace que el código sea 4 veces más lento. No exponer muchos elementos intrínsecos también puede ser un gran problema, pero eso depende mucho de lo que estés haciendo.
CodesInChaos
57

Soy un desarrollador de C ++ y C #. He desarrollado aplicaciones C # desde la primera versión beta del framework .NET y tengo más de 20 años de experiencia en el desarrollo de aplicaciones C ++. En primer lugar, el código C # NUNCA será más rápido que una aplicación C ++, pero no pasaré por una larga discusión sobre el código administrado, cómo funciona, la capa inter-op, los componentes internos de la administración de memoria, el sistema de tipos dinámicos y el recolector de basura. Sin embargo, permítanme continuar diciendo que los puntos de referencia enumerados aquí producen resultados INCORRECTOS.

Déjame explicarte: lo primero que debemos considerar es el compilador JIT para C # (.NET Framework 4). Ahora, el JIT produce código nativo para la CPU utilizando varios algoritmos de optimización (que tienden a ser más agresivos que el optimizador C ++ predeterminado que viene con Visual Studio) y el conjunto de instrucciones utilizado por el compilador .NET JIT es un reflejo más cercano de la CPU real. en la máquina, por lo que se podrían realizar ciertas sustituciones en el código de la máquina para reducir los ciclos de reloj y mejorar la tasa de aciertos en la caché de la canalización de la CPU y producir más optimizaciones de hiperprocesamiento, como el reordenamiento de instrucciones y mejoras relacionadas con la predicción de rama.

Lo que esto significa es que a menos que compile su aplicación C ++ usando los parámetros correctos para la compilación RELEASE (no la compilación DEBUG), su aplicación C ++ puede funcionar más lentamente que la correspondiente aplicación basada en C # o .NET. Al especificar las propiedades del proyecto en su aplicación C ++, asegúrese de habilitar "optimización completa" y "favorecer el código rápido". Si tiene una máquina de 64 bits, DEBE especificar generar x64 como plataforma de destino; de lo contrario, su código se ejecutará a través de una subcapa de conversión (WOW64) que reducirá sustancialmente el rendimiento.

Una vez que realiza las optimizaciones correctas en el compilador, obtengo .72 segundos para la aplicación C ++ y 1.16 segundos para la aplicación C # (ambos en la versión de compilación). Dado que la aplicación C # es muy básica y asigna la memoria utilizada en el bucle en la pila y no en el montón, en realidad se está desempeñando mucho mejor que una aplicación real involucrada en objetos, cálculos pesados ​​y con conjuntos de datos más grandes. Entonces, las cifras proporcionadas son cifras optimistas sesgadas hacia C # y el marco .NET. Incluso con este sesgo, la aplicación C ++ se completa en poco más de la mitad del tiempo que la aplicación C # equivalente. Tenga en cuenta que el compilador de Microsoft C ++ que utilicé no tenía las optimizaciones correctas de canalización y subprocesamiento (usando WinDBG para ver las instrucciones de ensamblaje).

Ahora bien, si usamos el compilador Intel (que por cierto es un secreto de la industria para generar aplicaciones de alto rendimiento en procesadores AMD / Intel), el mismo código se ejecuta en .54 segundos para el ejecutable C ++ frente a .72 segundos usando Microsoft Visual Studio 2010 Entonces, al final, los resultados finales son .54 segundos para C ++ y 1.16 segundos para C #. Por tanto, el código producido por el compilador .NET JIT tarda un 214% más que el ejecutable de C ++. La mayor parte del tiempo invertido en los .54 segundos fue para obtener el tiempo del sistema y no dentro del bucle en sí.

Lo que también falta en las estadísticas son los tiempos de inicio y limpieza que no están incluidos en los tiempos. Las aplicaciones C # tienden a dedicar mucho más tiempo al inicio y finalización que las aplicaciones C ++. La razón detrás de esto es complicada y tiene que ver con las rutinas de validación de código en tiempo de ejecución .NET y el subsistema de administración de memoria que realiza mucho trabajo al principio (y en consecuencia, al final) del programa para optimizar las asignaciones de memoria y la basura. coleccionista.

Al medir el rendimiento de C ++ y .NET IL, es importante mirar el código ensamblador para asegurarse de que TODOS los cálculos estén ahí. Lo que encontré es que sin poner código adicional en C #, la mayor parte del código en los ejemplos anteriores se eliminó del binario. Este también fue el caso con C ++ cuando usó un optimizador más agresivo como el que viene con el compilador Intel C ++. Los resultados que proporcioné anteriormente son 100% correctos y están validados a nivel de ensamblaje.

El principal problema con muchos foros en Internet es que muchos novatos escuchan la propaganda de marketing de Microsoft sin comprender la tecnología y hacen afirmaciones falsas de que C # es más rápido que C ++. La afirmación es que, en teoría, C # es más rápido que C ++ porque el compilador JIT puede optimizar el código para la CPU. El problema con esta teoría es que existe una gran cantidad de plomería en el marco .NET que ralentiza el rendimiento; plomería que no existe en la aplicación C ++. Además, un desarrollador experimentado sabrá cuál es el compilador correcto para usar para la plataforma dada y usará los indicadores apropiados al compilar la aplicación. En las plataformas Linux o de código abierto, esto no es un problema porque puede distribuir su fuente y crear scripts de instalación que compilen el código utilizando la optimización adecuada. En Windows o plataforma de código cerrado, tendrá que distribuir varios ejecutables, cada uno con optimizaciones específicas. Los binarios de Windows que se implementarán se basan en la CPU detectada por el instalador de msi (mediante acciones personalizadas).

Ricardo
fuente
22
1. Microsoft nunca hizo esas afirmaciones de que C # es más rápido, sus afirmaciones son aproximadamente el 90% de la velocidad, más rápido de desarrollar (y por lo tanto más tiempo para ajustar) y más libre de errores debido a la seguridad de la memoria y los tipos. Todo lo cual es cierto (tengo 20 años en C ++ y 10 en C #) 2. El rendimiento de inicio no tiene sentido en la mayoría de los casos. 3. También hay compiladores de C # más rápidos como LLVM (por lo que sacar Intel no es Apples to Apples)
ben
13
El rendimiento inicial no es insignificante. Es muy importante en la mayoría de las aplicaciones empresariales basadas en web, razón por la cual Microsoft introdujo páginas web para ser precargadas (inicio automático) en .NET 4.0. Cuando el grupo de aplicaciones se recicla de vez en cuando, la primera vez que se cargue cada página agregará un retraso significativo para las páginas complejas y provocará tiempos de espera en el navegador.
Richard
8
Microsoft afirmó que el rendimiento de .NET es más rápido en material de marketing anterior. También hicieron varias afirmaciones sobre el recolector de basura que tuvieron poco o ningún impacto en el rendimiento. Algunas de estas afirmaciones se incluyeron en varios libros (sobre ASP.NET y .NET) en sus ediciones anteriores. Aunque Microsoft no dice específicamente que su aplicación de C # será más rápida que su aplicación de C ++, sí pueden incluir comentarios genéricos y eslóganes de marketing como "Just-In-Time significa Run-It-Fast" ( msdn.microsoft.com/ en-us / library / ms973894.aspx ).
Richard
71
-1, esta perorata está llena de declaraciones incorrectas y engañosas como la obvia "El código C # NUNCA será más rápido que una aplicación C ++"
BCoates
32
-1. Debería leer la batalla de rendimiento C # vs C de Rico Mariani vs Raymond Chen: blogs.msdn.com/b/ricom/archive/2005/05/16/418051.aspx . En resumen: uno de los tipos más inteligentes de Microsoft tuvo que optimizar mucho para hacer que la versión C sea más rápida que una simple versión C #.
Rolf Bjarne Kvinge
10

mi primera suposición es una optimización del compilador porque nunca usas root. Simplemente lo asigna y luego lo sobrescribe una y otra vez.

Editar: ¡maldita sea, batir por 9 segundos!

Neil N
fuente
2
Yo digo que tienes razón. La variable real se sobrescribe y nunca se usa más allá de eso. Lo más probable es que csc simplemente renunciara a todo el ciclo, mientras que el compilador de c ++ probablemente lo dejó. Una prueba más precisa sería acumular los resultados y luego imprimir ese resultado al final. Además, no se debe codificar de forma rígida el valor de semilla, sino dejar que lo defina el usuario. Esto no le daría al compilador de c # espacio para dejar cosas fuera.
7

Para ver si el bucle se está optimizando, intente cambiar su código a

root += Math.Sqrt(i);

ans de manera similar en el código C, y luego imprime el valor de root fuera del ciclo.


fuente
6

Tal vez el compilador de c # se da cuenta de que no usa root en ningún lugar, por lo que simplemente omite todo el ciclo for. :)

Puede que ese no sea el caso, pero sospecho que sea cual sea la causa, depende de la implementación del compilador. Intente compilar su programa en C con el compilador de Microsoft (cl.exe, disponible como parte de win32 sdk) con optimizaciones y modo de lanzamiento. Apuesto a que verá una mejora de rendimiento sobre el otro compilador.

EDITAR: No creo que el compilador pueda simplemente optimizar el bucle for, porque tendría que saber que Math.Sqrt () no tiene efectos secundarios.

i_am_jorf
fuente
2
Quizás lo sepa.
2
@Neil, @jeff: De acuerdo, podría saberlo con bastante facilidad. Dependiendo de la implementación, el análisis estático en Math.Sqrt () puede no ser tan difícil, aunque no estoy seguro de qué optimizaciones se realizan específicamente.
John Feminella
5

Cualquiera que sea la diferencia horaria. puede ser, ese "tiempo transcurrido" no es válido. Solo sería válido si puede garantizar que ambos programas se ejecuten exactamente en las mismas condiciones.

Quizás deberías intentar una victoria. equivalente a $ / usr / bin / time my_cprog; / usr / bin / time my_csprog

Tom
fuente
1
¿Por qué se vota negativamente? ¿Alguien está asumiendo que las interrupciones y los cambios de contexto no afectan el rendimiento? ¿Alguien puede hacer suposiciones sobre errores de TLB, intercambio de páginas, etc.?
Tom
5

Reuní (según su código) dos pruebas más comparables en C y C #. Estos dos escriben una matriz más pequeña usando el operador de módulo para indexar (agrega un poco de sobrecarga, pero bueno, estamos tratando de comparar el rendimiento [a un nivel básico]).

Código C:

#include <stdlib.h>
#include <stdio.h>
#include <time.h>
#include <math.h>

void main()
{
    int count = (int)1e8;
    int subcount = 1000;
    double* roots = (double*)malloc(sizeof(double) * subcount);
    clock_t start = clock();
    for (int i = 0 ; i < count; i++)
    {
        roots[i % subcount] = sqrt((double)i);
    }
    clock_t end = clock();
    double length = ((double)end - start) / CLOCKS_PER_SEC;
    printf("Time elapsed: %f\n", length);
}

C ª#:

using System;

namespace CsPerfTest
{
    class Program
    {
        static void Main(string[] args)
        {
            int count = (int)1e8;
            int subcount = 1000;
            double[] roots = new double[subcount];
            DateTime startTime = DateTime.Now;
            for (int i = 0; i < count; i++)
            {
                roots[i % subcount] = Math.Sqrt(i);
            }
            TimeSpan runTime = DateTime.Now - startTime;
            Console.WriteLine("Time elapsed: " + Convert.ToString(runTime.TotalMilliseconds / 1000));
        }
    }
}

Estas pruebas escriben datos en una matriz (por lo que no se debe permitir que el tiempo de ejecución de .NET elimine la operación sqrt) aunque la matriz es significativamente más pequeña (no quería usar memoria excesiva). Los compilé en la configuración de lanzamiento y los ejecuté desde dentro de una ventana de consola (en lugar de comenzar a través de VS).

En mi computadora, el programa C # varía entre 6.2 y 6.9 segundos, mientras que la versión C varía entre 6.9 y 7.1.

Cecil tiene un nombre
fuente
5

Si solo realiza un solo paso del código a nivel de ensamblador, incluido el paso a través de la rutina de raíz cuadrada, probablemente obtendrá la respuesta a su pregunta.

No hay necesidad de adivinar.

Mike Dunlavey
fuente
Me gustaría saber cómo hacer esto
Josh Stodola
Depende de su IDE o depurador. Romper al comienzo del pgm. Muestre la ventana de desmontaje y comience a realizar un solo paso. Si usa GDB, hay comandos para avanzar una instrucción a la vez.
Mike Dunlavey
Ahora que es un buen consejo, esto ayuda a entender mucho más lo que realmente está sucediendo allí. ¿Eso también muestra optimizaciones JIT como inlining y tail calls?
gjvdkamp
FYI: para mí, esto mostró VC ++ usando fadd y fsqrt, mientras que C # usó cvtsi2sd y sqrtsd que, según tengo entendido, son instrucciones SSE2 y, por lo tanto, considerablemente más rápidas donde se admiten.
danio
2

El otro factor que puede ser un problema aquí es que el compilador de C compila en código nativo genérico para la familia de procesadores a la que se dirige, mientras que el MSIL generado cuando compiló el código C # luego se compila con JIT para apuntar al procesador exacto que tiene completo con cualquier optimizaciones que puedan ser posibles. Por lo tanto, el código nativo generado a partir de C # puede ser considerablemente más rápido que el de C.

David M
fuente
En teoría, sí. En la práctica, eso prácticamente nunca marca una diferencia mensurable. Un por ciento o dos, quizás, si tienes suerte.
jalf
o - si tiene cierto tipo de código que usa extensiones que no están en la lista permitida para el procesador 'genérico'. Cosas como sabores SSE. Pruebe con el objetivo del procesador configurado más alto, para ver qué diferencias obtiene.
gbjbaanb
1

Me parece que esto no tiene nada que ver con los lenguajes en sí, sino con las diferentes implementaciones de la función de raíz cuadrada.

Jack Ryan
fuente
Dudo mucho que las diferentes implementaciones de sqrt causen tanta disparidad.
Alex Fort
Sobre todo porque, incluso en C #, la mayoría de las funciones matemáticas todavía se consideran críticas para el rendimiento y se implementan como tales.
Matthew Olenik
fsqrt es una instrucción de procesador IA-32, por lo que la implementación del lenguaje es irrelevante en estos días.
No estoy seguro
Ingrese a la función sqrt de MSVC con un depurador. Hace mucho más que ejecutar la instrucción fsqrt.
bk1e
1

En realidad, chicos, el bucle NO se está optimizando. Compilé el código de John y examiné el .exe resultante. Las entrañas del bucle son las siguientes:

 IL_0005:  stloc.0
 IL_0006:  ldc.i4.0
 IL_0007:  stloc.1
 IL_0008:  br.s       IL_0016
 IL_000a:  ldloc.1
 IL_000b:  conv.r8
 IL_000c:  call       float64 [mscorlib]System.Math::Sqrt(float64)
 IL_0011:  pop
 IL_0012:  ldloc.1
 IL_0013:  ldc.i4.1
 IL_0014:  add
 IL_0015:  stloc.1
 IL_0016:  ldloc.1
 IL_0017:  ldc.i4     0x5f5e100
 IL_001c:  ble.s      IL_000a

¿A menos que el tiempo de ejecución sea lo suficientemente inteligente como para darse cuenta de que el bucle no hace nada y lo omite?

Editar: Cambiar el C # para que sea:

 static void Main(string[] args)
 {
      DateTime startTime = DateTime.Now;
      double root = 0.0;
      for (int i = 0; i <= 100000000; i++)
      {
           root += Math.Sqrt(i);
      }
      System.Console.WriteLine(root);
      TimeSpan runTime = DateTime.Now - startTime;
      Console.WriteLine("Time elapsed: " +
          Convert.ToString(runTime.TotalMilliseconds / 1000));
 }

Resultados en el tiempo transcurrido (en mi máquina) pasando de 0.047 a 2.17. Pero, ¿es eso solo la sobrecarga de agregar 100 millones de operadores adicionales?

Dana
fuente
3
Mirar el IL no le dice mucho sobre las optimizaciones porque aunque el compilador de C # hace algunas cosas como el plegado constante y la eliminación de código muerto, el IL se hace cargo y hace el resto en el momento de la carga.
Daniel Earwicker
Eso es lo que pensé que podría ser el caso. Sin embargo, incluso forzándolo a funcionar, sigue siendo 9 segundos más rápido que la versión C. (No hubiera esperado eso en absoluto)
Dana