Я оптимизировал код и наткнулся на странный случай. Вот два ассемблерных кода:
; FAST
lea rcx,[rsp+50h]
call qword ptr [Random_get_float3] ;this function only writes 3 components
movaps xmm0,xmmword ptr [rsp+50h]
lea rbx,[rbx+0Ch]
mulps xmm0,xmm6
movlps qword ptr [rbx-0Ch],xmm0
movaps xmmword ptr [rsp+50h],xmm0
extractps eax,xmm0,2
mov dword ptr [rbx-4],eax
; SLOW
lea rcx,[rsp+50h]
call qword ptr [Random_get_float3] ;this function only writes 3 components
movaps xmm0,xmmword ptr [rsp+50h]
lea rbx,[rbx+0Ch]
mulps xmm0,xmm6
movlps qword ptr [rbx-0Ch],xmm0
extractps eax,xmm0,2
mov dword ptr [rbx-4],eax
Обе версии выполняются 10000 раз в тесном цикле (один и тот же код цикла опущен). Как видите, сборки абсолютно одинаковые, за исключением одной лишней movaps xmmword ptr [rsp+50h],xmm0
инструкции в быстрой версии.
На самом деле это пустая операция, потому что rsp+50h будет перезаписан на следующей итерации:
lea rcx,[rsp+50h]
call qword ptr [Random_get_float3]
Что интересно в этом коде, так это то, что медленная версия в два раза медленнее быстрой, но в ней отсутствует одна дополнительная бесполезная инструкция.
Может кто-нибудь объяснить, почему?
Код C++ (скомпилированный с помощью MSVC v140 с VS 2022):
#include <immintrin.h>
#include <cstdlib>
__declspec(noinline) void random_get_float3(float* vec3) {
int v = rand();
vec3[0] = *(float*)&v;
v = rand();
vec3[1] = *(float*)&v;
v = rand();
vec3[2] = *(float*)&v;
vec3[0] = powf(vec3[0], 1.0f / 3.0f);
vec3[1] = powf(vec3[1], 1.0f / 3.0f);
vec3[2] = powf(vec3[2], 1.0f / 3.0f);
}
void* randomGetFuncPtr = &random_get_float3;
// Not aligned by 16.
struct Vector3 {
float x, y, z;
};
struct Vector3Array {
size_t length;
Vector3* m_Items;
};
static bool inited = false;
Vector3 scaledRandomPosExtern = Vector3{ 0.5f, 0.5f, 0.5f };
Vector3Array randomPositions;
#define __SLOW // comment to enable fast version.
int numObjectsExtern = 10000;
void TestFunc()
{
int numObjects = numObjectsExtern;
if (!inited) {
randomPositions = {
10000,
new Vector3[10000]
};
inited = true;
}
typedef void (*Random_get_float3_fptr) (__m128* __restrict);
Random_get_float3_fptr _il2cpp_icall_func = (Random_get_float3_fptr)randomGetFuncPtr;
Vector3 scaledRandomPos = scaledRandomPosExtern;
__m128 scaledRandomPosVec = _mm_setr_ps(scaledRandomPos.x, scaledRandomPos.y, scaledRandomPos.z, 0.0f);
Vector3Array* outputArray = &randomPositions;
int* items = (int*)&outputArray->m_Items[0];
for (int i = 0; i < numObjects; i++) {
__m128 v1;
_il2cpp_icall_func(&v1);
#ifdef __SLOW
__m128 v3;
v3 = _mm_mul_ps(v1, scaledRandomPosVec);
#define RESVEC v3
#else
v1 = _mm_mul_ps(v1, scaledRandomPosVec);
#define RESVEC v1
#endif
_mm_storel_pi((__m64*)(items), RESVEC);
items[2] = _mm_extract_ps(RESVEC, 2);
items += 3;
}
}
Воспроизводимо на
ПРОЦЕССОР:
AMD Ryzen 7 3700x Windows 10 19045.3930
Другие процессоры Ryzen
Невозможно воспроизвести на процессорах Intel.
@chtz random_get_float3 находится внутри движка с закрытым исходным кодом. Он записывает 3 компонента вектора, предоставленного указателем. В моем случае я использую указатель на __m128. Я могу точно сказать, что Random_get_float3 внутри не векторизован должным образом.
Можете ли вы заменить ее (random_get_float3
) фиктивной функцией, которая обеспечивает такое же поведение? Кроме того, попробуйте сделать свой код на C++ минимально воспроизводимым примером (т. е. чем-то, что можно скопировать+вставить и напрямую скомпилировать)?
@chtz да, я добавил пример в пост, также протестированный на msvc v19.latest в Compiler Explorer, он отражает изменения для определения __SLOW
Также интересно, что если я удалю 3 вызова powf, производительность медленной и быстрой версии будет идеально совпадать, поэтому избыточная запись результата xmm в стек не повлияет на производительность в положительную сторону.
Это не полный отказ от операций, поскольку четвертый компонент не перезаписывается вашей случайной функцией. Происходит ли то же самое, когда вы записываете нули в слот стека?
@fuz, вы правы, хотя функция не встроена и компилятор не знает о вызываемой функции, полная перезапись значения стека на самом деле делает ее быстрее.
Может ли неиспользованное значение v1
в варианте SLOW начинаться с ненормального значения? В этом случае умножение каждый раз может быть медленным. В варианте FAST начиная со второй итерации это будет 0.
@chtz да, вы правы, если я вручную запишу 4-й компонент числа с плавающей точкой в ноль, то код будет выполняться намного быстрее (так же, как вариант FAST) с обоими определениями. Если я записываю значение denorm с плавающей запятой в 4-й компонент, код в любом случае выполняется медленно. Странно, что на Интелах этого не происходит.
@Alex Я думаю, что Intel лучше справляется с денормализаторами. Я даже не рассматривал такую возможность!
@fuz @chtz спасибо за помощь! Очень ценю это. Clang по какой-то причине выдает неоптимальный код, потому что использует только 2 регистра xmm и сбрасывает ScaledRandomPosVec в стек, а затем использует mulps xmm0, xmm0, [rsp+scaledRandomPosVec]
. Обычно clang намного лучше оптимизирует SIMD, но теперь мне действительно интересно, почему это происходит.
Ваш C++ работает с неинициализированным значением, что технически делает его UB (я предполагаю, что это так, даже если результат этой операции никогда не используется). У компиляторов могут возникнуть проблемы с обнаружением этого, если значение является частью регистра SIMD. Обычно я стараюсь всегда инициализировать переменные (т. е. не объявлять их перед первым использованием), но это, вероятно, также вопрос стиля.
mulps xmm0, xmm0, [rsp+scaledRandomPosVec]
не обязательно плохо, если вы не ограничены IO. Для Linux x86_64 ABI он в любом случае более или менее должен загружать это из памяти, если вызываются функции, реализация которых неизвестна компилятору (поскольку им разрешено уничтожать любой SIMD-регистр) - я точно не знаю о Win64, но, вероятно, они резервируют некоторые регистры для сохранения вызываемым объектом.
@chtz да, написание denorm вручную замедляет работу кода, а дополнительные перемещения обратно в стек кажутся ошибкой MSVC (если бы это не так, можно было бы просто инициализировать его с самого начала), и clang тоже его не добавляет. В Intel это ничего не меняет, независимо от того, является ли 4-й компонент денормированным или нет. Да, clang не знает, коснется ли функция других xmm или нет, возможно, он пытается перестраховаться, поскольку функция не обязана сбрасывать затронутый регистр xmm. На винде похоже это не так.
@chtz: Да, в Windows x64 много регистров XMM, сохраняемых при вызовах, я бы сказал, слишком много. XMM6-15 сохраняются по вызову, оставляя только 6 регистров для игры без необходимости что-либо сохранять/восстанавливать. Learn.microsoft.com/en-us/cpp/build/… . (И да, x86-64 System V, к сожалению, не имеет векторных регистров с сохранением вызовов, которых слишком мало для многих случаев использования. XMM 6 и 7 или 14 и 15 были бы хорошим выбором.)
Спасибо @chtz и @fuz!
Оказывается, эта дополнительная инструкция скопировала результат умножения, где 4-й компонент был обычным числом с плавающей запятой. Без этой дополнительной инструкции 4-й компонент вектора не был инициализирован и представлял собой денормированное число с плавающей запятой, что приводило к замедлению вычислений.
Если вы вручную установите для 4-го компонента значение денормирования с плавающей запятой, то каждая операция mulps
будет выполняться примерно на 20 % медленнее, а инициализация 4-го компонента нулем устранит эти накладные расходы.
На процессорах Intel не имеет значения, является ли это число нормальным или денормальным, оно не влияет на скорость вычислений.
Эта дополнительная инструкция, скорее всего, является ошибкой оптимизатора MSVC, поскольку ее там не должно было быть, но она случайно ускорила код.
В некоторых случаях некоторые процессоры Intel могут замедляться из-за денормализации. См. раздел Sandybridge руководства по микроархам Агнера Фога (agner.org/optimize). Есть несколько случаев, когда штрафы за денормалы не предусмотрены, и вы, возможно, сталкиваетесь с этим. Или, может быть, новые процессоры Intel в большинстве случаев устраняют штрафы.
Кроме того, векторная загрузка, которая перезагружает несколько скалярных хранилищ, всегда будет вызывать остановку пересылки хранилища на Intel или AMD, так что это тоже нехорошо. (Какова стоимость неудачной пересылки из магазина в загрузку на x86?). Гораздо лучше сгенерировать целый вектор случайных чисел с плавающей запятой (с помощью SIMD PRNG) и замаскировать верхний элемент до нуля или чего-то еще. Или не маскируйте, если этот элемент вас не волнует, пока ваш ГПСЧ не генерирует денормализованные значения.
@PeterCordes так круто! Спасибо за советы
Содержание
Random_get_float3
было бы интересно. О каких "3 компонентах" здесь написано?