WHAT
性能优化是一项编码活动,它与BUG不同,性能是连续变量,而BUG只有存在和不存在的状态。性能可以是非常糟糕或非常优秀,也可能是介于两者之间的某种程度。优化相对特性开发需要对业务及实现都更加深入的理解。
性能优化主要为了改善逻辑处理的速度、提升吞吐量、优化内存及能耗的需求。由于性能优化是一个连续变量,故首先要确定优化目标。这个目标即我们优化的出发点,它可以是处理速度提升30%或吞吐量增加一倍等。
WHEN
软件开发人员在实际开发过程中由于对程序应用环境、负荷、场景理解的差异,很难理解单个编码决策对程序整体性能的影响,故常常写出的代码有很大的性能提升空间。这是否意味着在项目初期就进行性能优化呢?《计算机编程的艺术》作者图灵奖获得者Donald Knuth说过:程序员们浪费了大量时间来思考,或者说是担忧,他们的程序中非关键部分的运行速度。并且他们对于性能的这些尝试,实际上却对代码的调试和维护有着非常消极的影响。我们应当忘记那些不重要的性能影响,在97%的时间里都可以这么说:过早优化乃万恶之源。当然我们也不应当在那关键的3%上放弃我们的机会。在实际开发中切忌不要过早优化。但是学习高效编程并在项目开发中运用是值得推崇和鼓励的。
HOW
前提
性能测量报告是所有改善程序性能尝试的基础。程序热点是指程序中最耗时的部分,一般程序优化工作都是优先去优化热点部分,那么如何来定位程序热点呢?如果只是以程序员的主观判断往往不准确,对非程序热点的优化往往收效甚微。性能分析常用的工具有gprof和valgrind的callgrind。两者产生的报告类型不太一样,但是都可以看出耗时的函数信息,这样可以更准确的抓住程序热点进行优化。即使是经验丰富的团队在时间充裕的情况下编写出的代码,运行速度也可以通过优化得到大幅提高,不过通过微调代码让程序的运行速度提升10倍几乎是不可能的。此时选择一种更好的算法或是数据结构才是正道。
稳定统一的测试用例保证也是性能优化的基础和前提,性能优化自然属于重构,有充分的测试用例和完善的自动化测试保证对于性能优化大有裨益,这对于评判优化方案的可行性及代码的正确性都有决定性的作用。
迭代开发
在性能调优前必须要有正确的代码,性能调优是一种实验科学,往往迭代进行,在每次优化方案实施完毕后需要对程序的优化前后的性能进行对比来验证优化方案的可行性。当然有些优化方案被证明是可行的,但是同时也会存在优化方案实施后性能不变或变差的可能性,此时优化方案就被推翻。
在性能优化过程中做好记录是非常必要的,笔者最近在做性能优化过程中,提出了很多的优化方案,有些优化方案最初被证明是效率变差啦,但是它可以进一步的再优化,而最初的性能优化方案虽然比最初版本性能要好,但是没有进一步优化空间。所以在性能优化过程中,将脑海中出现的对于程序热点的优化思路记录下来是非常必要的。同时对于每个优化方案都要详细记录优化前后的效率差异,这样便于横向对比优化方案。
C++代码优化策略
编译器优化
C++11引入了右值引用和移动语义,这可以省去以往版本中无法避免的复制操作,同时可以通过打开编译器的优化选项来优化代码效率。最简单直接的方式就是打开O3优化,笔者在某次性能优化过程中发现打开O3优化后,效率直接提升6倍。
使用效率更高的数据结构及算法
根据数据集的特征选择更高的数据结构及算法是常用的优化策略,这就要求开发者对常用的数据结构及算法的空间复杂度及时间复杂度有清晰的认识。以排序为例:
排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|---|---|
冒泡排序 | 是 | |||
选择排序 | 否 | |||
插入排序 | 是 | |||
归并排序 | 是 | |||
快速排序 | 否 | |||
堆排序 | ) | 否 | ||
希尔排序 | 否 | |||
计数排序 | 是 | |||
基数排序 | 是 |
STL容器库在实际的开发中会被大量使用,因此对其底层数据结构清晰的认知非常重要。
STL | 底层数据结构 |
---|---|
vector | 数组 |
list | 双向链表 |
forward_list | 单向链表 |
map | 红黑树 |
multimap | 红黑树 |
unordered_map | HASH表 |
unordered_multimap | HASH表 |
set | 红黑树 |
multiset | 红黑树 |
unordered_set | HASH表 |
unordered_multiset | HASH表 |
priority_queue | 最小堆 |
deque | 中央控制器和多个缓冲区 |
stack | deque |
queue | deque |
减少内存分配
绝大多数C++语言特性的性能开销最多只是几个指令,但是每次调用内存管理器的开销却是数千个指令。因此在可以使用静态数据时,应尽量使用静态数据避免进行内存分配造成的性能问题。
考虑并发处理
现代计算机都可以使用多个处理核心来执行指令。如果一项工作被分给几个处理器执行,那么它可以更快地执行完毕。并发处理时可考虑线程池ThreadPool和OpenMP.
C++代码优化实操
使用效率更高的数据结构及算法
在性能提升时如果只是对代码细节的优化,性能提升一般不会提升10倍以上。如果当前性能与期望值的差异较大时,需要思考效率更高的数据结构及算法的使用。以查询为例,如果使用vector查询,则时间复杂度为。如果使用map查询时,时间复杂度为,而如果设计良好的unordered_map时间复杂度只有常数。如果数据量较大时,三者的差异非常大。另一方面,以数据排序为例,快速排序在某些输入数据情况下会出现效率变差的情况,此时可以考虑使用归并排序替代,因为归并排序的时间复杂度没有最坏的情况,都是 。因此算法与数据结构在当前性能与性能目标值差异非常大时,应该是首先考虑的。在实际项目开发中,对于算法时间复杂度为、 、一般都需要进行优化,因为在数据量大的情况下其效率恶化非常严重。一般算法复杂度在 或 是较好的算法。
string与STL容器
string与STL容器在日常开发中提升了开发效率,但其也造成了很多事情默默在程序员背后发生。以vector容器为例,它是一个内存连续的数组,向其插入数据时,如果超过它的容量,它就会申请原来内存的2倍的新的连续内存,将以前的数据拷贝过来。而string也是类似,同时字符串会进行大量复制造成效率的降低。标准库中的类是为通用用途而实现的,它们并不需要特别高效,也没有为某些特殊用途而进行优化,所以在特定场景下,可能需要自己编写适用于项目应用场景的容器与字符串类。
优先使用扁平数据结构
扁平数据结构是指一个数据结构中的元素被存储在连续的内存空间中。扁平数据结构相对于通过指针连接在一起的数据结构可以更好的利用CPU的缓存,具有显著的性能优势。计算机CPU高速缓存是用于减少处理器访问内存所需平均时间的部件。在金字塔式存储体系中它位于自顶向下的第二层,仅次于CPU寄存器。其容量远小于内存,但速度却可以接近处理器的频率。当处理器发出内存访问请求时,会先查看缓存内是否有请求数据。如果存在(cache命中),则不经访问内存直接返回该数据;如果不存在(cache miss),则要先把内存中的相应数据载入缓存,再将其返回处理器。一个性能出色的项目会尽量提升cache命中率来提升效率。
循环优化
- 对于频繁调用的函数考虑使用宏定义替换函数,C++引入inline进行优化,但是有时函数体较长时inline不起作用,所以可以考虑对频繁调用的函数改写为宏定义
- 对一个循环中多个无相关性的处理拆分成多个循环,这样更好的提高cache命中率,在特定场景下可以显著提升效率
- 将一些变量缓存在循环外(如缓存循环结束条件值),使用指针偏移处理也是一种常规手段
- 从循环中移除不变性代码
- 减少循环体内的跳转,尽量让流程顺序化
- 使用更高效的运算符,如使用<<代替*
实现move语义
C++11标准引入前,C++程序存在大量的复制操作,这些复制操作会大大降低程序的运行效率。C++11标准库引入了“move”语义。move它比复制更加高效,对于一些较大的类实现移动赋值是非常有必要的。为了实现移动语义,C++ 编译器需要能够识别一个变量在什么时候是临时值或将亡值。C++ 的类型系统被扩展了,它能够从函数调用上的左值中识别出右值。如果T是一个类型,那么声明T&&就是指向T 的右值引用——也就是说,一个指向类型T 的右值的引用。函数重载的解析规则也被扩展了,这样当右值是一个实参时,优先右值引用重载;而当左值是实参时,则需要左值引用重载。
优化并发
- 匹配设备核心数与可执行线程数量
性能优化人员要区别出具有不同行为的两种线程:连续计算的线程和可等待线程。对于连续计算的线程来说,一个线程会消耗它占用CPU的100%的计算资源,对于这种线程来说,增加超过核心数的线程数不但起不到任何的正向作用,反而会增加线程切换带来的性能损耗。而对于可等待线程,其只会消耗一个核心的部分计算资源,这种情况下增加线程数,让CPU可以交叉运行不同的可等待线程是可行的,但是此时也不是越多越好。 - 考虑使用线程池和任务队列
使用线程池和任务队列编程时,线程从任务队列中取任务并执行,当执行完毕后,线程并不终止,而是继续从任务队列中获取任务,如果任务队列空,则线程挂起,等待新任务的到来。这种方式可以提高处理器的利用率,并且可以消除为短周期任务启动线程的开销。 - 减少线程间的同步
同步和互斥会降低多线程程序的速度。减少同步可以提升程序性能。可以考虑使用面向事件编程和消息传递的方式减少同步。 - 让同步更加高效
高效同步实践过程中结合具体问题有很多的方法,如:尽量减小临界区的范围、保存资源副本减少竞争、分隔和细化资源(Java中的ConcurrentHashMap思想)、无锁数据结构等等。
在性能提升工作中,良好的程序热点分析及测试数据记录是基础,编程优化是手段,性能提升是目标。编程优化只是其中一环,只有各环节协调配合才能最终达到效率的提升。
#ifndef STOPWATCH_H_
#define STOPWATCH_H_
#include <chrono>
#include <iostream>
class Stopwatch {
public:
explicit Stopwatch(bool start) :
Stopwatch(std::cout, "Stopwatch", start) {
}
explicit Stopwatch(char const* process = "Stopwatch", bool start = true) :
Stopwatch(std::cout, process, start) {
}
Stopwatch(std::ostream& log, char const* process = "Stopwatch", bool start =
true) :
log_(log), process_(process) {
start_ =
start ? std::chrono::system_clock::now() : std::chrono::system_clock::time_point::min();
}
~Stopwatch() {
log_ << process_ << ": " << GetMs() << " ms" << std::endl << std::flush;
}
void Start() {
start_ = std::chrono::system_clock::now();
}
void Show(char const* event = "show") {
log_ << process_ << ": " << event << " run " << GetMs() << " ms"
<< std::endl << std::flush;
Start();
}
private:
unsigned long GetMs() {
if (IsStarted()) {
std::chrono::system_clock::duration diff;
diff = std::chrono::system_clock::now() - start_;
return (unsigned long) (std::chrono::duration_cast<
std::chrono::milliseconds>(diff).count());
}
return 0;
}
bool IsStarted() const {
return (start_ != std::chrono::system_clock::time_point::min());
}
std::ostream& log_;
char const* process_;
std::chrono::system_clock::time_point start_;
};
#endif /* STOPWATCH_H_ */
{
Stopwatch sw("test1");
Sleep(15);
}
{
Stopwatch sw("test2");
Sleep(15);
sw.Show("task_sleep");
Sleep(25);
}
WalkeR_ZG
网友评论