编码优化
同深层次的设计问题相比,性能方面的编码问题更容易解决,这些问题的规模通常较小,在其解决方法中,所包含的代码量都很小。
1 缓存
缓存主要用来存储使用频繁而且代价高昂的计算结果,这样就可以避免对这些结果的重复计算。
for(...; !done; ...) { done = patternMatch( pat1, pat2, isCaseSensitive() ); }
由于isCaseSensitive()的返回值独立于循环体,且不随迭代而改变,因此应该将将放在循环之外:
int isSensitive = isCaseSensitive(); for(...; !done; ... ) { done = patternMatch( pat1, pat2, isSensitive ); }
2 预先计算
将预先计算放置在影响性能的关键路径之外(例如初始化阶段),就可以避免在性能关键路径上进行代价高昂的计算(连缓存的那一次计算此时也不需要了)。
比如要将大小写字母混合的字符串转换成大写,即使将转换的函数写成内联,仍将包含如下的条件语句:
return ( c >= 'a' && c <= 'z' ) ? c - ( 'a' - 'A' ) : c;
如果在影响性能的关键路径上使用这个函数,代价可能是无法接受的。我们可以预先计算出每个可能出现的字符相应的大写:
void initLookupTable() { for( int i = 0; i < 256; i++ ) { uppercaseTable[i] = toUpper(i); } }
然后在使用时,只需要使用 *p = uppercaseTable[*p]就完成转换了,这将显著的提高字符串操作的效率。
3 降低灵活性
例如,当客户端发起请求时WEB服务器需要知道其IP地址。对于每一个请求,需要程序分配足够多的空间来容纳一个客户端的的IP地址,请求结果时再释放该空间。调用函数new()和delete()来处理存储空间的代价很高,尤其是每次请求均需要调用这两个函数的情况下将更加糟糕。即使使用内存池,也无法完全消除对new和delete的调用。
如果IP地址的长度没有限制,那么除了动态分配别无选择,但是,我们知道IP地址的长度不会15字节:xxx.xxx.xxx.xxx。如果再增加一个终结符NULL,长度变为16字节。即使IPV6的长度有所增加,但总归是有限的。既然IP地址的长度有限,那么以局部变量的形式将IP地址存储在堆栈上将更加高效。
char clientIPAddr[256];
4 80-20法则
80-20法则适用于很多场合:80%的程序执行只遍历20%代码;80%的程序运行时间耗费在执行路径的所遇到的20%函数上。这个法则有力证明了过于草率的判断是一个错误。
考虑表达式:
if ( e1 || e2 ) { ... }
e1在90%情况下为真,e2在50%情况下为真; e1执行时间为10个指令,e2执行时间为100个指令。那么期望代价为:10 * 100% + 100 * 10% = 20
如果交换e1和e2的顺序,代价为 100 * 100% + 10 * 50% = 105
性能差别显著。
5 延迟计算
不应该执行“只在某种情况下”才需要的昂贵计算,而应该只在必要的时候执行昂贵计算。这通常意味着把计算推迟到真正需要的时候才进行,因此称之为延迟计算。
int route( Message *msg ) { ExpensiveClass upstream( msg ); if ( goingUpstream ) { ... //处理对象upstream } //此处不处理对象upstream return SUCCESS; }
对象upstream的构造和析构函数代价很昂贵,而程序中只有一半的时间goingUpstream才是true,这意味着另一半时间并不需要使用upstream,此时对象的计算完全是浪费的。在真正需要upstream对象的地方定义它:
int route( Message *msg ) { if ( goingUpstream ) { ExpensiveClass upstream( msg ); ... //处理对象upstream } //此处不处理对象upstream return SUCCESS; }
6 无用计算
顾名思义,无用计算就是那些根本无须执行的计算,无论执行流程如何,这些计算结果从不使用,因此它们是完全没有意义的。
一个关于无用计算的精妙例子是成员对象的无用初始化。
class Student { public: Student( char* nm ); private: string name; };
如果构造函数是下面的:
Student::Student( char* nm ) { name = nm; }
C++保证在Student的构造函数体执行之前,所有的成员对象已经创建完成,因此此处编译器会插入对string默认构造函数的调用,该调用在Student的构造函数体执行之前进行。事实上,对string默认构造函数的调用是没有必要的,可以通过构造函数初始化列表中显式指明string构造函数,来避免这种无效计算:
Student::Student( char* nm ) : name( nm ) { }
7 系统体系结构
内存访问的代价差别很大。在某个特定的RISC体系结构中,若数据位于缓存中,访问它只需要一个CPU周期;若位于主内中(缓存失败),则需要8个CPU周期;若位于硬盘上(页面错误),则需要400000个CPU周期。
当访问数据时,最先搜索的是数据缓存。若数据不在缓存中,硬件产生缓存失败信号,该信号会从RAM或硬盘加载数据至缓存中。缓存以缓存行为单位,通常加载比我们所寻找的特定数据项更大的一块数据。这样的话,在4字节整数上的缓存失败可能导致加载128字节的缓存行到缓存中。由于相关数据项的位置在内存中很可能相邻,因此这对我们有用:如果随后的指令尝试访问相同缓存行中的其他数据项,那么第一次访问该数据时就将缓存成功,能较快地获取数据。我们应该尽可能帮助自己的代码显示这种引用的位置。
以类X为例:
class X { public: X() : a(1), c(2) {} private: int a; char b[4096]; int c; }
符合标准的编译器将按声明顺序来排列对象X:成员a和成员c被4096字节的成员b分离,因而不会出现在相同的缓存行内。当X::X构造函数访问c时有可能遇到缓存失败。
既然a和c被相邻指令访问,那么将它们放在相邻位置,会使得缓存成功率更高。
class X { public: X() : a(1), c(2) {} private: int a; int c; char b[4096]; }
8 内存管理
动态分配和释放内存的代价比较昂贵。如果在函数中需要使用一个临时局部变量,一种办法是用new和delete来分配和释放堆内存,另一种方法直接在栈上分配这个变量,因此不需要先为其分配内存,也不用在函数退出时释放内存,函数退出时,栈内存会自动释放,从而避免new和delete的巨大代价。
9 库和系统调用
库和系统调用时经常会有背后隐藏让人无法觉察的检查,比如可能在调用pthread_mutex_lock时,pthreads库的实现会检查线程是否已经拥有锁,这样就防止了死锁。但或许我们在使用这个函数时,可以确保线程不会拥有锁,那么额外的检查会是一种负担。(库和系统调用可能封装了一些过多的操作,如果我们不需要其中的某些,我们可以按需要自己来调用底层的函数来实现想要的功能)。
10 编译器优化
一个优秀的编译器可以代替开发者实施一些重要的优化,而无须开发者对源代码进行任何干预。第一个想到的优化是寄存器分配。当变量位于寄存器中时,载入和存储该变量是最快的。对执行路径中的许多方法都可以使用寄存器分配,当遇到循环索引变量时,这种优化作用更是特别明显。因为每次循环迭代时都需要访问和更新这些变量。
第二个是内联。
编码优化在范围上是局部的,并且不需要对程序的整体设计有深入的理解。当您加入到一个正在进行的开发项目中,并且您对其设计还没有完全理解时,这会是一个很好的起点。
最快的代码是从不执行的代码。