Как планируется x86 uops?

Современные процессоры 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? В частности:

  1. Когда на станции резервирования будет готово несколько мопов, в каком порядке они запланированы для портов?
  2. Когда UOP может перейти на несколько портов (например, add и lea в приведенном выше примере), как определяется, какой порт выбран?
  3. Если какой-либо из ответов включает в себя концепцию, подобную самой старой, для выбора среди мопов, как она определяется? Возраст с момента его доставки в РС? Возраст с тех пор, как он стал готов? Как нарушаются связи? Приходит ли когда-нибудь порядок программ?

Результаты на 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 Обратите внимание, что я не утверждаю, что это плохой дизайн или что-то в этом роде - вероятно, есть очень веские аппаратные причины, по которым планировщик не может легко принимать все свои решения во время выполнения.

Ответ 1

Ваши вопросы сложны по нескольким причинам:

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

Тем не менее, я постараюсь ответить...

Когда на станции резервирования готовы несколько устройств, в каком порядке они запланированы для портов?

Он должен быть самым старым [см. ниже], но ваш пробег может отличаться. Микроархитектура P6 (используемая в Pentium Pro, 2 и 3) использовала станцию ​​резервирования с пятью планировщиками (по одному на порт исполнения); планировщики использовали указатель приоритета в качестве места для начала сканирования для отправки готовых uops. Это был только псевдо-FIFO, поэтому вполне возможно, что самая старая готовая инструкция не всегда была запланирована. В микроархитектуре NetBurst (используется в Pentium 4) они отбросили единую резервную станцию ​​и вместо этого использовали две очереди. Это были правильные коллапсирующие очереди приоритетов, поэтому планировщикам гарантировалось получение самой старой готовой инструкции. Архитектура ядра вернулась на станцию ​​резервирования, и я рискнул бы получить обоснованное предположение, что они использовали свернутую очередь приоритетов, но я не могу найти источник, подтверждающий это. Если у кого-то есть окончательный ответ, я все уши.

Когда uop может перейти на несколько портов (например, add и lea в приведенном выше примере), как определяется, какой порт выбран?

Это сложно понять. Лучшее, что я мог найти, это патент от Intel, описывающий такой механизм. По существу, они сохраняют счетчик для каждого порта с избыточными функциональными единицами. Когда выходы покидают передний край станции резервирования, им назначается порт отправки. Если необходимо решить несколько избыточных исполнительных блоков, счетчики используются для равномерного распределения работы. Счетчики увеличиваются и уменьшаются по мере ввода и выхода из резервной станции соответственно.

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

В любом случае наличие патента необязательно означает, что идея была принята (хотя, как сказано, один из авторов был также техническим руководством Pentium 4, так кто знает?)

Если какой-либо из ответов включает в себя концепцию, такую ​​как старейший, чтобы выбрать среди uops, как она определяется? Возраст с тех пор, как он был доставлен в РС? Возраст с тех пор, как он стал готов? Как нарушаются связи? Применяется ли в нем порядок программирования?

Так как uops вставляются в станцию ​​резервирования по порядку, самый старый здесь действительно относится к времени, когда он вступил в станцию ​​резервирования, то есть самый старый в заказе программы.

Кстати, я бы взял эти результаты IACA с солью, поскольку они могут не отражать нюансы реального оборудования. В Haswell есть счетчик аппаратных средств, называемый uops_executed_port, который может рассказать вам, сколько циклов в вашем потоке было проблемой uops для портов 0-7. Возможно, вы могли бы использовать их для лучшего понимания своей программы?

Ответ 2

Вот что я нашел на Skylake, исходя из того, что мопы назначаются портам во время выдачи (т.е. когда они выдаются в RS), а не во время отправки (т.е. в момент, когда они отправляются на выполнение), Прежде чем я понял, что решение о порте было принято во время отправки.

Я провел множество тестов, которые пытались выделить последовательности операций add, которые могут перейти к операциям p0156 и imul, которые идут только к порту 0. Типичный тест выполняется примерно так:

mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]

... many more mov instructions

mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]

imul ebx, ebx, 1
imul ebx, ebx, 1
imul ebx, ebx, 1
imul ebx, ebx, 1

add r9, 1
add r8, 1
add ecx, 1
add edx, 1

add r9, 1
add r8, 1
add ecx, 1
add edx, 1

add r9, 1
add r8, 1
add ecx, 1
add edx, 1

mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]

... many more mov instructions

mov eax, [edi]
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]

Как правило, существует длинный ввод инструкций mov eax, [edi], которые выдаются только на p23 и, следовательно, не засоряют порты, используемые инструкциями (я мог бы также использовать инструкции nop, но тест будет немного другим, так как nop не выдает RS). Далее следует раздел "полезная нагрузка", состоящий из 4 imul и 12 add, а затем выводной раздел с более фиктивными инструкциями mov.

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

enter image description here

