c,c++ 代码优化

1. 记住阿姆达尔定律

Ahmdal's rule

  • funccost是函数func运行时间百分比,funcspeedup是你优化函数的运行的系数。
  • 所以,如果你优化了函数TriangleIntersect执行40%的运行时间,使它运行快了近两倍,而你的程序会运行快25%。
  • 这意味着不经常使用的代码不需要做较多优化考虑(或者完全不优化)。
  • 这里有句俗语:让经常执行的路径运行更加高效,而运行稀少的路径正确运行。

2. 代码先保证正确,然后再考虑优化

  • 这并不意味着用8周时间写一个全功能的射线追踪算法,然后用8周时间去优化它。
  • 分多步来做性能优化。
  • 先写正确的代码,当你意识到这个函数可能会被经常调用,进行明显的优化。
  • 然后再寻找算法的瓶颈,并解决(通过优化或者改进算法)。通常,改进算法能显著地改进瓶颈——也许是采用一个你还没有预想到的方法。所有频繁调用的函数,都需要优化。

3. 我所了解的那些写出非常高效代码的人说,他们优化代码的时间,是写代码时间的两倍。

4.跳转和分支执行代价高,如果可能,尽量少用。

  • 函数调用需要两次跳转,外加栈内存操作。
  • 优先使用迭代而不是递归。
  • 使用内联函数处理短小的函数来消除函数调用开销。
  • 将循环内的函数调用移动到循环外(例如,将for(i=0;i<100;i++) DoSomething();改为DoSomething() for(i=0;i<100;i++) … }})。
  • if…else if…else if…else if…很长的分支链执行到最后的分支需要很多的跳转。如果可能,将其转换为一个switch声明语句,编译器有时候会将其转换为一个表查询单次跳转。如果switch声明不可行,将最常见的场景放在if分支链的最前面。

5. 仔细思考函数下标的顺序。

  • 两阶或更高阶的数组在内存中还是以一维的方式在存储在内存中,这意味着(对于C/C++数组)array[i][j] 和 array[i][j+1]是相邻的,但是array[i][j] array[i+1][j]可能相距很远。
  • 以适当的方式访问存储实际内存中的数据,可以显著地提升你代码的执行效率(有时候可以提升一个数量级甚至更多)。
  • 现代处理器从主内存中加载数据到处理器cache,会加载比单个值更多的数据。该操作会获取请求数据和相邻数据(一个cache行大小)的整块数据。这意味着,一旦array[i][j]已经在处理器cache中,array[i][j+1]很大可能也已经在cache中了,而array[i+1][j]可能还在内存中。

6. 使用指令层的并行机制

  • 尽管许多程序还是依赖单线程的执行,现代处理器在单核中也提供了不少的并行性。例如:单个CPU可以同时执行4个浮点数乘,等待4个内存请求并执行一个分支预判。
  • 为了最大化利用这种并行性,代码块(在跳转之间的)需要足够的独立指令来允许处理器被充分利用。
  • 考虑展开循环来改进这一点。
  • 这也是使用内联函数的一个好理由。

7. 避免或减少使用本地变量。

  • 本地变量通常都存储在栈上。不过如果数量比较少,它们可以存储在CPU寄存器中。在这种情况下,函数不但得到了更快访问存储在寄存器中的数据的好处,也避免了初始化一个栈帧的开销。
  • 不要将大量数据转换为全局变量。

8. 减少函数参数的个数。

  • 和减少使用本地变量的理由一样——它们也是存放在栈上。

9. 通过引用传递结构体而不是传值

  • 我在射线追踪中还找不到一个场景需要将结构体使用传值方式(包括一些简单结构如:Vector,Point和Color)。

10. 如果你的函数不需要返回值,不要定义一个。

11. 尽量避免数据转换。

  • 整数和浮点数指令通常操作不同的寄存器,所以转换需要进行一次拷贝操作。
  • 短整型(char和short)仍然使用一整个寄存器,并且它们需要被填充为32/64位,然后在存储回内存时需要再次转换为小字节(不过,这个开销一定比一个更大的数据类型的内存开销要多一点)。

