A. Fog. "Оптимизация для процессоров семейства Pentium"

Agner Fog

"Оптимизация для процессоров семейства Pentium"

[C] Agner Fog, пер. Aquila (www.wasm.ru)

© 2002 wasm.ru - all rights reserved and reversed

2002. Составитель Alexela

Благодарности:

Edmond//HI-TECH
90210//HI-TECH
Unisoft
Вперед >>>


Оптимизация для процессоров семейства Pentium: 1. Введение

1. Введение

Это руководство подробно рассказывает о том, как писать оптимизированный код на ассемблере, с ориентированием на семейство микропроцессоров Pentium.

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

В данном руководстве обсуждаются следующие версии процессоров Pentium:

 аббревиатура  имя

 PPlain        обычный старый Pentium
 PMMX          Pentium с MMX
 PPro          Pentium Pro
 PII           Pentium II (включая Селероны и Ксеоны)
 PIII          Pentium III (включая его разновидности)

В данном руководстве используется синтаксис MASM 5.10. Для языка ассемблера x86 не существует официального стандарта, но это фактически является стандартом, так как многие ассемблеры имеют режим совместимости с MASM 5.10. (Я не рекомендую использовать сам MASM версии 5.10, так как в нем есть серьезный баг, проявляющийся в 32-х битном режиме. Используйте TASM или более позднюю версию MASM).

Некоторые замечания в данном руководстве могут показаться критикой Intel. Это не нужно понимать так, что другие марки лучше. Семейство микропроцессоров Pentium вполне сравнимо со своими конкурентами, они лучше документированы и у них есть другие отличительные особенности.

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

Архитектура чипов PPlain и PMMX имеет много особенностей, которые оптимизированы специально под некоторые наиболее часто используемые инструкции или комбинации инструкций. В этих случаях нельзя использовать общие методики оптимизации. Следовательно, правила для оптимизирования программного обеспечения весьма сложны и имеют много исключений, но возможный выигрыш в производительности значителен. У процессоров PPro, PII и PIII совершенно разный дизайн в том, как процессор берет на себя часть оптимизации, запуская инструкции в совершенно разном порядке, но усложненный дизайн этих процессоров становится причинной множества потенциальных узких мест, поэтому можно много выиграть, оптимизируя код вручную для этих процессоров. У Pentium 4 также другой дизайн, и пути оптимизации для Pentium 4 отличаются от тех, которые используются для других версий. Это руководство не рассказывает о Pentium 4 - читатель может обратиться к руководствам от Intel.

Прежде, чем вы начнете конвертировать ваш код в ассемблер, убедитесь, что используемый вами алгоритм оптимален. Зачастую вы можете гораздо сильнее улучшить ваш код путем оптимизации алгоритма, чем переписыванием кода на ассемблере.

Затем вам нужно выделить критические части вашей программы. Зачастую более, чем 99% времени процессора тратится на внутренний цикл программы. В этом случае вам нужно только оптимизировать этот цикл и оставить все остальное на языке высокого уровня. Некоторые асм-программисты тратят огромное количество энергии на оптимизирование не тех частей своей программы, единственным значительным результатом чего становится то, что программу становится труднее отлаживать и распространять!

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

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

Пожалуйста, не посылайте мне ваши вопросы о программировании. Я не собираюсь делать за вас вашу домашнюю работу.

Удачи в охоте за наносекундами!

[C] Агнер Фог, пер. Aquila


2. Литература

Множество полезную литературы и туториалов можно скачать бесплатно с www-сайта Intel или заказать на CD-ROM. Рекомендуется, чтобы вы изучили эту литературу для ознакомления с архитектурой процессора. Тем не менее, документы от Intel не всегда точны - особенно много ошибок содержат туториалы (похоже, что они не тестят собственные примеры).

Я не будут давать здесь прямые ссылки, потому что местоположение файлов часто меняется. Вы можете найти нужные вам документы используя функцию поиска на developer.intel.com или перейдя по ссылке, которые находятся на www.agner.org/assem.

Некоторые документы в формате .PDF. Если у вас нет никакого программного обеспечения для просмотра или распечатки файлов .PDF, вы можете скачать просмотровщик файлов Acrobat с www.adobe.com.

VTUNE - это утилита от Intel для оптимизирования кода. Я не тестировал его и поэтому не могу ничего сказать по этому поводу.

Кроме Intel существует множество другой полезной информации. Эти источники перечислены в FAQ ньюсгруппы comp.lang.asm.x86. Также обратите внимание на ссылки, приведенные на www.agner.org/assem.

[C] Агнер Фог, пер. Aquila

<<< Назад


3. Вызов ассемблерных функций из языка высокого уровня

Вы можете использовать встроенный ассемблер или написать процедуру полностью на ассемблере и вставить ее в свой проект. Если вы выберете последний вариант, рекомендуется, чтобы компилятор умел переводить высокоуровневый код напрямую в ассемблер. Это даст вам гарантию, что вы получите верный метод вызова функции. Большинство компиляторов C++ умеют это делать.

Методы вызова функций и манглинг имен могут быть достаточно сложными. Существует много различных соглашений о вызовах функций, и разные марки компиляторов не совместимы друг с другом в этом отношении. Если вы вызываете ассемблерную процедуру из C++, самым логичным и совместимым будет объявление вашей функции как extern "C" и _cdecl. К имени ассемблерной функции надо прибавить символ подчеркивания и компилировать ее, указав чувствительность к регистрам в именах внешних функций и переменных (опция -mx).

Если вам нужно использовать перегружаемые функции, перегружаемые операторы, функции-члены классов и другие расширения C++, тогда вам нужно сначала написать это на C++ и заставить ваш компилятор перевести это в ассемблер, чтобы получить верную информацию о линковке и методе вызова, так как они могут отличаться у разных компиляторов. Если вам нужна ассемблерная функция, соглашение о вызове которой отличается от extern "C" и _cdecl, вам нужно получить ее имя, создаваемое компилятором. Вот пример перегруженной функции вычисления квадратного корня:

  ; int square (int x);
  SQUARE_I PROC NEAR             ; целочисленная функция вычисления квадратного корня
  @square$qi LABEL NEAR          ; имя, создаваемое компилятором Borland
  ?square@@YAHH@Z LABEL NEAR     ; имя, создаваемое компилятором Microsoft
  _square__Fi LABEL NEAR         ; имя, создаваемое компилятором Gnu
  PUBLIC @square$qi, ?square@@YAHH@Z, _square__Fi
          MOV     EAX, [ESP+4]
          IMUL    EAX
          RET
  SQUARE_I ENDP

  ; double square (double x);
  SQUARE_D PROC NEAR             ; функция вычисления квадратного корня с двойной точностью

  @square$qd LABEL NEAR          ; имя, создаваемое компилятором Borland
  ?square@@YANN@Z LABEL NEAR     ; имя, создаваемое компилятором Microsoft
  _square__Fd LABEL NEAR         ; имя, создаваемое компилятором Gnu
  PUBLIC @square$qd, ?square@@YANN@Z, _square__Fd
          FLD     QWORD PTR [ESP+4]
          FMUL    ST(0), ST(0)
          RET
  SQUARE_D ENDP

Способ передачи параметров зависит от соглашения о вызове функции:

  соглашение          порядок помещения параметров в стек  кто очищает стек

  _cdecl              обратный                             вызывающий
  _stdcall            обратный                             процедура
  _fastcall           зависит от компилятора               процедура
  _pascal             прямой                               процедура

Использование регистров в 16-ти битном режиме или Windows, C или C++: 16-ти битное значение возвращается в AX, 32-х битное значение в DX:AX, значение с плавающей запятой в ST(0). Регистры AX, BX, CX, DX, ES и арифметические флаги могут быть изменены процедурой; другие регистры должны быть сохранены и восстановлены. Процедура может полагаться на то, что при вызове другой процедуры значение SI, DI, BP, DS и SS не изменится.

Использование регистров в 32-х битных Windows, C++ и других языках программирования: целочисленное значение возвращается в EAX, значение с плавающей точкой возвращается в ST(0). Регистры EAX, ECX, EDX (но не EBX) могут быть изменены процедурой; все другие регистры должны быть сохранены и восстановлены. Сегментные регистры нельзя изменять даже временно. CS, DS, ES и SS указывают на плоский сегмент. FS используется операционной системой. GS не используется, но зарезервирован. Флаги могут меняться процедурой, но со следующими ограничениями: флаг направления равен 0 по умолчанию. Его можно изменить временно, но при выходе из процедуры необходимо очистить. Флаг прерывания не может быть очищен. Стековый регистр плавающей запятой пуст при входе в процедуру и должен быть пустым при выходе, если только ST(0) не используется для возвращения значения. Регистры MMX можно изменять, и если это было сделано, их нужно очистить с помощью инструкции EMMS перед возвращением или вызовом любой другой процедуры, которая может использовать регистры плавающей запятой. Все XMM регистры можно свободно изменять. Правила для передачи параметров и возвращения значений через регистры XMM объяснены в интеловском документе AP 589. Процедура может полагаться на неизменность EBX, ESI, EDI, EBP и всех сегментных регистров при вызове другой процедуры.


4. Отладка

Отладка ассемблерного кода может оказаться довольно трудоемкой и неприятной, как вы, возможно, уже заметили. Я рекомендую вам сначала написать то, что вы хотите оптимизировать как подпрограмму на языке высокого уровня. Затем напишите тестовую программу, в которой вы будете отлаживать подпрограмму. Убедитесь, что тестовая программа удовлетворяет всем условиям ветвления и выравнивания.

Затем переведите код на язык ассемблера.

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

Протестируйте скорость наиболее критичных частей вашей программы с помощью метода, изложенного в главе 30 или с помощью тестовой программы. Если код значительно медленнее, чем ожидалось, тогда возможно, что: неправильно используется кэш (глава 7), невыравненны операнды (глава 6), цена первого запуска (глава 8), неправильное предсказание переходов (глава 22), проблемы загрузки кода (глава 15), потери скорости при чтении регистра (глава 16) или долгая цепь зависимости (глава 20).


5. Модель памяти

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


6. Выравнивание

Все данные в RAM должны быть выравненены так, чтобы их адреса были кратны 2, 4, 8 или 16 согласно данной схеме:

                             выравнивание
  размер операнда PPlain и PMMX   PPro, PII и PIII
  1  (byte)            1                  1
  2  (word)            2                  2
  4  (dword)           4                  4
  6  (fword)           4                  8
  8  (qword)           8                  8
  10 (tbyte)           8                  16
  16 (oword)          n.a.                16

На PPlain и PMMX при обращении к невыравненным данным будет теряться по меньшей мере 3 такта, если пересечена граница в 4 байта. Потери будут выше при пересечении границы кэша.

На PPro, PII и PIII невыравненные данные удут стоить вам 6-12 дополнительных тактов, если пересечена граница кэша. Невыравненные операнды, меньшие чем 16 байтов и не перешедшие границу в 32 байта, не приводят к потерям.

Выравнивание данных на 8 или 16 в стеке двойных слов может стать проблемой. Общий метод решения - установить выравненный указатель на кадр стека. Функция с выравненными локальными данными может выглядеть примерно так:

_FuncWithAlign PROC NEAR
        PUSH    EBP                        ; пролог
        MOV     EBP, ESP
        AND     EBP, -8                    ; выравнивание указателя на кадр
                                             стека на 8
        FLD     DWORD PTR [ESP+8]          ; параметр функции
        SUB     ESP, LocalSpace + 4        ; резервируем локальные данные
        FSTP    QWORD PTR [EBP-LocalSpace] ; теперь сохраняем что-нибудь в
                                           ; локальной переменной
        ...
        ADD     ESP, LocalSpace + 4        ; эпилог, восстанавливаем ESP

        POP     EBP                        ; (потеря скорости AGI на PPlain/PMMX)
        RET
_FuncWithAlign ENDP

В то время как выравнивание данных важно всегда, выравнивание кода не является необходимым на PPlain и PMMX. Принципы выравнивания кода на PPro, PII и PIII изложены в главе 15.


7. Кэш

У PPlain и PPro 8 килобайт кэша первого уровня для кода и 8 килобайт для данных. У PMMX, PII и PIII по 16 килобайт для кода и данных. Данные в кэше первого уровня можно читать или перезаписывать всего лишь за один такт, в то время как выход за границы кэша может стоить множества тактов. Поэтому важно, понимать, как работает кэш, чтобы использовать его более эффективно.

Кэш данных состоит из 256 или 512 рядов по 32 байта в каждом. Каждый раз, когда вы считываете элемент данных, который еще не находится в кэше, процессор считает весь кэш-ряд из памяти. Кэш-ряды всегда выравниваются по физическому адресу, кратному 32. Когда вы считываете байт по адресу, который кратен 32, чтение или запись в следующие 31 байт вам не будет стоить практически ничего. Вы можете использовать это к своей выгоде, организовав данные, которые используются вместе, в блоки по 32 байта. Если, например, у вас есть цикл, в котором обрабатываются два массива, вы можете перегруппировать эти два массива в один массив структур, чтобы данные, которые используются вместе, находились в памяти рядом друг с другом.

Если размер массива кратен 32 байтам, вам следует и выравнивать его по границе 32 байта.

Кэш set-ассоциативен. Это означает, что кэш-ряд нельзя ассоциировать с произвольным адресом памяти. У каждого кэш-ряда есть 7-ми битное значение, которое задает биты 5-11 адреса в физической памяти (биты 0-4 определяются положением элемента данных в кэш-ряде). У PPlain и PPro два кэш-ряда для каждого из возможных 128 set-значений, поэтому с определенным адресом в памяти можно ассоциировать только два жестко заданных кэш-ряда. В PMMX, PII и PIII - четыре.

Следствием этого является то, что кэш может содержать не более двух или четырех различных блока данных, у которых совпадают биты 5-11 их адресов. Вы можете определить, совпадают ли эти биты у двух адресов следующим образом: удалите нижние пять битов каждого адреса, чтобы получить значение, кратное 32. Если разница между этими обрезанными значениями кратна 4096 (=1000h), значит у этих адресов одно и то же set-значение.

Давайте я проиллюстрирую вышеизложенное с помощью следующего кода, где ESI содержит адрес, кратный 32:

AGAIN:  MOV  EAX, [ESI]
        MOV  EBX, [ESI + 13*4096 +  4]
        MOV  ECX, [ESI + 20*4096 + 28]
        DEC  EDX
        JNZ  AGAIN

У всех трех адресов, использованных здесь, совпадают set-значения, потому что разница между обрезанными адресами будет кратна 4096. Этот цикл будет очень плохо выполняться на PPlain и PPro. Во время записи в ECX нет ни одного свободного кэш-ряда, поэтому тот, который был использован для значения, помещенного в EAX, заполняется данными с [ESI+20*4096] до [ESI+20*4096+31] и записывает значение в ECX. Затем во время чтения EAX оказывается, что кэш-ряд, содержавший значение для EAX, уже изменен, поэтому то же самое происходит с кэш-рядом, содержащим значение для EBX, и так далее. Это пример нерационального использования кэша: цикл занимает около 60 тактов. Если третью линию изменить на:

        MOV  ECX, [ESI + 20*4096 + 32]

мы пересечем границу в 32 байта, поэтому у нас будет другое set-значение, и не возникнет никаких проблем с ассоциирование кэш-рядов к этим трем адресам. Цикл теперь занимает 3 такта (не считая первого раза) - весьма значительное улучшение! Стоит упомянуть, что у PMMX, PII и PIII для каждого set-значения есть четыре кэш-ряда. (Некоторые интеловские документы ошибочно утверждают, что у PII по два кэш-ряда на каждое set-значение).

Определить, одинаковые или у переменных set-значения, может оказаться достаточно сложным, особенно, если они разбросаны по разным сегментам. Лучшим, что вы можете придумать для избежания проблем подобного рода - это держать все данные, используемые в критической части вашей программы внутри одного блока, который будет не больше, чем кэш, либо в двух блоках, не больших, чем половина от его размера (например, один блок для статических данных, а другой для данных в стеке). Это будет гарантией того, что ваши кэш-ряды используются оптимальным образом.

Если критическая часть вашего кода имеет дело с большими структурами данных или данными, располагающимися в памяти случайным образом, тогда вам стоит подумать о том, чтобы держать наиболее часто используемые переменные (счетчики, указатели, флаговые переменные и т.д.) в одном блоке с максимальным размеров в 4 килобайта, чтобы использовались все кэш-ряды. Так как вам еще требуется стек для параметров подпрограммы и возвращаемых адресов, лучшее, что вы можете сделать - это скопировать наиболее часто используемые статические данные в динамические переменные в стеке, а после выхода из критической части кода скопировать обратно те из них, которые подверглись изменению.

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

При считывании больших блоков данных из памяти, скорость ограничена временем, уходящим на заполнение кэш-рядов. Иногда вы можете увеличить скорость, считывая данные в непоследовательном порядке: еще не считав данные из одного кэш-ряда, начать читать первый элемент из следующего кэш-ряда. Этот метода можете повысить скорость на 20-40% при считывании данных из основной памяти или кэша второго уровня на PPlain и PMMX, или из кэша второго уровня на PPro, PII и PIII. Недостаток этого метода заключается в том, что код программы становится очень запутанным и сложным. Другую информацию об этом методе вы можете получить на www.intelligentfirm.com.

Когда вы пишите в адрес, которого нет в кэше первого уровня, тогда значение пойдет прямо в кэш второго уровня или в память (в зависимости от того, как настроен кэш второго уровня) на PPlain и PMMX. Это займет примерно 100 ns. Если вы пишите восемь или более раз в тот же 32-байтный блок памяти, ничего не читая из него, и блок не находится в кэше первого уровня, возможно стоит сделать фальшивое чтение из него, чтобы он был загружен в кэш-ряд. Все последующие чтения будут производиться в кэш, что занимает всего лишь один такт. На PPlain и PMMX иногда происходит небольшая потеря в производительности при повторяющемся чтении по одному и тому же адресу без чтения из него между этим.

