Краткие инструкции CISC против длинных инструкций - PullRequest
0 голосов
/ 06 октября 2018

Я сейчас программирую компилятор и собираюсь реализовать генерацию кода.Целевой набор команд на данный момент - x64.
Теперь x64 - это CISC, поэтому существует много сложных инструкций.Но я знаю, что они внутренне преобразуются процессором в RISC, и после этого также происходит неупорядоченное выполнение.
Поэтому мой вопрос: не влияет ли использование более коротких инструкций (RISC-подобных) на производительность по сравнению с использованием меньшего количествасложные инструкции?Тестовые программы для моего языка не так уж велики, поэтому я думаю, что размещение инструкций в кеше в настоящее время не должно быть проблемой.

1 Ответ

0 голосов
/ 06 октября 2018

Нет, использование в основном простых инструкций x86 (например, избегание push и использование sub rsp, whatever и хранение аргументов с mov) было полезной оптимизацией для P5-pentium, потому что он не знал как разделить компактные, но сложные инструкции внутри.Его суперскалярный конвейер шириной 2 может выполнять только пару простых инструкций.

Современные процессоры x86 (начиная с Intel P6 (Pentium Pro / PIII) и включая все процессоры x86-64) декодируют сложные инструкции для нескольких операций, которые могут бытьзапланировано самостоятельно.(И для общих сложных инструкций, таких как push / pop, у них есть хитрости, чтобы обрабатывать их как один моп. В этом случае механизм стека, который переименовывает указатель стека вне части ядра, вышедшей из строяпоэтому нет необходимости в uop для rsp-=8 части push.)

Инструкции источника памяти, такие как add eax, [rdi], могут даже декодироваться в один uop на процессорах Intel, микросинтезируя нагрузку сALU uop, только разделяя их в планировщике вне очереди для отправки в исполнительные блоки.В остальной части конвейера, он использует только 1 запись (во внешнем интерфейсе и ROB).(Но см. Режимы Micro Fusion и Addressing , чтобы узнать об ограничениях для Sandybridge с индексированными режимами адресации, немного ослабленными в Haswell и более поздних версиях.) Процессоры AMD просто естественным образом смешивают операнды памяти с инструкциями ALU и не используются для декодированияих к дополнительным m-ops / uops, чтобы у них не было причудливого имени.

Длина инструкции не совсем соотносится с явным образом.Например, idiv rcx составляет всего 3 байта, но декодируется до 57 моп на Skylake.(Избегайте 64-битного деления, оно медленнее, чем 32-битное.)


Чем меньше код, тем лучше, все остальное равно.Предпочитайте 32-битный размер операнда, когда этого достаточно, чтобы избежать префиксов REX, и выберите регистр, который не нуждается в префиксах REX (например, ecx вместо r8d).Но обычно не тратьте лишние инструкции, чтобы это произошло.(например, используйте r8d вместо сохранения / восстановления rbx, чтобы вы могли использовать ebx в качестве еще одного регистра с нулями).

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


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

Если вы не можете правильно определить «холодный» код (выполняется редко) , оптимизируя размер по скорости с чем-то вроде 3-байтовых push 1 / pop rax вместо 5-байтовых mov eax, 1 определенно не подходит по умолчанию. Clang / LLVM будет выдвигать / выдавливать константы с -Oz (оптимизировать только по размеру), но не -Os (оптимизировать длябаланс размера и скорости).

Использование inc вместо add reg,1 экономит байт (только 1 в x86-64, против 2 в 32-битном коде). С регистром назначения это простов большинстве случаев на большинстве процессоров это происходит так же быстро. См. инструкцию INC против ADD 1: имеет ли это значение?


Современные основные x86-процессоры имеют декодированные кэш-памяти (AMD с тех порRyzen, Intel начиная с Sandybridge), которые в основном избегают узких мест на старших процессорах со средней инструкциейlength> 4.

До этого (Core2 / Nehalem) настройка, чтобы избежать узких мест переднего конца, была намного более сложной, чем просто использование коротких инструкций в среднем.См. Руководство по микроархам Agner Fog для получения подробной информации о шаблонах uop, которые декодеры могут обрабатывать в этих старых процессорах Intel, и эффектах выравнивания кода относительно 16-байтовых границ для выборки после перехода и многих других.

