编码实践,使编译器/优化器可以制作更快的程序


116

许多年前,C编译器并不是特别聪明。作为解决方法,K&R发明了register关键字,以向编译器提示,将这个变量保留在内部寄存器中可能是一个好主意。他们还让第三级操作员帮助生成更好的代码。

随着时间的流逝,编译器逐渐成熟。他们变得非常聪明,因为他们的流程分析使他们能够比您可能做的更好地决定要保存在寄存器中的值。register关键字变得不重要。

由于别名问题,对于某些类型的操作,FORTRAN可能比C更快。从理论上讲,经过仔细的编码,可以绕过这一限制,以使优化器生成更快的代码。

有哪些可用的编码实践可以使编译器/优化器生成更快的代码?

  • 确定您使用的平台和编译器,将不胜感激。
  • 为什么该技术似乎有效?
  • 鼓励使用示例代码。

这是一个相关的问题

[编辑] 此问题与概要分析和优化的总体过程无关。假设程序已正确编写,经过全面优化编译,经过测试并投入生产。您的代码中可能存在一些禁止优化器尽其所能的构造。您如何做才能重构以消除这些禁止并允许优化器生成更快的代码?

[编辑] 偏移相关链接


7
可能是社区Wiki恕我直言的一个很好的候选人,因为对于这个(有趣的)问题没有“唯一的”明确答案……
ChristopheD 2010年

我每次都想念它。感谢您指出。
EvilTeach 2010年

“更好”是指“更快”,还是您在考虑其他卓越标准?
高性能Mark

1
编写好的寄存器分配器非常困难,尤其是要移植时,寄存器分配对于性能和代码大小绝对至关重要。register通过与性能较差的编译器进行斗争,实际上使性能敏感的代码更易于移植。
Potatoswatter 2010年

1
@EvilTeach:社区Wiki并不意味着“没有确定的答案”,它与主观标签不是同义词。社区Wiki意味着您希望将您的帖子提交给社区,以便其他人可以对其进行编辑。如果您不喜欢维基,请不要感到压力。
朱丽叶

Answers:


54

写局部变量而不是输出参数!这对于解决混叠速度降低可能是一个巨大的帮助。例如,如果您的代码看起来像

void DoSomething(const Foo& foo1, const Foo* foo2, int numFoo, Foo& barOut)
{
    for (int i=0; i<numFoo, i++)
    {
         barOut.munge(foo1, foo2[i]);
    }
}

编译器不知道foo1!= barOut,因此每次循环都要重新加载foo1。在完成对barOut的写入之前,它也无法读取foo2 [i]。您可以开始使用受限制的指针,但是这样做同样有效(并且更加清晰):

void DoSomethingFaster(const Foo& foo1, const Foo* foo2, int numFoo, Foo& barOut)
{
    Foo barTemp = barOut;
    for (int i=0; i<numFoo, i++)
    {
         barTemp.munge(foo1, foo2[i]);
    }
    barOut = barTemp;
}

听起来很傻,但是编译器可以更聪明地处理局部变量,因为它不可能与任何参数在内存中重叠。这可以帮助您避免可怕的load-hit-store(由Francis Boivin在此线程中提到)。


7
这还有一个好处,就是经常使程序员也更容易阅读/理解它们,因为他们也不必担心可能的非显而易见的副作用。
Michael Burr 2010年

大多数IDE默认情况下都显示局部变量,因此键入的内容较少
EvilTeach 2010年

9
您还可以通过使用受限指针来启用该优化功能
Ben Voigt 2010年

4
@Ben-是的,但是我认为这种方式更清晰。另外,如果输入和输出确实重叠,则我相信结果是未使用受限的指针指定的(调试和发行版之间可能会获得不同的行为),而这种方式至少是一致的。不要误会我的意思,我喜欢使用限制,但是我不需要更多。
celion

您只是希望Foo没有定义可复制几兆数据的复制操作;-)
Skizz 2010年

76

这是一种编码实践,可以帮助编译器创建快速代码-任何语言,任何平台,任何编译器,任何问题:

千万不能使用任何聪明的技巧,其力,甚至鼓励,编译器奠定了变量在内存中(包括高速缓存和寄存器),你认为最好的。首先编写一个正确且可维护的程序。

接下来,分析您的代码。

然后,直到那时,您才可能要开始研究告诉编译器如何使用内存的效果。一次进行1次更改并衡量其影响。

期望会感到失望,并且确实必须非常努力地工作以提高性能。适用于成熟语言(例如Fortran和C)的现代编译器非常非常好。如果您读了一个“技巧”来从代码中获得更好的性能,请记住,编译器作者也已经阅读了该技巧,如果值得的话,可能会实现它。他们可能首先写了您所读的书。


20
像其他所有人一样,编译器开发人员也有有限的时间。并非所有优化都会使其进入编译器。就像&vs.的2 %的幂(很少,即使有,也没有优化,但可能会对性能产生重大影响)。如果您阅读了有关性能的技巧,则知道它是否有效的唯一方法是进行更改并评估影响。永远不要假设编译器会为您优化某些东西。
戴夫·贾维斯

22
&和%以及其他大多数免费的廉价算术技巧几乎总是经过优化的。没有得到优化的是右手操作数是一个变量,该变量总是总是2的幂。
Potatoswatter 2010年

8
为了澄清,我似乎使一些读者感到困惑:我建议的编码实践中的建议是首先开发一个简单的代码,该代码不使用内存布局指令来建立性能基准。然后,一次尝试一件事情并衡量其影响。我没有提供任何有关运营绩效的建议。
高性能Mark

