C ++低级优化技巧


79

假设您已经有了最佳选择算法,那么您可以提供哪些低级解决方案来从C ++代码中压缩出最后几帧呢?

不用说,这些技巧仅适用于您在事件探查器中已突出显示的关键代码部分,但它们应该是低级别的非结构性改进。我已经播了一个例子。


1
是什么使这成为游戏开发问题,而不是像这样的一般编程问题:stackoverflow.com/search?
q=c%2B%2B+optimization

@Danny-这可能是一个一般的编程问题。当然,这也是与游戏编程有关的问题。我认为这在两个网站上都是一个可行的问题。
Smashery

@Smashery两者之间的唯一区别是,游戏编程可能需要特定的图形引擎级别优化或着色器编码器优化,C ++部分相同。
Danny Varod 2010年

@Danny-是的,有些问题在一个站点或另一个站点上可能与“更多”相关;但我不想仅仅因为也可以在其他站点上询问而拒绝任何相关问题。
Smashery

Answers:


76

优化您的数据布局!(这不仅适用于C ++,还适用于更多的语言)

您可以深入研究,使其针对数据,处理器,很好地处理多核等进行专门调整。但是基本概念是这样的:

在紧密循环中处理事物时,您希望使每次迭代的数据尽可能小,并在内存中尽可能地紧密。这意味着理想的是对象(不是指针)的数组或向量,其中仅包含计算所需的数据。

这样,当CPU在循环的第一次迭代中获取数据时,接下来的几次迭代中将有价值的数据随之加载到缓存中。

确实,CPU速度很快,编译器也不错。使用更少,更快的指令并不能做太多事情。缓存一致性就在这里(我在Google上浏览过一篇随机文章-它提供了一个获取缓存一致性的好例子,该算法不能简单地线性处理数据。)


值得尝试的是链接的“缓存一致性”页面中的C示例。当我第一次发现这一点时,我为它带来的变化感到震惊。
Neel

9
另请参见出色的面向对象编程的陷阱演讲(Sony R&D)(research.scee.net/files/presentations/gcapaustralia09/…)以及Mike Acton的怪异但引人入胜的CellPerformance文章(cellperformance.beyond3d.com/articles/ index.html)。Noel Llopis的Games From Inside博客也经常涉及到这个主题(gamesfromwithin.com)。我不能推荐“陷阱”滑梯足够……
leander

2
我只是警告“使每次迭代的数据尽可能小,并在内存中尽可能地紧密”。访问未对齐的数据会使速度变慢。在这种情况下,填充将提供更好的性能。数据的顺序也很重要,因为顺序良好的数据可以减少填充。斯科特·梅耶斯(Scott Mayers)可以比我更好地解释这一点:)
乔纳森·康奈尔

+1对Sony演示文稿。我之前读过这篇文章,并且在考虑如何将数据拆分为多个块并将其正确对齐的基础上,如何在平台级别优化数据确实有意义。
克里斯·C(ChrisC)2011年

84

一个非常非常低级的技巧,但可以派上用场:

大多数编译器支持某种形式的显式条件提示。GCC具有一个称为__builtin_expect的函数,该函数可让您通知编译器结果的值可能是什么。GCC可以使用这些数据来优化条件,以便在预期情况下尽快执行,在意外情况下执行速度稍慢。

if(__builtin_expect(entity->extremely_unlikely_flag, 0)) {
  // code that is rarely run
}

正确使用此功能,可以使速度提高10-20%。


1
如果可以的话,我会投票两次。
tenpn

10
+ 1,Linux内核广泛使用此代码进行调度程序代码的微优化,并且在某些代码路径中产生了重大差异。
greyfade

2
不幸的是,Visual Studio中似乎没有等效的东西。stackoverflow.com/questions/1440570/...
mmyers

1
因此,期望值通常必须以哪个频率正确地获得性能?49/50次?还是999999/1000000次?
道格拉斯

36

您需要了解的第一件事是运行的硬件。它如何处理分支?缓存呢?它有SIMD指令集吗?它可以使用多少个处理器?是否需要与其他人共享处理器时间?

您可能会以非常不同的方式解决相同的问题-甚至您选择的算法也应取决于硬件。在某些情况下,O(N)的运行速度可能比O(NlogN)慢(取决于实现方式)。

作为优化的粗略概述,我要做的第一件事是准确地查看要解决的问题和数据。然后为此进行优化。如果您想获得出色的性能,那么就不用考虑通用的解决方案了-您可以对所有不常用的情况进行特殊处理。

