optimizing software in cpp

Optimizing in C++

1 不同的变量存储位置

stack

函数中的临时变量或对象一般存储在内存空间中的stack区。每当调用函数时,参数和临时变量进栈,当函数返回时,参数和临时变量出栈。栈是内存空间中最高效的存储方式。当临时变量中没有较大对象时,访问栈上的临时变量也基本能用上L1 data cache。

global or static

在函数体之外声明的变量称之为global变量,可被任何函数访问。被static修饰的变量称为static变量。

global和static变量在程序运行期间会被放置于内存空间中的静态数据区。

静态数据区域分为三个部分:

  • 一部分存储 const 类型的 global/static 变量
  • 一部分存储已被初始化的 global/static 变量,
  • 最后一部分存储未被初始化的 global/static 变量。

使用静态数据区的好处是,global/static 变量在程序启动前就有专门的存储位置,坏处是在程序的生命周期内,这些存储位置将被一直占据,可能会降低 data cache 的效率

所以建议尽量不要使用global变量。

register

register变量存储在CPU寄存器中,函数中的临时变量特别适合放到register中。优点很明显,访问 register 变量比访问RAM快得多。但是CPU寄存器大小是非常有限的,在64位x86架构中,有14个整数寄存器,16个浮点寄存器。

volatile

volatile 用于声明一个变量可被其他线程改变,阻止编译器依赖变量先前缓存的值来进行优化 。

1
2
3
4
5
6
7
8
9
volatile int seconds; // incremented every second by another thread
void DelayFiveSeconds()
{
seconds = 0;
while (seconds < 5)
{
// do nothing while seconds count to 5
}
}

上面的代码如果不声明为 volatile, 编译器将任务while条件一直成立,即使别的线程中改变了seconds的值。

thread-local

大多数编译器可以使用关键字 __thread__declspec(thread) 来实现静态变量和全局变量的线程本地存储。这样的变量在每个线程都有一个实例 。

线程本地存储是低效的,因为它是通过存储在线程访问块中的指针进行访问的。因此建议尽量避免线程本地存储,代之以stack存储。

dynamic memory allocation

c++中通过new/malloc动态分配存储,通过delete/free释放动态分配的的存储,动态分配的存储放在内存空间的heap区中。

优点:使用相对stack存储更加灵活

缺点:动态分配和释放很耗时,容易出现野指针、悬垂指针、内存泄露、内存碎片等问题。

variables declared inside a class

类中声明的变量按照在类中的顺序存储,存储位置由类的对象在哪里定义的决定。static修饰的类成员变量将存储在静态数据区,只有一个实例。

将变量存储在类中的好处是保证了空间局部性,对CPU data cache更友好。

2 整型变量和运算符

整数大小

对于不同的平台,不同整数类型(char/short/int/long)的大小可能不同。

无论大小如何,整数运算基本都很快,除非使用了大于CPU寄存器大小的类型,比如在32位系统中使用64位整数。

建议在与大小无关且没有溢出风险的情况下使用默认整数大小,例如简单变量、循环计数器等。

在大型数组中,为了更好地使用数据缓存,最好使用足够大的最小整数大小。

无符号整形数 vs. 有符号整形数

在大多数情况下,使用有符号整数和无符号整数在速度上没有区别。 除了

  1. 除以常数:当你将一个整数除以一个常数时,无符号要快于有符号
  2. 对于大多数指令集,有符号整数比无符号整数转换成浮点数要快
  3. 有符号变量和无符号变量的溢出行为不同。

整数运算符

整数运算非常快。加减和位操作只需一个时钟周期,乘法需要3-4个时钟周期,除法需要40-80个。

自增和自减运算符

当仅用于递增整数变量时,使用递增前或递增后都没有区别。

例如,for (i=0; i<n; i++)和for (i=0; i<n; ++i)是一样的。

但是当使用表达式的结果时,效率可能会有所不同。

例如,x = array[i++] 比 x = array[++i] 速度更快。因为在后一种情况下,数组元素的地址的计算必须等待 i 的新值,这将使 x 的可用性延迟大约两个时钟周期。BUT,两者表达的意思是完全不同的。

3 浮点计算和运算符

