被知乎大佬嘲讽后的一个月,我重新研究了一下内联函数(下)

前言

希望这篇文章能够改变你对内联函数的认识

如果不能,那起码这张题图会对你有所帮助

上篇文章总结了前两个阶段我对内联以及相关知识的理解,大部分内容都是我之前的理解。这篇文章我会继续深入分析,谈谈最近一个月学习的成果。

如果没有看过上篇,建议先阅读【这里】文中会有很多引用的参考链接,而且很多内容都是英文的(建议找时间慢慢学习),我会统一放到文末的位置,很多链接不适合在微信里面查看,建议用浏览器打开。

03:进阶阶段

由于前一阵总结的文章被指出inline的总结内容有诸多不妥,所以我开始换一个角度去理解inline。说实话,大佬文章中很多名词我听都没听过,因为之前除了学完编译原理这门课之后就完全与编译器拜拜了(虽然我无时无刻不在用IDE提供的编译器)

首先是关于内联的意义,前面说过内联的直接优点就是减少函数调用,这个是毋庸置疑的,但是他更大意义是它允许编译器进行进一步优化

【参考1:Inline expansion ;Reducing Indirect Function Call Overhead In C++ Programs;CppCon 2014: Andrei Alexandrescu "Optimization Tips" 】。

这点是我之前没有去想过,因为我们平时都在写业务代码,大部分情况下不需要考虑语言层面的问题。不过,我个人处于游戏行业,对“优化”一词还是比较敏感的,每次编译引擎(项目)所花费的时间、运行时的效率、调试效率、游戏帧数、打包时间等这些其实与我们的业务是息息相关的。一个庞大的项目一旦编译起来就花费很长时间,所以会有Debug、Development、Shipping等各种版本来满足我们不同情况下的需求。想要调试一个项目,当然是尽可能把优化都关掉才好;对于一个发行出去的游戏,当然是越小巧、高度优化、执行效率越高越好了。然而这些工作其实都是编译器在默默的帮助我们去做的(也可以说是各位编译领域相关的大佬帮我们做的),这时候我突然觉得我们连Debug与Release配置都搞不清真的有点对不起他们的工作了。

还是拿刚才的代码来说,我们再看一下内联后的汇编代码。

int main()
{
//......省略
int num1 = 1;
00F41F78  mov         dword ptr [num1],1  
int num2 = 2;
00F41F7F  mov         dword ptr [num2],2  
int myNum = Add(num1, num2);
00F41F86  mov         eax,dword ptr [num1]  
00F41F89  add         eax,dword ptr [num2]  
00F41F8C  mov         dword ptr [myNum],eax  
}

对于任何一个能看懂代码的人,我们都知道myNum就是2,所以集人类智慧于一身的编译器也应该知道。除了把函数return a+b这段代码内联过来之后还应该直接算出答案,这就是说inline后的代码与之前已经完全不同了,所以编译器也有必要再看看这个地方有没有什么值得优化的。事实证明如果我把这个程序改为release版本的,这段代码就直接返回了,不客气的说,我连 myNum=2 这个都可以直接优化掉,因为这个局部变量看起来并没有什么意义。虽然不同的编译器的反汇编代码有所不一样,但是他们都在努力的用内联去调整编译后的结果。

朴素一点的理解,所谓的内联就是为了方便编译器看到更多源码信息,如果我们能把所有函数内联到Main函数里面,那理论上我们可以就可以得到最佳的优化代码,可能一段非常复杂的代码到最后只要一个指令就足够了。关于编译器的优化方案,非常多而且大佬们还在不断的优化提出更多的优化方案,常见的有死代码删除、循环不变代码外提、常数折叠等等。

【参考2:Category:Compiler optimizations Reducing ;Indirect Function Call Overhead In C++ Programs】

既然谈到新的优化方案,就正好说一下虚函数调用。在比较老的编译器上,我们不会去对虚函数内联,原因很简单,因为虚函数的执行属于运行时动态,我们需要动态查阅虚函数表来找到对应的虚函数。由于根本不知道运行的时候到底是哪个类会执行这个虚函数,当然也就不知道到底调用的是哪个子类下override的版本。但是,大佬们自然不会轻易放弃,能优化一点咱们就尽量优化一点。当我们的编译器可以分析出当前的程序,如

struct A
{    
  virtual ~A() {}    
  virtual int foo() { return 0; }
};

inline int do_something(A& obj)
{  
  return obj.foo();
}

struct B : A
{    
  virtual ~B() {}    
  virtual int foo() { return 1; }
};  

int main()
{    
  B b;    
  return do_something(b);
}

