Efficient C++ Performance Programming Techniques
本章引入的实际问题为:定义一个简单的Trace类,将当前函数名输出到日志文件中。Trace对象会带来一定的开销,因此在默认情况下不会开启Trace功能。问题是:怎么设计Trace类,使得在不开启Trace功能时引入的开销最小。
用宏来开关Trace功能很简单,在不开启时开销完全没有:
#ifdef TRACE Trace trace("aaa"); #endif
缺点是每次开关都需要重新编译。
使用状态变量的话有一定的运行时开销,但能保证灵活性,是一种比较合理的选择:
class Trace { public: ... static bool isTraceEnabled; void Debug() { if (isTraceEnabled) { ... } } }
原本的Trace类中内置string成员,这样在不开启Trace时也要承担构造和析构的开销。可以将其改为string*,并在真正需要开启时再创建该成员。
如果Trace的开启时间远小于总时间,则此方法很有效,否则当动态创建的开销大于固定的1次构造和析构的开销时,原方法更好一些。
继承和合成会导致构造函数和析构函数的开销超过你的预期。如何在代码重用性与运行性能间权衡值得关注。
去掉不必要的对象使用,不管是类中的成员变量,还是函数的参数,都会带来不必要的构造和析构开销。
有些基类没有成员变量,也没有提供接口的作用,这种基类就属于无意义的基类,在继承层次中去掉这样的基类可以减少以下几项开销:
如果直接嵌入对象的话,对象A的构造会导致对象B和C的构造,会导致对象B1 B2 C1 C2的构造……会形成一个构造树,层次较多时这个的开销会很巨大。
嵌入指针的话属于延迟创建,在第一次使用时才构造,但会带来new的开销。若经常使用则直接嵌入对象会好一些。
这里写的是将对象的定义和创建尽量延后,直到所有条件都具备了再进行创建。
主要是指不必要的复制:
string s; s = "a"; // 有一次多余的赋值开销,还可能有一次将"a"转换为string临时对象的构造和析构开销
以及不在初始化列表中进行的初始化:
class A { public: A(const string &s) { name = s; //有一次多余的赋值开销 } private: string name; };
利用好虚函数的优点:动态绑定,以及节省代码。尽量避免虚函数带来的开销。
可分为三种:
不继承的话就是将各子类独立出来,缺点是在代码中会充斥大量的switch,非常没有灵活性,排除。
继承的缺点如3.2.1所述,成员函数无法内联,尤其是非常短小使用频繁的函数,会增加大量开销。
使用模板来实现隐式接口:
template <typename LockType> void func(LockType &lock) { lock.Lock(); ... lock.Unlock(); }
实现了一个需要有Lock和Unlock的隐式接口。因为模板是在编译时确定的,因此生成的函数可以内联,同时还省去了指针间接跳转的开销。
缺点是模板导致的编译错误非常难以调试,同时C++不支持这种隐式接口,开发时经常会弄错模板的接口要求。
任何时候只要路过了对象的创建和清除,就会获得性能上的收益。编译器会在可能时去掉一些临时对象的创建和清除,这种优化被称作返回值优化(RVO)。
函数结尾直接返回一个匿名对象往往可以进行RVO:
string Func() { string a = "a"; return a; } string FuncRVO() { return string("a"); }
FuncRVO相比于Func更容易进行RVO,编译器会去掉返回的对象,而将其值直接赋给接收返回值的对象中。
如果类A有“+”操作如下:
const A operator +(const A &x, const A &y) { A z(x); z += y; return z; }
那么在A中增加一个构造函数:
class A { public: A(const A &x, const A &y); // A = x + y; };
则将“+”改为以下形式可获得RVO收益:
const A operator +(const A &x, const A &y) { return A(x, y); }
优点是减少了一个临时对象的构造和析构成本,缺点是要为所有需要进行RVO的操作分别新增一个类似的构造函数,灵活性太差。如果对性能要求特别高,可以考虑这种优化方法。
如何避免产生不必要的临时对象。
在不同类型间的赋值容易无意中导致临时对象的创建。可以通过在单参数构造函数前加explicit来避免这种隐式的转换产生。
如下循环中:
Complex a; for (int i = 0; i < 10; ++i) { a += 1.0; }
其中每次循环都会创建一个值为1.0的Complex对象。可以在循环外创建一个值为1.0的Complex对象,来减少这种开销:
Complex one(1.0); for (int i = 0; i < 10; ++i) { a += one; }
默认的通用内存管理器的性能在特定场景下会造成一定的性能瓶颈。本章讨论的是在单线程环境下,每次分配固定大小和不固定大小的内存时,实现比通用new/delete性能更好的内存池管理器。
Rational *array[1000]; for (int j = 0; j < 500; ++j) { for (int i = 0; i < 1000; ++i) { array[i] = new Rational(i); } for (int i = 0; i < 1000; ++i) { delete array[i]; } }
每次分配Rational大小的内存块,用一个空闲链表维护已分配的空闲内存,在释放时重新将此内存块放回到链表中:
class Rational { ... static list<char *> freeList; void *operator new(size_t size) { if (freeList.empty()) { return new char[sizeof(Rational)]; } else { void *buf = freeList.back(); freeList.pop_back(); return buf; } } void operator delete(void *ptr, size_t size) { freeList.push_back(ptr); } };
此版本的内存池从不收缩,如果需要释放内存,则需要新增一个接口。
此版本的内存池与通用内存管理器相比,收益在于:
不只针对Rational,而是扩展为支持任意固定大小的类:
template <typename T> class FixedSizeMemoryPool { public: FixedSizeMemoryPool(): size_(sizeof(T)) {} ~FixedSizeMemoryPool() { for(char *&p: freeList_) { delete[] p; } } void *Alloc() { if (freeList_.empty()) { return new char[size_]; } else { void *buf = freeList_.back(); freeList_.pop_back(); return buf; } } void Free(void *buf) { freeList_.push_back(buf); } private: list<char *> freeList_; const size_t size_; };
Rational则需要改为:
class Rational { public: void *operator new(size_t size) { return pool.Alloc(); } void operator delete(void *ptr, size_t size) { pool.Free(ptr); } private: static FixedSizeMemoryPool<Rational> pool; };
不定大小的内存池的管理方法与上面的版本不同,因为没有办法直接从链表中返回一个内存块(大小不同)。这里我们在需要时分配一个大的固定大小的内存块,每次分配单个对象的内存时就从这个内存块上分配,空间不够时就分配大的内存块。
随着通用性的增加,性能也在逐渐下降。因此,在非常需要性能时,牺牲一些灵活性通用性也许会有很好的效果。
在单线程的内存池中加入互斥锁,来实现多线程环境下可工作的分配器。在初始化分配器时可以传入锁的参数。
在上一章MutableSizeMemoryPool中增加一个新的模板参数:typename LockType,允许传入一个LockType*,并在Alloc和Free时加锁,其它保持不变。
这一版的分配器的性能并不好,原因是pthread_mutex的性能超出了我们的需要,我们可能只需要一个功能很简单的锁。如果能传入一个更原始版本的互斥锁的话,会有更好的性能。
目前版本的内存池在高并发环境下性能不好,因为对内存块的访问(Alloc和Free)必须要串行化。可以增加多个内存块的列表,并为每个列表单独加锁,这样可以把多个请求分散到不同的列表中同时进行处理。
内联可能会提高性能,但也可能会降低性能。如何避免负面影响,同时利用好正面收益,是本章的关注点。
去除了函数调用的开销。一般的函数调用包括:
另外,还避免了进行跳转带来的处理器空转损失。
使得编译器可以进行跨函数的变量优化:
int Inc(int x) { return x + 1; } void Func() { int y = Inc(1); }
上面的Inc如果内联的话,Func就相当于:
void Func() { int x = 1; int y = x + 1; }
编译器甚至可以进一步优化为:
void Func() { int y = 2; }
通过内联,编译器可以重排大量的方法,从中省略掉大量不必要的语句,甚至包括对象的创建和清除。
通过内联关键路径上的函数,可以在完全不改变程序逻辑的情况下缩短关键路径,从而大幅提高性能。
内联后,函数代码会展开在每个调用点,如果函数代码量和调用点都比较多,则会导致代码体积膨胀。一方面会导致代码载入速度变慢,另一方面会导致更频繁的缺页发生。
但如果函数长度特别短,比整个调用过程还短,那么内联后反倒会减小代码体积,这种函数是一定要内联的。
内联的函数如果修改了代码,所有用到它的地方必须重新进行编译,因为该函数的代码已经在各个调用点展开了。因此大型工程往往在收尾时才进行内联。
本章主要关注内联的第2项收益:在内联函数的代码展开后,编译器针对其进行的各项性能优化。
内联后,调用函数的代码与调用处代码混合,这允许编译器进行很多高级的优化,如同将代码重新组织了一样。尤其是针对直接量进行的优化:
int Choice(int x) { switch (x) { case 1: return 5; break; case 2: ... ... case 100: return 301; break; default: return 0; break; } } int x = Choice(100);
在内联优化后,上面的代码可能只剩下:
int x = 301;
内联的主要缺点就是可能会增大代码体积。尤其是当相对庞大的方法被多层内联时会出现体积指数级膨胀的问题。
缺点2是每次修改需要全部重新编译。
缺点3是很难对内联函数进行调试,因为实际的函数已经没有了,无法追踪到函数的入口和出口。
在内联前应该统计各个函数的编译后体积和调用次数、调用点等信息,通过这些配置信息来决定对哪些函数进行内联。
可以将函数的尺寸分为:
对静态尺寸较小而动态尺寸较大的函数,内联会有很大的收益。对于只有一个调用点的函数,如循环内的调用,内联几乎总是对的。而对于调用点和调用次数都很多的函数,最好重写以展示出其快速路径,再进行内联。
某函数如下:
void FuncX { if (/* error handle code */) { ... // 30 lines } ... // real work (5 lines) }
FuncX有大约40行代码,表面上看不适于内联,但如果将它的错误处理代码拆成一个单独的函数:
void FuncX { if (...) FuncY(); ... // real work (5 lines) } void FuncY { ... // error handle code (30 lines) }
此时FuncX只有7行代码,很适合内联了。这也相当于将静态尺寸大而动态尺寸小的代码段拆出去,从而让剩余的静态尺寸小动态尺寸大的代码可以进行内联。
一些可帮助你更好的内联的技巧。
如果想用一个预编译选项来控制某些函数何时内联,何时关闭内联,可以使用条件内联的技巧。
将内联函数的定义放到.inl中,其它函数的定义放到.cpp中,然后在.h中加入:
#ifdef INLINE #include "*.inl" #endif
在.inl中加入:
#ifndef INLINE #define inline // let inline be void #endif inline FuncX(...){}
在.cpp中加入:
#ifndef INLINE #include "*.inl" #endif
可以将某函数在一些调用点处内联,而在其它调用点处不内联。具体内容不是很喜欢,略过。
尾递归的函数可以改成迭代函数,再寻找内联方法。
非尾递归的函数如果非常在意性能,可以将函数进行一定的展开:
void RecursiveInline() { ... Recursive(); ... } void Recursive() { ... RecursiveInline(); ... }
将前一个函数内联,这样会加快运行速度,但也会明显增加编译后体积。
也可以手动展开,或是用宏来维护,但很容易出问题。
有些体系结构下(如SPARC),函数调用的开销会在调用层次较少时非常的低,此时再内联那些非微小的函数的收益就很不明显了。因此,任何对非微小函数的内联都要建立在了解配置信息的基础上。
要比STL更好的话,往往要牺牲一定的通用性和灵活性,从一些特定的环境因素着手进行优化。
C++使用了引用计数来解决垃圾回收问题,基本思想是把对象清除的责任从客户端代码转移给对象本身。
引用计数可以减少内存使用、避免内存泄漏,但在执行速度方面却可能会有坏处,尤其是在多线程环境中。
实现A:类内置引用计数。类RefCountBase封装和引用计数相关的操作,需要实现引用计数的类继承它:
class RefCountBase { public: Attach() { ++refCount_; } Detach() { if (--refCount_ == 0) { delete this; } } protected: RefCountBase(): refCount_(0) {} RefCountBase(const RefCountBase &rc): refCount_(0) {} RefCountBase &operator=(const RefCountBase &rc) { return *this; } virtual ~RefCountBase() {} size_t refCount_; };
如类A继承自RefCountBase,为了实现引用计数,还需要一个代理类SmartPtr充当A的智能指针:
template <typename T> class SmartPtr { public: SmartPtr(T *ptr = nullptr): ptr_(ptr) {} SmartPtr(const SmartPtr &sptr): ptr_(sptr.ptr_) { if (ptr_) { ptr_->Attach(); } } SmartPtr &operator=(const SmartPtr &sptr) { if (sptr.ptr_) { sptr.ptr_->Attach(); } if (ptr_) ptr_->Detach(); ptr_ = sptr.ptr_; return *this; } T *operator->() { return ptr_; } T &operator*() ( return *ptr_; ) private: T *ptr_; };
实现B:将计数功能放入SmartPtr中。去掉RefCountBase,而是在SmartPtr中增加一个size_t *count_,对ptr_的Attach操作变为++*count_,而Detach操作则变为--*count_。其它相同。
SmartPtr中需要同时对count_和ptr_进行操作,在并发环境下这就意味着需要在操作前后加锁,来保证对两个对象的原子操作。
实现A中需要对原类进行修改,如果不能进行这种修改,则只能使用实现B。实现B中因为需要操作两个堆上的成员(count_和ptr_),创建和清除性能会比实现A差一些。
引用计数的收益是:
引用计数的坏处:
下列条件会增加引用计数的收益:
应用程序编码阶段会引入很多的性能问题,这类问题通常是小范围的问题,解决它们不需要看太多的代码,也不需要改变深层次的设计,但有可能会带来比较明显的性能提升。
记住频繁计算和计算代价高的计算结果。比较典型的是将在循环中需要反复计算的固定结果保存在循环外的一个变量中,并在循环中使用这个变量。
可以将一些在关键路径上需要频繁用到的计算结果提前进行计算,将结果保存起来,这样真正使用时只需要简单的查找就可以了。
如果目标代码使用的范围很固定,那么就不需要在代码中考虑太多的通用情况,而是可以针对目前已知的一些特定情况进行大胆的假设,从而加快运行速度。
80%的时间消耗在20%的函数调用上,因此尽量降低这20%的函数需要的时间就能大大提高整个系统的性能。
相似的例子出现在if (and1 && and2)以及if (or1 || or2)中,如果两个条件没有依赖关系,那么就将更有可能决定整个关系式值的条件放在前面,即如果and1比and2更容易为false,那就将and1放在前面,而如果or1比or2更容易为false,就将or2产在前面。
除了条件值的可能性外,还可以将每个操作的指令数也考虑进去,则可以令整个条件式指令数最小的条件放在前面。
而如果所有外部参数中有5%是特殊的,其它95%是类似的,那么我们可以单独为这5%的特殊参数设计一个路径,从而加快95%的常见情况的处理速度。
将计算延迟到真正需要的时候,从而避免昂贵的计算结果最后没被使用。这节没什么新东西。
这节没什么新东西。
在设计对象布局时考虑到体系结构的影响,主要是系统缓存带来的影响。如矩阵的行长度如果恰好和缓存行长度相等,那么在进行矩阵转置时会出现频繁的缓存未命中。而在设计经常需要一起访问的两个成员时,最好让它们可以处于同一缓存行中,这也需要让先被访问的成员放在前面。
性能是一种交易。没什么新东西。
很多性能细节都隐藏在库和系统调用的背后,因此在设计时要详细了解这些细节,并在多个可用的工具中选择功能刚刚好够用的那个,它的性能往往也要比那些功能更加完善强大的版本好一些。
在release时开启编译器优化,可能会有很大的性能提高。
设计上的优化是全局的,依赖于其它组件和代码。
在软件开发的早期,如果不了解程序的热点,那么就全面使用STL好了。当对程序的运行有一定了解后,可以用一些灵活性去换取性能。
web服务中每次请求都需要写入日志,并带有一个时间戳。如果单次请求需要多次写入,那么可以将计算出来的时间戳缓存起来供所有这些日志写使用。
如果对象经常需返回某个操作的值,那么可以将这个值内嵌在对象中,如各种容器的size等。
如果某段代码每次都需要判断请求的类型来决定运行路径,那么可以将它拆成两段代码分别做单一的操作。这是虚函数很擅长的领域。
没什么新东西。
没什么新东西。
并行或并发环境下的性能问题。
SMP体系的一个性能瓶颈是多个处理器需要共享与内存间的总线。
解决方案是每个处理器配一个大的缓存,但带来的主要问题是缓存一致性问题。
以上两个问题导致了实际的并行性能提升难以达到核心数量提升的倍数。
顺序计算是通往可伸缩性道路上的主要障碍。单独加速某一段带来的提升不会大于这段所占的总开销比例。
把单一的任务分解为多个并发子任务可以提高以下指标:
I/O密集型任务更适合并发执行。
例子:某线程服务于某请求,在线程生命期内,线程的许多操作都要作用于该请求之上。一种思路是在每个操作处调用pthread_getspecific,但这会带来严重的锁开销。另一种思路就是在线程开始时获得一次指针,并传给随后的所有函数。
上例中,更好的方法是直接将线程相关的东西放到与线程关联的结构中,这样可以完全地去掉需要串行化的部分。
在不知道请求数量的时候,可以用固定大小的线程池来进行服务,这样有着很好的伸缩性。
通常,把多个无关的资源融合到单个锁的保护之下不是个好主意。例外是满足以下两个条件的情况:
锁的粒度太粗会导致并行性下降,而粒度太细又会导致锁的开销增加、以及单个任务的处理时间增长。
SMP系统上,两个锁如果处于同一缓存行中,那么p1对m1的锁操作会导致整个缓存行在p2上失效,从而导致p2访问m2时要重新读内存。避免这个问题的方法是手动在m2和m2间插入一定的空白。
如果用多个线程accept,比如100个,那么在来连接请求时,100个线程都会醒来,但只有1个线程能获得请求,其它99个线程转而继续睡眠。这种CPU冲击会导致服务器萎缩并严重损害吞吐量。当吞吐量下降时,系统可能会增加更多的线程,从而导致问题更加严重。
解决问题的方法是只用一个线程accept再将请求分发给其它线程。
没什么新东西。
本章讨论的东西只简单的罗列如下: