¿El lenguaje ensamblador en línea es más lento que el código nativo de C ++?

183

Traté de comparar el rendimiento del lenguaje ensamblador en línea y el código C ++, así que escribí una función que agrega dos matrices de tamaño 2000 por 100000 veces. Aquí está el código:

#define TIMES 100000
void calcuC(int *x,int *y,int length)
{
    for(int i = 0; i < TIMES; i++)
    {
        for(int j = 0; j < length; j++)
            x[j] += y[j];
    }
}


void calcuAsm(int *x,int *y,int lengthOfArray)
{
    __asm
    {
        mov edi,TIMES
        start:
        mov esi,0
        mov ecx,lengthOfArray
        label:
        mov edx,x
        push edx
        mov eax,DWORD PTR [edx + esi*4]
        mov edx,y
        mov ebx,DWORD PTR [edx + esi*4]
        add eax,ebx
        pop edx
        mov [edx + esi*4],eax
        inc esi
        loop label
        dec edi
        cmp edi,0
        jnz start
    };
}

Aquí está main():

int main() {
    bool errorOccured = false;
    setbuf(stdout,NULL);
    int *xC,*xAsm,*yC,*yAsm;
    xC = new int[2000];
    xAsm = new int[2000];
    yC = new int[2000];
    yAsm = new int[2000];
    for(int i = 0; i < 2000; i++)
    {
        xC[i] = 0;
        xAsm[i] = 0;
        yC[i] = i;
        yAsm[i] = i;
    }
    time_t start = clock();
    calcuC(xC,yC,2000);

    //    calcuAsm(xAsm,yAsm,2000);
    //    for(int i = 0; i < 2000; i++)
    //    {
    //        if(xC[i] != xAsm[i])
    //        {
    //            cout<<"xC["<<i<<"]="<<xC[i]<<" "<<"xAsm["<<i<<"]="<<xAsm[i]<<endl;
    //            errorOccured = true;
    //            break;
    //        }
    //    }
    //    if(errorOccured)
    //        cout<<"Error occurs!"<<endl;
    //    else
    //        cout<<"Works fine!"<<endl;

    time_t end = clock();

    //    cout<<"time = "<<(float)(end - start) / CLOCKS_PER_SEC<<"\n";

    cout<<"time = "<<end - start<<endl;
    return 0;
}

Luego ejecuto el programa cinco veces para obtener los ciclos del procesador, que podrían verse como el tiempo. Cada vez que llamo a una de las funciones mencionadas anteriormente solamente.

Y aquí viene el resultado.

Función de la versión de montaje:

Debug   Release
---------------
732        668
733        680
659        672
667        675
684        694
Average:   677

Función de la versión C ++:

Debug     Release
-----------------
1068      168
 999      166
1072      231
1002      166
1114      183
Average:  182

El código C ++ en modo de lanzamiento es casi 3,7 veces más rápido que el código ensamblador. ¿Por qué?

Supongo que el código de ensamblaje que escribí no es tan efectivo como los generados por GCC. Es difícil para un programador común como yo escribir código más rápido que su oponente generado por un compilador. ¿Eso significa que no debería confiar en el rendimiento del lenguaje ensamblador escrito por mis manos, centrarme en C ++ y olvidarme del lenguaje ensamblador?

usuario957121
fuente
29
Más o menos. El ensamblaje codificado a mano es apropiado en algunas circunstancias, pero se debe tener cuidado para garantizar que la versión de ensamblaje sea realmente más rápida de lo que se puede lograr con un lenguaje de nivel superior.
Magnus Hoff
161
Puede resultarle instructivo estudiar el código generado por el compilador e intentar entender por qué es más rápido que su versión de ensamblaje.
Paul R
34
Sí, parece que el compilador es mejor escribiendo asm que tú. Los compiladores modernos realmente son bastante buenos.
David Heffernan
20
¿Has mirado la asamblea producida por GCC? Su posible GCC usó instrucciones MMX. Su función es muy paralela: potencialmente podría usar procesadores N para calcular la suma en 1 / N th el tiempo. Pruebe una función donde no hay esperanza de paralelización.
Chris
11
Hm, hubiera esperado que un buen compilador hiciera esto ~ 100000 veces más rápido ...
PlasmaHH

Respuestas:

261

Si, la mayoría de las veces.

En primer lugar, comienza con la suposición errónea de que un lenguaje de bajo nivel (ensamblado en este caso) siempre producirá un código más rápido que un lenguaje de alto nivel (C ++ y C en este caso). No es verdad. ¿El código C es siempre más rápido que el código Java? No porque hay otra variable: programador. La forma en que escribe el código y el conocimiento de los detalles de la arquitectura influyen enormemente en el rendimiento (como vio en este caso).

Puede siempre producirá un ejemplo en el código de montaje hecho a mano es mejor que el código compilado, pero por lo general es un ejemplo ficticio o una única rutina no es un verdadero programa de 500.000+ líneas de código C ++). Creo que los compiladores producirán un mejor código de ensamblaje 95% de veces y , a veces, solo algunas veces, es posible que deba escribir código de ensamblaje para pocas, cortas, altamente utilizadas , rutinas críticas de rendimiento o cuando tenga que acceder a las funciones de su lenguaje de alto nivel favorito No expone. ¿Quieres un toque de esta complejidad? Lea esta increíble respuesta aquí en SO.

¿Por qué esto?

En primer lugar, porque los compiladores pueden hacer optimizaciones que ni siquiera podemos imaginar (vea esta breve lista ) y las harán en segundos (cuando necesitemos días ).

Cuando codifica en ensamblado, debe realizar funciones bien definidas con una interfaz de llamada bien definida. Sin embargo, pueden tener en cuenta la optimización de todo el programa y la optimización entre procedimientos , como la asignación de registros , la propagación constante , la eliminación de subexpresiones comunes , la programación de instrucciones y otras optimizaciones complejas y no obvias ( modelo de Polytope , por ejemplo). En la arquitectura RISC , los muchachos dejaron de preocuparse por esto hace muchos años (la programación de instrucciones, por ejemplo, es muy difícil de ajustar a mano ) y las CPU CISC modernas tienen tuberías muy largas también.