17
对于恒定的2的幂,即使禁用了优化n,gcc 也会替换。那不完全是“很少,如果有的话”……% n& (n-1)
Porculus

12
CAN NOT被优化为与当所述类型是有符号时,由于C中的负整数除法白痴规则(圆形向0和具有负剩余部分,而不是向下取整,并总是具有正的余数)。而且大多数时候,无知的编码人员都使用带符号的类型...
R .. GitHub停止帮助ICE 2010年

47

遍历内存的顺序可能会对性能产生深远的影响,编译器并不能很好地解决并解决问题。如果您关心性能,则在编写代码时必须认真考虑缓存局部性问题。例如,C语言中的二维数组以行优先格式分配。以列主格式遍历数组将倾向于使您有更多的缓存未命中,并使程序比处理器的绑定更多的内存:

#define N 1000000;
int matrix[N][N] = { ... };

//awesomely fast
long sum = 0;
for(int i = 0; i < N; i++){
  for(int j = 0; j < N; j++){
    sum += matrix[i][j];
  }
}

//painfully slow
long sum = 0;
for(int i = 0; i < N; i++){
  for(int j = 0; j < N; j++){
    sum += matrix[j][i];
  }
}

严格来说,这不是优化程序问题,而是优化问题。
EvilTeach 2010年

10
当然,这是一个优化程序问题。数十年来,人们一直在撰写有关自动循环交换优化的论文。
Phil Miller 2010年

20
@Potatoswatter你在说什么?只要观察到相同的最终结果,C编译器就可以做任何想做的事情,实际上,GCC 4.4拥有-floop-interchange它,如果优化程序认为它有利可图,它将翻转内部和外部循环。
短暂

2
恩,你去那里。C语义常常因别名问题而受损。我想这里的真正建议是传递那个标志!
Potatoswatter

36

通用优化

这是我最喜欢的一些优化。通过使用这些,实际上我增加了执行时间,并减小了程序大小。

将小函数声明为inline或宏

每次对函数(或方法)的调用都会产生开销,例如将变量压入堆栈。某些功能也可能导致返回的开销。与合并的开销相比,效率低下的函数或方法在其内容中的语句更少。无论是作为#define宏还是作为inline函数,这些都是内联的不错选择。(是的,我知道inline这只是一个建议,但在这种情况下,我认为它是对编译器的提醒。)

删除无效和冗余代码

如果不使用该代码或对该程序的结果没有帮助,请删除它。

简化算法设计

我曾经通过记下正在计算的代数方程式,然后简化了代数表达式,从程序中删除了很多汇编代码和执行时间。简化的代数表达式的实现比原始函数占用的空间和时间更少。

循环展开

每个循环都有增量检查和终止检查的开销。要获得性能因子的估计值,请计算开销中的指令数(最少3:递增,检查,转到循环的开始),然后除以循环内的语句数。数值越低越好。

编辑: 提供循环展开的示例之前:

unsigned int sum = 0;
for (size_t i; i < BYTES_TO_CHECKSUM; ++i)
{
    sum += *buffer++;
}

展开后:

unsigned int sum = 0;
size_t i = 0;
**const size_t STATEMENTS_PER_LOOP = 8;**
for (i = 0; i < BYTES_TO_CHECKSUM; **i = i / STATEMENTS_PER_LOOP**)
{
    sum += *buffer++; // 1
    sum += *buffer++; // 2
    sum += *buffer++; // 3
    sum += *buffer++; // 4
    sum += *buffer++; // 5
    sum += *buffer++; // 6
    sum += *buffer++; // 7
    sum += *buffer++; // 8
}
// Handle the remainder:
for (; i < BYTES_TO_CHECKSUM; ++i)
{
    sum += *buffer++;
}

利用此优势,可以获得第二个好处:在处理器必须重新加载指令高速缓存之前,将执行更多的语句。

当我展开一个循环到32条语句时,我获得了惊人的结果。这是瓶颈之一,因为该程序必须在2GB的文件上计算校验和。这种优化与块读取相结合,将性能从1小时提高到了5分钟。循环展开也以汇编语言提供了出色的性能,我memcpy的速度比编译器要快得多memcpy。 - TM值

减少if陈述

处理器讨厌分支或跳转,因为它迫使处理器重新加载其指令队列。

布尔算术(编辑: 将代码格式应用于代码片段,添加了示例)

if语句转换为布尔分配。一些处理器可以有条件地执行指令而无需分支:

bool status = true;
status = status && /* first test */;
status = status && /* second test */;

短路的的逻辑与运算符(&&)防止测试的执行,如果statusfalse

例:

struct Reader_Interface
{
  virtual bool  write(unsigned int value) = 0;
};

struct Rectangle
{
  unsigned int origin_x;
  unsigned int origin_y;
  unsigned int height;
  unsigned int width;

  bool  write(Reader_Interface * p_reader)
  {
    bool status = false;
    if (p_reader)
    {
       status = p_reader->write(origin_x);
       status = status && p_reader->write(origin_y);
       status = status && p_reader->write(height);
       status = status && p_reader->write(width);
    }
    return status;
};

循环外的因子变量分配

如果在循环中动态创建变量,请将创建/分配移动到循环之前。在大多数情况下,不需要在每次迭代期间分配变量。

循环外的因子常量表达式

如果计算或变量值不取决于循环索引,则将其移到循环外部(之前)。

块中的I / O

读取和写入大块数据(块)。越大越好。例如,读取一个八位字节在一个时间比读1024个字节与一个读出效率较低。
例:

static const char  Menu_Text[] = "\n"
    "1) Print\n"
    "2) Insert new customer\n"
    "3) Destroy\n"
    "4) Launch Nasal Demons\n"
    "Enter selection:  ";
static const size_t Menu_Text_Length = sizeof(Menu_Text) - sizeof('\0');
//...
std::cout.write(Menu_Text, Menu_Text_Length);

可以从视觉上证明该技术的效率。:-)

