在固定大小的数组上展开C++循环是否有益?

Is it beneficial anymore to unroll loops in C++ over fixed-sized arrays?

本文关键字:循环 C++ 是否 数组      更新时间:2023-10-16

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

下面是一个最小示例:

#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&, Vector const&)函数使用for循环。

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

如果我注释掉除除

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成员函数的循环无论如何都会由编译器展开?

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

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

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

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

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

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

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