Может ли MOV x86 действительно быть "свободным"? Почему я не могу воспроизвести это вообще?

Я продолжаю видеть, как люди утверждают, что инструкция MOV может быть бесплатной в x86 из-за переименования регистров.

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

Например, вот код, который я компилирую с Visual C++:

#include <limits.h>
#include <stdio.h>
#include <time.h>

int main(void)
{
    unsigned int k, l, j;
    clock_t tstart = clock();
    for (k = 0, j = 0, l = 0; j < UINT_MAX; ++j)
    {
        ++k;
        k = j;     // <-- comment out this line to remove the MOV instruction
        l += j;
    }
    fprintf(stderr, "%d ms\n", (int)((clock() - tstart) * 1000 / CLOCKS_PER_SEC));
    fflush(stderr);
    return (int)(k + j + l);
}

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

LOOP:
    add edi,esi
    mov ebx,esi
    inc esi
    cmp esi,FFFFFFFFh
    jc  LOOP

Теперь я запускаю эту программу несколько раз и наблюдаю довольно постоянную разницу в 2% при удалении инструкции MOV:

Without MOV      With MOV
  1303 ms         1358 ms
  1324 ms         1363 ms
  1310 ms         1345 ms
  1304 ms         1343 ms
  1309 ms         1334 ms
  1312 ms         1336 ms
  1320 ms         1311 ms
  1302 ms         1350 ms
  1319 ms         1339 ms
  1324 ms         1338 ms

Так что же дает? Почему MOV не "бесплатно"? Этот цикл слишком сложен для x86?
Есть ли один пример, который может продемонстрировать, что MOV свободен, как утверждают люди?
Если так, то, что это? И если нет, то почему все продолжают утверждать, что MOV является бесплатным?

2 ответа

Решение

Пропускная способность рассматриваемого цикла не зависит от задержки MOV или (от Haswell) преимущества неиспользования исполнительного блока.

Цикл все еще только 4 мопа для внешнего интерфейса, чтобы выдать в ядро ​​не в порядке. (mov все еще должен отслеживаться неработающим ядром, даже если ему не нужен исполнительный блок, но cmp/jc макроплавки в один моп).

У процессоров Intel начиная с Core2 была проблема ширины 4 мопа за такт, поэтому mov не мешает ему выполнять (близко к) один итер в час на Haswell. Это также будет работать по одному за часы на Ivybridge (с устранением mov), но не на Sandybridge (без устранения mov). На SnB это будет около одного итера на циклы 1.333c, узкое место в пропускной способности ALU, потому что mov всегда будет нужен (У SnB/IvB есть только три порта ALU, в то время как у Haswell четыре).

Обратите внимание, что специальная обработка на этапе переименования была для x87 FXCHG (swap st0 с st1) гораздо дольше чем мов. Agner Fog перечисляет FXCHG как 0 латентности на PPro/PII/PIII (ядро P6 первого поколения).


Цикл в вопросе имеет две взаимосвязанные цепочки зависимостей (add edi,esi зависит от EDI и счетчика циклов ESI), что делает его более чувствительным к несовершенному планированию. Замедление на 2% по сравнению с теоретическим прогнозом из-за, казалось бы, несвязанных инструкций не является необычным, и небольшие различия в порядке инструкций могут иметь такого рода различие. Чтобы работать с точностью ровно 1 с на итерацию, в каждом цикле необходимо запускать INC и ADD. Поскольку все INC и ADD зависят от предыдущей итерации, выполнение не по порядку не может наверстать упущенное при запуске двух за один цикл. Хуже того, ADD зависит от INC в предыдущем цикле, что я и имел в виду под "блокировкой", поэтому потеря цикла в депо-цепочке INC также останавливает депо-цепочку ADD.

Кроме того, предсказанные ветки могут выполняться только на порту 6, поэтому любой цикл, когда порт 6 не выполняет cmp/jc, является циклом с потерянной пропускной способностью. Это происходит каждый раз, когда INC или ADD крадет цикл на порту 6 вместо того, чтобы работать на портах 0, 1 или 5. IDK, если это является причиной, или если потеря циклов в самих цепочках депозита INC/ADD является проблемой, или, возможно, некоторые из обоих.