不要使用printf 家庭来获取恒定数据

可以使用块写入输出恒定数据。格式化写入将浪费时间扫描文本以格式化字符或处理格式化命令。参见上面的代码示例。

格式化到内存,然后写入

char使用倍数将其格式化为数组sprintf,然后使用fwrite。这也允许将数据布局分为“恒定部分”和“可变部分”。想想邮件合并

将常量文本(字符串文字)声明为 static const

当声明变量时不带时static,某些编译器可能会在堆栈上分配空间并从ROM复制数据。这是两个不必要的操作。可以通过使用static前缀来解决。

最后,像编译器一样的代码

有时,编译器可以比一个复杂的版本更好地优化几个小语句。此外,编写代码以帮助编译器优化也有帮助。如果我希望编译器使用特殊的块传输指令,我将编写看起来应该使用特殊指令的代码。


2
有趣的是,您可以提供一个示例,其中使用一些小的语句而不是较大的语句来获得更好的代码。您能否显示一个使用布尔值重写if的示例。通常,我会将循环展开给编译器,因为它可能对缓存大小有更好的了解。我对sprintfing然后是fwriting的想法感到有些惊讶。我认为fprintf实际上是在后台执行此操作。您可以在此处提供更多详细信息吗?
EvilTeach 2010年

1
不能保证fprintf先将格式格式化为单独的缓冲区,然后再输出该缓冲区。简化的(用于内存使用)fprintf将输出所有未格式化的文本,然后进行格式化和输出,并重复进行直到处理完整个格式字符串,从而对每种类型的输出(格式化与未格式化)进行1个输出调用。其他实现将需要为每个调用动态分配内存以容纳整个新字符串(这在嵌入式系统环境中很糟糕)。我的建议减少了输出数量。
Thomas Matthews 2010年

3
我曾经通过循环提高了性能。然后,我弄清楚了如何通过使用间接方式将其更紧密地汇总,该程序的运行速度明显加快。(分析显示该特定功能占运行时的60-80%,我在此之前和之后都仔细测试了性能。)我相信这种改进是由于更好的局部性,但我对此并不完全确定。
David Thornley,2010年

16
其中许多是程序员优化,而不是程序员帮助编译器进行优化的方法,这是原始问题的重点。例如,循环展开。是的,您可以自己展开,但是我认为找出编译器为您展开并删除这些障碍有哪些障碍会更有趣。
Adrian McCarthy

26

实际上,优化器并不能真正控制程序的性能。使用适当的算法和结构以及配置文件,配置文件,配置文件。

就是说,您不应该在另一个文件中的一个文件的小函数内循环,因为这会阻止其内联。

尽可能避免使用变量的地址。要求指针不是“免费的”,因为这意味着该变量需要保留在内存中。如果避免使用指针,甚至可以将数组保留在寄存器中-这对于向量化至关重要。

指向下一点,请阅读^#$ @手册!如果您在__restrict__这里和__attribute__( __aligned__ )那里撒了一个,GCC可以向量化普通的C代码。如果您希望优化器提供非常具体的信息,则可能必须具体说明。


14
这是一个很好的答案,但是请注意,整个程序优化正变得越来越流行,并且实际上可以跨翻译单元内联函数。
Phil Miller 2010年

1
@Novelocrat是的-不用说,当我第一次看到A.c内联的东西时,我感到非常惊讶B.c
Jonathon Reinhart 2013年

18

在大多数现代处理器上,最大的瓶颈是内存。

混淆:加载-命中-存储可能会陷入死循环。如果您正在读取一个内存位置并写入另一个内存位置,并且知道它们是不相交的,那么仔细地在函数参数上放置一个别名关键字确实可以帮助编译器生成更快的代码。但是,如果内存区域确实重叠并且您使用了“别名”,那么您就可以进行良好的未定义行为调试会话!

Cache-miss:高速缓存未命中:由于它主要是算法算法,因此不确定要如何帮助编译器,但是预取内存有一些内在函数。

也不要尝试过多地将浮点值转换为int,反之亦然,因为它们使用不同的寄存器并将其从一种类型转换为另一种类型,这意味着调用实际的转换指令,将值写入内存并将其读回到适当的寄存器集中。


4
+1用于加载存储和不同的寄存器类型。我不确定在x86上有多大的麻烦,但是它们正在PowerPC(例如Xbox360和Playstation3)上毁灭。
celion 2010年

关于编译器循环优化技术的大多数论文都假设完美的嵌套,这意味着除了最里面的循环之外,每个循环的主体都是另一个循环。这些文件只是没有讨论将其概括的必要步骤,即使很明显可以做到。因此,由于需要额外的精力,我希望许多实现实际上不支持这些概括。因此,用于优化循环中缓存使用率的许多算法在完美嵌套上的效果可能会比在不完美嵌套上更好。
Phil Miller 2010年

11

人们编写的绝大多数代码将受I / O约束(我相信过去30年来我为金钱而编写的所有代码都是受约束的),因此对大多数人来说,优化器的工作将具有学术性。

但是,我要提醒人们,要优化代码,您必须告诉编译器对其进行优化-许多人(包括我在内,我忘了)在此处发布了C ++基准测试,这些基准测试没有启用就没有意义。


