优化C++软件

Posted wuhui_gdnt

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了优化C++软件相关的知识,希望对你有一定的参考价值。

7. 不同C++构造的效率

对一段程序代码如何被翻译到机器码,以及微处理器如何处理这个代码,许多程序员知之甚少。例如,许多程序员不知道双精度计算与单精度计算一样快。有谁知道一个模板类比一个多态类要更高效?

本章致力于解释不同C++语言元素的相对效率,以帮助程序员选择最高效的方案。在本系列手册的其他卷里,进一步解释理论背景。

7.1. 不同类型变量的储存

变量与对象保存在内存的不同部分,依赖于它们在C++程序里如何声明。这对数据缓存的效率存在影响(参考第88页)。如果数据随机分散在内存中,数据缓存不良。因此,理解变量如何保存是重要的。对简单的变量、数组及对象,储存原则是相同的。

栈上储存

声明在一个函数里的变量与对象保存在栈上,除了下面章节描述的情形。

栈是组织为先进先出形式的一部分内存。它用于保存函数的返回地址(即从哪里调用函数),函数参数,局部变量,以及在函数返回前必须恢复的寄存器。函数每次被调用时,它在栈上分配这些目的所要求数量的空间。在函数返回时,这个内存空间被释放。下一次调用函数时,可以将相同的空间用于新函数的参数。

栈是保存数据效率最高的内存空间,因为同一区域的内存地址被一再重用。如果没有大的数组,那么几乎可以肯定,这部分内存被映射到1级数据缓存,那里它被快速访问。

从这我们可以得到经验,所有变量与对象最好声明在使用它们的函数里。

通过声明在大括号内,可以使变量的作用域更小。不过,直到函数返回,大多数编译器才会释放变量使用的内存, 即使在退出声明变量的大括号时,它可以释放这个内存。如果变量保存在一个寄存器里(参考下面),有可能在函数返回前释放它。

全局或静态储存

声明在任何函数以外的变量称为全局变量。可以从任何函数访问它们。全局变量保存在内存的静态部分。静态内存也用于使用static关键字声明的变量、浮点常量、字符串常量、数组初始化列表、switch语句跳转表以及虚函数表。

静态数据区通常分为3部分:一部分用于程序从不修改的常量,一部分用于程序可能修改的已初始化变量,一部分用于程序可能修改的未初始化变量。

静态数据的好处是,在程序启动前,它可以被初始化为期望值。坏处是,内存空间在整个程序执行期间被占据,即使变量仅用在程序的很小一部分。这使得数据缓存效率较低。

如果可以避免,不要使变量变成全局的。在不同的线程间,可能需要全局变量用于通讯,但那是它们仅有的、不可避免的情形。如果要访问几个不同的函数,且你希望避免将变量作为函数参数传递的开销,使变量成为全局可能是有用的。不过,使函数在一个类中保存共享的变量,以及访问这个类保存好的变量成员,可能是一个更好的方案。你喜欢哪个方案,只是编程风格问题。

通常,最好使一个查找表成为静态。例子:

// Example 7.1

float SomeFunction (int x)

    static float list[] = 1.1, 0.3, -2.0, 4.4, 2.5;

    return list[x];

这里使用static的好处是,在该函数被调用时,这个类别无需被初始化。在程序载入内存时,值就放在那里了。如果从上面的例子里删除static,每次调用这个函数时,所有5个值都必须放入这个列表。这通过从静态内存拷贝整个列表到栈内存来完成。从静态内存拷贝常量到栈,在大多数情形下,是浪费时间;但在该数据在一个循环里被使用许多次,其中几乎整个1级缓存都用在若干你希望一起放在栈上的数组里的特殊情形中,这可能是最优的。例如:

// Example 7.2

a = b * 3.5;

c = d + 3.5;

这里,常量3.5将被保存在静态内存里。大多数编译器将识别处这两个常量是相同的,因此仅需要保存一个常量。在整个程序里,所有相等的常量将被合并起来,以尽量减少用于常量的缓存空间。

整数常量通常包括为指令代码的部分。你可以假定对整数常量没有缓存问题。

寄存器储存

数量有限的变量可以保存在寄存器而不是内存里。寄存器是CPU里用于临时储存的一小块内存。保存在寄存器中的变量可以非常快地访问。所有优化编译器自动选择函数中使用最多的变量用寄存器保存。同一个寄存器可用于多个变量,只要它们的使用(生命期)不重叠。

寄存器的数量是非常有限的。在32位操作系统中大约有6个通用整数寄存器,在64位系统中大约有14个整数寄存器。

