Подтвердить что ты не робот

Почему эта функция переводит RAX в стек в качестве первой операции?

В сборке источника С++ ниже. Почему 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
4b9b3361

Ответ 1

64-разрядный ABI требует, чтобы стек выравнивался до 16 байтов перед инструкцией call.

call подталкивает 8-байтовый обратный адрес в стеке, что нарушает выравнивание, поэтому компилятору нужно что-то сделать, чтобы снова выровнять стек до кратного 16 перед следующим call.

(Выбор дизайна ABI, требующий выравнивания перед call, а не после этого, имеет второстепенное преимущество в том, что если в стеке были переданы какие-либо аргументы, этот выбор делает первый аргумент 16B-aligned.)

Нажатие значения небезопасности хорошо работает и может быть более эффективным, чем sub rsp, 8 на процессорах с механизмом стека. (См. Комментарии).

Ответ 2

Причина push rax заключается в том, чтобы выровнять стек обратно до 16-байтовой границы, чтобы соответствовать 64-разрядной системе V ABI в случае, когда берется ветвь 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. Обратный адрес CALLER для функции f теперь вернется в верхнюю часть стека, и стек снова будет смещен на 8. Это то, чего мы хотим, потому что TAIL CALL выполняется с помощью jmp qword ptr [rdi + 24], чтобы передать управление функции a. Это будет JMP для функции, а не CALL. Когда функция 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.

Ответ 3

В других случаях 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, которое не связано с функциями args, например 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. (Он либо обрабатывается движком стека, либо на очень старых CPU push декодирует до нескольких uops, один из которых обновляет rsp независимо от uops, хранящих rax. Микро-слияние хранилища-адреса и хранилища- data uops let push - это единственный объединитель fused-domain, хотя в магазинах всегда принимают 2 непроверенных домена.)

Пока ничего не зависит от выхода push rax/pop rcx, это не проблема для исполнения вне порядка. Если push rax должен ждать, потому что rax не готов, это не приведет к тому, что ROB (буфер ReOrder) заполнит и в конечном итоге заблокирует выполнение более поздней независимой команды. ROB будет заполняться даже без push, потому что команда, которая медленно создает rax, и любая команда в вызывающем устройстве потребляет rax до того, как вызов еще старше, и не может уйти в отставку либо до тех пор, пока rax не будет готов. Выход на пенсию должен происходить в порядке в случае исключений/прерываний.

(Я не думаю, что загрузка кеш-памяти может уйти в отставку до завершения загрузки, оставив только запись в буфере-загрузке. Но даже если бы это было возможно, было бы бессмысленно производить результат в вызове-clobbered зарегистрируйтесь, не прочитав его с другой инструкцией, перед тем, как сделать call. Инструкция вызывающего абонента, которая потребляет rax, определенно не может выполнить/уйти в отставку, пока наш push не сможет сделать то же самое.)

Когда rax станет готовым, push может выполнить и уйти в отставку за пару циклов, разрешив более поздние инструкции (которые уже были выполнены не по порядку), чтобы уйти в отставку. Буфер-адрес uop уже выполнит, и я полагаю, что u-данные store могут завершиться в цикле или два после отправки в порт магазина. Магазины могут уйти в отставку, как только данные будут записаны в буфер хранилища. Commit to L1D происходит после выхода на пенсию, когда известно, что магазин не является спекулятивным.

Таким образом, даже в худшем случае, когда команда, создающая rax, была настолько медленной, что привела к тому, что ROB заполнил независимыми инструкциями, которые в основном уже выполнены и готовы к отставке, выполнение push rax приводит к тому, что пара дополнительных циклов задержки перед независимыми инструкциями после выхода на пенсию. (И некоторые из инструкций вызывающего абонента сначала уйдут на пенсию, сделав немного места в ROB еще до выхода нашего push.)


