现代C ++编译器的有效优化策略

时间:2010-05-28 21:04:30

标签: c++ optimization x86

我正在研究对性能至关重要的科学代码。该代码的初始版本已经编写和测试,现在,有了分析器,现在是时候从热点开始剃须周期了。

众所周知,一些优化,例如,循环展开,这些天被编译器更有效地处理,而不是手工干预的程序员。哪些技术还值得?显然,我会通过一个分析器来运行我尝试的所有东西,但是如果有传统的智慧可以使用什么,什么不可用,那将为我节省大量时间。

我知道优化非常依赖于编译器和体系结构。我正在使用面向Core 2 Duo的英特尔C ++编译器,但我也对gcc或“任何现代编译器”的效果感兴趣。

以下是我正在考虑的一些具体想法:

  • 用手工卷取代STL容器/算法有什么好处吗?特别是,我的程序包含一个非常大的优先级队列(当前为std::priority_queue),其操作占用了大量的总时间。这是值得研究的事情,还是STL实施可能是最快的?
  • 沿着类似的路线,对于需要大小未知但上限相当小的std::vector,用静态分配的数组替换它们是否有利可图?
  • 我发现动态内存分配通常是一个严重的瓶颈,消除它会导致显着的加速。因此,我很有兴趣通过值返回大型临时数据结构与通过指针返回相对于通过引用传递结果的性能权衡。有没有办法可靠地确定编译器是否会对给定方法使用RVO(假设调用者当然不需要修改结果)?
  • 编译器的缓存感知程度如何?例如,是否值得研究重新排序嵌套循环?
  • 鉴于该程序的科学性,浮点数被用于各处。我的代码中的一个重要瓶颈曾经是从浮点到整数的转换:编译器会发出代码来保存当前的舍入模式,更改它,执行转换,然后恢复旧的舍入模式---即使程序中没有任何内容永远改变了舍入模式!禁用此行为会大大加快我的代码速度。我应该注意哪些与浮点相关的类似问题吗?
  • C ++被单独编译和链接的一个后果是编译器无法进行看似非常简单的优化,例如在循环的终止条件下移动方法调用如strlen()。是否有任何类似的优化我应该注意,因为不能由编译器完成并且必须手工完成?
  • 另一方面,是否有任何我应该避免的技术,因为它们可能会干扰编译器自动优化代码的能力?

最后,将某些答案扼杀在萌芽状态:

  • 我知道优化在复杂性,可靠性和可维护性方面都有成本。对于这个特定的应用程序,提高性能是值得的。
  • 我知道最好的优化通常是改进高级算法,而且这已经完成了。

19 个答案:

答案 0 :(得分:25)

  

用手工卷取代STL容器/算法有什么好处?特别是,我的程序包含一个非常大的优先级队列(当前是std :: priority_queue),其操作占用了大量的总时间。这是值得研究的事情,还是STL实施可能是最快的?

我假设您知道STL容器依赖于复制元素。在某些情况下,这可能是一个重大损失。存储指针,如果你进行了大量的容器操作,你可能会看到性能提升。另一方面,它可能会减少缓存局部性并伤害您。另一种选择是使用专门的分配器。

某些容器(例如mapsetlist)依赖于大量指针操作。虽然违反直觉,但通常会导致更快的代码用vector替换它们。生成的算法可能会从O(1)O(log n)转到O(n),但由于缓存局部性,它在实践中可能会快得多。简介以确定。

你提到你正在使用priority_queue,我想这可以为重新安排元素付出很多,特别是如果它们很大的话。您可以尝试切换基础容器(可能是deque或专门的)。我几乎可以肯定存储指针 - 再次,配置确定。

  

沿着类似的路线,对于需要大小未知但上限相当小的std :: vectors,用静态分配的数组替换它们是否有利可图?

同样,这可能会有所帮助,具体取决于用例。您可以避免堆分配,但前提是您不需要阵列超过堆栈...或者您可以reserve() vector中的大小,以便重新分配时复制的次数减少。 / p>

  

