Visual Studio: 关于内联的决策过程

Visual Studio: 关于内联的决策过程

作者:HQ |  时间:2019-08-04 |  浏览:160 |  评论已关闭 条评论

近日,一位负责MSVC中代码生成功能的工程师Terry发布了一篇博客,讲述了Visual Studio中关于内联决策的内部细节。今天我们就来详细看看。

内联优化概述

首先是一个大背景:开发团队目前正在热火朝天的继续对Visual Studio这个大杀器进行开发演进。VS开发团队内部分工明确,各个项目团队专注在某一个特定的细分功能上。今天文章的焦点在于:在C++代码进行代码生成阶段,如何决策一个函数是否应该被内联,以及如何被内联。
从这些内幕中,我们可以窥见开发团队为了生成速度更快,尺寸更小的二进制文件而做出的种种努力和折中方案,这些设计内幕将会在不久的将来随着新一代VS的发布,和开发者见面。

谈到代码优化,内联可能是最重要的一种代码优化方式。除了移除函数的调用开销,内联决策在暴露额外的优化空间方面有着十分重要的地位。下面举个栗子:

在以上代码中,将bar内联进foo是一个很好的主意,因为一旦这样做了,编译器就有机会对整个函数进行评估,并且在最后的代码生成阶段,函数foo将直接返回16。因为我们在bar函数中传递了一个常量5,这样函数foo在编译期即可确定返回值。

然后,我们对比以下例子:

在以上这个例子中,看起来,我们如果将bar内联进foo, baz和zoo中,不像是一个赚钱的买卖。为什么呢?因为传递进bar中的参数不再是一个常量了,编译器无法在编译期计算出函数的最终返回值。没错,内联的确可以避免函数的调用开销,但是不能因为这个就简单的将所有的被调用函数进行内联,在以上的例子中,如果执行内联,则最终生成的二进制文件中,将会有4份bar函数的拷贝,这将显著的增加二进制文件的尺寸,另外的一个负面影响是,它降低了代码的cache locality。

所以,内联决策中一个重要目标是:它会权衡内联一个函数的带来的收益和代码在尺寸方面的增长。在MSVC组件中,开发团队设立了一个全局的目标:在某些情况下,即使内联函数能带来一定的收益,但是因为生成的二进制尺寸的关系,MSVC也不会对函数进行内联。

在MSVC的演进中,内联器将会变得足够智能和更加的强势。在智能方面,内联器可以观察到内联一个函数产生的潜在收益,这在以前版本的MSVC中是做不到的。另一方面,MSVC通过提升内联决策的判断参数,降低限值的方式来变得更加激进。接下来从3个方面来讲述MSVC内联器和其他编译器中的内联器的差异。

对未优化版本的代码进行内联
相比于其他编译器,MSVC在内联一个函数调用时,它将再次读取该函数的未经优化的代码,甚至被调用方已经进行了代码优化的情况下,也是如此。这种内联方式还意味着:这个内联决策将(重复地)发生在一个函数可能被调用的每个场景中,这样可能会带来一些不同的结果。

举个栗子
一个函数f调用函数g,函数g调用函数h,函数h已经被内联进函数g了。这个时候,如果函数g被内联至函数f,则函数f就不在需要再一次地被内联至函数f。编译器在内联一段已经优化后的代码(这里是另一个版本的函数g,这个函数已经有函数h内联)的时候,将为被调用函数重复的进行内联决策。
虽然看起来,重复的对每个调用上下文进行内联决策不是那么必要,开发团队认为,这样的内联决策或许是一个优势。另一方面,执行这个内联策略将显著的增加编译时间。开发团队正在考虑一种折中的方式:对那些显而易见的优化过的函数代码进行内联。

一开始就执行内联
在编译过程中,内联是代码优化的第一个阶段。所以,编译器不仅会内联一个优化后的被调用函数,而且它还会将生成的代码内联进一个经过优化的调用方函数。这样的内联方式也会有一个负面影响:它将意识不到一个显而易见的,可以被优化的机会。我们将第一个例子中的函数foo修改如下的版本:

MSVC认为,调用函数bar的参数”x+1″会是一个变量,所有它将不会将bar内联进函数foo。

另一个影响在于:对于一些函数的间接调用和虚函数调用,它们可以使用constant propagation来转化为直接调用。对于这种调用方式,开发团队并没有对其进行优化为内联。所以你会看到在最终生成的二进制文件中,一个间接调用被转为了直接调用,但是它却没有被内联。开发团队给出了答案:这个是组件的调用顺序有关,在有些情况下,内联器已经启动了,代码才开始了优化,而这些代码优化正是内联器启动时所需要的。

内联器的实现内幕
在High Level这个层面来看,MSVC的内联器的内联决策流程如下:
1. 标识所有内联候选者(第一次legality check)
2. 对于每个内联候选者
a. 读取内联候选者的代码,并执行第二次lagality check.
b. 执行一系列的inline heuristics.
c. 如果条件适用,在递归式的执行内联.
d. 执行最后一次lagality check.

首先,从上述流程中,我们看到这个是一个”深度优先”的搜索方式,在VS的后续版本中,开发团队将尝试将深度优先转为广度优先。

Legality check和Inline heuristics实际上是一个包含有函数指针的表格。如果任何一个legality check失败,则会导致对当前内联候选者的内联终止。只要有一个heuristic check成功,则内联流程会继续。
在以上内联流程中,有三个Legality check阶段:第一个阶段基于所知的潜在可能的内联者,此阶段内联器将不读取内联者的代码。第二阶段将会读取内联者代码。最后一个阶段,将会递归式的扩展至函数调用者。

关于Legality Check,它实际上是一个对内联与否的一个检查,开发团队没有对一些corner case进行实现。例如,以值的方式传递一个复杂的用户类型,在跨越多个不同的用户组件进行内联,内联一个包含一个try块的函数,内联一个包含setjmp的函数,还包括在一个我们很难知道内联深度时进行的一个内联深度检查等。

关于Inline Heuristics, 有一个被称之为”callgraph decision”的决策。这个决策可以被认为是一个真正的内联决策者。它实现了所有包含常量参数的函数的内联决策,这个请参考上面出现的例子。一个graph decision依赖一个从下至上的编译顺序,因为有一些关于被调用函数的信息,是在编译期被收集的,例如其被调用时传递的参数等。还有一些其他比较简单的heuristics,例如是否强制内联,是否内联为一个小函数等。

这个内联决策框架比较容易理解,也比较灵活。我们可以随时在表格中添加一个新的Lagality Check或者Heuristic。PGO(Profile Guided Optimization)PGO(Profile Guided Optimization)基于Profiling data来生成自己的内联决策引擎,从实现来说,它自己会持有一张内联决策表。

在instrumented build模式下,PGO将不会进行内联,从而能更加精确的收集调用信息。PGO实现这种非内联模式很简单:就是在一个Legality Check中返回false即可,这样内联就不会发生了。

可以在编译代码的时候,添加/d2inlinestats开关,那么在编译期会打印一张表格,这个表格会显示出哪些Legality check失败,同时也显示哪些heuristics成功。

总结
本文讲述了VS中的内联器的实现背景和基本流程。对于应用开发者来说,或许会感到不那么容易理解。说到这里,我不由得想起了大学时期的<编译原理>这门课,那本书对于我来说,简直跟天书一般。
但是没关系,对于一件不熟悉的事物,第一次可能只能弄明白一个大概,在后面的第二次,第三次,乃至第N次的摸爬滚打中,就会慢慢理解,直至融会贯通。
祝你好运!

评论已关闭。