c++ - 了解较长代码执行速度提高 4 倍的微架构原因(AMD Zen 2 架构)

标签 c++ assembly x86 cpu-architecture amd-processor

我在 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);
在第一个循环中,foostd::shared_ptreval()是一个虚方法:
__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)代码。
    这是两个循环的生成代码,以及在 上执行时以毫秒为单位的运行时间(对于 100M 迭代)。 AMD 线程撕裂者 3970X CPU(Zen 2 架构):
  • 使用 operator+=(Vec4f&, const Vec4f&) 的 SSE 内在实现:
    // 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 上,据我所知,addssaddps 指令有 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;
    }
    

    最佳答案

    我正在英特尔 Haswell 处理器上对此进行测试,但性能结果相似,我猜原因也相似,但对此持保留态度。 Haswell 和 Zen 2 之间当然存在差异,但据我所知,我所指责的效果应该适用于它们。
    问题是:虚拟方法/通过指针调用的函数/无论它是什么,都会进行 4 次标量存储,但是主循环会进行相同内存的 vector 加载。 Store-to-load forwarding 可以处理存储一个值然后立即加载的各种情况,但通常不是像这样一个负载依赖于多个存储的情况(更一般地说:负载依赖于仅部分提供的存储负载尝试加载的数据)。假设它可能是可能的,但它不是当前微架构的特征。
    作为实验,更改虚拟方法中的代码以使用 vector 存储。例如:

    __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 上,它使时间与快速版本一致,这支持了问题是由多个标量存储输入 vector 负载引起的假设。
    从 8 字节加载 16 字节 Vec2f不完全合法,必要时可以解决。只有 SSE(1) 有点烦人,SSE3 对于 _mm_loaddup_pd 会很好(又名 movddup)。
    如果 MSVC 返回 Vec4f,这个问题就不会存在。结果通过寄存器而不是通过出指针,但我不知道如何说服它这样做,除了将返回类型更改为 __m128 . __vectorcall也有帮助,但让 MSVC 在几个寄存器中返回结构,然后在调用者中重新组合并进行额外的洗牌。它比任何一个快速选项都有些困惑和慢,但仍然比存储转发失败的版本快。

    关于c++ - 了解较长代码执行速度提高 4 倍的微架构原因(AMD Zen 2 架构),我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/66438609/

    相关文章:

    c++ - C++中的字符串加法关联性

    linux - NASM scanf 未定义引用 (LINUX)

    c - 如何使用 SSE 进行 uint32/float 转换?

    c++ - Nasm,C++,传递类对象

    c++ - 编辑控件MFC中光标位置改变时是否有通知代码?

    c++ - 我应该从动态指针中删除一个 moved 吗

    c++ - 使用 for_each 时出错

    C内联汇编帮助(digital mars c编译器)

    c - sys_schedule() 在 Minix 3.1.8 中做什么?

    macos - 如何使用 mach_absolute_time 而不溢出?