12. 定义C++对象时需要注意。

  • 使用类初始化而不是使用赋值(Color c(black); Color c; c = black;更快)

13. 使类构造函数尽可能轻量。

  • 尤其是常用的简单类型(比如,color,vector,point等等),这些类经常被复制。
  • 这些默认构造函数通常都是在隐式执行的,这或许不是你所期望的。
  • 使用类初始化列表(Use Color::Color() : r(0), g(0), b(0) {},而不是初始化函数Color::Color() { r= g = b = 0; } .)

14. 如果可以的话,使用位移操作>>和<<来代替整数乘除法

15. 小心使用表查找函数

  • 许多人都鼓励将复杂的函数(比如:三角函数)转化为使用预编译的查找表。对于射线追踪功能来说,这通常导致了不必要的内存查找,这很昂贵(并不断增长),并且这和计算一个三角函数并从内存中获取值一样快(尤其你考虑到三角查找打乱了cpu的cache存取)。
  • 在其他情况下,查找表会很有用。对于GPU编程通常优先使用表查找而不是复杂函数。

16. 对大多数类,优先使用+= 、 -= 、 *= 和 /=,而不是使用+ 、 -、 、 和?/

  • 这些简单操作需要创建一个匿名临时中间变量。
  • 例如:Vector v = Vector(1,0,0) + Vector(0,1,0) + Vector(0,0,1);?创建了五个匿名临时Vector: Vector(1,0,0), Vector(0,1,0), Vector(0,0,1), Vector(1,0,0) + Vector(0,1,0), 和 Vector(1,0,0) + Vector(0,1,0) + Vector(0,0,1).
  • 对上述代码进行简单转换:Vector v(1,0,0); v+= Vector(0,1,0); v+= Vector(0,0,1);仅仅创建了两个临时Vector: Vector(0,1,0) 和 Vector(0,0,1)。这节约了6次函数调用(3次构造函数和3次析构函数)。

 

17. 对于基本数据类型,优先使用+?、?-?、?*?、?和?/,而不是+=?、?-=?、?*= 和 /=

18. 推迟定义本地变量

  • 定义一个对象变量通常需要调用一次函数(构造函数)。
  • 如果一个变量只在某些情况下需要(例如在一个if声明语句内),仅在其需要的时候定义,这样,构造函数仅在其被使用的时候调用。

19. 对于对象,使用前缀操作符(++obj),而不是后缀操作符(obj++)

  • 这在你的射线追踪算法中可能不是一个问题
  • 使用后缀操作符需要执行一次对象拷贝(这也导致了额外的构造和析构函数调用),而前缀的构造函数不需要一个临时的拷贝。

20. 小心使用模板

  • 对不同的是实例实现进行不同的优化。
  • 标准模板库已经经过良好的优化,不过我建议你在实现一个交互式射线追踪算法时避免使用它。
  • 使用自己的实现,你知道它如何使用算法,所以你知道如何最有效的实现它。
  • 最重要的是,我的经历告诉我:调试STL库非常低效。通常这也不是一个问题,除非你使用debug版本做性能分析。你会发现STL的构造函数,迭代器和其他一些操作,占用了你15%的运行时间,这会导致你分析性能输出更加费劲。

21. 避免在计算时进行动态内存分配

  • 动态内存对于存储场景和运行期间其他数据都很有用。
  • 但是,在许多(大多数)的系统动态内存分配需要获取控制访问分配器的锁。对于多线程应用程序,现实中使用动态内存由于额外的处理器导致了性能下降,因为需要等待分配器锁和释放内存。
  • 即便对于单线程应用,在堆上分配内存也比在栈上分配内存开销大得多。操作系统还需要执行一些操作来计算并找到适合尺寸的内存块。

22. 找到你系统内存cache的信息并利用它们

  • 如果一个是数据结构正好适合一个cache行,处理整个类从内存中只需要做一次获取操作。
  • 确保所有的数据结构都是cache行大小对齐(如果你的数据结构和一个cache行大小都是128字节,仍有可能因为你的结构体中的一个字节在一个cache行中,而其他127字节在另外一个cahce行中)。

