第一章 性能优化简介

178 阅读17分钟

1. 简介

性能为王:十年前如此,现在当然也是如此。2017 年全球每天产生 2.5 千万(亿)字节数据,预计到 2024 年,全球每天将产生 400 千万亿字节数据。在我们日益以数据为中心的世界中,信息交换的增长需要更快的软件和更快的硬件。

得益于摩尔定律,软件程序员几十年来一直过着“轻松的生活”。软件供应商可以依靠新一代硬件来加速其软件产品,即使他们没有投入人力资源来改进代码。但这种策略不再有效,通过查看下面的50年处理器性能趋势图,我们可以看到单线程性能的增长正在放缓:

  • 从1990年到2000年,SPECint 基准测试中的单线程性能提高了约25到30倍,这主要是由于更高的CPU频率和改进的微架构。
  • 从 2000 年到 2010 年,单线程 CPU 性能增长较为温和(增长了 4 到 5 倍)。由于功耗、散热问题、电压缩放限制(Dennard Scaling)和其他基本问题,时钟速度最高达到 4GHz 左右。尽管时钟速度停滞不前,但架构进步仍在继续:更好的分支预测、更深的管道、更大的缓存和更高效的执行单元。
  • 从 2010 年到 2020 年,单线程性能仅增长了两到三倍。在此期间,CPU 制造商开始更加注重多核处理器和并行性,而不是仅仅提高单线程性能。

由于每一代硬件都不再能提供显著的性能提升,我们必须开始更加关注代码的运行速度。在寻求提高性能的方法时,开发人员不应该依赖硬件。相反,他们应该开始优化应用程序的代码。

2. 软件为什么很慢?

现代处理器中的晶体管数量不断增加。例如,在大约四年的时间里,苹果芯片中的晶体管数量从 M1 的 160 亿增加到 M2 的 200 亿,再到 M3 的 250 亿,最后到 M4 的 280 亿。晶体管数量的增加使制造商能够在处理器中添加更多内核。到 2024 年,可以购买高端服务器处理器,单个 CPU 插槽上将有超过 100 个逻辑内核。不幸的是,它并不总是意味着更好的性能。典型的通用多线程应用程序的性能并不总是随着我们分配给任务的CPU核心数量线性增长的。了解为什么会发生这种情况以及可能的解决方法对于产品未来的增长至关重要。不能进行适当的性能分析和调优会导致性能和金钱的浪费,并可能毁掉产品。

根据论文[@Leisersoneaam9744]的观点:至少在短期内,大多数应用程序的性能收益的很大一部分将来自软件。可悲的是,应用程序默认情况下并不会获得最佳性能。该论文提供了一个很好的例子,说明了在源代码级别上可以进行的性能改进潜力。下表中总结了在60GB内存,双插槽(socket) Intel Xeon E5-2666 v3系统上运行的一个程序,该程序执行两个4096×4096矩阵相乘的加速效果。通过应用多个优化,最终得到的程序运行速度提高了60000多倍。提供这个例子的原因不是挑剔Python或Java,而是打破了软件默认具有“足够好”的性能的观念。

绝对加速:相对于 Python 版本的运行时间,该版本的运行时间减少了多少。

相对加速:相对于上一版本的运行时间,该版本的运行时间减少了多少。