На PPro, PII и PIII запись обычно ведет к загрузке памяти в кэш-ряд, но возможно указать область памяти, с которой следует поступать иначе (смотри Pentium Pro Family Developer's Manual, vol. 3: Operating System Writer's Guide").

Другие пути увеличения скорости чтения и записи в память обсуждаются в главе 27.8.

У PPlain и PPro есть два буфера записи, у PMMX, PII и PIII - четыре. На PMMX, PII и PIII у вас может быть до четырех незаконченных записей в некэшированную память без задержки последующих инструкций. Каждый буфер записи может обрабатывать операнды до 64 бит длиной.

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

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

Хранение временных данных в регистрах, разумеется, более эффективно. Так как регистров мало, вы, возможно, захотите использовать [ESP] вместо [EBP] для адресации данных к стеку, чтобы освободить EBP для других целей. Только не забудьте, что значение ESP меняется каждый раз, когда вы делаете PUSH или POP. (Вы не можете использовать ESP под 16-ти битным Windows, потому что прерывание таймера будет менять верхнее слово ESP тогда, когда это невозможно предугадать.)

Есть отдельных кэш для кода, которых схож с кэшем данных. Размер кэша кода равен 8 килобайтам на PPlain и PPro и 16 килобайтам на PMMX, PII и PIII. Важно, чтобы критические части вашего кода (внутренние циклы) умещались в кэш кода. Часто используемые процедуры следует помещать рядом друг с другом. Редко используемые ветви или процедуры нужно держать в самом низу вашего кода или где-нибудь еще.


8. Исполнение кода в первый раз

Обычно исполнение кода в первый раз занимает намного больше времени, чем при последующих повторениях в силу следующих причин:

  • Загрузка кода из RAM в кэш занимает намного больше времени, чем его выполнение.
  • Любые данные, к которым обращается код должны быть загружены в кэш, что также занимает много времени. Когда код повторяется, данные, как правило, уже находятся в кэше.
  • Инструкция перехода не будет находится в буфере предсказывания переходов в первый раз, поэтому маловероятно, что она будет предсказана правильно. Смотри главу 22.
  • На PPlain декодирование инструкций является узким местом. Если определение длины инструкции занимает один такт, то процессор не может раскодировать за такт две инструкции, так как он не знает, где начинает вторая инструкция. PPlain решает эту проблему, запоминает длину любой инструкции, которая осталась в кэше. Как следствие, ряд инструкции не спариваются на PPlain во время первого исполнения, если только первые две инструкции не занимают по одному байту. У PMMX, PPro, PII и PIII таких потерь нет.

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

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

Если в вашем цикле очень много переходов и вызовов, то у вас будут потери из-за регулярных ошибок предсказания переходов.

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


9. Задержка генерации адреса

Чтобы высчитать адрес в памяти, который нужен инструкции, требуется один такт. Обычно эти вычисления делаются одновременно с выполнение предыдущей инструкции или спаренных инструкций. Но если адрес зависит от результата инструкции, которая выполнялась в предыдущем такте, тогда вам придется подождать дополнительный такт, чтобы получить требуемый адрес. Это называется задержкой AGI.

Пример:

ADD EBX,4 / MOV EAX,[EBX] ; задержка AGI

Задержку в данном примере можно убрать, если поместить какие-нибудь другие инструкции между 'ADD EBX, 4' и 'MOV EAX, [EBX]' или переписав код следующим образом:

MOV EAX,[EBX+4] / ADD EBX,4

Также вы можете получить задержку AGI при использовании инструкции, которые используют ESP для адресации, например, PUSH, POP, CALL и RET, если ESP был именен в предыдущем такте такими инструкциями как MOV, ADD и SUB. У PPlain и PMMX есть специальная схема, чтобы предсказывать значение ESP после стековой операции, поэтому у вас не будет задержки AGI при изменении ESP с помощью PUSH, POP или CALL. При использовании RET она может случиться только если у него есть числовой операнд, который прибавляется к ESP.

Примеры:

ADD ESP,4 / POP ESI            ; задержка AGI
POP EAX   / POP ESI            ; нет задержки, спариваются
MOV ESP,EBP / RET              ; задержка AGI
CALL L1 / L1: MOV EAX,[ESP+8]  ; нет задержки
RET / POP EAX                  ; нет задержки
RET 8 / POP EAX                ; задержка AGI

Инструкция LEA также может стать причиной задержки AGI, если она использует базовый или индексный регистр, который был изменен в предыдущем такте.

Пример:

INC ESI / LEA EAX,[EBX+4*ESI]  ; задержка AGI

У PPro, PII и PIII нет задержки AGI для чтения из памяти и LEA, но есть задержка при записи в память. Это не очень важно, если только последующий код не должен ждать, пока операция записи не будет выполнена.


10. Спаривание целочисленных инструкций (PPlain и PMMX)

10.1 Совершенное спаривание

У PPlain и PMMX есть два конвейера, выполняющих инструкции, которые называются U-конвейер и V-конвейер. В определенных условий можно выполнить две инструкции одновременно, одну в U-конвейере, а другую в V-конвейере. Это практически удваивает скорость. Поэтому имеет смысл перегруппировать ваши инструкции и сделать их спариваемыми.

Следующие инструкции могут находится в любом конвейере.

  • MOV регистр, память или уже заданного числа в регистр или память
  • PUSH регистр или число, POP регистр
  • LEA, NOP
  • INC, DEC, ADD, SUB, CMP, AND, OR, XOR,
  • и некоторые разновидности TEST (смотри главу 26.14)

Следующие инструкции спариваемы только в U-конвейере:

  • ADC, SBB
  • SHR, SAR, SHL, SAL с число в качестве аргумента
  • ROR, ROL, RCR, RCL с единицей в качестве аргумента

Следующие инструкции спариваемы только в V-конвейере:

  • ближний вызов
  • короткий и ближний переход
  • короткий и ближний условный переход

Все другие целочисленные инструкции могут выполняться только в U-конвейере и не могут спариваться.

Две следующие друг за другом инструкции будут спариваться, если соблюдены следующие условия:

  • Первая инструкция спариваема в U-конвейере, а вторая - в V-конвейере.
  • Вторая инструкция не читает и не пишет из регистра, если пишет первая инструкция.

Примеры:

    MOV EAX, EBX / MOV ECX, EAX     ; чтение после записи, не спаривается
    MOV EAX, 1   / MOV EAX, 2       ; запись после записи, не спаривается
    MOV EBX, EAX / MOV EAX, 2       ; запись после чтения, спаривается
    MOV EBX, EAX / MOV ECX, EAX     ; чтение после чтения, спаривается
    MOV EBX, EAX / INC EAX          ; чтение и запись после чтения, спаривается

3. Неполные регистры считаются полными регистрами.

Пример:

    MOV AL, BL  /  MOV AH, 0

пишут в разные части одного и того же регистра, не спаривается.

4. Две инструкции, пишущие в разные части регистра флагов могут спариваться несмотря на правила 2 и 3.

Пример:

    SHR EAX, 4 / INC EBX            ; спаривается

5. Инструкция, которая пишет в регистр флагов может спариваться с условным переходом несмотря на правило 2.

Пример:

    CMP EAX, 2 / JA LabelBigger     ; спаривается

6. Следующая комбинация инструкций может спариваться несмотря на тот факт, что обе изменяют указатель на стек:

    PUSH + PUSH,  PUSH + CALL,  POP + POP

7. Есть ограничения на спаривание инструкций с префиксами. Есть несколько типов префиксов:

  • у инструкций, обращающихся к сегменту, не являющимся сегментом по умолчанию, есть сегментный префикс.
  • у инструкций, использующих 16-ти битные данные в 32х-битном режиме или 32-х битные данные в 16-битном режиме, есть префикс размера операнда.
  • у инструкций, использующих 32-х битную адресацию через регистры в 16-ти битном режиме, есть префикс размера адреса.
  • у повторяющихся есть префикс повторения.
  • у закрытых инструкций есть префикс LOCK.
  • у многих инструкций, которых не было на процессоре 8086 есть двухбайтный опкод, чей первый байт равен 0FH. Байт 0FH считается префиксом только на PPlain. Наиболее часто используемые инструкции с этим префиксом следующие: MOVZX, MOVSX, PUSH FS, POP FS, PUSH GS, POP GS, LFS, LGS, LSS, SETcc, BT, BTC, BTR, BTS, BSF, BSR, SHLD, SHRD, и IMUL с двумя операндами и без числового операнда.

На PPlain инструкция с префиксом может выполняться только в U-конвейере, кроме ближних условных переходов.

На PMMX инструкции с префиксами размера операнда, размера адреса или 0FH могут выполняться в любом конвейере, в то время как инструкции с префиксами сегмента, повторения или LOCK могут выполняться только в U-конвейере.

8. Инструкция, в которой используется одновременно адресация со смещение и числовые данные на PPlain не спариваются, на PMMX могут спариваться только в U-конвейере:

    MOV DWORD PTR DS:[1000], 0    ; не спаривается или только в U-конвейере
    CMP BYTE PTR [EBX+8], 1       ; не спаривается или только в U-конвейере
    CMP BYTE PTR [EBX], 1         ; спаривается
    CMP BYTE PTR [EBX+8], AL      ; спаривается

(Другая проблема, связанная с подобными инструкциями, выполняющимися на PMMX, заключается в том, что такие инструкции могут быть длиннее семи байтов, а это приводит к невозможности раскодировки больше одной инструкции за такт, подробнее это объясняется в главе 12.)

9. Обе инструкции должны быть заранее загружены и раскодированы. Это объяснение в главе 8.

10. Есть специальные правила спаривания для инструкций MMX на PMMX:

  • MMX-сдвиг, инструкции упаковки или распаковки могут выполнять в любом конвейере, но не могут спариваться с другим MMX-сдвигом, инструкциями упаковки или распаковки.
  • MMX-инструкции умножения могут выполняться в любом конвейере, но не могут спариваться с другими MMX-инструкциями умножения. Они занимают 3 такта, и последние 2 такта могут перекрыть последующие инструкции, также как это делают инструкции плавающей запятой (см. главу 24).
  • MMX-инструкция, обращающаяся к памяти или целочисленным регистрам может выполняться только в U-конвейер и не может спариваться с не-MMX инструкцией.

10.2 Несовершенное спаривание

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

Несовершенное спаривание возникает в следующих случаях:

  • Если вторая инструкция приводит к задержке AGU (глава 9).
  • Две инструкции не могут обращаться к одному и тому двойному слову в памяти одновременно:
    MOV AL, [ESI] / MOV BL, [ESI+1]

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

    MOV AL, [ESI+3] / MOV BL, [ESI+4]

    Эти два операнда находятся в разных двойных словах, поэтому они прекрасно спариваются и занимают всего один такт.

3. Правило 2 расширяет зону своего действия, если биты 2-4 обоих адресов одинаковы (конфликт банков кэша). Для адресов размеров в двойное слово это означает, что разность между обоими адресами не должна делиться на 32.

Примеры:

   MOV [ESI], EAX / MOV [ESI+32000], EBX ;  несовершенное спаривание
   MOV [ESI], EAX / MOV [ESI+32004], EBX ;  совершенное спаривание

Спариваемые целочисленные инструкции, которые не обращаются к памяти, требуют один такт для выполнения, кроме неправильно предсказанных переходов. Инструкции MOV, читающие или пишущие в память также занимают один такт, если данные находятся в кэше и правильно выровнены. Нет потери в скорости при использовании сложных способов адресации, такие как смещение и масштабирование.

Спариваемая целочисленная инструкция, которая читает из памяти, делает какие-то вычисления, а затем сохраняет результат в регистрах или флагах, занимает 2 такта (инструкции чтения/модифицирования).

Спариваемая целочисленная инструкция, которая читает из памяти, делает какие-то вычисления, а затем записывает результат обратно в память, занимает 3 такта (инструкции чтения/модифицирования/записи).

4. Если инструкция чтения/модифицирования/записи спаривается с инструкцией чтения/модифицирования или чтения/модифицирования/записи, тогда они спарятся несовершенно.

Количество тактов, которые потребуются для выполнения такой пары, даны в следующей таблице:

   Первая инструкция                    Вторая инструкция

                           MOV или регистр  чтение/изменение  чтение/изменение/запись

   MOV или регистр              1                2                 3
    чтение/изменение            2                2                 3
   чтение/изменение/запись      3                4                 5

Пример:

ADD [mem1], EAX / ADD EBX, [mem2] ; 4 такта
ADD EBX, [mem2] / ADD [mem1], EAX ; 3 такта

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

6. Спариваемая инструкция плавающей запятой и следующая за ней FXCH повлекут несовершенное спаривание, если следующая инструкция не является инструкцией плавающей запятой.

Чтобы избежать несовершенного спаривания, вы должны знать, какие инструкции пойдут в U-конвейер, а какие - в V-конвейер. Вы можете выяснить это, просмотрев свой код и поискав инструкции, которые не спариваются, или спариваются только в определенном конвейере, или не могут спариваться в силу одного из вышеизложенных правил.

Несовершенное спаривание можно зачастую избежать, реорганизовав свои инструкции.

Пример:

L1:     MOV     EAX,[ESI]
        MOV     EBX,[ESI]
        INC     ECX

Здесь две инструкции MOV формируют несовершенную пару, потому что обе обращаются к одной и той же области в памяти, поэтому последовательность займет 3 такта. Вы можете улучшить этот код, реорганизовав инструкции так, чтобы 'INC ECX' спаривалась с одной из инструкции MOV.

L2:     MOV     EAX,OFFSET A
        XOR     EBX,EBX

        INC     EBX
        MOV     ECX,[EAX]
        JMP     L1

Пара 'INC EBX / MOV ECX,[EAX]' несовершенная, потому что следующая инструкция приводит к задержке AGI. Последовательность занимает 4 такта. Если вы вставите NOP или какую-нибудь другую инструкцию, чтобы 'MOV ECX,[EAX]' спаривался с 'JMP L1', последовательность займет только три такта.

Следующий пример выполняется в 16-ти битном режима, предполагается, что SP делится на 4:

L3:     PUSH    AX
        PUSH    BX
        PUSH    CX

        PUSH    DX
        CALL    FUNC

Инструкции PUSH формируют две несовершенные пары, потому что оба операнда в каждой паре обращаются к одному и тому же слову в памяти. 'PUSH BX' могла бы совершенно спариться с PUSH CX (потому что они находятся по разные стороны от границы, отделяющей двойные слова друг от друга), но этого не происходит, потому что она уже спарена с PUSH AX. Поэтому последовательность занимает 5 тактов. Если вы вставите NOP или другую инструкцию, чтобы 'PUSH BX' спаривалась с 'PUSH CX', а 'PUSH DX' с 'CALL FUNC', последовательность займет только 3 такта. Другой путь разрешения данной проблемы - это убедиться, что SP не кратен четырем. Правда, узнать это в 16-ти битному режиме довольно сложно, поэтому лучший выход - использовать 32-х битный режим.


11. Разбивка сложных инструкций на более простые (PPlain и PMMX)

Вы можете разбить инструкции чтения/модифицирования и инструкции чтения/модифицирования/записи, чтобы улучшить спаривание.

Пример:

ADD [mem1],EAX / ADD [mem2],EBX ; 5 тактов

Этот код можно разбить на следующую последовательность, которая будет занимать только 3 такта:

    MOV ECX,[mem1] / MOV EDX,[mem2] / ADD ECX,EAX / ADD EDX,EBX
    MOV [mem1],ECX / MOV [mem2],EDX

Таким же образом вы можете разбивать неспариваемые инструкции на спариваемые:

    PUSH [mem1]
    PUSH [mem2]  ; не спаривается

Разбивается на:

    MOV EAX,[mem1]
    MOV EBX,[mem2]
    PUSH EAX
    PUSH EBX     ; все спаривается

Другие примеры неспариваемых инструкций, которые можно разбить на простые спариваемые:

CDQ разбивается на MOV EDX,EAX / SAR EDX,31
NOT EAX меняется на XOR EAX,-1
NEG EAX разбивается на XOR EAX,-1 / INC EAX
MOVZX EAX,BYTE PTR [mem] разбивается на XOR EAX,EAX / MOV AL,BYTE PTR [mem]
JECXZ разбивается на TEST ECX,ECX / JZ
LOOP разбивается на DEC ECX / JNZ
XLAT меняется на MOV AL,[EBX+EAX]

Если разбивка инструкций не повышает скорость, вы можете оставить сложные или неспариваемые конструкции, чтобы уменьшить размер кода.

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


12. Префиксы (PPlain и PMMX)

Инструкция с одним или более префиксами не может исполняться в V-конвейере (смотри главу 10, секцию 7).

На PPlain возникает задержка в один такт для каждого префикса, кроме 0FH в инструкциях условного ближнего перехода.

У PMMX нет задержки раскодирования из-за префикса 0FH. Префиксы сегментов и повторения занимают один такт для раскодирования. PMMX может раскодировать две инструкции, если у первой инструкции нет префиксов или есть префикс сегмента или повторения, у второй инструкции нет префиксов. Инструкции с префиксами размера адреса или операнда на PMMX раскодируются только отдельно. Инструкции с несколькими префиксами занимают по одному такту на каждый префикс.

Префиксы размера адреса нужно избегать, используя 32-х битный режим. Префиксы сегментов можно избежать в 32-х битном режиме, используя плоскую модель памяти. Префикс размера операнда можно избежать в 32-х битном режиме, используя только 8-ми битные и 32-х битные числа.

Когда префиксы нельзя избежать, задержку раскодирования можно скрыть, если предыдущая инструкция занимает больше одного такта для исполнения. Правило для PPlain такое: каждая инструкция, которая занимает N тактов для выполнения (не для раскодирования) может 'затенять' задержку раскодирования N-1 префиксов в следующих двух (иногда трех) инструкциях или пар инструкций. Другими словами, каждый дополнительных такт, который требует инструкция для выполнения, может быть использован для раскодирования одного префикса в следующей инструкции. Этот эффект иногда распространяется даже на правильно предсказанный переход. Любая инструкция, которая занимает больше одного такта для выполнения, и любая инструкция, чье выполнение задерживается из-за AGI, неправильного использования кэша, неправильного выравнивания или неправильного предсказания перехода, создает эффект 'затемнения'.

У PMMX есть похожий эффект 'затемнения', но он имеет другой механизм. Раскодированные инструкции хранятся в прозрачном "первым-вошел-первым-вышел" (FIFO) буфере, в котором может храниться до четырех инструкций. Когда буфер пуст, инструкции выполняются сразу после раскодировки. Буфер заполняется, когда инструкции раскодировываются быстрее, чем выполняются, то есть они неспарены или являются мультитактовыми. Буфер FIFO опустошается, когда инструкции выполняются быстрее, чем раскодируются, то есть когда возникают задержки из-за префиксов. Буфер FIFO становится пустым после неправильно предсказанного перехода. Буфер FIFO может получить две инструкции за такт, если у второй инструкции нет префиксов и ни одна из инструкций не длиннее 7 байт. Два конвейера (U и V) могут получать по одной инструкции за такт из буфера FIFO.

Примеры:

CLD / REP MOVSD

Инструкция CLD занимает два такта, и поэтому затемняет задержку раскодировки префикса REP. Код занял бы на один такт больше, если бы инструкция CLD находилась достаточно далеко от REP MOVSD.

CMP DWORD PTR [EBX],0 / MOV EAX,0 / SETNZ AL

Инструкция CMP занимает два такта, потому что это инструкция чтения/модифицирования. Префикс 0FH инструкции SETNZ раскодируется во время второго такта выполнения CMP, поэтому задержка раскодирования скрыта на PPlain (у PMMX нет задержки для 0FH).

Потери производительности, связанные с префиксами, на PPro, PII и PIII объясняются в главе 14.


13. Обзор конвейера PPro, PII и PIII

Архитектура микропроцессоров PPro, PII и PIII хорошо объяснена и проиллюстрирована в различных руководствах от Интела. Рекомендуется сначала изучить этот материал, чтобы понимать, как работают эти процессоры. Я коротко объясню его структуру с упором на те элементы, которые необходимы для оптимизирования кода.

Коды доставляются из кэша кода выравненными 16-ти байтными чанками в двойной буфер, в который умещается два таких чанка. Затем код поступает из двойного буфера в декодеры в виде блоков, названными мной "блоками доставки инструкций" (БДИ). Обычно длина БДИ 16 байт, но он может быть не выравнен. Цель двойного буфера - сделать возможным раскодировку инструкций, которые пересекают границу в 16 байт (например, адрес, кратный 16).

БДИ поступает в декодер длины инструкции, который определяет, где начинается каждая из инструкций и где заканчивается, а затем попадает в декодеры инструкций. Существуют три декодера, поэтому вы можете декодировать до трех инструкций за такт. Группа из трех инструкций, декодируемых за один и тот же такт, называется декодируемой группой.

Декодеры переводят инструкции в микрооперации (сокращенно "мопы"). Простые инструкции генерируют только один моп, в то время, как более сложные инструкции могут генерировать несколько мопов. Например, инструкция 'ADD EAX,[MEM]' декодируется в два мопа, один, считывающий исходный операнд из памяти, а другой, который выполняет сложение. Целью разбития инструкций на мопы является сделать обработку инструкций более эффективной.

Три декодера называются D0, D1 и D2. D0 может обрабатывать любые инструкции, в то время как D1 и D2 может обрабатывать только простые инструкции, генерирующие только один моп.

Мопы из декодеров через короткую очередь поступают в таблицу распределения регистров (RAT). Выполнение мопов осуществляется на временных регистрах, после чего результат записывается в постоянные регистры EAX, EBX и так далее. Целью RAT является указание мопам, какие временные регистры использовать и позволить переименование регистров (смотри далее).

После RAT мопы поступают в буфер перегруппировки (ROB). Целью ROB является неупорядоченное выполнение. Мопы остаются в области рекреации (reservation station), пока не станут доступны операнды, которые им необходимы. Если операнд для уопа задерживается из-за того, что генерирующий его предыдущий моп еще не закончил свою работу, тогда ROB может найти другой моп в очереди, чтобы сэкономить время.

Готовые к выполнению мопы посылаются в модули выполнения (execution units), которые сгруппированы вокруг пяти портов: порт 0 и 1 могут обрабатывать все арифметические операции, переходы и так далее. Порт 2 берет на себя операции считывания из памяти, порт 3 высчитывает адреса для записи в память, а порт 4 выполняет эту запись.

После того, как инструкция была выполнена, она помечается в ROB как готовая к удалению, после чего поступает в область удаления (retirement station). Здесь содержимое временных регистров, использованных мопами, записывается в постоянные регистры. Хотя мопы могут запускаться не по порядку, последний должен быть восстановлен при удалении.


14. Раскодировка инструкций (PPro, PII и PIII)

Я рассказываю о раскодировке инструкций до доставки инструкций, потому что вам необходимо знать, как работают раскодировщики, чтобы понимать возможные способы доставки.

Декодеры могут обрабатывать три инструкции за такт, но только, если соблюдены определенные условия. Декодер D0 может обработать за один такт любую инструкцию, которая генерирует до 4 мопов. Декодеры D1 и D2 могут обрабатывать только те инструкции, которые генерируют 1 моп и эти инструкции не могут занимать больше 8 байт.

Резюмируем правила для декодирования двух или трех инструкций за один такт:

  • Первая инструкция (D0) не должна генерировать больше 4-х мопов.
  • Вторая и третья инструкции не должны генерировать больше, чем по одному мопу.
  • Вторая и третья инструкции не могут занимать больше 8-ми байтов каждая.
  • Инструкции должны содержаться внутри одного 16-байтного БДИ (смотри следующую главу).

Нет ограничения на длину инструкции в D0 (несмотря на то, то руководства от Интел говорят об обратном) пока все три инструкции влезают в один 16-ти байтный блок.

Инструкция, которая генерирует больше 4-х мопов требует два или больше такта для раскодировки, и ни одна другая инструкция не может раскодироваться параллельно.

Из вышеприведенных правил следует, что декодеры могут генерировать максимум шесть мопов за такт, если первая инструкция в каждой раскодируемой группе разбивается на 4 мопа, а другие две - на один каждая. Минимальное количество - это два мопа за такт, если все инструкции генерируют по два мопа, так что D1 и D2 никогда не используются.

Для максимальной производительности рекомендуется перегруппировать ваши инструкции в блоки 4-1-1. Инструкции, которые генерируют 2 или 4 мопа можно разбить на две простые одномопные инструкции, что не будет вам стоить ни такта.

Пример:

MOV     EBX, [MEM1]     ; 1 uop  (D0)
INC     EBX             ; 1 uop  (D1)
ADD     EAX, [MEM2]     ; 2 uops (D0)
ADD     [MEM3], EAX     ; 4 uops (D0)

Это занимает три такта для раскодировки. Вы можете сохранить один такт, перегруппировав инструкции в две декодируемые группы:

ADD     EAX, [MEM2]     ; 2 uops (D0)
MOV     EBX, [MEM1]     ; 1 uop  (D1)
INC     EBX             ; 1 uop  (D2)
ADD     [MEM3], EAX     ; 4 uops (D0)

Теперь декодеры генерируют 8 мопов за два такта, что удовлетворительно. На более поздних стадиях конвейер может обрабатывать только 3 мопа за такт, поэтому при скорости декодирования высшей, чем эта, вы можете считать, что декодирование не является узким местом. Тем не менее, сложности в механизме доставки могут задержать раскодирование так, как это рассказывается в следующей главе поэтому, чтобы быть спокойным, лучше стремиться к скорости раскодировки большей 3-х мопов за такт.

Вы можете узнать количество мопов, генерируемых инструкцией в главе 29.

Префиксы инструкций также приводят к потере скорости раскодировки. У инструкций могут быть префиксы следующих видов:

  • Префикс размера операнда требуется, когда вы используете 16-ти битный операнд в 32-х битном окружении или наоборот. (Не считая инструкций, у которых операнды могут быть только одного размера например, FNSTSW AX). Префикс размера операнда вызывает потерю нескольких тактов, если у инструкции есть числовой 16-ти или 32-х битный операнд, потому что размер операнда меняется префиксом.

    Примеры:

            ADD BX, 9      ; no penalty because immediate operand is 8 bits
                       ; (нет потерь, так как числовой операнд занимает 8 бит)
        MOV WORD PTR [MEM16], 9  ; penalty because operand is 16 bits
                                 ; (есть потери, так как операнд занимает 16 бит)

    Последнюю инструкцию следует заменить на

            MOV EAX, 9
        MOV WORD PTR [MEM16], AX  ; no penalty because no immediate
                       ; (нет потерь, так как нет числовых операндов)
  • Префикс размера адреса используется при 32-х битной адресации в 16-ти битном режиме или наоборот. Это редко требуется и этого следует избегать. Префикс размера адреса вызывает потери каждый раз, когда операнды используются явно, потому что интерпретация изменяется с помощью префикса. Инструкции, использующие операнды памяти неявно (например строковые операции), не приводят к потерям, связанным с префиксом размера операнда.
  • Префиксы сегментов используются, когда вы обращаетесь к данным, находящимся не в сегменте данных по умолчанию. На PPro, PII и PIII префиксы сегментов не приводят к потерям.
  • Префиксы повторения и префиксы закрытия (lock prefixes) не приводят к потерям при декодировании.
  • Всегда есть потери, когда у вас больше одного префикса. Обычно уходит по одному такту на префикс.


15. Доставка инструкций (PPro, PII и PIII)

Код доставляется в двойной буфер из кэша кода чанками по 16 байт. Двойной буфер называется так, потому что он содержит два таких чанка. Затем код берется из двойного буфера и скармливается декодерам поблочно (каждый блок обычно 16 байтов длиной, но необязательно, он может быть и не выравнен по этой границе). Я называю эти блоки БДИ (блоки доставки инструкций). Если БДИ пересекает границу 16 байт в коде, его нужно изъять из обоих чанков и поместить в буфер. Таким образом, двойной буфер нужен для того, чтобы позволить доставку инструкций, пересекающих барьер в 16 байт.

Двойной буфер может доставить один 16-ти байтный чанк за такт и может сгенерировать один БДИ за это же время. БДИ обычно 16-ти байтов длиной, но могут быть короче, если есть предсказанный переход в блоке. (См. главу 22.Команды передачи управления и переходов..).

К сожалению, двойной буфер недостаточно велик, чтобы обрабатывать инструкции, связанные с переходами без задержек. Если БДИ, который содержит инструкцию перехода, пересекает 16-ти байтную границу, двойному буферу требуется держать два последовательных 16-ти байтных чанка, чтобы сгенерировать его. Если первая инструкция после перехода пересекает 16-ти байтную границу, тогда двойной буфер должен загрузить два новых 16-ти битных чанка кода, прежде чем будет сгенерирован БДИ. Это означает, что в худшем случае раскодировка первой инструкции после перехода может быть задержана на два цикла. У вас происходят потери из-за 16-ти байтных границ в БДИ, содержащем переход, и из-за пересечения 16-ти байтной границы после перехода. Вы можете получить выигрыш в производительности, если у вас больше, чем одна раскодированная группа в БДИ, которая содержит переход, потому что это дает двойному буферу дополнительное время для доставки одного или двух чанков кода следующих за переходом. Подобные выигрыши могут компенсировать потери согласно таблице ниже. Если двойной буфер доставляет только один 16-ти байтный чанк после перехода, тогда первый БДИ после перехода будет идентичен этом чанку, то есть выравнен по 16-ти байтной границе. Другими словами, первый БДИ после перехода не будет начинаться с первой инструкции, но с ближайшего предшествующего адреса кратного 16. Если у двойного буфера есть время, чтобы загрузить два чанка, тогда новый БДИ может пересечь 16-ти байтную границу и начаться с первой инструкции после перехода. Эти правила кратко представлены в следующей таблице:

 Количество     16-байтная      16-байтная       задержка выравнивание
 декодируемых   граница в этом  граница в первой декодера первого БДИ
 груп в БДИ,    БДИ             инструкции после          после перехода
 содержащем                     перехода
 переход
        1              0               0            0           на 16
        1              0               1            1      к инструкции
        1              1               0            1           на 16
        1              1               1            2      к инструкции
        2              0               0            0      к инструкции
        2              0               1            0      к инструкции
        2              1               0            0           на 16
        2              1               1            1      к инструкции
    3 или больше       0               0            0      к инструкции
    3 или больше       0               1            0      к инструкции
    3 или больше       1               0            0      к инструкции
    3 или больше       1               1            0      к инструкции

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

Следующая проблема с механизмом доставки инструкций заключается в том, что новый БДИ не сгененирируется, пока предыдущий не будет полностью отработан. Каждый БДИ может содержать несколько раскодированных групп. Если БДИ длиной 16 байт заканчивается незавершенной инструкцией, тогда следующий БДИ начнется в начале этой инструкции. Первая инструкция в БДИ всегда идет в D0, а следующие две инструкции направляются в D1 и D2, если это возможно. Как следствие D1 и D2 используются не совсем оптимально. Если код структурирован согласно правилу 4-1-1, а инструкция, которая, как предполагалось, должна направиться в D1 или D2, оказывается первой инструкцией в БДИ, тогда она попадает в D0, что ведет к потере одного такта. Вероятно, это недостаток архитектуры процессора. Из-за этого время, которое займет раскодировка определенного кода может зависеть от того, где начнется первый БДИ.

Если скорость раскодирования инструкций критична и вы хотите избежать этой проблемы, вам нужно знать, где начинается каждый БДИ. Это довольно нудная работа. Вначале вам необходимо разделить ваш сегмент на параграфы, чтобы знать, где находятся 16-ти байтные границы. Затем вам нужно взглянуть на ассемблерный листинг, чтобы увидеть, какова длина каждой инструкции. (Рекомендуется, изучить, как кодируются инструкции, чтобы уметь предсказывать их длину.) Если вы знаете, где начинается один БДИ, тогда вы можете найти, где начинается другой, а именно: сделать блок 16 байтов длиной. Если он кончается на границе между инструкциями, значит следующий БДИ начнется здесь. Если он включает в себя часть инструкции, тогда следующий БДИ начнется с этой инструкции. (Здесь нужно подсчитывать только длины инструкций. Не имеет значения, сколько мопов они генерируют.) Таким образом вы можете обработать весь код и отметить, где начинается каждый из БДИ-блоков. Единственная проблема - это узнать, где находится первый БДИ. Вот несколько подсказок:

  • Первый БДИ после перехода, вызова или возвращения может начинаться либо с первой, либо с ближайшей предшествующей 16-ти байтной границе, согласно вышеприведенной таблице. Если вы выравняете первую инструкцию так, чтобы она начиналась с 16-байтной границы, вы можете быть уверены, что первый БДИ начнется здесь. Вы можете выравнять подобным образом важные процедуры и циклы, чтобы сделать быстрее работу программы.
  • Если комбинированная длина двух последовательных инструкций больше 16 байтов, вы можете быть уверены, что вторая не влезет в тот же БДИ, что и первая, следовательно вы можете быть уверены, что вторая инструкция будет первой в БДИ. Вы можете использовать ее в качестве стартовой точки для того, чтобы найти, где начинаются следующие БДИ.
  • Первый БДИ после неправильного предсказания перехода начинается по 16-ти байтной границе. Как объясняется в главе 22.2, цикл, который повторяется больше 5 раз, всегда будет приводить к неправильному предсказанию перехода при выходе. Первый БДИ после такого цикла будет начинаться по ближайшей предшествующей 16-ти байтной границе.
  • Есть также другие события, приводящие к подобному эффекту, например прерывания, исключения, самомодифицирующийся код и такие инструкции как CPUID, IN и OUT.

Я уверен, что теперь вы хотите получить пример:

 адрес        инструкция              длина     мопы   ожидаемый декодер

 ----------------------------------------------------------------------
 1000h        MOV ECX, 1000             5         1       D0
 1005h   LL:  MOV [ESI], EAX            2         2       D0
 1007h        MOV [MEM], 0             10         2       D0
 1011h        LEA EBX, [EAX+200]        6         1       D1
 1017h        MOV BYTE PTR [ESI], 0     3         2       D0
 101Ah        BSR EDX, EAX              3         2       D0
 101Dh        MOV BYTE PTR [ESI+1],0    4         2       D0
 1021h        DEC ECX                   1         1       D1
 1022h        JNZ LL                    2         1       D2

Давайте предположим, что первый БДИ начинается по адресу 1000h и заканчивается в 1010h. Это до завершения инструкции 'MOV [MEM], 0', поэтому следующий БДИ начнется в 1007h и закончится в 1017h. Это граница между инструкциями, поэтому третий БДИ начнется в 1017h и захватит весь остаток цикла. Количество тактов, которое уйдет на раскодировку - это количество инструкций, попадающих в D0. Всего их в цикле 5. Последний БДИ содержит три раскодированных блока, включая последние пять инструкций, и одну 16-ти байтную границу (1020h). Если мы еще раз посмотрим на таблицу, то увидим, что первый БДИ после перехода будет начинаться с первой инструкции после перехода (там, где стоит метка LL) и заканчиваться у 1015h. Это перед концом инструкции LEA, поэтому следующий БДИ будет начинаться с 1011h до 1021h, а последний будет идти от 1021h до самого конца. Теперь LEA и DEC попадают в начало БДИ, поэтому они обе направляются в D0. У нас есть 7 инструкций в D0 и на раскодировку цикла во втором повторении уходит 7 тактов. Последний БДИ содержит только одну раскодированную группу (DEC ECX / JNZ LL) и у него нет 16-ти байтной границы. Согласно таблице, следующий БДИ после перехода начнется с 16-ти байтной границы, то есть 1000h. Мы оказываемся в той же ситуации, что и в первом повторении, и вы увидите, что раскодировка цикла занимает соответственно 5 и 7 тактов. Так как других узких мест нет, на выполнение цикла 1000 раз уйдет 6000 тактов. Если бы стартовый адрес был другим, и в первой или последней инструкции была бы 16-ти байтная граница, то это бы заняло 8000 тактов. Если вы перегруппируете цикл, так чтобы инструкции для D1 или D2 не попадали в начало БДИ, тогда выполнение цикла заняло бы еще меньше - 5000 тактов.

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

Например вы можете изменить стартовый адрес вашей процедуры, чтобы избежать 16-ти байтных границ, когда они вам не нужны. Помните, ваш сегмент кода должен быть выравнен по параграфу, чтобы вы знали, где находятся границы.

Если вы вставите директиву 'ALIGN 16' до начала цикла, тогда ассемблер поместит NOP и другие подобные инструкции, чтобы выравнять код. Большинство ассемблеров используют инструкцию 'XCNG EBX, EBX' в качестве двухбайтного заполнителя (иногда ее называют "двухбайтным NOP'ом"). Кому бы ни пришла в голову эта идея, лучше данную инструкцию не использовать, потому что она занимает больше времени, чем два NOP'а на большинстве процессоров. Если цикл выполняется много раз, тогда то, что находится за его пределами неважно, если говорить о скорости и вы не должны заботиться о том, какую инструкцию использовать в качестве заполнителя. Но если время, занимаемое этими инструкциями играет роль, тогда вы можете выбрать инструкцию-заполнитель самостоятельно. Вы также можете использовать инструкции, которые делают что-нибудь полезное, например обновляют регистр, дабы избежать задержек при чтении регистра. Например, если вы используете регистр EBP для адресации, но редко пишете в его, вы можете использовать 'MOV EBP,EBP' или 'ADD EBP, 0' в качестве заполнителя, чтобы снизить возможность вышеупомянутых задержек. Если вам не делать ничего подобного, вы можете использовать FXCH ST(0), потому что она не создает никакой нагрузки на порты выполнения, при условии, что ST(0) содержит верное значение с плавающей запятой.

Еще одним лекарством может стать перегруппировка ваших инструкций таким образом, чтобы границы между БДИ не приносили вреда. Это может стать сложной головоломкой и найти приемлемое решение не всегда возможно.

Другая возможность - манипуляции с длинами инструкций. Иногда вы можете заменить одну инструкцию другой с иной длиной. Многие инструкции можно закодировать различными вариантами с разной длиной. Ассемблер всегда выбирает наиболее короткую версию инструкции, но все можно закодировать в более длинную версию. Например 'DEC ECX' занимает один байт длиной, 'SUB ECX, 1' - тир байта, и вы можете закодировать 6-ти байтовую версию, используя числовой параметр размером в двойное слово, используя этот трюк:

         SUB ECX, 9999
         ORG $-4
         DD 1

Инструкции с операндами в памяти можно сделать на один байт длиннее с помощью SIB-байта, но самым легким путем сделать инструкцию на один байт длиннее является добавление сегментного префикса DS (DB 3Eh). Микропроцессоры обычно принимают избыточные и ничего не значащие префиксы (не считая LOCK), если длина инструкции не превышает 15-ти байт. Даже инструкции без операндов памяти могут иметь сегментный префикс. Поэтому если вы хотите, чтобы инструкция 'DEC ECX' была два байта длиной, напишите:

         DB  3Eh
         DEC ECX

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


16. Переименование регистров (PPro, PII и PIII)

16.1 Уничтожение зависимостей

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

         MOV EAX, [MEM1]
         IMUL EAX, 6
         MOV [MEM2], EAX
         MOV EAX, [MEM3]
         INC EAX
         MOV [MEM4], EAX

Здесь последние три инструкции независимы от трех первых в том смысле, что им не требуется результат, полученный после их выполнения. Чтобы оптимизировать этот код на ранних процессорах вы были должны использовать другой регистр, отличный от EAX, в последних трех инструкциях и перегруппировать инструкции так, чтобы последние три выполнялись параллельно с первыми тремя. PPro, PII и PIII делают это автоматически. Они назначают новый временный регистр для EAX каждый раз, когда вы пишете в него. Таким образом инструкции 'MOV EAX, [MEM3]' становятся независимыми от предшествующих инструкций. С помощью выполнения не по порядку 'MOV [MEM4], EAX' может выполниться до того окончания обработки медленной инструкции IMUL.

Переименование регистров происходит полностью автоматически. Новый временный регистр назначается как псевдоним постоянному регистру каждый раз, когда инструкция пишет в этот регистр. Например инструкция 'INC EAX' использует один временный регистр для ввода и другой временный регистр для вывода. Это, разумеется, не убирает зависимости, но имеет некоторое значение для последующих чтений из регистра, о чем я расскажу позже.

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

Общей практикой установки значения регистра в ноль является 'XOR EAX,EAX' или 'SUB EAX, EAX'. Эти инструкции не распознаются как независимые от предыдущего значения регистра. Если вы хотите убрать зависимость от медленных предшествующих инструкций, используйте 'MOV EAX, 0'.

Переименование регистров контролируется таблицей псевдонимов регистров (RAT) и буфером перегруппировки (ROB). Мопы из декодеров поступают в RAT через очередь, затем в ROB, а после чего в резервационную станцию (reservation station). RAT может обрабатывать только 3 мопа за такт. Это означает, что суммарная производительность процессора не может превышать 3 мопа за такт.

Практически нет никаких ограничений на количество переименований. RAT может переименовывать три регистра за такт, и он может даже переименовать один и тот же регистр три раза за один такт.

16.2 Задержки чтения регистров.

Но существует другое ограничение, которое может быть весьма серьезным, и это то, что за один такт вы можете читать только из двух постоянных регистров. Это ограничение относится ко всем регистрам, используемых инструкциям, не считая тех регистров, в которые инструкции только пишут. Пример:

         MOV [EDI + ESI], EAX
         MOV EBX, [ESP + EBP]

Первая инструкция генерирует два мопа: один считывает EAX, а другой - EDI и ESI. Вторая инструкция генерирует один моп, который читает ESP и EBP. EBX не учитывается, поскольку инструкция только пишет в него. Давайте предположим, что эти три мопа идут вместе через RAT. Я буду использовать слово триплет для группы из трех последовательных мопов, которые идут вместе через RAT. Так как ROB может обрабатывать только два чтения из постоянных регистров за такт, а нам нужно пять чтений, то наш триплет будет задержан на два дополнительных такта, прежде чем он попадет в резервационную станцию. При трех или четырех чтений из постоянных регистров он был бы задержан на один такт.

Из одного регистра в одном триплете можно читать больше одного раза без ущерба для качества. Если вышеприведенные инструкции поменять на:

         MOV [EDI + ESI], EDI
         MOV EBX, [EDI + EDI]

тогда произойдет только два чтения из регистров (EDI и ESI) и триплет не будет задержан.

Регистр, в который будет произведена запись текущим мопом, сохраняется в ROB, поэтому из него можно свободно читать, пока он не будет выгружен оттуда, что занимает по меньшей мере три такта, а обычно еще больше. Выгрузка из ROB является финальной стадией выполнения, когда значение становится доступным. Другими словами, вы можете читать любое количество раз из регистра в RAT без задержек, если их значение еще не стало доступным у модулей выполнения, поэтому вы можете быть уверены, что из регистра, в который была произведена в одном триплете, можно свободно читать как минимум в трех последующих. Если выгрузка (writeback) была задержана перегруппировкой, медленными инструкциями, цепочками зависимости, задержкой кэша или по какой-то другой причине, тогда из регистра можно свободно читать еще некоторое время.

Пример:

         MOV EAX, EBX
         SUB ECX, EAX
         INC EBX
         MOV EDX, [EAX]
         ADD ESI, EBX
         ADD EDI, ECX

Эти шесть инструкций генерирую 1 моп каждая. Давайте предположим, что первые три мопа идут через RAT вместе. Эти три мопа читают регистры EBX, ECX и EAX. Но так как мы пишем в EAX до того, как начали из него читать, чтение производится "бесплатно" и у нас нет никаких задержек. Следующие три мопа читают EAX, ESI, EBX, EDI и ECX. Так как оба EAX, EBX и ECX были изменены предыдущим триплетом и не были еще выгружены, из них можно свободно читать, поэтому учитываются только ESI и EDI, и у нас нет задержек и во втором триплете. Если инструкцию 'SUB ECX, EAX' в первом триплете заменить на 'CMP ECX, EAX', тогда в ECX не производится запись, и у нас происходит задержка во втором триплете при чтении ESI, EDI и ECX. Подобным же образом, если инструкция 'INC EBX' в первом триплете поменять на NOP или что-нибудь вроде этого, тогда у нас будет задержка во втором триплете при чтении ESI, EBX и EDI.

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

Чтобы подсчитать количество прочитываемых регистров, вам нужно включить все регистры, которые считываются инструкцией. В это число входят все целочисленные регистры, флаговые регистры, указатель на стек, регистры плавающей запятой и регистры MMX. Регистр XMM идет за два, кроме тех случаев, когда используется только его часть, например в ADDSS и MOVHLPS. Сегментные регистр и указатель на инструкцию не учитываются. Например в 'SETZ AL' вы считаете флаговый регистр, но не AL. В 'ADD EBX, ECX' считаются и EBX и ECX, но не регистр флагов, потому что в него только производится запись. 'PUSH EAX' читает EAX и указатель на стек, а потом пишет в последний.

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

Не путайте триплеты мопов с раскодированными группами. Последняя может генерировать от одного до шести мопа, и даже, если раскодированная группа имеет три инструкции и генерирует три мопа, нет никакой гарантии, что эти три мопа попадут в RAT вместе.

Очередь между декодерами и RAT так коротка (10 мопов), что вы не можете предположить, что задержки чтения регистров не оказывают влияния на декодеры, или какие-то изменения в выводе декодеров не задерживают RAT.

Очень трудно предсказать, какие мопы пойдут через RAT вместе, если очередь не пуста, и для оптимизированного кода очередь должна быть пуста только после неправильно предсказанного перехода. Несколько мопов, генерируемых одной инструкцией не обязательно пойдут через RAT вместе; мопы просто берутся последовательно из очереди по три за раз. Последовательность не нарушается предсказанным переходом: мопы до и после перехода могут пойти через RAT вместе. Только неправильно предсказанный переход сбросит очередь и начнет все сначала, поэтому три следующих мопа точно попадут в RAT вместе.

Если три последовательных мопа читают больше, чем из двух регистров, то вы бы, конечно, предпочли, чтобы они не шли вместе друг с другом в RAT. Вероятность того, что они пойдут вместе - одна третья. Задержка чтения трех или четырех выгруженных регистра в одной тройке мопов - один такт. Вы можете считать задержку в один такт эквивалентом загрузки еще трех мопов в RAT. С вероятностью в 1/3 что три мопа пойдут в RAT вместе, средняя задержка будет эквивалентна 3/3 = 1 моп. Чтобы посчитать среднее время, которое займет для некоторого кода проход через RAT, добавьте количество потенциальных задержек чтения регистров к количеству мопов и поделите на три. Вы можете видеть, что нет смысла убирать задержки с помощью добавления дополнительных регистров, пока вы точно не уверены, какие мопы пойдут в RAT вместе, или же вы можете предотвратить больше, чем одну задержку чтения регистра с помощью одной дополнительной инструкции.

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

  • Держать мопы, которые читают один и тот же регистр близко друг к другу, чтобы они попали в один триплет.
  • Держать мопы, которые читают разные регистры подальше друг от друга, чтобы они не могли попасть в один триплет.
  • Располагать мопы, которые читают регистр не дальше трех-четырех триплетов от инструкции, которая писала или изменяла этот регистр, чтобы он не был выгружен прежде, чем будут произведены все необходимые операции чтения (не важно, если у вас есть переход, если он будет правильно предсказан). Если у вас есть основания полагать, что запись в регистр будет задержана, вы можете спокойно читать из него еще некоторое количество инструкций.
  • Использовать абсолютные адреса вместо указателей, чтобы снизить количество читаемых регистров.
  • Вы можете переименовать регистр в триплете, если это не вызывает задержку, чтобы предотвратить задержку чтения для этого регистра в одном или больше следующих далее триплетов. Пример: 'MOV ESP,ESP / ... / MOV EAX,[ESP+8]'. Этот метод стоит дополнительный моп, поэтому его стоит применять только, если среднее предполагаемое количество предотвращенных задержек чтения больше 1/3.

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

Запись в память

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

Примеры:

MOV [EDI], EAX

Первый моп читает EAX, второй читает EDI.

FSTP QWORD PTR [EBX+8*ECX]

Первый моп читает ST(0), второй моп читает EBX и ECX.

Чтение и модифицирование

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

Пример:

ADD EAX, [ESI+20]

Первый моп читает ESI, второй моп читает EAX и пишет EAX и регистр флагов.

Чтение/модифицирование/запись

Инструкция этого рода генерирует четыре мопа. Первый моп (порт 2) читает регистр-указатель, второй (порт 0 или 1) читает и производит запись в исходный регистр и, возможно, пишет в регистр флагов, третий моп (порт 4) считывает только временный результат, которые не учитывается здесь, четвертый (порт 3) читает все регистры-указатели снова. Так как первый и четвертый регистр не могут идти в RAT вместе, вы не получить преимущество от того, что они читают один и тот же регистр-указатель.

Пример:

OR [ESI+EDI], EAX

Первый моп читает ESI и EDI, второй читает EAX и пишет в EAX и в регистр флагов, третий читает только временный результат, четвертый читает ESI и EDI снова. Вне зависимости о того, в каком порядке эти мопы пойдут в RAT, вы можете быть уверены, что моп, который читает EAX пойдет вместе с тем, который читает ESI и EDI. Поэтому задержка чтения регистра неизбежна в этой инструкции, если только один из регистров не был изменен ранее.

Сохранение регистра в стек.

Сохранение регистра в стек генерирует 3 мопа. Первый (порт 4) - это инструкция сохранения, чтение регистра. Второй моп (порт 4) генерирует адрес, считывая указатель на стек. Третий (порт 0 или 1) вычитает размер слова из указателя на стек, читая и модифицируя его.

Обратная операция генерирует два мопа. Первый (порт 2) загружает значение, считывая указатель на стек и записывая значение в регистр. Второй моп (порт 0 или 1) корректирует указатель на стек, читая и модифицируя его.

Вызов

Ближний вызов генерирует 4 мопа (порты 1, 4, 3, 01). Первый два мопа читают указатель на инструкцию (EIP), который не учитывается, потому что он не может быть переименован. Третий моп читает указатель на стек. Последний моп читает и модифицирует его.

Возврат

Ближний возврат генерирует 4 мопа (порт 2, 01, 01, 1). Первый моп считывает ESP. Третий читает и модифицирует его.

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


17. Выполнение кода не по порядку (PPro, PII и PIII)

Буфер перегруппировки вмещает 40 мопов. Каждый моп ждет в ROB, пока все операнды не будут готовы и не появится свободный модуль для их выполнения. Это делает возможным выполнение кода не по порядку. Если одна часть кода задерживается из-за загрузки в кэш, это не повлияет на выполнение других частей кода, если они не зависят от первой части.

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

Если ваш код пишет в память по какому-то адресу, а вскоре считывает оттуда же, тогда чтение по ошибке может быть произведено до записи, потому ROB не имеет понятия об адресах во время перегруппировки. Эта ошибка отлавливается, когда подсчитывается адрес памяти, поэтому операция чтения в таком случае выполняется снова. Потери при этом составляют 3 такта. Единственный путь избежать этого - это убедиться, что у модуля выполнения будет занят делом между последовательными записью в память и чтением из нее по тому же адресу.

Есть несколько модулей выполнения, сгруппированных вокруг пяти портов. Порт 0 и 1 - для арифметических операций. Простые пересылки, арифметические и логические операции попадают в порт 0 или 1, в зависимости от того, какой из них свободен. Порт 0 также обрабатывает умножение, деление, целочисленные побитовые сдвиги и операции с плавающей запятой. Порт 1 в дополнение к основным функциям занимается переходами и некоторыми операциями MMX и XMM. Порт 2 обрабатывает все чтения из памяти и несколько строковых операций. В главе 29 вы найдете полный список мопов, генерируемых инструкциями кода с указанием того, в какой порт они попадают. Заметьте, что все операции записи в память требуют два мопа, один для порта 3, а другой для порта 4, в то время как операции чтения из памяти требуют только один моп (порт 2).

В большинстве случаев каждый порт может получать один моп за такт. Это означает, что вы можете выполнять до 5 операций мопов за такт, если они попадают в пять разных портов, но так как есть ограничение на 3 мопа за такт, установленное ранее в конвейере, вам никогда не удастся выполнить больше 3 мопов за такт в среднем.

Вы должны убедиться, что никакой порт не получает больше одной трети мопов, если вы хотите достичь производительности в 3 мопа за такт. Используйте таблицу мопов в главе 29 и подсчитайте, сколько мопов попадет в каждый порт. Если порты 0 и 1 перегружены, в то время как порт 2 свободен, вы можете улучшить свой код, заменив некоторые пересылки вида "регистр, регистр" или "регистр, числовое значение" на пересылку вида "регистр, память", чтобы перенести часть нагрузки с портов 0 и 1 на порт 2.

Большинство мопов занимают один такт для выполнения, но умножения, деления и многие операции с плавающей запятой занимают больше:

Сложения и вычитания плавающей запятой занимают 3 такта, но модуль выполнения полностью конвейеризован, поэтому он может принимать новые FADD и FSUB каждый такт, пока предыдущие инструкции выполняются (конечно, если они независимы друг от друга).

Целочисленное умножение занимает 4 такта, умножения плавающей запятой - 5, а умножения MMX - 3 такта. Умножения целых чисел и MMX конвейеризованы, поэтому они могут получать новые инструкции каждый такт. Умножения плавающей запятой частично конвейеризованы: модуль выполнения может получать новую инструкцию FMUL через два такта после получения предыдущей, поэтому максимальная производительность будет равная одному FMUL за каждые два такта. "Дыры" между FMUL'ами нельзя заполнить целочисленными умножениями, так как они используют ту же схему. Сложения и умножения XMM занимают 3 и 4 такта соответственно и полностью конвейеризованы. Но так как каждый регистр XMM представлен в виде двух 64-х битных регистров, упакованная инструкция XMM будет состоять из двух мопов, а производительность будет равна одной арифметической инструкции XMM каждые два такта. Инструкции сложения и умножения XMM могут выполняться параллельно, потому что они не используют одни и те же порты.

Деление целых чисел и чисел с плавающей запятой занимает до 39 тактов и не конвейеризовано. Это означает, что модуль выполнения не может начать новое деление, пока предыдущие не будет выполнено. Вышесказанное относится к квадратному корню и трансцендентальным функциям.

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

Конечно вы должны избегать инструкций, которые генерируют много мопов. Например инструкцию LOOP XX нужно заменить на DEC ECX / JNZ XX.

Если у вас есть две последовательные POP-инструкции, вы можете заменить их на другие, чтобы снизить количество мопов.

POP ECX / POP EBX / POP EAX     ; можно заменить на:
MOV ECX,[ESP] / MOV EBX,[ESP+4] / MOV EAX,[ESP] / ADD ESP,12

Первый код генерирует 6 мопов, последующий - 4 и декодируется быстрее. Делать то же самое с инструкциями PUSH менее выгодно, потому разбитый код будет генерировать задержки чтения регистров, если только у вас нет других инструкций, которые можно вставить между ними или какие-то регистры не были переименованы раньше. Делать подобное с инструкциями CALL и RET означает мешать правильному предсказанию перехода. Также обратите внимание, что 'ADD ESP' может вызвать задержку AGI в более ранних моделях процессоров.


18. Вывод из обращения (PPro, PII и PIII)

Вывод из обращения (retirement) - это процесс, когда временные регистры, используемые мопами, копируют в постоянные регистры EAX, EBX и так далее. Когда моп выполнен, он помечается в ROB как готовый к выводу из обращения.

Станция вывода из обращения может обрабатывать три мопа за такт. Может показаться, что здесь нет никакой проблемы, потому что вывод уже ограничен в RAT тремя мопами за такт. Тем не менее, вывод из обращения может стать узким местом по двум причинам. Во-первых, инструкции должны выводиться из обращения по порядку. Если моп был выполнен не по порядку, то он не может быть выведен из обращения, пока все мопы, предшествующие ему по порядку, не будут выведены из обращения до него. И второе ограничение - это то, что полученные переходы должны быть выведены из обращения в трех первых слотах станции. Как декодеры D1 и D2 могут быть неактивны, если следующая инструкция помещается только в D0, последние два слота станции вывода из обращения могут быть неактивны, если следующий моп, который должен быть выведен из обращения - это полученный (taken) переход. Это важно, когда у вас есть маленький цикл, количество мопов в котором не кратно трем.

Все мопы находятся в буфере перегруппировки (ROB), пока они не будут изъяты из обращения. ROB вмещает 40 мопов. Это устанавливает ограничение на количество инструкций, которые могут быть выполнены во время большой задержки, вызванной, например, делением или другой медленной операцией. Прежде, чем будет закончено деление, ROB будет заполнен выполняющимися мопами, ожидающими своего изъятия из обращения. Только когда деление будет закончено и изъято, последующие могут сами начать изыматься из обращения, потому что этот процесс должен выполняться по порядку.

В случае предварительного выполнения предсказанных переходов (смотри главу 22), предварительно выполненные мопы не могут быть изъяты из обращения, пока процессор не убедится, что предсказание верно. Если окажется обратное, тогда предварительно выполненные мопы сбрасываются без изъятия из обращения.

Следующие инструкции не могут быть предварительно выполнены: записи в память, IN, OUT и синхронизирующие операции.


19. Частичные задержки (PPro, PII и PIII)

19.1 Частичные задержки регистра

Частичная задержка регистра - это проблема, которая возникает, когда вы пишите в часть 32-х битного регистра, а затем читаете из всего регистра или его большей части. Пример:

        MOV AL, BYTE PTR [M8]
        MOV EBX, EAX            ; частичная задержка регистра

Происходит задержка в 5-6 тактов. Причина состоит в том, что в соответствие AL был поставлен временный регистр (чтобы сделать его независимым от AH). Модулю выполнения пришлось ждать, пока запись в AL не будет выведена из обращения, прежде чем станет возможным соединить значение AL c тем, что находится в остальной части EAX. Задержку можно избежать, изменив код, поменяв код на:

        MOVZX EBX, BYTE PTR [MEM8]
        AND EAX, 0FFFFFF00h
        OR EBX, EAX

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

Вам нужно остерегаться частичных задержек, когда вы смешиваете различные размеры данных (8, 16 и 32):

        MOV BH, 0
        ADD BX, AX              ; задержка
        INC EBX                 ; задержка

У вас не будет задержки при чтении части регистра после записи в целый регистр или его большую часть:

        MOV EAX, [MEM32]
        ADD BL, AL              ; нет задержки
        ADD BH, AH              ; нет задержки
        MOV CX, AX              ; нет задержки
        MOV DX, BX              ; задержка

Самый легкий путь избегать частичные задержки регистра - это всегда использовать полные регистры и использовать MOVZX или MOVSX при чтении из операндов более мелкого размера. Эти инструкции быстры на PPro, PII и PIII, но медленны на более ранних процессорах. Если вы хотите, чтобы ваш код выполнялся достаточно быстро на всех процессорах, то существует разумный компромисс. 'MOVZX EAX,BYTE PTR [M8]'

        XOR EAX, EAX
        MOV AL, BYTE PTR [M8]

Процессоры PPro, PII и PIII делают специальное исключение для этой комбинации, поэтому при последующем чтении из EAX задержки не возникнет. Происходит это потому, что регистр помечается как пустой, когда он XOR'ится сам с собой. Процессор помнит, что верхние 24 бита равны нулю и за счет этого избегается задержка. Этот механизм работает только со следующими комбинациями:

        XOR EAX, EAX

        MOV AL, 3
        MOV EBX, EAX            ; нет задержки

        XOR AH, AH
        MOV AL, 3
        MOV BX, AX              ; нет задержки

        XOR EAX, EAX
        MOV AH, 3
        MOV EBX, EAX            ; задержка

        SUB EBX, EBX
        MOV BL, DL
        MOV ECX, EBX            ; нет задержки

        MOV EBX, 0
        MOV BL, DL
        MOV ECX, EBX            ; задержка

        MOV BL, DL
        XOR EBX, EBX            ; нет задержки

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

Вы можете установите XOR снаружи цикла:

        XOR EAX, EAX
        MOV ECX, 100
LL:     MOV AL, [ESI]
        MOV [EDI], EAX          ; задержка
        INC ESI
        ADD EDI, 4
        DEC ECX
        JNZ LL

Процессор помнит, что верхние 24 бита EAX равны нулю пока не происходит вызов прерывания, неправильного предсказания перехода или другого синхронизирующего события.

Вы должны помнить, что необходимо нейтрализовывать возможные частичные задержки регистра вышеописанным способом при вызове процедуры, которая будет PUSH'ить полный регистр:

        ADD BL, AL
        MOV [MEM8], BL
        XOR EBX, EBX            ; нейтрализируем BL
        CALL _HighLevelFunction

Большинство языков высокого уровня PUSH'ат EBX в начале процедуры, что в вышеприведенном примере привело к частичной задержке регистра, если бы ее не нейтрализовали.

Обнуление регистра с помощью XOR не устраняет его зависимость от предыдущих инструкций:

        DIV EBX
        MOV [MEM], EAX

MOV EAX, 0 ; прерываем зависимость XOR EAX, EAX ; предотвращаем частичную задержку регистра MOV AL, CL ADD EBX, EAX

Обнуление регистра дважды может показаться излишней, но без 'MOV EAX, 0' последние инструкции будут ждать, пока выполниться медленный DIV, а без 'XOR EAX, EAX' случиться частичная задержка регистра.

Инструкция 'FNSTSW AX' особенная: в 32-х битном режиме она ведет себя так же, как если бы писала в весь EAX. Фактически она делает в 32-х битном режиме следующее:

AND EAX,0FFFF0000h / FNSTSW TEMP / OR EAX,TEMP

Поэтому при чтении регистра после этой инструкции у вас не возникнет частичной задержки регистра в 32-х битном режиме:

    FNSTSW AX / MOV EBX,EAX         ; задержка только в 16-ти битном режиме
    MOV AX,0  / FNSTSW AX           ; задержка только в 32-х битном режиме

19.2 Частичные задержки флагов

Регистр флагов также может вызвать частичную задержку:

        CMP EAX, EBX
        INC ECX
        JBE XX          ; задержка

Инструкция JBE читает и флаг переноса, и флаг нуля. Так как инструкция INC изменяет флаг нуля, но не флаг переноса, то инструкции JBE приходится подождать, пока две предыдущие инструкции не будут выведены из обращения, прежде чем она сможет скомбинировать флаг переноса от инструкции CMP с флагом нуля от инструкции INC. Подобная ситуация больше похожа на баг, чем на преднамеренную комбинацию флагов. Чтобы скорректировать эту ситуацию, измените INC ECX на ADD ECX, 1. Похожий баг, вызывающий задержку, - это 'SAHF / JL XX'/. Инструкция JL тестирует флаг знака и флаг переполнения, но не меняет последний. Чтобы исправить это, измените 'JL XX' на 'JS XX'.

Неожиданно (и в противоположность тому, что говорят руководства от Intel), частичная задержка регистра может случиться, если были изменены какие-то биты регистра флагов, а затем считаны неизмененные.

        CMP EAX, EBX
        INC ECX
        JC  XX          ; задержка

но не при чтении только изменных битов:

        CMP EAX, EBX
        INC ECX
        JE  XX          ; нет задержки

Частичные задержки флагов возникают, как правило, при использовании инструкций, которые считывают много или все биты регистра флагов, например LAHF, PUSHF, PUSHFD. Инструкции, которые вызывают задержку, если за ними идут LAHF или PUSHF(D), следующие: INC, DEC, TEST, битовые тесты, битовые сканирования, CLC, STC, CMC, CLD, STD, CLI, STI, MUL, IMUL, и все виды битовых сдвигов и вращений. Следующие инструкции не вызывают задержки: AND, OR, XOR, ADD, ADC, SUB, SBB, CMP, NEG. Странно, что TEST и AND ведут себя различно, хотя по описанию они делают с флагами одно и то же. Вы можете использовать инструкции SETcc вместо LAHF или PUSHF(D) для сохранения значения флага, чтобы избежать задержки.

Примеры:

    INC EAX   / PUSHFD      ; задержка
    ADD EAX,1 / PUSHFD      ; нет задержки

    SHR EAX,1 / PUSHFD      ; задержка
    SHR EAX,1 / OR EAX,EAX / PUSHFD   ; нет задержки

    TEST EBX,EBX / LAHF     ; задержка
    AND  EBX,EBX / LAHF     ; нет задержки
    TEST EBX,EBX / SETZ AL  ; нет задержки

    CLC / SETZ AL           ; задержка
    CLD / SETZ AL           ; нет задержки

Потери при частичной задержки флагов примерно равны 4 тактам.

19.3 Задержки флагов после сдвигов и вращений

При чтении любого флагового бита после сдвига или вращения (кроме сдвига и вращения на 1, т.н. короткая форма) может возникнуть задержка, похожая на частичную задержку флагов:

    SHR EAX,1 / JZ XX                ; нет задержки
    SHR EAX,2 / JZ XX                ; задержка
    SHR EAX,2 / OR EAX,EAX / JZ XX   ; нет задержки

    SHR EAX,5 / JC XX                ; задержка
    SHR EAX,4 / SHR EAX,1 / JC XX    ; нет задержки

    SHR EAX,CL / JZ XX               ; задержка, даже если CL = 1

    SHRD EAX,EBX,1 / JZ XX           ; задержка
    ROL EBX,8 / JC XX                ; задержка

Потери для этого вида задержек приблизительно равны 4 тактам.

19.4 Частичные задержки памяти

Частичная задержка памяти похожа на частичную задержку регистра. Она случается, когда вы смешиваете размеры данных применительно к одному адресу в памяти:

        MOV BYTE PTR [ESI], AL
        MOV EBX, DWORD PTR [ESI]        ; частичная задержка в памяти

Здесь случается задержка, потому что процессор должен скомбинировать байт, записанный из AL с тремя следующими байтами, которые были в памяти раньше, чтобы получить все четыре байта, необходимые для произведения записи в EBX. Потери приблизительно равны 7-8 тактов.

В отличии от частичных задержек регистра, частичная задержка памяти случается, когда вы записывает операнд в память, а затем читаете его часть, если она не начинается по тому же адресу:

        MOV DWORD PTR [ESI], EAX
        MOV BL, BYTE PTR [ESI]          ; нет задержки
        MOV BH, BYTE PTR [ESI+1]        ; задержка

Вы можете избежать этой задержки, если измените последнюю лини на 'MOV BH, AH', но подобное решение невозможно в ситуации, подобной этой:

        FISTP QWORD PTR [EDI]
        MOV EAX, DWORD PTR [EDI]
        MOV EDX, DWORD PTR [EDI+4]      ; задержка

Интересно, что частичная задержка памяти может также случиться при записи и чтении совершенно разны адресов, если у них одинаковое set-значение в разных банках кэша:

        MOV BYTE PTR [ESI], AL
        MOV EBX, DWORD PTR [ESI+4092]   ; нет задержки
        MOV ECX, DWORD PTR [ESI+4096]   ; задержка


20. Цепочки зависимости (PPro, PII и PIII)

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

Пример:

   MOV EAX, [MEM1]
   ADD EAX, [MEM2]
   ADD EAX, [MEM3]
   ADD EAX, [MEM4]
   MOV [MEM5], EAX

В этом примере инструкция ADD генерирует 2 мопа, один для чтения из памяти (порт 2) и один для сложения (порт 0 или 1). Моп чтения может выполняться не по порядку, в то время как моп сложения должна ждать, пока выполниться предыдущий моп. Эта цепочка зависимости занимает не очень много времени, так как каждое сложение требует только один такт. Но если в вашем коде содержатся медленные инструкции, такие как умножения, или еще хуже - деление, тогда вам определенно нужно сделать что-нибудь, чтобы убрать цепочку зависимости. Это можно сделать, используя разные приемники:

   MOV EAX, [MEM1]         ; начало первой цепочки
   MOV EBX, [MEM2]         ; начало второй цепочки  с другим приемником
   IMUL EAX, [MEM3]
   IMUL EBX, [MEM4]
   IMUL EAX, EBX           ; в конце соединяем цепочки
   MOV [MEM5], EAX

Здесь вторая инструкция IMUL может начаться до того, как будет завершено выполнение первой. Так как у инструкции IMUL вызывает задержку в 4 такта и полностью конвейеризована, вы можете использовать до 4-х приемников.

Деление не конвейеризовано, поэтому вы не можете делать то же самое со связанными делениями, но, разумеется, вы можете умножить все делители и сделать только одно деление в конце.

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

   FLD [MEM1]         ; начинаем первую цепочку
   FLD [MEM2]         ; начинаем вторую цепочку с другим приемником
   FADD [MEM3]
   FXCH
   FADD [MEM4]
   FXCH
   FADD [MEM5]
   FADD               ; соединяем цепочки в конце
   FSTP [MEM6]

Вам потребуется для этого много инструкций FXCH, но не беспокойтесь: они стоят дешево. Инструкции FXCH обрабатываются в RAT с помощью переименования регистров, поэтому они не создают никакой нагрузки на порты выполнения. Тем не менее, FXCH генерирует один моп в RAT, ROB и в станции вывода из обращения.

Если цепочка зависимости очень длинная, вам может потребоваться три приемника:

        FLD [MEM1]              ; начинаем первую цепочку
        FLD [MEM2]              ; начинаем вторую цепочку
        FLD [MEM3]              ; начинаем третью цепочку
        FADD [MEM4]             ; третья цепочка
        FXCH ST(1)
        FADD [MEM5]             ; вторая цепочка
        FXCH ST(2)
        FADD [MEM6]             ; первая цепочка
        FXCH ST(1)
        FADD [MEM7]             ; третья цепочка

        FXCH ST(2)
        FADD [MEM8]             ; вторая цепочка
        FXCH ST(1)
        FADD                    ; соединяем первую и третью цепочку
        FADD                    ; результат соединяем со второй цепочкой
        FSTP [MEM9]

Избегайте сохранения промежуточных данных в памяти и немедленного их считывания:

        MOV [TEMP], EAX
        MOV EBX, [TEMP]

Возникают потерю из-за попытки чтения из памяти до того, как завершена предыдущая запись туда же. В вышеприведенном примере измените последнюю инструкцию на 'MOV EBX, EAX' или поместите какие-нибудь инструкции между ними.

Есть одна ситуация, когда вы не сможете избежать сохранения промежуточных данных в памяти. Она возникает тогда, когда вы перемещаете данные из целочисленного регистра в регистр FPU или наоборот. Например:

        MOV EAX, [MEM1]
        ADD EAX, [MEM2]
        MOV [TEMP], EAX
        FILD [TEMP]

Если вам нечего поместить между записью в TEMP и считыванием из него, вы можете использовать регистр плавающей запятой вместо EAX:

        FILD [MEM1]
        FIADD [MEM2]

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


21. Поиск узких мест (PPro, PII и PIII)

Оптимизируя код для этих процессоров, важно проанализировать, где находятся узкие места. Оптимизация одного узкого места не будет иметь смысла, если есть другое еще уже.

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

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

Если у вас много делений, попробуйте снизить их количество и убедитесь, что процессору есть чем заняться во время обработки операции деления.

Цепочки зависимости мешают выполнению не по порядку (глава 20). Попробуйте разрывать длинную цепочку зависимости, особенно, если они содержат медленные инструкции, такие как умножение, деление и инструкции плавающей запятой.

Если у вас много переходов, вызовов или возвратов, особенно если переходы плохо предсказуемы, посмотрите, нельзя ли избежать некоторых из них. Заменяйте условные переходы условными перемещениями, если это возможно, а маленькие процедуры макросами.

Если вы смешиваете разные типы данных (8, 16 и 32 битные целые числа), тогда следите за появление частичных задержек. Если вы используете инструкции PUSHF или LAHF, тогда следите за появлением частичных задержек флагов. Избегайте тестирования флагов после сдвигов больше, чем на 1 (глава 19).

Если вы нацелились на производительность в 3 мопа за такт, тогда следите, чтобы не происходило перебоев в доставке инструкций и их раскодировке (глава 14 и 15), особенно в маленьких циклах.

Ограничение на чтение максимум из двух постоянных регистров может снизить вашу производительность до уровня, меньшего чем 3 мопа за такт. Это может случиться, если вы часто читаете регистры, когда уже истекли четыре такта с момента их последнего изменения. Это может, например, случиться, если вы часто используете указатели для адресации, но редко модифицируете указатели.

Производительность в три мопа за такт требует, чтобы никакой порт выполнения не получал больше 1/3 всех мопов (глава 17).

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


22. Команды передачи управления и переходов (все процессоры)

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

Предсказания основываются на буфере переходов (BTB), который сохраняет историю каждого перехода и делает предсказания, основываясь на прежних выполнения каждой инструкции. BTB организован как множественно-ассоциативный (set-associative) кэш, в котором новые элементы используются согласно псевдослучайному методу замещений.

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

Механизм предсказания переходов адекватно не объясняется ни в руководствах от Интела, ни где-нибудь еще, поэтому здесь дается очень детальное описание. Эта информация основывается на моих собственных исследованиях (осуществленных с помощью Karki Jitendra Bahadur для PPlain).

Далее я буду использовать термин "команда передачи управления" для любой инструкции, которая меняет eip, включая условные и безусловные, прямые и косвенные, ближние и дальние переходы, вызовы и возвраты. Все эти инструкции используют предсказание.

22.1 Предсказывание переходов в PPlain

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

У PPlain есть буфер переходов (BTB), который может содержать информацию о 256 инструкциях перехода. BTB организована в виде 4-х направленного множественно-ассоциативного кэша по 64 элемента на каждом направлении. Это означает, что BTB может содержать не больше чем 4 элемента для каждого set-значения. Как высчитывается set-значение будет объяснено позже. Каждый элемент BTB хранит адрес, куда должен быть совершен переход и состояние предсказания, которое может иметь три разных значения:

  • состояние 0: "не будет осуществлен"
  • состояние 1: "возможно не будет осуществлен"
  • состояние 2: "возможно будет осуществлен"
  • состояние 3: "будет осуществлен"

Предсказывается, что инструкция перехода будет осуществлена, когда состояние равно 2 или 3, и пропускается при состоянии 0 или 1. Состояние перехода работает как двухбитный счетчик, поэтому состояние увеличивается на 1, если переход был осуществлен и понижается, если этого не произошло. Понижение и повышение значения происходит по принципу "насыщения", то есть значение не может понизиться ниже 0 (и стать 3) и не может повыситься выше 3 (и стать 0). По идее, все это должно привести к довольно хорошему проценту правильных предсказаний, потому что до того, как будет изменено предсказание, инструкция перехода должна быть выполнена два раза не так, как она выполняется обычно.

Тем не менее, этот механизм может был поставлен под угрозу тем, что состояние 0 также означает "неиспользованный элемент BTB". Поэтому элемент BTB с состоянием 0 означает примерно то же, что если бы его вообще не было. Это имеет смысл, потому что если у инструкции перехода нет соответствующего элемента BTB, предсказывается, что она не будет осуществлена. Это улучшает использование BTB, так как инструкция перехода, которая редко осуществляется большую часть времени не будет занимать никакого элемента BTB.

Если у инструкции перехода нет своего элемента в BTB, генерируется такой элемент и значение его состояние будет установлено в 3. Это означает, что перейти от состояния 0 к состоянию 1 невозможно (кроме очень специфических случаев, обсуждаемых позже). От состояния 0 вы можете перейти только к состоянию 3, если инструкция перехода будет выполнена. Если этого не произойдет, она не получит свой элемент в BTB.

Это серьезный промах в дизайне архитектуры процессора. Очевидно, что таким образом дизайнеры отдали предпочтение минимизации потери производительности при загрузке в первый раз часто выполняющихся инструкций перехода и проигнорировали то, что это серьезно искажает первоначальную идею и снижает производительность в маленьких внутренних циклах. Следствием этого изъяна является то, что инструкция перехода, которая большую часть времени не выполняется, может иметь в три раза больше неправильных предсказаний, чем инструкция перехода, которая большую часть времени выполняется. (Похоже, что пока я не опубликовал свои исследования, инженеры Интела были не в курсе насчет этого).

Вы можете принять эту асимметричность в расчет, организовав ваши ветви так, чтобы они чаще выполнялись, а не пропускались. Посмотрите на этот пример конструкции if-then-else:

        TEST EAX,EAX
        JZ   A
<ветвь 1>
        JMP  E
A:      <ветвь 2>
E:

Если ветвь 1 выполняется чаще, чем ветвь 2, а последняя выполняется реже в два раза, тогда вы можете снизить количество неправильных предсказаний, поменяв две ветви так, чтобы инструкция перехода выполнялась чаще, чем пропускалась:

        TEST EAX,EAX
        JNZ  A
        <ветвь 2>
        JMP  E
A:      <ветвь 1>
E:

(Это противоречит рекомендациям в интеловских руководствах и туториалах).

Тем не менее, есть причины, что помечать чаще выполняющуюся ветвь первой:

  • Помещение редко используемых ветвей подальше от основного кода делает использование кода кэша более оптимальным.
  • Редко выполняющаяся инструкция перехода не будет напрасно занимать элемент BTB, что, вероятно, сделает использование BTB более оптимальным.
  • Инструкция перехода будет предсказан как не выполняющаяся, если она была вытеснена другими инструкциями из BTB.
  • Асимметричность предсказывания переходов существует только в PPlain.

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

Так же вам следует организовать циклы таким образом, чтобы проверка условия производилась внизу цикла, как в нижеприведенном примере:

        MOV ECX, [N]
L:      MOV [EDI],EAX
        ADD EDI,4
        DEC ECX
        JNZ L

Если N велико, тогда инструкция JNZ будет чаще выполняться, чем пропускаться.

Представьте ситуацию, где инструкция перехода выполняется каждый второй раз. В первый раз она попадает в BTB с состоянием 3, а затем будет колебаться между состояниями 2 и 3. Каждый раз будет предсказываться, что она выполниться, но только 50% этих предсказаний будет верными. Теперь предположите, что однажды она отклонилась от привычного поведения и была пропущена.

01010100101010101010101, where 0 means nojump, and 1 means jump.
         ^             , где 0 означает пропуск, а 1 означает переход

Дополнительный пропуск помечен знаком '^'. После этого происшествия элемент BTB будет колебаться между состояниями 1 и 2, что будет давать 100% неправильных предсказаний. И так будет продолжаться, пока не случиться еще одно отклонение. Это худшее, что можете произойти в работе этого механизма предсказания переходов.

22.1.2 BTB смотрит вперед (PPlain)

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

Пример:

    SHR EAX,1
    MOV EBX,[ESI]
    CMP EAX,EBX
    JB  L

Здесь SHR спаривается с MOV, а CMP спаривается с JB. BTB-элемент для 'JB L' присоединяется к адресу инструкции 'SHR EAX,1'. Когда встречается этот BTB-элемент, и он находится в состоянии 2 или 3, тогда Pentium считает целевой адрес из элемента BTB и загрузит инструкции, следующие за L в конвейер. Это произойдет до того, как будет декодирована инструкция перехода, поэтому Pentium полагается исключительно на информацию, содержащуюся в BTB, когда делает это.

Вы можете вспомнить, что инструкции редко спариваются в первый раз при выполнении (смотрите главу 8). Если инструкции выше не спарились, тогда элемент BTB будет присоединен к адресу инструкции CMP, и данный элемент будет неверен при следующем выполнении, когда инструкции уже спарились. Тем не менее, в большинстве случаев PPlain достаточно умен, чтобы не делать элемент BTB при неиспользованной возможности спаривания, поэтому вы получите BTB-элемент только при втором выполнении, а следовательно, предсказания начнутся только с третьего. (В редких случаях, когда каждая вторая инструкция размером в один байт, элемент BTB может возникнуть уже при первом выполнении, что окажется неверным при втором выполнении, но так как инструкция, к которой был присоединен элемент, попадет в V-конвейер, это игнорируется и не вызывает особых потерь. Элемент BTB считывается только тогда, когда он присоединен к адресу инструкции для U-конвейера).

BTB-элемент идентифицируется своим set-значением, которое равно битам 0-5 адреса, к которому он присоединен. Биты 6-31 сохраняются в BTB как тег. Адреса, которые находятся на расстоянии кратном 64 байтам, будут иметь одно и то же set-значение. Вы можете иметь не более четырех BTB-элементов с одинаковым set-значением. Если вы хотите проверить, не будет ли ваша инструкция перехода бороться с другой инструкцией за один и тот же элемент BTB, вы можете сравнить биты 0-5 адресов инструкций, направляющихся в U-конвейер, из предыдущих пар инструкций. Это очень утомительно, и я никогда не слышал о том, что кто-нибудь делал подобное. Нет никаких инструментов, которые могли бы выполнить эту работу за вас.

22.1.3 Последовательные ветви (PPlain)

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

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

        SHR EAX,1
        MOV EBX,[ESI]
        CMP EAX,EBX
        JB  L1
        JMP L2

L1:     MOV EAX,EBX
        INC EBX

Когда 'JB L1' пропускается, вы можете получить элемент BTB, присоединенный к адресу 'CMP EAX,EBX'. Но что случится, если 'JB L1' будет выполнена? В тот момент, когда считывается BTB-элемент для 'JMP L2', процессор не знает, что следующая пара инструкций не содержит команду перехода, поэтому он фактически предскажет, что пара инструкций 'MOV EAX,EBX / INC EBX' перейдет на L2. Потери, связанные с предсказанием инструкций, не являющихся командами перехода, равны 3 тактам. Элемент BTB для 'JMP L2' понизит свое состояние на один, потому что он относится к чему-то, что не совершает перехода. Если мы перейдем к L1, тогда состояние элемента BTB для 'JMP L2' будет понижено до 1 и 0, поэтому данная проблема исчезнет, пока не снова не будет выполнено 'JMP L2'.

Потери при предсказании инструкций, не являющихся управляющими, случаются только тогда, когда предсказывается переход на L1. В случае, если выполнение 'JB L1' предсказано ошибочно, тогда конвейер очищается, и мы не получим фальшивую загрузку цели L2, поэтому в этом случае мы не заметим потери от предсказания неуправляющих инструкций как выполняющихся, но состояние элемента BTB для 'JMP L2' будет понижено.

Теперь предположите, что мы заменяем инструкцию 'INC EBX' на другую инструкцию перехода. Тогда третья инструкция перехода будет использовать тот же элемент BTB, что и 'JMP L2', что приводит к возможным потерям из-за предсказания неправильной цели (если только целью не является L2).

Теперь прорезюмируем возможные проблемы, к которым могут привести последовательные переходы:

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

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

Теперь еще один показательный пример:

        CALL P
        TEST EAX,EAX
        JZ   L2

L1:     MOV  [EDI],EBX
        ADD  EDI,4
        DEC  EAX
        JNZ  L1
L2:     CALL P

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

Сначала мы можем заметить, что функция P вызывается из двух различных мест кода. Это означает, что цель возвращения из P будет все время меняться. Соответственно, возврат из P будет все время предсказываться неправильно.

Теперь предположите, что EAX равен нулю. При переходе на L2 его цель не будет загружена, так как неправильно предсказанное возвращение приведет к очищению конвейера. Затем цель второго вызова P также не будет загружена, потому что 'JZ L2' вызовет новое очищение конвейера. Здесь мы имеем ситуацию, в которой цепь последовательных переходов заставляет постоянно очищаться конвейер из-за того, что первый переход был предсказан неправильно. Элемент BTB для 'JZ L2' сохраняется по адресу инструкции возвращения из функции P. Этот элемент BTB будет теперь неправильно присоединен к тому, что должно быть после второго вызова P, но это не приводит к потерям, так как конвейер очищается из-за неправильно предсказанного второго возвращения.

Теперь давайте посмотрим что случится, если EAX не равен нулю в следующий раз: 'JZ L2' будет всегда предсказываться как невыполняющийся из-за очищения конвейера. Второй 'CALL P' будет иметь свой элемент по адресу 'TEST EAX,EAX'. Этот элемент будет неправильно ассоциирован с парой 'MOV/ADD', предсказывая их переход на P. Это приведет к очищению конвейера, который не даст загрузить цель 'JNZ L1'. Если мы уже были здесь ранее, тогда второй 'CALL P' будет иметь другой элемент BTB с адресом 'DEC EAX'. При втором и третьем повторении цикла этот элемент также будет неправильно ассоциирован с парой 'MOV/ADD', пока ее состояние не понизится до 1 или 0. Это не вызовет потерь во втором выполнении, так как очищение конвейера от 'JNZ L1' не даст загрузить неверную цель, но в третьем выполнении так и случится. Последовательные итерации цикла не приводят к потерям, но если они есть, то JNZ L1 будет предсказан неправильно. Очищение буфера сделает невозможной загрузку цели 'CALL P', не считая того, что ее элемент BTB уже был уничтожен несколькими неправильными ассоциированиями.

Мы можем улучшить этот код, поместив несколько NOP'ов, чтобы отделить друг от друга последовательные переходы:

        CALL P
        TEST EAX,EAX
        NOP
        JZ   L2
L1:     MOV  [EDI],EBX
        ADD  EDI,4
        DEC  EAX
        JNZ  L1
L2:     NOP
        NOP
        CALL P

Дополнительные NOP'ы стоят 2 такта, но они сохранят гораздо больше. Более того, 'JZ L2' теперь перемещается в U-конвейер, что снижает потери в случае неправильного предсказания с 4 до 3 тактов. Единственная оставшаяся проблема - это возвраты из P, которые всегда предсказываются неправильно. Эту проблему можно решить, заменив вызов P на макрос (если у вас есть достаточное количество свободного кэша кода).

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

Мультиветвенные переходы (выражения условий) можно сделать либо в виде дерева инструкций переходов, либо в виде списка адресов переходов. Если вы выберите дерево инструкций переходов, то вам стоит включить несколько NOP'ов или других инструкций, чтобы отделить последовательные переходы друг от друга, поэтому список адресов может быть более выгодным вариантом на PPlain. Список адресов переходов следует помещать в сегменте данных. Никогда не помещает данные в сегменте кода!

22.1.4 Маленькие циклы (PPlain)

В маленьких циклах часто происходит доступ к одному и тому же элементу BTB с небольшими интервалами. Вместо того, чтобы ждать, когда обновится элемент BTB, PPlain каким-то образом минует конвейер и получает состояние после последнего перехода, прежде, чем оно записывает в BTB. Этот механизм почти прозрачен для пользователя, но в некоторых случаях он приводит к забавным эффектам: вы можете видеть предсказание перехода от состояния 0 к состоянию 1, а не к состоянию 3, если ноль еще не быль записан в BTB. Это случается, если в цикле не больше четырех пар инструкций. В цикла с двумя парами инструкций может случиться так, что состояние 0 сохраняется на протяжении двух итераций, причем элемент не стирается из BTB. В таких маленьких циклах в редких случаях предсказание использует состояние, бывшее два цикла назад, а не в предыдущем повторении. Эти забавные эффекты обычно не приводят к снижению качества.

22.2 Предсказание переходов в PMMX, PPro, PII и PIII

22.2.1 Организация BTB (PMMX, PPro, PII и PIII)

Буфер переходов (BTB) PMMX состоит из 256 элементов, организованных как 16 направлений * 16 множеств. Каждый элемент идентифицируется битами 2-31 адреса последнего байта инструкции передачи управления, которой он принадлежит. Биты 2-5 определяют множество, а биты 6-31 сохраняются в BTB как тег. Инструкции передачи управления, находящиеся друг от друга на расстоянии 64 байт, имеют одинаковое set-значение, и поэтому могут случайно выдавливать друг друга из BTB. Так как есть 16 направление на множество, то это не будет происходить слишком часто.

Буфер переходов PPro, PII и PIII имеет 512 элементов (16 направлений * 32 множества). Каждый элемент идентифицируется битами 4-31 адреса последнего байта инструкции передачи управления, к которой он принадлежит. Биты 4-8 определяют множество, и все биты сохраняются в BTB как тег. Инструкции передачи управления, которые находятся друг от друга на расстоянии 512 байт, могут случайно выдавливать друг друга из BTB. Так как есть 16 направлений на множество, это не бдует происходить слишком часто.

PPro, PII и PIII резервируют элемент BTB для любой инструкции передачи управления в первый раз ее выполнения (независимо от того, совершает ли она переход, или пропускается). PMMX резервирует элемент в первый раз, когда совершается переход. Инструкция перехода, которая никогда не совершает переход, будет оставаться вне BTB на PMMX. Как только она однажды совершит переход, она окажется в BTB и будет оставаться там все время, даже если она никогда не будет совершать переход снова.

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

22.2.2 Потери при неверном предсказании (PMMX, PPro, PII и PIII)

На PMMX потери из-за неверного предсказания условного перехода равны 4 тактам для инструкции в U-конвейере и 5 тактам, если она выполнялась в V-конвейере. Для всех других инструкций передачи управления потери равны 4 тактам.

На PPro, PII и PIII потери из-за неверного предсказания очень высоки, так как у этих процессоров длинный конвейер. Неправильное предсказание обычно стоит от 10 до 20 тактов. Поэтому очень важно избегать плохо предсказуемых ветвей, если программа будет выполняться на PPro, PII и PIII.

22.2.3 Распознавание последовательностей условных переходов (PMMX, PPro, PII и PIII)

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

Этот механизм называется "двухуровневой адаптирующейся схемой предсказания". Он был изобретен T.-Y. Yeh и Y. N. Patt. Механизм основывается на разновидности двухбитного счетчика, использующихся в PPlain (но без изъяна, описанного выше). Счетчик повышается, когда совершается переход и понижается, когда этого не происходит. Нет автоматического обнуления при повышении 3 или приравнивания к 3 при понижении 0. Инструкция перехода предсказывается как выполняющаяся, если соответствующий счетчик равен 3 или 3, и предсказывается как несовершающая переход, если он равен 0 или 1. Значительное улучшение данного алгоритма было получено путем введения 16 таких счетчиков для каждого элемента BTB. Он выбирает один из этих шестнадцати счетчиков, основываясь на истории выполнения переходов за четыре последних раза. Если, например, инструкция перехода однажды выполняется, а потом нет на протяжении четырех последних раз. Если, например, инструкция перехода совершает его однажды и затем не делает его три раза подряд, тогда биты истории равны 1000 (1 = переход, 0 = не-переход). Значит, будет использоваться счетчик под номером 8 (1000b = 8) для предсказания в следующий раз и обновления состояния счетчика.

Если последовательность 1000 всегда следует за 1, тогда счетчик под номером 8 вскоре достигнет своего наивысшего состояния (3), что означает постоянное предсказывания последовательности 1000. Потребуется два отклонения от этой последовательности, чтобы изменить предсказание. Повторяющаяся последовательность 100010001000 будет иметь счетчик 8 в состоянии 3 и счетчик 1, 2 и 4 в состоянии 0. Другие двенадцать счетчиков не будут использоваться.

22.2.4 Последовательности, предсказанные совершенно (PMMX, PPro, PII, PIII)

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

 period  perfectly predicted patterns
 1-5     all
 6       000011, 000101, 000111, 001011
 7       0000101, 0000111, 0001011
 8       00001011, 00001111, 00010011, 00010111, 00101101
 9       000010011, 000010111, 000100111, 000101101

 10      0000100111, 0000101101, 0000101111, 0000110111, 0001010011,

         0001011101
 11      00001001111, 00001010011, 00001011101, 00010100111

 12      000010100111, 000010111101, 000011010111, 000100110111,
         000100111011
 13      0000100110111, 0000100111011, 0000101001111

 14      00001001101111, 00001001111011, 00010011010111, 00010011101011,
         00010110011101, 00010110100111
         000010011010111, 000010011101011, 000010100110111,
 15      000010100111011, 000010110011101, 000010110100111,
         000010111010011, 000011010010111

 16      0000100110101111, 0000100111101011, 0000101100111101,
         0000101101001111

Читая эту таблицу, вы должны учитывать, что если последовательность предсказывается верно, тогда та же последовательность, но прочитанная задом наперед также будет предсказана верно, также как и та же последовательность, но с инвертированным битами. Пример: в таблице мы нашли последовательность: 001011. Изменение порядка битов на обратный дает: 1101000. Инвертирование всех битов дает 1110100. Изменение порядка битов и инвертирование вместе: 0010111. Все эти четыре последовательности будут распознаны. Циклический сдвиг всех битов на одну позицию влево дает: 0010110. Это, конечно, не новая последовательность, а всего лишь чуть сдвинутая версия той, которую мы рассматривали. Все последовательности, которые могут быть выведены от одной из перечисленных в таблице с помощью изменения порядка битов, инвертирования и циклического сдвига также могут быть разпознаны. В целях экономии места они не были перечислены.

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

22.2.5 Обработка отклонений от регулярных последовательностей (PMMX, PPro, PII и PIII)

Механизм предсказания также довольно хорошо справляется с обработкой 'почти регулярных' последовательностей или отклонений от регулярной последовательности. Он не только заучивает, как выглядит регулярная последовательность, он также изучает, какие существуют отклонения от нее. Если отклонения все время одни и те же, тогда он будет помнить, что идет после отклонения, и оно будет стоить только одно неправильное предсказание.

Пример:

0001110001110001110001011100011100011100010111000
                      ^                   ^

В этой последовательности 0 означает не-переход, а 1 - переход. Механизм выучивает то, что повторяющаяся последовательность равна 000111. Первое отклонение - это неожиданный 0, который я отметил знаком '^'. После этого 0 следующие три перехода могут быть неправильно предсказаны, потому что механизм еще не выучил, что идет после 0010, 0101 и 1011. После одного или двух отклонений того же вида он выучивает, что после 0010 идет 1, после 0101 идет 1, а после 1011 идет 1. Это означает, что после двух отклонений одного и того же вида, он выучивает как их обрабатывать, допуская только одно неправильное предсказание.

Механизм предсказания очень эффективен, когда происходит смена одной регулярной последовательности на другую. Если, например, у нас была последовательность 000111 (с периодом 6), которая повторилась много раз, потом последовательность 01 (период 2) много раз, а затем произошел возврат на последовательность 000111, тогда механизм не должен снова изучать последовательность 000111, так как счетчики, которые были использованы для этой последовательность остались в неприкосновенности. После нескольких смен последовательностей с одной на другую, механизм также выучивает, как обрабатывать изменения ценой только одного неправильного предсказания во время переключения одной последовательности на другую.

22.2.6 Последовательности, не предсказываемые совершенно (PMMX, PPro, PII и PIII)

Простейшая последовательность, которая не может быть предсказана совершенно - это переход, который совершается каждый 6-ой раз. Вот последовательность:

000001000001000001
    ^^    ^^    ^^
    ab    ab    ab

За последовательностью 0000 следует 0 в позициях, помеченных 'a', и 1, в позициях, помеченных 'b'. Это влияет на счетчик под номером 0, который постоянно повышает и понижает свое значение. Если счетчик 0 был вначале равен 0 или 1, тогда он будет колебаться между 0 и 1. Это приведет к неправильному предсказанию в позиции b. Если счетчик 0 был в самом начале равен 3, тогда он будет колебаться между состояниями 2 и 3, что приведет к неправильному предсказанию в позиции a. Худший случай - это когда его состояние было в начале равно 2. Он будет колебаться между 1 и 2, что приведет к неправильному предсказанию в обоих позициях. (Это аналогично худшему случаю для PPlain, объясненному выше). Какую из этих ситуаций мы получим, зависит от истории элемента BTB до того, как он был зарезервирован. Мы не можем это проконтролировать, так как элементы BTB выбираются случайно.

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

22.2.7 Полностью случайные последовательности (PMMX, PPro, PII и PIII)

Мощная способность распознавания последовательностей имеют небольшой изъян в случае полностью случайных нерегулярных последовательностей.

Следующая таблица перечисляет экспериментально найденную часть неверных предсказаний для полностью случайных нерегулярных последовательностей.

  часть переходов/не-переходов  часть неверных предсказаний
         0.001/0.999                    0.001001
          0.01/0.99                     0.0101
          0.05/0.95                     0.0525

          0.10/0.90                     0.110
          0.15/0.85                     0.171
          0.20/0.80                     0.235
          0.25/0.75                     0.300
          0.30/0.70                     0.362
          0.35/0.65                     0.418
          0.40/0.60                     0.462
          0.45/0.55                     0.490
          0.50/0.50                     0.500

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

22.2.8 Маленькие циклы (PMMX)

Предсказание переходов ненадежно в маленьких циклах, где у механизма распознавания последовательностей нет времени, чтобы обновить свои данные перед новой встречей с переходом. Это означает, что простые последовательности, которые в обычном случае были бы предсказаны совершенно, не будут распознаны. Также некоторые последовательности, которые в обычном случае не были бы распознаны, будут предсказаны в маленьком цикле. Например, цикл, который всегда повторяется 6 раз, имел бы последовательность 111110 для инструкции ветвления в начале цикла. В этой последовательности было бы одно или два неправильных предсказаний за итерацию, но в маленьком цикле не будет ни одного. То же самое касается цикла, который повторяется 7 раз. С другим количеством повторений предсказуемость будет хуже в маленьких циклах, чем обычно. Это означает, что количество повторений, равное 6 или 7, более предпочтительно для маленьких циклов. Вы можете развернуть цикл, чтобы сделать его больше.

Чтобы узнать, подпадает ли цикл на PMMX под действие вышеизложенных правил, вы можете сделать следующее: подсчитайте количество инструкций в цикле. Если количество равно 6 или меньше, тогда цикл является "маленьким". Если у вас больше 7 инструкций, тогда у вас может быть достаточно твердая уверенность в том, что распознавание последовательностей будет работать нормально. Довольно странно, что количество тактов, которое требуется для выполнения инструкций, не играет роли, как и спариваемость инструкций и вызываемые ими задержки. Сложные целочисленные инструкции при подсчете не учитываются. В цикле может быть много таких инструкций, и он будет себя вести как "маленький". Сложная целочисленная инструкция - это не спариваемая целочисленная инструкция, которая всегда требует больше одного такта. Сложные инструкции плавающей запятой и инструкции MMX идут одна за одну. Обратите внимание, что это правило выведено эвристическим путем и не 100% надежно. В важных случаях вы можете провести свое собственное тестирование. Вы можете использовать датчик качества с номером 35 в PMMX, чтобы подсчитать количество неправильно предсказанных переходов. Результаты тестов также не могут быть полностью надежны, потому что предсказания переходов зависят от истории элемента BTB до резервирования.

Маленькие циклы на PPro, PII и PIII предсказываются нормально, и каждое повторение занимает минимум два цикла.

22.2.9 Косвенные переходы и вызовы (PMMX, PPro, PII и PIII)

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

22.2.10 JECXZ и LOOP (PMMX)

На PMMX распознавание последовательностей не работает с этими двумя инструкциями, просто предсказывается то, что произошло в последний раз. Эти две инструкции следует избегать в критическом коде для PMMX (на PPro, PII и PIII) предсказания осуществляются с помощью распознавания последовательностей, но специальные инструкции циклов все равно ужасны по сравнению с DEC ECX / JNZ).

