四时宝库

程序员的知识宝库

程序员效率分享:加速C ++编译(c++提高速度)

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

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


C ++编译模型概述

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

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

  1. 预处理
  2. 汇编
  3. 链接

预处理

第一步是预处理。这期间,预处理器需要一个.cpp文件,并解析它,寻找预处理器指令,如#include#define#ifdef,等

// tiny.cpp
#define KONSTANTA 123

int main() {
    return KONSTANTA;
}

这个例子包含一个预处理程序指令#define。以后出现的任何情况KONSTANTA都应替换为123。通过预处理器运行文件将导致如下所示的输出:

$ clang++ -E tiny.cpp
# 1 "tiny.cpp"
# 1 "<built-in>" 1
# 1 "<built-in>" 3
# 383 "<built-in>" 3
# 1 "<command line>" 1
# 1 "<built-in>" 2
# 1 "tiny.cpp" 2

int main() {
    return 123;
}

我们可以看到,return KONSTANTAKONSTANTA部分已被替换123。还看到编译器留下了很多其他注释,这里对此并不太关心。

预处理器模型的最大问题是该#include指令的字面意思是“在此处复制粘贴此文件的所有内容”。当然,如果该文件的内容包含其他#include指令,则将打开更多文件,将其内容复制过去,进而,编译器将需要处理更多代码。也就是说,预处理通常会明显增加输入的大小。

以下是使用流的C ++中的简单“ Hello World”。

// hello-world.cpp
#include <iostream>

int main() {
    std::cout << "Hello World\n";
}

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

$ clang++ -E hello-world.cpp | 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之 类的工具可以https://include-what-you-use.org/)简化操作。


包含头文件的成本

下表显示了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-shape.hpp" // KeyShape的完整定义

size_t count_differences(KeyShape const& lhs, KeyShape const& rhs) {
    assert(lhs.positions() == rhs.positions());
    ...
}

还可以将前向声明与某些模板化类一起使用,随模板参数不会改变大小,例如std::unique_ptrstd::vector。但是,这样做可能会需要你重新定义构造函数,析构函数和其他特殊成员函数(SMF),因为通常需要查看这些类型的完整定义。代码看起来像这样:

// foo.hpp
#include <memory>

class Bar;

class Foo {
    std::unique_ptr<Bar> m_ptr;
public:
    Foo(); // = default;
    ~Foo(); // = default;
};
// foo.cpp
#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_backconstexpr 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 "static-vector.hpp"

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倍)改进,并且随着包含static-vector.hpp标头的已编译目标文件数量的增加,改进也越发看到效果。当然,文件越复杂,改进就越小,因为<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(); }

在上面的示例中,helper1helper2都是内部链接。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优化级别,helper1helper2完全消失。这是因为它们足够小,可以内联do_stuff,并且来自别的的代码都无法引用它们,因为它们是内部链接。

$ clang++ -c local-linkage.cpp -O1 && nm -C local-linkage.o
0000000000000000 T do_stuff()

这也是内部链接如何提高运行时性能的方式。因为编译器可以看到使用符号的所有位置,所以它可以将其内联到调用处,以完全删除该函数。

通过隐藏符号来提高编译性能通常很小。毕竟,链接每个符号所做的工作量很小。但是,大型二进制文件可以包含数百万个符号,就像与隐藏友元一样,隐藏符号也具有非编译性能优势,即可以防止在辅助函数之间违反ODR。


发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言
    友情链接