了解微架构的原因,导致更长的代码执行速度提高4倍(AMD Zen 2架构)

弗朗索瓦·波恩(Francois Beaune)

我有以下x ++模式下与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+=for的实现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)代码。

以下是在AMD Threadripper 3970X CPU(Zen 2架构)上执行时,两个循环的生成代码,以及以毫秒为单位的运行时间(对于100M迭代):

  • 通过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禅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;
}
哈罗德

我正在Intel Haswell处理器上对此进行测试,但是性能结果相似,我猜想原因也相似,但要花点时间。Haswell和Zen 2之间当然会有区别,但是据我所知,我为此归咎于这两个方面。

问题是:虚拟方法/被称为“通过指针的函数” /无论它是什么,都执行4个标量存储,但是主循环随后执行了该相同内存的向量加载。从存储到加载的转发可以处理先存储值然后立即加载的各种情况,但通常不是这样的情况,即加载取决于多个存储(更一般而言:依赖于仅部分供应的存储的加载)加载尝试加载的数据)。从理论上讲,这是可能的,但这并不是当前微体系结构的功能。

作为实验,请更改虚拟方法中的代码以使用向量存储。例如:

__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(aka movddup来说是不错的选择

如果MSVCVec4f通过寄存器而不是通过指针返回结果,就不会存在此问题,但是除了将返回类型更改为之外,我不知道如何说服它这样做__m128__vectorcall也有帮助,但是使MSVC在多个寄存器中返回结构,然后在调用程序中将它们与多余的混编重组。它比任何一个快速选项都有些混乱并且速度较慢,但​​是仍然比存储转发失败的版本更快。

本文收集自互联网,转载请注明来源。

如有侵权,请联系[email protected] 删除。

编辑于
0

我来说两句

0条评论
登录后参与评论

相关文章

来自分类Dev

了解微架构的原因,导致更长的代码执行速度提高4倍(AMD Zen 2架构)

来自分类Dev

64位架构双启动

来自分类Dev

Vaadin7架构

来自分类Dev

Visual Studio 2015更新2和Sql Server 2016架构比较

来自分类Dev

无法理解YOLOv4架构

来自分类Dev

为什么Ubuntu编译amd64架构4.20.1、4.20.2、5.0-rc2内核失败?

来自分类Dev

哪个版本的Android OS支持64位架构

来自分类Dev

汇编寄存器采用64位架构

来自分类Dev

将int转换为64位架构

来自分类Dev

32位架构与64位架构之间的主要区别在于应用程序速度和内存管理形式?

来自分类Dev

我可以在i686架构上安装kde neon amd64吗?

来自分类Dev

错误ITMS-90092:“此捆绑包无效。包含arm64架构的应用必须包含armv7架构。”

来自分类Dev

如何将现有的deb软件包从64位架构交叉编译到32位架构

来自分类Dev

如何将现有的deb软件包从64位架构交叉编译到32位架构

来自分类Dev

英特尔Gen8架构计算每个执行单元的内核总数

来自分类Dev

无法在64位架构上的Python中使用128位浮点

来自分类Dev

页面缓存如何在64位x86架构上的内核中映射?

来自分类Dev

x86架构上CONFIG_FRAME_WARN的安全值是多少?

来自分类Dev

更新64位架构的Phonegap iOS应用程序时出现问题

来自分类Dev

SFML 2.1架构x86_64错误的未定义符号

来自分类Dev

在9位架构中搜索8位对齐的字符串

来自分类Dev

NOP是否会影响Intel x86架构中的状态寄存器?

来自分类Dev

32位和64位架构上的内存访问

来自分类Dev

OpenSSL编译为可在Android x86架构上运行

来自分类Dev

将XSD 1.1架构转换为C#类

来自分类Dev

编译iOS项目的libtiff以包括64位架构

来自分类Dev

Swagger-将多个安全参数添加到同一架构定义

来自分类Dev

以编程方式将XSD 1.1架构转换为XSD 1.0?

来自分类Dev

Android应用程序,如何支持64位架构?

Related 相关文章

  1. 1

    了解微架构的原因,导致更长的代码执行速度提高4倍(AMD Zen 2架构)

  2. 2

    64位架构双启动

  3. 3

    Vaadin7架构

  4. 4

    Visual Studio 2015更新2和Sql Server 2016架构比较

  5. 5

    无法理解YOLOv4架构

  6. 6

    为什么Ubuntu编译amd64架构4.20.1、4.20.2、5.0-rc2内核失败?

  7. 7

    哪个版本的Android OS支持64位架构

  8. 8

    汇编寄存器采用64位架构

  9. 9

    将int转换为64位架构

  10. 10

    32位架构与64位架构之间的主要区别在于应用程序速度和内存管理形式?

  11. 11

    我可以在i686架构上安装kde neon amd64吗?

  12. 12

    错误ITMS-90092:“此捆绑包无效。包含arm64架构的应用必须包含armv7架构。”

  13. 13

    如何将现有的deb软件包从64位架构交叉编译到32位架构

  14. 14

    如何将现有的deb软件包从64位架构交叉编译到32位架构

  15. 15

    英特尔Gen8架构计算每个执行单元的内核总数

  16. 16

    无法在64位架构上的Python中使用128位浮点

  17. 17

    页面缓存如何在64位x86架构上的内核中映射?

  18. 18

    x86架构上CONFIG_FRAME_WARN的安全值是多少?

  19. 19

    更新64位架构的Phonegap iOS应用程序时出现问题

  20. 20

    SFML 2.1架构x86_64错误的未定义符号

  21. 21

    在9位架构中搜索8位对齐的字符串

  22. 22

    NOP是否会影响Intel x86架构中的状态寄存器?

  23. 23

    32位和64位架构上的内存访问

  24. 24

    OpenSSL编译为可在Android x86架构上运行

  25. 25

    将XSD 1.1架构转换为C#类

  26. 26

    编译iOS项目的libtiff以包括64位架构

  27. 27

    Swagger-将多个安全参数添加到同一架构定义

  28. 28

    以编程方式将XSD 1.1架构转换为XSD 1.0?

  29. 29

    Android应用程序,如何支持64位架构?

热门标签

归档