浮点变量使用不同类型的寄存器。在32位操作系统里有8个浮点寄存器,64位操作系统中有16个。某些编译器在32位模式里制作浮点寄存器变量有困难,除非启用了SSE2(或更高的)指令集。

Volatile

关键字volatile声明一个变量可以被另一个线程改变。这防止编译器进行依赖于该变量总是有代码中前前赋予的值这个假定的优化。例如:

// Example 7.3. Explain volatile

volatile int seconds; // incremented every second by another thread

void DelayFiveSeconds()

    seconds = 0;

    while (seconds < 5)

        // do nothing while seconds count to 5

   

在这个例子里,DelayFiveSeconds函数将等待,直到seconds被另一个线程递增到5。如果seconds没有被声明为volatile,那么一个优化编译器将假定seconds在while循环里保持为零,因为循环里没有什么可以改变这个值。循环将是while (0 < 5) ,这是一个无限循环。

关键字volatile的作用是确保变量被保存在内存里,而不是在寄存器中,防止该变量上的所有优化。在测试条件下,避免某些表达式被优化掉,这是有用的。

注意,volatile不意味着原子性。它不能防止两个线程同时尝试写该变量。上面例子中的代码,在尝试将seconds置零,同时另一个线程递增seconds的情形里,可能会失败。一个更安全的实现仅读取seconds的值,等待直到值被改变5次。

线程局部储存

大多数编译器通过使用关键字__thread或__declspec(thread),可以制作具有线程局部储存的静态及全局变量。这样的变量每线程一个实例。线程局部储存是低效的,因为它通过一个保存在线程环境块里的指针访问。尽可能避免线程局部储存。使用栈上储存替代(参考上面的第20页)。保存在栈上的变量总是属于创建它们的线程。

Far

具有分段内存的系统,比如DOS与16位Windows,允许通过使用关键字far,把变量保存在一个远数据段(数组还可以是huge)。远储存,远指针,以及远过程是低效的。如果程序的数据超出一个段,建议使用允许更大段的操作系统(32位或64位系统)。

动态内存分配

动态内存分配使用操作符new与delete或者函数malloc与free来完成。这些操作符与函数消耗大量的时间。一部分称为堆的内存为动态分配保留。在不同大小的对象随机分配与释放时,堆很容易碎片化。堆管理器会花费大量的时间清理不再使用的空间,以及查找空闲空间。这称为垃圾收集。顺序分配的对象不一定在内存里顺序保存。在堆变得碎片化时,它们可能分散在不同的地方。这使得数据缓存低效。

动态内存分配还倾向于使代码更复杂、易错。程序必须保持指向所有已分配对象的指针,记录它们何时不再使用。在程序流的所有可能情形里,所有已分配对象会得到释放,是重要的。没有这样做是一个称为内存泄露的常见错误源头。一个更糟的错误是在一个对象被释放后访问它。程序逻辑需要额外的开销来防止这样的错误。

使用动态内存分配的好处与坏处的进一步讨论,参考第91页。

某些编程语言,比如Java,对所有对象都使用动态内存分配。这当然效率不高。

声明在一个类里的变量

声明在一个类里的变量以它们在类声明里出现的次序保存。在声明类对象的地方确定储存类型。一个类、结构体或联合的一个对象可以使用上述任意储存方法。除非在最简单的情形里,一个对象不能保存在一个寄存器中,但其数据成员可以拷贝进寄存器。

一个带有static修饰符的类成员变量将被保存在静态内存里,且将仅有一个实例。同一个类的非静态成员将与类的每个实例一起保存。

要确保用在程序同一个部分的变量在彼此附近保存,在一个类或结构体中保存变量是一个好的方法。使用类的好处、坏处,参考第52页。

​​​​​​​7.2. 整数变量与操作符

整数大小

整数可以有不同大小,它们可以有符号或无符号。下表汇总了可用的不同整数类型。

声明

大小,比特

最小值

最大值

in stdint.h

char

8

-128

127

int8_t

short int

16位系统中:int

16

-32768

32767

int16_t

Int

16位系统中:long int

32

-231

231-1

int32_t

long longint64_t

MS编译器:__int64

64

-263

263-1

int64_t

64-bit Linuxlong int

unsigned char

8

0

255

uint8_t

unsigned short int

16位系统中:unsigned int

16

0

65535

uint16_t

unsigned int

16位系统中:unsigned long

32

0

232-1

uint32_t

unsigned long longuint64_t

MS编译器:unsigned __int64 64-bit

Linuxunsigned long int

64