Para algunos microcontroladores complejos, incluso las bibliotecas del sistema se escriben en C en lugar de ensamblar porque sus compiladores producen un código final mejor (y fácil de mantener).

Los compiladores a veces pueden usar automáticamente algunas instrucciones MMX / SIMDx por sí mismas, y si no las usa, simplemente no puede comparar (otras respuestas ya revisaron muy bien su código de ensamblaje). Solo para bucles, esta es una breve lista de optimizaciones de bucle de lo que comúnmente comprueba un compilador (¿cree que podría hacerlo usted mismo cuando se haya decidido su programación para un programa C #?) Si escribe algo en conjunto, I cree que debe considerar al menos algunas optimizaciones simples . El ejemplo de libro escolar para matrices es desenrollar el ciclo (su tamaño se conoce en tiempo de compilación). Hazlo y ejecuta tu prueba nuevamente.

En estos días también es muy poco frecuente que necesite usar lenguaje ensamblador por otra razón: la gran cantidad de CPU diferentes . ¿Quieres apoyarlos a todos? Cada uno tiene una microarquitectura específica y algunos conjuntos de instrucciones específicas . Tienen un número diferente de unidades funcionales y las instrucciones de montaje deben organizarse para mantenerlos a todos ocupados . Si escribe en C, puede usar PGO, pero en el ensamblaje necesitará un gran conocimiento de esa arquitectura específica (y repensar y rehacer todo para otra arquitectura ). Para tareas pequeñas, el compilador generalmente lo hace mejor, y para tareas complejas, generalmente el trabajo no se paga (ycompilador puede hacerlo mejor de todos modos).

Si te sientas y lees tu código, probablemente verás que ganarás más para rediseñar tu algoritmo que para traducirlo al ensamblaje (lee esta gran publicación aquí en SO ), hay optimizaciones de alto nivel (y sugerencias para el compilador) que puede aplicar de manera efectiva antes de que necesite recurrir al lenguaje ensamblador. Probablemente valga la pena mencionar que, a menudo, utilizando intrínsecos, obtendrá el aumento de rendimiento que está buscando y el compilador aún podrá realizar la mayoría de sus optimizaciones.

Dicho todo esto, incluso cuando puede producir un código de ensamblaje 5 a 10 veces más rápido, debe preguntar a sus clientes si prefieren pagar una semana de su tiempo o comprar una CPU 50 $ más rápida . La mayoría de nosotros simplemente no necesita una optimización extrema (y especialmente en aplicaciones LOB).

Adriano Repetti
fuente
9
Por supuesto no. Creo que es mejor para el 95% de las personas en el 99% de las veces. A veces porque es simplemente costoso (debido a las matemáticas complejas ) o el gasto de tiempo (luego costoso nuevamente). A veces porque simplemente nos olvidamos de las optimizaciones ...
Adriano Repetti
62
@ ja72: no, no es mejor escribir código. Es mejor para optimizar el código.
Mike Baranczak
14
Es contra-intuitivo hasta que realmente lo consideres. Del mismo modo, las máquinas basadas en VM están comenzando a hacer optimizaciones de tiempo de ejecución que los compiladores simplemente no tienen la información para hacer.
Bill K
66
@ M28: los compiladores pueden usar las mismas instrucciones. Claro, lo pagan en términos de tamaño binario (porque tienen que proporcionar una ruta alternativa en caso de que esas instrucciones no sean compatibles). Además, en su mayor parte, las "nuevas instrucciones" que se agregarían son instrucciones SMID de todos modos, que tanto las máquinas virtuales como los compiladores son bastante horribles de utilizar. Las máquinas virtuales pagan por esta función porque tienen que compilar el código al inicio.
Billy ONeal
9
@ Bill: PGO hace lo mismo para los compiladores.
Billy ONeal
194

Su código de ensamblaje es subóptimo y puede mejorarse:

  • Está presionando y haciendo estallar un registro ( EDX ) en su bucle interno. Esto debería sacarse del bucle.
  • Vuelva a cargar los punteros de la matriz en cada iteración del bucle. Esto debería salir del bucle.
  • Utiliza la loopinstrucción, que se sabe que es muy lenta en la mayoría de las CPU modernas (posiblemente como resultado de usar un antiguo libro de ensamblaje *)
  • No aprovecha el desenrollado manual del bucle.
  • No utiliza las instrucciones SIMD disponibles .

Entonces, a menos que mejore enormemente su conjunto de habilidades con respecto al ensamblador, no tiene sentido que escriba código de ensamblador para el rendimiento.

* Por supuesto, no sé si realmente recibió las loopinstrucciones de un antiguo libro de ensamblaje. Pero casi nunca lo ves en el código del mundo real, ya que todos los compiladores son lo suficientemente inteligentes como para no emitir loop, solo lo ves en los libros malos y obsoletos de mi humilde opinión.

Gunther Piez
fuente
los compiladores aún pueden emitir loop(y muchas instrucciones "obsoletas") si optimiza el tamaño
phuclv
1
@phuclv bueno, sí, pero la pregunta original era exactamente sobre la velocidad, no sobre el tamaño.
IGR94
60

Incluso antes de profundizar en el ensamblaje, hay transformaciones de código que existen en un nivel superior.

static int const TIMES = 100000;

void calcuC(int *x, int *y, int length) {
  for (int i = 0; i < TIMES; i++) {
    for (int j = 0; j < length; j++) {
      x[j] += y[j];
    }
  }
}

se puede transformar a través de Loop Rotation :

static int const TIMES = 100000;

void calcuC(int *x, int *y, int length) {
    for (int j = 0; j < length; ++j) {
      for (int i = 0; i < TIMES; ++i) {
        x[j] += y[j];
      }
    }
}

que es mucho mejor en lo que respecta a la localidad de memoria.

Esto podría optimizarse aún más, hacer a += bX veces es equivalente a hacerlo, a += X * bpor lo que obtenemos:

static int const TIMES = 100000;

void calcuC(int *x, int *y, int length) {
    for (int j = 0; j < length; ++j) {
      x[j] += TIMES * y[j];
    }
}

Sin embargo, parece que mi optimizador favorito (LLVM) no realiza esta transformación.

[editar] Descubrí que la transformación se realiza si teníamos el restrictcalificador para xy y. De hecho, sin esta restricción, x[j]y y[j]podría alias a la misma ubicación que hace que esta transformación sea errónea. [final de edición]

De todos modos, esta es, creo, la versión C optimizada. Ya es mucho más simple. En base a esto, aquí está mi crack en ASM (dejo que Clang lo genere, soy inútil):

calcuAsm:                               # @calcuAsm
.Ltmp0:
    .cfi_startproc
# BB#0:
    testl   %edx, %edx
    jle .LBB0_2
    .align  16, 0x90
.LBB0_1:                                # %.lr.ph
                                        # =>This Inner Loop Header: Depth=1
    imull   $100000, (%rsi), %eax   # imm = 0x186A0
    addl    %eax, (%rdi)
    addq    $4, %rsi
    addq    $4, %rdi
    decl    %edx
    jne .LBB0_1
.LBB0_2:                                # %._crit_edge
    ret
.Ltmp1:
    .size   calcuAsm, .Ltmp1-calcuAsm
.Ltmp2:
    .cfi_endproc

Me temo que no entiendo de dónde provienen todas esas instrucciones, sin embargo, siempre puede divertirse e intentar ver cómo se compara ... pero todavía usaría la versión C optimizada en lugar de la de ensamblaje, en código, Mucho más portátil.

Matthieu M.
fuente
Gracias por su respuesta. Bueno, es un poco confuso que cuando tomé la clase llamada "Principios del compilador", aprendí que el compilador optimizará nuestro código por muchos medios. ¿Eso significa que necesitamos optimizar nuestro código manualmente? ¿Podemos hacer un mejor trabajo que el compilador? Esa es la pregunta que siempre me confunde.
user957121
2
@ user957121: podemos optimizarlo mejor cuando tenemos más información. Específicamente aquí, lo que dificulta al compilador es el posible alias entre xy y. Es decir, el compilador no puede estar seguro de que para todos i,jen la [0, length)que tiene x + i != y + j. Si hay superposición, entonces la optimización es imposible. El lenguaje C introdujo la restrictpalabra clave para decirle al compilador que dos punteros no pueden tener alias, sin embargo, no funciona para las matrices porque aún pueden superponerse incluso si no tienen exactamente un alias.
Matthieu M.
El GCC actual y Clang se auto-vectorizan (después de verificar si no se superponen si se omite __restrict). SSE2 es la línea de base para x86-64, y con la combinación aleatoria, SSE2 puede hacer 2x multiplicaciones de 32 bits a la vez (produciendo productos de 64 bits, de ahí la combinación para volver a unir los resultados). godbolt.org/z/r7F_uo . (Se necesita SSE4.1 para pmulld: 32x32 empaquetado => multiplicación de 32 bits). GCC tiene un buen truco para convertir multiplicadores enteros constantes en shift / add (y / o restar), lo cual es bueno para multiplicadores con pocos bits establecidos. El código aleatorio de Clang va a obstaculizar el rendimiento aleatorio en las CPU de Intel.
Peter Cordes
41

Respuesta corta: sí.

Respuesta larga: sí, a menos que realmente sepa lo que está haciendo y tenga una razón para hacerlo.

Oliver Charlesworth
fuente
3
y luego solo si ha ejecutado una herramienta de creación de perfiles de nivel de ensamblaje como vtune para chips de inteligencia para ver dónde puede mejorar las cosas
Mark Mullin
1
Esto técnicamente responde a la pregunta, pero también es completamente inútil. A -1 de mi parte.
Navin
2
Respuesta muy larga: "Sí, a menos que tenga ganas de cambiar todo el código cada vez que se utiliza una nueva (er) CPU. Elija el mejor algoritmo, pero deje que el compilador haga la optimización"
Tommylee2k
35

He arreglado mi código asm:

  __asm
{   
    mov ebx,TIMES
 start:
    mov ecx,lengthOfArray
    mov esi,x
    shr ecx,1
    mov edi,y
label:
    movq mm0,QWORD PTR[esi]
    paddd mm0,QWORD PTR[edi]
    add edi,8
    movq QWORD PTR[esi],mm0
    add esi,8
    dec ecx 
    jnz label
    dec ebx
    jnz start
};

Resultados para la versión de lanzamiento:

 Function of assembly version: 81
 Function of C++ version: 161

El código de ensamblaje en modo de lanzamiento es casi 2 veces más rápido que el C ++.

Sasha
fuente
18
Ahora, si comienza a usar SSE en lugar de MMX (el nombre de registro es en xmm0lugar de mm0), obtendrá otra aceleración por un factor de dos ;-)
Gunther Piez
8
Cambié, obtuve 41 para la versión de ensamblaje. Es 4 veces más rápido :)
sasha
3
también puede obtener hasta un 5% más si usa todos los registros xmm
sasha
77
Ahora, si piensa en el tiempo que realmente le tomó: ¿ensamblaje, aproximadamente 10 horas más o menos? C ++, ¿unos minutos, supongo? Aquí hay un claro ganador, a menos que sea un código crítico para el rendimiento.
Calimo
1
Un buen compilador ya se vectorizará automáticamente con paddd xmm(después de verificar la superposición entre xy y, porque no lo usó int *__restrict x). Por ejemplo, gcc hace eso: godbolt.org/z/c2JG0- . O después de ingresar main, no debería ser necesario verificar la superposición porque puede ver la asignación y demostrar que no se superponen. (Y supondría una alineación de 16 bytes en algunas implementaciones x86-64, lo que no es el caso para la definición independiente). Y si compila gcc -O3 -march=native, puede obtener 256 bits o 512 bits vectorización
Peter Cordes
24