22.2.11 Возвраты (PMMX, PPro, PII и PIII)

У процессоры PMMX, PPro, PII и PIII есть стековый буфер возвратов (RSB), который используется для предсказывания инструкций возвратов. RSB работает как FIFO (первым-вошел-первым-вышел) буфер. Каждый раз, когда выполняется инструкция вызова, соответствующий адрес возврата помещается в RSB. И каждый раз, когда выполняется инструкция возврата, адрес возвращения вынимается из RSB и используется для предсказывания возврата. Этот механизм обеспечивает корректное предсказание этих инструкций, когда одна и та же подпрограмма вызывается из разных мест.

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

RSB может содержать до 4 элементов в PMMX, шестнадцать в PPro, PII и PIII. В случае, когда RSP пуст, инструкция возврата предсказывается также, как и косвенный переход, то есть ожидается переход туда, куда он был совершен в последний раз.

На PMMX, когда подпрограммы вложены друг в друга более, чем на 4 уровня, все возвраты, кроме 4 самых внутренних уровней, поддерживаемых PMMX, используют простой механизм предсказания, пока не происходит новых вызовов. Инструкция возврата, находящаяся в RSB, также занимает один элемент BTB. Четыре элемента в RSB PMMX'а кажутся не весть чем, но это, вероятно, эффективно. Подпрограммы, вложенные более, чем на 4 уровня, не являются чем-то необычным, но только внутренние уровни максимально эффективны в плане скорости, не считая возможности рекурсивных процедур.

