当前位置: 首页 > 知识库问答 >
问题:

了解微架构的原因,使更长的代码执行速度快4倍(AMD Zen2架构)

施同
2023-03-14

我有以下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);

在第一个循环中,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] };
}
__declspec(noinline) Vec4f eval_fn_impl(const Vec2f& p) noexcept
{
    return { p.v[0], p.v[1], p.v[0], p.v[1] };
}

>

  • 使用显式循环实现的:

    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)代码。

    // 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
    
    // 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
    

    这里有什么相关的建筑效果?在循环的后续迭代中,寄存器之间的依赖性更少?还是某种关于缓存的厄运?

    完整源代码:

    #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;
    }
    
  • 共有1个答案

    慕容康安
    2023-03-14

    我在一个Intel Haswell处理器上测试这一点,但是性能结果是相似的,我猜原因也是相似的,但对此半信半疑。当然Haswell和Zen 2之间有区别,但据我所知,我所指责的效果应该适用于他们两个。

    问题是:虚方法/function-call-via-pointer/无论它是什么,都要进行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;
    }
    

    如果MSVC通过寄存器而不是通过输出指针返回VEC4F结果,那么这个问题就不会存在了,但是除了将返回类型更改为__m128之外,我不知道如何说服它这样做。__vectorcall也有帮助,但使MSVC返回几个寄存器中的结构,然后在调用方中用额外的洗牌重新组合这些结构。它有点混乱,比任何一个快速选项都慢,但仍然比存储转发失败的版本快。

     类似资料:
    • 时间序列异常检测学件的架构 时间序列异常检测工程的整体分层,可以分为以下五层: 数据层(DB):存储检测异常信息、样本信息、任务信息等 服务层(SERVICE): 服务层划分为两大模块 数据驱动模块DAO: 封装了和DB层常见的数据操作接口。 业务模块service: 完成API层的具体业务逻辑。 学件层(LEARNWARE):学件层划分为三大模块 检测模块detect: 提供时间序列异常检测接口

    • 据我所知,直线的意思是,那个变量运动得到乘以向量inputVec的x部分的绝对值,但我不明白接下来会发生什么。

    • 我在尝试在Android Studio上构建项目时遇到此错误。尝试在res/raw/file.dat.上使用二进制文件压缩最终工件时会发生这种情况 这里解释了这样的解决方案:Maven在构建jar时破坏了源/主/资源中的二进制文件 将文件或res/raw文件夹添加到构建时的“false”筛选中。但问题是,我使用JFrog Artictorial配置了我的内部maven存储库,如下所示:https:

    • 我在程序(计时器类)中使用scheduleAtFixedRate方法。它每秒钟运行一次,但有时这种方法会变得非常快(每秒执行3-4次)。 然而,我在网上做了一些研究,发现了这个: 复制自android开发者页面: 对于固定速率执行,任务每次连续运行的开始时间都是计划的,而不考虑上一次运行的时间。如果延迟阻止计时器按时启动任务,则这可能会导致一系列串接运行(一个接一个地启动)。 我需要固定的计时器。

    • 使用耦合度量来支持系统架构 大多数设计良好的软件架构都趋向于支持系统的可扩展性、可维护性和可靠性。遗憾的是,对质量问题的疏忽极可能使软件架构师的努力白费。在追求代码质量 系列的这一期文章中,质量专家 Andrew Glover 解释如何持续地监视并纠正会影响软件架构的长期生存能力的代码质量方面。 上一期文章中,我展示了如何使用代码度量来评估代码质量。尽管在那一期介绍的圈复杂度针对低级细节,如方法中

    • MOSN 的架构和原理解析。 MOSN 核心概念 MOSN 的核心概念解析。 Sidecar 模式 Sidecar 模式是 Service Mesh 中习惯采用的模式。 流量劫持 MOSN 作为 Sidecar 使用时的流量劫持方案。 TLS 安全链路 MOSN 的 TLS 安全能力。 MOSN 平滑升级原理解析 如何在升级 Sidecar(MOSN)的时候而不影响业务,对于存量的长连接如何迁移,