c assembly llvm x86-64 compiler-optimization

¿Por qué clang produce asm ineficiente con-O0(para esta suma de punto flotante simple)?



assembly llvm (1)

-O0 (no optimizado) es el valor predeterminado . Le dice al compilador que desea que compile rápido (tiempos de compilación cortos), que no se tome más tiempo compilando para hacer código eficiente.

( -O0 no es literalmente ninguna optimización; por ejemplo, gcc todavía eliminará el código dentro de los bloques if(1 == 2){ } . Especialmente gcc más que la mayoría de los otros compiladores todavía hace cosas como usar inversos multiplicativos para la división en -O0 , porque aún transforma su fuente de C a través de múltiples representaciones internas de la lógica antes de que finalmente emita asm.)

Además, "el compilador siempre tiene la razón" es una exageración incluso en -O3 . Los compiladores son muy buenos a gran escala, pero las pequeñas optimizaciones perdidas todavía son comunes dentro de los bucles individuales. A menudo, con un impacto muy bajo, pero las instrucciones desperdiciadas (uops) en un bucle pueden consumir espacio en la ventana de reordenación de ejecución fuera de orden, y ser menos amigables con los subprocesos cuando se comparte un núcleo con otro hilo. Vea el código C ++ para probar la conjetura de Collatz más rápido que el ensamblaje escrito a mano, ¿por qué? para más información sobre vencer al compilador en un caso específico simple.

Más importante aún, -O0 también implica tratar todas las variables similares a las volatile para una depuración consistente . es decir, puede establecer un punto de interrupción o un solo paso y modificar el valor de una variable de C, y luego continuar la ejecución y hacer que el programa funcione de la forma en que esperaría de su fuente de C que se ejecuta en la máquina abstracta de C. Por lo tanto, el compilador no puede hacer ninguna propagación constante o simplificación del rango de valores. (por ejemplo, un entero que se sabe que no es negativo puede simplificar las cosas al usarlo, o hacer que algunas de las condiciones sean siempre verdaderas o siempre falsas).

(No es tan malo como volatile : las referencias múltiples a la misma variable dentro de una declaración no siempre resultan en cargas múltiples; en -O0 compiladores todavía se optimizarán de alguna manera dentro de una sola expresión).

Los compiladores tienen que específicamente anti-optimizar para -O0 almacenando / recargando todas las variables en su dirección de memoria entre las declaraciones . (En C y C ++, cada variable tiene una dirección a menos que se haya declarado con la palabra clave de register (ahora obsoleta) y nunca se haya tomado su dirección. La optimización de la dirección es posible de acuerdo con la regla de simulación de otras variables, pero no no hecho en -O0 )

Desafortunadamente, los formatos de información de depuración no pueden rastrear la ubicación de una variable a través de los registros, por lo que la depuración totalmente consistente no es posible sin este código de generación lento y estúpido.

Si no necesita esto, puede compilar con -Og para optimización de luz, y sin las anti-optimizaciones requeridas para una depuración consistente. El manual de GCC lo recomienda para el ciclo habitual de edición / compilación / ejecución, pero obtendrá "optimizado" para muchas variables locales con almacenamiento automático al realizar la depuración. Los argumentos globales y de función todavía suelen tener sus valores reales, al menos en los límites de la función.

Peor aún, -O0 código que aún funciona, incluso si usa el comando de jump de GDB para continuar la ejecución en una línea de origen diferente . Por lo tanto, cada declaración C debe compilarse en un bloque de instrucciones totalmente independiente. ( ¿Es posible "saltar" / "saltar" en el depurador GDB? )

for() bucles for() no se pueden transformar en bucles idiomáticos (para asm) do{}while() , y otras restricciones.

Por todas las razones anteriores, el código no optimizado de (micro) benchmarking es una enorme pérdida de tiempo; los resultados dependen de los detalles tontos de cómo escribió la fuente que no importan cuando compila con la optimización normal. -O0 vs. -O0 rendimiento no está relacionado linealmente; algún código acelerará mucho más que otros .

Los cuellos de botella en el código -O0 a menudo serán diferentes de -O3 , a menudo en un contador de bucle que se guarda en la memoria, creando una cadena de dependencia transmitida por bucle de ~ 6 ciclos. Esto puede crear efectos interesantes en el asm generado por compilador, como agregar una asignación redundante acelera el código cuando se compila sin optimización (que son interesantes desde la perspectiva de asm, pero no para C).

"Mi índice de referencia se ha optimizado de otro modo" no es una justificación válida para observar el rendimiento del código -O0 . Consulte la ayuda de optimización de bucle C para la asignación final para ver un ejemplo y más detalles sobre el agujero de conejo que es el ajuste para -O0 .

Obteniendo interesante salida del compilador

Si desea ver cómo el compilador agrega 2 variables, escriba una función que tome argumentos y devuelva un valor . Recuerde que solo desea ver el asm, no ejecutarlo, por lo que no necesita un valor literal o cualquier valor literal numérico para cualquier cosa que deba ser una variable de tiempo de ejecución.

Consulte también ¿Cómo eliminar el "ruido" de la salida del ensamblado de GCC / clang? para más sobre esto.

float foo(float a, float b) { float c=a+b; return c; }

compila con clang -O3 ( en el explorador del compilador de Godbolt ) al esperado

addss xmm0, xmm1 ret

Pero con -O0 derrama los argumentos para apilar la memoria. (Godbolt usa la información de depuración emitida por el compilador para codificar por colores las instrucciones asm de acuerdo con la declaración C de la que provienen. He agregado saltos de línea para mostrar bloques para cada instrucción, pero puede ver esto con el resaltado de color en el enlace de Godbolt anterior . A menudo es muy útil para encontrar la parte interesante de un bucle interno en la salida optimizada del compilador.)

gcc -fverbose-asm pondrá comentarios en cada línea que muestre los nombres de los operandos como C vars. En el código optimizado, a menudo se trata de un nombre tmp interno, pero en el código no optimizado es habitual una variable real de la fuente C. He comentado manualmente la salida de Clang porque no hace eso.

# clang7.0 -O0 also on Godbolt foo: push rbp mov rbp, rsp # make a traditional stack frame movss DWORD PTR [rbp-20], xmm0 # spill the register args movss DWORD PTR [rbp-24], xmm1 # into the red zone (below RSP) movss xmm0, DWORD PTR [rbp-20] # a addss xmm0, DWORD PTR [rbp-24] # +b movss DWORD PTR [rbp-4], xmm0 # store c movss xmm0, DWORD PTR [rbp-4] # return 0 pop rbp # epilogue ret

Dato curioso: utilizando el register float c = a+b; , el valor de retorno puede permanecer en XMM0 entre las declaraciones, en lugar de ser derramado / recargado. La variable no tiene dirección. (Incluí esa versión de la función en el enlace de Godbolt.)

La palabra clave de register no tiene ningún efecto en el código optimizado (excepto que es un error tomar la dirección de una variable, como por ejemplo, la forma en que un local le impide modificar algo accidentalmente). No recomiendo usarlo, pero es interesante ver que realmente afecta el código no optimizado.

Relacionado:

Estoy desmontando este código en llvm clang Apple LLVM versión 8.0.0 (clang-800.0.42.1):

int main() { float a=0.151234; float b=0.2; float c=a+b; printf("%f", c); }

Compilé sin especificaciones -O, pero también probé con -O0 (da lo mismo) y -O2 (en realidad calcula el valor y lo almacena precomputado)

El desmontaje resultante es el siguiente (quité las partes que no son relevantes)

-> 0x100000f30 <+0>: pushq %rbp 0x100000f31 <+1>: movq %rsp, %rbp 0x100000f34 <+4>: subq $0x10, %rsp 0x100000f38 <+8>: leaq 0x6d(%rip), %rdi 0x100000f3f <+15>: movss 0x5d(%rip), %xmm0 0x100000f47 <+23>: movss 0x59(%rip), %xmm1 0x100000f4f <+31>: movss %xmm1, -0x4(%rbp) 0x100000f54 <+36>: movss %xmm0, -0x8(%rbp) 0x100000f59 <+41>: movss -0x4(%rbp), %xmm0 0x100000f5e <+46>: addss -0x8(%rbp), %xmm0 0x100000f63 <+51>: movss %xmm0, -0xc(%rbp) ...

Aparentemente está haciendo lo siguiente:

  1. cargando los dos flotadores en los registros xmm0 y xmm1
  2. ponlos en la pila
  3. carga un valor (no el que xmm0 tenía anteriormente) de la pila a xmm0
  4. realizar la adición.
  5. almacenar el resultado de nuevo a la pila.

Lo encuentro ineficiente porque:

  1. Todo se puede hacer en el registro. No estoy usando ayb más tarde, por lo que podría omitir cualquier operación relacionada con la pila.
  2. incluso si quisiera usar la pila, podría guardar la recarga xmm0 de la pila si hiciera la operación con un orden diferente.

Dado que el compilador siempre tiene la razón, ¿por qué eligió esta estrategia?