以下是阻止系统默认达到最佳性能的一些最重要因素:

  • CPU限制:“为什么硬件不能解决我们所有的问题?” 现代CPU以令人难以置信的速度执行指令,并且每一代都在变得更好。但是,如果用于执行工作的指令不是最佳的,甚至是多余的,它们就无法做太多事情。处理器不能通过魔法将次优代码转换为性能更好的代码。例如,如果我们使用BubbleSort算法实现排序例程,CPU将不会尝试识别并使用更好的替代方案,例如QuickSort。它会盲目地执行被告知要执行的任何操作。
  • 编译器限制:“但是编译器不是应该做这些吗?为什么编译器不能解决我们所有的问题?” 的确,现在的编译器非常智能,但仍然可能生成次优代码。编译器擅长消除冗余工作,但是当涉及到更复杂的决策,如函数内联、循环展开等时,它们可能不会生成最佳的代码。例如,是否应该将一个函数始终内联到调用它的地方,这个问题没有二元的“是”或“否”答案。这通常取决于编译器应该考虑的许多因素。通常,编译器依赖于复杂的成本模型和启发式算法,这些算法可能不适用于每种可能的情况。此外,除非编译器确定这样做是安全的,并且不会影响生成的机器代码的正确性,否则它们不能执行优化。对于编译器开发人员来说,确保特定优化在所有可能的情况下生成正确的代码可能非常困难,因此他们通常必须保守行事,并避免进行一些优化。最后,编译器通常不会转换程序使用的数据结构,这在性能方面也是至关重要的。
  • 算法复杂度分析限制:开发人员经常过度关注算法的复杂度分析,这导致他们选择具有最优算法复杂度的流行算法,即使它对于给定问题可能不是最有效的。考虑两种排序算法,插入排序和快速排序,后者在平均情况的大O符号中显然更胜一筹:插入排序是O(N^2),而快速排序仅为O(N log N)。然而,对于相对较小的N值(最多50个元素),插入排序的性能优于快速排序。复杂度分析无法考虑各种算法的分支预测和缓存效果,因此人们只是将它们封装在隐含的常数C中,有时这可能会对性能产生重大影响。盲目地信任大O符号而没有在目标工作负载上进行测试可能会使开发人员走上错误的道路。因此,对于某个特定问题来说,最知名的算法并不一定是实践中最高效的。
  • 除了上述限制之外,编程范式还会产生开销: 优先考虑代码清晰度、可读性和可维护性的编码实践可能会降低性能。高度通用和可重用的代码可能会引入不必要的副本、运行时检查、函数调用、内存分配等。例如,面向对象编程中的多态性通常使用虚拟函数来实现,这会带来性能开销。

上述所有因素都会对软件产生“性能税”。通常有很多机会可以调整软件的性能,以充分发挥其潜力。

3. 为什么要关心性能?

除了硬件单线程性能增长放缓之外,还有其他一些商业原因需要关注性能。

  • 能源成本。 在 PC 时代,运行缓慢的软件的成本由用户承担,因为用户计算机上运行着低效的软件。随着 SaaS(软件即服务)和云计算的出现,运行缓慢的软件的成本被转嫁到软件提供商身上,而不是用户身上。如果您是 Meta 或 Netflix 这样的 SaaS 公司,无论您是在本地硬件上运行服务还是使用公共云,您都需要为服务器消耗的电力付费。低效的软件会直接削减您的利润和市场估值。根据 Synergy Research Group 的数据,2020 年全球在云服务上的支出超过 1000 亿美元,而根据 Gartner 的数据,到 2024 年将超过 6750 亿美元。对于在云中运行的大型分布式应用程序来说,小改进的影响非常重要。2018年,谷歌在运行云的实际计算服务器上花费的资金与在电力和冷却基础设施上花费的资金大致相同。能源效率是一个非常重要的问题,可以通过优化软件来改善。
  • 还有一个因素在起作用, 人们如何看待运行缓慢的软件。谷歌报告称,搜索速度降低 2% 会导致 [每位用户的搜索次数减少 2%]。页面加载速度提高 400 毫秒会导致流量增加 5-9%。在大数字游戏中,小改进可以产生重大影响。这些例子证明,服务运行速度越慢,使用它的人就越少。除了云服务之外,还有许多其他对性能至关重要的行业,这些行业不需要证明性能工程的合理性,例如人工智能 (AI)、高性能计算 (HPC)、高频交易 (HFT)、游戏开发等。此外,性能不仅是高度专业化的领域所必需的,对于通用应用程序和服务也至关重要。我们每天使用的许多工具如果不能满足其性能要求,就根本不会存在。例如,集成到 Microsoft Visual Studio IDE 中的 Visual C++ IntelliSense 功能具有非常严格的性能限制。要使 IntelliSense 自动完成功能正常工作,它必须在几毫秒内解析整个源代码库。(如果)需要几秒钟才能建议自动完成选项,那么没有人会使用源代码编辑器。这样的功能必须非常灵敏,并在用户输入新代码时提供有效的延续。