这样的代码的时候,我们就可以确定B就是继承树上的最终的子节点,也就可以将虚函数的查表调用改为直接调用进而进行内联优化,这种优化方式叫做Devirtualization。当然,这种代码确实过于理想的简单,我们常见的项目代码一定是分为多个编译单元的,编译器想进行跨编译单元的优化就还需要另一个方案,LTO(Link Time Optimization)即链接时优化。

【参考3: C++ Devirtualization Devirtualization in LLVM and Clang】

LTO顾名思义,就是在编译器进行链接时进行相关的代码优化,不同编译单元在链接的时候将其内部表示转储到磁盘,然后组成单个模块并进行优化。也因此,之前大佬纠正我说“写在cpp里面的函数也可以内联,每次修改会重新编译头文件增加编译时间这句话也说错误的”。【参考4:Link-time optimization for the kernel LLVM Link Time Optimization: Design and Implementation】

可是看完LTO相关资料后,我又产生了疑问,编译器优化不是还有IPO么。所谓IPO,Interprocedural Optimization,即过程间优化,传统的编译器是先将编译每个源文件成独立的目标文件,然后再通过链接器将目标文件链接成可执行文件(或库),其编译优化主要集中在每个源文件内部,而IPO可以打破这个局限对整个程序进行全局的优化。那么IPO与LTO是什么关系呢?看了wiki上的资料后,我大概理解为,LTO属于IPO的子集,IPO是一个可以在编译过程的任何阶段都能执行优化的解决方案,LTO只针对链接时优化,不过应该属于IPO一个最强有力的方案了。【参考5:Interprocedural optimization 现代C/C++编译器有多智能?能做出什么厉害的优化?】另外,C/C++这种纯编译型语言都可以做到链接时内联优化,而对于C#、Java这种半编译半解释型的语言,其优化的时机岂不是可以更为灵活随意了?

这时候我才发现,这么多关于内联的调整的优化好像都是编译器在搞,无论什么语言、什么平台,本质上都逃不过编译器的审核与优化。

那么,我们显示声明inline还有什么意义呢?好像我们写不写inline,没什么意义啊。只要是发行出去的版本,编译器自己决定,分分钟给你各种优化,你的各种inline建议好像都没什么意义了。不过,带着疑问我又去查了查资料,发现好像还没问想的那么简单,最起码inline还有两点意义:

1.   编译器并不是万能的,有时候人工的内联建议确实能解决一些编译器优化的盲点【参考6:libcxx修改1 、libcxx修改2 】

2.   inline并不只是只有把函数内联到调用的地方这个意义,他还关系到ODR (定义与单一定义规则)。所谓ODR,就是任何变量、函数、类类型、枚举类型、概念 (C++20 起)或模板,在每个翻译单元中都只允许有一个定义(它们有些可以有多个声明,但定义只允许有一个)。不过具体一点的说又有很多种情况,对于非inline且odr-used的变量、函数,要求全局只能有一个定义;Inline变量、函数在每个编译单元都有有一个定义;需要使用类的时候,在每个翻译单元都需要一个定义。

例如:你如果把一个非成员函数放到.h文件里面并被多个编译单元包含,那么在链接的时候就会报错。因为非inline的全局函数在全局只能有一个定义,如果每个编译单元都有一个成员函数,编译器不知道链接哪一个。如果给这个函数加上inline的话,就可以解决这个问题。而如果你在多个cpp里面定义了函数签名完全相同的但是内容不同inline函数,也不会发生编译失败,不过具体链接到哪个版本的inline函数可能是未定义行为。【参考7:One Definition Rule  既然编译器可以判断一个函数是否适合 inline,那还有必要自己加 inline 关键字吗? 最近看到陈硕的一本书提了一个问题,“编译器如何处理inline函数中的static变量?”】

关于优化,这里还涉及到一个概念, zero-overhead abstraction,即“零代价的抽象”(Rust里面叫zero-cost abstractions),简单来说就是抽象的同时不需要付出额外的代价,比如说vector<int> 数组在优化理想的编译器的发行版本下与int类型的数组开销应该是几乎相同的。因此,我们可以认为C++中的zero-overhead abstraction与编译器的优化是密不可分的,更进一步的说,C++语言本身的优良与编译器也是密不可分的。每当C++新的标准出来后,各大编译器团队都要及时的去支持这些新的特性,并兼顾语言的优化问题。【参考8:Rust所宣称的zero-cost abstractions是怎么回事? Zero-cost abstractions abstraction and hand-crafted code】