Семейство AMD Bulldozer помечает границы команд в кеше L1i и может декодировать до 2x 16 байт за цикл, если оба ядра кластера активны, в противном случае микроархив Agner Fog PDF (https://agner.org/optimize/) сообщает ~ 21 байт за цикл(по сравнению с Intel до 16 байт в такт для декодеров, когда они не работают из кэша UOP.) Более низкая внутренняя пропускная способность бульдозера, вероятно, означает, что узкие места внешнего интерфейса случаются реже. Но я действительно не знаю, у меня нетНастроил что-нибудь для семейства Bulldozer с доступом к оборудованию для тестирования чего-либо.


Пример: эта функция скомпилирована с помощью clang с -O3, -Os и -Oz

int sum(int*arr) {
    int sum = 0;
    for(int i=0;i<10240;i++) {
        sum+=arr[i];
    }
    return sum;
}

Исходный код + выход asm в проводнике компилятора Godbolt , где вы можете поиграть с этим кодом и параметрами компилятора.

Iтакже использовал -fno-vectorize, потому что я предполагаю, что вы не будете пытаться автоматически векторизовать с SSE2, даже если это базовый показатель для x86-64 (хотя это ускорит этот цикл в 4

# clang -O3 -fno-vectorize
sum:                                    # @sum
        xor     eax, eax
        mov     ecx, 7
.LBB2_1:                                # =>This Inner Loop Header: Depth=1
        add     eax, dword ptr [rdi + 4*rcx - 28]
        add     eax, dword ptr [rdi + 4*rcx - 24]
        add     eax, dword ptr [rdi + 4*rcx - 20]
        add     eax, dword ptr [rdi + 4*rcx - 16]
        add     eax, dword ptr [rdi + 4*rcx - 12]
        add     eax, dword ptr [rdi + 4*rcx - 8]
        add     eax, dword ptr [rdi + 4*rcx - 4]
        add     eax, dword ptr [rdi + 4*rcx]
        add     rcx, 8
        cmp     rcx, 10247
        jne     .LBB2_1
        ret

Это довольно глупо;это развернуло 8, но все еще только с 1 аккумулятором.Таким образом, это узкие места с задержкой в ​​1 цикл add вместо 2 нагрузок на тактовую частоту в Intel со времен SnB и AMD со времен K8.(И только чтение 4 байта за такт, это, вероятно, не является узким местом в пропускной способности памяти.)

Это лучше при нормальном -O3, не отключая векторизацию, используя 2 векторных аккумулятора:

sum:                                    # @sum
    pxor    xmm0, xmm0           # zero first vector register
    mov     eax, 36
    pxor    xmm1, xmm1           # 2nd vector

.LBB2_1:                                # =>This Inner Loop Header: Depth=1
    movdqu  xmm2, xmmword ptr [rdi + 4*rax - 144]
    paddd   xmm2, xmm0
    movdqu  xmm0, xmmword ptr [rdi + 4*rax - 128]
    paddd   xmm0, xmm1
    movdqu  xmm1, xmmword ptr [rdi + 4*rax - 112]
    movdqu  xmm3, xmmword ptr [rdi + 4*rax - 96]
    movdqu  xmm4, xmmword ptr [rdi + 4*rax - 80]
    paddd   xmm4, xmm1
    paddd   xmm4, xmm2
    movdqu  xmm2, xmmword ptr [rdi + 4*rax - 64]
    paddd   xmm2, xmm3
    paddd   xmm2, xmm0
    movdqu  xmm1, xmmword ptr [rdi + 4*rax - 48]
    movdqu  xmm3, xmmword ptr [rdi + 4*rax - 32]
    movdqu  xmm0, xmmword ptr [rdi + 4*rax - 16]
    paddd   xmm0, xmm1
    paddd   xmm0, xmm4
    movdqu  xmm1, xmmword ptr [rdi + 4*rax]
    paddd   xmm1, xmm3
    paddd   xmm1, xmm2
    add     rax, 40
    cmp     rax, 10276
    jne     .LBB2_1

    paddd   xmm1, xmm0        # add the two accumulators

     # and horizontal sum the result
    pshufd  xmm0, xmm1, 78          # xmm0 = xmm1[2,3,0,1]
    paddd   xmm0, xmm1
    pshufd  xmm1, xmm0, 229         # xmm1 = xmm0[1,1,2,3]
    paddd   xmm1, xmm0

    movd    eax, xmm1         # extract the result into a scalar integer reg
    ret

Эта версия разворачивает, вероятно, больше, чем нужно;накладные расходы на петли крошечные, а movdqu + paddd - всего 2 мопа, так что мы далеко от узких мест на переднем конце.При нагрузке 2 на такт movdqu этот цикл может обрабатывать 32 байта ввода за такт, предполагая, что данные горячие в кеше L1d или, возможно, L2, в противном случае он будет работать медленнее.Такое развертывание с превышением минимума позволит запустить выполнение вне очереди и увидеть условие выхода из цикла до того, как работа paddd будет завершена, и, возможно, в большинстве случаев скрыть неверный прогноз ветвления на последней итерации.

Использование более 2-х аккумуляторов для скрытия задержки очень важно в коде FP, где большинство инструкций не имеют задержки одного цикла.(Это также было бы полезно для этой функции в семействе AMD Bulldozer, где paddd имеет задержку в 2 цикла.)

При больших развертываниях и больших смещениях компиляторы иногда генерируют много инструкций, которым требуется disp32 смещение вместо disp8 в режиме адресации.Выбор точки, в которой вы увеличиваете счетчик цикла или указатель, чтобы сохранить как можно больше режимов адресации, используя смещение -128 .. +127, вероятно, было бы неплохо.

Если только вы не настраиваетесь на Nehalem /Core2 или другие процессоры без кэша UOP, вы, вероятно, не хотите добавлять дополнительные издержки цикла (add rdi, 256 дважды вместо add rdi, 512 или что-то в этом роде) просто для уменьшения размера кода.


Для сравнения, clang -Os по-прежнему автоматически векторизуется (если вы не отключите его) с внутренним циклом, длина которого ровно 4 мопа на процессорах Intel.

# clang -Os
.LBB2_1:                                # =>This Inner Loop Header: Depth=1
    movdqu  xmm1, xmmword ptr [rdi + 4*rax]
    paddd   xmm0, xmm1
    add     rax, 4
    cmp     rax, 10240
    jne     .LBB2_1

Но с clang -Os -fno-vectorizeмы получаем простую и очевидную минимальную скалярную реализацию:

# clang -Os -fno-vectorize
sum:                                    # @sum
    xor     ecx, ecx
    xor     eax, eax
.LBB2_1:                                # =>This Inner Loop Header: Depth=1
    add     eax, dword ptr [rdi + 4*rcx]
    inc     rcx
    cmp     rcx, 10240
    jne     .LBB2_1
    ret

Пропущенная оптимизация: использование ecx позволит избежать префикса REX для inc и cmp.Известно, что диапазон устанавливается в 32-битной версии.Вероятно, он использует RCX, потому что он повысил int до 64-битных, чтобы избежать расширения знака movsxd rcx,ecx до 64-битных перед использованием в режиме адресации.(Поскольку переполнение со знаком - это UB в C.) Но после этого он может снова оптимизировать его, заметив диапазон.

Цикл равен 3 моп (при условии, что в Intel слитый макрос cmp / jne, так как Nehalemи AMD с Bulldozer), или 4 мопа на Sandybridge (отмена добавления с помощью индексированного режима адресации.) Цикл инкремента указателя может быть несколько более эффективным на некоторых процессорах, требуя только 3 мопа внутри цикла даже на SnB / IvB.


Вывод Clang -Oz на самом деле больше, что свидетельствует о признаках его стратегии генерации кода.Нельзя доказать, что многие циклы выполняются хотя бы 1 раз, и, следовательно, им нужна условная ветвь, чтобы пропустить цикл, а не попадать в него в случае нулевого времени выполнения.Или им нужен прыжок к точке входа возле дна.( Почему циклы всегда компилируются в стиле "do ... while" (прыжок в хвост)? ).

Похоже, код LLVM -Oz code-gen безоговорочно использует переход книжняя стратегия без проверки, всегда ли верно условие на первой итерации.

 sum:                                    # @sum
    xor     ecx, ecx
    xor     eax, eax
    jmp     .LBB2_1
.LBB2_3:                                #   in Loop: Header=BB2_1 Depth=1
    add     eax, dword ptr [rdi + 4*rcx]
    inc     rcx
.LBB2_1:                                # =>This Inner Loop Header: Depth=1
    cmp     rcx, 10240
    jne     .LBB2_3
    ret

Все то же самое, кроме дополнительного jmp для входа в цикл.

В функции, которая выполнялаБолее того, вы увидите больше различий в коде поколения.Как, например, использование медленного div даже для констант времени компиляции вместо мультипликативного обратного ( Почему GCC использует умножение на странное число при реализации целочисленного деления? ).

...