小事:加速C++编译

2020-09-21 05:02:48

“小事”是一系列基于洛克斯利内部培训课程的新帖子。通常,内容要么是专有的(例如,特定主密钥平台的内部工作原理),要么通常不是很有趣(例如,我们的内部库和工具),但有时内容适合更广泛的受众,在这种情况下,我想分享它们。

这篇文章将介绍一些加速C++编译的源码级技术,以及它们的(不)优势。它不会谈论C++之外的事情,比如购买更好的硬件,使用更好的构建系统,或者使用更智能的链接器[1]。它也不会讨论可以找到编译瓶颈的工具,因为这将在稍后的文章中讨论。

我将从C++编译模型的快速概述开始,为我稍后将展示的一些技巧提供上下文。请注意,此概述将非常粗略,如果您想详细了解C++标准中定义的9阶段编译模型的细微之处,请查看其他地方。

第一步是预处理。在此过程中,预处理器获取一个.cpp文件并对其进行解析,查找预处理器指令,如#include、#define、#ifdef等。

它包含一个预处理器指令#DEFINE。上面说以后出现的任何KONSTANTA都应该用123代替。通过预处理器运行该文件会产生如下输出:

$clang++-E tiny.cpp#1";tiny.cpp";#1";<;内置>;";1#1";<;内置>;";3#383";<;内置>;";3#1";<;命令行>;1#1";<;内置>;";2#1";tiny.cpp";2int main(){return 123;}。

我们可以看到,作为回报,KONSTANTA的KONSTANTA部分被替换为123,这是理所当然的。我们还看到编译器给自己留下了一堆其他的注释,我们并不太在意[2]。

预处理器模型的一个大问题是,#include指令的字面意思是将该文件的所有内容复制粘贴到这里。当然,如果该文件内容包含更多的#include指令,那么将打开更多的文件,将其内容复制出去,反过来,编译器将有更多的代码需要处理。换句话说,预处理通常会显著增加输入的大小。

预处理之后,该文件将有28115[3]行用于下一步(编译)处理。

文件经过预处理后,被编译成目标文件。目标文件包含要运行的实际代码,但在没有链接的情况下无法运行。原因之一是目标文件可以引用它们没有定义(代码)的符号(通常是函数)。例如,如果.cpp文件使用已声明但未定义的函数,则会发生这种情况,如下所示:

您可以使用nm(Linux)或Dumpbin(Windows)查看编译后的目标文件内部,以了解它提供了哪些符号以及需要哪些符号。如果我们查看unlinked.cpp文件的输出,我们会得到以下结果:

U表示该符号未在此对象文件中定义。T表示符号位于文本/代码部分,并且已导出,这意味着其他目标文件可以从这个未链接的位置获取foo。o。重要的是要知道,符号也可能出现在目标文件中,但对其他目标文件不可用。这样的符号用t标记。

在将所有文件编译成目标文件之后,必须将它们链接到最终的二进制工件中。在链接期间,以特定格式(例如ELF)将所有各种目标文件粉碎在一起,并且使用由不同目标文件(或库)提供的符号地址来解析对目标文件中未定义符号的各种引用。

概述完成后,我们可以开始处理加快代码编译的不同方法。让我们从简单的开始吧。

包含一个文件通常会带来大量额外的代码,然后编译器需要解析和检查这些代码。因此,加快代码编译的最简单、通常也是最大的方法就是#include较少的文件。减少包含集在头文件中特别有用,因为它们很可能是从其他文件中包含的,从而放大了改进的影响。

要做到这一点,最简单的方法是删除任何未使用的Include。未使用的包含不应该经常发生的内容,但有时它们在重构过程中会被抛在后面,使用IWYU这样的工具可以使操作变得简单。然而,仅仅清理未使用的Include不太可能提供很多好处,因此您将不得不使用更大的枪、转发声明和手动大纲。

但是在解释向前声明和手动大纲之前,我想快速回顾一下包含头文件的成本,这样我们就可以直观地了解削减包含图可以带来什么样的速度提升。

下表显示了Clang[5]编译只包含一些stdlib头的文件所需的时间。

