Visual Studio 2017 性能提升和建议

[原文发表地址] Visual Studio 2017 Throughput Improvements and Advice

[原文发表时间] 2018/01/4

随着C++项目的壮大和优化器的日渐复杂,编译器的编译时间,或者说是性能,逐渐成为人们关注的焦点。这是我们Visual C++组非常关注的问题,也成为了15.5版本和未来工作的重点。我想花几分钟时间来为各位总结一下我们最近为提升性能所做的特殊的更改,并且可以为你提升编译工程的性能提几点建议。

这里需要注意的是,并非所有的更改都可以提升所有场景的性能。把编译时间降低到一个期望值是一个长远的事业。最近我们开始把AAA游戏作为一个基准。未来还需要付出更多的努力。

VS工具集有三个部分需要分别改进。第一就是编译器前端,也就是c1xx.dll的执行。这是一个将cpp文件作为输入并且生成一种不依赖于中间语言的语言的工具,或者IL,即将被输入到编译器后端的内容。编译器后端也就是c2.dll。它从前端读取IL并从中生成包含真正机器代码的obj。最后是链接器,它将读取后端编译器生成的各种obj和lib,并且将他们合并生成一个最终的二进制文件。

编译器前端性能

在许多工程里,前端编译时间阻碍了整体性能的提升。幸运的是,通过直接给msbuild或者其他编译系统加/MP选项(该选项可以使cl.exe同时处理多个文件),或者甚至可以使用像incredibuild这样的工具通过分布式机器来加速。想要提升性能的第一步是在编译工程时进行高效的分布和并行。

第二步是确保你高效使用了PCH文件。一个PCH文件基本上是cl.exe 充分解析了.h文件之后的内存转储-解决了每次都需要这样做的麻烦。你会被它的作用所震惊,头文件(比如windows.h或者一些DirectX头)一旦被完全预处理会变得非常大,并且常常会成为一个后处理源文件的最主要的部分。PCH文件会开启一个新世界。这里的主要操作是只将会被频繁更改的文件包含进来,这就保证PCH会帮你改进很多性能。

最后一点建议是对#include的使用限制。在PCH文件之外,包含一个文件是一个非常昂贵的操作,这就牵扯到了在包含的路径里搜索每一个文件夹的问题。很多文件的输入输出操作,这是一个每次都要被重复的传递性操作。这就是PCH会起很大作用的原因。在微软内部,人们有很多有关“只包含你要使用的文件”的成功案例。/showInclude选项可以让你认识到包含文件是多么的昂贵,并且可以指导你只包含你需要用的东西。

最后,我想让你了解一下/Bt选项。这个选项可以显示每个文件的前端(同样也包括后端和链接时间)编译时间。它可以帮你查清性能低的原因,让你知道哪个文件需要你花时间去优化。

以下是我们为提升前端性能所做的更改。

刷新PGO计数

PGO,或者叫配置文件优化,是一种编译器后端科技,在微软被广泛使用。基本原理是你生成了一个特殊检测版本的产品,通过运行测试用例生成配置文件,基于已收集的数据进行重新编译/优化。

我们发现在编译或者优化前端二进制文件(c1xx.dll)时使用的是旧的配置文件数据。当我们重新检测或者重新收集 PGO数据时,会看到10%的性能提升。

这里我们学到的是,如果在产品中用PGO提升性能,请确保定期收集训练过的数据。

移除_assume的使用

_assume(0)给编译器后端传递一个信号, 告诉它一个特定的代码路径(也许是一个默认情况的标签,等等)无法到达。许多产品会把它包含在一个宏里,并命名为类似于UNREACHABLE这样的名字,然后执行,这样debug版本会声明这个信号,ship版本会把这个信号传递给编译器。编译器会做一些操作比如移除枝干或者转移目标声明。

这是有道理的,如果在运行时一个_assume(0)声明实际上是可到达的,结果就是生成错误代码。这会在很多不同的情况下带来很多问题(并且一些人抱怨这会引发安全问题)-- 所以我们做了一个实验来看看通过重新定义一个宏来简单的移除所有的_assume(0)会带来的影响。如果回归很小,也许不值得把它放在产品中免得引发其他的问题。

令我们惊讶的是,移除_assume声明使前端性能提升了1%-2%。这就很容易做决定了。这一现象的根源就是尽管在很多情况下_assume对应优化器是一个有效的信号,但实际上它也许会阻碍其他优化(尤其是比较新的优化)。在未来的版本中我们将持续对_assume进行改进。

改进winmd文件加载

在winmd文件加载问题上我们做了很多更改,旨在提升10%的加载性能(这一项大概占总编译时间的1%)。这只会影响UWP工程。

