随着C++程序越来越大,优化器变得越来越复杂,编译器的编译时间或吞吐量越来越成为人们关注的焦点。随着新模式的出现和流行(比如游戏中的“统一”构建),这是需要不断解决的问题。这是我们在Visual C++团队中关注的焦点,并且在最近15.5次发布中成为了一个重点,并将继续向前发展。我想花几分钟时间向大家介绍我们为帮助您缩短编译时间所做的一些具体更改,并提供一些技巧,说明如何更改项目或使用工具中的技术来帮助您缩短编译时间。
请注意,并非所有的变化都是为了在所有情况下提供小幅度的增长。通常,我们的目标是长极角的情况,试图将编译时间降到接近该大小项目的预期平均值的某个地方。我们最近开始关注AAA游戏标题作为基准。还有更多的工作要做。
该工具集有三个部分需要单独改进。首先是编译器的前端, 在“c1xx.dll”中实现。它是一组代码,它接受一个.cpp文件并生成一个独立于语言的中间语言(IL),然后将其输入到编译器的后端。 编译器后端在“c2.dll”中实现。它从前端读取IL并从中生成一个obj文件,其中包含实际的机器代码。 最后,linker(link.exe)是一个工具,它从后端获取各种obj文件以及您提供给它的任何lib文件,并将它们混合在一起生成最终的二进制文件。
编译器前端吞吐量
在许多项目中,编译器的前端是构建吞吐量的瓶颈。幸运的是,通过使用 /MP
switch(它将生成多个cl.exe进程来处理多个输入文件),通过MSBuild或其他生成系统进行外部切换,甚至可以使用类似于increduild的工具跨机器分布。构建项目的有效分布和并行化应该是提高吞吐量的第一步。
你应该采取的第二步是确保你有效地利用 PCH文件 . PCH文件本质上是cl.exe的内存转储,带有完全解析的.h文件– 省去了每次都要重做的麻烦 . 你会惊讶这有多重要;头文件(如windows.h或某些DirectX头文件)在完全预处理后可能会非常庞大,并且通常占后处理源文件的绝大部分。PCH文件在这里可以使世界不同。这里的关键是只包括不经常更改的文件,确保PCH文件是您的净收益。
最后一条建议实际上是限制你所包含的内容。在PCH文件之外,包含一个文件实际上是一个相当昂贵的过程,需要搜索包含路径中的每个目录。它需要大量的文件I/O,而且是一个可传递的过程,每次都需要重复。这就是为什么PCH文件如此有用。在微软内部,人们通过对他们的项目进行“包含你使用的内容”的传递,已经取得了很多成功。使用 /showIncludes
这里的选项可以让你知道这是多么昂贵,并帮助指导你只包括你所使用的。
最后, 我想让你知道 /Bt
cl.exe选项 . 这将输出每个源文件在前端(以及后端和链接器)花费的时间。这将帮助您确定瓶颈,并知道您要花时间优化哪些源文件。
下面是我们在前端更改的一些内容,以帮助提高性能。
刷新的PGO计数
PGO,或“轮廓引导优化” ,是一种在Microsoft中广泛使用的后端编译器技术。基本思想是生成产品的特殊指令构建,运行一些测试用例来生成概要文件,并基于收集的数据重新编译/优化。
我们发现在编译和优化前端二进制文件(c1xx.dll)时使用的是较旧的概要文件数据。当我们重新测试并重新收集PGO数据时,我们看到性能提高了10%。
这里的教训是,如果您使用PGO是为了提高产品的性能,请确保定期回忆您的培训数据!
删除u假设的用法
__假设(0)是编译器后端的提示 某个代码路径(可能是标签的默认大小写等)是不可访问的。许多产品会将其包装在一个宏中,名为UNREACHABLE之类的东西,实现了这样的调试生成将断言,而ship生成将把这个提示传递给编译器。编译器可能会执行一些操作,例如删除以该语句为目标的分支或开关。
因此,如果在运行时一个uuu-assume(0)语句实际上是可访问的,则可能会导致错误的代码生成。这会以许多不同的方式导致问题(有些人认为这可能会导致安全问题)–因此我们做了一个实验,通过重新定义宏,看看简单地删除所有的uuuu assume(0)语句会产生什么影响。如果回归很小,考虑到它引起的其他问题,也许它不值得在产品中使用。
令我们大吃一惊的是,在去掉了假设语句的情况下,前端的速度实际上提高了1-2%。这使得这个决定相当容易。这里的根本原因似乎是,尽管在许多情况下,假设可以有效地提示优化器,但它似乎实际上可以抑制其他优化(尤其是较新的优化)。对于将来的版本来说,改进假设也是一个活跃的工作项。
改进winmd文件加载
对winmd文件的加载方式进行了各种更改,增加了大约10%的加载时间(可能是总编译时间的1%)。这只会影响UWP项目。
编译器后端
编译器后端包括优化器。这里有两类吞吐量问题,“一般”问题(我们做了大量工作,希望获得1-2%的全面胜利)和“长极”问题,其中特定函数导致一些优化走上病态的道路,需要30秒或更长时间来编译-但绝大多数人没有受到影响。我们关心并致力于两者。
如果你使用 /Bt
到cl.exe并在c2.dll(后端)中看到一个异常值,该异常值花费了不寻常的时间,下一步是使用 /d2cgsummary
. cgmsummary(或“代码生成摘要”)将告诉您所有时间都在使用哪些函数。如果幸运的话,函数不在关键性能路径上,您可以 像这样禁用函数的优化 :
#pragma optimize("", off)void foo() {...}#pragma optimize("", on)
那么优化器就不会在这个函数上运行了。然后与我们联系,看看我们能否解决吞吐量问题。
除了关闭具有病态编译时间的函数的优化器之外,我还需要警告您,在可能的情况下,不要过于自由地使用forceinline。通常,客户需要使用forceinline来让内联器做他们想做的事情,在这种情况下,建议应该尽可能有针对性。编译器的后端非常重视forceinline。它不受所有内联预算检查的约束(forceinline函数的开销甚至不计入内联预算),并且始终受到尊重。多年来,我们已经看到许多案例,出于代码质量(CQ)的原因,大量应用forceinline可能会造成一个主要的瓶颈。基本上,这是因为与其他编译器不同,我们总是内联 预优化 从前端直接从IL执行功能。这有时是一个优势-我们可以在不同的环境下做出不同的优化决策,但缺点之一是我们最终会重做很多工作。如果你有一个很深的“树”,这可以很快得到病理。这是Tensorflow/lib等地方编译时间过长的根本原因。这是我们希望在未来版本中解决的问题。
查看LTCG构建的增量链接时代码生成(iLTCG) . 增量LTCG是一种相对较新的技术,它允许我们只对LTCG构建中已更改的函数(以及依赖项,例如它们的内联线)进行代码生成。没有它,我们实际上会在整个二进制文件上重做代码生成,即使只是一个小的编辑。如果您因为LTCG对内部开发循环造成的影响而放弃了LTCG的使用,请使用iLTCG再看一眼。
最后一条建议也适用于LTCG构建(其中有一个link.exe进程执行codegen而不是分布在cl.exe进程之间),请考虑通过 /cgthreads#
. 正如您将在下面看到的,我们在这里做了一些更改以更好地扩展,但是默认情况下仍然使用4核。在未来,我们将考虑增加默认的核心数,甚至使其与机器上的核心数保持动态。
以下是最近对后端所做的一些更改,这些更改将帮助您免费更快地构建:
内联读卡器缓存
在其他一些编译器中,内联是通过在内存中优化后保留所有内联候选函数来实现的。内联只是将内存复制到当前函数的适当位置。
然而,在VC++中,我们实现内联的方式略有不同。实际上,我们从磁盘上重新读取了一个inline的未优化版本。这显然会慢很多,但同时可能会占用更少的内存。这可能会成为一个瓶颈,尤其是在有大量forceinline调用需要处理的项目中。
为了缓解这种情况,我们朝着其他编译器的“内存”内联方法迈出了一小步。在函数内联读取一定次数后,后端现在将缓存该函数。一些实验表明N=100是吞吐量和内存使用之间的一个很好的平衡。这可以通过传递 /d2FuncCache#
编译器(或 /d2:-FuncCache#
到LTCG构建的链接器)。传递0将禁用此功能,传递50意味着函数只有在内联50次之后才被缓存,以此类推。
类型系统构建改进
这适用于LTCG构建。在LTCG构建开始时,编译器后端尝试构建程序中使用的所有类型的模型,以用于各种优化,如虚拟化。这是缓慢的,需要大量的内存。在过去,当遇到涉及类型系统的问题时,我们建议人们通过传递关闭它 /d2:-notypeopt
到链接器。最近我们对类型系统做了一些改进,希望能够彻底解决这个问题。实际的变化是非常基本的,它们涉及到我们如何实现位集。
更好地扩展到多核
编译器后端是多线程的。但也有一些限制:我们按照“自底向上”的顺序编译——这意味着函数只有在编译完所有被调用方之后才被编译。这样函数就可以使用在被调用方编译期间收集的信息进行更好的优化。
在这方面一直有一个限制:超过一定大小的函数是免责的,只需立即开始编译,而不使用这种自下而上的信息。这样做是为了防止编译在单个线程上遇到瓶颈,因为它在最后几个剩余的大量函数中来回运行,而这些函数由于依赖关系树很大而无法更快地启动。
我们重新评估了“大功能”限制,并大幅降低了它。以前,所有Microsoft中只有少数函数触发了此行为。现在我们期望每个项目可能有几个函数。我们没有用这个变化来衡量任何重大的CQ损失,但是吞吐量的增加可能很大,这取决于一个项目以前在其大型功能上有多少瓶颈。
其他内联改进
在内联期间,我们对符号表的构造和合并方式进行了修改,这提供了一个全面的附加小好处。
细粒度锁定
像大多数项目一样,我们不断地分析和检查锁定瓶颈,并追击大人物。因此,我们在一些实例中改进了锁定的粒度,特别是IL文件的映射和访问方式以及符号之间的映射方式。
围绕符号表和符号映射的新数据结构
在LTCG期间,需要做大量的工作来正确地跨模块映射符号。这部分代码是使用新的数据结构重写的,以提供增强。这尤其有助于“统一”风格的构建,这在游戏行业很常见,这些符号键映射可能会变得相当大。
LTCG的多线程附加部分
说编译器是多线程的只是部分正确。我们说的是后端的“代码生成”部分——到目前为止要完成的最大工作量。
然而,LTCG构建要复杂得多。他们还有一些其他的部分。我们最近做了多线程处理这些部分中的另一部分的工作,使LTCG构建的速度提高了10%。这项工作将继续到未来的版本。
链接器改进
如果你用的是 LTCG公司 (你应该是),你可能会认为链接器是构建系统中的瓶颈。这有点不公平,因为在LTCG期间,链接器只是调用c2.dll来生成代码——所以上面的建议适用。但除了代码生成之外,链接器还有它的传统工作,即解析引用并将obj粉碎在一起以生成最终的二进制文件。
你在这里能做的最大的事就是使用“快速链接” . Fastlink实际上是一种新的PDB格式,通过传递 /debug:fastlink
到链接器。这大大减少了在链接期间生成PDB文件所需的工作。
在调试版本中, 你应该使用 /INCREMENTAL
. 增量链接允许链接器只更新已修改的obj,而不是重建整个二进制文件。这会让你很生气 戏剧性的 在“内部开发循环”中,您正在进行一些更改、重新编译/链接和测试以及重复。与fastlink类似,我们在这里做了大量的稳定性改进。如果你以前试过但发现它不稳定,请再给它一次机会。
最近您将免费获得的一些链接器吞吐量改进包括:
新的ICF启发式算法
ICF-或相同的comdat折叠,是链接器中最大的瓶颈之一。在这个阶段,为了节省空间,所有相同的函数都被折叠在一起,对这些函数的任何引用也被重定向到它的单个实例。
这次发布,ICF得到了一点重写。总结一下,我们现在依赖一个强大的哈希函数来实现相等,而不是执行memcmp。这大大加快了ICF的速度。
回退到64位链接器
对于大型项目,32位链接器存在地址空间问题。它通常将内存映射文件作为访问它们的一种方式,如果文件很大,这并不总是可能的,因为内存映射需要连续的地址空间。作为备份,链接器采用较慢的缓冲I/O方法,根据需要读取部分文件。
众所周知,缓冲I/O代码路径比内存映射I/O慢得多。因此,我们添加了新的逻辑,其中32位链接器在返回到缓冲I/O之前,尝试以64位进程的形式重新启动自身。
Fastlink改进
/调试:fastlink是一个相对较新的特性,它显著加快了调试信息的生成,这是整个链接时间的主要部分。我们建议大家仔细阅读这个功能,如果可能的话就使用它。在这个版本中,我们使它更快、更稳定,我们将继续在未来的版本中投资fastlink。如果你最初使用它,但搬走了因为一个坏的经验,请再给它一次机会!在15.6及更高版本中,我们有更多的改进。
增量链接回退
我们听到的关于增量链接的抱怨之一是,它有时可能比完全链接慢,这取决于修改了多少objs或lib。我们现在更积极地发现这种情况,并直接救援到一个完整的链接。
结论
这个列表并不是详尽无遗的,但它很好地总结了过去几个月中以吞吐量为中心的一些较大变化。如果您以前对VC++的编译时间或链接时间感到失望,我建议您使用15.5工具集再尝试一下。如果您碰巧有一个项目,与其他类似大小的项目或其他工具集相比,它需要花费不合理的长时间来编译,我们很乐意看一看!
记住,你可以用 /d2cgsummary
到cl.exe或 /d2:-cgsummary
以帮助诊断代码生成吞吐量问题。这包括关于上面讨论的内联读取器缓存的信息。对于整个工具集,将/Bt传递到cl.exe,它将分解每个阶段(前端、后端和链接器)花费的时间。当您传递它/time+时,链接器本身将输出它的时间细分,包括ICF期间花费的时间。