x86架构中有两种浮点计算方法。

  • 原始方法:使用8个浮点寄存器组成寄存器栈(长双精度80位)。
    • 优点:精度高,不同精度之间无需额外转换,有浮点计算指令可用;
    • 缺点:难以生成寄存器变量,浮点计算较慢,整数和浮点数之间转换效率很低
  • 向量方法:使用8个或16个向量寄存器(XMM或YMM)。
    • 优点:可并行计算,寄存器变量容易实现;
    • 缺点:不支持长双精度,数学函数必须使用函数库(但通常比硬件指令更快)

XMM向量寄存器在x86_64架构中可用。如果处理器和操作系统支持AVX指令集,则可使用YMM向量寄存器。当XMM可用时,编译器一般用向量方法计算浮点数。

根据微处理器的不同,浮点加法需要 3‐6 个时钟周期。乘法需要 4‐8 个时钟周期。除法需要 14‐45 个时钟周期。

4 枚举

枚举只是隐藏的整数。

5 布尔值

布尔操作数的顺序

短路逻辑:当 && 的左操作数为false时,便不会计算右操作数。同理, || 的做操作数为true时,也不会计算右操作数。因此建议将通常为true的操作数放在&&表达式最后,或||表达式的开头。

布尔变量被过度检查

由于所有以布尔变量作为输入的运算符都要检查输入是否有除0或1之外的值,因此布尔变量会被过度检查。如果知道操作数除了 0 就是 1,布尔运算可以变得有效率的多。

编译器之所以不这样假设,是因为变量可能是没有被初始化的或者是来自其它未知来源的。

布尔向量操作

一个整数可被当做布尔向量操作。例如 bitmap

6 指针和引用

指针 vs. 引用

指针和引用的效率是一样的,因为它们实际上做的事情是相同的,区别在于编程风格。

指针的优点:功能上更灵活,可以做指针计算(例如访问缓冲区),当然也更容易出错

引用的优点:语法更简单,也更安全。

效率

运行时,需要一个额外的寄存器来保存指针或引用的值,而寄存器是一种稀缺资源,如果没有足够的寄存器,那么指针每次使用时都必须从内存中加载,这会使程序变慢。

另一个缺点是指针的值需要几个时钟周期才能访问所指向的变量。也就是说,要读取指针或引用的值,最坏情况下需要访问两次内存。

函数指针

通过函数指针调用函数通常要比直接调用函数多花几个时钟周期 。

智能指针

使用智能指针的目的是为了确保对象被正确删除,以及在对象不再使用时释放内存 。

通过智能指针访问对象没有额外的成本。 但是,每当创建、删除、复制或从一个函数转移到另一个函数时,都会产生额外的成本。shared_ptr 的这些成本要高于 unique_ptr。

7 数组

数组是通过在内存中连续存储元素来实现的,没有存储关于数组大小的信息。因此c/c++中数组相比其他语言更快,但也更不安全。

当不按顺序索引时,为了使地址的计算更高效,那么除了第一个维度外,所有维度的大小最好是 2 的幂。当以非顺序访问元素,则对象的大小(以字节为单位)最好为 2 的幂。

上述建议是为了更好利用CPU的data cache。

8 类型转换

signed/unsigned转换

寄存器值不变,只是编译器换了解释方法。因此没有额外的性能成本。

整形类型大小的转换

类型大小转换通常不需要额外的时间

浮点进度转换

当使用浮点寄存器时,浮点、双精度和长双精度之间的转换不需要额外的时间

当用XMM寄存器时,需要 2 到 15个时钟周期(取决于处理器)

因此建议使用向量化寄存器存储浮点数时,不要混用浮点类型。

整形转浮点型

将有符号整数转换为浮点数或双精度浮点数需要 4‐16个时钟周期,这取决于处理器和使用的寄存器类型。无符号整数的转换需要更长的时间。

如果没有溢出的风险,首先将无符号整数转换为有符号整数会更快。

浮点型转化为整形

如果不启用 SSE2 或者更新的指令集,浮点数到整数的转换将花费很长的时间。通常,转换需要 50‐100 个时钟周期。

解决方案是:

  • 使用 64 位模式或启用 SSE2 指令集;
  • 使用四舍五入代替截断,并用汇编语言制作一个舍入函数。

指针类型转换

指针可以转换为另一种类型的指针。同样,可以将指针转换为整数,也可以将整数转换为指针。值还是那些值,只是换了种解释方法,因此没有任何开销。

重新解释对象

