online compiler assembler asm c optimization gcc assembly x86

compiler - c to asm



GCC optó por la oportunidad perdida (3)

Estoy compilando este código C:

int mode; // use aa if true, else bb int aa[2]; int bb[2]; inline int auto0() { return mode ? aa[0] : bb[0]; } inline int auto1() { return mode ? aa[1] : bb[1]; } int slow() { return auto1() - auto0(); } int fast() { return mode ? aa[1] - aa[0] : bb[1] - bb[0]; }

Las funciones slow() y fast() tienen la intención de hacer lo mismo, aunque fast() hace con una instrucción de rama en lugar de dos. Quería comprobar si GCC colapsaría las dos ramas en una sola. Lo he intentado con GCC 4.4 y 4.7, con varios niveles de optimización, como -O2, -O3, -Os y -Ofast. Siempre da los mismos resultados extraños:

lento():

movl mode(%rip), %ecx testl %ecx, %ecx je .L10 movl aa+4(%rip), %eax movl aa(%rip), %edx subl %edx, %eax ret .L10: movl bb+4(%rip), %eax movl bb(%rip), %edx subl %edx, %eax ret

rápido():

movl mode(%rip), %esi testl %esi, %esi jne .L18 movl bb+4(%rip), %eax subl bb(%rip), %eax ret .L18: movl aa+4(%rip), %eax subl aa(%rip), %eax ret

De hecho, solo se genera una rama en cada función. Sin embargo, slow() parece ser inferior de una manera sorprendente: utiliza una carga extra en cada rama, para aa[0] y bb[0] . El código fast() usa directamente desde la memoria en los subl sin cargarlos primero en un registro. Entonces slow() usa un registro extra y una instrucción adicional por llamada.

Un simple micro-benchmark muestra que llamar fast() mil millones de veces lleva 0,7 segundos, frente a 1,1 segundos para slow() . Estoy usando un Xeon E5-2690 a 2.9 GHz.

¿Por qué debería ser esto? ¿Puedes modificar mi código fuente de alguna manera para que GCC haga un mejor trabajo?

Editar: aquí están los resultados con clang 4.2 en Mac OS:

lento():

movq _aa@GOTPCREL(%rip), %rax ; rax = aa (both ints at once) movq _bb@GOTPCREL(%rip), %rcx ; rcx = bb movq _mode@GOTPCREL(%rip), %rdx ; rdx = mode cmpl $0, (%rdx) ; mode == 0 ? leaq 4(%rcx), %rdx ; rdx = bb[1] cmovneq %rax, %rcx ; if (mode != 0) rcx = aa leaq 4(%rax), %rax ; rax = aa[1] cmoveq %rdx, %rax ; if (mode == 0) rax = bb movl (%rax), %eax ; eax = xx[1] subl (%rcx), %eax ; eax -= xx[0]

rápido():

movq _mode@GOTPCREL(%rip), %rax ; rax = mode cmpl $0, (%rax) ; mode == 0 ? je LBB1_2 ; if (mode != 0) { movq _aa@GOTPCREL(%rip), %rcx ; rcx = aa jmp LBB1_3 ; } else { LBB1_2: ; // (mode == 0) movq _bb@GOTPCREL(%rip), %rcx ; rcx = bb LBB1_3: ; } movl 4(%rcx), %eax ; eax = xx[1] subl (%rcx), %eax ; eax -= xx[0]

Interesante: clang genera condicionales sin ramas para slow() pero una rama para fast() ! Por otro lado, slow() hace tres cargas (dos de las cuales son especulativas, una será innecesaria) frente a dos para fast() . La implementación fast() es más "obvia" y, como con GCC, es más corta y usa un registro menos.

GCC 4.7 en Mac OS generalmente sufre el mismo problema que en Linux. Sin embargo, utiliza el mismo patrón "carga 8 bytes luego dos veces extrae 4 bytes" como Clang en Mac OS. Eso es algo interesante, pero no muy relevante, ya que el problema original de emitir subl con dos registros en lugar de una memoria y un registro es el mismo en cualquiera de las plataformas para GCC.


¿Ha intentado modificar los parámetros de los compiladores internos (--param name = value en la página man)? Esos no se cambian con ningún nivel de optimizaciones (con tres excepciones menores).

Algunos de ellos controlan la reducción / deduplicación del código.

Para algunas optimizaciones en esta sección, puede leer cosas como «valores más grandes pueden aumentar exponencialmente el tiempo de compilación».


La razón es que en el código intermedio inicial, emitido para slow() , la carga de memoria y la resta están en diferentes bloques básicos:

slow () { int D.1405; int mode.3; int D.1402; int D.1379; # BLOCK 2 freq:10000 mode.3_5 = mode; if (mode.3_5 != 0) goto <bb 3>; else goto <bb 4>; # BLOCK 3 freq:5000 D.1402_6 = aa[1]; D.1405_10 = aa[0]; goto <bb 5>; # BLOCK 4 freq:5000 D.1402_7 = bb[1]; D.1405_11 = bb[0]; # BLOCK 5 freq:10000 D.1379_3 = D.1402_17 - D.1405_12; return D.1379_3; }

mientras que en fast() están en el mismo bloque básico:

fast () { int D.1377; int D.1376; int D.1374; int D.1373; int mode.1; int D.1368; # BLOCK 2 freq:10000 mode.1_2 = mode; if (mode.1_2 != 0) goto <bb 3>; else goto <bb 4>; # BLOCK 3 freq:3900 D.1373_3 = aa[1]; D.1374_4 = aa[0]; D.1368_5 = D.1373_3 - D.1374_4; goto <bb 5>; # BLOCK 4 freq:6100 D.1376_6 = bb[1]; D.1377_7 = bb[0]; D.1368_8 = D.1376_6 - D.1377_7; # BLOCK 5 freq:10000 return D.1368_1; }

GCC se basa en la combinación de instrucciones para manejar casos como este (es decir, aparentemente no en el pase de optimización de mirilla) y la combinación de trabajos en el alcance de un bloque básico. Es por eso que la resta y la carga se combinan en una sola entrada en fast() y ni siquiera se consideran para combinar en slow() .

Más tarde, en el paso de reordenamiento del bloque básico, la resta en slow() se duplica y se mueve a los bloques básicos, que contienen las cargas. Ahora hay una oportunidad para que el combinador combine la carga y la resta, pero desafortunadamente, el pase combinador no se ejecuta nuevamente (y tal vez no se puede ejecutar tan tarde en el proceso de compilación con los registros duros ya asignados y esas cosas).


No tengo una respuesta en cuanto a por qué GCC no puede optimizar el código de la forma en que lo desea, pero tengo una forma de reorganizar su código para lograr un rendimiento similar. En lugar de organizar su código de la manera en que lo hizo en slow() o fast() , le recomendaría que defina una función en línea que devuelva aa o bb función del mode sin necesidad de una bifurcación:

inline int * xx () { static int *xx[] = { bb, aa }; return xx[!!mode]; } inline int kwiky(int *xx) { return xx[1] - xx[0]; } int kwik() { return kwiky(xx()); }

Cuando compilado por GCC 4.7 con -O3 :

movl mode, %edx xorl %eax, %eax testl %edx, %edx setne %al movl xx.1369(,%eax,4), %edx movl 4(%edx), %eax subl (%edx), %eax ret

Con la definición de xx() , puede redefinir auto0() y auto1() manera:

inline int auto0() { return xx()[0]; } inline int auto1() { return xx()[1]; }

Y, a partir de esto, debería ver que slow() ahora compila en código similar o idéntico a kwik() .