编译器后端

编译器后端包含了优化器。这里有两个等级的性能问题,“常规”问题(在这里我们做了大量的工作希望能有1-2%的提升),和“长远”问题,这里有一个特殊的方法会导致一些优化到达了一个不合理的路径并且会花30s或者更长时间去编译 — 但是大部分人没被影响。我们关心这个并且一直为之改进。

如果你使用/Bt选项并且看到一个异常的文件花费了非正常的时间做后端编译,下一步就是在编译时使用/d2cgsummary选项。Cgsummary(或者叫代码生成概要)将会告诉你哪个函数花费的时间长。如果这个函数不在你的关键性能路径中,说明你很幸运,那么你就可以用以下的方法为该函数关闭优化:

#pragma optimize("", off)

void foo() {

...

}

#pragma optimize("", on)

那么这个函数就不会被优化。和我们保持联系,我们可以帮你看看是否能修复这个问题。

除了为编译时间不正常的方法关闭优化之外,我需要提示你,使用 _forceinline时要当心。通常客户会使用forceinline让内联器做他们想做的事情,这种情况下,我的建议是尽可能的有针对性的使用。编译器后端会非常非常重视_forceinline。它会免除所有的内联预算检查(_forceinline的花费不会对内联预算不利)。这些年我们看了许多案例,以代码质量为由随意使用_forceinline是性能提神的主要阻碍。基本的,不像其他编译器,我们经常通过前端的IL内联预优化的方法。这样做有时候有利,我们为不同的内容做不同的优化,但一个弊端是,很多工作我们将无法恢复。如果你有一个很深的内联树,那么这将很快变得无法控制。这就是碰到像是Tensorflow/libsodium这样的地方编译时间过长的根源。这是我们未来版本将要着眼改进的地方。 当使用LTCG build时请了解一下iLTCG。增量LTCG是一项新科技,使用它我们只需要对LTCG build中更改过的函数(以及该函数所依赖的函数,比如它的内联函数)做代码生成。没有它,即使只做了少量的更改,也要对整个二进制文件重新代码生成。如果你曾因为LTCG使你陷入内部开发循环而放弃使用它,请了解一下iLTCG。

最后一点建议,也更加适用于LTCG build(这里只有link.exe单独做代码生成而不是分布cl.exe),考虑使用/cgtreads#来适应默认核心扩展策略。正如你以下将要看到的,为了更好的衡量我们做了一些更改,但默认的仍旧是使用4核。将来我们会着眼于增加默认核数,或者甚至可以灵活的适用机器的核数。

以下是我们近期为编译器后端所做的免费的性能提升:

内联读取缓存

一些编译器,通过将优化过的所有内联函数保存在内存中来实现内联。这个时候,内联的执行,就是一个内存拷贝到当前函数的指定位置的问题。

然而在VC++中,我们对内联的操作有一些不同。我们实际上是从磁盘中重读没被优化的版本。这明显会慢一些,但是同时可以减少内存的使用。在性能提升的问题上,这将是一个阻碍,尤其是对一个大量使用_forceinline的工程来说。

为了平衡内存和性能的问题,相对于其他编译器,我们在内存问题上做了小的改动。编译器后端在一个方法因为内联操作而被读取了固定的次数之后会缓存这个方法。实验表明,当次数N=100时可以很好的平衡内存和性能问题。我们可以给编译器传递参数/d2FuncCache#(或者在LTCG

Build时给链接器传递参数/de:-FuncCache)来实现。0表示不缓存,50表示inline执行50次时缓存。

类型系统编译提升

此项适用于LTCG build。LTCG build开始,后端会致力于编译在所有类型程序中使用多种优化的模型,比如虚拟化。这很慢并且内存占用量大。以前,当碰到类型系统的问题时,我们建议人们通过给链接器传递/d2:-noteypeopt来禁用它。最近,我们做了大量更改,为类型系统平衡这一问题。实际上这个更改非常基础,这牵扯到我们如何执行bitset这一操作。

更好的扩展到多核

后端是多线程的。但是也有很多束缚:我们自下而上的执行编译指令 – 意味着一个方法无论被调用过几次都只编译一次。这就是一个函数如果在被调用函数的编译过程中使用收集到的信息做更好的优化。

这也存在一个限制:以上方法如果大小一定的情况下可以免除,并且可以在不适用自下而上的信息的情况下直接开始编译。这样就使得单线程编译不会遭遇瓶颈,因为它会流失掉最终剩余的少量的很大的方法,这类方法因为依赖树很深而无法立即启动。