0

264-1

uint64_t

表7.1. 不同整数类型的大小

不幸的是,对不同的平台,声明一个特定大小整数的方法是不同的,如上表所示。如果标准头文件stdint.h或inttypes.h可用,建议使用之,以一个可移植方式定义一个特定大小的整数类型。

在大多数情形里,整数操作不管大小都是快的。不过,使用一个超过最大可用寄存器的整数大小是低效的。换而言之,在16位系统里使用32位整数或在32位系统里使用64位整数是低效的,特别是如果代码涉及了乘法或除法。

如果不指定大小声明一个int,编译器总是选择最高效的整数大小。更小的整数(char,short int)效率稍低。在许多情形里,在进行计算时,编译器将把这些整数类型转换为缺省大小,然后仅使用结果的低8位或低16位。你可以假定类型转换需要零或一时钟周期。在64位系统中,32位整数与64位整数效率间的差异极小,只要不做除法。

建议在大小不重要且没有溢出的风险时,比如简单变量、循环计数器等,使用缺省的整数大小。在大数组中,最好使用对这个特定目的足够大的最小整数大小,以更好地使用数据缓存。8、16、32以及64位大小以外的位域(bit-field)效率较低。在64位系统中,你可以使用64位整数,如果应用可以使用额外的比特。

无符号整数类型size_t,在32位系统中是32位,在64位系统中是64位。它的目的是,在你希望即使对超过2GB的数组溢出也确保不会出现时,用作数组大小与数组索引。

在考虑一个特定的整数大小是否对一个特定目的足够大时,必须考虑中间计算是否会导致溢出。例如,在表达式a = (b*c)/d中,会出现(b*c)溢出,即使a,b,c与d都在最大值以下。没有整数溢出的自动检查。

有符号与无符号整数

在大多数情形里,使用有符号和无符号整数间速度没有差别。但存在少数情形,这个区别是重要的:

  • 除以常量:在一个整数除以一个常量时,无符号比有符号快(参考第140页)。这也适用于取模操作符%。
  • 对大多数指令集,使用有符号转换到浮点,比使用无符号快(参考第145页)。
  • 有符号与无符号变量的溢出行为不同。一个无符号变量的溢出产生一个小的正数结果。一个有符号变量的溢出,官方的说法是未定义。通常的行为是正值溢出回绕为一个负值,而编译器基于溢出不会发生的假设,可能会优化掉依赖溢出的分支。

有符号与无符号整数间的转换是没有代价的。它只是相同的比特不同的解释而已。在转换到无符号时,一个负整数将被解释为一个非常大的正数。

// Example 7.4. Signed and unsigned integers

int a, b;

double c;

b = (unsigned int)a / 10;     // Convert to unsigned for fast division

c = a * 2.5;                             // Use signed when converting to double

在例子7.4中,我们将a转换为无符号,以使除法更快。当然,这仅在确定a不会是负数时奏效。最后一行在乘以double常量2.5之前,隐含地将a转换为double。这里,我们倾向a是有符号的。

确保在比较中,比如<,不要混用有符号与无符号整数。将有符号整数与无符号整数比较的结果是有歧义的,会产生非期望的结果。

整数操作符

整数操作符通常非常快。简单的整数操作,如加法、减法、比较、比特操作与偏移操作,在大多数微处理器上仅需要1时钟周期。

乘法与除法需要更长时间。整数乘法在Pentium 4处理器上需要11时钟周期,在其他大多数微处理器上3 ~ 4时钟周期。整数除法需要40 ~ 80时钟周期,取决于微处理器。在AMD处理器上,整数大小越小,整数除法越快,但在Intel处理器上不是。指令时延的细节列出在手册4《指令表》中。如何加速乘法与除法的小建议分别在第139与140页给出。

递增与递减操作符

前置递增操作符++i与后置递增操作符i++与加法一样快。在只是用来递增一个整数变量时,使用前置还是后置形式没有差别。效果相同。例如,for (i=0; i<n; i++)与for (i=0; i<n; ++i)一样。但在使用表达式的结果时,效率上可能有不一样。例如,x = array[i++]比x = array[++i]更高效,因为对后者,数组元素的地址计算必须等待i的新值,这将推迟x大约2时钟周期。当然,i的初始值必须调整,如果将前置改为后置。

也存在前置比后置高效的情形。例如,对a = ++b;,编译器将认识到,在这个语句后,a与b的值是相同的,因此它可以对两者使用相同的寄存器;而表达式a = b++;将使用a与b的值不相同,因此它们不能使用同一个寄存器。

