【问题标题】:Is it beneficial anymore to unroll loops in C++ over fixed-sized arrays?在固定大小的数组上展开 C++ 中的循环是否有益?
【发布时间】:2019-01-11 14:26:47
【问题描述】:

我想用std::array来存储N维向量的数据,并对这些向量进行算术运算。我想,由于 std::array 现在有一个 constexpr size() 成员函数,我可以使用它来展开我需要的循环,以便对其元素进行算术运算。

这是一个最小的例子:

#include <array> 
#include <type_traits>
#include <iostream>
#include <cassert>

template<std::size_t N=0, typename Vector>
void plus_equals(Vector& result, Vector const& input) 
{
    result[N] += input[N]; 

    if constexpr (N + 1 < result.size()) 
        plus_equals<N+1>(result, input); 
}

template<typename INT, size_t N>
class Vector
{
    std::array<INT, N> data_; 

    public: 

        template<typename ... BracketList> 
        Vector(BracketList ... blist)
        :
            data_{std::forward<BracketList>(blist)...}
        {} 

        INT& operator[](std::size_t i)
        {
            return data_[i]; 
        }

        INT operator[](std::size_t i) const 
        {
            return data_[i]; 
        }

        decltype(auto) begin() const 
        {
            return data_.begin(); 
        }

        decltype(auto) end() const 
        {
            return data_.end(); 
        }

        decltype(auto) end() 
        {
            return data_.end(); 
        }

        constexpr decltype(auto) size()
        {
            return data_.size(); 
        }

        void operator+=(Vector const& other)
        {
            plus_equals(*this, other); 
        }
};

template<size_t N = 0, typename Vector> 
Vector operator+(Vector const& uVec, Vector const& vVec)
{
    Vector result {uVec};  

    result += vVec;  

    return result;  
}

template<size_t N = 0, typename Vector> 
Vector sum(Vector const& uVec, Vector const& vVec)
{
    Vector result {uVec};  

    for (decltype(result.size()) i = 0; i < result.size(); ++i)
        result[i] += vVec[i]; 

    return result;  
}

template<typename Vector> 
void print(Vector&& v)
{
    for (const auto& el : v) std::cout << el << " ";  
    std::cout << std::endl;
}

using namespace std; 

int main()
{
    Vector<int, 3> c1 = {1,2,3}; 
    Vector<int, 3> c2 = {3,2,1}; 
    auto r1 = c1 + c2;
    print (r1);

    auto r2 = sum(c2, c2);
    print (r2); 

    Vector<int, 3> s1, s2; 

    for (std::size_t i = 0; i < 3; ++i)
        cin >> s1[i];
    for (std::size_t i = 0; i < 3; ++i)
        cin >> s2[i];

    auto r3 = s1 + s2;
    print(r3);

    auto r4 = sum(s1, s2);
    print(r4);


    return 0;
}

sum 操作是使用 plus_equals 实现的,它应该对 Vector 的元素展开单独的 += 操作,而 sum(Vector const&amp;, Vector const&amp;) 函数使用 for 循环。

我使用-O3 -std=c++2agodbolt 上编译了示例。

如果我注释掉除了

之外的所有内容
Vector<int, 3> c1 = {2,11,7}; 
Vector<int, 3> c2 = {9,22,5}; 
auto r1 = c1 + c2;
print (r1);

我明白了

    movabs  rax, 141733920779
    sub     rsp, 24
    lea     rdi, [rsp+4]
    mov     QWORD PTR [rsp+4], rax
    mov     DWORD PTR [rsp+12], 12
    call    void print<Vector<int, 3ul>&>(Vector<int, 3ul>&)
    xor     eax, eax
    add     rsp, 24
    ret

这里发生了什么?为什么我看不到前两个常量c1[0] + c2[0]c1[1] + c2[1]?另一方面7 + 5 = 12 被移动:

    mov     DWORD PTR [rsp+12], 12

为什么是汇编代码

