导语 | 每个C++程序员好像都是人形编译器,不止要看懂代码外表的逻辑,甚至要晓得每行代码对应的汇编指令。优化代码也成了C++工程师日常必备,正所谓“一杯茶,一包烟,一段代码,优化一天”。在经验过无数个性能优化的日夜后,笔者也总结了几个中过招的性能陷阱,与你分享~
本文介绍的性能陷阱次要分为两大类:“有老本形象”和“与编译器作对”。前者是指在应用C++的性能/库时须要留神的隐形老本,后者则是一些C++老手可能会写出不利于编译器优化的代码。另外本文的程序是由根底到进阶,读者能够依据须要间接跳到本人想看的局部。
有老本形象
C++“信徒”们经常宣扬C++的“零老本形象(Zero Cost Abstraction)”。然而对于“零老本形象”这个概念存在很多误会。比方有的老手会认为:“应用C++的任何个性都没有老本”。那显然是大错特错的,比方应用模版就会导致编译工夫变慢的编译期老本,而且我花了21天工夫精通C++的工夫老本也是老本啊(狗头)。有些教训的C++程序员会解释为”应用C++的任何个性都没有运行时老本“,这也是对C++最常见的误会。C++的创始人Bjarne Stroustrup是这样解释“零老本形象”的:
- 你不会为任何你没有应用的个性付出任何老本。
- 对于你应用的个性,你只会付出最小运行时老本。
简略来说,就是C++不会背着你偷偷干坏事(比方垃圾回收),而你指定C++干的活,C++会尽量在编译期做,保障在运行期只会做“起码”的工作。连小学生都应该晓得,“起码”并不等于“零”,所以“零老本形象”其实是一种谎话,Google的C++负责人Chandler Carruth就已经在CppCon 2019说过:C++基本不存在”零老本形象“。
(链接:https://www.youtube.com/watch...)
显然,C++的很多个性是有性能老本的,而且,这些老本往往呈现在你“没有写”的代码里,即C++帮你增加的隐形代码。作为C++工程师,咱们就必须理解每个个性会带来的性能损耗,在做代码设计时尽量抉择损耗小的个性。
而下文介绍的很多坑点,C++语言服务器clangd能够帮你实时检测进去并主动修复。
(一)虚函数
陈词滥调的性能损耗,这里只介绍一下虚函数调用带来的老本:
- 会多一次寻址操作,去虚函数表查问函数的地址。
- 可能会毁坏cpu流水线,因为虚函数调用是一次间接调用,须要进行分支预测。
- 妨碍了编译器内联,大部分状况下,虚函数是无奈被内联的(与前两条相比,无奈内联才是虚函数性能损耗的次要起源)。
然而在理论生产环境中,可能很多的运行时多态是无奈防止的,毕竟这是OOP的根底个性,因而对于虚函数咱们也只能理解背地的老本而已。某些状况下咱们能够应用编译期多态来代替虚函数,比方CRTP(Curiously Recurring Template Pattern)、Tempated Visitor Pattern、Policy Based Design等等,我的下一篇文章《C++独有的设计模式》中会介绍这些技巧,敬请期待。
(二)隐形的拷贝
也是一个陈词滥调的性能损耗,这里次要介绍几个容易被忽略的场景:
Member Initialization构造函数
class C { public: C(A a, B b): a_(a), b_(b){} private: A a_; B b_;}int main() { A a; B b; C c(a, b);}
如果A、B是非平庸的类,会各被复制两次,在传入构造函数时一次,在结构时一次。C的构造函数该当改为:
C(A a, B b): a_(std::move(a)), b_(std::move(b)){}
For循环
std::vector<std::string> vec;for(std::string s: vec){ // ...}
这里每个string会被复制一次,改为for(const std::string& s: vec)即可。
Lambda捕捉
A a;auto f = [a]{};
lambda函数在值捕捉时会将被捕捉的对象拷贝一次,能够依据需要思考应用援用捕捉auto f= [&a]{};或者用std::move捕捉初始化auto f= [a = std::move(a)]{};(仅限C++14当前)。
隐式类型转换
std::unordered_map<int, std::string> map;for(const std::pair<int, std::string>& p: map){ // ...}
这是一个很容易被忽视的坑点,这段代码用了const援用,然而因为类型错了,所以还是会产生拷贝,因为unordered\_map element的类型是std::pair<const int, std::string>,所以在遍历时,举荐应用const auto&,对于map类型,也能够应用结构化绑定。
(三)隐形的析构
在C++代码中,咱们简直不会被动去调用类的析构函数,都是靠实例来到作用域后主动析构。而“隐形”的析构调用,也会导致咱们的程序运行变慢:
简单类型的析构
咱们的业务代码中有这样一种接口
int Process(const Req& req, Resp* resp) { Context ctx = BuildContext(req); // 非常复杂的类型 int ret = Compute(ctx, req, resp); // 次要的业务逻辑 PrintTime(); return ret; }int Api(const Req& req, Resp* resp) { int ret = Process(req, resp); PrintTime();}
在日志中,Process函数内打印的工夫和PrintTime打印的工夫居然差了20毫秒,而咱们过后接口的总耗时也不过几十毫秒,我过后百思不得其解,还是靠我老板tomtang一语道破先机,原来是析构Context足足花了20ms。前面咱们实现了Context的池化,间接将接口耗时降了20%。
平庸析构类型
如何定义类的析构函数也大有考究,看下上面这段代码:
class A { public: int i; int j; ~A() {};};A get() { return A{42};}
get函数对应的汇编代码是:
get(): # @get() movq %rdi, %rax movabsq $180388626473, %rcx # imm = 0x2A00000029 movq %rcx, (%rdi) retq
而如果我能把析构函数改一下:
class A { public: int i; int j; ~A() = default; // 留神这里};A get() { return A{41, 42};}
对应的汇编代码则变成了:
get(): # @get() movabsq $180388626473, %rax # imm = 0x2A00000029 retq
前者多了两次赋值,也多用了两个寄存器,起因是前者给类定义了一个自定义的析构函数(尽管啥也不干),会导致类为不可平庸析构类型(std::is\_trivially\_destructible)和不可平庸复制类型(std::is\_trivially\_copyable),依据C++的函数调用ABI标准,不能被间接放在返回的寄存器中(%rax),只能间接赋值。除此之外,不可平庸复制类型也不能作为编译器常量进行编译器运算。所以,如果你的类是平庸的(只有数值和数字,不波及堆内存调配),千万不要顺手加上析构函数!
对于非平庸析构类型造成的性能损耗,后文还会屡次提到。
(四)滥用std::shared\_ptr
C++外围指南是这样举荐智能指针的用法的:
- 用 std::unique\_ptr或 std::shared\_ptr表白资源的所有权。
- 不波及所有权时,用裸指针。
- 尽量应用std::unique\_ptr,只有当资源须要被共享所有权时,再用std::shared\_ptr。
然而在理论代码中,用std::shared\_ptr的场景大略就是以下几种:
- 小局部是因为代码作者是写python或者java来的,不会写没有gc的代码(比方apache arrow我的项目,所有数据全用std::shared\_ptr,像是被apache的Java环境给荼毒了)。
- 绝大部分是因为代码作者是会写C++的,然而太懒了,不想梳理内存资源模型。不得不说,std::shared\_ptr的确是懒人的福音,既保证了资源的平安,又不必梳理资源的所有权模型。
- 很小一部分是因为的确须要应用std::shared\_ptr的场景(不到10%)。我能想到的必须用std::shared\_ptr的场景有:异步析构,缓存。除此之外想不出任何必须的场景,欢送小伙伴们在评论区补充。
实际上,std::shared\_ptr的结构、复制和析构都是十分重的操作,因为波及到原子操作,std::shared\_ptr是要比裸指针和std::unique\_ptr慢10%~20%的。即应用了std::shared\_ptr也要应用std::move和援用等等,尽量避免拷贝。
std::shared\_ptr还有个陷阱是肯定要应用std::make\_shared<T>()而不是std::shared\_ptr<T>(new T)来结构,因为后者会调配两次内存,且原子计数和数据自身的内存是不挨着的,不利于cpu缓存。
(五)类型擦除:std::function和std::any
std::function,顾名思义,能够封装任何可被调用的对象,包含惯例函数、类的成员函数、有operator()定义的类、lambda函数等等,当咱们须要存储函数时std::function十分好用,然而std::function是有老本的:
- std::function要占用32个字节,而函数指针只须要8个字节。
- std::function实质上是一个虚函数调用,因而虚函数的问题std::function都有,比方无奈内联。
- std::function可能波及堆内存调配,比方lambda捕捉了大量值时,用std::function封装会须要在堆上分配内存。
因而咱们只应在必须时才应用std::function,比方须要存储一个不确定类型的函数。而在只须要多态调用的,齐全能够用模版动态派发:
template <typename Func>void Run(Func&& f){ f();}
std::any同理,用类型擦除的机制能够存储任何类型,然而也不举荐应用。
(六)std::variant和std::optional
我在我的另一篇文章大肆吹捧了一波std::variant和std::optional,然而说实话,C++的实现还是有些性能开销的,这里以std::optional为例介绍:
- 必须的多余内存开销:简略来说,std::optional有两个成员变量,类型别离为bool和T,因为内存对齐的起因,sizeof(std::optional)会是sizeof(T)的两倍。相比之下,rust语言的option实现则有null pointer optimization,即如果一个类的非法内存示意肯定不会全副字节为零,比方std::reference\_wrapper,那就能够零开销地示意std::optional,而C++因为须要兼容C的内存对齐,不可能实现这项优化。
- c++规范要求如果T是可平庸析构的(见上文析构的局部),std::optional也必须是平庸析构的,然而gcc在8.0.0之前的实现是有bug的,所有std::optional都被设置为了非平庸类型,所以用std::optional作为工厂函数的返回值是由额定性能开销的。
- 对NRVO(返回值优化)不敌对,见下文NRVO的局部。
(七)std::async
std::async是一个很好用的异步执行形象,然而在应用的时候可能一不小心,你的代码就变成了同步调用:
不指定policy
std::async的接口是:
template< class Function, class... Args >std::future<std::invoke_result_t<std::decay_t<Function>, std::decay_t<Args>...>> async( Function&& f, Args&&... args );template< class Function, class... Args >std::future<std::invoke_result_t<std::decay_t<Function>, std::decay_t<Args>...>> async( std::launch policy, Function&& f, Args&&... args );
其中std::launch类型包含两种:std::launch::async异步执行和std::launch::deferred懈怠执行,如果你应用第一种接口不指定policy,那么编译器可能会本人帮你抉择懈怠执行,也就是在调用future.get()的时候才同步执行。
不保留返回值
这是c++的std::async的一个大坑点,非常容易踩坑,比方这段代码:
void func1() { // ... }void func2() { // ... }int main() { std::async([std::launch::async](https://en.cppreference.com/w/cpp/thread/launch), func1); std::async([std::launch::async](https://en.cppreference.com/w/cpp/thread/launch), func2);}
在这段代码里,func1和func2其实是串行的!因为std::async会返回一个std::future,而这个std::future在析构时,会同步期待函数返回后果才析构完结。这也是上文“隐形的析构”的另外一种体现。正确的代码该当长这样:
更奇葩的是,只有std::async返回的std::future在析构时会同步期待,std::packaged\_task,std::promise结构的std::future都不会同步期待,切实是让人有力吐槽。
对于std::async等等C++多线程工具,在我之后的文章《古代C++并发编程指南》会介绍,敬请期待。
与编译器作对
家喻户晓,古代编译器是十分弱小的。毛主席已经说过:要团结所有能够团结的力量。面对如此弱小的编译器,咱们应该争取做编译器的敌人,而不是与编译器为敌。做编译器的敌人,就是要充分利用编译器的优化。而很多优化是有条件的,因而咱们要争取写出优化敌对的代码,把剩下的工作交给编译器,而不是本人胡搞蛮搞。
(八)返回值优化NRVO(Named Return Value Optimization)
当一个函数的返回值是以后函数内的一个局部变量,且该局部变量的类型和返回值统一时,编译器会将该变量间接在函数的返回值接管处结构,不会产生拷贝和挪动,比方:
#include <iostream>struct Noisy { Noisy() { std::cout << "constructed at " << this << '\n'; } Noisy(const Noisy&) { std::cout << "copy-constructed\n"; } Noisy(Noisy&&) { std::cout << "move-constructed\n"; } ~Noisy() { std::cout << "destructed at " << this << '\n'; }};Noisy f() { Noisy v = Noisy(); return v;}void g(Noisy arg) { std::cout << "&arg = " << &arg << '\n'; }int main() { Noisy v = f(); g(f());}
这段代码中,函数f()满足NRVO的条件,所以Noisy既不会拷贝,也不会move,只会被结构和析构两次,程序的输入:
constructed at 0x7fff880300aeconstructed at 0x7fff880300af&arg = 0x7fff880300afdestructed at 0x7fff880300afdestructed at 0x7fff880300ae
滥用std::move
自从C++11退出std::move语义之后,有些“自以为是”的程序员会到处增加move。在这些状况下,std::move是基本没用的:
- 被move的对象是平庸类型。
- 被move的对象是一个常量援用。
而在某些状况下,move反而会导致负优化,比方妨碍了NRVO:
Noisy f() { Noisy v = Noisy(); return std::move(v);}
还是下面的代码,只不过返回值被改成move走,后果就多了两次move结构和两次析构,反而得失相当:
constructed at 0x7ffc54006cdfmove-constructeddestructed at 0x7ffc54006cdfconstructed at 0x7ffc54006cdfmove-constructeddestructed at 0x7ffc54006cdf&arg = 0x7ffc54006d0fdestructed at 0x7ffc54006d0fdestructed at 0x7ffc54006d0e
工厂返回std::optional
同样的,应用std::optional也可能会妨碍NRVO优化:
std::optional<Noisy> f() { Noisy v = Noisy(); return v;}
因为返回值类型不对应,因而该当改为
std::optional<Noisy> f() { std::optional<Noisy> v; v = Noisy(); return v;}
为了性能就义了局部可读性。
(九)尾递归优化
尾递归优化是函数式语言罕用的一种优化,如果某个函数的最初一步操作是调用本身,那么编译器齐全能够不必调用的指令(call),而是用跳转(jmp)回以后函数的结尾,省略了新开调用栈的开销。然而因为C++的各种隐形操作,尾递归优化不是那么好实现。我已经在知乎上看到这样一个问题:zhihu.com/question/5523。题主的函数长这样:
unsigned btd_tail(std::string input, int v) { if (input.empty()) { return v; } else { v = v * 2 + (input.front() - '0'); return btd_tail(input.substr(1), v); }}
间接return本身的调用,如果在函数式语言就是一个规范的尾递归,然而,理论执行的代码是:
unsigned btd_tail(std::string input, int v) { if (input.empty()) { return v; } else { v = v * 2 + (input.front() - '0'); auto temp = btd_tail(input.substr(1), v); input.~string(); // 留神这里 return temp; }}
因为在return前C++有隐形的析构操作,所以这段代码并不是尾递归。而须要析构的实质起因是std::string不是可平庸析构的对象,解决办法也很简略,换成std::string\_view就好了
unsigned btd_tail(std::string_view input, int v) { if (input.empty()) { return v; } else { v = v * 2 + (input.front() - '0'); return btd_tail(input.substr(1), v); }}
std::string\_view是可平庸析构的,所以编译器基本不须要调用析构函数,这也是上文举荐尽量选用可平庸析构对象的另一个理由。对于std::string\_view的介绍,可参考我的另一篇文章《C++17在业务代码中最好用的十个个性》。我的下一篇文章《C++函数式编程指南》会介绍C++函数式编程,敬请期待。
(十)主动向量化优化
古代CPU大部分都反对一些向量化指令集如SSE、AVX等,向量化指的是SIMD操作,即一个指令,多条数据。在某些条件下,编译器会主动将循环优化为向量化操作:
- 循环外部拜访的是间断内存。
- 循环外部没有函数调用,没有if分支。
- 循环之间没有依赖。
举个例子,下方的代码十分的向量化不敌对:
enum Type { kAdd, kMul };int add(int a, int b) { return a + b; }int mul(int a, int b) { return a * b; }std::vector<int> func(std::vector<int> a, std::vector<int> b, Type t) { std::vector<int> c(a.size()); for (int i = 0; i < a.size(); ++i) { if (t == kAdd) { c[i] = add(a[i], b[i]); } else { c[i] = mul(a[i], b[i]); } } return c;}
既有if,又有函数调用,而如果咱们通过模版if和内联函数,这两条都能够躲避:
enum Type { kAdd, kMul };inline __attribute__((always_inline)) int add(int a, int b) { return a + b; }inline __attribute__((always_inline)) int mul(int a, int b) { return a * b; }template <Type t>std::vector<int> func(std::vector<int> a, std::vector<int> b) { std::vector<int> c(a.size()); for (int i = 0; i < a.size(); ++i) { if constexpr (t == kAdd) { c[i] = add(a[i], b[i]); } else { c[i] = mul(a[i], b[i]); } } return c;}
这样就变成了向量化敌对的代码。咱们团队正在基于apache arrow做一些向量化计算的工作,后续也会有文章分享对于向量化优化的具体介绍。
举荐浏览
AI绘画火了!一文看懂背地技术原理
CPU如何与内存交互?
揭秘go内存!
C++20协程初探!