7
我承认自己很特别-我致力于处理内存带宽受限的大型科学数字运算代码。对于程序的总体人群,我同意尼尔的看法。
高性能Mark

6
真正; 但是,如今,这些受I / O约束的代码中有很多是用几乎是悲观的语言编写的,甚至是没有编译器的语言。我怀疑仍在使用C和C ++的区域将倾向于在某些方面进行更重要的优化(CPU使用率,内存使用率,代码大小...)
Porculus 2010年

3
在过去的30年中,我大部分时间都在用很少的I / O编写代码。保存2年可做数据库。图形,控制系统,仿真-不受I / O约束。如果I / O是大多数人的瓶颈,那么我们就不会给Intel和AMD太多关注。
phkahler 2010年

2
是的,我真的不赞成这种说法,否则我们(在我的工作中)不会寻找方法来花费更多的计算时间来进行I / O。另外-我遇到的许多受I / O约束的软件都是受I / O约束的,因为I / O的处理很随意。如果优化访问模式(就像使用内存一样),则可以在性能上获得巨大收益。
dash-tom-bang 2010年

3
我最近发现,几乎没有用C ++语言编写的代码都受I / O约束。当然,如果您正在调用OS函数进行大容量磁盘传输,则您的线程可能会进入I / O等待状态(但是使用缓存,即使这样还是有问题的)。但是,每个人都推荐的标准I / O库功能是因为它们是标准的和可移植的,与现代磁盘技术(甚至是价格适中的东西)相比,实际上却要慢得多。最有可能的是,仅当您只写了几个字节之后就一直将所有内容刷新到磁盘时,I / O才是瓶颈。太太,UI是另一回事,我们人类很慢。
Ben Voigt 2010年

11

在代码中尽可能使用const正确性。它允许编译器进行更好的优化。

本文档中还有许多其他优化技巧:CPP优化 (虽然有点旧的文档)

强调:

  • 使用构造函数初始化列表
  • 使用前缀运算符
  • 使用显式构造函数
  • 内联函数
  • 避免临时物体
  • 注意虚拟功能的成本
  • 通过参考参数返回对象
  • 考虑按班分配
  • 考虑STL容器分配器
  • “空成员”优化
  • 等等

8
很少,很少。但是,它确实可以提高实际的正确性。
Potatoswatter 2010年

5
在C和C ++中,编译器不能使用const进行优化,因为将其抛弃是明确定义的行为。
dsimcha 2010年

+1:const是直接影响已编译代码的一个很好的例子。回复@dsimcha的评论-一个好的编译器将进行测试以查看是否发生这种情况。当然,好的编译器会“找到”无论如何都没有声明的const元素……
Hogan 2010年

@dsimcha:更改const restrict合格指针,然而,是未定义的。因此,在这种情况下,编译器可以进行不同的优化。
Dietrich Epp

6
@dsimcha完全抛弃const了指向非对象的const引用或const指针const。修改实际const对象(即声明为const原始对象的对象)不是。
史蒂芬·林

9

尝试使用静态单一分配进行编程。SSA与您在大多数功能性编程语言中获得的最终结果完全相同,这就是大多数编译器将其转换为用于进行优化的代码的原因,因为它更易于使用。通过这样做,发现了可能使编译器感到困惑的地方。它还使除最差的寄存器分配器之外的所有寄存器都能与最佳的寄存器分配器一样工作,并且使您可以更轻松地进行调试,因为您几乎不必怀疑变量从何处获得了它的值,因为它只分配了一个位置。
避免使用全局变量。

通过引用或指针处理数据时,请将其拖入局部变量中,进行工作,然后将其复制回去。(除非您有充分的理由不这样做)

在进行数学或逻辑运算时,利用大多数处理器为您提供的几乎与0几乎免费的比较。您几乎总是会得到== 0和<0的标志,从中可以轻松获得3个条件:

x= f();
if(!x){
   a();
} else if (x<0){
   b();
} else {
   c();
}

几乎总是比测试其他常量便宜。

另一个技巧是使用减法来消除范围测试中的一个比较。

#define FOO_MIN 8
#define FOO_MAX 199
int good_foo(int foo) {
    unsigned int bar = foo-FOO_MIN;
    int rc = ((FOO_MAX-FOO_MIN) < bar) ? 1 : 0;
    return rc;
} 

这通常可以避免对布尔表达式进行短路的语言的跳跃,并且避免编译器不得不设法弄清楚如何在执行第二个然后合并它们的同时跟上第一个比较的结果。看起来它有可能用完一个额外的寄存器,但几乎永远不会用完。通常,您无论如何都不再需要foo了,如果您这样做了,rc尚未被使用,因此可以去那里。

在c中使用字符串函数(strcpy,memcpy等)时,请记住它们返回的内容-目标!您通常可以通过“忘记”指向目标的指针的副本并从这些函数的返回中收回来获得更好的代码。

永远不要忽略返回与您调用的最后一个函数完全相同的东西的机会。编译器不擅长于:

foo_t * make_foo(int a, int b, int c) {
        foo_t * x = malloc(sizeof(foo));
        if (!x) {
             // return NULL;
             return x; // x is NULL, already in the register used for returns, so duh
        }
        x->a= a;
        x->b = b;
        x->c = c;
        return x;
}

当然,如果只有一个返回点,则可以颠倒逻辑。

(我稍后记得的技巧)

尽可能将函数声明为静态总是一个好主意。如果编译器可以证明自己已经解决了某个特定函数的每个调用者,那么它可以以优化的名义破坏该函数的调用约定。编译器通常可以避免将参数移动到被调用函数通常希望其参数进入的寄存器或堆栈位置中(为此,它必须偏离被调用函数和所有调用方的位置)。编译器通常还可以利用知道被调用函数将需要什么内存和寄存器的优势,并避免生成代码以保留变量或被调用函数不会干扰的内存位置中的变量值。当很少调用函数时,此方法特别有效。


2
当测试范围,LLVM,GCC和我的编译器至少自动执行此操作时,实际上没有必要使用减法。很少有人会理解减法后的代码会做什么,甚至很少能理解它为何真正起作用。
Gratian Lup

在上面的示例中,无法调用b(),因为如果(x <0)则将调用a()。
EvilTeach

@EvilTeach不,不会。导致调用a()的比较结果是!x
nategoose

@nategoose。如果x为-3,则!x为true。
EvilTeach

@EvilTeach在C 0中为false,其他所有条件为true,因此-3为true,所以!-3为false
nategoose

9

我编写了一个优化的C编译器,这里有一些非常有用的事情要考虑:

  1. 使大多数功能静态。这允许过程间常量传播和别名分析完成其工作,否则编译器需要假定可以从转换单元外部以完全未知的参数值调用该函数。如果您查看著名的开放源代码库,它们中的所有功能都将标记为静态,但真正需要外部调用的功能除外。

  2. 如果使用全局变量,请尽可能将它们标记为静态和常量。如果将它们初始化一次(只读),则最好使用初始化列表,例如static const int VAL [] = {1,2,3,4},否则编译器可能不会发现变量实际上是初始化的常量,并且将无法用常量替换变量中的负载。

  3. 切勿在循环内部使用goto,大多数编译器将不再识别该循环,并且不会应用最重要的优化方法。

  4. 仅在必要时使用指针参数,并在可能时标记它们为限制。这对别名分析很有帮助,因为程序员保证没有别名(过程间别名分析通常非常原始)。很小的结构对象应按值而不是按引用传递。

  5. 尽可能使用数组而不是指针,尤其是在循环(a [i])内部。数组通常为别名分析提供更多信息,并且经过一些优化后,无论如何都会生成相同的代码(如果好奇,请搜索循环强度的降低)。这也增加了应用循环不变代码运动的机会。

  6. 尝试将循环调用提升到没有副作用(不取决于当前循环迭代)的大型函数或外部函数。在许多情况下,小型函数可以内联或转换为易于提升的内在函数,但是大型函数在编译器实际上没有的情况下似乎具有副作用。外部函数的副作用是完全未知的,除了标准库中的某些函数有时由某些编译器建模之外,这使得循环不变的代码运动成为可能。

  7. 当编写具有多种条件的测试时,最有可能将其放在第一位。if(a || b || c)应该是if(b || a || c),如果b比其他可能性更大。编译器通常不了解条件的可能值以及使用更多分支的知识(可以通过使用配置文件信息来了解它们,但很少有程序员使用它)。

  8. 使用开关比执行类似if(a || b || ... || z)的测试更快。首先检查您的编译器是否自动执行此操作,是否执行某些操作,则更容易理解是否具有if


7

对于嵌入式系统和用C / C ++编写的代码,我尽量避免动态分配内存。我这样做的主要原因不一定是性能,但是这个经验法则确实会影响性能。

众所周知,在某些平台(例如vxworks)中,用于管理堆的算法速度很慢。更糟糕的是,从调用返回到malloc所花费的时间高度依赖于堆的当前状态。因此,任何调用malloc的函数都会导致性能下降,这是无法轻易解决的。如果堆仍然干净,则性能下降可能很小,但是在该设备运行一段时间后,堆可能会变得碎片化。这些通话将花费更长的时间,并且您无法轻易计算出性能会随着时间下降的方式。您无法真正得出更坏的情况估计。在这种情况下,优化器也无法为您提供任何帮助。更糟糕的是,如果堆变得过于分散,则调用将完全失败。解决方案是使用内存池(例如,glib slices)而不是堆。如果操作正确,分配调用将变得更加快捷和确定。


我的经验法则是,如果您必须动态分配,则获取一个数组,这样就无需再次执行该操作。将它们预分配向量。
EvilTeach

7

愚蠢的小技巧,但可以为您节省一些微观的速度和代码。

始终以相同的顺序传递函数参数。

如果您有f_1(x,y,z)调用f_2,则将f_2声明为f_2(x,y,z)。不要将其声明为f_2(x,z,y)。

原因是C / C ++平台ABI(称为AKA调用约定)承诺在特定的寄存器和堆栈位置中传递参数。当参数已经在正确的寄存器中时,则不必移动它们。

在阅读反汇编的代码时,我看到一些荒谬的寄存器重排,因为人们没有遵循此规则。


2
C和C ++都不保证,甚至不提及传递特定的寄存器或堆栈位置。由ABI(例如Linux ELF)确定参数传递的详细信息。
Emmet

5

在上面的列表中没有看到的两种编码技术:

通过将代码编写为唯一源来绕过链接器

尽管单独的编译对于编译时间确实很不错,但是当谈到优化时却非常糟糕。基本上,编译器无法优化超出编译单元的范围,即链接器保留的域。

但是,如果您对程序进行了精心设计,则还可以通过唯一的通用源进行编译。那不是编译unit1.c和unit2.c然后链接两个对象,而是编译all.c,只是#include unit1.c和unit2.c。因此,您将从所有编译器优化中受益。

这就像只用C ++编写标头程序(甚至更容易用C语言编写)一样。

如果您从一开始就编写程序来启用该技术,那么它很容易,但是您还必须意识到它会更改C语义的一部分,并且会遇到诸如静态变量或宏冲突之类的问题。对于大多数程序来说,克服出现的小问题很容易。另请注意,将其作为唯一的源进行编译会比较慢,并且可能会占用大量内存(通常对于现代系统而言不是问题)。

使用这种简单的技术,我碰巧使某些程序的编写速度提高了十倍!

就像register关键字一样,此技巧也可能很快就会过时。编译器开始支持通过链接器进行优化gcc:链接时间优化

循环中的独立原子任务

这个比较棘手。它涉及算法设计与优化器管理缓存和寄存器分配的方式之间的交互。程序经常必须遍历某些数据结构,并为每个项目执行一些操作。通常,可以将执行的动作划分为两个逻辑上独立的任务。在这种情况下,您可以编写完全相同的程序,并在同一边界上执行两个循环,从而执行一个任务。在某些情况下,以这种方式编写代码可能比唯一循环要快(细节更为复杂,但可以解释为,在简单的任务情况下,所有变量都可以保存在处理器寄存器中,而在更为复杂的情况下,则不可能这样做,有些必须将寄存器写入内存,然后再回读,其成本要比其他流控制高。

请谨慎使用此功能(是否使用此技巧来配置文件性能),就像使用register一样,其性能也可能会低于改进的性能。


2
是的,到目前为止,LTO已使该职位的前半部分多余,并且可能是不好的建议。
underscore_d

@underscore_d:仍然存在一些问题(主要与导出符号的可见性有关),但是从性能的角度来看,可能再也没有问题了。
克里斯,

4

我实际上已经在SQLite中看到了这一点,他们声称这可以使性能提高5%左右:将所有代码放在一个文件中,或者使用预处理器来完成此操作。这样,优化器将可以访问整个程序,并且可以执行更多的过程间优化。


5
将使用在一起的功能紧密地放在源代码中会增加它们在目标文件中彼此接近以及在可执行文件中彼此接近的可能性。改进的指令局部性可以帮助避免在运行时丢失指令缓存。
paxos1977

AIX编译器有一个编译器开关来鼓励该行为-qipa [= <suboptions_list>] | -qnoipa打开或自定义一类优化,称为过程间分析(IPA)。
EvilTeach 2010年

4
最好是有一种不需要这种方法的开发方式。以此事实为借口编写非模块化代码,总的来说只会导致代码变慢并且存在维护问题。
霍根2010年

3
我认为此信息有些过时。从理论上讲,现在许多编译器中内置的全程序优化功能(例如gcc中的“链接时优化”)具有相同的好处,但具有完全标准的工作流程(与将全部内容放入一个文件相比,重新编译时间更快) !)
Ponkadoodle 2014年

@Wallacoloo可以肯定的是,这已经过时了。FWIW,我今天才第一次使用GCC的LTO,而且-在其他所有条件都相同的情况下-O3-它使我程序的原始大小炸毁了22%。(它不受CPU限制,因此关于速度我没有太多要说的。)
underscore_d

4

大多数现代的编译器都应该做好加速尾递归的工作,因为可以优化函数调用。

例:

int fac2(int x, int cur) {
  if (x == 1) return cur;
  return fac2(x - 1, cur * x); 
}
int fac(int x) {
  return fac2(x, 1);
}

当然,此示例没有任何边界检查。

后期编辑

虽然我对代码没有直接的了解;显然,专门设计了在SQL Server上使用CTE的要求,以便可以通过尾端递归进行优化。


1
问题是关于C的。C不会删除尾递归,因此尾递归或其他递归,如果递归太深,堆栈可能会崩溃。
蟾蜍

1
我通过使用goto避免了调用约定问题。这样可以减少开销。
EvilTeach 2010年

2
@hogan:这对我来说是新的。您能指出执行此操作的任何编译器吗?您如何确定它实际上对其进行了优化?如果可以做到这一点,那么确实需要确保做到这一点。您不是希望编译器优化器继续使用它(例如内联哪些行得通或行不通)
Toad

6
@hogan:我纠正了。没错,Gcc和MSVC都进行了尾递归优化。
Toad

5
这个例子不是尾递归,因为它不是最后的递归调用,而是乘法。
Brian Young 2010年

4

不要一遍又一遍地做同样的工作!

我看到的常见反模式遵循以下原则:

void Function()
{
   MySingleton::GetInstance()->GetAggregatedObject()->DoSomething();
   MySingleton::GetInstance()->GetAggregatedObject()->DoSomethingElse();
   MySingleton::GetInstance()->GetAggregatedObject()->DoSomethingCool();
   MySingleton::GetInstance()->GetAggregatedObject()->DoSomethingReallyNeat();
   MySingleton::GetInstance()->GetAggregatedObject()->DoSomethingYetAgain();
}

实际上,编译器必须始终调用所有这些函数。假设您(程序员)知道这些对象在这些调用过程中没有发生变化,因为对所有神圣事物的热爱……

void Function()
{
   MySingleton* s = MySingleton::GetInstance();
   AggregatedObject* ao = s->GetAggregatedObject();
   ao->DoSomething();
   ao->DoSomethingElse();
   ao->DoSomethingCool();
   ao->DoSomethingReallyNeat();
   ao->DoSomethingYetAgain();
}

对于单例getter,调用可能不会花费太多,但是肯定会产生成本(通常,“检查对象是否已创建,如果尚未创建,则创建它,然后返回它)。”这种吸气剂链变得越复杂,我们所浪费的时间就越多。


3
  1. 对所有变量声明使用尽可能局部的范围。

  2. const尽可能使用

  3. 除非打算同时注册和不注册,否则不要使用注册

