Optimizing software in C++
An optimization guide for Windows, Linux, and Mac platforms§
作者:Agner Fog, Technical University of Denmark
最后更新时间:2021-08-11
1 Introduction§
软件工程 vs. 性能工程§
结构编程,面向对象编程,可读性,成熟度,可重用性,多层抽象,系统工程,函数代码行数。
Wirth's Law
“Software is getting slower more rapidly than hardware is getting faster.”
Niklaus Wirth, 1995
2 Choosing the optimal platform§
硬件§
CISC§
选择机器变得没那么重要?CISC 和 RISC 的融合,普遍的向量化指令、多核处理器。
价格,兼容性,开发工具可用性 > 处理器性能。
技术角度,CISC 指令集(x86)并不是一个好的选择,指令集向前兼容到。但仍然有两个优点,
- 相比 RISC 能够更好地利用缓存
- 可以用更少地指令完成相同的工作
超算,移动设备§
超算仍在科学计算领域占有一席之地,但是对于大多数用途,PC 的性能/价格比更高。
不推荐将依赖网络资源的小型机用于关键应用程序,因为无法控制网络资源的响应时间。
移动手持设备越来越流行,在这种比 PC 的存储和算力更小的机器上,节约资源可能更为重要。
平台的选择显然收到任务需求的影响。
图形加速器§
在游戏和动画领域,甚至有专用的物理处理器计算物体的物理运动。
重度的图形处理应用最好使用图形加速器,GPU。
一些非图形的问题对 GPU 算力有需求。这些应用是高度依赖硬件的,不考虑可移植性。
可编程逻辑设备§
可以使用硬件定义语言(HDL)编程,如 Verilog,VHDL。最常见的设备是 CPLD 和 FPGA。
C++ 这种软件编程语言通过指令序列定义算法。
硬件定义语言定义硬件芯片的组成,门、触发器、算术单元等还有布线和连接,天然并行。
在可编程逻辑设备上,复杂的数字运算通常会更快,因为是专用的。
在 FPGA 上实现的微处理器就是所谓的软处理器。这种软处理器又要比专用微处理器更慢。
但是,为某些特定问题通过 HDL 编写后的专用软处理器也是一种高效的解决方案。
更强大的解决方案是异构融核,混合专用的微处理器核与 FPGA 在一个芯片上,这种方案用于一些嵌入式系统。
微处理器§
多核处理器适用于可并行应用,低功耗的轻量处理器处理一些算力较低的应用也是充分的。
操作系统§
x86系列微处理器可以运行在16/32/64位模式下。
16位用于 DOS 和 Windows 3.x。当程序和数据超过 64KB 时,系统使用分段内存。
一些现代处理器不对16位模式优化,一些操作系统不能兼容16位程序。
相比32位系统,64位系统针对一些包含大量函数调用的 CPU密集型应用和使用大量物理内存的应用有性能提升。
在32位模式下,Windows 和 Linux 使用相同的函数调用约定,性能相当。
为了性能,避免位置无关代码和延迟绑定,可以使用静态链接并且不使用位置无关代码(-fno-pic
)。
64位系统相比32位系统的优点:
- 双倍寄存器,AVX512 指令
- 函数参数存入寄存器而非栈中
- 支持64位的整型寄存器,利好使用64位整型的程序
- 分配和释放大块内存更高效
- 所有64位处理器均支持 SSE2 指令
- 指令集支持相对寻址,使得位置无关代码更加高效
缺点:
- 指针,引用和栈入口占用64位
- 访问静态和全局数组时可能需要额外的指令用于计算地址
- 当代码和数据超过 2GB,在一个更大的内存模型中,地址计算更加复杂
- 一些指令会长一个字节
在64位模式下,不同操作系统的函数调用约定不同。
Windows 只允许在寄存器中存入4个函数参数。
Linux,BSD 和 Mac 允许最多14个,6个整型和8个浮点型。
通过内联和静态的关键函数可以减轻一些负担。
编程语言§
底层语言适合优化性能和程序体积,高层语言适合清晰和结构化的代码,更快、更易于开发用户接口和网络资源、数据库等接口。
编译型语言性能更好,一些解释型语言使用了 JIT(just-in-time) 编译技术,加速执行。
一些现代编程语言使用中间代码,如字节码。中间代码必须再经过解释或编译才可以运行。
Java 的一部分实现通过模拟 Java 虚拟机来执行,高频使用的代码会被虚拟机 JIT 编译。
C# 和微软 .NET 框架的其它语言都基于 JIT 编译中间代码。
使用中间代码的好处是可以实现平台无关,最大的缺点是需要安装较大的运行时框架用来解释或编译中间代码,这个框架通常比代码本身占用更多资源。
中间代码的另一个缺点是由于加入中间抽象层使得细节优化更为困难。
编程语言的历史和它们的实现是曲折往复的。在效率、平台无关性和易开发性(开发时间)上总有冲突。
考虑性能,直接选编译型语言,众多编译型语言中,C++的优点,
- 有非常好的编译器和高度优化的函数库
- 作为高级语言,具备一下其它语言没有的高级特性
- C++语言包含C语言这个子集,能够进行底层优化
- 大多数C++编译器支持生成汇编代码,适用于检查代码片段的优化
- 需要高度优化时,大多数C++编译器允许内联汇编和链接汇编模块
- C++语言的编译器几乎存在于各个平台
C++的主要缺点在安全性,缺少对数组边界越界、整型溢出和无效指针的检查。这些检查成为了开发者的责任。
在一些情况下,技术选型不能直接使用C++作为整个项目的语言,但是一些特定代码仍然需要高度优化。混合实现也是一种可行的解决方案,优化后的部分C++代码可以编译为动态链接库或共享对象。
编译器§
C++编译器日益复杂,市面上的编译器种类在减少。许多新特性也增加了这种复杂度。
Microsoft Visual Studio§
非常用户友好,完全版是挺贵的,社区版是够用的。代码优化方面不是最好的。
GNU§
Clang§
Clang 是 Mac 平台上最常用的编译器。Clang 的 Windows 版本有一些复杂,命令行是可用的。
Cygwin64 版本的 Clang 默认使用中等内存模型,对于程序中的静态变量和常量来说使用64位的绝对地址有一些浪费,可以指定-mcmodel=small
。
当需要直接链接外部动态链接库时,中等内存模型是需要的。Cygwin 版本的另一个缺点是发布可执行程序时需要链接 Cygwin 的动态库。
Intel§
支持自动 CPU 适配,为多种不同的 Intel CPU 提供各自版本的优化代码。缺点是对非 Intel 的处理器可能缺少足够的优化。
总结§
对编译器的选择往往取决于遗产代码兼容性、特定IDE倾向、便于调试、GUI 开发、数据库集成、Web应用集成、混合语言编程的需求。
函数库§
最耗时的库函数通常属于以下几类
- 文件 I/O
- 图形和声音处理
- 内存和字符串操作
- 数学函数
- 加密,解密,数据压缩
最好的函数库是高度优化的,使用汇编语言实现并且能够自动进行 CPU 适配。
Microsoft§
GNU§
64位版本优于32位版本。GNU 编译器使用一些内置代码代替常用的内存和字符串指令。使用-fno-builtin
使用库中的实现。
Mac§
Mac OS X (Darwin) 系统平台上的 GNU 编译器和库属于 XNU 项目。支持 Intel 和新版本的处理器,不支持 AMD 处理器。
Intel§
高度优化,支持 x86 和 x86-64 全平台。
一些专用的库,Intel MKL (Math Kernel Library),IPP (Integrated Performance Primitives)。
AMD§
AMD core Math library 优化了一些数学函数,可用于 Intel 处理器,性能不如 Intel 的库。
Asmlib§
作者 Agner Fog 自己写的,用于演示。包含优化的内存和字符串函数,比其它库快,支持 x86 和 x86-64。
UI 框架§
MFC (Microsoft Foundation Classes),可链接运行时 DLL 或静态库,运行时 DLL 比静态库消耗更多内存资源,可执行程序体积会小一些。
另一个轻量的选择 WTL (Windows Template Library),WTL 应用通常比 MFC 应用更快更紧凑。因为缺少文档和高级开发工具,使用 WTL 的开发成本会更高。
跨平台的 UI 库,还有 Qt 和 wxWidgets。
选型需要在开发时间、可用性、程序体积和执行时间上权衡。
克服 C++ 语言的不足§
可移植性§
在 C++ 语法完全标准化的角度,C++ 是完全可移植的。
但 C++ 可以直接访问硬件接口或系统调用,又是系统特有的。
为了便于跨平台移植,推荐将 UI 和其它一些系统特有部分的代码放在一起,将系统无关的另一部分代码放在一起。
整型大小以及一些硬件相关的细节取决于硬件平台和操作系统。
开发时间§
C++ 项目的开发时间和可维护性可以通过一致的模块化和可重用类来改善。
安全§
两大问题:缺少对数组越界访问和无效指针的检查。
数组越界访问是C++程序中最常见的引发问题的原因,可能会重写其它变量的值,甚至重写当前函数的返回地址。这些都会导致奇怪的非预期的行为。
数组通常用作存储文本或输入数据的缓冲区。输入数据缺少检查的缓冲区溢出是一种攻击手段。建议尽量使用 STL 的容器。
通过引用代替指针、初始化指针为0、对象无效时指针置空、避免指针运算和转型等可以尽量避免指针问题。避免使用scanf
。
字符串是特别容易出问题的,特别是字符串的长度没有明确限制。
C 语言风格的方式是将字符串存在一个字符数组中,高效但不安全,最好要在每次存入前能够检查字符串长度。
标准的解决方法是使用 string
或 CString
,这是安全并且灵活的,但在大型程序中效率并不高。
每次创建或修改字符串时,都会分配一个新的内存块。这会引入大量内存碎片,导致过高的堆管理和垃圾回收开销。
更高效而且不失安全性的方法是将所有字符串存在一个内存池中。
整型溢出也是一个问题,C 语言标准中对有符号整型的溢出认为是未定义行为。
使用-ftrapv
对整型溢出陷入中断,非常低效。
使用-Wstrict-overflow=2
获取编译器警告。
使用-fwrapv
或-fno-strict-overflow
得到有定义的溢出行为。
3 Finding the biggest time consumers§
时钟周期§
计算机硬件总会升级,CPU 时钟频率会改变。所以可以计算出一个时钟周期的长度,然后以时钟周期为单位记录性能。
一个时钟周期的长度是时钟频率的倒数。
使用 profiler,寻找热点§
AQtime, Intel VTune, AMD CodeAnalyst
主要的分析方法:
- 对函数调用插桩
- 在函数中插入临时断点
- 基于时间的采样,每过一段时间发生中断,在程序中运行一部分代码出现中断的次数,不修改代码,但不够可靠
- 基于事件的采样,每发生特定的事件中断,需要 CPU 专用的 profiler,可以设置事件比如出现1000次缓存不命中
profiler 并不总是可靠,常见的问题:
- 时间测量是粗粒度的
- 执行时间过短或过长,时间太短,生成的数据对于分析过少;时间太长,profiler 可能无法处理大量数据
- 用户交互和请求资源的时间,应该使用测试数据代替,避免记录这部分时间
- 受到其它处理器的干扰,测试的时间不仅仅是目标程序本身
- 高度优化的代码,函数地址可能会被重组,而且所有的 profiler 都不能定位 inline 函数
- 一些 profiler 要求使用调试版本的代码,提供必要的信息来识别函数和代码行
- 一些进程或线程可能会在 CPU 核之间转换,但是事件计数器会驻留在一个 CPU 核上,可能需要设置线程掩码为线程指定 CPU 核
- 对于一些随机性的事件,分析结果难以复现,比如任务切换、垃圾回收
除了 profiler,还可以在调试器运行程序中,按下 break,大概率是性能热点
有时最好的测量性能瓶颈的方式是手动插桩,而不是使用现成的 profiler。
手动插桩是在开发中非常有用的追踪方式,最好通过#if
语句来进行控制,可以方便地在发布版代码中禁用。
有时对于很短的时间区间需要高精度的测量结果。Windows 下可以使用,GetTickCount
和QueryPerformanceCounter
可以获得毫秒单位的时间。
更高的精度可以通过 CPU 的时间戳计数器获取,__rdtsc()
。
如果线程在不同的 CPU 核之间跳转,时间戳计数器是无效的,可以在测量期间为线程指定 CPU,Windows 下使用SetThreadAffinityMask
,Linux 下使用sched_setaffinity
。
程序应该使用真实的测试数据测试,测试数据应该具有一定程度的随机性。
profiler 最适合的是 CPU 密集型的代码。
程序安装§
程序安装时间和兼容性问题也应该是开发者需要考虑的。
安装过程总是应该使用标准化的安装工具。
自动更新§
只要程序当前版本能够满足用户的使用需求,程序的自动更新应该是可选或默认关闭的,除非是重要的安全更新。
当程序使用时,更新进程应该运行在一个低优先级的线程上。
操作系统的更新通常比较耗时,有时会不太方便,比如因安全原因想要关机或注销时,可能无法操作。