Чому компілятори наполягають на використанні тут збереженого регістру?


10

Розглянемо цей код C:

void foo(void);

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

Коли я компілюю його на GCC 9.3 з будь-яким -O3або -Os, я отримую це:

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

Вихід з clang ідентичний за винятком вибору rbxзамість r12регістра, збереженого callee.

Однак я хочу / сподіваюся побачити збірку, яка виглядає приблизно так:

bar:
        push    rdi
        call    foo
        pop     rax
        ret

Англійською мовою ось що я бачу:

  • Натисніть на старе значення реєстру, збереженого викликом, до стеку
  • Перемістіться xдо цього реєстру, який збережено для виклику
  • Дзвінок foo
  • Перемістіться xз реєстру збереженого виклику в регістр повернених значень
  • Надіньте стек, щоб відновити старе значення регістра, збереженого викликом

Навіщо взагалі турбуватися возитися з збереженим реєстром калє? Чому б цього не зробити? Це здається коротшим, простішим і, ймовірно, швидшим:

  • Натисніть xна стек
  • Дзвінок foo
  • Поп xз стека в регістр повернених значень

Невірно моя збірка? Це якось менш ефективно, ніж возитися з додатковим реєстром? Якщо відповідь на те і те й інше - «ні», то чому б ні GCC, ні Clang не робили це так?

Godbolt посилання .


Редагувати: Ось менш тривіальний приклад, щоб показати, що це відбувається, навіть якщо змінна змістовно використовується:

long foo(long);

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

Я отримую це:

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

Я вважаю за краще це:

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

Цього разу це лише одна інструкція від двох, але основна концепція - однакова.

Godbolt посилання .


4
Цікава пропущена оптимізація.
фуз

1
швидше за все, припущення про те, що переданий параметр буде використаний, тому ви хочете зберегти мінливий регістр і зберегти переданий параметр в регістрі не в стеці, оскільки подальший доступ до цього параметра швидше відбувається з реєстру. перейдіть до foo, і ви побачите це. тому, ймовірно, це просто загальна частина їх налаштування кадру стека.
old_timer

надано Я бачу, що без foo він не використовує стек, так що так це пропущена оптимізація, але щось комусь потрібно буде додати, проаналізувати функцію, і якщо значення не використовується, і немає конфлікту з цим реєстром (як правило, там є).
old_timer

Арка "бекенд" робить це теж на gcc. так що, швидше за все, не бекенд
old_timer

кланг 10 та сама історія (рука витягнутий).
old_timer

Відповіді:


5

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 / середній годинник.)

Якщо fooPush / 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до великої кількості, і вони чомусь не можуть вбудовуватись до своїх абонентів.


Не впевнені, чи є сенс запитати, чому якийсь компілятор перекладає код таким чином, коли може бути краще використовувати .., якщо не помилка в перекладі. наприклад, можливо запитати, чому так незвично (не оптимізовано) кланг переклав цю петлю, порівняйте з gcc, icc та навіть msvc
RbMm

1
@RbMm: Я не розумію вашої точки зору. Це виглядає як абсолютно окрема пропущена оптимізація для клангу, не пов'язана з тим, про що йдеться у цьому питанні. Пропущені помилки оптимізації існують, і в більшості випадків слід виправити. Попередьте
Пітер Кордес,

так, мій приклад коду абсолютно не пов'язаний з оригінальним запитанням. просто ще один приклад дивного (на мій погляд) перекладу (і лише для одного компілятора clang). але результат ASM код все одно правильний. тільки не найкраще і eveen не рідне порівняти gcc / icc / msvc
RbMm
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.