Производительность SIMD в два раза медленнее без дополнительной копии

Я оптимизировал код и наткнулся на странный случай. Вот два ассемблерных кода:

; 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;
  }
}

Зеркало в Compiler Explorer

Воспроизводимо на
ПРОЦЕССОР: AMD Ryzen 7 3700x Windows 10 19045.3930
Другие процессоры Ryzen
Невозможно воспроизвести на процессорах Intel.

Содержание Random_get_float3 было бы интересно. О каких "3 компонентах" здесь написано?

chtz 19.07.2024 11:32

@chtz random_get_float3 находится внутри движка с закрытым исходным кодом. Он записывает 3 компонента вектора, предоставленного указателем. В моем случае я использую указатель на __m128. Я могу точно сказать, что Random_get_float3 внутри не векторизован должным образом.

Alex 19.07.2024 11:35

Можете ли вы заменить ее (random_get_float3) фиктивной функцией, которая обеспечивает такое же поведение? Кроме того, попробуйте сделать свой код на C++ минимально воспроизводимым примером (т. е. чем-то, что можно скопировать+вставить и напрямую скомпилировать)?

chtz 19.07.2024 11:38

@chtz да, я добавил пример в пост, также протестированный на msvc v19.latest в Compiler Explorer, он отражает изменения для определения __SLOW

Alex 19.07.2024 11:58

Также интересно, что если я удалю 3 вызова powf, производительность медленной и быстрой версии будет идеально совпадать, поэтому избыточная запись результата xmm в стек не повлияет на производительность в положительную сторону.

Alex 19.07.2024 12:16

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

fuz 19.07.2024 12:24

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

Alex 19.07.2024 13:07

Может ли неиспользованное значение v1 в варианте SLOW начинаться с ненормального значения? В этом случае умножение каждый раз может быть медленным. В варианте FAST начиная со второй итерации это будет 0.

chtz 19.07.2024 14:11

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

Alex 19.07.2024 14:21

@Alex Я думаю, что Intel лучше справляется с денормализаторами. Я даже не рассматривал такую ​​возможность!

fuz 19.07.2024 14:30

@fuz @chtz спасибо за помощь! Очень ценю это. Clang по какой-то причине выдает неоптимальный код, потому что использует только 2 регистра xmm и сбрасывает ScaledRandomPosVec в стек, а затем использует mulps xmm0, xmm0, [rsp+scaledRandomPosVec]. Обычно clang намного лучше оптимизирует SIMD, но теперь мне действительно интересно, почему это происходит.

Alex 19.07.2024 14:57

Ваш C++ работает с неинициализированным значением, что технически делает его UB (я предполагаю, что это так, даже если результат этой операции никогда не используется). У компиляторов могут возникнуть проблемы с обнаружением этого, если значение является частью регистра SIMD. Обычно я стараюсь всегда инициализировать переменные (т. е. не объявлять их перед первым использованием), но это, вероятно, также вопрос стиля.

chtz 19.07.2024 15:17
mulps xmm0, xmm0, [rsp+scaledRandomPosVec] не обязательно плохо, если вы не ограничены IO. Для Linux x86_64 ABI он в любом случае более или менее должен загружать это из памяти, если вызываются функции, реализация которых неизвестна компилятору (поскольку им разрешено уничтожать любой SIMD-регистр) - я точно не знаю о Win64, но, вероятно, они резервируют некоторые регистры для сохранения вызываемым объектом.
chtz 19.07.2024 15:22

@chtz да, написание denorm вручную замедляет работу кода, а дополнительные перемещения обратно в стек кажутся ошибкой MSVC (если бы это не так, можно было бы просто инициализировать его с самого начала), и clang тоже его не добавляет. В Intel это ничего не меняет, независимо от того, является ли 4-й компонент денормированным или нет. Да, clang не знает, коснется ли функция других xmm или нет, возможно, он пытается перестраховаться, поскольку функция не обязана сбрасывать затронутый регистр xmm. На винде похоже это не так.

Alex 19.07.2024 15:57

@chtz: Да, в Windows x64 много регистров XMM, сохраняемых при вызовах, я бы сказал, слишком много. XMM6-15 сохраняются по вызову, оставляя только 6 регистров для игры без необходимости что-либо сохранять/восстанавливать. Learn.microsoft.com/en-us/cpp/build/… . (И да, x86-64 System V, к сожалению, не имеет векторных регистров с сохранением вызовов, которых слишком мало для многих случаев использования. XMM ​​6 и 7 или 14 и 15 были бы хорошим выбором.)

Peter Cordes 20.07.2024 02:02
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
3
15
108
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Спасибо @chtz и @fuz!

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

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

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

В некоторых случаях некоторые процессоры Intel могут замедляться из-за денормализации. См. раздел Sandybridge руководства по микроархам Агнера Фога (agner.org/optimize). Есть несколько случаев, когда штрафы за денормалы не предусмотрены, и вы, возможно, сталкиваетесь с этим. Или, может быть, новые процессоры Intel в большинстве случаев устраняют штрафы.

Peter Cordes 19.07.2024 21:32

Кроме того, векторная загрузка, которая перезагружает несколько скалярных хранилищ, всегда будет вызывать остановку пересылки хранилища на Intel или AMD, так что это тоже нехорошо. (Какова стоимость неудачной пересылки из магазина в загрузку на x86?). Гораздо лучше сгенерировать целый вектор случайных чисел с плавающей запятой (с помощью SIMD PRNG) и замаскировать верхний элемент до нуля или чего-то еще. Или не маскируйте, если этот элемент вас не волнует, пока ваш ГПСЧ не генерирует денормализованные значения.

Peter Cordes 19.07.2024 21:34

@PeterCordes так круто! Спасибо за советы

Alex 22.07.2024 12:29

Другие вопросы по теме