我们已经重新评估了大容量函数的限制,并且明显的降低了这一限制。我们这个更改,不对任意明显的代码质量流失做评价,但是这次性能的提升会大大取决于,这个工程之前究竟多大程度的被大容量的方法所限制。

其他内联提升

我们为内联时符号表的构造和合并做了更改。这一更改提供了一个全面的附加的很小的好处。

更好封锁粒度的锁操作

像大部分工程,我们持续不断的配置和检查锁操作的限制。结果是,在少数情况下,我们提升了锁的封锁粒度,尤其是IL文件如何被映射和得到以及符号如果互相映射。

符号表和符号映射的新的数据结构

LTCG时,准确的在模块中映射符号需要做大量的工作。我们用新的数据结构重写了这部分代码并带来了提升。这个尤其对整体类型有帮助,通常是游戏产业,这些工程里图例的映射操作会非常大。

LTCG多线程的其他部分

编译器后端是多线程的只是相对正确的说法。我们只谈论后端的代码生成部分,而撇开了主要工作不谈。

然而,LTCG builds要复杂的多。它也包含一些其他部分。最近我们使其他部分多线程运作,代价是放弃了LTCG build 10%速度的提升。未来的版本中这项工作仍旧会持续。

链接器的改进

如果你使用LTCG(并且你应该使用),你将可能看到链接器是你编译系统的一个阻碍。这有些不公平,因为LTCG时链接器只用c2.dll做代码生成 – 所以以上的建议都适用。但是除了代码生成,链接器有它传统的工作要做,即解析引用和把所有obj生成一个最终的二进制文件。 这里你能做的最重要的事就是使用fastlink。Fastlink实际上是一个新的PDB格式,通过/debug:fastlink选项使用。链接时,这将极大的减少生成PDB所要做的工作。

在你的debug版本,你应该使用/increamental。增量链接允许链接器只更新被更改的obj而不是rebuild整个二进制文件。当你在内部循环做了一些更改,需要重新编译链接测试重复这些步骤的时候,增量链接会带来很大的不同。和fastlink类似,我们在这里做了大量的稳定的提升。假如你之前用过但是发现它不稳定,请重试一次。

以下是近期对编译器性能提升所做的更改:

新的ICF heuristic

ICF-折叠,是链接器的重大阻碍之一。这是任何相同的函数为了节省空间而折叠在一起的阶段,也是这些函数的引用被重定向到单一实例的阶段。

这个版本,对ICF做了一点更改。总结一下,通过依赖一个健壮的哈希函数来做对等代替使用memcmp。这将明显加快ICF的速度。

回退到64位链接器

32位链接器对于大工程来说存在着地址空间的问题。它经常用获取文件的方式将文件进行内存映射,假如文件很大,需要相邻地址空间的内存映射将不可能实现。作为备用方法,链接器退回到速度较慢的缓存I/O路径,这里链接器只读取它需要的部分文件。

链接器知道,与内存映射I/O相比,缓存I/O代码路径会非常非常的慢。所以我们添加了一个新的逻辑,是的32位链接器在退回到缓存I/O之前将自己重启为一个64位的进程。

/debug:fastlink 在一定程度上是一个新的特征,它可以明显的加速调试信息生成,而这一步是所有链接实践中一个主要的部分。我们建议所有人专攻这个选项,并且在任何可能的情况下使用它。在这个版本,我们加固并加速了这一选项,并在在未来的版本中给这个选项的编写投入更多的时间和金钱来改进它。如果你最初使用过它,但是因为某一次不好的体验而不再使用它,请再次尝试。我们在15.6版本中对这个选项做了更多的提升。

增量连接的回退

我们听到一个有关增量链接的抱怨,说有时候增量链接会比整体链接更慢,这取决于有多少obj和lib被更改。目前我们非常努力的查明这个状况并且会直接的求助于整体链接。

结论

以上所列举的内容并不详尽,但是很好的总结了过去几个月VS有关重大性能提升的更改。如果你曾经被VC++编译链接时间过长所困扰,我建议你再次尝试15.5的工具集。如果遇到一个工程和其他大小差不多的工程相比编译时间毫无理由的过长,或者是跟其他工具相比时间过长,我们很愿意帮你看一下。

请记得,cl.exe可以加/d2cgsummary,link.exe可以加/d2:-cgsummary,这两个方法可以帮助你查明在生成代码性能上的问题。这包含了以上所讨论的内联器读取缓存的问题。当遇到大工程,记得加/Bt,它可以帮你查明每个文件的前端后端编译时间和链接时间。/time+可以显示链接时间,包含了ICF使用的时间。