На PPro, PII и PIII, когда подпрограммы вложены глубже, чем на 16 уровней, внутренние 16 уровней используют RSB, в то время как все последующие возвраты из внешних уровней предсказываются неверно, поэтому рекурсивные процедуры не следует делать вложенными более, чем на 16 уровней.

22.2.12 Статическое предсказание (PMMX)

Инструкция передачи управления, которая не встречалась ранее, или которая не находится в BTB всегда предсказывается как невыполняющаяся на PMMX. Не играет роли, куда совершается переход: вперед или назад.

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

Любая инструкция передачи управления, которая переходит на адрес, непосредственно следующий за ней же, не получает элемент в BTB.

Пример:

        JMP SHORT LL
LL:

Эта инструкция никогда не поулчит элемент в BTB и поэтому всегда будет предсказываться неправильно.

22.2.13 Статическое предсказывание (PPro, PII и PIII)

На PPro, PII и PIII инструкция передачи управления, которая не встречалась ранее или которой нет в BTB, предсказывается как невыполняющаяся, если она указывает вперед, и как выполняющаяся, если она указывает назад (например, цикл). Статическое предсказывание занимает больше времени, чем динамическое предсказание на этих процессорах.

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

22.2.14 Закрытые переходы (PMMX)

На PMMX существует риск, что две инструкции передачи управления разделят один и тот же элемент BTB, если они находятся слишком близко друг к другу. Очевидным результатом станет то, что они всегда будут предсказываться неправильно.

Элемент BTB для инструкции передачи управления определяется по битам 2-31 адреса последнего байта инструкции. Если две инструкции передачи управления находятся так близко друг от друга, что отличаются только битами 0-1 этих адресов, то у нас есть проблема разделяемого элемента BTB. Пример:

        CALL    P
        JNC     SHORT L

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

Есть несколько путей решить эту проблему:

  • Переместить код чуть вверх или вниз в памяти, чтобы между двумя адресами была граница двойного слова.
  • Изменить короткий переход на ближний переход, чтобы конец инструкции сместился чуть вниз.
  • Поместить какую-либо инструкцию между CALL и JNC. Это самый простой метод и единственный, если вы не знаете, где находятся границы двойного слова, например если ваш сегмент не выравнен по двойному слову или потому что код смещается то вверх, то вниз в результате изменений в предшествующем коде.

        CALL    P
        MOV     EAX,EAX         ; добавим два байта, чтобы быть спокойными
        JNC     SHORT L

Если вы хотите избежать проблем и на PPlain, тогда поместите вместо MOV два NOP'а, чтобы предотвратить спаривание.

Инструкция RET особенно беззащитна перед этой проблемой, потому она всего один байт длиной.

        JNZ     NEXT
        RET

Здесь вам может потребоваться вставить три байта:

        JNZ     NEXT

        NOP
        MOV     EAX,EAX
        RET

22.2.15 Последовательные вызовы или возвраты (PMMX)

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

Пример:

FUNC1   PROC    NEAR
        NOP             ; избегаем вызова после вызова
        NOP
        CALL    FUNC2
        CALL    FUNC3
        NOP             ; избегаем возврата после возврата

        RET
FUNC1   ENDP

Требуется два NOP перед 'CALL FUNC2', потому что один NOP будет спариваться с CALL. Одного NOP достаточно перед RET, потому что эта инструкция неспариваема. Не требуется NOP между двумя инструкциями CALL, потому что нет потерь при вызове после возврата (на PPlain вам потребуется здесь два NOP).

Подобные потери последовательных вызовах возникают только, когда одни и те же подпрограммы вызываются из более, чем одного места (вероятно, это происходит из-за обновления RSB). Последовательные возвраты всегда приводят к потерям. Иногда возникает небольшая задержка при переходе после вызова, но нет потерь при возврате после вызова, вызова после возврата, перехода, вызова или возврата после перехода или перехода после возврата.

22.2.16 Последовательные переходы (PPro, PII и PIII)

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

22.2.17 Проектирование предсказуемых переходв (PMMX, PPro, PII и PIII)

Многоветвенные переходы (реализующие высокоуровневые выражения switch/case) реализуются как список адресов переходов или как дерево инструкций переходов. Так как косвенные переходы предсказываются плохо, то последний метод может быть более предпочтителен, если дерево будет представлено в виде хорошо предсказуемой последовательности и у вас есть достаточное количество элементов BTB. В случае, если решите использовать предыдущий метод, рекомендуется, чтобы адреса переходов были помещены в сегмент данных.

Вы можете захотеть реорганизовать ваш код так, чтобы последовательности переходов, которые не предсказываются совершенно, были заменены другими последовательностями. Предположите, например, цикл, который выполняется 20 раз. Условный переход внизу цикла выполнится 19 раз и нет - каждый 20 раз. Эта последовательность регулярна, но не будет узнана механизмом распознавания последовательностей, поэтому невыполнение данной инструкции будет всегда предсказываться неправильно. Вы можете сделать два вложенных цикла по 4 и 5 повторений или развернуть цикл в четыре раза и позволить ему выполниться 5 раз, чтобы были только распознаваемые последовательности. Этот вид сложных схем оправдывает себя только на PPro, PII и PIII, где неверное предсказание стоит слишком дорого. Для циклов с большим количеством повторения особо смысла бороться с одним неправильным предсказанием нет.

22.3. Избегание переходов (все процессоры)

Есть много причин, почему вы можете захотеть снизить количество переходов, вызовов и возвратов:

  • неверные предсказания стоят очень дорого
  • в зависимости от процессора могут возникнуть различные потери при последовательных вызовах
  • инструкции перехода могут выталкивать друг друга из буфера переходов
  • возврат занимает 2 такта на PPlain и PMMX, вызовы и возвраты генерируют 4 мопа на PPro, PII и PIII.
  • На PPro, PII и PIII доставка инструкций может быть задержана после перехода (глава 15), а вывод из обращения может быть менее эффективным для совершенных переходов, чем для других мопов (глава 18).

Вызовы и возвраты можно избежать, если заменить маленькие процедуры макросами. Во многих случаях можно снизить количество переходов, перегруппировав ваш код. Например, переход на переход можно заменить переходом к конечной цели. В некоторых случаях это возможно сделать даже с условными переходами, если условие то же самое или известно. Переход на возврат можно заменить возвратом. Если вы хотите устранить возврат на возврат, тогда вы не должны манипулировать стековым указателем, потому что это будет создавать помехи механизму предсказания, основывающимся на RSP. Вместо этого вы можете заменить предыдущий вызов на переход. Например 'CALL PRO1 / RET' можно заменить на 'JMP PRO1', если PRO1 заканчивается тем же видом RET.

Вы можете также устранить переходом, дублируя код, на который совершается переход. Это может быть полезным, если у вас есть двунаправленная ветвь внутри цикла или до возврата.

Пример:

A:      CMP     [EAX+4*EDX],ECX
        JE      B
        CALL    X
        JMP     C
B:      CALL    Y
C:      INC     EDX
        JNZ     A
        MOV     ESP, EBP
        POP     EBP
        RET

Переход на C можно устранить, продублировав эпилог цикла:

A:      CMP [EAX+4*EDX],ECX

        JE      B
        CALL    X
        INC     EDX
        JNZ     A
        JMP     D
B:      CALL    Y
C:      INC     EDX
        JNZ     A
D:      MOV     ESP, EBP
        POP     EBP
        RET

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

22.4. Избегание условных переходов, используя флаги (все процессоры)

Самое главное - избавиться от условных переходов, особенно если они плохо предсказуемы. Иногда возможно получить тот же эффект, искусно манипулируя битами и флагами.

         CDQ
         XOR EAX,EDX
         SUB EAX,EDX

(На PPlain и PMMX используйте 'MOV EDX,EAX / SAR EDX,31' вместо CDQ).

Флаг переноса особенно полезен для подобных трюков:

  • Установка флага переноса, если значение равно нулю: CMP [VALUE],1
  • Установка флага переноса, если значение не равно нулю: XOR EAX,EAX / CMP EAX,[VALUE]
  • Повышение счетчика на 1, если установлен флаг переноса: ADC EAX,0
  • Установка бита каждый раз, когда установлен флаг переноса: RCL EAX,1
  • Генерация битовой маски, если установлен флаг переноса: SBB EAX,EAX
  • Установка бита при определенном условии: SETcond AL
  • Установка всех бит при определенном условии: XOR EAX,EAX / SETNcond AL / DEC EAX (помните о том, что в последнем примере условие должно быть сформулировано наоборот).

Следующий пример находит меньшее из двух беззнаковых чисел: if (b < a) a = b

         SUB EBX,EAX
         SBB ECX,ECX
         AND ECX,EBX
         ADD EAX,ECX

А вот пример, как выбрать между двумя числами: if (a != 0) a = b; else a = c;

         CMP EAX,1
         SBB EAX,EAX
         XOR ECX,EBX
         AND EAX,ECX
         XOR EAX,EBX

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

22.5. Замена условных переходов условными перемещениями (PPro, PII и PIII)

У процессоров PPro, PII и PIII есть инструкции условного перемещения данных, созданных специально для избежания переходов, потому что неверное предсказание перехода стоит на этих процессорах слишком дорого. Есть инструкции условного перемещения данных и для целочисленных регистров и для регистров плавающей запятой. В коде, который будет выполняться только на этих процессорах, вы можете заменить плохо предсказуемые переходы инструкциями условного перемещения там, где это возможно. Если вы хотите, чтобы ваш код выполнялся на всех процессорах, вы можете сделать две версии самого критичного кода, одну для процессоров, которые поддерживают инструкции условного перемещения и одну для тех, которые не поддерживаются (смотрите главу 27.10, чтобы узнать, как определить, поддерживает ли процессор условные переходы).

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


23. Уменьшение размера кода (все процессоры)

Как было объяснено в главе 7, размер кода кэша равен 8 или 16 килобайтам. Если у вас есть подозрение, что критические части кода не поместятся в кэш, тогда вы можете подумать о том, чтобы уменьшить их размер.

32-х битный код обычно больше, чем 16 битный код, потому что адреса и константы занимают 4 байта в 32-х битном режиме, а 16-ти битном режиме только два. Тем не менее, 16-ти битный код другие потери, такие как префиксы и проблемы с соседними словами памяти (смотри главу 10.2). Некоторые другие методы для уменьшения размера кода обсуждаются ниже.

Оба адреса перехода, адреса данных и константы занимают меньше места, если их значение находится между -128 до +127.

Для адресов переходов это означает, что короткие переходы занимают два байта, в то время как переходы более чем 127 байт занимают 5 байтов, если переход безусловный и 6 байтов, если условный.

Таким же образом адреса данных занимают меньше места, если они могут быть выражены как указатель в интервале от -128 до +127

Пример:

MOV EBX,DS:[100000] / ADD EBX,DS:[100004] ; 12 байт

Уменьшаем размер:

MOV EAX,100000 / MOV EBX,[EAX] / ADD EBX,[EAX+4] ; 10 байт

Преимущество использования указателя становится еще более очевидным, если вы используете его много раз. Хранение данных в стеке и использование EBP или ESP в качестве указателя сделает ваш код меньше, чем если бы вы использовали абсолютные адреса, если только, конечно, ваши данные в пределах +/-127 байтах указателя. Использование PUSH и POP для записи и чтения временных данных еще короче.

Константы могут также занимать меньше места, если они между -128 и +127. Большинство инструкций с числовыми операндами имеют короткую форму, где операнд - это один байт со знаком.

Примеры:

    PUSH 200      ; 5 байт
    PUSH 100      ; 2 байт

    ADD EBX,128   ; 6 байт
    SUB EBX,-128  ; 3 байт

Самая важная инструкция с числовым операндом, у которой нет короткой формы, это MOV.

Примеры:

    MOV EAX, 0              ; 5 байт

Можно заменить на:

    XOR EAX,EAX             ; 2 байта

И

    MOV EAX, 1              ; 5 байтов

Можно заменить на:

    XOR EAX,EAX / INC EAX   ; 3 байта

или:

    PUSH 1 / POP EAX        ; 3 байта

И

    MOV EAX, -1             ; 5 байта

Можно заменить на:

    OR EAX, -1              ; 3 байта

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

Пример:

        MOV     [mem1],200             ; 10 байтов
        MOV     [mem2],200             ; 10 байтов
        MOV     [mem3],201             ; 10 байтов
        MOV     EAX,100                ;  5 байтов
        MOV     EBX,150                ;  5 байтов

Предполагая, что mem1 и mem3 находятся в пределах -128/127 байтов от em2, это можно изменить на:

        MOV     EBX, OFFSET mem2       ;  5 байтов
        MOV     EAX,200                ;  5 байтов

        MOV     [EBX+mem1-mem2],EAX    ;  3 байта
        MOV     [EBX],EAX              ;  2 байта
        INC     EAX                    ;  1 байт
        MOV     [EBX+mem3-mem2],EAX    ;  3 байта
        SUB     EAX,101                ;  3 байта
        LEA     EBX,[EAX+50]           ;  3 байта

Остерегайтесь задержек AGI в инструкции LEA (для PPlain и PMMX).

Также стоит учитывать то, что разные инструкции имеют разную длину. Следующие инструкции занимают только один байт и поэтому очень привлекательны: PUSH reg, POP reg, INC reg32, DEC reg32. INC и DEC с 8-ми битовыми регистрами занимают 2 байта, поэтому 'INC EAX' короче, чем 'INC AL'.

'XCHG EAX,reg' также однобайтовая инструкция и поэтому занимает меньше места, чем 'MOV EAX,reg', но это медленнее.

Некоторые инструкции занимают на один байт меньше, когда они используют аккумулятор, а не другой регистр.

Пример:

    MOV EAX,DS:[100000]  меньше, чем  MOV EBX,DS:[100000]
    ADD EAX,1000         меньше, чем  ADD EBX,1000

Инструкции с указателями занимают на один байт меньше, чем когда они используют адресацию по базе (не ESP) со сдвигом, а не косвенную адресацию с масштабированием, или и то, и другое вместе, или ESP в качестве базы.

Примеры:

    MOV EAX,[array][EBX]  меньше, чем  MOV EAX,[array][EBX*4]
    MOV EAX,[EBP+12]      меньше, чем  MOV EAX,[ESP+12]

Инструкции с EBP в качестве базы без смещения занимают на один байт больше, чем при использовании других регистров:

    MOV EAX,[EBX]    меньше чем  MOV EAX,[EBP], но
    MOV EAX,[EBX+4]  такого же размера, как и MOV EAX,[EBP+4]

Также адресация со сдвигом бывает выгоднее, чем адресация с масштабированием:

    LEA EAX,[EBX+EBX]  короче, чем  LEA EAX,[2*EBX]


24. Работа с плавающей запятой (PPlain и PMMX)

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

  • первая инструкция (выполняющаяся в U-конвейере) должна быть FLD, FADD, FSUB, FMUL, FDIV, FCOM, FCHS или FABS.
  • вторая инструкция (в V-конвейере) должна быть FXCH.
  • инструкция, следующая за FXCH, должна быть инструкцией плавающей запятой, иначе FXCH спарится несовершенно и займет лишний такт.

Это особенное спаривание играет важную роль, что вкратце будет объяснено.

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

Пример:

    FADD ST(1),ST(0)   ; такты 1-3
    FADD ST(2),ST(0)   ; такты 2-4
    FADD ST(3),ST(0)   ; такты 3-5
    FADD ST(4),ST(0)   ; такты 4-6

Очевидно, что выполнение двух инструкций не может пересекаться, если второй инструкции нужен результат первой. Так как почти все инструкции плавающей запятой работают с вершиной стека регистров ST(0), возможностей сделать их независимыми друг от друга не очень много. Решение этой проблемы состоит в переименовании регистров. Инструкция FXCH в реальности не обменивает содержимое двух регистров, оно только меняет их имена. Инструкции, которые помещают или извлекают значение из стека регистров также работают с помощью переименования. Переименование регистров на Pentium настолько хорошо оптимизировано, что можно переименовать использующийся регистр. Переименование регистров никогда не вызывает задержек - возможно даже переименовать регистр более чем один раз за такт, например, когда вы спариваете FLD или FCOMPP с FXCH.

Правильно используя инструкции FXCH, вы можете создать условия, чтобы инструкции плавающего кода были более независимыми друг от друга.

Пример:

    FLD     [a1]    ; такт 1
    FADD    [a2]    ; такт 2-4
    FLD     [b1]    ; такт 3
    FADD    [b2]    ; такт 4-6
    FLD     [c1]    ; такт 5
    FADD    [c2]    ; такт 6-8
    FXCH    ST(2)   ; такт 6
    FADD    [a3]    ; такт 7-9
    FXCH    ST(1)   ; такт 7
    FADD    [b3]    ; такт 8-10

    FXCH    ST(2)   ; такт 8
    FADD    [c3]    ; такт 9-11
    FXCH    ST(1)   ; такт 9
    FADD    [a4]    ; такт 10-12
    FXCH    ST(2)   ; такт 10
    FADD    [b4]    ; такт 11-13
    FXCH    ST(1)   ; такт 11
    FADD    [c4]    ; такт 12-14
    FXCH    ST(2)   ; такт 12

В вышеприведенном примере мы создали три независимые ветви. Каждый FADD занимает 3 такта, поэтому мы можем каждый такт начинать выполнение нового FADD. Когда мы начали выполнение ветви 'a', у нас есть время, чтобы начать выполнение двух новых инструкций FADD в ветвях 'b' и 'c' до возвращения к ветви 'a', поэтому каждый третий FADD принадлежит той же ветви. Мы используем инструкции FXCH каждый раз, когда необходимо, чтобы ST(0) стал равен регистру, который принадлежит к желаемой ветви. Как вы можете видеть из примера, это образует регулярную последовательность, но уясните хорошо, что инструкции FXCH повторяются с периодом, равным двум, в то время как у ветвей период равен трем. Это может немного смущать, поэтому вам следует проработать этот пример, чтобы понять где находится какой из регистров.

Все версии инструкций FADD, FSUB, FMUL и FILD занимают три такта и конвейеризуются, поэтому вышеописанный метод можно применять и с этими инструкциями. Использование переменных в памяти не отнимает больше времени, чем использование регистров, если переменная в памяти находится в кэше первого уровня и правильно выравнена.

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

Пример:

    FLD     [a1]    ; такт 1
    FLD     [b1]    ; такт 2
    FLD     [c1]    ; такт 3
    FXCH    ST(2)   ; такт 3
    FMUL    [a2]    ; такты 4-6
    FXCH            ; такт 4
    FMUL    [b2]    ; такты 5-7    (задержка)
    FXCH    ST(2)   ; такт 5
    FMUL    [c2]    ; такты 7-9    (задержка)
    FXCH            ; такт 7
    FSTP    [a3]    ; такты 8-9
    FXCH            ; такт 10     (неспарены)

    FSTP    [b3]    ; такты 11-12
    FSTP    [c3]    ; такты 13-14

Здесь у вас есть задержка между FMUL [b2] и между FMUL [c2], потому что другая FMUL началась в предыдущий такт. Вы можете улучшить этот код, поместив инструкции FLD между FMUL'ами.

    FLD     [a1]    ; такт 1
    FMUL    [a2]    ; такт 2-4
    FLD     [b1]    ; такт 3
    FMUL    [b2]    ; такт 4-6
    FLD     [c1]    ; такт 5

    FMUL    [c2]    ; такт 6-8
    FXCH    ST(2)   ; такт 6
    FSTP    [a3]    ; такт 7-8
    FSTP    [b3]    ; такт 9-10
    FSTP    [c3]    ; такт 11-12

В других случаях вы можете поместить FADD, FSUB или что-нибудь еще между FMUL'ами, чтобы избежать задержек.

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

    FLD     [a]     ; такт 1
    FADD    [b]     ; такты 2-4
    FLD     [c]     ; такт 3
    FADD    [d]     ; такты 4-6
    FXCH            ; такт 4
    FADD    [e]     ; такты 5-7
    FXCH            ; такт 5
    FADD    [f]     ; такты 7-9    (задержка)
    FADD            ; такты 10-12  (задержка)

Здесь у нас есть задержка в один такт перед FADD [f], потому что она ожидает результата выполнения FADD [d] и задержка в два такта перед последним FADD, потому что она ожидает результата FADD [f]. Более поздняя задержка может быть спрятана путем заполнения ее несколько целочисленными инструкциями, но с первой задержкой это не получится, так как целочисленная инструкция в этом месте приведет к тому, что FXCH будет спариваться несовершенно.

Первую задержку можно избежать, создав три ветви вместо двух, но это будет стоить дополнительно FLD, поэтому мы ничего не выиграем, прибегнув к этому варианту, если только у нас нет восьми чисел, которые нужно сложить.

Выполнение не все инструкций плавающей запятой может пересекаться. И выполнение некоторых инструкций плавающей запятой лучше сочетается с целочисленными инструкциями. Например, инструкция FDIV занимает 39 тактов. Во все, кроме первого такта, выполнение этой инструкции может пересекаться с целочисленными инструкциями, но только в последние два такта она может пересекаться с инструкциями плавающей запятой.

Пример:

 FDIV         ; такт 1-39  (U-конвейер)
 FXCH         ; такт 1-2   (V-конвейер, несовершенное спаривание)
 SHR EAX,1    ; такт 3     (U-конвейер)
 INC EBX      ; такт 3     (V-конвейер)
 CMC          ; такт 4-5   (не спаривается)
 FADD [x]     ; такт 38-40 (U-конвейер, ждет, пока FPU не освободится)
 FXCH         ; такт 38    (V-конвейер)
 FMUL [y]     ; такт 40-42 (U-конвейер, ждет результат FDIV)

Первый FXCH спаривается с FDIV, но занимает дополнительный такт, потому что за ней не следует инструкция плавающей запятой. Пара SHR / INC начинает свое выполнение до того, как будет закончено выполнение FDIV, но была вынуждена подождать, пока свое выполнение закончит FXCH.

Если у вас нет ничего, что поместить после инструкции плавающей запятой, которая может выполняться одновременно с целочисленной инструкцией, (FDIV, FSQRT), вы можете поместить чтение значение из какой-нибудь переменной в памяти, которое может вам понадобиться в дальнейшем, чтобы она на 100% была в кэше.

Пример:

        FDIV    QWORD PTR [EBX]
        CMP     [ESI],ESI
        FMUL    QWORD PTR [ESI]

Здесь мы загружаем загружаем значение в [ESI] в кэш, в то время как FDIV вычисляется (результат самой операции сравнения нам не важен).

В главе 28 приведен полный список инструкций плавающей запятой, и с чем они могут спариваться.

Никаких потерь при использовании переменных в памяти в инструкциях плавающей запятой, потому что модуль арифметических вычислений на один шаг дальше в конвейере, чем модуль чтения. Однако при сохранении данных может случиться задержка, аналогичная задержке AGI: выполнение инструкции FST или FSTP с переменной в памяти в качестве операнда занимает два такта, но данные должны быть готовы в предыдущем такте, поэтому будет задержка в один такт, если значение, которое нужно сохранить не будет готово еще в предыдущем такте.

Пример:

    FLD     [a1]    ; такт 1
    FADD    [a2]    ; такт 2-4
    FLD     [b1]    ; такт 3
    FADD    [b2]    ; такт 4-6
    FXCH            ; такт 4
    FSTP    [a3]    ; такт 6-7
    FSTP    [b3]    ; такт 8-9

FSTP задерживается на один такт, потому что результат FADD не был готов в предыдущем такте. Во многих случаях вы не можете скрыть этот тип задержек без организации вашего кода с плавающей запятой в четыре ветви или помещения внутрь каких-то целочисленных инструкций. Два такта на стадии выполнения инструкции FST(P) не могут спариваться или пересекаться с любой другой последующей инструкцией.

Инструкции с целочисленными операндами, такими как FIADD, FISUB, FIMUL, FIDIV, FICOM можно разделить на простые операции, чтобы улучшить пересекаемость выполнений инструкций.

Пример:

    FILD    [a]     ; clock cycle 1-3
    FIMUL   [b]     ; clock cycle 4-9

Разделить на:

    FILD    [a]     ; такты 1-3
    FILD    [b]     ; такты 2-4
    FMUL            ; такты 5-7

В этом примере вы экономите два такта, пересекая выполнение двух инструкций FILD.


25. Оптимизация циклов (все процессоры)

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

Во всех следующих примерах предполагается, что все данные находятся в кэше первого уровня. Если скорость ограничивается из-за того, что данные загружаются в кэш, нет никакого смысла оптимизировать инструкции. Лучше сконцентрируйтесь на правильной организации данных (глава 7).

25.1 Циклы в PPlain и PMMX

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

Эта процедура на C может выглядеть так:

void ChangeSign (int * A, int * B, int N) {
  int i;
  for (i=0; i<N; i++) B[i] = -A[i];}

Переводя ее на ассемблер мы могли бы написать эту процедуру следующим образом:

Пример 1.1:

_ChangeSign PROC NEAR
        PUSH    ESI
        PUSH    EDI
A       EQU     DWORD PTR [ESP+12]
B       EQU     DWORD PTR [ESP+16]
N       EQU     DWORD PTR [ESP+20]
        MOV     ECX, [N]
        JECXZ   L2

        MOV     ESI, [A]
        MOV     EDI, [B]
        CLD
L1:     LODSD
        NEG     EAX
        STOSD
        LOOP    L1
L2:     POP     EDI
        POP     ESI
        RET                     ; нет дополнительного pop, если объявлено
                                ; соглашение о вызове _cdecl
_ChangeSign     ENDP

Это выглядит как достаточно красивое решение, но оно не самое оптимальное, потому что оно использует медленные неспариваемые инструкции. Каждая итерация занимает 11 тактов при условии, что все данные находятся в кэше первого уровня.

Использование только спариваемых инструкций (PPlain и PMMX)

Пример 1.2:

        MOV     ECX, [N]
        MOV     ESI, [A]
        TEST    ECX, ECX
        JZ      SHORT L2
        MOV     EDI, [B]
L1:     MOV     EAX, [ESI]       ; u
        XOR     EBX, EBX         ; v (спаривается)
        ADD     ESI, 4           ; u
        SUB     EBX, EAX         ; v (спаривается)
        MOV     [EDI], EBX       ; u
        ADD     EDI, 4           ; v (спаривается)
        DEC     ECX              ; u

        JNZ     L1               ; v (спаривается)
L2:

Здесь мы используем только спариваемые инструкции, и так их располагаем, что они все спариваются. Теперь они занимают только 4 такта за итерацию. Мы можем получить ту же самую скорость не разделяя инструкцию NEG, но другие неспариваемые инструкции все же стоит разделить.

Использования одного регистра как счетчика и индекса

Пример 1.3:

        MOV     ESI, [A]
        MOV     EDI, [B]

        MOV     ECX, [N]
        XOR     EDX, EDX
        TEST    ECX, ECX
        JZ      SHORT L2
L1:     MOV     EAX, [ESI+4*EDX]          ; u
        NEG     EAX                       ; u
        MOV     [EDI+4*EDX], EAX          ; u
        INC     EDX                       ; v (спаривается)
        CMP     EDX, ECX                  ; u
        JB      L1                        ; v (спаривается)
L2:

Использование одного регистра как счетчика и индекса уменьшает количество инструкций в теле цикла, но он по-прежнему занимает 4 такта, так как у нас две неспариваемые инструкции.

Пусть конечное значение счетчика равняется нулю (PPlain и PMMX)

Мы хотим избавиться от инструкции CMP в примере 1.3, сделав так, чтобы последнее значение счетчика равнялось нулю, и использовать флаг нуля как знак того, что цикл закончен (как мы сделали в примере 1.2). Один вариант заключается в том, чтобы исполнить цикл задом наперед, взяв последний элемент первым. Тем не менее, кэш данных оптимизирован для получения последних в прямом порядке, а не в обратном, поэтому если вероятны промахи кэша, вам следует задать значение счетчика -N и повышать его значение до нуля. Базовые регистры тогда должны указывать на конец массива, а не на его начало:

Пример 1.4:

        MOV     ESI, [A]
        MOV     EAX, [N]
        MOV     EDI, [B]
        XOR     ECX, ECX
        LEA     ESI, [ESI+4*EAX]          ; указывает на конец массива A
        SUB     ECX, EAX                  ; -N
        LEA     EDI, [EDI+4*EAX]          ; указывает на конец массива B
        JZ      SHORT L2
L1:     MOV     EAX, [ESI+4*ECX]          ; u
        NEG     EAX                       ; u
        MOV     [EDI+4*ECX], EAX          ; u

        INC     ECX                       ; v (спаривается)
        JNZ     L1                        ; u
L2:

Однако цикл по-прежнему занимает 4 такта из-за плохой спариваемости. (Если адреса и размеры массива являются константами, мы можем сохранить два регистра). Теперь давайте посмотрим, как мы можем улучшить спариваемость.

Спаривание вычислений в цикле (PPlain и PMMX)

Мы можем улучшить спаривание, перемешав инструкции вычисления с инструкциями управления циклом. Если мы хотим поместить что-нибудь между 'INC ECX' и 'JNZ L1', это должно быть что-нибудь, что не влияет на флаг нуля. Инструкция 'MOV [EDI+4*ECX],EBX после 'INC ECX' сгенерирует задержку AGI, поэтому нам придется нужно быть более искусными:

Пример 1.5:

        MOV     EAX, [N]
        XOR     ECX, ECX
        SHL     EAX, 2                    ; 4 * N
        JZ      SHORT L3

        MOV     ESI, [A]
        MOV     EDI, [B]
        SUB     ECX, EAX                  ; - 4 * N
        ADD     ESI, EAX                  ; указывает на конец массива A
        ADD     EDI, EAX                  ; указывает на конец массива B
        JMP     SHORT L2
L1:     MOV     [EDI+ECX-4], EAX          ; u
L2:     MOV     EAX, [ESI+ECX]            ; v (спаривается)
        XOR     EAX, -1                   ; u
        ADD     ECX, 4                    ; v (спаривается)
        INC     EAX                       ; u

        JNC     L1                        ; v (спаривается)
        MOV     [EDI+ECX-4], EAX
L3:

Здесь я использовал другой способ, чтобы посчитать отрицательное значение EAX: инвертирование всех битов и добавление еще одного. Причина, по которой я задействовал этот метод состоит в том, что я могу использовать хитрый трюк с инструкцией INC: INC не изменяет флаг переноса, в то время как ADD это делает. Я использую ADD вместо INC, чтобы повышать счетчик цикла и проверяю флаг переноса, а не флаг нуля. Тогда можно поместить 'INC EAX' между ними без вреда для флага переноса. Возможно вы подумали, что можно было бы использовать 'LEA EAX,[EAX+1]' вместо 'INC EAX', по крайней мере он не меняет никаких флагов, но инструкция LEA сгенерирует задержку AGI, поэтому это не лучшее решение. Обратите внимание, что трюк с инструкцией INC, которая не меняет флаг переноса, будет полезен только на PPlain и PMMX, так как на PPro, PII и PIII будут сгенерированы частичные задержки регистра флагов.

Здесь мне удалось добиться совершенного спаривания, и цикл теперь занимает только 3 такта. Хотите ли вы повышать значение счетчика цикла на 1 (как в примере 1.4) или на 4 (как в примере 1.5) - это дело вкуса, никакого влияния на время выполнения цикла это не окажет.

Пересечение времени выполнения одной операции с другой (PPlain и PMMX)

Метод, использованный в примере 1.5 подойдет далеко не во всех случаях, поэтому мы можем поискать другие способы улучшения спариваемости. Один из путей реорганизовать цикл - это сделать так, чтобы конец выполнения одной операции пересекался с началом выполнения другой. Я буду называть это свернутым циклом. У свернутого цикла в конце каждого цикла находится инструкция, выполнение которой будет закончено в следующем повторении. Фактически, в примере 1.5 последний MOV спаривается с первым. Мы исследуем этот метод поподробнее:

Пример 1.6:

        MOV     ESI, [A]
        MOV     EAX, [N]
        MOV     EDI, [B]
        XOR     ECX, ECX
        LEA     ESI, [ESI+4*EAX]          ; указывает на массив A
        SUB     ECX, EAX                  ; -N
        LEA     EDI, [EDI+4*EAX]          ; указывает на массив B
        JZ      SHORT L3
        XOR     EBX, EBX
        MOV     EAX, [ESI+4*ECX]
        INC     ECX
        JZ      SHORT L2
L1:     SUB     EBX, EAX                  ; u

        MOV     EAX, [ESI+4*ECX]          ; v (спаривается)
        MOV     [EDI+4*ECX-4], EBX        ; u
        INC     ECX                       ; v (спаривается)
        MOV     EBX, 0                    ; u
        JNZ     L1                        ; v (спаривается)
L2:     SUB     EBX, EAX
        MOV     [EDI+4*ECX-4], EBX
L3:

Здесь мы начинаем считывать второе значение до того, как сохранили первое, и это, конечно, улучшает возможности к спариванию. Инструкция 'MOV EBX,0' была помещена между 'INC ECX' и 'JNZ L1' не для того, чтобы улучшить спаривание, а для того, чтобы избежать задержку AGI.

Разворачивание цикла (PPlain и PMMX)

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

Пример 1.7:

        MOV     ESI, [A]
        MOV     EAX, [N]
        MOV     EDI, [B]
        XOR     ECX, ECX
        LEA     ESI, [ESI+4*EAX]          ; указывает на конец массива A
        SUB     ECX, EAX                  ; -N
        LEA     EDI, [EDI+4*EAX]          ; указывает на конец массива B

        JZ      SHORT L2
        TEST    AL,1                      ; тестируем N на нечетность
        JZ      SHORT L1
        MOV     EAX, [ESI+4*ECX]          ; N нечетно
        NEG     EAX
        MOV     [EDI+4*ECX], EAX
        INC     ECX                       ; делаем дополнительную операцию,
                                          ; если счетчик нечетен
        JZ      SHORT L2                  ; N = 1
L1:     MOV     EAX, [ESI+4*ECX]          ; u
        MOV     EBX, [ESI+4*ECX+4]        ; v (спаривается)
        NEG     EAX                       ; u

        NEG     EBX                       ; u
        MOV     [EDI+4*ECX], EAX          ; u
        MOV     [EDI+4*ECX+4], EBX        ; v (спаривается)
        ADD     ECX, 2                    ; u
        JNZ     L1                        ; v (спаривается)
L2:

Теперь мы делаем две операции одновременно, что приводит к лучшему спариванию. Нам приходится тестировать N на нечетность, и если оно нечетно, то делаем дополнительную операцию вне цикла, потому что внутри него мы можем сделать только четное количество операций.

В цикле есть задержка AGI, генерируемая первой инструкцией MOV, потому что ECX повышается на 1 в предыдущем такте, поэтому для двух операций цикл занимает 6 тактов.

Реорганизация цикла для удаления задержки AGI (PPlain и PMMX)

Пример 1.8:

        MOV     ESI, [A]
        MOV     EAX, [N]
        MOV     EDI, [B]
        XOR     ECX, ECX
        LEA     ESI, [ESI+4*EAX]          ; указывает на конец массива A
        SUB     ECX, EAX                  ; -N

        LEA     EDI, [EDI+4*EAX]          ; указывает на конец массива B
        JZ      SHORT L3
        TEST    AL,1                      ; тестируем N на нечетность
        JZ      SHORT L2
        MOV     EAX, [ESI+4*ECX]          ; делаем дополнительную операцию,
                                          ; если счетчик нечетен
        NEG     EAX                       ; нет возможности к спариванию
        MOV     [EDI+4*ECX-4], EAX
        INC     ECX                       ; делаем счетчик четным
        JNZ     SHORT L2
        NOP                    ; добавляем NOP'ы, если JNZ L2 не предсказуем

        NOP
        JMP     SHORT L3                  ; N = 1
L1:     NEG     EAX                       ; u
        NEG     EBX                       ; u
        MOV     [EDI+4*ECX-8], EAX        ; u
        MOV     [EDI+4*ECX-4], EBX        ; v (спаривается)
L2:     MOV     EAX, [ESI+4*ECX]          ; u
        MOV     EBX, [ESI+4*ECX+4]        ; v (спаривается)
        ADD     ECX, 2                    ; u
        JNZ     L1                        ; v (спаривается)
        NEG     EAX

        NEG     EBX
        MOV     [EDI+4*ECX-8], EAX
        MOV     [EDI+4*ECX-4], EBX
L3:

Трюк заключается в том, что найти пару инструкций, которые не используют счетчик цикла в качестве индекса, и реорганизовать цикл так, чтобы счетчик повышал свое значение в предыдущем такте. Сейчас мы близки к 5 тактам для двух операций, что близко к самому лучшему варианту.

Если кэширование данных критично, тогда вы можете улучшить скорость, соединив массивы A и B в один массив, так чтобы каждый B[i] находился непосредственно за соответствующим A[i]. Если структурированный массив выравнен по крайней мере на 8, тогда B[i] всегда будет находится в той же линии кэша, что и A[i], поэтому будет всегда случаться промах кэша при записи в B[i]. Это, конечно, может сглаживаться приростом производительности в других частях программы, поэтому вы должны взвесить все за и против.

Разворачивание более чем на 2 (PPlain и PMMX)

Вы можете прикинуть возможность более чем двух операций за одно повторение, чтобы снизить количество действий, необходимых для организации цикла. Но так как в большинстве случаев время, требуемое для выполнения таких действий, можно снизить только на один такт, то разворачивание цикла в четыре раза, а не в два, сохранит только 1/4 такта на операцию, что вряд ли стоит усилий, которые будут на это потрачены. Только если можно сохранить еще больше, и если N очень велико, вам стоит думать о разворачивании цикла в четыре раза.

Недостатки слишком большого разворачивания цикла следующие:

  • Вам будет необходимо посчитать N MODULO R, где R - это коэффициент разворачивания, и сделать N MODULO R операций до или после основного цикла, чтобы выполнить недостающее количество операций. На это уйдет дополнительный код и плохо предсказуемые операции условного перехода. И, конечно, тело цикла станет больше.
  • Кусок кода обычно выполняется в первый раз гораздо дольше, и чем больше ваш код, тем больше будут связанные с этим потери, особенно, если N невелико.
  • Значительное увеличение кода делает работу с кэшем менее эффективной.

Одновременная обработка нескольких 8-ми или 16-ти битных операндов в 32-х битных регистра (PPlain и PMMX)

Если вам нужно обрабатывать массивы, состоящие из 8-ми или 16-ти битных операндов, тогда у вас появятся проблемы с развернутыми циклами, потому что вы не сможете спарить две операции доступа к памяти. Например, 'MOV AL,[ESI] / MOV BL,[ESI+1]' не будут спариваться, так как оба операнда находятся внутри одного и того же двойного слова памяти. Но есть продвинутый способ обрабатывать сразу два байта за раз в одном 32-х битном регистре.

Следующий пример добавляет 2 ко всем элементам массива байтов:

Пример 1.9:

        MOV     ESI, [A]         ; адрес массива байтов
        MOV     ECX, [N]         ; количество элементов в массиве байтов
        TEST    ECX, ECX         ; проверяем, равен ли N нулю
        JZ      SHORT L2
        MOV     EAX, [ESI]       ; считываем первые четыре байта
L1:     MOV     EBX, EAX         ; копируем в EBX
        AND     EAX, 7F7F7F7FH   ; получаем нижние 7 битов каждого байта в EAX

        XOR     EBX, EAX         ; получаем самый высший бит каждого из байтов
        ADD     EAX, 02020202H   ; добавляем желаемое значение ко всем четырем
                                 ; байтам
        XOR     EBX, EAX         ; снова комбинируем биты
        MOV     EAX, [ESI+4]     ; считываем следующие четыре байта
        MOV     [ESI], EBX       ; сохраняем результат
        ADD     ESI, 4           ; повышаем значение указателя на 4
        SUB     ECX, 4           ; понижаем значение счетчика цикла
        JA      L1               ; цикл
L2:

Этот цикл занимает 5 тактов для каждых 4 байт. Массив, разумеется, должен быть выравнен на 4. Если количество элементов в массиве не кратно четырем, тогда вы можете добавить в конец несколько байтов, чтобы сделать его делимым на четыре. Этот цикл будет читать за концом массива, поэтому вам нужно убедиться, что тот не находится в конце сегмента, дабы избежать ошибки общей защиты.

Обратите внимание, что перед прибавлением я "спрятал" наивысший бит каждого байта, чтобы не испортить их значение (если результат сложения для конкретного байта превысит 256). Я использую XOR, а не ADD, когда помещаю последний бит на место по той же самой причине.

Инструкция 'ADD ESI,4' можно избежать, если использовать счетчик цикла в качестве индекса, как это сделано в примере 1.4. Тем не менее, в результате количество инструкций в цикле будет нечетно, а значит одна инструкция будет не спарена, и цикл по-прежнему займет 5 тактов. Делая инструкцию перехода неспаренной сохранит один такт после последней операции, если инструкция была предсказана неверно, но нам придется потратить дополнительный такт в коде пролога, чтобы установить указатель в конец массива и высчитать -N, поэтому эти два метода будут одинаково быстры. Метод, представленный здесь, является самым простым и самым коротким.

Следующий пример находит длину строки, заканчивающейся нулем, путем поиска первого байта, равного нулю. Он быстрее, чем использовать REP SCASB:

Пример 1.10:

STRLEN  PROC    NEAR
        MOV     EAX,[ESP+4]               ; получаем указатель
        MOV     EDX,7
        ADD     EDX,EAX                   ; указатель+7 используется в конце
        PUSH    EBX
        MOV     EBX,[EAX]                 ; читаем первые 4 байта
        ADD     EAX,4                     ; повышаем значение указателя

L1:     LEA     ECX,[EBX-01010101H]       ; вычитаем один из каждого байта
        XOR     EBX,-1                    ; инвертируем все байты
        AND     ECX,EBX                   ; и эти два
        MOV     EBX,[EAX]                 ; читаем следующие 4 байта
        ADD     EAX,4                     ; повышаем значение указателя
        AND     ECX,80808080H             ; тестируем все биты знаков
        JZ      L1                        ; нет нулевых байтов, продолжаем
                                          ; цикл
        TEST    ECX,00008080H             ; тестируем первые два байта

        JNZ     SHORT L2
        SHR     ECX,16                    ; не в первых двух байтах
        ADD     EAX,2
L2:     SHL     CL,1                      ; используем флаг переноса, чтобы
                                          ; избежать переход
        POP     EBX
        SBB     EAX,EDX                   ; высчитываем длину
        RET
STRLEN  ENDP

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

Количество инструкций в цикле нечетно, поэтому одна из них неспарена. Сделав неспаренной инструкцию перехода, а не какую-либо другую, мы сэкономим один такт, если инструкция перехода будет предсказана неверно.

Инструкция 'TEST ECX,00008080H' неспариваема. Вы можете использовать здесь спариваемую инструкцию 'OR CH,CL' вместо нее, но тогда вам придется поместить NOP или что-нибудь еще, чтобы избежать потерь из-за последовательных инструкций перехода. Другая проблема с 'OR CH,CL' состоит в том, что она вызовет частичную задержку регистра на PPro, PII или PIII. Поэтому я выбрал использование неспариваемой инструкции TEST.

Обработка 4-х байт за раз может быть довольно сложной. Код использует формулу, которая генерирует ненулевое значение для байт только тогда, когда байт равен нулю. Это делает возможным протестировать все четыре байта за одну операцию. Этот алгоритм включает вычитание 1 из всех байтов (в инструкции LEA). Я не стал "прятать" наивысший бит перед вычитанием, как я сделал это в предыдущем примере, поэтому операция вычитания может испортить значение следующего байта в EAX, но только если текущий равен нулю, и нам не важно, что будет со следующим байтом. Если бы мы искали нулевой байт в обратном порядке, нам бы пришлось считать двойное слово повторно после обнаружения нуля, а затем протестировать все четыре байта, чтобы найти последний ноль, или использовать BSWAP для изменения порядка байтов.

Если вы хотите найти байт с другим, ненулевым значением, тогда вы можете XOR'ить все четыре байта со значением, которое вы ищете, а затем используйте метод выше для поиска нуля.

Циклы с операциями MMX (PMMX)

Обработка нескольких операндов в одном регистре проще на MMX-процессорах, потому что у них специальные инструкции и регистры именно для этих целей.

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

Пример 1.11:

.data
ALIGN   8
ADDENTS DQ      0202020202020202h       ; указываем байт, который нужно
                                        ; добавить 8 раз
A       DD      ?                       ; адрес массива байтов
N       DD      ?                       ; количество итераций

.code
        MOV     ESI, [A]
        MOV     ECX, [N]
        MOVQ    MM2, [ADDENTS]
        JMP     SHORT L2
        ; top of loop
L1:     MOVQ    [ESI-8], MM0    ; сохраняем результат
L2:     MOVQ    MM0, MM2        ; загружаем слагаемые

        PADDB   MM0, [ESI]      ; обрабатываем 8 байт за одну операцию
        ADD     ESI, 8
        DEC     ECX
        JNZ     L1
        MOVQ    [ESI-8], MM0    ; сохраняем последний результат
        EMMS

Инструкция сохранения помещена после инструкции управления циклом, чтобы избежать задержки сохранения.

Этот цикл занимает 4 такта, потому что инструкция PADDB не спаривается с 'ADD ESI, 8'. (Инструкция MMX с доступом к памяти не может спариваться с не-MMX инструкцией или с другой инструкцией MMX с доступом к памяти). Мы можем избавиться от 'ADD ESI, 8', используя в качестве индекса ECX, но это приведет к задержке AGI.

Так как затраты на управления циклом значительны, мы можем захотеть развернуть цикл:

Пример 1.12:

.data
ALIGN   8
ADDENTS DQ      0202020202020202h       ; значение, добавляемое к 8 байтам
times
A       DD      ?                       ; адрес массива байтов
N       DD      ?                       ; количество итераций

.code
        MOVQ    MM2, [ADDENTS]
        MOV     ESI, [A]
        MOV     ECX, [N]
        MOVQ    MM0, MM2
        MOVQ    MM1, MM2
L3:     PADDB   MM0, [ESI]

        PADDB   MM1, [ESI+8]
        MOVQ    [ESI], MM0
        MOVQ    MM0, MM2
        MOVQ    [ESI+8], MM1
        MOVQ    MM1, MM2
        ADD     ESI, 16
        DEC     ECX
        JNZ     L3
        EMMS

Этот развернутый цикл занимает 6 тактов на выполнение при обработке 16 байтов. Инструкции PADDB не спариваются. Две ветви перемешаны, чтобы избежать задержки сохранения.

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

Циклы с инструкциями плавающей запятой (PPlain и PMMX).

Методы оптимизации циклов с инструкциями плавающей запятой примерно те же самые, что и для циклов с целочисленными инструкциями, хотя инструкции плавающей запятой пересекаются, а не спариваются.

У нас есть следующий код языка C:

  int i, n;  double * X;  double * Y;  double DA;
  for (i=0; i<n; i++)  Y[i] = Y[i] - DA * X[i];

Этот кусок кода (под названием DAXPY) является ключом к решению линейных уравнений.

Пример 1.13:

DSIZE   = 8                                      ; размер данных
        MOV     EAX, [N]                         ; количество элементов
        MOV     ESI, [X]                         ; указатель на X
        MOV     EDI, [Y]                         ; указатель на Y
        XOR     ECX, ECX
        LEA     ESI, [ESI+DSIZE*EAX]             ; указывает на конец X
        SUB     ECX, EAX                         ; -N
        LEA     EDI, [EDI+DSIZE*EAX]             ; указывает на конец Y

        JZ      SHORT L3                         ; тестируем N == 0 или нет
        FLD     DSIZE PTR [DA]
        FMUL    DSIZE PTR [ESI+DSIZE*ECX]        ; DA * X[0]
        JMP     SHORT L2                         ; переходим к циклу
L1:     FLD     DSIZE PTR [DA]
        FMUL    DSIZE PTR [ESI+DSIZE*ECX]        ; DA * X[i]
        FXCH                                     ; получаем старый результат
        FSTP    DSIZE PTR [EDI+DSIZE*ECX-DSIZE]  ; сохраняем Y[i]
L2:     FSUBR   DSIZE PTR [EDI+DSIZE*ECX]        ; вычитаем из Y[i]

        INC     ECX                              ; увеличиваем значение
                                                 ; индекса на 1
        JNZ     L1                               ; цикл
        FSTP    DSIZE PTR [EDI+DSIZE*ECX-DSIZE]  ; сохраняем последний
                                                 ; результат
L3:

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

Смешение различных инструкций плавающей запятой работает прекрасно: задержка в 2 такта между FMUL и FSUBR заполняется FSTP предыдущего результата. Задержка в 3 такта между FSUBR и FSTP заполняется инструкциями управления циклом и первые двумя инструкциями следующего повторения. Задержку AGI удалось избежать благодаря чтению в первом такте после изменения индекса только тех параметров, которые от индекса не зависят.

Это решение занимает 6 тактов на операцию, и оно лучше, чем аналогичное развернутое решение от Интела!

Разворачивание циклов с инструкциями плавающей запятой (PPlain и PMMX)

Цикл DAXPY, развернутый в 3 раза, довольно сложен:

Пример 1.14:

DSIZE = 8                                 ; размер данных
IF DSIZE EQ 4
SHIFTCOUNT = 2
ELSE
SHIFTCOUNT = 3
ENDIF

        MOV     EAX, [N]                  ; количество элементов
        MOV     ECX, 3*DSIZE              ; погрешность счетчика

        SHL     EAX, SHIFTCOUNT           ; DSIZE*N
        JZ      L4                        ; N = 0
        MOV     ESI, [X]                  ; указатель на X
        SUB     ECX, EAX                  ; (3-N)*DSIZE
        MOV     EDI, [Y]                  ; указатель на Y
        SUB     ESI, ECX                  ; конец указателя - погрешность
        SUB     EDI, ECX
        TEST    ECX, ECX
        FLD     DSIZE PTR [ESI+ECX]       ; первый X
        JNS     SHORT L2                  ; меньше чем 4 операции

L1:     ; main loop
        FMUL    DSIZE PTR [DA]
        FLD     DSIZE PTR [ESI+ECX+DSIZE]
        FMUL    DSIZE PTR [DA]
        FXCH
        FSUBR   DSIZE PTR [EDI+ECX]
        FXCH
        FLD     DSIZE PTR [ESI+ECX+2*DSIZE]
        FMUL    DSIZE PTR [DA]
        FXCH
        FSUBR   DSIZE PTR [EDI+ECX+DSIZE]
        FXCH    ST(2)
        FSTP    DSIZE PTR [EDI+ECX]
        FSUBR   DSIZE PTR [EDI+ECX+2*DSIZE]
        FXCH
        FSTP    DSIZE PTR [EDI+ECX+DSIZE]

        FLD     DSIZE PTR [ESI+ECX+3*DSIZE]
        FXCH
        FSTP    DSIZE PTR [EDI+ECX+2*DSIZE]
        ADD     ECX, 3*DSIZE
        JS      L1                        ; цикл
L2:     FMUL    DSIZE PTR [DA]            ; заканчиваем оставшуюся операцию
        FSUBR   DSIZE PTR [EDI+ECX]
        SUB     ECX, 2*DSIZE              ; изменяем погрешность указателя
        JZ      SHORT L3                  ; закончили
        FLD     DSIZE PTR [DA]            ; начинаем новую операцию

        FMUL    DSIZE PTR [ESI+ECX+3*DSIZE]
        FXCH
        FSTP    DSIZE PTR [EDI+ECX+2*DSIZE]
        FSUBR   DSIZE PTR [EDI+ECX+3*DSIZE]
        ADD     ECX, 1*DSIZE
        JZ      SHORT L3                  ; закончили
        FLD     DSIZE PTR [DA]
        FMUL    DSIZE PTR [ESI+ECX+3*DSIZE]
        FXCH
        FSTP    DSIZE PTR [EDI+ECX+2*DSIZE]
        FSUBR   DSIZE PTR [EDI+ECX+3*DSIZE]
        ADD     ECX, 1*DSIZE
L3:     FSTP    DSIZE PTR [EDI+ECX+2*DSIZE]
L4:

Причина, по которой я показываю вам, как развернуть цикл в три раза, не в том, чтобы порекомендовать подобный подход, а в том, чтобы продемонстрировать, как это сложно! Будьте готовы провести значительное количество времени, отлаживая и проверяя ваш код, когда будете делать что-нибудь подобное. Есть несколько проблем, о которых нужно позаботиться: в большинстве случаев вы не сможете устранить все задержки из цикла с инструкциями плавающей запятой, развернутого менее чем на 4, если только вы не свернете его (то есть в конце цикла будут операции, выполнение которых закончится в конце следующего повторения). Последний FLD в основном цикле выше является началом первой операции в следующем повторении. Было бы соблазнительным сделать решение, которое бы считывало после конца массива, а затем бы избавлялось от лишнего значения (как в примере 1.9 и 1.10), но это не рекомендуется с инструкциями плавающей запятой, потому что чтение дополнительного значения может сгенерировать исключение ненормального значения операнда, если после массива не находится число с плавающей запятой. Чтобы избежать этого, нам приходиться совершать по крайней мере на одну операцию больше после основного цикла.

Количество операций, которое необходимо выполнить снаружи развернутого цикла, как правило будет равно N MODULE R, где N - количество операций, а R - коэффициент развернутости цикла. Но в случае со свернутым циклом, нам нужно сделать на одну операцию больше, то есть (N-1) MODULO R + 1 в силу вышеуказанных причин.

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

Другая задача - это высчитать, как влиять на счетчик цикла так, чтобы он изменил знак в правильный момент, и привести в порядок базовые указатели. Наконец, вам следует убедиться, что оставшийся от свертывания операнд был обработан верно для всех значений N.

Эпилоговый код, делающий 1-3 операции, можно организовать как отдельный цикл, но это будет стоить неправильного предсказания, поэтому решение выше быстрее.

Теперь, когда я напугал вас трудностями разворачивания на 3, я покажу, насколько проще разворачивать на 4:

Пример 1.15:

DSIZE   = 8                               ; размер данных
        MOV     EAX, [N]                  ; количество элементов
        MOV     ESI, [X]                  ; указатель на X
        MOV     EDI, [Y]                  ; указатель на Y
        XOR     ECX, ECX
        LEA     ESI, [ESI+DSIZE*EAX]      ; указывает на конец X

        SUB     ECX, EAX                  ; -N
        LEA     EDI, [EDI+DSIZE*EAX]      ; указывает на конец Y
        TEST    AL,1                      ; тестируем N на нечетность
        JZ      SHORT L1
        FLD     DSIZE PTR [DA]            ; делаем нечетную операцию
        FMUL    DSIZE PTR [ESI+DSIZE*ECX]
        FSUBR   DSIZE PTR [EDI+DSIZE*ECX]
        INC     ECX                       ; увеличиваем значение счетчика
        FSTP    DSIZE PTR [EDI+DSIZE*ECX-DSIZE]
L1:     TEST    AL,2            ; проверяем, можно ли сделать еще 2 операции

        JZ      L2
        FLD     DSIZE PTR [DA]        ; N MOD 4 = 2 или 3. Делаем еще две
        FMUL    DSIZE PTR [ESI+DSIZE*ECX]
        FLD     DSIZE PTR [DA]
        FMUL    DSIZE PTR [ESI+DSIZE*ECX+DSIZE]
        FXCH
        FSUBR   DSIZE PTR [EDI+DSIZE*ECX]
        FXCH
        FSUBR   DSIZE PTR [EDI+DSIZE*ECX+DSIZE]
        FXCH
        FSTP    DSIZE PTR [EDI+DSIZE*ECX]
        FSTP    DSIZE PTR [EDI+DSIZE*ECX+DSIZE]
        ADD     ECX, 2                ; счетчик не делится 4

L2:     TEST    ECX, ECX
        JZ      L4                        ; больше операций нет
L3:     ; main loop:
        FLD     DSIZE PTR [DA]
        FLD     DSIZE PTR [ESI+DSIZE*ECX]
        FMUL    ST,ST(1)
        FLD     DSIZE PTR [ESI+DSIZE*ECX+DSIZE]
        FMUL    ST,ST(2)
        FLD     DSIZE PTR [ESI+DSIZE*ECX+2*DSIZE]
        FMUL    ST,ST(3)
        FXCH    ST(2)
        FSUBR   DSIZE PTR [EDI+DSIZE*ECX]
        FXCH    ST(3)
        FMUL    DSIZE PTR [ESI+DSIZE*ECX+3*DSIZE]

        FXCH
        FSUBR   DSIZE PTR [EDI+DSIZE*ECX+DSIZE]
        FXCH    ST(2)
        FSUBR   DSIZE PTR [EDI+DSIZE*ECX+2*DSIZE]
        FXCH
        FSUBR   DSIZE PTR [EDI+DSIZE*ECX+3*DSIZE]
        FXCH    ST(3)
        FSTP    DSIZE PTR [EDI+DSIZE*ECX]
        FSTP    DSIZE PTR [EDI+DSIZE*ECX+2*DSIZE]
        FSTP    DSIZE PTR [EDI+DSIZE*ECX+DSIZE]
        FSTP    DSIZE PTR [EDI+DSIZE*ECX+3*DSIZE]
        ADD     ECX, 4                    ; увеличиваем значение индекса на 4

        JNZ     L3                        ; цикл
L4:

Обычно довольно легко добиться отсутствия задержек в цикле, развернутом на 4, и нет нужды в свертывании последней операции. Количество дополнительных операций, которые нужно сделать за пределами основного цикла равно N MODULO 4, что можно легко посчитать без деления, просто протестировав два нижних бита в N. Дополнительные операции делаются до основного цикла, а не после, чтобы сделать обработку счетчика цикла проще.

Недостаток разворачивания циклов в том, что дополнительные операции, выполняемые за пределами цикла медленнее из-за несовершенного пересечения и возможных неправильных предсказаний переходов, а потери при первой загрузке кода выше из-за возросшего размера кода.

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

25.2 Циклы в PPro, PII и PIII

В предыдущей главе (25.1) я объяснил, как использовать свертывание и разворачивания циклов, чтобы улучшить спаривание в PPlain и PMMX. На PPro, PII и PIII нет никакой причины делать это благодаря механизму выполнения инструкций не по порядку. Но здесь есть другие проблемы, о которых надо позаботиться, связанные с границами БДИ и задержка чтения регистров.

Я выбрал те же примеры, что и в главе 25.1 для предыдущих микропроцессоров: процедура, которая считывает целые числа из массива, изменяет их знак, и сохраняет результаты в другой массив.

На C эта процедура выглядела бы так:

void ChangeSign (int * A, int * B, int N) {
  int i;
  for (i=0; i<N; i++) B[i] = -A[i];}

Переводя ее на ассемблер, нам следовало бы написать так:

Пример 2.1:

_ChangeSign PROC NEAR
        PUSH    ESI
        PUSH    EDI
A       EQU     DWORD PTR [ESP+12]
B       EQU     DWORD PTR [ESP+16]
N       EQU     DWORD PTR [ESP+20]

        MOV     ECX, [N]
        JECXZ   L2

        MOV     ESI, [A]
        MOV     EDI, [B]
        CLD
L1:     LODSD
        NEG     EAX
        STOSD
        LOOP    L1
L2:     POP     EDI
        POP     ESI
        RET
_ChangeSign     ENDP

Это выглядит как довольно красивое решение, но не самое оптимальное, потому что он использует инструкции LOOP, LODSD и STOSD, которые генерируют много мопов. Одна итерация цикла занимает 6-7 тактов, если все данные находятся в кэше первого уровня. Если мы будем избегать данных инструкций, то получим следующее:

Пример 2.2:

        MOV     ECX, [N]
        JECXZ   L2
        MOV     ESI, [A]
        MOV     EDI, [B]
ALIGN   16
L1:     MOV     EAX, [ESI]       ; len=2, p2rESIwEAX
        ADD     ESI, 4           ; len=3, p01rwESIwF
        NEG     EAX              ; len=2, p01rwEAXwF
        MOV     [EDI], EAX       ; len=2, p4rEAX, p3rEDI
        ADD     EDI, 4           ; len=3, p01rwEDIwF
        DEC     ECX              ; len=1, p01rwECXwF
        JNZ     L1               ; len=2, p1rF

L2:

Комментарии интерпретируются следующим образом: инструкция 'MOV EAX,[ESI]' два байта длиной, она генерирует один моп для порта 2, который читает ESI и пишет в (переименовывает) EAX. Эта информация требуется для анализирования возможных узких мест.

Давайте сначала проанализируем раскодировку инструкций (глава 14): одна из инструкций генерирует два мопа ('MOV [EDI],EAX'). Эта инструкция должна попасть в декодер D0. Есть три раскодированные группы в цикле, поэтому его можно раскодировать за 3 такта.

Теперь давайте посмотрим на доставку инструкций (глава 15): если границы между БДИ не дадут первым трем инструкциям раскодироваться вместе, тогда будет три раскодированные группы в последнем БДИ, поэтому в следующем повторении БДИ начнется с первой инструкции, там где нам нужно, и мы получим задержку только в первом повторении. Худшим вариантом будет 16-ти байтная граница и граница БДИ в одно из следующих трех инструкций. Согласно таблице, приведенной в соответствующей главе, это сгенерирует задержку в один такт, и приведет к тому, что в следующем повторении первый БДИ будет выравнен на 16, и проблема будет повторяться каждую итерацию. В результате время доставки будет равно 4 тактам на итерацию (а не 3). Есть два пути предотвратить подобный вариант развития событий: первый метод заключается в том, чтобы контролировать расположение 16-ти байтных границ. Другой метод проще. Так как во всем цикле только 15 байт кода, вы можете избежать 16-ти байтных границ, выравняв цикл на 16, как показано выше. Тогда весь цикл будет умещаться в один БДИ, поэтому никакого дальнейшего анализа доставки инструкций не потребуется.

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

Четвертый анализ - это выполнение инструкций (глава 17). Подсчитывая мопы, предназначающиеся для разных портом, мы получаем:

порт 0 или 1: 4 мопа порт 1 : 1 моп порт 2: 1 моп порт 3: 1 моп порт 4: 1 моп

Предположив, что мопы, которые могут пойти в порт 0 или 1, распределяются оптимальным образом, время выполнения будет равно 2.5 такта на итерацию.

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

В заключение, цикл, представленный выше, может выполняться за 3 такта на итерацию, если код цикла выравнен на 16. Я предполагаю, что условный переход предсказывается каждый раз, кроме выхода из цикла (глава 22.2).

Использование одного и того же регистра для счетчика и индекса, последнее значение счетчика равно нулю (PPro, PII и PIII)

Пример 2.3:

        MOV     ECX, [N]
        MOV     ESI, [A]
        MOV     EDI, [B]
        LEA     ESI, [ESI+4*ECX]          ; указывает на конец массива A
        LEA     EDI, [EDI+4*ECX]          ; указывает на конец массива B
        NEG     ECX                       ; -N
        JZ      SHORT L2
ALIGN   16
L1:     MOV     EAX, [ESI+4*ECX]          ; len=3, p2rESIrECXwEAX

        NEG     EAX                       ; len=2, p01rwEAXwF
        MOV     [EDI+4*ECX], EAX          ; len=3, p4rEAX, p3rEDIrECX
        INC     ECX                       ; len=1, p01rwECXwF
        JNZ     L1                        ; len=2, p1rF
L2:

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

