Выполнение «условного звонка» на amd64 - PullRequest
0 голосов
/ 25 февраля 2019

При рассмотрении вызова условной функции в критической части кода я обнаружил, что и gcc, и clang будут разветвляться вокруг вызова.Например, для следующего (по общему признанию тривиального) кода:

int32_t __attribute__((noinline)) negate(int32_t num) {
    return -num;
}

int32_t f(int32_t num) {
    int32_t x = num < 0 ? negate(num) : num;
    return 2*x + 1;
}

И GCC, и Clang компилируются по существу в следующее:

.global _f
_f:
    cmp     edi, 0
    jg      after_call
    call    _negate
after_call:
    lea     rax, [rax*2+1]
    ret

Это заставило меня задуматься: что, если x86 имелинструкция условного вызова типа ARM?Представьте себе, была ли такая инструкция "ccall cc " с семантикой вроде cmov cc .Тогда вы можете сделать что-то вроде:

.global _f
_f:
    cmp     edi, 0
    ccalll  _negate
    lea     rax, [rax*2+1]
    ret

Хотя мы не можем избежать предсказания ветвления, мы исключаем ветвление.А именно, в фактическом выводе GCC / clang мы вынуждены переходить независимо от того, num < 0 или нет.И если num < 0, мы должны разветвляться дважды.Это кажется расточительным.

Сейчас такой инструкции в amd64 не существует, но я разработал способ имитации такой инструкции.Я сделал это, разбив call func на составные части: push rip (ну технически [rip+label_after_call_instruction]) и затем jmp func.Мы можем сделать jmp условным, но нет условного push.Мы можем смоделировать это, вычислив [rip+label_after_call_instruction] и записав его в соответствующее место в стеке, а затем условно обновив rsp, если мы планируем вызвать функцию (которая фактически "выталкивает" [rip+label_after_call_instruction]).Это выглядит примерно так:

.global _f
_f:
    cmp     edi, 0

    # ccalll _negate
    lea     rax, [rip+after_ccall]  # Compute return address
    mov     [rsp-8], rax            # Prepare to "push" return address
    lea     rax, [rsp-8]            # Compute rsp (after push)
    cmovl   rsp, rax                # Conditionally push (by actually changing rsp)
    jl      _negate                 # "Conditional call"
after_ccall:

    lea     rax, [rax*2+1]
    ret

У этого подхода есть несколько потенциальных недостатков:

  • Он вводит несколько инструкций (но их общее количество циклов меньше).чем штраф за неправильное предсказание ветви)
  • Требуется запись в память (но стек, вероятно, кешируется?)
  • Он всегда выполняет 2 lea s и mov, даже если вызовне сделано (но я понимаю, что это не имеет значения, так как cmov cc занимает столько же циклов, что и, например, mov)

Для проверки свойств каждогоиз этих подходов я провел критические разделы через iaca.Если он установлен (и вы клонируете мой список тестов ниже), вы можете запустить make iaca, чтобы убедиться в этом.Передайте IACAFLAGS='-arch=...', чтобы указать другую арку.

Выход для подхода с переходом через ветвь:

Intel(R) Architecture Code Analyzer Version -  v3.0-28-g1ba2cbb build date: 2017-10-30;16:57:45
Analyzed File -  ./branch_over_call_iaca.o
Binary Format - 64Bit
Architecture  -  SKL
Analysis Type - Throughput

Throughput Analysis Report
--------------------------
Block Throughput: 0.82 Cycles       Throughput Bottleneck: Dependency chains
Loop Count:  36
Port Binding In Cycles Per Iteration:
--------------------------------------------------------------------------------------------------
|  Port  |   0   -  DV   |   1   |   2   -  D    |   3   -  D    |   4   |   5   |   6   |   7   |
--------------------------------------------------------------------------------------------------
| Cycles |  0.5     0.0  |  0.0  |  0.3     0.0  |  0.3     0.0  |  1.0  |  0.0  |  0.5  |  0.3  |
--------------------------------------------------------------------------------------------------

DV - Divider pipe (on port 0)
D - Data fetch pipe (on ports 2 and 3)
F - Macro Fusion with the previous instruction occurred
* - instruction micro-ops not bound to a port
^ - Micro Fusion occurred
# - ESP Tracking sync uop was issued
@ - SSE instruction followed an AVX256/AVX512 instruction, dozens of cycles penalty is expected
X - instruction not supported, was not accounted in Analysis

