C++ API 设计 12 第七章 性能

第七章 性能

告诉你如何优化你所要求的性能,亦非告诉你是否有必要,这些不是本书要关注的。性能应该是根据需要来定:有些对性能要求高的API,每秒都会调用好几次,而其它的API就很少被调用,这样它们的速度就没那么受关注了。不过,本书的关注点是告诉你什么样的API设计会影响到性能,还有如何优化接口的性能。

或许,你的实现不要求有那么高的性能,但是你的接口还是应该尽可能的优化,不会暗地里降低性能。需求改变后,你可能发现需要在发布第一个版本的API后优化实现。在这种情况下,你希望你会提前考虑到性能对API的影响,以便在不强制破坏向后兼容性的情况下提高性能。

然而,这里要声明的最重要一点是你千万不要因为性能的原因而去包装你的API。良好的设计通常都有良好的性能(Bloch, 2008; Tulach, 2008)。即使在你优化实现后,API还是要为问题域提供干净和符合逻辑的表示。在有些情况下,这是不大可能的。例如,如果正在编写的API必须和远程过程调用(Remote ProcedureCall)进行通信,你可能会碰到这样的情况,执行许多单独的API调用会非常慢,而你觉得有必要引入一个向量化API(vectorized API),捆绑很多调用到一个单独的方法中。然而,像这样的实例是个例外。API的背后可以完成很多优化工作:毕竟,这就是为什么你要编写一个API。因此,你应该努力限制任何性能上的改进的影响,不要让这些去改变接口。

提示

不要为了实现高性能而去包装API。

API的性能有几个构成要素,我会在接下来的部分讨论这些要素:

(1).编译时速度:你的API会影响用户编译程序所消耗的时间。这会影响用户的生产效率。

(2).运行时速度:调用API方法所消耗的时间。当方法被频繁调用或不同的输入规模不会带来大的影响时,这是很重要的。

(3).运行时内存开销:调用API方法时的内存开销。当你希望创建很多对象并保存在内存中时,这是很重要的的。它也会影响CPU的缓存性能。

(4).库大小:API所实现的对象代码的大小,用户必须链接到他们的应用程序中。这会影响到用户程序的磁盘和内存的占用。

(5).启动时间:用来加载和初始化API库所需要的时间。有很多因素会影响这个,如模板解析、绑定未分辨的符号、调用静态初始化块和库路径搜索。

除了这些特定的API因素会影响这些性能指标。你也应该检查编译器的选项来看看打开或关闭了哪些会改进性能标志。例如,如果你不需要使用dynamic_cast运算符(通常都会这么做。GNU C++编译器中是-fno-rtti),就可以关闭运行时类型信息(Run-Time Type Information,RTTI)

性能优化最重要的是你要相信自己的直觉:你觉得实现的哪个部分是比较慢的。你应该总是在实际情况下测量API的实际性能,接着着重优化那些你觉得有重大影响的部分。这样做的结果是你不需要一开始就用最有效率的实现:先从简单的地方开始,一旦所有的都可以正常工作,再评估哪些实现部分需要优化。

在皮克斯的时候,我们有各种团队为一部影片的特定部分的不同组成工作。例如,涉及汽车驾驶系统的团队是由研发团队实现一个通用的模拟插件系统,GUI工程师为动画师提供直接操作控制,电影的制作团队把一切都整合到他们的流水线中。接着,一旦软件全部都功能化和集成好,我们会召开“加速团队”会议来确认哪里出了瓶颈,并把任务分配给相关的工程师,以便整个系统能够符合特定的性能标准。

这里要强调的要点是阿姆达尔定律(Amdahl’s law)。要想使整体性能获得提升,对某个部件的优化对整个系统的优化帮助是有限的,应该挑选对整体有重大影响的部件来进行优化。如果你优化了API的某部分的10倍性能,但是用户程序中在那个代码只花了1%,那么整体提高就减少到0.1倍(10*0.01)。

提示

要优化一个API,检测你的代码并搜集实际例子中的性能数据。接着优化真正的瓶颈处。不要去猜想哪里是性能的热点。

7.1 通过常量引用传递输入参数

在第六章中,我建议选用常量引用来代替指针传递输入参数。也就是说,函数不会修改参数。不过,对于输出参数,你应该喜欢指针,胜过非常量引用,以便用户能够清楚地了解这是可以修改的。这个部分讲述在传递输入参数到一个函数时,偏好选用常量指针的性能方面的额外原因。

在默认情况下,C++中的函数参数是“通过值”传递的。这意味着传递到函数中的对象是一个副本,当函数返回时会销毁这个副本。因此,传递到方法中的原始对象不会发生改变。然而,这涉及到调用对象拷贝构造函数的开销,接着是析构函数。相反,你应该传递一个常量引用给对象。这只有通过传递一个指针给对象才有效果,但是也要确保对象不会被方法修改。这对只有非常有限的堆栈空间的嵌入式系统是特别重要的。

[代码 P211 第一段]

提示

总是选择传递一个非可变(non-mutable)对象做为引用参数,而不是通过值传递。这可以避免内存和性能花费在对象和其所有的成员和继承的对象的临时副本的创建和销毁上。

这个规则只适用在对象上。内建对象是不适合的,如int、bool、float、double或char,因为这些足够小,可以放在CPU的寄存器中。此外,STL迭代器和函数对象也是设计成通过值传递的。然而,对于其它所有的自定义类型,你应该选择引用或常量引用。让我们看一个特殊的例子:

[代码 P211 第二段]

如果你认为SetObjectByValue()和SetObjectByConstReference()方法都是简单地把它们的参数赋值给mObject成员变量,那么下面这些方法被调用时,执行的操作序列应该是这样的:

[代码 P212 第一段]

如果MyObject是从某个基类继承下来的,情况会变得更糟,因为在对象层级中的每个基类的拷贝构造函数和析构函数都会通过值传递。

避免通过值传递参数还有另一个原因,就是“切割问题”(slicing problem,Meyers, 2005)。这个问题是如果一个方法通过值接受基类的参数,并传递给一个派生类,那么派生类的任何额外字段都会被切割掉。这是因为通过值传递的对象的大小已经在编译时决定了,是基类的大小。通过常量引用来代替值传递参数可以避免切割问题。

