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
foo
no 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 x
cadena 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 foo
empuja / revienta RBX, entonces no hay mucho que ganar para la latencia. ret
Es 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 ret
error 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 rax
serí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 foo
haga y el equilibrio entre la latencia adicional de almacenamiento / recarga x
frente 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, 8
para derramar / recargar x
en una ranura de pila. (Pero los compiladores tampoco conocen esta optimización, de usar push
para 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_frame
metadatos 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 foo
salvará / 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 x
entre 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 call
si 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 -flto
para 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 bar
en línea con la persona que llama, o foo
en línea bar
. Esto está bien a menos que haya muchas bar
funciones diferentes y foo
es grande, y por alguna razón no pueden conectarse con sus llamadores.