Почему добавление инструкции xorps делает эту функцию с помощью cvtsi2ss и addss в 5 раз быстрее? - PullRequest
2 голосов
/ 15 марта 2020

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

    .text
test:
    #xorps  %xmm0, %xmm0
    cvtsi2ss    %edi, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    addss   %xmm0, %xmm0
    retq
    .global test

Эта функция следует соглашению о вызовах x86-64 GCC / Clang для объявления функции extern "C" float test(int); Обратите внимание на закомментированную инструкцию xorps. раскомментирование этой инструкции значительно повышает производительность функции. Тестируя его на моей машине с i7-8700K, тест Google показывает, что функция без , инструкция xorps занимает 8,54 нс (ЦП), а функция с инструкция xorps. занимает 1,48 нс. Я протестировал это на нескольких компьютерах с различными ОС, процессорами, процессорами разных поколений и разными производителями процессоров (Intel и AMD), и все они демонстрируют одинаковую разницу в производительности. Повторение инструкции addss делает замедление более выраженным (до некоторой точки), и это замедление по-прежнему происходит с использованием других инструкций (например, mulss) или даже набора инструкций, если они все зависят от значения в %xmm0 в некотором роде. Стоит отметить, что только вызов xorps каждого вызова функции приводит к повышению производительности. Выборка производительности с помощью al oop (как это делает Google Benchmark) с помощью вызова xorps за пределами l oop по-прежнему показывает более низкую производительность.

Поскольку в этом случае исключительно добавляет инструкция повышает производительность, это, кажется, вызвано чем-то действительно низким уровнем в процессоре. Поскольку это происходит в самых разных процессорах, кажется, что это должно быть преднамеренным. Однако я не смог найти никакой документации, объясняющей, почему это происходит. У кого-нибудь есть объяснение тому, что здесь происходит? Эта проблема, кажется, зависит от сложных факторов, так как замедление, которое я видел в моем исходном коде, происходило только на определенном c уровне оптимизации (-O2, иногда -O1, но не -Os), без встраивания и использования специфики. c компилятор (Clang, но не G CC).

1 Ответ

7 голосов
/ 15 марта 2020

cvtsi2ss %edi, %xmm0 объединяет число с плавающей точкой в ​​элемент младшего элемента XMM0, поэтому оно ложно зависит от старого значения. (При повторных вызовах одной и той же функции создается один длинный l oop -принесенный цепочка зависимостей.)

xor-zeroing разрывает цепочку dep, позволяя exe c не в порядке работать со своими волхвами c. Таким образом, вы ограничиваете пропускную способность addss (0,5 цикла) вместо задержки (4 цикла).

Ваш ЦП является производной от Skylake, так что это числа; ранее Intel имела 3-тактную задержку, 1-тактную пропускную способность, используя выделенный исполняющий модуль FP-add вместо запуска его на модулях FMA. https://agner.org/optimize/. Вероятно, служебные вызовы call / ret препятствуют тому, чтобы вы увидели полное 8-кратное ожидаемое ускорение от произведения задержки * в полосе пропускания 8 × 102 моп в полете в конвейерных блоках FMA; вы должны получить это ускорение, если вы удалите xorps dep-break из al oop в пределах одной функции.


G CC имеет тенденцию быть очень «осторожным» в отношении ложных зависимостей , тратя дополнительные инструкции (полосу пропускания внешнего интерфейса), чтобы на всякий случай их сломать. В коде, который является узким местом во внешнем интерфейсе (или где общий размер кода / занимаемая площадь кэша UOP является фактором), это снижает производительность, если регистр все равно действительно был готов вовремя.

Clang / LLVM безрассудный и неосторожный в этом вопросе , обычно не заботящийся о том, чтобы избежать ложных зависимостей от регистров, не записанных в текущей функции. (т.е. предполагая / делая вид, что регистры "холодные" при входе в функцию). Как показано в комментариях, clang избегает создания al oop -переданной цепочки dep путем обнуления xor при зацикливании внутри одной функции вместо нескольких вызовов одной и той же функции.

Clang даже использует 8-битный Частичные регистры GP-целочисленные без причины в некоторых случаях, когда это не сохраняет размер кода или инструкции по сравнению с 32-битными регистрами. Обычно это, вероятно, хорошо, но есть риск объединения в длинную цепочку депозита или создания цепочки зависимостей, переносимой al oop, если у вызывающей стороны (или при вызове функции родственного брата) все еще есть загрузка из-за отсутствия кэша в полете к этому регистру, когда мы Вызов, например.


См. Понимание влияния lfence на al oop с двумя длинными цепочками зависимостей, для увеличения длины для получения дополнительной информации о том, как OoO exe c может перекрывать короткие и средние длины независимые деп цепи. Также связано: Почему Мулсс занимает всего 3 цикла на Haswell, в отличие от таблиц инструкций Агнера? (Развертывание циклов FP с несколькими аккумуляторами) - это развертывание точечного продукта с несколькими аккумуляторами, чтобы скрыть задержку FMA.

https://www.uops.info/html-instr/CVTSI2SS_XMM_R32.html содержит сведения о производительности для этой инструкции в различных выпусках .


Этого можно избежать, если использовать AVX с vcvtsi2ss %edi, %xmm7, %xmm0 (где xmm7 - это любой регистр, который вы недавно не писали или который был ранее в депе цепочка, которая приводит к текущему значению EDI).

Как я уже упоминал в Почему задержка команды sqrtsd изменяется в зависимости от ввода? Процессоры Intel

Это проектирование ISA благодаря Intel, оптимизирующей в краткосрочной перспективе с SSE1 на Pentium III. P3 внутренне обрабатывал 128-битные регистры как две 64-битные половины. Оставляя верхнюю половину неизменной, пусть скалярные инструкции декодируются в один моп. (Но это все еще дает PIII sqrtss ложную зависимость). Наконец, AVX позволяет нам избежать этого с помощью vsqrtsd %src,%src, %dst, по крайней мере, для источников регистров, если не памяти, и аналогично vcvtsi2sd %eax, %cold_reg, %dst для аналогично близоруких разработанных инструкций скалярного преобразования int-> fp.
(G CC пропущенная оптимизация отчеты: 80586 , 89071 , 80571 .)

Если cvtsi2ss / sd обнулили верхние элементы регистров, у нас не было бы этой глупой проблемы / не нужно было бы разбрасывать инструкцию xor-zeroing; спасибо интел. (Другая стратегия состоит в том, чтобы использовать SSE2 movd %eax, %xmm0, который делает растяжение нуля, затем упакованное преобразование int-> fp, которое работает на всем 128-битном векторе. Это может быть безубыточным для float, где int-> fp скалярное преобразование - 2 моп, а векторная стратегия - 1 + 1. Но не вдвое, когда преобразование в упаковку int-> fp стоит shuffle + FP uop.)

Это именно та проблема, которой AMD64 избегает выполнение записи в 32-разрядные целочисленные регистры неявным образом расширяется до полного 64-разрядного регистра вместо того, чтобы оставить его неизменным (иначе говоря, слияние). Почему инструкции x86-64 для 32-разрядных регистров обнуляют верхнюю часть полного 64-разрядного регистра? (запись 8- и 16-разрядных регистров do вызывает ложные зависимости на процессорах AMD и Intel со времен Haswell).

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...