Раскодировка: в этом цикле две раскодированные группы, поэтому раскодировка пройдет в два такта.

Доставка инструкций:

Цикл всегда занимает по крайней мере на один такт больше, чем количество 16-ти байтных блоков. Так как в этом цикле только 11 байтов кода, можно уместить их все в один БДИ. выравняв цикл на 16 мы будем уверены, что будет только один 16-ти байтный блок, поэтому возможно осуществить доставку за 2 такта.

Задержки чтения регистров:

Регистры ESI и EDI прочитаны, но не модифицируются внутри цикла. Поэтому эти считывания будут осуществляться из постоянных регистров, но не в одном триплете. Регистры EAX, ECX и флаги модифицируются внутри цикла и считываются до того, как они записываются обратно, поэтому чтения постоянных регистров не будет. То есть можно сделать заключение, что задержек чтения регистров нет.

Выполнение: порт 0 or 1: 2 моп порт 1: 1 моп порт 2: 1 моп порт 3: 1 моп порт 4: 1 моп Время выполнения: 1.5 такта.

Вывод из обращения: 6 мопов = 2 такта.

Заключение: этот цикл занимает только два такта на итерацию.

Если вы используете абсолютные адреса вместо ESI и EDI, тогда цикл будет занимать 3 такта, потому что он не сможет уместиться в один 16-ти байтный блок.

Разворачивание цикла (PPro, PII и PIII)

Осуществление более чем одной операции за проход, а соответственно, и меньшего количества проходов называется разворачиванием циклов. В предыдущих процессорах вам следовало развернуть циклы, чтобы разворачивать циклы, чтобы добиться лучшего спаривания (глава 25.1). В PPro, PII и PIII это не требуется, потому что об этом заботится механизм выполнения инструкций не по порядку. Нет никакой надобности использовать два разных регистра, потому что этим занимается переименование регистров. Целью разворачивания является снижение расходов на управление циклом при каждом повторении.

Следующий пример по смыслу тот же, что и пример в 2.2, но развернутый в два раза, что означает выполнение двух операций за раз, и меньшее в два раза количество проходов.

Пример 2.4:

        MOV     ECX, [N]
        MOV     ESI, [A]
        MOV     EDI, [B]
        SHR     ECX, 1           ; N/2
        JNC     SHORT L1         ; тестируем N на нечетность
        MOV     EAX, [ESI]       ; делаем нечетный раз
        ADD     ESI, 4
        NEG     EAX
        MOV     [EDI], EAX

        ADD     EDI, 4
L1:     JECXZ   L3

ALIGN   16
L2:     MOV     EAX, [ESI]       ; len=2, p2rESIwEAX
        NEG     EAX              ; len=2, p01rwEAXwF
        MOV     [EDI], EAX       ; len=2, p4rEAX, p3rEDI
        MOV     EAX, [ESI+4]     ; len=3, p2rESIwEAX
        NEG     EAX              ; len=2, p01rwEAXwF
        MOV     [EDI+4], EAX     ; len=3, p4rEAX, p3rEDI
        ADD     ESI, 8           ; len=3, p01rwESIwF
        ADD     EDI, 8           ; len=3, p01rwEDIwF

        DEC     ECX              ; len=1, p01rwECXwF
        JNZ     L2               ; len=2, p1rF
L3:

В примере 2.2 инструкции управления циклом (то есть изменение значений указателей и счетчика, а также переход назад) занимал 4 мопа, а 'реальная работа' занимала 4 мопа. При разворачивании цикла в два раза

Анализируя доставку инструкций в этом цикле мы увидим, что новый БДИ начинается с инструкции 'ADD ESI, 8', следовательно она пойдет в декодер D0. Поэтому цикл раскодируется за 5 тактов, а не за 4, как нам хотелось. Мы можем решить эту проблему, заменив предыдущую инструкцию на более длинную версию.

Изменить 'MOV [EDI+4],EAX' на:

    MOV [EDI+9999],EAX     ; создаем инструкцию с большим смещением
    ORG $-4
    DD 4                   ; изменяем смещение на 4

Это заставит новый БДИ начаться с длинной инструкции 'MOV [EDI+4]', поэтому время раскодировки сейчас близко к 4 тактам. Оставшаяся свободная часть конвейера может обрабатывать 3 мопа за такт, поэтому ожидаемое время выполнения равно 4 тактам или 2 тактам на операцию.

Тестирование этого решения показало, что в реальности оно занимает немного больше. Мои измерения показывают примерно 4.5 такта за итерацию. Вероятно это связано с неоптимальной перегруппировкой мопов. Возможно, ROB не смог найти оптимального порядка выполнения. Проблемы подобного рода непредсказуемы, и только тестирование может выявить их. Мы можем помочь ROB'у сделать часть перегруппировки сами:

Пример 2.5:

ALIGN   16
L2:     MOV     EAX, [ESI]       ; len=2, p2rESIwEAX
        MOV     EBX, [ESI+4]     ; len=3, p2rESIwEBX
        NEG     EAX              ; len=2, p01rwEAXwF
        MOV     [EDI], EAX       ; len=2, p4rEAX, p3rEDI
        ADD     ESI, 8           ; len=3, p01rwESIwF
        NEG     EBX              ; len=2, p01rwEBXwF
        MOV     [EDI+4], EBX     ; len=3, p4rEBX, p3rEDI
        ADD     EDI, 8           ; len=3, p01rwEDIwF
        DEC     ECX              ; len=1, p01rwECXwF

        JNZ     L2               ; len=2, p1rF
L3:

Цикл теперь выполняется в 4 тактах на итерацию. Также была решена проблема с БДИ. Это стоило нам дополнительного регистра, потому что мы не могли использовать преимущества переименования регистров.

Разворачивание более чем в 2 раза

Развертка циклов рекомендуется, когда инструкции по управлению циклами занимают значительную часть общего времени выполнения. В примере 2.3 они занимают только 2 мопа, поэтому выгода будет невелика, но тем не менее я покажу, как его развернуть, просто ради упражнения.

'Настоящая работа' равна 4 мопам, на управление циклом уходит 2 мопа. Развернув его, мы получим 2*4+3 = 10 мопов. Время вывода из обращения будет равно 10/3, округляем в сторону ближайшего целого, получается 4 такта. Эти вычисления показывают, что разворачиванием мы ничего не добьемся:

Пример 2.6:

        MOV     ECX, [N]
        SHL     ECX, 2                    ; количество, которое нужно
                                          ; обработать
        MOV     ESI, [A]
        MOV     EDI, [B]
        ADD     ESI, ECX                  ; указываем на конец массива A

        ADD     EDI, ECX                  ; указываем на конец массива B
        NEG     ECX                       ; -4*N
        TEST    ECX, 4                    ; тестируем N на нечетность
        JZ      SHORT L1
        MOV     EAX, [ESI+ECX]            ; N нечетно. Делаем нечетный раз
        NEG     EAX
        MOV     [EDI+ECX], EAX
        ADD     ECX, 4
L1:     TEST    ECX, 8                    ; Тестируем N/2 на нечетность
        JZ      SHORT L2
        MOV     EAX, [ESI+ECX]            ; N/2 нечетно. Делаем два
                                          ; дополнительных раза

        NEG     EAX
        MOV     [EDI+ECX], EAX
        MOV     EAX, [ESI+ECX+4]
        NEG     EAX
        MOV     [EDI+ECX+4], EAX
        ADD     ECX, 8
L2:     JECXZ   SHORT L4

ALIGN   16
L3:     MOV     EAX, [ESI+ECX]            ; len=3, p2rESIrECXwEAX
        NEG     EAX                       ; len=2, p01rwEAXwF
        MOV     [EDI+ECX], EAX            ; len=3, p4rEAX, p3rEDIrECX
        MOV     EAX, [ESI+ECX+4]          ; len=4, p2rESIrECXwEAX
        NEG     EAX                       ; len=2, p01rwEAXwF

        MOV     [EDI+ECX+4], EAX          ; len=4, p4rEAX, p3rEDIrECX
        MOV     EAX, [ESI+ECX+8]          ; len=4, p2rESIrECXwEAX
        MOV     EBX, [ESI+ECX+12]         ; len=4, p2rESIrECXwEAX
        NEG     EAX                       ; len=2, p01rwEAXwF
        MOV     [EDI+ECX+8], EAX          ; len=4, p4rEAX, p3rEDIrECX
        NEG     EBX                       ; len=2, p01rwEAXwF
        MOV     [EDI+ECX+12], EBX         ; len=4, p4rEAX, p3rEDIrECX
        ADD     ECX, 16                   ; len=3, p01rwECXwF

        JS      L3                        ; len=2, p1rF
L4:

БДИ распределяются так, как нам нужно. Время раскодировки равно 6 тактам.

Задержки чтения регистров является здесь проблемой, так как ECX выводится из обращения в конце цикла, а нам нужно читать ESI, EDI и ECX. Инструкции были перегруппированы так, чтобы избежать чтения ESI рядом с концом цикла во избежания задержки чтения регистра. Другими словами причина перегруппировки инструкций и использования дополнительного регистра не та, что в предыдущем примере.

Здесь 12 мопов и цикл выполняется за 6 тактов на итерацию или 1.5 тактов на операцию.

Вы можете прикинуть возможность более чем двух операций за одно повторение, чтобы снизить количество действий, необходимых для организации цикла. Но так как в большинстве случаев время, требуемое для выполнения таких действий, можно снизить только на один такт, то разворачивание цикла в четыре раза, а не в два, сохранит только 1/4 такта на операцию, что вряд ли стоит усилий, которые будут на это потрачены. Только если можно сохранить еще больше, и если N очень велико, вам стоит думать о разворачивании цикла в четыре раза.

Недостатки слишком большого разворачивания цикла следующие:

  • Вам будет необходимо посчитать N MODULO R, где R - это коэффициент разворачивания, и сделать N MODULO R операций до или после основного цикла, чтобы выполнить недостающее количество операций. На это уйдет дополнительный код и плохо предсказуемые операции условного перехода. И, конечно, тело цикла станет больше.
  • Кусок кода обычно выполняется в первый раз гораздо дольше, и чем больше ваш код, тем больше будут связанные с этим потери, особенно, если N невелико.
  • Значительное увеличение кода делает работу с кэшем менее эффективной.

Использование коэффициента разворачивания, не являющегося степенью 2 делает вычисление N MODULO R довольно трудным, и как правило не рекомендуется, если только N не кратно R. Пример 1.14 показывает, как разворачивать в 3 раза.

Обработка нескольких 8-ми или 16-ти битных операндов одновременно в 32-х битных регистрах (PPro, PII или PIII).

Иногда возможно обрабатывать четыре байта за раз в том же 32-х битном регистре. Следующий пример добавляет 2 ко всем элементам массива байтов.

Пример 2.7:

        MOV     ESI, [A]         ; адрес массива байтов
        MOV     ECX, [N]         ; количество элементов в массиве байтов
        JECXZ   L2
ALIGN   16
        DB   7  DUP (90H)        ; 7 NOP'ов выравнивания

 L1:    MOV     EAX, [ESI]       ; читаем четыре байта
        MOV     EBX, EAX         ; копируем в EBX
        AND     EAX, 7F7F7F7FH   ; получаем 7 нижних бит каждого байта
        XOR     EBX, EAX         ; получаем наивысший бит каждого байта

        ADD     EAX, 02020202H   ; добавляем желаемое значение ко всем байтам
        XOR     EBX, EAX         ; снова комбинируем биты
        MOV     [ESI], EBX       ; сохраняем результат
        ADD     ESI, 4           ; увеличиваем значение указателя
        SUB     ECX, 4           ; понижаем значение счетчика
        JA      L1               ; цикл
L2:

Обратите внимание, что перед прибавлением я "спрятал" наивысший бит каждого байта, чтобы не испортить их значение (если результат сложения для конкретного байта превысит 256). Я использую XOR, а не ADD, когда помещаю последний бит на место по той же самой причине.

Этот цикл в идеальном случае занимает 4 такта на итерацию, но в реальном случае он может занять немного больше из-за цепочки зависимости и трудностей с перегруппировкой. На PII и PIII вы можете это делать еще более эффективно, используя регистры MMX.

Следующий пример находит длину строки, заканчивающейся нулем. Он гораздо быстрее, чем REPNE SCASB:

Пример 2.8:

_strlen PROC    NEAR
        PUSH    EBX
        MOV     EAX,[ESP+8]            ; получаем указатель на строку

        LEA     EDX,[EAX+3]            ; указатель+3 используется в конце
L1:     MOV     EBX,[EAX]              ; читаем первые 4 байта
        ADD     EAX,4                  ; повышаем значение указателя
        LEA     ECX,[EBX-01010101H]    ; вычитаем 1 из каждого байта
        NOT     EBX                    ; инвертируем все байты
        AND     ECX,EBX                ; и эти два
        AND     ECX,80808080H          ; тестируем все биты
        JZ      L1                     ; нет нулевых байтов, продолжаем цикл

        MOV     EBX,ECX
        SHR     EBX,16
        TEST    ECX,00008080H          ; тестируем первые два байта
        CMOVZ   ECX,EBX                ; сдвигаем вправо, если не впервых двух
                                       ; байтах
        LEA     EBX,[EAX+2]
        CMOVZ   EAX,EBX
        SHL     CL,1                   ; используем флаг переноса, чтобы избежать
                                       ; ветвления
        SBB     EAX,EDX                ; высчитываем длину
        POP     EBX
        RET
_strlen ENDP

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

Обработка 4-х байт за раз может быть довольно сложной. Код использует формулу, которая генерирует ненулевое значение для байт только тогда, когда байт равен нулю. Это делает возможным протестировать все четыре байта за одну операцию. Этот алгоритм включает вычитание 1 из всех байтов (в инструкции LEA). Я не стал "прятать" наивысший бит перед вычитанием, как я сделал это в предыдущем примере, поэтому операция вычитания может испортить значение следующего байта в EAX, но только если текущий равен нулю, и нам не важно, что будет со следующим байтом. Если бы мы искали нулевой байт в обратном порядке, нам бы пришлось считать двойное слово повторно после обнаружения нуля, а затем протестировать все четыре байта, чтобы найти последний ноль, или использовать BSWAP для изменения порядка байтов.

Если вы хотите найти байт с другим, ненулевым значением, тогда вы можете XOR'ить все четыре байта со значением, которое вы ищете, а затем используйте метод выше для поиска нуля.

Циклы с инструкциями MMX (PII и PIII)

С помощью инструкций MMX мы можем сравнивать 8 байтов за одну операцию:

Пример 2.9:

_strlen PROC    NEAR
        PUSH    EBX
        MOV     EAX,[ESP+8]
        LEA     EDX,[EAX+7]
        PXOR    MM0,MM0
L1:     MOVQ    MM1,[EAX]        ; len=3 p2rEAXwMM1
        ADD     EAX,8            ; len=3 p01rEAX
        PCMPEQB MM1,MM0          ; len=3 p01rMM0rMM1
        MOVD    EBX,MM1          ; len=3 p01rMM1wEBX

        PSRLQ   MM1,32           ; len=4 p1rMM1
        MOVD    ECX,MM1          ; len=3 p01rMM1wECX
        OR      ECX,EBX          ; len=2 p01rECXrEBXwF
        JZ      L1               ; len=2 p1rF
        MOVD    ECX,MM1
        TEST    EBX,EBX
        CMOVZ   EBX,ECX
        LEA     ECX,[EAX+4]
        CMOVZ   EAX,ECX
        MOV     ECX,EBX
        SHR     ECX,16
        TEST    BX,BX
        CMOVZ   EBX,ECX
        LEA     ECX,[EAX+2]
        CMOVZ   EAX,ECX

        SHR     BL,1
        SBB     EAX,EDX
        EMMS
        POP     EBX
        RET
_strlen ENDP

В этом цикле 7 мопов для порта 0 и 1, что дает среднее время выполнения 3.5 такта на итерацию. Тесты показали 3.8 тактов, что говорит о том, что ROB справляется с ситуацией достаточно хорошо, несмотря на цепочку зависимости, которая равна 6 мопам. Тестирование 8 байтов меньше, чем за 4 такта гораздо быстрее, чем REPNE SCASB.

Циклы с плавающими инструкциями (PPro, PII и PIII)

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

Предположите код на языке C:

  int i, n;  double * X;  double * Y;  double DA;
  for (i=0; i<n; i++)  Y[i] = Y[i] - DA * X[i];

Этот кусок кода (под названием DAXPY) является ключом к решению линейных уравнений.

Пример 2.10:

DSIZE   = 8                      ; размер данных (4 or 8)
        MOV     ECX, [N]         ; количество элементов
        MOV     ESI, [X]         ; указатель на X
        MOV     EDI, [Y]         ; указатель на Y
        JECXZ   L2               ; проверяем, равняется ли N нулю
        FLD     DSIZE PTR [DA]   ; загружаем DA вне цикла
ALIGN   16
        DB    2 DUP (90H)        ; 2 NOP'а для выравнивания
L1:     FLD     DSIZE PTR [ESI]  ; len=3 p2rESIwST0
        ADD     ESI,DSIZE        ; len=3 p01rESI

        FMUL    ST,ST(1)         ; len=2 p0rST0rST1
        FSUBR   DSIZE PTR [EDI]  ; len=3 p2rEDI, p0rST0
        FSTP    DSIZE PTR [EDI]  ; len=3 p4rST0, p3rEDI
        ADD     EDI,DSIZE        ; len=3 p01rEDI
        DEC     ECX              ; len=1 p01rECXwF
        JNZ     L1               ; len=2 p1rF
        FSTP    ST               ; сбрасываем DA
L2:

Цепочка зависимости длиной в 10 тактов, но цикл занимает только 4 такта на итерацию, потому что он может начать новую операцию еще до того, как выполнена предыдущая. Цель выравнивания - предотвратить 16-ти байтную границу в последнем БДИ.

Пример 2.11:

DSIZE   = 8                                ; размер данных (4 или 8)
        MOV     ECX, [N]                   ; количество элементов
        MOV     ESI, [X]                   ; указатель на X
        MOV     EDI, [Y]                   ; указатель на Y
        LEA     ESI, [ESI+DSIZE*ECX]       ; указатель на конец массива
        LEA     EDI, [EDI+DSIZE*ECX]       ; указатель на конец массива
        NEG     ECX                        ; -N
        JZ      SHORT L2                   ; проверяем, равняется ли N нулю

        FLD     DSIZE PTR [DA]             ; загружаем DA вне цикла
ALIGN   16
L1:     FLD     DSIZE PTR [ESI+DSIZE*ECX]  ; len=3 p2rESIrECXwST0
        FMUL    ST,ST(1)                   ; len=2 p0rST0rST1
        FSUBR   DSIZE PTR [EDI+DSIZE*ECX]  ; len=3 p2rEDIrECX, p0rST0
        FSTP    DSIZE PTR [EDI+DSIZE*ECX]  ; len=3 p4rST0, p3rEDIrECX
        INC     ECX                        ; len=1 p01rECXwF
        JNZ     L1                         ; len=2 p1rF
        FSTP    ST                         ; сбрасываем DA

L2:

Здесь мы используем тот же самый трюк, что и в примере 2.3. В идеальном случае, этот цикл будет занимать 3 такта, но измерения говорят примерно о 3.5 в виду длинной цепочки зависимости. Разворачивание цикла сэкономило немного.

Циклы с инструкциями XMM (PIII)

Инструкции XMM на PIII позволят вам оперировать четырьмя числами с плавающей запятой одинарной точности одновременно. Операнды должны быть выравнены на 16.

Алгоритм DAXPY не очень подходит для инструкций XMM, потому что точность невелика, может не быть возможности выравнять операнды на 16, поэтому вам потребуется дополнительный код, если количество операций не кратно четырем. Тем не менее, я покажу вам код в качестве примера цикла с инструкциями XMM:

Пример 2.12:

        MOV     ECX, [N]                   ; количество элементов
        MOV     ESI, [X]                   ; указатель на X
        MOV     EDI, [Y]                   ; указатель на Y
        SHL     ECX, 2
        ADD     ESI, ECX                   ; указывает на конец X
        ADD     EDI, ECX                   ; указывает на конец Y
        NEG     ECX                        ; -4*N
        MOV     EAX, [DA]                  ; загружаем DA вне цикла
        XOR     EAX, 80000000H             ; меняем знак DA

        PUSH    EAX
        MOVSS   XMM1, [ESP]                ; -DA
        ADD     ESP, 4
        SHUFPS  XMM1, XMM1, 0              ; копируем -DA во все четыре
                                           ; позиции
        CMP     ECX, -16
        JG      L2
L1:     MOVAPS  XMM0, [ESI+ECX]            ; len=4 2*p2rESIrECXwXMM0
        ADD     ECX, 16                    ; len=3 p01rwECXwF
        MULPS   XMM0, XMM1                 ; len=3 2*p0rXMM0rXMM1
        CMP     ECX, -16                   ; len=3 p01rECXwF

        ADDPS   XMM0, [EDI+ECX-16]         ; len=5 2*p2rEDIrECX, 2*p1rXMM0
        MOVAPS  [EDI+ECX-16], XMM0         ; len=5 2*p4rXMM0, 2*p3rEDIrECX
        JNG     L1                         ; len=2 p1rF
L2:     JECXZ   L4                         ; check if finished
        MOVAPS  XMM0, [ESI+ECX]            ; 1-3 операции пропущены, делаем
                                           ; еще четыре
        MULPS   XMM0, XMM1
        ADDPS   XMM0, [EDI+ECX]
        CMP     ECX, -8
        JG      L3
        MOVLPS  [EDI+ECX], XMM0            ; сохраняем еще два результата

        ADD     ECX, 8
        MOVHLPS XMM0, XMM0
L3:     JECXZ   L4
        MOVSS   [EDI+ECX], XMM0            ; сохраняем еще один результат
L4:

Цикл L1 занимает 5-6 тактов на 4 операции. Инструкции с ECX были помещены до и после 'MULPS XMM0, XMM1', чтобы избежать задержки чтения регистра, которую бы сгенерировало чтение двух частей регистра XMM1 вместе с ESI и EDI в RAT. Дополнительный код после L2 заботится о ситуации, когда N не делится на 4. Обратите внимание, что этот код может прочитать несколько байтов после конца A и B. Это может задержать последнюю операцию, если в этих байтах не находились нормальные числа с плавающей запятой. Если возможно, поместите в массив какие-нибудь дополнительные данные, чтобы сделать количество операций кратным 4 и избавиться от лишнего кода после L2.


26. Проблемные инструкции

26.1 XCHG (все процессоры)

Инструкция 'XCHG регистр, [память]' опасна. По умолчанию эта инструкция имеет неявный префикс LOCK, что не дает ей загружаться в кэш. Поэтому выполнение данной инструкции отнимает очень много времени, и ее следует избегать.

26.2 Вращение через флаг переноса (все процессоры)

RCR и RCL, сдвигающие более, чем один бит, медленны, и их следует избегать.

26.3 Строковые инструкции (все процессоры)

Строковые инструкции без префикса повторения слишком медленны, и их следует заменить более простыми инструкциями. То же самое относится к LOOP на всех процессорах и к JECXZ на PPlain и PMMX.

REP MOVSD и REP STOSD довольно быстры, если число повторений не слишком мало. Всегда используйте версию DWORD, где это возможно, и убедитесь, что источник и приемник выравнены на 8.

Некоторые другие методы перемещения данных быстрее в определенных условиях. Подробнее смотрите главу 27.8.

Обратите внимание, что пока инструкция REP MOVS записывает слово в приемник, она считывает следующее слово из источника в том же такте. У вас может конфликт банков кэша, если биты 2-4 у этих двух адресов одни и те же. Другими словами, у вас будут неизбежные потери в один такт на итерацию, если ESI+(размер слова)-EDI кратно 32. Самый простой путь избежать конфликтов банков кэша - это использовать версию DWORD и выравнивать источник и приемник на 8. Никогда не используйте MOVSB или MOVSW в оптимизированном коде, даже в 16-ти битном.

REP MOVS и REP STOS могут выполняться очень быстро, если перемещать целую линию кэша за раз на PPro, PII и PIII:

  • источник и приемник должны быть выравнены на 8
  • должно быть задано направление вперед (очищен флаг направления)
  • счетчик (ECX) должен иметь значение равное или большее 64
  • разница между EDI и ESI должна быть численно больше или равна 32

При этих условиях количество мопов будет примерно равно 215+2*ECX для REP MOVSD и 185+1.5*ECX для REP STOSD, что дает примерную скорость в 5 байтов в такт для обоих инструкций, что в три раза больше, если какое-нибудь из вышеприведенных условий не будет соблюдено.

Версии этой инструкции для байтов и слов также выигрывают от соблюдений данных условий, но они менее эффективны, чем версии для двойных слов.

REP STOSD оптимальна при тех же условиях, что и REP MOVSD

REP LOADS, REP SCAS и REP CMPS не оптимальны, и их можно заменить на циклы. Смотри пример 1.10, 2.8 и 2.9 для поиска альтернатив REPNE SCASB. REP CMPS может вызвать конфликт баноков кэша, если биты 2-4 одинаковы в ESI и EDI.

26.4 Тестирование битов (все процессоры)

Инструкции BT, BTC, BTR и BTS следует заменять инструкциями типа TEST, AND, OR, XOR или сдвигами на PPlain и PMMX. На PPro, PII и PIII битовых тестов операнда в памяти следует избегать.

26.5 Целочисленное умножение (все процессоры)

Целочисленное умножение занимает до 9 тактов на PPlain и PMMX и до 4 тактов на PPro, PII и PIII. Поэтому часто выгоднее бывает заменить умножение на константу и комбинацию других инструкций, таких как SHL, ADD, SUB и LEA.

Пример:

IMUL EAX,10

можно заменить на

MOV EBX,EAX / ADD EAX,EAX / SHL EBX,3 / ADD EAX,EBX

или

LEA EAX,[EAX+4*EAX] / ADD EAX,EAX

Умножение чисел с плавающей запятой быстрее, чем целочисленное умножение на PPlain и PMMX, но время, затрачиваемое на преобразование целых чисел в числа с плавающей запятой и обратную конвертацию полученного результата, обычно больше, чем время, сэкономленное в результате использования умножения с плавающей запятой, не считая тех случаев, когда количество конвертаций несравнимо с количеством умножений. Умножение MMX достаточно быстро, но доступно только для 16-ти битных операндов.

26.6 Инструкция WAIT (все процессоры)

Зачастую вы сможете повысить скорость, пренебрегнув инструкцией WAIT. Эта инструкция имеет три функции:

a. Старый процессор 8087 требовали инструкцию WAIT перед каждой инструкцией с плавающей запятой, чтобы убедиться, что сопроцессор готов ее получить.

b. WAIT используется для координирования доступа памяти между модулем вычислений плавающей запятой и модулем целочисленных вычислений.

Примеры:

b.1.  FISTP [mem32]
      WAIT             ; ждем, пока FPU запишет в память, а потом..
      MOV EAX, [mem32] ; считываем результат модулем целочисленных вычислений

b.2.  FILD [mem32]
      WAIT             ; ждем, пока FPU считает значение из памяти..
      MOV [mem32],EAX  ; перед ее перезаписью целым числом

b.3.  FLD QWORD PTR [ESP]
      WAIT             ; предотвращаем случайную ошибку от..
      ADD ESP,8        ; перезаписи значения в стеке

c. WAIT иногда используется, чтобы следить за исключениями. Он сгенерирует прерывание, если бит исключения в слове статуса FPU был установлен предыдущей операцией плавающей запятой.

Относительно a:

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

Относительно b:

Инструкции WAIT для координации доступа к памяти были действительно нужны на 8087 и 80287, но на Pentium'ах она в этом качестве совершенно не обязательна. Что касается 80386 и 80486, тут ситуация не совсем ясна. Я сделал несколько тестов на этих интеловских процессорах и не смог спровоцировать ни одной ошибки, пропустив WAIT, на любом из 32-х битных интеловских процессоров, хотя руководства от Intel говорят, что WAIT необходима для этой цели, не считая инструкций FNSTSW и FNSTCW. Пропус инструкций WAIT для координирования доступа к памяти не 100% надежно даже при написании 32-х битного кода, потому что код может быть выполнен на очень редкой комбинации 80386 процессора с 287 сопроцессором, который требует WAIT. Также у меня нет информации о неинтеловских процессорах, и я не тестировал все возможные комбинации железа и программного обеспечения, поэтому могут быть ситуации, когда WAIT окажется нужен.

Если вы хотите быть уверены, что ваш коду будет работать на любом 32-х битном процессоре (включая неинтеловские процессоры), я рекомендую вам использовать WAIT в этом качестве на всякий случай.

Относительно c:

Ассемблер автоматически вставляет WAIT для этих целей перед следующими инструкциями: FCLEX, FINIT, FSAVE, FSTCW, FSTENV, FSTSW. Вы можете пропустить WAIT, написав FNCLEX и т.п. Мои тесты показывают, что в большинстве случаев WAIT не нужен, потому что эти инструкции без WAIT все равно будут генерировать прерывания или исключения, кроме FNCLEX и FNINIT на 80387. (Есть некоторая неопределенность, касаемая того, указывает ли IRET от прерывания на инструкцию FN.. или на следующую инструкцию).

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

Вам все еще может понадобиться WAIT, если нужно точно знать, где случается исключение, чтобы проконтролировать ситуацию. Возьмем, например, код из b.3: если вы хотите проконтролировать исключение, которое сгенерирует в подобной ситуации FLD, вам нужен WAIT, потому что прерывание после 'ADD ESP,8' перезапишет значение, которое надо загрузить. FNOP будет быстрее, чем WAIT, и предназначается для той же цели.

26.7 FCOM + FSTSW AX (все процессоры)

Инструкция FNSTSW очень медленна на любых процессорах. У процессоров PPro, PII и PIII есть инструкции FCOMI, чтобы избежать этой инструкции. Использование FCOMI вместо обычной последовательности 'FCOM / FNSTSW AX / SAHF' сэкономит вам 8 тактов. Поэтому вам следует использовать FCOMI, чтобы избегать FNSTSW везде, где это возможно, даже если это будет стоить дополнительного кода.

На процессорах без инструкции FCOMI обычной практикой сравнения значений с плавающей запятой является:

    FLD [a]
    FCOMP [b]
    FSTSW AX
    SAHF
    JB ASmallerThanB

Вы можете улучшить этот код, использовал FNSTSW AX вместо FSTSW AX и протестировав AH напрямую, а не используя неспариваемый SAHF (у TASM 3.0 есть баг, связанный с инструкцией FNSTSW AX):

    FLD [a]
    FCOMP [b]
    FNSTSW AX
    SHR AH,1
    JC ASmallerThanB

Тестирование на ноль или равенство:

    FTST
    FNSTSW AX
    AND AH,40H
    JNZ IsZero     ; (флаг нуля инвертирован!)

Проверка, больше ли одно значение другого:

    FLD [a]
    FCOMP [b]
    FNSTSW AX
    AND AH,41H
    JZ AGreaterThanB

Не используйте 'TEST AH,41H', так как он не спаривается на PPLain и PMMX.

На PPlain и PMMX инструкция FNSTSW занимает 2 такта, но она вызывает задержку в дополнительные 4 такта после любой инструкции с плавающей запятой, потому что она ждет слово статуса FPU. Этого не происходит после целочисленных инструкций. Вы можете заполнить промежуток между FCOM и FNSTSW целочисленными инструкциями на 4 такта. Спаренный FXCH сразу после FCOM не задерживает FNSTSW, даже если спаривание несовершенно:

    FCOM                  ; такт 1
    FXCH                  ; такты 1-2 (несовершенное спаривание)
    INC DWORD PTR [EBX]   ; такты 3-5
    FNSTSW AX             ; такты 6-7

Вы можете здесь использовать FCOM вместо FTST, потому что FTST не спаривается. Не забудьте включить N в FNSTSW. У FSTSW (без N) префикс WAIT, который задержит ее в дальнейшем.

Иногда быстрее использовать целочисленные инструкции для сравнения значений с плавающей запятой, как это объяснено в главе 27.6.

26.8 FPREM (все процессоры)

Инструкции FPREM и FPREM1 медленны на всех процессорах. Вы можете заменить их следующим алгоритмом: умножьте на противоположный делитель, получайте дробную часть, получите дробную часть, вычитая усеченное значение, затем умножьте на делитель (смотрите главу 27.5, чтобы узнать, как усекать значения).

Некоторые документы говорят, что эти инструкции могут давать неполную редукцию, и поэтому необходимо повторять инструкции FPREM и FPREM1, пока она не будет сделана. Я протестировал это на нескольких процессорах, начиная со старого 8087, и у меня не было ни одной ситуации, когда потребовалось бы повторение FPREM или FPREM1.

26.9 FRNDINT (все процессоры)

Эта инструкция медленна на всех процессорах. Замените ее следующим:

    FISTP QWORD PTR [TEMP]
    FILD  QWORD PTR [TEMP]

Этот код быстрее, несмотря на возможные потери из-за попытки считать [TEMP], когда запись еще не окончена. Рекомендуется поместить какие-нибудь другие инструкции.

26.10 FSCALE и экпоненциальная функция (все процессоры)

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

Для |N| < 27-1 вы можете использовать одинарную точность:

    MOV     EAX, [N]
    SHL     EAX, 23
    ADD     EAX, 3F800000H
    MOV     DWORD PTR [TEMP], EAX
    FLD     DWORD PTR [TEMP]

Для |N| < 210-1 вы можете использовать двойную точность:

    MOV     EAX, [N]
    SHL     EAX, 20
    ADD     EAX, 3FF00000H
    MOV     DWORD PTR [TEMP], 0
    MOV     DWORD PTR [TEMP+4], EAX
    FLD     QWORD PTR [TEMP]

Для |N| < 214-1 используйте длинную двойную точность:

    MOV     EAX, [N]
    ADD     EAX, 00003FFFH
    MOV     DWORD PTR [TEMP],   0
    MOV     DWORD PTR [TEMP+4], 80000000H
    MOV     DWORD PTR [TEMP+8], EAX
    FLD     TBYTE PTR [TEMP]

FSCALE часто используется в вычислениях экспоненциальных функций. Следующий код показывает экспоненциальную функцию без медленных FRNDINT и FSCALE:

; extern "C" long double _cdecl exp (double x);
_exp    PROC    NEAR
PUBLIC  _exp
        FLDL2E
        FLD     QWORD PTR [ESP+4]             ; x
        FMUL                                  ; z = x*log2(e)
        FIST    DWORD PTR [ESP+4]             ; round(z)
        SUB     ESP, 12

        MOV     DWORD PTR [ESP], 0
        MOV     DWORD PTR [ESP+4], 80000000H
        FISUB   DWORD PTR [ESP+16]            ; z - round(z)
        MOV     EAX, [ESP+16]
        ADD     EAX,3FFFH
        MOV     [ESP+8],EAX
        JLE     SHORT UNDERFLOW
        CMP     EAX,8000H
        JGE     SHORT OVERFLOW
        F2XM1
        FLD1
        FADD                                  ; 2^(z-round(z))
        FLD     TBYTE PTR [ESP]               ; 2^(round(z))

        ADD     ESP,12
        FMUL                                  ; 2^z = e^x
        RET

UNDERFLOW:
        FSTP    ST
        FLDZ                                  ; return 0
        ADD     ESP,12
        RET

OVERFLOW:
        PUSH    07F800000H                    ; +infinity
        FSTP    ST
        FLD     DWORD PTR [ESP]               ; return infinity
        ADD     ESP,16
        RET

_exp    ENDP

26.11 FPTAN (все процессоры)

Согласно руководствам, FPTAN возвращает два значения X и Y и оставляет на программиста деление Y на X для получения окончательного результата, но фактически она всегда возвращает в X 1, поэтому вы можете сэкономить на делении. Мои тесты показывают, что на всех 32-х битные интеловские процессоры с модулем плавающей запятой или сопроцессором, FPTAN всегда возвращает 1 в X независимо от аргумента. Если вы хотите быть абсолютно уверены, что ваш код будет выполняться корректно на всех процессорах, тогда вы можете протестировать, равен ли X одному, что быстрее, чем деление на X. Значение Y может быть очень высоко, но не бесконечно, поэтому вам не надо тестировать, содержит ли Y правильное число, если вы знаете, что аргумент верен.

26.12 FSQRT (PIII)

Быстрый способ вычислить приблизительное значение квадратного корня на PIII - это умножить обратный корень от x на сам x:

SQRT(x) = x * RSQRT(x)

