您的位置 首页 > 娱乐休闲

程序员效率分享:加速C ++编译

更多互联网新鲜资讯、工作奇淫技巧关注原创【飞鱼在浪屿】(日更新)

这篇文章将介绍一些用于加速C ++编译的源代码级技术。它不会谈论C ++外部的事情,例如购买更好的硬件,使用更好的构建系统或使用更智能的链接器。它也不会谈论可以发现编译瓶颈的工具。


C ++编译模型概述

从C ++编译模型的简介开始,为稍后将介绍的一些技巧提供铺垫。

C ++二进制文件的编译分为3个步骤:

  1. 预处理
  2. 汇编
  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。


责任编辑: 鲁达

1.内容基于多重复合算法人工智能语言模型创作,旨在以深度学习研究为目的传播信息知识,内容观点与本网站无关,反馈举报请
2.仅供读者参考,本网站未对该内容进行证实,对其原创性、真实性、完整性、及时性不作任何保证;
3.本站属于非营利性站点无毒无广告,请读者放心使用!

“如何减少c语言编译大小,手机如何编译c语言,c语言如何重新编译”边界阅读