世面上将c++性能优化的书其实不少了,但是很多都停留在架构、算法、数据结构层面,大都是些老生常谈了。而从语言本身、操作系统、硬件层面系统阐述性能优化的技术书则少了很多。而《optimizing software in c++》正是这样的一本书,作者Agner Fog的职业也挺有意思,除了是计算机科学家,还是进化人类学家,而且后者看起来还是主业..

这周末花了一天时间阅读了这本书中感兴趣的几个章节。笔者将会连载几篇读书笔记总结主要知识点。

  • 7 The efficiency of different C++ constructs
  • 8 Optimizations in the compiler
  • 9 Optimizing memory access
  • 11 Out of order execution
  • 12 Using vector operations

The efficiency of different C++ constructs

这章主要介绍c++语言中各种特性对性能的影响。

不同的变量存储位置

stack

众所周知,函数中的临时变量或对象一般存储在内存空间中的stack区。每当调用函数时,参数和临时变量进栈,当函数返回时,参数和临时变量出。s由于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用于声明一个变量可被其他线程改变,阻止编译器依赖变量始终具有代码中先前分配的值的假设来进行优化 。

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更友好

整型变量和运算符

整数大小

对于不同的平台,不同整数类型(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 的可用性延迟大约两个时钟周期。

浮点计算和运算符

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

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

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

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

枚举

枚举只是隐藏的整数。

布尔值

布尔操作数的顺序

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

布尔变量被过度检查

由于所有以布尔变量作为输入的运算符都要检查输入是否有除0或1之外的值,因此布尔变量会被过度检查。 如果知道 操作数除了 0 就是 1,布尔运算可以变得有效率的多。编译器之所以不这样假设,是因为如果变量是没有被初始化的或者是 来自其它未知来源的。

布尔向量操作

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

指针和引用

指针 vs. 引用

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

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

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

效率

最重要的是,需要一个额外的寄存器来保存指针或引用的值,而寄存器是一种稀缺资源,如果没有足够的寄存器,那么指针每次使用时都必须从内存中加载,这会使程序变慢。另一个缺点是指针的值需要几个时钟周期才能访问所指向的变量。也就是说,要读取指针或引用的值,最坏情况下需要访问两次内存。

函数指针

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

成员指针

在简单的情况下,数据成员指针只是存储数据成员相对于对象开头的偏移量,而成员函数指针只是成员函数的地址。

智能指针

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

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

数组

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

应该按顺序访问多维数组,保证最后一个索引变化最快;当不按顺序索引时,为了使地址的计算更高效,那么除了第一个维度外,所有维度的大小最好是 2 的幂。如果以非顺序访问元素,则对象的大小(以字节为单位)最好为 2 的幂。 上述建议是为了更好利用cpu的data cache

类型转换

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

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

转换类对象

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

分支和switch语句

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

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

switch 语句也是一种分支,它可以有两个以上的分支。如果 case 标签是遵循每个标签等于前一个标签加 1 的序列,在这 个时候 switch语句的效率是最高的,因为它可以被实现为一个目标跳转表。如果 switch 语带有许多标签值,并且彼此相 差较大,这将是低效的,因为编译器必须将其转换成一个分支树

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

参考

https://www.agner.org/optimize/optimizing_cpp.pdf