这里所说的关于递增操作符的一切,也适用于整数变量上的递减操作符。

​​​​​​​7.4. 浮点变量与操作符

X86家族的现代微处理器有两个不同类型的浮点寄存器,相应地两种不同类型的浮点指令。每个类型各有优缺点。

执行浮点操作最开始的方法涉及8个组织为寄存器栈的浮点寄存器。这些寄存器有长双精度(80位)。使用寄存器栈的好处有:

  • 所有计算以长双精度进行。
  • 不同精度间的转换不需额外时间。
  • 有用于数学函数的固有函数指令,比如对数与三角函数。
  • 代码紧凑,在代码缓存中需要很少空间。

寄存器栈也有缺点:

  • 对编译器而言,因为寄存器栈的组织方式,不容易制作寄存器变量。
  • 浮点比较慢,除非启用了Pentium-II或更新的指令集。
  • 整数与浮点值间的比较低效。
  • 在使用双精度时,除法、平方根与数学函数需要更多时间计算。

进行浮点操作的一个更新的方法涉及8或16个可用于多个用途的向量寄存器(XMM或YMM)。浮点操作以单精度或双精度完成,总是使用与操作数相同的精度计算中间结果。使用向量寄存器的好处有:

  • 容易制作浮点寄存器变量。
  • 向量操作可在XMM寄存器中的2个双精度向量或4个单精度向量上进行并行计算(参考第107页)。如果AVX指令集可用,那么在YMM寄存器中,每个向量可以保存4个双精度或8个单精度变量。

缺点有:

  • 不支持长双精度。
  • 计算操作数有混合精度的表达式,要求相当耗时的精度转换指令(参考第143页)。
  • 数学函数必须使用一个函数库,但这通常比固有硬件函数快。

在所有有浮点能力的系统中,浮点栈寄存器都可用(除了64位Windows的设备驱动)。在64位系统以及启用SSE2或更新的指令集(单精度仅要求SSE)的32位系统中,XMM向量寄存器可用。如果处理器与操作系统支持AVX指令集,YMM寄存器可用。如何测试这些指令集是否可用,参考第125页。

一旦可用,大多数编译器将XMM寄存器用于浮点计算,即在64位模式或在启用SSE2指令集时。少数编译器能混用两种类型的浮点操作,并选择对每个计算最优的类型。

在大多数情形里,双精度计算需要的时间与单精度一样。在使用浮点寄存器时,单精度与双精度间的速度没有差别。长双精度需要时间稍多。在大多数处理器上,在使用XMM寄存器时,单精度除法、平方根以及数学函数计算得比双精度快,而加法、减法、乘法等的速度仍然相同(在不使用向量乘法时)。

如果对应用有好处,你可以使用双精度,无需太担心代价。如果有大数组且希望将尽可能多数据放入数据缓存,可以使用单精度。如果可以利用向量操作,单精度是好的,如第107页所述。

浮点加法需要3 ~ 6时钟周期,依赖于微处理器。乘法需要4 ~ 8时钟周期。除法需要14 ~ 45时钟周期。在使用浮点栈寄存器时,浮点比较是低效的。在使用浮点栈寄存器时,将浮点或双精度转换到整数需要长时间。

在使用XMM寄存器时,不要混用单精度与双精度。参考第143页。

尽可能避免整数与浮点变量间的转换。参考第144页。

在XMM寄存器里产生浮点下溢的应用,会从设置flush-to-zero模式获益,而不是在下溢情形下产生次正规值。

// Example 7.5. Set flush-to-zero mode (SSE):

#include <xmmintrin.h>

_MM_SET_FLUSH_ZERO_MODE(_MM_FLUSH_ZERO_ON);

强烈建议设置flush-to-zero模式,除非你有特别原因使用次正规值。另外,如果SSE2可用,你可以设置denormals-are-zero模式。

// Example 7.6. Set flush-to-zero and denormals-are-zero mode (SSE2):

#include <xmmintrin.h>

_mm_setcsr(_mm_getcsr() | 0x8040);

更多关于数学函数的信息,参考第149与122页。

​​​​​​​7.5. 枚举

枚举只是伪装下的一个整数。枚举实际上与整数一样高效。

注意,枚举值(enumerator,值满足)将与任何同名的变量或函数冲突。因此,头文件中的枚举应该有长且唯一的枚举值名字,或者放在名字空间里。

以上是关于优化C++软件的主要内容,如果未能解决你的问题,请参考以下文章

优化C++软件

优化C++软件

优化C++软件

优化C++软件

优化C++软件

优化C++软件