int main()
{
    Vector<int, 3> c1 = {2,11,7}; 
    Vector<int, 3> c2 = {9,22,5}; 
    //auto r1 = c1 + c2;
    //print (r1);

    auto r2 = sum(c1, c2);
    print (r2); 

完全一样吗?

如果我尝试使用运行时变量:

    Vector<int, 3> s1, s2; 
    for (std::size_t i = 0; i < 3; ++i)
        cin >> s1[i];
    for (std::size_t i = 0; i < 3; ++i)
        cin >> s2[i];

    auto r3 = s1 + s2;
    print(r3);

我明白了

    mov     edx, DWORD PTR [rsp+28]
    mov     eax, DWORD PTR [rsp+32]
    lea     rdi, [rsp+36]
    add     eax, DWORD PTR [rsp+20]
    add     edx, DWORD PTR [rsp+16]
    mov     ecx, DWORD PTR [rsp+24]
    add     ecx, DWORD PTR [rsp+12]
    mov     DWORD PTR [rsp+44], eax
    mov     DWORD PTR [rsp+36], ecx
    mov     DWORD PTR [rsp+40], edx

链接到plus_equals 函数模板并按预期展开迭代。

对于sum

Vector<int, 3> s1, s2; 
for (std::size_t i = 0; i < 3; ++i)
    cin >> s1[i];
for (std::size_t i = 0; i < 3; ++i)
    cin >> s2[i];

//auto r3 = s1 + s2;
//print(r3);

auto r4 = sum(s1, s2);
print(r4);

程序集是:

    mov     edx, DWORD PTR [rsp+32]
    add     edx, DWORD PTR [rsp+20]
    add     ecx, eax
    shr     rax, 32
    add     eax, DWORD PTR [rsp+28]
    mov     DWORD PTR [rsp+44], edx
    mov     DWORD PTR [rsp+40], eax
    mov     DWORD PTR [rsp+36], ecx

并且没有相等比较和跳转,所以循环已经展开。

当我查看sum模板的汇编代码时,那里有比较运算符和跳转。这是我所预料的,因为我认为编译器首先为任何Vector 生成通用代码,然后再确定Vector::size() 是否为constexpr 并应用进一步的优化。

翻译可以吗?如果是这样,是否可以得出结论,对于固定大小的数组手动展开迭代没有意义,因为使用-O3,使用constexpr size 成员函数的循环无论如何都会被编译器展开?

【问题讨论】:

  • 只是为了确定,您是否测量并分析了您之前的代码是否会因为您的要求而变慢,或者它是您程序中的瓶颈?您是否检查过编译器是否还没有进行一些展开或其他优化?这不是过早的优化吗?
  • 我已经使用 hpctoolkit 和 scorep 对我的代码进行了多次分析。这与我的问题没有任何关系:如果可以保证编译器在循环定义中有 constexpr size() 时会展开循环,那么使用 for 循环和使用递归函数模板会使代码更具可读性.这很重要,因为代码是科学的,可能会被其他人扩展。
  • 标准不保证任何循环都会展开。事实上,编译器甚至可能重新滚动手动展开的循环(我不知道编译器会这样做的情况 - 但这种情况可能存在)。
  • " 如果有保证编译器" - 你甚至不必继续,答案已经是'否',所以我想这回答了你的问题。无法保证编译器如何在语言规范之外进行任何优化。
  • 如果您希望 gcc/clang 在编译时已知大小时展开循环(无论 constexpr 与否),请使用标志 -funroll-loops --param max-unroll-times=... 可以控制展开深度。如今,编译器通常不会进行积极的展开,因为 CPU 可以非常有效地处理循环。但是由于jcc 对吞吐量的影响不可忽略,在某些情况下添加-funroll-all-loop 会带来显着的改善。尤其是一些低于标准的 gcc 矢量化,它们具有与展开循环相同的性能,默认情况下不这样做。

标签: c++ optimization stdarray loop-unrolling


【解决方案1】:

编译器足够智能,可以自动为您展开循环,您应该相信它能够进行这些(以及许多其他)优化。

一般来说,编译器更擅长进行微优化,而程序员更擅长进行宏优化。

微优化(编译器可以做什么):

  • 展开循环
  • 自动内联函数
  • 应用尾调用优化来加速尾递归函数(许多函数最终与等效循环一样快)
  • 省略复制和移动:如果您按值返回某些内容,在许多情况下编译器可以摆脱复制或完全移动。
  • 使用矢量化浮点指令(不过,这有时仍需要您帮助编译器)
  • 消除不必要或多余的 if 语句(例如,当您检查某些内容,然后调用一个也检查它的成员函数时,当它内联成员函数时,它将消除不必要的检查)
  • 内联 lambda 传递给其他函数(仅当您不将其包装在 std::function 中时才会这样做 - 它不能内联 std::function
  • 将局部变量甚至整个结构存储在寄存器中,而不是使用 RAM 或缓存​​
  • 大量数学优化

宏优化(编译器不能做的):

这些是程序员仍然需要注意的事情。

  • 更改数据的存储方式。如果某些东西不需要是指针,则将其存储在堆栈中!
  • 更改用于计算某些内容的算法。算法设计仍然很重要!
  • 其他东西

【讨论】:

    猜你喜欢
    • 2010-12-30
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-02-28
    • 1970-01-01
    • 2019-08-17
    相关资源
    最近更新 更多