最后,再简单提一下LLVM。前面的很多链接里面都提到了LLVM(很久以前,LLVM曾经是Low Level Virtual Machine的缩写,现在已经不是了),它是一个编译器基础设施框架,包含了我们编写编译器需要的一系列库(如程序分析、代码优化、机器代码生成等),并且提供了调用这些库的相关工具。Clang, llbc++, lld等项目就是基于LLVM开发的,Objective-C编译器也是基于LLVM开发的。LLVM的编译过程与传统的编译器有所差异(参考下图),中间会生成一套通用的与语言无关的中间语言LLVM IR,我们可以去阅读这个中间语言了解更多信息【参考9:测试工具】,所以编译优化与使用方式也是非常灵活(我目前在VS上还没有找到可以阅读的单个编译单元编译后的文件)

我没有深入了解,更多内容可以【参考10:LLVM 谁说不能与龙一起跳舞:Clang / LLVM (1) 周末花了点时间看 LLVM IR, 闲扯几句 编译器LLVM浅浅玩 】

这段时间查了各种资料,算是勉强到了一个新的阶段。这个阶段的我开始关注了core language之外的东西——编译器,虽然研究的并不是很透彻,但是在概念上我对程序的运行编译、程序与操作系统之间的关系有了进一步的理解,有时候会觉得豁然开朗,看起来毫无关系的知识突然就联系在了一起。

知识图谱又稍微修改了一下:

也许这篇文章看完,你会觉得这个内联好像懂了以后对编程也没多少帮助,但正如我知乎上的评论所说的,

其实这个就像我们学的数理化那些,看起来生活中很少或者基本不会直接用到,但是里面的思想会影响到我们对事物做出的逻辑判断,甚至万一遇到问题了还能解决,就拿汇编来说,大部分做业务的程序员基本不会用到,但是一旦遇到问题甚至要在没有单步调试环境的下面debug,反编译后能看下汇编代码,调试起来还是会比不会汇编的人方便快捷很多。很多东西怕就怕万一用到了~

End

游戏开发那些事

长安图片识别二维码关注获取更多学习资料

参考链接:

【1】.Inline expansion ;

https://en.wikipedia.org/wiki/Inline_expansion

CppCon 2014: Andrei Alexandrescu "Optimization Tips" 

https://www.youtube.com/watch?v=Qq_WaiwzOtI

【2】.Category:Compiler optimizations Reducing ;

https://en.wikipedia.org/wiki/Category:Compiler_optimizations

Reducing Indirect Function Call Overhead In C++ Programs;

http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.27.5761&rep=rep1&type=pdf 

【3】.C++ Devirtualization 

http://lazarenko.me/devirtualization/ 

Devirtualization in LLVM and Clang

http://link.zhihu.com/?http://blog.llvm.org/2017/03/devirtualization-in-llvm-and-clang.html 

【4】.Link-time optimization for the kernel LLVM 

http://llvm.org/docs/LinkTimeOptimization.html

Link Time Optimization: Design and Implementation

https://lwn.net/Articles/512548/ 

【5】.Interprocedural optimization 

https://en.wikipedia.org/wiki/Interprocedural_optimization 

现代C/C++编译器有多智能?能做出什么厉害的优化?

https://www.zhihu.com/question/43598164/answer/122186527 

【6】.libcxx修改

https://reviews.llvm.org/D22782 

https://reviews.llvm.org/D22834 

【7】.One Definition Rule 

https://en.wikipedia.org/wiki/One_Definition_Rule

既然编译器可以判断一个函数是否适合 inline,那还有必要自己加 inline 关键字吗?

https://www.zhihu.com/question/53082910 

【8】.Rust所宣称的zero-cost abstractions是怎么回事?

 https://www.zhihu.com/question/31645634

Zero-cost abstractions

https://ruudvanasseldonk.com/2016/11/30/zero-cost-abstractions

Interview with Bjarne Stroustrup - abstraction and hand-crafted code

https://stackoverflow.com/questions/20134585/interview-with-bjarne-stroustrup-abstraction-and-hand-crafted-code 

【9】.测试工具 http://ellcc.org/demo/index.cgi 

【10】.LLVM

http://www.aosabook.org/en/llvm.html 

谁说不能与龙一起跳舞:Clang / LLVM (1) 

https://zhuanlan.zhihu.com/p/21889573 

周末花了点时间看 LLVM IR, 闲扯几句 

https://segmentfault.com/a/1190000002669213 

编译器LLVM浅浅玩 

https://medium.com/@zetavg/%E7%B7%A8%E8%AD%AF%E5%99%A8-llvm-%E6%B7%BA%E6%B7%BA%E7%8E%A9-42a58c7a7309 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值