我发现动态内存分配通常是一个严重的瓶颈,消除它会导致显着的加速。因此,我很有兴趣通过值返回大型临时数据结构与通过指针返回相对于通过引用传递结果的性能权衡。有没有办法可靠地确定编译器是否会对给定方法使用RVO(假设调用者当然不需要修改结果)?

您可以查看生成的程序集以查看是否应用了RVO,但是如果返回指针或引用,则可以确定没有副本。这是否有用取决于你正在做什么 - 例如无法返回对临时工具的引用。您可以使用竞技场进行分配 并重用对象,所以不要支付大量的惩罚。

  

编译器的缓存感知程度如何?例如,是否值得研究重新排序嵌套循环?

我在这个领域看到了戏剧性的(严重戏剧性)加速。我看到了比以后通过多线程处理代码看到的更多改进。自那以后的五年里,情况可能发生了变化 - 只有一种方式可以肯定 - 简介。

  

另一方面,我是否应该避免使用任何技术,因为它们可能会干扰编译器自动优化代码的能力?

  • 在单个参数构造函数上使用explicit。临时对象构造和销毁可能隐藏在您的代码中。

  • 请注意大型对象上隐藏的复制构造函数调用。在某些情况下,请考虑使用指针替换。

  • 个人资料,个人资料,个人资料。调整瓶颈区域。

答案 1 :(得分:20)

请查看优秀的Pitfalls of Object-Oriented Programming slides,了解有关重建地方代码的一些信息。根据我的经验,获得更好的地方几乎总是最大的胜利。

一般过程:

  • 在调试器中学习喜欢反汇编视图,或让构建系统尽可能生成中间汇编文件(.s)。密切关注变化或看起来令人震惊的事情 - 即使不熟悉给定的指令集架构,您也应该能够清楚地看到一些事情! (我有时检查一系列具有相应.cpp / .c更改的.s文件,只是为了利用我的SCM中可爱的工具来观察代码和相应的asm随时间的变化。)
  • 获取可以观察CPU性能计数器的分析器,或者至少可以猜测缓存未命中。 (AMD CodeAnalyst,cachegrind,vTune等)

其他一些具体事项:

  • Understand strict aliasing. 如果您的编译器拥有它,请使用restrict。 (也可以在这里检查一下痉挛!)
  • 在处理器和编译器上查看不同的浮点模式。如果您不需要非规范化范围,则选择不使用此模式的模式可以获得更好的性能。 (根据您对舍入模式的讨论,听起来您已经在这个领域做过一些事情。)
  • 绝对避免分配:reserve时调用std::vector ,或在您知道尺寸时使用std::array 编译时。
  • 使用内存池增加位置并减少alloc / free开销;还要确保缓存线对齐并防止乒乓。
  • 使用帧分配器,如果您以可预测的模式分配内容,并且可以一次性解除分配所有内容。
  • 了解不变量。你知道的不变的东西可能不是编译器,例如在循环中使用struct或class成员。我发现在这里找到正确习惯的最简单方法是给所有东西命名,并且更喜欢在循环之外命名。例如。 const int threshold = m_currentThreshold;或者Thing * const pThing = pStructHoldingThing->pThing;幸运的是,您通常可以在反汇编视图中看到需要此处理的内容。这也有助于稍后调试(使得watch / locals窗口在调试版本中表现得更好)!
  • 如果可能的话,避免在循环中写入 - 首先累积,然后写入或批量写入一些写入。当然是YMMV。

WRT你的std::priority_queue问题:将东西插入向量(priority_queue的默认后端)往往会移动很多元素。如果你可以分成几个阶段,在那里插入数据,然后对它进行排序,然后在它排序后读取它,你可能会好多了。虽然你肯定会失去局部性,但你可能会发现一个更自我排序的结构,如std :: map或std :: set值得花费 - 但这真的依赖于你的使用模式。

