Ответ 1
Посмотрите на свой цикл еще раз: movss xmm1, src
не имеет отношения к старому значению xmm1
, поскольку его назначение предназначено только для записи. Каждая итерация mulss
независима. Выполнение вне порядка может и действительно использует этот уровень parallelism на уровне инструкций, поэтому вы определенно не будете узким местом в латентности mulss
.
Дополнительное чтение: в терминах компьютерной архитектуры: переименование регистров позволяет избежать WAR-данных об опасностях, связанных с зависимостью, от повторного использования одного и того же архитектурного регистра. (Некоторые схемы конвейеризации + схемы отслеживания зависимостей до переименования реестров не решают всех проблем, поэтому область компьютерной архитектуры делает большую проблему из-за различных видов опасностей данных.
Зарегистрировать переименование с помощью алгоритм Tomasulo заставляет все уйти, кроме фактических истинных зависимостей (читать после записи), поэтому любая инструкция, также исходный регистр не имеет взаимодействия с цепочкой зависимостей, включающей старое значение этого регистра. (За исключением ложных зависимостей, например popcnt
на процессорах Intel, и записывая только часть регистра, не освобождая остальных (например, mov al, 5
или sqrtss xmm2, xmm1
). Связано: Почему большинство команд x64 обнуляют верхнюю часть 32-разрядного регистра).
Вернуться к вашему коду:
.L13:
movss xmm1, DWORD PTR [rdi+rax*4]
mulss xmm1, DWORD PTR [rsi+rax*4]
add rax, 1
cmp cx, ax
addss xmm0, xmm1
jg .L13
Зависимые от цикла зависимости (от одной итерации к следующей):
-
xmm0
, прочитанный и написанныйaddss xmm0, xmm1
, который имеет 3 цикла задержки на Haswell. -
rax
, прочитанный и написанныйadd rax, 1
. 1c, поэтому это не критический путь.
Похоже, вы правильно измерили время выполнения/цикл, потому что узкие места цикла в латентности 3c addss
.
Ожидается, что последовательная зависимость в точечном продукте является добавлением в одну сумму (ака сокращение), а не умножения между векторными элементами.
Это, безусловно, является доминирующим узким местом для этого цикла, несмотря на различные незначительные неэффективности:
short i
создал глупую cmp cx, ax
, которая принимает дополнительный префикс размера операнда. К счастью, gcc удалось избежать выполнения add ax, 1
, потому что переполнение с подписью Undefined Поведение в C. Поэтому оптимизатор может предположить, что этого не происходит. (обновление: целые правила продвижения делают его другим для short
, поэтому UB не входит в него, но gcc все еще может легально оптимизировать. Довольно дурацкие вещи.)
Если вы скомпилировали с помощью -mtune=intel
или лучше, -march=haswell
, gcc поместил бы cmp
и jg
рядом друг с другом, где они могли бы сглаживать макросы.
Я не уверен, почему у вас есть *
в вашей таблице в инструкциях cmp
и add
. (обновление: я просто догадывался, что вы использовали нотацию вроде IACA, но, видимо, вы не были). Ни один из них не сливается. Единственное слияние - это микро-слияние mulss xmm1, [rsi+rax*4]
.
И так как это 2-оперантная команда ALU с регистром назначения чтения-модификации-записи, она остается с макросплавкой даже в ROB на Haswell. (Sandybridge не будет ламинировать его в заданное время.) Обратите внимание, что vmulss xmm1, xmm1, [rsi+rax*4]
тоже не будет ламинировать на Haswell.
Ничто из этого не имеет особого значения, поскольку вы просто полностью задерживаете латентность FP-добавления, намного медленнее, чем любые ограничения пропускной способности. Без -ffast-math
ничего не может сделать компилятор. С -ffast-math
, clang обычно будет разворачиваться с несколькими аккумуляторами, и он будет автоматически прорисовывать так, чтобы они были векторными аккумуляторами. Таким образом, вы можете, вероятно, насытить пропускную способность Haswell пропускной способности 1 векторного или скалярного добавления FP за такт, если вы попали в кеш L1D.
Когда FMA имеет 5c латентность и 0,5c пропускную способность на Haswell, вам понадобится 10 аккумуляторов, чтобы поддерживать 10 FMA в полете и максимальную пропускную способность FMA, сохраняя p0/p1 насыщенным FMA. (Skylake уменьшает задержку FMA до 4 циклов и запускает умножение, добавление и FMA на устройства FMA, поэтому на самом деле имеет более высокую задержку добавления, чем Haswell).
(У вас узкие места при загрузке, потому что вам нужно две нагрузки для каждого FMA. В других случаях вы можете фактически увеличить пропускную способность, заменив некоторую инструкцию a vaddps
на FMA с коэффициентом умножения 1.0. латентность, чтобы скрыть, поэтому лучше всего в более сложном алгоритме, где у вас есть добавление, а не на критический путь в первую очередь.)
Re: uops per port:
в порте 5 есть 1.19 uops за цикл, это намного больше, чем ожидалось 0.5, это вопрос о диспетчере uops, пытающемся сделать uops на каждом порту такого же
Да, что-то в этом роде.
Уопс не назначается случайным образом или равномерно распределяется по каждому порту, на котором они могут работать. Вы предположили, что add
и cmp
будут равномерно распределены по p0156, но это не так.
Этап выпуска назначает удаленные порты в зависимости от количества ожидающих этого порта. Поскольку addss
может работать только на p1 (и это узкое место цикла), обычно выдается много p1 uops, но не выполняется. Таким образом, немногие другие устройства будут назначены на порт1. (Это включает в себя mulss
: большая часть mulss
uops в конечном итоге запланирована на порт 0.)
Взятые ветки могут выполняться только на порте 6. Порт 5 не имеет в этом цикле никаких совпадений, которые могут работать только там, поэтому он заканчивает привлекать много многопортовых устройств.
Планировщик (который выбирает неустановленные домены из резервной станции) недостаточно умен, чтобы запускать критический путь в первую очередь, поэтому этот алгоритм присваивания уменьшает латентность ресурсов (другие удаляют порт 1 по циклам, когда addss
мог работать). Это также полезно в тех случаях, когда вы сталкиваетесь с пропускной способностью данного порта.
Планирование уже назначенных uops, как я понимаю, обычно старее всего готово. Этот простой алгоритм вряд ли удивителен, так как он должен выбрать uop со своими входами, готовыми для каждого порта из 60-entry RS каждый такт, без таяния вашего процессора. Механизм нарушенного порядка, который находит и использует ILP, является одной из значительных затрат на электроэнергию в современном процессоре, сравнимой с единицами исполнения, которые выполните фактическую работу.
Связанные/дополнительные сведения: Как запланировано x86 uops?
Дополнительные материалы анализа производительности:
Помимо недостатков промахов/ветвей кэша, три основных возможных узких места для циклов, связанных с процессором, следующие:
- цепи зависимостей (как в этом случае)
- пропускная способность переднего плана (макс. 4-х разобранных доменов, выпущенных за часы на Haswell)
- узкие места выполнения портлетов, например, если вам нужно много ppm/p1 или p2/p3, как в вашем развернутом цикле. Считайте unused-domain uops для определенных портов. Как правило, вы можете предполагать распределение наилучшего случая, с помощью uops, которые могут запускаться на других портах, не очень часто воруя занятые порты, но это действительно происходит.
Тело цикла или короткий блок кода может быть приблизительно охарактеризовано тремя вещами: счетчиком uop с плавной областью, количеством неиспользуемых доменов, из которых могут выполняться исполняемые модули, и общей задержкой критического пути, предполагающей наилучшее планирование для его критический путь. (Или задержки с каждого входа A/B/C на выход...)
Например, чтобы сделать все три, чтобы сравнить несколько коротких последовательностей, см. мой ответ на Каков эффективный способ подсчета бит в позиции или ниже?
Для коротких циклов современные процессоры имеют достаточно ресурсов вне очереди (размер физического регистра, поэтому для переименования не хватает регистров, размер ROB), чтобы иметь достаточно итераций цикла в полете, чтобы найти все parallelism. Но поскольку цепи зависимостей внутри циклов становятся длиннее, в конечном итоге они заканчиваются. См. Измерение емкости буферизации порядка для получения подробной информации о том, что происходит, когда ЦПУ заканчивает переименовывать регистры.
См. также много ссылок на производительность и ссылки в x86 теги wiki.
Настройка петли FMA:
Да, точечный продукт на Haswell будет узким местом на пропускной способности L1D только в половине пропускной способности блоков FMA, так как он принимает две нагрузки на умножение + добавление.
Если вы делали B[i] = x * A[i] + y;
или sum(A[i]^2)
, вы могли бы насытить пропускную способность FMA.
Похоже, что вы по-прежнему пытаетесь избежать повторного использования регистра даже в случаях с записью, таких как назначение загрузки vmovaps
, поэтому после разворота из-за 8 у вас закончились регистры. Это прекрасно, но может иметь значение для других случаев.
Кроме того, использование ymm8-15
может немного увеличить размер кода, если это означает, что вместо 2-байтного байта нужен 3-байтовый префикс VEX. Забавный факт: vpxor ymm7,ymm7,ymm8
нужен 3-байтовый VEX, а vpxor ymm8,ymm8,ymm7
нужен только 2-байтовый префикс VEX. Для коммутативных ops сортируйте исходные регистры с высокой до низкой.
Наше узкое место загрузки означает, что максимальная пропускная способность FMA составляет половину от максимального, поэтому нам нужно как минимум 5 векторных аккумуляторов, чтобы скрыть их латентность. 8 - это хорошо, поэтому в цепочках зависимостей достаточно провисать, чтобы догнать их после любых задержек от неожиданной латентности или конкуренции за p0/p1. 7 или, может быть, даже 6 будет хорошо: ваш коэффициент разворота не должен быть 2.
Развертывание ровно 5 означает, что вы также правы на узком месте для цепочек зависимостей. Каждый раз, когда FMA не работает в точном цикле, его вход готов - означает потерянный цикл в этой цепочке зависимостей. Это может произойти, если нагрузка медленная (например, она промахивается в кеше L1 и должна ждать L2), или если нагрузки заканчиваются не в порядке, а FMA из другой цепи зависимостей крадет порт, на который запланировано FMA. (Помните, что планирование происходит во время проблемы, поэтому в диспетчере, находящемся в планировщике, есть либо порт FMA, либо порт 1 FMA, а не FMA, который может принимать любой порт, который находится в режиме ожидания).
Если вы оставите некоторый провисание в цепочках зависимостей, выполнение вне порядка может "догнать" FMA, потому что они не будут иметь узких мест при пропускной способности или задержке, просто ожидая результатов загрузки. @Forward нашла (в обновлении вопроса), что разворачивание на 5 уменьшило производительность с 93% пропускной способности L1D до 89,5% для этого цикла.
Я предполагаю, что развернуть на 6 (на один больше, чем минимум, чтобы скрыть латентность) было бы хорошо здесь и получить ту же производительность, что и развернуть на 8. Если бы мы были ближе к максимальной пропускной способности FMA (а не просто узкое место при загрузке нагрузки), еще один минимум может быть недостаточно.
обновление: экспериментальный тест @Forward показывает, что моя ошибка была неправильной. Между unroll5 и unroll6 нет большой разницы. Кроме того, unroll15 в два раза ближе, чем unroll8, до теоретической максимальной пропускной способности 2x 256 бит нагрузки за такт. Измерение только с независимыми нагрузками в контуре или с независимыми нагрузками и только с регистрами FMA могло бы рассказать нам, сколько из-за взаимодействия с цепочкой зависимостей FMA. Даже самый лучший случай не получит идеальной 100% -ной пропускной способности, хотя бы из-за ошибок измерения и сбоев из-за прерываний таймера. (Linux perf
измеряет только циклы пользовательского пространства, если вы не запускаете его как root, но время по-прежнему включает время, затрачиваемое на обработчики прерываний. Вот почему ваша частота процессора может быть указана как 3,87 ГГц при запуске как не root, а 3.900 ГГц при запуске от имени root и измерения cycles
вместо cycles:u
.)
Мы не испытываем недостатка в пропускной способности интерфейса, но мы можем уменьшить счетчик uop с плавным доменом, избегая индексированных режимов адресации для инструкций mov
. Меньше лучше и делает это более гиперпотоком при совместном использовании ядра с чем-то другим, чем это.
Простым способом является просто сделать два инкремента указателя внутри цикла. Сложный способ - это аккуратный трюк индексации одного массива относительно другого:
;; input pointers for x[] and y[] in rdi and rsi
;; size_t n in rdx
;;; zero ymm1..8, or load+vmulps into them
add rdx, rsi ; end_y
; lea rdx, [rdx+rsi-252] to break out of the unrolled loop before going off the end, with odd n
sub rdi, rsi ; index x[] relative to y[], saving one pointer increment
.unroll8:
vmovaps ymm0, [rdi+rsi] ; *px, actually py[xy_offset]
vfmadd231ps ymm1, ymm0, [rsi] ; *py
vmovaps ymm0, [rdi+rsi+32] ; write-only reuse of ymm0
vfmadd231ps ymm2, ymm0, [rsi+32]
vmovaps ymm0, [rdi+rsi+64]
vfmadd231ps ymm3, ymm0, [rsi+64]
vmovaps ymm0, [rdi+rsi+96]
vfmadd231ps ymm4, ymm0, [rsi+96]
add rsi, 256 ; pointer-increment here
; so the following instructions can still use disp8 in their addressing modes: [-128 .. +127] instead of disp32
; smaller code-size helps in the big picture, but not for a micro-benchmark
vmovaps ymm0, [rdi+rsi+128-256] ; be pedantic in the source about compensating for the pointer-increment
vfmadd231ps ymm5, ymm0, [rsi+128-256]
vmovaps ymm0, [rdi+rsi+160-256]
vfmadd231ps ymm6, ymm0, [rsi+160-256]
vmovaps ymm0, [rdi+rsi-64] ; or not
vfmadd231ps ymm7, ymm0, [rsi-64]
vmovaps ymm0, [rdi+rsi-32]
vfmadd231ps ymm8, ymm0, [rsi-32]
cmp rsi, rdx
jb .unroll8 ; } while(py < endy);
Использование неиндексированного режима адресации в качестве операнда памяти для vfmaddps
позволяет ему оставаться микроконфигурированным в ядре вне порядка, вместо того, чтобы быть без ламинирования. Режимы микросовключения и адресации
Таким образом, моя петля - это 18 fused-domain uops для 8 векторов. Для каждой пары vmovaps + vfmaddps вместо 2 требуется 3 плавных домена, из-за отсутствия ламинирования индексированных режимов адресации. У обоих из них все еще есть 2 неуправляемых домена uops (port2/3) на пару, так что все еще узкое место.
Меньшее количество фьюзических доменов uops позволяет выйти из строя, чтобы увидеть больше итераций вперед, потенциально помогая им поглощать недостатки кэша лучше. Это незначительная вещь, когда мы узлы в исполнительном блоке (в этом случае загрузите uops) даже без пропусков в кэше. Но с помощью hyperthreading вы получаете только каждый цикл полосы пропускания переднего плана, если другой поток не застопорился. Если он не слишком сильно конкурирует с нагрузкой и p0/1, меньшее количество флагов с объединенными доменами позволит этому циклу работать быстрее при совместном использовании ядра. (например, может быть, другой гиперпоток запускает много port5/port6 и сохраняет uops?)
Так как un-lamination происходит после uop-cache, ваша версия не занимает дополнительного места в кэше uop. Disp32 с каждым uop в порядке и не занимает лишнего места. Но более объемный размер кода означает, что uop-cache с меньшей вероятностью будет паковать так же эффективно, так как вы попадете в границы 32B до того, как строки кэша uop будут заполнены чаще. (На самом деле, меньший код также не гарантирует лучшего. Меньшие инструкции могут привести к заполнению строки кэша uop и необходимости одной записи в другой строке до пересечения границы 32B.) Этот небольшой цикл может выполняться из буфера loopback (LSD), поэтому к счастью, uop-cache не является фактором.
Затем после цикла: Эффективная очистка - это трудная часть эффективной векторизации для небольших массивов, которая не может быть кратной коэффициенту unroll или особенно ширине вектора
...
jb
;; If `n` might not be a multiple of 4x 8 floats, put cleanup code here
;; to do the last few ymm or xmm vectors, then scalar or an unaligned last vector + mask.
; reduce down to a single vector, with a tree of dependencies
vaddps ymm1, ymm2, ymm1
vaddps ymm3, ymm4, ymm3
vaddps ymm5, ymm6, ymm5
vaddps ymm7, ymm8, ymm7
vaddps ymm0, ymm3, ymm1
vaddps ymm1, ymm7, ymm5
vaddps ymm0, ymm1, ymm0
; horizontal within that vector, low_half += high_half until we're down to 1
vextractf128 xmm1, ymm0, 1
vaddps xmm0, xmm0, xmm1
vmovhlps xmm1, xmm0, xmm0
vaddps xmm0, xmm0, xmm1
vmovshdup xmm1, xmm0
vaddss xmm0, xmm1
; this is faster than 2x vhaddps
vzeroupper ; important if returning to non-AVX-aware code after using ymm regs.
ret ; with the scalar result in xmm0
Подробнее о горизонтальной сумме в конце см. Самый быстрый способ сделать горизонтальную векторную сумму float на x86. В двух табуляции 128b, которые я использовал, даже не требуется немедленный байт управления, поэтому он сохраняет 2 байта размера кода по сравнению с более очевидным shufps
. (И 4 байта кода-размера против vpermilps
, потому что этот код операции всегда нужен 3-байтовый префикс VEX, а также немедленный). AVX 3-операнд очень хорош по сравнению с SSE, особенно при написании на C со встроенными функциями, поэтому вы не можете легко выбрать холодный регистр на movhlps
в.