У меня есть следующий код C ++ 17, который я компилирую с VS 2019 (версия 16.8.6) в режиме x64:
struct __declspec(align(16)) Vec2f { float v[2]; };
struct __declspec(align(16)) Vec4f { float v[4]; };
static constexpr std::uint64_t N = 100'000'000ull;
const Vec2f p{};
Vec4f acc{};
// Using virtual method:
for (std::uint64_t i = 0; i < N; ++i)
acc += foo->eval(p);
// Using function pointer:
for (std::uint64_t i = 0; i < N; ++i)
acc += eval_fn(p);
В первом цикле foo
- это std::shared_ptr
, а eval()
- виртуальный метод:
__declspec(noinline) virtual Vec4f eval(const Vec2f& p) const noexcept
{
return { p.v[0], p.v[1], p.v[0], p.v[1] };
}
Во втором цикле eval_fn
- указатель на функцию ниже:
__declspec(noinline) Vec4f eval_fn_impl(const Vec2f& p) noexcept
{
return { p.v[0], p.v[1], p.v[0], p.v[1] };
}
Наконец, у меня есть две реализации operator+=
для Vec4f
:
Один реализован с использованием явного цикла:
Vec4f& operator+=(Vec4f& lhs, const Vec4f& rhs) noexcept { for (std::uint32_t i = 0; i < 4; ++i) lhs.v[i] += rhs.v[i]; return lhs; }
И один реализован с помощью встроенного SSE:
Vec4f& operator+=(Vec4f& lhs, const Vec4f& rhs) noexcept { _mm_store_ps(lhs.v, _mm_add_ps(_mm_load_ps(lhs.v), _mm_load_ps(rhs.v))); return lhs; }
Вы можете найти полный (автономный, только для Windows) код теста ниже.
Вот сгенерированный код для двух циклов, а также время выполнения в миллисекундах (для 100 миллионов итераций) при выполнении на процессоре AMD Threadripper 3970X (архитектура Zen 2):
С внутренней реализацией SSE
operator+=(Vec4f&, const Vec4f&)
:// Using virtual method: 649 ms $LL4@main: mov rax, QWORD PTR [rdi] // fetch vtable base pointer (rdi = foo) lea r8, QWORD PTR p$[rsp] // r8 = &p lea rdx, QWORD PTR $T3[rsp] // not sure what $T3 is (some kind of temporary, but why?) mov rcx, rdi // rcx = this call QWORD PTR [rax] // foo->eval(p) addps xmm6, XMMWORD PTR [rax] sub rbp, 1 jne SHORT $LL4@main // Using function pointer: 602 ms $LL7@main: lea rdx, QWORD PTR p$[rsp] // rdx = &p lea rcx, QWORD PTR $T2[rsp] // same question as above call rbx // eval_fn(p) addps xmm6, XMMWORD PTR [rax] sub rsi, 1 jne SHORT $LL7@main
С реализацией явного цикла
operator+=(Vec4f&, const Vec4f&)
:// Using virtual method: 167 ms [3.5x to 4x FASTER!] $LL4@main: mov rax, QWORD PTR [rdi] lea r8, QWORD PTR p$[rsp] lea rdx, QWORD PTR $T5[rsp] mov rcx, rdi call QWORD PTR [rax] addss xmm9, DWORD PTR [rax] addss xmm8, DWORD PTR [rax+4] addss xmm7, DWORD PTR [rax+8] addss xmm6, DWORD PTR [rax+12] sub rbp, 1 jne SHORT $LL4@main // Using function pointer: 600 ms $LL7@main: lea rdx, QWORD PTR p$[rsp] lea rcx, QWORD PTR $T4[rsp] call rbx addps xmm6, XMMWORD PTR [rax] sub rsi, 1 jne SHORT $LL7@main
(Насколько я могу судить, в архитектуре AMD Zen 2 инструкции addss
и addps
имеют задержку в 3 цикла, и до двух таких инструкций могут выполняться одновременно.)
Случай, который меня озадачивает, - это использование виртуального метода и явной реализации цикла operator+=
:
Почему он в 3,5–4 раза быстрее трех других вариантов?
Какие архитектурные эффекты здесь задействованы? Меньше зависимостей между регистрами при последующих итерациях цикла? Или какое-то невезение с кешированием?
Полный исходный код:
#include <Windows.h>
#include <cstdint>
#include <cstdio>
#include <memory>
#include <xmmintrin.h>
struct __declspec(align(16)) Vec2f
{
float v[2];
};
struct __declspec(align(16)) Vec4f
{
float v[4];
};
Vec4f& operator+=(Vec4f& lhs, const Vec4f& rhs) noexcept
{
#if 0
_mm_store_ps(lhs.v, _mm_add_ps(_mm_load_ps(lhs.v), _mm_load_ps(rhs.v)));
#else
for (std::uint32_t i = 0; i < 4; ++i)
lhs.v[i] += rhs.v[i];
#endif
return lhs;
}
std::uint64_t get_timer_freq()
{
LARGE_INTEGER frequency;
QueryPerformanceFrequency(&frequency);
return static_cast<std::uint64_t>(frequency.QuadPart);
}
std::uint64_t read_timer()
{
LARGE_INTEGER count;
QueryPerformanceCounter(&count);
return static_cast<std::uint64_t>(count.QuadPart);
}
struct Foo
{
__declspec(noinline) virtual Vec4f eval(const Vec2f& p) const noexcept
{
return { p.v[0], p.v[1], p.v[0], p.v[1] };
}
};
using SampleFn = Vec4f (*)(const Vec2f&);
__declspec(noinline) Vec4f eval_fn_impl(const Vec2f& p) noexcept
{
return { p.v[0], p.v[1], p.v[0], p.v[1] };
}
__declspec(noinline) SampleFn make_eval_fn()
{
return &eval_fn_impl;
}
int main()
{
static constexpr std::uint64_t N = 100'000'000ull;
const auto timer_freq = get_timer_freq();
const Vec2f p{};
Vec4f acc{};
{
const auto foo = std::make_shared<Foo>();
const auto start_time = read_timer();
for (std::uint64_t i = 0; i < N; ++i)
acc += foo->eval(p);
std::printf("foo->eval: %llu ms\n", 1000 * (read_timer() - start_time) / timer_freq);
}
{
const auto eval_fn = make_eval_fn();
const auto start_time = read_timer();
for (std::uint64_t i = 0; i < N; ++i)
acc += eval_fn(p);
std::printf("eval_fn: %llu ms\n", 1000 * (read_timer() - start_time) / timer_freq);
}
return acc.v[0] + acc.v[1] + acc.v[2] + acc.v[3] > 0.0f ? 1 : 0;
}
__vectorcall
, которое передает / возвращает__m128
в регистрах XMM? (Если это даже поможет, еслиVec4f
не обрабатывается так же, как__m128
). Интересно, является ли пересылка хранилища проблемой, если вызываемый объект выполняет скалярные хранилища. (обновление, это то, что предлагает Гарольд). - person Peter Cordes   schedule 02.03.2021