当前位置:  开发笔记 > 编程语言 > 正文

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

如何解决《编码实践,使编译器/优化器能够制作更快的程序》经验,为你挑选了13个好方法。

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

随着时间的推移,编译器逐渐成熟.他们变得非常聪明,他们的流量分析使他们能够更好地决定寄存器中的值,而不是你可能做的.register关键字变得不重要了.

由于别名问题,FORTRAN对于某些操作可能比C更快.从理论上讲,仔细编码可以解决这个限制,使优化器能够生成更快的代码.

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

确定您使用的平台和编译器,将不胜感激.

为什么这项技术似乎有效?

鼓励使用示例代码.

这是一个相关的问题

[编辑] 这个问题不是关于分析和优化的整个过程.假设程序编写正确,编译完全优化,测试并投入生产.您的代码中可能有一些构造禁止优化器尽其所能地完成最佳工作.您可以做什么来重构将删除这些禁令,并允许优化器生成更快的代码?

[编辑] 偏移相关链接



1> High Perform..:

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

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

接下来,分析您的代码.

然后,只有这样,您可能想要开始研究告诉编译器如何使用内存的效果.一次进行1次更改并测量其影响.

期望失望并且必须非常努力地进行小的性能改进.成熟语言(如Fortran和C)的现代编译器非常非常好.如果您阅读了一个"技巧"的帐户以获得更好的代码性能,请记住编译器编写者也已阅读过它,如果值得这样做,可能会实现它.他们可能首先写了你读的内容.