¿Eso significa que no debería confiar en el rendimiento del lenguaje ensamblador escrito por mis manos?

Sí, eso es exactamente lo que significa, y es cierto para todos los idiomas. Si no sabe cómo escribir código eficiente en el lenguaje X, entonces no debe confiar en su capacidad para escribir código eficiente en X. Por lo tanto, si desea un código eficiente, debe usar otro idioma.

El ensamblaje es particularmente sensible a esto, porque, bueno, lo que ves es lo que obtienes. Escribe las instrucciones específicas que desea que ejecute la CPU. Con lenguajes de alto nivel, hay un compilador entre ellos, que puede transformar su código y eliminar muchas ineficiencias. Con el montaje, estás solo.

jalf
fuente
2
Creo que es para escribir que, especialmente para un procesador x86 moderno, es excepcionalmente difícil escribir un código de ensamblaje eficiente debido a la presencia de tuberías, unidades de ejecución múltiple y otros trucos dentro de cada núcleo. Escribir código que equilibre el uso de todos estos recursos para obtener la mayor velocidad de ejecución a menudo dará como resultado un código con una lógica directa que "no debería" ser rápido de acuerdo con la sabiduría de ensamblaje "convencional". Pero para las CPU menos complejas, según mi experiencia, la generación de código del compilador C se puede mejorar significativamente.
Olof Forshell
44
El código de los compiladores de C puede normalmente ser inmejorable, incluso en una CPU x86 moderna. Pero debe comprender bien la CPU, que es más difícil de hacer con una CPU x86 moderna. Ese es mi punto. Si no comprende el hardware al que se dirige, no podrá optimizarlo. Y luego el compilador probablemente hará un mejor trabajo
jalf
1
Y si realmente quiere volar el compilador, debe ser creativo y optimizar de manera que el compilador no pueda. Es una compensación por tiempo / recompensa, por eso C es un lenguaje de script para algunos y un código intermedio para un lenguaje de nivel superior para otros. Para mí, sin embargo, el montaje es más por diversión :). al igual que grc.com/smgassembly.htm
Hawken
22

