Сценарий: вы пишете сложный алгоритм с использованием SIMD. Используется несколько констант и / или редко меняющихся значений. В конечном итоге алгоритм использует более 16 ymm
, что приводит к использованию указателей стека (например, код операции содержит vaddps ymm0,ymm1,ymmword ptr [...]
вместо vaddps ymm0,ymm1,ymm7
).
Чтобы алгоритм вписывался в доступные регистры, константы могут быть «встроенными». Например:
const auto pi256{ _mm256_set1_ps(PI) };
for (outer condition)
{
...
const auto radius_squared{ _mm256_mul_ps(radius, radius) };
...
for (inner condition)
{
...
const auto area{ _mm256_mul_ps(radius_squared, pi256) };
...
}
}
… становится …
for (outer condition)
{
...
for (inner condition)
{
...
const auto area{ _mm256_mul_ps(_mm256_mul_ps(radius, radius), _mm256_set1_ps(PI)) };
...
}
}
Независимо от того, является ли рассматриваемая одноразовая переменная постоянной или она вычисляется редко (вычисляется по внешнему контуру), как можно определить, какой подход обеспечивает наилучшую пропускную способность? Это вопрос какой-то концепции, такой как «ptr добавляет 2 дополнительные задержки»? Или он является недетерминированным, так что он отличается в каждом конкретном случае и может быть полностью оптимизирован только методом проб и ошибок + профилирования?
Хороший оптимизирующий компилятор должен генерировать одинаковый машинный код для обеих версий. Просто определите ваши векторные константы как локальные или используйте их анонимно для максимальной читабельности; позвольте компилятору беспокоиться о распределении регистров и выберите самый дешевый способ справиться с исчерпанием регистров, если это произойдет.
Лучше всего помогать компилятору, если возможно, использовать меньше разных констант. например вместо _mm_and_si128
с обоими set1_epi16(0x00FF)
а также 0xFF00
использовать _mm_andn_si128
маскировать другой путь. Обычно вы ничего не можете сделать, чтобы повлиять на то, что он решает хранить в регистрах, а не на, но, к счастью, компиляторы довольно хороши в этом, потому что это также важно для скалярного кода.
Компилятор выведет константы из цикла (даже вставляя вспомогательную функцию, содержащую константы), или, если используется только на одной стороне ветви, перенесет установку в эту сторону ветви.
Исходный код вычисляет одно и то же без каких-либо различий в видимых побочных эффектах, поэтому правило «как будто» дает компилятору свободу делать это.
Я думаю, что компиляторы, как правило, распределяют регистры и выбирают, что нужно пролить / перезагрузить (или просто использовать векторную константу только для чтения) после выполнения CSE (устранения общего подвыражения) и определения инвариантов цикла и констант, которые можно поднять.
Когда он обнаруживает, что у него недостаточно регистров, чтобы хранить все переменные и константы в регистрах внутри цикла, первый выбор для чего-то не keep в регистре обычно был бы инвариантным для цикла вектором, либо константой времени компиляции, либо чем-то, вычисляемым до цикла.
Дополнительная загрузка, которая попадает в кэш L1d, дешевле, чем хранение (иначе говоря, разлив) / перезагрузка переменной внутри цикла. Таким образом, компиляторы будут выбирать загрузку констант из памяти независимо от того, где вы поместили определение в исходный код.
Частью написания на C ++ является то, что у вас есть компилятор, чтобы принять это решение за вас. Поскольку разрешено делать одно и то же для обоих источников, выполнение разных действий будет пропущенной оптимизацией по крайней мере для одного из случаев. (Лучшее, что можно сделать в каждом конкретном случае, зависит от окружающего кода, но обычно использование векторных констант в качестве операндов источника памяти хорошо, когда компилятору не хватает регистров.)
Это вопрос какой-то концепции, такой как «ptr добавляет 2 дополнительные задержки»?
Микросинтез операнда источника памяти не удлиняет критический путь от непостоянного входа до выхода. Load uop может начаться, как только адрес будет готов, а для векторных констант это обычно либо RIP-относительный, либо [rsp+constant]
режим адресации. Поэтому обычно загрузка готова к выполнению, как только она поступает в вышедшую из строя часть ядра. Предполагая попадание в кэш L1d (поскольку он будет оставаться горячим в кэше, если загружается при каждой итерации цикла), это всего лишь ~ 5 циклов, поэтому он легко будет готов во времени, если на входе векторного регистра будет узкое место в цепочке зависимостей.
Это даже не повредит фронтальной пропускной способности. Если вы не ограничены пропускной способностью порта загрузки (2 загрузки в такт на современных процессорах x86), это, как правило, не имеет значения. (Даже с высокоточными методами измерения.)
Других решений пока нет …