4. 什么是性能分析?

如果你曾与同事争论过某段代码的性能,那么你可能知道预测哪段代码的性能最好有多难。现代处理器内部有如此多的部件,即使对代码进行微小的调整也会引起明显的性能变化。在优化应用程序时依靠直觉通常会导致随机的“修复”,而不会对性能产生实际影响。

关于性能优化的第一条建议是:不要仅仅依靠你的直觉,始终需要测量。世界上流传的许多微优化技巧在过去都是有效的,但当前的编译器已经学会了它们。 一个这样的例子是在整个代码库中用i++(前增量)替换++i(后增量)(假设i未使用以前的值)。一般情况下,这种变化不会对生成的代码产生影响:每个像样的优化编译器都会识别出以前的值未被i使用,并且无论如何都会消除冗余副本。此外,有些人倾向于过度使用传统的位操作技巧。XOR交换习语就是这样一个例子。实际上,简单std::swap会产生等效或更快的代码。这种意外的更改可能不会提高应用程序的性能。找到正确的调优位置应该是仔细的性能分析的结果,而不是直觉或猜测。

性能分析是收集有关程序如何执行的信息并对其进行解释以寻找优化机会的过程。程序源代码中的任何更改都应通过分析和解释收集的数据来驱动。我们将展示如何使用性能分析技术来发现优化机会,即使在庞大而陌生的代码库中也是如此。有许多性能分析方法。根据问题的不同,有些方法会比其他方法更有效。随着经验的积累,您将制定自己的策略来决定何时使用每种方法。

5. 什么是性能调优?

找到性能瓶颈只是工程师工作的一半,另一半是正确地修复它有时更改程序源代码中的一行代码就可以大幅提高性能。错过这样的机会可能会非常浪费性能分析和调优就是要找到并修复这一行代码

要充分利用现代 CPU 的所有计算能力,您需要了解它们的工作原理。或者正如性能工程师喜欢说的那样,您需要具有“机械同情心”。这个术语是从赛车界借用的。这意味着对赛车工作原理有很好了解的赛车手比不了解的竞争对手更有优势。性能工程也是如此。

这就是所谓的 "low-level优化" 。这是一种考虑到底层硬件功能细节的优化。它不同于“hight-level优化”,后者更多地涉及应用程序级逻辑、算法和数据结构。大多数"low-level优化"可以应用于各种现代处理器。要成功实现"low-level优化",您需要对底层硬件有很好的了解。

过去,软件开发人员对机械有更多的理解,因为他们经常要处理硬件实现的细微差别。在 PC 时代,开发人员通常直接在操作系统上编程,中间可能还会使用一些库。随着世界进入云时代,软件堆栈变得更深、更广、更复杂。堆栈的顶层(大多数开发人员都在其上工作)已经远离硬件。这种演变的负面影响是,现代应用程序的开发人员对运行其软件的实际硬件的亲和力较低。

唐纳德·克努斯有一句名言:“过早优化是万恶之源”。但事实往往恰恰相反,推迟性能工程可能为时已晚,造成的危害与过早优化一样大。对于从事性能关键型项目的开发人员来说,了解底层硬件的工作原理至关重要。在这样的角色中,软件的性能特征必须与正确性、安全性一起成为主要目标。性能不佳就像安全漏洞一样,很容易毁掉一个产品。

性能工程是一项重要且有意义的工作,但可能非常耗时。事实上,性能优化是一场永无止境的游戏。总会有需要优化的地方。不可避免的是,开发人员会达到收益递减的临界点,此时进一步的改进将无法通过预期的工程成本来证明。知道何时停止优化是性能工作的一个关键方面。

