¿Por qué los compiladores insisten en usar un registro guardado por el destinatario aquí?

10

Considere este código C:

void foo(void);

long bar(long x) {
    foo();
    return x;
}

Cuando lo compilo en GCC 9.3 con cualquiera -O3o -Os, obtengo esto:

bar:
        push    r12
        mov     r12, rdi
        call    foo
        mov     rax, r12
        pop     r12
        ret

La salida de clang es idéntica, excepto por elegir en rbxlugar de r12como el registro guardado por el destinatario.

Sin embargo, quiero / espero ver un ensamblaje que se parezca más a esto:

bar:
        push    rdi
        call    foo
        pop     rax
        ret

En inglés, esto es lo que veo que sucede:

  • Empuje el valor anterior de un registro guardado por el llamado a la pila
  • Pasar xa ese registro guardado por el llamado
  • Llamada foo
  • Pasar xdel registro guardado por el destinatario al registro de valor de retorno
  • Haga estallar la pila para restaurar el valor anterior del registro guardado por la persona que llama

¿Por qué molestarse en meterse con un registro guardado? ¿Por qué no hacer esto en su lugar? Parece más corto, más simple y probablemente más rápido:

  • Empujar xa la pila
  • Llamada foo
  • Pop xde la pila en el registro de valor de retorno

¿Está mal mi montaje? ¿Es de alguna manera menos eficiente que jugar con un registro adicional? Si la respuesta a ambas preguntas es "no", ¿por qué no lo hacen GCC o clang de esta manera?

Enlace Godbolt .


Editar: Aquí hay un ejemplo menos trivial, para mostrar que sucede incluso si la variable se usa de manera significativa:

long foo(long);

long bar(long x) {
    return foo(x * x) - x;
}

Entiendo esto:

bar:
        push    rbx
        mov     rbx, rdi
        imul    rdi, rdi
        call    foo
        sub     rax, rbx
        pop     rbx
        ret

Prefiero tener esto:

bar:
        push    rdi
        imul    rdi, rdi
        call    foo
        pop     rdi
        sub     rax, rdi
        ret

Esta vez, es solo una instrucción en lugar de dos, pero el concepto central es el mismo.

Enlace Godbolt .

Joseph Sible-Reinstate a Monica
fuente
44
Interesante optimización perdida.
fuz
1
lo más probable es la suposición de que se usará el parámetro pasado, por lo que desea guardar un registro volátil y mantener el parámetro pasado en un registro que no esté en la pila, ya que los accesos posteriores a ese parámetro son más rápidos desde el registro. pasa x a foo y verás esto. por lo tanto, es probable que solo sea una parte genérica de su configuración de marco de pila.
old_timer
concedido veo que sin foo no usa la pila, así que sí, es una optimización perdida, pero algo que alguien necesitaría agregar, analizar la función y si el valor no se usa y no hay conflicto con ese registro (generalmente hay es).
old_timer
el backend del brazo también hace esto en gcc. tan probable que no sea el backend
old_timer
clang 10 misma historia (backend del brazo).
old_timer

Respuestas:

5

TL: DR:

  • Los componentes internos del compilador probablemente no estén configurados para buscar esta optimización fácilmente, y probablemente solo sea útil para funciones pequeñas, no dentro de funciones grandes entre llamadas.
  • Hacer fila para crear funciones grandes es una mejor solución la mayor parte del tiempo
  • Puede haber una compensación entre latencia y rendimiento si foono se guarda / restaura RBX.

Los compiladores son piezas complejas de maquinaria. No son "inteligentes" como un ser humano, y los algoritmos costosos para encontrar todas las optimizaciones posibles a menudo no valen el costo en tiempo de compilación adicional.

Informé esto como error 69986 de GCC: es posible un código más pequeño con -Os usando push / pop para derramar / recargar en 2016 ; no ha habido actividad ni respuestas de los desarrolladores de GCC. : /

Ligeramente relacionado: el error 70408 de GCC: la reutilización del mismo registro de llamada preservada daría un código más pequeño en algunos casos , los desarrolladores del compilador me dijeron que tomaría una gran cantidad de trabajo para que GCC pueda hacer esa optimización porque requiere elegir el orden de evaluación de dos foo(int)llamadas basadas en lo que simplificaría el asm de destino.


Si foo no se guarda / restaura rbx, hay una compensación entre el rendimiento (recuento de instrucciones) frente a una latencia adicional de almacenamiento / recarga en la xcadena de dependencia -> retval.

Los compiladores generalmente favorecen la latencia sobre el rendimiento, por ejemplo, usando 2x LEA en lugar de imul reg, reg, 10(latencia de 3 ciclos, rendimiento de 1 / reloj), porque la mayoría de los códigos promedian significativamente menos de 4 uops / reloj en tuberías típicas de 4 anchos como Skylake. (Sin embargo, más instrucciones / uops ocupan más espacio en el ROB, reduciendo qué tan adelante puede ver la misma ventana fuera de orden, y la ejecución está realmente llena de puestos que probablemente representan algunos de los menos de 4 uops / promedio de reloj.)

Si fooempuja / revienta RBX, entonces no hay mucho que ganar para la latencia. retEs probable que la restauración se realice justo antes de que en lugar de justo después no sea relevante, a menos que haya un reterror de predicción o falta de I-cache que retrase la obtención de código en la dirección de retorno.

