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

Рассмотрим этот C код:

void foo(void);

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

Когда я компилирую его на G CC 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

В Engli sh происходит следующее:

  • Pu sh старое значение сохраненного в регистре регистра в стеке
  • Move x в этот регистр, сохраненный вызываемым абонентом
  • Вызов foo
  • Перемещение x из регистра, сохраненного вызываемым абонентом, в регистр возвращаемого значения
  • Выгрузка стека восстановить старое значение регистра, сохраненного вызывающим абонентом

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

  • Pu sh x в стек
  • Вызов foo
  • Pop x из стек в регистр возвращаемого значения

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

Godbolt link .


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

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 link .

1 Ответ

6 голосов
/ 23 апреля 2020

TL: DR:

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

Компиляторы являются сложными частями машин. Они не «умны», как человек, и дорогие алгоритмы, позволяющие найти любую возможную оптимизацию, часто не стоят затрат в дополнительное время компиляции.

Я сообщил об этом как G CC ошибка 69986 - меньший код возможен с -O с помощью push / pop для разлива / перезагрузки в 2016 году ; не было активности или ответов от G CC devs. : /

Немного связано: G CC ошибка 70408 - повторное использование одного и того же регистра с сохранением вызовов в некоторых случаях даст меньший код - разработчики компилятора сказали мне, что потребуется огромное количество работайте для G CC, чтобы иметь возможность выполнить эту оптимизацию, потому что она требует выбора порядка оценки двух foo(int) вызовов на основе того, что сделает целевой ассм проще.


Если foo не сохраняет / восстанавливает rbx, то существует компромисс между пропускной способностью (количеством команд) и дополнительной задержкой сохранения / перезагрузки в цепочке зависимостей x -> retval.

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

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

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


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

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


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

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

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

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

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

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


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

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

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

...