23. 避免不需要的数据初始化

  • 如果你需要初始化一大段的内存,考虑使用memset。

24. 尽早结束循环和尽早返回函数调用

  • 考虑一个射线和三角形交叉,通常的情况是射线会越过三角,所以这里可以优化。
  • 如果你决定将射线和三角面板交叉。如果射线和面板交叉t值是负数,你可以立即返回。这允许你跳过射线三角交叉一大半的质心坐标计算。这是一个大的节约,一旦你知道这个交叉不存在,你就应该立即返回交叉计算函数。
  • 同样的,一些循环也应该尽早结束。例如,当设置阴影射线,对于近处的交叉通常都是不必须的,一旦有类似的的交叉,交叉计算就应该尽早返回。(这里的交叉含义不太明白,可能是专业词汇,译者注)

25. 在稿纸上简化你的方程式

  • 许多方程式中,通常都可以或者在某些条件中取消计算。
  • 编译器不能发现这些简化,但是你可以。取消一个内部循环的一些昂贵操作可以抵消你在其他地方的好几天的优化工作。

26. 整数、定点数、32位浮点数和64位双精度数字的数学运算差异,没有你想象的那么大

  • 在现代CPU,浮点数运算和整数运算差不多拥有同样的效率。在计算密集型应用(比如射线追踪),这意味这可以忽略整数和浮点数计算的开销差异。这也就是说,你不必要对算数进行整数处理优化。
  • 双精度浮点数运算也不比单精度浮点数运算更慢,尤其是在64位机器上。我在同一台机器测试射线追踪算法全部使用double比全部使用floats运行有时候更快,反过来测试也看到了一样的现象(这里的原文是:I have seen ray tracers run faster using all doubles than all floats on the same machine. I have also seen the reverse)。

27. 不断改进你的数学计算,以消除昂贵的操作

  • sqrt()经常可以被优化掉,尤其是在比较两个值的平方根是否一致时。
  • 如果你重复地需要处理 除x 操作,考虑计算1/x的值,乘以它。这在向量规范化(3次除法)运算中赢得了大的改进,不过我最近发现也有点难以确定的。不过,这仍然有所改进,如果你要进行三次或更多除法运算。
  • 如果你在执行一个循环,那些在循环中执行不发生变化的部分,确保提取到循环外部。

  • 考虑看看你的计算值是否可以在循环中修改得到(而不每次都重新开始循环计算)。
性 能优化 - 之一 (C/C++) (Optimization)


任务:把一个小头(little endian)的整型(32bit)转化为大头(big endian)。


我 们需要这样一个函数 void foo(unsigned int &u); 用来颠倒整数u的字节序。类似于socket函数htonl()或者ntohl()。也就是说,在以某个整数u为参数调用foo以后,u小头变大头,或者 反过来。这无所谓,因为小头和大头是对称的。

我发现对这个简单的任务,采用不同的作法,效率能差到很多,这两天研究了一下, 写一点心得出来与同好分享。


第一种作法:
extern "C"  void f1(unsigned int &u)
{
       unsigned int v = u;
       char *src = ((char *)&v + 3);
       char *dst = (char *)&u;

       *dst ++ = *src --;
       *dst ++ = *src --;
       *dst ++ = *src --;
       *dst ++ = *src --;
}
这是我最早想到的一种作法,也是最直观的作法。我当时的考虑是这样只 有简单的赋值操作,避免了移位(>>或者<<),效率*应该*会比较高。但是测试的结果令人沮丧, 执行一千万次所需要的时间平均下来有390毫秒。

为何如此?经过一番思索,我认为一定和内存访问有关。要知道v是一个局部变量,本来一个 优化的编译器完全可以把v放入某个寄存器中,那么后续对v值的引用就无需再访问内存,但是注意到在上面的代码中,有一个对v求地址的操作: char *src = ((char *)&v + 3); 而寄存器是没有地址的,所以编译器只能选择为此生成效率较低的代码,也就是,把v放入堆栈。

