使用更好的库
使用更好的库
在性能优化阶段,库是一个需要特别注意的地方。库提供了组装程序的基础。库函数和类常常被用在嵌套循环的最底层,因此通常它们都是热点代码。编译器或是操作系统提供的库在使用时非常高效。而对于各个项目自己的库,则需要仔细地设计,以确保它们能够被高效地使用。
优化标准库的使用
C++为以下常用功能提供了一个简洁的标准库。
- 确定那些依赖于实现的行为,如每种数值类型的最大值和最小值。
- 最好不要在C++中编写的函数,如strcpy()和memmove()。
- 易于使用但是编写和验证都很繁琐的可移植的超越函数(transcendental function)——超越函数指的是变量之间的关系不能用有限次加减乘除、乘方开方运算表示的函数,如正弦函数和余弦函数、对数函数和幂函数、随机数函数,等等。
- 除了内存分配外,不依赖于操作系统的可移植的通用数据结构,如字符串、链表和表。
- 可移植的通用数据查找算法、数据排序算法和数据转换算法。
- 以一种独立于操作系统的方式与操作系统的基础服务相联系的执行内存分配、操作线程、管理和维护时间以及流I/O等任务的函数。考虑到兼容性,这其中包含了一个继承自C语言的函数库。
C++标准库中的许多部分都包含了可以产生极其高效的代码的模板类和函数。
C++标准库的哲学
为了跟上作为系统编程语言的目标,C++提供了有些斯巴达式的标准库。这个标准库需要简单、通用并且足够快速。哲学上,C++标准库之所以提供这些函数和类,是因为要么无法以其他方式提供这些函数和类,要么这些函数和类会被广泛地用于多种操作系统上。
C++的这种实现方法的优点包括C++程序能够运行于没有提供任何操作系统的硬件智商,以及在适当时程序员能够选择一种专业的适用于某种操作系统特性的库,或是在要实现平台独立性时使用一种跨平台的库。
相比之下,包括C#和Java在内的部分编程语言提供了包括视图用户接口框架、Web服务器、套接字网络和其他大型子系统等在内的大量标准库。提供整体标准库的优点在于,开发人员只需学习如何在所有支持的平台上让一套库高效运行就可以了。但是这样的库都对操作系统有要求。随着编程语言提供的这些库还代表着一个最小共通功能的集合,它们没有原生视窗系统或是任何特定操作系统的联网能力那么强大。因此它们会在某种程度上限制习惯了某种特定操作系统的原生能力的程序员。
使用C++标准库的注意事项
-
标准库的实现中有bug
尽管在软件开发中bug是不可避免的,但是就连经验丰富的开发人员都可能没有在标准库代码中发现过bug。他们可能会因此认为标准库的各种不同实现都坚如磐石。但很遗憾的是,性能优化将开发人员带到了标准库的后巷和黑暗角落汇总,在这些地方潜伏着bug的可能性最大。性能优化开发人员必须时刻做好失望的准备,因为经常会出现这样的情况:本人让人信心满满的性能优化手段无法实现,或是性能优化手段适用于一种编译器却在另一种编译器上出错。另外,标准库与编译器是单独维护的,编译器中也可能存在bug。对于GCC,标准库的维护者是志愿者。对于微软的Visual C++,标准库是购买自第三方的组件,它的发布计划既与Visual C++的发布周期不同,也与C++标准的发布周期不同。标准需求的改变、责任的分散、计划问题以及标准库的复杂度都会不可避免地影响到它的质量。事实上,更加有趣的是,标准库实现中的bug竟然如此之少。 -
标准库的实现可能不符合C++标准
可能世界上根本就没有符合标准的实现。在现实世界中,编译器厂商认为,如果他们的产品大部分都贴近C++标准,包括其中一些最重要的特性,那么就可以出售了。
库的发布计划于编译器是不同的,而编译器的发布计划又与C++标准不同。在符合标准方面,一个标准库的实现可能会领先或是落后于编译器,库的一部分也可能会领先或是落后于一部分。对于对性能优化感兴趣的开发人员而言,C++标准中的变化,意味着有些函数的行为可能会让用于大吃一惊,因为无法记录或是确定一个库所符合的标准的版本。
对于某些库,编译器可能会有限制。编译器的非完美支持可能会限制对标准库类的使用。有时,在试图使用一项特性时编译器会报错,而开发人员无法确定到底是编译器有问题还是标准库的实现有问题。当一位非常熟悉C++标准的性能优化开发人员发现他正在使用的编译器中有一项很少被使用的特性没有被实现时,可能会感到非常沮丧。
-
对标准库开发人员来说,性能并非最重要的事情
尽管对于C++开发人员来说性能非常重要,但对于标准库的开发人员来说,它并非最重要的因素。特性的覆盖率很重要,特别是在标准库的开发人员要检查最新C++标准的特性列表时。简单性和可维护性很重要,因为库会被长期使用。如果库的实现需要支持多种编译器,那么可移植性很重要。性能有时会排在这些更加重要的因素之后。
从标准库函数调用到相关的原生函数的路途可能会漫长而蜿蜒。我曾经跟踪过fopen()的调用,直至调用Windows的OpenFile()最终要求操作系统打开文件之前,它穿越了多层进行参数转换的函数。使用库函数看起来使得所编写的代码行数变少了,但是多层的函数调用会导致性能降低。
-
库的实现可能会让一些优化手段无效
Linux的AIO库(并非C++标准库,但是对于性能优化开发人员却非常有用)曾经打算提供一个非常高效的、异步的、免复制的用于读取文件的接口。问题在于AIO要求一个特定的Linux内核版本。在绝大部分Linux发行版升级内核之前,AIO都是以老式的方式写的,它只能进行缓慢的I/O调用。开发人员可以编写AIO调用,但是无法得到AIO的性能。
-
并非C++标准库中的所有部分都同样有用
有些C++特征,例如良好的异常层次结构以及标准库分配器等,都是在加入到标准中多年以后才被开发人员所使用。这些特性实际上使得编码变得更加困难,而不是简单。好在标准委员胡似乎克制住了之前对于未经测试的新特性的热情。现在,所推荐的库的新特性都会在Boost库中孕育多年后,才会被标准委员会所采纳。
-
标准库不如最好的原生函数高效
标准库没有为某些操作系统提供异步文件I/O等特性。性能优化开发人员对调用了标准库的代码进行优化是由极限的。要想获得最后一点性能提升,性能优化开发人员只能通过调用原生函数,牺牲可移植性来换取运行速度。
优化现有库
优化现有库就如同扫雷一样。这是可能的,也是有必要的。但是这是一项需要耐心和对细节极度专注的工作,否则就会引起爆炸。
最容易优化的库是设计良好、低耦合和带有优秀测试用例的库。不幸的是,这类库通常都已经被优化过了。现实情况是,如果你被要求去优化一个库,那么它可能是一对功能耦合在了函数或类中,而这些函数或类要么做了太多事情,要么什么都没做。
修改库会引入一些风险,因为有些其他程序依赖于当前实现中的未意识到的或是未在文档中记录的行为。尽管修改一个程序让其运行得更快时,修改自身不太可能会引发问题。但是有些随之而来的行为上的变化则可能会导致问题。
修改开源库可能会在你的工程中所使用的库版本和主库之间引入潜在的兼容性问题。当开源库升级版本,修复了Bug或是增加了功能后,这就会有问题了。要么你必须将你的修改手动合并到修改后的库中,要么你的修改就会随着库版本升级而丢失;抑或是你修改后的库中的bug被其他贡献者及时地修复了,但是你却错过了这次修复。因此,在选择开源库时,最好确认开源社区是否欢迎你进行修改,或是该库是否已经非常成熟和稳定。
不过,这并不意味着优化现有库是完全没有希望的。下面将会介绍一些修改现有库的原则。
改动越少越好
不要向类或函数中添加或移除功能,也不要改变函数签名。这类改动几乎肯定会破坏修改后的库与使用库的程序之间的兼容性。
另外一个尽量对库少进行改动的理由是,这样可以缩小需要理解的库的代码的范围。
只需要修改有关的代码即可,这样就可以将修改范围缩至最小。永远不要对这类代码进行重构,即使它们看起来非常需要重构。
添加函数,不要改动功能
在现有库中加入新函数和类是相对安全的。当然,这也存在着一种风险,因为该库以后的版本中可能会出现一个与我们新加入的类或函数同名的类或函数。当然,只要谨慎地选择名字,这种风险就是可控的,而且即使发生了重名问题,也可以编写宏来解决。
以下是一些安全地修改现有库来提高性能的方法:
- 向现有库中添加函数,将循环处理移动到这个新函数内,在你的代码中使用编程惯用法。
- 通过向现有库中添加接收右值引用作为参数的新函数,重载现有库中的旧函数来在老版本的库中实现移动语义。
设计优化库
面对设计糟糕的库,性能优化开发人员几乎无能为力。但是面对一个空白屏幕时,性能优化开发人员则有更大的使用最佳时间以及避免性能陷阱的余地。
草率编码后悔多
-
接口的稳定性是设计可持续交付的库的核心。
匆匆忙忙地设计库或是在库中揉入一对强耦合的函数,都会导致无法定义出优秀的调用规则和返回规则,无法实现优秀的内存分配行为以及效率。紧接着,保持库的稳定性的压力会随之而来。不过,修复库中所有函数需要太多的时间,这会妨碍开发人员维持库的稳定。
设计优化库与设计其他C++代码是一样的,不过风险更高。
-
从定义上来说,库意味着更广泛地使用。库的任何设计、实现或是性能上的瑕疵都会对所有用户造成影响。在一些不重要的代码中随意地进行编码可能不会有太大问题,但是在开发库时这么做就会带来大麻烦。老式的开发手法,包括在项目前期完善规范和设计、文档以及模块化测试,都会在开发库这样的关键代码时派上用场。
测试用例对所有软件都很重要。它们可以帮助我们验证最初设计的正确性,并降低在性能优化过程中修改代码时对程序的正确性造成影响的概率。测试用例在库代码中的重要性更高,不过风险也更高。
测试用例可以帮助我们在设计库的过程中识别出依赖性关系和耦合性。具有良好设计的库中的函数都应当能够独立测试。如果在测试一个目标函数前需要实例化许多对象,那么这对于设计人员来说就是一个信号,它表明库的组件之间存在着太多的耦合。
测试用例可以帮助设计人员了解如何使用库。如果缺乏这方面的了解,就连经验丰富的设计人员也难以设计出重要的接口函数。测试用例可以帮助设计人员在早期识别出设计下次,此时对库接口进行修改还不算太麻烦。知道如何使用库可以帮助设计人员识别要使用的惯用法,这样有些惯用法就会被植入在库的设计汇总,有助于编写出更高效的函数接口。
测试用例可以帮助我们测量库的性能。性能测量可以确保所采用的优化手段确实改善了性能。我们也可以将性能测量自身加入到其他测试用例中,以确保库的修改不会对性能造成影响。
在库的设计上,简约是一种美德
读者可能认为这是KISS(keep it simple, stupid)原则。简约表示库应当专注于某项任务,而且只应当使用最小限度的资源来完成这项任务。
例如:
- 对于一个库函数而言,接收一个有效的std::istream引用作为参数并通过它读取数据,比接收一个文件名作为参数,然后打开这个文件更加简约;处理与操作系统相关的文件名语义和I/O错误并非进行数据处理的库的核心。
- 接收一个指向内存缓冲区的指针作为参数,比分配并返回一块内存更加简约;这意味着库不必处理内存溢出异常。
简约是持续地适用SOLID设计原则中的单一职责原则以及接口隔离原则等优秀C++开发原则的终极结果。简约的库都是简单的。它们是由非成员函数或是简单的类组成的。
不要在库内分配内存
这是简约原则的一个具体示例。由于内存分配非常昂贵,如果可能的话,请在库外部进行内存分配。例如,应当让库函数通过参数接收内存,然后向其中写值,而不要让库函数分配并返回内存。
将内存分配移动到库函数外部,可以允许调用方实现——在每次调用函数时尽可能地重用内存,而不是分配新的存储空间。
如果有必要,可以将内存分配放到继承类中,然后在基类中仅仅保存一个指向已分配内存的指针。这种方式可以让继承类以不同的方式分配内存。
要求在库外部分配内存会影响到函数签名,因此,在设计库时作出这个决定非常重要。试图修改那些已被其他程序使用的库函数的签名,会导致库的调用方也必须进行相应的修改。
若有疑问,以速度为准
对于库类或是库函数,优秀的性能特别重要。库的设计人员是无法预计在库的应用场景中会出现什么性能问题的。而在发生性能问题之后再去改善性能则会非常困难,甚至是不可能的,特别是当牵扯到需要改变函数签名或是函数行为时。即使是修改一个只在企业内部使用的库,也可能会涉及许多程序。如果库已经被广泛使用了,例如随着开源项目被遗弃发布了,可能会无法更新甚至无法找到所有的使用者。库的任何改动都会引起大范围的修改。
函数比框架更容易优化
库可以分为两种:函数库和框架。框架在概念上是一个非常庞大的类,它实现了一个完整程序的骨架。你可以用小函数来装饰这个框架,让它成为你专属的视窗应用程序或是Web服务器。第二种库是函数和类等组件的集合,可以将它们组合起来实现程序。这两种库都可以实现强大的功能和提高生产力。一组功能的集合可以被打包为函数(像Windows SDK中那样)或是框架(像Windows MFC中那样)。不过,从性能优化人员的角度看,函数库比框架更容易优化。
函数的优势在于我们可以独立地测量和优化它们的性能。调用一个框架会牵扯到它内部的所有类和函数,使得修改变得难以隔离和测试。框架违反了分离原则或是单一职责原则,这使得它们难以优化。
我们能够在一个更大的应用程序中集中使用函数。只有库中那些必需的功能才会与程序链接起来。而框架则包含了“上帝函数”,它们自身会关联框架中的许多部分。这会导致在程序中引入许多从不会被使用的代码,使程序变得臃肿不堪。
具有优秀设计的函数不会依赖于它们所运行的环境。相反,框架则基于一个由希望开发人员做的事情所组成的庞大、通用的模型。只要这个模型与开发人员的实际需求之间存在着不匹配的情况,就会导致性能变得低下。
扁平继承层次关系
多数抽象都不会有超过三层继承层次:一个具有通用函数的基类,一个或多个实现多态的继承类,以及一个在非常复杂的情况下可能会引入的多重继承混合层。在某些特殊情况下,开发人员必须自己决定设计多少层类继承层次。不过,一旦继承层次超过了3层,这就是一个信号,表明类的层次结构不够清晰,其引入的复杂性会导致性能下降。
从性能优化的角度看,继承层次越深,在成员函数被调用时引入额外计算的风险就越高。在有许多层基类的继承类中,构造函数和析构函数需要穿越很长的调用链才能执行它们的任务。尽管它们通常并不会被频繁地调用,但是其中仍然存在着在性能需求极其严格的运算中引入昂贵的函数调用的风险。
扁平调用链
与继承类一样,绝大多数抽象的实现都不会超过三层嵌套函数调用:一种非成员函数或是成员函数实现策略,调用某个类的成员函数,调用某个实现了抽象或是访问数据的共有或私有的成员函数。
如果嵌套抽象是通过调用被包含的类实例的方法实现的,那么在其中访问数据可能会导致发生三层函数调用。这种解析会递归地向嵌套抽象链下方前进。在已经充分解耦的库中是不会包含冗长的嵌套抽象调用链的。这些嵌套抽象调用会在函数调用和返回时引入额外的开销。
扁平分层设计
有时候,我们必须用一种抽象来实现另外一种抽象,创建一种分层设计。正如之前所指出的,这可能会走向极端,导致对性能产生影响。
但是在其他时候,一种抽象会在一个层次中被重复实现。这么做的理由如下:
- 要使用Facade模式改变调用规则来实现一个层:也许是将一个项目特有的参数切换为操作系统特有的参数;也许是将参数从文本字符串切换为数字;也许是插入一段项目特有的错误处理。
- 要使用一个紧密相关且有现成代码的抽象来实现另外一种抽象。
- 要在返回错误的函数调用和抛出异常的函数调用之间实现一种过渡。
- 要实现PIMPL惯用法。
- 要调动DLL或是插件。
在以上这几种情况下,开发人员必须自己进行判断,因为虽然有非常充分的理由做上面这些事情,但每穿越一层都会发生一次额外的函数调用和返回,导致每次函数调用的性能都会降低。设计人员必须评审各层之间的穿越情况,检查是否确实需要跨越层,或是否可以将两层或者多层压缩为一层。下面是一些指导代码评审的建议:
- 如果在一个项目的Facade模式中有许多实例,可能意味着过度设计。
- 过度分层设计的一个信号是一个层出现了不止一次,例如返回错误的层调用了异常处理层,而这个异常处理层接着又调用了返回错误的层。
- PIMOL的初衷是提供重编译防火墙,但它其中的嵌套实例却难以为此开脱。多数子系统其实都没有大到需要嵌套使用PIMPL的程度。
- 项目特有的DLL常常被推荐用来封装Bug修复。很少有项目意识到这个工具,因为bug修复往往是批量发布的,这跨越了DLL的边界。
移除多余的层是一项智能在设计阶段完成的任务。在设计阶段,设计人员会得到库的商业需求信息。一旦库设计完成之后,不管它有什么瑕疵,进行任何修改时都必须衡量成本与收益。经验告诉我,除非你拿枪指着他们的头,否则任何一位项目尽力都不愿意让你花费几个迭代的时间来修复库。
避免动态查找
大型程序包往往含大量的配置信息或是注册表项。音频和视频流文件等复杂的数据文件往往包含了可选的用于描述数据的元数据。如果只有少量元数据项目,那么很容易定义一个结构体或是类来存储它们。但是如果有几十甚至上百个元数据项目,许多设计人员会试图采用一种通过给定的关键字字符串在表中查找元数据的方法。如果配置信息是JSON或XML格式,他们更可能这样做,因为有现成的从JSON或XML文件中动态地查找元素的库。有些编程语言,如Objective-C,自带了进行这种处理的系统库。不过,动态地查找符号表可能是性能杀手,原因如下:
- 动态查找天生低效。有些库查找JSON或XML元素的性能是O(n),时间开销与待查找的文件大小成正比。基于表的查找的时间开销可能是O(logn)。相比之下,从结构体中获取一个元素的时间开销只有O(1),而且这个比例常量非常小。
- 库的设计人员可能对库需要访问的元数据不太了解。如果配置文件的初始化只是在程序启动时进行一次,那么开销可能不大。但是实际上,许多元数据会在程序进行处理的过程中反复地被读取,而且可能会在不同的工作单元之间发生改变,虽然过早优化是万恶之源,但是查找一个关键字字符串永远不可能比查找键值对表更快,而且不会破坏现有的实现。显然,万恶之源不止一个。
- 一旦决定采用基于表的查找的设计方式,那么接下来问题就是一致性了。对于一个给定的变换,表中包含了所有所需的元数据吗。必须成对出现的命令行参数真的成对出现了吗?尽管我们可以检查基于表的数据仓库的一致性,但这是一项性能开销昂贵的运行时运算,它涉及代码编写和多次昂贵的查找。访问一个简单的结构体中的数据远比多次查找表要快。
- 基于结构体的数据仓库在某种程度上可以说是自描述的,因为所有可能的元数据都是立即课件的。相比之下,符号表则是一个不透明的大包包,里面装满了未命名的值。使用这种数据仓库的团队需要仔细地记录在程序的各个执行阶段中出现的元数据。但是就我的经验而言,一个团队很难一直遵守这项纪律。另一种解决方法是编写无尽的代码,试图重新生成丢失的元数据,但永远不知道这段代码是否会被调用,更别谈这段代码是否正确了。
留意“上帝函数”
“上帝函数”是指实现了高级策略的函数。如果在程序中使用这种函数,会导致链接器向可执行文件中添加许多库函数,在嵌入式系统中,可执行文件的增大会耗尽物理内存;而在桌面级计算机上,可执行文件的增大则会增加虚拟内存分页。
在许多现有的库中都存在着性能开销昂贵的上帝函数。优秀的库在设计时会移除这些函数。但是如果将库作为框架设计,则无法避免上帝函数。
#include <stdio.h>
int main(){
printf("hello world");
return 0;
}
这段程序占用了8KB,而且这仅仅是代码的大小,不包含符号表、加载器信息和其他任何东西。
#include <stdio.h>
int main(){
puts("hello world");
return 0;
}
这段程序实际上与之前的程序是一样的,但只占用了大约100字节。
原因是printf()自身就是一个大函数,但是真正让它变大的原因是,它引入了格式化各种基本类型的标准库函数。事实上,printf()是上帝函数的典型代表——一个吸收了C运行时库,可以做许多事情的函数。另一方面,puts()只是将字符串放到标准输出中而已,它的内部非常简单。
小结
- C++标准库之所以提供这些函数和类,是因为无法以其他方式提供这些函数和类,要么这些函数和类会被广泛地用于多种操作系统上。
- 在标准库实现中也存在bug。
- 没有一种完全符合标准的实现。
- 标准库不如最好的原生函数高效。
- 当要升级库时,尽量只进行最小的改动。
- 接口的稳定性是可交付的库的核心。
- 在对库进行性能优化时,测试用例非常关键。
- 设计库与设计其他C++代码是一样的,只是风险更高。
- 多数抽象都不需要超过三层类继承层次。
- 多数抽象的实现都不需要超过三层嵌套函数调用。