包装内在函数的简单 C++ 表达式模板会产生不同的指令

Simple C++ expression templates wrapping intrinsics produces different instructions

我正在测试一个非常简单的程序,它使用 C++ 表达式模板来简化对值数组进行操作的 SSE2 和 AVX 代码的编写。

我有一个 class svec 代表值数组。

我有一个 class sreg 代表一个 SSE2 双寄存器。

我有 expradd_expr 表示添加 svec 个数组。

与手动代码相比,编译器为我的表达式模板测试用例在每个循环中额外生成三个指令。我想知道这是否有原因,或者我可以进行任何更改以使编译器产生相同的输出?

完整的测试工具是:

#include <iostream>
#include <emmintrin.h>

struct sreg
{
    __m128d reg_;

    sreg() {}

    sreg(const __m128d& r) :
        reg_(r)
    {
    }

    sreg operator+(const sreg& b) const
    {
        return _mm_add_pd(reg_, b.reg_);
    }
};

template <typename T>
struct expr
{
    sreg operator[](std::size_t i) const
    {
        return static_cast<const T&>(*this).operator[](i);
    }

    operator const T&() const
    {
        return static_cast<const T&>(*this);
    }
};

template <typename A, typename B>
struct add_expr : public expr<add_expr<A, B>>
{
    const A& a_;
    const B& b_;

    add_expr(const A& a, const B& b) :
        a_{ a }, b_{ b }
    {
    }

    sreg operator[](std::size_t i) const
    {
        return a_[i] + b_[i];
    }
};

template <typename A, typename B>
inline auto operator+(const expr<A>& a, const expr<B>& b)
{
    return add_expr<A, B>(a, b);
}

struct svec : public expr<svec>
{
    sreg* regs_;
    std::size_t size_;

    svec(std::size_t size) :
        size_{ size }
    {
        regs_ = static_cast<sreg*>(_aligned_malloc(size * 32, 32));
    }

    ~svec()
    {
        _aligned_free(regs_);
    }

    template <typename T>
    svec& operator=(const T& expression)
    {
        for (std::size_t i = 0; i < size(); i++)
        {
            regs_[i] = expression[i];
        }

        return *this;
    }

    const sreg& operator[](std::size_t index) const
    {
        return regs_[index];
    }

    sreg& operator[](std::size_t index)
    {
        return regs_[index];
    }

    std::size_t size() const
    {
        return size_;
    }
};

static constexpr std::size_t size = 64;

int main()
{
    svec a(size);
    svec b(size);
    svec c(size);
    svec d(size);
    svec vec(size);

    //hand rolled loop
    for (std::size_t j = 0; j < size; j++)
    {
        vec[j] = a[j] + b[j] + c[j] + d[j];
    }

    //expression templates version of hand rolled loop
    vec = a + b + c + d;

    std::cout << "Done...";

    std::getchar();

    return EXIT_SUCCESS;
}

对于手卷循环,说明是:

00007FF621CD1B70  mov         r8,qword ptr [c]  
00007FF621CD1B75  mov         rdx,qword ptr [b]  
00007FF621CD1B7A  mov         rax,qword ptr [a]  
00007FF621CD1B7F  vmovupd     xmm0,xmmword ptr [rcx+rax]  
00007FF621CD1B84  vaddpd      xmm1,xmm0,xmmword ptr [rdx+rcx]  
00007FF621CD1B89  vaddpd      xmm3,xmm1,xmmword ptr [r8+rcx]  
00007FF621CD1B8F  lea         rax,[rcx+rbx]  
00007FF621CD1B93  vaddpd      xmm1,xmm3,xmmword ptr [r10+rax]  
00007FF621CD1B99  vmovupd     xmmword ptr [rax],xmm1  
00007FF621CD1B9D  add         rcx,10h  
00007FF621CD1BA1  cmp         rcx,400h  
00007FF621CD1BA8  jb          main+0C0h (07FF621CD1B70h)  

对于表达式模板版本:

00007FF621CD1BC0  mov         rdx,qword ptr [c]  
00007FF621CD1BC5  mov         rcx,qword ptr [rcx]  
00007FF621CD1BC8  mov         rax,qword ptr [r8]  
00007FF621CD1BCB  vmovupd     xmm0,xmmword ptr [r9+rax]  
00007FF621CD1BD1  vaddpd      xmm1,xmm0,xmmword ptr [rcx+r9]  
00007FF621CD1BD7  vaddpd      xmm0,xmm1,xmmword ptr [rdx+r9]  
00007FF621CD1BDD  lea         rax,[r9+rbx]  
00007FF621CD1BE1  vaddpd      xmm0,xmm0,xmmword ptr [rax+r10]  
00007FF621CD1BE7  vmovupd     xmmword ptr [rax],xmm0  
00007FF621CD1BEB  add         r9,10h  
00007FF621CD1BEF  cmp         r9,400h  
00007FF621CD1BF6  jae         main+154h (07FF621CD1C04h)  # extra instruction 1
00007FF621CD1BF8  mov         rcx,qword ptr [rsp+60h]     # extra instruction 2
00007FF621CD1BFD  mov         r8,qword ptr [rsp+58h]      # extra instruction 3
00007FF621CD1C02  jmp         main+110h (07FF621CD1BC0h)

请注意,这是专门演示问题的最少可验证代码。代码是使用 Visual Studio 2015 Update 3.

中的默认发布构建设置编译的

我打折的想法:

这两个循环似乎在每次迭代时都重新加载数组指针。 (例如,第一个循环中的 mov r8, [c])。第二个版本只是效率更低,有两个间接级别。其中之一出现在循环的末尾,在条件分支跳出循环之后。

请注意,您未将其标识为 "new" 的更改指令之一是 mov rcx, [rcx]。寄存器分配在循环之间是不同的,但那些是数组起始指针。它(以及存储后的 rcx,[rsp+60h])正在替换 mov rax,qword ptr [a]。我假设 a 也是相对于 RSP 的偏移量,实际上并不是静态存储的标签。


大概是因为 MSVC++ 没有成功进行别名分析来证明 vec[j] 中的存储不能修改任何指针。我没有仔细查看您的模板,但如果您引入了您希望优化掉的额外间接级别,问题是它不是。

显而易见的解决方案是使用优化更好的编译器。 clang3.9 做得很好(自动矢量化,无需重新加载指针),gcc 将其完全优化,因为结果未被使用。

但是如果您坚持使用 MSVC,请查看是否有任何严格别名选项或无别名关键字或声明,它们会有所帮助。例如GNU C++ 扩展包括 __restrict__ 以获得与 C99 的 restrict 关键字相同的 "this doesn't alias" 行为。 IDK 如果 MSVC 支持类似的东西。


吹毛求疵:

jae 称为"extra" 指令不太正确。它只是 jb 的相反谓词,所以现在它是一个 while(true){ ... if() break; reload; } 循环而不是更高效的 do{...}while() 循环。 (我使用 C 语法来显示 asm 循环结构。显然,如果您实际编译了那些 C 循环,编译器可以优化它们。)所以如果有的话,"extra instruction" 是无条件分支,JMP。

对于偶然发现此问题的任何其他人,这是一个 MSVC 可以优化的无锯齿版本,而不会出现我所看到的问题。我不得不使用一些模板元编程来阻止运算符过载过于贪婪。不知道有没有更简单的方法...

#include <iostream>
#include <utility>
#include <type_traits>
#include <emmintrin.h>

class sreg
{
    using reg_type = __m128d;

public:
    reg_type reg_;

    sreg() {}

    sreg(const reg_type& r) :
        reg_(r)
    {
    }

    sreg operator+(const sreg& b) const
    {
        return _mm_add_pd(reg_, b.reg_);
    }
};

struct expr
{
};

template <typename... Ts>
struct meta_or : std::false_type
{
};

template <typename T, typename... Ts>
struct meta_or<T, Ts...> : std::integral_constant<bool, T::value || meta_or<Ts...>::value>
{
};

template <class... T>
using meta_is_expr = meta_or<std::is_base_of<expr, std::decay_t<T>>..., std::is_base_of<expr, T>...>;

template <class... T>
using meta_enable_if_expr = std::enable_if_t<meta_is_expr<T...>::value>;

template <typename A, typename B>
struct add_expr : public expr
{
    A a_;
    B b_;

    add_expr(A&& a, B&& b) :
        a_{ std::forward<A>(a) }, b_{ std::forward<B>(b) }
    {
    }

    sreg operator[](std::size_t i) const
    {
        return a_[i] + b_[i];
    }
};

template <typename A, typename B, typename = meta_enable_if_expr<A, B>>
inline auto operator+(A&& a, B&& b)
{
    return add_expr<A, B>{ std::forward<A>(a), std::forward<B>(b) };
}

struct svec : public expr
{
    sreg* regs_;;
    std::size_t size_;

    svec(std::size_t size) :
        size_{ size }
    {
        regs_ = static_cast<sreg*>(_aligned_malloc(size * 32, 32));
    }

    ~svec()
    {
        _aligned_free(regs_);
    }

    template <typename T>
    svec& operator=(const T& expression)
    {
        for (std::size_t i = 0; i < size(); i++)
        {
            regs_[i] = expression[i];
        }

        return *this;
    }

    const sreg& operator[](std::size_t index) const
    {
        return regs_[index];
    }

    sreg& operator[](std::size_t index)
    {
        return regs_[index];
    }

    std::size_t size() const
    {
        return size_;
    }
};

static constexpr std::size_t size = 64;

int main()
{
    svec a(size);
    svec b(size);
    svec c(size);
    svec d(size);
    svec vec(size);

    //hand rolled loop
    for (std::size_t j = 0; j < size; j++)
    {
        vec[j] = a[j] + b[j] + c[j] + d[j];
    }

    //expression templates version of hand rolled loop
    vec = a + b + c + d;

    std::cout << "Done...";

    std::getchar();

    return EXIT_SUCCESS;
}

非常感谢@Peter Cordes 提供的正确线索,他要求提供一些有关 "expression" 工作原理的信息。

对于我们的svec,单循环发生在赋值运算符中:

template <typename T>
svec& operator=(const T& expression)
{
    for (std::size_t i = 0; i < size(); i++)
    {
        regs_[i] = expression[i];
    }

    return *this;
}

运算符重载:

template <typename A, typename B, typename = meta_enable_if_expr<A>>
inline auto operator+(A&& a, B&& b)
{
    return add_expr<A, B>{ std::forward<A>(a), std::forward<B>(b) };
}

负责强制编译器为我们建立一个表达式树。通过在 sreg 上重载 + 运算符并循环遍历我们的数据,就好像它是 sreg 的数组一样,编译器会将我们的表达式作为运算符内联在我们的 sreg 内部包装器上,表示 __m128d.

表达式 expr 的每个特化都是 sreg 上的一种函子。我刚刚实施了 expr_add 用于测试目的。