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

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

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

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

#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 С++):

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 был бесплатным?

Ответ 1

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

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

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

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


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

Кроме того, предсказанные ветки могут работать только на port6, поэтому любой цикл, в котором port6 ​​не выполняется, 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 юапа, поэтому интерфейс может работать впереди).
  • пропускная способность в 1 раз за часы.
  • цепочка зависимостей, включающая esi (латентность INC 1 за часы)
  • цепочка зависимостей, включающая edi (ADD латентность 1 за такт, а также зависит от INC от предыдущей итерации)

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

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

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


Настоящий пример преимущества устранения mov.

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

Так как макро-fused 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/ и другие ссылки в tag wiki).

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

$ 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, но он масштабируется с подсчетом итераций, поэтому он не просто накладные расходы на запуск. Вероятно, это связано с прерываниями таймера, поскольку IIRC Linux perf не вмешивается в работу с первыми счетчиками при обработке прерываний и просто позволяет им вести учет. (perf виртуализирует счетчики производительности оборудования, чтобы вы могли получать подсчеты на каждый процесс, даже когда поток переносится через центральные процессоры.) Кроме того, прерывания таймера в логическом ядре дочернего узла, которые используют одно и то же физическое ядро, будут немного беспокоить.

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

В цикле есть 3-х платных доменов: mov, lea и macro-fused dec/jnz. Счетчик 3G uops_issued.any подтверждает, что: он рассчитывается в плавленном домене, который является всем конвейером от декодеров до выхода на пенсию, за исключением планировщика (RS) и исполнительных блоков. (пары с макросплавными ключами остаются как единичные uop везде. Это только для микро-слияния магазинов или ALU + нагрузки, что 1 fused-domain uop в ROB отслеживает прогресс двух непроверенных доменов.)

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

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

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

Модификация исключения с нулевой задержкой может быть полезна для нулевого расширения от 32 до 64 бит и от 8 до 64. ( movzx eax, bl исключается, movzx eax, bx isn ' т).


Без mov-elim

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.

Что изменилось, так это то, что теперь счетчик uop незадействованных доменов совпадает с объединенным доменом. Для всех юотов нужен исполнительный блок; ни одна из инструкций mov не была устранена, поэтому все они добавили латентность 1c к цепочке отрезков, связанной с циклом.

(Когда есть микро-fused uops, например 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 цикла для цепочки отрезков с циклом.

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


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

Обратите внимание, что если я запустил perf как root и считал 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.9GHz позже.)


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

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

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

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


Ограничения исключения mov

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

.loop:
  mov eax, ecx
  mov ecx, eax

  sub ecx, 2
  jnz .loop

Haswell: незначительная переменная run-to-run (от 1,746 до 1,749 c/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, который выполняется вместо того, чтобы быть устраненным, добавляет 1c латентности к цепочке отрезка цикла, так что это не совпадение, что uops_executed и cycles очень похожи. Все вершины являются частью одной цепочки зависимостей, поэтому нет возможности parallelism. cycles всегда примерно на 5M выше, чем uops_executed, независимо от изменения run-to-run, поэтому я предполагаю, что в другом месте используется только 5M циклов.

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). Таким образом, почти в каждом цикле, в котором выпущены любые uops, выдается полная группа из 4 (из буфера цикла. Вероятно, я должен был посмотреть на другой счетчик, который не заботится о том, откуда они пришли, например uops_issued.stall_cycles для подсчета циклов, где не было выпущено uops).

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

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

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


В менее экстремальном случае SKL и HSW одинаковы: оба не смогли исключить 0.3333 из каждых двух инструкций 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                  

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


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

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

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

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


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

Ответ 2

Вот два небольших теста, которые, как я считаю, окончательно показывают доказательства для удаления 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 циклов на итерацию. На моем Хасуэле оба принимают около 2 циклов на итерацию, что не может произойти без исключения mov.