为了提高应用程序的性能,我们必须在开发阶段考虑循环优化技术。
我将向您展示一些不同的方法来迭代一个简单的std::vector<uint32_t> v
:
带索引的未优化循环:
uint64_t sum = 0;
for (unsigned int i = 0; i < v.size(); i++)
sum += v[i];
使用迭代器的未优化循环:
uint64_t sum = 0;
std::vector<uint32_t>::const_iterator it;
for (it = v.begin(); it != v.end(); it++)
sum += *it;
缓存std::vector::end
迭代器:
uint64_t sum = 0;
std::vector<uint32_t>::const_iterator it, end(v.end());
for (it = v.begin(); it != end; it++)
sum += *it;
预增量迭代器:
uint64_t sum = 0;
std::vector<uint32_t>::const_iterator it, end(v.end());
for (it = v.begin(); it != end; ++it)
sum += *it;
基于范围的循环:
uint64_t sum = 0;
for (auto const &x : v)
sum += x;
还有其他方法可以在C ++中构建循环;例如,使用std::for_each
,BOOST_FOREACH
等...
哪种方法可以提高性能?为什么?
此外,在性能关键型应用程序中,展开循环会很有用:我怎么能这样做?
答案 0 :(得分:6)
没有硬性规定,因为它取决于 实现。如果我几年前做的措施是 然而,典型的是:关于唯一有所作为的东西 正在缓存结束迭代器。修复前或修复后没有 差异,无论容器和迭代器类型如何。
当时,我没有测量索引(因为我在比较
不同类型容器的迭代器,并非全部
支持索引)。但我想如果你使用索引,
你应该缓存v.size()
的结果。
当然,这些措施适用于一个编译器(g ++) 系统,具有特定的硬件。你唯一可以知道的方式 你的环境就是衡量自己。
请注意:您确定已启用完整优化吗? 我的测量显示3和4之间没有差异,我怀疑 今天的commpilers减少了优化。
这里的优化非常重要 函数实际上是内联的。如果他们不是, 后增量确实需要一些额外的复制,并且 通常需要额外的函数调用(复制 迭代器的构造函数)。一旦功能完成 但是,内联编译器可以轻松地看到所有这些 一个不必要的,(至少在我尝试的时候)完全生成 两种情况下的代码相同。 (我会使用预增量 无论如何。不是因为它有所作为,而是因为如果你 不要,有些白痴会声称会这样,尽管如此 你的措施。或许他们不是白痴,但只是在使用 一个特别愚蠢的编译器。)
说实话,当我做测量时,我感到很惊讶
即使对于缓存最终迭代器也会产生影响
矢量,其中前和后没有区别
后增量,甚至是反向迭代器到地图中。
毕竟,end()
也被内联了;事实上,每一个人
我的测试中使用的函数是内联的。
关于展开循环:我可能会做这样的事情:
std::vector<uint32_t>::const_iterator current = v.begin();
std::vector<uint32_t>::const_iterator end = v.end();
switch ( (end - current) % 4 ) {
case 3:
sum += *current ++;
case 2:
sum += *current ++;
case 1:
sum += *current ++;
case 0:
}
while ( current != end ) {
sum += current[0] + current[1] + current[2] + current[3];
current += 4;
}
(这是因子4.你可以轻松增加它 必要的。)
答案 1 :(得分:2)
我假设您已经充分意识到过早的微优化的弊端,并且您已通过分析和其他所有内容确定了代码中的热点。我也假设你只关心速度方面的表现。也就是说,您并不关心生成的代码或内存使用的大小。
除了缓存的end()
迭代器之外,您提供的代码段将产生大致相同的结果。除了尽可能多地进行缓存和内联之外,您无法通过调整上述循环的结构来实现性能的显着提升。
在关键路径中编写高性能代码首先要依赖于为作业选择最佳算法。如果您遇到性能问题,请先仔细查看算法。编译器通常会在微观优化您编写的代码方面做得比您希望的要好得多。
所有这些,你可以做一些事情来为你的编译器提供一些帮助。
const
件事物。这为编译器提供了额外的微优化机会。学习您的工具链和架构将带来最大的好处。例如,GCC有许多选项可以启用以提高性能,包括循环展开。见here。在迭代数据集时,保持每个项目与高速缓存行的大小对齐通常是有益的。在现代架构中,这通常意味着64字节,但要学习您的架构。
Here是在英特尔环境中编写高性能C ++的绝佳指南。
一旦学会了架构和工具链,您可能会发现最初选择的算法在现实世界中并不是最佳的。面对新数据,愿意改变。
答案 2 :(得分:2)
现代编译器很可能会为您上面提供的方法生成相同的程序集。您应该查看实际的程序集(在启用优化之后)以查看。
当你担心循环的速度时,你应该考虑你的算法是否真正最优。如果你确信它是,那么你需要考虑(并利用)数据结构的底层实现。 std::vector
使用下面的数组,并且,根据编译器和函数中的其他代码,指针别名可能会阻止编译器完全优化代码。
指针别名有很多信息(包括What is the strict aliasing rule?),但Mike Acton有一些关于pointer aliasing的精彩信息。
restrict
关键字(请参阅What does the restrict keyword mean in C++?或再次,Mike Acton),可通过编译器扩展提供多年,并在C99中编码(目前仅作为C ++中的编译器扩展提供) ,是为了解决这个问题。在代码中使用它的方式更像C,但可能允许编译器更好地优化循环,至少对于您给出的示例:
uint64_t sum = 0;
uint32_t *restrict velt = &v[0];
uint32_t *restrict vend = velt + v.size();
while(velt < vend) {
sum += *velt;
velt++;
}
但是,要了解这是否有所不同,您确实需要为实际的现实问题分析不同的方法,并可能查看生成的基础组件。如果您要汇总简单的数据类型,这可能会对您有所帮助。如果你正在做更复杂的事情,包括调用一个无法在循环中内联的函数,它根本不可能有任何不同。
答案 3 :(得分:1)
如果您正在使用clang,请将这些标志传递给它:
-Rpass-missed=loop-vectorize
-Rpass-analysis=loop-vectorize
在Visual C ++中将此添加到构建:
/Qvec-report:2
这些标志会告诉你循环是否无法矢量化(并给你一个经常神秘的消息来解释原因)。
一般来说,更喜欢选项4和5(或std :: for_each)。虽然clang和gcc在大多数情况下通常会做得不错,但是Visual C ++可能会在谨慎的情况下犯错误。如果变量的范围是未知的(例如,传递给函数或此指针的引用或指针),则向量化经常失败(局部范围中的容器几乎总是向量化)。
#include <vector>
#include <cmath>
// fails to vectorise in Visual C++ and /O2
void func1(std::vector<float>& v)
{
for(size_t i = 0; i < v.size(); ++i)
{
v[i] = std::sqrt(v[i]);
}
}
// this will vectorise with sqrtps
void func2(std::vector<float>& v)
{
for(std::vector<float>::iterator it = v.begin(), e = v.end(); it != e; ++it)
{
*it = std::sqrt(*it);
}
}
Clang和gcc也不会对这些问题免疫。如果你总是拿一份开头/结尾,那就不是问题了。
这是另一个令人遗憾地影响许多编译器的经典(clang 3.5.0未通过这个简单的测试,但它已在clang 4.0中修复)。它出现了很多!
struct Foo
{
void func3();
void func4();
std::vector<float> v;
float f;
};
// will not vectorise
void Foo::func3()
{
// this->v.end() !!
for(std::vector<float>::iterator it = v.begin(); it != v.end(); ++it)
{
*it *= f; // this->f !!
}
}
void Foo::func4()
{
// you need to take a local copy of v.end(), and 'f'.
const float temp = f;
for(std::vector<float>::iterator it = v.begin(), e = v.end(); it != e; ++it)
{
*it *= temp;
}
}
最后,如果您关心的是它,请使用编译器的矢量化报告来修复您的代码。如上所述,这基本上是指针别名的问题。您可以使用restrict关键字来帮助修复其中的一些问题(但我发现将限制应用于&#39;这通常没那么有用)。
答案 4 :(得分:0)
默认使用基于范围,因为它将为编译器提供最优化的直接信息(例如,编译器知道它可以缓存结束迭代器)。然后分析并仅在您发现重大瓶颈时进一步优化。现实世界的情况很少,这些不同的循环变体会产生有意义的性能差异。编译器非常擅长循环优化,你更有可能将优化工作集中在其他地方(比如选择更好的算法或专注于优化循环体)。