了解较长代码执行速度提高 4 倍的微架构原因(AMD Zen 2 架构)
Understanding micro-architectural causes for longer code to execute 4x faster (AMD Zen 2 architecture)
我在 x64 模式下使用 VS 2019(版本 16.8.6)编译了以下 C++17 代码:
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-only)测试代码。
这是在 AMD Threadripper 3970X CPU(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 arch 上,据我所知,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;
}
我正在 Intel Haswell 处理器上对此进行测试,但性能结果相似,我猜原因也相似,但对此持保留态度。 Haswell 和 Zen 2 之间当然存在差异,但据我所知,我所指责的效果应该适用于两者。
问题是:虚拟 method/function-called-via-pointer/whatever 确实是 4 个标量存储,但随后主循环对同一内存进行矢量加载。 Store-to-load 转发可以处理存储一个值然后立即加载的各种情况,但通常不会像这样的情况,其中负载依赖于多个存储(更一般地说:负载依赖于仅部分供应的存储负载尝试加载的数据)。假设这可能是可能的,但这不是当前微体系结构的特征。
作为实验,更改虚拟方法中的代码以使用向量存储。例如:
__declspec(noinline) virtual Vec4f eval(const Vec2f& p) const noexcept
{
Vec4f r;
auto pv = _mm_load_ps(p.v);
_mm_store_ps(r.v, _mm_shuffle_ps(pv, pv, _MM_SHUFFLE(1, 0, 1, 0)));
return r;
}
在我的 PC 上,使时间恢复与快速版本一致,这支持了问题是由多个标量存储馈入向量负载引起的假设。
从 8 字节加载 16 字节 Vec2f
不完全合法,必要时可以解决。只有 SSE(1) 有点烦人,SSE3 对 _mm_loaddup_pd
(又名 movddup
)来说会很好。
如果 MSVC return通过寄存器而不是通过外指针编辑 Vec4f
结果,则不会存在此问题,但我不知道如何说服它这样做那,除了将 return 类型更改为 __m128
。 __vectorcall
也有帮助,但使 MSVC return 成为 多个寄存器 中的结构,然后在调用者中通过额外的随机播放重新组合。它有点乱,比任何一个快速选项都慢,但仍然比存储转发失败的版本快。
我在 x64 模式下使用 VS 2019(版本 16.8.6)编译了以下 C++17 代码:
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-only)测试代码。
这是在 AMD Threadripper 3970X CPU(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 arch 上,据我所知,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;
}
我正在 Intel Haswell 处理器上对此进行测试,但性能结果相似,我猜原因也相似,但对此持保留态度。 Haswell 和 Zen 2 之间当然存在差异,但据我所知,我所指责的效果应该适用于两者。
问题是:虚拟 method/function-called-via-pointer/whatever 确实是 4 个标量存储,但随后主循环对同一内存进行矢量加载。 Store-to-load 转发可以处理存储一个值然后立即加载的各种情况,但通常不会像这样的情况,其中负载依赖于多个存储(更一般地说:负载依赖于仅部分供应的存储负载尝试加载的数据)。假设这可能是可能的,但这不是当前微体系结构的特征。
作为实验,更改虚拟方法中的代码以使用向量存储。例如:
__declspec(noinline) virtual Vec4f eval(const Vec2f& p) const noexcept
{
Vec4f r;
auto pv = _mm_load_ps(p.v);
_mm_store_ps(r.v, _mm_shuffle_ps(pv, pv, _MM_SHUFFLE(1, 0, 1, 0)));
return r;
}
在我的 PC 上,使时间恢复与快速版本一致,这支持了问题是由多个标量存储馈入向量负载引起的假设。
从 8 字节加载 16 字节 Vec2f
不完全合法,必要时可以解决。只有 SSE(1) 有点烦人,SSE3 对 _mm_loaddup_pd
(又名 movddup
)来说会很好。
如果 MSVC return通过寄存器而不是通过外指针编辑 Vec4f
结果,则不会存在此问题,但我不知道如何说服它这样做那,除了将 return 类型更改为 __m128
。 __vectorcall
也有帮助,但使 MSVC return 成为 多个寄存器 中的结构,然后在调用者中通过额外的随机播放重新组合。它有点乱,比任何一个快速选项都慢,但仍然比存储转发失败的版本快。