Эта таблица используется для выбора между p0 или p1 для 3-х мопов в группе вопросов для архитектуры с 3-мя ширинами, обсуждаемой в патенте. Обратите внимание, что поведение зависит от положения мопа в группе, и что существует 4 правила 1, основанные на количестве, которые логически распределяют мопы вокруг. В частности, количество должно быть на уровне + / - 2 или больше, прежде чем всей группе будет назначен недостаточно используемый порт.

Давайте посмотрим, сможем ли мы наблюдать, как "положение в группе вопросов" влияет на поведение Склейка. Мы используем полезную нагрузку одного add, например:

add edx, 1     ; position 0
mov eax, [edi]
mov eax, [edi]
mov eax, [edi]

... и мы перемещаем его внутри 4-х инструкций, как:

mov eax, [edi]
add edx, 1      ; position 1
mov eax, [edi]
mov eax, [edi]

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

  • Первые инструкции add переходят к p5 или p6, причем выбранный порт обычно чередуется по мере замедления выполнения команды (т.е. инструкции add в четных позициях переходят в p5, а в нечетных позициях переходят в p6).
  • Вторая инструкция add также относится к p56 - в зависимости от того, к какому из двух не пришел первый.
  • После этого дальнейшие инструкции add начинают уравновешиваться вокруг p0156, с p5 и p6, как правило, впереди, но с вещами довольно равномерными (т.е. разрыв между p56 и двумя другими портами не увеличивается).

Далее я взглянул на то, что происходит, если загрузить p1 с помощью операций imul, а затем сначала выполнить несколько операций add:

imul ebx, ebx, 1
imul ebx, ebx, 1
imul ebx, ebx, 1
imul ebx, ebx, 1

add r9, 1
add r8, 1
add ecx, 1
add edx, 1

add r9, 1
add r8, 1
add ecx, 1
add edx, 1

add r9, 1
add r8, 1
add ecx, 1
add edx, 1

Результаты показывают, что планировщик хорошо справляется с этим - все imul перешло в запланированное время до p1 (как и ожидалось), а затем ни одна из последующих инструкций add не пошла в p1, а затем была распространена вокруг p056 вместо. Так что здесь планирование работает хорошо.

Конечно, когда ситуация меняется на противоположную, и серия imul появляется после add s, p1 загружается со своей долей добавлений до попадания imul. Это является результатом назначения портов, происходящего по порядку во время выпуска, так как отсутствует механизм "смотреть в будущее" и видеть imul при планировании add.

В целом, планировщик выглядит хорошо в этих тестовых случаях.

Это не объясняет, что происходит в меньших, более плотных циклах, таких как:

sub r9, 1
sub r10, 1
imul ebx, edx, 1
dec ecx
jnz top

Как и Пример 4 в моем вопросе, этот цикл заполняет p0 только на ~ 30% циклов, несмотря на наличие двух инструкций sub, которые должны быть в состоянии перейти к p0 на каждый цикл. p1 и p6 переподписаны, каждый выполняя по 1,24 моп для каждой итерации (1 - идеальный вариант). Я не смог триангулировать разницу между примерами, которые хорошо работают в верхней части этого ответа, с помощью плохих циклов - но есть еще много идей, которые можно попробовать.

Я заметил, что примеры без указания задержек, похоже, не страдают от этой проблемы. Например, вот еще одна 4-х контурная петля с "сложным" давлением порта:

top:
    sub r8, 1
    ror r11, 2
    bswap eax
    dec ecx
    jnz top

Карта Uop выглядит следующим образом:

instr   p0 p1 p5 p6 
sub      X  X  X  X
ror      X        X
bswap       X  X   
dec/jnz           X

Таким образом, sub всегда должен идти к p15, которым делятся с bswap, если что-то пойдет на пользу. Они делают:

Статистика счетчика производительности для "./sched-test2" (2 прогона):

   999,709,142      uops_dispatched_port_port_0                                     ( +-  0.00% )
   999,675,324      uops_dispatched_port_port_1                                     ( +-  0.00% )
   999,772,564      uops_dispatched_port_port_5                                     ( +-  0.00% )
 1,000,991,020      uops_dispatched_port_port_6                                     ( +-  0.00% )
 4,000,238,468      uops_issued_any                                               ( +-  0.00% )
 5,000,000,117      instructions:u            #    4.99  insns per cycle          ( +-  0.00% )
 1,001,268,722      cycles:u                                                      ( +-  0.00% )

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


1 В таблице 5 правил, но правила для счетчиков 0 и -1 идентичны.

2 Конечно, я не могу быть уверен, где начинаются и заканчиваются группы вопросов, но независимо от того, как мы тестируем четыре разные позиции, когда скатываемся вниз по четырем инструкциям (но метки могут быть неправильными). Я также не уверен, что максимальный размер группы проблем равен 4 - более ранние части конвейера шире - но я верю, что это так, и некоторые тесты, казалось, показали, что это так (циклы с кратным числом 4 мопов показали согласованное поведение планирования). В любом случае выводы верны при разных размерах групп планирования.