7.2 最小化#include依赖

编译一个大型项目的时间主要取决于#include文件的数量和深度。因此,一个减少构建时间的通用技术就是试着减少头文件中#include语句的数量。

7.2.1 避免庞大的头部

有些API提供了单个巨型的头文件,把所有的类和全局定义都放到接口中。这看起来是对用户比较方便,只是增加了用户代码和API间的编译时耦合,这意味着即使是使用API最小的一部分,也要把每个公共符号都拉进来。

例如,标准的Win32头文件windows.h把超过200,000行代码(Visual Studio 9.0)都拉了进来。每个.cpp文件所包含的头文件增加了超过4MB的额外外码,这些需要载入90多个独立的文件,每个源文件也都需要编译。相似地,Mac OS X头文件Cocoa/Cocoa.h展开来也有超过3M的100,000行代码。

通过把这些庞大的头文件预处理成更加优化的形式(如.pch或.gch文件),将有助于减轻这个负担。然而,有种更加模块化和松耦合的解决方法可以为API的每个组件提供更小的独立头文件的集合。接着,用户只要选择#include那些他们需要使用到的API的子集的声明。这虽然会让用户代码中出现更长的#include语句,但是结果是他们的代码必须引入的API数量将会减少。7.2.2 前置声明

为了引入头文件中使用的类、函数、结构、枚举或其它实体的声明,这个头文件A包含了另一个头文件B。在面向对象编程中最常见的情况是:头文件A要从头文件B中引入若干个类的声明。然而,在很多情况下,头文件A并不是真正需要包含头文件B,只是要简单地为需要的类提供一个前置声明。前置声明可以用在如下地方:

(1).不需要知道类的大小。如果你把所包含的类做为一个成员变量或其子类,那么编译器就需要知道类的大小。

(2).你不会引用类中的任何成员方法。这么做需要知道方法的原型:它的参数和返回类型。

(3).你不会引用类中的任何成员变量;但是你已经知道不会把那些设置成public(或protected)。

例如,如果头文件A只通过指针或引用从头文件B中引用了类名,那么你就可以使用前置声明:

[代码 P213 第一段]

然而,如果你改变了A类的定义,以至于编译器需要知道B类的大小,那么你就必须包含B类的实际声明。也就是说,你必须#include它的头文件。例如,如果你在A中存储了B中的一份拷贝:

[代码 P213 第二段]

很明显,如果你使用那个头文件的那些类,你需要在任何的.cpp文件中#include全部的头文件。例如,A.cpp必须包含B.h。一个前置声明只是简单地告诉编译器把名称添加到它的符号表中,而且你保证会在确实需要它时提供完整的声明。

有趣的一点是:如果你通过值传递把一个变量传递(或通过值返回)给方法,那么前置声明也是满足需求的(虽然你更应该选用常量引用,而不是靠值传递一个参数)。你或许认为在此处编译器需要知道类的大小,但是实际上只是实现它的方法和任何要调用它的用户代码才需要知道。因此,下面的例子是完全合法的:

[代码 P214 第一段]

提示

一般说来,你应该只在下面情况下#include一个类的头文件,如果你把这个类的对象做为你自己类中的一个数据成员或你要从那个类继承。

要注意的是如果你只在自己的代码中使用前置声明,那么这通常是更安全的。通过使用前置声明,你其实已经把符号是如何声明的嵌入到头文件中。例如,如果你给一个对外的头文件使用前置声明,你的API用于那个头文件的不同版本的环境下,那么在那个头文件的某个类的声明能够被修改成一个类型定义或那个类可以被修改成一个模板类,这会破坏你的前置声明。这就是为什么你应该不要试图前置声明STL对象的一个原因。例如,总是要这么做:#include <string>,千万别尝试前置声明:std::string。

提示

只前置声明你自己API中的符号。

最后,值得注意的是:一个头文件应该声明它的全部依赖,包括前置声明或显式#include行。否则,其它文件中头部的包含会变成顺序依赖。这和最小化#include语句的数量有点矛盾,但是出于API健壮性和优雅,这是一个重要的例外。一种很好测试这个的方式是:给定一个空的.cpp文件,只包含你的公共头文件,确保能够没有错误地通过编译。

提示

头文件应该#include或前置声明它的所有依赖。

7.2.3 防止冗余#include

减少解析太多包含文件的开销的另一种方法是在包含处添加预处理器防御代码。例如,如果你有个需要包含的文件:bigfile.h,如下所示:

[代码 P215 第一段]

那么你可以从另一头文件中这么包含这个文件,如下所示:

[代码 P215 第二段]

如果你已经包含了某个头文件,这可以节约无意义地打开和解析整个包含文件的开销。这或许看起来像是一种不重要的优化,不过事实上它可以减包含的层级。然而,如果你有一个大型的包含很多头文件的代码,那么这种优化就非同小可了。在1996年,John Lakos做了几个试验来演示这种优化在大型项目中对性能的影响,结果是非常显著的(Lakos, 1996)。不过,这些结果是在上个世纪90年代中期得到的,我设计了一个类似的试验来测试这个在现代编译器中的效果,结果和Lakos得到的非常吻合。

给定一个数值N,我生成的N个包含文件,每个文件都包含其它N-1个包含文件。每个包含文件也都包含100行的类声明。我也生成N个.cpp文件,每个.cpp文件只包含一个头。接着,我计算编译每个.cpp文件要多长时间。因此,这个试验最糟的情况是O(n*n)包含结构,虽然它也包含运行编译器N次的时间。该试验执行两种版本,一种是包含预处理器防护,另一种是没有的。表格7.1这个试验的平均结果,是使用的GNU C++ 编译器的4.2.1版本,是在Mac OS X 10.6系统上运行,CPU是英特尔的酷睿2双核处理器,内存是2GB。

表格7.1 编译排序的加速时间是根据使用包含防护,在最糟地包含的层级有N个包含文件上得出的。

[表格P215 第一张]

当然,这个行为会因为不同的编译器和平台而有所不同,因此出于对实验技术的兴趣,我使用版本14.0的微软C++编译器版本重复了这个实验,运行在英特尔酷睿2四核CPU、3.25G内存的Windows XP平台上。在这种情况下得到的结果是更加显著的,在N=128的时候,加速了18倍。

 