第一行显示编译一个完全空的文件所需的时间,以提供编译器启动、读取文件和不执行任何操作所需的基准时间。其他的几行更有趣。正如第二行所说,仅包括<;Vector>;就会使编译时间增加57ms,即使不会发出实际的行。我们可以看到,包含<;string>;的成本是<;Vector>;的两倍多,而包含<;stdexcept>;的成本与包含<;string>;的成本大致相同。

更有趣的是标题组合的行,因为没有任何标题组合比单独编译每个标题更昂贵。原因很简单:它们的内部包含重叠。最极端的情况是<;string>;+<;stdexcept>;,因为<;stdexcept>;基本上是从std::Exception派生的几个类型。

即使您不使用标题中的任何内容,也必须付费。

很多时候,当我们提到一种类型时,我们只需要知道它是否存在,而不需要知道它的定义。常见的情况是创建指向类型的指针或引用,在这种情况下,您需要知道类型存在(转发声明),但不需要知道它看起来是什么样子(定义)。

#include";key-shape.hpp";//提供KeyShapesize_t count_Difference(KeyShape const&;lhs,KeyShape const&;rhs){assert(lhs.position()==rhs.position());...}的完整定义。

您还可以将正向声明与一些模板化类一起使用,它们的大小不会根据模板参数而改变,例如std::Unique_ptr和std::Vector[6]。但是,这样做可能会迫使您概述构造函数、析构函数和其他特殊成员函数(SMF),因为这些函数通常需要查看类型的完整定义。然后,您的代码最终如下所示:

//foo.hpp#include<;memory&>class Bar;class foo{std::Unique_ptr<;Bar>;m_ptr;public:foo();//=default;~foo();//=default;};

注意,我们仍然使用编译器生成的默认构造函数和析构函数,但是在.cpp文件中这样做,在该文件中我们可以看到Bar的完整定义。我还喜欢使用//=default;注释向阅读代码的其他程序员发出信号,表明SMF是显式声明的,但将是缺省的,因此其中不会有任何特殊的逻辑。

使用此技术时,请记住,如果没有LTO,概述的函数不能内联。换句话说,您可能不想仅仅因为可以列出每个函数的大纲,因为调用琐碎的函数可能比直接内联它们的代码要昂贵得多。

显式大纲背后的想法非常简单:有时,如果将一段代码显式地从函数中分离出来,我们会得到更好的结果。可能具有讽刺意味的是,最常见的原因之一是通过使函数的公共路径变小来改进内联。但是,在我们的例子中,这样做的原因是为了缩短编译时间。

如果一段代码的编译成本很高,并且内联它对性能并不重要,那么只有一个TU需要为编译它付费。通常抛出异常,特别是来自<;stdexcept>;的异常,这就是典型的例子。抛出异常会生成相当多的代码,并且抛出更复杂的标准异常类型(如std::Runtime_Error)也需要昂贵的[7]头,<;stdexcept>;要包括在内。

通过按照[[noreturn]]void jo_foo(char const*msg)的行将所有的throfoo;语句替换为对帮助器函数的调用,调用站点变得更小,并且与Throw语句相关的所有编译成本都集中在单个TU中。即使对于只存在于.cpp文件中的代码,这也是一个有用的优化。对于头[8]中的代码,由于文本代码包含的乘法效应,这种优化几乎是关键的。

让我们用一个简单的例子来试一试:考虑一个玩具conexpr static_Vector[9]实现。如果没有更多的容量,它将从PUSH_BACK抛出STD::LOGIC_ERROR,我们将测试两个版本:一个是内联抛出异常,另一个是调用帮助器函数进行抛出。

#include<;stdexcept>;class static_Vector{int arr[10]{};std::size_t idx=0;public:conexpr void Push_back(Int I){if(idx>;=10){throststd::logic_error(";overfled static Vector";);}arr[idx++]=i;}constexpr std::size_t size()const{return idx;}//其他相应的constexpr访问器和修饰符};

行外抛出实现中唯一的变化是抛出std::logic_error(...)。行被替换为对Throw_LOGIC_ERROR帮助器函数的调用。除此之外,它们都是一样的。

我们现在将创建包括静态向量头的5个TU,并包含一个使用静态向量的简单函数,如下所示:

#include";static-Vector.hpp";void foo1(Int N){static_Vector vec;for(int i=0;i<;n/2;++i){vec.ush_back(I);}}。

