共计 9109 个字符,预计需要花费 23 分钟才能阅读完成。
导语 | 每个 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 0x7fff880300ae
constructed at 0x7fff880300af
&arg = 0x7fff880300af
destructed at 0x7fff880300af
destructed 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 0x7ffc54006cdf
move-constructed
destructed at 0x7ffc54006cdf
constructed at 0x7ffc54006cdf
move-constructed
destructed at 0x7ffc54006cdf
&arg = 0x7ffc54006d0f
destructed at 0x7ffc54006d0f
destructed 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 协程初探!