在固定大小的数组上展开 C++ 中的循环是否有用?
Is it beneficial anymore to unroll loops in C++ over fixed-sized arrays?
我想用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&, 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
成员函数的循环?
编译器足够智能,可以自动为您展开循环,您应该相信它能够进行那些(以及许多其他)优化。
一般来说,编译器更擅长微观优化,而程序员更擅长宏观优化。
微优化(编译器可以做什么):
- 展开循环
- 自动内联函数
- 应用尾调用优化来加速尾递归函数(许多最终与等效循环一样快)
- 删除副本和移动:如果您 return 一些值,在许多情况下,编译器可以删除副本或完全移动。
- 使用矢量化浮点指令(虽然有时这仍然需要你帮助编译器)
- 消除不必要的或多余的 if 语句(例如,当您检查某些内容,然后调用一个也检查它的成员函数时,当它内联该成员函数时,它将消除不必要的检查)
- 内联 lambda 传递给其他函数(只有当您不将其包装在
std::function
中时它才会这样做 - 它不能内联 std::function
)
- 在寄存器中存储局部变量甚至整个结构,而不是使用 RAM 或缓存
- 很多数学优化
宏优化(编译器不能做的):
这些是程序员还是要注意的。
- 更改数据的存储方式。如果不需要是指针,就把它存入栈中!
- 更改用于计算某些内容的算法。算法设计还是很重要的!
- 其他内容
我想用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&, 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
成员函数的循环?
编译器足够智能,可以自动为您展开循环,您应该相信它能够进行那些(以及许多其他)优化。
一般来说,编译器更擅长微观优化,而程序员更擅长宏观优化。
微优化(编译器可以做什么):
- 展开循环
- 自动内联函数
- 应用尾调用优化来加速尾递归函数(许多最终与等效循环一样快)
- 删除副本和移动:如果您 return 一些值,在许多情况下,编译器可以删除副本或完全移动。
- 使用矢量化浮点指令(虽然有时这仍然需要你帮助编译器)
- 消除不必要的或多余的 if 语句(例如,当您检查某些内容,然后调用一个也检查它的成员函数时,当它内联该成员函数时,它将消除不必要的检查)
- 内联 lambda 传递给其他函数(只有当您不将其包装在
std::function
中时它才会这样做 - 它不能内联std::function
) - 在寄存器中存储局部变量甚至整个结构,而不是使用 RAM 或缓存
- 很多数学优化
宏优化(编译器不能做的):
这些是程序员还是要注意的。
- 更改数据的存储方式。如果不需要是指针,就把它存入栈中!
- 更改用于计算某些内容的算法。算法设计还是很重要的!
- 其他内容