A push rax, который должен ждать, свяжет некоторые другие микроархитектурные ресурсы, оставив еще одну запись для поиска parallelism между другими более поздними инструкциями. (An add rsp,8, который мог выполнить, будет потреблять только запись ROB, а не больше.)

Он будет использовать одну запись в нестандартном планировщике (aka Reservation Station/RS). Адрес магазина uop можно выполнить, как только будет свободный цикл, так что останется только u-store-data uop. Адрес загрузки pop rcx uop готов, поэтому он должен отправляться в порт загрузки и выполнять. (Когда выполняется загрузка pop, он обнаруживает, что его адрес совпадает с неполным хранилищем push в буфере хранилища (также называемом буфером порядка памяти), поэтому он устанавливает пересылку хранилища, которая будет выполняться после того,. Это, вероятно, потребляет запись буфера загрузки.)

Даже старые процессоры, такие как Nehalem имеет 36 записей RS, против 54 в Sandybridge или 97 в Skylake. Хранение 1 записи, занятой дольше обычного, в редких случаях не о чем беспокоиться. Альтернатива выполнения двух uops (stack-sync + sub) хуже.

(отключить тему)
ROB больше, чем RS, 128 (Nehalem), 168 (Sandybridge), 224 (Skylake). (Он держит fops-domain uops от выхода до выхода на пенсию, а RS - с недопустимым доменом uops от выпуска до исполнения). При пропускной способности 4 мкП на максимальную пропускную способность за такт max более 50 циклов задержки на Skylake. (Старые урчы с меньшей вероятностью выдержит 4 такта за такт...)

Размер ROB определяет окно вне порядка для скрытия медленной независимой операции. (Если ограничения размера регистрационного файла не являются меньшим лимитом). Размер RS определяет окно вне порядка для нахождения parallelism между двумя отдельными цепочками зависимостей. (например, рассмотрите тело цикла 200 мкп, где каждая итерация независима, но в каждой итерации она представляет собой одну длинную цепочку зависимостей без значительного уровня инструкций parallelism (например, a[i] = complex_function(b[i])). Skylake ROB может содержать более 1 итерации, но мы можем 't получить uops от следующей итерации в RS до тех пор, пока мы не достигнем 97 uops конца текущего. Если цепочка dep была не столько больше размера RS, но и от 2 итераций, может быть, в полете большая часть время.)


Бывают случаи, когда push rax / pop rcx может быть более опасным:

Вызывающий этой функции знает, что rcx является сбрасываемым вызовом, поэтому не будет считывать значение. Но это может иметь ложную зависимость от rcx после возврата, например bsf rcx, rax/jnz или test eax,eax/setz cl. Недавние процессоры Intel больше не переименовывают низкоуровневые регистры, поэтому setcc cl имеет ложный отпечаток на rcx. bsf фактически оставляет цель немодифицированной, если источник равен 0, хотя Intel документирует его как значение undefined. Документы AMD остаются неизмененными.

Фальшивая зависимость может создать цепочку отрезков, переданную петлями. С другой стороны, ложная зависимость может это сделать, если наша функция написала rcx с инструкциями, зависящими от ее входов.

Было бы хуже использовать push rbx/pop rbx для сохранения/восстановления сохраненного в журнале регистра, который мы не собирались использовать. Вероятно, вызывающий абонент прочитал бы его после того, как мы вернемся, и мы представили бы задержку пересылки хранилища в цепочку зависимостей вызывающего абонента для этого регистра. (Кроме того, возможно, более вероятно, что rbx будет написан непосредственно перед call, поскольку все, что вызывающий абонент хотел сохранить во время вызова, будет перемещен в регистры, сохраненные в кодах, такие как rbx и rbp.)


На процессорах с закрытыми регистрами (Intel pre-Sandybridge), чтение rax с помощью push может привести к остановке или 2-3 циклам на Core2/Nehalem, если вызывающий что-то вроде setcc al перед call. Sandybridge не останавливается при вставке слияния uop и 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 в верхней части функции.