La única razón para usar el lenguaje ensamblador hoy en día es usar algunas funciones a las que el lenguaje no tiene acceso.

Esto aplica a:

  • Programación del kernel que necesita acceder a ciertas funciones de hardware, como la MMU
  • Programación de alto rendimiento que utiliza instrucciones vectoriales o multimedia muy específicas que su compilador no admite.

Pero los compiladores actuales son bastante inteligentes, incluso pueden reemplazar dos declaraciones separadas, como d = a / b; r = a % b;con una sola instrucción que calcula la división y el resto de una vez si está disponible, incluso si C no tiene dicho operador.

fortran
fuente
10
Hay otros lugares para ASM además de esos dos. Es decir, una biblioteca bignum generalmente será significativamente más rápida en ASM que en C, debido a que tiene acceso para llevar banderas y la parte superior de la multiplicación y demás. También puedes hacer estas cosas en C portátil, pero son muy lentas.
Mooing Duck
@MooingDuck Eso podría considerarse como acceder a funciones de hardware de hardware que no están directamente disponibles en el idioma ... Pero siempre que solo esté traduciendo su código de alto nivel al ensamblaje a mano, el compilador lo superará.
fortran
1
es eso, pero no es la programación del núcleo, ni el proveedor específico. Aunque con ligeros cambios de trabajo, fácilmente podría caer en cualquier categoría. Supongo que ASM cuando desea el rendimiento de las instrucciones del procesador que no tienen asignación de C.
Mooing Duck
1
@fortran Básicamente solo estás diciendo que si no optimizas tu código, no será tan rápido como el código optimizado por el compilador. La optimización es la razón por la que uno escribiría ensamblaje en primer lugar. Si te refieres a traducir, entonces optimiza, no hay razón para que el compilador te gane a menos que no seas bueno para optimizar el ensamblaje. Entonces, para vencer al compilador, debe optimizar de maneras que el compilador no puede. Es bastante autoexplicativo. La única razón para escribir ensamblado es si eres mejor que un compilador / intérprete . Esa siempre ha sido la razón práctica para escribir ensamblaje.
Hawken
1
Solo digo: Clang tiene acceso a las banderas de acarreo, multiplicación de 128 bits, etc. a través de funciones incorporadas. Y puede integrar todo esto en sus algoritmos de optimización normales.
gnasher729
19

Es cierto que un compilador moderno hace un trabajo increíble en la optimización del código, pero aún así lo alentaría a que siga aprendiendo ensamblaje.

En primer lugar, claramente no está intimidado por eso , eso es una gran ventaja, a continuación: está en el camino correcto al realizar un perfil para validar o descartar sus suposiciones de velocidad , está solicitando la opinión de personas experimentadas y usted tener la mejor herramienta de optimización conocida por la humanidad: un cerebro .

A medida que aumente su experiencia, aprenderá cuándo y dónde usarlo (por lo general, los bucles más íntimos y ajustados de su código, después de haber optimizado profundamente a nivel algorítmico).

Para inspirarte, te recomendaría que busques los artículos de Michael Abrash (si no has tenido noticias suyas, él es un gurú de la optimización; ¡incluso colaboró ​​con John Carmack en la optimización del procesador de software Quake!)

"No existe el código más rápido" - Michael Abrash


fuente
2
Creo que uno de los libros de Michael Abrash es el libro negro de programación de gráficos. Pero él no es el único en usar el ensamblaje, Chris Sawyer escribió los dos primeros juegos de magnates de la montaña rusa en ensamblaje solo.
Hawken
14

He cambiado el código asm:

 __asm
{ 
    mov ebx,TIMES
 start:
    mov ecx,lengthOfArray
    mov esi,x
    shr ecx,2
    mov edi,y
label:
    mov eax,DWORD PTR [esi]
    add eax,DWORD PTR [edi]
    add edi,4   
    dec ecx 
    mov DWORD PTR [esi],eax
    add esi,4
    test ecx,ecx
    jnz label
    dec ebx
    test ebx,ebx
    jnz start
};

Resultados para la versión de lanzamiento:

 Function of assembly version: 41
 Function of C++ version: 161

El código de ensamblaje en modo de lanzamiento es casi 4 veces más rápido que el C ++. IMHo, la velocidad del código de ensamblaje depende del programador

Sasha
fuente
Sí, mi código realmente necesita ser optimizado. ¡Buen trabajo para ti y gracias!
user957121
55
Es cuatro veces más rápido porque solo haces una cuarta parte del trabajo :-) El shr ecx,2es superfluo, porque la longitud de la matriz ya está dada inty no en byte. Entonces básicamente logras la misma velocidad. Puede probar la padddrespuesta de Harolds, esto realmente será más rápido.
Gunther Piez
13

¡Es un tema muy interesante!
He cambiado el MMX por SSE en el código de Sasha.
Aquí están mis resultados:

Function of C++ version:      315
Function of assembly(simply): 312
Function of assembly  (MMX):  136
Function of assembly  (SSE):  62

El código de ensamblaje con SSE es 5 veces más rápido que el C ++

salaoshi
fuente
12

La mayoría de los compiladores de idiomas de alto nivel están muy optimizados y saben lo que están haciendo. Puede intentar volcar el código de desmontaje y compararlo con su ensamblaje nativo. Creo que verá algunos buenos trucos que está utilizando su compilador.

Solo por ejemplo, incluso si ya no estoy seguro de que sea correcto :):

Haciendo:

mov eax,0

cuestan más ciclos que

xor eax,eax

que hace lo mismo

El compilador conoce todos estos trucos y los usa.

Nuno_147
fuente
44
Sigue siendo cierto, consulte stackoverflow.com/questions/1396527/… . No por los ciclos utilizados, sino por la huella de memoria reducida.
Gunther Piez
10

El compilador te ganó. Lo intentaré, pero no haré ninguna garantía. Voy a suponer que la "multiplicación" de veces que se pretende que sea una prueba de rendimiento más relevante, que yy xestán alineados-16, y que lengthes un múltiplo no cero de 4. Eso es probablemente todo es verdad de todos modos.

  mov ecx,length
  lea esi,[y+4*ecx]
  lea edi,[x+4*ecx]
  neg ecx
loop:
  movdqa xmm0,[esi+4*ecx]
  paddd xmm0,[edi+4*ecx]
  movdqa [edi+4*ecx],xmm0
  add ecx,4
  jnz loop

Como dije, no hago garantías. Pero me sorprendería si se puede hacer mucho más rápido: el cuello de botella aquí es el rendimiento de la memoria, incluso si todo es un golpe L1.

harold
fuente
Creo que el direccionamiento complejo está ralentizando su código, si cambia el código a mov ecx, length, lea ecx,[ecx*4], mov eax,16... add ecx,eaxy luego usa [esi + ecx] en todas partes, evitará un bloqueo de ciclo por instrucción que acelere los lotes de bucle. (Si tiene la última versión de Skylake, esto no se aplica). Add reg, reg simplemente hace que el bucle sea más estricto, lo que puede o no ayudar.
Johan
@Johan, eso no debería ser un bloqueo, solo una latencia de ciclo adicional, pero seguro que no puede hacer daño no tenerlo ... Escribí este código para Core2 que no tenía ese problema. ¿No es r + r también "complejo" por cierto?
Harold
7

Simplemente implementar ciegamente el mismo algoritmo, instrucción por instrucción, en el ensamblaje se garantiza que será más lento de lo que el compilador puede hacer.

Esto se debe a que incluso la optimización más pequeña que realiza el compilador es mejor que su código rígido sin ninguna optimización.

Por supuesto, es posible superar el compilador, especialmente si es una parte pequeña y localizada del código, incluso tuve que hacerlo yo mismo para obtener un aprox. Se acelera 4 veces, pero en este caso tenemos que confiar en gran medida en el buen conocimiento del hardware y en numerosos trucos aparentemente contraintuitivos.

vsz
fuente
3
Creo que esto depende del idioma y el compilador. Me imagino un compilador de C extremadamente ineficiente cuya salida podría ser fácilmente superada por un ensamblaje sencillo de escritura humana. El CCG, no tanto.
Casey Rodarmor
Dado que los compiladores de C / ++ son una tarea de este tipo, y solo 3 importantes, tienden a ser bastante buenos en lo que hacen. Todavía es (muy) posible en ciertas circunstancias que el ensamblaje escrito a mano sea más rápido; una gran cantidad de bibliotecas matemáticas caen a asm para manejar mejor los valores múltiples / anchos. Entonces, aunque garantizado es un poco demasiado fuerte, es probable.
ssube
@peachykeen: No quise decir que el ensamblaje sea más lento que C ++ en general. Me refería a esa "garantía" en el caso de que tenga un código C ++ y lo traduzca ciegamente línea por línea al ensamblaje. Lea el último párrafo de mi respuesta también :)
vsz
5

Como compilador, reemplazaría un bucle con un tamaño fijo para muchas tareas de ejecución.

int a = 10;
for (int i = 0; i < 3; i += 1) {
    a = a + i;
}

Producirá

int a = 10;
a = a + 0;
a = a + 1;
a = a + 2;

y eventualmente sabrá que "a = a + 0;" es inútil, por lo que eliminará esta línea. Esperemos que haya algo en su cabeza dispuesto a adjuntar algunas opciones de optimización como comentario. Todas esas optimizaciones muy efectivas harán que el lenguaje compilado sea más rápido.

Miah
fuente
44
Y a menos que asea ​​volátil, hay una buena posibilidad de que el compilador lo haga int a = 13;desde el principio.
vsz
4

Es exactamente lo que significa. Deje las microoptimizaciones al compilador.

Luchian Grigore
fuente
4

Me encanta este ejemplo porque demuestra una importante lección sobre el código de bajo nivel. Sí, puede escribir un ensamblaje que sea tan rápido como su código C. Esto es tautológicamente cierto, pero no necesariamente significa nada. Claramente, alguien puede, de lo contrario el ensamblador no conocería las optimizaciones apropiadas.

Del mismo modo, se aplica el mismo principio a medida que asciende en la jerarquía de la abstracción del lenguaje. Sí, puede escribir un analizador en C que sea tan rápido como un script perl rápido y sucio, y mucha gente lo hace. Pero eso no significa que debido a que usaste C, tu código será rápido. En muchos casos, los lenguajes de nivel superior realizan optimizaciones que quizás nunca haya considerado.

tylerl
fuente
3

En muchos casos, la forma óptima de realizar alguna tarea puede depender del contexto en el que se realiza la tarea. Si una rutina está escrita en lenguaje ensamblador, generalmente no será posible variar la secuencia de instrucciones según el contexto. Como un ejemplo simple, considere el siguiente método simple:

inline void set_port_high(void)
{
  (*((volatile unsigned char*)0x40001204) = 0xFF);
}

Un compilador para código ARM de 32 bits, dado lo anterior, probablemente lo representaría de la siguiente manera:

ldr  r0,=0x40001204
mov  r1,#0
strb r1,[r0]
[a fourth word somewhere holding the constant 0x40001204]

o quizás

ldr  r0,=0x40001000  ; Some assemblers like to round pointer loads to multiples of 4096
mov  r1,#0
strb r1,[r0+0x204]
[a fourth word somewhere holding the constant 0x40001000]

Eso podría optimizarse ligeramente en código ensamblado a mano, ya sea:

ldr  r0,=0x400011FF
strb r0,[r0+5]
[a third word somewhere holding the constant 0x400011FF]

o

mvn  r0,#0xC0       ; Load with 0x3FFFFFFF
add  r0,r0,#0x1200  ; Add 0x1200, yielding 0x400011FF
strb r0,[r0+5]

Ambos enfoques ensamblados a mano requerirían 12 bytes de espacio de código en lugar de 16; este último reemplazaría una "carga" con un "complemento", que en un ARM7-TDMI se ejecutaría dos ciclos más rápido. Si el código se ejecutara en un contexto en el que r0 era no sabe / no importa, las versiones en lenguaje ensamblador serían algo mejores que la versión compilada. Por otro lado, suponga que el compilador sabía que algún registro [por ejemplo, r5] iba a tener un valor que estaba dentro de 2047 bytes de la dirección deseada 0x40001204 [por ejemplo, 0x40001000], y además sabía que algún otro registro [por ejemplo, r7] iba para mantener un valor cuyos bits bajos eran 0xFF. En ese caso, un compilador podría optimizar la versión C del código para simplemente:

strb r7,[r5+0x204]

Mucho más corto y más rápido que incluso el código de ensamblaje optimizado a mano. Además, supongamos que set_port_high ocurrió en el contexto:

int temp = function1();
set_port_high();
function2(temp); // Assume temp is not used after this

Nada inverosímil cuando se codifica para un sistema embebido. Si set_port_highestá escrito en el código de ensamblaje, el compilador tendría que mover r0 (que contiene el valor de retorno function1) a otro lugar antes de invocar el código de ensamblaje, y luego mover ese valor nuevamente a r0 después (ya function2que esperará su primer parámetro en r0), entonces el código de ensamblaje "optimizado" necesitaría cinco instrucciones. Incluso si el compilador no supiera de ningún registro que contenga la dirección o el valor para almacenar, su versión de cuatro instrucciones (que podría adaptar para usar cualquier registro disponible, no necesariamente r0 y r1) superaría al ensamblado "optimizado" versión en lenguaje. Si el compilador tuviera la dirección y los datos necesarios en r5 y r7 como se describió anteriormente, function1no alteraría esos registros y, por lo tanto, podría reemplazarset_port_highcon una sola strbinstrucción: cuatro instrucciones más pequeñas y más rápidas que el código de ensamblaje "optimizado a mano".

Tenga en cuenta que el código de ensamblaje optimizado a mano a menudo puede superar a un compilador en los casos en que el programador conoce el flujo preciso del programa, pero los compiladores brillan en los casos en que se escribe un fragmento de código antes de que se conozca su contexto, o donde se puede encontrar un fragmento de código fuente invocado desde múltiples contextos [si set_port_highse usa en cincuenta lugares diferentes en el código, el compilador podría decidir independientemente para cada uno de ellos cuál es la mejor manera de expandirlo].

En general, sugeriría que el lenguaje ensamblador es apto para producir las mayores mejoras de rendimiento en aquellos casos en los que cada fragmento de código puede abordarse desde un número muy limitado de contextos, y es perjudicial para el rendimiento en lugares donde un fragmento de código El código puede ser abordado desde muchos contextos diferentes. Curiosamente (y convenientemente) los casos en que el ensamblaje es más beneficioso para el rendimiento son a menudo aquellos en los que el código es más sencillo y fácil de leer. Los lugares donde el código del lenguaje ensamblador se convertiría en un desastre pegajoso son a menudo aquellos en los que escribir en ensamblaje ofrecería el menor beneficio de rendimiento.