c++中的reinterpret_cast,没有任何额外开销。

const_cast

const_cast 运算符用于解除 const 对指针的限制 。 没有任何额外开销。

static_cast

static_cast 运算符的作用与 C 风格的类型转换相同。例如,它用于将 float 转换为 int

reinterpret_cast

没有任何额外开销。

dynamic_cast

dynamic_cast 运算符用于将指向一个类的指针转换为指向另一个类的指针。它在运行时检查转换是否有效 。dynamic_cast比static_cast更耗时,也更安全。

转换类对象

只有当程序员定义了构造函数、重载赋值运算符或重载类型转换运算符(指定如何进行转换)时,才有可能进行涉及类对象(而不是指向对象的指针)的转换。

构造函数或重载运算符与成员函数的效率是一样的。

9 分支和switch语句

在微处理器做出正确分支预测的情况下,执行分支指令通常需要 0‐2 个时钟周期。根据处理器的不同,从分支错误预测中恢复所需的时间大约为 12‐25 个时钟周期。这被称为分支预测错误的惩罚。

for 循环或 while 循环也是一种分支。

在每次迭代之后,它决定是重复还是退出循环。嵌套循环只能在某些处理器上得到很好的预测。在许多处理器上,包含多个分支的循环并不能很好地被预测。

switch 语句也是一种分支,它可以有两个以上的分支。

如果 case 标签是遵循每个标签等于前一个标签加 1 的序列,在这个时候 switch语句的效率是最高的,因为它可以被实现为一个目标跳转表。

如果 switch 带有许多标签值,并且彼此相差较大,这将是低效的,因为编译器必须将其转换成一个分支树。

分支和 switch 语句的数量,在程序的关键部分,最好控制在较少的水平 。

因为分支和函数调用的目标保存在称为分支目标缓冲区的特殊缓存中。如果一个程序中有许多分支或函数调用,那么在分支目标缓冲区中就可能产生竞争。这种竞争的结果是,即使分支具有良好的可预测性,它们也可能被错误地预测。

10 循环

循环的效率取决于微处理器对循环控制分支的预测能力。

一个较小并且有固定的重复计数,且没有分支的循环,可以完美地被预测。

循环展开

展开前

1
2
3
4
5
6
7
8
9
int i;
for (i = 0; i < 20; i++)
{
if (i % 2 == 0);
FuncA(i);
else
FuncB(i);
FuncC(i);
}

展开后

1
2
3
4
5
6
7
8
int i;
for (i = 0; i < 20; i+=2)
{
FuncA(i);
FuncC(i);
FuncB(i+1);
FuncC(i+1);
}

这样做的好处:

  • 循环次数变成了10次而不是20次,CPU可以更完美的进行预测
  • if分支被消除,有利于编译器自动进行向量化等优化

循环展开的坏处:

  • 展开循环后在代码缓存中占用更多空间

  • 非常小的循环展开不如不展开

  • 如果重复计数为奇数,并将其展开为2, 则必须在循环之外执行额外的迭代。

只有在能够取得特定好处的情况下,才应该使用循环展开。

比如,如果一个循环包含浮点运算,且循环计数器是整数,那么通常可以假设整个计算时间是由浮点代码决定的,而不是由循环控制分支决定的。在这种情况下,展开循环是没有任何好处的 。

循环控制条件

  1. 如果循环控制分支依赖于循环内部的计算,则效率较低。

  2. 确定最坏情况下的最大重复计数并始终使用此迭代次数的效率会更高。

  3. 循环计数器最好是整数。

复制或清除数组

对于诸如复制数组或将数组中的元素全部设置为零这样的琐碎任务,使用循环可能不是最佳选择。

使用 memset 和 memcpy 函数通常会更快。

11 函数

函数调用会让程序慢下来,因为

  • 代码地址跳转,可能需要4个时钟周期
  • 如果代码分散在内存中会降低代码缓存效率
  • 如果函数参数不够放在寄存器中,需要入到栈中,效率不高
  • 需要额外时间设置stack frame, 保存和恢复寄存器
  • 每个函数调用语句需要在分支目标缓冲区(BTB)中占用空间,BTB中发生资源竞争可能会导致分支预测失败。

如何避免函数调用降低效率呢?

避免不必要函数

不要过度封装。

使用内联函数