提示

可以考虑往头文件中添加#include防御代码来为用户优化编译时间。

做为对比,我发现当在Linux(N=128时加速1.03倍)上时并没有什么效果,大概是GNU编译器的组合和Linux磁盘缓存提供了更有效率的环境。然而,API的用户可能使用很多不同的平台,因此这种优化对他们的影响很大。即使只加速1.29倍也可以让他们减少等待构建结束的时间。本实验代码包含在本书网站附带的完整源码包中,你可以在你的平台上试一试。

这个技术可以用在很多大型的API上。这里讲一个很复古的例子,Commodore Amiga平台使用这个技术来提高AmigaOS API的性能。例如,intuition/screens.h头文件的前面部分看起来很像20世纪90年代早期的Amiga:

[代码 P216 第一段]

7.3 声明常量

你常常要给API定义很多公共常量。这是一个很好的技术,可以避免用户的代码中出现硬编码的扩散,如最大值或默认字符串。例如,你可能用这样的方式来在头文件中声明几个全局常量:

[代码 P217 第二段]

这里需要知道的问题是只有简单的内建类型常量才会通过C++编译器内联。默认情况下,你用这种方式定义的任何变量都会导致编译器在包含的头文件中为每个模块的变量储存空间。在前面提供的那个例子中,浮点和字符串常量都很可能是这样的。如果你声明了很多常量,而你的API头文件又被很多.cpp文件所包含,那么这会导致用户的对象文件和最终的二级制的臃肿。下面的解决方法是用extern声明常量:

[代码 P217 第二段]

接着定义相关的.cpp文件中每个常量的值。通过这种方式,变量的空间只会分配一次。这还可以从头文件隐藏真实的变量值:它们毕竟是属于实现细节。

实现这个的更好方式是在类中声明这些常量。接着你就可以把它们声明成静态常量(因此它们将不计入每个对象的内存大小)。

[代码 P217 第三段]

接着就就可以在这些常量相关的.cpp文件中定义值了:

[代码 P218 第二段]

或许你认为这只是一个微不足道的优化,但是这会节省大量的空间。不过,在Linden Lab时,我们决定清理Second Life Viewer中这些实例的源代码。最后的效果是我们差不多减小了10%的生成文件的大小。

避免臃肿问题的另一种选择时在某些情况下可以用枚举代替变量或者你可以使用getter方法来返回常量值,例如:

[代码 P218 第三段]

 

提示

把全局常量用extern声明或把类中的常量声明成静态引用。接着在.cpp文件中定义常量的值。这可以减小包含头文件的模块的对象文件的大小。更好的是,把这些常量隐藏到一个函数调用中。

7.3.1 新的关键字constexpr

我们刚刚罗列出来的所有问题是编译器在编译时不再评估常量表达式,因为实际的值是隐藏在.cpp文件中。例如,用户不能这么做:

[代码 P218 第四段]

然而,做为新的C++0x规范的一部分,正在考虑一个新的特性,它允许更加积极的编译时优化:constexpr关键字。这可以用来标示你认为是常量的函数或变量,以便编译器能够执行更多的优化。例如,考虑下面的代码:

[代码 P218 第五段]

这在C++98标准中是非法的,因为编译器无法知道GetTableSize()返回的值是一个编译时常量。然而,在新的C++规范中,你能够告诉编译器实际的情况:

[代码 P219 第一段]

constexpr关键字也可以用在变量身上。然而,事实上它可以用来标记一个函数的结果,做为编译时常量并对外开放,让我们通过函数调用来定义常量,同时也允许用户在编译时利用这个常量。

[代码 P219 第二段]

7.4 初始化列表

C++提供的构造初始化列表可以让你轻松地初始化类中的所有成员变量。使用这个特性只要承担一点性能开销,只是简单地在构造函数中初始化每个成员变量。例如,不是这么写:

[代码 P219 第三段]

你可以这么编写:

[代码 P219 第四段]

因为成员变量是在构造函数体被调用前就已经构造好了,在第一个例子中默认的std::string构造函数被调用时会初始化mName成员变量,接着在构造函数里面的赋值运算符才会被调用(DeLoura, 2001)。然而,在第二个例子中:只调用了赋值运算符。因此,使用初始化列表可以避免为每个成员变量调用默认构造函数的开销。

当然,就良好的API设计而言:你应该尽可能隐藏头文件中的更多细节。因此,在头文件中定义构造函数的最好方式是:

[代码 P220 第二段]

接着是提供相关的.cpp文件的构造函数和初始化列表:

[代码 P220 第三段]

提示

通过使用初始化列表来避免为每个成员变量调用默认构造函数的开销,但是要把这些声明到.cpp文件中以隐藏实现细节。

当使用初始化列表的时候,这里有几个注意点要知道一下:

(1).初始化列表中的变量顺序必须和类中指定的变量顺序相匹配。

(2).不能在初始化列表中指定数组。不过,你可以指定一个std::vector,这是一个更适合的数据结构。

(3).如果你在声明一个派生类,任何基类的默认构造函数都会被隐式地调用。取而代之的是你可以通过使用初始化列表去调用一个非默认构造函数。如果指定好了,调用基类的构造函数会发生在任何成员变量之前。

(4).如果你已经声明过任何的引用成员变量或常量成员变量,那么你必须通过初始化列表来对它们进行初始化(为了避免默认构造函数定义它们的初始值)。

此外,新的C++0x规范在关于对象构造方面做了一些改进。在C++98中,构造函数无法调用其它的构造函数。不过,这个限制在C++0x的草案标准中已经放宽了。特别地,C++0x允许构造函数调用同一个类中的其它函数。这可以避免你在构造函数之间复制代码,一个构造函数的实现可以委托给另一个构造函数实现,请看下面的例子:

[代码 P221 第一段]

使用默认参数的单独构造函数也可以达到同样的效果,它能够把默认值传递到用户代码中。新的C++0x语法让你隐藏这个值,因为你能够(和应该)在.cpp文件中定义初始化列表。

7.5 内存优化

现代的CPU、内存延迟成为一个影响大型程序性能的最重要问题之一。那是因为平均每年CPU的处理速度都差不多提高55%,访问DRAM(内存)时间每年大概提高7%(Hennessy and Patterson, 2006)。这就导致所谓的处理器-内存性能差距(Processor–Memory Performance Gap),如图7.1所示。

由于这种趋势,内存开销成为绝大多数程序执行时间的主要因素。这在没有缓存的情况下开销会更加恶化。也就是说,访问主内存的开销,从30年前的几个CPU周期增加到现代架构的400多个周期。这会导致看起来优雅和非常正确的算法在实际情况中因为无法预期的缓存行为而导致糟糕的行为。因此,近几年来,缓存缺失的优化成为性能优化实践中极其重要的元素。

虽然本书并不会关注于技术上实现缓存方面的优化的实现细节,但是有几条和API相关的措施可以应用在提高用户缓存效率上。其中一个关键技术是减小对象的大小:所用的对象越小,它们中就越多适合缓存。下面给出几种减小对象大小的方式:

(1).按照类型构建成员变量的群集:如今的计算机每次都是按照单个“字”(word)单位来访问内存。因此,C++编译器会对齐某些数据成员,以便内存地址落在字区间内。为了实现这个,可能会添加很多填充字节到结构中。通过把相同类型的所有成员变量相互聚集,你就可以节省因为这些填充字节而造成的内存损失。表7.2所示的例子是在Windows平台上成员变量的对齐图示。

[图 P222 第一张]

图7.1

CPU性能方面的改进与改善内存性能的差距正逐步拉大。要注意的是垂直轴是对数刻度。

改编自Hennessy and Patterson (2006) Morgan Kaufmann出版社版权所有。

(2).使用位字段(bit field):位字段可以做为成员变量的修饰符,用来指定变量应该占用的位大小。例如,int tinyInt:4。这是用来把几个布尔值打包成单个字节或把两个或更多数字压缩到单个int空间中。当使用的位字段的大小不是8的整数倍时,比较容易会影响性能,但是如果你更关注内存,那么这种开销还是可以接受的。当执行性能优化时,你常常会用空间换取时间,或者反之亦然。记住,如果对某个特性有所怀疑的话,请放到实践中去测量性能。

[图 P 222 第二张]

图7.2 在Windows x86 CPU下典型的不同类型的成员变量的对齐

(3).使用联合:联合是一种数据成员共享内存空间的结构。这可以用来允许多个共享内存的值可以在同一时刻弃用,这样可以节约内存。联合的大小取决于联合中最大类型的大小。例如:

[代码 P223 第一段]

(4).不要添加虚方法,直到你需要它们:我在第二章建议过这个,用来使API保持最小完整性,不过这么做也有性能方面的原因。一旦你给一个类添加了一个虚方法,那么那个类也就需要有张虚拟表。即使只有一份这样的虚拟表副本,每个类的类型都需要分配一个虚拟表副本,而且对象的每个实例都会储存指向虚拟表的指针。这个指针也会增加每个对象的大小(32位程序中通常是4个字节或64位程序中是8个字节)。

(5).使用显式的基于大小的类型:每个类型的大小也会因为平台、编译器和构建32位还是64位的程序的不同而不同。如果你指定一个成员变量的精确大小,那么你应该使用强制的类型,而不是使用这样的类型:bool、short或int。不幸的是,声明固定大小的变量的方式会因为平台的不同而不同。例如,在基于UNIX系统中,stdint.h头文件提供诸如int8_t、uint32_t和int64_t类型来指定8位整型、32位整型和64位整型。不过,Boost库的boost/cstdint.hpp头文件提供了这些类型平台无关的版本。

让我们再看一个例子。下面的结构定义的是描述烟花效果的变量集合。它包含烟花粒子变量的颜色和颜色变量,还有一些标志,如效果是否在当前激活和效果从屏幕中的哪个位置开始。一个实际中的烟花类会有更多的状态,不过出于演示的目的,这里的足够了。

[代码 P223 第二段]

这个类中的变量顺序大致是根据它们在函数中的逻辑位置,没有考虑到它们打包后在内存中的效率。大部分成员变量都是这样排序的,或者有时更随机的在类的末尾添加一个新的变量。对于这个特殊的例子,这个结构在32位计算机中的总大小是48个字节。也就是说,sizeof(Fireworks_A) == 48。

如果你简单地根据成员变量的类型来聚集,并且根据它们每个类型来排序(bool,char,最后是int),那么这个结构的大小就可以变成32位,减了33%。

[代码 P244 第一段]

提示

通过成员变量的类型来聚集可以优化对象的大小。

你还可以进一步压缩这个结构。你可以通过使用位字段来实现,给每个布尔标志使用单个位来代替整个字节。这么做可以把结构压缩到28个字节,减小了42%。

[代码 P224 第二段]

提示

考虑使用位字段来压缩对象,不过要知道它们性能影响。

最后,你可以先回头考虑每个变量所需要的真正大小,而不是为所有的整型值使用int类型。这么做的话,RGB变量变体类型就只需要1个字节,而每个屏幕空间坐标要2个字节。我将使用char和short类型来简化流程,但是在实际中你可以使用指定大小的类型,如:int8_t和uint16_t。

[代码 P225 第一段]

提示

使用指定大小的类型,如int32_t或uint16_t来指定变量所需要的位的最大数值。

我们的这个版本的结构只占用16个字节:比起原先的那个48字节的已经减少了66%。这节约了非常多的内存,仅仅是通过重新排列了成员变量和多考虑一下它们需要的大小。图7.2精确地给出了采用这四种配置的内存情况。

[图 P226 第一张]

图7.2

类中成员变量在4种不同配置下的内存布局。Fireworks_A是原先未优化的版本,Fireworks_B采用类型聚集,Fireworks_C使用了位字段来压缩布尔变量和Fireworks_D使用更小的整数类型。

7.6 如非必要 勿用内联

这个建议在性能章节或许看起来有点奇怪:不要内联代码!然而,这是API书中首先提到的,在头文件中内联代码会破坏API设计的一个重要原则:不要暴露实现细节,这并不是说,你永远不应该内联代码。有时候出于性能需求,你还是需要它,但是这种情况下你一定要睁大眼睛,要完全理解下面列出的含义:

(1).暴露实现细节:正如刚刚提到过的,避免在公共API头文件中使用内联的主要原因是这会导致直接暴露头文件中的API方法的实现。我在第二章中花了整节的篇幅来解释为什么你不应该那么做。

(2).在用户程序中嵌入代码:API头文件中的内联代码会直接编译到用户的程序中。这意味着只要内联代码有任何变动,用户在使用API的新发布的版本时就需要重新编译他们的代码。他们不能只是简单地把新版本共享库丢入安装处就可以让他们的程序正常运行,内联破坏了二进制兼容性。

(3).代码臃肿:过度的内联会增大对象文件的大小并对二进制特别有影响。也就是,因为每个对内联方法的调用都会被替换成那个方法的所有操作。这种较大的代码尺寸会导致对磁盘的过多访问和虚拟内存页面故障,从而对性能产生负面的影响。

(4).调式复杂化:在处理内联代码时,很多调试器会出现问题。这很容易理解:很难在一个不是真实存在的函数内设置断点!解决这些问题的通常方法是为调式代码关闭内联。

正如Donald Knuth说过的名言:“过早的优化是一切不幸的根源”(Knuth, 1974)。

尽管有这些缺点,但是还是有需要你在API公共头文件中放置内联代码的情况。这么做主要有两大原因,如下所示:

(1).性能:在代码中使用getter/setter方法来包装对成员变量的访问会对性能造成影响,如果那些方法在一秒内多次调用的话。内联可以避免这种性能损失,而且仍然允许你保存getter/setter方法(本书附带的源码有一个简单的程序供你进行这样的测试)。然而,需要指出的的是把一个函数设置成内联的或许对你要求的性能没什么帮助。其中一个原因这相当于编译器可以忽略的一个小提示。有些情况下可能被忽略的请求是在函数中使用循环、调用另一个内联函数或递归。即使当编译器内联了方法,结果代码会变大或变小,也就可能变快或变慢,这取决于原来方法的大小,CPU的指令缓存和虚拟内存系统(Cline等, 1998)。内联倾向于使用在小、简单和频繁使用的函数上。

(2).模板:你也可能在头文件中使用模板并强制使用内联模板实现。虽然在上一章的C++用法中提到过这种用法,但是有时候你可以使用显式模板初始化来避免使用这个。

 

(2). 

提示

应该避免在头文件中使用内联代码,除非你能证明代码会有性能问题并且内联可以解决那个问题。

对于那些你应该使用内联代码的例子,我会讨论最佳的使用方式。一种方式是把内联代码简单地包含到类的方法实现中,例如:

[代码 P227 第一段]

这很好地演示了在头文件中包含实现细节:API的用户可以查看你的头文件,还能够清楚地知道内联方法是如何实现的。在本例中,方法的实现是比较简单的,但是它能够轻松地暴露更多的复杂性。

另一种使用内联代码的方式是使用C++的inline关键字。这种方法至少提供了语法上的改进,它让你能够在类的方法外定义内联代码。虽然代码还是在头文件中,但是至少你不会混淆类的声明和内联代码。

[代码 P228 第一段]

有种更好的风格是把内联语句隐藏到一个独立的头文件中去,而头文件的名称指明了它所包含的实现细节。这和先前建议过的处理模板的技术是一样的,这应用在几个商用API上,如Boost的头文件。Boost使用一个“细节”子目录的协定来保存所有在头文件中暴露的私有细节,接着从公共头文件中#include那些。例如:

[代码 P228 第二段]

Boost头文件常常使用一个“细节”子目录协定来包含所有的私有实现代码,例如:boost::tuples::detail。这是一种很好的做法,可以进一步分割公共头文件中的代码。

7.7 写时复制

有一种节省内存的最好方式是不进行内存分配,直到你真正需要的时候。这从本质上说是写时复制(copy-on-write)技术的目标。这允许所有的用户共享单个资源,直到他们中的一个需要时才修改这个资源。只在需要时的那个时间点才复制。这就是这个叫法的由来:写时复制。这个的优点是如果资源不会被修改,那么它就可以由所有的用户所共享。这和轻量级设计模式(Flyweight design pattern)是有密切联系的,它描述的是对象尽可能多的共享内存,最小化内存的消耗(Gamma等,1994)。

提示

使用写时复制语义可以减少对象很多拷贝的内存开销。

例如,几个字符串变量可以保存相同的文本并共享所有相同的内存缓冲。接着当其中一个字符串必须修改文本的时候,它创建一份内存缓冲的拷贝,以便这种修改不会影响其它字符串。图7.3说明了这个概念。绝大多数的STL字符串是通过写时复制实现的,以便通过值来传递它们,这个开销是比较小的(Stroustrup, 2000)。

实现写时复制的方式有好多种。有种流行的方式是声明一个类模板来让你创建对象的指针,这是用同样的方式来管理写时复制语义,这样你就可以创建一个共享的弱指针模板。这个类通常包含一个标准的共享指针,用来追踪基本对象的引用计数,并提供私有的Detach()方法,用来操作对象的修改。因此,需要分离共享的对象和创建的新拷贝。下面的实现使用的是Boost共享指针。这里为了简化说明,类中的声明采用了内联方法。在实际应用程序中,你应该把这些内联定义隐藏到一个独立的头文件中去,这样就不会和接口声明相混淆。在随书的源码包中,我已经采用这种方式来隐藏类的实现。

[图 P229 第一张]

图7.3

写时复制说明:Object1和Object2共享相同的资源,直到Object2要修改它,当这个发生时,需要复制一份拷贝,以便这种修改不会影响Object1的状态。

[代码 P230 第一段]

这个类的用法如下所示:

[代码 P230 第二段]

在本例中,string2和string1指向同一个对象,而string3指向对象的一份拷贝,因为string3需要修改它。前面已经讲过,许多std::string的实现都是使用写时复制语义。我这里将简单地通过这个来给出一个适宜的例子。

在CowPtr的实现中有个漏洞可以利用。用户可能深入到写时复制指针并访问底层对象,这样就可以持有对它的数据的引用。接着,他们就可以直接修改数据,从而会影响到所有的CowPtr变量所共享的对象。例如:

[代码 P232 第二段]

在上述代码中,用户获取到一个底层string1的std::string的字符的引用。在string2创建后,和string1共享相同的内存,接着用户直接修改了共享字符串中的第二个字符,致使string1和string2现在都等于“Spare Me”。

避免这种滥用的最好方式是不把CowPtr暴露给你的用户。在绝大多数情况下,你不需要用户知道你在使用写时复制优化:这毕竟是实现细节。你可以在对象中使用CowPtr声明成员变量,不要通过任何方式去修改公共API。这在Qt库中叫做隐式共享。例如:

[代码 P232 第三段]

MyObject的实现如下所示:

[代码 P232 第四段]

采用这种方式,用户就可以使用MyObject API而无需任何写时复制的知识,不过这个掩盖了对象只要有可能就会共享内存并能够执行高效的复制和赋值操作。

[代码 P233 第二段]

在本例中,obj1和obj2会欧冠能共享相同的底层字符串对象,而obj3包含的是它的拷贝,因为它要修改那个字符串。

7.8 在元素上迭代

在用户代码中对象集合进行迭代是一种比较常见的任务,因此值得花时间来寻找一些可供选择的带有不同优缺点策略。这样你就可以为你的特殊的API需求选择一种最佳的解决方案。

7.8.1 迭代器

STL解决这个问题的方法就是使用迭代器。这些对象可以遍历容器类的部分或全部元素(Josuttis, 1999)。迭代器指向容器类的单个元素,带有多种运算符,如operator*返回当前的元素、operator->用来直接访问容器元素的成员,还有operator++步进到下个元素。这一设计是有意模仿纯指针操作接口的C / C + +的。

用户可以为每个容器类使用begin()和end()方法来返回绑定在容器类中的所有元素的迭代,或者也可以使用各种STL算法来返回迭代,如std::find()、std::lower_bound()和std::upper_bound()。下面的代码片段所提供的简单例子是使用一个STL迭代器来对std::vector的所有值进行求和:

[代码 P233 第三段]

这纯粹是一个用来说明的例子。如果你真的要计算这个容器类所有的元素的和,你应该更喜欢使用STL的算法:std::accumulate。

就API设计而言,下面有几个理由说明了为什么你要采用迭代器来允许用户迭代数据。

q迭代器是绝大多数工程师都已经熟悉的模式。因此,在你的API中使用迭代器模型可以最小化用户的学习难度。这个符合第二章中介绍过的易于使用的品质。例如,绝大多数工程师已经知道的所有性能问题,他们更喜欢迭代器(++it)的预增量运算符,相对于后增量(it++),这是为了避免临时变量的构造和销毁。

q迭代器抽象可以应用于简单的连续数据结构上,如数组或列表。还包括更加复杂的数据结构,如集合和映射。这些常常用在自平衡二叉搜索树,如红黑树(Josuttis, 1999)。

q迭代器可以高效地执行,在一些例子中就和指针一样简单。事实上,std::vector迭代器的实现方式在Visual C++ 6和GNU C++ 3就是这么实现的(但是现在绝大多数的STL实现都是采用迭代类)。

q迭代器可以用来遍历无法全部放入内存的大量数据集。例如,迭代器可以根据需要从磁盘把数据分页成块,继续处理数据块前释放掉上一个。当然,用户也可以在任何点停止遍历,而无需访问容器中所有的元素。

q用户可以创建多个迭代器来遍历相同的数据并同时使用这些迭代器。当遍历容器时,按照用户的希望来插入或删除元素,这么做是一种既定的模式,同时可以维持迭代器的完整性。

7.8.2 随机访问

迭代器允许用户线性遍历容器类的每个元素。然而,有时你希望支持对任意元素的随机访问。如:访问数组中或向量容器中指定的元素。STL容器类支持随机访问,为此提供了几种方式。

(1).[]运算符:这看上去很像C/C++的数组索引语法。通常,这个运算符的实现没有进行边界检查,因此执行效率很高。

(2).at()方法:这个方法需要检查提供的索引是否超出了边界,如果是的话抛出一个异常。因此,这种方法比[]运算符会慢。

为了说明这些概念,上个小节中的迭代源码例子可以修改成利用[]运算符的形式,如下所示:

[代码 P234 第一段]

就性能而言,这两种方法是差不多的。显然地,对于给定的平台和编译器,一种方法可能比起另一个会高效一些,但是总的说来它们应该包含相同尺度的开销。

如果你计划给API添加随机访问的功能,你应该利用STL的一致性来完成这个设计。然而,如果你的API不需要提供对基础数据的随机访问,你应该更喜欢使用[]运算符的迭代器模型方法,因此迭代器更能清楚地表达用户的意图,使用户代码更加明白和一致。

7.8.3 数组引用

做为对迭代器的一种替代,一些API使用的方法是用户通过引用来传入一个数组数据结构。接着,API把请求的元素填充到数组中并把它返回给用户。Maya API大量使用这个模式。Autodesk Maya是一个高端3D建模和动画系统,广泛使用在电影和游戏领域。提供的包涵盖C++和Python API,用来编程访问场景中的基础2D和3D数据。

做为这个模式的一个例子,MfnDagNode::getAllPaths()方法是用来返回Maya场景图中的结点路径的序列。这是通过引用传入一个MdagPathArray对象实现的,接着返回一个MdagPath引用填充过的对象。这个设计的原因,为什么给API采用这个方法,如下所示:

q这个方法的主要目的就是性能。从本质上说,这种方式是把一系列图像数据结构的连接结点折叠进一个连续的数组数据结构中。这种结构能够非常高效地进行迭代,不过也让元素定位在内存相邻的位置上。这种数据结构可以很好地利用CPU的缓存策略,而树形结构中的独立点在处理的地址空间中是比较分散的。

q如果用户保持相同的数组来服务于多个getAllPaths()调用,那么这个技术是特别的有效率。还有,如果保持数组支持多个迭代它的元素,任何的填充数组的初始化性能开销都可以被抵消。

q这个技术也提供一个迭代器模型没有的特定属性:对非连续元素的支持,也就是说,传统的迭代器无法处理不同的元素排序或忽略一个序列中的某些元素。而是用数组引用技术,你可以任意顺序往数组里填充元素的任何子集。