Добавление дополнительного MOV не добавляет никакого давления на порт выполнения, предполагая, что оно устранено на 100%, но оно останавливает запуск внешнего интерфейса перед ядром выполнения. (Только 3 из 4 мопов в цикле нуждаются в исполнительном модуле, и ваш процессор Haswell может запускать INC и ADD на любом из своих 4 портов ALU: 0, 1, 5 и 6. Таким образом, узкими местами являются:

  • максимальная пропускная способность переднего конца 4 мопов за такт. (Цикл без MOV составляет всего 3 мопа, поэтому интерфейс может работать впереди).
  • Пропускная способность по одному на такт.
  • цепочка зависимостей, включающая esi (Задержка INC 1 в час)
  • цепочка зависимостей, включающая edi (Задержка при добавлении 1 за такт, а также зависит от INC из предыдущей итерации)

Без MOV клиентский интерфейс может выдавать три мопа цикла по 4 за такт, пока ядро ​​не выйдет из строя. (AFAICT, он "разворачивает" крошечные петли в буфере петель (Loop Stream Detector: LSD), поэтому цикл с мопами ABC может выдавать паттерн ABCA BCAB CABC .... Счетчик перфокарт для lsd.cycles_4_uops подтверждает, что это в основном проблемы в группах по 4, когда он выпускает любые мопс.)

Процессоры Intel присваивают порты мопам по мере их выхода в неработающее ядро. Решение основано на счетчиках, которые отслеживают, сколько мопов для каждого порта уже находится в планировщике (или Reservation Station, RS). Когда в RS имеется много мопов, ожидающих выполнения, это работает хорошо, и обычно следует избегать планирования INC или ADD для port6. И я предполагаю также избегать планирования INC и ADD таким образом, чтобы время было потеряно из любой из этих цепочек деп. Но если RS пуст или почти пуст, счетчики не остановят ADD или INC от кражи цикла на порту6.

Я думал, что я на что-то здесь, но любое неоптимальное планирование должно позволить переднему концу нагнать и поддерживать задний конец полный. Я не думаю, что мы должны ожидать, что внешний интерфейс вызовет достаточно пузырьков в конвейере, чтобы объяснить падение на 2% ниже максимальной пропускной способности, поскольку крошечный цикл должен работать из буфера цикла с очень постоянной пропускной способностью 4 на такт. Может быть, что-то еще происходит.


Реальный пример пользы mov устранение.

я использовал lea построить цикл, который имеет только один mov за час, создавая идеальную демонстрацию, где устранение MOV успешно 100%, или 0% времени с mov same,same продемонстрировать латентное узкое место, которое производит.

Так как макрос слиты dec/jnz является частью цепочки зависимостей, включающей счетчик цикла, несовершенное планирование не может задержать его. Это отличается от случая, когда cmp/jc "разветвляется" из цепочки зависимостей критического пути на каждой итерации.

_start:
    mov     ecx, 2000000000 ; each iteration decrements by 2, so this is 1G iters
align 16  ; really align 32 makes more sense in case the uop-cache comes into play, but alignment is actually irrelevant for loops that fit in the loop buffer.
.loop:
    mov eax, ecx
    lea ecx, [rax-1]    ; we vary these two instructions

    dec ecx             ; dec/jnz macro-fuses into one uop in the decoders, on Intel
    jnz .loop

.end:
    xor edi,edi    ; edi=0
    mov eax,231    ; __NR_exit_group from /usr/include/asm/unistd_64.h
    syscall        ; sys_exit_group(0)

В семействе Intel SnB LEA с одним или двумя компонентами в режиме адресации работает с задержкой 1с (см. http://agner.org/optimize/ и другие ссылки в вики-теге x86).

Я построил и запустил это как статический двоичный файл в Linux, поэтому счетчики перфектов в пользовательском пространстве для всего процесса измеряют только цикл с незначительными накладными расходами при запуске / завершении работы. (perf stat это действительно легко по сравнению с помещением запросов perf-counter в саму программу)

$ yasm -felf64 -Worphan-labels -gdwarf2 mov-elimination.asm && ld -o mov-elimination mov-elimination.o &&
  objdump -Mintel -drwC mov-elimination &&
  taskset -c 1 ocperf.py stat -etask-clock,context-switches,page-faults,cycles,instructions,branches,uops_issued.any,uops_executed.thread  -r2 ./mov-elimination

Disassembly of section .text:

00000000004000b0 <_start>:
  4000b0:       b9 00 94 35 77          mov    ecx,0x77359400
  4000b5:       66 66 2e 0f 1f 84 00 00 00 00 00        data16 nop WORD PTR cs:[rax+rax*1+0x0]

00000000004000c0 <_start.loop>:
  4000c0:       89 c8                   mov    eax,ecx
  4000c2:       8d 48 ff                lea    ecx,[rax-0x1]
  4000c5:       ff c9                   dec    ecx
  4000c7:       75 f7                   jne    4000c0 <_start.loop>

00000000004000c9 <_start.end>:
  4000c9:       31 ff                   xor    edi,edi
  4000cb:       b8 e7 00 00 00          mov    eax,0xe7
  4000d0:       0f 05                   syscall 

perf stat -etask-clock,context-switches,page-faults,cycles,instructions,branches,cpu/event=0xe,umask=0x1,name=uops_issued_any/,cpu/event=0xb1,umask=0x1,name=uops_executed_thread/ -r2 ./mov-elimination

 Performance counter stats for './mov-elimination' (2 runs):

    513.242841      task-clock:u (msec)       #    1.000 CPUs utilized    ( +-  0.05% )
             0      context-switches:u        #    0.000 K/sec                  
             1      page-faults:u             #    0.002 K/sec                  
 2,000,111,934      cycles:u                  #    3.897 GHz              ( +-  0.00% )
 4,000,000,161      instructions:u            #    2.00  insn per cycle   ( +-  0.00% )
 1,000,000,157      branches:u                # 1948.396 M/sec            ( +-  0.00% )
 3,000,058,589      uops_issued_any:u         # 5845.300 M/sec            ( +-  0.00% )
 2,000,037,900      uops_executed_thread:u    # 3896.865 M/sec            ( +-  0.00% )

   0.513402352 seconds time elapsed                                          ( +-  0.05% )

Как и ожидалось, цикл запускается 1G раз (branches ~ = 1 миллиард). "Дополнительные" 111k циклов после 2G - это издержки, которые присутствуют и в других тестах, в том числе и без mov, Это не случайный сбой mov-elission, но он масштабируется с счетчиком итераций, так что это не просто накладные расходы при запуске. Это, вероятно, от прерывания по таймеру, так как IIRC Linux perf не возиться с счетчиками перфораторов при обработке прерываний, а просто позволяет им продолжать считать. (perf виртуализирует аппаратные счетчики производительности, чтобы вы могли получать счетчики для каждого процесса, даже когда поток мигрирует между процессорами.) Кроме того, прерывания по таймеру на логическом ядре, принадлежащем к одному и тому же физическому ядру, будут немного мешать.

Узким местом является цепочка зависимостей, переносимая циклами, включающая счетчик циклов. Циклы 2G для 1G iters - 2 такта на итерацию или 1 такт на декремент. Это подтверждает, что длина цепи dep составляет 2 цикла. Это возможно только если mov имеет нулевую задержку. (Я знаю, что это не доказывает, что не существует какого-то другого узкого места. Это действительно только доказывает, что задержка составляет не более 2 циклов, если вы не верите моему утверждению, что задержка является единственным узким местом. resource_stalls.any счетчик перфектов, но у него не так много вариантов разбить, какой микроархитектурный ресурс был исчерпан.)

Цикл имеет 3 мопа слитых доменов: mov, lea и слитый макрос dec/jnz, 3G uops_issued.any count подтверждает, что: он считает в объединенном домене, который является всем конвейером от декодеров до выхода на пенсию, за исключением планировщика (RS) и исполнительных блоков. (Пары команд с макросами остаются повсеместно единичными мопами. Только для микро-слияния хранилищ или загрузки ALU+ 1 моп с слитыми доменами в ROB отслеживает ход двух мопов с не слитыми доменами.)

2G uops_executed.thread (unsused-domain) говорит нам, что все mov Мопы были устранены (т.е. обработаны этапом выпуска / переименования и помещены в ROB в уже выполненном состоянии). Они по-прежнему занимают проблему пропускания / удаления, а также пространство в кэше UOP и размер кода. Они занимают место в ROB, ограничивая размер окна не по порядку. mov инструкция никогда не бывает бесплатной. Существует много возможных узких мест в микроархитектуре, помимо портов с задержкой и портами выполнения, наиболее важными из которых являются скорость выдачи внешнего интерфейса в 4 раза.

На процессорах Intel нулевая задержка часто является более сложной задачей, чем отсутствие необходимости в исполнительном устройстве, особенно в Haswell и более поздних версиях, где имеется 4 порта ALU. (Но только 3 из них могут обрабатывать векторные мопы, поэтому неисчерпываемые векторные перемещения были бы более узким местом, особенно в коде без многих нагрузок или хранилищ, забирающих входную полосу пропускания (4 мопа слитых доменов за такт) от мопов ALU Кроме того, планирование мопов на исполнительные блоки не является идеальным (больше похоже на то, что сначала готово к самым старым), поэтому мопы, которые не находятся на критическом пути, могут украсть циклы с критического пути.)

Если мы поставим nop или xor edx,edx в цикле, они также будут выпускать, но не выполнять на процессорах семейства Intel SnB.

Устранение движения с нулевой задержкой может быть полезно для расширения нуля от 32 до 64 бит и от 8 до 64. (movzx eax, bl устранен, movzx eax, bx не)


Без мов-устранения

mov ecx, ecx      # Intel can't eliminate  mov same,same
lea ecx, [rcx-1]

dec ecx
jnz .loop

 3,000,320,972      cycles:u                  #    3.898 GHz                      ( +-  0.00% )
 4,000,000,238      instructions:u            #    1.33  insn per cycle           ( +-  0.00% )
 1,000,000,234      branches:u                # 1299.225 M/sec                    ( +-  0.00% )
 3,000,084,446      uops_issued_any:u         # 3897.783 M/sec                    ( +-  0.00% )
 3,000,058,661      uops_executed_thread:u    # 3897.750 M/sec                    ( +-  0.00% )

Это требует 3G циклов для 1G итераций, потому что длина цепочки зависимостей теперь составляет 3 цикла.

Число uop в слитых доменах не изменилось, все еще 3G.

Что изменилось, так это то, что теперь число мопов в неиспользованном домене совпадает со значением в fused-domain. Все мопы нуждались в исполнительном блоке; ни один из mov инструкции были исключены, поэтому все они добавили задержку 1с в цепочку депонирования с переносом цикла.

(Когда есть микроплавкие мопы, как add eax, [rsi], uops_executed количество может быть выше, чем uops_issued, Но у нас этого нет.)


Без mov совсем:

lea ecx, [rcx-1]

dec ecx
jnz .loop


 2,000,131,323      cycles:u                  #    3.896 GHz                      ( +-  0.00% )
 3,000,000,161      instructions:u            #    1.50  insn per cycle         
 1,000,000,157      branches:u                # 1947.876 M/sec                  
 2,000,055,428      uops_issued_any:u         # 3895.859 M/sec                    ( +-  0.00% )
 2,000,039,061      uops_executed_thread:u    # 3895.828 M/sec                    ( +-  0.00% )

Теперь мы возвращаемся к задержке в 2 цикла для переносимой по петле цепи dep.

Ничто не устранено.


Я тестировал на 3,9 ГГц i7-6700k Skylake. Я получаю идентичные результаты на Haswell i5-4210U (с точностью до 40 КБ из 1G) для всех событий perf. Это примерно тот же предел погрешности, что и при повторном запуске в той же системе.

Обратите внимание, что если я побежал perf как корень и считается cycles вместо cycles:u (только пользовательское пространство), он измеряет частоту процессора как ровно 3.900 ГГц. (IDK, почему Linux подчиняется только настройкам bios для max turbo сразу после перезагрузки, но затем падает до 3,9 ГГц, если я оставляю его без дела на пару минут. Asus Z170 Pro Gaming mobo, Arch Linux с ядром 4.10.11-1-ARCH Видел то же самое с Ubuntu. balance_performance каждому из /sys/devices/system/cpu/cpufreq/policy[0-9]*/energy_performance_preference от /etc/rc.local исправляет, но пишет balance_power позже он снова падает до 3,9 ГГц.)


Вы должны получить те же результаты на AMD Ryzen, так как он может устранить целое число mov, Семейство AMD Bulldozer может исключать только копии регистра xmm. (По словам Агнера Фога, ymm копии регистров - это исключенная нижняя половина и ОП ALU для верхней половины.)

Например, AMD Bulldozer и Intel Ivybridge могут поддерживать пропускную способность 1 на тактовую частоту для

 movaps  xmm0, xmm1
 movaps  xmm2, xmm3
 movaps  xmm4, xmm5
 dec
 jnz .loop

Но Intel Sandybridge не может устранить ходы, поэтому он будет узким местом на 4 мегапикселях ALU для 3 портов выполнения. Если бы это было pxor xmm0,xmm0 вместо movaps SnB также может выдерживать одну итерацию за такт. (Но семейство Bulldozer не могло этого сделать, потому что для обнуления xor по-прежнему требуется исполнительный модуль на AMD, хотя он не зависит от старого значения регистра. А для семейства Bulldozer для PXOR пропускная способность составляет всего 0,5 с.)


Ограничения мов-устранения

Две зависимые инструкции MOV подряд показывают разницу между Haswell и Skylake.

.loop:
  mov eax, ecx
  mov ecx, eax

  sub ecx, 2
  jnz .loop

Haswell: незначительная межпроизводственная изменчивость (от 1,746 до 1,749 с / iter), но это типично:

 1,749,102,925      cycles:u                  #    2.690 GHz                    
 4,000,000,212      instructions:u            #    2.29  insn per cycle         
 1,000,000,208      branches:u                # 1538.062 M/sec                  
 3,000,079,561      uops_issued_any:u         # 4614.308 M/sec                  
 1,746,698,502      uops_executed_core:u      # 2686.531 M/sec                  
   745,676,067      lsd_cycles_4_uops:u       # 1146.896 M/sec                  

Не все инструкции MOV исключены: около 0,75 из 2 на итерацию использовали порт выполнения. Каждый MOV, который выполняется вместо того, чтобы быть удаленным, добавляет 1с задержки в цепочку депонирования с переносом цикла, так что это не случайно, что uops_executed а также cycles очень похожи Все мопы являются частью одной цепочки зависимостей, поэтому параллелизм невозможен. cycles всегда около 5М выше, чем uops_executed независимо от различий между циклами, так что я предполагаю, что где-то еще используется всего 5 миллионов циклов.

Skylake: более стабильный, чем результаты HSW, и большее устранение mov: только 0,6666 MOV из каждых 2 нуждались в исполнительной единице.

 1,666,716,605      cycles:u                  #    3.897 GHz
 4,000,000,136      instructions:u            #    2.40  insn per cycle
 1,000,000,132      branches:u                # 2338.050 M/sec
 3,000,059,008      uops_issued_any:u         # 7014.288 M/sec
 1,666,548,206      uops_executed_thread:u    # 3896.473 M/sec
   666,683,358      lsd_cycles_4_uops:u       # 1558.739 M/sec

На Haswell, lsd.cycles_4_uops приходилось все мопы. (0,745 * 4 ~= 3). Таким образом, почти в каждом цикле, где выдаются любые мопы, выдается полная группа из 4 (из буфера цикла. Мне, вероятно, следовало бы взглянуть на другой счетчик, которому все равно, откуда они взялись, например, uops_issued.stall_cycles подсчитывать циклы, когда не было выполнено моп)

Но на SKL, 0.66666 * 4 = 2.66664 меньше 3, поэтому в некоторых циклах интерфейс выдаёт меньше 4 моп. (Обычно он останавливается, пока в ядре не по порядку есть место для выпуска полной группы из 4, вместо выпуска неполных групп).

Странно, IDK, каково точное микроархитектурное ограничение. Поскольку цикл состоит всего из 3 мопов, каждая группа выпусков из 4 мопов - это больше, чем полная итерация. Таким образом, группа проблем может содержать до 3 зависимых MOV. Возможно, Skylake предназначен для того, чтобы иногда это разрушать, чтобы допустить большее устранение mov?

обновление: на самом деле это нормально для 3-х циклов на Skylake. uops_issued.stall_cycles показывает, что HSW и SKL выдают простой цикл по 3 мопа без устранения mov, так же, как они выдают этот. Таким образом, лучшая устранение mov является побочным эффектом разделения групп вопросов по какой-то другой причине. (Это не узкое место, потому что взятые ветви не могут работать быстрее, чем 1 за такт, независимо от того, насколько быстро они выдают). Я до сих пор не знаю, почему SKL отличается, но я не думаю, что стоит о чем-то беспокоиться.


В менее экстремальном случае SKL и HSW одинаковы, и оба не смогли устранить 0,3333 из каждых 2 инструкций MOV:

.loop:
  mov eax, ecx
  dec eax
  mov ecx, eax

  sub ecx, 1
  jnz .loop

 2,333,434,710      cycles:u                  #    3.897 GHz                    
 5,000,000,185      instructions:u            #    2.14  insn per cycle         
 1,000,000,181      branches:u                # 1669.905 M/sec                  
 4,000,061,152      uops_issued_any:u         # 6679.720 M/sec                  
 2,333,374,781      uops_executed_thread:u    # 3896.513 M/sec                  
 1,000,000,942      lsd_cycles_4_uops:u       # 1669.906 M/sec                  

Все мопы выпускаются в группах по 4. Любая непрерывная группа из 4 мопов будет содержать ровно два мопа MOV, которые являются кандидатами на исключение. Поскольку он явно преуспевает в устранении обоих в некоторых циклах, IDK почему не всегда может это сделать.


Руководство по оптимизации Intel гласит, что перезапись результата удаления mov как можно раньше освобождает микроархитектурные ресурсы, что позволяет добиться успеха чаще, по крайней мере, для movzx, Смотрите пример 3-25. Последовательность переупорядочения для повышения эффективности инструкций MOV с нулевой задержкой.

Так, может быть, это отслеживается внутри с помощью таблицы подсчетов рефитов ограниченного размера? Что-то должно помешать освобождению записи физического регистра, если она больше не нужна в качестве значения исходного архитектурного регистра, если она все еще необходима в качестве значения места назначения mov. Ключевым моментом является освобождение записей PRF как можно скорее, поскольку размер PRF может ограничить размер окна, выходящего за рамки порядка, меньшим, чем размер ROB.

Я попробовал примеры на Haswell и Skylake, и обнаружил, что удаление mov на самом деле работало значительно больше времени при этом, но на самом деле оно было немного медленнее в общих циклах, чем быстрее. Этот пример был призван продемонстрировать преимущества IvyBridge, который, вероятно, является узким местом на его 3 портах ALU, но HSW/SKL является лишь узким местом при конфликтах ресурсов в цепочках dep и, похоже, не беспокоит необходимость использования порта ALU для большей части movzx инструкции.

См. Также Почему XCHG reg, reg 3 микрооперационная инструкция по современным архитектурам Intel? для дополнительного исследования + догадки о том, как работает mov-elission и может ли оно работать для xchg eax, ecx, (На практике xchg reg,reg на Intel 3 ALU мопов, а на Ryzen 2 Интересно догадаться, могла ли Intel реализовать это более эффективно.)


Кстати, в качестве обходного пути для ошибки в Haswell, Linux не предоставляет uops_executed.thread когда включена гиперпоточность, только uops_executed.core, Другое ядро ​​определенно бездействовало все время, даже не прерывая таймера, потому что я перевел его в автономный режим с echo 0 > /sys/devices/system/cpu/cpu3/online, К сожалению, этого нельзя сделать раньше perf решает, что HT включен, а на моем ноутбуке Dell нет опции BIOS для отключения HT. Так что я не могу получить perf использовать все 8 аппаратных счетчиков PMU одновременно в этой системе, только 4.:/

Вот два небольших теста, которые, я считаю, убедительно показывают доказательства устранения mov:

__loop1:
    add edx, 1
    add edx, 1
    add ecx, 1
    jnc __loop1

против

__loop2:
    mov eax, edx
    add eax, 1
    mov edx, eax
    add edx, 1
    add ecx, 1
    jnc __loop2

Если mov добавив цикл в цепочку зависимостей, можно ожидать, что вторая версия занимает около 4 циклов за итерацию. На моем Haswell оба требуют около 2 циклов на итерацию, что не может произойти без удаления mov.

Другие вопросы по тегам