&和%几乎总是被优化,以及大多数其他廉价的算术技巧.没有得到优化的是右手操作数是一个变量,它总是发生在2的幂.
与其他人一样,Compiier开发人员的时间有限.并非所有优化都会进入编译器.就像`&`vs.`%`for powers of two(很少,如果有的话,优化,但可能会产生重大的性能影响).如果你读了一个表演技巧,知道它是否有效的唯一方法就是进行改变并测量影响.*永远不要假设编译器会为您优化一些东西.*
对于恒定的二次幂'n`,即使禁用优化*,gcc也会用`&(n-1)`*替换'%n`.那不是"很少,如果有的话"......
由于C的负整数除法的愚蠢规则(向0舍入并且具有负余数,而不是向下舍入并且总是具有正余数),%**不能被优化为&当类型被签名时.大多数时候,无知的编码员使用签名类型......
为了澄清,我似乎让一些读者感到困惑:我建议的编码实践中的建议是首先开发一个简单的代码,它不使用内存布局指令来建立性能基线.然后,一次尝试一个并测量它们的影响.我没有就运营表现提出任何建议.
我不确定这是一个合理的建议,至少目前的写作.你如何在内存中布局数据,至少在大图中如何 - 比如你是在堆栈上还是在堆上分配,内循环中读取的内存是否在空间上本地化等等 - 是程序员的责任,对性能有很大影响.如果你做错了,我知道的C++编译器可以为你修复它.

2> celion..:

写入局部变量而不是输出参数!这对于消除混叠减速可能是一个巨大的帮助.例如,如果您的代码看起来像

void DoSomething(const Foo& foo1, const Foo* foo2, int numFoo, Foo& barOut)
{
    for (int i=0; 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

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


您还可以使用受限制的指针启用该优化
这还有一个额外的好处,即经常使程序员更容易阅读/理解,因为他们也不必担心可能的非显而易见的副作用.
@Ben - 这是真的,但我认为这种方式更清晰.此外,如果输入和输出重叠,我相信结果是未指定限制指针(可能在调试和发布之间得到不同的行为),而这种方式至少是一致的.不要误会我的意思,我喜欢使用限制,但我更喜欢不需要它.

3> vicatcu..:

您遍历内存的顺序会对性能产生深远的影响,而编译器并不擅长确定并修复它.如果您关心性能,则在编写代码时必须充分考虑缓存局部性问题.例如,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];
  }
}


@Potatoswatter你在说什么?只要观察到相同的最终结果,C编译器就可以做任何想做的事情.事实上,GCC 4.4具有`-floop-interchange`,如果优化器认为它有利可图,它将翻转内部和外部循环.
当然这是一个优化问题.几十年来,人们一直在撰写关于自动循环交换优化的论文.
嗯,你去的地方.C语义经常受到别名问题的影响.我想这里真正的建议是通过那面旗帜!

4> Thomas Matth..:
通用优化

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

声明小函数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 family用于常量数据

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

格式化为内存,然后写入

char使用多个格式化为数组sprintf,然后使用fwrite.这也允许将数据布局分解为"常量部分"和可变部分.想想邮件合并.

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

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

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

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


其中许多是程序员优化,而不是程序员帮助编译器优化的方法,这是原始问题的主旨.例如,循环展开.是的,您可以自己展开,但我认为找出编译器为您展开并删除它们的障碍更为有趣.
通过卷起循环,我曾经获得了显着的性能提升.然后我想出了如何通过使用一些间接来更紧密地推动它,并且程序明显加快了.(分析显示这个特定的功能是运行时的60-80%,我在前后仔细测试了性能.)我相信改进是由于更好的局部性,但我不完全确定.
有趣的是,您可以提供一个示例,其中您使用一些小语句而不是更大的语句获得更好的代码.你能展示一个使用布尔值重写if的例子吗?通常,我会将循环展开到编译器,因为它可能对缓存大小有更好的感觉.我对sprintfing的想法感到有些惊讶,然后是写字.我认为fprintf实际上是在幕后做的.你能在这里详细介绍一下吗?

5> Potatoswatte..:

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

也就是说,你不应该从另一个文件中的一个文件内部循环一个小函数,因为这样就不会内联它.

如果可能,避免使用变量的地址.要求指针不是"自由",因为它意味着变量需要保存在内存中.如果你避免使用指针,即使数组也可以保存在寄存器中 - 这对于矢量化很重要.

这导致下一点,阅读^#$ @手册!GCC可以渲染普通的C代码,如果你撒在__restrict__这里和__attribute__( __aligned__ )那里.如果您想要优化器中非常具体的东西,您可能必须具体.


这是一个很好的答案,但请注意,整个程序优化变得越来越流行,实际上可以跨翻译单元内联函数.

6> Francis Boiv..:

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

别名:Load-Hit-Store在紧密循环中可能是毁灭性的.如果您正在读取一个内存位置并写入另一个内存位置并且知道它们是不相交的,那么在函数参数上小心地放置别名关键字可以真正帮助编译器生成更快的代码.但是,如果内存区域重叠并且您使用了'alias',那么您可以进行未定义行为的良好调试会话!

缓存未命中:不确定如何帮助编译器,因为它主要是算法,但有预取内存的内在函数.

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


+1为load-hit-store和不同的寄存器类型.我不确定它在x86上的交易有多大,但是它们在PowerPC上贬值(例如Xbox360和Playstation3).

7> Toad..:

在代码中尽可能使用const正确性.它允许编译器更好地优化.

在本文档中有大量其他优化技巧:CPP优化 (虽然有点旧文档)

强调:

使用构造函数初始化列表

使用前缀运算符

使用显式构造函数

内联函数

避免临时物体

注意虚拟功能的成本

通过引用参数返回对象

考虑按班级分配

考虑stl容器分配器

'空成员'优化

等等


不多,很少.但它确实提高了实际的正确性.
@dsimcha在`const`引用上抛弃`const`或者`const`指向非`constst`对象的指针是明确定义的.修改一个实际的`const`对象(即最初声明为`const`的对象)不是.
在C和C++中,编译器不能使用const进行优化,因为将其抛弃是明确定义的行为.

8> 小智..:

人们编写的绝大多数代码都是I/O限制的(我相信我在过去30年里为所有代码编写的代码都受到了限制),因此对于大多数人来说,优化器的活动将是学术性的.

但是,我要提醒人们,为了优化代码,你必须告诉编译器优化它 - 很多人(包括我在忘记的时候)在这里发布C++基准测试,如果没有启用优化器就没有意义.


我承认自己很奇怪 - 我研究的是大型科学数字运算代码,这些代码是内存带宽限制的.对于一般人口的节目我同意尼尔.
真正; 但是现在很多I/O绑定的代码都是用几乎没有编译器的语言编写的* - 甚至没有编译器的语言.我怀疑仍然使用C和C++的领域往往是优化某些东西更重要的领域(CPU使用率,内存使用量,代码大小......)
在过去的30年里,我花了很多时间研究I/O很少的代码.在数据库中保存2年.图形,控制系统,模拟 - 没有任何I/O限制.如果I/O成为大多数人的瓶颈,我们就不会对英特尔和AMD给予太多关注.
我最近发现,几乎没有用C++语言编写的代码是I/O绑定的.当然,如果你正在调用一个OS函数进行批量磁盘传输,你的线程可能会进入I/O等待(但缓存,即使这是有问题的).但是,与现代磁盘技术(即使是价格适中的东西)相比,通常的I/O库函数,每个人都推荐的因为它们是标准的和可移植的,实际上速度很慢.最有可能的是,I/O只是在写完几个字节后才会一直刷到磁盘上的瓶颈.OTOH,UI是另一回事,我们人类很慢.
是的,我并不是真的买这个论点 - 否则我们(在我的工作中)不会想办法花更多的计算时间来做I/O. 此外,我遇到的大部分I/O绑定软件都受到I/O限制,因为I/O已经完成了; 如果优化访问模式(就像内存一样),可以获得巨大的性能提升.

9> nategoose..:

尝试尽可能使用静态单一分配进行编程.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;
}

当然,如果且只有一个返回点,你可以反转逻辑.

(后来我回忆起的技巧)

在可能的情况下将函数声明为静态总是一个好主意.如果编译器可以向自己证明它已经占用了特定函数的每个调用者,那么它可以在优化名称中破坏该函数的调用约定.编译器通常可以避免将参数移动到寄存器或堆栈位置,这些位置调用函数通常期望它们的参数存在(它必须在被调用函数和所有调用者的位置都有偏差才能执行此操作).编译器还经常利用知道所调用的函数将需要什么内存和寄存器,并避免生成代码以保留寄存器或被调用函数不会干扰的存储器位置中的变量值.当对函数的调用很少时,这种方法特别有效.这得到了内联代码的大部分好处,但没有实际内联.


在测试范围时,实际上没有必要使用减法,LLVM,GCC和我的编译器至少自动执行此操作.很少有人会理解减法代码的作用,更不用说实际工作的原因.

10> Gratian Lup..:

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

    使大多数功能静止.这允许过程间常量传播和别名分析完成其工作,否则编译器需要假设可以从翻译单元外部调用该函数,其中参数的值完全未知.如果你看一下着名的开源库,他们都会将函数标记为静态,除了真正需要extern的函数.

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

    永远不要在循环内部使用goto,大多数编译器都不会再识别循环,并且不会应用任何最重要的优化.

    仅在必要时使用指针参数,并在可能的情况下将其标记为限制.这有助于别名分析,因为程序员保证没有别名(过程间别名分析通常非常原始).非常小的struct对象应该通过值传递,而不是通过引用传递.

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

    尝试在循环调用之外提升大函数或没有副作用的外部函数(不依赖于当前循环迭代).在许多情况下,小函数被内联或转换为易于提升的内在函数,但是大型函数可能看起来编译器在它们实际上没有副作用时会产生副作用.外部函数的副作用是完全未知的,除了标准库中的某些函数(有时由某些编译器建模),使得循环不变代码运动成为可能.

    在编写具有多个条件的测试时,最有可能首先进行测试.if(a || b || c)应该是if(b || a || c),如果b比其他b更可能是真的.编译器通常不了解条件的可能值以及更多的分支(通过使用配置文件信息可以知道,但很少有程序员使用它).

    使用开关比执行if(a || b || ... || z)等测试更快.首先检查你的编译器是否自动执行此操作,有些执行此操作,并且具有if但更具可读性.



11> figurassa..:

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

在某些平台(例如,vxworks)中,用于管理堆的算法非常慢.更糟糕的是,从调用返回到malloc所需的时间在很大程度上取决于堆的当前状态.因此,任何调用malloc的函数都会受到无法轻易解释的性能影响.如果堆仍然是干净的,那么性能损失可能是最小的,但是在该设备运行一段时间后,堆可能变得碎片化.这些电话需要更长的时间,你无法轻易计算出性能会随着时间的推移而降低的程度.你无法真正产生更糟糕的案例估计.在这种情况下,优化器也无法为您提供任何帮助.更糟糕的是,如果堆碎片过于分散,则调用将完全失败.解决方案是使用内存池(例如,glib切片)而不是堆.如果你做对了,分配调用会更加快速和确定.



12> Zan Lynx..:

一个愚蠢的小技巧,但是会为你节省一些微观的速度和代码.

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

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

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

在阅读反汇编代码时,我看到一些荒谬的寄存器改组,因为人们没有遵循这条规则.


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

13> kriss..:

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

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

虽然单独的编译非常适合编译时间,但是当你谈到优化时,这是非常糟糕的.基本上编译器无法优化超出编译单元,即链接器保留域.

但是如果你设计好你的程序,你也可以通过一个独特的公共源代码编译它.那就是编译unit1.c和unit2.c然后链接两个对象,编译all.c只是#include unit1.c和unit2.c.因此,您将受益于所有编译器优化.

它非常像只用C++编写头文件(在C中更容易编写).

如果您编写程序从一开始就启用它,这种技术很容易,但您还必须意识到它会改变C语义的一部分,您可以遇到一些问题,如静态变量或宏冲突.对于大多数程序来说,很容易克服发生的小问题.还要注意,编译作为一个独特的源是慢的,可能需要大量的内存(通常不是现代系统的问题).

使用这种简单的技术,我碰巧做了一些我写得快了十倍的程序!

与register关键字一样,这个技巧也很快就会过时.通过链接器优化开始受编译器支持gcc:链接时间优化.

在循环中分离原子任务

这个更棘手.它是关于算法设计和优化器管理缓存和寄存器分配的方式之间的交互.程序通常必须遍历某些数据结构,并且每个项目都执行一些操作.通常,所执行的操作可以在两个逻辑上独立的任务之间分开.如果是这种情况,您可以使用同一边界上的两个循环执行完全相同的程序,只执行一项任务.在某些情况下,以这种方式编写它可能比独特循环更快(细节更复杂,但可以解释的是,在简单的任务情况下,所有变量都可以保存在处理器寄存器中,而更复杂的变量则不可能保留在处理器寄存器中必须将寄存器写入存储器并稍后读回,并且成本高于额外的流控制.

小心这个(使用这个技巧或没有使用这个技巧),就像使用寄存器一样,它可能会比改进的性能更低.


是的,到目前为止,LTO已经使这篇文章的前半部分变得冗余,可能是糟糕的建议.
推荐阅读
家具销售_903
这个屌丝很懒,什么也没留下!
DevBox开发工具箱 | 专业的在线开发工具网站    京公网安备 11010802040832号  |  京ICP备19059560号-6
Copyright © 1998 - 2020 DevBox.CN. All Rights Reserved devBox.cn 开发工具箱 版权所有