答案 2 :(得分:13)

答案 3 :(得分:7)

  1. 与动态分配相比,使用内存缓冲池可以带来很大的性能优势。如果它们在长时间执行运行中减少或防止堆碎片,则更是如此。

  2. 请注意数据位置。如果您有本地数据与全局数据的重要组合,则可能会使缓存机制过度使用。尽量使数据集保持紧密,以最大限度地利用缓存行有效性。

  3. 尽管编译器在循环方面做得很好,但在进行性能调优时我仍会仔细检查它们。您可以发现在编译器只能修剪百分比的情况下产生数量级的架构缺陷。

  4. 如果单个优先级队列在其操作中使用了大量时间,则创建一组代表优先级桶的队列可能会有好处。在这种情况下,以速度交易会很复杂。

  5. 我注意到你没有提到使用SSE类型指令。它们适用于您的数字运算类型吗?

  6. 祝你好运。

答案 4 :(得分:5)

Here是一篇关于这个主题的好文章。

答案 5 :(得分:3)

关于STL容器。

这里的大多数人声称STL提供了容器算法中最快的实现之一。我说的恰恰相反:对于最现实世界的场景,STL容器被视为产生了非常强烈的灾难性能。

人们争论STL中使用的算法的复杂性。这里STL很好:list / queue的O(1),向量(摊销)和map的O(log(N))。但这不是典型应用程序性能的真正瓶颈!对于许多应用程序而言,真正的瓶颈是堆操作malloc / freenew / delete等。)

list上的典型操作只需几个CPU周期。在map - 几十,可能更多(这取决于缓存状态和log(N)当然)。典型的堆操作从需求成本到数千(!!!)的CPU周期。例如,对于多线程应用程序,它们还需要同步(互锁操作)。此外,在某些操作系统(如Windows XP)上,堆函数完全在内核模式下实现。

因此,典型场景中STL容器的实际性能主要取决于它们执行的堆操作量。在这里,他们是灾难性的。不是因为它们的实施效果不佳,而是因为它们的设计。也就是说,这是设计的问题。

另一方面,还有其他不同设计的容器。 一旦我根据自己的需要设计和编写了这样的容器:

http://www.codeproject.com/KB/recipes/Containers.aspx

事实证明,从性能的角度来看,我不仅仅是优秀的。

但最近我发现我并不是唯一一个想到这一点的人。 boost::intrusive是以类似于我当时的方式实现的容器库。

我建议你尝试一下(如果你还没有)

答案 6 :(得分:2)