其中的前2个,尤其是第1个,可帮助优化程序分析代码。这将特别有助于它对要在寄存器中保留哪些变量做出好的选择。

盲目使用register关键字可能会帮助您优化,这很可能难以解决。在查看程序集输出或概要文件之前,很难知道会发生什么。

还有其他一些事情要使代码获得良好的性能;例如,设计数据结构以最大程度地提高缓存一致性。但是问题是关于优化器的。



3

让我想起了我曾经遇到的某些事情,其症状只是我们内存不足,但结果是大大提高了性能(以及内存占用量的大幅度减少)。

在这种情况下,问题在于我们所使用的软件分配的资源很少。例如,在此处分配四个字节,在此处分配六个字节,等等。许多小对象也在8-12字节范围内运行。问题不是很多,程序需要很多小东西,而是它单独分配了很多小东西,这使每个分配膨胀到(在这个特定平台上)32个字节。

解决方案的一部分是将Alexandrescu样式的小对象池组合在一起,但对其进行扩展,以便我可以分配小对象数组以及单个项。这也极大地提高了性能,因为任何时候都可以将更多项目放入缓存中。

解决方案的另一部分是用SSO(小字符串优化)字符串代替对手工管理的char *成员的大量使用。最小分配为32个字节,我建立了一个字符串类,在char *后面有一个嵌入式28个字符的缓冲区,因此我们95%的字符串不需要做额外的分配(然后我手动替换了几乎所有的这个新类在这个库中找到char *,这很有趣。这也有助于解决大量的内存碎片问题,从而增加了其他指向对象的参考位置,并且同样提高了性能。


3

从@MSalters评论学会了一个整洁的技术我这个答案让编译器做复制省略甚至返回根据一些条件不同的对象时:

// before
BigObject a, b;
if(condition)
  return a;
else
  return b;

// after
BigObject a, b;
if(condition)
  swap(a,b);
return a;

2

如果您有反复调用的小函数,那么过去我通过将它们放在标头中作为“静态内联”获得很大的收益。ix86上的函数调用非常昂贵。

使用显式堆栈以非递归方式重新实现递归函数也可以带来很多好处,但是您确实处于开发时间与收益之间。


对于开发光线跟踪器并编写其他渲染算法的人们,将递归转换为堆栈是ompf.org上的一种假定优化。
汤姆(Tom)2010年

...我应该补充一点,在我的个人raytracer项目中,最大的开销是使用Composite模式通过边界-体积层次结构进行基于vtable的递归。它实际上只是一堆嵌套的树状结构,但是使用该模式会导致数据膨胀(虚拟表指针)并降低指令一致性(可能是小/紧循环,现在是函数调用链)
Tom

2

这是我的第二条优化建议。与我的第一条建议一样,这是通用的,而不是特定于语言或处理器的。

彻底阅读编译器手册,并了解其内容。最大限度地使用编译器。

我同意另外一两个受访者的观点,他们认为选择正确的算法对于降低程序性能至关重要。除此之外,您花费在使用编译器上的时间上的回报率(以代码执行改进的程度衡量)远高于调整代码的回报率。

是的,编译器作者并非来自一堆编码巨人,并且编译器包含错误,根据手册和编译器理论,应该使速度加快,有时会使速度降低。这就是为什么您必须一次迈出一步来衡量调整前后的性能。

是的,最终,您可能会遇到编译器标志的爆炸式增长,因此您需要一个或两个脚本来运行带有各种编译器标志的make,将作业排在大型集群上并收集运行时统计信息。如果只是您和PC上的Visual Studio,那么在尝试足够多的编译器标记组合之前,您将失去兴趣。

问候

标记

当我第一次使用一段代码时,我通常可以在1.4倍的时间内获得1.4到2.0倍的性能提升(例如,新版本的代码运行时间是旧版本的1 / 1.4或1/2)。摆弄编译器标志可以节省一两天的时间。当然,这可能是对那些源于我所研究的大部分代码的科学家缺乏精通编译器的评论,而不是我卓越的征兆。将编译器标志设置为max(很少是-O3)后,可能要花费数月的时间才能得出另一个1.05或1.1的系数。


2

当DEC推出其alpha处理器时,建议将函数的参数数量保持在7以下,因为编译器将始终尝试自动在寄存器中放置6个参数。


x86-64位还允许许多寄存器传递的参数,这可能对函数调用开销产生巨大影响。
汤姆(Tom)2010年

1

为了提高性能,首先要专注于编写可维护的代码-组件化的,松散耦合的等,因此,当您必须隔离某个部分以进行重写,优化或简单地进行概要分析时,您可以毫不费力地做到这一点。

优化器将对您的程序的性能有所帮助。


3
这仅在耦合“接口”本身适合优化的情况下才有效。接口可以固有地“缓慢”,例如,通过强制执行冗余查找或计算,或强制进行错误的缓存访问。
汤姆(Tom)2010年

1

您在这里得到了很好的答案,但是他们认为您的程序一开始就非常接近最佳,并且您说

假设程序已正确编写,经过全面优化编译,经过测试并投入生产。

以我的经验,程序可以正确编写,但这并不意味着它接近最佳。要达到这一点,需要付出额外的工作。

如果我能举一个例子,这个答案说明了如何通过宏优化使外观完美合理的程序快40倍以上。不可能每次都实现大幅度提速根据我的经验,程序初次编写时,但是在许多程序中(非常小的程序除外),可以做到。

完成之后,(热点的)微观优化可以为您带来丰厚的回报。


1

我使用英特尔编译器。在Windows和Linux上都可以。

当或多或少完成时,我会分析代码。然后挂在热点上,尝试更改代码以使编译器做得更好。

如果代码是可计算的代码且包含很多循环-英特尔编译器中的矢量化报告非常有用-请在帮助中查找“ vec-report”。

所以主要思想是-完善性能关键代码。至于其余的-正确和可维护的优先级-简短的功能,清晰的代码,一年后可以理解。


您即将回答这个问题.....您对代码进行了哪些操作,以使编译器可以进行这些优化?
EvilTeach

1
尝试以C风格编写更多内容(相对于C ++),例如避免没有绝对需要的虚函数,尤其是如果它们经常被调用时,请避免使用AddRefs ..和所有很酷的东西(同样,除非确实需要)。编写易于内联的代码-更少的参数,更少的“ if” -s。除非绝对需要,否则不要使用全局变量。在数据结构中-将较宽的字段放在首位(双精度,int64在int之前)-因此编译器将结构按第一字段的自然大小对齐-对齐有利于性能。
jf。

1
数据布局和访问对于性能绝对至关重要。因此,在进行概要分析之后,有时会根据访问的位置将结构分解为几个结构。另一个通用技巧-使用int或size-t vs. char-即使数据值很小-避免各种性能。惩罚存储到负载阻塞,部分寄存器的问题停顿。当然,当需要大量此类数据时,这并不适用。
jf。

还有一个-除非有实际需要,否则避免系统调用:)-它们非常昂贵
jf。

