Почему компиляторы настаивают на том, чтобы использовать регистр, сохраненный вызываемым пользователем?

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сохраненного в регистре.

Тем не менее, я хочу / ожидаю увидеть сборку, которая выглядит примерно так:

bar:
        push    rdi
        call    foo
        pop     rax
        ret

На английском вот что происходит:

  • Перенесите старое значение сохраненного в регистр пользователя в стек
  • Переместиться xв этот сохраненный регистр
  • Вызов foo
  • Переместиться xиз сохраненного в регистре вызываемого абонента в регистр возвращаемого значения
  • Вставьте стек, чтобы восстановить старое значение сохраненного в регистре вызываемого абонента.

Зачем вообще возиться с регистром, сохраненным вызываемым абонентом? Почему бы не сделать это вместо этого? Это кажется короче, проще и, вероятно, быстрее:

  • Толкать xв стек
  • Вызов foo
  • Выскочить xиз стека в регистр возвращаемого значения

Моя сборка неправильная? Это как-то менее эффективно, чем возиться с дополнительным регистром? Если ответом на оба вопроса является «нет», то почему бы ни GCC, ни Clang не сделали это таким образом?

Годболт ссылка .


Изменить: Вот менее простой пример, чтобы показать, что это происходит, даже если переменная используется осмысленно:

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

На этот раз это только одна инструкция против двух, но основная концепция та же.

Годболт ссылка .

Джозеф Сибл-Восстановить Монику
источник
4
Интересная пропущенная оптимизация.
fuz
1
Скорее всего, предполагается, что переданный параметр будет использоваться, поэтому вы хотите сохранить изменчивый регистр и сохранить переданный параметр в регистре, а не в стеке, поскольку последующий доступ к этому параметру происходит быстрее из регистра. передайте x в foo, и вы увидите это. так что, скорее всего, это просто общая часть настройки стека.
old_timer
Конечно, я вижу, что без foo он не использует стек, так что да, это пропущенная оптимизация, но кое-что нужно добавить, проанализировать функцию, и если значение не используется и конфликт с этим регистром отсутствует (обычно является).
old_timer
backend рука делает это тоже на 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цепочке зависимостей -> retval.

Компиляторы обычно предпочитают задержку перед пропускной способностью, например, используя 2x LEA вместо imul reg, reg, 10(задержка 3 цикла, пропускная способность 1 / тактовая частота), потому что большинство кодов в среднем значительно меньше, чем 4 моп / такт на типичных конвейерах с шириной 4, таких как Skylake. (Больше инструкций / мопов занимают больше места в ROB, уменьшая, насколько далеко вперед может видеть то же самое окно с неупорядоченным порядком, и выполнение фактически взрывное с остановками, вероятно, составляющими некоторые из менее чем 4 мопов / часы в среднем.)

Если сделать fooRBX push / pop, то выиграть немного времени не получится. Восстановление, выполняемое непосредственно перед заменой, а retне сразу после, вероятно, не имеет значения, если только не retпроизойдет неправильный прогноз или промах I-кэша, который задерживает выборку кода по адресу возврата.

Большинство нетривиальных функций сохраняют / восстанавливают RBX, поэтому часто не стоит полагать, что если оставить переменную в RBX, это на самом деле означает, что она действительно остается в регистре на протяжении всего вызова. (Хотя рандомизация выбора регистров, сохраняемых вызовом, может быть хорошей идеей для смягчения этого иногда.)


Так что да push rdi/ pop raxбыло бы более эффективно в этом случае, и это, вероятно, пропущенная оптимизация для крошечных неконечных функций, в зависимости от того, что fooделает и баланс между дополнительной задержкой сохранения / перезагрузки xи большим количеством инструкций для сохранения / восстановления вызывающей стороны rbx.

Метаданные для разматывания стека могут представлять здесь изменения RSP, как если бы они использовались sub rsp, 8для разлива / перезагрузки xв слот стека. (Но составители не знают эту оптимизацию либо, использовать pushдля резервного пространства и инициализировать переменную. Что C / C ++ компилятор может использовать инструкции толчок популярности для создания локальных переменных, а не просто увеличение особ раз? . И делать это для более один локальный var приведет к увеличению .eh_frameметаданных стека, потому что вы перемещаете указатель стека отдельно при каждом нажатии. Это не мешает компиляторам использовать push / pop для сохранения / восстановления сохраненных вызовом регистров.)