| Num Of   |                    Ports pressure in cycles                         |      |
|  Uops    |  0  - DV    |  1   |  2  -  D    |  3  -  D    |  4   |  5   |  6   |  7   |
-----------------------------------------------------------------------------------------
|   1      | 0.5         |      |             |             |      |      | 0.5  |      | jnle 0x6
|   4^#    |             |      | 0.3         | 0.3         | 1.0  |      |      | 0.3  | call 0x5
Total Num Of Uops: 5

И выход для подхода условного вызова:

Intel(R) Architecture Code Analyzer Version -  v3.0-28-g1ba2cbb build date: 2017-10-30;16:57:45
Analyzed File -  ./conditional_call_iaca.o
Binary Format - 64Bit
Architecture  -  SKL
Analysis Type - Throughput

Throughput Analysis Report
--------------------------
Block Throughput: 1.94 Cycles       Throughput Bottleneck: Dependency chains
Loop Count:  35
Port Binding In Cycles Per Iteration:
--------------------------------------------------------------------------------------------------
|  Port  |   0   -  DV   |   1   |   2   -  D    |   3   -  D    |   4   |   5   |   6   |   7   |
--------------------------------------------------------------------------------------------------
| Cycles |  1.0     0.0  |  1.0  |  0.5     0.0  |  0.5     0.0  |  1.0  |  1.0  |  1.0  |  0.0  |
--------------------------------------------------------------------------------------------------

DV - Divider pipe (on port 0)
D - Data fetch pipe (on ports 2 and 3)
F - Macro Fusion with the previous instruction occurred
* - instruction micro-ops not bound to a port
^ - Micro Fusion occurred
# - ESP Tracking sync uop was issued
@ - SSE instruction followed an AVX256/AVX512 instruction, dozens of cycles penalty is expected
X - instruction not supported, was not accounted in Analysis

| Num Of   |                    Ports pressure in cycles                         |      |
|  Uops    |  0  - DV    |  1   |  2  -  D    |  3  -  D    |  4   |  5   |  6   |  7   |
-----------------------------------------------------------------------------------------
|   1      |             | 1.0  |             |             |      |      |      |      | lea rax, ptr [rip]
|   2^     |             |      | 0.5         | 0.5         | 1.0  |      |      |      | mov qword ptr [rsp-0x8], rax
|   1      |             |      |             |             |      | 1.0  |      |      | lea rax, ptr [rsp-0x8]
|   1      | 1.0         |      |             |             |      |      |      |      | cmovl rsp, rax
|   1      |             |      |             |             |      |      | 1.0  |      | jl 0x6
Total Num Of Uops: 6

Похоже, что условный подход к вызову использует больше оборудования.Но мне показалось интересным, что условный подход имеет только 1 моп (в подходе ветвления было 5 моп).Я думаю, это имеет смысл, учитывая, что под капотом вызов превращается в push и jmp (а push превращается в rsp math и память mov).Это наводит меня на мысль, что подход условного вызова приблизительно эквивалентен (хотя, может быть, мой упрощенный анализ здесь ошибочен?).

По крайней мере, мое всеобъемлющее подозрение заключалось в введении нескольких инструкций между cmp иjl, я бы сделал возможным, чтобы результат cmp был доступен до того, как jl может быть спекулятивно выполнен (таким образом, предотвращая прогноз ветвления вообще).Хотя, возможно, трубопровод длиннее этого?Это затрагивает области, с которыми (несмотря на то, что я прочитал и сохранил понимание глубины Руководства по оптимизации Агнера Фога ), я не очень знаком.

Моя гипотеза состоит в том, что для равномерного распределения(отрицательный и положительный) num с (где предсказание ветвления не сможет предсказать ветвление вокруг call), что мой подход «условного вызова» превзойдет ветвление вокруг вызова.

Я написал жгут для оценки производительности этих двух подходов .Вы можете git clone https://gist.github.com/baileyparker/8a13c22d0e26396921f501fe87f166a9 и make запустить тесты на вашем компьютере.

Вот время выполнения 100 итераций каждого подхода на массиве из 1 048 576 чисел (равномерно распределенных между int32_t мин. И макс.).

