声明
本手册是 Agner Fog 优化手册系列第一册 “Optimizing software in C++:An optimization guide for Windows,Linux adn Mac.” 的中文翻译。可以从www.agner.org/optimize/上获取该手册英文版的最新版本。当前中文版是基于2018.9.5日更新的版本翻译的。版权声明请参考本手册最后一章。
1 简介
本手册适用于那些想要使软件更快的编程人员和软件开发者。本手册假设读者熟练掌握 C++ 编程语言,并了解编译器是如何工作的。至于选择 C++ 作为本手册基础的原因,将在稍后解释.
本手册的内容基于笔者对编译器和微处理器是如何工作的研究。本手册中的建议是针对 x86 家族的微处理器,包括 Intel、AMD 和 VIA 的处理器(包括 64 位版本)。x86 处理器是 Widows,Linux, BSD 和 Mac OS X 中最常用的平台,即使这些操作系统也适用于其他微处理器,当然很多设备也使用其他平台和变异语言。
本手册是一个系列五本手册中的第一本:
Optimizing software in C++:An optimization guide for Windows,Linux adn Mac.
Optimizing subroutines in assembly languague:An optimization guide for x86 platforms.
The microarchitecture of Intel,AMD and VIA CPUs:An optimization guide for assembly programmers and compiler makers.
Instruction tables:Lists of instruction latencies,throughputs and micro-operation breakdowns for Intel, AMD and VIA CPUs.
Calling conventions for dirrerent C++ compilers and operating systems.
这些手册的最新版本可以在www.agner.org/optimize/,版权声明将列在手册的最后一章。
只用高级语言编写软件的读者只需要阅读本书即可。后续的内容是为了那些想要深入了解指令集,汇编语言和编译器,处理器微架构的读者准备的。对于 CPU 热点代码,可以通过使用汇编获得更高层次的优化,这将会在后续的内容中进一步讨论。
请注意到有非常多的人使用到我的优化手册。因此我不可能有时间回答每一个人的问题。请不要将你的编程问题发送给我,因为你将得不到任何答案。建议初学者在提高自己的编程经验后,再来尝试手册中所提到的技术。,如果你在相关书籍和手册中找不到答案的话,你可以在互联网上的诸多论坛中找到你问题的答案。
我想要感谢那些给我的优化手册发送修正和建议的人,我很高兴能够收到相关信息。
1.1 优化的代价
如今大学的编程课程在软件开发过程中强调结构化、面向对象、模块化、可重用性、系统化。但是这些要求通常都和优化软件的速度和大小相冲突的。
如今,软件老师更经常建议我们函数或者方法的行数应该尽可能的少。但是在几十年前,建议通常是相反的:如果某些功能只会调用一次,那么就不要把他们封装在分离的子程序中。软件编写风格的建议的变化,是因软件项目变的越来越大、越来越复杂,需要将注意力集中在软件开发中,而且电脑的性能也越来越强大。
软件结构化开发的高优先级和程序性能的低优先级,首先反映在编程语言和接口框架的选择上。这对于最终的用户来说,这通常是一个缺点,他们不得不购买性能更加强大的计算机,来应对更大的软件包,即使对于简单的任务,响应时间也长的不能接受,这使得他们感到沮丧。
有时候为了使软件更小更快,有必要在软件开发的高级原则上做一些妥协。本手册讨论了如何在这些要求之间取得合理的平衡。讨论了如何识别和隔离程序中的最关键部分,并将优化工作集中在该部分。讨论了在相对原始的编程风格中,如何克服缺少自动检查数组越界,无效指针等问题。讨论了在哪些高级编程结构需要的执行时间更多,哪些需要的执行时间更少。
2 选择最优平台
2.1 硬件平台的选择
硬件平台的选择相对于过去来说,变成的更不重要了。RISC(精简指令集)和 CISC(复杂指令集)处理器、PC 和 大型主机(mainframes)以及 简单处理器(simple processors)和 向量处理器(vector processors)之间的区别,变得越来越模糊。拥有 CISC 指令集的标准 PC 处理器也包括了 RISC 核心、向量处理指令(vector processing instruction)、多核、超过以前大型主机的处理速度。
现如今,对于确定任务的硬件平台的选择通常是由诸如价格、兼容性、第二选择(sencond source)和可用的好的开发工具等因素而不是处理能力决定的。在一个网路中连接几个标准 PC 可能比投资一个大型主机更便宜、更有效率。具有大规模并行向量处理能力的大型超级计算机在科学计算中占有一席之地,但是对于大多数目的来说,标准 PC 处理器还是首选,因为它们具有更高的性价比。
从技术角度来看,标准 PC 处理器的的 CISC 指令集(也称为 x86)不是最佳的。这个指令集还在维护,是为了兼容那些在 70 年代产生的软件,而当时 RAM 和硬盘空间是非常稀缺的资源。然而,CISC 指令集实际上要比它的名声要好。紧凑的代码使得缓存的效率在缓存资源依旧非常有限的今天更加高效。CISC 指令集实际上在缓存资源非常有限的时候表现的比 RISC 指令集更好。x86 指令集最糟糕的问题是寄存器的缺乏。这个问题在 x86 指令集的 64 位扩展中得到了缓解,其中的寄存器数量翻了一倍。
由于无法控制网络资源的响应时间,对于关键的应用程序,不建议使用依赖网络资源的瘦客户机(Thin clients)。
小型手持设备正变得越来越受欢迎,并被用于越来越多的用途,如电子邮件、浏览网页,这些在以前都需要使用一台 PC。类似的,我们正看到有越来越多的设备和机器采用嵌入式处理器。我对使用哪些平台和操作系统更高效,没有任具体的建议。但我们需要认识到这些设备通常情况下,内存和计算能力都是要弱于 PC 的,这非常重要。因此在这样的系统上节约使用资源比在 PC 平台上更加重要。然而,通过良好的软件设计,即使在这样的小型设备上,许多应用程序也可以具有良好的表现,这些将在第17章进行讨论。
本手册基于标准的 PC 平台,采用 Intel、AMD 或者 VIA 处理器,使用 Windows、Linux、BSD 或者 MAC 操作系统。这里给出的很多建议也适用于其它平台,但是都只在 PC 平台上通过测试。
图形加速器
平台的选择明显受任务要求的影响。例如,较大的图形应用编程序最好在具有图形协处理器或者图形加速卡的平台上实现。一些系统也有专门的物理处理器来处理游戏或者动画中的物理运动。
在某些情况下,图形加速卡的高处理能力可以用于除了图形渲染之外的其他用途。然而,这样的应用具有非常高的系统依赖性。因此如果可移植性非常重要的话,就不推荐这么做。本手册将不会讨论图形处理器。
可编程逻辑器件
可编程逻辑器件是一种可以使用硬件描述语言(如 VHDL、Verilog)进行编程的芯片。常见的有 CPLD 和 FPGA。编程语言(例如 C++)和硬件描述语言的区别是:编程语言定义了一个一系列指令的算法,而硬件描述语言定义了由例如门、触发器、多路复用器、算术单元 等原件和连接它们的导线组成的硬件电路。硬件描述语言天生就是并行的,因为它定义的是电气连接而不是一系列串行操作。
对于一个复杂的数字操作,可编程逻辑器件通常比微处理器中处理的更快,因为硬件可以按特定的目的连接。
在 FPGA 中实现微处理器作为所谓的软核(soft processor)是可能的。然而这样的处理通常比专用微处理器慢的多,因此它本身并没有什么优势。但是在某些情况下,使用硬件描述语言在同一芯片中定义的软核,执行某些关键应用中的特定指令,是一个非常有效的解决方案。当然将专用微处理器和 FPGA 集成在同一个芯片中是一个更加强大额解决方案。像这样的混合解决方案已经在一些嵌入式系统中被采用了。
我认为这样类似的解决方案,会有一天在 PC 处理器中采用。应用程序将可以定义由硬件描述语言编码的应用程序专用指令。这样的处理器除了代码缓存和数据缓存外,还将会有用于硬件描述代码的缓存。
2.2 微处理器的选择
由于激烈的竞争,不同竞品微处理器的基准性能都非常接近。多核处理器对于那些需要并行运行多个线程的应用程序来说,是有好处的。而小型轻量型的低功耗处理器对于非密集型的应用来说也是相当强大的。
一些系统具有图形处理单元,无论是在图形卡上,亦或是集成在 CPU 芯片中。这样的单元可以当作协处理器,做一些繁重的图形计算。在某些情况下,也可以将图形处理单元的计算能力用于其它目的,而不是当初设计它的目的。一些系统还有一个物理处理单元用于计算电脑游戏中物体的运动。
2.3 操作系统的选择
x86 家族中,所有较新的处理都可以在 16位、32位以及 64位模式下运行。
16位模式在较早的操作系统 DOS 和Windows 3.x 中使用。如果程序或者数据的大小超过 64kbytes,这些系统将使用内存分割。这是非常低效的。现代微处理器没有针对 16位模式进行优化,一些系统也没有向后兼容 16位的程序。除了小型嵌入式系统,是不建议编写 16位程序的。
如今(2013年)32位和64位的操作系统非常常见,它们在性能上也没有很大的区别。64位软件的市场并不是很大,但可以确定的是 64位系统将是未来的主流。
对于一些具很多函数调用的大量使用 CPU 资源的应用程序来说,64位系统可以提升5-10%的性能。如果性能瓶颈在其它地方,32位系统和 64位系统并没有区别。当然使用大量内存的应用程序可以得益于 64位系统大地址空间。
软件开发者可以在两个版本中选择需要消耗大量内存的软件:为了与现有系统的兼容的 32位版本,以及具有最佳性能的 64位版本。
对于 32位软件,Windows操作系统和Linux操作系统的性能几乎相当,因为这两个系统使用同样的函数调用约定(function calling conventions )。FreeBSD 和Open BSD 在软件优化上,几乎所有方面都是相同的。这里说关于Linux 的所有建议,同样适用于BSD系统。
基于Intel 的Mac OS X 操作系统 实在BSD 的基础上开发的,但是编译器默认使用位置无关代码(position-independent code)和延迟绑定,这会降低它的效率。可以通过使用静态链接 和不使用 位置无关代码(选项:-fno-pic),来提升性能。
相对于 32位系统, 64位系统具有以下几个优点:
- 两倍的寄存器数量。这样可以在寄存器中而不是内存中存储中间数据和局部变量。
- 函数参数使用寄存器传递,而不是使用堆栈,这使得函数调用额效率更高。
- 整数寄存器扩展到 64位。这样的唯一好处是,应用程序可以使用 64位整数。
- 大内存的分配和释放的效率更高。
- 所有的 64位CPU 和操作系统都支持SSE2指令集。
- 64位指令集支持数据的自相关寻址,这使得位置无关代码 的效率更高。
相对于 32位系统, 64位系统具有以下几个缺点:
- 指针、引用和堆栈入口使用 64位而不是 32位,这导致数据缓存的效率更低。
- 在 64位模式下,如果装载地址不能保证小于2^31 , 访问静态或者全局数组将会需要几个额外的指令来计算地址。这些额外的成本在 64位的 Windows 和 Mac 程序中可以观察到,但是在 Linux 中很少见。
- 在大内存模型(代码和数据的大小超过 2Gbytes)中,地址的计算将更加的复杂。虽然这种大内存模型很少能用到。
- 一些指令的长度, 64位模式下的长度要比 32位模式下要长 1 字节。
- 一些 64位编译器要不如它们的 32位版本。
总的来说,如果程序有很多函数调用、大量大内存快的分配、或者可以利用 64位整数的优势,那么你可以期待 64位程序会比 32位程序跑的略微快一点。当程序使用超过 2 gigabytes 的数据时,就非常有必要使用 64位的系统了。
当在 64位模式下运行时,操作系统之间的相似性将会消失,因为函数的调用约定是不同的。64位的Windows 只允许 4 个函数参数通过寄存器传递,而 64位的Linux、BSD、Mac 允许通过寄存器传递 14 个参数( 6 个整数和 8 个浮点数)。还有其他的细节使得 64位Linux 的函数调用比 64位Windows 的效率更高(详见第五册:Calling conventions for different compilers and operating systems)。一个具有很多函数调用的的程序,有可能在64位的Linux 上,比在64位的Windows 运行的更快。64位Windows 的这个缺点可以通关是关键函数为内联的或者静态的,或者通过使用可以使进行这个程序优化的编译器来减轻。
2.4 编程语言的选择
在开始一个新的软件项目之前,决定哪种编程语言最适合手上的项目是非常重要的。低级语言有利于优化程序执行速度,而高级语言则有利于开发出清晰和结构良好的代码,以及快速和容易的开发用户界面,利用网络资源和数据库的接口等。
最终应用程序的效率取决于编程语言是如何实现的。当代码被编程并翻译成二进制可以行代码时,效率最高。C++、Pascal以及Fortran的绝大多数实现都是通过编译器的。
其它一些编程语言通过解释器实现。代码按原样分发(distribute),运行时逐行解释。例如JavaScript、PHP、ASP以及UNIX shell script。解释代码是是非常没有效率的,因为循环的每一次迭代,被一次又一次的解释位一个循环的主体。
有些是通过即时编译(just-in-time compilation)实现的。程序代码按照原样存储,一边编译一边执行,例如Perl。
一些现代编程语言使用一种中间代码(byte code,字节码),源码被编程成中间代码,这是分发的代码。中间代码不能按照原样立即执行,在执行之前,它必须经过第二步的解释或者编译。Java 的一些实现基于解释器,解释器通过模拟所谓的 Java 虚拟机来解释中间代码。最好的 Java 虚拟机对于代码的最常用部分使用即时编译。C#、托管C++ 以及 MicroSoft .Net FrameWork 的一些其它一些语言都是基于中间代码的即时编译。
使用中间代码的目的是为了独立于平台且紧凑。使用中间代码的最大缺点是:为了解释或者编译中间代码,用户必须安装庞大的runtime framework。而这个framework 通常需要使用比代码本身多的多的资源。中间代码的另一个缺点是:它增加了额外的抽象层,这使得一些具体的优化更加困难。另一方面,即时编译器可以针对它所运行的 CPU 进行专门的优化,而在预编译代码中进行针对 CPU 的优化更加复杂。
编程语言及其实现的历史揭示了一个曲折的过程,反映了效率、平台独立性和易于开发的等相关冲突的考量。例如,第一台 PC 有一个Basic 的解释器,而由于Basic 解释器实在太慢了,很快就有了Basic 编译器。如今,最受欢迎的Basic 版本,是基于中间代码和即时编译的Visual Basic .NET。一些早期的Pascal 实现使用类似今天Java 的中间代码,但从有了真正的可用的编译器后,该语言获得了显著的欢迎。
从本文的讨论中可以清楚的看到,编程语言的选择需要在效率、可移植性和开发时间等原因进行妥协。当效率很重要的时候,解释类编程语言就不再考虑范围内。而当可移植性和易于开发比速度更重要时,基于中间代码和即使编译的语言可能是一种可行的这种方案。这包括C#、Visual Basic 以及最好的Java 实现。然而,这些语言的缺点时运行时框非常庞大,而每次运行程序时都必须加载该框架。加载框架和编译程序的时间有可能比执行程序所要的时间还长。而且运行时框架所消耗的资源可能比运行程序本身还多。程序使用这样的框架,对于简单的任务例如按下按钮或者移动鼠标,有时会有难以接受的长响应时间。当速度很关键时就应该避免使用.Net framework。
毫无疑问,使用完全编译的代码可以获得最快的执行速度。编译语言包括C、C++、D、Pascal、Fortan 以及其它几种非著名语言。由于一些原因,我更喜欢C++。一些非常好的编译器和优化的函数库都支持C++。C++ 是一种先进的高级语言(advancd high-level language),具有其他语言中少见的丰富的高级特性。但是C++ 还将低级的C 语言作为一个子集,因此可以进行低层次的优化。多数C++ 编译器都支持生成汇编语言,这对于检查编译器对代码的优化程度非常有用。此外,当最高级别的优化是必要的时候,大多数 C++ 编译器允许类似会汇编的函数指令、内联汇编或者易于链接汇编语言模块。C++ 编译器存在于所有主流平台,在这个意义上,C++ 语言是可移植的。Pascal 相对于C++ 具有很多优势。但是不是很通用。Fortran 也相当有效率,但是语法相当的过时。
由于有强大的开发工具可用,C++ 开发非常高效。Microsoft Visual Studio 是一种非常流行的开发工具。这个工具可以使用C++ 的两种不同实现,直接编译和基于.NET framework公共语言运行时的中间代码。显然,当速度很重要时,直接编译的版本更受青睐。
C++ 的一个重要缺点与安全性相关。它没有对数组越界、整数溢出以及无效指针的检查。这些检查的缺失使得代码执行速度比那些拥有这些检查的编程语言更快。由于程序规则无法排除这些错误情况,这使得程序员有责任对这些错误进行显示的检查。后面将会有关于这些检查的指导。
当性能优化具有很高优先级时,C++ 绝对时首选的编程语言。与其他编程语言相比,性能上的提升是相当可观的。当性能对最终的用户很重要时,在开发时间上可能会有的细微提升相对于性能提升所获得的收益,是说的过去的。
由于其他一些原因,可能需要基于中间代码的高级框架,但是部分代码仍需要仔细优化。在这种情况下,混合实现可能是一个可行的解决档案。代码中最重要的部分可以由基于编译的C++ 或者汇编语言实现,而剩余的部分包括用户界面等,可以使用高级框架实现。被优化的代码部分可以被编译为动态链接库(DLL),供其他代码调用。这不是一个最佳的解决档案,因为高级框架任然消耗大量的资源,而这两种代码之间的转换也会产生额外耗费 CPU 时间的消耗。但是当对时间要求高的部分可以完全包含在 DLL 中时,这种解决方案也可以显著的提高性能。
另一个值得考虑的选择是D语言。D语言 具有Java 和C++ 的许多特性,同时避免了很多C++ 的缺点。而且,D语言 编译成的二进制代码可以与C 或者C++ 代码链接在一起,但是D语言 的 IDE 和编译器没有C++ 的开发的好。
2.5 编译器的选择
市面上有几种不同的C++ 编译器可供选择。很难预测哪一个编译器对于一段特定的代码可以做到最佳的优化。每一个编译器都会做一些非常聪明和非常愚蠢的事情。下面将列举一些常见的编译器。
Microsoft Visual Studio
这是一个非常友好的编译器,具有许多特性。完整的版本非常昂贵,但是有限制的非商业版本是免费的。Visual Studio 可以为 . Net框架 构建代码,也可以直接编译代码(编译时不使用公共语言运行时,CLR,生成二进制代码)。支持 32位和 64位 Windows。集成开发环境(IDE)支持多种编程语言的分析和调试。支持多核处理的 OpenMP 指令。Visual Studio 的优化相当好,但它不是最好的。
Borland/CodeGear/Embarcadero C++ builder
它的 IDE 具有很多和 VS 相同的特性,只支持 32位Windows。不支持最新的指令集。优化做的没有 Microsoft、Intel 和 Gnu 的编译器好。
Intel C++ compiler (parallel composer)
Intel 编译器没有它自己的 IDE。它可以作为 VS 和 Eclipse 的插件。当使用命令行或者 make 工具时,它也可以作为一个独立的编译器。支持 32位和 64位 的Windows和Linux,也支持基于 Intel 的Mac OS 和 Itaniumx系统。Intel 编译器支持向量指令、自动矢量化、OpenMP 和自动并行化。支持 CPU 调度,为不同的 CPU生成不同版本的代码。在所有的平台上,对于内联汇编都有非常好的支持,使得在Windows 和Linux 上使用相同的内联汇编语法成为可能。编译还提供了一些具有最佳优化的数学函数库。
Intel 编译器最重要的缺点是:它编译的代码在AMD 和VIA 的处理器上运行的较慢或者根本不运行。可以通过绕过所谓的 CPU分派机制 来避免这个问题,该分派机制检查代码是否运行在Intel CPU 上。请参考 13.7 Intel 编译器中的 CPU分派
就代码可以从它众多的优化特性中受益和可以移植到众多平台上的来说,Intel 编译器是一个很好额选择。
Gnu
虽然对用户不够友好,但这是可以使用的最佳编译器之一。它是免费并且开源的。它支持大多数Linux 发行版本、BSD、Mac OS X,无论是 32位的还是 64位的。支持 OpenMP、自动并行化和自动矢量化。Gnu 的函数库至今还没有被完全优化过。同时支持AMD 和Intel 的向量数学库(vector math libraries)。Gnu C++ 编译器可以在众多的平台上使用,包括 32位和 64位的Linux、BSD、Windows 以及Mac。对于所有的平台来说Gnu 编译器都是一个非常不错的选择。它是使用命令行运行的独立编译器,但是可以用于很多 IDE,包括Eclipse、NetBeans、CodeBlocks 和BloodShed。
Clang
Clang 编译器基于LLVM(Low Level Virtual Machine)。它和Gnu 编译器在很多方面都相似,并与Gnu 编译器高度兼容。这是Mac 平台上最常用的编译器,也支持Linux 和Windows 平台。对于所有的平台Clang编译器都是一个不错的选择。它可以和Eclipse IDE 一起使用。
PGI
该编译器支持 32位和 64位的Windows、Linux 和Mac。支持并行编程、OpenMP 和自动矢量化。优化做的相当不错。但是向量指令的效率很低。
Digital Mars
这是一个便宜的编译器,用于32位 Windows,包含 IDE。优化做的不是很好。
Open Watcom
另一个32位的 Windows 开源编译器。默认情况下不符合标准的调用约定,优化做的很不错。
Codeplay VectorC
一个 32位的 Windows 商业编译器。可以集成到 Microsoft Visual Studio IDE 中。显然已经不再更新了。可以做自动矢量化。优化功能中等水平。支持三种不同的目标文件格式。
总结
在没有 IDE 的情况下,所有这些编译器都可以作为命令行版本使用。商业编译器有免费的试用版本提供。
在Linux 平台 上,通常可以混合来自不同编译器的目标文件(Object File),在某些情况下,也可以在 Windows 平台 上也可以。Microsoft 和Intel 的Windows编译器在目标文件级别上完全兼容,而Digital Mars 编译器基本上与它们兼容。Embarcadero、Codeplay 和Watcom 编译器在目标文件级别上与其他编译器不兼容。
为了良好的代码性能,我建议在 Unix 应用程序中使用 Gnu、Clang 或 Intel 编译器,在 Windows 应用程序中使用 Gnu、Clang、Intel 或 Microsoft 编译器。如果你希望你的代码在 AMD 微处理器上高效运行,请不要使用 Intel 编译器。
编译器的选择可能在某些情况下由兼容遗留代码,IDE 具体的参数选择,调试工具,简单的 GU I开发,数据库集成 web应用程序集成,混合语言编程等要求决定。如果所选择的编译器不提供最好的优化,在这种情况下,使用不同的编译器生成最关键模块可能是非常有帮助的。在大多数情况下,如果包含必要的库文件,那么由Intel 编译器生成的目标文件可以毫无问题地链接到使用Microsoft 或Gnu 编译器生成的项目中。或者,使用最好的编译器生成DLL,并从使用另一个编译器构建的项目中调用它。
2.6 函数库的选择
有些应用程序将大部分执行时间花在执行库函数上。耗时的库函数通常属于以下类别之一:
- 文件输入/输出
- 图形和声音处理
- 内存和字符串操作
- 数学函数
- 加密,解密和数据压缩
大多数编译器都包含用于这些目的的标准库。不幸的是,标准库并不总是完全优化的。
库函数通常是许多用户在许多不同应用程序中使用的一小段代码。因此,与优化特定于应用程序中的代码相比,值得在优化库函数方面投入更多的精力。最好的函数库是使用汇编语言 和自动 CPU分派 以及最新的指令集扩展 高度优化的。
如果分析显示在某个特定应用程序中函数库占用了大量 CPU 时间,或者如果这是显而易见的,那么可以通过使用不同的函数库来显著的提高性能。如果应用程序在库函数中花费了大部分时间,那么除了寻找最有效的库和节省库函数调用之外,可能不需要优化其他任何地方。建议尝试不同的库,看看哪个最好。
下面将讨论一些常见的函数库。还有许多用于特殊目的的库。
Microsoft
微软编译器自带。有些函数优化得很好,有些则没有。支持 32位和 64位 Windows。
Borland / CodeGear / Embarcadero
Borland C++ builder自带。未针对SSE2 和后续指令集进行优化。只支持32位 Windows。
Gnu
Gnu 编译器自带。没有像编译器本身优化的好。64位版本比 32位版本好。Gnu 编译器经常插入内置代码,而不是最常见的内存和字符串指令。内置代码不是最优的。使用选项-fno-builtin 可以迫使编译器使用库版本来替代内置版本。Gnu 库支持 32位和 64位Linux 和BSD。当前的Windows 可使用版本还不是最新的。
Mac
Mac OS X (Darwin)上 Gnu 编译器中包含的库是 Xnu 项目的一部分。在所谓的 commpage 中,操作系统内核中包含了一些最重要的函数。这些功能针对Intel Core 和稍后的Intel 处理器版本进行了高度优化。AMD 处理器和早期的英特尔处理器根本不被支持。只能在 Mac 平台上运行。
Intel
Intel 编译器包含标准函数库。还有一些特殊用途的库,如“Intel Math Kernel Library”和 “ntegrated Performance Primitives”。这些函数库针对大型数据集进行了高度优化。然而,英特尔的库在 AMD 和 VIA 处理器上并不能总是运行良好。有关解释和可能的解决方法,请参见后面的章节。支持所有 x86 和 x86-64 平台。
AMD
AMD Math core library 包含优化过的数学函数。它也适用于英特尔处理器。性能不如Intel库。支持 32位和 64位Windows和Linux。
AsmLib
我自己的函数库是,是为了演示而创建的。可以从www.agner.org/optimize/asmlib.zip获得。目前包括内存和字符串函数的优化版本,以及其他一些很难在其他地方找到的函数。在最新的处理器上运行时,比大多数其他库都要快。支持所有 x86 和 x86-64 平台。
Test | Processor | Microsoft | CodeGear | Intel | Mac | Gnu 32位s | Gnu 32位s-fno-builtin | Gnu 64位s-fno-builtin | Asmlib |
---|---|---|---|---|---|---|---|---|---|
memcpy 16kB aligned operands |
Intel Core 2 | 0.12 | 0.18 | 0.12 | 0.11 | 0.18 | 0.18 | 0.18 | 0.11 |
memcpy 16kB unaligned op. |
Intel Core 2 | 0.63 | 0.75 | 0.18 | 0.11 | 1.21 | 0.57 | 0.44 | 0.12 |
memcpy 16kB aligned operands |
AMD Opteron K8 | 0.24 | 0.25 | 0.24 | n.a. | 1.00 | 0.25 | 0.28 | 0.22 |
memcpy 16kB unaligned op. |
AMD Opteron K8 | 0.38 | 0.44 | 0.40 | n.a. | 1.00 | 0.35 | 0.29 | 0.28 |
strlen 128 bytes |
Intel Core 2 | 0.77 | 0.89 | 0.40 | 0.30 | 4.5 | 0.82 | 0.59 | 0.27 |
strlen 128 bytes |
AMD Opteron K8 | 1.09 | 1.25 | 1.61 | n.a. | 2.23 | 0.95 | 0.6 | 1.19 |
表中的数字是每字节数据的核心时钟周期(低数字意味着良好的性能)。对齐的操作数意味着源和目标的地址都可以被16整除。
用于测试库的版本(不是最新的)
- Microsoft Visual studio 2008, v. 9.0
- CodeGear Borland bcc, v. 5.5
- Mac: Darwin8 g++ v 4.0.1.
- Asmlib: v. 2.00
- Intel C++ compiler, v. 10.1.020. 使用库libircmt.lib 中的
_intel_fast_memcpy
和__intel_new_strlen
函数。函数名没有记录。
2.7 用户界面框架的选择
典型软件项目中的大多数代码都用于用户界面。不需要大量计算的应用程序很可能在用户界面上花费的 CPU 时间比在程序的基本任务上花费的还要多。
程序员很少从头开始编写自己的图形用户界面。这不仅浪费了程序员的时间,也给最终用户带来了不便。出于可用性的考虑,菜单、按钮、对话框等应该尽可能地标准化。程序员可以使用操作系统附带的标准用户界面元素或编译器和开发工具附带的库。
Microsoft Foundation Classes 是一个流行的 Windows C++ 用户界面库(MFC)。与之竞争的产品是 Borland 现已停止继续维护的Object Windows Library(OWL)。Linux 系统有几个可用的图形界面框架。用户界面库可以作为运行时 DLL 或静态库链接。除非多个应用程序同时使用同一个 DLL,运行时DLL 比静态库占用更多的内存资源。
用户界面库可能比应用程序本身更大,需要更多的时间来加载。一个轻量级的替代方案是Windows Template Library(WTL)。WTL 应用程序通常比 MFC 应用程序更快、更紧凑。由于糟糕的文档、缺乏高级开发工具,WTL 应用程序可能会花费更多的时间去开发。
通过放弃使用图形用户界面并使用控制台模式程序,可以获得最简单的用户界面。控制台模式程序的输入通常在命令行或输入文件中指定。输出到控制台或文件。控制台模式的程序是快速、紧凑和易于开发的。方便移植到不同的平台,因为它不依赖于系统特定的图形界面调用。可用性可能很差,因为它缺少图形用户界面的自解释菜单。控制台模式程序对于从其他应用程序(如实现工具库)调用非常有用。
结论是,用户界面框架的选择必须是开发时间、可用性、程序紧凑性和运行时间之间的折衷。没有一个通用的解决方案对所有应用程序都是最好的。
2.8 克服C++语言的缺点
虽然 C++ 在优化方面有很多优点,但它也有一些缺点,这使得开发人员不得不选择其他编程语言。本节将讨论在选择C++ 进行优化时如何克服这些缺点。
可移植性
C++ 是完全可移植的,因为它的语法在所有主要平台上都是完全标准化和受支持的。然而,C++ 也是一种允许直接访问硬件接口和系统调用的语言。这些当然是系统特有的。为了方便在平台之间进行移植,建议将用户界面代码和其他系统特定部分放在一个单独的模块中,并将代码的任务特定部分(应该是与系统无关的)放在另一个模块中。
整数的大小和其他硬件相关细节取决于硬件平台和操作系统。详情见 7.2 整型变量和运算符。
开发时间
一些开发人员认为特定的编程语言和开发工具比其他语言和开发工具使用起来更快。虽然有些区别仅仅是习惯的问题,但确实有些开发工具具有强大的功能,可以自动完成许多琐碎的编程工作。通过一致的模块化和可重用类,可以降低 C++ 项目的开发时间并提高可维护性。
安全性
C++ 语言最严重的问题与安全性有关。标准C++ 的实现没有检查数组边界违规和无效指针。这是C++ 程序中常见的错误来源,也是黑客可能的攻击点。有必要遵守某些编程原则,以防止在涉及安全性的程序中出现此类错误。
无效指针的问题可以通过使用引用代替指针,通过初始化指针为 0,通过将指针指向的对象无效时将指针设置为 0 来避免,还可以通过避免指针算术和指针类型转换来避免。通常使用指针的链表和其他数据结构可以使用更高效的容器类模板替代,如9.7 容器类所述。避免使用scanf
函数。
数组越界可能是C++ 程序错误的最常见原因。对数组边界外的赋值操作,可能会重写其他变量,更糟糕的是,它可能会重写定义数组的函数的返回地址。这会导致各种奇怪和意想不到的行为。数组通常用作存储文本或输入数据的缓冲区。缺少对输入数据缓冲区溢出的检查是黑客经常利用的一个常见错误。
防止此类错误的一个好方法是使用经过良好测试的容器类来替换数组。标准模板库(STL)是此类容器类的一个有用来源。不幸的是,许多标准容器类以一种低效的方式来使用动态内存分配。有关如何避免动态内存分配的示例,请参见9.6 动态内存分配。有关高效容器类的讨论,请参见9.7 容器类。www.agner.org/optimize/cppexamples.zip上本手册的附录含带有边界检查和各种高效容器类的数组示例。
文本字符串尤其有问题,因为字符串的长度可能没有特定的限制。在字符数组中存储字符串的老式C 风格方法快速有效,但不安全,除非在存储之前检查每个字符串的长度。这个问题的标准解决方案是使用 string
类,例如 string
或 CString
。这是安全且灵活的,但在大型应用程序中效率非常低。每次创建或修改字符串时,string
类都会分配一个新的内存块。这可能会导致内存碎片化,并涉及高成本的堆管理和垃圾收集。一个不影响安全性的更有效的解决方案是将所有字符串存储在一个内存池中。有关如何在内存池中存储字符串,请参见附录中的示例(参见www.agner.org/optimize/cppexamples.zip)。
整数溢出是另一个安全问题。官方的C 标准说,在溢出的情况下,有符号整数的行为是“未定义的”。这允许编译器忽略溢出或假设它没有发生。在Gnu 编译器的情况下,假设不发生带符号整数溢出的不幸后果是,它允许编译器优化掉溢出检查。对于这个问题,有许多可能的补救措施:(1)在溢出前进行检查,(2)使用无符号整数 —— 它们是保证回绕(wrap around),(3)使用选项 -ftrapv 捕获整数溢出,但这是非常低效的,(4)使用选项 -Wstrict-overflow = 2,对这样的优化进行警告,(5)使用选项 -fwrapv 或 -fno-strict-overflow 明确定义溢出行为。
在代码中速度很重要的关键部分,你可能会偏离上述安全建议。如果不安全的代码仅限于经过良好测试的函数、类、模板或模块,并且与程序的其余部分有定义良好的接口,那么这是被允许的。
3 找到消耗时间最多的地方
3.1 一个时钟周期是多长?
在本手册中,我使用 CPU 时钟周期而不是秒或微秒来作为时间度量单位。这是因为不同计算机有不同的速度。今天,如果我写下的某个任务需要 10μs,那么在下一代的电脑,它可能只需要 5μs,而我的手册将很快被淘汰。但是如果我写下某事需要 10个时钟周期,即使 CPU 时钟频率加倍,那么它仍然需要 10个时钟周期。
时钟周期的长度是时钟频率的倒数。例如,如果时钟频率是 2GHz,那么时钟周期的长度是:
1 | $$ |
一台计算机上的时钟周期并不总是可以与另一台计算机上的时钟周期相比较。奔腾4 (NetBurst) CPU 的被设计为具有比其他 CPU 更高的时钟频率,但是总的来说,在执行同一段代码时,它比其他 CPU 耗费更多的时钟周期。
假设程序中的一个循环重复1000次,循环中有100个浮点运算(加法、乘法等)。在 2GHz CPU 的上,如果每个浮点运算需要5个时钟周期,然后我们可以大致估计,循环将1000 100 5 * 0.5 ns = 250μs。我们应该尝试优化这个循环吗?当然不!250μs 小于 1/50 的时间刷新屏幕。用户不可能看到延迟。但是在这个循环中还存在另一个循环,另一个循环也重复 1000次,那么我们估计计算时间为 250毫秒。这种延迟的时间足够长,足以引起注意,但也不够长,足以令人厌烦。我们可能决定做一些测量,看看我们的估计是否正确,或者计算时间是否实际超过 250毫秒。如果响应时间太长,用户实际上必须等待结果,那么我们将考虑是否有可以改进的地方。
3.2 使用分析器查找热点(hot spots)
在开始优化任何东西之前,必须先识别程序的关键部分。在一些程序中,超过 99% 的时间花在最内部的循环中进行数学计算。在其他程序中,99% 的时间花在读取和写入数据文件上,只有不到 1% 的时间花在实际操作这些数据上。优化重要部分的代码,而不是优化只占总时间的一小部分的代码,这一点非常重要。优化代码中不太重要的部分不仅会浪费时间,还会使代码不太清晰,更难于调试和维护。
大多数编译器包都包含一个分析器,它可以告诉我们每个函数被调用的次数和时间。也有第三方分析器,如AQtime、Intel VTune 和AMD CodeAnalyst。
有几种不同的分析方法:
- 植入:编译器在每次函数调用时插入额外的代码,以计算调用函数的次数和时间。
- 调试:分析器在每个函数或每一行代码中插入临时调试断点。
- 基于时间的采样:分析器告诉操作系统生成一个中断,例如每毫秒一次。分析器会统计在程序的每个部分中发生中断的次数。这不需要修改被测程序,但可靠性较差。
- 基于事件的采样:分析器告诉 CPU 在某些事件上生成中断,例如每发生1000次缓存不命中。这使得查看程序的哪个部分有最多的缓存丢失、分支错误预测、浮点异常等等成为可能。基于事件的采样需要基于 CPU 的分析器。对于Intel CPU 使用Intel VTune,对于 AMD CPU 使用AMD CodeAnalyst。
不幸的是,分析器通常是不可靠的。它们有时会给出误导的结果,或者完全因为技术问题而失败。分析器的一些常见问题是:
- 粗糙的时间分辨率。如果时间是以毫秒级的分辨率测量的,如果关键函数的执行需要几微秒,那么测量可能变得不精确,或者干脆为零。
- 执行时间过短或过长。如果被测试的程序在短时间内完成,那么采样生成的数据太少,无法进行分析。如果程序执行时间太长,那么采样生成的数据太多,超出分析器的分析能力。
- 等待用户输入。许多程序将大部分时间用于等待用户输入或网络资源。这个时间包含在分析文件中。为了使分析可行,可能需要修改程序以使用一组测试数据而不是用户输入。
- 来自其他过程的干扰。分析器不仅测量被测试程序中所花费的时间,而且还测量在同一台计算机上运行的所有其他进程(包括分析器本身)所使用的时间。
- 函数地址在优化后的程序中是模糊的。分析器通过地址识别程序中的所有热点,并尝试将这些地址转换为函数名。但是,高度优化的程序经常以这样一种方式重新组织:函数名和代码地址之间没有明确的对应关系。内联函数的名称对于分析器可能根本不可见。其结果将是关于哪些函数花费的时间最多的误导性报告。
- 使用调试版本的代码。一些分析器要求你正在测试的代码包含调试信息,以便识别单个函数或代码函数。代码的调试版本没有被优化。
- 在 CPU 内核之间跳转。进程或线程不一定停留在多核 CPU 上的同一处理器内核中,但事件计数器可以。这导致在多个 CPU 内核之间跳转的线程的事件计数没有意义。你可能需要通过设置线程关联掩码将线程锁定到特定的 CPU 内核。
- 再现性差。程序执行中的延迟可能是由不可重现的随机事件引起的。诸如任务切换和垃圾收集之类的事件可以在随机时间发生,这使得程序的某些部分看起来比正常情况下花费的时间更长。
有多种方法可以替代分析器。一个简单的替代方法是在调试器中运行程序,并在程序运行时按下break。如果有一个热点,占用 90%的 CPU 时间,那么中断有 90%的机会发生在这个热点。重复中断几次可能足以确定一个热点。在调试器中使用调用堆栈来识别热点周围的情况。
有时,识别性能瓶颈的最佳方法是将度量工具直接放入代码中,而不是使用现成的分析器。这虽然不能解决与概要分析相关的所有问题,但通常会提供更可靠的结果。如果你不满意分析器的工作方式,那么你可以将所需的测量仪器插入程序本身。你可以添加计数器变量来计算程序的每个部分执行了多少次。此外,你可以读取程序中每个最重要或关键部分前后的时间,以度量每个部分所花费的时间。有关此方法的进一步讨论,请参阅 16 测试速度。
你的测量代码应该包含 #if
指令,以便可以在代码的最终版本中禁用它。在代码中插入自己的分析工具,是在程序开发过程中跟踪程序性能的一种非常有用的方法。
如果时间间隔很短,时间测量可能需要很高的分辨率。在 Windows 中,你可以使用 GetTickCount
或 QueryPerformanceCounter
函数获得毫秒级的分辨率。使用 CPU 中的时间戳计数器可以获得更高的分辨率,它以 CPU 时钟频率计数(在 Windows 中: __rdtsc()
)。
如果线程在不同的 CPU 内核之间跳转,时间戳计数器将会失效。在时间度量期间,你可能必须将线程固定到特定的CPU核心,以避免这种情况。(在Windows 中是SetThreadAffinityMask
,在Linux 中是sched_setaffness
)。
程序应该用一组真实的测试数据进行测试。测试数据应该具有典型的随机性,以便获得真实数量的缓存丢失和分支错误预测的情况。
当发现程序中最耗时的部分时,重要的是将优化工作集中在耗时的部分上。关键代码片段可以使用16 测试速度中描述的方法进行进一步测试和研究。
分析器对查找出与 CPU 密集型代码相关的问题最有帮助。但是许多程序在加载文件或访问数据库、网络和其他资源时所花费的时间比算术运算要多。下面几节将讨论程序中最常见的耗时部分。
3.3 安装程序
安装程序包所需的时间通常不被认为是软件优化问题。但这肯定会占用用户的时间。如果软件优化的目标是为了用户节省时间,那么安装软件包并使其正常工作所花费的时间是不能忽略的。由于现代软件的高度复杂性,安装过程花费一个多小时是很正常的。为了找到并解决兼容性问题,用户必须多次重新安装软件包,这种情况也很常见。
软件开发人员在决定软件包是否使用需要安装许多文件的复杂框架时,应该考虑安装时间和兼容性问题。安装过程应该始终使用标准化的安装工具。需要可以在开始时选择所有安装的选项,以便在无人参与的情况下继续安装过程的其余部分。卸载也应该以标准化的方式进行
3.4 自动更新
许多软件程序通过互联网定期自动下载更新。有些程序在每次计算机启动时都会搜索更新,即使该程序从未被使用过。安装了许多这类程序的计算机需要花费几分钟才能启动,这完全是在浪费用户的时间。其他一些程序在每次启动时搜索更新。如果当前版本满足用户的需求,则用户可能不需要更新。搜索更新应该是可选的,应该默认是关闭的,除非有令人信服的安全理由进行更新。更新过程应该在低优先级线程中运行,并且只有在程序实际使用时才运行。任何程序在不使用时,都不应该在后台进程中运行。下载的程序更新的安装应该延迟到程序关闭并重新启动。
操作系统的更新尤其耗时。有时安装操作系统的自动更新需要几个小时。这是非常有问题的,因为这些耗时的更新可能在不方便的时候出现。如果用户在离开工作场所之前出于安全原因必须关闭或注销计算机,并且系统禁止用户在更新过程中关闭计算机,那么这将是一个非常大的问题。
3.5 程序加载
很多时候,加载一个程序要比执行它花费更多的时间。对于基于大型运行时框架、中间代码、解释器、即时编译器等的程序,加载时间可能会非常长,这是使用Java, C#, Visual Basic 等编程语言编写的程序的常见情况。
但是,即使是用编译的C++ 实现的程序,加载程序也会耗费时间。如果程序使用大量运行时 DLL(动态链接的库或共享对象)、资源文件、配置文件、帮助文件和数据库,通常会发生这种情况。当程序启动时,操作系统可能不会加载一个大程序的所有模块。有些模块可能只在需要时加载,或者在 RAM 大小不足时将其交换到硬盘。
用户希望对简单的操作(如按键或鼠标移动)立即作出响应。如果因为它需要从磁盘加载模块或资源文件,导致响应延迟了几秒钟,这对于用户来说是不可接受的。使用大量内存的应用程序会迫使操作系统将内存交换到磁盘。内存交换是鼠标移动或按键等简单操作的响应时间长得不可接受的常见原因。
避免大量的 DLL,配置文件、资源文件、帮助文件等,分散再硬盘的不同地方。几个文件、最好和.exe 文件在同一个路径下,这样是可以接受的。
3.6 动态链接和位置无关的代码
函数库可以是静态链接库(\.lib,*.a),或动态链接库,也称为共享对象(*.dll,* . so)。有几个因素可以使动态链接库比静态链接库慢。这些因素将在*14.11 静态库 VS 动态库中详细解释。
位置无关代码用于类 Unix 系统中的共享对象。默认情况下,Mac 系统在任何地方都使用与位置无关的代码。位置无关的代码效率很低,尤其是在 32位模式下,原因如14.11 静态库 VS 动态库中所述。
3.7 文件存取
读取或写入硬盘上的文件通常比处理文件中的数据花费更多的时间,特别是如果用户有一个病毒扫描程序,扫描所有要访问的文件。
文件的顺序向前访问比随机访问快。读或写大块比一次读或写一小块文件更快。一次读写不要少于几千字节。你可以将整个文件复制到内存缓冲区中,并在一个操作中读写它,而不是以非顺序的方式读写几个位。通常,访问最近访问过的文件要比第一次访问快得多。这是因为文件已经复制到磁盘缓存。
远程或可移动媒体(如软盘和u盘)上的文件可能不会被缓存。这可能会产生非常戏剧性的后果。我曾经编写过一个Windows程序,该程序通过调用 WritePrivateProfileString
来创建一个文件,它每写一行就会打开和关闭一次文件。由于磁盘缓存,这在硬盘上工作得非常快,但是将文件写入软盘需要几分钟。
包含数字数据的大文件如果以二进制形式存储比以ASCII格式存储的文件更紧凑和高效。二进制数据存储的一个缺点是它不可读,并且不容易移植到具有大端存储的系统中。
在具有许多文件输入/输出操作的程序中,优化文件访问比优化CPU使用更重要。如果处理器在等待磁盘操作完成时可以执行其他工作,那么将文件访问放在一个单独的线程中可能会有好处。
3.8 系统数据库
在 Windows 中访问系统数据库可能需要几秒钟时间。与 Windows 系统中的大型注册数据库相比,将特定于应用程序的信息存储在单独的文件中更有效。注意,如果使用 GetPrivateProfileString
和WritePrivateProfileString
等函数读写配置文件(*.ini 文件),系统可能会将信息存储在数据库中。
3.9 其他数据库
许多软件应用程序使用数据库来存储用户数据。数据库会消耗大量的 CPU 时间、RAM 和磁盘空间。在简单的情况下,可以用普通的旧数据文件替换数据库。数据库查询通常可以通过使用索引、使用集合而不是循环等方式进行优化。优化数据库查询超出了本手册的范围,但是你应该知道,优化数据库访问通常可以获得很多好处。
3.10 图形
图形用户界面可能使用大量的计算资源。通常会使用特定的图形框架。操作系统可以在其 API 中提供这样的框架。在某些情况下,在操作系统 API 和应用程序软件之间有一个额外的第三方图形框架层。这样一个额外的框架会消耗大量额外的资源。
应用软件中的每个图形操作都通过调用图形库或 API 函数的函数调用实现的,然后这些函数调用设备驱动程序。对图形函数的调用非常耗时,因为它可能经过多个层,并且需要切换到受保护模式并再次返回。显然,对绘制整个多边形或位图的图形函数进行一次调用要比通过多次函数调用分别绘制每个像素或线条更有效率。
计算机游戏和动画中图形对象的计算当然也是很耗时的,特别是在没有图形处理单元的情况下。
各种图形函数库和驱动程序的性能差别很大。对于使用哪一种更好,我没有具体的建议。
3.11 其它系统资源
对打印机或其他设备的最好是一次写入大块内容,而不是每次一小块,因为对驱动程序的每次调用都涉及到切换到受保护模式并再次返回的开销。
访问系统设备和使用操作系统的高级工具可能会很耗时,因为它可能涉及到加载几个驱动程序、配置文件和系统模块。
3.12 访问网络
一些应用程序使用Internet或内部网络进行自动更新、远程帮助文件、数据库访问等。这里所存在的问题是访问时间无法控制。在简单的测试配置中,网络访问可能会很快,但在网络过载或用户远离服务器的使用情况下,网络访问可能很慢或完全无法访问。在决定是在本地还是远程存储帮助文件和其他资源时,这些问题应该被列入考量之中。如果需要频繁更新,那么最好在本地映射远程数据。访问远程数据库通常需要使用密码登录。对于许多辛勤工作的软件用户来说,登录是一个恼人的耗时过程。在某些情况下,如果网络或数据库负载过重,登录过程可能需要一分钟以上。
3.13 访问内存
与对数据进行计算所需的时间相比,从 RAM 内存访问数据需要相当长的时间。这就是所有现代计算机都有内存缓存的原因。通常,一级数据缓存为 8 - 64Kb,二级缓存为 256Kb - 2Mb。计算机中也有可能还存在一个三级缓存。
如果程序中所有数据的总和大于二级缓存,并且分散在内存中或以非顺序方式访问,那么内存访问可能是程序中最耗时的地方。如果变量在内存缓存中,读写它只需要 2 - 3 个时钟周期,如果不缓存,则需要几百个时钟周期。关于数据存储,请参考 7.1 不同类型变量的存储;关于内存缓存,请参考 9 优化内存访问。
3.14 上下文切换
上下文切换是多任务环境中不同任务之间的切换,多线程程序中不同线程的切换,或大型程序中的不同部分的切换。频繁的上下文切换会降低性能,因为数据缓存的内容,代码缓存、分支目标缓冲区、分支模式历史等都可能需要更新。
如果分配给每个任务或线程的时间片很小,上下文切换将会更频繁。时间片的长度由操作系统决定,而不是由应用程序。
在具有多个 CPU 或具有多核 CPU 的计算机中,上下文切换的数量会更少。
3.15 依赖链
现代微处理器可以乱序(错序)执行。这意味着如果软件指定 A 和 B 的计算,而 A 的计算速度较慢,则微处理器可以在计算完 A 之前开始计算 B。显然,这只有在计算 B 不需要 A 的值时才有可能。
为了利用乱序执行的优势,必须避免长依赖链。依赖链是一系列的计算,其中每个计算依赖于前一个计算的结果。这会妨碍 CPU 同时执行多个计算或者导致混乱。有关如何打破依赖关系链的示例,请参见 11 乱序执行。
3.16 执行单元的吞吐量
延迟和执行单元的吞吐量之间有一个重要的区别。例如,在现代 CPU 上执行浮点加法可能需要 3 - 5 个时钟周期。但每个时钟周期可以开始一个新的浮点加法。这意味着,如果每个加法依赖于前一个加法的结果,那么每三个时钟周期只有一个加法。但是,如果所有的加法都是独立的,那么每个时钟周期可以有一个加法。
在计算密集型的程序中,如果没有上面提到的耗时代码占主导地位,并且没有很长的依赖链,则性能才可能达到最高。在这种情况下,性能受到执行单元吞吐量的限制,而不是延迟或内存访问的限制。
现代微处理器的执行核心分为几个执行单元。通常,有两个或多个整数单元、一个或两个浮点加法单元和一个或两个浮点乘法单元。这意味着可以同时进行整数加法、浮点加法和浮点乘法。
因此,进行浮点运算的代码最好能够平衡加法和乘法。减法和加法使用相同的执行单位。除法需要更长的时间。在浮点操作之间执行整数操作而不降低性能是可能的,因为整数操作使用不同的执行单元。例如,执行浮点运算的循环通常使用整数运算来递增循环计数器、比较循环计数器与其极限等。在大多数情况下,可以假设这些整数操作不会增加总计算时间。
4 性能和可用性
更好的软件产品是可以为用户节省时间的产品。对于许多计算机用户来说,时间是一种宝贵的资源,许多时间浪费在速度慢、难于使用、不兼容或容易出错的软件上。所有这些问题都是可用性问题,我认为应该从更广泛的可用性角度来看待软件性能。
这不是一本关于可用性的手册,但是我认为有必要在这里引起软件编程人员注意一些影响软件使用效率的常见障碍。有关这个主题的更多信息,请参见我在 Wikibooks 上提供的免费电子书Usability for Nerds。
下面的列表指出了一些让软件用户感到沮丧和浪费时间的典型原因,以及软件开发人员应该注意的一些重要的可用性问题。
大型运行时框架。.NET 框架和Java 虚拟机框架通常比它们运行的程序占用更多的资源。这些框架是资源问题和兼容性问题的常见来源,它们在框架本身的安装过程中、在框架下运行的程序的安装过程中、在程序启动过程中以及在程序运行过程中都会浪费大量时间。使用这种运行时框架的主要原因是为了跨平台的可移植性。不幸的是,跨平台兼容性并不总是如预期的那么好。我相信通过更好地标准化编程语言、操作系统和 API,可以更有效地实现可移植性。
内存交换。软件开发人员通常拥有比最终用户拥有更多 RAM 的,功能更强大的计算机。因此,开发人员可能看不到过多的内存交换和其他资源问题,对于最终用户,这些问题将会导致使用大量资源的应用程序表现很差。
安装问题。程序的安装和卸载过程应该标准化,由操作系统而不是由单独的安装工具来完成。
自动更新。如果网络不稳定或新版本有旧版本中不存在的问题,则软件的自动更新可能会导致问题。更新机制经常会弹出一些烦人的弹出消息,比如请安装这个重要的新更新,或者在用户在忙于重要工作时,告诉它们要重启计算机。更新机制不应该中断用户,而应该只显示一个独立的图标,表示有更新的可用,或者在计算机重新启动时自动更新。软件分销商经常滥用更新机制来宣传其软件的新版本。这对用户来说很烦人。
兼容性问题。所有软件都应该在不同的平台、不同的屏幕分辨率、不同的系统颜色设置和不同的用户访问权限上进行测试。软件应该使用标准的 API 调用,而不是自定义的修改和直接访问硬件。应该使用现成的协议和标准化的文件格式。Web 系统应该在不同的浏览器、不同的平台、不同的屏幕分辨率等环境中进行测试。应遵守可访问性指南。
复制保护。一些复制保护方案是基于违反或规避操作系统标准的黑客攻击。这种方案是兼容性问题和系统崩溃的常见根源。许多复制保护方案都是基于硬件识别的。当硬件更新时,这种方案会导致问题。大多数的复制保护方案都让用户感到厌烦,并且阻止合法备份的复制,却没有阻止非法的复制。应该权衡复制保护方案的好处和在可用性和必要支持上付出的成本。
硬件更新。更改硬盘或其他硬件通常要求重新安装所有软件,并且还会丢失用户设置。花上整个工作日或者更多时间重新安装的情况也很常见。许多软件应用程序需要有更好的备份功能,当前的操作系统需要更好的硬盘复制支持。
安全。具有网络访问权限的软件易受病毒攻击和其他滥用的弱点,可能使用户付出极其昂贵的代价。而防火墙、病毒扫描器和其他保护手段是造成兼容性问题和系统崩溃的最常见原因。此外,病毒扫描器比计算机上的其他任何东西都要花费更多的时间,这种情况并不少见。作为操作系统一部分的安全软件通常比第三方安全软件更可靠。
后台服务。许多在后台运行的服务对用户来说是不必要的,是对资源的浪费。考虑只在用户激活时运行服务。
过多的特性。由于市场原因,软件通常会向每个新版本添加新特性。这可能会导致软件速度变慢或需要更多的资源,即使用户从不使用过这些新特性。
认真对待用户反馈。用户的抱怨应该被视为关于 bug、兼容性问题、可用性问题和所需新特性的有价值的信息来源。系统地处理用户反馈,确保信息得到合理利用。用户应该得到关于问题调查和解决方案计划的回复。可以从网站上方便的获得补丁。
5 选择最优算法
要优化 CPU 密集型软件,首先要找到最佳算法。算法的选择对于排序、搜索和数学计算等任务非常重要。在这种情况下,选择最好的算法比优化想到的第一个算法,你可以得到更多的提升。在某些情况下,你可能需要测试几种不同的算法,以便找到在一组典型测试数据上最有效的算法。
话虽如此,我必须提醒凡事不要过度。如果一个简单的算法可以足够快地完成这项工作,就不要使用高级和复杂的算法。例如,一些程序员甚至使用哈希表来存储很小的数据列表。对于非常大的数据库,哈希表可以显著地提高搜索时间,但是对于使用二分搜索甚至线性搜索的都可以很快完成的列表,就没有理由使用它。哈希表增加了程序的大小和数据文件的大小。如果瓶颈是文件访问或缓存访问,而不是 CPU 时间,这反而会降低效率。复杂算法的另一个缺点是,它使程序的开发成本更高,而且更容易出错。
对于不同目的的不同算法的讨论超出了本手册的范围。你必须查阅关于标准任务(如排序和搜索)的算法和数据结构的一般文献,或针对更复杂的数学任务的特定文献。
在开始编写代码之前,你可以考虑其他人是否已经在你之前完成了这项工作。针对许多标准任务的优化函数库有许多种来源。例如,Boost包含许多常用的经过良好测试的库。“Intel Math Kernek Library”包含许多函数用于常见的数学计算,包括线性代数和统计学,以及“Intel Performance Primitives”库包含许多含函数用于音频和视频处理,信号处理、数据压缩和密码学(www.intel.com)。如果你正在使用Intel 函数库,确保在非Intel 处理器上可以运作良好(参见 13.7 Intel 编译器中的 CPU分派)。
在开始编程之前选择最优算法通常说起来容易做起来难。许多程序员已经发现,只有在他们将整个软件项目放在一起并对其进行测试之后,才有更好的方法来处理问题。通过测试和分析程序性能以及研究瓶颈获得的见解,可以更好地理解问题的整个结构。这种新的见解可以导致程序的完全重新设计,例如,当你发现有更好的方法来组织数据时。
对一个已经投入使用的的程序进行彻底的重新设计当然是一项相当大的工作,但这可能是一个相当好的投资。重新设计不仅可以提高性能,还可以得到更易于维护的、结构良好的程序。实际上,你花在重新设计程序上的时间可能比你花在解决原来设计糟糕的程序的问题上的时间要少。
6 开发过程
关于使用哪种软件开发过程和软件工程原则存在着相当多的争论。我不打算推荐任何具体的模型。相反,我将对开发过程如何影响最终产品的性能发表一些评论。
在规划阶段,最好对数据结构、数据流和算法进行全面的分析,以预测哪些资源是最关键的。然而,在早期规划阶段可能有许多未知的因素,因此很难对问题进行详细的概述。在后一种情况下,你可以将软件开发工作视为一个学习过程,其中主要的反馈来自于测试。在这里,你应该为多次迭代的重新设计做好准备。
一些软件开发模型具有严格的形式,它要求在软件的逻辑架构中有几个抽象层。你应该知道,这种形式主义有其固有的性能成本。将软件分割成过多的抽象层是降低性能的常见原因。
由于大多数开发方法本质上都是增量的或迭代的,所以一定要有一种策略来保存每个中间版本的备份。对于单人项目,制作每个版本的 zip文件就足够了。对于团队项目,建议使用版本控制工具。
7 不同C++结构的效率
大多数程序员对于如何将一段程序代码转换成机器码以及微处理器如何处理这些代码,了解很少或者根本不了解。例如,许多程序员不知道双精度运算和单精度运算一样快。谁会知道模板类比多态类更高效呢?本章旨在解释不同C++ 语言元素的相对效率,以帮助程序员选择最有效的替代方案。本系列手册的其他卷进一步解释了理论背景。
7.1 不同类型变量的存储
变量和对象存在内存的存储位置,取决于它们在C++程序中是如何声明的。这会影响数据缓存的效率(参见第89页,TODO)。如果数据在内存中随机分布,则数据缓存的效率很低。因此,理解变量是如何存储的非常重要。对于简单的变量、数组和对象,存储原则是相同的。
栈存储(Storage on the stack)
函数中声明的变量和对象存储在栈中,但以下描述的几种情况除外。
栈是内存的一部分,以先入后出的方式组织。它用于存储函数返回地址(即函数是从哪里调用的)、函数参数、局部变量,以及保存在函数返回之前必须恢复的寄存器。每次调用函数时,它都会为所有这些目的在栈上分配所需的空间。当函数返回时释放该内存空间。下一次调用函数时,新函数的参数可以使用相同的空间。
因为重复使用相同范围的地址,栈是用来存储数据效率最高的内存空间了。如果没有很大的数组,那么这一部分数据基本是缓存在一级缓存中的,而这里的访问速度是非常快的。
我们从中可以学到的教训是,所有变量和对象最好在使用它们的函数中声明。
通过在{}
中声明变量,可以使变量的作用域更小。然而,大多数编译器在函数返回之前不会释放变量所使用的内存,即使在退出声明变量的{}
时可以释放内存。如果变量存储在寄存器中(见下文),那么它可能在函数返回之前被释放。
全局或静态存储(Global or static storage)
在任何函数之外声明的变量称为全局变量。可以在任何函数中访问它们。全局变量存储在内存的静态部分中。静态内存还用于使用static
关键字声明的变量、浮点常量、字符串常量、数组初始化器列表、switch
语句跳转表和虚拟函数表。
静态数据区域通常分为三部分:一部分用于程序从不修改的常量,一部分用于程序可能会修改的初始化变量,另一部分用于程序可能会修改的未初始化变量。
静态数据的优点是可以在程序启动之前将其初始化为所需的值。缺点是内存空间在整个程序执行过程中都被占用,即使变量只在程序的一小部分中使用。这会降低数据缓存的效率。
如果可以避免,就不要定义全局变量。不同线程之间的通信可能需要全局变量,但这是惟一不可避免的情况。如果一个变量被多个不同的函数访问,或者你希望避免将变量做为函数的参数的额外开销,那么让它成为全局变量可能是有用的。但是,将需要访问相同变量的函数作为同一个类的成员函数并在类中保存共享的变量可能是一个更好的方案。当然喜欢用哪一种方案是编程风格的问题。
通常最好将查找表声明为static
,例如
1 | // Example 7.1 |
在这里使用 static
的优点是在调用函数时不需要初始化这个数组。当程序加载到内存中时,这些值就会被放在那里。如果将上面的示例中的 static
去掉,那么每次调用函数时都必须重新将所有这五个值放入数组中。这是通过将整个列表从静态内存复制到堆栈内存来完成的。在大多数情况下,从静态内存中复制常数数据到栈是在浪费时间。但这在特殊情况下可能是最优的:在循环中多次使用数据,而一级缓存已经被很多数组占用了,这时你可能会想把数据保存在栈上。
字符串常量和浮点常量在优化后的代码中,被存储在静态内存中。例如:
1 | // Example 7.2 |
在这里,常量3.5
将会被存储在静态内存中。大多数编译器将识别出这两个常量是相同的,因此只需要存储一个常量。将整个程序中所有相同的常量会被在一起,以最小化常量使用的缓存空间。
整数常量通常包含在指令代码中。你可以假设整数常量不存在缓存问题。
寄存器存储(Register storage)
有限数量的变量可以存储在寄存器中而不是主存中。寄存器是 CPU 中用于临时存储的一小块内存。存储在寄存器中的变量可以被快速访问。所有优化编译器都会自动选择一个函数中最常用的变量存储在寄存器中。同一个寄存器可以用于多个变量,只要它们的使用(生存周期)不重叠。
寄存器的数量非常有限。在 32 位操作系统中,大约有 6 个整数寄存器可用于一般用途,而在 64 位系统中,有 14 个整数寄存器。
浮点变量使用不同类型的寄存器。32位操作系统中有 8个浮点寄存器,64位操作系统中有 16个浮点寄存器。一些编译器很难在 32位模式下生成浮点寄存器变量,除非启用了 SSE2 指令集(或更高版本)。
易变变量(Volatile)
volatile
关键字用于声明一个变量可变被其它线程改变。这可以阻止编译器依赖变量始终具有代码中先前分配的值的假设来进行优化。例如:
1 | // Example 7.3. Explain volatile |
在本例中,DelayFiveSeconds
函数将等待,直到另一个线程将秒数增加到5。如果 seconds
没有声明为 volatile
,那么具有优化功能的编译器将假设秒在 while
循环中保持为零,因为循环中没有任何东西可以更改该值。循环 while(0 < 5){}
,将是一个无限循环。
要注意到 volatile
并不意味着原子性(atomic
)。它不会阻止两个线程同时改变变量。如果上面示例中的代码试图在其他线程增加 seconds
的同时将 seconds
置为零,那么它可能会失败。更安全的实现是只读取 seconds
的值,并等待该值改变五次。
线程本地存储(Thread-local storage)
大多数编译器可以使用关键字 __thread
或 __declspec(thread)
来实现静态变量和全局变量的线程本地存储。这样的变量对于每个线程都有一个实例。线程本地存储是低效的,因为它是通过存储在线程环境块中的指针进行访问的。如果可能的话,应该避免线程本地存储,并将其替换为栈上的存储(参见上文)。存储在栈中的变量总是属于创建它们的线程。
Far
具有分段内存的系统,如DOS 和16位Windows,允许使用关键字 far
(数组也可以用 huge
声明)将变量存储在其它数据段中。far
存储、far
指针和 far
过程是低效的。如果一个程序在一个段(内存)中有太多的数据,那么建议使用允许更大段的不同操作系统( 32位或 64位系统)。
动态内存分配
动态内存分配由 new
和delete
运算符或malloc
和free
函数完成。这些运算符和函数消耗大量时间。内存中称为堆的一部分保留给动态分配。当以随机顺序分配和释放不同大小的对象时,堆很容易变得碎片化。堆管理器可以花费大量时间清理不再使用的空间并搜索空闲空间。这称为垃圾收集。按顺序分配的对象不一定按顺序存储在其中。当堆变的碎片化时,它们可能分散在不同的地方。这使得数据缓存效率低下。
动态内存分配还会使代码更复杂,更容易出错。程序必须保留指向所有已分配对象的指针,并跟踪它们何时不再使用。重要的是,在程序流的所有可能的情况下,也要释放所有分配的对象。不这样做是一个常见的导致错误的原因,称为内存泄漏。更糟糕的一种错误是在释放对象之后访问该对象。程序逻辑可能需要额外的开销来防止此类错误。
有关使用动态内存分配的优点和缺点的进一步讨论,请参见9.6 动态内存分配。
一些编程语言,如 Java,所有对象都使用动态内存分配。这当然是低效的。
类中声明的变量
类中声明的变量按照它们在类声明的顺序存储。存储类型由类的对象是在哪里定义的来决定。类、结构或联合的对象可以使用上面提到的任何存储方法。除了在最简单的情况下,对象不能存储在寄存器中,但是它的数据成员可以复制到寄存器中。
带有 static
修饰符的类成员变量将存储在静态内存中,并且只有一个实例。同一类的非静态成员将存储在该类的每个实例中。
将变量存储在类或结构中是一种很好的方法,可以确保在程序的相同部分中使用的变量也存储在彼此附近。使用类的优点和缺点见7.19 结构体和类。
7.2 整型变量和运算符
整数大小
整数可以是不同的大小,可以有符号也可以无符号。下表总结了可用的不同整数类型。
delaration | size, bits | minimum value | maximum value | in stdint.h |
---|---|---|---|---|
char |
8 | -128 | 127 | int8_t |
shortint in 16-bit system: int |
16 | -32768 | 32767 | int16_t |
int in 16-bit system: long int |
32 | -2^31 | 2^31 -1 | int32_t |
long long or int64_t MS compiler:__int64 64位 Linux: long int |
64 | -2^63 | 2^63 -1 | int64_t |
unsigned char |
8 | 0 | 255 | uint8_t |
unsigned short int in 16-bit system: unsigned int |
16 | 0 | 65535 | uint16_t |
unsigned int in 16-bit system: unsigned long |
32 | 0 | 2^32 -1 | uint32_t |
unsigned long long or uint64_t MS compiler: unsigned __int64 64位 Linux: unsigned long int |
64 | 0 | 2^64 -1 | uint64_t |
Table 7.1 Sizes of different integer types
不幸的是,对于不同的平台,声明特定大小的整数的方式是不同的,如上表所示。如果标准头文件 stdin .h
或 inttypes.h
可用,则建议使用标准头文件,以可移植的方式定义特定大小的整数类型。
无论大小如何,整数运算在大多数情况下都是很快的。但是,使用大于最大可用寄存器大小的整数大小是低效的。换句话说,在 16位系统中使用 32位整数或在 32位系统中使用 64位整数效率会很低,特别是在代码涉及乘法或除法的情况下。
如你定义一个 int
类型,而不指定该类型的大小,编译器将始终选择效率最高的整数大小。较小大小的整数(char
,short int
)的效率稍微低一些。在许多情况下,编译器在进行计算时将这些类型转换为默认大小的整数,然后只使用结果中较低的 8位或 16位。你可以假设类型转换需要 0 或 1 个时钟周期。在 64位系统中,只要不进行除法,32位整数和 64位整数的效率之间只有极小的差别。
建议在与大小无关且没有溢出风险的情况下使用默认整数大小,例如简单变量、循环计数器等。在大型数组中,为了更好地使用数据缓存,最好使用对于特定用途来说足够大的最小整数大小。大小不同于 8、16、32和 64位的位域(Bit-fields
)效率较低。在 64位系统中,如果应用程序可以利用额外的位,那么可以使用 64位整数。
无符号整数类型 size_t
在 32位系统中是 32位,在 64位系统中是 64位。当你希望确保永远不会发生溢出时(即使是对于大于 2GB的数组),它可以被用于数组大小和数组索引。
在考虑特定整数大小是否足够大以满足特定用途时,必须考虑中间计算是否会导致溢出。例如,在表达式$a = (bc)/d$中,即使$a$、$b$、$c$和$d$都低于最大值,也可能发生($bc$)溢出。这里没有对整数溢出的自动检查。
有符号整数 VS 无符号整数
在大多数情况下,使用有符号整数和无符号整数在速度上没有区别。但在一些情况下会有一些区别:
- 除以常数:当你将一个整数除以一个常数时,无符号要快于有符号(参见14.5 整数除法)。这也适用于模运算符
%
。 - 对于大多数指令集,有符号整数比无符号整数转换成浮点数要快(参见14.8 浮点数和整数相互转换:将整数转换成浮点数)。
- 有符号变量和无符号变量的溢出行为不同。无符号变量的溢出会得到一个较小的正数。带符号变量的溢出没有被正式定义。正常的行为下,正溢出将会变为负值,但是编译器可以基于不会发生溢出的假设,优化掉依赖于溢出的分支。
有符号整数和无符号整数之间的转换是无代价的。这仅仅是对相同的位进行不同的解释。负整数在转换为无符号时将被解释为一个非常大的正数。
1 | // Example 7.4. Signed and unsigned integers |
在例 7.4中,我们将 a
转换为无符号整数,以使除法更快。当然,这只在确定 a
永远不会为负的情况下有效。最后一行是将 a
隐式地转换为 double
然后乘以常数 2.5
,结果也是 double
类型的。在这里我们希望 a
是有符号的。
确保不要在比较中混合有符号整数和无符号整数,例如 <
。比较有符号整数和无符号整数的结果是模糊的,可能会产生不希望的结果。
整数运算符
整数运算通常非常快。在大多数微处理器上,简单的整数操作(如加减、比较、位操作和移位操作)只需一个时钟周期。
乘法和除法需要更长的时间。在奔腾4处理器上,整数乘法需要 11 个时钟周期,在大多数其他微处理器,需要 3 - 4 个时钟周期。整数除法需要 40 - 80 个时钟周期,具体取决于微处理器。整数除法在 AMD 处理器上整数位数越小速度越快,但在英特尔处理器上不会这样。关于指令延迟的详细信息列在手册4:“Instruction tables”中。关于如何加速乘法和除法的技巧,请分别参考14.4 整数乘法和14.5 整数除法。
自增和自减运算符
增量(前缀)运算符 ++i
和增量(后缀)运算符 i++
和加法一样快。当仅用于递增整数变量时,使用递增前或递增后都没有区别。效果完全相同。例如,for (i=0; i<n; i++)
和for (i=0; i<n; ++i)
是一样的。但是当使用表达式的结果时,效率可能会有所不同。例如,x = array[i++]
比 x = array[++i]
速度更快,因为在后一种情况下,数组元素的地址的计算必须等待 i
的新值,这将使 x
的可用性延迟大约两个时钟周期。显然,如果将增量(前缀)更改为增量(后缀),则必须调整i
的初始值。
还有一些情况下,增量(前缀)比增量(后缀)更有效率。例如,在 a = ++b
的情况下;编译器会在这条语句之后识别出 a
和 b
的值是相同的,这样它就可以对两者使用相同的寄存器,而表达式 a = b++
,将使 a
和 b
的值不同,这样它们就不能使用相同的寄存器。
这里所说的关于递增运算符的所有内容也适用于整数变量上的递减运算符。
7.3 浮点变量和运算符
x86 家族中的现代微处理器有两种不同类型的浮点寄存器,相应地也有两种不同类型的浮点指令。每种类型都有各有优缺点。
进行浮点运算的原始方法涉及到将8个浮点寄存器组成一个寄存器栈(register stack)。这些寄存器具有长双精度(80位)。使用寄存器栈的优点是:
- 所有的计算都是长双精度的。
- 不同精度之间的转换不需要额外的时间。
- 对于数学函数,如对数函数和三角函数,有一些指令可用。
- 代码很紧凑,在代码缓存中占用的空间很小。
寄存器堆栈也有缺点:
- 由于寄存器堆栈的组织方式,编译器很难生成寄存器变量。
- 浮点数比较较慢,除非使用奔腾-II或者更新的指令集。
- 整数和浮点数之间的转换效率很低。
- 当使用长双精度时,除法、平方根和数学函数需要更多的时间。
还有一种新的浮点运算方法涉及8个或16个向量寄存器(XMM或YMM),可用于多种用途。浮点运算以单精度或双精度进行,中间结果的计算精度始终与操作数相同。使用向量寄存器的优点是:
- 浮点寄存器变量很容易实现。
- 矢量运算可用于对XMM 寄存器中两个双精度或四个单精度变量的矢量进行并行计算(参见12 使用向量操作)。如果AVX 指令集是可用的,那么在YMM 寄存器中每个向量可以容纳4个双精度或8个单精度变量。
缺点是:
- 不支持长双精度。
- 在运算数具有混合精度的表达式的计算需要精确的转换指令,这可能非常耗时(参见14.7 不要混合使用 float 和 double)。
- 数学函数必须使用函数库,但这通常比硬件函数更快。
浮点栈寄存器在所有具有浮点功能的系统中都可用(64位Windows 的设备驱动程序除外)。XMM 向量寄存器可以在64位系统中使用,也可以在启用SSE2 或更高的指令集时在 32位系统中使用(单精度只需要SSE )。如果处理器和操作系统支持AVX 指令集,则可以使用YMM 寄存器。有关如何测试这些指令集的可用性,请参见13 为不同指令集生成多个版本的关键代码。
当XMM 寄存器可用时,大多数编译器都会使用它进行浮点计算,例如在 64位系统中,或者启用SSE2 指令集时。很少有编译器能够混合这两种类型的浮点运算,并为每种计算选择最优的类型。
在大多数情况下,双精度运算不会比单精度运算花费更多的时间。当使用浮点寄存器时,单精度和双精度的速度没有差别。长双精度只需要稍多一点的时间。单精度的除法、平方根和数学函数的计算速度都快于双精度。当使用XMM 寄存器时,加、减、乘等操作的速度,无论精度如何,在大多数处理器上仍是相同的(未使用向量操作)。
如果对应用程序有利,可以使用双精度,而不必太担心成本。如果你有大数组,并且希望将尽可能多的数据放入数据缓存,则可以使用单精度。如果可以利用向量操作,单精度就很好,如12 使用向量操作所述。
根据微处理器的不同,浮点加法需要3 - 6个时钟周期。乘法需要 4 - 8 个时钟周期。除法需要 14 - 45 个时钟周期。当使用浮点栈寄存器时,浮点比较效率不高。在使用浮点堆栈寄存器时,浮点或双精度浮点到整数的转换需要很长时间。
当使用XMM 寄存器时,不要混合使用单精度和双精度。参见14.7 不要混合使用 float 和 double。
如果可能的话,避免整数和浮点变量之间的转换。见14.8 浮点数和整数相互转换。
在XMM 寄存器中长生浮点浮点数向下溢出的应用程序,可以从flush-to- zero
模式而不是在向下溢出的情况下生成非规格化数(subnormal number)中获益:
1 | // Example 7.5. Set flush-to-zero mode (SSE): |
强烈建议设置为 flush-to-zero
模式,除非有特殊原因需要使用非规格化数。此外,如果SSE2 可用,你可以设置为 denormars-are-zero 模式:
1 | // Example 7.6. Set flush-to-zero and denormals-are-zero mode (SSE2): |
有关数学函数的更多信息,请参阅14.10 数学函数和12. 7 用于向量的数学函数。
7.4 枚举
enum
只是一个隐藏的整数。枚举的效率和整数一样。注意,枚举数(值名)将与具有相同名称的任何变量或函数冲突。因此,头文件中的枚举应该具有长且唯一的枚举数名称,或者将其放在命名空间中。
7.5 布尔值
布尔操作数的顺序
布尔运算符 &&
和 ||
的操作数将会按照下面的顺序就行计算。如果 &&
的第一个操作数为 false
,那么就不会计算第二个操作数的值,因为表达式的结果无论第二个操作数的值是 true
还是 false
,结果都为 false
。类似的,如果 ||
的第一个操作数为 true
,那么也不会计算第二个操作数的值,因为无论如何结果都为 true
将通常为 true
的操作数放在 &&
表达式的最后,或者作为 ||
表达式的第一个操作数中,这可能是有好处的。例如,假设 a
在 50% 的情况下为真,b在 10% 的情况下为真。当 a
为真时,表达式a && b
需要对 b
求值,即 50% 的情况。等价表达式 b && a
只需要在 b
为 true
时对 a
求值,只有 10% 的情况。如果 a
和 b
的计算时间相同,并且分支预测机制预测的可能性相同,这样的计算速度会更快。有关分支预测的解释,请参见7.12 分支及 switch 语句。
如果一个操作数的可预测性比另一个更好,那么将最可预测的操作数放在前面。
如果一个操作数的计算速度快于另一个操作数,则将计算速度最快的操作数放在第一位。
但是,在交换布尔操作数的顺序时必须小心。如果操作数的求值有副作用,或者如果第一个操作数决定第二个操作数是否有效,则不能交换操作数。例如:
1 | // Example 7.7 |
在这里,你不能交换操作数的顺序,因为当 i
不小于 ARRAYSIZE
时,表达式 list[i]
是无效的。另一个例子:
1 | // Example 7.8 |
布尔变量被过度检查(overdetermined)
布尔变量存储在 8位整数中,0
代表 false
, 1
代表 true
。
由于所有以布尔变量作为输入的运算符都要检查输入是否有除0
或1
之外的值,因此布尔变量会被过度检查。但是以布尔值作为输出的运算符只能产生 0 或 1。这使得使用布尔变量作为输入的操作效率低于可能的效率。比如说:
1 | // Example 7.9a |
编译器通常是以以下方式实现:
1 | bool a, b, c, d; |
这当然远远不是最优的。为了防止错误的预测,这些分支可能耗费很长的时间(详见7.12 分支及 switch 语句)。如果知道操作数除了 0
就是 1
,布尔运算可以变得有效率的多。编译器之所以不这样假设,是因为如果变量是没有被初始化的或者是来自其它未知来源的。如果 a
和 b
被初始化为有效的值,或者他们来自输为布尔值的运算符,那么上述代码是可以被优化的。优化后的代码类似下面这样:
1 | // Example 7.9b |
在这里我使用 char
(或 int
)来代替 bool
,是为了使用位运算符(&
和|
)来代替布尔运算符(&&
和||
)。位运算符是单条指令,只需一个时钟周期。即使 a
和 b
不是 0 或 1,OR
运算符(|
)也可以正常工作。如果操作数有不是 0 或 1 的值,AND
(&
)运算符和EXCLUSIVE OR
运算符(^
)可能会给出不一致的结果。
请注意到这里有几个陷阱(pitfalls)。你不能使用 ~
代替 NOT
。相反,如果已知变量的值为 0
或 1
,你可以对变量 XOR
上 1
,来对变量进行取反操作(Boolean NOT)。
1 | // Example 7.10a |
可以被优化为:
1 | // Example 7.10b |
如果当 a
为 false
时,表达式不应该被计算的话,你不能使用 a&b
代替 a&&b
。类似的,如果当 a
为 true
时,表达式不应该被计算的话,不能用 a|b
代替 a||b
。
使用位运算符的技巧在操作数是变量而不是比较表达式等其它情况时,更有优势。例如:
1 | // Example 7.11 |
上述形式在大多数情况下通常是最优的。不要把 &&
改成 &
,除非你希望 &&
表达式产生很多错误的分支预测。
布尔向量操作:
一个整数可以被当作布尔向量使用。例如,如果 a
和 b
是 32位整数,那么表达式 y=a&b
,将会在一个时钟周期中进行32个与操作。运算符 &
,|
,^
,~
在进行布尔向量操作时都非常常用。
7.6 指针和引用
指针 VS 引用
指针和引用的效率是一样的,因为它们实际上做的事情是相同的。例如:
1 | // Example 7.12 |
这两个函数做的是相同的事情,如果你查看编译器生成的代码时,你会注意到这两个函数的代码完全相同,区别仅仅在于编程风格。使用指针的优点是:
- 当你查看上面的函数体时,可以清楚地看到
p
是一个指针,但是不清楚r
是一个引用还是一个简单的变量。使用指针使读者更清楚地了解正在发生的事情。 - 可以用指针做引用不可能做的事情。你可以改变指针指向什么,你可以用指针做算术运算。
使用引用的优点是:
- 使用引用时语法更简单。
- 引用比指针更安全,因为在大多数情况下,它们肯定指向一个有效的地址。如果指针没有初始化,或者指针算术计算超出了有效地址的范围,亦或是指针被转换为错误的类型,指针可能无效并导致致命错误。
- 对于复制构造函数和重载运算符来说,引用更加常用。
- 被声明为常量引用的函数参数接受表达式作为参数,而指针和非常量引用则需要一个变量。
效率
通过指针或引用访问变量或对象可能与直接访问它一样快。拥有这种效率的原因在于微处理器的构造方式。函数中声明的所有非静态变量和对象都存储在堆栈中,并且实际上是相对于栈指针寻址的。同样,我们知道在C++ 中。类中声明的所有非静态变量和对象都可以通过已知的隐式指针 ‘this’
进行访问。因此,我们可以得出这样的结论:结构良好的C++ 程序中的大多数变量实际上都是通过指针这种方式访问的,这就是效率相同的原因。然而,使用指针和引用也有缺点。最重要的是,它需要一个额外的寄存器来保存指针或引用的值。寄存器是一种稀缺资源,尤其是在 32位模式下。如果没有足够的寄存器,那么指针每次使用时都必须从内存中加载,这会使程序变慢。另一个缺点是指针的值需要几个时钟周期才能访问所指向的变量。
指针算术
指针实际上是一个保存内存地址的整数。因此,指针算术运算和整数算术运算一样快。当一个整数被添加到一个指针时,它的值乘以所指向的对象的大小。例如:
1 | // Example 7.13 |
在这里,p
所增加的值不是 i
而是 i*12
,因为 abc
的大小是12个字节。p
加上 i
的时间等于做乘法和加法的时间的和。如果 abc
的大小是 2 的幂,那么乘法可以被移位运算代替,移位运算要快得多。在上面的示例中,通过向结构体中添加一个整数,abc
的大小可以增加到 16 字节。
递增或递减指针不需要乘法,只需要加法。比较两个指针只需要一个整数比较,这是很快的。计算两个指针之间的差值需要一个除法,这是很慢的,除非所指向的对象类型的大小是 2 的幂(关于除法,请参阅14.5 整数运算)。
在计算指针的值之后,访问所指向的对象大约需要两个时钟周期。因此,建议在使用指针之前计算该指针的值。例如,x = *(p++)
比 x = *(++p)
更高效,因为在后一种情况下,x
的读取必须在指针 p
被递增后等待几个时钟周期,而在前一种情况下,x
可以在 p
在递增之前被读取。有关递增和递减运算符的更多讨论,请参见 7.2 整型变量和运算符 关于递增和递减运算的讨论。
7.7 函数指针
如果可以预测目标地址,那么通过函数指针调用函数通常要比直接调用函数多花几个时钟周期。如果函数指针的值与上次执行语句时相同,则可以预测目标地址。如果函数指针的值发生了变化,那么目标地址很可能会被错误地预测,从而导致长时间的延迟。有关分支预测,请参见7.12 分支和 switch 语句。如果函数指针的变化遵循一个简单的规则,奔腾M 处理器可能能够预测目标地址,而奔腾4 和 AMD 处理器只要函数指针发生了变化,就一定会做出错误的预测。
7.8 成员指针(Member pointers)
在简单的情况下,数据成员指针只是存储数据成员相对于对象开头的偏移量,而成员函数指针只是成员函数的地址。但是在一些特殊的情况下,比如需要实现复杂的多的多重继承。一定要避免这些复杂的情况。
如果编译器没有关于成员指针所引用的类的完整信息,那么它必须使用最复杂的成员指针实现。例如:
1 | // Example 7.14 |
在这里,定义 MemberPointer
时,编译器除了类 c1
的名称之外,没有其他关于类 c1
的信息。因此,它必须假设这是最坏的情况,并对成员指针进行复杂的实现。可以通过在声明 MemberPointer
之前完整地声明 c1
来避免这种情况。避免多重继承、虚函数 和其他会降低成员指针效率的复杂情况。
大多数C++ 编译器都有不同的选项来控制成员指针的实现方式。如果可能的话,使用尽可能简单的实现选项,并确保对使用相同成员指针的所有模块使用相同的编译器选项。
7.9 智能指针
智能指针是一个行为像指针的对象。它有一个特殊的特性,当指针被删除时,它所指向的对象被删除。智能指针仅用于使用 new
存储在动态分配内存中的对象。使用智能指针的目的是为了确保对象被正确删除,以及在对象不再使用时释放内存。智能指针可以被认为是只包含单个元素的容器。
智能指针最常见的实现是 auto_ptr
和 shared_ptr
。auto_ptr
的特性是,始终只有一个 auto_ptr
拥有所分配的对象,并且通过赋值将所有权从一个 auto_ptr
转移到另一个 auto_ptr
。shared_ptr
则允许多个指针指向同一个对象。
通过智能指针访问对象没有额外的成本。无论 p
是简单指针还是智能指针,通过 *p
或 p->member
访问对象的速度都是一样快的。但是,每当创建、删除、复制或从一个函数转移到另一个函数时,都会产生额外的成本。shared_ptr
的这些成本要高于 auto_ptr
。
当程序的逻辑结构要求一个对象必须由一个函数动态创建,然后由另一个函数删除,并且这两个函数互不相关(不是同一个类的成员)时,智能指针非常有用。如果相同的函数或类负责创建和删除对象,则不需要使用智能指针。
如果一个程序为每个智能指针使用许多动态分配的小对象,那么需要考虑一下这个解决方案的成本是否太高。将所有对象合用到一个容器中可能更有效,最好是使用连续内存。参见9.7 容器类关于容器类的讨论。
7.10 数组
数组是通过在内存中连续存储元素来实现的。没有存储关于数组大小的信息。这使得在 C
和 C++
中使用数组比在其他编程语言中更快,但也更不安全。这个安全问题可以通过定义一个容器类来解决,该类的行为类似于一个带有边界检查的数组,如下例所示:
1 | // Example 7.15a. Array with bounds checking |
www.agner.org/optimize/cppexamples.zip中给出更多关于容器类的例子。
使用上述模板类的数组是通过将类型和大小指定为模板参数来声明的,如下面的例7.15b所示。可以使用方括号索引访问它,就像普通数组一样。构造函数将所有元素设置为零。如果你不希望这个初始化,或者类型 T 是一个具有默认构造函数的类,它会执行必要的初始化,那么可以删除 memset
这一行。编译器可能会报告 memset
已被弃用。这是因为如果参数 size
错误,它会导致错误,但它仍然是将数组设置为 0 的最快方法。如果索引超出范围,[]
运算符将检测到错误(参见14.2 边界检查)。在这里,通过返回空引用这一非常规的方式引发错误消息。如果数组元素被访问,这将在受保护的操作系统中引发错误消息,并且这个错误很容易通过调试器跟踪。你可以用任何其他形式的错误报告来替换这一行。例如,在Windows 中,你可以这么写:FatalAppExitA(0,"Array index out of range");
,更好方法的是创建自己的错误消息函数。
下面的例子演示了如何使用SafeArray
:
1 | // Example 7.15b |
由列表初始化的数组最好是静态的,如7.1节,如全局或静态存储 中所述。数组可以使用 memset
初始化为 0:
1 | // Example 7.16 |
应该按顺序访问多维数组,保证最后一个索引变化最快(最后一个索引用于最内层循环):
1 | // Example 7.17 |
这确保了元素是按顺序访问的。这两个循环的相反顺序将使访问是非顺序的,这将降低数据缓存的效率。当不按顺序索引时,为了使地址的计算更高效,那么除了第一个维度外,所有维度的大小最好是 2 的幂:
1 | // Example 7.18 |
在这里,代码必须计算 (FuncRow(i)*columns + FuncCol(i)) * sizeof(float)
才能找到矩阵元素的地址。在这种情况下,当大小是 2 的幂时,乘上列数的速度更快。在前面的示例中,这不是问题,因为优化编译器可以看到这些行是连续访问的,并且可以通过将行长度添加到前一行的地址来计算每行的地址。
同样的建议也适用于结构体或类对象的数组。如果以非顺序访问元素,则对象的大小(以字节为单位)最好为 2 的幂。
将列数设置为 2 的幂的建议并不总是适用于大于一级数据缓存且以非顺序访问的数组,因为这可能会导致缓存竞争。关于这个问题的讨论请参阅9 优化内存访问。
7.11 类型转换
C++ 语法有几种不同的类型转换方法:
1 | // Example 7.19 |
这些不同的方法有完全相同的效果。使用哪种方法取决于编程风格。下面讨论不同类型相互转换的时间消耗。
signed/unsigned 转换
1 | // Example 7.20 |
有符号整数和无符号整数之间的转换只是使编译器换一种解释整数的不同位的方法。没有检查溢出,代码没有消耗额外的时间。这些转换可以随意使用,而不需要担心任何性能成本。
整型类型大小的转换
1 | // Example 7.21 |
如果整数有符号,则通过扩展符号位将其转换为更大的类型;如果没有符号,则通过扩展 0 将其转换为更大的类型。如果代码是算术表达式,这通常需要一个时钟周期。如果是从内存中的变量中读取值,则类型大小转换通常不需要额外的时间,如例7.22所示:
1 | // Example 7.22 |
将整数转换成较小的类型大小只需忽略较高的位即可。没有溢出检查。例如:
1 | // Example 7.23 |
这种转换不需要额外的时间。它只存储 32 位整数中较低的 16 位。
浮点精度转换
当使用浮点寄存器栈时,浮点、双精度和长双精度之间的转换不需要额外的时间。使用XMM 寄存器时,需要 2 到 15个时钟周期(取决于处理器)。有关寄存器栈与XMM 寄存器的区别,请参见7.3 浮点变量和运算符。例如:
1 | // Example 7.24 |
在本例中,如果使用XMM 寄存器,那么转换的成本很高。为了避免这种情况,a
和 b
应该是同一类型的。进一步讨论请参阅14.7 不要混合使用 float 和 double。
整型转换为浮点型
将有符号整数转换为浮点数或双精度浮点数需要 4 - 16个时钟周期,这取决于处理器和使用的寄存器类型。无符号整数的转换需要更长的时间。如果没有溢出的风险,首先将无符号整数转换为有符号整数会更快:
1 | // Example 7.25 |
整型到浮点型的转换有时可以通过将整数替换为浮点型变量来避免。例如:
1 | // Example 7.26a |
在本例中,可以通过添加一个浮点变量来避免 i
转换为 float
:
1 | // Example 7.26b |
浮点型转换为整型
如果不启用SSE2 或者更新的指令集,浮点数到整数的转换将花费很长的时间。通常,转换需要 50 - 100 个时钟周期。原因是C/ C++ 标准指定了截断,因此浮点四舍五入的模式必须更改为截断并再次返回。如果在代码的关键部分存在浮点数到整数的转换,那么对其采取一些措施是很重要的。可能的解决方案有:
- 使用不同类型的变量避免转换。
- 通过将中间结果存储为浮点数,将转换移出最内层循环。
- 使用 64位模式或启用SSE2 指令集(需要一个支持该模式的微处理器)。
- 使用四舍五入代替截断,并用汇编语言制作一个舍入函数。有关舍入的详细信息,请参见14.8 浮点数和整数相互转换。
指针类型转换
指针可以转换为另一种类型的指针。同样,可以将指针转换为整数,也可以将整数转换为指针。确保整数有足够的位来保存指针是很重要的。
这些转换不会产生任何额外的代码。这仅仅是用不同的方式解释相同的位或者绕过语法检查的问题。
当然,这些转换是不安全的。程序员有责任确保结果是有效的。
重新解释对象
通过类型转换它的地址,编译器可以将一个变量或对象当作另一个不同的类型来处理:
1 | // Example 7.27 |
这里的语法可能看起来有点奇怪。将 x
的地址类型转换为指向整数的指针,然后对该指针取值,以便将 x
作为整数访问。编译器不会生成任何额外的代码来实际创建指针。指针被简单地优化掉了,结果是 x
被当作一个整数。但是,运算符强制编译器将 x
存储在内存中,而不是寄存器中。上面的示例使用 |
运算符设置 x
的符号位,只能应用于整数。这样操作比 x = -abs(x)
更快。
在类型转换指针时,有许多危险的地方需要注意:
- 这个技巧违反了标准 C 的严格的别名规则,规定不同类型的两个指针不能指向相同的对象(
char
指针除外)。优化编译器可以将浮点数和整数表示形式存储在两个不同的寄存器中。你需要检查编译器是否按照你希望的方式运行。使用union
会更安全,如14.9 用整数操作来改变浮点型变量中例 14.23所示。 - 如果将对象视为比实际更大的对象,这个技巧就会失效。如果
int
比float
使用更多的位,上面的代码将会失败。(两者在 x86系统中都使用 32个位)。 - 如果你只访问变量的一部分,例如 64位双精度浮点数中的 32位,那么代码将无法移植到使用大端存储的平台上。
- 如果你以部分访问的方式访问变量,例如,如果你一次操作 64位
double
类型的32位,那么由于 CPU 中的存储转发延迟,代码的执行速度可能会低于预期(参见手册3:“The microarchitecture of Intel, AMD and VIA CPUs”)。
const_cast
const_cast
运算符用于解除 const
对指针的限制。它有一些语法检查,因此比 C 风格的类型转换更加安全,而无需添加任何额外的代码。例如:
1 | // Example 7.28 |
这里 const_cast
运算符的作用是消除 x
上的 const
限制,这是一种解除语法限制的方法,但它不会生成任何额外的代码,也不会花费任何额外的时间。这是确保一个函数可以修改 x
,而其他函数不能修改 x
的有用方法。
static_cast
static_cast
运算符的作用与 C 风格的类型转换相同。例如,它用于将 float
转换为 int
。
reinterpret_cast
reinterpret_cast
运算符用于指针转换。它的作用与 C 风格类型转换相同,只是多了一点语法检查。它不产生任何额外的代码。
dynamic_cast
dynamic_cast
运算符用于将指向一个类的指针转换为指向另一个类的指针。它在运行时检查转换是否有效。例如,当基类的指针转换为派生类的指针时,它检查原始指针是否实际指向派生类的对象。这种检查使得 dynamic_cast
比简单类型的转换更耗时,但也更安全。它可以捕获那些无法检测到的编程错误。
转换类对象
只有当程序员定义了构造函数、重载赋值运算符或重载类型转换运算符(指定如何进行转换)时,才有可能进行涉及类对象(而不是指向对象的指针)的转换。构造函数或重载运算符与成员函数的效率是一样的。
7.12 分支和switch语句
现代微处理器的高速运转是通过一个流水线(pipeline)来实现的,指令在执行之前,会在不同的几个阶段中被提取和解码。然而,流水线结构有一个大问题。当代码有分支时(例如 if-else
),微处理器事先不知道选取这两个分支中哪个分支的数据送入流水线。如果错误的分支被输入到管道中,那么需要 10 - 20 个时钟周期之后才能检测到错误,在这段时间内,它通过获取、解码和推测性地执行指令所做的工作已经被浪费了。结果是,每当微处理器将一个分支送入到管道中,然后发现它选择了错误的分支时,它就会浪费几个时钟周期。
微处理器设计者已经竭尽全力减少这个问题的发生。其中最重要的方法是分支预测。现代微处理器使用先进的算法,根据该分支和附近其他分支的过去历史来预测分支的发展方向。对于不同类型的微处理器,用于分支预测的算法是不同的。这些算法在手册3“The microarchitecture of Intel, AMD and VIA CPUs”中有详细的描述。
在微处理器做出正确预测的情况下,执行分支指令通常需要 0 - 2 个时钟周期。根据处理器的不同,从分支错误预测中恢复所需的时间大约为 12 - 25 个时钟周期。这被称为分支预测错误的惩罚。
如果大多数时候分支是可预测的,那么它们的消耗很少;但是如果预测错误,那么它们的消耗就大了。当然,总是沿着同一方向发展的分支是可预测的。一个分支在大多数情况下是单向的,很少是反向的,只有当它向另一个方向发展时,才会预测错误。一个分支向一个方向走了很多次,然后又向另一个方向走了很多次,只有当它发生变化时才会被错误地预测。如果一个遵循简单周期模式的分支,在一个有很少或没有其它分支的循环中,那么也可以很好地被预测。一个简单的周期模式可以是,例如,一条路走两遍,另一条路走三遍。同样的,两倍第一种方法,三倍另一种方法,等等。最坏的情况是一个分支随机地向一个方向或另一个方向移动,任意方向的概率都是50%。这样的分支有50%的几率会被错误的预测。
for
循环或 while
循环也是一种分支。在每次迭代之后,它决定是重复还是退出循环。如果重复计数很小且始终相同,则通常可以很好地预测循环分支。根据处理器的不同,可以完美预测的最大循环数在 9 到 64 之间变化。嵌套循环只能在某些处理器上得到很好的预测。在许多处理器上,包含多个分支的循环并不能很好地被预测。
switch
语句也是一种分支,它可以有两个以上的分支。如果 case
标签是遵循每个标签等于前一个标签加 1 的序列,在这个时候 switch
语句的效率是最高的,因为它可以被实现为一个目标跳转表。如果 switch
语带有许多标签值,并且彼此相差较大,这将是低效的,因为编译器必须将其转换成一个分支树。
在较老的处理器上,会简单的认为带有顺序标签的 switch
语句的执行分支与上一次相同。因此,无论何时它走了不是上次走的分支,它肯定会被错误地预测。较新的处理器有时能够预测一个 switch
语句,如果它遵循一个简单的周期模式,或者它与前面的分支相关,并且不同目标的数量很少的话。
分支和 switch
语句的数量最好在程序的关键部分控制在较少的水平,特别是在分支的可预测性较差的情况下。如果展开循环可以消除分支,那这可能是有用的,这将在下一段中解释。
分支和函数调用的目标保存在称为分支目标缓冲区的特殊缓存中。如果一个程序中有许多分支或函数调用,那么在分支目标缓冲区中就可能产生竞争。这种竞争的结果是,即使分支具有良好的可预测性,它们也可能被错误地预测。由于这个原因,甚至函数调用也可能被错误地预测。因此,在代码的关键部分具有许多分支和函数调用的程序可能会收到预测错误的影响。
在某些情况下,可以用表查找来替换难以预测的分支。例如:
1 | // Example 7.29a |
?:
运算符在这里就是一个分支。如果它的可预测性很差,那么可以用一个查找表来代替它:
1 | // Example 7.29b |
如果将 bool
变量用作数组索引,那么需要确保它被初始化或来自可靠的源,这非常重要,这样它除了 0 或 1 之外就不会有其他值。见7.5 布尔值:布尔变量被过度检查。
在某些情况下,编译器可以根据指定的指令集,自动使用条件转移来代替分支。
14.1 使用查找表和14.3 使用位运算符一次检查多个值中的例子展示了减少分支数量的各种方法。
手册3:“The microarchitecture of Intel, AMD and VIA CPUs”提供了不同微处理器中分支预测的更多细节。
7.13 循环
循环的效率取决于微处理器对循环控制分支的预测能力。有关分支预测的说明,请参阅前文和手册3:“The microarchitecture of Intel, AMD and VIA CPUs”。一个具有一个较小并且固定的重复计数,没有分支的循环,可以完美地被预测。如上所述,可以预测的最大循环数取决于处理器。只有在某些具有特殊循环预测器的处理器上,嵌套循环才能被很好地预测。在其他处理器上,只能很好地预测最内层的循环。只有在循环退出时,才会错误地预测具有高重复计数的循环。例如,如果一个循环重复 1000次,那么循环控制分支在 1000次中只会出现一次错误预测,因此错误预测的惩罚对总执行时间的影响可以忽略不计。
循环展开
在某些情况下,展开循环可能有很多好处。例如:
1 | // Example 7.30a |
这个循环重复 20 次,交替调用 FuncA
和 FuncB
,然后是 FuncC
。展开两个给出循环得到:
1 | // Example 7.30b |
这么做有三个好处:
i<20
循环控制分支执行 10 次而不是 20 次。- 重复计数已经从 20 减少到 10,这意味着可以在奔腾4上完美地进行预测。
if
分支被消除。
展开循环同样也有缺点:
- 展开循环在代码缓存或micro-op 缓存中占用更多空间。
- 非常小的循环(少于65个字节的代码)在 Core2 处理器上可以执行得更好。
- 如果重复计数为奇数,并将其展开为 2,则必须在循环之外执行额外的迭代。通常,当重复计数不能被展开因子整除时,就会出现这种问题。
只有在能够取得特定好处的情况下,才应该使用循环展开。如果一个循环包含浮点运算,且循环计数器是整数,那么通常可以假设整个计算时间是由浮点代码决定的,而不是由循环控制分支决定的。在这种情况下,展开循环是没有任何好处的。
最好避免在有micro-op 缓存的处理器上展开循环。因为节省mircro-op 缓存的使用非常重要。
如果有利可图的话(见8.1 编译器是如何优化的:循环展开), 编译器通常会自动展开一个循环。程序员不必手动展开循环,除非需要获得特定的优势,例如消除 例 7.30b中的 if
分支。
循环控制条件
最高效的循环控制条件是一个简单的整数计数器。一个具有无序功能的微处理器(参见11 乱序执行)将能够在几个迭代之前评估循环控制语句。
如果循环控制分支依赖于循环内部的计算,则效率较低。下面的示例将以零结束的 ASCII 字符串转换为小写:
1 | // Example 7.31a |
如果字符串的长度是已知的,那么使用循环计数器效率会更高:
1 | // Example 7.31b |
在数学迭代中,循环控制分支依赖于循环内部的计算是一种常见情况,如泰勒展开和牛顿-拉弗森迭代。在这里,需要重复迭代,直到残差小于一定的公差。计算残差的绝对值并将其与公差进行比较所需的时间可能会很长,因此确定最坏情况下的最大重复计数并始终使用此迭代次数的效率会更高。这种方法的优点是微处理器可以提前执行循环控制分支,并在循环内的浮点运算完成之前解决任何分支的错误预测。如果典型的重复计数接近最大重复计数,且每次迭代的残差计算对总计算时间有显著贡献时,则该方法是有好处的。
循环计数器最好是整数。如果循环需要浮点计数器,那么创建一个额外的整数计数器。例如:
1 | // Example 7.32a |
这可以通过添加一个整数计数器并在循环控制条件中使用整数来提升效率:
1 | // Example 7.32b |
注意带有多个计数器的循环中的逗号和分号之间的区别,如例 7.32b所示。for
循环有三个子句:初始化、条件和增量。这三个子句用分号分隔,每个子句中的多个语句用逗号分隔。条件子句中应该只有一个语句。
将整数与零进行比较有时比将其与任何其他数字进行比较的效率更高。因此,将循环计数减少到 0 比将其增加到某个正值 n
要稍微快一些。但如果循环计数器用作数组索引,则不是这样。数据缓存是为向前而不是向后访问数组而优化的。
复制或清除数组
对于诸如复制数组或将数组中的元素全部设置为零这样的琐碎任务,使用循环可能不是最佳选择。例如:
1 | // Example 7.33a |
使用memset
和memcpy
函数通常会更快:
1 | // Example 7.33b |
至少在简单的情况下,大多数编译器会自动使用 memset
和 memcpy
的替换这些循环。显式地使用 memset
和 memcpy
是不安全的,因为如果参数 size
大于目标数组的大小,可能会发生严重地错误。但是如果循环计数太大,同样的错误也会发生在循环中。
7.14 函数
函数调用可能会使程序慢下来,原因如下:
- 函数调用使微处理器跳转到不同的代码地址,然后再返回。这可能需要4个时钟周期。在大多数情况下,微处理器能够将调用和返回操作与其他计算重叠以节省时间。
- 如果代码分散在内存中,那么代码缓存的效率就会降低。
- 在 32 位模式下,函数参数存储在堆栈中。将参数存储在堆栈上并再次读取它们需要额外的时间。如果参数是关键依赖链的一部分,这个延迟是很明显的。
- 需要额外的时间来设置栈帧(stack frame)、保存和恢复寄存器,可能还需要保存异常处理信息。
- 每个函数调用语句需要在分支目标缓冲区(BTB)中占用空间。如果程序的关键部分有许多调用和分支,BTB 中的竞争可能导致分支预测错误。
以下方法可用于减少在程序关键部分中,在函数调用上所花费的时间。
避免不必要的函数
一些编程教科书建议,长度超过几行的每个函数都应该分成多个函数。我不同意这个规则。将一个函数分解成多个更小的函数只会降低程序的效率。仅仅因为一个函数很长就拆分它并不会使得程序更清晰,除非这个函数正在执行多个逻辑上不同的任务。如果可能的话,关键的最内层循环最好完全保留在一个函数中。
使用内联函数
内联函数会像宏一样展开,因此调用该函数的每个语句都会被函数体替换。如果使用了 inline
关键字,或者在类定义中定义了函数的主体,那么函数通常是内联的。如果函数很小,或者只在程序中的一个位置调用它,那么内联函数是有好处的。小函数通常由编译器自动内联。另一方面,在某些情况下,如果内联会导致技术问题或性能问题,编译器可能会忽略对函数内联的请求。
避免在最内层循环中嵌套函数调用
调用其他函数的函数称为帧函数(frame function),而不调用任何其他函数的函数称为叶函数(leaf function)。叶函数比框架函数更高效,原因见第63页(TODO)。如果程序关键的最内层循环包含对帧函数的调用,那么代码有可能通过内联帧函数或使帧函数调用的所有函数内联(把帧函数变为叶函数)来提升效率。
使用宏代替函数
用 #define
声明的宏肯定是内联的。但是要注意,宏的参数每次使用时都会被重新计算。例如:
1 | // Example 7.34a. Use macro as inline function |
在这个例子中,f(x)
或 g(x)
被计算了两次,因为宏引用了它们两次。你可以通过使用内联函数而不是宏来避免这种情况。如果你想让函数可以使用任何类型的参数,那么可以使用模板:
1 | // Example 7.34b. Replace macro by template |
宏的另一个问题是名称不能重载或限制作用区域。宏将干扰具有相同名称的任何函数或变量,而与作用域或命名空间无关。因此,对于宏来说,使用足够长且唯一的名称非常重要,这在头文件中尤其重要。
使用 fastcall
函数
在 32位模式下,关键字 __fastcall
将会改变函数的调用方式,使用寄存器而不是栈来传递前两个整型参数(CodeGear 编译器则是前三个)。这可以提升拥有整型参数的函数的速度。
浮点型参数则不会被 __fastcall
影响。成员函数中隐藏的 'this'
指针也被视为一个参数,所以可能只剩下一个空闲寄存器用于传输其他参数。因此,确保在使用__fastcall
时,最关键的整数参数放在第一位。64位模式下的函数参数默认是使用寄存器传递的。因此,64位模式下无法识别 __fastcalll
关键字。
使函数局部化
应该使同一个模块中使用的函数(即当前.cpp 文件)是局部的。这使得编译器更容易将函数内联,并对函数调用进行优化。有三种方法使一个函数局部化:
- 将关键字
static
添加到函数声明中。这是最简单的方法,但它不适用于类成员函数,在类成员函数中,static
有不同的含义。 - 函数或类放入匿名的命名空间中。
- 在Gnu 编译器中,允许使用
__attribute__((visibility("hidden")))
。
使用全程序优化
一些编译器具有对整个程序进行优化的选项,也可以选择将多个 .cpp 文件组合成一个对象文件。这使得编译器能够在组成程序的所有 .cpp 模块之间优化寄存器分配和参数传递。对于作为目标文件或库文件分发的函数库,不能使用全程序优化。
使用 64位模式
在 64位模式下,参数传递比在 32位模式下更高效,而 64位Linux 比64位Windows 更快。在 64位Linux 中,前 6个整数参数和前 8个浮点参数使用寄存器传递,总计 14个寄存器参数。而在64位Windows 中,前四个参数在寄存器中传递,而不管它们是整数还是浮点数。因此,如果函数有四个以上的参数,64位Linux 比64位Windows 更快。32位Linux和32位Windows 在这个层面上没有差别。
7.15 函数参数
在大多数情况下,函数参数是按值传递的。这意味着参数的值被复制到一个局部变量中。对于 int
、float
、double
、bool
、enum
以及指针和引用等简单类型,这非常快。
数组总是使用指针传递,除非它们被打包在类或者结构体中。
如果参数是复合类型,例如结构体或类,那么情况会更复杂一些。复合类型的参数传递在符合一下几个条件的情况下是最高效的:
- 对象很小,可以装入一个寄存器中。
- 对象没有拷贝构造函数和析构函数。
- 对象没有虚成员。
- 对象没有使用运行时类型识别(RTTI )。
如果这些条件中,有任何一个不满足,那么使用指针或引用来传递对象通常会更快。如果对象很大,那么显而易见,复制整个对象需要时间。当对象复制到参数时,必须调用复制构造函数,如果有析构函数的话,必须在函数返回之前调用析构函数。
将复合对象传递给函数的首选方法是使用 const
引用。const
引用确保原始对象没有被修改。与指针或非 const
引用不同,const
引用允许函数参数为表达式或匿名对象。如果函数是内联的,编译器可以很容易地优化掉 const
引用。
另一种解决方案是使函数成为对象的类(或结构体)的成员,这同样有用。
在 32位系统中,简单的函数参数在栈上传递,但在 64位系统,使用寄存器中传递。后者效率更高。64位Windows 允许在寄存器中传输最多4个参数。64位Unix 系统允许在寄存器中传输最多14个参数( 8个浮点数或双精度数加上 6个整数、指针或引用参数)。成员函数中的 this
指针占用一个参数。手册5:“Calling conventions for different C++ compilers and operating systems”给出了更多的细节。
7.16 函数返回类型
函数的返回类型最好是简单类型、指针、引用或 void。返回复合类型的对象更为复杂,而且常常效率低下。
复合类型的对象只能在最简单的情况下在寄存器中返回。有关何时可以在寄存器中返回对象的详细信息,请参见手册5:“Calling conventions for different C++ compilers and operating systems”。
除了最简单的情况外,复合对象的返回方式是通过一个隐藏指针将它们复制到调用方指定的位置。复制构造函数(如果有的话)通常在复制过程中被调用,而析构函数则在销毁原始对象时被调用。在简单的情况下,编译器可以通过在对象的最终目的地构造对象来避免调用复制构造函数和析构函数的,但是不要依赖这一点。
你可以考虑以下替代方法,而不是返回复合对象:
- 在函数中构造对象。
- 使函数修改一个现有的对象,而不是创建一个新的对象。现有对象可以通过指针或引用传递给函数,或者函数可以是对象的类的成员。
- 使函数返回一个指向函数内部定义的静态对象的指针或引用。这是有效的,但也有风险。返回的指针或引用仅在下一次调用函数并覆盖本地对象(可能在不同的线程中)之前有效。如果忘记将局部对象定义为静态的,那么一旦函数返回,它就会失效。
- 使用
new
在函数中构造一个对象,并返回一个指向它的指针。由于动态内存分配的成本,这是低效的。如果忘记删除对象,此方法还涉及内存泄漏的风险。
7.17 函数尾调用
尾调用是优化函数调用的一种方法。如果函数的最后一条语句是对另一个函数的调用,那么编译器可以用跳转到第二个函数来替换该调用。优化编译器将自动完成此任务。第二个函数不会返回到第一个函数,而是直接返回第一个函数被调用的位置。这样效率更高,因为它消除了返回操作。例如:
1 | // Example 7.35. Tail call |
在这里,通过直接跳到 function2
来消除 function1
的返回。即使有返回值,也可以这样做:
1 | // Example 7.36. Tail call with return value |
尾调用优化只有在两个函数具有相同的返回类型时才有效。如果函数在栈上有参数(在 32位模式下通常是这样),那么这两个函数必须为参数使用相同数量的栈空间。
7.18 递归函数
递归函数是一个调用自身的函数。函数递归调用对于处理递归数据结构非常有用。递归函数的代价是所有参数和局部变量在每次递归时都会有一个新实例,这会占用栈空间。深度递归还会降低返回地址的预测效率。这个问题通常出现在递归深度超过 16 的情况下(参见手册3“The microarchitecture of Intel, AMD and VIA CPUs”中对返回栈缓冲区的解释)。
递归函数调用仍然是处理分支数据树结构最有的效解决方案。较宽的树形结构比较深的树形结构,有更高的递归效率。无分支递归总是可以用循环代替,这样的效率更高。递归函数的一个常见教科书例子是阶乘函数:
1 | // Example 7.37. Factorial as recursive function |
这种实现非常低效,因为 n
的所有实例和所有返回地址都会占用栈上的存储空间。使用循环效率更高:
1 | // Example 7.38. Factorial function as loop |
递归尾调用(尾递归)比其他递归调用更高效,但仍然不如循环快。
初学者有时会调用 main
函数来重启程序。这不是一个好主意,因为每次递归调用 main
函数时,栈都会被所有本地变量的新实例填满。重新启动程序的正确方法是在 main
函数中使用循环。
7.19 结构体和类
现在,编程教科书推荐面向对象编程作为一种使软件更加清晰和模块化的方法。所谓的对象是结构和类的实例。面向对象的编程风格对程序性能既有积极的影响,也有消极的影响。积极的影响是:
- 如果一起使用的变量是相同结构或类的成员,那么它们会被存储在一起。这使得数据缓存更有效率。
- 不需要将类成员的变量作为参数传递给类成员函数。这些变量避免了参数传递的开销。
面向对象编程的负面影响有:
- 非静态成员函数有一个
this
指针,该指针作为隐形参数传递给函数。this
的参数传输开销会在所有非静态成员函数上产生。 this
指针占用一个寄存器。在 32位系统中,寄存器是一种稀缺资源。- 虚成员函数的效率较低(参见7.22 虚成员函数 )。
关于面向对象编程的正面影响还是负面影响占主导地位,还没有一个通用的说法。至少,可以这样说,使用类和成员函数的代价并不大。如果面向对象的编程风格有利于程序的逻辑结构和清晰性,那么你可以使用这种风格,只要你避免在程序最关键的部分调用过多的函数。结构体的使用(没有成员函数的)对性能没有负面影响。
7.20 类的数据成员(变量实例)
类或结构体的数据成员是按创建类或结构实例时声明它们的顺序连续存储。将数据组织到类或结构体中不存在性能损失。访问类或结构体对象的数据成员所花费的时间不比访问简单变量多。
大多数编译器将数据成员对齐到可以被特定数整除的地址以优化访问,不同数据类型的这个数的大小如下表所示:
Type | size,bytes | alignments, bytes |
---|---|---|
bool |
1 | 1 |
char , signed or unsigned |
1 | 1 |
short int , signed or unsigned |
2 | 2 |
int , signed or unsigned |
4 | 4 |
64位 integer, signed or unsigned |
8 | 8 |
pointer or reference, 32位 mode | 4 | 4 |
pointer or reference, 64位 mode | 8 | 8 |
float |
4 | 4 |
double |
8 | 8 |
long double |
8, 10, 12 or 16 | 8 or 16 |
Table 7.2. Alignment of data members
如果结构体或类中的成员的大小不一,这样的对齐会导致未被使用的字节空洞。
1 | // Example 7.39a |
这里,a
和 b
之间有 6 个未使用的字节,因为 b
必须从一个能被 8 整除的地址开始。最后还有 4 个未使用的字节。由于数组中 S1
的下一个实例必须从一个可被 8 整除的地址开始,这样做,方便将其中的 b
成员与 8 对齐。通过将最小的成员放在最后,可以将未使用的字节数减少到 2:
1 | // Example 7.39b |
这样子重新排序使结构体少用了 8个字节,数组占用的空间减少了 800字节。
通过对数据成员的重新排序,结构体和类对象通常可以变得更小。如果类至少有一个虚成员函数,则在第一个数据成员之前或最后一个成员之后有一个指向虚拟表的指针。这个指针在 32位系统中是4字节,在 64位系统中是 8字节。如果你对一个结构体或其每个成员的大小有疑问,那么你可以使用 sizeof
运算符进行一些测试。sizeof
运算符返回的值包括对象末尾未使用的字节。
如果成员相对于结构体或类的开头的偏移量小于 128,则访问数据成员的代码会更紧凑,因为偏移量可以表示为 8位有符号数字。如果相对于结构或类的开头的偏移量是 128字节或更多,那么偏移量必须表示为 32位数字(在 8位到 32位偏移量之间,指令集没有其它可选择的偏移量)。例如:
1 | // Example 7.40 |
b
的偏移量是 400。任何通过指针或成员函数(如 ReadB
)访问 b
的代码都需要将偏移量编码为 32位数字。如果交换了 a
和 b
,那么可以使用一个被编码为 8位有符号数字的偏移量来访问它们,或者完全不使用偏移量。这使得代码更紧凑,从而更有效地使用代码缓存。因此,建议在结构或类声明中,大数组和其他大对象放在最后,最常用的数据成员放在前面。如果不可能在前 128 个字节中包含所有数据成员,则将最常用的成员放在前 128个字节中。
7.21类的成员函数(方法)
每次声明或创建类的新对象时,它都会生成数据成员的新实例。但是每个成员函数只有一个实例。函数代码不会被复制,因为相同的代码可以应用于类的所有实例。
调用成员函数与调用简单函数使用结构体(类)的指针或引用一样快。例如:
1 | // Example 7.41 |
Sum1
, Sum2
和 Sum3
这三个函数做的是完全一样的事情,它们的效率是一样的。如果查看编译器生成的代码,你会注意到一些编译器将为这三个函数生成完全相同的代码。Sum1
有一个隐式的 this
指针,它在 Sum2
和 Sum3
中 p
和 r
的作用相同。无论你是让函数成为类的成员,还是给它一个指向类或结构的指针或引用,都只是编程风格的问题。一些编译器通过使用寄存器中而不是栈传输 this
,使 Sum1
在 32位Windows 中比 Sum2
和 Sum3
效率略高一些。
静态成员函数不能访问任何非静态数据成员或非静态成员函数。静态成员函数比非静态成员函数快,因为它不需要 this
指针。如果成员函数不需要访问任何非静态的东西,可以通过将它们声明为静态以变得更快。
7.22 虚成员函数
虚函数用于实现多态类。一个多态类的每个实例都有一个指针指向一个指针表(虚函数表),其中的指针指向虚函数的不同版本。这个所谓的虚函数表用于在运行时查找虚函数的正确版本。多态性是面向对象程序比非面向对象程序效率低的主要原因之一。如果可以避免使用虚函数,那么你就可以获得面向对象编程的大多数优势,而无需付出性能成本。
如果函数调用语句总是调用虚函数的相同版本,那么调用虚成员函数所花费的时间要比调用非虚成员函数多几个时钟周期。如果版本发生了变化,你可能会得到10 - 20个时钟周期的错误预测惩罚。虚函数调用的预测和错误预测规则与 switch
语句相同,如7.12 分支和 switch语句所述;
在对已知类型的对象调用虚函数时,可以绕过分派机制,但是不能总是依赖编译器绕过分派机制,即使这样做使显而易见的。见8.1 编译器是如何优化的:实体化。
只有在编译时无法知道调用了多态成员函数的哪个版本时,才会需要运行时多态。如果需要在程序的关键部分中使用虚函数,那么你可以考虑是否可以在不使用多态性或使用编译时多态性的情况下完成所需的功能。
有时可以使用模板而不是虚函数来获得所需的多态性效果。模板参数应该是包含具有多个版本的函数的类。这个方法更快,因为模板参数总是在编译时解析,而不是在运行时解析。7.30 模板:使用模板实现多态中的例 7.47展示了如何做到这一点。不幸的是,它的语法非常笨拙,可能不值得花这么多功夫。
7.23 运行时类型识别(RTTI)
运行时类型识别会向所有类对象添加额外的信息,而且效率不高。如果编译器有RTTI 选项,那么关闭它并使用其他实现。
7.24 继承
派生类的对象与包含父类和子类成员的简单类的对象的实现方法相同。父类和子类的成员访问速度相同。一般来说,你可以假设使用继承几乎没有任何性能损失。
由于如下原因代码缓存的性能可能会有轻微的下降:
- 父类数据成员的大小被添加到子类成员的偏移量中。访问总偏移量大于 127字节的数据成员的代码稍微不那么紧凑。7.20 类的数据成员(变量实例)。
- 父类和子类的成员函数通常存储在不同的模块中。这可能会导致大量的跳转和低效的代码缓存。这个问题可以通过确保相互调用的函数存储在彼此附近来解决。详情见9.3 一起使用的函数应该被放在一起。
同一个类从多个父类继承会导致成员指针和虚函数,或者通过指向基类之一的指针访问派生类对象的复杂性很高。你可以通过在派生类中创建对象来避免多重继承:
1 | // Example 7.42a. Multiple inheritance |
可以替换为:
1 | // Example 7.42b. Alternative to multiple inheritance |
7.25 构造函数和析构函数
构造函数在内部被实现为一个成员函数,该成员函数返回对象的引用。新对象的内存分配不一定由构造函数本身完成。因此构造函数和其他成员函数效率一样。这适用于默认构造函数、复制构造函数和任何其他构造函数。
类不需要构造函数。如果对象不需要初始化,则不需要默认构造函数。如果仅通过复制所有数据成员就可以复制对象,则不需要复制构造函数。可以将简单的构造函数定义为内联的来提高性能。
无论何时通过赋值复制对象、作为函数参数或作为函数返回值,都可以调用复制构造函数。如果复制构造函数涉及内存或其他资源的分配,则它可以相当耗时。有很多方法可以避免这种浪费的内存块的复制,例如:
- 使用对象的引用或指针,而不是复制它。
- 使用“移动构造函数”(move constructor)来转移内存块的所有权。这需要一个支持C++ 0x 的编译器。
- 创建一个成员函数或友元函数或运算符,将内存块的所有权从一个对象转移到另一个对象。失去内存块所有权的对象应该将其指针设置为
NULL
。当然,应该有一个析构函数来销毁对象所拥有的任何内存块。 - 析构函数和成员函数效率一样。如果没有必要,不要创建析构函数。虚析构函数和虚成员函数效率一样。见7.24 继承。
7.26 联合体
union
是数据成员共享相同内存空间的结构。union
可以通过允许从不同时使用的两个数据成员共享同一块内存来节省内存空间。参见9.4 一起使用的变量应该存储在一起。
union
还可以用于以不同的方式访问相同的数据。例如:
1 | // Example 7.43 |
在本例中,f
的符号位是通过使用位或( |
)运算符设置的,该运算符只能用于整数。
7.27 位域
位域可能有助于使数据更加紧凑。访问位域成员不如访问结构的成员效率高。如果在大数组可以节省缓存空间或使文件更小,那么额外的时间是合理的。
使用 <<
和 |
组合操作来操作位域比单独操作成员要快。例如:
1 | // Example 7.44a |
假设A
、B
、C
的值很小,不会导致溢出,可以通过以下方式对该代码进行改善:
1 | // Example 7.44b |
或者,如果需要防止溢出:
1 | // Example 7.44c |
7.28 重载函数
重载函数的不同版本被简单地视为不同的函数。使用重载函数没有性能损失。
7.29 重载运算符
重载的运算符相当于一个函数。使用重载运算符与使用具有相同功能的函数效率一样。
表达式具有多个重载运算符,将导致为中间结果创建临时对象,这可能是我们不希望看到的。例如:
1 | // Example 7.45a |
为中间结果(b+c
)创建临时对象可以通过加入以下操作来避免:
1 | // Example 7.45b |
幸运的是,大多数编译器会在简单的情况下自动进行优化。
7.30 模板
模板与宏的相似之处在于,模板参数在编译之前被它们的值所替换。下面的例子说明了函数参数和模板参数之间的区别:
1 | // Example 7.46 |
a和b 都得到$10 * 8 = 80$。区别在于 m
传递到函数的方式。在这个简单的函数中,m
在运行时从调用者转移到被调用的函数。但是在模板函数中,m
在编译时被它的值所代替,这样编译器看到的是常量 8 而不是变量 m
。使用模板参数而不是函数参数的优点是避免了参数传递的开销。缺点是编译器需要为每个不同的值创建模板函数的新实例。如果在本例中使用许多不同的系数作为模板参数来调用 MultiplyBy
,那么代码可能会变得非常大。
在上面的例子中,模板函数比简单函数快,因为编译器知道它可以通过移位操作来实现乘以 2 的幂。x*8
被 x<<3
所代替,速度更快。在简单函数的情况下,编译器不知道 m
的值,因此不能进行优化,除非函数可以内联。(在上面的例子中,编译器能够内联和优化这两个函数,并简单地将 80
存入 a
和 b
中。但在更复杂的情况下,编译器可能无法做到这一点)。
模板参数也可以是类型。7.10 数组中的示例展示了如何使用相同的模板创建不同类型的数组。
模板是高效的,因为模板参数总是在编译时被解析。模板使源代码更加复杂,而不是编译后的代码。一般来说,使用模板在执行速度方面没有任何成本。
如果模板参数完全相同,则将两个或多个模板实例合并为一个。如果模板参数不同,那么每一组模板参数都将生成一个实例。有许多实例的模板会使编译后的代码变大,并使用更多的缓存空间。
过度使用模板会使代码难以阅读。如果模板只有一个实例,那么你也可以使用 #define
、const
或 typedef
来代替模板参数。
模板可以用于元编程,如15 元编程所述;
使用模板实现多态
模板类可用于实现编译时多态性,这比使用虚拟成员函数获得的运行时多态性更加高效。下面的示例首先展示了运行时多态性:
1 | // Example 7.47a. Runtime polymorphism with virtual functions |
如果编译器不知道对象 p
指向什么类(参见8.1 编译器是如何优化的:去虚拟化),则会在运行时分发到 C1::Disp()
或 C2::Disp()
。当前的编译器不太擅长优化掉 p
,并内联 Object1.Hello()
的调用,不过将来的编译器可能能够做到这一点。
如果在编译时知道对象是属于类C1
还是C2
,那么我们就可以避免低效的虚函数分发过程。这可以通过在活动模板库(ATL )和Windows模板库(WTL )中使用的特殊技巧来实现:
1 | // Example 7.47b. Compile-time polymorphism with templates |
在这里 CParent
是一个模板类,它通过模板参数获取关于其子类的信息。它可以通过将它的 this
指针类型转换为指向它的子类的指针来调用它的子类的多态成员。只有将正确的子类名作为模板参数时,这才是安全的。换句话说,你必须确保子类的声明 class CChild1 : public CParent<CChild1> {
和模板参数具有相同的名称。
现在继承的顺序如下。第一代类(CGrandParent
)包含任何非多态成员函数。第二代类(CParent<>
)包含任何需要调用多态函数的成员函数。第三代类包含多态函数的不同版本。第二代类可以通过模板参数获取关于第三代类的信息。
如果对象的类名是已知的,那么在运行时分派虚成员函数将不会浪费时间。这些信息包含在具有不同类型的 p1
和 p2
中。缺点是 CParent::Hello()
有多个实例占用缓存空间。
例 7.47b中的语法显然是非常笨拙的。通过避免虚函数分发机制,我们节省出来的几个时钟周期,难以证明如此复杂的难以理解的,因此也难以维护的代码是合适的。如果编译器能够自动执行去虚拟化(参见第8.1 编译器是如何优化的:去虚拟化),那么依赖编译器优化肯定比使用这种复杂的模板方法更加方便。
7.31 线程
线程用于同时或看起来是同时地执行两个或多个作业。如果计算机只有一个CPU 核心,那么不可能同时执行两个任务。对于前台任务,每个线程将获得通常为 30ms 的时间片,对于后台任务,每个线程将获得 10 ms 的时间片。每个时间片之后的上下文切换非常耗时,因为所有缓存都必须适应新的上下文。可以通过设置更长的时间片来减少上下文切换的次数。这将使应用程序运行得更快,但用户输入的响应时间会更长。(在Windows 中,你可以通过在高级系统性能选项下为后台服务选择优化性能,将时间片增加到 120ms。我不知道这在Linux 中是否可行)。
为不同任务的不同线程分配不同的优先级是非常有用的。例如,在字处理软件中,用户希望按下一个按键或移动鼠标时能够立即得到响应,这项任务必须有很高的优先级。而其他任务,例如拼写检查和重新分页,在其他优先级较低的线程中运行。如果不同的任务没有被划分成具有不同优先级的线程,那么当程序忙于拼写检查时,可能需要花很长时间来响应键盘和鼠标输入,这是用户所不希望遇到的。
如果应用程序有图形用户界面,那么任何需要很长时间的任务,比如繁重的数学计算,都应该安排在单独的线程中。否则程序将无法快速响应键盘或鼠标输入。
在应用程序中执行类似线程的调度而不调用操作系统线程调度程序,以节省开销是可能的。这可以通过在图形用户界面(在Windows MFC 中为 OnIdle
)的消息循环中调用的函数中逐块地进行大量的后台计算来实现。这种方法可能比在只有一个CPU 内核的系统中创建单独的线程要快,但是它要求后台作业可以被分割成合适持续时间的多个小块。
充分利用具有多个CPU 内核的系统的最佳方法是将工作划分为多个线程。然后每个线程可以在自己的CPU 内核上运行。
在优化多线程应用程序时,我们必须考虑多线程的四种成本:
- 启动和停止线程的成本。如果与启动和停止线程所需的时间相比,任务的持续时间较短,则不要将其放入单独的线程中。
- 任务切换的成本。如果具有相同优先级的线程数量不超过CPU 内核的数量,则此成本达到最小值。
- 线程间同步和通信的成本。信号量、互斥量等的开销相当大。如果两个线程经常为了访问同一资源而相互等待,那么最好将它们合并到一个线程中。多个线程之间共享的变量必须声明为
volatile
。这将阻止编译器对该变量进行优化。 - 不同的线程需要单独的存储空间。多线程使用的函数或类都不应该依赖于静态变量或全局变量。(参见线程本地存储p.28,TODO)。线程有各自的堆栈。如果线程共享相同的缓存,这可能会导致缓存竞争。
多线程程序必须使用线程安全的函数。线程安全的函数永远不应该使用静态变量。
有关多线程技术的进一步讨论,请参见10 多线程。
7.32 异常和错误处理
运行时错误会导致异常,这些异常可以通过陷阱(traps)或软件中断的形式检测到。可以使用 try-catch
块捕捉这些异常。如果启用异常处理且没有 try-catch
块,则程序将崩溃,并显示错误消息。
异常处理旨在检测很少发生的错误,并以一种优雅的方式从错误条件中恢复。你可能认为只要没有发生错误,异常处理就不需要额外的时间,但不幸的是,这并不总是正确的。为了知道如何在异常事件中恢复,程序可能需要做大量的记录工作。这种记录的消耗在很大程度上取决于编译器。有些编译器具有高效的基于表的方法,开销很少或没有,而其他编译器则具有低效的基于代码的方法,或者需要运行时类型识别(RTTI ),这会影响代码的其他部分。更详细的信息请参阅 ISO/IEC TR18015 Technical Report on C++ Performance 。
下面的例子解释了为什么需要记录工作:
1 | // Example 7.48 |
函数 F1
在返回时应该调用对象 x
的析构函数。但是如果 F1
中的某个地方发生异常怎么办?然后我们跳出 F1
而不返回。F1
的清理工作被阻止了,因为它被中断了。现在,异常处理程序负责调用 x
的析构函数,这只有在 F1
保存了要调用的析构函数的所有信息或可能需要的任何其他清理信息时才有可能。如果 F1
调用另一个函数进而调用另一个函数,等等,如果在最里面的函数产生了一个异常,然后异常处理程序需要关于函数调用链和需要遵循的函数调用的顺序等所有信息,来检查所有必要的清理工作。这叫做堆栈展开。
即使没有异常发生,所有函数仍必须为异常处理程序保存一些信息。这就是异常处理在某些编译器中代价高昂的原因。如果你的应用程序不需要异常处理,那么应该禁用它,以便使代码更小、更高效。你可以通过关闭编译器中的异常处理选项来禁用整个程序的异常处理。你也可以通过向函数原型中添加 throw()
声明来禁用单个函数的异常处理:
1 | void F1() throw(); |
这允许编译器假设 F1
永远不会抛出任何异常,这样它就不必为函数 F1
保存恢复信息。但是,如果 F1
调用另一个可能抛出异常的函数 F2
,那么 F1
必须检查 F2
抛出的异常,并在 F2
实际抛出异常时调用 std::unexpected()
函数。因此,只有当 F1
调用的所有函数也有一个 throw()
声明时才可以对 F1
使用 throw()
声明。throw()
声明对于库函数很有用。
编译器会区分叶函数和帧函数。帧函数是至少调用一个其他函数的函数。叶函数是一个不调用任何其他函数的函数。叶函数比帧函数简单,因为如果可以排除异常,或者在发生异常时没有什么需要清理的情况下,堆栈展开信息可以被忽略。帧函数可以通过内联它调用的所有函数来转换为叶函数。如果程序最内层的关键循环不包含对帧函数的调用,则可以得到最佳性能。
虽然throw()
语句在某些情况下可以提升、优化程序性能,但是没有理由添加诸如throw(A,B,C)
这样的语句来显式地告诉函数可以抛出什么样的异常。实际上,编译器可能会添加额外的代码来检查抛出的异常是否属于指定的类型(参见Sutter的文章:A Pragmatic Look at Exception Specifications, Dr Dobbs Journal, 2002)。
在某些情况下,即使在程序最关键的部分使用异常处理也是最优的。如果替代实现的效率较低,并且你希望能够从错误中恢复,那么就会出现这种情况。下面的示例演示了这种情况:
1 | // Example 7.49 |
假设 b[i]
和 c[i]
中的数字非常大,以至于在乘法 b[i]*c[i]
中可以发生溢出,尽管这种情况很少发生。上面的代码将捕获溢出时的异常,并以一种花费更多时间但避免溢出的方式重新执行计算。对每个因子取对数,而不是对乘积取对数,可以确保不会发生溢出,但是计算时间增加了一倍。
支持异常处理所需的时间可以忽略不计,因为在关键的最内层循环中没有 try
块或函数调用(日志除外)。log
是一个库函数,我们假设它是经过优化的。无论如何,我们都不能更改其可能的异常处理支持。异常发生时代价很高,但这不是问题,因为我们假设这种情况很少发生。
在这里,测试循环内部的溢出条件不需要任何成本,因为我们依赖微处理器硬件在发生溢出时引发异常。异常被操作系统捕获,如果有 try
块,操作系统会将其重定向到程序中的异常处理程序。
捕获硬件异常存在可移植性问题。这种机制依赖于编译器、操作系统和 CPU
硬件中的非标准化细节。将这样的应用程序移植到不同的平台可能需要修改代码。
让我们在这个例子中看看异常处理的可能替代方法。在相乘之前,我们可以检查 b[i]
和 c[i]
是否太大,从而检查溢出。这将需要两个浮点数比较,这是比较耗时的,因为它们必须在最内层循环中。另一种可能是始终使用安全的公式 a[i] = log(b[i]) + log(c[i])
,这将使 log
的调用次数增加一倍,而对数需要很长时间来计算。如果有一种方法可以在不检查所有数组元素的情况下检查循环之外的溢出,那么这可能是一种更好的解决方案。如果所有因子都是由相同的几个参数生成的,那么在循环之前进行这样的检查是可能的。或者,如果结果由某些公式组合成单个结果,那么可以在循环之后进行检查。
异常和向量代码
向量指令对于并行执行多个计算是有用的。下文第12章对此进行了描述。异常处理不适用于向量代码,因为向量中的单个元素可能会导致异常,而其他向量元素可能不会。由于分支在向量代码中实现的方式,你甚至可以在未采用的分支中得到异常。如果代码可以从向量指令中获益,那么最好禁用异常捕获,转而依赖 NAN
和 INF
的传递。见下文7.34 堆栈展开的其它情况。关于这一点进一步讨论参见www.agner.org/optimize/nan_propagation.pdf。
避免异常处理的成本
当不需要尝试从错误中恢复时,不需要异常处理。如果你只是希望程序发出错误消息并在出现错误时停止程序,那么就没有理由使用 try
、catch
和 throw
。更好的方法是定义自己的错误处理函数,该函数只打印适当的错误消息,然后调用 exit
。
如果有已分配的资源需要被清理的话, 调用 exit
可能并不安全,解释如下。还有其他不使用异常处理错误的可能方法。检测错误的函数可以返回一个错误代码,调用函数可以使用该代码进行恢复或发出错误消息。
建议使用系统的、经过深思熟虑的方法来处理错误。你必须区分可恢复错误和不可恢复错误;确保分配的资源在发生错误时得到清理;并向用户发送适当的错误消息。
编写异常安全代码
假设一个函数以独占模式打开一个文件,并且在文件关闭之前有一个错误条件终止了程序。程序被终止之后,该文件将保持锁定后,用户将无法访问该文件,直到计算机重新启动。为了防止这类问题,你必须使你的程序异常安全。换句话说,程序必须在异常或其他错误情况下清理所有东西。可能需要清理的东西包括:
- 使用
new
或者malloc
分配的内存。 - 窗口、图形画刷等的句柄。
- 锁定的互斥量。
- 打开的数据库连接。
- 打开的文件和网络连接。
- 需要被删除的临时文件。
- 需要保存的用户工作。
- 任何其它已分配的资源。
C++ 处理清理工作的方法是创建一个析构函数。可以将读取或写入文件的函数包装到具有确保文件关闭的析构函数的类中。相同的方法可以用于任何其他资源,例如动态分配的内存、窗口、互斥量、数据库连接等等。
C++ 异常处理系统确保调用本地对象的所有析构函数。如果包装器类有析构函数来处理分配资源的所有清理工作,则程序是异常安全的。如果析构函数引发另一个异常,则系统可能会出现问题。
如果你使用自己的错误处理系统而不是使用异常处理,那么你无法确保调用了所有析构函数并清理了资源。如果错误处理程序调用 exit()
、abort()
、_endthread()
等,则不能保证所有析构函数被调用。在不使用异常的情况下处理不可恢复错误的安全方法是从函数返回。如果可能,函数可能返回错误代码,或者错误代码可以存储在全局对象中。然后调用函数必须检查错误代码。如果后者也需要清理,那么它必须返回给自己的调用者,依此类推。
7.33 堆栈展开的其它情况
前面一节描述了一种称为堆栈展开的机制,异常处理程序使用这种机制清理和调用任何必要的析构函数,这些析构函数在出现异常时跳出函数,而不使用正常的返回路径。这种机制也适用于其他两种情况:
当线程终止时,可以使用堆栈展开机制。目的是检测线程中声明的任何对象是否具有需要调用的析构函数。建议在结束线程之前从需要清理的函数返回。你不能确保对 _endthread()
的调用会清除堆栈。这种行为依赖于具体的实现。
当使用 longjmp
函数从函数中跳出时,也使用堆栈展开机制。如果可能,避免使用 longjmp
。在效率相当重要的代码中不要依赖 longjmp
。
7.34 NAN
和INF
的传递
在大多数情况下,浮点错误会传播到一系列计算的最终结果。这是异常和错误捕获的一种非常有效的替代方法。
浮点溢出和除以 0 得到无穷大。如果你把无穷大和某数相加或相乘,结果就是无穷大。INF
代码可以以这种方式传播到最终结果。然而,并不是所有使用INF
输入的操作都会得到INF
。如果用一个正常的数字除以INF
,会得到0。特殊情况INF-INF
和INF/INF
得到NAN
(not-a-number)。当你用 0 除以 0 以及函数的输入超出范围时,比如sqrt(-1)
和log(-1)
,也会出现特殊的代码NAN
。
使用 NAN
作为输入的大多数操作将输出 NAN
,因此 NAN
将传播到最终结果。这是一种简单有效的浮点错误检测方法。几乎所有以 INF
或 NAN
形式出现的浮点错误都将传播到它们最终结果。如果打印结果,你将看到 INF
或 NAN
,而不是数字。跟踪错误不需要额外的代码,INF
和 NAN
的传播也不需要额外的成本。
NAN
可以包含带有额外信息的负载(payload)。函数库可以在出现错误时将错误代码放入此负载中,此负载将传播到最终的结果。
当参数为 INF
或 NAN
时,函数 finite()
将返回 false
,如果它是一个普通的浮点数,则返回 true
。这可用于在浮点数转换为整数之前检测错误,以及在其他需要检查错误的情况下。
INF
和 NAN
传播的详细信息请参阅NAN propagation versus fault trapping in floating point code。该手册还讨论了 INF
和 NAN
的传递失败的情况,以及影响这些代码传递的编译器优化选项。
7.35 预处理命令
就程序性能而言,预处理指令(以#
开头的所有指令)的性能成本很少,因为它们在程序编译之前就已经解析了。
#if
指令对于支持多个平台或使用相同源代码的多个配置是很有用的。#if
比 if
更高效,因为 #if
是在编译时解析的,而 if
是在运行时解析的。
当用于定义常量时,#define
指令等价于 const
定义。例如,#define ABC 123
和 const int ABC = 123
的效率相同的,因为在大多数情况下,编译器优化可以用它的值替换整数常量。然而,const int
声明在某些情况下可能占用内存空间,而 #define
指令从来不占用内存空间。浮点常量总是会占用内存空间,即使没有给它命名。
当作为宏使用时,#define
指令有时比函数更高效。参见7.14 函数:使用宏代替函数的讨论。
7.36 命名空间
使用名称空间,对执行速度没有影响。
8 编译器中的优化
8.1 编译器是如何优化的
现代编译器为了提高性能,会对代码进行大量修改。知道编译器能做什么和不能做什么,对程序员是很有帮助的。下面几节描述了一些编译器优化,这些优化是程序员需要了解的。
函数内联
编译器可以用被调用函数的主体替换函数调用。例如:
1 | // Example 8.1a |
编译器可以将对square
的调用替换为square
内部的代码:
1 | // Example 8.1b |
函数内联的优点是:
- 节约了调用、返回和参数传递的开销。
- 因为代码变得连续了,代码缓存的效率会更高。
- 如果只调用一次内联函数,那么代码就会变得更小。
- 如下所述,函数内联可以使其他优化的成为可能。
函数内联的缺点是:如果对内联函数有多个调用且函数体很大,则代码会变得更大。 如果函数很小,或者只从一个或几个地方调用它,那么编译器更可能使函数内联。
常量折叠和常数传播
只包含常量的表达式或子表达式将被计算结果替换。例如:
1 | // Example 8.2a |
编译器将会替换成下面的代码:
1 | // Example 8.2b |
这其实很方便,使用 2.0/3.0
要比计算值并使用许多小数要来的容易。建议为这样的子表达式加上括号,以确保编译器将其识别为子表达式。例如,b*2.0/3.0
将识别为 (b*2.0)/3.0
,而不是b*(2.0/3.0)
,除非为常量子表达式加上括号。
常量可以通过一系列的计算来传播:
1 | // Example 8.3a |
有可能被编译器替换成:
1 | // Example 8.3b |
当表达式包含不能被内联的函数或者不能再编译时期计算的时候,常量折叠和常量传播就不可能起作用。例如:
1 | // Example 8.4 |
sin
函数是在一个单独的函数库中定义的,不能期望编译器能够内联这个函数并在编译时计算它。一些编译器能够在编译时计算最常见的数学函数,如 sqrt
和 pow
,但不能计算更复杂的函数,比如 sin
。
消除指针
如果指向的目标已知,则可以消除指针或引用。例如:
1 | // Example 8.5a |
可能被编译器替换成:
1 | // Example 8.5b |
消除公共子表达式
如果相同的子表达式出现多次,那么编译器可能只会计算一次。例如:
1 | // Example 8.6a |
可能被编译器替换成:
1 | // Example 8.6b |
寄存器变量
最常用的变量存储被在寄存器中(参见7.1 不同类型变量的存储:寄存器存储(register storage))。
在 32位系统中,整数寄存器变量的最大数量大约是 6个,在 64位系统中大约是 14个。
在 32位系统中,浮点寄存器变量的最大数量为 8个,在 64位系统中为 16个。一些编译器很难在 32位系统中生成浮点寄存器变量,除非启用了SSE2(或更高版本)指令集。
编译器将选择最常用的变量做为寄存器变量。这包括指针和引用,它们可以存储在整数寄存器中。寄存器变量的典型候选对象是临时中间变量、循环计数器、函数参数、指针、引用、this
指针、公共子表达式和归纳变量(见下文)。
如果一个变量的地址被取走,也就是说,如果有指向它的指针或引用,那么这个变量就不能存储在寄存器中。因此,对于可能受益于寄存器存储的变量,你应该避免使任何指针或引用。
活动范围分析
变量的活动范围是指变量被使用的代码范围。对于活动范围不重叠的变量,编译器优化可以使用相同的寄存器。这在可用寄存器数量有限的时候是非常有用的。例如:
1 | // Example 8.7 |
在本例中,a
、b
和 c
可以共享同一个寄存器,因为它们的活动范围不重叠。如果 c = b + 1
更改为 c = a + 2
,那么 a
和 b
就不能使用相同的寄存器,因为它们的活动范围现在重叠了。
编译器通常不会将此原则用于存储在内存中的对象。对于不同的对象,它不会使用相同的内存区域,即使它们的活动范围不重叠。有关如何使不同的对象共享相同的内存区域的示例,请参见9.4 一起使用的函数应该被放在一起。
合并相同的分支
通过合并相同的代码片段,可以使代码更加紧凑。例如:
1 | // Example 8.8a |
可能被编译器替换为:
1 | // Example 8.8b |
消除跳转
可以通过复制它跳转到的代码来避免跳转。例如:
1 |
|
这段代码从a=a*2
跳转到return a+1;
,。编译器可以通过复制return
语句来消除这个跳转:
1 | // Example 8.9b |
如果条件可以被简化为永远为真或永远为假,则可以消除分支:
1 | // Example 8.10a |
可以被简化为:
1 | // Example 8.10b |
如果可以从前一个分支知道某个分支的情况,那么也可以删除该分支。例如:
1 | // Example 8.11a |
编译器可能会把这个简化成:
1 | // Example 8.11b |
循环展开
如果需要高度优化,一些编译器将会展开循环。见7.13 循环。如果循环体非常小,或者它使进一步优化成为可能,那么这可能是有利的。重复计数非常小的循环可以完全展开,以避免循环开销。例如:
1 | // Example 8.12a |
编译器可能会把这个简化成:
1 | // Example 8.12b |
不幸的是,一些编译器展开太多。过多的循环展开不是最优的,因为它会占用太多的代码缓存空间,并且会填满某些微处理器的循环缓冲区。在某些情况下,关闭编译器中的循环展开选项是有用的。
移动循环中的不变代码
如果计算独立于循环计数器,则可以将其移出循环。例如:
1 | // Example 8.13a |
可能会被编译器改成这样:
1 | // Example 8.13b |
归纳变量(Induction variables)
循环计数器的线性函数表达式可以通过在前一个值上添加一个常数来计算。例如:
1 | // Example 8.14a |
编译器可能会将其改成下面的形式以避免乘法:
1 | // Example 8.14b |
归纳变量常用于计算数组元素的地址。例如:
1 | // Example 8.15a |
为了访问 list
的元素,编译器必须计算它的地址。list[i]
的地址等于 list
的起始地址加上 i*sizeof(S1)
。这是一个关于 i
的线性函数,这是可以通过归纳变量计算的。编译器可以使用相同的归纳变量来访问 list[i].a
和 list[i].b
。当可以提前计算归纳变量的最终值时,也可以消去 i
,用归纳变量作为循环计数器。这可以将代码简化为:
1 | // Example 8.15b |
因子 sizeof(S1) = 16
实际上隐藏在例 8.15b中的C++ 语法后面。&list[100]
的整数表示形式为 (int)(&list[100]) = (int)(&list[0]) + 100*16
,而 temp++
实际上是在 temp
的整数值上加上 16。
编译器不需要归纳变量来计算简单类型的数组元素的地址,当地址可以表示为一个基地址加上一个常数加上索引乘以一个系数1,2,4或8(但不是任何其他因数), CPU 中有硬件支持这样的计算。如果在例 8.15a中的 a
和 b
是 float
而不是 double
,那么 sizeof(S1)
的值将是 8,那么就不需要归纳变量了,因为 CPU 有硬件可以寄计算 index
乘上 8。
我研究的编译器不为浮点表达式或更复杂的整数表达式生成归纳变量。有关如何使用归纳变量计算多项式的示例,请参见8.3 编译器优化的障碍:浮点归纳变量。
排序
编译器可以为了并行执行对指令重新排序。例如:
1 | // Example 8.16 |
在这个例子中,编译器可以交错这两个公式,先算 a + b
,然后是 d + e
,然后将 c
加到第一个和中,之后 f
被加到第二个和中,第一个结果是存储在 x
中,最后第二个结果存储在 y
中。这样做的目的是帮助CPU 同时进行多个计算。现代CPU 实际上可以在没有编译器帮助的情况下对指令进行重新排序(参见11 乱序执行),但是编译器可以使CPU 更容易地对指令进行重新排序。
代数化简
多数编译器可以使用代数的基本定律来化简简单的代数表达式。例如,编译器可以将表达式 -(-a)
更改为 a
。
我不认为程序员会经常写出像 -(-a)
这样的表达式,但是这种表达式可能是其他优化(如函数内联)的结果。可化简的表达式也经常作为宏展开的结果出现。
然而,程序员经常编写可以化简的表达式。这可能是因为未化简的表达式更好地解释了程序背后的逻辑,或者因为程序员没有考虑代数化简的可能性。例如,程序员可能更喜欢使用 if(!a && !b)
而不是同等的 if(!(a || b))
即使后者少用一个运算符。幸运的是,在这种情况下,所有编译器都能够进行化简。
你不能指望编译器化简复杂的代数表达式。例如,在我测试的编译器中,只有一个编译器能够将 (a*b*c)+(c*b*a)
化简为 a*b*c*2
。在编译器中实现很多代数规则是相当困难的。一些编译器可以化简某些类型的表达式,而另一些编译器可以化简其他类型的表达式,但我所见过的编译器都不能化简所有类型的表达式。在布尔代数中,可以实现一种通用算法(例如,Quine-McCluskey 或者 Espresso)来化简任何表达式,但我测试过的编译器似乎都没有这样做。
编译器在化简整数表达式上比浮点表达式做得更好,尽管这两种情况下的代数规则是相同的。这是因为浮点表达式的代数操作可能会产生我们不希望的效果。这种效果可以用下面的例子来说明:
1 | // Example 8.17 |
这里 y
的值是 $-100+100+100 = 100$。现在,根据代数规则,我们可以这样写:
1 |
|
如果子表达式 c+b
可以在其他地方重用,那么这可能很有用。在这个例子中,我们使用的是 8 位整数,范围从-128到+127。整数溢出将使值反转(wrap around)。127 加 1 等于 -128,减 1 等于 -128。计算 c+b
会产生溢出,结果是 -56
而不是 200
。接下来,我们将 -100
加到 -56
中,这将产生一个下溢,得到 100
,而不是 -156
。令人惊讶的是,我们得到了正确的结果,因为向上溢出和向下溢出相互抵消了。这就是为什么对整数表达式使用代数操作是安全的(<
、<=
、>
和 >=
运算符除外)。
同样的讨论不适用于浮点表达式。浮点变量在上溢和下溢时,不会反转。浮点变量的范围非常大,除了在特殊的数学应用中,我们不必太担心上溢和下溢。但是我们必须担心精度的损失。让我们用浮点数重复上面的例子:
1 | // Example 8.18 |
这里的计算结果先得出 a+b=0
,然后 0+1.23456 = 1.23456
。但是如果我们改变操作数的顺序,先加 b
和 c
,就不会得到相同的结果。b + c = 100000001.23456
。浮点类型的精度大约为7位有效数字,因此 b+c
的值四舍五入为 100000000
。把 a
加到这个数上得到 0
,而不是 1.23456
。
这里讨论的结果是,改变浮点操作数的顺序,就有丢失精度的风险。除非你指定一个允许不需要精确的浮点运算的选项,否则编译器不会这么做。即使打开了所有相关的优化选项,编译器也不会执行诸如 0/a = 0
这样的明显简化,因为如果 a
为 0、无穷大或 NAN
(不是一个数字),这将是无效的。不同的编译器的行为不同,因为对于哪些不精确应该被允许,哪些不应该被允许,存在不同的观点。
不能依赖编译器对浮点代码执行任何代数消减,只能依赖于对整数代码进行最简单的缩减。手动消减会更安全。我测试了在7个不同的编译器,简化各种代数表达式的能力。结果如下表8.1所示。
去虚拟化(Devirtualization)
如果知道所需要虚函数的版本,编译器优化可以绕过虚函数表查找,直接调用虚函数。例如:
1 | // Example 8.19. Devirtualization |
如果不进行优化,编译器需要在虚函数表中查找 p->f()
调用是否要转到 C0::f
或 C1::f
。但是编译器优化将看到 p
总是指向类 C1
的对象,因此它可以直接调用 C1::f
,而不使用虚函数表。不幸的是,很少有编译器能够进行这种优化。
8.2 不同编译器的对比
我在 9 种不同的C++ 编译器上做了一系列的实验,看看它们是否能够进行不同种类的优化。结果见表8.1。该表显示了不同的编译器是否成功地在我的测试示例中应用了各种优化方法和代数化简。
该表可以提供一些关于你可以期望特定的编译器获得哪些优化,以及必须手动进行哪些优化的指导。
必须强调的是,编译器在不同的测试示例上可能有不同的行为。你不能期望编译器总是根据表格的结果来运行。
Optimization method | Microsoft | Borland | Intel | Gnu | PathScale | PGI | Digital Mars | Watcom | Codeplay |
---|---|---|---|---|---|---|---|---|---|
Function inlining | X | - | X | X | X | X | - | - | X |
Constant folding | X | X | X | X | X | X | X | X | X |
Constant propagation | X | - | X | X | X | X | - | - | X |
Pointer elimination | X | X | X | X | X | X | X | X | X |
Common subexpression elimin, integer | X | (X) | X | X | X | X | X | X | X |
Common subexpression elimin, float | X | - | X | X | X | X | - | X | X |
Register variables, integer | X | X | X | X | X | X | X | X | X |
Register variables, float | X | - | X | X | X | X | - | X | X |
Live range analysis | X | X | X | X | X | X | X | X | X |
Join identical branches | X | - | - | X | - | - | - | X | - |
Eliminate jumps | X | X | X | X | X | X | - | X | X |
Eliminate branches | X | - | X | X | X | X | - | - | - |
Remove branch that is always true/false | X | - | X | X | X | X | X | X | X |
Loop unrolling | X | - | X | X | X | X | - | - | X |
Loop invariant code motion | X | - | X | X | X | X | X | X | X |
Induction variables for array elements | X | X | X | X | X | X | X | X | X |
Induction variables for other integer expressions | X | - | X | X | X | - | X | X | X |
Induction variables for float expressions | - | - | - | - | - | - | - | - | - |
Automatic vectorization | - | - | X | X | X | X | - | - | X |
Devirtualization | - | - | - | X | - | - | - | - | - |
Profile-guided optimization | X | - | X | X | X | X | - | - | - |
Whole program optimization | X | - | X | X | X | - | - | - | - |
Integer algebra reductions: | |||||||||
a+b = b+a |
X | (X) | X | X | X | X | - | X | X |
a*b = b*a |
X | (X) | X | X | X | X | - | X | X |
a+b+c = a+(b+c) |
X | - | X | X | - | - | X | X | - |
a+b+c = c+a+b |
X | - | - | X | - | - | - | - | - |
a+b+c+d = (a+b)+(c+d) |
- | - | X | X | - | - | - | - | - |
a*b+a*c = a*(b+c) |
X | - | X | X | X | - | - | - | X |
a*x*x*x+b*x*x+c*x+d = ((a*x+b)*x+c)*x+d |
X | - | X | X | X | - | X | X | X |
X*X*X*X*X*X=((X^2 )^2 )^2 |
- | - | - | X | - | - | - | - | - |
a+a+a+a=a*4 |
X | - | X | X | - | - | - | - | X |
-(-a)=a |
X | - | X | X | X | X | X | X | - |
a-(-b)=a+b |
X | - | X | X | X | X | - | X | - |
a-a = 0 |
X | - | X | X | X | X | X | X | X |
a+0 = a |
X | X | X | X | X | X | X | X | X |
a*0 = 0 |
X | X | X | X | X | X | X | - | X |
a*1 = a |
X | X | X | X | X | X | X | X | X |
(-a)*(-b) = a*b |
X | - | X | X | X | - | - | - | - |
a/a = 1 |
- | - | - | - | X | - | - | - | X |
a/1 = a |
X | X | X | X | X | X | X | X | X |
0/a = 0 |
- | - | - | X | X | - | - | X | X |
(-a == -b) = (a == b) |
- | - | - | X | X | - | - | - | - |
(a-c == b+c) = (a == b) |
- | - | - | - | X | - | - | - | - |
!(a < b) = (a >= b) |
X | X | X | X | X | X | X | X | X |
(a<b && b<c && a<c) = (a<b && b<c) |
- | - | - | - | - | - | - | - | - |
Multiply by constant = shift and add | X | X | X | X | - | X | X | X | - |
Divide by constant = multiply and shift | X | - | X | X | X | (-) | X | - | - |
Floating point algebra reductions: | |||||||||
a+b = b+a |
X | - | X | X | X | X | - | - | X |
a*b = b*a |
X | - | X | X | X | X | - | - | X |
a+b+c = a+(b+c) |
X | - | X | X | - | - | - | - | - |
(a+b)+c = a+(b+c) |
- | - | X | X | - | - | - | - | - |
a*b*c = a*(b*c) |
X | - | X | - | - | - | - | - | - |
a+b+c+d = (a+b)+(c+d) |
- | - | - | X | - | - | - | - | - |
a*b+a*c = a*(b+c) |
X | - | - | - | X | - | - | - | - |
a*x*x*x+b*x*x+c*x+d = ((a*x+b)*x+c)*x+d |
X | - | X | X | X | - | - | - | - |
X*X*X*X*X*X=((X^2 )^2 )^2 |
- | - | - | X | - | - | - | - | - |
a+a+a+a=a*4 |
X | - | X | X | - | - | - | - | - |
-(-a)=a |
- | - | X | X | X | X | X | X | - |
a-(-b)=a+b |
- | - | - | X | X | X | - | X | - |
a+0 = a |
X | - | X | X | X | X | X | X | - |
a*0 = 0 |
- | - | X | X | X | X | - | X | X |
a*1 = a |
X | - | X | X | X | X | X | - | X |
(-a)*(-b) = a*b |
- | - | - | X | X | X | - | - | - |
a/a = 1 |
- | - | - | - | X | - | - | - | X |
a/1 = a |
X | - | X | X | X | - | X | - | - |
0/a = 0 |
- | - | - | X | X | - | - | X | X |
(-a == -b) = (a == b) |
- | - | - | X | X | - | - | - | - |
(-a > -b) = (a < b) |
- | - | X | X | - | - | - | - | X |
Divide by constant = multiply by reciprocal | X | X | - | X | X | - | - | X | - |
Boolean algebra reductions: | |||||||||
!(!a) = a |
X | - | X | X | X | X | X | X | X |
a && a = a |
X | - | X | X | X | X | - | - | - |
Bit vector algebra reductions: | |||||||||
~(~a) = a |
X | - | X | X | X | X | X | - | - |
a & a = a |
X | - | - | X | X | X | - | - | X |
a & ~a = 0 |
- | - | - | X | X | X | - | - | - |
a&b&c&d = (a&b)&(c&d) |
- | - | - | X | - | - | - | - | - |
a ^ 0 = a |
X | X | X | X | X | - | X | X | X |
a ^ -1 = ~a |
X | - | X | X | X | - | X | X | - |
a ^ a = 0 |
X | - | X | X | X | X | - | X | X |
a ^ ~a = -1 |
- | - | - | X | X | X | - | - | - |
~a ^ ~b = a ^ b |
- | - | - | X | X | - | - | - | - |
a<<b<<c = a<<(b+c) |
X | - | X | X | X | - | - | X | X |
Integer XMM (vector) reductions: | |||||||||
Common subexpression elimination | X | n.a. | X | X | X | - | n.a. | n.a. | X |
Constant folding | - | n.a. | - | X | - | - | n.a. | n.a. | - |
a+b = b+a , a*b = b*a |
- | n.a. | - | X | - | - | n.a. | n.a. | X |
(a+b)+c = a+(b+c) |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
a*b+a*c = a*(b+c) |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
X*X*X*X*X*X=((X^2 )^2 )^2 |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
a+a+a+a = a*4 |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
-(-a) = a |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
a-a = 0 |
- | n.a. | X | - | - | - | n.a. | n.a. | - |
a+0 = a |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
a*0 = 0 |
- | n.a. | - | X | - | - | n.a. | n.a. | - |
a*1 = a |
- | n.a. | - | X | - | - | n.a. | n.a. | - |
(-a)*(-b) = a*b |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
!(a < b) = (a >= b) |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
Floating point XMM (vector) reductions: | |||||||||
a+b = b+a , a*b = b*a |
X | n.a. | - | X | - | - | n.a. | n.a. | X |
(a+b)+c = a+(b+c) |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
a*b+a*c = a*(b+c) |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
-(-a) = a |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
a-a = 0 |
- | n.a. | - | X | - | - | n.a. | n.a. | - |
a+0 = a |
- | n.a. | X | - | - | - | n.a. | n.a. | - |
a*0 = 0 |
- | n.a. | X | - | - | - | n.a. | n.a. | - |
a*1 = a |
- | n.a. | - | X | - | - | n.a. | n.a. | - |
a/1 = a |
- | n.a. | - | X | - | - | n.a. | n.a. | - |
0/a = 0 |
- | n.a. | X | X | - | - | n.a. | n.a. | - |
Divide by constant = multiply by reciprocal | - | n.a. | - | - | - | - | n.a. | n.a. | - |
Boolean XMM (vector) reductions: | |||||||||
~(~a) = a |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
a & ~a = 0 |
- | n.a. | - | X | - | - | n.a. | n.a. | - |
a & 0 = 0 |
- | n.a. | - | X | - | - | n.a. | n.a. | - |
a ^ a = 0 |
- | n.a. | X | X | - | - | n.a. | n.a. | - |
andnot(a,a) = 0 |
- | n.a. | - | X | - | - | n.a. | n.a. | - |
a<<b<<c = a<<(b+c) |
- | n.a. | - | - | - | - | n.a. | n.a. | - |
Tabel 8.1. Comparison of optimizations in different C++ compilers
测试中所有相关的优化选项都被打开,包括放宽浮点精度。被测试的编译器版本如下:
- Microsoft C++ Compiler v. 14.00 for 80x86 / x64 (Visual Studio 2005).
- Borland C++ 5.82 (Embarcadero/CodeGear/Borland C++ Builder 5, 2009).
- Intel C++ Compiler v. 11.1 for IA-32/Intel64, 2009.
- Gnu C++ v. 4.1.0, 2006 (Red Hat).
- PathScale C++ v. 3.1, 2007.
- PGI C++ v. 7.1-4, 2008.
- Digital Mars Compiler v. 8.42n, 2004.
- Open Watcom C/C++ v. 1.4, 2005.
- Codeplay VectorC v. 2.1.7, 2004.
没有发现Microsoft、Intel、Gnu 和PathScale 编译器的 32位和 64位代码的优化功能有任何差异。
8.3 编译器优化的障碍
有几个因素妨碍编译器执行我们希望它完成的优化。对于程序员来说,了解这些障碍并知道如何避免它们是很重要的。下面将讨论优化的一些重要障碍。
无法跨模块优化
编译器除了正在编译的模块外,没有关于其他模块中的函数的信息。这阻止了它对函数调用进行优化。例如:
1 | // Example 8.20 |
假如 Func1
和 Func2
在同一个模块中,那么编译器将能够进行函数内联和常量传播,并将 a
化简为常量5。但是在编译 module2.cpp 时,编译器没有关于 Func1
的必要信息。
解决这个问题最简单的方法是使用 #include
指令将多个 .cpp 模块组合成一个模块。这个方法适用于所有编译器。有些编译器有一个称为“全程序优化”的特性,它将支持跨模块的优化(参见8.5 编译器优化选项)。
指针别名(pointer aliasing)
当通过指针或引用访问变量时,编译器可能无法完全排除所指向的变量与代码中的其他变量相同的可能性。例如:
1 | // Example 8.21 |
在这里,需要重新加载 *p
并计算 *p+2
100次,因为 p
所指向的值与循环过程中会发生变化的 a[]
中的一个元素相同。不允许假定 *p+2
是可以移出循环的循环不变代码。例 8.21确实是一个非常刻意的例子,但关键是编译器不能排除这种刻意的例子存在的理论可能性。因此,编译器不能假设 *p+2
是一个可以移动到循环外部的循环不变表达式。
大多数编译器都有一个假设没有指针别名(/Oa )的选项。克服可能的指针别名障碍的最简单方法是打开这个选项。这要求你仔细分析代码中的所有指针和引用,以确保在代码的同一部分中没有以一种以上的方式访问任何变量或对象。如果编译器支持的,还可以通过使用关键字 __restrict
或 __restrict__
告诉编译器某个特定指针不是任何变量的别名。
我们永远不能确定编译器是否接受关于没有指针别名的提示。确保代码得到优化的唯一方法是显式地进行优化。在例 8.21中,如果你确信指针不是数组中的任何元素的别名,那么可以先计算 *p+2
并将其存储在循环外部的临时变量中。这种方法要求你能够预先知道优化的障碍在哪里。
动态内存分配
动态分配(使用new
或malloc
)的任何数组或对象都必须通过指针进行访问。对于程序员来说,指向不同动态分配的对象的指针没有重叠或混淆是很明显的,但是编译器通常看不到这一点。它还阻止了编译器以最佳的方式来对齐数据,或者阻止编译器知道对象是对齐的。最好在需要的函数中声明对象和固定大小的数组。
纯函数(Pure functions)
纯函数是一个没有副作用(side-effects)的函数,它的返回值只取决于参数的值。这与“函数”的数学概念密切相关。
多次使用相同参数调用纯函数肯定会得到相同的结果。编译器可以消除包含纯函数调用的常见子表达式,并且可以移出包含纯函数调用的循环不变代码。不幸的是,如果函数是在不同的模块或函数库中定义的,编译器则无法知道函数是否为纯函数。
因此,当涉及纯函数调用时,有必要手动进行优化,如公共子表达式消除、常量传播和移动循环不变代码。
Gnu 编译器和用于Linux 的Intel 编译器都有一个属性,该属性可以用于声明函数原型,以告诉编译器这是一个纯函数。例如:
1 | // Example 8.22 |
在这里,Gnu 编译器将只调用 Func1
一次,而其他编译器将会调用两次。
其他一些编译器(Microsoft,Intel )知道像 sqrt
、pow
和 log
这样的标准库函数是纯函数,但不幸的是,无法告诉这些编译器用户定义的函数是纯函数。
虚函数和函数指针
编译器几乎不可准确地预测将调用虚函数的哪个版本,或者函数指针指向什么。因此,它不能内联这些函数,也不能对函数调用进行优化。
代数化简
大多数编译器可以做简单的代数化简,比如-(a) = a
,但是它们不能做更复杂的化简。代数化简是一个复杂的过程,这很难在编译器中实现。
由于数学的纯粹性(mathematical purity),许多代数化简是不被允许的。在许多情况下,可以构造一些晦涩的例子,其中化简会导致溢出或精度损失,特别是在浮点表达式中(参见第74页TODO)。编译器不能排除特定情况下某个特定化简无效的可能性,但是程序员可以。因此,在许多情况下有必要显式地进行代数化简。
整数表达式不太容易出现溢出和精度损失的问题,原因见8.1 编译器是如何优化的:代数化简。因此,编译器可以对整数表达式进行比浮点数表达式更多的化简。大多数涉及整数加法、减法和乘法的化简在所有情况下都是被允许的,而许多涉及除法和关系运算符(如“>
”)的化简,由于数学的纯粹性是不被允许的。例如,由于存在隐藏的溢出的可能性,编译器不能将整数表达式 -a > -b
化简为 a < b
。
表 8.1显示了编译器在某些情况下,能够进行哪些化简,以及不能进行哪些化简。编译器无法完成的所有化简都必须由程序员手动完成。
浮点归纳变量
编译器不能生成浮点归纳变量的原因与它们不能对浮点表达式进行代数化简的原因相同。因此,有必要手动完成这项工作。当循环计数器的函数通过以前的值计算比使用循环计数器计算更有效时,这个方法就很有用。循环计数器的$n$次多项式的任何表达式都可以通过$n$次加法来计算,而不需要使用乘法。下面的例子展示了使用加法二阶多项式的原理:
1 | // Example 8.23a. Loop to make table of polynomial |
这个多项式的计算通过两个归纳变量,只需要两个加法就可以完成:
1 | // Example 8.23b. Calculate polynomial with induction variables |
例8.23b中的循环中有两个循环依赖链(loop-carried dependency chain),即两个归纳变量 Y
和 Z
。每个依赖链都有一个延迟,这个延迟与浮点加法的延迟相同。这个延迟足够小,说明该方法是合适的。一个较长的循环依赖链会使归纳变量方法变得不利,除非该值是从一个两次或多次迭代的值计算出来的。
如果你考虑到每个值都是从序列中位于 r
位置之前的值计算出来的,其中 r
是一个向量中的元素数或循环展开因子,那么归纳变量的方法也可以向量化。要在每种情况下找到正确的公式,需要一点数学知识。
内联函数的非内联副本
函数内联的复杂性在于,同一个函数可能从另一个模块调用。为了在另一个模块中调用该函数,编译器必须生成一个内联函数的非内联的副本,。如果没有其他模块调用这个函数,那么这个非内联副本就是无用代码。这种代码片段降低了缓存的效率。
有很多方法可以解决这个问题。如果一个函数没有被任何其他模块引用,那么将关键字 static
添加到函数定义中。这将告诉编译器不能从任何其他模块调用该函数。静态声明使编译器更容易评估使函数内联是否是最优的,并防止编译器生成未使用的内联函数副本。static
关键字还使各种其他优化成为可能,由于这些函数在其他模块中是无法访问的,因此编译器不必遵守任何特定的函数调用约定。可以用 static
声明所有本地非成员函数。
不幸的是,这个方法并不适用于类成员函数,因为 static
关键字对于成员函数有不同的含义。可以通过在类定义中声明函数体来强制使成员函数内联。这将防止编译器生成函数的非内联副本,但它的缺点是,即使在不适合内联的情况下(例如,如果成员函数很大,并且从许多不同的地方调用),函数也总是内联的。
一些编译器有一个选项(Windows:/Gy
, Linux:- fffunction -sections
),允许链接器删除未引用的函数。建议打开此选项。
8.4 CPU 优化的障碍
现代 CPU 可以通过乱序执行指令来进行很多优化。如3.15 依赖链所述,代码中的长依赖链妨碍CPU 的乱序执行。避免长依赖链,特别是具有长延迟的循环依赖链。
8.5 编译器优化选项
所有C++ 编译器都有各种各样的优化选项,你可以打开或关闭它们。研究正在使用的编译器的可用选项并打开所有相关选项是非常重要的。
许多优化选项与调试不兼容。调试器可以一次一行地执行代码,并显示所有变量的值。显然,当部分代码被重新排序、内联或优化时,这是不可能的。生成两个版本的可执行文件是很常见的:一个带有完整调试支持的调试版本(在程序开发期间使用)和一个带有所有相关优化选项的发布版本。大多数IDE(集成开发环境)都有用于生成目标文件和可执行文件的调试版本和发布版本的工具。确保能够区分这两个版本,并在可执行文件的优化版本中关闭调试和性能分析支持。
大多数编译器都提供了大小优化和速度优化的选择。当代码非常快时,你希望可执行文件尽可能小;或者当代码缓存非常关键时,优化大小是非常重要的。当CPU 访问和内存访问是消耗巨大时,速度优化是与之相关的。选择可用的优化程度最大的优化选项。
一些编译器提供配置分析引导的优化。其工作方式如下。首先,编译程序并使之支持分析。然后使用分析器进行测试运行,分析器确定程序流以及每个函数和分支执行的次数。然后,编译器可以使用这些信息来优化代码,并将不同的函数按最佳顺序排列。
一些编译器支持全程序优化。这可以通过两个步骤进行编译。所有源文件首先被编译成中间文件格式,而不是通常的目标文件格式。然后在第二步中将中间文件链接在一起后完成编译。寄存器分配和函数内联是在第二步中完成的。中间文件格式没有标准化。它甚至不兼容同一编译器的不同版本。因此,不可能以这种格式分发函数库。
其他编译器提供了将多个 .cpp
文件编译为单个对象文件的可能性。这使编译器能够在启用过程间优化时进行跨模块优化。一种更原始但更有效的方法是通过 #include
指令将所有源文件连接到一个文件中,并声明所有函数为静态或内联的。这将使编译器能够对整个程序进行过程间优化。
在CPU 发展的历史中,每一代CPU 都增加了可用的指令集,更新的指令集使得编译器可以生成更高效的代码,但这使得代码与旧的CPU 不兼容。奔腾Pro 指令集使浮点数比较更高效。所有现代CPU 都支持这个指令集。SSE2 指令集非常有意思,因为它使浮点代码在某些情况下更高效,并使使用向量指令成为可能(参见12 使用向量操作)。然而,使用SSE2 指令集并不总是最优的。在某些情况下,SSE2指令集使浮点代码变的更慢,特别是在代码混合浮点和双精度浮点时(参见12 使用向量操作)。目前大多数CPU 和操作系统都支持SSE2 指令集。
当不需要兼容旧版本CPU 时,可以选择较新的指令集。更好的方法是,你可以使代码中最关键部分的有多个版本支持不同的CPU。这个方法在13 为不同指令集生成多个版本的软件代码中有解释。
当没有异常处理时,代码会变得更高效。建议关闭对异常处理的支持,除非代码依赖于结构化异常处理,并且你希望代码能够从异常中恢复。见7.32 异常和错误处理。
建议关闭对运行时类型识别(RTTI)的支持。参见第55页(TODO)。
建议启用快速浮点运算或关闭对严格浮点运算的要求,除非要求严格。参见7.23 运行时类型识别(RTTI)中的讨论。
如果选项“函数级链接”(function level linking)可用,可以打开该选项。有关此选项的解释,请参见8.3 编译器优化的障碍:内联函数的非内联副本。
如果你确定代码没有指针别名,请使用“假设没有指针混叠“(assume no pointer aliasing)选项。有关解释,请参阅8.3 编译器优化的障碍:指针别名。(Microsoft编译器 仅在专业版 和企业版 中支持该选项)。
不要修正“FDIV bug
”。FDIV bug
是最老版本的奔腾CPU 中的一个小错误。在一些罕见的浮点除法情况下可能会导致轻微的不精确。修正了FDIV bug 将导致浮点除法变慢。
许多编译器都有“标准栈帧”(standard stack frame)或“帧指针“(frame pointer)选项。标准栈帧用于调试和异常处理。省略标准堆栈帧可以使函数调用更快,并节省出一个额外的寄存器用于其他目的。这是有利的,因为寄存器是一种稀缺资源。除非程序依赖异常处理,否则不要使用堆栈帧。
8.6 优化指令
一些编译器有许多关键字和指令,用于在代码中的特定位置给出特定的优化指令。其中许多指令是特定于编译器的。你不能期望Windows编译器 的指令在Linux编译器 上工作,反之亦然。但是大多数Microsoft指令 可以在Intel编译器 和Gnu编译器 的Windows 版本上工作,而大多数Gnu 指令也可用于PathScale 和Intel 编译器的Linux 版本。
适用于所有C++编译器的关键字
可以将 register
关键字添加到变量声明中,告诉编译器希望它是一个寄存器变量。register
关键字只是一个提示,编译器可能不会接受提示,但是在编译器无法预测哪些变量将被最多使用的情况下,它会非常有用。
与 register
相反的是 volatile
。volatile
关键字确保变量永远不会存储在寄存器中,即使是临时的。这适用于在多个线程之间共享的变量,但也可以在用于测试目的时,关闭的变量的所有优化。
const
关键字表示变量永远不会改变。这将允许编译器在许多情况下优化掉变量。例如:
1 | // Example 8.24. Integer constant |
在这里,编译器可以将所有出现的 ArraySize
替换为$1000$。如果循环计数 ArraySize
是常量,编译器在编译时能知道它的值,则可以以更高效的方式实现示例 8.24中的循环。将不会为整数常量分配内存,除非它的地址(&ArraySize
)被取走。
const
指针或 const
引用不能更改它所指向的内容。const
成员函数不能修改数据成员。建议在适当的情况下使用 const
关键字来为编译器提供关于变量、指针或成员函数的额外信息,因为这可能会提高优化的可能性。例如,编译器可以安全地假设类数据成员的值在调用同一类的 const
函数时保持不变。
根据上下文,static
关键字有多种含义。当关键字 static
应用于非成员函数时,意味着该函数不被任何其他模块访问,这使得内联更加高效,并支持过程间优化, 见8.3 编译器优化的障碍:内联函数的非内联副本;当应用于全局变量时,意味着它不被任何其他模块访问,这将支持过程间优化;当应用于函数内部的局部变量时,意味着该变量将在函数返回时保留,并在下一次调用该函数时保持不变,这可能是低效的,因为一些编译器会插入额外的代码来防止多个线程同时访问该变量。即使变量被声明为 const
,也可能会这样。
然而,可能有一个原因将局部变量声明为静态,并确保它只在第一次调用函数时初始化。例如:
1 | // Example 8.25 |
在这里,log(2.0)
只会在第一次执行 Func
被计算。如果没有 static
,将会在每次执行 Func
时重新计算。这样做的缺点是,函数必须检查以前是否调用过它。这比再次计算对数要快,但是将 log2
作为全局 const
变量或将其替换为计算后的值会更快。
当 static
关键字用于类的成员函数时表示该函数不能访问任何非静态的成员变量和成员函数。由于不需要 this
指针,调用静态成员函数会比非静态成员函数更快。建议在任何合适的时候将成员函数声明为静态的。
特定编译器的关键字
快速函数调用。__fastcall
或者 __attribute__((fastcall))
。fastcall
修饰符可以使函数调用在 32位模式下更快。前两个整型参数将会在寄存器而不是栈中传递(对于 CodeGear编译器
则是前三个参数)。快速调用函数在编译器之间不兼容。在 64位模式下不需要快速调用,因为参数已经在寄存器中传递的。
纯函数。__attribute__((const))
(Linux only),制定函数位纯函数。这将允许消除公共子表达式和移动循环不变代码。参见8.3 编译器优化的障碍:纯函数;
假设没有指针别名。__declspec(noalias)
或 __restrict
或 #pragma optimize("a", on)
,假设没有指针别名。见8.3 编译器优化的障碍:指针别名的解释。注意到这些指令并不是总是有用的。
数据对齐。__declspec(align(16))
或 __atrribute__((aligned(16)))
,指定数组和结构体的对齐。这对向量操作非常有用,参见12 使用向量操作。
8.7 检查编译器做了什么
研究编译器生成的代码,看看它如何优化代码,这是非常有用的。有时编译器会做一些非常巧妙的事情来提高代码的效率,而有时它会做一些非常愚蠢的事情。查看编译器输出通常能够发现可以通过修改源代码来改进的内容,如下面的示例所示。
检查编译器生成的代码的最佳方法是将编译器选项设置位输出汇编语言。在大多数编译器上,可以从命令行调用编译器来,通过使用所有相关优化选项和选项-S 或 /Fa 以输出汇编代码来实现这一点。一些系统上的IDE 也提供输出汇编代码的选项。如果编译器有输出汇编语言的选项,则可以使用目标文件反汇编器。
请注意 Intel 编译器 在输出汇编代码时,有源代码注释选项:FAs 或 -fsource-asm。这个选项使汇编输出的可读性更高,但不幸的是这会妨碍某些特定的优化。如果你想要看到拥有全部优化的结果,请不要使用源代码注释选项。
还可以在调试器的反汇编窗口中看到编译器生成的代码。但是,你在调试器中看到的代码不是优化后的版本,因为调试选项阻止了优化。调试器无法在完全优化的代码中设置断点,因为它没有行号信息。通常可以使用的内联汇编指令在代码中插入一个固定的断点:interrupt 3。代码是 __asm int 3
,或 __asm("int 3")
,或 __debugbreak ()
。如果你在调试器中运行优化的代码(release 版本),那么它将在 interrupt 3 断点处中断,并显示反汇编后的代码,可能没有关于函数名和变量名的信息。记住删除断点interrupt 3。
下面的示例显示编译器的汇编代码输出是什么样子的,以及如何使用它来改进代码。
1 | // Example 8.26a |
对于例 8.26a(32位模式)生成以下汇编代码:
1 | ; Example 8.26a compiled to assembly: |
编译器生成的大多数注释已经被我的注释(灰色)所取代。阅读和理解编译器生成的汇编代码需要一定的经验。让我详细解释一下上面的代码。看着有点怪异的名字 ?Func@@YAXQAHAAH@Z
是 Func
的名称,其中添加了许多关于函数类型及其参数的信息。这叫做名称重整(name mangling)。汇编的名称允许使用 “?” 、“@”和“$”。有关名称重整的详细信息在手册5:“Calling conventions for different C++ compilers and operating systems”中有解释。参数 a
和 r
在地址为 esp+8
和 esp+12
的栈上传递,并分别加载到 ecx
和 edx
中(在64位模式下,参数将在寄存器中传递,而不是在栈中)。ecx
现在包含数组 a
的第一个元素的地址,edx
包含 r
指向的变量的地址。引用和指针在汇编代码中是一样的。寄存器 ebx
在使用之前入栈,在函数返回之前出栈。这是因为寄存器使用约定不允许函数更改ebx
的值。只有寄存器 eax
、ecx
和 edx
可以自由更改。循环计数器 i
作为寄存器变量存储在 eax
中。循环初始化条件 i=0
,已翻译成指令 xor eax,eax
。这是一种将寄存器设置为 0 的常见方法,比 mov eax, 0
更快。循环体从标签 $B1$2
开始。这只是编译器为标签选择的任意名称。它使用 ebx
作为计算 i/2+r
的临时寄存器。指令 mov ebx,eax
/ shr ebx,31
将 i
的符号位复制到 ebx
的最小有效位。接下来的两条指令是 add ebx, eax
/ sar ebx,1
把这个加到i
上然后向右移动一个位置以便将i
除以2。指令 add ebx, DWORD PTR [edx]
加到 ebx
上的不是 edx
,而是地址位为 edx
中值的变量。方括号表示使用 edx
中的值作为内存指针。这是 r
所指向的变量。现在 ebx
包含 i/2+r
。下一条指令 mov DWORD PTR [ecx+eax*4],ebx
将这个结果存储在 a[i]
中。注意数组地址的计算是很高效的。ecx
包含数组开头的地址。eax
保存了索引 i
,这个索引必须乘以每个数组元素的大小(以字节为单位)才能计算出第 i
个元素的地址,int
的大小是 4。所以数组元素 a[i]
的地址是 ecx+eax*4
。结果 ebx
存储在地址 [ecx+eax*4]
。这都是在一条指令中完成的。CPU 支持这种指令来快速访问数组元素。指令 add eax,1
是循环增量 i++
。cmp eax, 100
/ jl $B1$2
是循环条件 i < 100
。它将 eax
与 100 进行比较,如果 i < 100
,则跳回回 $B1$2
标签。pop ebx
恢复在开始时保存的 ebx
值。ret
从函数返回。
汇编代码清单显示了三个可以进一步优化的地方。我们注意到的第一个地方是它对 i
的符号做了一些怪异的处理,以便将 i
除以2。编译器没有注意到 i
不能是负的,所以我们不需要关心符号位。我们可以通过将 i
声明为无符号整型数或在除以 2 之前将 i
的类型转换为无符号整型数,来告诉编译器这一点(参见14.5 整数除法)。
我们注意到的第二个地方是,r
所指向的值会从内存中重新加载 100次。这是因为我们忘记告诉编译器假设没有指针别名(8.3 编译器优化的障碍:指针别名)。添加编译器选项“assume no pointer aliasing”(如果可用的化)有可能改善代码。
第三个可以改进的是 r+i/2
可以通过归纳变量来计算因为它是循环索引的阶梯函数(staircase function)。整数除法将会防止编译器生成归纳变量,除非循环由 2 展开。(8.1 编译器是如何优化的:循环展开)。
结论是,我们可以帮助编译器优化例 8.26a,方法是将循环由 2 展开,并创建一个显式归纳变量。(这消除了对前两个改进建议的需要)。
1 | // Example 8.26b |
从例 8.26b编译器将生成以下汇编代码:
1 | ; Example 8.26b compiled to assembly: |
这个解决方案显然更好。尽管它在一个循环中执行了两次迭代,但循环体现在只包含 6条指令,而不是 9条。编译器用包含当前数组元素地址的第二个归纳变量(eax
)来代替 i
。它没有将 i
与循环条件中的100 进行比较,而是将数组指针 eax
与数组末端的地址进行比较,后者是它预先计算并存储在 edx
中。此外,这个解决方案少使用了一个寄存器,这样它就不必压入和弹出 ebx
。
9 优化内存访问
9.1 代码和数据缓存
高速缓存是计算机主存的代理。代理更小,更接近CPU,比主内存快,因此访问速度更快。可能有两级或者三级缓存,以便尽可能快地访问使用最多的数据。
CPU 的速度比RAM 内存的速度增长得快。因此,高效的缓存变得越来越重要。
9.2 缓存结构
如果你正在编写需要非顺序访问大数据结构的程序,并且希望防止缓存竞争,那么如果了解缓存是如何组织的,将会很有有用。如果你喜欢更多的启发式指导原则,可以跳过本节。
大多数缓存被组织成行和组。让我用一个例子来解释一下。我的示例是一个大小为 8kb、行大小为 64字节的缓存。每行包含 64个连续字节的内存。1KB 是 1024字节,所以我们可以计算出行数是 8*1024/64 = 128
。这些行按 32组,每组 4个缓存线的方式组织在一起。这意味着不能将特定的内存地址加载到任意的缓存行中。只能使用 32组缓存中的一个,但是在同一个组中的4行可以随意使用。我们可以通过公式:(set) = (memory address) / (line size) % (number of sets)
,来计算特定内存地址使用缓存中的哪个组。在这里,/
表示带截断的整数除法,%
表示模。例如,如果我们想从内存地址a = 10000
中读取数据,那么我们有(set) =(10000 / 64) % 32 = 28$
。这意味着必须将a
读入第 28组中的 4个缓存线之一。如果我们使用十六进制数,计算就会变得更简单,因为所有的数都是 2的幂。使用十六进制数,我们得到a = 0x2710
和(set) = (0x2710 / 0x40) % 0x20 = 0x1C$
。从地址 0x2710
读取或写入变量将导致缓存将地址 0x2700
到 0x273F
的全部 64
或 0x40
字节加载到组 0x1C
的4个缓存行之一。如果程序随后读取或写入该范围内的任何其他地址,那么该值已经在缓存中,因此我们不必等待另一个内存访问。
假设一个程序从地址 0x2710
读取数据,之后从地址 0x2F00
、0x3700
、0x3F00
和 0x4700
读取数据。这些地址都属于组 0x1C
。每组中只有 4条缓存线。如果缓存总是选择最近使用最少的缓存线,那么当我们从 0x4700
读取数据时,将会覆盖地址范围从 0x2700
到0x273F
的内容。再次读取地址0x2710
将导致缓存不命中。但是,如果程序从具有不同设置值的不同地址读取,那么包含地址范围从 0x2700
到 0x273F
的行仍然在缓存中。问题只出现在地址之间的间隔是 0x800
的倍数的情况下。我把这段距离称为关键步长。内存中的距离是关键步长的倍数的变量将争夺相同的缓存线。关键步长可以这样计算:(critical stride) = (number of sets) * (line size) = (total cache size) / (number of ways)
。
如果一个程序包含许多分散在内存中的变量和对象,那么就存在这样一种风险,即多个变量恰好被多个关键步长隔开,从而在数据缓存中引起竞争。如果程序内存中有许多分散的函数,那么在代码缓存中也会发生同样的情况。如果在程序的同一部分中使用的几个函数恰好被多个关键步间隔,那么这可能会在代码缓存中引起竞争。接下来的章节将描述各种避免这些问题的方法。
关于缓存如何工作的更多细节可以在 Wikipedia 的词条:CPU缓存中找到。
手册3:“The microarchitecture of Intel, AMD and VIA CPUs”涵盖了不同处理器的缓存组织细节。
9.3 一起使用的函数应该被放在一起
如果在代码内存中使用的函数彼此接近,那么代码缓存的工作效率最高。函数通常按照它们在源代码中出现的顺序存储。因此,最好将代码中最关键部分中使用的函数集中在同一个源文件中,这些函数彼此相邻。将经常使用的函数与很少使用的函数分开,并将很少使用的分支(如错误处理)放在函数的末尾或单独的函数中。
有时,为了模块化,函数被保存在不同的源文件中。例如,在一个源文件中有父类的成员函数,在另一个源文件中有派生类的成员函数,这样做可能比较方便。如果父类和派生类的成员函数是在程序的相同关键部分被调用的,那么在程序内存中保持这两个模块的连续是有利的。这可以通过控制模块链接的顺序来实现。链接顺序通常是模块在项目窗口或makefile
中出现的顺序。你可以通过向链接器请求映射文件来检查内存中函数的顺序。映射文件告诉每个函数相对于程序开始的地址。映射文件包含从静态库链接(.lib或.a )的库函数的地址,但不是动态库(.dll或.so)。没有一种简单的方法可以控制动态链接库函数的地址。
9.4 一起使用的变量应该被放在一起
缓存不命中的代码是非常高昂的。从缓存中加载一个变量只需要几个时钟周期,但是如果变量不在缓存中,那么将需要耗费超过 100个时钟周期的时间从RAM 加载它。
如果一起使用的数据片段在内存中彼此靠近存储,则缓存的工作效率最高。变量和对象最好在使用它们的函数中声明。这些变量和对象将存储在栈中,栈很可能在一级缓存中。7.1 不同类型变量的存储解释了不同类型的变量存储。如果可能,避免全局变量和静态变量,并避免动态内存分配(new
和delete
)。
面向对象编程是一种将数据存储在一起的有效方法。类的数据成员(也称为属性)总是一起存储在类的对象中。父类和派生类的数据成员一起存储在派生类的对象中(参见7.19 类和结构体)。
如果代码中有大数据结构,那么存储数据的顺序可能非常重要。例如,如果一个程序有两个数组,a
和 b
,并且元素的访问顺序是a[0]
, b[0]
, a[1]
, b[1]
,…,然后,你可以通过将数据组织为结构体的数组来提高性能:
1 | // Example 9.1a |
如果按如下方法组织数据,那么这个例子中的数据可以在内存中被按顺序访问:
1 | // Example 9.1b |
使用例 9.1b中这样的数据结构,程序代码中将不会有额外的开销。相反的,代码将变的更加简单,因为只需要计算一个数组的地址,而不是两个。
一些编译器将为不同的数组使用不同的内存空间,即使它们从未同时被使用过。例如:
1 | // Example 9.2a |
在这里,可以为 a
和 b
使用相同的内存区域,因为它们的活动范围不重叠。通过将 a
和 b
放入 union
中,可以节省大量缓存空间:
1 | // Example 9.2b |
当然,使用 union
不是一种安全的编程实践,因为如果 a
和 b
的使用重叠,编译器不会发出警告。你应该只对占用大量缓存空间的大型对象使用此方法。将简单变量放入union
中不是最佳选择,因为它会阻止寄存器变量的使用。
9.5 数据对齐
如果将变量存储在可被变量大小整除的内存地址中,则访问该变量的效率最高。例如,double
占用 8字节的存储空间。因此,最好将其存储在可被 8整除的地址中。大小应该总是 2的幂。大于 16字节的对象应该存储在可被 16整除的地址中。你通常可以假设编译器会自动处理这种对齐。
结构和类成员的对齐可能会造成缓存空间的浪费,如7.20 类的数据成员(变量实例)中例 7.39所展示的那样。
你可以选择按缓存线大小对齐大型对象和数组(通常是 64字节)。这可以确保对象或数组的开头与缓存线的开头一致。一些编译器会自动对齐大的静态数组,但你也可以通过以下方式显示指定:
1 | __declspec(align(64)) int BigArray[1024]; // Windows syntax |
或1
int BigArray[1024] __attribute__((aligned(64))); // Linux syntax
关于动态分配内存对齐的讨论参见9.7 容器类和12.8 对齐动态分配的内存、12.9 对齐RGB视频或三维向量。
9.6 动态分配内存
对象和数组可以通过 new
和 delete
或 malloc
和 free
动态分配。在编译时期不知道所需的内存大小时,这可能非常有用。下面是动态内存分配的四种典型用法:
- 可以在编译时不知道数组大小的情况下动态分配大数组。
- 当编译时不知道对象总数时,可以动态分配可变数量的对象。
- 可以动态分配文本字符串和类似大小可变对象。
- 对于栈来说太大的数组可以动态分配。
动态分配内存的优点有:
- 在某些情况下提供了更清晰的程序结构。
- 不会分配超过所需的空间。缓存效率与为了覆盖最坏的情况下最大可能的内存要求,固定大小的数组变的很大时相比,会高的多。
- 当不能预先给出所需内存空间的合理上限时,这是非常有用的。
动态分配内存的缺点有:
- 动态分配和释放内存的过程比其他类型的存储需要更多的时间。见7.1 不同类型变量的存储。
- 当以随机顺序分配和释放不同大小的对象时,堆空间就会变得碎片化。这使得数据缓存效率低下。
- 如果已分配的数组已满,则可能需要调整其大小。这可能需要分配一个新的更大的内存块,并将整个内容复制到新块中。指向旧块中的数据的任何指针都将失效。
- 当堆空间变得过于碎片化时,堆管理器将启动垃圾收集。此垃圾收集可能在不可预测的时间开始,并在用户等待响应的不方便的时间导致程序执行的延迟。
- 程序员有责任确保已分配的所有内容也被释放。如果不这样做,将导致堆被填满。这是一种常见的编程错误,称为内存泄漏。
- 序员有责任确保在释放对象之后没有对象被访问。没有这么做也是一个常见的编程错误。
- 所分配的内存可能不是最佳对齐的。有关如何对齐动态分配的内存,请参见12.8 对齐动态分配的内存。
- 编译器很难优化使用指针的代码,因为它不能排除别名(参见8.3 编译器优化的障碍:指针别名)。
- 当行长度在编译时是未知的,矩阵或多维数组的效率较低,因为在每次访问时需要额外的工作来计算行地址。编译器可能无法使用归纳变量对其进行优化。
在决定是否使用动态内存分配时,权衡利弊是很重要的。当数组的大小或对象的数量在编译时已知或可以知道合理的上限时,没有理由使用动态内存分配。
当分配的数量有限时,动态内存分配的成本可以忽略不计。因此,当一个程序有一个或几个可变大小的数组时,动态内存分配是有利的。另一种解决方案是将数组设置得非常大,以覆盖最坏的情况,但这会浪费缓存空间。如果一个程序有几个大数组,并且每个数组的大小是关键步长(参见9.2 缓存结构)的倍数,那么很可能会在数据缓存中引起竞争。
如果一个数组中的元素数量在程序执行期间增长,那么最好从一开始就分配最终的数组大小,而不是一步一步地分配更多的空间。在大多数系统中,你无法增加已经分配的内存块的大小。如果最终大小无法预测,或者预测结果太小,那么就需要分配一个新的更大内存块,并将旧内存块的内容复制到新的更大内存块的开头。当然,这是低效的,并且会导致堆空间变得碎片化。另一种方法是保留多个内存块,要么以链表的形式,要么以内存块的索引的形式。具有多个内存块的方法使得对单个数组元素的访问更加复杂和耗时。
一个可变数量的对象集合通常被实现为一个链表。链表中的每个元素都有自己的内存块和指向下一个块的指针。链表的效率不如线性数组,原因如下:
- 每个对象都是单独分配的。分配、释放和垃圾收集需要大量的时间。
- 对象没有连续地存储在内存中。这会降低数据缓存的效率。
- 额外的内存空间用于链接指针和堆管理器为每个分配的块存储的信息。
- 遍历链表比遍历线性数组要花费更多的时间。在加载前一个元素指针之前,不能加载任何指针。这就形成了一个关键的依赖链,这会妨碍乱序执行。
为所有对象分配一个大内存块(内存池)通常比为每个对象分配一个小内存块效率更高。
使用 new
和 delete
分配可变大小的数组的一个鲜为人知的替代方法是使用 alloca
分配来代替。这是一个在栈上而不是堆上分配内存的函数。内存空间在当从调用 alloca
的函数返回时会被自动释放。在使用 alloca
时,不需要显式地释放空间。与 new
和 delete
或 malloc
和 free
相比,alloca
的优势有:
- 分配过程的开销很小,因为微处理器有硬件支持对栈的操作。
- 由于堆栈的先入后出特性,内存空间不会变得支离破碎。
- 重新分配没有成本,因为它在函数返回时将自动执行。不需要垃圾收集。
- 所分配的内存与栈上的其他对象是连续的,这使得数据缓存非常高效。
下面的例子将展示如何适应alloca
分配可变大小的数组:
1 |
|
显然,函数不应该返回任何使用 alloca
分配的指针或引用,因为它在函数返回时被释放。alloca
可能与结构化异常处理不兼容。有关使用 alloca
的限制,请参阅编译器手册。
C99 扩展支持可变大小的数组。这个特性是有争议的,并且只在 C 中可用,而不能在C++ 中使用。你可以使用 alloca
而不是可变大小的数组,因为它提供了相同的功能。
9.7 容器类
每当使用动态内存分配时,建议将分配的内存包装到容器类中。容器类必须具有析构函数,以确保所分配的所有内容也被释放。这是防止内存泄漏和与动态内存分配相关的其他常见编程错误的最佳方法。
容器类还可以方便地向数组中添加边界检查,以及使用先进的数据结构,如先进先出(或先进后出)访问、排序和搜索工具、二叉树、哈希映射等。
通常以模板的形式创建容器类,其中包含的对象类型作为模板参数提供。使用模板没有性能成本。
现成的容器类模板可用于许多不同的用途。最常用的容器集是标准模板库(STL ),它时大多数现代 C++编译器 自带的。使用现成容器的优点是你不必重新发明轮子。STL 中的容器是通用的、灵活的、经过良好测试的,对于许多不同的用途都非常有用。
然而,STL 是以通用性和灵活性为准则而设计,而执行速度、内存经济性、缓存效率和代码大小的优先级较低。特别是在 STL 中,内存分配存在不必要的浪费。一些 STL 模板,如 list
、set
和 map
,甚至可能分配比容器中对象更大的内存块。STL deque
(双向链表)为每四个对象分配一个内存块。STL vector
将所有的对象都存储在同一个内存块中,当这快内存被填满时会重新分配,这种情况经常发生,因为块大小每次只增长 50%或更少。在一个实验中,10个元素被一个接一个地插入STL vector
中,结果导致内存一共重新分配了 7次,大小分别是 1、2、3、4、5、6和13(MS Visual Studio 2008 version)。可以在将第一个对象添加到 vector
之前,可以通过调用 vector::reserve
预先分配预测或估计的最终大小的内存来防止这种浪费的行为。其他 STL 容器没有预先分配内存的功能。
频繁地使用new
和delete
(或malloc
和free
)分配和释放内存会导致内存碎片化和缓存效率低下。如上所述,这会导致内存管理和垃圾收集的开销很大。
STL 的通用性还会影响代码的大小。实际上STL 因代码膨胀和复杂性而饱受批评(en.wikipedia.org/wiki/Standard_Template_Library)。存储在 STL 容器中的对象允许具有构造函数和析构函数。每次移动对象时都会调用每个对象的复制构造函数和析构函数,而这种情况经常发生。如果存储的对象本身就是容器,那么这是必要的。但是在 STL 使用一个或多个vector
中实现一个矩阵,就像我们经常看到的那样,这肯定是一个非常低效的解决方案。
许多容器使用链表。链表是使容器可扩展的一种简便方法,但效率非常低。在大多数情况下,线性数组比链表快。
STL 中用于访问容器元素的所谓的迭代器对于许多程序员来说很麻烦,如果你可以使用带有简单索引的线性列表,那么它们就不是必需的。一个好的编译器可以在某些情况下优化掉迭代器的额外开销(但不是全部)。
幸运的是,在执行速度、节约内存和代码大小比代码通用性具有更高优先级的情况下,可以使用效率更高的替代方案。最重要的补救措施是内存池。将多个对象存储在一个大内存块中比将每个对象存储在它自己分配的内存块中更有效。如果没有复制构造函数和析构函数要调用,可以通过对 memcpy
的一次调用复制或移动包含许多对象的大块,而不是单独移动每个对象。
我实现了一组示例容器类,它们使用这些方法来提高效率。这些文件作为本手册的附录可以在www.agner.org/optimize/cppexamples.zip上获得,其中包含用于不同用途的容器类和模板。所有这些示例都针对执行速度和最小化内存碎片进行了优化。为了安全起见,包含了边界检查,但是如果出于性能原因需要,可以在调试之后删除。在 STL 的性能不令人满意的情况下,可以使用这些示例容器。
在为特定用途选择容器时,应考虑以下因素:
- 包含一个还是多个元素?如果容器包含一个元素,那么可以使用智能指针(见7.9 智能指针)。
- 编译时是否知道大小?如果在编译时已知元素的数量,或者可以设置不太大的上限,那么最优解决方案是一个固定大小的数组或容器,而不需要动态内存分配。但是,如果数组或容器对于栈来说太大的时候,则可能需要动态内存分配。
- 在存储第一个元素之前,大小是否已知?如果在存储第一个元素之前可以知道元素的总数(或者有一个合理的估计),那么最好使用允许预先分配(
reserve
)内存的容器,而不是分段分配内存或当内存块太小的时候重新分配。 - 对象是连续编号的么?如果对象是由连续的索引或有限范围内的键标识的,那么简单的数组是高效的解决方案。
- 对象是以先进先出的方式访问的么?如果在先进先出(FIFO)的基础上访问对象,则使用队列。将队列作为循环缓冲区而不是链表使用更高效。
- 对象是以先进后出的方式访问的么?如果对象是在先入后出(FILO)的基础上访问的,那么使用带有栈顶部索引的线性数组。
- 对象是由键标识的么?如果键值被限制在一个较窄的范围内,那么可以使用一个简单的数组。如果对象的数量很多,那么最高效的解决方案可能是二叉树或哈希图。
- 对象有顺序吗?如果你需要做这样的搜素:“离元素 x 最近的是哪个?”或者 “在 x 和 y之间有多少个元素?”,那么你可以使用有序列表或者二叉树。
- 添加所有对象之后是否需要搜索?如果需要搜索工具,但必须在容器中存储了所有对象之后,那么线性数组将是一个高效的解决方案。在添加所有元素之后对数组进行排序,然后使用二分搜索来查找元素。哈希表也可能是一种高效的解决方案。
- 添加所有对象之前是否需要搜索?如需要搜索工具,并且可以随时添加新对象,那么解决方案就更复杂了。如果元素的总数很少,那么有序列表是最高效的解决方案,因为它的简单。但是如果列表很大,有序列表会非常低效,因为在列表中插入一个新元素会导致所有后续元素都需要移动。在这种情况下我们需要二叉树或者哈希表。如果元素是有序的,并且在一定间隔后就会有搜素请求,那么可以使用二叉树。哈希表则可以在元素没有特定顺序但又唯一的键标识时使用。
- 对象是否具有混合类型或大小?可以在同一个内存池中存储不同类型的对象或不同长度的字符串。见 www.agner.org/optimize/cppexamples.zip。如果在编译时知道元素的数量和类型,那么就不需要使用容器或内存池。
- 是否要对齐?一些应用程序要求数据按可以被整除的地址对齐。特别是使用向量指令时,需要对齐的地址可以被 16整出。在某些情况下,将数据结构对齐到可被缓存线大小整除的地址(通常为64)可以提高性能。
- 是否使用多线程?如果多个线程可以同时添加、删除或修改对象,那么容器类通常不是线程安全的。在多线程应用程序中,为每个线程设置单独的容器要比临时锁定一个容器以供每个线程独占访问高效的多。
- 有指向包含的对象的指针么?将指针指向包含的对象可能是不安全的,因为容器可能在需要重新分配内存时移动对象。容器内的对象应该通过其在容器中的索引或键来标识,而不是通过指针或引用。但是,如果没有其他线程访问容器,则可以将指向此类对象的指针或引用传递给不添加或删除任何对象的函数。
- 容器可以被回收么?创建和删除容器的消耗很大。如果程序的逻辑允许,复用一个容器可能比删除它再重新创建一个更高效。
我在www.agner.org/optimize/cppexamples.zip中提供了几个合适的容器类模板示例。如果不需要标准模板库容器的通用性和灵活性,那么可以使用它们作为标准模板库(STL )的替代品。你可以编写自己的容器类,或者修改可用的容器类来满足特定的需求。
9.8 字符串
文本字符串通常具有在编译时不知道的可变长度。文本字符串在 string
、wstring
或 CString
等类中的存储使用 new
和 delete
来在每次创建或修改字符串时分配一个新的内存块。如果一个程序创建或修改了很多字符串,这可能是非常低效的。
在大多数情况下,处理字符串最快的方法是使用老式 C 风格的字符数组。字符串可以通过 C 函数如 strcpy
、strcat
、strlen
、sprintf
等进行操作。但是要注意,这些函数没有检查数组是否溢出。数组溢出会导致在程序的其他地方出现难以预测的错误,这些错误很难诊断。程序员有责任确保数组足够大,能够处理包括终止符(0)在内的字符串,并在必要时进行溢出检查。在www.agner.org/optimize/asmlib.zip的 asmlib 库中提供了常用字符串函数的快速版本以及用于字符串搜索和解析的高效函数。
如果希望在不影响安全性的情况下提高速度,可以将所有字符串存储在内存池中,如上所述,在本手册的附录www.agner.org/optimize/cppexamples.zip中提供相关示例。
9.9 按顺序访问数据
当按顺序访问数据时,缓存的工作效率最高。当逆序访问数据时,它的工作效率略低,而当以随机方式访问数据时,它的工作效率则低得多。这对读取和写入数据都是适用的。
多维数组应该以在最内层循环中变化最后一个索引的方式进行访问。这反映了元素在内存中存储的顺序。例如:
1 | // Example 9.4 |
不要交换这两个循环的顺序(除非是在 Fortran 中,具有相反的存储顺序)。
9.10 在大数据结构中的缓存竞争
按顺序访问多维数组并不总是可能的。一些应用程序(例如,在线性代数中)需要其他访问模式。如果一个大矩阵中的行之间的距离恰好等于关键步长,就会导致严重的延迟,如9.2 缓存组织所述。如果矩阵行的大小(以字节为单位)是 2 的高次幂,就会发生这种情况。
下面的例子说明了这一点。我的例子是一个对二次矩阵进行转置的函数,即每个元素矩阵 [r][c]
与元素矩阵 [c][r]
交换。
1 | // Example 9.5a |
矩阵的转置和以对角线为轴做镜像是一样的。对角线以下的每个元素矩阵 [r][c]
在对角线以上的镜像位置与元素矩阵 [c][r]
交换。例 9.5a中的循环 c 从最左边的列到对角线。对角线上的元素保持不变。
这段代码的问题是,如果对角线以下的元素矩阵 [r][c]
是逐行访问的,那么对角线以上的镜像元素矩阵 [c][r]
是逐列访问的。
假设现在我们在奔腾4电脑上运行这段代码,矩阵的大小是 64。电脑的一级缓存为 8 kb = 8192 bytes
,4 路,行大小为 64。每个缓存行可以保存8个 double
变量,每个变量的大小为8个字节。关键步长为 $8192/4=2048 bytes = 4 rows$。
让我们看看循环内部发生了什么,例如当 r = 28
时。我们从对角线以下的第 28行取出元素,并将这些元素与对角线以上的第 28列交换。第 28行中的前 8个元素共享同一缓存线。因为缓存线按行而不是按列缓存,在第 28列中的 8个元素将进入 8个不同的缓存行中。每四个高速缓存线属于同一组高速缓存。当我们操作到第 28列中的16号元素时,缓存将收回该列中0号使用的缓存线。17号元素将覆盖1号元素使用的缓存线,18号元素将覆盖 2号元素使用的缓存线,依此类推。这意味着当我们将第 29列与第 29行交换时,对角线以上使用的所有缓存线都被覆盖了。因为在我们需要下一个元素之前,它会被删除,每个缓存线必须重新加载 8次。我已经通过使用不同矩阵大小的奔腾4上的示例9.5a来测量转置矩阵所需的时间来证实这一点。我的实验结果如下,时间单位是每个数组元素所需要要的时钟周期。
Matrix Size | Total kilobytes | Time per element |
---|---|---|
63*63 | 31 | 11.6 |
64*64 | 32 | 16.4 |
65*65 | 33 | 11.8 |
127*127 | 126 | 12.2 |
128*128 | 128 | 17.4 |
129*129 | 130 | 14.4 |
511*511 | 2040 | 38.7 |
512*512 | 2048 | 230.7 |
513*513 | 2056 | 38.1 |
Table 9.1. Time for transposition of different size matrices, clock cycles per element.
从表中可以看出,当矩阵的大小是一级缓存大小的倍数时,转置矩阵要多花 40%的时间。这是因为关键步长是矩阵行的倍数。由于无序执行机制可以预先加载数据,延迟比一级缓存从二级缓存中重新加载数据的时间少。
当竞争发生在二级缓存中时,这种效果更为显著。二级缓存$512 kb$,8路。二级缓存的关键步长是$512 kb / 8 = 64 kb$。这对应于$512512$矩阵中的16行数据。我在*表 9.1中的实验结果表明,在二级缓存中发生竞争时,转置矩阵所需的时间是不发生竞争时的 6倍。这种效果在二级缓存竞争中比在一级缓存竞争中强得多的原因是二级缓存一次不能预加载多行。
解决这个问题的一个简单方法是使矩阵中的行比需要的长,以避免关键步长是矩阵行大小的倍数。我试着让矩阵的大小为$512*520$,包含不使用最后 8列。这消除了竞争,时间消耗减少到 36个时钟周期。
在某些情况下,不可能向矩阵中添加未使用的列。例如,一个数学函数库应该能够有效地处理所有大小的矩阵。在这种情况下,一个有效的解决方案是将矩阵分成更小的正方形,一次处理一个正方形。这被称为square blocking 或tiling。示例9.5b演示了这种技术:
1 | // Example 9.5b |
在我的实验中,使用这段代码,对于512*512的矩阵来说,每个元素消耗50个时钟周期。
二级缓存中竞争的代价是如此的昂贵,因此对它们采取措施非常重要。因此,你应该了解矩阵中列数为 2的高次幂的情况。一级缓存中的竞争消耗较少,可能不值得为了一级缓存中使用像square blocking这么复杂的技术。
Squre blocking以及类似的技术在 S. Goedecker 和 A. Hoisie 2001年出版的 “Performance Optimization of Numerically Intensive Codes”一书中有更详细的描述。
9.11 显示缓存控制
具有 SSE 和 SSE2 指令集的微处理器具有某些指令,允许你操作数据缓存。这些指令可以从支持指令集函数(如Microsoft 、Inte l和 Gnu )的编译器中访问。其他编译器需要通过汇编代码来访问这些指令。
Function | Assembly name | Intrinsic function name | Instruction set |
---|---|---|---|
Prefetch | PREFETCH |
_mm_prefetch |
SSE |
Store 4 bytes without cache | MOVNTI |
_mm_stream_si32 |
SSE2 |
Store 8 bytes without cache | MOVNTQ |
_mm_stream_pi |
SSE |
Store 16 bytes without cache | MOVNTPS |
_mm_stream_ps |
SSE |
Store 16 bytes without cache | MOVNTPD |
_mm_stream_pd |
SSE2 |
Store 16 bytes without cache | MOVNTDQ |
_mm_stream_si128 |
SSE2 |
Table 9.2. Cache control instructions.
除了表 9.2中提到的指令外,还有其他缓存控制指令,如fluse 和fence 指令,但这些指令与优化几乎没有关系。
预加载数据
预取指令可用于预先加载我们希望稍后在程序流中使用的缓存线。然而,在我测试的所有示例中,这并没有提高执行速度。原因是由于无序执行和先进的预测机制,现代处理器能自动预取数据。现代微处理器能够在包含多个具有不同步长的数据流的有规律访问模式下自动预取数据。因此,如果按照固定步长有规律地访问数据,则不必显式地预取数据。
未缓存内存存储(uncached memory store)
未缓存写入(uncached write)比未缓存读取(uncached read)开销更大,因为写入会需要先读取。然后再写回整个缓存行。
所谓的非时序写指令(MOVNT)就是为了解决这个问题而设计的。这些指令直接写入内存,而无需加载缓存线。如果我们正在写入未缓存的内存中,并且我们不希望在缓存线被清除之前再次从相同的或附近的地址读取数据,那么这是非常有利的。不要在同一个内存区域同时使用非时序的写操作和普通的写操作或读操作。
非时序写指令不适合例 9.5,因为我们在相同的地址读和写,所以无论如何都会加载缓存线。如果我们修改例 9.5,使它只写,那么非时序写指令的效果就会很明显。下面的示例对一个矩阵进行置换,并将结果存储在不同的数组中。
1 | // Example 9.6a |
这个函数逐列写入矩阵 a
,而由于关键步长导致所有写入都在一级缓存和二级缓存中都需要加载新的缓存线。使用非时序写指令可以防止二级缓存为矩阵 a
的加载任何缓存线:
1 | // Example 9.6b. |
在奔腾4计算机上测量了不同矩阵大小下每个矩阵单元的执行时间。测量结果如下:
Matrix size | Time per element Example 9.6a | Time per element Example 9.6b |
---|---|---|
64*64 | 14.0 | 80.8 |
65*65 | 13.6 | 80.9 |
512*512 | 378.7 | 168.5 |
513*513 | 58.7 | 168.3 |
Table 9.3. Time for transposing and copying different size matrices, clock cycles per element.
如表 9.3所示,当且仅当可能出现二级缓存不命中时,不经过缓存存储数据的方法才是有利的。$6464$的矩阵大小会导致一级缓存中出现不命中,但这对总执行时间几乎没有任何影响,因为一个存储操作上的缓存不命中不会延迟后续指令。$512 512$的矩阵大小导致二级缓存不命中。这对执行时间有非常显著的影响,因为内存总线已经饱和。这是可以通过使用非时序写来改善。如果缓存竞争可以通过其他方式防止,如9.10 在大数据结构中的缓存竞争所述,那么非时序写指令就不是最优的。
使用表 9.2中所列的指令有一定的限制。所有这些指令都要求微处理器具有表中所列的 SSE 或 SSE2 指令集。16字节指令MOVNTPS
、MOVNTPD
和MOVNTDQ
要求操作系统支持 XMM 寄存器;参见13 使用不同指令集生成多个版本的关键代码。
当使用#pragma vector nontemporal
时,Intel编译器 可以在向量化代码中自动插入非时态写操作。然而,在例 9.6b中这并不适用。
在任何浮点指令之前,MOVNTQ
指令后必须跟着 EMMS
指令。代码为_mm_empty()
,如例 9.6b所示。MOVNTQ
指令不能在 64位Windows 设备驱动程序中使用。
10 多线程
CPU 的时钟频率受到物理因素的限制。在时钟频率有限的情况下,提高 CPU 密集型程序的吞吐量的方法是同时做多个事情。有三种方法可以并行地执行任务:
- 使用多个 CPU 或 多 核 CPU,如本章所述。
- 使用现代 CPU 的乱序执行能力,如第11章所述。
- 使用现代 CPU 的向量操作,如第12章所述。
多数现代 CPU 都拥有两个或更多个核心,可以预期的是,在未来核心的数量还会继续增加。为了使用多个 CPU 或者多个 CPU 核心,我们需要将任务划分到不同的线程。这里有两个主要的方法:功能分解和数据分解。功能分解意味着不同的线程做不同的工作。例如,一个线程处理用户界面,另一个线程处理和远程数据库的通信,第三个线程处理数学计算。将用户界面和耗时任务放在不同的线程中是很重要的,否则响应时间会变的长且不规则,这是很令人讨厌的。将耗时的任务放在低优先级的单独线程中通常是很有帮助的。
然而,在许多情况下,一个任务就消耗了大部分资源。在这种情况下,我们需要将数据分割成多个块,以便利用多个处理器内核。然后每个线程应该处理自己的数据块。这就是数据分解。
在决定并行处理是否有利时,区分粗粒度并行和细粒度并行非常重要。粗粒度并行是指长序列的操作可以独立于并行运行的其他任务的情况。细粒度并行是指任务被划分为许多小的子任务,但是在与其他子任务进行必要的协调之前,不可能在特定的子任务上工作很长时间。
由于不同内核之间的通信和同步比较慢,因此粗粒度并行比使用细粒度并行效率更高。如果粒度太细,那么将任务拆分为多个线程是没有优势的。无序执行(第11章)和向量操作(第12章)是利用细粒度并行的更有用的方法。
使用多个 CPU 内核的方法是将工作划分为多个线程。7.31 线程讨论了线程的使用。在数据分解的情况下,我们最好不要有比系统中可用的内核或逻辑处理器数量更多的具有相同优先级的线程。可用逻辑处理器的数量可以通过系统函数获得(例如 Windows 中的 GetProcessAffinityMask
)。
有几种方法可以在多个CPU 内核之间划分工作负载:
- 定义多个线程,并在每个线程中投入等量的工作。此方法适用于所有编译器。
- 使用自动并行化。Gnu、Intel 和 PathScale 编译器可以自动检测代码中的并行化机会,并将其划分为多个线程,但编译器可能无法找到数据的最佳分解方案。
- 使用 OpenMP 指令。OpenMP 是 C++ 和 Fortran 中定义并行处理的标准。这些指令被 Microsoft、Intel、PathScale 和 Gnu 编译器所支持。有关详细信息,请参见www.openmp.org和编译器手册。
- 使用具有内部使用多线程的函数库,例如 Intel Math Kernel Library。
多个 CPU 内核或逻辑处理器通常共享相同的缓存,至少在最后一级缓存中是这样,在某些情况下甚至共享相同的一级缓存。共享相同缓存的优点是线程之间的通信变得更快,并且线程可以共享相同的代码和只读数据。缺点是,如果线程使用不同的内存区域,缓存就会被填满,如果线程写入相同的内存区域,就会发生缓存竞争。
只读的数据可以在多个线程之间共享,而可以修改的数据应该被每个线程单独存储。让两个或多个线程写入同一缓存行是没有任何好处的,因为线程会使彼此的缓存无效,并造成较大的延迟。每个线程都有自己的堆栈。使数据特定于线程的最简单方法是在线程函数中声明它,使其为线程本地的,以便将其存储在堆栈中。或者,你可以为包含特定于线程的数据定义结构或类,并为每个线程创建一个实例。此结构或类应至少按缓存线大小进行对齐,以避免多个线程写入同一缓存线。在现代处理器上,缓存线大小通常为 64个字节。在未来的处理器上,缓存线大小可能会更大(128 或 256字节)。
线程之间有很多通信和同步方法,如信号量、互斥量和消息系统。所有这些方法都很耗时。因此,应该对数据和资源进行组织,以便尽可能减少线程之间的必要通信。例如,如果多个线程共享相同的队列、列表、数据库或者其他数据结构,那么你可以考虑能否给每个线程分配自己的数据,当所有线程完成耗时的数据处理后,最后再将多个数据合并起来。
如果在一个只有一个逻辑处理器的系统上运行多个线程,而这些线程会争夺相同的资源,那么这不是一种优势。但是将耗时计算放在一个优先级低于用户界面的单独线程中可能是一个好主意。将文件访问和网络访问放在不同的线程中也很有用,这样一个线程可以在另一个线程等待硬盘或网络响应时进行计算。
Intel 提供了各种支持多线程软件的开发工具。参见《Intel 技术期刊》2007年第11卷第4期。
10.1 同步多线程技术
许多微处理器能够在每个内核中运行两个线程。例如,一个有 4个内核的处理器可以同时运行 8个线程。这个处理器有四个物理处理器,但有八个逻辑处理器。
“超线程”是 Intel 对同步多线程的称呼。在同一个内核中运行的两个线程总是会争夺相同的资源,比如缓存和执行单元。如果有任何的共享资源是制约性能的因素,那么使用同步多线程没有任何优势。相反,由于缓存回收和其他资源冲突,每个线程的运行速度可能不到一半。但是,如果大部分时间存在缓存不命中、分支错误预测或长依赖链,那么每个线程的运行速度将超过单线程速度的一半。在这种情况下,使用同步多线程有一点优势,但性能不会提高一倍。与另一个线程共享内核资源的线程总是比在内核中单独运行的线程运行得慢。
为了确定在特定的应用程序中使用同步多线程是否有利,常常需要进行测试。
如果同步多线程没有优势,那么有必要查询某些操作系统函数(例如Windows 中的 GetLogicalProcessorInformation
),以确定处理器是否具有同步多线程。如果有,那么你可以通过只使用序号为偶数的逻辑处理器(0、2、4等)来避免同步多线程。旧的操作系统缺乏区分物理处理器数量和逻辑处理器数量的必要功能。
没有一个可以告诉处理器给一个线程分配比另一个线程更高优先级的办法。因此,低优先级线程经常会从运行在同一内核中的高优先级线程窃取资源。操作系统的责任是避免在同一个处理器内核中运行优先级相差很大的两个线程。不幸的是,当代的操作系统并不能很好地解决这个问题。
Intel编译器 能够生成两个线程,其中一个线程用于为另一个线程预加载数据。然而,在大多数情况下,自动硬件预加载比软件预加载效率更高。
11 乱序执行
除了一些小的低功耗 CPU (如Intel Atom )之外,所有现代 x86 CPU 都可以乱序执行指令或同时执行多个操作。下面的示例展示了如何利用这种功能:
1 | // Example 11.1a |
这个表达式计算为 ((a+b)+c)+d
。这是一个依赖链,每个加法都必须等待前一个的结果。你可以这样写来提高效率:
1 | // Example 11.1b |
两个括号可以独立计算。在计算完 (a+b)
之前,CPU 将开始计算 (c+d)
。这可以节省几个时钟周期。你不能假定优化编译器会自动将例 11.1a中的代码更改为例 11.1b,尽管这似乎是一件显而易见的事情。编译器不对浮点表达式进行这种优化的原因是,它可能会导致精度的损失,如8.1 编译器是如何优化的:代数化简所述。你必须手动添加这些括号。
当依赖链较长时,其影响更大。在循环中通常是这样。考虑下面的例子,它计算100个数字的和:
1 | // Example 11.2a |
这里有一个很长的依赖链。如果浮点加法需要 5个时钟周期,那么这个循环大约需要 500个时钟周期。通过展开循环并将依赖链一分为二,可以显著提高性能:
1 | // Example 11.2b |
如果微处理器从时间 T 到 T+5 对sum1
做加法,那么它可以从时间 T+1 到 T+6 对 sum2
做加法,整个循环只需要 256个时钟周期。
在循环中,每个迭代都需要前一个迭代的结果,这种循环中的计算称为循环依赖链。这样的依赖链可能非常长,并且非常耗时。如果这样的依赖链能够被打破,将会有很多收益。sum1
和 sum2
这两个求和变量称为累加器。当前的CPU只有一个浮点加法单元,但是如上所述,这个单元是流水线操作的,因此它可以在前一个加法完成之前开始一个新的加法。
浮点加法和乘法的累加器的最佳数量可能是 3个或 4个,这取决于CPU。
如果循环的次数不能被展开因子整除,那么展开循环就会变得稍微复杂一些。例如,如果例 11.2b中 list
中的元素数量是奇数,那么我们必须在循环之外计算最后一个元素,或者向 list
中添加一个额外的伪元素,使这个额外的元素为零。
如果没有循环依赖链,则不需要展开循环并使用多个累加器。具有乱序执行功能的微处理器可以重叠迭代,并在前一个迭代完成之前开始下一个迭代的计算。例如:
1 | // Example 11.3 |
具有无序功能的微处理器非常智能。他们可以检测到例 11.3中循环的一次迭代中的寄存器临时值独立于前一次迭代中的值。这允许它在计算完前一个值之前开始计算一个新的临时值。它通过为 temp
分配一个新的物理寄存器来实现这一点,即使在机器码中出现的逻辑寄存器是相同的。这叫做寄存器重命名。CPU可以保留同一逻辑寄存器的许多重命名实例。
这种优势是自动产生的。没有理由展开循环并使用 temp1
和 temp2
。现代 CPU 能够在满足某些条件的情况下重命名寄存器和并行执行多个计算。使 CPU 能够重叠循环迭代计算的条件为:
- 没有循环依赖链。一次迭代的计算不应该依赖于前一次迭代的结果(循环计数器除外,当它是整数时,它的计算速度很快)。
- 所有的中间结果都应该保存在寄存器中,而不是内存中。重命名机制只对寄存器有效,而对内存或缓存中的变量无效。在例 11.3中,即使没有
register
关键字,大多数编译器也会使temp
成为寄存器变量。CodeGear编译器不能生成浮点寄存器变量,但会在内存中保存临时变量。这会阻止 CPU 的重叠计算。 - 循环分支需要可以被预测。如果重复计数很大或恒定,则不存在此问题。如果循环计数很小且不断变化,那么CPU可能偶尔会预测循环分支已经退出了,而实际上它没有,因此无法开始下一个计算。然而,乱序执行机制允许CPU提前增加循环计数器,这样它就可以在判断错误之前及时发现。因此,你不必太担心这种情况。
通常,乱序执行机制是自动工作的。但是,程序员可以做一些事情来最大限度地利用乱序执行。最重要的是避免过长的依赖链。你可以做的另一件事是混合不同类型的操作,以便在 CPU 中的不同执行单元之间均匀地分配工作。只要不需要在整数和浮点数之间进行转换,就可以混合使用整数和浮点数计算。将浮点加法与浮点乘法混合使用、将简单整数与向量整数操作混合使用、将数学计算与内存访问混合使用也有很大的好处。
过长的依赖链会给 CPU 的乱序执行资源带来了压力,即使它们没有进入循环的下一次迭代。一个现代的 CPU 通常可以处理 100多个待定操作(参见手册3:“The microarchitecture of Intel, AMD and VIA CPUs”)。将循环分割并存储中间结果,对打破一个非常长的依赖链是有帮助的。
12 使用向量操作
现如今的微处理器拥有向量指令,使得同时对向量的所有元素进行操作成为可能。这也称为单指令多数据操作(SIMD)。每个向量的总大小可以是 64位(MMX)、128位(XMM)、256位(YMM)和 512位(ZMM)。
在大型数据集中,对多个数据元素执行相同操作且程序逻辑允许并行计算时,向量操作非常有用。例如图像处理、声音处理以及向量和矩阵的数学运算。本质上是串行的算法,比如大多数排序算法,不太适合向量操作。严重依赖于查找表或需要大量数据变换的算法(如许多加密算法)可能也不太适合向量操作。
向量操作使用一组特殊的向量寄存器。如果 SSE2 指令集可用,每个向量寄存器的最大大小为128位(XMM );如果微处理器和操作系统支持 AVX 指令集,则为256位(YMM );当 AVX512 指令集可用时为 512位。每个向量中的元素数量取决于数据元素的大小和类型,如下所示:
Type of elements | Size of each elements, bits | Number of elements | Total size of vector, bits | Instruction set |
---|---|---|---|---|
char |
8 | 8 | 64 | MMX |
short int |
16 | 4 | 64 | MMX |
int |
32 | 2 | 64 | MMX |
int64_t |
64 | 1 | 64 | MMX |
char |
8 | 16 | 128 | SSE2 |
short int |
16 | 8 | 128 | SSE2 |
int |
32 | 4 | 128 | SSE2 |
int64_t |
64 | 2 | 128 | SSE2 |
float |
32 | 4 | 128 | SSE |
double |
64 | 2 | 128 | SSE2 |
char |
8 | 32 | 256 | AVX2 |
short int |
16 | 16 | 256 | AVX2 |
int |
32 | 8 | 256 | AVX2 |
int64_t |
64 | 4 | 256 | AVX2 |
float |
32 | 8 | 256 | AVX |
double |
64 | 4 | 256 | AVX |
char |
8 | 64 | 512 | AVX512BW |
short int |
16 | 32 | 512 | AVX512BW |
int |
32 | 16 | 512 | AVX512 |
int64_t |
64 | 8 | 512 | AVX512 |
float |
32 | 16 | 512 | AVX512 |
double |
64 | 8 | 512 | AVX512 |
Table 12.1. Vector sizes available in different instruction set extensions
例如,当SSE2 指令集可用时,一个 128位的XMM 寄存器可以组织成一个包含 8个 16位整数或 4个浮点数的向量。应该避免使用老旧的 64位MMX 寄存器,因为它们不能与x87 类型的浮点代码混合使用。
128位的 XMM 向量必须按照 16对齐,即存储在一个可以被 16整除的内存地址中(见下文)。256位的YMM 向量最好按 32对齐,而512位 ZMM 寄存器需要按 64对齐,但是在编译 AVX 和之后的指令集时,对齐的要求不那么严格。
在较新的处理器上,向量操作特别快。许多处理器可以像标量一样快速地计算向量(标量意味着单个元素)。支持新向量大小的第一代处理器的执行单元、内存端口(memory ports)等通常只有最大向量大小的一半。为了处理向量中所有元素,这些单元必须被使用两次。
数据元素越小,向量运算的使用就越有优势。例如,你可以同时进行四个 float
的加法,而对于 double
则只有两个。在现在CPU 中,如果数据很符合向量寄存器,则使用向量运算几乎总是有利的。如果要将正确的数据放入正确的向量元素中,需要进行大量的数据操作,那么这样做可能并没有什么优势。
12.1 AVX 指令集和 YMM 寄存器
128位的 XMM 寄存器在 AVX 指令集中被扩展为256位的 YMM 寄存器。AVX 指令集的主要优点是它允许更大的浮点向量。还有其他一些优势可能会在一定程度上提高性能。AVX2 指令集也允许 256位整数向量。
为 AVX 指令集编译的代码只有在 CPU 和操作系统都支持 AVX 的情况下才能运行。在 Windows 7 、 Windows Server 2008 R2 和 Linux 内核2.6.30及以上版本中支持 AVX 。 Microsoft 、 Intel 、 Gnu 和 Clang 的最新编译器支持 AVX 指令集。
在某些英特尔处理器上混合使用和不使用 AVX 支持编译的代码时会出现问题。当从 AVX 代码转换到 非AVX 代码时,由于YMM 寄存器状态的变化,会造成性能损失。应该在从 AVX 代码转换到 非AVX 代码之前调用内部函数 _mm256_zeroupper()
来避免这种损失。在以下情况下,这是必要的:
- 如果程序的一部分是使用 AVX 支持编译,而另一部分未使用 AVX 支持编译时,那么需要在离开 AVX 部分之前调用
_mm256_zeroupper()
。 - 如果一个函数使用 CPU分派,在多个版本中使用或者不使用 AVX 支持进行编译,那么在离开 AVX 部分之前调用
_mm256_zeroupper()
。 - 如果使用了 AVX 支持编译的代码调用了编译器附带的库之外的库中的函数,而该库不支持 AVX ,则在调用库函数之前调用
_mm256_zeroupper()
。
12.2 AVX512 指令集和 ZMM 寄存器
256位的YMM 寄存器在AVX512 指令集中被扩展为 512位的ZMM 寄存器。在 64位模式下,向量寄存器的数量从 16个扩展到 32个,而在 32位模式下只有 8个向量寄存器。因此,最好将AVX512 代码编译为 64位模式。
AVX512 指令集还添加了一组掩码寄存器。它们被用作布尔向量。几乎任何向量指令都可以用掩码寄存器进行掩模操作,这样,只有当掩码寄存器中的对应位为1时,才会计算向量元素。这使得使用分支的代码向量化效率更高。
AVX512 还有几个额外的扩展。所有支持AVX512 的处理器都有一些这样的扩展,但是到目前为止还没有一个处理器拥有全部这些扩展(写于2016年)。下面是对AVX512 的已有和计划添加的扩展:
- AVX512F。基础扩展。所有支持 AVX512 的处理器都有这个扩展。包含在 512位向量中对 32位和 64位整数,
float
和double
的操作以及掩码操作。 - AVX512VL。包含在 128和 256位向量中的相同操作。包含掩码操作和 32个向量寄存器。
- AVX512BW。包含在 512位向量中 8位和 16位整数的操作。
- AVX512DQ。64位整数的乘法和转换指令。以及其它一些浮点和双精度指令。
- AVX512ER。 快速倒数、倒数平方根、指数函数。对于
float
类型是准确值,对于double
类型则是近似值。 - AVX512CD。冲突检测。找到向量中的重复元素。
- AVX512PF。带聚集/分散逻辑的预取指令。
- AVX512VBMI。 8位粒度的置换和移位指令。
- AVX512IFMA。打包 52位整数的乘法和加法。
- AVX512_4VNNIW。Iterated dot product on 16-bit integers.
- AVX512_4FMAPS。 Iterated fused multiply-and-add, single precision.
这使得CPU分派 更加复杂。你可以选择对特定任务有用的扩展,并为具有此扩展的处理器创建代码分支。
在 AVX512 代码中,_mm256_zeroupper()
的使用不那么重要,但是仍然推荐使用。参见手册2:“Optimizing subroutines in assembly language”的13.2 节和手册5:“Calling conventions”的6.3节。
12.3 自动向量化
好的编译器(如 Gnu、Clang 和 Intel 编译器)可以在并行性明显的情况下自动使用向量操作。有关详细说明,请参阅编译器文档。例如:
1 | // Example 12.1a. Automatic vectorization |
一个好的编译器会在指定SSE2 或更高的指令集时使用向量操作来优化这个循环。根据使用指令集的不同,代码将读取4个,或8个,或16个 b
中的元素到一个向量寄存器中,与另一个向量寄存器包含(2,2,2,…)做加法,并将结果存储到 a
中。此操作将被重复多次,次数为数组大小除以每个向量的元素数量。速度相应地提高了。循环计数能最好能被每个向量的元素数整除。你甚至可以在数组的末尾添加多余的元素,使数组大小成为向量大小的倍数。
当数组是通过指针访问的时候,这将会有一个缺点,例如:
1 | // Example 12.1b. Vectorization with alignment problem |
如果数组按向量大小对对齐,XMM、YMM 和 ZMM 寄存器分别为16、32或64字节,则性能最佳。在 AVX 之前,指令集下的高效的向量操作要求数组按可被16整除的地址排列。在例 12.1a中,编译器可以根据需要对数组进行对齐,但在例 12.1b中,编译器无法确定数组是否正确对齐。循环仍然可以向量化,但是代码的效率会降低,因为编译器必须对未对齐的数组采取额外的预防措施。当通过指针或引用访问数组时,你可以做许多事情来提高代码的效率:
- 如果使用的是 Intel 编译器,可以使用
#pragma vector aligned
或_assume_aligned
指令,告诉编译器数组是对齐的,并确保它们是对齐的。 - 将函数声明为
inline
。这使编译器可能将 例 12.1b简化为12.1a。 - 如果可能的话,启用向量大小最大的指令集。AVX 和之后的指令集对对齐的限制很少,无论数组是否对齐,生成的代码都是高效的。
如果满足以下条件,自动向量化效果最好:
- 使用支持自动向量化的编译器,如 Gnu、Clang、Intel 或 PathScale。
- 使用编译器的最新版本。编译器在向量化方面变得越来越好。
- 使用适当的编译器选项来启用所需的指令集(
/arch:SSE2
,/arch:AVX
等,用于 Windows,-msse2
,-mavx
等,用于 Linux)。 - 使用限制较少的浮点选项。对于Gnu 编译器,使用
-O3 -fnotrapping-math -fno-math-errno
。 - 对于 SSE2 ,数组和大结构的地址按 16字节对齐,对于AVX 最好按 32字节对齐,而 AVX512 则最好按 64字节对齐。
- 循环计数最好是一个能被向量中的元素数整除的常数。
- 如果数组是通过指针访问的,因此在你想要向量化的函数的范围内,对齐是不可见的,那么请遵循上面给出的建议。
- 如果数组或结构体是通过指针或引用访问的,那么显式地告诉编译器指针没有别名 (如果合适的话)。有关如何做到这一点,请参阅编译器文档。
- 在向量元素级别上最小化分支的使用。
- 避免在向量元素级别使用查找表。
你可以查看汇编代码输出清单,以查看代码是否确实按预期被向量化(参见8.7 检查编译器做了什么)。
如果对一系列连续变量执行相同的操作,则编译器还可以在没有循环的情况下使用向量操作。例如:
1 | // Example 12.2 |
4个浮点数的结构适合 128位的 XMM 寄存器。在例 12.2中,优化后的代码将结构 y
加载到向量寄存器中,添加常量向量 (1,2,3,4)
,并将结果存储在 x
中。
编译器并不总是能够正确地预测向量化是否有利。Intel 编译器允许你始终使用 #pragma vector always
来告诉编译器总是进行向量化,或者使用 #pragma novector
来告诉编译器不要向量化。必须将 pragmas
语句放在循环或一系列你所希望应用的语句之前。
使用适合应用程序的最小数据大小是有利的。在例 12.3中,例如,你可以通过使用 short int
代替 int
以得到 2倍的速度。short int
是 16位的, 而 int
是 32位的,所以在相同的向量中,你可以存储 8个 short int
类型的数字,而只能存储 4个 int
类型的数字。因此,在不会产生溢出的情况下,使用足够大的最小位宽的类型类存储问题中的数字是有利的。同样地,如果代码可以向量化,那么使用 float
代替 double
是有好处的,因为 float
占用 32位,而 double
占用 64位。
SSE2 向量指令集不能对大小大于 short int
(16位)的整数进行乘法。没有指令可以进行整数除法。但是 vector class library 和 asmlib中有函数可以进行整数向量除法。
12.4 使用指令集函数
很难预测编译器是否会将循环向量化。下面的例子显示了编译器可以自动向量化,也可以不自动向量化的代码。代码中有一个分支,它为数组中的每个元素选择两个表达式:
1 | // Example 12.4a. Loop with branch |
可以使用所谓的指令集函数显式地向量化代码。当类似例 12.4a等当前编译器不会自动向量化代码的情况下非常有用。或者在自动向量化的代码不够优化的情况下,它也很有用。
指令集函数是基本操作,即每个指令集函数调用都被翻译成一个或几个机器指令。 Gnu、Clang、Intel、Microsoft 和 PathScale 编译器都支持指令集函数(PGI 编译器也支持指令集函数,但效率很低,Codeplay 编译器支持部分指令集函数,但是函数名与其他编译器不兼容)。使用 Gnu、Clang 和 Intel 编译器可以获得最佳的性能。
我们想对例 12.4a中的循环进行向量化,这样我们就可以在包含 8个 16位整数的向量中同时处理 8个元素。根据可用的指令集,循环内部的分支可以以多种方式实现。最兼容的方法是制作一个位掩码,当 bb[i] > 0
为真时全为 1,当为假时全为 0。将 cc[i]+2
与上掩码,对掩码取反并与上 bb[i]**cc[i]
。表达式的结果是与上全部是1的掩码结果不变,而与上全是 0的掩码得到的结果为 0。然后对这两个记过进行或操作就能得到要选择的表达式。
例 12.4b将展示如何使用 SSE2 指令集的函数实现上述步骤。
1 | // Example 12.4b. Vectorized with SSE2 |
生成的代码将非常高效,因为它一次处理8个元素,并且避免了循环中的分支。例 12.4b的执行速度是例 12.4a的3到7倍,具体取决于循环中分支的可预测性。
__m128i
类型定义了一个存储整数的 128位向量。它可以存储 16个 8位的整数,8个 16位的整数,4个 32位的整数,或者2个 64位的整数。__m128
类型定义了一个包含 4个 float
变量的 128位向量。__m128d
类型定义了包含 2个 double
类型变量的 128为变量。
指令集向量函数的名称以 _mm
开头。编译器手册或 Intel 的编程手册:“IA-32 Intel Architecture Software Developer’s Manual” 2A and 2B卷中列出了这些函数。指令集中有数百种不同的函数,很难找到适合特定用途的函数。
例 12.4b中笨拙的 AND-OR 结构可以被 SSE4.1 指令集中的 blend 指令替换:
1 | // Example 12.4c. Same example, vectorized with SSE4.1 |
你必须为要编译的指令集包含合适的头文件。头文件的名称如下:
Instruction set | Header file |
---|---|
MMX | mmintrin.h |
SSE | xmmintrin.h |
SSE2 | emmintrin.h |
SSE3 | pmmintrin.h |
Suppl. SSE3 | tmmintrin.h |
SSE4.1 | smmintrin.h |
SSE4.2 | nmmintrin.h (MS) smmintrin.h (Gnu) |
AES, PCLMUL | wmmintrin.h |
AVX | immintrin.h |
AMD SSE4A | ammintrin.h |
AMD XOP | ammintrin.h (MS) xopintrin.h (Gnu) |
AMD FMA4 | fma4intrin.h (Gnu) |
all | intrin.h (MS) x86intrin.h (Gnu) |
Table 12.2. Header files for intrinsic functions
你必须确保 CPU 支持相应的指令集。如果你包含了高于 CPU 支持的指令集头文件,那么你就有可能插入 CPU 不支持的指令,程序就会崩溃。有关如何检查支持的指令集,请参见13 使用不同指令集生成多个版本的关键代码。
数据对齐
如果数据的地址按可被向量大小(16或32字节)整除方式对齐,那么将数据加载到向量中会更快。这对旧的处理器和英特尔 Atom 处理器都有很大的影响,但在大多数较新的处理器上不是很重要。下面的例子展示了如何对齐数组。
1 | // Example 12.5. Aligned arrays |
查找表向量化
查找表对于优化代码非常有用,如14.1 使用查找表所述。不幸的是,查找表常常是向量化的一个障碍。最新的指令集包括一些可用于向量化查找表的指令。这些指令总结如下。
Intrinsic function | Max. number of elementsin table | Size of each table element | Number of simultaneouslookups | Instruction set needed |
---|---|---|---|---|
_mm_shuffle_epi8 | 16 | 1 byte = char | 16 | SSSE3 |
_mm_perm_epi8 | 32 | 1 byte = char | 16 | XOP, AMD only |
_mm_permutevar_ps | 4 | 4 bytes = float or int | 4 | AVX |
_mm256_permutevar_ps | 4 | 4 bytes = float or int | 8 | AVX2 |
_mm_i32gather_epi32 | unlimited | 4 bytes = int | 4 | AVX2 |
_mm256_i32gather_epi32 | unlimited | 4 bytes = int | 8 | AVX2 |
_mm_i64gather_epi32 | unlimited | 8 bytes = int64_t | 2 | AVX2 |
_mm256_i64gather_epi32 | unlimited | 8 bytes = int64_t | 4 | AVX2 |
_mm_i32gather_ps | unlimited | 4 bytes = float | 4 | AVX2 |
_mm256_i32gather_ps | unlimited | 4 bytes = float | 8 | AVX2 |
_mm_i64gather_pd | unlimited | 8 bytes = double | 2 | AVX2 |
_mm256_i64gather_pd | unlimited | 8 bytes = double | 4 | AVX2 |
Table 12.3. Intrinsic functions for vectorized table lookup
使用指令集函数可能会非常繁琐,代码会变得非常庞大,难以阅读。如下一节所述,使用向量类通常更简单一些。
12.5 使用向量类
用例 12.4b和例 12.4c中的方式编写程序确实很乏味。通过将这些向量操作包装到 C++
类中,并使用重载的运算符(如向量加法),可以以更清晰易懂的方式编写相同的代码。运算符是内联的,因此生成的机器码与直接使用指令集函数时的机器码相同。只是编写 a + b
比编写 _mm_add_epi16(a,b)
更容些。
目前可以使用几种不同的预定义的向量类库,包括一个来自 Intel的,一个来自我编写的。我编写的向量类库(VCL )有许多特性,请参见www.agner.org/optimize/#vectorclass。Intel vector class library 最近没有更新,我觉得可能有些过时。
Vector class library | Intel | VCL (Agner) |
---|---|---|
Available from | Intel and Microsoft C++ compilers | VCL |
Include file | dvec.h | vectorclass.h |
Supported compilers | Intel, Microsoft | Intel, Microsoft, Gnu, Clang |
Supported operating systems | Windows, Linux, Mac | Windows, Linux, Mac, BSD |
Instruction set control | no | yes |
License | license included in compiler price GNU General Public License, optional commercial license |
Table 12.4. Vector class libraries
下表列出了可用的向量类。包含适当的头文件将使你能够访问所有这些类。
Size of each element, bits | Number of elements in vector | Type of elements | Total size of vector, bits | Vector class, Intel | Vector class,VCL |
---|---|---|---|---|---|
8 | 8 | char | 64 | Is8vec8 | |
8 | 8 | unsigned char | 64 | Iu8vec8 | |
16 | 4 | short int | 64 | Is16vec4 | |
16 | 4 | unsigned short int | 64 | Iu16vec4 | |
32 | 2 | int | 64 | Is32vec2 | |
32 | 2 | unsigned int | 64 | Iu32vec2 | |
64 | 1 | int64_t | 64 | I64vec1 | |
8 | 16 | char | 128 | Is8vec16 | Vec16c |
8 | 16 | unsigned char | 128 | Iu8vec16 | Vec16uc |
16 | 8 | short int | 128 | Is16vec8 | Vec8s |
16 | 8 | unsigned short int | 128 | Iu16vec8 | Vec8us |
32 | 4 | int | 128 | Is32vec4 | Vec4i |
32 | 4 | unsigned int | 128 | Iu32vec4 | Vec4ui |
64 | 2 | int64_t | 128 | I64vec2 | Vec2q |
64 | 2 | uint64_t | 128 | Vec2uq | |
8 | 32 | char | 256 | Vec32c | |
8 | 32 | unsigned char | 256 | Vec32uc | |
16 | 16 | short int | 256 | Vec16s | |
16 | 16 | unsigned short int | 256 | Vec16us | |
32 | 8 | int | 256 | Vec8i | |
32 | 8 | unsigned int | 256 | Vec8ui | |
64 | 4 | int64_t | 256 | Vec4q | |
64 | 4 | uint64_t | 256 | Vec4uq | |
32 | 16 | int | 512 | Vec16i | |
32 | 16 | unsigned int | 512 | Vec16ui | |
64 | 8 | int64_t | 512 | Vec8q | |
64 | 8 | uint64_t | 512 | Vec8uq | |
32 | 4 | float | 128 | F32vec4 | Vec4f |
64 | 2 | double | 128 | F64vec2 | Vec2d |
32 | 8 | float | 256 | F32vec8 | Vec8f |
64 | 4 | double | 256 | F64vec4 | Vec4d |
32 | 16 | float | 512 | Vec16f | |
64 | 8 | double | 512 | Vec8d |
Table 12.5. Vector classes defined in two libraries
不建议使用总大小为 64位的向量,因为它们与浮点数代码不兼容。如果使用 64位的向量,那么必须在 64位向量操作之后和浮点代码之前执行调用 _mm_empty()
。较大的向量没有这个问题。
只有在 CPU 和操作系统支持的情况下,256位和512位大小的向量才可用 (参见12.1 AVX 指令集和 YMM寄存器)。我的 VCL 向量类库可以用两个128位向量模拟一个256位向量,或者将用两个256位向量或四个128位向量模拟一个512位向量。下面的示例展示了与例 12.4b相同功能的代码,使用 Intel vector classes 重写:
1 | // Example 12.4d. Same example, using Intel vector classes |
同样的例子使用我的VCL 向量类是这样的:
1 | // Example 12.4e. Same example, using VCL |
由于对齐的问题,Microsoft 编译器不允许将向量对象作为函数参数。建议使用常量引用:
1 | // Example 12.6. Function with vector parameters |
使用向量类进行 CPU分派
VCL 向量类库使从相同的源代码位不同的指令集编译代码成为可能。该库具有为给定指令集选择最佳实现的预处理指令。
下面的示例展示了如何使用自动 CPU分派实现例 12.4e中的 SelectAddMul
。本例中的代码应该编译三次,一次使用 SSE2 指令集,一次使用 SSE4.1 指令集,一次用于 AVX2 指令集,所有三个版本都应该被链接到同一个可执行文件中。SSE2 是 VCL 支持的最旧指令集,SSE4.1 在 select
函数中具有优势,AVX2 指令集具有更大的向量寄存器的优势。当使用 AVX2 指令集编译时,VCL 将会为 Vec16s
分配一个256位向量寄存器,使用低版本的指令集时,则分配两个 128位向量寄存器。预处理宏 INSTRSET
用于在使用不同指令集时赋予函数不同的名称。更多内容详见 vectorclass manual。
1 | // Example 12.7. Vector class code with automatic CPU dispatching |
12.6 为向量化转换串行代码
并不是所有具有并行结构的代码都可以轻松地组织成向量形式的。很多代码都是串行的,也就是说每个计算都依赖于前一个的结果。然而,如果代码是重复的,则可以以一种可被向量化的方式组织代码。最简单的情况是一长串数字的和:
1 | // Example 12.8a. Sum of a list |
上述的代码是串行的,因为每次迭代 sum
的值都依赖于前一次迭代后 sum
的值。诀窍是将循环按 n
展开并重新组织代码,每个值依赖于 n
个位置之前的值,其中 n
是向量中元素的数量。如果 n = 4
,我们得到:
1 | // Example 12.8b. Sum of a list, rolled out by 4 |
现在,s0
、s1
、s2
和 s3
可以组合成一个128位的向量,这样我们就可以在一个操作中做4个加法。如果我们使用 fast math 选项并指定SSE 或更高指令集的选项,一个好的编译器会自动将例 12.8a转换为12.8b,并将代码向量化。
再一些更复杂的情况下不能自动向量化。例如,让我们看看泰勒级数的例子。指数函数可由级数计算:
1 |
|
用C++ 实现看起来可能是这样的:
1 | // Example 12.9a. Taylor series |
在这里每个 $x^n$ 的值由前一个值计算而来,即 $x^n = x(x^{n-1})$,每个 $n!$ 的值也由前一个值计算而来,即 $n!= n(n-1)!$。如果我们想要将循环按 4展开,那我们必须要用 4个位置之前的值来计算当前的值。因此,我们将用 $x^4x^{n-4}$来计算$x^n$。没有简单的方法来展开阶乘的计算,但是这个并不是必需的,因为阶乘并不依赖 $x$ ,我们可以将值预先计算好,存一个表中。更好的方法是存储阶乘的倒数,这样我们就不需要除法了(如你所知,除法是很慢的)。现在上述的的代码可以按如下的方式向量化(使用 *Intel vector classes):
1 | // Example 12.9b. Taylor series, vectorized |
这个循环在一个向量中计算四个连续的项。如果循环很长,那么进一步展开循环可能是值得的,因为这里的速度可能受到 xxn
相乘的延迟而不是吞吐量的限制(参见11 乱序执行)。这里的系数表是在编译时计算的。在运行时计算系数表可能更方便,只要你能确保系数表只被计算一次,而不是每次调用函数时都会被计算一次。
12. 7 用于向量的数学函数
对于向量,有很多的函数库可以用于计算对数函数、指数函数、三角函数等数学函数。这些函数库对于向量化数学代码非常有用。
向量数学库有两种:长向量库(long vector library )和短向量库(short vector library )。为了解释它们之间的区别,我们假设你想用相同的函数对一千个数进行计算。使用长向量库时,你将一个包含一千个数字的数组作为参数提供给库函数,该函数将一千个结果存储在另一个数组中。使用长向量库的缺点是,如果要进行一长串计算,则必须在进行进一步计算前之前,必须每个步骤的中间结果存储在临时数组中。使用短向量库时,你可以将数据集划分为子向量,这些子向量与 CPU 中向量寄存器的大小相匹配。如果向量寄存器可以容纳 4个数字,那么你必须调用库函数 250次,每次将 4个数字装入向量寄存器。库函数将在向量寄存器中返回结果,向量寄存器可以在计算序列中的下一个步骤直接使用,而不需要将中间结果存储在 RAM 内存中。尽管有额外的函数调用,但这可能会更快,因为 CPU 可以在预取下一个函数的代码的同时进行计算。然而,如果计算序列形成了长依赖链,使用短向量的方法可能会处于不利的地位。我们希望 CPU 在完成对第一个子向量的计算之前开始对第二个子向量的计算。长依赖链可能会填满 CPU 中挂起的指令队列,并阻止其充分利用乱序执行的计算能力。
下面是一些长向量数学库的列表:
- Intel vector math library (VML, MKL)。支持所有x86 平台。这个库降低了非英特尔CPU 的性能,除非你重写了英特尔的 CPU分派程序。见13.7 Intel 编译器中的 CPU分派。
- Intel Performance Primitives (IPP)。支持所有x86 平台。在非英特尔 CPU 上也能工作的很好。包括许多用于统计学,信号处理和图像处理的函数。
- Yeppp。开源库。支持 x86 和 ARM 平台以及多种编程语言。www.yeppp.info。
下面是一些短向量数学库的列表:
- Sleef library。支持多种不同的平台。开源。www.sleef.org。
- Intel short vector math library (SVML)。这是由 Intel 编译器 提供的,并通过自动向量化调用。Gnu编译器 可以通过选项
-mveclibabi=svml
使用这个库。如果不使用 Intel 编译器话,这个库通常可以很好地处理 非Intel CPU。见13.7 Intel 编译器中的 CPU分派。 - AMD LIBM library。只在64位Linux 和Windows 平台上可用。这个库在没有FMA4 指令集的情况下降低了CPU 的性能(这个指令集最初是由英特尔设计的,但目前只有 AMD 的CPU 支持)。在 Gnu编译器 中,可以通过选项
-mveclibabi=acml
使用这个库。 - VCL vector class library。支持所有x86 平台。支持 Microsoft、Intel、Gnu 和Clang 编译器。代码是内联的,不需要链接外部库。www.agner.org/optimize/#vectorclass。
所有这些库都具有很好的性能和精度。速度比任何非向量库快很多倍。
SVML 和 LIBM 库中的函数名没有很好的说明文档。如果你想直接调用库函数,可以参考下表中的例子:
Library | exp function of 4 floats | exp function of 2 double |
---|---|---|
Intel SVML v.10.2 & earlier | vmlsExp4 |
vmldExp2 |
Intel SVML v.10.3 & later | __svml_expf4 |
__svml_exp2 |
Intel SVML + ia32intrin.h | _mm_exp_ps |
_mm_exp_pd |
AMD Core Math Library | __vrs4_expf |
__vrd2_exp |
AMD LIBM Library | amd_vrs4_expf |
amd_vrd2_exp |
VCL vector class library | exp |
exp |
12.8 对齐动态分配的内存
使用 new
或 malloc
分配的内存通常按 8字节对齐,而不是按 16字节对齐。当矢量运算需要按 16对齐时,这就是个问题了。Intel 编译器 通过定义 _mm_malloc
和 _mm_free
解决了这个问题。
更通用的方法是将分配的数组封装到容器类中,由容器类负责对齐。参见www.agner.org/optimize/cppexamples.zip了解如何使用向量访问使数组对齐。
12.9 对齐RGB视频或三维向量
RGB 图像数据每个点有三个值。这不适用于具有四个浮点数的向量。这一点同样适用于三维几何和其他奇数大小的向量数据。为了提高效率,数据必须按向量大小对齐。使用未对齐的读和写可能会降低执行速度,从而减少使用向量操作的优势。你可以选择以下一种解决方案,具体哪种方案最适合取决于你的算法:
- 加入不使用的第四个值,使数据刚好可以装入向量中。这是一个简单的解决方案,但是它增加了内存使用量。如果内存访问是瓶颈,需要避免使用这种方法。
- 将四个(或八个)点的的数据组成一组,其中一个向量中有四个 R值,下一个向量中有四个 G值,最后一个向量中有四个 B值。
- 首先将所有的 R值组织成数据,然后是所有的 G值,最后是所有的 B值。
选择哪种方法取决于哪种方法最适合所讨论的算法。你可以选择可以写出最简单代码的方法。
如果点的数量不能被向量大小整除,那么可以在最后面添加几个未使用的点,以得到整数个向量。
12.10 总结
如果代码天生具有并行性,那么使用向量可以大大提高速度。增益取决于每个向量的元素数。最简单和最干净的解决方案是依赖于编译器的自动向量化。在并行性明显且代码只包含简单标准操作的情况下,编译器将自动向量化代码。你所要做的就是使用适当的指令集和限制少的浮点选项。
然而,在许多情况下,编译器无法自动对代码进行向量化,或者以一种不太理想的方式进行向量化。在这里,你必须显式地向量化代码,有很多方法可以做到:
- 使用汇编语言。
- 使用指令集。
- 使用预定义的向量类。
使用向量类是向量化代码的最简单方法。如果你需要的操作向量类库并没有提供,那么你可以通过使用向量类外加几个指令集函数来实现。无论你选择使用指令集函数还是向量类,这只是是否方便的问题 — 在性能上没有区别。一个好的优化编译器应该在这两种情况下生成相同的代码。指令集函数看起来笨拙而乏味。当你使用向量类和重载运算符时,代码将变得更具可读性。
好的编译器通常能够在手动向量化代码之后进一步优化代码。编译器可以使用函数内联、公共子表达式消除、常量传播、循环优化等优化技术。这些技术很少用于手动编写编码,因为它使代码变得笨拙、容易出错,而且难以维护。因此,在许多情况下,手动向量化与编译器的进一步优化相结合可以得到最好的结果。当前的编译器并不总是擅长于在向量化的代码上进行常量传播和某些其他优化技术。因此,在编译器的自动向量化不出问题情况下,依赖于编译器的自动向量化会更好。为了找到最佳解决方案,可能需要进行一些试验。
向量化代码通常包含许多额外的指令,用于将数据转换为正确的格式,并将它们放到向量中的正确位置。这种数据转换和变换有时会比实际计算花费更多的时间。在决定使用向量化代码是否有利可图时,应该考虑到这一点。
我将通过总结决定矢量化有多有利的因素来结束本节。
使向量化有利的因素:
- 小数据类型:
char
、short
、int
、float
。 - 大数组中对所有数据进行相似的操作。
- 数组大小可以被向量大小整除。
- 在不可预测的分支中选择两个简单表达式。
- 只有向量操作数可用的操作:取最小值、取最大值、饱和加法、快速近似倒数、快速近似倒数平方根、RGB色差。
- 向量指令集可用时,如:AVX、AVX2、AVX-512。
- 数学向量函数库。
- 使用 GNU、Clang、Intel 编译器。
使向量化不那么有用的因素:
- 大数据类型:
int64_t
、double
。 - 未对齐的数据。
- 额外的数据转换:需要 shuffling、packing、unpacking 等操作。
- 分支可预测时,在未选中时可以跳过大量表达式。
- 编译器没有足够的指针对齐和别名信息。
- 在指令集中缺少合适类型的向量操作,如 SSE4.1 之前,没有 32位整数乘法和整数除法。
- 执行单元小于向量寄存器大小的老 CPU。
对于程序员来说,向量化的代码更不容易编写,因此也更容易出错。因此,向量化代码最好放在可重用且经过良好测试的库模块和头文件中。
13 为不同指令集生成多个版本的关键代码
微处理器制造商不断地向指令集中添加新的指令。这些新的指令可以使某些类型的代码执行得更快。对指令集最重要的补充是第12章中提到的向量运算。
如果代码是为特定的指令集编译的,那么它将与支持该指令集或任何更高指令集的所有CPU 兼容,但可能不与更早的CPU 兼容。向后兼容指令集的顺序如下:
Instruction set | Important features |
---|---|
80386 | 32 bit mode |
SSE | 128 bit float vectors |
SSE2 | 128 bit integer and double vectors |
SSE3 | horizontal add, etc. |
SSSE3 | a few more integer vector instructions |
SSE4.1 | some more vector instructions |
SSE4.2 | string search instructions |
AVX | 256 bit float and double vectors |
AVX2 | 256 bit integer vectors |
FMA3 | floating point multiply-and-add |
AVX-512 | 512 bit integer and floating point vectors |
Table 13.1. Instruction sets
手册4 “Instruction tables”提供了对指令集的更详细的说明。在混合使用 AVX 或更高版本编译的代码和不使用 AVX 编译的代码时会有一定的限制,如12.1 AVX 指令集和 YMM寄存器中所述。
使用最新指令集的一个缺点是缺失了与旧版本微处理器的兼容性。这个难题可以在关键部分通过为不同的 CPU 使用多个版本的代码中来解决。这称为 CPU分派。例如,你可能希望创建一个利用AVX2 指令集优势的版本,另一个只使用SSE2 指令集的,以及一个而不使用任何这些指令集与旧版本微处理器兼容的通用版本。程序应该自动检测CPU 支持哪个指令集。
13.1 CPU分派策略
在开发、测试和维护方面,将一段代码转换成多个版本,每个版本都针对一组特定的 CPU 进行仔细的优化和微调,这代价是相当大的。对于在多个应用程序中使用的通用函数库,这些代价是合理的,但这对于用于特定应用程序的代码并不是总是合理的。如果你考虑使用CPU分派 来生成高度优化的1代码,那么如果可能的话,最好以可重用库的形式来实现。这也使得测试和维护更加容易。
我对CPU分派 做了大量的研究,发现很多常用的程序都使用了不合适的CPU分派 方法。
CPU分派最常见的陷阱是:
- 为当前的处理器而不是未来的处理器做优化。考虑使用CPU分派 开发和发布函数库所需的时间。在应用程序程序员获得库的新版本之前,还需要额外的时间。再加上开发和推广应用程序所需的时间。在加上最终用户获得应用程序的最新版本所需的时间。总而言之,你的代码要在大多数最终用户的计算机上运行通常需要几年的时间。此时,你所优化的任何处理器都可能已经过时。程序员常常低估了这种时间延迟。
- 考虑特定的处理器型号而不是处理器特性。程序员通常会考虑的是“什么任务在处理器X上工作得最好?”而不是“什么任务在具有这个指令集的处理器上工作得最好?”。每个处理器型号使用的代码分支的列表将会非常长,而且难以维护。最终用户也不太可能拥有最新的版本。CPU分派器 不应该考虑CPU 的品牌和型号,而应该考虑它具有哪些指令集和其他特性。
- 假设处理器型号组成一个合理的序列。如果你知道处理器型号N支持一个特定的指令集,那么你就不能假定型号N+1会至少支持相同的指令集。一个数字更大的型号不一定是更新的。CPU 系列和型号并不总是连续的,对于未知的CPU,你不能根据它的系列和型号对其进行任何假设。
- 无法正确处理未知处理器。许多 CPU分派器 被设计成只处理已知的处理器。在编写程序时未知的其他品牌或型号,通常会使用通用的代码分支,这是性能最差的分支。我们必须记住,许多用户更愿意在最新的 CPU 上运行速度关键型程序,而这个CPU 在编写程序时很可能是未知的。 CPU分派器 应该给一个未知品牌或型号的 CPU 分配最好的分支,如果 CPU 支持该分支兼容的指令集的话。“我们不支持处理器X”这样的常见借口在这里是不恰当的,它揭示了 CPU分派 的根本缺陷。
- 低估维持更新 CPU分派程序 的成本。很容易为特定CPU 型号对代码进行调优,然后认为在新的CPU 型号上市时你可以进行更新。但是,调优、测试、验证和维护一个新的代码分支的成本是如此之高,以至于在未来的许多年里,对每一个进入市场的新处理器都这么做是不现实的。即使是大型软件公司也常常无法使它们的CPU分派程序 保持最新。一个更现实的目标是,只有当一个新的指令集出现,使显著地提升性能成为可能时,才创建一个新的分支。
- 创建太多的代码分支。如果你正在创建针对特定的CPU 品牌或特定型号进行调优的分支,那么你很快就会得到许多占用缓存空间且难以维护的分支。你在特定 CPU 型号中处理的任何特定瓶颈或任何特别慢的指令在一两年内都可能变得不相关。通常,只要有两个分支就足够了:一个用于最新的指令集,另一个与最多 5年或 10年前的 CPU 兼容。CPU 市场发展如此之快,以至于今天全新的 CPU 将在明年成为主流。
- 忽略虚拟化。CPUID 指令能够真正表示已知CPU 型号的时代已经结束了。虚拟化变得越来越重要。虚拟处理器会减少内核的数量,以便为同一机器上的其他虚拟处理器保留资源。为了反映这一点,或者与一些历史遗留软件兼容,虚拟处理器可能会给出一个错误的型号。它甚至可能有一个错误的供应商字符串。在未来,我们可能还会看到不对应于任何已知硬件CPU的仿真处理器 和FPGA软核。这些虚拟处理器可以是任意品牌和型号。我们唯一可以依赖的CPUID 信息是特性信息,比如支持的指令集和缓存大小。
幸运的是,这些问题的解决方案在大多数情况下都非常简单:CPU分派器 应该拥有尽可能少的分支,并且分派应该基于CPU 支持的指令集,而不是CPU 的品牌、系列和型号。
我见过许多CPU分派 做的很差劲的例子。例如,Mathcad 的最新版本(v15.0)使用的是Intel 的Math Kernel LIbrary 6年前的版本(MKL v7.2)。这个库有一个CPU分派器,它不能以最优的方式处理现在的CPU。如果将CPUID 人为地更改为旧的 Pentium 4,在当前Intel CPU 上,某些任务的速度可以提高 33% 以上。原因是MKL 中的CPU分派程序 依赖于CPU 的族号(family number),Pentium 4的CPU 族号是 15,而所有较新的Intel CPU 的族号为 6!当 CPUID 被改成假的 Intel Pentium 4 时,在非 Intel CPU 上执行这项任务的速度提高了一倍多。更糟糕的是,许多软件产品无法识别VIA 处理器,因为这个品牌在软件开发时不太受欢迎。
对不同品牌CPU 的不平等的CPU分派机制 可能会成为一个严重的法律问题,正如你可以在我的博客中看到的那样。在这里,你还可以找到更糟糕的CPU分派的例子。
显然,你应该只对程序的最关键部分使用CPU分派 —— 最好隔离到单独的函数库中。只有当指令集相互不兼容时,才能使用将整个程序转换成多个版本这样激进的解决方案。一个具有定义良好的功能和接口的函数库比一个把分派分支分散在源文件中的程序更容易管理和测试、维护和验证。
13.2 指定型号的分派
在某些情况下,特定的代码实现在特定型号的处理器表现糟糕。你可以忽略这个问题,并假设下一个处理器型号将会表现的更好。如果这个问题太重要而不能忽略,那么解决方案是为该版本代码表现的不好的处理器型号创建一个负面清单(negative list )。为该版本代码表现良好的处理器型号列一个可用清单(positive list )不是一个好主意。原因是,每当市场上出现新的、更好的处理器时,都需要更新可用清单,这样的一个清单在你的软件生命周期内几乎肯定会过时。另一方面,在下一代处理器表现更好的情况下,负面清单不需要更新。每当处理器有一个特定的弱点或瓶颈时,生产者很可能会试图修复这个问题,使下一个型号表现的更好。
请再次记住,大多数软件在大部分时间内都是在编写软件时未知的处理器上运行的。如果软件包含运行最高级代码版本的处理器型号的可用清单,那么它将在程序编写时未知的处理器上运行较差的版本。但是,如果软件包含一个负面清单,其中列出了避免运行高级版本的处理器模型,那么它将在编程时未知的所有较新的型号上运行高级版本。
13.3 棘手的例子
在大多数情况下,可以根据所支持的指令集、缓存大小等CPUID 信息选择最优分支。但是,在一些情况下,有不同的方法可以做相同的事情,而CPUID 指令没有提供关于哪种实现最好的必要信息。这些情况有时用汇编语言处理。下面是一些例子:
strlen
函数。字符串长度函数遍历字符串的所有字节以找到第一个值为零字节。好的实现使用XMM 寄存器,每次测试 16个字节,然后使用 BSF(bit scan forward)指令在 16个字节的块中定位第一个值为零字节。这个位扫描指在一些CPU 的实现速度特别慢。单独测试过strlen
函数的程序员对该函数在位扫描指令很慢的CPU 上的性能不满意,并为特定的CPU 型号实现了一个单独的版本。但是,我们必须考虑到,对于每个函数调用,位扫描指令只执行一次,因此你必须在性能变得重要之前,调用该函数数十亿次,而很少有程序会需要这样做。因此,为位扫描指令的很慢的CPU 编写特殊版本的strlen
函数是不值得的。我的建议是直接使用位扫描指令,并期待在未来的CPU 上,这是最快的解决方案。- 一半大小的执行单元。向量寄存器已经从 64位的MMX 增加到了 128位的XMM 和 256位的YMM 寄存器。第一个支持 128位向量寄存器的处理器实际上执行单元只有 64位。每个 128位操作被分成两个 64位操作,因此使用更大的向量大小几乎没有任何速度上的优势。后来的型号拥有 128位的完整执行单元,因此速度更快。同样,最初支持 256位指令的处理器将 256位读取操作拆分为两个 128位读取操作。对于即将到来的 512位指令集以及将来寄存器大小的进一步扩展,也可以期望得到相同的结果。通常,新寄存器大小的全部优势只出现在支持它的第二代处理器中。在某些情况下,向量实现仅在具有全尺寸执行单元的CPU 上是最优的。问题是CPU分派程序 很难知道最大的向量寄存器是以半速还是全速处理的。这个问题的一个简单解决方案是,只有在支持下一个更高的指令集时才使用新的寄存器大小。例如,只有在这种应用程序中支持AVX2 时才使用AVX。或者,使用负面清单,包含那些使用最新指令集没有优势的处理器型号。
- 高精度数学。用于高精度数学的库允许使用大整数加法。这通常在循环中使用
ADC
指令(带进位)完成,其中进位必须从一个迭代传递到下一个迭代中。进位可以保存在进位标志中,也可以保存在寄存器中。如果进位保存在进位标志中,那么循环分支必须依赖使用零标志的指令,并且不能修改进位标志(例如DEC
、JNZ
)。在将标志寄存器的分割为进位标志位和零标志位存在问题的老版本Intel CPUs 上,由于所谓的部分标志位(so-called partical flags)在处理器中存在问题,这个方案可能会导致很长的延迟,但是在AMD CPUs 上不存在这个问题(详见手册3:“The microarchitecture of Intel, AMD and VIA CPUs”)。这是少数几种使根据CPU 品牌进行分派变得合理的情况之一。在特定品牌的最新CPU 上表现良好的版本也可能是未来同样品牌其它型号的最优选择。较新的处理器支持用于高精度数学的ADX
指令。 - 内存复制。复制内存块有几种不同的方法。这些方法在手册2“Optimizing subroutines in assembly language”第17.9节“移动数据块”中进行了讨论,其中还讨论了在不同的处理器上哪种方法速度最快。在C++ 程序中,你应该选择一个最新的,很好地实现了
memcpy
函数的函数库。由于存在不同的微处理器、不同的对齐方式和不同大小的需要复制的数据块等很多不同情况,因此惟一合理的解决方案是使用一个具有CPU分派 的标准函数库。这个函数非常重要,并且被广泛使用,大多数函数库的这个函数都有CPU分派 功能,尽管不是所有库都有最好的和最新的解决方案。编译器在复制大型对象时可能会隐式地使用memcpy
函数,除非有一个复制构造函数以其他方式指定。
在诸如此类的困难情况下,记住你的代码很可能大部分时间在编写程序时还未知的处理器上运行是很重要的 。因此,重点要考虑哪种方法可能对未来处理器效果最好,并且为所有支持必要指令集的处理器选择该方法。如果问题在未来由于微处理器硬件设计的总体改进而消失,那么就不值得花费精力来根据复杂的条件或者具体CPU 型号的清单来编写CPU分派器。
终极解决方案的选择将需要性能测试,该测试度量关键代码的每个版本的速度,以查看在实际处理器上哪个解决方案是最优的。然而,这涉及到时钟频率可能的动态变化,以及由于中断和任务切换而导致测量不稳定的问题。因此,为了做出可靠的决策,有必要对不同的版本进行多次交替测试。
13.4 测试和维护
当软件使用CPU分派 时,有两件事情需要测试:
- 通过使用特定版本的代码,你在速度上获得了多少增益。
- 检查所有版本代码是否正确工作。
速度测试最好是针对每个代码分支所特定的CPU 类型进行。换句话说,如果你想优化几个不同CPU,就需要在几个不同的CPU 上进行测试。
另一方面,没有必要使用许多不同的CPU 来验证所有代码分支是否正确工作。一个使用低版本指令集的代码分支仍然可以在一个支持高版本指令集的CPU 上运行。因此,你只需要一个支持最高版本指令集的CPU 来测试所有分支的正确性。因此,建议在代码中添加一个测试特性,使你能够重写CPU分派 并运行任何代码分支来进行测试。
如果代码是作为函数库或单独的模块实现的,那么编写一个可以单独调用所有代码分支并测试其功能的测试程序是很方便的。这对以后的维护非常有帮助。然而,这不是一本关于测试理论的教科书。关于如何测试软件模块的正确性的建议一定可以在其他地方找到。
13.5 实现
CPU分派机制 可以在不同的地方实现,在不同的时间做出分派决策:
- 在每次调用时分派。使用分支树或者
switch
语句为关键函数选择合适的版本。每次调用关键函数时都会判断分支。这存在需要花费时间去判断分支的问题。 - 在第一次调用时分派。通过函数指针调用函数,该指针最初指向一个调度器。分派器更改函数指针并使其指向函数的正确版本。这样做的好处是,在函数从未被调用时,分派器不会花费时间决定使用哪个版本。下面的例 13.1演示了这种方法。
- 在初始化时生成指针。程序或函数库有一个初始化路径,该初始化路径在第一次调用关键函数之前调用。初始化路径将函数指针设置为函数的正确版本。这样做的好处是,函数调用的响应时间是一致的。
- 在初始化时加载库。每个代码版本都在一个单独的动态链接库(.dll 或 .so)中实现。程序有一个初始化路径,它会加适当版本的库。如果库非常大,或者必须使用不同的编译器编译不同的版本,则该方法非常有用。
- 在加载阶段分派。程序使用一个过程链接表(PLT),过程链接表在程序加载时候初始化。这个方法需要操作系统的支持,在最新版本的Linux 系统中可用(Mac OS 或许也可以),见13.6 GNU 编译器中的 CPU分派。
- 在安装时分派。每个代码版本都在一个单独的动态链接库(.dll 或 .so)中实现。安装程序创建适当版本库的符号链接,应用程序通过符号链接加载库。
- 使用不同的可执行文件。如果指令集互不相容,可以使用这种方法。你可以为 32位和 64位系统创建单独的可执行程序。程序的适当版本可以在安装过程中选择,也可以通过可执行文件选择。
如果关键代码的不同版本使用不同的编译器编译,那么建议对所有关键代码调用的库函数使用静态链接,这样你不需要分发应用程序中属于不同编译器的所有代码。
各种指令集的可用性可以通过调用系统函数来确定(例如:在Windows 中可以调用 IsProcessorFeaturePresent
)。或者,你可以直接调用CPUID 指令,也可以使用我提供的函数库中的CPU 检测函数,这个函数的名字是 InstructionSet()
。下面的这个例子将展示如何使用 InstructionSet()
实现在第一次调用方法时进行CPU 分派:
1 | // Example 13.1 |
函数 InstructionSet()
包含在函数库 asmlib。这个函数是独立于操作系统的,它检查 CPU 和操作系统是否支持不同的指令集。例13.1中 CriticalFunction
的不同版本可以在必要时放在单独的模块中,每个模块都为特定的指令集编译。
13.6 GNU 编译器中的 CPU分派
Linux 中引入了一个名为“Gnu 间接函数 ”的特性,并在 2010年被 Gnu 实用工具所支持。该特性用于CPU 分派,并在Gnu C 库中被使用。它需要编译器、链接器和加载器的支持(binutils 的版本为 2.20, glibc 版本为 2.11的 ifunc 分支)。
使用这个特性按照下述方法以一种普通的方式使用过程链接表(PLT ):同一函数有两个或多个版本,每个版本都针对特定的CPU 或其他硬件条件进行了优化。分派函数决定使用哪个函数,并返回指向所需函数的指针。PLT 入口最初指向调度函数。当程序加载时,加载器调用调度函数,并用从调度函数获得的指针替换PLT 入口。这将使对函数的任何调用转到所需的版本。注意,分配函数通常在程序开始运行之前调用,并且在调用任何构造函数之前调用。因此,分配函数不能依赖于正在初始化的任何其他东西。即使从未调用被调度函数,也很可能调用调度函数。
不幸的是,目前在 Gnu 手册中描述的语法并不能正确工作。然而,可以使用以下方案:
1 | // Example 13.2. CPU dispatching in Gnu compiler |
在Gnu C 函数库中,间接函数特性被用于一些特别关键的函数。
13.7 Intel 编译器中的 CPU分派
Intel 编译器有一个特性,可以为一个函数的生成多个版本对应于多个Intel CPU。每次调用方法都使用分派。当调用该函数时,为函数分配所期望的版本。通过使用选项/QaxAVX 或 -axAVX 编译模块,可以对模块中所有合适的函数进行自动分派。甚至会给非关键函数也生成多个版本。通过使用指令 _declspec(cpu_dispatch(…))
,可以只对速度关键的函数执行分派。有关详细信息,请参阅Intel C++ 编译器文档。注意,Intel 编译器中的CPU分派机制 只适用于Intel CPU,而不适用于AMD 和VIA 等其他品牌的CPU。下一节展示了一种解决CPU 检测机制中的这种限制和其他缺陷的方法。
Intel 编译器中的CPU分派机制 的效率低于Gnu 编译器 中的机制,因为它对关键函数的每次调用都进行分派。在某些情况下,每次调用函数时,Intel 的机制都会执行一系列分支,而Gnu 的机制则在过程链接表中存储指向所需版本的指针。如果一个分派函数调用另一个分派函数,那么后者的分派分支也将被执行,即使此时已经知道CPU 的类型。这可以通过内联后一个函数来避免,但是更好的方法是像例 13.1 中(13.5 实现)那样显式地执行CPU分派。
Intel 编译器和函数库具有自动 CPU分派 的特性。针对不同的处理器和指令集,很多Intel 函数库都有几个不同的版本。同样的,编译器可以使用自动 CPU分派为用户写的代码生成多个版本的代码。
不幸的是,Intel 编译器的CPU 检测机制 存在几个缺陷:
- 只有在运行在Intel 处理器上时才会选择代码的最佳版本。CPU分派程序 在检查它支持的指令集之前,检查处理器是否是Intel 的。如果处理器不是Intel 的,则选择较差版本的代码,即使处理器与较好版本的代码兼容。这可能导致在AMD 和 VIA 处理器上的性能急剧下降。
- 显式 CPU分派 只适用于Intel 处理器。对于非 Intel 处理器,通过简单地执行一个非法操作,使分派器发出错误信号,然后使程序崩溃。
- CPU分派器 不检查操作系统是否支持XMM 寄存器。它将在不支持SSE 的旧操作系统上崩溃。
由 Intel 发布的几个函数库具有类似的CPU分派机制,其中一些函数库还以次优方式处理非Intel CPU。
英特尔CPU分派器 以非最佳方式处理非英特尔 CPU 的事实已经成为一个严重的法律问题。详情请参阅我的博客。
Intel 编译器的行为使程序员陷入了一个糟糕的困境。你可能更喜欢使用Intel 编译器,因为它具有许多高级优化特性,而且你可能希望使用经过良好优化的Intel 函数库,但是谁愿意给程序加上一个说它在非 Intel 机器上不能很好地工作的标签呢?
这个问题可能的解决方案如下所列:
- 使用特定的指令集编译,例如 /arch:SSE2。编译器将为这个指令集生成最优代码,并且大多数库函数直插入SSE2 版本,而不进行CPU分派。测试一下程序是否在非英特尔CPU 上令人满意地运行。如果没有,则可能需要替换CPU 检测功能,如下所述。该程序将与不包含当前选择的指令集的旧版本微处理器不兼容。
- 为代码中最关键的部分创建两个或多个版本,并使用指定的合适指令集分别编译它们。在代码中插入显式的CPU分派,以调用适合其运行的微处理器的版本。
- 替换或绕过英特尔编译器的 CPU检测功能。该方法将在下面一节中讨论。
- 直接调用特定于CPU 版本的库函数。特定于CPU 的函数的名称带有后缀,例如对于AVX 指令集,后缀为 .R。这些后缀在手册5:“calling conventions”中的表 19中列出。函数名中符号. 在C++ 中是不被允许的,因此你需要使用汇编代码或 objconv 或类似的实用程序来修改对象文件中的名称。
- 使用在所有品牌CPU 上都运行良好的函数库。
如果程序中最耗时的部分包含自动 CPU分派 或内存密集型函数,如 memcpy
、memmove
、memset
或数学函数,如pow
、log
、exp
、sin
等,则可以使用上述一种或多种方法提升非intel 处理器 的性能。
重载 Intel CPU 检测功能
在某些情况下,CPU检测功能 有两个版本,一个区分CPU 品牌,另一个不区分。
未文档中记录的Intel 库函数 _intel_cpu_features_init()
设置变量 _intel_cpu_feature_indicator
(其中每个位表示Intel CPU上特定的CPU 特性)。另一个函数 _intel_cpu_features_init_x()
在不区分CPU 品牌的情况下执行相同的操作,并以类似的方式设置变量 __intel_cpu_feature_indicator_x
。只需将这些变量设置为零,然后调用_intel_cpu_features_init_x()
,就可以绕过对CPU 品牌的检查。
在其他情况下,可以通过使另一个具有相同名称的函数来替换Intel 函数库和编译器生成的代码中的CPU 检测函数。在Windows 操作系统,这需要使用静态链接(例如,选项 /MT )。在Linux 和Mac 系统中,静态链接和动态链接都能起作用。
http://www.agner.org/optimize/asmlib.zip 中的文件包含这些方法的完整代码示例。
如果你正在使用Intel 编译器,那么请确保启动代码和 main()
在编译时没有任何限制CPU 品牌的选项。代码的关键部分可以放在一个单独的C 或C++ 文件中,并为所需的指令集编译。如果这些方法中有任何一种绕过了CPU 品牌检查,那么关键部分就可以在任何CPU 品牌的CPU 上得到最佳的性能。
当Intel 函数库与其他编译器一起使用时,这些方法也可以工作。包括MKL、VML 和 SVML 等函数库。IPP 函数库不需要任何补丁。
注意,这些方法是基于我自己的研究,而不是基于公开的信息。它们在 Intel编译器从版本 7 到 14 的测试中运行良好,每个版本都有一些变化。这些示例适用于 Windows 和 Linux, 32位和64位。它们还没有在 Mac 系统中进行测试。
14 具体的优化主题
14.1 使用查找表
如果列表是被缓存过的,从表中读取一个常数的值是很快的。通常情况下,从缓存在一级缓存的列表读取操作只需要花费几个时钟周期。如果函数只有有限数量的可能输入,我们可以利用这个事实,用查找表来替换函数调用。
让我们以整数阶乘函数($n!$)为例。唯一允许的输入是从0到12的整数。更大的输入会导致溢出,负的输入会得到无穷大。阶乘函数的一个典型实现是下面这样的:
1 | // Example 14.1a |
这种计算需要 $n-1$次乘法,而这将会花费当长的时间。如果使用查找表的话效率会更高:
1 | // Example 14.1b |
该实现使用查找表,而不是在每次调用函数时重新计算值。我在这里添加了一个界限检查,因为当 n
是数组索引时,n
超出范围的后果可能比n是循环计数时更严重。边界检查的方法在下面的14.2 边界检查中解释。
表应该声明为 const
,以便启用常量传播和其他优化。你可以将函数声明为内联的。
用查找表替换函数,在可能输入的数量有限且没有缓存问题的大多数情况下是有利的,如果你希望每次调用后列表从缓存中被擦出,以及计算函数所花费的时间小于从内存中重新加载值的时间,加上程序的其他部分占据缓存所导致的时间开销之和,那么使用查找表是没有好处的。
查找表无法使用当前的指令集进行向量化。如果这妨碍使用更快的向量化代码,那么就不要使用查找表。
在静态内存中存储数据可能会导致缓存问题,因为静态数据可能分散在不同的内存地址。如果缓存是一个问题,那么将表从静态内存复制到最内层循环外的栈内存上可能是有用的。我们可以在函数中但在最内层循环之外声明表(不使用 static
关键字):
1 | // Example 14.1c |
例 14.1c中的 FactorialTable
在调用 CriticalInnerFunction
时从静态内存中复制到栈上。编译器将表存储在静态内存中,并在函数开始的地方插入代码,将表复制到栈内存中。当然,复制表需要额外的时间,但是当它位于关键的最内层循环之外时,这是被允许的。循环将使用存储在栈内存中的表的副本,这与其它本地变量相邻,因此缓存效率可能比静态内存更高。
如果你不喜欢手工计算表值并将值插入代码中,那么你当然可以让程序进行计算。只要只需要一次计算,那么计算表所花费的时间并不重要。有人可能会说,在程序中计算表比直接输入值更安全,因为手写表中的输入错误可能无法被检测到。
查找表的原理可用于程序在两个或多个常量之间进行选择的任何情况。例如,在两个常量之间进行选择的分支可以被一个包含两个条目的表替换。如果分支的可预测性很差,这可能可以提升性能。例如:
1 | // Example 14.2a |
如果我们假设 b
总是 0 或 1,并且它的值可预测性很差,那么使用查找表来代替分支是有利的:
1 | // Example 14.2b |
在这里,因为安全性的原因,我将 b
按位与上 1,b & 1
的值肯定只有 0 或 1(参见14.2 边界检查)。如果 b
的值肯定为 0 或 1,那么就可以省略对 b
的额外检查。使用 a = OneOrTwo5[b!=0]
,同样可以正确运行,但是效率稍低。但是,当 b
是 float
或 double
类型时,这种方法效率很低,因为我测试的所有编译器对OneOrTwo5[b!=0]
的实现都是 OneOrTwo5[(b!=0) ? 1 : 0]
,在这种情况下,我们无法摆脱分支。当 b
是浮点数时,编译器使用不同的实现似乎不合逻辑。我觉得原因是编译器制的开发人员认为浮点数比较比整数比较更容易预测。对于表达式 a = 1.0f + b * 1.5f
,当 b
是一个浮点数时是高效的,但如果 b
是一个整数则效率较低,因为整数到浮点数的转换比查找表花费更多的时间。
将查找表作为 switch
语句的替代尤其有利,因为 switch
语句的可预测性经常较差。例如:
1 | // Example 14.3a |
这可以使用查找表来提升效率:
1 | // Example 14.3b |
表的声明有两个 const
,因为它们指向的指针和文本都是常量。
14.2 边界检查
在C++ 中,通常有必要检查数组索引是否超出范围。这常常看起来是这样的:
1 | // Example 14.4a |
i < 0
和 i >= size
这两个比较可以使用一个比较替换:
1 | // Example 14.4b |
当 i
被解释为无符号整数时,i
可能的负值将以一个较大的正数出现,这将触发错误条件。用一个比较替换两个比较可以加快代码的速度,因为测试一个条件相对比较昂贵,而类型转换根本不会生成额外的代码。
这个方法可以扩展到一般情况下:你想要检查一个整数是否在一个特定的区间之内:
1 | // Example 14.5a |
可以修改成:
1 | // Example 14.5b |
如果所需区间的长度是 2的幂,则有一种更快的方法来限制整数的范围。例如:
1 | // Example 14.6 |
这需要略微解释一下。i&15
的值肯定在 0 到 15 的区间内。如果 i
在这个区间之外,例如 i = 18
,那么 &
运算符(按位与)将 i
的二进制值截断为 4 位,结果将是 2。结果与 i
除上 16 的余数相同。如果我们不需要错误消息的话,这种方法在数组索引超出范围时可以防止程序出错。需要注意的是,这种方法只适用于2的幂(即2、4、8、16、32、64、……)。通过按位与上$2^{n -1}$,我们可以确保一个数的值小于 $2^n$,并且不是负的。按位与操作隔离数字中有效的低 n 位,并将所有其他位设为零。
14.3 使用位运算符一次检查多个值
位运算符 &
, |
,^
,~
, <<
,>>
可以在一次操作中测试或操作整数的所有位。例如,如果 32 位整数的每个位都有特定的含义,那么可以使用 |
运算符在一个操作中设置多个位;你用 &
运算符清除或遮掩掉多个位。你可以用 ^
运算符转换多个位。
&
运算符对于测试单个操作中的多个条件也很有用。例如:
1 | // Example 14.7a. Testing multiple conditions |
本例中的 if
语句有三个条件,它们被实现为三个分支。如果将 Sunday
、Monday
等常量定义为 2的幂,则可以将它们合并为一个分支:
1 | // Example 14.7b. Testing multiple conditions using & |
通过在例14.7b中给每个常数的值设置成 一个 2的幂,我们实际上是在使用 Day
中的每一位来表示星期几。我们可以用这种方法定义的常量的最大数量等于整数中的位的数量,通常是 32。在 64 位系统中,我们可以使用 64 位整数,这几乎没有任何性能上的损失。
在例 14.7b中的表达式 (Tuesday | Wednesday | Friday)
被编译器转换成 0x2C
,这样的话 if
条件就可以通过一个 &
操作来计算,而这是很快的。如果变量 Day
中设置了 Tuesday
、Wednesday
或 Friday
中的的任何位,&
操作的结果将是非零的,因此将被视为真。
注意布尔运算符 &&
, ||
, !
以及对应的位运算符 &
, |
, ~
。布尔运算符产生一个结果,true(1)或 false (0),且第二个操作数只在需要时计算。位运算符在应用于 32位整数时,会产生 32个结果,它们总是对两个操作数进行求值。然而,位运算符的计算速度比布尔运算符快得多,因为只要操作数是整数表达式而不是布尔表达式,它们就不需要使用分支。
当使用整数作为布尔向量时,位运算符可以做很多事情,而且这些操作非常快。这在包含许多布尔表达式的程序中很有用。无论常量是用 enum
、const
还是 #define
定义的,都不会影响性能。
14.4 整数乘法
整数乘法比加法和减法需要更长的时间(3 - 10个时钟周期,取决于处理器)。编译器优化通常会用一个常量替换整数乘法,并结合加法和移位操作。乘以 2的幂要比乘以其他常数快,因为它可以通过移位操作完成。例如,a*16
使用 a << 4
计算,a * 17
使用 (a << 4) + a
计算。当与常数相乘时,你可以利用和 2的幂相乘的这个优势。编译器也有快速乘以 3、5 和 9 的方法。
在计算数组元素的地址时,会有隐式的乘法计算。在某些情况下,当因子为2的幂时,这个乘法会更快。例如:
1 | // Example 14.8 |
这里,matrix[j][0]
的地址在内部使用下面的式子计算:
(int)&matrix[0][0] + j * (columns * sizeof(float))
。
现在,要乘以 j
的因子是 (cloumns * sizeof(float)) = 8 * 4 = 32
。这是 2的幂,所以编译器可以用 j << 5
替换 j * 32
。如果列的大小不是 2的幂,那么乘法会花费更长的时间。因此,如果以无序方式访问矩阵中的行,则将矩阵中的列数设置为 2的幂是有利的。
这同样适用于结构体或类元素的数组。如果以无序方式访问对象,则每个对象的大小最好是 2的幂。例如:
1 | // Example 14.9 |
在这里,我们在结构体中插入了 UnusedFiller
,以确保其大小是的 2的幂,以使地址计算的更快。
使用 2的幂的优势只适用于以无序方式访问元素的情况。如果 例 14.8和 14.9 中的代码发生了更改,以 i
代替 j
作为索引,那么编译器可以看到地址是按顺序访问的,并且可以通过在前一个地址上添加一个常量来计算每个地址(参见8.1 编译器时如何优化的:归纳变量(Induction variables))。在这种情况下,大小是否为2的幂并不重要。
使大小为2次幂的建议并不适用于非常大的数据结构。相反,如果矩阵太大以至于缓存成为问题,则应该尽量避免大小为2的幂。如果矩阵中的列数是2的幂,并且矩阵大于缓存,那么就可以得到代价非常昂贵的缓存竞争,如9.10 在大数据结构中的缓存竞争所解释的那样。
14.5 整数除法
整数除法的耗时要比加法、减法和乘法的耗时长得多(32位整数需要27 - 80个时钟周期,具体取决于处理器)。
整数除以 2的幂可以用移位运算来做,这样会快得多。
除以一个常数比除以一个变量快的多,因为编译器优化可以通过选择合适的 $n$ 使用公式: $a * (2^n/b) >> n$ 来计算 $a/b$。 常量 $(2^n/b)$ 是被预先计算好的,乘法是通过位的扩展数(extended number of bits)来完成的。该方法稍微复杂一些,因为必须添加符号和舍入误差的各种更正。该方法在手册2: “Optimizing subroutines in assembly language” 中有更详细的描述。当被除数是无符号的,该方法会快的多。
以下准则可用于改进包含整数除法的代码:
- 整数除以常数比变量快。确保在编译时知道除数的值。
- 如果常数是 2的幂的话,整数除法会更快。
- 当被除数是无符号时,整数除以常量会更快。
例如:
1 | // Example 14.10 |
相同的准则同样适用于取模运算:
1 | // Example 14.11 |
可以利用这些指导原则,如果可能的话,可以使用一个 2的幂的常数做为除数,如果确定被除数不为负数,可以将被除数更改为无符号。
如果除数在编译时是未知,但程序不断重复除以同一个除数,仍然可以使用上述方法。在这种情况下,你必须在编译时对 $(2^n / b)$ 等进行必要的计算。www.agner.org/optimize/asmlib.zip中的函数库包含用于这些计算的各种函数。
通过将循环按常数展开,可以避免将循环计数器除以一个常数,例如:
1 | // Example 14.12a |
这个可以使用下面的代码替换:
1 | // Example 14.12b |
类似的方法也可以用于避免模运算:
1 | // Example 14.13a |
可以被替换成:
1 | // Example 14.13b |
例 14.12b和14.13b中的循环展开仅当循环计数可被展开因子整除时才有效。如果不能被整除,则必须在循环之外执行额外的操作:
1 | // Example 14.13c |
14.6 浮点数除法
浮点数除法的耗时比加法、减法和乘法(20 - 45个时钟周期)耗时要长得多。
浮点数除以一个常数可以用乘以常数的倒数来代替:
1 | // Example 14.14a |
可以把这个改成:
1 | // Example 14.14b |
编译器将在编译时计算 (1./1.2345)
的值,并将倒数插入到代码中,因此你将不会在除法上花费时间。一些编译器会自动将例 14.14a中的代码替换为14.14b的,但只有在某些选项被设置为放宽浮点精度要求时才会这样做(请参阅8.1 编译器是如何优化的:代数化简)。因此显式地进行这种优化更加安全。
有时除法会被完全消除,例如:
1 | // Example 14.15a |
有时会被替换成:
1 | // Example 14.15b |
但是要注意这里的陷阱:如果 c < 0
,不等式符号必须反转。如果 b
和 c
是整数,除法是不精确的,而乘法是精确的。
乘法和除法可以结合在一起,例如:
1 | // Example 14.16a |
这里我们可以通过公分母来消去一个除法:
1 | // Example 14.16b |
使用公分母的技巧甚至可以用于完全独立的除法。例如:
1 | // Example 14.17a |
这可以这样变化:
1 | // Example 14.17b |
14.7 不要混合使用 float 和 double
不管你使用的是单精度还是双精度,浮点数的计算通常花费相同的时间。但是在为 64位操作系统编译的程序和使用指令集SSE2 或更高版本编译的程序中,混合使用单精度和双精度是有代价的。例如:
1 | // Example 14.18a |
C/C++ 标准规定所有浮点数常量在默认情况下都是双精度的。 所以在这个例子中, 1.2
是一个双精度的常量。因此,在将 b
与双精度常数相乘之前,需要将 b
从单精度转换为双精度,然后再将结果转换回单精度。这些转换需要很长的时间。你可以通过避免转换,来使代码达到 5倍的效率,无论是通过使常数变成单精度或 使 a
和 b
变成双精度的:
1 | // Example 14.18b |
当为没有SSE2 指令集的旧处理器编译代码时,混合不同的浮点精度不会带来任何损失,但是最好在所有操作数中保持相同的精度,以防代码被移植到另一个平台。
14.8 浮点数和整数相互转换
将浮点数转换成整数
根据C++ 语言的标准,所有从浮点数到整数的转换都使用向零的截断,而不是四舍五入。这是不幸的,因为除非使用SSE2 指令集,否则截断要比舍入花费更长的时间。如果可能,建议启用SSE2 指令集。在64位模式下,SSE2 总是被启用。
在没有SSE2 的情况下,从浮点数到整数的转换通常需要 40个时钟周期。如果在代码的关键部分不能避免从 float
或 double
到 int
的转换,那么可以使用舍入而不是截断来提高效率。这大约快了三倍。程序的逻辑可能需要修改,以补偿舍入和截断之间的差异。
使用 lrintf
和 lrint
函数可以高效地将浮点数或双精度数转换为整数。不幸的是,由于对C99 标准的争议,许多商业编译器中缺少这些这些函数。下面的例 14.19给出了 lrint
函数的实现。该函数将浮点数四舍五入到最近的整数。如果两个整数相等,则返回偶数。没有溢出检查。此函数适用于 32位Windows 和 32位Linux 中的 Microsoft、Intel 和Gnu 编译器 。
1 | // Example 14.19 |
这段代码只适用于兼容Intel/x86 的微处理器。函数库amslib也提供了该函数。
在 64位模式下或启用 SSE2 指令集时,四舍五入和截断之间的速度没有差别。缺失的功能在 64位模式或启用SSE2 指令集时可以按如下代码实现:
1 | // Example 14.21. // Only for SSE2 or x64 |
例 14.21 中的代码比其他四舍五入方法更快,但当启用SSE2 指令集时,它既不比截断快,也不比截断慢。
将整数转换成浮点数
整数到浮点数的转换比浮点数转换到整数快。转换时间通常在 5 到 20 个时钟周期之间。在某些情况下,使用浮点变量进行简单的计算可能是有利的,以避免从整数到浮点的转换。
无符号整数转换为浮点数的效率低于有符号整数转换成浮点数。如果无符号整数转换为有符号整数不会导致溢出,那么在转换为浮点数之前将无符号整数转换为有符号整数效率会更高。例如:
1 | // Example 14.22a |
如果你确定$u < 2^{31}$,那么在转换为浮点数之前先将其转换为有符号的:
1 | // Example 14.22b |
14.9 用整数操作来改变浮点型变量
根据IEEE 754 (1985) 标准,浮点数以二进制表示形式存储。几乎在所有现代微处理器和操作系统中都使用这个标准(一些非常老的DOS 编译器除外)。
float
、double
和 long double
的表示法反映了形式为$\pm 2^{eee}.1.ffff$的浮点值。$\pm$表示符号,$eee$ 是指数,$fffff$ 是分数形式的二进制小数。符号位存储为单个位,0 表示正数,1 表示负数。指数存储为偏置二进制整数,分数存储为二进制数。如果可能的话,指数总是规格化的,所以小数点前的值是 1。这个“1”不包括在表示形式中,除非是 long double
类型。格式可以表示如下:
1 |
|
非零浮点数的值可以使用下面的方式计算:
1 |
|
如果除符号位之外的所有位都为 0,则值为 0。0可以表示可以包括或者不包括符号位。
浮点格式是标准化的这一事实允许我们使用整数操作直接操作浮点表示的不同部分。这可能是一个优势,因为整数操作比浮点操作快。只有当你确信你知道你在做什么时,你才应该使用这些方法。有关注意事项,请参阅本节的末尾。
我们只需要反转一个符号位就可以改变浮点数的符号:
1 | // Example 14.23 |
我们可以将符号位设置成 0以得到绝对值:
1 | // Example 14.24 |
我们可以通过测试除符号位以外的所有位来检查浮点数是否为零:
1 | // Example 14.25 |
我们对指数部分加上 $n$ 就可以将一个非零浮点数乘上 $2^n$:
1 | // Example 14.26 |
例 14.26不会检查溢出,而且只有$n$是整数时才能有用。当没有下溢风险时,你可以对指数部分减去 $n$ 以达到除以 $2^n$的目的。
1 | // Example 14.27 |
在 例 14.27假设我们知道 u.f
,v.f
都是正的。如果两者都是负数,或者其中一个为 0,另一个为 -0(符号位为0),则会失败。
我们可以将符号位移出来比较绝对值:
1 | // Example 14.28 |
例 14.28中乘以 2 将移出符号位,使其余位表示浮点数绝对值的单调递增函数。
我们可以通过设置分数部分的位将在区间 $0 <= n < 2^{23}$的整数转换成在区间 $[1.0, 2.0)$的浮点数:
1 | // Example 14.29 |
该方法对随机数生成器非常有用。
通常,如果浮点变量存储在内存中,那么以整数的形式访问它会更快,但如果它是寄存器变量,则不会更快。union
强制变量存储在内存中,至少是临时的。因此,如果相同变量使用寄存器可以使其它临近代码获益时,那么使用上述示例中的方法将没有好处。
在这些例子中,我们使用 union
而不是指针的类型转换,是因为这种方法更安全。指针的类型转换可能不适用于遵循标准 C 严格的别名规则的编译器,该规则指定不同类型的指针不能指向同一对象,char
指针除外。
上面的例子都使用单精度。在 32位系统中使用双精度浮点数会变得更复杂。双精度浮点数用 64个位表示,但是 32 位系统不支持 64位整数。许多 32位系统允许你定义 64位整数,但是它们实际上用两个32位整数来表示,效率较低。你可以使用双精度浮点数的高 32位,它允许你访问符号位、指数和分数中的高几位。例如,可以这样测试双精度浮点数的符号:
1 | // Example 14.23b |
不建议通过修改 double
类型的一半二进制位来修改它,比如,如果你想要通过 u.i[1] ^= 0x80000000
来反转上述示例中的符号位的话,但这很在 CPU 中产生存储转发延迟(参见手册3:“The microarchitecture of Intel, AMD and VIA CPUs”)。在64 位系统中,可以通过使用 64位整数而不是两个 32位整数表示 double
来避免这种情况。
访问双精度浮点数中的 32位的另一个问题是,它不能移植到大端存储的系统中。因此,如果要具有大端存储的其他平台上实现,例 14.23b 和 例 14.30 将需要修改。所有x86 平台(Windows、Linux、BSD、基于Intel CPU 的Mac OS 等)都使用小端存储,但其他系统可能使用大端存储(如PowerPC)。
我们可以通过比较 32 - 62 位来近似比较双精度浮点数。这在高斯消元法中求矩阵中值最大的主元是很有用的。例 14.28 中的方法在主元搜寻中可以这么使用:
1 | // Example 14.30 |
例 14.30 找到数组中 数字(除去符号位)最大(或差不多最大的)的元素。它可能无法区分相对差小于$2^{-20}$的元素,但这对于寻找合适的主元来说是足够准确的。整数比较可能比浮点比较更快。在大的端系统中,你必须用 u[0]
替换 u[1]
。
14.10 数学函数
最常见的数学函数如对数、指数函数、三角函数等都是在 x86 CPU 的硬件中实现的。然而,在大多数情况下,当SSE2 指令集可用时,软件实现比硬件实现更快。如果启用了SSE2 指令集,好的编译器将使用软件实现。
使用这些函数的软件实现而不是硬件实现的优势对于单精度比对于双精度更大。但在大多数情况下,软件实现要比硬件实现快,即使对于双精度也是如此。
通过包含与Intel C++ 编译器一起提供的库:libmmt.lib 和头文件 mathimf.h,你可以在不同的编译器中使用Intel 数学函数库。这个库包含许多有用的数学函数。Intel’s Math Kernel Library 提供了许多高级数学函数,可以从www.intel.com获得(可参见12.7 用于向量的数学函数)。AMD 数学核心库 包含类似的功能,但优化较差。
注意,当在非Intel 处理器上运行时,Intel 函数库没有使用最好的指令集(有关如何克服这个限制,请参阅13.7 Intel 编译器中的 CPU分派)。
14.11 静态库 VS 动态库
函数库可以实现为静态链接库(.ilb, .a),或动态链接库,也称为共享对象(.dll, . so)。静态链接的机制是链接器从库文件中提取所需的函数并将它们复制到可执行文件中。只需要将可执行文件分发给最终用户。
动态链接的工作方式则不同。动态库中函数的链接在加载库或运行时解析。因此,当程序运行时,可执行文件和一个或多个动态库都被会加载到内存中。可执行文件和所有动态库都需要分发给最终用户。
静态链接相对于动态链接的优点是:
- 使用静态链接,应用程序只需要包含库中所需要的部分,而使用动态链接则需要将整个库(或至少库的大部分)加载到内存中,即使只需要库中的一个函数。
- 当使用静态链接时,所有代码都包含在一个可执行文件中。而使用动态链接使得程序启动时必须加载多个文件。
- 调用动态库中的函数要比调用在静态链接库中的函数花费更长的时间,因为它需要通过导入表中的指针进行额外的跳转,还可能需要在过程链接表(PLT )中进行查找。
- 当代码分布在多个动态库之中时,内存空间变得更加碎片化。动态库加载在可被内存页大小(4096)整除的圆形内存地址(round memory addresses)处。这将使所有动态库争用相同的高速缓存线路。这降低了代码缓存和数据缓存的效率。
- 动态库在某些系统中效率可能会较低,因为需要位置无关代码(参见下面的内容)。
- 如果使用动态链接,安装使用相同动态库的更新版本的第二个应用程序,可以改变第一个应用程序的行为,但是如果使用静态链接,则不能改变第一个应用程序的行为。
使用动态链接的优点是:
- 同时运行的多个应用程序可以共享相同的动态库,无需将库的多个实例加载到内存中。这适用于同时运行多个进程的服务器。实际上,只有代码节和只读数据节可以共享。任何可写数据部分,每个进程都需要一个单独的实例。
- 无需更新调用程序,动态链接库就可以更新到新的版本。
- 动态链接库可以被不支持静态链接的编程语言调用。
- 使用动态链库可以用于为已有程序制作插件来添加新的功能。
权衡每种方法的上述优点,显然静态链接更适合于速度关键型函数。许多函数库都有静态和动态版本。如果速度很重要,则建议使用静态版本。
有些系统允许函数调用的延迟绑定。延迟绑定的原则是,在加载程序时不解析链接函数的地址,而是等到第一次调用该函数时才解析。延迟绑定对于大型库非常有用,因为在大型库中,在单个会话中实际调用的函数很少。但是延迟绑定肯定会降低所调用函数的性能。当一个函数第一次被调用时,由于它需要加载动态链接器,会出现相当大的延迟。
延迟绑定造成的延迟会导致交互程序的可用性问题,因为单击菜单的响应时间变得不一致,有时长得令人无法接受。因此,延迟绑定应该只用于非常大的库。
无法预先确定加载动态库的内存地址,因为固定地址可能与另一个需要相同地址的动态库冲突。有两种常用的方法来处理这个问题:
- 重定位。如果需要,代码中的所有指针和地址都会被修改,以适应实际的加载地址。重定位由链接器和加载器完成。
- 位置无关代码。代码中的所有地址都是相对于当前位置的。
在Windows 中,dll 使用重定位。链接器将dll 重新定位到特定的加载地址。如果这个地址不是空的,那么dll 将被加载程序重新定位(rebase )到另一个地址。在主可执行文件中调用dll 中的函数要经过导入表或指针。dll 中的变量可以通过 main
函数中的导入指针来访问(A variable in a DLL can be accessed from main through an imported pointer),但是很少使用这个特性。通过函数调用来交换数据或指向数据的指针更为常见。对dll 内数据的内部引用在 32 位模式下使用绝对引用,在 64位模式下使用相对引用。后者的效率略微高一点,因为相对引用在加载时不需要重新定位。
共享对象在类Unix 系统中默认使用位置无关代码。这比重定位的效率要低,尤其是在 32位模式下。下一章将描述这是如何工作的,并提出避免位置无关代码成本的方法。
14.12 位置无关代码
Linux、BSD 和Mac 系统中的共享对象通常使用所谓的位置无关代码。“位置无关代码”的名称实际上比它所表达的含义更丰富。编译为位置无关的代码具有以下特性:
- 代码部分不包含需要重新定位的绝对地址,只包含自相对地址(self-relative addresses )。因此,代码段可以在任意内存地址加载,并在多个进程之间共享。
- 数据部分不会在多个进程之间共享,因为它通常包含可写数据。因此,数据部分可能包含需要重新定位的指针或地址。
- 在Linux 和BSD 中,所有公共函数和公共数据都可以被覆盖。如果主可执行文件中的函数与共享对象中的函数具有相同的名称,那么不仅在主可执行文件调用时,而且在从共享对象调用时,主可执行文件中的版本都将是优先的。同样的,当主可执行文件中的全局变量具有与共享对象中的全局变量相同的名称时,即使是从共享对象访问,也将使用主可执行文件中的实例。这种所谓的符号插入是为了模拟静态库的行为。为了实现这个“覆盖”特性,共享对象有一个指向其函数的指针表,称为过程链接表(PLT )和一个指向其变量的指针表,称为全局偏移表(GOT )。所有对函数和公共变量的访问都要经过PLT 和GOT。
允许在Linux 和BSD 中重写公共函数和数据的符号插入 特性代价高昂,而且在大多数库中从不会使用。每当调用共享对象中的函数时,都需要在过程链接表中查找函数地址。当访问共享对象中的公共变量时,则需要先在全局偏移表中查找该变量的地址。即使访问同一个共享对象中访问函数或变量,也需要这些查找表。显然,所有这些表查找操作都会大大降低执行速度。更详细的讨论可以在 https://www.macieira.org/blog/2012/01/sorry-state-of-dynamic-libraries-on-linux/中找到。
另一个沉重的负担是在 32位模式下计算自相关引用。32位的x86 指令集没有用于数据自相对寻址的指令。代码通过以下步骤访问公共数据对象:(1)通过函数调用获得其自身的地址。(2)通过一个自相对地址查找GOT。(3)在GOT 中查找数据对象的地址。最后,(4)通过这个地址访问数据对象。在 64位模式下不需要步骤(1),因为x86-64 指令集支持自相对寻址。
在 32位Linux 和BSD 中,所有静态数据都使用较慢的GOT 查找过程,包括不需要“覆盖”特性的本地数据。这包括静态变量、浮点常量、字符串常量和初始化过的数组。我无法解释为什么不必要的时候使用这种延迟很高的流程。
显然,避免繁重的位置无关代码和表查找的最佳方法是使用静态链接,如前一节(14.11 静态库 VS 动态库)所述。在无法避免动态链接的情况下,有多种方法可以避免位置无关代码的时间消耗特性。这些解决方法依赖于系统,如下所述。
32 位 Linux 中的共享对象
根据Gnu 编译器手册,共享对象通常都是使用 -fpic 选项编译的。该选项使代码段是位置无关的,为所有函数生成PLT,为所有公共和静态数据生成GOT。
不使用 -fpic 选项也可以编译共享对象。这样我们就可以摆脱了上面提到的所有问题。代码将运行得更快,因为只需要一个步骤,我们就可以访问内部变量和内部函数,而不是通过前面介绍的复杂的地址计算和表查找机制。在没有 -fpic 选项的情况下编译的共享对象要快得多,除非是一个非常大的共享对象,而其中大多数函数都不会被调用。在 32位Linux 中不使用 -fpic 编译的缺点是加载器将有更多的引用需要重新定位,但是这些地址计算只需要执行一次,而在每次访问时必须执行运行时地址计算。在不使用 -fpic 选项的情况下编译代码部分时,每个进程都需要一个实例,因为代码部分中的重新定位对每个进程来说是不同的。显然,我们失去了覆盖公共符号的能力,但无论如何很少需要使用这个特性。
为了可以移植到 64位模式,你最好避免全局变量或者隐藏它们,解释如下。
64 位 Linux 中的共享对象
在 64位模式下,计算自相对地址的过程要简单得多,因为 64位指令集支持数据的相对寻址。在 64位模式下,由于默认使用相对地址,对特殊的位置无关代码需求更少。然而,我们仍然希望摆脱对本地引用的GOT 和PLT 查找。
在 64位模式下,如果我们不使用-fpic 选项编译共享对象,我们会遇到其它的问题。编译有时会使用 32位的绝对地址(主要是静态数组)。这在主可执行文件中是没有问题的,因为它肯定是在低于 2GB的地址的地方加载的,但对于共享对象则不是这样的,共享对象通常加载在 32 位(signed)地址无法表示的较高地址。在这种情况下,链接器会产生一条错误信息。最佳的解决方案是使用-fpie 选项代替-fpic 选项来进行编译。这将在代码部分生成相对地址,但对于内部引用它不会使用GOT 和PLT。因此,它将比用 -fpic 选项编译时运行得更快,并且对于 32位的情况,它不会有上面提到的缺点。在 32位模式下,-fpie 选项的作用没有那么大,因为它仍然使用GOT*。
另一种方法是使用-mcmodel=large 选项编译,但这将对所有内容使用 64位地址,这是非常低效的,而且它将在代码部分产生重定位,因此不能被共享。
使用 -fpie 选项时,在 64位共享对象中,不可以有公共变量,因为当链接器看到一个公共变量的相对引用时,它会产生一个错误消息,因为它期望这个公共变量有一个GOT 入口。你可以通过避免使用任何公共变量来避免该错误。所有全局变量(即定义在任何函数外部的变量)都应该使用声明 static
或 _attribute__((visibility ("hidden"))
来隐藏。
Gnu 编译器 5.1 及以后版本有一个选项:-fno-semantic-interposition,可以使它能够避免使用 PLT 和 GOT,但仅限于同一文件中的引用。通过使用内联汇编代码为变量提供两个名称,一个全局名称和一个本地名称,并为本地引用使用本地名称,可以得到相同的效果。
尽管有这些技巧,当使用多个模块(源文件)生成共享对象时,并且存在一个模块调用另一个模块时,你可能仍然会得到错误消息:“ “relocation R_X86_64_PC32 against symbol `functionname’ can not be used when making a shared object; recompile with -fPIC”。我至今没有找到该问题的解决方法。
BSD 中的共享变量
BSD 中的共享对象与 Linux 中的工作方式相同。
32位 Mac OS X
32位Mac OS X 的编译器默认情况下使位置无关代码和延迟绑定,即使不使用共享对象。目前在 32位Mac 代码中用于计算自相对地址的方法使用了一种不幸的方法,它会导致错误地预测返回地址,从而延迟执行(有关返回预测的解释,请参阅手册3:“The microarchitecture of Intel, AMD and VIA CPUs”)。
只要在编译器中关闭与位置无关代码的标志,就可以显著加速不属于共享对象的所有代码。因此,请记住,在为 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位共享对象。
14.13 系统编程
设备驱动程序、中断服务路由、系统核心和高优先级线程是速度特别关键的地方。在系统代码或高优先级线程中非常耗时的函数可能会阻塞其他所有内容的执行。
系统代码必须遵守寄存器使用的某些规则,如手册5中的“Calling conventions for different C++ compilers and operating systems”中 “内核代码中的寄存器用法”一章所述。因此,你只能使用针对系统代码的编译器和函数库。系统代码应该使用 C、C++ 或汇编语言 编写。
在系统代码中节约资源的使用是非常重要的。动态内存分配特别有风险,因为它涉及在不方便的时候激活非常耗时的垃圾收集器的风险。队列应该实现为固定大小的循环缓冲区,而不是链表。不要使用STL容器。见9.6 动态内存分配。
15 元编程
元编程意味着编写生成代码的代码。例如,在解释脚本语言中,通常可以编写一段生成字符串的代码段,然后将该字符串解释为代码。
如果计算的所有输入在编译时都可用,元编程在编译语言(如C++)中非常有用,可以在编译时期而不是运行时期做一些计算。(当然,在所有事情都在运行时发生的解释语言中,则没有这样的优势)。
在C++ 中,可以考虑使用以下技术进行元编程:
- 预处理指令。例如使用
#if
代替if
。这是一个移除无效代码的有用方法,但是,由于预处理器先于编译器,并且只理解最简单的表达式和运算符,所以它所能做的工作受到了严重的限制。 - 编写一个C++ 程序,生成另一个C++ 程序(或它的一部分)。在某些情况下,这可能很有用,例如生成最终程序中作为静态数组的数学函数表。当然,这需要编译第一个程序的输出。
- 优化编译器可能会在编译时尽可能多地执行操作。例如,所有好的编译器都会将
int x = 2 * 5
化简位int x = 10
; - 模板在编译时实例化。在编译模板实例之前,将其参数替换为它们的实际值。这就是为什么使用模板实际上没有成本的原因(见7.30 模板)。使用模板元编程可以表达任何算法,但是这种方法非常复杂和笨拙,稍后你就会看到。
下面的例子解释了当指数是编译时已知的整数时,如何使用元编程来加速幂函数的计算。
1 | // Example 15.1a. Calculate x to the power of 10 |
在一般情况下,pow
函数使用对数,但在上面这种情况下,它将识别到 10是整数,因此结果可以只使用乘法计算。当指数为正整数时,在 pow
函数中使用以下算法:
1 | // Example 15.1b. Calculate integer power using loop |
当我们展开循环并重新组织时,例 15.1b 中使用的方法将更容易理解:
1 | // Example 15.1c. Calculate integer power, loop unrolled |
正如我们所看到的,只需要四次乘法就可以计算出 pow(x,10)
。那怎么才能将 例 15.1b 转换到 例 15.1c呢?我们利用了在编译时已知 n
的事实,消除了只依赖于 n
的所有内容,包括 while
循环、if
语句和所有整数计算。例 15.1c中的代码比 例 15.1b 更快,在这种情况下,它可能也更小。
从 例 15.1b 到 例 15.1c 的转换是由我手动完成的,但是如果我们想生成一段代码,使它可以用于编译时已知的常量 n
,那么我们需要元编程。我测试过的所有编译器都不能自动将 例 15.1a 转换为 例 15.1c,只有 Gnu 编译器 才能将 例 15.1b 转换为 例 15.1c。我们只能希望将来的编译器能够自动进行这样的优化,但只要不能做到,我们就可能需要元编程。
下一个示例显示使用模板元编程来实现计算。如果你不懂也不要惊慌。我给出这个示例只是为了说明模板元编程是多么复杂。
1 | // Example 15.1d. Integer power using template metaprogramming |
如果你想知道这是怎么回事,请看下面的解释。如果你不确定是否需要,可以跳过下面的解释。
在C++ 模板元编程中,循环被实现为递归模板。powN
模板正在调用自己,以便模拟 例 15.1b 中的 while
循环。分支是通过(部分)模板特化实现的, 这就是对 例 15.1b中的 if
分支的实现。递归必须始终以非递归模板特化结束,而不是在模板中包含分支。
powN
模板是类模板而不是函数模板,因为只允许对类进行部分模板特化。将 N
分解成二进制表示的各个位是非常需要技巧的的。我使用的技巧是 N1 = N&(N-1)
给得到 N
的去掉最右边的 1 位的值。如果 N
是 2 的幂,那么 N&(N-1)
为 0。常量 N1
可以用其他方法定义,而不是只能使用宏定义,但是这里使用的方法是我尝试过的所有编译器中唯一全部适用的方法。
Microsoft、Intel 和Gnu 编译器实际上按照预期地将 例15.1d 化简到 例 15.1c,而Borland 和Digital Mars 编译器产生的代码不太理想,因为它们无法消除公共子表达式。
为什么模板元编程如此复杂?因为C++ 的模板特性从来不是为该目的设计。这只是碰巧可行。模板元编程非常复杂,我认为使用它是不明智的。复杂的代码本身就是一个风险,而且验证、调试和维护这些代码的成本非常高,因此很少有理由在获得相对较小的性能收益时使用它。
然而在某些情况下,模板元编程是确保在编译时完成某些计算的唯一方法。(可以在我的 vector class library 中找到例子)。
D 语言 允许编译时 if
语句(称为 static if
),但不没有编译时循环 或编译时生成标识符名称。我们只能希望这样的功能在将来能够实现。如果C++ 的未来版本应该会允许 编译时 If 和编译时 while 循环,那么将例 15.1b转换为元编程将非常简单。MASM 汇编语言 具有完整的元编程特性,包括通过字符串函数来定义函数名和变量名的能力。在手册2“Optimizing subroutines in assembly language”的“宏循环”一节中,提供了一个类似于例 15.1b和 例 15.1d的使用汇编语言的元编程实现。
当我们在等待更好的元编程工具出现时,我们可以选择那些最擅长在任何可能的情况下自动进行等价化简的编译器。使用自动将 例 15.1a 简化到例 15.1c的编译器当然是最简单和最可靠的解决方案。(在我的测试中,Intel 编译器将例 15.1a简化为内联的例 15.1b, Gnu 编译器 可以将例 15.1b简化为例 15.1c,但是没有一个编译器能将例 15.1a简化为例 15.1c)。
16 测试速度
测试程序的速度是优化工作的重要组成部分。你必须检查你的修改是否真的提高了速度。
有多种可用的分析器,它们对于查找热点和测量程序的总体性能非常有用。然而,分析器并不总是准确的,而且当程序花费大部分时间等待用户输入或读取磁盘文件时,可能很难准确地测量你需要的是什么。有关分析的讨论请参见3.2 使用分析器查找热点(hot spots)。
当确定了热点之后,隔离热点并仅对代码的这一部分进行测量可能是有帮助的。这可以通过使用所谓的时间戳计数器来获得CPU 时钟的分辨率来实现。这是一个计数器,用来测量CPU 启动以来的时钟脉冲数。时钟周期的长度是时钟频率的倒数,如3.1 一个时钟周期是多长?所述。如果你在执行一段关键代码之前和之后读取时间戳计数器的值,那么你可以得到确切的时间消耗,即两个时钟计数之间的差值。
使用例 16.1中列出的函数 ReadTSC
可以获得时间戳计数器的值。此代码仅适用于支持指令集函数的编译器。或者,你可以使用www.agner.org/optimize/testp.zip中的头文件 timingtest.h,或者从www.agner.org/optimize/asmlib.zip获得 ReadTSC
作为库函数来使用。
1 | // Example 16.1 |
你可以使用此函数来测量执行关键代码前后的时钟计数。测试设置可能是这样的:
1 | // Example 16.2 |
例 16.2中的代码调用关键函数十次,并将每次运行的时间消耗存储在一个数组中。然后在测试循环之后输出这些值。以这种方式测量的时间包括调用 ReadTSC
函数所需的时间。你可以从计数中减去这个值。这个值可以简单地通过移除例 16.2中的 CriticalFunction
函数的调用来测量。
测量的时间按以下方式解释。第一次调用地计数通常高于随后的数。这是当代码和数据没有被缓存时执行 CriticalFunction
函数所需要的时间。随后的计数给出当代码和数据被经可能缓存好时所需的执行时间 。第一个计数和随后的计数分别表示“最坏情况”和“最佳情况”的值。这两个值中哪一个最接近真实情况取决于最终程序中对 CriticalFunction
函数的调用一次还是多次,以及对 CriticalFunction
调用之间是否有其他代码使用缓存。如果你的优化工作集中在CPU 效率上,那么它是“最好的情况”就很重要,你应该看看某个修改是否有利可图。另一方面,如果你的优化工作集中于按顺序排列数据以提高缓存效率上,然后你还可以查看“最坏情况”下的计数。在任何情况下,典型应用程序中,用户可能经过的时间延迟应该时这么计算的:时钟计数 时钟周期 调用 CriticalFunction
函数的次数。
有时候,你测量的时钟计数比正常情况下要高得多。当在 CriticalFunction
函数执行期间发生任务切换时,就会发生这种情况。你无法在受保护的操作系统中避免这种情况,但是你可以通过在测试前增加线程优先级并在测试后将优先级设置为正常来减少这个问题的发生。
时钟计数经常波动,测试结果的可重复性可能不是很好。这是因为现代CPU 可以根据工作负载动态地改变时钟频率。工作负荷大时时钟频率增大,工作负荷小时时钟频率减小,以节约电能。有多种方法可以获得可重复的时间测量值:
- 通过在测试代码之前给CPU 一些繁重的工作来预热CPU。
- 禁用BIOS 设置中的省电选项。
- 在Intel CPU 上:使用内核时钟周期计数器(见下面内容)。
16.1 使用性能监视器计数器
许多CPU 都有一个内置的测试特性,称为性能监视计数器。性能监视器计数器是CPU 中的一个计数器,可以设置它来计数某些事件,比如执行的机器指令数量、缓存丢失、分支错误预测等。这些计数器对于研究性能问题非常有用。性能监视计数器是特定于CPU 的,每个CPU 模型都有自己的一组性能监视参数。
CPU 厂商会提供适合他们CPU 的分析工具。英特尔的分析器叫做 VTune;AMD 的分析器叫做 CodeAnalyst。这些分析器对于识别代码中的热点非常有用。
在我自己的研究中,为了使用性能监视器计数器,我开发了一个测试工具。我的测试工具同时支持Intel、AMD 和VIA 处理器,可以从www.agner.org/optimize/testp.zip获得。这个工具不是分析器。它不是用于寻找热点的,而是用于在确定了热点之后研究代码段。
我的测试工具可以以两种方式使用。第一种方法是将要测试的代码插入测试程序本身并重新编译它。我使用它来测试单个汇编指令或小段代码。第二种方法是在运行要优化的程序之前设置性能监视器计数器,并在要测试的代码段之前和之后读取程序内部的性能计数器。你可以使用与上面例 16.2相同的原则,但是读取一个或多个性能监视器计数器,替换(除了)时间戳计数器。测试工具可以在所有CPU 内核中设置并启用一个或多个性能监视器计数器,并保持启用它们(每个CPU 内核中有一组计数器)。计数器会一直开着,直到你关掉它们,或者直到电脑重置或进入睡眠模式。有关详细信息,请参阅我的测试工具手册(www.agner.org/optimize/testp.zip)。
英特尔处理器中一个特别有用的性能监视器计数器称为核心时钟周期计数器。核心时钟周期计数器是按照CPU 核心运行时的实际时钟频率而不是外部时钟计算时钟周期的。这给出了一个几乎与时钟频率变化无关的度量。当测试一段代码的哪个版本最快时,核心时钟周期计数器非常有用,因为你可以避免时钟频率上升和下降的问题。
记得在程序中插入一个开关,以便在不测试时关闭计数器的读取。当性能监视器计数器被禁用时,试图读取它们将导致程序崩溃。
16.2 单元测试的陷阱
在软件开发中,通常单独测试每个函数或类。这种单元测试对于验证优化函数的功能是必要的,但是不幸的是,单元测试并没有提供关于函数性能在速度方面的全部信息。
假设你有两个不同版本的关键函数,你想找出哪个是最快的。测试这一点的典型方法是编写一个小型测试程序,使用一组合适的测试数据多次调用关键函数,并测量所需的时间。在此单元测试下性能最好的版本可能比其他版本占用更大的内存。在单元测试中看不到缓存不命中损失,因为测试程序使用的代码和数据内存总量可能小于缓存大小。
当在最终程序中插入关键函数时,代码缓存和数据缓存很可能是关键资源。现代CPU 的速度如此之快,以至于时钟周期花费在执行指令上不太可能像内存访问和缓存大小那样成为瓶颈。如果是这种情况,那么关键函数的最佳版本可能是单元测试中花费更长的时间但内存占用更少的版本。
例如,如果你想知道展开一个大循环是否有利的,那么你不能依赖单元测试而不考虑缓存效果。
通过为链接器使用“生成映射文件”选项,你可以查看链接映射或汇编代码列表来计算函数使用了多少内存。代码缓存使用和数据缓存使用都很重要。分支目标缓冲区也是一个关键的缓存。因此,还应该考虑函数中跳转、调用和分支的数量。
一个实际的性能测试不仅应该包含单个函数或热点,还应该包含包含关键函数和热点的最内层循环。应该使用一组真实的数据来进行测试,以便为分支错误预测获得可靠的结果。性能度量不应该包括程序中等待用户输入的任何部分。用于文件输入和输出的时间应该分开测量。
不幸的是,用单元测试来度量性能的谬论非常普遍。即使是一些最佳优化的函数库也会使用过多的循环展开,因此内存占用非常大。
16.3 最差条件测试
大多数性能测试都是在最佳条件下进行的。消除了所有干扰的影响,所有资源都是充足的,缓存条件是最优的。在最佳条件下测试是有用的,因为它提供了更可靠和可重复的结果。如果你想比较同一算法的两种不同实现的性能,那么你需要消除所有干扰影响,以便使测量尽可能准确和可重复。
然而,在某些情况下,在最坏的情况下测试性能更为相关。例如,如果你想确保对用户输入的响应时间永远不会超过可接受的范围,那么你应该在最坏情况下测试响应时间。
产生流媒体音频或视频的程序也应该在最坏的情况下进行测试,以确保它们始终保持预期的实时速度。输出中的延迟或故障是不可接受的。
在测试最坏情况下的性能时,以下每一种方法都可能是相关的:
- 第一次激活程序的某个特定部分时,由于代码的延迟加载、缓存不命中和分支预测错误,它可能比之后的速度慢。
- 测试整个软件包,包括所有运行时库和框架,而不是隔离单个函数。在软件包的不同部分之间切换,以增加程序代码的某些部分未被缓存或甚至被交换到磁盘的可能性。
- 依赖于网络资源和服务器的软件应该在流量较大的网络和被充分使用的服务器上进行测试,而不是专用的测试服务器。
- 使用包含大量数据的大型数据文件和数据库。
- 使用CPU 速度慢、RAM 不足、安装了大量无关软件、运行了大量后台进程、硬盘速度慢且碎片化的旧计算机。
- 使用不同品牌的 CPU、不同类型的显卡等进行测试。
- 使用杀毒程序,扫描所有的文件访问。
- 同时运行多个进程或线程。如果微处理器支持超线程,那么尝试在同一个处理器内核中运行两个线程。
- 尝试分配比现有内存更多的RAM,以便强制将内存交换到磁盘。
- 通过使最内层循环中使用的代码大小或数据大于缓存大小来触发缓存不命中。或者,你可以主动地使缓存失效。操作系统可能有一个用于此目的的函数,或者你可以使用指令集函数
_mm_clflush
。 - 使数据比正常情况更随机,从而引发分支错误预测。
17 在嵌入式系统中优化
在小型嵌入式应用程序中使用的微控制器比标准PC 拥有更少的计算资源。时钟频率可以低 100倍甚至 1000倍;而且 RAM内存的数量甚至可能比 PC少一百万倍。尽管如此,如果你避免使用大型图形框架、解释器、即时编译器、系统数据库以及通常用于大型系统的其他额外软件层和框架,则可以使软件在这样的小型设备上运行得相当快。
系统越小,选择一个占用较少资源的软件框架就越重要。在最小的设备上,甚至没有操作系统。
可以通过选择可以在 PC上交叉编译的编程语言来获得最佳的性能。任何要求在目标设备上编译或者解释的语言都会对资源产生极大的浪费。由于这些原因,首选的语言通常是 C 或 C++。关键设备驱动程序可能需要汇编语言。
如果遵循下面的指导原则,C++ 只需要比C 多一点点的资源。你可以根据最适合所需程序结构的方式选择 C 或 C++。
节约内存的使用是很重要的。大数组应该在函数中声明,以便在函数返回时释放它们。或者,你可以将相同的数组重用于多个目的。
应该避免所有使用 new
/ delete
或 malloc
/ free
的动态内存分配,因为管理内存堆的开销很大。堆管理器有一个垃圾收集器,它可能以不可预测的间隔消耗时间,这可能会干扰实时应用程序。
请记住,STL (标准模板库)和其他容器类库中的容器类使用 new
和 delete
来动态内存,而且常常过多地使用动态内存分配。除非你有足够的资源,否则绝对应该避免使用这些容器。例如,FIFO 队列应该被实现为一个固定大小的循环缓冲区,以避免动态内存分配。不要使用链表(请参阅9.7 容器类)。
字符串类的所有常见实现都使用动态内存分配。你应该避免这些,并以老式的 C风格使用字符数组处理字符串。注意,C风格的字符串函数不会检查数组是否溢出。程序员需要确保数组足够大,可以处理字符串(包括终止符 0),并在必要时进行溢出检查(参见9.8 字符串)。
C++ 中的虚函数比非虚函数占用更多的资源。尽可能避免使用虚函数。
较小的微处理器没有本地浮点执行单元。此类处理器上的任何浮点运算都需要一个很大的浮点库,这非常耗时。因此,应该避免使用浮点表达式。例如,a = b * 2.5
可能改为a = b * 5 / 2
(注意中间表达式 b * 5
可能会溢出)。只要程序中有一个浮点常量,就会加载整个浮点库。如果你想用两个小数来计算一个数字,那么你应该把它乘以100,这样它就可以表示为一个整数。
整数变量可以是 8位、16位或 32位(很少有 64位)。如果需要,可以使用不会导致特定应用程序溢出的最小整数大小来节省RAM空间。整数大小没有跨平台标准化。有关每种整数类型的大小,请参阅编译器文档。
中断服务程序和设备驱动程序尤其重要,因为它们可以阻止其他所有东西的执行。这通常属于系统编程领域,但在没有操作系统的应用程序中,这是应用程序程序员的工作。当没有操作系统时,程序员更容易忘记系统代码是很关键的,因此系统代码没有与应用程序代码分离。中断服务程序应该做尽可能少的工作。通常,它应该将接收到的数据的一个单元保存在静态缓冲区中,或者从缓冲区发送数据。它永远不应该响应某个命令,或者执行它所服务的特定事件之外的其他输入/输出。中断接收到的命令最好以较低的优先级响应,通常在主程序的消息循环中响应。有关系统代码的进一步讨论,请参见14.13 系统编程。
在本章中,我描述了一些对资源有限的小型设备特别重要的考虑。本手册其余部分的大部分建议也与小型设备有关,但由于小型微控制器的设计会存在一些差异:
- 较小的微控制器没有分支预测(见7.12 分支和 switch语句)。软件中不需要考虑分支预测。
- 较小的微控制器没有缓存(见9.2 缓存结构)。不需要组织数据来优化缓存。
- 较小的微控制器没有无序执行。没有必要打破依赖链(见3.15 依赖链)。
18 编译器选项一览
Table 18.1. Command line options relevant to optimization
MS compiler Windows | Gnu compiler Linux | Intel compiler Windows | Intel compiler Linux | |
---|---|---|---|---|
Optimize for speed | /O2 or /Ox | -O3 or -Ofast | /O3 | -O3 |
Interprocedural optimization | /Og | |||
Whole program optimization | /GL | —combine -fwhole-program | /Qipo | -ipo |
No exception handling | /EHs | |||
No stack frame | /Oy | -fomit- frame-pointer | -fomit- frame-pointer | |
No runtime type identification (RTTI) | /GR– | -fno-rtti | /GR- | -fno-rtti |
Assume no pointer aliasing | /Oa | -fno-alias | ||
Non-strict floating point | -ffast-math | /fp:fast /fp:fast=2 | -fp-model fast, -fp-model fast=2 | |
Simple member pointers | /vms | |||
Fastcall functions | /Gr | |||
Function level linking (remove unreferen-ced functions) | /Gy | -ffunction- sections | /Gy | -ffunction- sections |
SSE instruction set (128 bit float vectors) | /arch:SSE | -msse | /arch:SSE | -msse |
SSE2 instruction set (128 vectors of integer or double) | /arch:SSE2 | -msse2 | /arch:SSE2 | -msse2 |
SSE3 instruction set | -msse3 | /arch:SSE3 | -msse3 | |
Suppl. SSE3 instr. set | -mssse3 | /arch:SSSE2 | -mssse3 | |
SSE4.1 instr. set | -msse4.1 | /arch:SSE4.1 | -msse4.1 | |
AVX instr. set | /arch:AVX | -mAVX | /arch:AVX | -mAVX |
Automatic CPU dispatch | /QaxSSE3, etc. (Intel CPU only) | -axSSE3, etc. (Intel CPU only) | ||
Automatic vectorization | -O3 -fno- trapping-math -fno- math-errno-mveclibabi | |||
Automatic paralleli- zation by multiplethreads | /Qparallel | -parallel | ||
Parallelization by OpenMP directives | /openmp | -fopenmp | /Qopenmp | -openmp |
32 bit code | -m32 | |||
64 bit code | -m64 | |||
Static linking | /MT | -static | /MT | -static |
(multithreaded) | ||||
Generate assembly listing | /FA | -S - masm=intel | /FA | -S |
Generate map file | /Fm | |||
Generate optimization report | /Qopt-report | -opt-report |
Table 18.2. Compiler directives and keywords relevant to optimization
MS compiler Windows | Gnu compiler Linux | Intel compiler Windows | Intel compiler Linux | |
---|---|---|---|---|
Align by 16 | __declspec(align(16)) |
__attribute((aligned(16))) |
__declspec(align(16)) |
__attribute((aligned(16))) |
Assume pointer isaligned | #pragma vector aligned |
#pragma vector aligned |
||
Assume pointer notaliased | #pragma optimize("a", on) __restrict |
__restrict |
__declspec(noalias) __restrict #pragma ivdep |
__restrict #pragma ivdep |
Assume function ispure | __attribute((const)) |
__attribute((const)) |
||
Assume function does notthrow exceptions | throw() |
throw() |
throw() |
throw() |
Assume function called only fromsame module | static |
static |
static |
static |
Assume member functioncalled only fromsame module | __attribute__((visibility ("internal"))) |
__attribute__((visibility ("internal"))) |
||
Vectorize | #pragma vector always |
#pragma vector always |
||
Optimize function | #pragma optimize(...) |
|||
Fastcall function | __fastcall |
__attribute((fastcall)) |
__fastcall |
|
Noncached write | #pragma vector nontemporal |
#pragma vector nontemporal |
Table 18.3. Predefined macros
MS compiler Windows | Gnu compiler Linux<\br> | Intel compiler Windows | Intel compiler Linux | |
---|---|---|---|---|
Compiler identification | MSC_VER and not __INTEL_COMPILER |
__GNUC__ and not _INTEL_COMPILER |
__INTEL_COMPILER |
__INTEL_COMPILER |
16 bit | not _WIN32 |
n.a. | n.a. | n.a. |
platform | ||||
32 bitplatform | not _WIN64 |
not _WIN64 |
||
64 bit platform | _WIN64 |
_LP64 _WIN64 _LP64 |
||
Windows platform | _WIN32 |
_WIN32 |
||
Linux platform | n.a. | __unix__ __linux__ |
__unix__ __linux__ |
|
x86 platform | _M_IX86 |
_M_IX86 |
||
x86-64 platform | M_IX86 and _WIN64 |
_M_X64 |
_M_X64 |
19 文献
Agner Fog 的其它手册
本手册是五本系列中手册的第一本。有关手册列表,请参见1 简介。
关于代码优化的文献
Intel:”Intel 64 and IA-32 Architectures Optimization Reference Manual”。developer.intel.com。许多用于在英特尔 CPU 优化 C++和汇编代码的建议。定期更新版本;
AMD:”Software Optimization Guide for AMD Family 15h Processors”。 www.amd.com。许多用于在 AMD CPU 优化 C++和汇编代码的建议。定期更新版本;
Intel:”Intel® C++ Compiler Documentation”。包含在英特尔 C++编译器中,可以从 www.intel.com 上找到。使用 Intel C++ 编译器优化特性的手册
维基百科关于编译器优化的文章。en.wikipedia.org/wiki/Compiler_optimization。
ISO/IEC TR 18015, “Technical Report on C++ Performance”。 www.openstd.org/jtc1/sc22/wg21/docs/TR18015.pdf。
OpenMP。www.openmp.org。用于并行处理的OpenMP指令的文档。
Scott Meyers: “Effective C++”. Addison-Wesley. Third Edition, 2005; and “More Effective C++”. Addison-Wesley, 1996。这两本书包含了许多关于高级c++编程的技巧,如何避免难以发现的错误,以及一些提高性能的技巧。
Stefan Goedecker and Adolfy Hoisie: “Performance Optimization of Numerically Intensive Codes”, SIAM 2001。关于 C++ 和 Fortran 代码优化的高级书籍。主要关注具有大数据集的数学应用。涵盖个人电脑,工作站和科学向量处理器。
Henry S. Warren, Jr.: “Hacker’s Delight”. Addison-Wesley, 2003。包含许多位操作技巧。
Michael Abrash: “Zen of code optimization”, Coriolis group books 1994。大部分已经过时了。
Rick Booth: “Inner Loops: A sourcebook for fast 32位 software development”, AddisonWesley 1997。大部分已经过时了。
微处理器文档
Intel: “IA-32 Intel Architecture Software Developer’s Manual”, Volume 1, 2A, 2B, and 3A and3B. developer.intel.com.
AMD: “AMD64 Architecture Programmer’s Manual”, Volume 1 - 5. www.amd.com。
网络论坛
一些互联网论坛和新闻组包含关于代码优化的有用讨论。参见www.agner.org/optimization和新闻组 comp.lang.asm.x86 的一些链接。
20 版权声明
这五本手册的版权归 Agner Fog 所有。不允许公开分发和镜像。出于教育目的,允许向有限的受众进行非公开发行。这些手册中的代码示例可以无限制地使用。知识共享许可CC-BY-SA将在我死后自动生效。参见 https://creativecommons.org/licenses/by-sa/4.0/legalcode