使用与以前相同的编译器、设置[5:1]和机器,在内联抛出情况下编译一个完整的二进制文件需要883.2毫秒(±1.8),而在行外抛出情况下编译一个完整的二进制文件需要285.5毫秒(±0.8)。这是一个显著的改进(~3倍),并且改进随着包括static-Vector tor.hpp头的编译TU数量的增加而增长。当然,最好也记住TU越复杂,改进的效果就越小,因为<;stdexcept>;头的成本在TU的总成本中所占的比例更小。

关于通过只包含更少的东西来改善构建时间,没有什么可说的,所以现在是时候看看另一个诀窍了:使用隐藏的朋友。

隐藏朋友是一种技术的名称,它使用关于名称(函数/运算符)可见性的相对模糊的规则来减小重载集的大小。其基本思想是,只能通过参数相关查找(ADL)找到和调用仅在类内声明的友元函数。这意味着该函数不参与重载解析,除非表达式中存在其拥有的";类型。

Struct A{Friend int操作符<;<;(A,int);//隐藏朋友int操作符<;<;(int,A);//不是隐藏朋友};int操作符<;<;(int,A);

在上面的代码片段中,只有操作符<;<;的第一个重载是隐藏的朋友。第二个重载不是,因为它也是在A';的声明之外声明的。

重载解析失败时更短的编译错误。比较有隐藏的朋友和没有隐藏的朋友的相同表情的错误。

隐式转换发生的可能性较小。要进行隐式转换,至少必须有一个参数已经具有目标类型,不能选择需要所有参数隐式转换的重载。举例。

考虑到这篇文章的主题,最后一个优势是我们所关心的。那么,使用隐藏的朋友会带来多大的不同呢?为了测试这一点,我生成了一个简单的.cpp文件,其中包含200个类似于上面的结构,总共提供了400[10]个操作符<;<;重载。TU还包含一个返回A1{}<;<;1的单行函数,以诱导操作符<;<;的重载解析。

使用隐藏重载时,Clang[5:2]25.4(±0.1)ms将此TU编译成目标文件。在没有隐藏过载的情况下,所需时间为36.7(±0.2)ms。这已经是一个很好的提速了,问题是,提速会不会随着TU中更多的过载分辨率而扩大?让我们尝试修改该函数,使其包含1/10/50/100个汇总操作符<;调用,并查看结果。

正如我们可以看到的,速度随着TU要求的重载分辨率的数量而增加,即使重载解析总是发生在相同的表达式中。然而,即使对于具有大的过载集和许多过载分辨率的大TU,绝对数的差异也约为50ms。这是一个很好的加速,但是如果您还记得包含不同stdlib头的成本表,就会知道这比编译空文件和包含<;Vector>;的文件之间的差异要小。

在实践中,这意味着删除不必要的#include比使用隐藏的朋友更有可能在编译时间上获得更大的改进。但是,隐藏的朋友也会以不同的方式改进您的代码,并且在高度模板化的代码中具有惊人的强大功能。

使用隐藏的朋友有一个缺点。声明类和隐藏朋友的标头必须包含声明隐藏朋友所涉及的所有其他声明。例如,如果您需要为流插入运算符[11]包括STD::OSTREAM&;的<;iosfwd>;,则这会显著增加标头的权重(<;iosfwd>;for std::ostream&;)。

总而言之,使用隐藏的朋友可以缩短编译时间,改善错误消息,还可以防止某些情况下的隐式转换。这意味着您应该默认提供操作员重载和ADL定制点作为隐藏的朋友[12]。

现在让我们来看一下我们今天要看的最后一个技巧,给链接器施加的压力更小。

有两种方法可以减少链接器的工作。第一个是从链接中隐藏符号,第二个是缩短符号名称。因为后者是...。不值得这样做,除非在极端情况下[13],我们只看前者。

在编译模型概述期间,我提到符号可能存在于目标文件中,而不适用于其他目标文件。这样的符号被认为具有内部链接(与具有外部链接相反)。具有内部链接的符号的编译速度优势来自这样一个事实,即链接器不必将其记录为可用,因此需要做的工作更少。

正如我们稍后将看到的,符号隐藏还有运行时性能和对象文件大小方面的好处,但首先,让我们看一个示例。

//local-linkage.cppstatic int helper1(){return-1;}命名空间{int helper2(){return 1;}}int do_Stuff(){return helper1()+helper2();}。

在上面的示例中,helper1和helper2都有内部链接。Helper1,因为它包含在一个未命名的[14]命名空间中。我们可以使用nm进行检查:

$clang++-c local-linkage.cpp&;&;nm-C local-linkage.o00000000000000 T do_Stuff()0000000000000030 t helper1()0000000000000040 t(匿名命名空间)::helper2()。

更有趣的是,如果我们提升优化级别,helper1和helper2都会完全消失。这是因为它们足够小,可以内联到do_Stuff中,并且来自不同TU的任何代码都不能引用它们,因为它们有内部链接。

这也是内部链接如何提高运行时性能的原因。因为编译器可以看到使用该符号的所有位置,所以它有更多的动机将其内联到调用点,以完全删除该函数。即使它不能,它也可以根据它的调用点用额外的知识优化代码。

隐藏符号对编译性能的改善通常很小。毕竟,链接器对每个符号所做的工作量很小,特别是如果您的链接器很聪明的话。但是,大型二进制文件可能有数百万个符号,就像隐藏的朋友一样,隐藏符号也有非编译性能优势,即防止帮助器函数之间的ODR冲突。

这就是这篇文章的全部内容。在稍后的帖子中,我打算写一些工具,这些工具可以用来查找不必要地影响编译时间的地方,以及其他一些缓解这种情况的技术。

然而,如果您的日常工作是编写C++,那么您应该使用忍者,在具有大量内核和RAM的机器上工作,看看LLD是否适合您。↩︎。

这些可以有许多不同的用途。其中一个更有趣的功能是编译器可以标记一行来自哪个文件(和行),这样它就可以使用适当的上下文发出诊断。MSVC STL使用类似的技巧告诉编译器,当用户要求调试“只调试我的代码”时,它应该单步执行所有层,例如std::function内部。↩︎。

至少针对使用特定版本的Clang的特定版本的libstdc++。更改任何一个的版本都可以更改确切的数字,但不会改变那么多...。另一方面,切换到MSVC+MSVC STL可以在预处理后获得大约50k行。↩︎。

在撰写本文时,IWYU有一个很大的问题,因为它硬编码了Google的C++风格指南中关于包含的假设。这意味着,如果您的项目使用相同的样式,即使您仍然需要检查它所做的更改,或者它将包含<;iosfwd>;以提供size_t;,您也可以很好地使用该样式。如果您的项目使用<;>;样式作为内部包含,则它将建议替换您的每一个包含。↩︎。

编译使用版本10中的Clang,针对libstdc++版本8(发布日期20200304)进行编译,使用-g-std=c++17-c-o/dev/null命令行参数。↩︎

请记住,std::Vector由三个指向动态分配的内存块的指针组成。因为sizeof(T*)不会基于T改变,所以sizeof(std::Vector<;T>;)也不会基于T.↩︎改变。

这可能意味着模板化代码、常量表达式代码,或者仅仅是对于内联很重要的纯代码,即使没有LTO构建也是如此。↩︎。

Static_Vector是具有固定容量的向量,通常分配在对象本身中。这允许它在C++20';的constexpr分配[15]之前被conexpr,但权衡的是您必须提前知道需要多少内存,并且必须放入堆栈。↩︎。

我认为使用400个重载可以在相对较大(但并非不可能)的代码库中模拟TU。如果您觉得这个数字似乎太高,请考虑基本上每个Qt类型都有用于QDebug的运算符<;<;,并且您自己的自定义类型可能会为std::ostream提供运算符<;<;。或者,可以考虑创建许多不同类型的高度泛型库,这会导致模板化函数的许多不同实例化。↩︎。

我们的代码库避免对特定类使用隐藏的朋友,因为即使在Linux上,包括<;iosfwd>;也会使头文件重量增加3倍以上。对于MSVC,差异是数量级的。↩︎。

就像默认情况下使用std::Vector的建议一样,这并不意味着从来没有使用其他东西更好的情况。只是你需要有一个偏离的理由,而且你应该总是记录下你有的原因。↩︎。

我唯一能想到的就是我自己建议缩短你的符号。

.