[Nota menor: hay algunos lugares donde el código de ensamblaje se puede usar para producir un desastre pegajoso hiper optimizado; por ejemplo, un fragmento de código que hice para ARM necesitaba recuperar una palabra de RAM y ejecutar una de las doce rutinas basadas en los seis bits superiores del valor (muchos valores asignados a la misma rutina). Creo que optimicé ese código para algo como:

ldrh  r0,[r1],#2! ; Fetch with post-increment
ldrb  r1,[r8,r0 asr #10]
sub   pc,r8,r1,asl #2

El registro r8 siempre contenía la dirección de la tabla de despacho principal (dentro del bucle donde el código pasó el 98% de su tiempo, nada lo usó para ningún otro propósito); Las 64 entradas se refieren a direcciones en los 256 bytes que le preceden. Dado que el ciclo primario tenía en la mayoría de los casos un límite de tiempo de ejecución difícil de aproximadamente 60 ciclos, la recuperación y el despacho de nueve ciclos fue muy instrumental para alcanzar ese objetivo. El uso de una tabla de 256 direcciones de 32 bits habría sido un ciclo más rápido, pero habría engullido 1 KB de RAM muy valiosa [la memoria flash habría agregado más de un estado de espera]. El uso de 64 direcciones de 32 bits habría requerido agregar una instrucción para enmascarar algunos bits de la palabra obtenida, y aún habría engullido 192 bytes más que la tabla que realmente usé. El uso de la tabla de compensaciones de 8 bits produjo un código muy compacto y rápido, pero no es algo que esperaría que un compilador pudiera encontrar; Tampoco esperaría que un compilador dedique un registro "a tiempo completo" para mantener la dirección de la tabla.

El código anterior fue diseñado para ejecutarse como un sistema autónomo; podría llamar periódicamente al código C, pero solo en ciertos momentos cuando el hardware con el que se comunicaba podría ponerse en estado "inactivo" de forma segura durante dos intervalos de aproximadamente un milisegundo cada 16 ms.

Super gato
fuente
2

En los últimos tiempos, todas las optimizaciones de velocidad que he realizado reemplazan el código lento dañado por el cerebro con un código razonable. Pero debido a que la velocidad era realmente crítica y puse un esfuerzo serio para hacer algo rápido, el resultado siempre fue un proceso iterativo, donde cada iteración daba más información sobre el problema, encontrando formas de resolver el problema con menos operaciones. La velocidad final siempre dependía de la cantidad de información que tuviera sobre el problema. Si en cualquier etapa utilicé el código de ensamblaje, o el código C que estaba demasiado optimizado, el proceso de encontrar una mejor solución habría sufrido y el resultado final sería más lento.

gnasher729
fuente
2

C ++ es más rápido a menos que esté utilizando lenguaje ensamblador con un conocimiento más profundo de la manera correcta.

Cuando codifico en ASM, reorganizo las instrucciones manualmente para que la CPU pueda ejecutar más de ellas en paralelo cuando sea lógicamente posible. Apenas uso RAM cuando codifico en ASM, por ejemplo: podría haber más de 20000 líneas de código en ASM y nunca utilicé push / pop.

Potencialmente, podría saltar en el medio del código de operación para auto modificar el código y el comportamiento sin la posible penalización del código de auto modificación. Acceder a los registros toma 1 tick (a veces toma .25 ticks) de la CPU. Acceder a la RAM puede tomar cientos.

Para mi última aventura de ASM, nunca utilicé la RAM para almacenar una variable (para miles de líneas de ASM). ASM podría ser potencialmente inimaginablemente más rápido que C ++. Pero depende de muchos factores variables como:

1. I was writing my apps to run on the bare metal.
2. I was writing my own boot loader that was starting my programs in ASM so there was no OS management in the middle.

¡Ahora estoy aprendiendo C # y C ++ porque me di cuenta de que la productividad es importante! Podría intentar hacer los programas más rápidos imaginables utilizando ASM puro solo en el tiempo libre. Pero para producir algo, use un lenguaje de alto nivel.

Por ejemplo, el último programa que codifiqué estaba usando JS y GLSL y nunca noté ningún problema de rendimiento, incluso hablando de JS, que es lento. Esto se debe a que el simple concepto de programar la GPU para 3D hace que la velocidad del lenguaje que envía los comandos a la GPU sea casi irrelevante.

La velocidad del ensamblador solo en el metal desnudo es irrefutable. ¿Podría ser aún más lento dentro de C ++? - Podría ser porque está escribiendo código de ensamblaje con un compilador que no está utilizando un ensamblador para comenzar.

Mi consejo personal es que nunca escriba el código de ensamblaje si puede evitarlo, aunque me encanta el ensamblaje.


fuente
1

Todas las respuestas aquí parecen excluir un aspecto: a veces no escribimos código para lograr un objetivo específico, sino por pura diversión . Puede que no sea económico invertir el tiempo para hacerlo, pero podría decirse que no hay mayor satisfacción que vencer al fragmento de código optimizado del compilador más rápido en velocidad con una alternativa asm rodada manualmente.

madoki
fuente
Cuando solo quiere vencer al compilador, generalmente es más fácil tomar su salida asm para su función y convertirla en una función asm independiente que ajuste. Usar inm asm es un montón de trabajo extra para que la interfaz entre C ++ y asm sea correcta y compruebe que se está compilando en un código óptimo. (Pero al menos cuando lo hace por diversión, no tiene que preocuparse de que derrote optimizaciones como la propagación constante cuando la función se alinea en otra cosa. Gcc.gnu.org/wiki/DontUseInlineAsm ).
Peter Cordes
Consulte también la Conjetura de Collatz C ++ frente a preguntas y respuestas sobre asm escritas a mano para obtener más información sobre cómo vencer al compilador por diversión :) Y también sugerencias sobre cómo usar lo que aprende para modificar C ++ para ayudar al compilador a mejorar el código.
Peter Cordes
@PeterCordes Entonces, lo que estás diciendo es que estás de acuerdo.
madoki el
1
Sí, asm es divertido, excepto que el asm en línea suele ser la elección incorrecta, incluso para jugar. Esta es técnicamente una pregunta en línea, por lo que sería bueno al menos abordar este punto en su respuesta. Además, esto es realmente más un comentario que una respuesta.
Peter Cordes
Vale, de acuerdo. Solía ​​ser un chico único, pero eso fue en los años 80.
madoki
-2

Un compilador de c ++ produciría, después de la optimización a nivel organizacional, un código que utilizaría las funciones integradas de la CPU objetivo. HLL nunca superará o superará al ensamblador por varias razones; 1.) HLL se compilará y generará con el código de acceso, la verificación de límites y, posiblemente, la recolección de basura integrada (que anteriormente abordaba el alcance en el estilo de OOP), todo lo cual requiere ciclos (flips y flops). HLL hace un excelente trabajo en estos días (incluidos C ++ más nuevos y otros como GO), pero si superan al ensamblador (es decir, su código), debe consultar la documentación de la CPU: las comparaciones con código descuidado ciertamente no son concluyentes y los idiomas compilados como ensamblador se resuelven. hasta el código de operación, HLL extrae los detalles y no los elimina; de lo contrario, su aplicación no se ejecutará si el sistema operativo host la reconoce.

La mayoría del código ensamblador (principalmente objetos) se muestra como "sin cabeza" para su inclusión en otros formatos ejecutables con mucho menos procesamiento requerido, por lo tanto, será mucho más rápido, pero mucho más inseguro; si el ensamblador genera un ejecutable (NAsm, YAsm; etc.), seguirá ejecutándose más rápido hasta que coincida completamente con el código HLL en la funcionalidad, entonces los resultados se pueden pesar con precisión.

Llamar a un objeto de código basado en ensamblador desde HLL en cualquier formato agregará inherentemente una sobrecarga de procesamiento, además de las llamadas de espacio de memoria utilizando memoria asignada globalmente para tipos de datos variables / constantes (esto se aplica tanto a LLL como a HLL). Recuerde que el resultado final es usar la CPU en última instancia como su api y abi en relación con el hardware (código de operación) y ambos, los ensambladores y los "compiladores HLL" son esencialmente / fundamentalmente idénticos, con la única excepción verdadera que es la legibilidad (gramatical).

