跳转到内容

C++ 编程/优化

来自维基教科书,开放的书籍,为开放的世界

优化可以被认为是提高某事物性能的定向努力,这是工程学中的一个重要概念,特别是我们正在讨论的软件工程领域。我们将处理特定的计算任务和最佳实践,以减少资源利用率,不仅是系统资源,还有程序员和用户的资源,所有这些都是基于从假设和逻辑步骤的经验验证中发展而来的最优解。

所有采取的优化步骤都应以减少需求和促进程序目标为目标。任何主张只能通过对给定问题和应用的解决方案进行性能分析来证实。没有性能分析,任何优化都是无稽之谈

优化通常是程序员之间讨论的话题,并非所有结论都是一致的,因为它们与目标、程序员经验密切相关,并取决于特定的设置。优化级别主要直接取决于程序员采取的行动和做出的决定。这些可能是简单的事情,从基本的编码实践到选择用来创建程序的工具。甚至选择正确的编译器也会产生影响。一个好的优化编译器允许程序员定义他对优化结果的期望;编译器优化的好坏取决于程序员对编译结果的满意程度。

最安全的一种优化方法是减少复杂性,简化组织和结构,同时避免代码膨胀。这需要规划的能力,而不失去对未来需求的追踪,事实上,这是程序员在众多因素之间做出的妥协。

代码优化技术可分为以下几类

  • 高级优化
    • 算法优化(数学分析)
    • 简化
  • 低级优化
    • 循环展开
    • 强度削弱
    • Duff's Device
    • 干净循环

“保持简单,愚蠢”(KISS)原则要求在开发中优先考虑简单性。这与阿尔伯特·爱因斯坦的一句格言非常相似,即“一切都应该尽可能简单,但不要更简单”,对于许多采用者来说,难点在于确定应保持何种程度的简单性。无论如何,对基本和更简单系统的分析总是更容易,消除复杂性也将为代码重用和更通用的任务和问题解决方法打开大门。

代码清理

[编辑 | 编辑源代码]

代码清理的大部分好处对于经验丰富的程序员来说应该是显而易见的,由于采用了良好的编程风格指南,它们已经成为第二天性。但就像任何人类活动一样,错误会发生,也会做出例外,因此,在本节中,我们将尝试记住那些可以对代码优化产生影响的微小变化。

使用虚成员函数

记住虚成员函数对性能的影响(在介绍virtual 关键字时已经讨论过)。当优化成为一个问题时,大多数与优化相关的项目设计更改将不再可能,但仍然可以清理一些遗留问题。确保没有多余的虚函数使用(例如,在你的类/结构继承树的叶子节点中),将允许进行其他优化(即:编译器优化内联)。

在正确的容器中使用正确的数据

[编辑 | 编辑源代码]

当今系统中的主要瓶颈之一是处理内存缓存,无论是CPU 缓存还是物理内存资源,即使分页问题正变得越来越少见。由于程序将在设计级别处理的数据(以及负载级别)是高度可预测的,因此更好的优化仍然落到程序员身上。

应该将适当的数据结构存储在适当的容器中,优先存储指向对象的指针而不是对象本身,使用“智能”指针(参见 Boost 库),并且不要尝试将 auto_ptr<> 存储在 STL 容器中,这是标准不允许的,但有些实现已知会错误地允许它。

避免在容器中间删除和插入元素,在容器末尾进行操作开销更小。当对象的数目未知时,使用 STL 容器;当对象数目已知时,使用静态数组或缓冲区。这需要理解每个容器及其 O(x) 保证。

以 STL 容器为例,在使用 (myContainer.empty()) 与 (myContainer.size() == 0) 之间的区别时,重要的是要理解,根据容器类型或其实现,size 成员函数可能需要先计算对象数量,然后再将其与零进行比较。这在列表类型容器中非常常见。

虽然 STL 试图为一般情况提供最优解,但如果性能不符合你的要求,请考虑为你的情况编写你自己的最优解,也许是一个自定义容器(可能基于 vector),它不调用单个对象析构函数,并使用自定义分配器来避免删除时的开销。