Инструкция RSQRTSS или RSQRTPS дает обратный корень с точностью 12 бит. Вы можете улучшить точность до 23 бит, используя формулу Ньютона-Рафсона, использованную в интеловской сопроводительной заметке AP-803:

x0 = RSQRTSS(a)
x1 = 0.5 * x0 * (3 - (a * x0)) * x0)

где x0 - это первое приближение к обратному корню от a, а x1 - лучшее приближение. Порядок вычисления имеет значение. Вы можете использовать эту формулу до умножения, чтобы получить квадратный корень.

26.13 MOV [MEM], ACCUM (PPlain и PMMX)

Инструкции 'MOV [mem],AL', 'MOV [mem],AX', MOV [mem],EAX расцениваются механизмом спаривания как пишущие в аккумулятор. Поэтому следующие инструкции не спариваются:

    MOV [mydata], EAX
    MOV EBX, EAX

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

В 32-х битном режиме вы можете записать основную форму 'MOV [mem],EAX' следующим образом:

    DB 89H, 05H

    DD OFFSET DS:mem

В 16-ти битном режиме вы можете записать основную форму MOV [mem],AX' так:

    DB 89H, 06H
    DW OFFSET DS:mem

Чтобы использовать AL вместо (E)AX, вам нужно заменить 89H на 88H.

Этот изъян не был исправлен в PMMX.

26.14 Инструкция TEST (PPlain и PMMX)

Инструкция TEST с числовым операндом спаривается только, если назначением являются AL, AX или EAX.

'TEST регистр,регистр' и 'TEST регистр,память' всегда спаривается.

Пример:

    TEST ECX,ECX                ; спаривается
    TEST [mem],EBX              ; спаривается
    TEST EDX,256                ; не спаривается
    TEST DWORD PTR [EBX],8000H  ; не спаривается

Чтобы сделать их спариваемыми, используйте один из следующих методов:

    MOV EAX,[EBX] / TEST EAX,8000H
    MOV EDX,[EBX] / AND  EDX,8000H
    MOV AL,[EBX+1] / TEST AL,80H
    MOV AL,[EBX+1] / TEST AL,AL  ; (результат в флаге знака)
(Причина этой неспариваемости, вероятно, состоит в том, что первый байт двухбайтной инструкции та же самая, что и для неспариваемых инструкций, и процессор не может проверить второй байт во время проверки спариваемости.)

26.15 Битовое сканирование (PPlain и PMMX)

BSF и BSR - хуже всего оптимизированные инструкции на PPlain и PMMX, которые занимают приблизительно 11+2*n тактов, где n равен количеству пропущенных нулей.

Следующий код эмулирует BSR ECX,EAX:

        TEST    EAX,EAX
        JZ      SHORT BS1
        MOV     DWORD PTR [TEMP],EAX
        MOV     DWORD PTR [TEMP+4],0
        FILD    QWORD PTR [TEMP]
        FSTP    QWORD PTR [TEMP]
        WAIT    ; WAIT требуется только для совместимости со старым 286
                ; процессором

        MOV     ECX, DWORD PTR [TEMP+4]
        SHR     ECX,20        ; изолируем экспоненту
        SUB     ECX,3FFH      ; снижаем значение
        TEST    EAX,EAX       ; очищаем флаг нуля
BS1:

Следующий код эмулирует BSF ECX,EAX:

        TEST    EAX,EAX
        JZ      SHORT BS2
        XOR     ECX,ECX
        MOV     DWORD PTR [TEMP+4],ECX
        SUB     ECX,EAX
        AND     EAX,ECX
        MOV     DWORD PTR [TEMP],EAX
        FILD    QWORD PTR [TEMP]

        FSTP    QWORD PTR [TEMP]
        WAIT    ; WAIT требуется только для совместимости со старым 286
                ; процессором

        MOV     ECX, DWORD PTR [TEMP+4]
        SHR     ECX,20
        SUB     ECX,3FFH
        TEST    EAX,EAX       ; очищаем флаг нуля
BS2:

Этот код эмуляции не следует использовать PPro, PII и PIII, на которых инструкции битового сканирования занимают только 1 или 2 такта, и где данный код вызовет около двух задежек чтения памяти.

26.16 FLDCW (PPro, PII и PIII)

На PPro, PII и PIII инструкция FLDCW вызывает серьезную задержку, если за ней следует любая инструкция плавающей запятой, считывающая контрольное слово (как делают практически все инструкции плавающей запятой).

Компиляторы C или C++ часто генерируют множество инструкций FLDCW, потому что конвертация чисел с плавающей запятой в целые числа делается с помощью усечения, в то время как другие инструкции плавающей запятой используют округления. После перевода на ассемблер, вы можете улучшить код, использовав округление вместо усечения, где это возможно, или убрав FLDCW из цикла, если требуется усечение внутри него.

Смотрите главу 27.5, чтобы узнать, как сконвертировать число с плавающей запятой в целое без изменения контрольного слова.


27. Специальные темы

27.1 Инструкция LEA (все процессоры)

Инструкция LEA полезна для самых разных целей, потому что она умеет делать сдвиг, два сложения и перемещение за один такт.

Пример:

LEA EAX,[EBX+8*ECX-1000]

Гораздо быстрее, чем

MOV EAX,ECX / SHL EAX,3 / ADD EAX,EBX / SUB EAX,1000

Инструкцию LEA можно использовать, чтобы делать сложение или сдвиг без изменения флагов. Источник и назначение не обязательно должны быть размером в слово, поэтому 'LEA EAX,[BX]' может стать возможной заменой для 'MOVZX EAX,BX', хотя на многих процессорах это не совсем оптимально.

Как бы то ни было, вам следует знать, что инструкция LEA вызывает задержку AGI на PPlain и PMMX, если она использует базовый или индексный регистр, в которой была произведена запись в предыдущем такте.

Так как инструкция LEA спариваема в V-конвейер на PPlain и PMMX, а инструкции сдвига - нет, вы можете использовать LEA в качестве замены SHL на 1, 2 или 3, если вы хотите, чтобы инструкция выполнялась в V-конвейере.

У 32-х битных конвейеров нет документированного режима адресации только с индексным регистром, поэтому инструкция LEA EAX,[EAX*2] на самом деле записывается как 'LEA EAX,[EAX*2+00000000] с 4-х байтовым смещением. Вы можете снизить размер инструкции, написав 'LEA EAX,[EAX+EAX]' или, что еще лучше, 'ADD EAX,EAX'. Последний вариант не приведет к задержке AGI на PPlain и PMMX. Если случилось так, что у вас есть регистр, равный нулю (например, счетчик цикла после последнего прохода), вы можете использовать его как базовый регистр, чтобы снизить размер кода:

LEA EAX,[EBX*4]     ; 7 байтов
LEA EAX,[ECX+EBX*4] ; 3 байтов

27.2 Деление (все процессоры)

Деление отнимает очень много времени. На PPro, PII и PIII целочисленное деление занимает 19, 23 или 39 для байта, слова и двойного слова соответственно. На PPlain и PMMX беззнаковое целочисленное деление занимает приблизительно то же время, хотя деление со знаком занимает немного больше. Поэтому более предпочтительно использовать операнды маленького размера, которые не вызовут переполнения, даже если это будет стоить префикса размера операнда, и использовать по возможности беззнаковое деление.

Целочисленное деление на константу (все процессоры)

Целочисленное деление на степень от двух можно сделать, сдвигая значение вправо. Деление беззнакового целого числа на 2N:

        SHR     EAX, N

Деление целого числа со знаком на 2N:

        CDQ
        AND     EDX, (1 SHL N) -1  ; или SHR EDX, 32-N
        ADD     EAX, EDX
        SAR     EAX, N

Альтернативный SHR короче, чем 'AND if N > 7, но может попасть только в порт 0 (или U-конвейер), в то время как AND может попасть как в порт 0, так и в порт 1 (U- или V-конвейер).

Деление на константу можно сделать на обратное число. Чтобы произвести беззнаковое целочисленное деление q = x / d, вам вначале нужно посчитать число, обратное делителю, f = 2r / d, где r определяет позицию двоично-десятичной точки (точка основания системы счисления). Затем нужно умножить x на f и сдвинуть полученный результат на r позиций вправо. Максимальное значение r равно 32+b, где b равно числу двоичных цифр в d минус 1. (b - это самое большое целое число, для которого 2b <= d). Используйте r = 32+b, чтобы покрыть максимальное количество возможных значений делимого x.

Этот метод требует некоторых приемов, чтобы скомпенсировать ошибки округления. Следующий алгоритм даст вам верные результаты для деления беззнакового целого числа с усечением, то есть тот же результат, что дает инструкция DIV (спасибо Terje Mathisen, который изобрел этот метод):

  b = (количество значимых битов в d) - 1
  r = 32 + b
  f = 2r / d
  Если f - целое число, тогда d - это степень от 2: переходим к случаю A.
  Если f - не целое число, тогда проверяем, меньше ли дробная часть f 0.5.

  Если дробная часть f < 0.5: переходим к случаю B.
  Если дробная часть f > 0.5: переходим к случаю C.

  случай A: (d = 2b)
  результат = x SHR b

  случай B: (дробная часть f < 0.5)
  округляем f вниз до ближайшего целого числа
  результат = ((x+1) * f) SHR r

  случай C: (дробная часть f > 0.5)
  округляем f вверх до ближайшего целого числа
  результат = (x * f) SHR r

Пример:

Предположите, что вы хотите разделить на 5.

5 = 00000101b.
b = (количество значимых двоичных чисел) - 1 = 2
r = 32+2 = 34

f = 234 / 5 = 3435973836.8 = 0CCCCCCCC.CCC...(hexadecimal)

Дробная часть больше, чем половина: используем случай C. Округляем f вверх до 0CCCCCCCDh.

Следующий код делит EAX на 5 и возвращает результат в EDX:

        MOV     EDX,0CCCCCCCDh
        MUL     EDX
        SHR     EDX,2

После умножения EDX содержит значение, сдвинутое вправо на 32. Так как r = 34, вам нужно сдвинуть еще на 2, чтобы получить окончательный результат. Чтобы поделить на 10, вам нужно всего лишь заменить последнюю строку на 'SHR EDX,3'.

В случае B у вас будет следующее:

        INC     EAX
        MOV     EDX,f
        MUL     EDX
        SHR     EDX,b

Этот код работает для всех значений x, кроме 0FFFFFFFFH, которое дает ноль из-за переполнения в инструкции INC. Если возможно, что x = 0FFFFFFFFH, тогда замените этот код на:

        MOV     EDX,f
        ADD     EAX,1
        JC      DOVERFL
        MUL     EDX
DOVERFL:SHR     EDX,b

Если значение x ограничено, тогда вам следует использовать меньшее значение r, то есть меньшее количество цифр. Может быть несколько причин для того, чтобы сделать это:

  • вы можете установить r = 32 и избежать 'SHR EDX,b' в конце.
  • вы можете установить r = 16+b и использовать инструкции умножения, которые дают 32-х битный результат, вместо 64-х битного. Тогда можно освободить регистр EDX: IMUL EAX,0CCCDh / SHR EAX,18
  • вы можете выбрать значение r, которое будет чаще приводить к случаю C, а не B, чтобы избежать инструкции 'INC EAX'.

Максимальное значение x в этих случаях равно по крайней мере 2r-b, иногда выше. Вы должны делать систематические тесты, если хотите узнать точное максимальное значение x, при котором ваш код будет работать корректно.

Вы можете заменить медленную инструкцию умножения более быстрыми инструкциями, как это объяснено в главе 26.5.

Следующий пример делит EAX на 10 и возвращает результат в EAX. Я выбрал r=17, а не 19, потому что это дает код, который легче оптимизировать, и он покрывает такое же количество значений x. f = 217 / 10 = 3333h, случай B: q = (x+1)*3333h:

        LEA     EBX,[EAX+2*EAX+3]
        LEA     ECX,[EAX+2*EAX+3]
        SHL     EBX,4

        MOV     EAX,ECX
        SHL     ECX,8
        ADD     EAX,EBX
        SHL     EBX,8
        ADD     EAX,ECX
        ADD     EAX,EBX
        SHR     EAX,17

Проведенные тесты показывают, что этот код работает правильно для всех значений x < 10004H.

Повторяемое деление целого числа на одно и то же значение (все процессоры)

Если делитель не известен во время ассемблирования программы, но вы делите на одно и то же число несколько раз, вы тоже можете использовать данный метод. Код должен определить, с каким случаем (A, B и C) он имеет дело, и высчитать f до совершения делений.

Следующий далее код показывает как делать несколько делений на одно и то же число (беззнаковое деление с усечением). Сначала вызовите SET_DIVISOR, чтобы установить делитель и обратное ему число, затем вызовите DIVIDE_FIXED для каждого значения, которое нужно разделить на один и тот же делитель.

.data

RECIPROCAL_DIVISOR DD ?            ; округленное число, обратное делителю
CORRECTION         DD ?            ; случай A: -1, случай B: 1, случай C: 0
BSHIFT             DD ?            ; количество бит в делителе - 1

.code

SET_DIVISOR PROC NEAR              ; делитель в EAX
        PUSH    EBX
        MOV     EBX,EAX
        BSR     ECX,EAX            ; b = количество бит в делителе - 1
        MOV     EDX,1
        JZ      ERROR              ; ошибка: делитель равен нулю
        SHL     EDX,CL             ; 2^b
        MOV     [BSHIFT],ECX       ; сохраняем b
        CMP     EAX,EDX
        MOV     EAX,0
        JE      SHORT CASE_A       ; делитель - степень от 2
        DIV     EBX                ; 2^(32+b) / d

        SHR     EBX,1              ; делитель / 2
        XOR     ECX,ECX
        CMP     EDX,EBX            ; сравниваем остаток с делителем/2
        SETBE   CL                 ; 1 если случай B
        MOV     [CORRECTION],ECX   ; коррекция возможных ошибок округления
        XOR     ECX,1
        ADD     EAX,ECX            ; добавляем 1 если случай C
        MOV     [RECIPROCAL_DIVISOR],EAX ; округленное число, обратное
                                         ; делителю
        POP     EBX
        RET
CASE_A: MOV     [CORRECTION],-1    ; запоминаем, что у нас случай A

        POP     EBX
        RET
SET_DIVISOR     ENDP

DIVIDE_FIXED PROC NEAR                 ; делимое в EAX, результат в EAX
        MOV     EDX,[CORRECTION]
        MOV     ECX,[BSHIFT]
        TEST    EDX,EDX
        JS      SHORT DSHIFT           ; делитель - степень от 2
        ADD     EAX,EDX                ; коррекция возможных ошибок округления
        JC      SHORT DOVERFL          ; коррекция при переполнении
        MUL     [RECIPROCAL_DIVISOR]   ; умножаем на число, обратное делителю

        MOV     EAX,EDX
DSHIFT: SHR     EAX,CL                 ; сдвигаем на количество бит
        RET
DOVERFL:MOV     EAX,[RECIPROCAL_DIVISOR] ; делимое = 0FFFFFFFFH
        SHR     EAX,CL                 ; делаем деление с помощью сдвига
        RET
DIVIDE_FIXED    ENDP

Этот код даст тот же результат, что и инструкция DIV для 0 <= x < 232, 0 < d < 232.

Обратите внимание, что линия 'JC DOVERFL' и ее цель не нужны, если вы уверены, что x < 0FFFFFFFFH.

Если степени от 2 случаются так редко, что не стоит делать специальную оптимизацию из-за них, то вы можете убрать переход на DSHIFT и делать вместо него умножения с CORRECTION = 0 для случая A.

Если делитель меняется так часто, что процедура SET_DIVISOR нуждается в оптимизации, то вы можете заменить инструкцию BSR кодом, который приведен в главе 26.15 для процессоров PPlain и PMMX.

Деление чисел с плавающей запятой (все процессоры)

Деление чисел с плавающей запятой занимает 38 или 39 тактов при самой высокой точности. Вы можете сэкономить время, указав более низкую точность в контрольном слове (на PPlain и PMMX только FDIV и FIDIV более быстры при низкой точности; на PPro, PII и PIII это также относится к FSQRT. Выполнение других инструкций убыстрить этим способом нельзя).

Параллельное деление (PPlain и PMMX)

На PPlain и PMMX можно производить деление числа плавающей запятой и целочисленное деление параллельно. На PPro, PII и PIII это не возможно, потому что целочисленное деление и деление чисел с плавающей запятой используют один и тот же механизм.

Пример: A = A1 / A2; B = B1 / B2

        FILD    [B1]
        FILD    [B2]
        MOV     EAX, [A1]
        MOV     EBX, [A2]
        CDQ
        FDIV
        DIV     EBX

        FISTP   [B]
        MOV     [A], EAX

Убедитесь, что вы установили в контрольном слове FPU желаемый метод округления.

Использование обратных инструкций для быстрого деления (PIII)

На PIII вы можете использовать быстрые обратные инструкции RCPSS или PCPPS с делителем, а затем умножить на делимое. Правда, точность будет всего 12 бит. Вы можете повысить ее до 23-х, использовав метод Ньютона-Рафсона, объясненного в интеловской сопроводительной заметке AP-803:

x0 = RCPSS(d)
x1 = x0 * (2 - d * x0) = 2*x0 - d * x0 * x0

где x0 - это первое приближение к обратному от делителя d, а x1 - лучшее приближение. Вы должны использовать эту формулу перед умножение на делимое:

        MOVAPS  XMM1, [DIVISORS]         ; загружаем делители
        RCPPS   XMM0, XMM1               ; приближенное обратное число
        MULPS   XMM1, XMM0               ; формула Ньютона-Рафсона
        MULPS   XMM1, XMM0

        ADDPS   XMM0, XMM0
        SUBPS   XMM0, XMM1
        MULPS   XMM0, [DIVIDENDS]        ; результаты в XMM0

Это позволяет сделать 4 деления за 18 тактов с точностью 23 бита. Повышение точность, повторяя формулу Ньютона-Рафсона возможно, но не очень выгодно.

Если вы хотите использовать этот метод для целочисленных делений, тогда вам нужно проверять результаты на ошибки округления. Следующие код делает четыре деления с усечением на упакованных целых числах размером в слово за примерно 42 такта. Это дает точные результаты для 0 <= делимое < 7FFFFH и 0 < делитель lt;= 7FFFFH:

        MOVQ MM1, [DIVISORS]      ; загружаем четыре делителя
        MOVQ MM2, [DIVIDENDS]     ; загружаем четыре делимых
        PUNPCKHWD MM4, MM1        ; распаковываем делители в DWORD'ы
        PSRAD MM4, 16
        PUNPCKLWD MM3, MM1
        PSRAD MM3, 16
        CVTPI2PS XMM1, MM4        ; конвертируем делители в плавающие числа,
                                  ; (два верхних из них)
        MOVLHPS XMM1, XMM1
        CVTPI2PS XMM1, MM3        ; конвертируем нижние два операнда
        PUNPCKHWD MM4, MM2        ; распаковываем делимые в DWORD'ы

        PSRAD MM4, 16
        PUNPCKLWD MM3, MM2
        PSRAD MM3, 16
        CVTPI2PS XMM2, MM4        ; конвертируем делимые d плавающие числа
                                  ; (верхние два операнда)
        MOVLHPS XMM2, XMM2
        CVTPI2PS XMM2, MM3        ; конвертируем два нижних операнда
        RCPPS XMM0, XMM1          ; приближенное обратное число делителей
        MULPS XMM1, XMM0          ; улучшаем точность с помощью метода Ньютона-Рафсона
        PCMPEQW MM4, MM4          ; создаем четыре целочисленных единицы за раз

        PSRLW MM4, 15
        MULPS XMM1, XMM0
        ADDPS XMM0, XMM0
        SUBPS XMM0, XMM1          ; обратные делители с точностью в 23 бита
        MULPS XMM0, XMM2          ; умножаем на делимые
        CVTTPS2PI MM0, XMM0       ; усекаем нижние два результата
        MOVHLPS XMM0, XMM0
        CVTTPS2PI MM3, XMM0       ; усекаем верхние два результата
        PACKSSDW MM0, MM3         ; упаковываем четыре результата в MM0
        MOVQ MM3, MM1             ; умножаем результаты на делители...

        PMULLW MM3, MM0           ; чтобы выявить ошибки округления
        PADDSW MM0, MM4           ; добавляем 1, чтобы скомпенсировать
                                  ; последнее вычитание
        PADDSW MM3, MM1           ; добавляем делитель. он должен быть больше
                                  ; делимого
        PCMPGTW MM3, MM2          ; проверяем, не слишком ли мал
        PADDSW MM0, MM3           ; вычитаем 1, если это не так
        MOVQ [QUOTIENTS], MM0     ; сохраняем четыре результата

Этот код проверяет, не слишком ли мал результат и делает соответствующую коррекцию. Не нужно проверять, если результат слишком велик.

Избегание делений (все процессоры)

Очевидно, что вам минимизировать количество делений. Деления плавающей запятой на константу или повторяющиеся деления на одно и то же значения следует делать через умножения на обратное число. Но есть много других ситуаций, когда вы можете снизить количество делений. Например: if (A/B >c) можно переписать как if (A > B*C), если B положительны, и как обратное сравнение, если B отрицательны.

A/B + C/D можно переписать как (A*D + C*B) / (B*D)

Если вы используете целочисленное деление, вам стоит остерегаться того, что ошибки округления могут стать другими после переписывания формул.

27.3 Освобождение регистров FPU (все процессоры)

Вы должны освободить все использованные регистры FPU до выходы из подпрограммы, не считая регистра, использованного для возвращения результата.

Самый быстрые способ освободить один регистр - это FSTP ST. Самый быстрый способ освободить два регистра на PPlain и PMMX - это FCOMPP, на PPro, PII и PIII вы можете использовать как FCOMPP, так и FSTP ST дважды.

Не рекомендуется использовать FFREE.

27.4 Переход от инструкций FPU к MMX и обратно (PMMX, PII и PIII)

Вы должны выполнить инструкцию EMMS после инструкции MMX, за которой может последовать код с инструкциями FPU.

На PMMX переключение между инструкциями FPU и MMX вызывает высокие потери. Выполнение первой инструкции FPU после EMMS занимает примерно на 58 тактов больше, а первой инструкции MMX после инструкции FPU - на 38 тактов больше.

На PII и PIII подобных потерь нет. Задержку после EMMS можно скрыть, поместив целочисленные инструкции между EMMS и первой инструкции FPU.

27.5 Конвертации чисел с плавающей запятой в целые (все процессоры)

Все подобные конверсии и обратно должны осуществляться через память:

    FISTP DWORD PTR [TEMP]
    MOV EAX, [TEMP]

На PPro, PII и PIII этот код может вызвать потери из-за попытки считать из [TEMP] до того, как закончена запись туда же, потому что инструкция FIST медленная (глава 17). WAIT не поможет (глава 26.6). Рекомендуется поместить другие инструкции между записью в [TEMP] и чтением оттуда, что бы избежать этих потерь. Это относится ко всем примерам, которые последуют в дальнейшем.

Спецификация языка C и C++ требует, чтобы конверсия из чисел с плавающей запятой в целые числа осуществлялась с помощью усечения, а не округления. Метод, используемый большинством библиотек C, это изменение контрольного слова FPU, чтобы указать инструкции FISTP на усечение, и изменение контрольного слова в прежнее состояние после ее выполнения. Это метод очень медленен на всех процессорах. На PPro, PII и PIII контрольное слово FPU не может быть переименовано, поэтому все последующие инструкции плавающей запятой будут ждать, пока инструкция FLDCW не будет выведена из обращения.

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

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

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

Округление к ближайшему

; extern "C" int round (double x);
_round  PROC    NEAR
PUBLIC  _round
        FLD     QWORD PTR [ESP+4]
        FISTP   DWORD PTR [ESP+4]
        MOV     EAX, DWORD PTR [ESP+4]
        RET
_round  ENDP

Усечение к нулю

; extern "C" int truncate (double x);
_truncate PROC    NEAR
PUBLIC  _truncate
        FLD     QWORD PTR [ESP+4]   ; x
        SUB     ESP, 12             ; память для локальных переменных
        FIST    DWORD PTR [ESP]     ; округленное значение
        FST     DWORD PTR [ESP+4]   ; значение с плавающей запятой
        FISUB   DWORD PTR [ESP]     ; вычитаем округленное значение
        FSTP    DWORD PTR [ESP+8]   ; разность
        POP     EAX                 ; округленное значение

        POP     ECX                 ; значение с плавающей запятой
        POP     EDX                 ; разность (с плавающей запятой)
        TEST    ECX, ECX            ; тестируем знак x
        JS      SHORT NEGATIVE
        ADD     EDX, 7FFFFFFFH      ; устанавливаем флаг переноса, если
                                    ; разность меньше -0
        SBB     EAX, 0              ; вычитаем 1, если x-round(x) < -0
        RET
NEGATIVE:
        XOR     ECX, ECX
        TEST    EDX, EDX
        SETG    CL                  ; 1, если разность > 0
        ADD     EAX, ECX            ; добавляем 1, если x-round(x) > 0

        RET
_truncate ENDP

Усечение к минус бесконечности

; extern "C" int ifloor (double x);
_ifloor PROC    NEAR
PUBLIC  _ifloor
        FLD     QWORD PTR [ESP+4]   ; x
        SUB     ESP, 8              ; память для локальных переменных
        FIST    DWORD PTR [ESP]     ; округленное значение
        FISUB   DWORD PTR [ESP]     ; вычитаем округленное значение
        FSTP    DWORD PTR [ESP+4]   ; разность
        POP     EAX                 ; округленное значение

        POP     EDX                 ; разность (с плавающей запятой)
        ADD     EDX, 7FFFFFFFH      ; устанавливаем флаг переноса, если
                                    ; разность меньше -0
        SBB     EAX, 0              ; вычитаем 1, если x-round(x) < -0
        RET
_ifloor ENDP

Эти процедуры работают для -231 < x < 231-1. Они не проверяют на переполнение или NAN'ы.

У PIII есть инструкции для усечения чисел с плавающей запятой одинарной точности: CVTTSS2SI and CVTTPS2PI. Эти инструкции очень полезны, если одинарная точность вас удовлетворяет, но если вы конвертируете число с более высокой точностью в число с одинарной точностью, чтобы использовать эти инструкции, у вас могут столкнуться с тем, что число может быть округлено вверх во время конверсии.

Альтернатива инструкции FISTP (PPlain и PMMX)

Конвертирование числа с плавающей запятой в целое обычно осуществляется следующим образом:

        FISTP   DWORD PTR [TEMP]
        MOV     EAX, [TEMP]

Альтернативный метод заключает в:

.DATA
ALIGN 8
TEMP    DQ      ?
MAGIC   DD      59C00000H   ; FPU-представление 2^51 + 2^52

.CODE
        FADD    [MAGIC]
        FSTP    QWORD PTR [TEMP]
        MOV     EAX, DWORD PTR [TEMP]

Добавление 'волшебного числа' 251+252 есть такой эффект, что любое целое число между -231 и +231 будет выравнено в нижних 32-х битах, когда сохраняется как число с плавающей запятой двойной точности. Результат будет такой же, какой бы вы получили с помощью инструкции FISTP со всеми методами округления, кроме усечения к нулю. Результат будет отличаться от FISTP, если в контрольном слове задано усечение или в случае переполнения. Вам может потребоваться инструкция WAIT для совместимости со старым 80287 процессором (глава 26.6)

Этот метод не быстрее использования FISTP, но он дает большую гибкость на PPlain и PMMX, потому между FADD и FSTP 3 такта, которые можно заполнить другими инструкциями. Вы можете умножить или разделить число на степень от двух в той же операции, сделав обратно по отношению к магическому числу. Вы также можете добавить константу, добавив ее к магическому числу, которое тогда будет иметь двойную точность.

27.6 Использование целочисленных инструкция для осуществления операций плавающей запятой (все процессоры)

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

Пример:

FLD QWORD PTR [ESI] / FSTP QWORD PTR [EDI]

Заменить на:

MOV EAX,[ESI] / MOV EBX,[ESI+4] / MOV [EDI],EAX / MOV [EDI+4],EBX

Тестируем, не равно ли значение с плавающей запятой нулю:

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

Пример:

FLD DWORD PTR [EBX] / FTST / FNSTSW AX / AND AH,40H / JNZ IsZero

Используйте целочисленные инструкции вместо этого и сдвиньте бит знака:

MOV EAX,[EBX] / ADD EAX,EAX / JZ IsZero

Если число с плавающей запятой имеет двойную точность (QWORD), тогда вам нужно протестировать только биты 32-62. Если они равны нулю, тогда нижняя половина будет также равна нулю, если это верное число с плавающей запятой.

Тест на то, отрицательно ли значение:

Число с плавающей запятой отрицательно, если бит знака и установлен по крайней мере один бит.

Пример:

MOV EAX,[NumberToTest] / CMP EAX,80000000H / JA IsNegative

Манипулирование битом знака:

Вы можете изменить знак числа с плавающей запятой просто инвертирововав бит знака:

Пример:

XOR BYTE PTR [a] + (TYPE a) - 1, 80H

Похожим образом вы можете получить абсолютное значение числа с плавающей запятой, просто сANDив бит знака:

Сравнивание чисел:

Числа с плавающей запятой сохраняются в особом формате, который позволяет использовать целочисленные инструкции для сравнения чисел с плавающей запятой, не считая бита знака. Если вы уверены, что оба сравниваемые числа с плавающей запятой являются правильными и положительными, вы можете простой сравнить их как целые:

Пример:

FLD [a] / FCOMP [b] / FNSTSW AX / AND AH,1 / JNZ ASmallerThanB

Изменить на:

MOV EAX,[a] / MOV EBX,[b] / CMP EAX,EBX / JB ASmallerThanB

Этот метод работает только, если у двух чисел одна и та же точность, и вы уверены, что ни у одного из числа не установлен бит знака.

Если возможны отрицательные числа, вы можете их сконвертировать определенным образом и сделать знаковое сравнение:

        MOV     EAX, [a]

        MOV     EBX, [b]
        MOV     ECX, EAX
        MOV     EDX, EBX
        SAR     ECX, 31              ; скопировать бит знака
        AND     EAX, 7FFFFFFFH       ; убрать бит знака
        SAR     EDX, 31
        AND     EBX, 7FFFFFFFH
        XOR     EAX, ECX      ; преобразуем, если установлен бит знака
        XOR     EBX, EDX
        SUB     EAX, ECX
        SUB     EBX, EDX
        CMP     EAX, EBX
        JL      ASmallerThanB        ; знаковое сравнение

Этот метод работает для всех правильных чисел с плавающей запятой, включая -0.

27.7 Использование инструкции с плавающей запятой, чтобы осуществлять целочисленные операции (PPlain и PMMX)

Целочисленное умножение (PPlain и PMMX)

Умножение плавающей запятой быстрее, чем целочисленное умножение на PPlain и PMMX, но цена конверсии целых чисел в числа с плавающей запятой и конвертирование результата обратно в целое число очень высоко, поэтому умножение плавающей запятой имеет смысл только тогда, если количество требуемых преобразование мало сравнимо с количеством умножений. (Довольно соблазнительно использование ненормальных чисел с плавающей запятой, чтобы пропустить часть конвертаций, но обработка таких чисел очень медленна, поэтому это плохая идея!)

На PMMX инструкции умножения MMX быстрее, чем целочисленное умножение, и могут конвейеризоваться, поэтому одним из лучших решений на PMMX может быть использование этих инструкций на PMMX, если вы можете жить с 16-ти битной точностью.

Целочисленное умножение быстрее, чем умножение с плавающей запятой на PPro, PII и PIII.

Целочисленное деление (PPlain и PMMX)

Деление плавающей запятой не быстрее, чем целочисленное деление, но вы можете делать другие целочисленные операции (включая целочисленное деление, но не целочисленное умножение), в то время как работает FPU на деление плавающей запятой (смотри пример выше).

Конвертирование двоичных чисел в десятичные (все процессоры)

Использование инструкции FBSTP - простой и удобный путь для того, чтобы конвертировать двоичные числа в десятичные, хотя не обязательно самый быстрый метод.

27.8 Перемещение блоков данных (все процессоры)

Есть несколько способов перемещения блоков данных. Наиболее общий метод - это REP MOVSD, но при определенных условиях другие методы быстрее.

На PPlain и PMMX быстрее переместить 8 байтов за раз, если место назначения не находится в кэше:

TOP:    FILD    QWORD PTR [ESI]
        FILD    QWORD PTR [ESI+8]
        FXCH
        FISTP   QWORD PTR [EDI]
        FISTP   QWORD PTR [EDI+8]
        ADD     ESI, 16
        ADD     EDI, 16
        DEC     ECX
        JNZ     TOP

Источник и место назначения должны быть выравнены на 8. Дополнительное время, используемое медленными инструкциями FILD и FISTP компенсируется тем, что вам требуется сделать в два раза меньше операций записывания. Обратите внимание, что этот метод имеет преимущество только на PPlain и PMMX и только тогда, когда место назначения не находится в кэше первого уровня. Вы не можете использовать FLD и FSTP (без I) с противоположными последовательностями битов, потому что ненормальные числа обрабатываются медленно и не гарантируется, что они останутся неизмененными.

На PMMX, если назначение не находится в кэше, быстрее использовать инструкции MMX для перемещения восьми байтов за раз, если место назначения не находится в кэше.

TOP:    MOVQ    MM0,[ESI]
        MOVQ    [EDI],MM0
        ADD     ESI,8
        ADD     EDI,8
        DEC     ECX
        JNZ     TOP

Данный цикл не нужно оптимизировать или разворачивать, если ожидаются промахи кэша, потому что здесь узкое место - это доступ к памяти, а не выполнение инструкций.

На процессорах PPro, PII и PIII инструкция REP MOVSD особенно быстра, если соблюдены следующие условия:

  • источник и назначение должны быть выравнены на 8
  • направление должно быть вперед (очищен флаг направления)
  • счетчик (ECX) должен быть больше или равен 64
  • разность между EDI и ESI должна быть больше или равна 32

На PII быстрее использовать регистры MMX, если вышеприведенные условия не соблюдены и место назначения находится в кэше первого уровня. Цикл можно развернуть в два раза, а источник и назначение должны быть выравнены на 8.

На PIII самый быстрый путь перемещения данных - это использовать инструкцию MOVAPS, если вышеприведенные условия не соблюдены или если место назначения не находится в кэше первого или второго уровня:

        SUB     EDI, ESI
TOP:    MOVAPS  XMM0, [ESI]
        MOVAPS  [ESI+EDI], XMM0
        ADD     ESI, 16
        DEC     ECX
        JNZ     TOP

В отличии от FLD, MOVAPS может обрабатывать любую последовательность битов без всяких проблем. Помните, что источник и назначение должны быть выравнены на 16.

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

На PIII у вас также есть опция прямой записи в RAM-память без вовлечения кэша, используя инструкцию MOVNTQ или MOVNTPS. Это может быть полезным, если вы не хотите, чтобы место назначение попало в кэш. MOVNTPS чуть-чуть быстрее, чем MOVNTQ.

27.9 Самомодифицирующийся код (все процессоры)

Потери при выполнении кода сразу после того, как тот был изменен, занимают примерно 19 тактов на PPlain, 31 на PMMX и 150-300 на PPro, PII и PIII. Процессоры 80486 и более ранние требуют переход между модифицирующим и модифицируемым кодом, чтобы очистить кэш кода.

Чтобы получить разрешение на модифицирование кода в защищенное операционной системе, вам потребуется вызвать специальные системные функции: в 16-битной Windows это ChangeSelector, в 32-х битной Windows - VirtualProtect и FlushInstructionCache (или поместить код в сегмент данных).

Самомодифицирующийся код не считается хорошим тоном программирования, но можно пойти на его применение, если выигрыш в скорости значителен.

27.10 Определение типа процессора (все процессоры)

Я думаю, что теперь достаточно очевидно, что оптимальное для одного процессора может не являться таковым для другого. Вы можете сделать несколько вариантов наиболее критичных участков кода вашей программы, чтобы они выполнялись максимально быстро на любом процессоре. Однако вам потребуется определить, на каком процессоре программа выполняется в настоящий момент. Если вы используете инструкции, которые не поддерживаются всеми процессорами, т.е. условные перемещения, FCOMI, инструкции MMX и XMM), то вы можете сначала проверить, поддерживает ли процессор данные инструкции. Процедура, приведенная ниже, проверяет тип процессора и поддерживаемые им технологии.

