关于c++:10大性能陷阱每个C工程师都要知道

2次阅读

共计 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 协程初探!

正文完
 0