使用内存预分配可以提高一些速度,并且像使用 STL vector<T>::reserve() 一样简单。优化系统内存和目标硬件的使用。在当今的系统中,有虚拟内存、线程和多核(每个核都有自己的缓存),其中主内存上的 I/O 操作以及在内存中移动数据的所需时间会减慢速度。这可能成为性能瓶颈。相反,选择基于数组的数据结构(缓存一致数据结构),例如 STL vector,因为数据是连续存储在内存中的,而不是指针链接的数据结构,例如链表。这将避免“因交换而死”,因为程序需要访问高度碎片化的数据,甚至有助于大多数现代处理器今天执行的内存预取。

尽可能避免按值返回容器,按引用传递容器。

考虑安全成本

[编辑 | 编辑源代码]

安全总是有成本,即使在编程中也是如此。对于任何算法,添加检查都会导致完成所需的步骤数量增加。随着语言变得更加复杂和抽象,理解所有细微差别(并记住它们)会增加获得所需经验所需的时间。不幸的是,C++ 语言一些实现者采取的大多数步骤都缺乏程序员的可见性,并且由于它们超出了标准语言,因此通常不会被学习。请务必熟悉正在使用的 C++ 实现的任何扩展或特殊情况。

作为一种将决策权赋予程序员的语言,C++ 提供了许多实例,其中可以以类似但不同的方式实现类似的结果。理解这些有时细微的差异很重要。例如,在决定访问 std::vector 成员所需的条件时,可以选择 []、at() 或迭代器。所有这些都有类似的结果,但具有不同的性能成本和安全考虑因素。

代码重用

[编辑 | 编辑源代码]

优化也反映在代码的有效性上。如果你可以使用一个已经存在的代码库/框架,该框架可供相当数量的程序员访问,那么你可以预期它将包含更少的错误,并且针对你的特定需求进行了优化。

一些代码库可以作为库供程序员使用。请谨慎考虑依赖关系,并检查实现方式:如果在没有考虑的情况下使用,也可能导致代码膨胀和内存占用增加,以及降低代码的可移植性。我们将在本书的库部分中详细介绍这些库。

为了提高代码重用率,你可能会将代码分割成更小的部分,文件或代码,请记住,更多文件和总体复杂性也会增加编译时间。

函数和算法优化

[编辑 | 编辑源代码]

在创建函数或算法来解决特定问题时,我们有时会处理数学结构,这些结构专门指示通过已建立的数学最小化方法进行优化,这属于工程分析优化的特定领域。


Clipboard

待办事项
使用小例子扩展


如前所述,在检查 inline 关键字时,它允许定义一种内联类型的函数,其工作方式类似于 循环展开,以提高代码性能。非内联函数需要一个调用指令,几个指令来创建堆栈帧,然后还需要几个指令来销毁堆栈帧并从函数返回。通过复制函数体而不是进行调用,机器代码的大小会增加,但执行时间会减少

除了使用 inline 关键字来声明内联函数外,优化编译器还可以决定将其他函数也内联(参见编译器优化部分)。

如果可移植性不是问题,并且你精通汇编语言,你可以使用它来优化计算瓶颈,即使查看反汇编器的输出通常也有助于寻找改进它的方法。在你的代码中使用汇编会带来一些其他问题(例如可维护性),所以请在你的优化过程中将其作为最后的手段使用,如果你使用它,请确保将你的操作记录清楚。

x86 反汇编 维基教科书提供了一些使用 x86 汇编代码的优化示例

注意
如果使用 gcc 编译器,-S 选项将输出编译生成的汇编代码。

减少编译时间

[编辑 | 编辑源代码]

一些项目可能需要很长时间才能编译。要减少完成编译所需的时间,第一步是检查是否存在任何硬件缺陷。你的内存资源可能不足,或者你的 CPU 速度可能很慢,即使你的硬盘存在大量碎片也会增加编译时间。

另一方面,问题可能不是由于硬件限制,而是由于你使用的工具造成的,请检查你是否在使用适合当前任务的工具,查看你是否拥有最新版本,或者如果有,是否是它导致了问题,一些不兼容性可能是由于更新造成的。在编译器中,更新总是更好的,但你应该先检查一下发生了哪些变化以及它是否符合你的目的。