La aplicación de consola Hello World en ensamblador que usa FAsm es de 1.5 KB (y esto es en Windows aún más pequeño en FreeBSD y Linux) y supera todo lo que GCC puede tirar en su mejor día; Las razones son relleno implícito con nops, validación de acceso y verificación de límites, por nombrar algunos. El objetivo real es libs HLL limpias y un compilador optimizable que apunta a una CPU de una manera "hardcore" y la mayoría lo hace en estos días (finalmente). GCC no es mejor que YAsm: son las prácticas de codificación y la comprensión del desarrollador las que están en cuestión y la "optimización" se produce después de la exploración novata y la capacitación y experiencia interinas.

Los compiladores tienen que vincular y ensamblar para la salida en el mismo código de operación que un ensamblador porque esos códigos son todo lo que una CPU excepto (CISC o RISC [PIC también]). YAsm optimizó y limpió mucho en los primeros NAsm, lo que aceleró en última instancia toda la salida de ese ensamblador, pero aun así, YAsm aún, como NAsm, produce ejecutables con dependencias externas dirigidas a las bibliotecas del sistema operativo en nombre del desarrollador, por lo que el kilometraje puede variar. Al cerrar, C ++ se encuentra en un punto increíble y mucho más seguro que el ensamblador para más del 80 por ciento, especialmente en el sector comercial ...

El Cuervo
fuente
1
C y C ++ no tienen ningún control de límites a menos que lo solicite, y ninguna recolección de basura a menos que lo implemente usted mismo o use una biblioteca. La verdadera pregunta es si el compilador crea mejores bucles (y optimizaciones globales) que un humano. Por lo general, sí, a menos que el humano realmente sepa lo que está haciendo y pase mucho tiempo en ello .
Peter Cordes
1
Puede hacer ejecutables estáticos usando NASM o YASM (sin código externo). Ambos pueden salir en formato binario plano, por lo que podría hacer que ensamblen los encabezados ELF usted mismo si realmente no desea ejecutar ld, pero no hace ninguna diferencia a menos que esté tratando de optimizar realmente el tamaño del archivo (no solo el tamaño del archivo segmento de texto). Vea un tutorial de Whirlwind sobre la creación de ejecutables ELF realmente para Teensy para Linux .
Peter Cordes
1
Quizás esté pensando en C #, o std::vectorcompilado en modo de depuración. Las matrices de C ++ no son así. Los compiladores pueden verificar cosas en el momento de la compilación, pero a menos que habilite opciones de endurecimiento adicionales, no hay verificación en tiempo de ejecución. Vea, por ejemplo, una función que incrementa los primeros 1024 elementos de un int array[]argumento. La salida asm no tiene comprobaciones de tiempo de ejecución: godbolt.org/g/w1HF5t . Todo lo que obtiene es un puntero rdi, sin información de tamaño. Depende del programador evitar un comportamiento indefinido al nunca llamarlo con una matriz más pequeña que 1024.
Peter Cordes
1
Lo que esté hablando no es una matriz simple de C ++ (asignar con new, eliminar manualmente con delete, sin verificación de límites). Usted puede utilizar C ++ para producir mierda hinchada asm / máquina de código (como la mayoría del software), pero eso es culpa del programador, no C ++ 's. Incluso puede usar allocapara asignar espacio de pila como una matriz.
Peter Cordes
1
Enlace un ejemplo en gcc.godbolt.org de g++ -O3generar código de verificación de límites para una matriz simple, o hacer cualquier otra cosa de la que esté hablando. C ++ hace que sea mucho más fácil generar binarios hinchados (y, de hecho, debes tener cuidado de no hacerlo si buscas rendimiento), pero no es literalmente inevitable. Si comprende cómo C ++ se compila en asm, puede obtener un código que es solo algo peor de lo que podría escribir a mano, pero con una alineación y propagación constante en una escala mayor de la que podría manejar a mano.
Peter Cordes
-3

El ensamblaje podría ser más rápido si su compilador genera mucho código de soporte OO .

Editar:

Para los votantes: el OP escribió "¿debería ... centrarme en C ++ y olvidarme del lenguaje ensamblador?" y mantengo mi respuesta. Siempre debe vigilar el código que genera OO, especialmente cuando se utilizan métodos. Sin olvidar el lenguaje ensamblador significa que revisará periódicamente el ensamblaje que genera su código OO, lo que creo que es imprescindible para escribir un software que funcione bien.

En realidad, esto pertenece a todos los códigos compilables, no solo a OO.

Olof Forshell
fuente
2
-1: no veo ninguna función OO en uso. Su argumento es el mismo que "el ensamblaje también podría ser más rápido si su compilador agrega un millón de NOP".
Sjoerd
No estaba claro, esta es realmente una pregunta C. Si escribe código C para un compilador de C ++, no está escribiendo código C ++ y no obtendrá nada de OO. Una vez que comience a escribir en C ++ real, al usar material OO, debe tener mucho conocimiento para que el compilador no genere código de soporte OO.
Olof Forshell
¿Entonces tu respuesta no es sobre la pregunta? (Además, las aclaraciones van en la respuesta, no los comentarios. Los comentarios se pueden eliminar en cualquier momento sin previo aviso, notificación o historial.
Mooing Duck
1
No estoy seguro de qué quiere decir exactamente con "código de soporte" de OO. Por supuesto, si usa una gran cantidad de RTTI y similares, el compilador tendrá que crear muchas instrucciones adicionales para admitir esas características, pero cualquier problema que sea lo suficientemente alto como para ratificar el uso de RTTI es demasiado complejo para poder escribirlo en el ensamblaje. . Lo que puede hacer, por supuesto, es escribir solo la interfaz externa abstracta como OO, enviando a un código de procedimiento puro optimizado para el rendimiento donde sea crítico. Pero, dependiendo de la aplicación, C, Fortran, CUDA o simplemente C ++ sin herencia virtual podría ser mejor que el ensamblaje aquí.
Leftaroundabout
2
No. Al menos no muy probable. Hay una cosa en C ++ llamada regla de sobrecarga cero, y esto se aplica la mayor parte del tiempo. Obtenga más información sobre OO: descubrirá que al final mejora la legibilidad de su código, mejora la calidad del código, aumenta la velocidad de codificación, aumenta la robustez. También para incrustado, pero use C ++ ya que le da más control, incrustado + OO la forma de Java le costará.
Zane