Современные процессоры x86 разбивают входящий поток инструкций на микрооперации (uops 1) и затем планируют эти uops вне очереди, когда их входные данные становятся готовыми. Хотя основная идея ясна, я хотел бы знать конкретные детали того, как планируются готовые инструкции, поскольку это влияет на решения по микрооптимизации.
Например, возьмите следующий игрушечный цикл 2:
top:
lea eax, [ecx + 5]
popcnt eax, eax
add edi, eax
dec ecx
jnz top
это в основном реализует цикл (со следующим соответствием: eax -> total, c -> ecx
):
do {
total += popcnt(c + 5);
} while (--c > 0);
Я знаком с процессом оптимизации любого маленького цикла, рассматривая разбивку uop, задержки в цепочке зависимостей и так далее. В приведенном выше цикле у нас есть только одна переносимая цепочка зависимостей: dec ecx
. Первые три инструкции цикла (lea
, imul
, add
) являются частью цепочки зависимостей, которая начинает каждый цикл заново.
Финальные dec
и jne
слиты. Таким образом, мы имеем в общей сложности 4 мопа слитых доменов и одну цепочку зависимостей с циклом переноса с задержкой в 1 цикл. Исходя из этого критерия, кажется, что цикл может выполняться за 1 цикл/итерацию.
Тем не менее, мы также должны посмотреть на давление в порту:
lea
может выполняться на портах 1 и 5- Popcnt может выполняться на порту 1
add
может выполняться на портах 0, 1, 5 и 6- Прогнозируемый
jnz
выполняется на порту 6
Таким образом, чтобы перейти к 1 циклу/итерации, вам необходимо выполнить следующее:
- Popcnt должен выполняться на порту 1 (единственный порт, на котором он может выполняться)
lea
должен выполняться на порту 5 (и никогда на порту 1)add
должен выполняться на порту 0, и никогда ни на одном из трех других портов он не может выполняться на- В любом случае
jnz
может выполняться только на порту 6
Это много условий! Если бы инструкции были запланированы случайным образом, вы могли бы получить намного худшую пропускную способность. Например, 75% add
будет идти в порт 1, 5 или 6, что приведет к задержке popcnt
, lea
или jnz
на один цикл. Аналогично для lea
, который может использовать два порта, один используется совместно с popcnt
.
IACA, с другой стороны, сообщает о результате, очень близком к оптимальному, 1,05 цикла на итерацию:
Intel(R) Architecture Code Analyzer Version - 2.1
Analyzed File - l.o
Binary Format - 64Bit
Architecture - HSW
Analysis Type - Throughput
Throughput Analysis Report
--------------------------
Block Throughput: 1.05 Cycles Throughput Bottleneck: FrontEnd, Port0, Port1, Port5
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.0 0.0 | 0.0 0.0 | 0.0 | 1.0 | 0.9 | 0.0 |
---------------------------------------------------------------------------------------
N - port number or number of cycles resource conflict caused delay, DV - Divider pipe (on port 0)
D - Data fetch pipe (on ports 2 and 3), CP - on a critical path
F - Macro Fusion with the previous instruction occurred
* - instruction micro-ops not bound to a port
^ - Micro Fusion happened
# - ESP Tracking sync uop was issued
@ - SSE instruction followed an AVX256 instruction, dozens of cycles penalty is expected
! - 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 | | | CP | lea eax, ptr [ecx+0x5]
| 1 | | 1.0 | | | | | | | CP | popcnt eax, eax
| 1 | 0.1 | | | | | 0.1 | 0.9 | | CP | add edi, eax
| 1 | 0.9 | | | | | | 0.1 | | CP | dec ecx
| 0F | | | | | | | | | | jnz 0xfffffffffffffff4
Он в значительной степени отражает необходимое "идеальное" планирование, о котором я упоминал выше, с небольшим отклонением: он показывает порт 5 кражи add
из lea
на 1 из 10 циклов. Он также не знает, что слитая ветвь собирается перейти на порт 6, так как это предсказано, как принятый, поэтому он помещает большую часть мопов для ветки на порт 0, и большую часть мопов для add
на порт 6, а не наоборот.
Неясно, являются ли дополнительные 0,05 цикла, которые IACA сообщает по оптимальному, результатом некоторого глубокого, точного анализа или менее проницательного следствия алгоритма, который он использует, например, анализ цикла по фиксированному числу циклов, или просто ошибка или что-то еще. То же самое касается 0,1 доли мопа, которая, по ее мнению, попадет в неидеальный порт. Также не ясно, если одно объясняет другое - я думаю, что неправильное назначение порта 1 из 10 приведет к счету циклов 11/10 = 1,1 цикла на итерацию, но я не определил фактический нисходящий поток. результаты - может быть, влияние меньше в среднем. Или это может быть просто округление (от 0,05 == 0,1 до 1 десятичного знака).
Так как же на самом деле планируются современные процессоры x86? В частности:
- Когда на станции резервирования будет готово несколько мопов, в каком порядке они запланированы для портов?
- Когда UOP может перейти на несколько портов (например,
add
иlea
в приведенном выше примере), как определяется, какой порт выбран? - Если какой-либо из ответов включает в себя концепцию, подобную самой старой, для выбора среди мопов, как она определяется? Возраст с момента его доставки в РС? Возраст с тех пор, как он стал готов? Как нарушаются связи? Приходит ли когда-нибудь порядок программ?
Результаты на Skylake
Позвольте измерить некоторые реальные результаты на Skylake, чтобы проверить, какие ответы объясняют экспериментальные данные, так что вот некоторые реальные измеренные результаты (из perf
) на моей коробке Skylake. Смущает, что я собираюсь переключиться на использование imul
для моей инструкции "выполняется только на одном порту", поскольку у нее много вариантов, включая версии с тремя аргументами, которые позволяют вам использовать разные регистры для источника (я) и места назначения. Это очень удобно при создании цепочек зависимостей. Это также позволяет избежать всей "неправильной зависимости от пункта назначения", которой обладает popcnt
.
Независимые инструкции
Начнем с рассмотрения простого (?) Случая, когда инструкции относительно независимы - без каких-либо цепочек зависимостей, кроме тривиальных, таких как счетчик цикла.
Здесь 4 моп циклы (только 3 выполненных мопа) с умеренным давлением. Все инструкции независимы (не указывайте ни источники, ни пункты назначения). add
может в принципе украсть p1
, необходимый для imul
или p6
, необходимый для dec:
Пример 1
instr p0 p1 p5 p6
xor (elim)
imul X
add X X X X
dec X
top:
xor r9, r9
add r8, rdx
imul rax, rbx, 5
dec esi
jnz top
The results is that this executes with perfect scheduling at 1.00 cycles / iteration:
560,709,974 uops_dispatched_port_port_0 ( +- 0.38% )
1,000,026,608 uops_dispatched_port_port_1 ( +- 0.00% )
439,324,609 uops_dispatched_port_port_5 ( +- 0.49% )
1,000,041,224 uops_dispatched_port_port_6 ( +- 0.00% )
5,000,000,110 instructions:u # 5.00 insns per cycle ( +- 0.00% )
1,000,281,902 cycles:u
( +- 0.00% )
Как и ожидалось, p1
и p6
полностью используются imul
и dec/jnz
соответственно, а затем add
выдает примерно половину между оставшимися доступными портами. Обратите внимание, что фактическое соотношение составляет 56% и 44%, и это соотношение довольно стабильно во всех прогонах (обратите внимание на вариацию +- 0.49%
). Если я отрегулирую выравнивание циклы, разделение изменится (53/46 для выравнивания 32B, больше похоже на 57/42 для выравнивания 32B + 4). Теперь мы ничего не изменим, кроме позиции imul
в цикле:
Пример 2
top:
imul rax, rbx, 5
xor r9, r9
add r8, rdx
dec esi
jnz top
Затем внезапно разделение p0
/p5
составило ровно 50%/50% с отклонением 0,00%:
500,025,758 uops_dispatched_port_port_0 ( +- 0.00% )
1,000,044,901 uops_dispatched_port_port_1 ( +- 0.00% )
500,038,070 uops_dispatched_port_port_5 ( +- 0.00% )
1,000,066,733 uops_dispatched_port_port_6 ( +- 0.00% )
5,000,000,439 instructions:u # 5.00 insns per cycle ( +- 0.00% )
1,000,439,396 cycles:u ( +- 0.01% )
Так что уже интересно, но сложно сказать, что происходит. Возможно, точное поведение зависит от начальных условий при входе в цикл и чувствительно к упорядочению в цикле (например, потому что используются счетчики). Этот пример показывает, что происходит нечто большее, чем "случайное" или "глупое" планирование. В частности, если вы просто удалите инструкцию imul
из цикла, вы получите следующее:
Пример 3
330,214,329 uops_dispatched_port_port_0 ( +- 0.40% )
314,012,342 uops_dispatched_port_port_1 ( +- 1.77% )
355,817,739 uops_dispatched_port_port_5 ( +- 1.21% )
1,000,034,653 uops_dispatched_port_port_6 ( +- 0.00% )
4,000,000,160 instructions:u # 4.00 insns per cycle ( +- 0.00% )
1,000,235,522 cycles:u ( +- 0.00% )
Здесь add
теперь примерно равномерно распределен между p0
, p1
и p5
- поэтому наличие imul
действительно повлияло на планирование add
: это было не просто следствие некоторого правила "избегать порта 1".
Обратите внимание, что общее давление порта составляет всего 3 моп/цикл, поскольку xor
является идиомой обнуления и устраняется в переименователе. Давайте попробуем с максимальным давлением 4 моп. Я ожидаю, что любой механизм, задействованный выше, сможет идеально спланировать это. Мы изменяем только xor r9, r9
на xor r9, r10
, так что это больше не означает обнуление. Мы получаем следующие результаты:
Пример 4
top:
xor r9, r10
add r8, rdx
imul rax, rbx, 5
dec esi
jnz top
488,245,238 uops_dispatched_port_port_0 ( +- 0.50% )
1,241,118,197 uops_dispatched_port_port_1 ( +- 0.03% )
1,027,345,180 uops_dispatched_port_port_5 ( +- 0.28% )
1,243,743,312 uops_dispatched_port_port_6 ( +- 0.04% )
5,000,000,711 instructions:u # 2.66 insns per cycle ( +- 0.00% )
1,880,606,080 cycles:u ( +- 0.08% )
Oops! Вместо того, чтобы равномерно планировать все в p0156
, планировщик недоиспользовал p0
(он только выполняет что-то ~ 49% циклов), и, следовательно, p1
и p6
переподписаны, потому что они выполняют обе свои требуемые операции p0
. TG462] и dec/jnz
. Такое поведение, я думаю, согласуется с индикатором давления на основе счетчика, как указано в ответе hayesti, и с тем, что мопы назначаются порту во время выпуска, а не во время выполнения, поскольку оба
Хайести и Питер Кордес упоминаются. Такое поведение 3 делает выполнение самого старого правила готовых мопов не таким эффективным. Если бы мопы не были связаны с портами исполнения в вопросе, а скорее во время исполнения, то это "самое старое" правило решило бы проблему выше после одной итерации - однажды один imul
и один dec/jnz
были задержаны для одной итерации, они всегда будут старше конкурирующих инструкций xor
и add
, поэтому всегда должны планироваться первыми. Однако я усвоил одну вещь: если порты назначаются во время выдачи, это правило не помогает, поскольку порты заранее определены во время выдачи. Я думаю, это все еще немного помогает в одобрении инструкций, которые являются частью длинных цепочек зависимости (так как они, как правило, отстают), но это не панацея, как я думал.
Это также объясняет приведенные выше результаты: p0
получает больше давления, чем на самом деле, потому что комбо dec/jnz
теоретически может выполняться на p06
. На самом деле, поскольку ветвление предсказывается, оно берется только в p6
, но, возможно, эта информация не может поступать в алгоритм балансировки давления, поэтому счетчики имеют тенденцию видеть одинаковое давление на p016
, что означает, что add
и xor
распространяется не так, как оптимально.
Вероятно, мы можем проверить это, немного развернув цикл, чтобы jnz
был менее важным фактором...
1 Хорошо, это правильно написано "мопс", но это убивает возможность поиска и фактически вводит "μ" символ, который я обычно прибегаю к копированию и вставке символа с веб-страницы.
2 Я первоначально использовал imul
вместо popcnt
в цикле, но, невероятно, IACA не поддерживает его!
3 Обратите внимание, что я не утверждаю, что это плохой дизайн или что-то в этом роде - вероятно, есть очень веские аппаратные причины, по которым планировщик не может легко принимать все свои решения во время выполнения.