经验表明,如果你遇到编译速度慢的问题,你尝试编译的程序可能是设计不当的,请检查对象依赖关系的结构,包括文件,并花一些时间来构建自己的代码,以尽量减少更改后的重新编译,如果编译时间证明这一点是合理的。

使用预编译头文件和外部头文件保护,这将减少编译器的工作量。

编译器优化

[编辑 | 编辑源代码]

编译器优化 是调整编译器输出的过程,主要通过自动方式,以尝试改进程序员请求的操作,从而最大程度地减少或最大化已编译程序的某些属性,同时确保结果完全相同。通过利用编译器优化,程序员可以编写更直观的代码,并仍然以合理的速度执行它们,例如,跳过使用前置自增/自减运算符

一般来说,优化并没有,也不能在 C++ 标准中定义。标准设定了规则和最佳实践,这些规则和最佳实践规定了输入和输出的规范化。C++ 标准本身允许编译器在执行任务时有一定的自由度,因为某些部分被标记为实现相关,但通常会建立一个基线,即使如此,一些供应商/实现者也会在其中加入一些独特的特性,显然是为了安全和优化的好处。

需要牢记的一点是,没有完美的 C++ 编译器,但大多数最新的编译器默认情况下会执行一些简单的优化,这些优化试图抽象并利用现有的更深层的硬件优化或目标平台的特定特性,大多数这些优化几乎总是受欢迎的,但仍然取决于程序员对正在发生的事情以及它们是否确实有益有一个想法。因此,强烈建议检查你的编译器文档,了解它的操作方式以及哪些优化在程序员的控制之下,仅仅因为编译器理论上可以进行某些优化,并不意味着它会这样做,甚至不意味着它会带来优化效果。

程序员可用的最常见的编译器优化选项分为三类

  • 速度;提高生成的目标代码的运行时性能。这是最常见的优化
  • 空间;减小生成的目标代码的大小
  • 安全性;减少数据结构被破坏的可能性(例如,确保不会写入非法数组元素)

不幸的是,许多“速度”优化会使代码变大,而许多“空间”优化会使代码变慢,这就是所谓的时空权衡

自动内联类似于隐式内联。内联可以是优化,也可以是弊端,具体取决于代码和选择的优化选项。

利用扩展指令集

[编辑 | 编辑源代码]
Clipboard

待办事项
3DNow!MMX 等...


Clipboard

待办事项
添加缺失的信息


运行时间

[编辑 | 编辑源代码]

如前所述,运行时间是指程序执行的持续时间,从开始到结束。这是分配所有运行已编译代码所需资源并有望释放资源的地方,这是任何要执行的程序的最终目标,因此它应该是最终优化的目标。

内存占用

[编辑 | 编辑源代码]

过去,计算机内存价格昂贵,技术上尺寸有限,是程序员的稀缺资源。程序员们花费了大量的智慧来实现复杂的程序,并使用尽可能少的资源来处理大量数据。如今,现代系统拥有足够多的内存来满足大多数用途,但容量需求和期望也随之增长;因此,减少内存使用的技术仍然至关重要,事实上,随着移动计算的重要性不断提升,运行性能也获得了新的动力。

衡量程序的内存使用情况既困难又耗时,而且程序越复杂,获得良好指标就越困难。问题的另一方面是,除了最基本和通用的考虑之外,没有标准的基准测试(并非所有内存使用都相同)或实践来处理这个问题。

注意
请注意,在大多数情况下,在调试编译中执行内存测试不会产生任何有效的内存使用洞察,充其量它只能为你提供被测试函数的预期内存使用上限的指示。

请记住对 std::vector(或 deque)使用 swap()

当尝试使用 swap() 减少(或归零)向量或 deque 的大小,在该类型的标准容器上,将保证释放内存,并且不会使用用于增长的开销缓冲区。它还会避免使用 erase()reserve() 的谬误,这些操作不会减少内存占用。

延迟初始化

[编辑 | 编辑源代码]

始终需要在系统性能和资源消耗之间保持平衡。延迟实例化是一种内存节省机制,通过该机制,对象初始化被推迟到需要时才进行。

请查看以下示例

#include <iostream>