这是我用过的一些东西:

  • 模板专门用于最内层的循环边界(使它们非常快)
  • 使用__restrict__关键字来解决别名问题
  • 预先保留向量以确保默认值。
  • 避免使用地图(可能非常慢)
  • vector append / insert可能会非常慢。如果是这种情况,原始操作可能会使其更快
  • N字节内存对齐(英特尔已编译对齐,http://www.intel.com/software/products/compilers/docs/clin/main_cls/cref_cls/common/cppref_pragma_vector.htm
  • 尝试将内存保留在L1 / L2缓存中。
  • 使用NDEBUG编译
  • 使用oprofile的配置文件,使用opannotate查找特定的行(stl开销清晰可见)

这里是个人资料数据的示例部分(所以你知道在哪里寻找问题)

 * Output annotated source file with samples
 * Output all files
 *
 * CPU: Core 2, speed 1995 MHz (estimated)
--
 * Total samples for file : "/home/andrey/gamess/source/blas.f"
 *
 * 1020586 14.0896
--
 * Total samples for file : "/home/andrey/libqc/rysq/src/fock.cpp"
 *
 * 962558 13.2885
--
 * Total samples for file : "/usr/include/boost/numeric/ublas/detail/matrix_assign.hpp"
 *
 * 748150 10.3285

--
 * Total samples for file : "/usr/include/boost/numeric/ublas/functional.hpp"
 *
 * 639714  8.8315
--
 * Total samples for file : "/home/andrey/gamess/source/eigen.f"
 *
 * 429129  5.9243
--
 * Total samples for file : "/usr/include/c++/4.3/bits/stl_algobase.h"
 *
 * 411725  5.6840
--

我项目的代码示例

template<int ni, int nj, int nk, int nl>
inline void eval(const Data::density_type &D, const Data::fock_type &F,
                 const double *__restrict Q, double scale) {

    const double * __restrict Dij = D[0];
    ...
    double * __restrict Fij = F[0];
    ...

    for (int l = 0, kl = 0, ijkl = 0; l < nl; ++l) {
        for (int k = 0; k < nk; ++k, ++kl) {
            for (int j = 0, ij = 0; j < nj; ++j, ++jk, ++jl) {
                for (int i = 0; i < ni; ++i, ++ij, ++ik, ++il, ++ijkl) {

答案 7 :(得分:2)

  

用手工卷取代STL容器/算法有什么好处吗?

一般情况下,除非您正在使用糟糕的实施方案。我不会因为您认为可以编写更严格的代码而替换STL容器或算法。只有当STL版本比你的问题需要的更通用时我才会这样做。如果你可以编写一个更简单的版本来满足你的需要,那么可能会有一些速度来实现。

我看到的一个例外是将一个写入时复制的std :: string替换为不需要线程同步的一个。

  

对于需要大小未知但上限相当小的std :: vectors,用静态分配的数组替换它们是否有利可图?

不太可能。但是如果你使用大量时间分配一定的大小,添加一个reserve()调用可能是有利可图的。

  

按值返回大型临时数据结构与通过指针返回相对于通过引用传递结果的性能权衡。

使用容器时,我传递输入的迭代器和输出迭代器,这仍然非常通用。

  

编译器的缓存感知程度如何?例如,是否值得研究重新排序嵌套循环?

不是很好。是。我发现错过的分支预测和缓存恶意内存访问模式是性能的两个最大杀手(一旦你有了合理的算法)。许多旧代码使用“早期”测试来减少计算。但是在现代处理器上,这通常比做数学和忽略结果更昂贵。

  

我的代码中的一个重要瓶颈曾经是从浮点到整数的转换

烨。我最近发现了同样的问题。

  

C ++被单独编译和链接的一个后果是编译器无法执行看似非常简单的优化,例如在循环的终止条件下移动方法调用如strlen()。

有些编译器可以处理这个问题。 Visual C ++有一个“链接时代码生成”选项,可以有效地重新调用编译器来进行进一步的优化。并且,在像strlen这样的函数的情况下,许多编译器会将其识别为内在函数。

  

我是否应该注意这样的优化,因为它们不能由编译器完成,必须手工完成?另一方面,我是否应该避免使用任何技术,因为它们可能会干扰编译器自动优化代码的能力?

当您在这个低级别进行优化时,几乎没有可靠的经验法则。编译器会有所不同。测量您当前的解决方案,并确定它是否太慢。如果是,则提出一个假设(例如,“如果我用查找表替换内部if语句怎么办?”)。它可能有所帮助(“消除由于分支预测失败导致的停顿”)或者它可能会受到伤害(“查找访问模式会损害缓存一致性”)。逐步实验和测量。

我经常克隆简单的实现,并使用#ifdef HAND_OPTIMIZED / #else / #endif在参考版本和调整版本之间切换。它对以后的代码维护和验证很有用。我提交了每个成功的实验以更改控制,并保留一个日志(电子表格),其中包含更改列表编号,运行时间以及优化中每个步骤的说明。随着我对代码行为的了解越来越多,日志可以很容易地在另一个方向上进行备份和分支。

您需要一个框架来运行可重复的计时测试,并将结果与​​参考版本进行比较,以确保您不会无意中引入错误。

答案 8 :(得分:2)

如果我正在研究这个问题,我希望会有一个结束阶段,比如缓存局部性和向量运算等。

然而,在进入最后阶段之前,我希望找到一系列不同大小的问题与编译器级优化没什么关系,而且更多的是与奇怪的东西有关,这是永远不会被猜到的,但是一旦发现,很容易修复。通常它们围绕类过度设计和数据结构问题展开。

Here's an example of this kind of process.

我发现带有迭代器的通用容器类,原则上编译器可以优化到最小周期,通常不会因某些不明原因而优化。我也听说过SO的其他案例。

其他人说,在你做任何其他事情之前,简介。我同意这种方法,除了我认为有更好的方法,并在该链接中表明。每当我发现自己询问某些特定事物(如STL)是否会成为问题时,我可能是正确的 - 但是 - 我猜测。性能调优的基本获胜理念是找出,不要猜测。很容易发现什么是花时间,所以不要猜。

答案 9 :(得分:1)

我认为任何人都能给你的主要提示是: 衡量 衡量 测量 即可。那和改进你的算法 你使用某些语言功能的方式,编译器版本,std lib实现,平台,机器 - 所有这些都是他们在性能方面的作用,你没有提到很多这些,我们没有人有过你的确切设置。

关于替换std::vector:使用替代品(例如this one)并尝试一下。

答案 10 :(得分:1)

  

编译器的缓存感知程度如何?例如,是否值得研究重新排序嵌套循环?

我无法代表所有编译器,但我对GCC的经验表明,它不会在缓存方面大量优化代码。我希望大多数现代编译器都适用。优化(例如重新排序嵌套循环)肯定会影响性能。如果您认为自己拥有可能导致许多缓存未命中的内存访问模式,那么调查此问题符合您的利益。

答案 11 :(得分:0)

我很惊讶没人提到这两个:

  • 链接时间优化来自4.5的clang和g ++支持链接时间优化。我听说在g ++案例中,启发式方法仍然相当不成熟,但是由于主要架构已经布局,它应该会很快改进。

    优势包括目标文件级别的程序间优化,包括非常需要的内容,例如虚拟调用(虚拟化)

  • 项目内联这可能看起来像是一种非常粗糙的方法,但是它非常粗糙使它如此强大:这相当于将所有标题和.cpp文件转储到单个,非常大的.cpp文件并编译;基本上它会给你带来与你1999年旅行中链接时优化相同的好处。当然,如果你的项目真的大,你仍然需要一台2010年的机器;就像没有明天一样,这东西会占用你的内存。但是,即使在这种情况下,您也可以将其拆分为多个不那么大的.cpp文件

答案 12 :(得分:0)

  

C ++被单独编译和链接的一个结果是编译器无法进行看起来非常简单的优化,例如在循环的终止条件下移动方法调用如strlen()。有没有像我这样的优化,因为它们不能由编译器完成,必须手工完成?

在某些编译器上,这是不正确的。编译器完全了解所有翻译单元(包括静态库)中的所有代码,并且可以像在单个翻译单元中一样优化代码。我想到了一些支持此功能的功能:

  • Microsoft Visual C ++编译器
  • 英特尔C ++编译器
  • LLVC-GCC
  • 海湾合作委员会(我认为,不确定)

答案 13 :(得分:0)

例如,如果您处理大型矩阵,请考虑平铺循环以改善局部性。这通常会带来显着的改善。您可以使用VTune / PTU来监控L2缓存未命中。

答案 14 :(得分:0)

STL优先级队列实现针对其功能进行了相当优化,但某些类型的堆具有可以提高某些算法性能的特殊属性。斐波那契堆就是一个例子。此外,如果您使用小密钥和大量卫星数据存储对象,如果单独存储该数据,您将获得缓存性能的重大改进,即使这意味着每个对象存储一个额外的指针。

至于数组,我发现std :: vector甚至略微超出了编译时常量数组。也就是说,它的优化是一般性的,并且对算法访问模式的具体了解可能允许您进一步优化缓存局部性,对齐,着色等。如果您发现由于缓存效应导致性能显着下降超过某个阈值,请手在某些情况下,优化后的数组可能会将问题大小阈值移动两倍,但对于容易在缓存中容纳的小内环,或者超出任意大小的大型工作集,它不太可能产生巨大差异。 CPU缓存。首先处理优先级队列。

动态内存分配的大部分开销相对于正在分配的对象的大小是恒定的。分配一个大对象并通过指针返回它不会像复制它那样受到太大伤害。复制与动态分配的阈值在不同系统之间差异很大,但在芯片生成中它应该相当一致。

当打开cpu特定的调优时,编译器可以识别缓存,但是他们不知道缓存的大小。如果您正在优化高速缓存大小,则可能需要检测该高速缓存,或者让用户在运行时指定它,因为即使在同一代的处理器之间也会有所不同。

至于浮点数,你绝对应该使用SSE。这并不一定需要自己学习SSE,因为有许多高度优化的SSE代码库可以执行各种重要的科学计算操作。如果您正在编译64位代码,编译器可能会自动发出一些SSE代码,因为SSE2是x86_64指令集的一部分。 SSE还可以节省一些x87浮点的开销,因为它不会在内部来回转换为80位值。这些转换也可能是准确性问题的根源,因为根据它们的编译方式,您可以从同一组操作中获得不同的结果,因此最好将其删除。

答案 15 :(得分:0)

答案 16 :(得分:-1)

我当前的项目是一个媒体服务器,具有多线程处理(C ++语言)。这是一个时间关键的应用程序,一旦低性能功能可能导致媒体流的不良结果,如失去同步,高延迟,大量延迟等。

我通常用来获得最佳性能的策略是尽量减少分配或管理内存,文件,套接字等资源的繁重的操作系统调用量。

起初我编写了自己的STL,网络和文件管理类。

我的所有容器类(“MySTL”)都管理自己的内存块,以避免多次alloc(新)/ free(删除)调用。释放的对象排入内存块池,以便在需要时重用。在这种方式下,我提高了性能并保护我的代码免受内存碎片的影响。

需要访问性能较低的系统资源(如文件,数据库,脚本,网络写入)的代码部分我使用单独的线程。但是每个单元没有一个线程(例如每个套接字没有一个线程),如果是这样,操作系统在管理大量线程时会失去性能。因此,如果可能,您可以将相同类的对象分组以在单独的线程上进行处理。

例如,如果必须将数据写入网络套接字,但套接字写入缓冲区已满,则将数据保存在sendqueue缓冲区(与所有套接字共享内存)中,以便在单独的线程上发送插座再次可写时通过这种方式,您的主线程永远不会停止在阻塞状态下处理,等待操作系统释放特定资源。释放的所有缓冲区都会被保存并在需要时重复使用。

毕竟,欢迎使用配置文件工具查找程序瓶并显示应该改进哪些算法。

一旦我在Linux机器上运行500天以上的服务器而没有重新启动,我成功使用该策略,每天有数千名用户登录。

[02:01] -alpha.ip.tv-正常运行时间:525天12小时43分7秒

答案 17 :(得分:-1)

这是一次对我有用的东西。我不能说它会对你有用。我的代码就是

switch(num) {
   case 1: result = f1(param); break;
   case 2: result = f2(param); break;
   //...
}

然后当我将其更改为

时,我得到了严重的性能提升
// init:
funcs[N] = {f1, f2 /*...*/};
// later in the code:
result = (funcs[num])(param);

也许有人可以解释后一版本更好的原因。我想它与那里没有条件分支这一事实有关。

答案 18 :(得分:-1)

如果您正在进行大量的浮点数学计算,那么如果能很好地映射到您的问题,您应该考虑使用SSE来矢量化您的计算。

Google SSE内在函数,了解有关此内容的更多信息。