这个概念也可以在其它语言中见到,如PHP中的iterator_to_array()函数,用来把迭代器转换成一个某些情况下可以更快遍历的数组。

做为一种用户提供的数组的可选方法,你也可以返回一个对象的常量容器并依赖编译器的返回值优化,以避免复制数据。

提示

为了遍历简单的线性数据结构而采用迭代器。如果你有一个链表或树数据结构,那么可以考虑使用数组引用(如果迭代的性能比较差的话)。

7.9 性能分析

做为本章关于性能的最后一个章节,我将讲述一些帮助你衡量系统性能的工具和技术。这些的大多数目标是分析你的实现代码的性能,并不直接和如何设计API相关。然而,很明显这也是生成一个高效的API的重要部分,因此是值得关注的。

我会讨论关于性能的几个不同方面:基于时间的性能、内存开销和多线程的争论。还有,应该指出的是本章前面的所有章节都是使用C++的稳定特性处理的,下面的文本给出的软件产品可能会随着时间而改变。产品诞生、淘汰、改变所有权和关注点。然而,我尽力在出版日期到来时列个表格(相关的URL)。要查看更多的产品列表,请访问附带的网站:http://APIBook.com/

7.9.1 基于时间的分析

性能最为传统的解释是代码执行各种操作所需要的时间。例如,如果你正在开发一个图像处理库,Sharpen()(锐化)或RedEyeReduction()(红眼消除)方法在处理图像时需要多少时间?这里隐含的意思是你必须编写一些样例或使用API的测试程序,以便你可以记录API性能在不同的实际场景中的时间。假设你已经编写了这样的程序,你可以考虑使用以下几种性能分析的方式:

(1).内部测试:最有针对性和有效的分析,就是你自己编写的。因为软件的每个部分都是不同的,你的代码中决定性能的部分在API中是明确的。因此,访问一个快速定时器类是非常有帮助的,可以插入到你的代码的关键点,收集准确的计时信息。结果可以输出到一个文件并进行离线分析或把计时器结果的可视化显示集成到用户的最终程序中去。

Second Life Viewer通过LLFastTimer类提供这一功能。这是使用标明需要分析的区域的扩展标签,把LLFastTimer()调用插入到决定性能的代码处。例如,LLFastTimer(RENDER_WATER)。Second Life Viewer本身有提供一个调试的叠加显示,以实时查看定时器累积的结果。图7.4显示的是这个调试视图的例子。

(2).二进制仪器(Binary instrumentation):这项技术包括通过添加代码来检测一个程序或共享库,记录每个函数的调用。运行二进制仪器,接着为特殊的部分创建函数调用的精确跟踪。处理返回结果能够测定程序最花时间的顶部调用栈。

这个方法的一个缺点是额外的仪器开销会较大地减慢程序的运行速度,有时会达到10-100倍,尽管相对的性能应该仍然有所保留。最后,这种技术显然不会在二进制文件中出现记录时间的函数符号,如内联函数。

(3).采样:这包括一个独立程序的使用,通过连续地测试程序来测定程序的计数器。这是一种低开销的统计技术,意思是它不会记录程序产生的每个函数调用,但是足够高的采样率仍够可以用来告诉你程序的哪个部分最花时间。

采样可以执行在系统级别上(如:如果你的应用程序在系统调用上花了很多时间或它在I/O输入输出边界上)或隔离到程序的函数中。除了记录时间样本,这个技术也适用于处理器事件的采样,如缓存丢失、错误预测分支和CPU延迟。

(4).计数器监视:许多在用的商业系统都有提供性能计数器,用来报告各种子系统执行的状况,如处理器、内存、网络和磁盘等。例如,微软提供的性能计数器API访问Windows平台下的计数器数据。在你的程序运行时,通过监视这些计数器可以测定系统的瓶颈,也可以评估影响API性能的系统资源。

[图 P237 第一张]

图7.4

Second Life Viewer的截图,通过它的内建视图显示嵌入代码中的各种计时器的结果。

给这些性能分析技术做个分类,下面的列表给出的是截止本书出版前市场上的分析工具。