2
@jf:我对您的答案+1了,但是请您把答案从评论移到答案正文吗?它会更容易阅读。
kriss

1

我在C ++中使用的一种优化方法是创建一个不执行任何操作的构造函数。必须手动调用init()才能使对象进入工作状态。

在我需要这些类的较大向量的情况下,这很有用。

我调用reserve()来为向量分配空间,但构造函数实际上并未触及对象所在的内存页面。所以我已经花了一些地址空间,但实际上并没有消耗很多物理内存。我避免了与相关的建造成本相关的页面错误。

当我生成对象来填充矢量时,我使用init()进行设置。这限制了我的总页面错误,并且避免了在填充向量时需要对向量进行resize()的情况。


6
我相信std :: vector的典型实现实际上在您预留()更多容量时并不会构造更多对象。它只是分配页面。当您实际上将对象添加到向量中时(可能是在调用init()之前),稍后会使用new放置调用构造函数,因此,您实际上并不需要单独的init()函数。还要记住,即使您的构造函数在源代码中为“空”,编译后的构造函数也可能包含用于初始化诸如虚拟表和RTTI之类的代码,因此无论如何在构造时都会触及页面。
Wyzard

1
是的 在我们的例子中,我们使用push_back填充向量。对象没有任何虚函数,所以这不是问题。第一次使用构造函数进行尝试时,我们对页面错误的数量感到惊讶。我意识到发生了什么,然后我们放弃了构造函数的勇气,页面错误问题消失了。
EvilTeach 2010年

这让我感到惊讶。您使用了哪些C ++和STL实现?
David Thornley,2010年

3
我同意其他人的观点,这听起来像是std :: vector的错误实现。即使您的对象确实具有vtable,也要等到push_back才构造它们。您应该能够通过将默认构造函数声明为私有来进行测试,因为所有向量都将需要push_back的copy-constructor。
汤姆(Tom)2010年

1
@David-实现在AIX上进行。
EvilTeach 2010年

1

我所做的一件事是尝试将昂贵的操作保留在用户可能希望程序延迟一点的地方。总体性能与响应能力有关,但并不完全相同,在许多方面,响应能力是性能的更重要部分。

上次我真的必须提高整体性能时,我一直关注次优算法,并寻找可能存在缓存问题的地方。我首先概述并评估了性能,然后在每次更改后再次进行了性能评估。然后公司倒闭了,但无论如何这是一件有趣且有启发性的工作。


0

我长期以来一直在怀疑,但从未证明将数组声明为具有2的幂(作为元素的数量)可以使优化器在查找时通过将移位乘以位数来代替乘数来降低强度个别元素。


6
过去确实如此,如今已不复存在。实际上,事实恰恰相反。如果用2的幂来声明数组,则很可能会遇到这样的情况:在内存上使用两个指针的乘数为2。问题在于,CPU缓存的组织方式是这样的,您最终可能会围绕两个缓存行争夺两个阵列。这样您将获得可怕的性能。将指针之一放在两个字节之前(例如,不使用2的幂)可以防止这种情况。
Nils Pipenbrinck'3

+1个Nils,一种特定的情况是Intel硬件上的“ 64k别名”。
汤姆(Tom)2010年

顺便说一下,这是很容易通过反汇编证明的。多年前,令我惊讶的是,看到gcc如何通过移位和加法优化各种常数乘法。例如val * 7变成了其他情况(val << 3) - val
dash-tom-bang 2010年

0

将小的和/或经常调用的函数放在源文件的顶部。这使编译器更容易找到内联的机会。


真?您可以为此举一个理由和例子吗?并不是说这是不正确的,只是听起来直觉上位置很重要。
underscore_d

@underscore_d在知道函数定义之前不能内联某些东西。尽管现代编译器可能会进行多次传递,以便在代码生成时就知道该定义,但我不认为它是正确的。
Mark Ransom

我以为编译器会处理抽象调用图而不是物理函数顺序,这无关紧要。当然,我想特别小心不会有什么害处-尤其是在不考虑性能的情况下,IMO在定义调用函数之前先定义调用函数似乎更合乎逻辑。我必须测试性能,但是如果需要的话会感到惊讶,但是在那之前,我很惊讶!
underscore_d
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.