|                    CPU                    | Conditional Call | Branch Over |
|-------------------------------------------|-----------------:|------------:|
| Intel(R) Core(TM) i7-7920HQ CPU @ 3.10GHz |       10.9872 ms |   8.4602 ms |
| Intel(R) Xeon(R) CPU E3-1240 v6 @ 3.70GHz |        8.8132 ms |   7.0704 ms |

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

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

Какие детали аппаратного обеспечения, которые я пропускаю (или неправильно), объясняютэтот?Из моих вычислений дополнительные инструкции добавляют где-то около 6-7 циклов, но ошибочное прогнозирование ветвлений стоит 15. Таким образом, в среднем половина чисел прогнозируется неверно, поэтому каждая итерация стоит 15/2 циклов (для подхода с переходом по ветвлению) и всегда 6-7 циклов для условного вызова.Упс из iaca предполагают, что подходы в этом отношении еще ближе.Так что, не должно ли выступление быть ближе?Является ли мой пример кода слишком надуманным / коротким?Разве моя методика бенчмаркинга не подходит для такого рода тестирования критических секций низкого уровня?Есть ли способ переупорядочить / изменить условный вызов, чтобы сделать его более производительным (лучше или сравнимым с подходом ветвления, может быть)?

tl; dr Почему мой код условного вызова(4-й фрагмент кода) работает хуже, чем производит gcc / clang (условный переход через call) (2-й фрагмент кода) (для кода в 1-м фрагменте) на моем тесте ?

Ответы [ 2 ]

0 голосов
/ 07 марта 2019

Вы можете точно определить, почему conditional_call подход медленнее, чем branch_over_call.Вы провели эксперименты на двух процессорах KBL, но в блоге , на который вы ссылались, не обсуждается, как работает RAS на KBL.Таким образом, первый шаг анализа состоит в том, чтобы определить, неправильно ли прогнозируется ret в функции negate (как это было бы в более ранних микроархитектурах).Вторым шагом является определение стоимости неправильного прогнозирования этой ret инструкции по общему времени выполнения.Самое близкое, что я имею к KBL, это CFL, и мои номера оказались близки к вашим.Единственное существенное различие между ними заключается в том, что LSD включен в CFL, но отключен в KBL.Однако LSD не имеет значения в этом случае из-за инструкции call в цикле, которая не позволяет LSD обнаруживать любой цикл.Вы также можете легко повторить тот же анализ на KBL.

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

Можно использовать события производительности BR_INST_RETIRED_* для подсчета общего числаудаленных динамических инструкций ветвления и общее количество определенных типов устаревших инструкций ветвления, включая условные, вызовы и возвраты.События BR_MISP_RETIRED_* могут использоваться для подсчета общего количества неверных прогнозов, общих условных ошибок и общих ошибочных прогнозов.

Полный контрольный график свечения conditional_call выглядит следующим образом:

           total   misp
call         1      0
    jl       1     0.5
       ret  0.5     1
    ret      1      0
jne          1      0

Первая инструкция call вызывает функцию conditional_call, которая содержит jl и ret.Инструкция jl условно переходит к функции negate, которая содержит ret.Инструкция jne используется для цикла.Числа, показанные в первом и втором столбцах, нормализуются по общему количеству итераций и общему количеству динамических инструкций соответственно.Из статической структуры программы мы знаем, что call, jl, conditional_call * ret и jne выполняются по одному разу в каждой итерации.Самый внутренний ret выполняется только тогда, когда берется ветвь jl.Используя события производительности, мы можем подсчитать общее количество выполненных инструкций возврата и вычесть из него общее количество итераций, чтобы получить количество выполнений самого внутреннего ret.Поскольку входные данные рандомизированы в соответствии с равномерным распределением, неудивительно, что самое внутреннее ret выполняется в половине случаев.