6. 后续文章包含哪些内容

后面文章将会解答如下问题

  • 为什么我的更改会导致性能下降 2 倍?
  • 我们的客户抱怨我的应用程序运行缓慢。我该如何调查?
  • 为什么我的手写压缩算法比传统算法慢?
  • 我是否已经将我的程序优化到最大限度?
  • 我的平台上有哪些性能分析工具?
  • 有哪些技术可以减少缓存未命中和分支预测错误的次数?

第一部分(第 2-7 章)教你如何发现性能问题,第二部分(第 8-13 章)教你如何修复这些问题。

  • 第 2 章讨论了公平性能实验及其分析。介绍了性能测试和比较结果的最佳实践。
  • 第 3 章介绍了 CPU 微架构,并仔细研究了英特尔的 Golden Cove 微架构。
  • 第 4 章介绍了性能分析中使用的术语和指标。本章最后,我们提供了一个案例研究,其中包含了在四个实际应用程序中收集的各种性能指标。
  • 第 5 章探讨了最流行的性能分析方法。我们描述了分析工具的工作原理以及它们可以收集哪些类型的数据。
  • 第 6 章介绍了现代 Intel、AMD 和 ARM CPU 提供的功能,用于支持和增强性能分析。它展示了它们的工作原理以及它们有助于解决哪些问题。
  • 第 7 章概述了 Linux、Windows 和 MacOS 上最流行的工具。
  • 第 8 章介绍优化内存访问、缓存友好代码、数据结构重组和其他技术。
  • 第 9 章是关于优化计算;它探讨了数据依赖性、函数内联、循环优化和矢量化。
  • 第 10 章介绍无分支编程,用于避免分支错误预测。
  • 第 11 章介绍机器代码布局优化,例如基本块放置、函数拆分和配置文件引导优化。
  • 第 12 章包含前四章未涉及的优化主题,但仍然足够重要,值得讨论。在本章中,我们将讨论特定于 CPU 的优化,研究几个与微架构相关的性能问题,探索用于优化低延迟应用程序的技术,并为您提供有关调整系统以获得最佳性能的建议。
  • 第 13 章讨论了分析多线程应用程序的技术。它深入探讨了优化多线程应用程序的一些最重要的挑战。我们提供了五个真实多线程应用程序的案例研究,解释了为什么它们的性能不会随着 CPU 线程数的增加而增长。我们还讨论了缓存一致性问题(例如“错误共享”)和一些用于分析多线程应用程序的工具。

7. 没有讨论什么内容

系统性能取决于不同的组件:CPU、DRAM、I/O 和网络设备等。应用程序可能会从调整系统的各个组件中受益,具体取决于瓶颈所在。一般来说,工程师应该分析整个系统的性能。然而,系统性能的最大因素是它的核心,即 CPU。这就是为什么主要关注从 CPU 角度进行性能分析的原因。我们还广泛讨论了内存子系统,但我们不探讨 I/O 和网络性能。

同样,软件堆栈包括许多层,例如固件、BIOS、操作系统、库和应用程序的源代码。但是,由于大多数较低层不在我们的直接控制之下,因此主要关注的是源代码级别。

我们讨论的范围不超出单个 CPU 插槽,因此我们不会讨论分布式、NUMA 和异构系统的优化技术。不讨论使用 OpenCL 和 openMP 等解决方案将计算卸载到加速器(GPU、FPGA 等)。

讨论范围适用于大多数现代 CPU,包括英特尔、AMD、苹果和其他基于 ARM 的处理器。同样,大多数示例都是在 Linux 上运行的,但同样,大多数时候这并不重要,因为相同的技术有利于在 Windows 和 macOS 操作系统上运行的应用程序。

文中代码片段是用 C 或 C++ 编写的,但在很大程度上,也可以应用于编译为本机代码的其他语言,如 Rust、Go 甚至 Fortran。由于针对的是运行在硬件附近的用户模式应用程序,因此我们不会讨论托管环境,例如 Java。