在 优化打开的情况下,编译器会把src和dst放入寄存器而不是堆栈,所以这样一来,对于语句:
*dst ++ = *src --;
来 说,需要访问两次内存。其中*src需要访问一次,得到其所指地址的值,然后再把这个值写回到*dst所指向的内存又是一次。。反汇编得到的代码也验证了 这一点:

       pushl   %ebp
       movl    %esp, %ebp
       subl    $4, %esp
       movl    8(%ebp), %edx
       movl    (%edx), %eax
       movl    %eax, -4(%ebp)
       movzbl  -1(%ebp), %eax
       movb    %al, (%edx)
       movzbl  -2(%ebp), %eax
       movb    %al, 1(%edx)
       movzbl  -3(%ebp), %eax
       movb    %al, 2(%edx)
       movzbl  -4(%ebp), %eax
       movb    %al, 3(%edx)
       leave
       ret

一共有13次内存访问的指令。

这时候,我考虑如何让编译器把v变量放到寄存器里。根据上面的分 析,很显然,办法是不要有对v求地址的操作,那么为了得到v各个byte的值,要执行移位动作就不可避免了。但是考虑到v在寄存器里,那么对它的移位操作 也不过就是一条指令而已,比访问内存要快的多了。这样我就得到了第二种做法:

第二种做法:
extern "C"  void f2(unsigned int &u)
{
       unsigned int v = u;
       char *dst = (char *)&u;

       *dst ++ = (v >> 24);
       *dst ++ = ((v >> 16) & 0xFF);
       *dst ++ = ((v >> 8) & 0xFF);
       *dst ++ = (v & 0xFF);
}

那 么现在让我们假定v是某个寄存器,对于上面的4条赋值语句,每一条都只需要访问内存一次,看看反汇编生成的代码(v相当于ecx,而保存v移位生成的临时 变量用的是eax):

       pushl   %ebp
       movl    %esp, %ebp
       movl    8(%ebp), %edx
       movl    (%edx), %ecx
       movl    %ecx, %eax
       shrl    $24, %eax
       movb    %al, (%edx)
       movl    %ecx, %eax
       shrl    $16, %eax
       movb    %al, 1(%edx)
       movl    %ecx, %eax
       shrl    $8, %eax
       movb    %al, 2(%edx)
       movb    %cl, 3(%edx)
       popl    %ebp
       ret

只需要访问8次内存。测试的结果是喜人的,现在执行一 千万次该函数调用,只需要200毫秒,效率几乎提高了一倍。看来消除访问内存的努力确实有效果。这时候代码中的dst指针又变成了目标,如果消除掉它改成 寄存器访问,我们又可以减少4次内存引用,减去一次把寄存器内容写回u的访存指令,一共就可以减少3次内存访问。这样我就得到了第三个版本:


第三种做法:
extern "C" void f3(unsigned int &u)
{
       unsigned int v = u;

       u = ((v >> 24) |
               (((v >> 16) & 0xFF) << 8) |
               (((v >> 8) & 0xFF) << 16) |
               (v << 24));
}

首先反汇编:

       pushl   %ebp
       movl    %esp, %ebp
       pushl   %ebx
       movl    8(%ebp), %ebx
       movl    (%ebx), %ecx
       movl    %ecx, %eax
       movl    %ecx, %edx
       shrl    $8, %eax
       andl    $65280, %eax
       shrl    $24, %edx
       orl     %eax, %edx
       movzbl  %ch, %eax
       sall    $16, %eax
       orl     %eax, %edx
       sall    $24, %ecx
       orl     %ecx, %edx
       movl    %edx, (%ebx)
       popl    %ebx
       popl    %ebp
       ret

因为有太多的临时变量,寄存器已经不够用了,编译器必须使用ebx,而ebx不属于“调用者保存”的寄存器。所以如果函数内部要使用它,必须自 己保存再恢复,这样就多了两条push ebx和pop ebx的指令,那么这个函数需要访问内存7次,看上去不是很理想。不过测试结果却更加喜人,简直是令人惊异。一千万次函数调用,现在竟然只需要80毫秒! 效率提高了一倍有余。我这里只能猜测第二种做法里面大量的movb,在32位的机器上,可能比movl要慢很多。否则这个现象很难解释。


