更多互联网新鲜资讯、工作奇淫技巧关注原创【飞鱼在浪屿】(日更新)
这篇文章将介绍一些用于加速C ++编译的源代码级技术。它不会谈论C ++外部的事情,例如购买更好的硬件,使用更好的构建系统或使用更智能的链接器。它也不会谈论可以发现编译瓶颈的工具。
C ++编译模型概述
从C ++编译模型的简介开始,为稍后将介绍的一些技巧提供铺垫。
C ++二进制文件的编译分为3个步骤:
- 预处理
- 汇编
- 链接
预处理
第一步是预处理。这期间,预处理器需要一个.cpp文件,并解析它,寻找预处理器指令,如#include,#define,#ifdef,等
// #define KONSTANTA 123 int main() { return KONSTANTA; }
这个例子包含一个预处理程序指令#define。以后出现的任何情况KONSTANTA都应替换为123。通过预处理器运行文件将导致如下所示的输出:
$ clang++ -E # 1 "" # 1 "<built-in>" 1 # 1 "<built-in>" 3 # 383 "<built-in>" 3 # 1 "<command line>" 1 # 1 "<built-in>" 2 # 1 "" 2 int main() { return 123; }
我们可以看到,return KONSTANTA的KONSTANTA部分已被替换123。还看到编译器留下了很多其他注释,这里对此并不太关心。
预处理器模型的最大问题是该#include指令的字面意思是“在此处复制粘贴此文件的所有内容”。当然,如果该文件的内容包含其他#include指令,则将打开更多文件,将其内容复制过去,进而,编译器将需要处理更多代码。也就是说,预处理通常会明显增加输入的大小。
以下是使用流的C ++中的简单“ Hello World”。
// #include <iostream> int main() { std::cout << "Hello World\n"; }
预处理后,该文件将有28115 行用于下一步(编译)进行处理。
$ clang++ -E | wc -l 28115
汇编
预处理文件后,将其编译为目标文件。目标文件包含要运行的实际代码,但是如果没有链接就无法运行。原因之一是目标文件可以引用它们没有其定义(代码)的符号(通常是函数)。例如,如果.cpp文件使用已声明但未定义的函数,则发生这种情况:
// unlinked.cpp void bar(); // 可能任何其他位置定义 void foo() { bar(); }
您可以使用nm(Linux)或dumpbin(Windows)在已编译的目标文件中查看其提供的符号以及所需的符号。如果我们查看unlinked.cpp文件的输出,则会得到以下信息:
$ clang++ -c unlinked.cpp && nm -C unlinked.o U bar() 0000000000000000 T foo()
U表示该符号未在此目标文件中定义。T表示该符号在text / code部分中,并且已将其导出,这意味着其他对象文件可以foo从this 获得unlinked.o。符号也可能存在于目标文件中,但不可用于其他目标文件。此类符号用标记t。
链接
在将所有文件编译成目标文件之后,必须将它们链接到最终的二进制文件中。在链接期间,所有各种目标文件都以特定格式(例如ELF)拼凑在一起,并使用由不同目标文件(或库)提供的符号地址来解析对目标文件中未定义符号的各种引用。
接下来开始研究加快代码编译速度的各种方法。
#include 少用
包含文件通常会带来很多额外的代码,然后编译器需要对其进行解析和检查。因此,加快代码编译速度的最简单方法(通常也是最有效方法)#include较少文件数量。减少头文件特别有好处,因为它们很可能会被其他文件间接包含进来,从而扩大了改进的影响。
最简单的方法是删除所有未使用的include。未使用的include可能不会经常发生,但是有时它们在重构过程中会被遗忘,使用IWYU之 类的工具可以()简化操作。
包含头文件的成本
下表显示了Clang编译仅包含一些stdlib标头的文件所需的时间。
第一行显示了编译一个完全空的文件所需的时间。这是编译器启动,读取文件以及不执行任何操作所需的基准时间。第二行看出,<vector>即使没有实际使用,仅包括在内就增加了57 ms的编译时间。还可以看到,include <string>的成本是<vector>的两倍多,include <stdexcept>的成本几乎和<string>相同。
包含多个头文件的结果比较有趣,因为多个头文件组合并不是单独编译每个头文件的成本相加。原因很简单:它们的内部包含有重叠。最极端的情况是<string>+ <stdexcept>,因为<stdexcept>基本上是衍生<string>的几种std::exception类型,所以这里和<string>成本一样。
- 即使不使用头文件中的任何内容,也仍然需要为此付出成本。
- include成本既不能简单地相加,也不能相减。
forward declaration/前向声明/预先声明
通常,当提到一个类型时,只需要知道它的存在而不必知道它的定义。通常的做法是创建类型的指针或引用(forward declaration)。例如:
class KeyShape; // forward declaration size_t count_differences(KeyShape const& lhs, KeyShape const& rhs);
只要实现文件包含相应的头,就可以:
#include "key-; // KeyShape的完整定义 size_t count_differences(KeyShape const& lhs, KeyShape const& rhs) { asser() == r()); ... }
还可以将前向声明与某些模板化类一起使用,随模板参数不会改变大小,例如std::unique_ptr和std::vector。但是,这样做可能会需要你重新定义构造函数,析构函数和其他特殊成员函数(SMF),因为通常需要查看这些类型的完整定义。代码看起来像这样:
// #include <memory> class Bar; class Foo { std::unique_ptr<Bar> m_ptr; public: Foo(); // = default; ~Foo(); // = default; };
// #include "bar.hpp" Foo::Foo() = default; Foo::~Foo() = default;
这里仍然使用编译器生成的默认构造函数和析构函数,但是在.cpp文件中可以看到完整定义,但仍使用它Bar。这里习惯使用该// = default;注释来告诉其他程序员,已明确声明指定函数,但将使用默认实现,因此其中不会包含任何特殊逻辑。
显式概述
显式概述的基本思想很简单:如果将一段代码从一个函数中分离出来,通过内联减小函数的调用路径路径。而这样做的还有个好处是缩短编译时间。
抛出一个异常<stdexcept>会生成大量代码,而引发更复杂的标准异常类型(例如std::runtime_error),也需要inclue重量级的头文件<stdexcept>。
通过改为throw foo;使用辅助函数void throw_foo(char const* msg),调用开销变得更小,并且与该throw语句相关的所有编译成本都集中在单个模块中。即使对于仅存在于.cpp文件中的代码,这也是一个有用的优化。
简单的示例:如果没有更多的空间进行push_back,constexpr static_vector实现将引发std::logic_error。将比较两个版本:一个抛出异常的内联,和一个改为调用一个辅助函数。
内联抛出实现看起来像这样:
#include <stdexcept> class static_vector { int arr[10]{}; std::size_t idx = 0; public: constexpr void push_back(int i) { if (idx >= 10) { throw std::logic_error("overflew static vector"); } arr[idx++] = i; } constexpr std::size_t size() const { return idx; } };
另一个版本throw std::logic_error(...)行被调用throw_logic_errorhelper函数代替。做以下实验
#include "; void foo1(int n) { static_vector vec; for (int i = 0; i < n / 2; ++i) { vec.push_back(i); } }
在内联抛出异常情况下编译一个完整的二进制文件需要883.2 ms(±1.8),而在外联函数抛出下要花费285.5 ms (±0.8)。这是显著的(〜3倍)改进,并且随着包含标头的已编译目标文件数量的增加,改进也越发看到效果。当然,文件越复杂,改进就越小,因为<stdexcept>报头的成本在总成本中所占的比例较小。
隐藏的友元
隐藏的友元是相关符号(函数/运算符)的可见性的模糊来减少重载集的大小。基本思想是只能通过参数依赖查找( Argument Dependent Lookup ,ADL)找到并调用仅在类内部声明的friend函数。然后,这意味着该函数将不参与重载解析,除非该表达式的“拥有”类型存在。
隐藏的友元操作符函数<<
struct A { friend int operator<<(A, int); // hidden friend friend int operator<<(int, A); // not a hidden friend }; int operator<<(int, A);//A之外声明过了
在上面的代码段中,只有的第一个重载operator<<是隐藏的友元。第二次重载不是,因为它在A声明之外声明过。
减少过载集让编译速度更快,因为编译器要做的工作较少。
减少链接工作量
编译模型中,一个符号可能会出现在目标文件中,而其他目标文件无法使用。称这种符号具有内部链接。具有内部链接的符号在编译速度更快,是因为链接器不必随时跟踪它的可用状态,因此要做的工作较少。符号隐藏在内部还对运行时性能和目标文件大小有好处。
// local-linkage.cpp static int helper1() { return -1; } namespace { int helper2() { return 1; } } int do_stuff() { return helper1() + helper2(); }
在上面的示例中,helper1和helper2都是内部链接。helper1因为有static关键字,helper2因为它包含在一个未命名的名字空间中。我们可以用nm:
$ clang++ -c local-linkage.cpp && nm -C local-linkage.o 0000000000000000 T do_stuff() 0000000000000030 t helper1() 0000000000000040 t (anonymous namespace)::helper2()
现在开启O1优化级别,helper1和helper2完全消失。这是因为它们足够小,可以内联do_stuff,并且来自别的的代码都无法引用它们,因为它们是内部链接。
$ clang++ -c local-linkage.cpp -O1 && nm -C local-linkage.o 0000000000000000 T do_stuff()
这也是内部链接如何提高运行时性能的方式。因为编译器可以看到使用符号的所有位置,所以它可以将其内联到调用处,以完全删除该函数。
通过隐藏符号来提高编译性能通常很小。毕竟,链接每个符号所做的工作量很小。但是,大型二进制文件可以包含数百万个符号,就像与隐藏友元一样,隐藏符号也具有非编译性能优势,即可以防止在辅助函数之间违反ODR。