然后进行配置。个人资料,个人资料,个人资料。查看内存使用情况,查看分支惩罚,查看函数调用开销,查看管道利用率。找出使您的代码变慢的原因。可能是数据访问(我写了一篇名为“ The Latency Elephant”的文章,介绍了数据访问的开销-用谷歌搜索。由于我没有足够的“信誉”,所以我无法在此处发布2个链接),因此请仔细检查并然后优化您的数据布局(漂亮的大平面同质数组很棒)和数据访问(在可能的情况下预取)。

将内存子系统的开销降至最低后,请尝试确定指令现在是否已成为瓶颈(希望它们已成为瓶颈),然后查看算法的SIMD实现-数组结构(SoA)实现可能非常数据化,指令缓存效率高。如果SIMD不能很好地解决您的问题,则可能需要内部函数和汇编程序级别的编码。

如果您仍然需要更高的速度,请平行进行。如果您可以在PS3上运行,那么SPU就是您的朋友。使用它们,爱它们。如果您已经编写了SIMD解决方案,那么使用SPU将会获得巨大的好处。

然后,再配置一些。在游戏场景中进行测试-此代码仍然是瓶颈吗?您能否更改更高级别使用此代码的方式以最大程度地减少其使用(实际上,这应该是您的第一步)?您可以将计算推迟到多个框架吗?

无论您使用哪种平台,都将尽可能多地了解可用的硬件和分析器。不要以为您知道瓶颈是什么-通过分析器找到它。并确保您有试探性的方法来确定您是否确实使游戏运行得更快。

然后再次对其进行配置。


31

第一步:仔细考虑与算法有关的数据。O(log n)并不总是比O(n)快。一个简单的例子:只有几个键的哈希表通常最好用线性搜索代替。

第二步:查看生成的程序集。C ++为表带来了很多隐式代码生成。有时,它会在您不知情的情况下偷偷溜走。

但是假设这确实是最紧要的时间:Profile。说真的 随机应用“性能技巧”可能会带来伤害,而可能会有所帮助。

然后,一切都取决于您的瓶颈所在。

数据缓存未命中=>优化数据布局。这是一个很好的起点:http : //gamesfromwithin.com/data-oriented-design

代码缓存未命中=>查看虚拟函数调用,过多的调用栈深度等。导致性能下降的常见原因是错误地认为基类必须是虚拟的。

其他常见的C ++性能接收器:

  • 过多的分配/取消分配。如果它对性能至关重要,请不要调用运行时。曾经
  • 复制结构。尽量避免。如果它可以是const引用,请使其成为一个。

当您查看程序集时,以上所有内容都是显而易见的,因此请参见上文;)


19

删除不必要的分支

在某些平台和某些编译器上,分支可能会丢弃整个管道,因此即使微不足道的if()块也可能会很昂贵。

PowerPC体系结构(PS3 / x360)提供了浮点选择指令fsel。如果块是简单分配,则可以在分支位置使用它:

float result = 0;
if (foo > bar) { result = 2.0f; }
else { result = 1.0f; }

成为:

float result = fsel(foo-bar, 2.0f, 1.0f);

当第一个参数大于或等于0时,返回第二个参数,否则返回第三个。

失去分支的代价是将同时执行if {}和else {}块,因此,如果一个操作很昂贵或取消引用NULL指针,则此优化不适合。

有时您的编译器已经完成了这项工作,因此请首先检查您的程序集。

以下是有关分支和fsel的更多信息:

http://assemblyrequired.crashworks.org/tag/intrinsics/


浮点结果=(foo> bar)?2.f:1.f
knight666

3
@ knight666:仍然会在任何经过​​“ if”操作的地方产生分支。我之所以这样说,是因为至少在ARM上,这样的小序列可以使用不需要分支的条件指令来实现。
chrisbtoo 2010年

1
@ knight666,如果幸运的话,编译器可以将其转换为fsel,但不确定。FWIW,我通常会用一个三级运算符来编写该代码段,然后在分析器同意的情况下进行优化以选择fsel。
tenpn

在IA32上,您有CMOVcc。
Skizz 2010年

另请参见blueraja.com/blog/285/…(请注意,在这种情况下,如果编译器性能良好,它应该能够对其本身进行优化,因此您通常不必担心)
BlueRaja-Danny Pflughoeft

16

不惜一切代价避免内存访问,尤其是随机访问。

这是在现代CPU上进行优化的最重要的一件事。在等待来自RAM的数据时,您可以进行大量的算术运算,甚至可以执行很多错误的预测分支。

您还可以反过来阅读此规则:在两次内存访问之间进行尽可能多的计算。



11

删除不必要的虚拟函数调用