class Wheel {
        int speed;
    public:
        int getSpeed(){
            return speed;
        }
        void setSpeed(int speed){
            this->speed = speed;
        }
};

class Car{
    private:
        Wheel wheel;
    public:
        int getCarSpeed(){
            return wheel.getSpeed();
        }
        char const* getName(){
            return "My Car is a Super fast car";
        }
};

int main(){
    Car myCar;
    std::cout << myCar.getName() << std::endl;
}

默认情况下,类 Car 的实例化会实例化类 Wheel。整个类的目的是打印汽车的名称。由于实例化的 Wheel 没有任何用途,初始化它完全是资源浪费。

最好推迟不需要的类的实例化,直到需要它为止。修改上面的类 Car 如下所示

class Car{
    private:
        Wheel *wheel;
    public:
        Car() {
            wheel=NULL; // a better place would be in the class constructor initialization list
        }
        ~Car() {
            delete wheel;
        }
        int getCarSpeed(){
            if (wheel == NULL) {
                wheel = new Wheel(); 
            }
            return wheel->getSpeed();
        }
        char const* getName(){
            return "My Car is a Super fast car";
        }
};

现在,只有当调用成员函数 getCarSpeed() 时才会实例化 Wheel。

并行化

[编辑 | 编辑源代码]

正如在检查线程时所看到的,线程可以是一种“简单”的形式,可以利用硬件资源并优化程序的速度性能。在处理线程时,您应该记住,线程在复杂性、内存方面会带来成本,如果在需要同步时处理不当,甚至会降低速度性能,如果设计允许,最好让线程尽可能地无障碍运行。

I/O 读写

[编辑 | 编辑源代码]
队列系统的示意图
Clipboard

待办事项
延迟写入、提前读取、操作系统如何处理 I/O 请求...


性能分析

[编辑 | 编辑源代码]

性能分析是一种动态程序分析(与静态代码分析相对),包括使用在程序执行时收集的信息来研究程序的行为。它的目的通常是确定程序的哪些部分需要优化。主要通过确定程序的哪些部分占用了大部分执行时间,导致访问资源的瓶颈或访问这些资源的级别。

比较应用程序性能时,全局时钟执行时间应该是底线。通过检查执行的渐近阶数来选择算法,因为在并行设置中,它们将继续提供最佳性能。如果您发现一个无法并行化的热点,即使在检查调用堆栈的更高级别之后也是如此,那么您应该尝试找到一个更慢但可并行化的算法。


Clipboard

待办事项
小型示例


分支预测性能分析器
生成调用图的缓存性能分析器
逐行性能分析
堆性能分析器

性能分析器

[编辑 | 编辑源代码]
免费性能分析工具
  • Valgrind(http://valgrind.org/)用于构建动态分析工具的工具框架。包括缓存和分支预测性能分析器、生成调用图的缓存性能分析器和堆性能分析器。它在以下平台上运行:X86/Linux、AMD64/Linux、PPC32/Linux、PPC64/Linux 和 X86/Darwin(Mac OS X)。根据 GNU 通用公共许可证版本 2 开放源代码。
  • GNU gprof(http://www.gnu.org/software/binutils/)是一个性能分析工具。该程序最初是在 1982 年的 SIGPLAN 编译器构造研讨会上介绍的,现在是大多数 UNIX 版本中提供的 binutils 的一部分。它能够监控函数(甚至源代码行)中花费的时间以及对它们的调用。根据 GNU 通用公共许可证开放源代码。
  • Linux perf(http://perf.wiki.kernel.org/)是一个性能分析工具,它是 Linux 内核的一部分。它通过采样运行。
  • WonderLeak 是一款高性能 Windows 堆和句柄分配性能分析器,适用于使用 C/C++ API 和 CLI 集成的 x86/x64 本机代码开发人员。
商业性能分析工具
  • Deleaker(http://deleaker.com/)是一个工具和 Visual Studio 扩展,用于查找内存泄漏、句柄、GDI 和 USER 对象泄漏。适用于 Windows,支持 x86 / x64。它基于钩子,不需要代码检测。

进一步阅读

[编辑 | 编辑源代码]
华夏公益教科书