Почему эта функция помещает RAX в стек в качестве первой операции?
В сборке источника C++ ниже. Почему RAX помещается в стек?
RAX, как я понимаю из ABI, может содержать что угодно из вызывающей функции. Но мы сохраним его здесь, а затем переместим стек обратно на 8 байт. Так что RAX в стеке, я думаю, имеет отношение только к std::__throw_bad_function_call()
операция...?
Код:-
#include <functional>
void f(std::function<void()> a)
{
a();
}
Выход из gcc.godbolt.org
с использованием Clang 3.7.1 -O3:
f(std::function<void ()>): # @f(std::function<void ()>)
push rax
cmp qword ptr [rdi + 16], 0
je .LBB0_1
add rsp, 8
jmp qword ptr [rdi + 24] # TAILCALL
.LBB0_1:
call std::__throw_bad_function_call()
Я уверен, что причина очевидна, но я изо всех сил пытаюсь понять это.
Вот звонок без std::function<void()>
Обертка для сравнения:
void g(void(*a)())
{
a();
}
Тривиальное:
g(void (*)()): # @g(void (*)())
jmp rdi # TAILCALL
3 ответа
64-битный ABI требует, чтобы стек был выровнен до 16 байтов до call
инструкция.
call
помещает 8-байтовый адрес возврата в стек, что нарушает выравнивание, поэтому компилятору необходимо что-то сделать, чтобы снова выровнять стек до кратного 16 до следующего call
,
(Выбор дизайна ABI, требующий выравнивания перед call
вместо after имеет незначительное преимущество в том, что если в стек были переданы какие-либо аргументы, этот выбор выравнивает первый аргумент 16B.)
Выдвигать значение безразличия работает хорошо и может быть более эффективным, чем sub rsp, 8
на процессорах со стековым движком. (См. Комментарии).
Причина push rax
необходимо выровнять стек обратно к 16-байтовой границе, чтобы соответствовать 64-битному ABI System V в случае, когда je .LBB0_1
ветка взята. Значение в стеке не имеет значения. Другим способом было бы вычесть 8 из RSP с sub rsp, 8
, ABI утверждает выравнивание следующим образом:
Конец области входного аргумента должен быть выровнен по границе 16 байтов (32, если __m256 передается в стеке). Другими словами, значение (%rsp + 8) всегда кратно 16 (32), когда управление передается в точку входа в функцию. Указатель стека% rsp всегда указывает на конец последнего выделенного кадра стека.
До вызова функции f
стек был выровнен по 16 байтов в соответствии с соглашением о вызовах. После того, как управление было передано через CALL f
адрес возврата был помещен в стек, смещая стек на 8. push rax
это простой способ вычитания 8 из RSP и повторного выравнивания. Если ветка взята call std::__throw_bad_function_call()
стек будет правильно выровнен, чтобы этот вызов работал.
В случае, если сравнение проваливается, стек будет выглядеть так же, как и при входе в функцию, как только add rsp, 8
инструкция выполнена. Адрес возврата ЗВОНОКА для работы f
теперь вернется на вершину стека, и стек снова будет смещен на 8. Это то, что мы хотим, потому что TAIL CALL производится с jmp qword ptr [rdi + 24]
передать управление функции a
, Это позволит JMP функции не вызывать ее. Когда функция a
делает RET, он вернется непосредственно к функции, которая вызвала f
,
На более высоком уровне оптимизации я бы ожидал, что компилятор должен быть достаточно умен, чтобы выполнять сравнение, и позволить ему прорваться прямо к JMP. Что на этикетке .LBB0_1
затем можно выровнять стек по 16-байтовой границе, чтобы call std::__throw_bad_function_call()
работает правильно.
Как указал @CodyGray, если вы используете GCC (не CLANG) с уровнем оптимизации -O2
или выше, созданный код кажется более разумным. Выход GCC 6.1 от Godbolt:
f(std::function<void ()>):
cmp QWORD PTR [rdi+16], 0 # MEM[(bool (*<T5fc5>) (union _Any_data &, const union _Any_data &, _Manager_operation) *)a_2(D) + 16B],
je .L7 #,
jmp [QWORD PTR [rdi+24]] # MEM[(const struct function *)a_2(D)]._M_invoker
.L7:
sub rsp, 8 #,
call std::__throw_bad_function_call() #
Этот код больше соответствует тому, что я ожидал. В этом случае может показаться, что оптимизатор GCC может обрабатывать эту генерацию кода лучше, чем CLANG.
В других случаях clang обычно исправляет стек перед возвратом с pop rcx
,
С помощью push
имеет преимущество для эффективности в размере кода (push
только 1 байт против 4 байт для sub rsp, 8
), а также в мопах на процессорах Intel. (Нет необходимости в синхронизации по стеку, которую вы получите, если получите доступ rsp
непосредственно потому что call
то, что привело нас к вершине текущей функции, делает движок стека "грязным").
В этом длинном и бессвязном ответе обсуждаются наихудшие риски производительности при использовании push rax
/ pop rcx
для выравнивания стека, а также rax
а также rcx
Хорошие варианты регистрации. (Извините, что сделал это так долго.)
(TL: DR: выглядит хорошо, возможный недостаток, как правило, небольшой, и потенциал роста в общем случае оправдывает это. Сбои с частичным регистром могут стать проблемой для Core2/Nehalem, если al
или же ax
"грязные", хотя Ни у одного другого 64-битного процессора нет больших проблем (потому что они не переименовывают частичные регистры и не объединяют эффективно), а 32-битному коду требуется более 1 дополнительного push
выровнять стек на 16 для другого call
если только он не сохранял / восстанавливал некоторые сохраненные вызовы регистры для собственного использования.)
С помощью push rax
вместо sub rsp, 8
вводит зависимость от старого значения rax
, так что вы могли бы подумать, что это может замедлить ситуацию, если значение rax
является результатом цепочки зависимостей с большой задержкой (и / или отсутствием кэша).
например, абонент мог сделать что-то медленное с rax
это не связано с аргументами функции, как var = table[ x % y ]; var2 = foo(x);
# example caller that leaves RAX not-ready for a long time
mov rdi, rax ; prepare function arg
div rbx ; very high latency
mov rax, [table + rdx] ; rax = table[ value % something ], may miss in cache
mov [rsp + 24], rax ; spill the result.
call foo ; foo uses push rax to align the stack
К счастью, выполнение заказа не по назначению сработает хорошо.
push
не имеет значения rsp
зависит от rax
, (Это либо обрабатывается механизмом стека, либо на очень старых процессорах push
декодирует к нескольким мопам, один из которых обновляет rsp
независимо от мопов, которые хранят rax
, Микро-слияние адреса магазина и данных магазина позволяет push
быть единичным мопом с доменом слияния, хотя магазины всегда берут 2 мопа с незанятым доменом.
Пока от вывода ничего не зависит push rax
/ pop rcx
, это не проблема для выполнения не по порядку. Если push rax
должен ждать, потому что rax
не готов, это не приведет к тому, что ROB (ReOrder Buffer) заполняется и в конечном итоге блокирует выполнение последующих независимых инструкций. ROB заполняется даже без push
потому что инструкция, которую медленно производить rax
и все инструкции в вызывающей стороне потребляет rax
до вызова еще старше, и не может выйти на пенсию, пока rax
готов. Выход на пенсию должен происходить по порядку в случае исключений / прерываний.
(Я не думаю, что загрузка из-за отсутствия кэша может прекратиться до завершения загрузки, оставив только запись в буфере загрузки. Но даже если бы это было возможно, было бы бессмысленно выдавать результат в регистре с прерыванием вызова без чтения это с другой инструкцией, прежде чем сделать call
, Инструкция вызывающего абонента, которая потребляет rax
определенно не может выполнить / уйти в отставку, пока наш push
может сделать то же самое.)
когда rax
становится готовым, push
может выполнить и удалить в несколько циклов, что позволяет более поздним инструкциям (которые уже были выполнены не по порядку) также удалиться. Утилита store-address uop уже будет выполнена, и я предполагаю, что uop store-data может завершиться за один или два цикла после отправки в порт хранилища. Хранилища могут закрываться, как только данные записываются в буфер хранилища. Фиксация L1D происходит после выхода на пенсию, когда известно, что магазин не является спекулятивным.
Так что даже в худшем случае, когда инструкция, которая производит rax
был настолько медленным, что это привело к тому, что ROB заполнялся независимыми инструкциями, которые в основном уже выполнены и готовы к выводу из эксплуатации. push rax
вызывает только пару дополнительных циклов задержки перед независимыми инструкциями после того, как он может удалиться. (И некоторые из инструкций вызывающего абонента будут удалены в первую очередь, освобождая место в ROB еще до нашего push
уходит в отставку.)
push rax
то, что нужно ждать, свяжет некоторые другие микроархитектурные ресурсы, оставив одну запись для нахождения параллелизма между другими более поздними инструкциями. (An add rsp,8
который мог бы выполнить, потреблял бы только запись ROB, и больше ничего.)
Он будет использовать одну запись в планировщике вне очереди (Reservation Station / RS). Утилита store-address uop может выполняться, как только появляется свободный цикл, поэтому останется только uop store-data. pop rcx
Адрес загрузки uop готов, поэтому он должен отправиться на порт загрузки и выполнить. (Когда pop
загрузка выполняется, он обнаруживает, что его адрес совпадает с неполным push
сохранить в буфере хранилища (он же буфер порядка памяти), поэтому он устанавливает пересылку хранилища, которая произойдет после того, как выполнится операция store-data. Это, вероятно, потребляет запись в буфере загрузки.)
Даже у старых процессоров, таких как Nehalem, RS - 36, против 54 в Sandybridge или 97 в Skylake. В редких случаях не нужно беспокоиться о том, чтобы занимать 1 запись дольше обычного. Альтернатива выполнения двух мопов (синхронизация стека + sub
) хуже.
(не по теме)
ROB больше, чем RS, 128 (Nehalem), 168 (Sandybridge), 224 (Skylake). (Он содержит мопы с слитными доменами от выпуска к выходу на пенсию, в то время как RS удерживает мопы с неиспользуемым доменом от выпуска до выполнения). При максимальной пропускной способности внешнего интерфейса 4 мопа за такт, это более 50 циклов скрытия задержки на Skylake. (У более старых уаршей меньше шансов выдержать 4 мопа за час в течение столь длительного времени...)
Размер ROB определяет окно не по порядку для сокрытия медленной независимой операции. ( Если только ограничения размера регистра-файла не являются меньшими). Размер RS определяет нестандартное окно для нахождения параллелизма между двумя отдельными цепочками зависимостей. (Например, рассмотрим тело цикла 200 моп, где каждая итерация независима, но внутри каждой итерации это одна длинная цепочка зависимостей без большого параллелизма на уровне команд (например, a[i] = complex_function(b[i])
). ROB Skylake может содержать более 1 итерации, но мы не можем получить мопы из следующей итерации в RS, пока мы не окажемся в пределах 97 мопов от конца текущей. Если цепь dep была не намного больше, чем размер RS, мопы из двух итераций большую часть времени могли бы быть в полете.)
Есть случаи, когда push rax / pop rcx
может быть опаснее
Вызывающий эту функцию знает, что rcx
является закрытым вызовом, поэтому не будет читать значение. Но это может иметь ложную зависимость от rcx
после того, как мы вернемся, как bsf rcx, rax
/ jnz
или же test eax,eax
/ setz cl
, Последние процессоры Intel больше не переименовывают low8 частичные регистры, поэтому setcc cl
имеет ложную депо на rcx
, bsf
фактически оставляет место назначения неизменным, если источник равен 0, хотя Intel документирует его как неопределенное значение. Документы AMD оставляют неизменным поведение.
Ложная зависимость может создать переносимую по цепочке цепочку деп. С другой стороны, ложная зависимость может сделать это в любом случае, если наша функция написала rcx
с инструкциями, зависящими от его входов.
Было бы хуже использовать push rbx
/ pop rbx
сохранить / восстановить сохраненный вызов регистр, который мы не собирались использовать. Вызывающий, скорее всего , прочтет его после того, как мы вернемся, и мы добавили бы задержку пересылки хранилища в цепочку зависимостей вызывающего для этого регистра. (Кроме того, это может быть более вероятно, что rbx
будет написано прямо перед call
поскольку все, что абонент хотел сохранить во время вызова, будет перемещено в регистры с сохранением вызова, например rbx
а также rbp
.)
На процессорах с частичным регистром (Intel pre-Sandybridge) чтение rax
с push
может вызвать остановку или 2-3 цикла на Core2/Nehalem, если вызывающая сторона сделала что-то вроде setcc al
перед call
, Sandybridge не останавливается при вставке объединяющего мопа, а Haswell и более поздние не переименовывают регистры low8 отдельно от rax
совсем.
Было бы неплохо push
регистр, который с меньшей вероятностью использовал low8. Если бы компиляторы пытались избежать префиксов REX по размеру кода, они избегали бы dil
а также sil
, так rdi
а также rsi
было бы менее вероятно иметь проблемы с частичной регистрацией. Но, к сожалению, gcc и clang не одобряют использование dl
или же cl
в качестве 8-битных регистров, используя dil
или же sil
даже в крошечных функциях, где больше ничего не используется rdx
или же rcx
, (Хотя отсутствие переименования low8 в некоторых процессорах означает, что setcc cl
имеет ложную зависимость от старого rcx
, так setcc dil
безопаснее, если установка флага зависела от функции arg в rdi
.)
pop rcx
в конце "очищает" rcx
любой части частичной регистрации. поскольку cl
используется для подсчета сдвига, а функции иногда пишут только cl
даже когда они могли бы написать ecx
вместо. (IIRC я видел, что clang делает это. Gcc более решительно поддерживает 32-битные и 64-битные размеры операндов, чтобы избежать проблем с частичной регистрацией.)
push rdi
вероятно, будет хорошим выбором во многих случаях, так как остальная часть функции также читает rdi
поэтому введение другой зависимой от него инструкции не повредит. Это останавливает выполнение не по порядку получения push
с дороги, если rax
готов раньше rdi
, хоть.
Другим потенциальным недостатком является использование циклов на портах загрузки / хранения. Но они вряд ли будут насыщены, и альтернативой является мопс для портов ALU. С дополнительной стековой синхронизацией на процессорах Intel, которую вы получите от sub rsp, 8
, это будет 2 ALU моп в верхней части функции.