如果你让每个 C++工程师列出他们喜欢 C++的原因,那“掌控力”绝对是排在前几的特性。与 go、java 等垃圾回收语言的大道至简、python 等解释语言的小快灵不同,C++最大的魅力就是给予工程师对代码完全的掌控,每个 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++根本不存在”零成本抽象“。
显然,C++的很多特性是有性能成本的,而且,这些成本往往出现在你“没有写”的代码里,即 C++帮你添加的隐形代码。作为 C++工程师,我们就必须了解每个特性会带来的性能损耗,在做代码设计时尽量选择损耗小的特性。
而下文介绍的很多坑点,C++语言服务器 clangd 可以帮你实时检测出来并自动修复。
老生常谈的性能损耗,这里只介绍一下虚函数调用带来的成本:
然而在实际生产环境中,可能很多的运行时多态是无法避免的,毕竟这是 OOP 的基础特性,因此对于虚函数我们也只能了解背后的成本而已。某些情况下我们可以使用编译期多态来替代虚函数,比如 CRTP(Curiously Recurring Template Pattern)、Tempated Visitor Pattern、Policy Based Design 等等,我的下一篇文章《C++独有的设计模式》中会介绍这些技巧,敬请期待。
也是一个老生常谈的性能损耗,这里主要介绍几个容易被疏忽的场景:
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)){}
这种写法是 clang-tidy 推荐的https://clang.llvm.org/extra/clang-tidy/checks/modernize/pass-by-value.html#pass-by-value-in-constructors ,相比传 const 引用进来,如果外面也是传右值,则完全没有拷贝。
std::vector<std::string> vec;
for(std::string s: vec){
// ...
}
这里每个 string 会被复制一次,改为for(const std::string& s: vec)
即可.
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{41, 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),只能间接赋值。除此之外,不可平凡复制类型也不能作为编译器常量进行编译器运算。所以,如果你的类是平凡的(只有数值和数字,不涉及堆内存分配),千万不要随手加上析构函数!
关于非平凡析构类型造成的性能损耗,后文还会多次提到。
C++核心指南是这样推荐智能指针的用法的:
std::unique_ptr
或 std::shared_ptr
表达资源的所有权。std::unique_ptr
,只有当资源需要被共享所有权时,再用std::shared_ptr
。但是在实际代码中,用std::shared_ptr
的场景大概就是以下几种:
std::shared_ptr
,像是被 apache 的 Java 环境给荼毒了)。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,顾名思义,可以封装任何可被调用的对象,包括常规函数、类的成员函数、有 operator()定义的类、lambda 函数等等,当我们需要存储函数时 std::function 非常好用,但是 std::function 是有成本的:
因此我们只应在必须时才使用 std::function,比如需要存储一个不确定类型的函数。而在只需要多态调用的,完全可以用模版静态派发:
template <typename Func>
void Run(Func&& f){
f();
}
std::any 同理,用类型擦除的机制可以存储任何类型,但是也不推荐使用。
我在我的另一篇文章《C++17 在业务代码中最好用的十个特性 》大肆吹捧了一波 std::variant 和 std::optional,但是说实话,C++的实现还是有些性能开销的,这里以 std::optional 为例介绍:
std::async 是一个很好用的异步执行抽象,但是在使用的时候可能一不小心,你的代码就变成了同步调用:
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, func1);
std::async(std::launch::async, func2);
}
在这段代码里,func1 和 func2 其实是串行的!因为 std::async 会返回一个 std::future,而这个 std::future 在析构时,会同步等待函数返回结果才析构结束。这也是上文“隐形的析构”的另外一种表现。正确的代码应当长这样:
auto future1 = std::async(std::launch::async, func1);
auto future2 = std::async(std::launch::async, func2);
更奇葩的是,只有 std::async 返回的 std::future 在析构时会同步等待,std::packaged_task,std::promise 构造的 std::future 都不会同步等待,实在是让人无力吐槽。
关于 std::async 等等 C++多线程工具,在我之后的文章《现代 C++并发编程指南》会介绍,敬请期待。
众所周知,现代编译器是非常强大的。毛主席曾经说过:要团结一切可以团结的力量。面对如此强大的编译器,我们应该争取做编译器的朋友,而不是与编译器为敌。做编译器的朋友,就是要充分利用编译器的优化。而很多优化是有条件的,因此我们要争取写出优化友好的代码,把剩下的工作交给编译器,而不是自己胡搞蛮搞。
当一个函数的返回值是当前函数内的一个局部变量,且该局部变量的类型和返回值一致时,编译器会将该变量直接在函数的返回值接收处构造,不会发生拷贝和移动,比如:
#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
自从 C++11 加入 std::move 语义之后,有些“自以为是”的程序员会到处添加 move。在这些情况下,std::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 也可能会阻碍 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++的各种隐形操作,尾递归优化不是那么好实现。我曾经在知乎上看到这样一个问题:https://www.zhihu.com/question/552352098。题主的函数长这样:
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 是可平凡析构的,所以编译器根本不需要调用析构函数,这也是上文推荐尽量选用可平凡析构对象的另一个理由。我的下一篇文章《C++函数式编程指南》会介绍 C++函数式编程,敬请期待。
现代 CPU 大部分都支持一些向量化指令集如 SSE、AVX 等,向量化指的是 SIMD 操作,即一个指令,多条数据。在某些条件下,编译器会自动将循环优化为向量化操作:
举个例子,下方的代码非常的向量化不友好:
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 做一些向量化计算的工作,后续也会有文章分享关于向量化优化的详细介绍。
本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/UW2Ual0v21KNQz60MHT1nQ
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。