程序性能优化
在硬件资源昂贵的时代,编程人员非常注重程序的性能,以期望用尽可能少的硬件资源完成尽可能多的事情。随着科技的发展,
摩尔定律的魔力使得硬件资源已越来越便宜,速度也越来越快,似乎性能已不是编程人员所需关注的事情了。然而在一个竞争
与发展的时代,软件的功能越来越复杂,用户的操作体验越来越重要,而且竞争越来越激烈,谁能以更优势的价格,更好的操
作体验,完成更多更复杂的事情,谁就将在激烈竞争中胜出。因而软件的性能优化必将一直是软件领域所要关注的内容之一。
虽然软件的性能优化贯穿了设计与编码的整个过程,本文也将从设计与编码两个层次对性能优化进行分析。本文还将从CPU、内
存、磁盘、网络四个方面描述性能问题分析的过程。
2.设计出来的性能
1)系统架构
控制流与数据流?减少不必要的模块
2)程序结构
多线程程序
锁的粒度、各种锁/信号量的性能对比
共享内存通信
降低灵活性以获取高性能。
减少不必要的重复判断(SHTTP/HTTP)
3)接口设计
好的接口给予使用者充分的灵活性
4)数据结构与算法
Linux内存管理,数量小时使用链表
3.编码的艺术
1)内存访问与文件
减少new/delete或malloc/free操作减少换页
减少文件打开与关闭操作
减少文件读写次数(减少系统调用)
2)减少不必要的运算
消除重复运算
循环中的运算
最忙的循环放在里面
3)语言及库函数特性的利用
if与case语句
构造与析构
宏与内联函数
迟缓型计算
减少临时变量
缓存字符串的长度
不必要的memset
4)硬件特性的利用
字节对齐
移位与乘除2
性能热点用汇编实现
4.性能分析工具-callgrind
valgrind系列工具因为免费,所以在linux系统上面最常见。callgrind是valgrind工具里面的一员,它的主要功能是模拟cpu的cache,能够计算多
级cache的有效、失效次数,以及每个函数的调用耗时统计。
callgrind的实现机理(基于外部中断)决定了它有不少缺点。比如,会导致程序严重变慢、不支持高度优化的程序、耗时统计的结果误差较大等,
更多的外部工具有oprofile,gprof,tprof,Rational Quantify and Intel VTune
5.编译器参数的优化
大家要记住的是,编译器绝对比想象的要强大的多。编写编译器的人大都是十年、几十年代码编写经验的科学家!你能简单想到的,他们都已经想
到过了。普通的编译器,可以支持大部分已知的优化策略以及多媒体指令。至于哪个编译器更好,大部分人的观点是:intel。Intel毕竟是最优秀的
cpu提供者,他们的编译器考虑了很多cpu的特性,跑的更快。但目前intel编译器有一些比较弱智的地方,即它只识别自己的cpu,不是自己的cpu,
就认为是最差的i386-i686机器,从而不能在amd等平台上面支持sse功能。我们在linux上面写代码,一般更加喜欢流行的编译器,比如gcc。
Gcc的优点是它更新快,开源,bug修改迅速。正因为他更新快,所以它能够支持部分C03的规范。
5.1 gcc支持的优化技术
1) 函数内联
函数调用的过程是:压入参数到堆栈、保护现场、调用函数代码、恢复现场。当一个函数被大量调用的时候,函数调用的开销特别巨大。函数内
联是指把这些开销都去除,而直接调用代码。函数内联的不好之处是难以调试,因为函数实际上已经不存在了。
2) 常量预先计算
a = b + 1000 * 8
对于这段代码,程序会预先计算b + 1000 * 8,从而变成:
a = b+ 8000
3) 相同子串提取
a=(b+1)*(b+1)
这里,b+1需要计算2次,可以只用计算一次:
tmp=b+1
a=tmp*tmp
4) 生存周期分析
这是一个比较高级的技术。假设有代码:
a=b+1
c=a+1
在执行的时候,因为第二句依赖第一句,所以2句是线性执行。
但编译器其实可以知道,c就是等于b+2,所以代码变成:
a=b+1
c=b+2
这样,这2句就没有任何关系来了,执行的时候,cpu可以并行执行它们。
5) 清除跳转
看如下代码:
int func()
{
int ret = 0;
if(xxx)
ret=5;
else if(yyy)
ret=6;
return ret;
}
当条件xxx满足的时候,程序还会跳到下面执行,但其实是没有必要的。编译器会把它变成:
int func()
{
if(xxx)
return 5;
else if(yyy)
return 6;
}
6) 循环展开
循环由几个部分组成:计数器赋值、计算器比较、跳转。每次循环,后面2步都是必须的消耗。把循环内的代码拷贝多份,可以大大减少
循环的次数,节约后面2步的耗时。参考:
for(int counter = 0; counter < 4; count++)
xxx;
可以变成:
xxx;
xxx;
xxx;
xxx;
编译器不仅仅可以展开普通循环,它还能展开递归函数。原理是一样的,递归其实是一个不定长的借用了堆栈的循环。
7) 循环内常量移除
for(int idx=0;idx<100;idx++)
a[idx]=a[idx]*b*b;
因为b*b在循环体内的值固定(常量),所以代码可以变成:
tmp=b*b;
for(int idx=0;idx<100;idx++)
a[idx]=a[idx]*tmp;
8) 并行计算
大家都知道,现代cpu支持超流水线技术,同时可以执行多条语句。多条语句能否同时执行的限制是不能互相依赖。编译器会自动帮我们把
看起来单线程执行的代码,变成并行计算,参考:
d=a+b;
e=a+d+f;
可以变成:
tmp=a+f;
d=a+b;
e=d+tmp;
9) 表达式简化
当年笔者在学习《离散数学》和《数字电路》的时候,总被眼花缭乱的布尔运算简化题目难倒。gcc终于让我松了一口气。参考:
!a && !b
这句需要3步执行,但变成:
!(a || b)
只需要2步执行。
5.2 gcc重要优化选项
1) 内联
-finline-small-functions
内联比较小的函数。-O2选项可以打开。
-findirect-inlining
间接内联,可以内联多层次的函数调用。-O2选项可以打开。
-finline-functions
内联所有可以内联的函数。-O3选项可以打开。
-finline-limit=N
可以进行内联的函数的最小代码长度。注意,这里是伪代码,不是真实代码长度。伪代码是编译器经过处理后的代码。带inline等标志的函数,默认
300行代码即可内联,不带的默认50行代码。和这个相关的选项是max-inline-insns-single和max-inline-insns-auto。
max-inline-insns-recursive-auto
内联递归函数时,函数的最大代码长度。
large-function-insns、large-function-growth、large-unit-insns等
函数内联的副作用是它导致代码变多,程序变长。这里的几个参数可以控制代码的总长度,避免编译后出现巨大的程序,影响性能和浪费资源。
2) -fomit-frame-pointer
不采用标准的ebp来记录堆栈指针,节省了一个寄存器,并且代码会更短。但据说在某些机器上面会导致debug模式出错。实际测试表明,在gcc4.2.4以
下,O2和O3都无法打开这个选项。
3) -fwhole-program
把代码当做一个最终交付的程序来编译,即明确指定了不是编译库。这个时候,编译器可以使用更多的static变量,来加快程序速度。
4) mmx/ssex/avx
多媒体指令,主要支持向量计算。一般来说,-march=i686、-mmx、-msse、-msse2是目前机器都支持的指令。
除了基本的多媒体支持外,gcc编译器还支持-ftree-vectorize,这个选项告诉编译器自动进行向量化,也是-O3支持的选项。
多说几句。在平常的使用中,多媒体指令不是很常见(除非游戏)。如果你有几个位表(bitset),它们需要进行各种位操作的话,多媒体指令还是挺有效果滴。
5.3 gcc大杀器-profile driven optimize
这是比较晚才出现的技术。其基本原理是:根据实际运行情况,缩短hot路径的长度。编译器通过加入各种计数器来监控程序的运行,然后根据计算出来
的实际访问路径情况,来分析hot路径,并且缩短其长度。根据gcc开发者的说法,这种技术可以提高20-30%的运行效率。
其使用方式为:
编译代码,加上-fprofile-generate选项
到正式环境一段时间
当程序退出后,会产生一个分析文件
利用这个分析文件,加上-fprofile-use,重新编译一次程序
举个例子来说:
a=b*5;
如果编译发现b经常等于10,那么它可以把代码变成:
a=50;
if(b != 10)
a=b*5;
从而在大多数情况下,避免了乘法消耗。
5.4 gcc支持的优化属性(__attribute__)
aligned
可以设置对齐到64字节,和cpu的cache line看齐
fastcall
如果函数调用的前面2个参数是整数类型的话,这个选项可以用寄存器来传递参数,而不是用常规的堆栈
pure
函数是纯粹的函数,任何时刻,同样的输入,都会有同样的输出。可以很方便依据概率来优化它。
5.5 gcc其他优化技术
#pragma pack()
对齐到一个字节,节省内存
__builtin_expect
直接告诉编译器表达式最可能的结果,方便优化
编译带debug信息的小文件
以下代码能够大大减少编译后程序大小,同时保留debug信息。其原理是外链一个带debug的版本。
g++ tst.cpp -g -O2 -pipe
copy a.out a.gdb
strip --strip-debug a.out
objcopy --add-gnu-debuglink=a.gdb a.out
6.算法是核心
算法是程序的核心,一个程序的好坏,大部分是看起算法的好坏。对于一般的程序员来讲,我们无法改变系统和库的调用,只能根据
规则来使用它们,我们可以改变的是我们自己核心代码的算法。
算法能够十倍甚至百倍的提高程序性能。如快排和冒泡排序,在上千万规模的时候,后者比前者慢几千倍。
通常情况下,有如下一些领域的算法:
A)常见数据结构和算法
B)输入输出
C)内存操作
D)字符串操作
E)加密解密、压缩解压
F)数学函数
总上所述:性能问题通常体现在四个方面:CPU、内存、磁盘、网络几个方面。解决方法可以是修改代码甚至程序结构以更充分的利用现有资源,
也可以是增加相应的硬件以增加资源供给。