La mayoría de las funciones no triviales guardarán / restaurarán RBX, por lo que a menudo no es una buena suposición que dejar una variable en RBX realmente signifique que realmente permaneció en un registro durante la llamada. (Aunque aleatorizar qué funciones de registros conservados de llamadas elegir puede ser una buena idea para mitigar esto a veces).


Entonces sí push rdi/ pop raxsería más eficiente en este caso, y esta es probablemente una optimización perdida para pequeñas funciones que no son hojas, dependiendo de lo que foohaga y el equilibrio entre la latencia adicional de almacenamiento / recarga xfrente a más instrucciones para guardar / restaurar la persona que llama rbx.

Es posible que los metadatos de desenrollado de pila representen los cambios en RSP aquí, como si se hubiera usado sub rsp, 8para derramar / recargar xen una ranura de pila. (Pero los compiladores tampoco conocen esta optimización, de usar pushpara reservar espacio e inicializar una variable. ¿Qué compilador C / C ++ puede usar instrucciones push pop para crear variables locales, en lugar de aumentar el esp una vez? ¿ Y hacerlo por más de una var local llevaría a .eh_framemetadatos de desenrollado de pila más grandes porque está moviendo el puntero de la pila por separado con cada inserción. Sin embargo, eso no impide que los compiladores usen push / pop para guardar / restaurar registros conservados de llamadas.


IDK si valdría la pena enseñar a los compiladores a buscar esta optimización

Tal vez sea una buena idea en torno a una función completa, no a través de una llamada dentro de una función. Y como dije, se basa en la suposición pesimista que foosalvará / restaurará RBX de todos modos. (O bien, optimizar el rendimiento si sabe que la latencia desde x hasta el valor de retorno no es importante. Pero los compiladores no lo saben y generalmente optimizan la latencia).

Si comienza a hacer esa suposición pesimista en muchos códigos (como alrededor de llamadas de funciones individuales dentro de funciones), comenzará a obtener más casos en los que RBX no se guarda / restaura y podría haber aprovechado.

Tampoco desea que este push / pop adicional de guardar / restaurar en un bucle, solo guarde / restaure RBX fuera del bucle y use registros conservados de llamadas en bucles que realicen llamadas a funciones. Incluso sin bucles, en el caso general, la mayoría de las funciones realizan múltiples llamadas de función. Esta idea de optimización podría aplicarse si realmente no usa xentre ninguna de las llamadas, justo antes de la primera y después de la última, de lo contrario tiene un problema de mantener la alineación de la pila de 16 bytes para cada una callsi está haciendo un pop después de un llamar, antes de otra llamada.

Los compiladores no son buenos para las funciones pequeñas en general. Pero tampoco es genial para las CPU. Las llamadas a funciones no en línea tienen un impacto en la optimización en el mejor de los casos, a menos que los compiladores puedan ver las partes internas del destinatario y hacer más suposiciones de lo habitual. Una llamada a una función no en línea es una barrera de memoria implícita: la persona que llama tiene que suponer que una función puede leer o escribir cualquier dato accesible a nivel mundial, por lo que todos estos valores deben estar sincronizados con la máquina abstracta C. (El análisis de escape permite mantener a los locales en registros a través de llamadas si su dirección no ha escapado de la función). Además, el compilador tiene que suponer que todos los registros con bloqueo de llamadas están bloqueados. Esto apesta al punto flotante en x86-64 System V, que no tiene registros XMM conservados para llamadas.

Pequeñas funciones como bar()son mejores en línea con sus llamadores. Compile con -fltopara que esto pueda suceder incluso a través de los límites del archivo en la mayoría de los casos. (Los punteros de función y los límites de la biblioteca compartida pueden vencer esto).


Creo que una de las razones por las que los compiladores no se han molestado en intentar hacer estas optimizaciones es que requeriría un montón de código diferente en los componentes internos del compilador , diferente del código normal de pila frente al código de asignación de registro que sabe cómo guardar las llamadas preservadas registros y usarlos.

es decir, sería mucho trabajo implementar y mantener mucho código, y si se entusiasma demasiado al hacerlo, podría empeorar el código.

Y también que (con suerte) no es significativo; si es importante, debe estar baren línea con la persona que llama, o fooen línea bar. Esto está bien a menos que haya muchas barfunciones diferentes y fooes grande, y por alguna razón no pueden conectarse con sus llamadores.

Peter Cordes
fuente
No estoy seguro de que tenga sentido preguntar por qué algún compilador traduce el código de esa manera, cuándo puede ser mejor usarlo, si no es un error en la traducción. por ejemplo posible, pregunte por qué el sonido metálico tan extraño (no optimizado) tradujo este bucle, compárelo con gcc, icc e incluso msvc
RbMm
1
@RbMm: No entiendo tu punto. Parece una optimización perdida totalmente separada para el sonido metálico, sin relación con lo que trata esta pregunta. Existen errores de optimizaciones perdidas, y en la mayoría de los casos deben corregirse. Siga adelante e infórmelo
Peter Cordes
Sí, mi ejemplo de código no tiene relación alguna con la pregunta original. simplemente otro ejemplo de traducción extraña (para mi aspecto) (y para un solo compilador clang). pero de todos modos el código asm resulta correcto. solo que no es el mejor e incluso no es nativo comparar gcc / icc / msvc
RbMm