在这个介绍性章节中,我们将从程序性能的一般性讨论入手:程序性能为何重要?它的决定要素是什么?以及程序员通常如何处理性能相关问题。在介绍传统的程序性能相关的知识之前,我们将先就编程中性能相关话题做广泛的讨论,本章的最后则会讨论现代CPU架构对性能的影响。
因此,本章包含如下话题:
1. 为什么性能如此重要:在深入具体技术之前先确定目标,所谓有的放矢。
2. 性能相关的既有经验和指南:包括既有的且被验证过的性能相关知识。
3. 现代处理器架构:至少包括其中的性能相关部分。
有可能你仅仅是出于好奇,刚刚开始阅读本书,你可以问自己这样一个问题:为何性能重要?这不是过去才需要考虑的事情吗?因为很久之前,CPU没有足够的算力,内存很小,网络经常断线,而如今的高科技时代,我们有足够的计算资源——现在的计算机都运行得狂快!
诚然,大体上你说得没错,但你是否考虑过如下话题:
1. 程序跑得更快,同时耗电量更少,于咱们的星球是件好事(如果这个程序运行在大型服务集群上),于用户是件好事(如果这个程序运行在台式机上)。
2. 相比于慢程序,快程序可以在相同时间内服务更多的请求。这对商业大有好处,因为你仅需要租买更少的机器就可以服务好客户,同时,又减少了能耗,有益于地球环境。
3. 在当今残酷的商业竞争中,比竞争对手更快的软件是巨大的竞争优势。最有说服力的例子是自动化交易领域,(因为高性能要求,这个领域的软件编程语言通常由C++统治着);反面例子是那些是不是蹦出“加载中”的缓慢站点和程序是鲜有成功的。
4. 最后,特别是在移动设备上,程序仍然需要处理受限的资源——网速是有限的(光速),电池容量有限,占用资源越少,跑得越快,越讨用户喜欢。
因此我想说对程序性能的追求绝非小事,我们应该以这样的一个三赢目标而上下求索:保护地球、提升商业竞争力、让用户的生活更美好。
那么,性能优化的一切都是美好的吗?呃,事实并非如此,性能优化亦有它至暗的一面。假如我们不顾一切的从硬件中压榨出最后一滴性能,到头来,我们要面对的可能是一堆不可读、不灵活、无法维护的——丑陋至极的代码!
所以,我们应当对性能优化的代价保持警醒,并评估为某些性能提升所付出的代价是否值得,以及何时停止优化以保全代码的清晰。
犹记得在很久之前我初学编程的时候,送给新手的关于性能优化的建议通常是如下几条:
1.(先)别做(优化)
2. 过早优化是万恶之源
3. 先跑起来,再跑得对,最后跑得快
第一条建议中的“先”字,恐怕只有真正的专家才会留意并付诸实现;第二条建议从过去到现在常常被断章取义,“大约90%的情况下”这部分通常被忽略了;第三条给人的感觉是写出一个程序就实属不易,哪还有时间为程序性能发愁呢。毫无疑问,通常处理程序性能的方式都是:以后再改!
然而这些关于性能优化的金玉良言,无不在向我们强调:性能问题在代码里并不是均匀分布的。80-20原则,甚至90-10原则都是适用的,在某些关键代码上需要极度的谨慎,而没有必要优化代码的每个角落,换句话说,95%的代码都不需要性能优化。
那么,我们不能忽视的那20%,10%甚至5%的关键代码究竟是什么呢?正如那句古老的编程智慧所言——程序员总是拙于找到性能的瓶颈。(正所谓只在此山中,云深不知处,程序员不能找出性能瓶颈也是常被诟病的。)
因此,我们不应该在事后通过测量一个已完成的程序来发现性能瓶颈。这种做法与代码新手“日后再改”的做法非常相似。唔,本书的观点是虽然过早的性能优化应当避免,然而过早的性能恶化更应该不惜一切代价避免,因为它更糟糕。然而,避免过早的性能恶化,需要许多具体的知识如用哪种语言,用哪种框架,以及采用的架构决策及其性能代价。本书将以Qt框架为背景介绍这些知识。
但是,我们先要谈谈一些最基本的原则,这些基本原则回答了这样一个问题:为避免程序性能恶化,什么是应该避免的?依我之见,我们可以从以下几条基本常识中提取到传统的性能优化智慧:
1. 相同的事不要做两次
2. 不要经常做慢的事
3. 不要拷贝非必需的数据
你认为这些对性能优化没有帮助吗?如果是,让我们更深入地探讨一下这三条简洁而基础的见解吧!
第一点中的这些技术旨在于消除非必要的重复工作。最基本的对策是缓存,即将计算结果保存以备后用。一个更极端的避免重复计算工作的例子是将结果在第一次使用前预计算好。这通常通过手写(或通过脚本产生)预计算表(precomputed tables),或者如果你使用的编程语言支持,也可以通过编译期计算(compile-time computation)来产生。在后续的例子中,我们将用编译时长换取更好的运行时性能。我们将在第3章深入C++及其性能中展示C++编译时技术。
选择最优的算法和数据结构也可以归于这类技术,不同的数据结构和算法是为不同领域的用例精心设计的,你必须做出明智的选择。在第4章高效地使用数据结构和算法中,我们将展示使用Qt内建数据结构的一些陷阱。
那些最基础的技术,诸如将非必要的代码(如重复计算、初始化局部变量等)移出循环体也属于此类优化,但我相信你早就知道这些了。
当我们不得不做一些被贴上高开销标签的事时,这第二类优化技术便能派上用场。一个例子是与操作系统或硬件的交互,诸如写数据到一个文件,通过网络发送一个数据包。这类例子,我们通过批量处理来解决,在I/O处理中被称为缓冲区(buffering)——即与其每次发送一小片数据,连续发送多次,不如将小片数据累积起来一次性写入或发送,从而避免单次操作的高开销。
我们也能将这类技术应用到其他领域。在I/O或内存管理领域的一个例子是数据预取(prefetching of data), 也被称为预读取技术。当从文件读取数据时,读取比实际请求多的数据,是希望多读的数据可以在后续读取时很快用到。网络领域的一个例子是当用户在浏览器中悬停于某个链接甚至在连接这些地址之前(pre-connecting)完成对这些DNS地址的推测性预解析(pre-resolving)。然而这种方法在预测错误的时候会适得其反,因此需要非常细致的调试!
该话题中的其他技巧还包括避免系统调用和避免用锁以减少系统调用和切换到内核上下文的开销。我们将在本书的最后几章讨论I/O,图形和网络时看到这类技术的一些应用。
另一个可以运用这条准测的例子是内存管理。通用的内存分配器在多次分配时常会导致高开销,补救方法是先一次性预分配好一块大缓冲区,然后用我们定制的内存分配策略管理这个缓冲区以满足应用程序的需求。如果我们知道要分配的对象会有多大,则只需要针对不同的对象大小预分配几个缓冲区,这样的定制化内存分配策略很容易实现。在程序启动时预分配内存通常是提升内存性程序性能的经典方法。我们将在第3章深入C++及其性能中讨论这些C++技术细节。
归于这第三个准测的技术通常有种天然的底层意味。第一个例子是对一个函数调用传递参数时要避免拷贝数据。合理地西安则数据结构也能避免数据拷贝——例如使用可以自动扩容地地vector。在大多数情况下,我们可以使用预分配技术(例如使用std::vector地reserve方法)或选择一种更适合应用场景地数据结构来避免数据拷贝。
另一个数据拷贝会引起问题的常见例子是字符串处理。例如将两个字符串相加,在底层实现时,先分配一个新的空间,再将连个字符串的内容拷贝过来并连接在一起。由于在编程中字符串操作是司空见惯的,因此这将成为一个大的性能问题!补救方法是使用静态字符串,或者选择一个更好的字符串实现库。我们将在第3章深入C++及其性能和第4章高效的使用数据结构和算法中讨论这些主题。
另一个这一优化准则的实际例子是网络编程的圣杯——零拷贝发送/接收数据。它的核心思想是:在数据发出之前,不在用户缓冲区和网络协议栈之间拷贝数据。大多数现代网络硬件都支持分散收集(scatter gather)(或被称为向量化I/O技术),即待发送的数据并不一定由单个连续的缓冲区提供,而是可以由一系列的独立缓冲区构成。
这样,用户的数据不用在发送之前统一整理,从而避免了不必要的数据拷贝开销。同样的原理也适用于软件API;例如,Facebook最近的TSL1.3的实现(codename Fizz, 已开源)在软件库的层面支持了分散收集API (scatter-gather API)!
目前为止,我们列举了如下经典的优化技术:
1. 优化的算法
2. 优化的数据结构
3. 缓存
4. 预计算数据表(precomputed tables)
5. 内存预分配及定制化分配器
6. 缓冲及批量处理技术
7. 预读取
8. 避免拷贝
9. 寻找更好的软件库
以我们目前的知识,我们可以得出如下的通用性能优化步骤:
1. 在写代码时以不大的代价避免非必要的性能损失,例子有:
(1) 以引用方式传递参数
(2) 使用合适的被普遍认可的算法和数据结构
(3) 避免非必要的数据拷贝和内存分配
这些措施可以给你一个相对不错的性能基线。
2. 测量性能,找出性能瓶颈,然后使用一些前述的标准技术进行优化。之后再测量、再优化并不断迭代。尽管我们有良好的编程实践,但如果我们的程序性能不满足需求,优化这一步是必须要做的。不幸的是,我们无法预知软硬件交互过程中的所有事情——常常会有让我们吃惊的事情等着我们。
3. 如果你仍然不能取得好的性能,那么有可能你的硬件真的太慢了。即使采用性能优化技术,我们也无法变魔术,抱歉!
前述的建议看起来合情合理,你可能会问:就这些了吗?看起来也没那么可怕!遗憾的是,事情并不完全是这样。让我们一起来了解一下现代处理器的架构的抽象含义。
(PS: Hands On High Performance Programming With Qt 5 - Marek Krajewski 原稿第13页)