英特尔的Vtune(http://software.intel.com/en-us/intel-vtune/):这个商业性能分析套装可同时适用于微软的Windows和Linux的平台。它包含一个二进制仪器特性(叫做调用图)、基于时间和事件的采样、一个计数器监视器和其它各种工具。它还包含大量强大的图像工具用以可视化显示性能数据结果。

gprof(http://www.gnu.org/software/binutils/)gprof是一个GNU分析工具。它使用二进制仪器记录每个函数的调用次数和时间开销。它集成在GNU C++编译器中,通过-pg命令行选项来激活。运行二进制仪器创建一个当前目录的数据文件可以用gprof进行分析(或者在Mac OS X系统中Saturn程序)。

OProfile (http://oprofile.sourceforge.net/):这是Linux上的一个开源性能工具。这是一个全系统的采样分析器,还可以利用硬件性能计数器。可以生成函数或指令级别的分析数据,软件还包含支持通过分析信息来注释源码树。

AMD CodeAnalyst (http://developer.amd.com/cpu/codeanalyst):这是来自AMD运行在Windows和Linux上的免费分析工具。它是基于OProfile,特别支持分析和对AMD处理器的流水线阶段的可视化。

Open SpeedShop (http://www.openspeedshop.org/):这是基于SGI’s IRIX SpeedShop,运行在Linux上的开源性能测量工具,现在由Krell Institute提供支持。Open SpeedShop使用的采样技术是通过对硬件性能计数器的支持实现的。它还支持并行和多线程程序,同时还包含一个Python脚本API。

Sysprof (http://www.daimi.au.dk/~sandmann/sysprof/):这是Linux上的一个开源性能分析工具,使用全系统的采样分析技术来分析你的程序运行时的整个Linux系统。提供一个简单的用户界面来浏览结果数据。

CodeProphet Profiler (http://www.codeprophet.co.cc/): 这是一个免费的工具,使用二进制仪器来收集程序运行时的时间信息。它支持32位和64位的Windows平台,还有Windows Mobile。它还提供一个CodeProphet View程序来让.cpg结果文件的可视化。

Callgrind (http://valgrind.org/):这是Linux和Mac OS X系统上的valgrind仪器框架的一部分。它使用二进制仪器技术来给运行的程序收集调用图和指令数据。有个独立的Kcachegrind工具用来让分析数据可视化。一个可选的缓存模拟器用来分析内存访问行为。

Apple Shark (http://developer.apple.com/tools/sharkoptimize.html):Shark是由苹果编写的全系统采样分析工具,做为他们的开发工具的一部分,是免费的。它也能够分析硬件和软件事件,如缓存遗漏(cache misses)和虚拟内存活动。Shark包含一个直观和易用的界面来浏览苹果程序的重要区域。

DTrace (http://en.wikipedia.org/wiki/DTrace):这个独特和强大的跟踪框架可以用来实时监测程序。这是通过编写自定义的跟踪程序实现的,当触发监测器时,定义的一系列操作会被执行。监测器包括打开文件、启动一个线程或执行特定的代码行和在运行时分析上下文,如调用堆栈。苹果还Mac OS X 10.5中的叫Instrument的GUI中添加了Dtrace也适用于FreeBSDktrace。

7.9.2 基于内存的分析

正如上一提到过的,内存性能和基于时间的性能是同等重要的。频繁地进行内存分配和解除分配的算法或者不能很好映射处理器缓存都会减慢最终的执行速度。还有内存错误,如重复释放或访问未分配的内存,都会导致毁坏数据或程序崩溃。内存泄露会随着时间的推移而最终消耗光所有可用的内存并降低用户程序的性能,导致程序运行缓慢或崩溃。

下面的工具可以用来分析API的内存性能并侦测内存错误。

qIBM Rational Purify (http://www.ibm.com/software/awdtools/purify/):这个商业内存调试器使用二进制仪器来侦测C/C++程序中的内存访问错误。在程序运行后,Purify输出一个报告文件,可以在一个图形化界面中浏览。它还包含一个可以访问你的程序的API。Purify适用于Solaris、Linux、AIX和Windows操作系统。

qValgrind (http://valgrind.org/):Valgrind是Linux和Mac OS X上的开源仪器框架,它出身于一个内存分析工具。不过,现在它已经成长为一个通用的性能分析工具了。它可以对可执行文件执行二进制仪器操作,当在程序结束时输出一个文本报告。可用几个早期的GUI工具浏览输出文件,如Valkyrie和Alleyoop。

qTotalView MemoryScape (http://www.totalviewtech.com/):这个商业内存分析工具可用于UNIX和Mac OS X 平台上,不需要二进制仪器就可以运行。它提供实时的堆内存图像视图,包括:内存使用、非法边界分配和泄露。它能够处理并行和多线程程序,采用一种脚本语言来执行批量测试。

qParasoft Insure++ (http://www.parasoft.com/):这是一个适用于Windows、Linux、Solaris、AIX和HP-UX的商业内存调试工具。Insure++执行基于源码级别的仪器操作,把其程序添加到你的命令行前。你甚至可以添加__Insure_trap_error()断点,在它检测到任何错误时停止调试器。当然,它也有提供一个GUI工具让你可以浏览检测到的内存错误。

qCoverity (http://www.coverity.com/):Coverity和列表中的其它工具不同。它是一个静态分析工具,这意味着它在检测源代码时并没有真正地执行你的程序。它把所有潜在的编码错误使用一个唯一的ID并记录到数据库中,这在同时运行多个分析时是十分稳定的。它提供一个Web接口来查看静态分析的结果。

qMALLOC_CHECK:GNU C/C++编译器支持另一种更加稳定的内存分配器,可以避免简单的内存错误,如重复释放和单字节缓冲区溢出。需要权衡的是,这个内存分配器的效率不高,因此你可能不会在发布版本中使用它,不过它在调试内存问题时还是有用的。你可以通过设置MALLOC_CHECK_环境变量来打开这个特殊的分配器。

7.9.3 多线程分析

这里所涉及的性能的最后一个方面是多线程性能。编写有效率的多线程代码是一件困难的任务,但是幸运的是有很多工具可以帮助你找到代码中逻辑线程错误,如竞赛条件或死锁,还有分析线程代码的性能,找到并发瓶颈。

Intel Thread Checker (http://software.intel.com/en-us/intel-thread-checker/):这是一个运行于Windows和Linux之上的32位与64位的商业线程分析工具。它可以用来发现逻辑线程错误,如潜在的死锁。你可以通过命令行的方式使用它,输出一个文本报告或使用附带的可视化GUI来映射潜在的错误到源码行。

Intel Thread Profiler (http://software.intel.com/en-us/intel-vtune/):线程分析器通过显示时间表可视化你的线程程序的行为,可以显示线程在做什么和它们是如何交互的。这让你能够判定是否让你的代码达到最大的并发性。它运行于Windows和Linux之上。英特尔现在把线程分析器和他们的Vtune产品捆绑起来。

Intel Parallel Studio (http://software.intel.com/en-us/intel-parallel-studio-home/):Intel Parallel Studio提供一个支持多核系统上的并行程序的工具套装,包括识别候选函数并行化的程序,英特尔线程构建模块库(TBB库)是一个检查工具,用来检测线程和内存错误,还有为并行程序进行性能分析的工具。

Acumem ThreadSpotter (http://www.acumem.com/):这个工具让你可以找到多线程和OpenMPI程序的性能问题,它是运行在Solaris、Linux和Windows之上。它包含Acumem单线程分析器SlowSpotter(包括分析内存带宽、延迟和数据方位)的所有功能,还包括线程通信和交互模块。

Helgrind and DRD (http://valgrind.org/):Helgrind和DRD都是开源Valgrind仪器框架的两个模块。它们可以用来检测基于线程库(pthreads-based)的程序的同步错误,包括误用了pthreads API、死锁和竞赛条件。它们可以使用在Linux和Mac OS X系统上。

Power by YOZOSOFT

C++ API 设计 12 第七章 性能,布布扣,bubuko.com

C++ API 设计 12 第七章 性能

上一篇:坑(二十一)——DBUtils版本不同导致导包方式不同


下一篇:Ubuntu12.04更新源地址列表