虚函数的分派可能非常慢。 文章给出了为什么一个很好的交代。如果可能,对于每帧被调用许多次的函数,请避免使用它们。

您可以通过以下两种方式进行操作。有时您可以重写类而不需要继承-也许事实证明MachineGun是Weapon的唯一子类,您可以合并它们。

您可以使用模板将运行时多态替换为编译时多态。仅当您在运行时知道对象的子类型时,这才有效,并且可能是主要的重写。


9

我的基本原则是:不要做任何不必要的事情

如果发现某个特定功能是瓶颈,则可以优化该功能-或者可以尝试避免首先调用它。

这并不一定意味着您正在使用错误的算法。例如,这可能意味着您正在对可能缓存一小段时间(或完全预先计算)的每个帧进行计算。

在进行任何真正的低级优化之前,我总是尝试这种方法。


2
这个问题假设您已经完成了所有可以做的结构性工作。
tenpn

2
是的 但是通常您会以为自己拥有,但实际上却没有。因此,实际上,每次需要优化昂贵的函数时,都要问自己是否需要调用该函数。
Rachel Blum

2
...但是有时候,即使以后要舍弃结果,而不是分支,它实际上可以更快地进行计算。
tenpn 2011年

9

如果您尚未使用SIMD(通过SSE)。Gamasutra 在这方面有一篇不错的文章。您可以从本文结尾的提供的库中下载源代码。


6

最小化依赖链,以更好地利用CPU管线。

在简单的情况下,如果启用循环展开,则编译器可能会为您执行此操作。但是,它通常不会这样做,尤其是当涉及浮点数时,对表达式进行重新排序会改变结果。

例:

float *data = ...;
int length = ...;

// Slow version
float total = 0.0f;
int i;
for (i=0; i < length; i++)
{
  total += data[i]
}

// Fast version
float total1, total2, total3, total4;
for (i=0; i < length-3; i += 4)
{
  total1 += data[i];
  total2 += data[i+1];
  total3 += data[i+2];
  total4 += data[i+3];
}
for (; i < length; i++)
{
  total += data[i]
}
total += (total1 + total2) + (total3 + total4);

4

不要忽视您的编译器-例如,如果您在Intel上使用gcc,则可以通过切换到Intel C / C ++编译器轻松获得性能提升。如果您以ARM平台为目标,请查看ARM的商业编译器。如果您使用的是iPhone,Apple允许从iOS 4.0 SDK开始使用Clang。

优化(尤其是在x86上)可能会涉及到一个问题,那就是在现代CPU实现上最终会遇到很多直观的问题。不幸的是,对于我们大多数人来说,对编译器进行优化的能力早已消失了。编译器可以根据自身对CPU的内部知识来调度流中的指令。此外,CPU还可以根据自身需求重新安排指令。即使您想到了一种安排方法的最佳方式,也很可能编译器或CPU已经自行提出了建议,并且已经执行了该优化。

我最好的建议是忽略低级优化,而专注于高级优化。无论它们的性能如何,编译器和CPU都无法将您的算法从O(n ^ 2)更改为O(1)算法。这将要求您仔细查看要尝试做的事情,并找到一种更好的方法来做到这一点。让编译器和CPU担心底层,而将精力集中在中高层。


我明白您在说什么,但是到了O(logN)的时刻,您就不会再从结构更改中获益了,可以进行低级优化并获得您那额外的半毫秒。
tenpn

1
请参阅我的答复:O(log n)。另外,如果您需要半毫秒,则可能需要查看更高的级别。那就是您的帧时间的3%!
Rachel Blum

4

限制关键字是潜在的方便,尤其是在你需要操纵的对象与指针的情况。它允许编译器假定所指向的对象不会以任何其他方式修改,从而使编译器可以执行更积极的优化,例如将对象的某些部分保留在寄存器中或更有效地对读写进行重新排序。

关于关键字的一个好处是,它是一个提示,您可以一次应用它,而无需重新安排算法就可以从中受益。不利的一面是,如果在错误的地方使用它,则可能会看到数据损坏。但是通常很容易发现在哪里使用它是合法的-这是可以合理地期望程序员知道比编译器可以安全假设的更多信息的少数示例之一,这就是引入关键字的原因。

从技术上讲,“限制”在标准C ++中并不存在,但是大多数C ++编译器都可以使用特定于平台的等效项,因此值得考虑。

另请参阅:http : //cellperformance.beyond3d.com/articles/2006/05/demystifying-the-restrict-keyword.html


2

const一切!

您向编译器提供的有关数据的信息越多,优化效果就越好(至少以我的经验而言)。

void foo(Bar * x) {...;}

成为