; задаем инструкцию CPUID, если она не известна ассемблеру:
CPUID   MACRO
        DB      0FH, 0A2H
ENDM

; Прототип С++:
; extern "C" long int DetectProcessor (void);

; возвращаемое значение:
; bits 8-11 = семья (5 для PPlain и PMMX, 6 для PPro, PII и PIII)
; bit  0 = поддерживаются инструкции FPU
; bit 15 = поддерживаются условные переходы и инструкция FCOMI
; bit 23 = поддерживаются инструкции MMX
; bit 25 = поддерживаются инструкции XMM

_DetectProcessor PROC NEAR

PUBLIC  _DetectProcessor
        PUSH    EBX
        PUSH    ESI
        PUSH    EDI
        PUSH    EBP
        ; определяем, поддерживает ли микропроцессор инструкцию CPUID
        PUSHFD
        POP     EAX
        MOV     EBX, EAX
        XOR     EAX, 1 SHL 21    ; проверяем, можно ли изменять бит CPUID
        PUSH    EAX
        POPFD
        PUSHFD
        POP     EAX
        XOR     EAX, EBX
        AND     EAX, 1 SHL 21
        JZ      SHORT DPEND      ; инструкция CPUID не поддерживается

        XOR     EAX, EAX
        CPUID                    ; получаем количество функций CPUID
        TEST    EAX, EAX
        JZ      SHORT DPEND      ; функция 1 CPUID не поддерживается
        MOV     EAX, 1
        CPUID                    ; получаем семью и особенности процессора
        AND     EAX, 000000F00H  ; семья
        AND     EDX, 0FFFFF0FFH  ; флаги особенностей
        OR      EAX, EDX         ; комбинируем биты
DPEND:  POP     EBP
        POP     EDI
        POP     ESI
        POP     EBX

        RET
_DetectProcessor ENDP

Обратите внимание, что некоторые операционные системы не позволяют использовать инструкции XMM. Информация о том, как узнать, поддерживает ли операционная система инструкции XMM, можно найти в интеловской инструкции AP-900: "Identifying support for Streaming SIMD Extensions in the Processor and Operating System". Больше информации о идентификации процессора можно найти в инструкции AP-485: "Intel Processor Identification and the CPUID Instruction".

Если ассемблер не поддерживает инструкции MMX, XMM, условного перемещения данных, можно использовать специальные макросы (www.agner.org/assem/macros.zip)


28. Список периодов выполнения инструкций для PPlain и PMMX

Пояснения:

Операнды: r = регистр, m = память, i = число, sr - сегментный регистр, m32 = 32-х битный операнд памяти и так далее.

Такты: указанные значения являются минимальными. Промахи кэша, невыравненность и исключения могут значительно увеличить количество требуемых для выполнений тактов.

Спариваемость: u = спаривается в u-конвейере, v = спаривается в v-конвейере, uv = спаривается в любом конвейере, np = не спаривается.

28.1 Целочисленные инструкции (PPlain и PMMX)

Инструкции          Операнды            Такты          Спариваемость

 NOP                                     1              uv
 MOV                 r/m, r/m/i          1              uv
 MOV                 r/m, sr             1              np
 MOV                 sr , r/m            >= 2 b)        np
 MOV                 m , accum           1              uv h)
 XCHG                (E)AX, r            2              np
 XCHG                r , r               3              np
 XCHG                r , m               >15            np

 XLAT                                    4              np
 PUSH                r/i                 1              uv
 POP                 r                   1              uv
 PUSH                m                   2              np
 POP                 m                   3              np
 PUSH                sr                  1 b)           np
 POP                 sr                  >= 3 b)        np
 PUSHF                                   3-5            np

 POPF                                    4-6            np
 PUSHA POPA                              5-9 i)         np
 PUSHAD POPAD                            5              np
 LAHF SAHF                               2              np
 MOVSX MOVZX         r , r/m             3 a)           np
 LEA                 r , m               1              uv
 LDS LES LFS LGS LSS m                   4 c)           np
 ADD SUB AND OR XOR  r , r/i             1              uv

 ADD SUB AND OR XOR  r , m               2              uv
 ADD SUB AND OR XOR  m , r/i             3              uv
 ADC SBB             r , r/i             1              u
 ADC SBB             r , m               2              u
 ADC SBB             m , r/i             3              u
 CMP                 r , r/i             1              uv
 CMP                 m , r/i             2              uv
 TEST                r , r               1              uv

 TEST                m , r               2              uv
 TEST                r , i               1              f)
 TEST                m , i               2              np
 INC DEC             r                   1              uv
 INC DEC             m                   3              uv
 NEG NOT             r/m                 1/3            np
 MUL IMUL            r8/r16/m8/m16       11             np
 MUL IMUL            all other versions  9 d)           np

 DIV                 r8/m8               17             np
 DIV                 r16/m16             25             np
 DIV                 r32/m32             41             np
 IDIV                r8/m8               22             np
 IDIV                r16/m16             30             np
 IDIV                r32/m32             46             np
 CBW CWDE                                3              np
 CWD CDQ                                 2              np

 SHR SHL SAR SAL     r , i               1              u
 SHR SHL SAR SAL     m , i               3              u
 SHR SHL SAR SAL     r/m, CL             4/5            np
 ROR ROL RCR RCL     r/m, 1              1/3            u
 ROR ROL             r/m, i(><1)         1/3            np
 ROR ROL             r/m, CL             4/5            np
 RCR RCL             r/m, i(><1)         8/10           np
 RCR RCL             r/m, CL             7/9            np

 SHLD SHRD           r, i/CL             4 a)           np
 SHLD SHRD           m, i/CL             5 a)           np
 BT                  r, r/i              4 a)           np
 BT                  m, i                4 a)           np
 BT                  m, i                9 a)           np
 BTR BTS BTC         r, r/i              7 a)           np
 BTR BTS BTC         m, i                8 a)           np
 BTR BTS BTC         m, r                14 a)          np

 BSF BSR             r , r/m             7-73 a)        np
 SETcc               r/m                 1/2 a)         np
 JMP CALL            short/near          1 e)           v
 JMP CALL            far                 >= 3 e)        np
 conditional jump    short/near          1/4/5/6 e)     v
 CALL JMP            r/m                 2/5 e          np
 RETN                                    2/5 e          np
 RETN                i                   3/6 e)         np

 RETF                                    4/7 e)         np
 RETF                i                   5/8 e)         np
 J(E)CXZ             short               4-11 e)        np
 LOOP                short               5-10 e)        np
 BOUND               r , m               8              np
 CLC STC CMC CLD STD                     2              np
 CLI STI                                 6-9            np
 LODS                                    2              np

 REP LODS                                7+3*n g)       np
 STOS                                    3              np
 REP STOS                                10+n g)        np
 MOVS                                    4              np
 REP MOVS                                12+n g)        np
 SCAS                                    4              np
 REP(N)E SCAS                            9+4*n g)       np
 CMPS                                    5              np

 REP(N)E CMPS                            8+4*n g)       np
 BSWAP                                   1 a)           np
 CPUID                                   13-16 a)       np
 RDTSC                                   6-13 a) j)     np

Примечания:

a) у этой инструкции есть префикс OFH, который занимает дополнительный такт
на PPlain, если до этого не было мультитактовой инструкции (смотри главу
12).
b) у версий с FS и GS есть префикс OFH, смотри примечание a.
c) у версий с SS, FS и GS есть префикс OFH, смотри примечание a.
d) у версий с двумя операндами (не числами) есть префикс OFH, смотри
примечание a.
e) смотри главу 22
f) спаривается, только если в качестве приемника регистр, смотри главу 26.14.
g) добавляет один такт для раскодировки префикса повторения, если ранее не
предшествовала мультитактовая инструкция (такая как CLD, например, смотри главу
12).
h) спаривается, как если бы производилась запись в приемник, смотри главу 26.14.
i) 9, если SP кратно 4. Смотри главу 10.2.
j) на PPlain: 6 в привилегированном или реальном режиме, 11 в
непривилегированном, ошибка в виртуальном. На PMMX: 8 и 13 тактов
соответственно.

28.2 Инструкции FPU (PPlain и PMMX)

Пояснения:

Операнды: r = регистр, m = память, sr - сегментный регистр, m32 = 32-х битный операнд памяти и так далее.

Такты: указанные значения являются минимальными. Промахи кэша, невыравненность, ненормальные операнды и исключения могут значительно увеличить количество требуемых для выполнений тактов.

Pairability: + = pairable with FXCH, np = not pairable with FXCH.

Спариваемость: + = спариваемо c FXCH, np = не спариваемо с FXCH.

i-ov: пересечение времени выполнения с целочисленными инструкциями. i-ov = 4 означает, что последние четыре такта могут пересекаться с последующими целочисленными инструкциями.

fp-ov: пересечение времени выполнения с инструкциями FPU. fp-ov = 2 означает, что последние два такта могут пересекаться с последующими инструкциями FPU (WAIT здесь считается как инструкция FPU).

Инструкция        Операнд    Такты          Спариваемость i-ov    fp-ov
 FLD               r/m32/m64  1              +             0       0
 FLD               m80        3              np            0       0
 FBLD              m80        48-58          np            0       0
 FST(P)            r          1              np            0       0

 FST(P)            m32/m64    2 m)           np            0       0
 FST(P)            m80        3 m)           np            0       0
 FBSTP             m80        148-154        np            0       0
 FILD              m          3              np            2       2
 FIST(P)           m          6              np            0       0
 FLDZ FLD1                    2              np            0       0
 FLDPI FLDL2E etc.            5 s)           np            2       2

 FNSTSW            AX/m16     6 q)           np            0       0
 FLDCW             m16        8              np            0       0
 FNSTCW            m16        2              np            0       0
 FADD(P)           r/m        3              +             2       2
 FSUB(R)(P)        r/m        3              +             2       2
 FMUL(P)           r/m        3              +             2       2 n)
 FDIV(R)(P)        r/m        19/33/39 p)    +             38 o)   2

 FCHS FABS                    1              +             0       0
 FCOM(P)(P) FUCOM  r/m        1              +             0       0
 FIADD FISUB(R)    m          6              np            2       2
 FIMUL             m          6              np            2       2
 FIDIV(R)          m          22/36/42 p)    np            38 o)   2
 FICOM             m          4              np            0       0
 FTST                         1              np            0       0

 FXAM                         17-21          np            4       0
 FPREM                        16-64          np            2       2
 FPREM1                       20-70          np            2       2
 FRNDINT                      9-20           np            0       0
 FSCALE                       20-32          np            5       0
 FXTRACT                      12-66          np            0       0
 FSQRT                        70             np            69 o)   2

 FSIN FCOS                    65-100 r)      np            2       2
 FSINCOS                      89-112 r)      np            2       2
 F2XM1                        53-59 r)       np            2       2
 FYL2X                        103 r)         np            2       2
 FYL2XP1                      105 r)         np            2       2
 FPTAN                        120-147 r)     np            36 o)   0
 FPATAN                       112-134 r)     np            2       2

 FNOP                         1              np            0       0
 FXCH              r          1              np            0       0
 FINCSTP FDECSTP              2              np            0       0
 FFREE             r          2              np            0       0
 FNCLEX                       6-9            np            0       0
 FNINIT                       12-22          np            0       0
 FNSAVE            m          124-300        np            0       0

 FRSTOR            m          70-95          np            0       0
 WAIT                         1              np            0       0

Примечания:

m) значения, которое нужно сохранить, должно быть готово на один такт раньше.
n) 1, если пересекающаяся инструкция тоже FMUL.
o) не может пересекаться с инструкциями целочисленного умножения.
p) FDIV занимает 19, 33 или 39 тактов для 24, 53 и 64-х битной точности
соответственно. FIDIV занимает на 3 такта больше. Точность задается битами
8-9 контрольного слова FPU.
r) такты типичны. Тривиальные случаи могут быть быстрее, крайние - медленнее.
s) может быть на 3 такта больше, когда требуется выходной результат FST, FCHS
или FABS.

28.3 Инструкции MMX (PMMX)

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

Инструкция EMMS занимает только один такт, но первая инструкция FPU после EMMS занимает примерно на 58 тактов больше, а первая инструкция MMX после инструкции FPU занимает примерно на 38 тактов больше.

Нет потерь при использовании операндов памяти в инструкции MMX, потому что арифметический модуль MMX на один шаг дальше в конвейере, чем модуль загрузки. Но потери будут, когда вы станете сохранять данные из регистра MMX в память или в 32-х битный регистр: данные должны быть готовы на один такт раньше. Это аналогично инструкциям FPU.

Все инструкции MMX, кроме EMMS, спариваются в любом конвейере. Правила спаривания для инструкций MMX объяснены в главе 10.


29. Список периодов выполнения инструкций и задержек микроопераций для PPro, PII и PIII

Пояснения:

Операнды: r = регистр, m = память, i = число, sr = сегментный регистр, m32 = 32-х битный операнд в памяти и так далее.

Микрооперации: количество микроопераций, которые генерирует инструкция для каждого порта выполнения.

  • p0: порт 0: ALU и т.д.
  • p1: порт 1: ALU, переходы
  • p01: инструкции, которые могут попасть как в порт 0, так и в порт 1 (какой будет свободен первым)
  • p2: порт 2: загрузка данных и т.д.
  • p3: порт 3: генерация адреса для сохранения
  • p4: порт 4: сохранение данных

Задержка:

Это задержка, которую генерирует инструкция в цепочке зависимости. (Это не то же самое, что время, потраченное в модуле выполнения. Значения могут быть неточны в тех ситуациях, когда они не могут быть измерены точно, особенно, что касается операндов в памяти). Значения являются минимальными. Промахи кэша, невыравненность и исключения могут повысить количество тактов весьма значительно. Предполагается, что операнды с плавающей запятой являются нормальными. Ненормальные числа, NAN'ы и бесконечность увеличивают задержку на 50-150 тактов, кроме перемещений XMM, перемешиваний и булевых инструкций. Переполнения FPU, потеря значимости, ненормальные или NAN-результаты дают аналогичную задержку.

Производительность:

Максимальная производительность для нескольких инструкций одного вида, например, производительность в 1/2 для FMUL означает, что новая инструкция FMUL может начинать выполнение каждые 2 такта.

29.1 Целочисленные инструкции (PPro, PII и PIII)

 Инструкции          Операнды     микрооперации         задержка производительности
                                  p0 p1 p01  p2 p3  p4

 NOP                                    1
 MOV                 r,r/i              1
 MOV                 r,m                     1
 MOV                 m,r/i                      1   1
 MOV                 r,sr               1
 MOV                 m,sr               1       1   1
 MOV                 sr,r         8                     5
 MOV                 sr,m         7          1          8
 MOVSX MOVZX         r,r                1
 MOVSX MOVZX         r,m                     1

 CMOVcc              r,r          1     1
 CMOVcc              r,m          1     1    1
 XCHG                r,r                3
 XCHG                r,m                4    1  1   1   high b)
 XLAT                                   1    1
 PUSH                r/i                1       1   1
 POP                 r                  1    1
 POP                 (E)SP              2    1
 PUSH                m                  1    1  1   1
 POP                 m                  5    1  1   1

 PUSH                sr                 2       1   1
 POP                 sr                 8    1
 PUSHF(D)                         3     11      1   1
 POPF(D)                          10    6    1
 PUSHA(D)                               2       8   8
 POPA(D)                                2    8
 LAHF SAHF                              1
 LEA                 r,m          1                     1 c)
 LDS LES LFS LGS
 LSS                 m                  8    3

 ADD SUB AND OR XOR  r,r/i              1
 ADD SUB AND OR XOR  r,m                1    1
 ADD SUB AND OR XOR  m,r/i              1    1  1   1
 ADC SBB             r,r/i              2
 ADC SBB             r,m                2    1
 ADC SBB             m,r/i              3    1  1   1
 CMP TEST            r,r/i              1
 CMP TEST            m,r/i              1    1
 INC DEC NEG NOT     r                  1
 INC DEC NEG NOT     m                  1    1  1   1

 AAS DAA DAS                         1
 AAD                              1     2               4
 AAM                              1  1  2               15
 MUL IMUL            r,(r),(i)    1                     4       1/1
 MUL IMUL            (r),m        1          1          4       1/1
 DIV IDIV            r8           2     1               19      1/12
 DIV IDIV            r16          3     1               23      1/21
 DIV IDIV            r32          3     1               39      1/37

 DIV IDIV            m8           2     1    1          19      1/12
 DIV IDIV            m16          2     1    1          23      1/21
 DIV IDIV            m32          2     1    1          39      1/37
 CBW CWDE                               1
 CWD CDQ                          1
 SHR SHL SAR ROR
 ROL                 r,i/CL       1
 SHR SHL SAR ROR
 ROL                 m,i/CL       1          1  1   1
 RCR RCL             r,1          1     1
 RCR RCL             r8,i/CL      4     4

 RCR RCL             r16/32,i/CL  3     3
 RCR RCL             m,1          1     2    1  1   1
 RCR RCL             m8,i/CL      4     3    1  1   1
 RCR RCL             m16/32,i/CL  4     2    1  1   1
 SHLD SHRD           r,r,i/CL     2
 SHLD SHRD           m,r,i/CL     2     1    1  1   1
 BT                  r,r/i              1
 BT                  m,r/i        1     6    1
 BTR BTS BTC         r,r/i              1
 BTR BTS BTC         m,r/i        1     6    1  1   1

 BSF BSR             r,r             1  1
 BSF BSR             r,m             1  1    1
 SETcc               r                  1
 SETcc               m                  1       1   1
 JMP                 short/near      1                          1/2
 JMP                 far          21         1
 JMP                 r               1                          1/2
 JMP                 m(near)         1       1                  1/2
 JMP                 m(far)       21         2

 conditional jump    short/near      1                          1/2
 CALL                near            1  1       1   1           1/2
 CALL                far          28         1  2   2
 CALL                r               1  2       1   1           1/2
 CALL                m(near)         1  4    1  1   1           1/2
 CALL                m (far)      28         2  2   2
 RETN                                1  2    1                  1/2
 RETN                i               1  3    1                  1/2

 RETF                             23         3
 RETF                i            23         3
 J(E)CXZ             short           1  1
 LOOP                short        2  1  8
 LOOP(N)E            short        2  1  8
 ENTER               i,0                12      1   1
 ENTER               a,b          ca. 18+4b     b-1 2b
 LEAVE                                  2    1
 BOUND               r,m          7     6    2
 CLC STC CMC                            1
 CLD STD                                4

 CLI                              9
 STI                              17
 INTO                                   5
 LODS                                        2
 REP LODS                               10+6n
 STOS                                        1  1   1
 REP STOS                               ca. 5n a)
 MOVS                                   1    3  1   1
 REP MOVS                               ca. 6n a)
 SCAS                                   1    2
 REP(N)E SCAS                           12+7n

 CMPS                                   4    2
 REP(N)E CMPS                           12+9n
 BSWAP                            1     1
 CPUID                            23-48
 RDTSC                            31
 IN                               18                    >300
 OUT                              18                    >300
 PREFETCHNTA  d)     m                        1
 PREFETCHT0  d)      m                        1
 PREFETCHT1  d)      m                        1

 PREFETCHT2  d)      m                        1
 SFENCE  d)                                      1   1          1/6

Примечания:

a) быстро при определенных условиях: смотри главу 26.3.
b) смотри главу 26.1.
c) 3, если константа без базового или индексного регистра.
d) только PIII.

29.2 Инструкции FPU (PPro, PII и PIII)

 Инструкции         Операнды     микрооперации      задержка производительность
                              p0  p1 p01  p2 p3  p4

 FLD                r         1
 FLD                m32/64                1         1
 FLD                m80       2           2
 FBLD               m80       38          2
 FST(P)             r         1
 FST(P)             m32/m64                  1   1  1
 FSTP               m80       2              2   2
 FBSTP              m80       165            2   2
 FXCH               r                               0      3/1 f)
 FILD               m         3           1         5

 FIST(P)            m         2              1   1  5
 FLDZ                         1
 FLD1 FLDPI FLDL2E etc.       2
 FCMOVcc            r         2                     2
 FNSTSW             AX        3                     7
 FNSTSW             m16       1              1   1
 FLDCW              m16       1      1    1         10
 FNSTCW             m16       1              1   1
 FADD(P) FSUB(R)(P) r         1                     3      1/1
 FADD(P) FSUB(R)(P) m         1           1         3-4    1/1

 FMUL(P)            r         1                     5      1/2 g)
 FMUL(P)            m         1           1         5-6    1/2 g)
 FDIV(R)(P)         r         1                     38 h)  1/37
 FDIV(R)(P)         m         1           1         38 h)  1/37
 FABS                         1
 FCHS                         3                     2
 FCOM(P) FUCOM      r         1                     1
 FCOM(P) FUCOM      m         1           1         1
 FCOMPP FUCOMPP               1      1              1

 FCOMI(P) FUCOMI(P) r         1                     1
 FCOMI(P) FUCOMI(P) m         1           1         1
 FIADD FISUB(R)     m         6           1
 FIMUL              m         6           1
 FIDIV(R)           m         6           1
 FICOM(P)           m         6           1
 FTST                         1                     1
 FXAM                         1                     2
 FPREM                        23
 FPREM1                       33
 FRNDINT                      30

 FSCALE                       56
 FXTRACT                      15
 FSQRT                        1                     69     e,i)
 FSIN FCOS                    17-97                 27-103 e)
 FSINCOS                      18-110                29-130 e)
 F2XM1                        17-48                 66     e)
 FYL2X                        36-54                 103    e)
 FYL2XP1                      31-53                 98-107 e)
 FPTAN                        21-102                13-143 e)

 FPATAN                       25-86                 44-143 e)
 FNOP                         1
 FINCSTP FDECSTP              1
 FFREE              r         1
 FFREEP             r         2
 FNCLEX                              3
 FNINIT                       13
 FNSAVE                       141
 FRSTOR                       72
 WAIT                                2

Примечания:

e) не конвейеризуется
f) FXCH генерирует 1 микрооперацию, что делается с помощью переименования
регистром, порты при этом не задействуются.
g) FMUL использует ту же схему, что и целочисленное умножение. Поэтому
комбинированная производительность целочисленных умножений и умножений FPU
равна 1 FMUL + 1 IMUL за 3 такта.
h) задержка FDIV зависит от заданной в контрольном слове точности: 64 бита
дают задержку в 38 тактов, 53 - в 32 такта, 24 - в 18. Деление на степень от
двух занимает 9 тактов. Производительность равна 1/(задержка-1).
i) быстрее для низкой точности.

29.3 Инструкции MMX (PII и PIII)

 Инструкция              Операнды       микрооперации  задержка производительность
                                     p0 p1 p01  p2 p3 p4

 MOVD MOVQ               r,r               1                    2/1
 MOVD MOVQ               r64,m32/64             1               1/1
 MOVD MOVQ               m32/64,r64                1  1         1/1
 PADD PSUB PCMP          r64,r64           1                    1/1
 PADD PSUB PCMP          r64,m64           1    1               1/1
 PMUL PMADD              r64,r64     1                    3     1/1
 PMUL PMADD              r64,m64     1          1         3     1/1

 PAND PANDN POR
 PXOR                    r64,r64           1                    2/1
 PAND PANDN POR
 PXOR                    r64,m64           1    1               1/1
 PSRA PSRL PSLL          r64,r64/i      1                       1/1
 PSRA PSRL PSLL          r64,m64        1       1               1/1
 PACK PUNPCK             r64,r64        1                       1/1
 PACK PUNPCK             r64,m64        1       1               1/1
 EMMS                                11                   6 k)

 MASKMOVQ  d)            r64,r64            1       1  1  2-8   1/30-1/2
 PMOVMSKB  d)            r32,r64         1                 1     1/1
 MOVNTQ  d)              m64,r64                    1  1        1/30-1/1
 PSHUFW  d)              r64,r64,i       1                 1     1/1
 PSHUFW  d)              r64,m64,i       1       1         2     1/1
 PEXTRW  d)              r32,r64,i       1  1              2     1/1
 PISRW  d)               r64,r32,i       1                 1     1/1

 PISRW  d)               r64,m16,i       1       1         2     1/1
 PAVGB PAVGW  d)         r64,r64            1              1     2/1
 PAVGB PAVGW  d)         r64,m64            1    1         2     1/1
 PMINUB PMAXUB PMINSW
 PMAXSW  d)              r64,r64            1              1     2/1
 PMINUB PMAXUB PMINSW
 PMAXSW  d)              r64,m64            1    1         2     1/1
 PMULHUW  d)             r64,r64      1                    3     1/1
 PMULHUW  d)             r64,m64      1          1         4     1/1

 PSADBW  d)              r64,r64      2     1              5     1/2
 PSADBW  d)              r64,m64      2     1    1         6     1/2

Примечания:

d) только PIII.

k) Вы можете спрятать задержку, вставив другие инструкции между EMMS и
последующими инструкциями FPU.

29.4 Инструкции XMM (PIII)

 Инструкция        Операнды           микрооперации      задержка производительность
                              p0   p1  p01   p2   p3  p4

 MOVAPS            r128,r128           2                   1     1/1
 MOVAPS            r128,m128                 2             2     1/2
 MOVAPS            m128,r128                      2   2    3     1/2
 MOVUPS            r128,m128                 4             2     1/4
 MOVUPS            m128,r128       1              4   4    3     1/4
 MOVSS             r128,r128           1                   1     1/1
 MOVSS             r128,m32            1     1             1     1/1

 MOVSS             m32,r128                       1   1    1     1/1
 MOVHPS MOVLPS     r128,m64            1                   1     1/1
 MOVHPS MOVLPS     m64,r128                       1   1    1     1/1
 MOVLHPS MOVHLPS   r128,r128           1                   1     1/1
 MOVMSKPS          r32,r128   1                            1     1/1
 MOVNTPS           m128,r128                       2   2         1/15-1/2
 CVTPI2PS          r128,r64        2                       3     1/1

 CVTPI2PS          r128,m64        2         1             4     1/2
 CVTPS2PI
 CVTTPS2PI         r64,r128        2                       3     1/1
 CVTPS2PI          r64,m128        1         2             4     1/1
 CVTSI2SS          r128,r32        2         1             4     1/2
 CVTSI2SS          r128,m32        2         2             5     1/2
 CVTSS2SI
 CVTTSS2SI         r32,r128        1         1             3     1/1
 CVTSS2SI          r32,m128        1         2             4     1/2

 ADDPS SUBPS       r128,r128       2                       3     1/2
 ADDPS SUBPS       r128,m128       2         2             3     1/2
 ADDSS SUBSS       r128,r128       1                       3     1/1
 ADDSS SUBSS       r128,m32        1         1             3     1/1
 MULPS             r128,r128  2                            4     1/2
 MULPS             r128,m128  2              2             4     1/2
 MULSS             r128,r128  1                            4     1/1

 MULSS             r128,m32   1              1             4     1/1
 DIVPS             r128,r128  2                            48    1/34
 DIVPS             r128,m128  2              2             48    1/34
 DIVSS             r128,r128  1                            18    1/17
 DIVSS             r128,m32   1              1             18    1/17
 ANDPS ANDNPS ORPS
 XORPS             r128,r128       2                       2     1/2
 ANDPS ANDNPS ORPS
 XORPS             r128,m128       2         2             2     1/2

 MAXPS MINPS       r128,r128       2                       3     1/2
 MAXPS MINPS       r128,m128       2         2             3     1/2
 MAXSS MINSS       r128,r128       1                       3     1/1
 MAXSS MINSS       r128,m32        1         1             3     1/1
 CMPccPS           r128,r128       2                       3     1/2
 CMPccPS           r128,m128       2         2             3     1/2
 CMPccSS           r128,r128       1         1             3     1/1

 CMPccSS           r128,m32        1         1             3     1/1
 COMISS UCOMISS    r128,r128       1                       1     1/1
 COMISS UCOMISS    r128,m32        1         1             1     1/1
 SQRTPS            r128,r128  2                            56    1/56
 SQRTPS            r128,m128  2              2             57    1/56
 SQRTSS            r128,r128  2                            30    1/28
 SQRTSS            r128,m32   2              1             31    1/28

 RSQRTPS           r128,r128  2                            2     1/2
 RSQRTPS           r128,m128  2              2             3     1/2
 RSQRTSS           r128,r128  1                            1     1/1
 RSQRTSS           r128,m32   1              1             2     1/1
 RCPPS             r128,r128  2                            2     1/2
 RCPPS             r128,m128  2              2             3     1/2
 RCPSS             r128,r128  1                            1     1/1

 RCPSS             r128,m32   1              1             2     1/1
 SHUFPS            r128,r128,i     2   1                   2     1/2
 SHUFPS            r128,m128,i     2         2             2     1/2
 UNPCKHPS UNPCKLPS r128,r128       2   2                   3     1/2
 UNPCKHPS UNPCKLPS r128,m128       2         2             3     1/2
 LDMXCSR           m32        11                           15    1/15
 STMXCSR           m32        6                            7     1/9

 FXSAVE            m4096      116                          62
 FXRSTOR           m4096      89                           68


30. Тестирование скорости

У микропроцессоров семьи Pentium есть встроенный 64-х битный счетчик, который можно считать в EDX:EAX, используя инструкцию RDTSC (read time stamp counter). Эта инструкция очень полезна для того, чтобы точно узнать, сколько тактов занял кусок кода.

Программа ниже полезна для измерения количества тактов, которое занимает код. Программа выполняет код 10 раз и сохраняет 10 значений счетчика. Программу можно использовать как в 16-ти, так и в 32-х битном режиме на PPlain и PMMX.

;************   Тестовая программа для PPlain и PMMX: ********************

ITER    EQU     10              ; количество повторений
OVERHEAD EQU    15              ; 15 для PPlain, 17 для PMMX

RDTSC   MACRO                   ; определяем инструкцию RDTSC
        DB      0FH,31H
ENDM
;************   Data segment:                   ********************
.DATA                           ; сегмент данных
ALIGN   4
COUNTER DD      0               ; счетчик цикла
TICS    DD      0               ; временная переменная для значения счетчика

RESULTLIST  DD  ITER DUP (0)    ; список тестовых результатов
;************   Code segment:                   ********************
.CODE                           ; сегмент кода
BEGIN:  MOV     [COUNTER],0     ; сбрасываем счетчик цикла
TESTLOOP:                       ; тестовый цикл
;********* Делаем здесь необходимую инициализацию: *****************
        FINIT
;************      Конец инициализиции          ********************
        RDTSC                   ; считываем значение счетчика тактов

        MOV     [TICS],EAX      ; сохраняем его
        CLD                     ; не спариваемая инструкция
REPT    8
        NOP                     ; 8 NOP'ов, чтобы избежать эффекта "затенения"
ENDM

;***  Поместите здесь инструкции, которые нужно протестировать:  ***
        FLDPI                   ; это всего лишь пример
        FSQRT
        RCR     EBX,10
        FSTP    ST
;******** Конец инструкций, которые нужно тестировать  *************

        CLC                     ; инструкция против спаривания и затенения

        RDTSC                   ; снова читаем счетчик
        SUB     EAX,[TICS]      ; вычисляем разность
        SUB     EAX,OVERHEAD    ; вычитаем такты, которые использовали
                                ; подсобные инструкции (против спаривания и
                                ; затенения)
        MOV     EDX,[COUNTER]   ; счетчик цикла
        MOV     [RESULTLIST][EDX],EAX   ; сохраняем результат в таблице
        ADD     EDX,TYPE RESULTLIST     ; увеличиваем значение счетчика
        MOV     [COUNTER],EDX           ; сохраняем счетчик
        CMP     EDX,ITER * (TYPE RESULTLIST)
        JB      TESTLOOP                ; повторяем заданное количество раз

; вставьте здесь код, чтобы считать значения в RESULTLIST

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

На PMMX вы можете захотеть вставить 'XOR EAX,EAX / CPUID' перед тестируемым кодом, если вы хотите, чтобы FIFO-буфер инструкций был очищен или какую-нибудь длительную инструкцию (например, CLI или AAD), если вы хотите, чтобы он был полон (CPUID не вызывает эффекта затенения может начаться раскодировка префиксов последующих инструкций).

На PPro, PII и PIII вы можете вставить 'XOR EAX,EAX / CPUID' до и после каждого RDTSC, чтобы предотвратить ее возможное выполнение параллельно с какой-нибудь другой инструкцией и убрать подсобные инструкции. (CPUID - это синхронизирующая инструкция, что означает то, что она очищает конвейер и ждет, пока все исполняющиеся инструкции не будут выполнены, и только тогда выполнится сама. Это может быть полезно для тестирования.)

Инструкция RDTSC не может выполняться в виртуальном режиме на PPlain и PMMX, поэтому если вы запускаете DOS-программы, вам нужно перегрузиться в реальный режим. (Нажмите F8 во время загрузки и выберите "safe mode command promt only" или "bypass startup files").

Полный исходник тестовой программы доступен на www.agner.org/assem/.

У процессоров Pentium есть специальные счетчики наблюдения за качеством работы, которые отслеживают и подсчитывают такие события как промахи кэша, невыравненность, различные задержки. Подробности об использовании счетчиков не затрагиваются в данном руководстве, но их можно найти в "Intel Architecture Software Developer's Manual", vol. 3, Appendix A.


31. Сравнение различных микропроцессоров

В следующей таблице изложены некоторые важные различия между процессорами семьи Pentium:

                                PPlain    PMMX   PPro   PII     PIII

 кэш кода, кб                   8         16     8      16      16
 кэш данных, кб                 8         16     8      16      16
 встроенный кэш 2 уровня, кб    0         0      256    512 *)  512 *)
 инструкции MMX                 нет       да     нет    да      да
 инструкции XMM                 нет       нет    нет    нет     да
 инструкции условной пересылки  нет       нет    да     да      да
 данных
 выполнение не по порядку       нет       нет    да     да      да

 предсказывание переходов       плохо     хорошо хорошо хорошо  хорошо
 количестов элементво в буфере  256       256    512    512     512
 предсказывания переходов
 размер стекового буфера        0         4      16     16      16
 возвращений
 потери при неправильном        3-4       4-5    10-20  10-20   10-20
 предсказании перехода
 задержки чтения регистров      0         0      5      5       5

 время ожидания FMUL            3         3      5      5       5
 производительность FMUL        1/2       1/2    1/2    1/2     1/2

 время ожидания IMUL            9         9      4      4       4
 производительность IMUL        1/9       1/9    1/1    1/1     1/1

*) Селерон: 0-128, Xeon: 512 или больше, доступны другие варианты. На
некоторых версиях кэш второго уровня выполняется на половинной скорости.

Комментарии к таблице:

Размер кэша кода важен, если критические части вашей программы занимают достаточно много места.

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

Инструкции MMX и XMM полезны для тех программ, которые обрабатывают большие массивы данных, такие как звук и изображения. Не всегда от использования инструкций MMX и XMM можно получить выгоду.

Инструкции условной пересылки данных полезны для того, чтобы избавиться от плохо предсказуемых условных переходов.

Выполнение не по порядку улучшает качество (особенно не оптимизированного кода). Оно включает в себя автоматическую перегруппировку и переименование регистров.

Процессоры с хорошим механизмом предсказания переходов умеют предсказывать простые повторяющиеся последовательности. Чем выше потери при неправильном предсказании переходов, тем важнее их предсказуемость.

Стековый буфер возвращений улучшает предсказание инструкций возвращений, когда подпрограмма вызывается из нескольких мест.

Задержки чтения регистров делают обработку смешанных типов данных (8, 16, 32 бита) более сложной.

Время ожидания инструкции умножения зависит от цепочки зависимости. Производительность 1/2 значит, что инструкция может конвейеризироваться, поэтому новое умножение может начинаться каждый второй такт. Это определяет скорость обработки параллельных данных.

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

Использование инструкций FPU на PPlain и PMMX часто требует большое количество дополнительных инструкций FXCH. Это затормозит выполнение на старых процессорах, но не на процессорах семейства Pentium и продвинутых неинтеловских процессорах.

Использование инструкций MMX на процессорах PMMX, PII и PIII или инструкций условной пересылки данных на PPro, PII и PIII создаст проблемы, если вы хотите, чтобы ваш код был совместим с более ранними процессорами. Решение может состоять в том, чтобы написать несколько версий вашего кода, каждая из которых будет оптимизирована под определенный процессор. Ваша программа должна определять, на каком процессоре она выполняется и выбирать соответствующую версию кода (глава 27.10).