特定优化主题
使用查找表
如果缓存了常数表,则从该表读取值的速度非常快。通常,从一级cache中的表读取值只需要几个时钟周期。利用这一事实,如果函数只有有限数量的输入,我们可以通过用表查找来替换函数调用。
让我们以整数阶乘函数(n!)为例。唯一允许的输入是0到12之间的整数。高输入产生溢出,负输入产生无穷大。阶乘函数的典型实现如下所示:
。。。
此实现使用查找表,而不是每次调用函数时去计算。在这里添加了对n
的边界检查,因为当n
是数组索引时,越界是更严重的错误。下文解释了边界检查的方法。该表应声明为const,以便利用常量传播和其他优化。可以声明函数为内联函数。
在可能的输入数量有限且不存在缓存问题的大多数情况下,用查找表替换函数是有利的。如果希望在每次调用不是从cache中取值,并且计算函数所需的时间小于重新加载值加上程序其他部分装入cache所需的时间,则使用查找表并不有益。
当前指令集不支持表查找的向量化。如果会妨碍更快的向量化代码,请不要使用查找表。
在静态内存中存储某些内容可能会导致缓存问题,因为静态数据可能分散在不同的内存地址。如果缓存是一个问题,那么在最内部循环之外,将查找表从静态内存复制到堆栈内存可能会很有用。这是通过在函数内部但在最内部的循环外部声明表来实现,且不添加static关键字:
。。。
示例14.1c中的FactorialTable
在调用CriticalInnerFunction
时,从静态内存复制到堆栈。编译器将把表存储在静态内存,并在函数开始处插入一段代码,将表复制到函数的堆栈内存中。复制表当然需要额外的时间,但在关键的内层循环外,这是允许的。循环将使用存储在堆栈内存中的表的副本,因此可能比静态内存更高效地利用缓存。
如果您不想手工计算表中的值,当然可以让程序进行计算,只要计算只运行一次。有人可能会说让程序计算更安全,因为人工键入可能无法被检测到。
查表原理可用于程序任何在两个或多个常数之间选择的情况。例如,在两个常数分支之间进行选择的可以由具有两个条目的表替换。这可能会提高性能,如果这一分支很难预测。例如:
。。。
用查找表替换switch
语句尤其有益。因为switch
语句通常很难进行分支预测。例如:
。。。
有两个const
是因为指针和其指向的文本内容都是常量。
边界检查
在C++中进行数组越界检查,通常是很必要的。典型的形式如下:
。。。
这个方法适用于防止数组索引越界并且不需要错误信息的情境。要注意的是数字的长度必须是2的次幂。
使用位操作符一次检测多个值
位操作符&, | , ^, ~, <<, >>
能够在一次操作中测试一个整数的所有位。
&
在一次操作中测试多个条件也是有用的。例如:
。。。
需要注意逻辑操作符&&, ||, !
和位操作符&, | , ~
的不同。布尔操作符产生一个结果,true(1)或false(0);而且第二个操作符只有需要时才会计算。未操作符产生32个(整数位数)结果结果并且两个操作数都要计算。但位操作比布尔操作快得多,因为不需要用到分支。
定义常量用enum, const, #define
在性能上没有区别。
整数乘法
整数乘法比加减法需要更多的时间(3-10个时钟周期,取决于处理器)。优化的编译器经常把乘法替换为移位操作和常量加法的组合操作。例如:a * 17
会按照(a << 4) + a
来计算。
在计算一个数组元素的地址时,乘法会隐式计算。在一些场景中,当因子是2的次幂时,计算是更快的。例如:
。。。
结构体和类的数组同样适用。每个对象的大小最好是2的次幂,如果这些对象不是按照次序访问的。例如:
。。。
这里,我们在结构体中插入了UnusedFiller
,以确保其大小为2的幂,从而加快地址计算。
使用2的幂的优点仅适用于不按次序访问元素的情况。如果更改了示例14.8和14.9中的代码,使其以i
而不是j
作为索引,则编译器可以看到地址是按顺序访问的,并且可以通过在上次的地址上添加一个常量来计算每个地址(参见编译器优化)。在这种情况下,大小是否为2的幂并不重要。
使用2的幂的建议不适用于非常大的数据结构。相反,如果矩阵太大以至于缓存成为一个问题,应该尽一切努力避免2的幂。如果矩阵中的列数为2的幂,且矩阵大于缓存,那么会有严重的缓存争用问题,如此文所述。
整数除法
整数除法比加法、减法和乘法耗时更长(27-80个时钟周期,取决于处理器)。
整数除以2的幂可以通过移位操作完成,这要快得多。
用常数除法比用变量除法快,因为优化编译器可以将a/b
计算为a*(2^n/b)>>n
,通过选择合适的n
。常数(2^n) / b
可以提前计算,然后用进行乘法。这个方法有些复杂,因为要添加符号和舍入的错误检查。如果除法是无符号的,则该方法速度更快。
以下准则可用于改进包含整数除法的代码:
- 整数除以常数比除以变量快。确保除数的值在编译时已知。
- 如果常数是2的次幂,则整数除以常数的速度更快
- 如果被除数是无符号的,则整数除以常数的速度更快
示例:
。。。
如果除数的值在编译时未知,但程序使用相同的除数重复除法,则仍可使用上述方法。在这种情况下,需要在编译时对(2^n / b)
等进行必要的计算。
循环计数器除以常数可以通过将循环除以相同的常数来避免。例子:
。。。
浮点除法
浮点除法比加法、减法和乘法花费更长的时间(20-45个时钟周期)。
浮点除以常数应乘以倒数来进行:
。。。
不要混合使用浮点和双精度
无论您使用的是单精度还是双精度,浮点计算通常需要相同的时间。但在编译64位操作系统的程序和为指令集SSE2或更高版本编译的程序中混合使用单精度和双精度会损失性能。例如:
。。。
浮点数和整数的转换
浮点转换为整型
根据C++语言的标准,从浮点数到整数的所有转换都使用截断,而不是舍入。这是不幸的,因为除非使用SSE2指令集,否则截断比舍入花费的时间要长得多。如果可能,建议启用SSE2指令集。SSE2在64位模式下总是启用的。
不带SSE2的浮点到整数的转换通常需要40个时钟周期。如果在代码的关键部分无法避免从float
或double
到int
的转换,那么可以使用舍入而不是截断来提高效率。这大约快三倍。程序的逻辑可能需要修改,以补偿舍入和截断之间的差异。
可以使用函数lrintf
和lrint
实现从浮点或双精度到整数的高效转换。不幸的是,由于C99标准的争议,许多商业编译器中都缺少这些函数。下面的示例14.19给出了lrint
函数的一个实现。函数将浮点数舍入为最接近的整数。如果两个整数相等,则返回偶数整数。没有检查溢出。此函数适用于32位Windows和32位Linux的微软、英特尔和Gnu编译器。
。。。
此代码仅适用于与Intel/x86兼容的微处理器。
下边的例子战士如何使用lrint
函数:
。。。
在64位模式下或启用SSE2指令集时,舍入和截断之间的速度没有差异。在64位模式下或启用SSE2指令集时,可以按如下方式实现缺失的舍入函数:
。。。
整型转换为浮点
只有在启用SSE2指令集时,有符号整数到浮点的转换才会很快。只有启用AVX512指令集时,无符号整数到浮点的转换速度才会更快。
参考整型转换为浮点。
在浮点变量乘法中使用整数操作
根据IEEE标准754(1985),浮点数以二进制表示形式存储。这个标准几乎用于所有现代微处理器和操作系统(但不用于一些非常旧的DOS编译器)。
float
、double
和long double
的写为
±
2
e
e
e
⋅
1.
f
f
f
f
f
\pm 2^{eee}\cdot 1.fffff
±2eee⋅1.fffff,
±
\pm
±是符号,eee是指数,fffff是分数的二进制小数。符号存储为单个位,0表示正数,1表示负数。指数存储为有偏二进制整数,分数存储为二进制数字。如果可能,指数始终是标准化的,因此小数点前的值为1。此“1”不包含在表示中,除了long double
。格式可以表示为以下内容:
。。。
非零浮点数的值可按如下方式计算:
如果除符号位以外的所有位均为零,则该值为零。零可以带符号位表示,也可以不带符号位。
浮点格式是标准化的,这一事实允许我们使用整数运算直接操作浮点表示的不同部分。这可能是一个优势,因为整数运算比浮点运算快。只有当你确信自己知道自己在做什么时,你才应该使用这些方法。有关一些注意事项,请参见本节末尾。
我们可以通过反转符号位来更改浮点数的符号:
。。。
指数的表示是有偏差的,这一事实允许我们通过将两个正浮点数作为整数进行比较:
。。。
通常,如果浮点变量存储在内存中,则以整数形式访问它会更快,但如果它是寄存器变量,则访问速度不会更快。union强制将变量存储在内存中,至少是暂时存储。因此,如果代码的其他邻近部分可以受益于使用相同变量的寄存器,那么使用上述示例中的方法将是一个缺点。
在这些示例中,我们使用联合而不是指针的类型转换,因为这种方法更安全。指针的类型转换在依赖标准C的严格别名规则的编译器上可能不起作用,它指定不同类型的指针不能指向同一个对象,除了char
指针。
以上示例都使用单精度。在32位系统中使用双精度会带来一些额外的复杂性。double用64位表示,但32位系统不支持64位整数。许多32位系统允许定义64位整数,但实际上它们表示为两个32位整数,效率较低。您可以使用double的上32位,它可以访问符号位、指数和分数的最高有效部分。例如,要测试double的符号:
。。。
在64位系统中,可以通过在双精度上使用64位整数而不是两个32位整数来避免这种情况。
访问64位双精度存储器的32位的另一个问题是,它不能移植到具有big-endian存储的系统。因此,如果在具有big-endian存储的其他平台上y用示例14.22b和14.29,则需要进行修改。所有x86平台(Windows、Linux、BSD、基于Intel的Mac OS等)都是小端存储,但其他系统可能有大端存储(例如PowerPC)。
我们可以通过比较位32-62来对double进行近似比较。这对于在高斯消去法中寻找矩阵中用作枢轴的数值最大元素非常有用。示例14.27中的方法可以在pivot搜索中这样实现:
。。。
示例14.29查找数组中数值最大的元素,或大致如此。它可能无法区分相对差值小于2-20的元素,但这对于找到合适的枢轴元素而言足够准确。整数比较可能比浮点比较快。在big-endian系统中,必须用u[0]
替换u[1]
。
数学函数
最常见的数学函数(如对数、指数函数、三角函数等)在x86 CPU用硬件实现。但是,在SSE2指令集可用的大多数情况下,软件实现比硬件实现更快。如果启用SSE2指令集,则大多数编译器使用软件实现。
使用这些功能的软件实现而不是硬件实现的对于单精度比双精度优势更大。但在大多数情况下,软件实现比硬件实现快,即使是双精度。
你可以与不同编译器一起使用英特尔数学函数库,通过包含 libmmt.lib
和头文件mathimf.h
,它们与Intel C++编译器有关。这个库包含许多有用的数学函数。英特尔的数学内核库提供了许多高级数学函数,可从www.Intel.com获得。AMD数学核心库包含相似函数。
静态库VS动态库
函数库可以实现为静态链接库(*.lib,*.a
)或动态链接库,也称为共享对象(*.dll,*.so
)。静态链接的机制是链接器从库文件中提取并复制所需的函数到可执行文件。只有可执行文件需要分发给最终用户。
动态链接的工作方式不同。需要使用的函数链接到动态库中,这个拦截在加载库时或在运行时解析。因此,可执行文件和一个或多个库文件在程序运行时加载到内存中。可执行文件和所有动态库都需要分发给最终用户。
使用静态链接而不是动态链接的优点是:
- 静态链接的应用程序仅包括库中实际需要的部分,而动态链接使整个库(或至少大部分库)都加载到内存,即使只需要库中的一个函数。
- 使用静态链接时,所有代码都包含在单个可执行文件中。动态链接使得程序启动时需要加载多个文件。
- 在动态库中调用函数比在静态链接库中调用函数需要更长的时间,因为它需要通过导入表中的指针进行额外的跳转,可能还需要在过程链接表(PLT)中查找。
- 当代码分布在多个动态库中时,内存空间变得更加碎片化。动态库以可被内存页大小整除(4096)的回环地址加载。这将使所有动态库相互竞争缓存。这会降低代码缓存和数据缓存的效率。
- 动态库在某些系统中效率较低,因为需要位置无关代码,见下文。
- 安装使用同一动态库更新版本的第二个应用程序。如果使用动态链接,则可能改变第一个应用程序的行为,但如果使用静态链接,则不会。
动态链接的优点是:
- 同时运行的多个应用程序可以共享相同的动态库无需将库的多个实例加载到内存中。这是在同时运行多个进程的服务器上非常有用。实际上,只有代码节和只读数据节可以共享。任何可写数据,每个进程都需要自己的实例。
- 动态库可以更新为新版本,而无需更新调用它的程序。
- 可以从不支持静态的编程语言调用动态库链接。
- 动态库可用于制作插件,向现有库添加功能。
权衡每种方法的上述优点,显然静态链接更可取用于速度关键功能。许多函数库既有静态的,也有动态的版本。如果速度很重要,建议使用静态版本。
有些系统允许函数调用的延迟绑定。
。。。
Windows DLL使用重新定位。DLL由链接器重新定位到特定的加载地址。如果此地址不为空,则DLL将再次定位,加载到不同的地址。从主可执行文件调用DLL中的函数,需要通过导入表或指针。通过导入的指针,DLL中的变量可以从main访问,但很少使用此功能。更常见的是
通过函数调用交换数据或指向数据的指针。对内部数据的内部引用,在32位模式下使用绝对引用,在64位模式下主要使用相对引用。后者稍微高效些,因为相对引用不需要在加载时进行重定位。
类Unix系统中的共享对象默认使用位置无关的代码。这比重新定位效率低,尤其是在32位模式下。下一章将描述这是如何工作的,并提出避免位置无关代码成本的方法。
位置无关代码
Linux、BSD和Mac系统中的共享对象通常使用所谓的位置无关代码。“位置无关代码”的名称实际上比字面意思有更多含义。编译为位置无关的代码具有以下特性:
- 代码部分不包含需要重新定位的绝对地址,只包含自相关地址。因此,代码段可以加载到任意内存地址,并在多个进程之间共享。
- 数据部分不在多个进程之间共享,因为它通常包含可写数据。因此,数据段可能包含需要重新定位的地址。
- 所有公共函数和公共数据都可以在Linux和BSD中重写。如果主可执行文件与共享对象中的函数具有相同的名称,那么main中的版本将优先调用,不仅当从main调用时,而且当从共享对象调用时也如此。同样,当main中的全局变量与共享对象中的全局变量同名,则main中的实例将被使用,即使从共享对象访问时也是如此。这个所谓的符号插入旨在模拟静态库的行为。共享对象的过程链接表(PLT,包含指向其函数的指针)和全局偏移表(GOT,包含指向其地址的指针)就是为了实现指针“覆盖”功能。所有对函数和公共变量的访问都通过PLT和GOT。
32位Linux中的共享对象
根据Gnu手册,共享对象通常使用-fpic
选项编译。此选项使代码段位置无关,创建PLT用于所有函数,GOT用于所有公共和静态数据。
可以在不使用-fpic
选项的情况下编译共享对象,可以避免上述问题。现在代码将运行得更快,因为我们访问内部变量和内部函数只需一步,而不是通过复杂的地址计算和表查找机制。这会快得多,除了一个非常大的共享对象而其中大部分这些函数永远不会被调用的情况。32位Linux中不用-fpic
编译的缺点是加载程序将有更多的引用要重新定位,但这些地址计算是仅执行一次,而执行运行时地址计算每次访问都要计算。当在没有-fpic
的情况下编译代码段时,每个进程都需要一个实例因为代码部分中的重定位对于每个进程都是不同的。显然,失去覆盖公共符号的能力,但也很少需要此功能。
为了便于移植到64位模式,最好避免使用全局变量或隐藏它们,如下所述。
64位Linux中的共享对象
在64位模式下,计算自相关地址的过程要简单得多,因为64位指令集支持数据的相对寻址。对位置无关的代码较小的特性的需要,64位模式代码默认情况下通常使用相对地址。但是,对于本地引用仍然希望摆脱GOT和PLT查找。
如果我们在64位模式下不使用-fpic
编译共享对象,会遇到另一个问题。编译器有时使用32位绝对地址(主要用于静态数组)。这在主可执行文件中起作用,因为它肯定是在2GB以下的地址加载的,但不在共享对象中。共享对象通常加载在更高的地址,而该地址不能使用32位地址访问到。链接器将生成错误消息。最好的解决方案是使用选项-fpie
而不是-fpic
进行编译。这将在代码段中生成相对地址,但不会将GOT和PLT用于内部引用。因此,它将比使用-fpic
编译时运行得更快,并且不会出现上面提到的32位的缺点。-fpie
选项在32位模式中不太有用
32位模式,因为仍使用GOT。
另一种是使用-mcmodel=large
编译,但这将使用对所有使用完整的64位地址,这是相当低效的,它会在内核代码段生成重定位,使其无法共享。
BSD中的共享对象
BSD中的共享对象与Linux中的工作方式相同。
32位Mac OS X
默认情况下,32位Mac OS X编译器会生成位置无关的代码和延迟绑定,即使不使用共享对象。目前用于32位Mac代码中的自相关地址的计算方法不好,该方法会延缓执行,导致返回地址预测失误。
所有不属于共享对象的代码,不使用位置无关代码编译标志,都可以显著加速。因此,请记住,始终要指定编译32位Mac OS X时的编译器选项-fno pic
,除非正在共享对象。
编译时,可以在没有位置独立代码的情况下创建共享对象,编译时使用选项-fno pic
,链接时使用选项-read_only_relocs suppress
。
GOT和PLT表不用于内部引用。
64位Mac OS X
代码段始终是位置无关的,因为这是这种情况最有效的内存模型。编译器选项-fno-pic
显然没有效果。
GOT和PLT表不用于内部引用。
在Mac OS X中加速64位共享对象不需要采取特别的预防措施。
系统编程
设备驱动程序、中断服务例程、系统核心和高优先级线程都属于速度非常关键的领域。在系统代码或应用程序中非常耗时的高优先级线程可能会阻止其他所有操作的执行。
如本章所述,系统代码必须遵守有关寄存器使用的某些规则。因此,系统编程只能适用可以用于系统内核的编译器和函数库。系统代码应该用C、C++编写或汇编语言。
系统代码中节约资源非常重要。动态内存分配特别危险,因为它可能在不合适的时机激活非常耗时的垃圾回收。队列应实现为具有固定大小的回环缓冲区,不能用链表。不要使用标准的C++容器,参考标准C++容器。
欢迎交流