void foo(const Bar * const x) {...;}

现在,编译器知道指针x不会改变,并且指向的数据也不会改变。

另一个额外的好处是,您可以减少意外错误的数量,阻止自己(或其他人)修改不应该的错误。


您的代码伙伴将爱您!
tenpn

4
const不能改善编译器优化。的确,如果编译器知道变量不会改变,但const不能提供足够强的保证,则可以生成更好的代码。
deft_code 2010年

3
不。“限制”远比“常量”有用。参见gamedev.stackexchange.com/questions/853/…–
Justicle

+1 PPL说常量不能帮助是错误... infoq.com/presentations/kixeye-scalability
NoSenseEtAl

2

通常,获得性能的最佳方法是更改​​算法。实施的通用性越差,您就越接近金属。

假设已经完成了...

如果确实是至关重要的代码,请尝试避免读取内存,并避免计算可以预先计算的内容(尽管没有查找表,因为它们违反了规则1)。知道算法的作用,并以编译器也知道的方式编写它。检查组装以确保确实如此。

避免缓存未命中。尽可能多地进行批处理。避免使用虚函数和其他间接方式。

最终,衡量一切。规则一直在变化。3年前用来加速代码的功能现在使其速度变慢。一个很好的例子是“使用双数学函数而不是浮点型”。如果我不读的话,我不会意识到的。

我忘记了-不要让默认的构造函数初始化您的变量,或者,如果您坚持要这么做,至少也要创建不这样做的构造函数。请注意配置文件中未显示的内容。当您在每行代码中丢失一个不必要的周期时,分析器中将不会显示任何内容,但是总体上将损失很多周期。同样,知道您的代码在做什么。使您的核心功能精简而不是万无一失。如果需要,可以调用万无一失的版本,但并非总是需要。多功能性需要付出代价-性能是其中之一。

编辑解释为什么没有默认初始化:很多代码说:Vector3 bla; bla = DoSomething();

构造函数中的初始化浪费时间。同样,在这种情况下,浪费的时间很小(可能清除了向量),但是,如果您的程序员习惯性地这样做,则会加起来。另外,许多函数会创建一个临时(请考虑重载的运算符),将其初始化为零并立即分配。隐藏的丢失周期太小,无法在探查器中看到峰值,但整个代码库中的渗漏周期却很大。另外,有些人在构造函数上做得更多(这显然是不行的)。我已经从一个未使用的变量中获得了数毫秒的收益,其中构造函数恰好在繁重的一面。一旦构造函数产生副作用,编译器将无法对其进行优化,因此,除非您从不使用上述代码,否则我更喜欢非初始化构造函数,或者,正如我所说,

Vector3 bla(noInit); bla = doSomething();


/不/在构造函数中初始化您的成员?有什么帮助?
tenpn

参见编辑后的帖子。不在评论框中。
Kaj 2010年

const Vector3 = doSomething()?然后,可以开始执行返回值优化,并可能省去一两个赋值。
tenpn

1

减少布尔表达式评估

这真的很绝望,因为这是对代码的非常微妙但危险的更改。但是,如果您对一个条件进行了无数次的求值,则可以通过使用按位运算符来减少布尔求值的开销。所以:

if ((foo && bar) || blah) { ... } 

成为:

if ((foo & bar) | blah) { ... }

而是使用整数算术。如果您的foo和bar是常量或在if()之前求值,则它可能比普通的布尔版本快。

另外,算术版本的分支少于常规布尔版本的分支。这是另一种优化方法

最大的缺点是您失去了懒惰的评估-整个区块都被评估了,所以您不能这样做foo != NULL & foo->dereference()。因此,很难维护它是有争议的,因此折衷可能会太大。


1
就性能而言,这是一个非常令人吃惊的折衷,主要是因为它并没有立即显而易见。
鲍勃·萨默斯

我几乎完全同意你的看法。我确实说那是绝望的!
tenpn

3
这样是否还可以打破短路并使分支预测更加不可靠?
伊贡(Egon)2010年

1
如果foo为2而bar为1,则代码的行为完全不一样。我认为,这是最大的弊端,而不是早期评估。

1
严格来说,C ++ 中的布尔值保证为0或1,因此只要您只对布尔值进行操作就可以确保安全。更多:altdevblogaday.org/2011/04/18/understanding-your-bool-type
tenpn 2011年

1

密切关注您的堆栈使用情况

调用函数时,添加到堆栈中的所有内容都是额外的推送和构建。当需要大量的堆栈空间时,提前分配工作内存有时是有益的,并且如果您正在使用的平台具有可用的快速RAM,那就更好了!

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.