80毫秒的测试结果令我非常满意,因为最简单的函数:
void simple(unsigned int &u)
{
       ++ u;
}
调用一千万次都需要40几毫秒,我认为几乎已经是极限了,但是...事实 显然并非如此。


我们还有第四种做法:
extern "C" void f4(unsigned int &u)
{
       __asm__("bswap %0" : "=r" (u) : "0" (u));
}

从80486开始,为了方 便网络程序的处理,主要就是htonl()和ntohl()啦,Intel特意添加了一条专门用来转换大头小头的指令,也就是 BSWAP ,它可以在一条指令中,完成上面我辛辛苦苦实现出来的全部功能,而且速度,你可以想象,应该和上面那个void simple(unsigned int &u)相当。事实也是如此,一千万次对f4()的调用,确实只需要40几毫秒。

不过对我的需求来说,80毫秒的战绩已经很足够 了。而引入内嵌汇编 BSWAP 来实现,有两个麻烦处,最主要的是不同的编译器,有不同的内嵌汇编格式,我主要用gcc和vc,维护两份汇编码太累,而且今后如果要和别的编译器兼容,也 很讨厌。其二是这个指令只在80486以后才有,虽然我可以断定我的代码绝对不会运行在386上面:-),但是对于追求“形式完美”的程序员,比如说鄙 人,来说,是不太能接受的:-)


两个结论:
1. 尽量以一种方便编译器优化的方式使用局部变量,比如说,不要对局部变量求地址。
2. 尽量定义和机器字长相同的变量,正如上面所猜测的,movb比movl要慢很多。


注1:测试结果中具体的的数值,会根据机器性能 的不同而不同。但是在不同的机器上,4种方法所消耗时间的比例,应该大体上是一致的。
注2:第四种方法来自于参考linux kernel中对htonl()函数的实现。


附测试代码test.cpp,请使用gcc编译,带-O2选项:

#include <iostream>
#include <windows.h>

extern "C"  void f1(unsigned int &u)
{
       unsigned int v = u;
       char *src = ((char *)&v + 3);
       char *dst = (char *)&u;

       *dst ++ = *src --;
       *dst ++ = *src --;
       *dst ++ = *src --;
       *dst ++ = *src --;
}

extern "C"  void f2(unsigned int &u)
{
       unsigned int v = u;
       char *dst = (char *)&u;

       *dst ++ = (v >> 24);
       *dst ++ = ((v >> 16) & 0xFF);
       *dst ++ = ((v >> 8) & 0xFF);
       *dst ++ = (v & 0xFF);
}

extern "C" void f3(unsigned int &u)
{
       unsigned int v = u;

       u = ((v >> 24) |
               (((v >> 16) & 0xFF) << 8) |
               (((v >> 8) & 0xFF) << 16) |
               (v << 24));
}

extern "C" void f4(unsigned int &u)
{
       __asm__("bswap %0" : "=r" (u) : "0" (u));
}

int main()
{
       using std::cout;
       using std::endl;

       const unsigned cnt = 100 * 100 * 100 * 10;
       unsigned int u = 1024;

       unsigned int tk = GetTickCount();
       for (unsigned i = 0; i < cnt; ++ i)
               f1(u);
       tk = GetTickCount() - tk;
       cout << cnt << " times f1() cost " << tk << " ms" << endl;


       tk = GetTickCount();
       for (unsigned i = 0; i < cnt; ++ i)
               f2(u);
       tk = GetTickCount() - tk;
       cout << cnt << " times f2() cost " << tk << " ms" << endl;


       tk = GetTickCount();
       for (unsigned i = 0; i < cnt; ++ i)
               f3(u);
       tk = GetTickCount() - tk;
       cout << cnt << " times f3() cost " << tk << " ms" << endl;


       tk = GetTickCount();
       for (unsigned i = 0; i < cnt; ++ i)
               f4(u);
       tk = GetTickCount() - tk;
       cout << cnt << " times f4() cost " << tk << " ms" << endl;

       return 0;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值