漫谈C++性能优化

WHAT

性能优化是一项编码活动,它与BUG不同,性能是连续变量,而BUG只有存在和不存在的状态。性能可以是非常糟糕或非常优秀,也可能是介于两者之间的某种程度。优化相对特性开发需要对业务及实现都更加深入的理解。
性能优化主要为了改善逻辑处理的速度、提升吞吐量、优化内存及能耗的需求。由于性能优化是一个连续变量,故首先要确定优化目标。这个目标即我们优化的出发点,它可以是处理速度提升30%或吞吐量增加一倍等。

WHEN

软件开发人员在实际开发过程中由于对程序应用环境、负荷、场景理解的差异,很难理解单个编码决策对程序整体性能的影响,故常常写出的代码有很大的性能提升空间。这是否意味着在项目初期就进行性能优化呢?《计算机编程的艺术》作者图灵奖获得者Donald Knuth说过:程序员们浪费了大量时间来思考,或者说是担忧,他们的程序中非关键部分的运行速度。并且他们对于性能的这些尝试,实际上却对代码的调试和维护有着非常消极的影响。我们应当忘记那些不重要的性能影响,在97%的时间里都可以这么说:过早优化乃万恶之源。当然我们也不应当在那关键的3%上放弃我们的机会。在实际开发中切忌不要过早优化。但是学习高效编程并在项目开发中运用是值得推崇和鼓励的。

HOW

前提

性能测量报告是所有改善程序性能尝试的基础。程序热点是指程序中最耗时的部分,一般程序优化工作都是优先去优化热点部分,那么如何来定位程序热点呢?如果只是以程序员的主观判断往往不准确,对非程序热点的优化往往收效甚微。性能分析常用的工具有gprof和valgrind的callgrind。两者产生的报告类型不太一样,但是都可以看出耗时的函数信息,这样可以更准确的抓住程序热点进行优化。即使是经验丰富的团队在时间充裕的情况下编写出的代码,运行速度也可以通过优化得到大幅提高,不过通过微调代码让程序的运行速度提升10倍几乎是不可能的。此时选择一种更好的算法或是数据结构才是正道。
稳定统一的测试用例保证也是性能优化的基础和前提,性能优化自然属于重构,有充分的测试用例和完善的自动化测试保证对于性能优化大有裨益,这对于评判优化方案的可行性及代码的正确性都有决定性的作用。

迭代开发

在性能调优前必须要有正确的代码,性能调优是一种实验科学,往往迭代进行,在每次优化方案实施完毕后需要对程序的优化前后的性能进行对比来验证优化方案的可行性。当然有些优化方案被证明是可行的,但是同时也会存在优化方案实施后性能不变或变差的可能性,此时优化方案就被推翻。
在性能优化过程中做好记录是非常必要的,笔者最近在做性能优化过程中,提出了很多的优化方案,有些优化方案最初被证明是效率变差啦,但是它可以进一步的再优化,而最初的性能优化方案虽然比最初版本性能要好,但是没有进一步优化空间。所以在性能优化过程中,将脑海中出现的对于程序热点的优化思路记录下来是非常必要的。同时对于每个优化方案都要详细记录优化前后的效率差异,这样便于横向对比优化方案。

C++代码优化策略

编译器优化

C++11引入了右值引用和移动语义,这可以省去以往版本中无法避免的复制操作,同时可以通过打开编译器的优化选项来优化代码效率。最简单直接的方式就是打开O3优化,笔者在某次性能优化过程中发现打开O3优化后,效率直接提升6倍。

使用效率更高的数据结构及算法

根据数据集的特征选择更高的数据结构及算法是常用的优化策略,这就要求开发者对常用的数据结构及算法的空间复杂度及时间复杂度有清晰的认识。以排序为例:

排序算法 平均时间复杂度 最坏时间复杂度 空间复杂度 是否稳定
冒泡排序 O(n^2) O(n^2) O(1)
选择排序 O(n^2) O(n^2) O(1)
插入排序 O(n^2) O(n^2) O(1)
归并排序 O(nlogn) O(nlogn) O(n)
快速排序 O(nlogn) O(n^2) O(logn)
堆排序 O(nlogn)) O(nlogn) O(1)
希尔排序 O(nlogn) O(ns) O(1)
计数排序 O(n+k) O(n+k) O(n+k)
基数排序 O(N*M) O(N*M) O(M)

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++语言特性的性能开销最多只是几个指令,但是每次调用内存管理器的开销却是数千个指令。因此在可以使用静态数据时,应尽量使用静态数据避免进行内存分配造成的性能问题。

考虑并发处理

现代计算机都可以使用多个处理核心来执行指令。如果一项工作被分给几个处理器执行,那么它可以更快地执行完毕。并发处理时可考虑线程池ThreadPoolOpenMP.

C++代码优化实操

使用效率更高的数据结构及算法

在性能提升时如果只是对代码细节的优化,性能提升一般不会提升10倍以上。如果当前性能与期望值的差异较大时,需要思考效率更高的数据结构及算法的使用。以查询为例,如果使用vector查询,则时间复杂度为O(n)。如果使用map查询时,时间复杂度为O(logn),而如果设计良好的unordered_map时间复杂度只有常数。如果数据量较大时,三者的差异非常大。另一方面,以数据排序为例,快速排序在某些输入数据情况下会出现效率变差的情况,此时可以考虑使用归并排序替代,因为归并排序的时间复杂度没有最坏的情况,都是 O(nlogn)。因此算法与数据结构在当前性能与性能目标值差异非常大时,应该是首先考虑的。在实际项目开发中,对于算法时间复杂度为O(n^2)O(2^n)O(n!)一般都需要进行优化,因为在数据量大的情况下其效率恶化非常严重。一般算法复杂度在O(nlogn)O(n)是较好的算法。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,373评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,732评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,163评论 0 238
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,700评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,036评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,425评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,737评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,421评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,141评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,398评论 2 243
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,908评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,276评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,907评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,018评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,772评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,448评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,325评论 2 261

推荐阅读更多精彩内容