如果函数很小,或者只在程序中的一个位置调用它,那么内联函数是有好处的。小函数通常由编译器自动内联。

避免在最内层循环嵌套函数调用

如果在程序关键的最内层循环包含对帧函数的调用,那么代码有可能通过内联帧函数或使帧函数调用的所有函数内联(把帧函数变为叶函数)来提升效率。

帧函数(Frame Function):

  • 帧函数是指在程序执行期间创建的函数调用帧(Function Frame)或栈帧(Stack Frame)。每当函数被调用时,会在内存中分配一个帧来存储函数的局部变量、参数和其他相关信息。
  • 帧函数用于描述函数调用期间的堆栈结构,包含了函数的执行上下文、局部变量和临时数据等。它提供了函数执行的环境和上下文切换所需的信息。
  • 帧函数还包括函数调用返回时所需的清理操作,如恢复调用者的上下文和处理返回值等。

叶函数(Leaf Function):

  • 叶函数是指在函数调用期间不会调用其他函数的函数,即它没有其他的子函数调用。叶函数执行完毕后直接返回,而不会进一步调用其他函数。
  • 叶函数通常比较简单,不涉及复杂的递归或函数调用链,并且在性能优化方面具有一定的优势。

使用宏代替函数

不要滥用宏,宏的问题是:名称不能重载或限制作用区域。宏将干扰具有相同名称的任何函数或变量,而与作用域或命名空间无关。

使函数局部化

应该使同一个模块中使用的函数(即当前 .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)优化,从而避免复制构造和析构成本,但开发者不应依赖这一点。

函数尾调用

尾调用是优化函数调用的一种方法。如果函数的最后一条语句是对另一个函数的调用,那么编译器可以用跳转到第二个函数来替换该调用。

编译器优化将自动完成此任务。第二个函数不会返回到第一个函数,而是直接返回第一个函数被调用的位 置。这样效率更高,因为它消除了返回操作。

递归函数

函数递归调用对于处理递归数据结构非常有用。递归函数的代价是所有参数和局部变量在每次递归时都会有一个新实例,这会占用栈空间。

较宽的树形结构比较深的树形结构,有更高的递归效率。

无分支递归总是可以用循环代替,这样的效率更高

12 结构体和类

面向对象的好处:

  • 变量存储在一起,数据缓存更有效率
  • 无需将类成员变量作为参数传递给类成员函数,避免参数传递的开销

面向对象的坏处:

  • 非静态成员函数有this指针,有额外开销
  • 虚成员函数的效率较低

如果面向对象的编程风格有利于程序的逻辑结构和清晰性,那么你可以使用这种风格

类的数据成员

类或结构体的数据成员是按创建类或结构实例时声明它们的顺序连续存储。将数据组织到类或结构体中不存在性能损失。

大多数编译器将数据成员对齐到可以被特定数整除的地址以优化访问,副作用是产生字节空洞。

类的成员函数

每次声明或创建类的新对象时,它都会生成数据成员的新实例。但是每个成员函数只有一个实例。函数代码不会被复制。

静态成员函数不能访问任何非静态数据成员或非静态成员函数。静态成员函数比非静态成员函数快。

虚成员函数

多态性是面向对象程序比非面向对象程序效率低的主要原因之一。 如果可以避免使用虚函数,那么你就可以获得面向对象编程的大多数优势,而无需付出性能成本 。

如果函数调用语句总是调用虚函数的相同版本,那么调用虚成员函数所花费的时间要比调用非虚成员函数多几个时钟周期。如果版本发生了变化,你可能会得到10‐20个时钟周期的错误预测惩罚。

有时可以使用模板(编译时多态)而不是虚函数来获得所需的多态性效果。

运行时类型识别(RTTI)

效率不高。如果编译器有RTTI 选项,那么关闭它并使用其他实现。

继承

派生类的对象与包含父类和子类成员的简单类的对象的实现方法相同。父类和子类的成员访问速度相同。

一般来说,你可以假设使用继承几乎没有任何性能损失。 除了:

  • 父类数据成员大小会添加到子类成员的偏移量中。偏移量太大时,会造成数据缓存效率降低。
  • 父类和子类代码可能在不同模块。造成代码缓存效率降低。

另外,尽量不使用多重继承,代之以组合。

联合体

union 是数据成员共享相同内存空间的结构。union 可以通过允许不同时使用的两个数据成员共享同一块内存来节省内存空间。

位域

位域虽然有助于使数据更加紧凑,但是访问位域成员不如访问结构的成员效率高。如果在大数组可以节省缓存空间或使文件更小,那么额外的时间是合理的

重载函数

重载函数的不同版本被简单地视为不同的函数。使用重载函数没有性能损失。

重载运算符

重载的运算符相当于一个函数。使用重载运算符与使用具有相同功能的函数效率一样。

13 模板

模板与宏的相似之处在于,模板参数在编译之前被替换。

模板是高效的,因为模板参数总是在编译时被解析。模板使源代码更加复杂,而不是编译后的代码。一般来说,使用模板在执行速度方面没有任何成本。

使用模板实现编译时多态

模板类可用于实现编译时多态性,这比使用虚拟成员函数获得的运行时多态性更加高效。

模板代码可读性不佳。

14 线程

线程上下文切换非常耗时,可通过设置更长的时间片来减少上下文切换的次数。另外,为不同任务的不同线程分配不同的优先级是非常有用的。

为了充分利用多核,可以将工作划分成多个线程,每个线程在单独的CPU core上执行。但是多线程有四个成本:

  • 启动和停止线程的成本。如果任务执行时间很短,不要为其单独分配线程。
  • 线程切换成本。
  • 线程间同步和通信成本。
  • 不同线程需要单独的存储空间,线程有各自的堆栈,如果线程共享相同的缓存,可能会导致缓存竞争。

多线程程序必须使用线程安全函数,线程安全函数永远不应该使用静态变量 (除非是只读的静态变量) 。

15 异常和错误处理

C++中通过try catch捕获异常。异常处理旨在检测很少发生的错误,并以一种优雅的方式从错误条件中恢复。

但是,即使程序运行时没有错误,异常处理仍需要额外的时间,花销多少取决于编译器实现。

如果你的应用程序不需要异常处理,那么应该禁用它,以便使代码更小、更高效。

可以通过关闭编译器中的异常处理选项来禁用整个程序的异常处理。或者,也可以通过向函数原型中添加 throw() 声明来禁用单个函数的异常处理。

异常和向量代码

向量指令对于并行执行多个计算是有用的。如果代码可以从向量指令中获益,那么最好禁用异常捕获,转而依赖 NAN 和 INF 的传递。

避免异常处理的成本

当不需要尝试从错误中恢复时,不需要异常处理。

  • 建议使用系统的、经过深思熟虑的方法来处理错误。
  • 区分可恢复错误和不可恢复错误。
  • 确保分配的资源在发生错误时得到清理,向用户发送适当的错误消息。

编写异常安全代码

为了保证异常安全,需要在发生异常时清理下列资源:

  • 使用new和malloc分配的内存
  • 句柄
  • 互斥量
  • 数据库连接
  • 网络连接
  • 待删除临时文件
  • 待保护的用户工作
  • 其他已分配的资源

C++ 处理清理工作的方法是创建一个析构函数。C++ 异常处理系统确保调用本地对象的所有析构函数。

如果类有析构函数来处理分配资源的所有清理工作,则程序是异常安全的。如果析构函数引发另一个异常,则系统可能会出现问题。

如果你使用自己的错误处理系统而不是使用异常处理,那么你无法确保是否调用了所有析构函数并清理了资源。

如果错误处理程序调用 exit()、abort()、_endthread() 等,则不能保证所有析构函数被调用。

NAN和INF的传递

  • 浮点溢出和除以 0 得到无穷大 INF。
  • 如果你把无穷大和某数相加或相乘,结果就是无穷大 INF。
  • 如果用一个正常的数字除以 INF,会得到 0。
  • INF ‐ INF 和 INF / INF 得到 NAN (not‐a‐number)。
  • 当你用 0 除以 0 以及函数的输入超出范围时,比如 sqrt(‐1) 和 log(‐1),也会出现特殊的代码NAN。

INF 和 NAN 的传播也不需要额外的成本。

当参数为 INF 或 NAN 时,函数 finite() 将返回 false,如果它是一个普通的浮点数,则返回 true。

16 预处理命令

就程序性能而言,预处理指令(以#开头的所有指令)的性能成本很少,因为它们在程序编译之前就已经解析了。

17 命名空间

使用名称空间,对执行速度没有影响。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!