IDK, если бы стоило учить компиляторов искать эту оптимизацию

Возможно, это хорошая идея для всей функции, а не для одного вызова внутри функции. И, как я уже сказал, это основано на пессимистическом предположении, что fooвсе равно сохранит / восстановит RBX. (Или оптимизация пропускной способности, если вы знаете, что задержка от x до возвращаемого значения не важна. Но компиляторы этого не знают и обычно оптимизируют для задержки).

Если вы начнете делать это пессимистическое предположение в большом количестве кода (например, вокруг отдельных вызовов функций внутри функций), вы начнете получать больше случаев, когда RBX не сохраняется / восстанавливается, и вы могли бы воспользоваться.

Вы также не хотите этого дополнительного сохранения / восстановления push / pop в цикле, просто сохраняйте / восстанавливайте RBX вне цикла и используйте сохраняемые вызовы регистры в циклах, которые выполняют вызовы функций. Даже без циклов, в общем случае большинство функций выполняет несколько вызовов функций. Эта идея оптимизации может быть применима, если вы действительно не используете xмежду любыми вызовами, непосредственно перед первым и после последнего, в противном случае у вас есть проблема поддержания выравнивания стека по 16 байтов для каждого, callесли вы делаете один щелчок после звоните, перед другим звонком.

Компиляторы не очень хороши в крошечных функциях вообще. Но это не очень хорошо для процессоров. Вызовы не встроенных функций оказывают влияние на оптимизацию в лучшие времена, если только компиляторы не могут видеть внутренности вызываемого и делать больше предположений, чем обычно. Вызов не встроенной функции является неявным барьером памяти: вызывающий должен предположить, что функция может читать или записывать любые глобально доступные данные, поэтому все такие переменные должны быть синхронизированы с абстрактной машиной Си. (Анализ Escape позволяет сохранять локальные значения в регистрах между вызовами, если их адрес не экранирован от функции.) Кроме того, компилятор должен предположить, что регистры с вызовом-сглаживанием все засорены. Это отстой для плавающей запятой в x86-64 System V, которая не имеет сохраненных вызовов регистров XMM.

Крошечные функции, как bar()лучше, встраиваясь в своих абонентов. Скомпилируйте с -fltoтаким образом, что в большинстве случаев это может происходить даже через границы файлов. (Указатели функций и границы разделяемой библиотеки могут победить это.)


Я думаю, что одна из причин, по которой компиляторы не удосужились попытаться выполнить эти оптимизации, заключается в том, что для этого потребуется целая куча другого кода во внутренних компонентах компилятора , отличного от обычного стека и кода выделения регистров, который знает, как сохранить сохраняемый вызов регистрирует и использует их.

т. е. было бы много работы для реализации и много кода для поддержки, и если он слишком увлечен этим, он может сделать код хуже .

А также, что это (надеюсь) не имеет существенного значения; если это имеет значение, вы должны встраиваться barв его вызывающего или fooв bar. Это хорошо, если только не существует много различных barфункций и они fooвелики, и по какой-то причине они не могут встроиться в своих вызывающих.

Питер Кордес
источник
не уверен, что есть смысл спрашивать, почему некоторые компиляторы переводят код таким образом, когда может быть лучше использовать .., если не ошибка в переводе. например, возможно спросить, почему clang так странно (не оптимизирован) перевел этот цикл, сравните с gcc, icc и даже msvc
RbMm
1
@RbMm: я не понимаю вашу точку зрения. Это выглядит как отдельная пропущенная оптимизация для clang, не связанная с тем, о чем этот вопрос. Пропущенные ошибки оптимизации существуют, и в большинстве случаев должны быть исправлены. Пойдите и сообщите об этом на bugs.llvm.org
Питер Кордес
да, мой пример кода абсолютно не связан с оригинальным вопросом. просто еще один пример странного (на мой взгляд) перевода (и только для одного компилятора clang). но результат кода asm в любом случае правильный. только не лучшее и даже не родное сравнение gcc / icc / msvc
RbMm