【问题标题】:Why does this simple C++ SIMD benchmark run slower when SIMD instructions are used?为什么使用 SIMD 指令时这个简单的 C++ SIMD 基准测试运行速度会变慢?
【发布时间】:2020-02-10 10:18:52
【问题描述】:

我正在考虑编写一个 SIMD 向量数学库,因此作为一个快速基准测试,我编写了一个程序,该程序执行 1 亿(4 个浮点)向量元素乘法并将它们添加到累积总数中。对于我经典的非 SIMD 变体,我只是制作了一个具有 4 个浮点数的结构,并编写了我自己的乘法函数“multiplyTwo”,它将两个这样的结构元素相乘并返回另一个结构。对于我的 SIMD 变体,我使用了“immintrin.h”以及 __m128、_mm_set_ps 和 _mm_mul_ps。我在 i7-8565U 处理器(威士忌湖)上运行并编译:g++ main.cpp -mavx -o test.exe 以在 GCC 中启用 AVX 扩展指令。

奇怪的是,SIMD 版本大约需要 1.4 秒,而非 SIMD 版本只需要 1 秒。我觉得好像我做错了什么,因为我认为 SIMD 版本的运行速度应该快 4 倍。任何帮助表示赞赏,代码如下。我已将非 SIMD 代码放在 cmets 中,当前形式的代码是 SIMD 版本。

#include "immintrin.h" // for AVX 
#include <iostream>

struct NonSIMDVec {
    float x, y, z, w;
};

NonSIMDVec multiplyTwo(const NonSIMDVec& a, const NonSIMDVec& b);

int main() {
    union { __m128 result; float res[4]; };
    // union { NonSIMDVec result; float res[4]; };

    float total = 0; 
    for(unsigned i = 0; i < 100000000; ++i) {
        __m128 a4 = _mm_set_ps(0.0000002f, 1.23f, 2.0f, (float)i);
        __m128 b4 = _mm_set_ps((float)i, 1.3f, 2.0f, 0.000001f);
        // NonSIMDVec a4 = {0.0000002f, 1.23f, 2.0f, (float)i}; 
        // NonSIMDVec b4 = {(float)i, 1.3f, 2.0f, 0.000001f};

        result = _mm_mul_ps(a4, b4); 
        // result = multiplyTwo(a4, b4);

        total += res[0];
        total += res[1];
        total += res[2];
        total += res[3];
    }

    std::cout << total << '\n';
}

NonSIMDVec multiplyTwo(const NonSIMDVec& a, const NonSIMDVec& b)
{ return {a.x*b.x + a.y*b.y + a.z*b.z + a.w*b.w}; }

【问题讨论】:

  • 如今的编译器非常擅长优化。也许它只是能够创建比您想象的更优化的代码?您可以查看两个不同可执行文件的生成代码,并比较一下编译器与您的代码相比自己做了什么。
  • 您忘记启用优化。手动向量化需要更多的语句,并且在-O0 上通常更糟。哦,神圣像素蝙蝠侠,你也把你的水平和inside循环。
  • 顺便说一句,这也是对_mm_set_ps的滥用,如果你这样使用它就不能指望好的代码生成
  • 我还尝试使用-O3 运行两者并获得相同的运行时间。这是否意味着在许多简单的情况下(比如我的基准测试),编写显式 SIMD 向量化甚至都不值得?
  • @Montana 这个基准测试意外地复杂化了,将常量和变量混合到同一个向量中。缓慢的总和可以很容易地修复。如果您选择了一些真实的代码来测试 SIMD,可能会更好,因为它不需要 _mm_set_ps 的人为奇怪用法

标签: c++ performance simd intrinsics avx


【解决方案1】:

禁用优化(gcc 默认为-O0),内在函数通常很糟糕。 Anti-optimized -O0 code-gen 对于内在函数通常会造成很大的伤害(甚至比对标量的伤害更大),并且一些类似函数的内在函数会引入额外的存储/重新加载开销。此外,-O0 的额外存储转发延迟往往会造成更大的伤害,因为当您使用 1 个向量而不是 4 个标量进行操作时,ILP 会更少。

使用gcc -march=native -O3

但即使启用了优化,您的代码仍会通过在循环内对每个向量进行水平相加来破坏 SIMD 的性能。请参阅How to Calculate Vector Dot Product Using SSE Intrinsic Functions in C 了解如何这样做:使用_mm_add_ps 来累积__m128 total 向量,并且只在循环外对其进行水平求和。

通过在循环内执行标量 total +=,您可以在 FP-add 延迟上限制循环。该循环携带的依赖链意味着您的循环在 Skylake 派生的微架构上每 4 个周期运行的速度不能超过 1 个float,其中addss 延迟为 4 个周期。 (https://agner.org/optimize/)

甚至比 __m128 total 更好,使用 4 或 8 个向量来隐藏 FP 添加延迟,因此您的 SIMD 循环可能会成为 mul/add(或 FMA)吞吐量而不是延迟的瓶颈。


一旦你解决了这个问题,那么@harold 指出你在循环中使用_mm_set_ps 的方式将导致编译器产生非常糟糕的 asm。当操作数不是常量或至少是循环不变的时,在循环内这不是一个好的选择。

你的例子显然是人为的;通常你会从内存中加载 SIMD 向量。但如果您确实需要更新__m128 向量中的循环计数器,则可以使用tmp = _mm_add_ps(tmp, _mm_set_ps(1.0, 0, 0, 0))。或者通过添加 1.0、2.0、3.0 和 4.0 展开展开,因此循环携带的依赖项仅是一个元素中的 += 4.0。

x + 0.0 是即使对于 FP 的标识操作(可能带符号的零除外),因此您可以对其他元素执行此操作而无需更改它们。

或者对于向量的低位元素,可以使用_mm_add_ss(标量)只修改它。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2021-07-30
    • 1970-01-01
    • 2015-06-09
    • 1970-01-01
    • 1970-01-01
    • 2017-08-15
    • 1970-01-01
    相关资源
    最近更新 更多