TL: DR:
- Внутрішній інтерфейс компілятора, ймовірно, не створений для того, щоб легко шукати цю оптимізацію, і він, ймовірно, корисний лише для невеликих функцій, а не для великих функцій між викликами.
- Більшість часу краще створювати ідеї для створення великих функцій
- Якщо
foo
не зберегти / відновити RBX, може виникнути затримка проти компромісу .
Компілятори - це складні частини техніки. Вони не такі "розумні", як людина, і дорогі алгоритми для пошуку всілякої оптимізації часто не коштують витрат за додатковий час компіляції.
Я повідомив про це як помилку GCC 69986 - можливий менший код із -Os, використовуючи push / pop, щоб пролити / перезавантажити ще у 2016 році ; не було жодної активності та відповідей від розробників GCC. : /
Трохи пов'язані з цим: помилка GCC 70408 - повторне використання того ж збереженого виклику регістра дасть менший код у деяких випадках - розробник-компілятор сказав мені, що GCC може зайняти величезну кількість роботи, щоб зробити цю оптимізацію, оскільки для цього потрібен вибір порядку оцінки з двох foo(int)
дзвінків на основі того, що зробить цільову зору простішою.
Якщо foo
не зберегти / відновити rbx
себе, існує компроміс між пропускною здатністю (кількість інструкцій) та додатковою затримкою зберігання / перезавантаження в x
ланцюзі залежності відшкодування ->.
Компілятори, як правило, віддають перевагу затримці над пропускною здатністю, наприклад, використовуючи 2x LEA замість imul reg, reg, 10
( тривалість циклу, 1 / тактова пропускна здатність), оскільки більшість кодів в середньому значно менше 4 уп / такт на типових 4-широких трубопроводах, таких як Skylake. (Більше інструкцій / uops дійсно займає більше місця в ROB, зменшуючи, наскільки далеко попереду може побачити те саме вікно поза замовленням, а виконання насправді лопне, коли кіоски, ймовірно, складають частину менш ніж 4 Uops / середній годинник.)
Якщо foo
Push / pop RBX, то затримка заробити не так вже й багато. ret
Мабуть, відновлення відбудеться безпосередньо перед замість того, як тільки після цього, ймовірно, не має значення, якщо тільки не буде передбачено ret
неправильного прогнозування або пропуску кеш-пам'яті, який затримує код отримання за зворотною адресою.
Більшість нетривіальних функцій дозволить зберегти / відновити RBX, тому часто не є хорошим припущенням, що залишення змінної в RBX насправді означає, що вона справді залишається в реєстрі протягом виклику. (Хоча рандомізація обраних функцій регістрів, що зберігаються у викликах, може бути корисною ідеєю для пом'якшення цього випадку.)
Отже, так push rdi
/ pop rax
було б ефективнішим у цьому випадку, і це, мабуть, пропущена оптимізація для крихітних функцій, які foo
не містять листя, залежно від того, що робить, і балансу між додатковою затримкою для зберігання / перезавантаження для x
більшої кількості інструкцій щодо збереження / відновлення абонента rbx
.
Метадані, розмотані стеком, можуть представляти зміни в RSP тут, як якщо б вони використовувались sub rsp, 8
для розливу / перезавантаження x
в слот стека. (Але компілятори також не знають цієї оптимізації використання push
резерву простору та ініціалізації змінної. Який компілятор C / C ++ може використовувати інструкції push pop для створення локальних змінних, а не просто збільшувати esp один раз?) І робити це більше, ніж один локальний var призведе до збільшення .eh_frame
метаданих розмотування стека, оскільки ви переміщуєте покажчик стека окремо з кожним натисканням. Це не зупиняє компіляторів використовувати push / pop для збереження / відновлення регрес-збережених викликів.)
IDK, якщо варто було б навчити компіляторів шукати цю оптимізацію
Це може бути хорошою ідеєю навколо цілої функції, а не в одному дзвінку всередині функції. І як я вже говорив, це засноване на песимістичному припущенні, яке в foo
будь-якому випадку збереже / відновить АТС. (Або оптимізація пропускної здатності, якщо ви знаєте, що затримка від х до повернення значення не важлива. Але компілятори цього не знають і зазвичай оптимізують для затримки).
Якщо ви почнете робити це песимістичне припущення у великій кількості коду (наприклад, навколо викликів однієї функції всередині функцій), ви почнете отримувати більше випадків, коли RBX не зберігається / не відновлюється, і ви могли б скористатися ними.
Ви також не хочете, щоб це додаткове збереження / відновлення push / pop у циклі, просто збережіть / відновіть RBX поза циклом і використовуйте регістри, що зберігаються при виклику, у циклах, які здійснюють функціональні дзвінки. Навіть без циклів, у загальному випадку більшість функцій здійснюють кілька функціональних дзвінків. Ця ідея оптимізації може бути застосована, якщо ви дійсно не використовуєте x
жоден з дзвінків безпосередньо перед першим і після останнього, інакше у вас є проблема збереження вирівнювання 16-байтних стеків для кожного, call
якщо ви робите один поп після дзвінок, перед черговим дзвінком.
Компілятори не відрізняються крихітними функціями взагалі. Але це не чудово і для процесорів. Виклики функцій, що не вбудовуються, впливають на оптимізацію в кращі часи, якщо компілятори не зможуть побачити внутрішні дані виклику та зробити більше припущень, ніж зазвичай. Виклик не вбудованої функції - це неявний бар'єр пам’яті: абонент повинен припустити, що функція може читати або записувати будь-які доступні в усьому світі дані, тому всі такі параметри повинні синхронізуватися з абстрактною машиною C. (Аналіз сканування дозволяє зберігати місцевих жителів у регістрах через дзвінки, якщо їх адреса не уникнула функції.) Крім того, компілятор повинен припустити, що регістри, що перебувають у виклику, є усіма клоберами. Це підходить для плаваючої точки в x86-64 System V, яка не має збережених викликів XMM-регістрів.
Крихітні функції, як-от bar()
, краще вбудовувати свої дзвінки. Компілювати -flto
так, що в більшості випадків це може статися навіть через межі файлів. (Покажчики функцій та межі бібліотеки спільного використання можуть перемогти це.)
Я думаю, що одна з причин, що компілятори не намагалися зробити ці оптимізації, полягає в тому, що це вимагатиме цілого ряду різних кодів у внутрішніх програмах компілятора , відмінних від звичайного стека проти коду розподілу, який знає, як зберегти збережені виклики реєструє та використовує їх.
тобто було б дуже багато роботи для впровадження, і багато коду для підтримання, і якщо він буде надто захоплений цим робити, це може погіршити код.
А також, що це (сподіваємось) не суттєво; якщо це має значення, вам слід вписатись bar
у його абонента або вписати foo
його bar
. Це добре, якщо існує безліч різних bar
функцій, подібних foo
до великої кількості, і вони чомусь не можуть вбудовуватись до своїх абонентів.