Инструкция call никогда не бывает ошибочной.Инструкция jne также никогда не прогнозируется неправильно, за исключением последнего выполнения инструкций (где оно выходит из цикла).Следовательно, мы можем приписать общее количество условных неправильных прогнозов команде jl.Это может быть вычтено из общего количества неправильных прогнозов, чтобы получить количество неправильных прогнозов возврата, которые могут быть отнесены к одной или обеим инструкциям возврата.Второе значение ret может быть неверно предсказано, если неправильное предсказание первого ret затруднит или сместит RAS.Один из способов определить, был ли когда-либо предсказан второй ret, состоит в использовании точной выборки BR_MISP_RETIRED.ALL_BRANCHES.Другой способ - использовать метод, описанный в сообщении в блоге, которое вы цитировали.В самом деле, только внутреннее большинство ret неверно предсказано.Тот факт, что jl ошибочно предсказан в половине случаев, говорит о том, что инструкция либо предсказывается, либо всегда выполняется, либо всегда не принимается.

Полный график контрольного свечения branch_over_call выглядит следующим образом:

           total   misp
call         1      0
    jg       1     0.5
    call    0.5     0
        ret 0.5     0
    ret      1      0
jne          1      0

Единственная неверно предсказанная инструкция - это jg, которая ошибается в половине случаев.

Чтобы измерить среднюю стоимость одного неверного прогноза ret в подходе conditional_call, инструкцию ret можно заменить последовательностью lea/jmp, чтобы для прогнозирования использовался BTB, а не RAS.С этим изменением единственная неправильная инструкция - это jl.Разницу во времени выполнения можно рассматривать как оценку общей стоимости ret неправильных прогнозов.На моем процессоре CFL это примерно 11,3 цикла на ret неправильное прогнозирование.Кроме того, conditional_call стал примерно на 3% быстрее, чем branch_over_call.Ваши цифры в KBL показывают, что средняя стоимость неправильного прогноза ret составляет около 13 циклов.Я не уверен, в чем причина этой разницы.Это не может быть микроархитектура.Я использовал gcc 7.3, но вы использовали gcc 8, так что, возможно, есть некоторые различия в коде или выравнивании различных частей кода, которые вызывают расхождения между нашими результатами.

0 голосов
/ 01 марта 2019

Как отметил @fuz в комментариях, проблема производительности почти наверняка связана с стеком адресов возврата (RAS) , который является специализированным предиктором ветвления для возвратов функций.

В качестве преимущества наличия отдельных call и ret инструкций от jmp и ручной модификации стека, процессоры включаются в смысл выполняемого кода.В частности, когда мы call функция, она, вероятно, собирается ret, а когда это произойдет, мы собираемся вернуться к rip, выдвинутому до call.Другими словами, call s обычно в паре с ret.Процессор использует это, сохраняя стек фиксированной длины просто адресами возврата, называемыми стеком адресов возврата (RAS).call инструкции в дополнение к отправке адреса возврата в реальный стек в памяти дополнительно передадут его в RAS.Таким образом, когда встречается ret, ЦП может выскочить из RAS (что намного быстрее, чем доступ к памяти для фактического стека) и спекулятивно выполнить возврат.Если окажется, что адрес, извлеченный из RAS, был адресом, извлеченным из стека, ЦП продолжает работу без штрафа.Однако, если RAS предсказал неправильный адрес возврата, происходит сброс конвейера, что является дорогостоящим.

Моя первоначальная интуиция заключалась в том, что условные инструкции будут лучше, поскольку они дадут время для получения результата сравнения.перед прыжком.Однако, какую бы пользу это ни принесло, имея несбалансированный jmp / ret (мой условный вызов заменил call на jmp, но вызываемая функция все еще использовала ret), что заставило RAS, вероятно, всегда прогнозироватьнеправильный адрес возврата (и, таким образом, мой подход, несмотря на то, что изначально пытался избежать этого, вызывает больше остановок конвейера).Ускорение от RAS более значимо, чем моя «оптимизация», поэтому подход с ветвлением превзошел подход условного вызова.

Согласно некоторые эмпирические результаты несоответствие call и ret (вв частности, использование jmp + ret) занимает в 5-6 раз больше циклов, чем правильное соединение call и ret.Некоторая математика для салфеток предполагает, что штраф в +21 такт при 3,1 ГГц для 1048 576 вызовов добавляет около 7,1 мс к общему времени выполнения.Наблюдаемое замедление было меньше чем это.Вероятно, это комбинация условных инструкций, задерживающих переход до тех пор, пока условие не будет готово, и тот факт, что переходы колеблются между фиксированными точками в памяти (что другие предсказатели ветвей, вероятно, стали хорошими в прогнозировании).

...