书接上回,继续阅读第七章(The efficiency of different C++ constructs)
The efficiency of different C++ constructs
循环
循环的效率取决于微处理器对循环控制分支的预测能力。一个具有一个较小并且固定的重复计数,没有分支的循环,可以完美地被预测。
循环展开
展开前
int i; for (i = 0; i < 20; i++) { if (i % 2 == 0); FuncA(i); else FuncB(i); FuncC(i); }
展开后
int i; for (i = 0; i < 20; i+=2) { FuncA(i); FuncC(i); FuncB(i+1); FuncC(i+1); }
这样做的好处:
- 循环次数变成了10次而不是20次,CPU可以更完美的进行预测
- if分支被消除,有利于编译器自动进行向量化等优化
循环展开的坏处:
- 展开循环后在代码缓存中占用更多空间
- 非常小的循环展开不如不展开
- 如果重复计数为奇数,并将其展开为2, 则必须在循环之外执行额外的迭代。
只有在能够取得特定好处的情况下,才应该使用循环展开。如果一个循环包含浮点运算,且循环计数器是整数,那么通常可 以假设整个计算时间是由浮点代码决定的,而不是由循环控制分支决定的。在这种情况下,展开循环是没有任何好处的 。
循环控制条件
如果循环控制分支依赖于循环内部的计算,则效率较低。
确定最坏情况下的最大重复计数并始终使用此迭代次数的效率会更高。
循环计数器最好是整数。
复制或清除数组
对于诸如复制数组或将数组中的元素全部设置为零这样的琐碎任务,使用循环可能不是最佳选择。使用memset和memcpy函数通常会更快
函数
函数调用会让程序慢下来,因为
- 代码地址跳转,可能需要4个时钟周期
- 如果代码分散在内存中会降低代码缓存效率
- 如果函数参数不够放在寄存器中,需要入到栈中,效率不高
- 需要额外时间设置stack frame, 保存和恢复寄存器
- 每个函数调用语句需要在分支目标缓冲区(BTB)中占用空间 , BTB争用可能会导致分支预测失败
如何避免函数调用降低效率呢?
避免不必要函数
不要过度封装
使用内联函数
如果函数很小,或者只在程序中的一个位置调用它,那么内联函数是有好处的。小函数通常由编译器自动内联
避免在最内层循环嵌套函数调用
如果程序关键的最内层循环包含对帧函数的调用,那么代码有可能通过内联帧函数或使帧函数调用的所有函数内联(把帧函数变为叶函数)来提升效率
使用宏代替函数
但是不要滥用宏,宏的问题是:名称不能重载或限制作用区域。宏将干扰具有相同名称的任何函数或变量,而与作用域或命名空间无关
使函数局部化
应该使同一个模块中使用的函数(即当前*.cpp* 文件)是局部的。 这使得编译器更容易将函数内联,并对函数调用进行优 化。
如何使函数局部化呢?
- 对于非类成员函数,直接使用static
- 对于类成员函数,将函数或类放置于匿名命名空间中
使用全程序优化
一些编译器具有对整个程序进行优化的选项,也可以选择将多个 .cpp 文件组合成一个对象文件。这使得编译器能够在组成程 序的所有 .cpp 模块之间优化寄存器分配和参数传递。
使用64位模式
现在服务器端开发都是64位模式了吧
函数参数
在大多数情况下,函数参数是按值传递的。这意味着参数的值被复制到一个局部变量中。对于int、float、double、bool、enum 以及指针和引用等简单类型,这非常快。
数组总是使用指针传递,除非它们被打包在类或者结构体中。
如果参数是复合类型,在以下情况下传值更高效,否则使用指针和引用更高效
- 对象很小,可以装入一个寄存器
- 对象没有拷贝构造函数和析构函数
- 对象没有虚成员
- 对象没有使用RTTI
将复合对象传递给函数的首选方法是使用const引用。其次是使函数成为对象的类成员
64位unix系统允许寄存器中传输最多14个参数(8个float或double, 加上6个整数、指针或引用参数)
函数返回类型
函数的返回类型最好是简单类型、指针、引用或 void。返回复合类型的对象更为复杂,而且常常效率低下。
简单情况下,复合类型对象直接从寄存器返回。否则通过一个隐藏指针将它们复制到调用方指定的位置。
当直接返回复杂类型对象的值时,编译器可能会进行RVO(return value optimization)优化,从而避免复制构造和析构成本,但开发者不应依赖这一点。
函数尾调用
尾调用是优化函数调用的一种方法。如果函数的最后一条语句是对另一个函数的调用,那么编译器可以用跳转到第二个函数 来替换该调用。优化编译器将自动完成此任务。第二个函数不会返回到第一个函数,而是直接返回第一个函数被调用的位 置。这样效率更高,因为它消除了返回操作。
递归函数
函数递归调用对于处理递归数据结构非常有用。递归函数的代价是所有参数和局部变量在每次递归时都会有一个新实例,这会占用栈空间。
较宽的树形结构比较深的树形结构,有更高的递归效率 .
无分支递归总是可以用循环代替,这样的效率更高
结构体和类
面向对象的好处
- 变量存储在一起,数据缓存更有效率
- 无需将类成员变量作为参数传递给类成员函数,避免参数传递的开销
面向对象的坏处:
- 非静态成员函数有this指针,有额外开销
- 虚成员函数的效率较低
如果面向对象的编程风格有利于程序的逻辑结构和清晰性,那么你可以使用这种风格
类的数据成员
类或结构体的数据成员是按创建类或结构实例时声明它们的顺序连续存储。将数据组织到类或结构体中不存在性能损失。
大多数编译器将数据成员对齐到可以被特定数整除的地址以优化访问,副作用是产生字节空洞
类的成员函数
每次声明或创建类的新对象时,它都会生成数据成员的新实例。但是每个成员函数只有一个实例。函数代码不会被复制
静态成员函数不能访问任何非静态数据成员或非静态成员函数。静态成员函数比非静态成员函数快。
虚成员函数
多态性是面向对象程序比非面向对象程序效率低的主要原因之一。 如果可以避免使用虚函数,那么你就可以获得面向对象编程的大多数优势,而无需付出性能成本 。
如果函数调用语句总是调用虚函数的相同版本,那么调用虚成员函数所花费的时间要比调用非虚成员函数多几个时钟周期。如果版本发生了变化,你可能会得到10 ‐ 20个时钟周期的错误预测惩罚。
有时可以使用模板而不是虚函数来获得所需的多态性效果。
运行时类型识别(RTTI)
效率不高。如果编译器有RTTI 选项,那么关闭它并使用其他实现。
继承
派生类的对象与包含父类和子类成员的简单类的对象的实现方法相同。父类和子类的成员访问速度相同。一般来说,你可以 假设使用继承几乎没有任何性能损失。
除了
- 父类数据成员大小会添加到子类成员的偏移量中。偏移量太大时,会造成数据缓存效率降低
- 父类和子类代码可能在不同模块。造成代码缓存效率降低
尽量不使用多重继承,代之以组合
联合体
union 是数据成员共享相同内存空间的结构。union 可以通过允许从不同时使用的两个数据成员共享同一块内存来节省内存 空间。
位域
位域可能有助于使数据更加紧凑。访问位域成员不如访问结构的成员效率高。如果在大数组可以节省缓存空间或使文件更 小,那么额外的时间是合理的
重载函数
重载函数的不同版本被简单地视为不同的函数。使用重载函数没有性能损失
重载运算符
重载的运算符相当于一个函数。使用重载运算符与使用具有相同功能的函数效率一样
模板
模板与宏的相似之处在于,模板参数在编译之前被它们的值所替换
模板是高效的,因为模板参数总是在编译时被解析。模板使源代码更加复杂,而不是编译后的代码。一般来说,使用模板在 执行速度方面没有任何成本
使用模板实现编译时多态
模板类可用于实现编译时多态性,这比使用虚拟成员函数获得的运行时多态性更加高效
模板代码可读性不佳。如果编译器能够自动执行去虚拟化(去掉动态绑定时对虚函数表的查询), 那么依赖编译器优化肯定比使用这种复杂的模板方法更加方便。
线程
线程上下文切换非常耗时,可通过设置更长的时间片来减少上下文切换的次数。
为不同任务的不同线程分配不同的优先级是非常有用的
为了充分利用多核,我们将工作划分成多个线程,每个线程在单独的cpu core上执行。但是多线程有四个成本:
- 启动和停止线程的成本。如果任务执行时间很短,不要为其单独分配线程
- 线程切换成本。
- 线程间同步和通信成本。
- 不同线程需要单独的存储空间,线程有各自的堆栈,如果线程共享相同的缓存,可能会导致缓存竞争。
多线程程序必须使用线程安全函数,线程安全函数永远不应该使用静态变量(除非只读)
异常和错误处理
c++中通过try catch捕获异常。异常处理旨在检测很少发生的错误,并以一种优雅的方式从错误条件中恢复
虽然程序运行时没有错误,但是异常处理仍需要额外的时间,花销多少取决于编译器实现。
如果你的应用程序不需要异常处理,那么应该禁用它,以便使代码更小、更高效。你可以通过关闭编译器中的异常处理选项来禁 用整个程序的异常处理。你也可以通过向函数原型中添加 throw() 声明来禁用单个函数的异常处理:
异常和向量代码
向量指令对于并行执行多个计算是有用的。
如果代码可以从向量指令中获益,那么最好禁用异常捕获,转而依赖 NAN 和 INF 的传递。
避免异常处理的成本
当不需要尝试从错误中恢复时,不需要异常处理。
建议使用系统的、经过深思熟虑的方法来处理错误。你必须区分可恢复错误和不可恢复错误;确保分配的资源在发生错误时 得到清理;并向用户发送适当的错误消息。
编写异常安全代码
为了保证异常安全,需要在发生异常时清理资源
- 使用new和malloc分配的内存
- 句柄
- 互斥量
- 数据库连接
- 网络连接
- 待删除临时文件
- 待保护的用户工作
- 其他已分配的资源
C++ 处理清理工作的方法是创建一个析构函数。C++ 异常处理系统确保调用本地对象的所有析构函数。 如果包装器类有析构函数来处理分配资源的所有清理工作,则程序是异常安全的。如果析构函数引发另一个异常,则系统可能会出现问题。
如果你使用自己的错误处理系统而不是使用异常处理,那么你无法确保调用了所有析构函数并清理了资源。如果错误处理程 序调用 exit()、abort()、_endthread() 等,则不能保证所有析构函数被调用
NAN和INF的传递
浮点溢出和除以 0 得到无穷大。如果你把无穷大和某数相加或相乘,结果就是无穷大。 如果用一个正常的数字除以INF,会得到0 。特殊情况INF‐INF和INF/INF得到NAN (not‐a‐number)。当你用 0 除以 0 以及函数的输入超出范围时,比如sqrt(‐1)和log(‐1),也会出现 特殊的代码NAN。
INF 和 NAN 的传播也不需要额外的成本
当参数为 INF 或 NAN 时,函数 finite() 将返回 false,如果它是一个普通的浮点数,则返回 true。
预处理命令
就程序性能而言,预处理指令(以#开头的所有指令)的性能成本很少,因为它们在程序编译之前就已经解析了
命名空间
使用名称空间,对执行速度没有影响