【问题标题】:Why does C++ compilation take so long?为什么 C++ 编译需要这么长时间?
【发布时间】:2010-09-24 00:30:38
【问题描述】:

与 C# 和 Java 相比,编译 C++ 文件需要很长时间。编译 C++ 文件比运行正常大小的 Python 脚本花费的时间要长得多。我目前正在使用 VC++,但任何编译器都一样。这是为什么呢?

我能想到的两个原因是加载头文件和运行预处理器,但这似乎不能解释为什么需要这么长时间。

【问题讨论】:

  • VC++ 支持预编译头文件。使用它们会有所帮助。很多。
  • 是的,在我的情况下(主要是 C 有几个类 - 没有模板)预编译的头文件加速大约 10 倍
  • It takes significantly longer to compile a C++ file - 你的意思是 2 秒与 1 秒相比吗?当然,这是两倍长,但意义不大。还是您的意思是 10 分钟与 5 秒相比?请量化。
  • OT:使用ccache 加快速度:-)
  • 我把赌注押在了模块上;我不认为 C++ 项目的构建速度会比其他编程语言上的模块更快,但是对于大多数项目来说,它可以通过一些管理变得非常接近。我希望在模块之后看到一个很好的包管理器与工件集成

标签: c++ performance compilation


【解决方案1】:

一些原因是:

1) C++ 语法比 C# 或 Java 更复杂,解析时间更长。

2) (更重要的是)C++ 编译器生成机器代码并在编译期间进行所有优化。 C# 和 Java 只完成了一半,将这些步骤留给 JIT。

【讨论】:

    【解决方案2】:

    C++ 被编译成机器码。所以你有预处理器、编译器、优化器,最后是汇编器,所有这些都必须运行。

    Java 和 C# 编译成字节码/IL,Java 虚拟机/.NET Framework 在执行之前执行(或 JIT 编译成机器码)。

    Python 是一种解释型语言,也可以编译成字节码。

    我确信这还有其他原因,但总的来说,不必编译为本地机器语言可以节省时间。

    【讨论】:

    • 预处理增加的成本微不足道。速度变慢的主要“其他原因”是编译被拆分为单独的任务(每个目标文件一个),因此常见的头文件被一遍又一遍地处理。这是 O(N^2) 最坏情况,与大多数其他语言 O(N) 解析时间相比。
    • 您可以从同样的论点中看出 C、Pascal 等编译器速度很慢,但平均而言并非如此。它更多地与 C++ 的语法和 C++ 编译器必须维护的巨大状态有关。
    • C 很慢。它与公认的解决方案存在相同的标头解析问题。例如。取一个简单的 windows GUI 程序,在几个编译单元中包含 windows.h,并在添加(短)编译单元时测量编译性能。
    【解决方案3】:

    另一个原因是使用 C 预处理器来定位声明。即使有标题保护,.h 仍然必须一遍又一遍地解析,每次包含它们时。一些编译器支持预编译的头文件,可以帮助解决这个问题,但它们并不总是被使用。

    另请参阅:C++ Frequently Questioned Answers

    【讨论】:

    • 我认为您应该在预编译的标头上加粗注释,以指出您答案的这一重要部分。
    • 如果整个头文件(可能的 cmets 和空行除外)都在头文件保护范围内,gcc 能够记住该文件,如果定义了正确的符号则跳过它。
    • @CesarB:每个编译单元(.cpp 文件)仍然需要对其进行一次完整的处理。
    【解决方案4】:

    编译语言总是需要比解释语言更大的初始开销。此外,也许您没有很好地构建您的 C++ 代码。例如:

    #include "BigClass.h"
    
    class SmallClass
    {
       BigClass m_bigClass;
    }
    

    编译速度比:

    class BigClass;
    
    class SmallClass
    {
       BigClass* m_bigClass;
    }
    

    【讨论】:

    • 特别是如果 BigClass 碰巧包含它使用的另外 5 个文件,最终包含程序中的所有代码。
    • 这也许是一个原因。但是,例如 Pascal 只需要等效 C++ 程序的十分之一的编译时间。这不是因为 gcc:s 优化需要更长的时间,而是因为 Pascal 更容易解析并且不必处理预处理器。另请参阅 Digital Mars D 编译器。
    • 这不是更容易解析,而是模块化避免了重新解释 windows.h 和每个编译单元的无数其他头文件。是的,Pascal 解析更容易(尽管像 Delphi 这样成熟的解析器又更复杂了),但这并不是最大的不同。
    • 这里展示的提高编译速度的技术被称为forward declaration
    • 在一个文件中编写类。不会是乱码吧?
    【解决方案5】:

    几个原因

    头文件

    每个编译单元都需要 (1) 加载和 (2) 编译成百上千个头文件。 它们中的每一个通常都必须为每个编译单元重新编译, 因为预处理器确保编译头文件的结果可能在每个编译单元之间有所不同。 (可以在一个编译单元中定义一个宏来改变头文件的内容)。

    这可能是的主要原因,因为它需要为每个编译单元编译大量代码, 此外,每个头文件都必须编译多次 (每个包含它的编译单元一次)。

    链接

    编译后,所有目标文件都必须链接在一起。 这基本上是一个无法很好并行化的单一流程,必须处理您的整个项目。

    解析

    语法解析极其复杂,严重依赖于上下文,并且很难消除歧义。 这需要很长时间。

    模板

    在 C# 中,List<T> 是唯一被编译的类型,无论您的程序中有多少 List 实例。 在 C++ 中,vector<int> 是与 vector<float> 完全不同的类型,每个类型都必须单独编译。

    此外,模板构成了编译器必须解释的完整图灵完备的“子语言”, 这可能会变得非常复杂。 即使是相对简单的模板元编程代码也可以定义创建数十个模板实例的递归模板。 模板也可能导致极其复杂的类型,名称长得离谱,给链接器增加了很多额外的工作。 (它必须比较很多符号名称,如果这些名称可以增长到数千个字符,那将变得相当昂贵。

    当然,它们加剧了头文件的问题,因为模板通常必须在头文件中定义, 这意味着必须为每个编译单元解析和编译更多的代码。 在纯 C 代码中,标头通常只包含前向声明,但很少包含实际代码。 在 C++ 中,几乎所有代码都驻留在头文件中的情况并不少见。

    优化

    C++ 允许一些非常显着的优化。 C# 或 Java 不允许完全消除类(它们必须用于反射目的), 但即使是一个简单的 C++ 模板元程序也可以轻松生成数十或数百个类, 所有这些都在优化阶段再次内联和消除。

    此外,C++ 程序必须由编译器完全优化。 C# 程序可以依靠 JIT 编译器在加载时执行额外的优化, C++ 没有得到任何这样的“第二次机会”。编译器生成的内容已尽可能优化。

    机器

    C++ 被编译成机器码,这可能比 Java 或 .NET 使用的字节码复杂一些(尤其是在 x86 的情况下)。 (这是出于完整性而提到的,只是因为它在 cmets 等中提到过。 实际上,这一步不太可能只占用总编译时间的一小部分)。

    结论

    大多数这些因素都由 C 代码共享,实际上编译效率很高。 解析步骤在 C++ 中要复杂得多,并且可能会占用更多时间,但主要的问题可能是模板。 它们很有用,使 C++ 成为一种更强大的语言,但它们也在编译速度方面造成了损失。

    【讨论】:

    • 关于第 3 点:C 编译明显快于 C++。导致速度变慢的绝对是前端,而不是代码生成。
    • 关于模板:不仅vector必须与vector分开编译,而且vector在每个使用它的编译单元中都会重新编译。链接器消除了冗余定义。
    • dribeas:是的,但这并不特定于模板。头文件中定义的内联函数或任何其他内容将在包含它的任何地方重新编译。但是,是的,使用模板尤其痛苦。 :)
    • @configurator:Visual Studio 和 gcc 都允许预编译头文件,这可以大大加快编译速度。
    • 不确定优化是否是问题所在,因为我们的 DEBUG 构建实际上比发布模式构建慢。 pdb 生成也是罪魁祸首。
    【解决方案6】:

    减速不一定与任何编译器相同。

    我没有使用过 Delphi 或 Kylix,但在 MS-DOS 时代,Turbo Pascal 程序几乎可以立即编译,而等效的 Turbo C++ 程序只能爬行。

    两个主要区别是非常强大的模块系统和允许单遍编译的语法。

    编译速度可能并不是 C++ 编译器开发人员的首要任务,但 C/C++ 语法中也存在一些固有的复杂性,使其更难处理。 (我不是 C 方面的专家,但 Walter Bright 是,在构建了各种商业 C/C++ 编译器之后,他创建了 D 语言。One of his changes 是为了强制执行上下文无关语法以使该语言更易于解析。 )

    另外,您会注意到通常 Makefile 的设置是为了在 C 中单独编译每个文件,因此如果 10 个源文件都使用相同的包含文件,则该包含文件将被处理 10 次。

    【讨论】:

    • 比较 Pascal 很有趣,因为 Niklaus Wirth 在设计他的语言和编译器时使用了编译器编译自身所花费的时间作为基准。有一个故事说,他在仔细编写了一个用于快速符号查找的模块后,将其替换为简单的线性搜索,因为减小的代码大小使编译器自身编译速度更快。
    • @DietrichEpp 经验主义得到了回报。
    【解决方案7】:

    解析和代码生成实际上相当快。真正的问题是打开和关闭文件。请记住,即使使用包含保护,编译器仍然会打开 .H 文件,并读取每一行(然后忽略它)。

    有一次,一位朋友(在工作无聊的时候)拿了他公司的应用程序并将所有内容(所有源文件和头文件)放入一个大文件中。编译时间从 3 小时缩短到 7 分钟。

    【讨论】:

    • 嗯,文件访问肯定在这方面有所帮助,但正如 jalf 所说,造成这种情况的主要原因是其他原因,即重复解析许多许多(嵌套!)头文件在你的情况下完全退出。
    • 此时你的朋友需要设置预编译头文件,打破不同头文件之间的依赖关系(尽量避免一个头文件包含另一个头文件,而不是前向声明)并获得更快的硬盘。除此之外,这是一个非常惊人的指标。
    • 如果整个头文件(可能的 cmets 和空行除外)都在头文件保护范围内,gcc 能够记住该文件,如果定义了正确的符号则跳过它。
    • 解析很重要。对于具有相互依赖关系的 N 对大小相似的源/头文件,有 O(N^2) 次通过头文件。将所有文本放入一个文件中可以减少重复解析。
    • 小注:包含防护可防止每个编译单元进行多次解析。总体上不反对多重解析。
    【解决方案8】:

    您得到的权衡是程序运行得更快了一点。在开发过程中,这对您来说可能是一种冷酷的安慰,但一旦开发完成并且程序只是由用户运行,这可能会非常重要。

    【讨论】:

      【解决方案9】:

      最大的问题是:

      1) 无限头重新解析。已经提到了。缓解措施(如 #pragma once)通常仅适用于每个编译单元,而不适用于每个构建。

      2) 工具链通常被分成多个二进制文件(在极端情况下,make、预处理器、编译器、汇编器、归档器、impdef、链接器和 dlltool),所有这些二进制文件都必须始终重新初始化和重新加载所有状态每次调用(编译器、汇编器)或每两个文件(归档器、链接器和 dlltool)。

      另请参阅有关 comp.compilers 的讨论:http://compilers.iecc.com/comparch/article/03-11-078 特别是这个:

      http://compilers.iecc.com/comparch/article/02-07-128

      请注意,comp.compilers 的主持人 John 似乎同意这一点,这意味着如果完全集成工具链并实现预编译的头文件,C 也应该可以实现类似的速度。许多商业 C 编译器在某种程度上做到了这一点。

      请注意,将所有内容分解为单独的二进制文件的 Unix 模型是 Windows 的一种最坏情况模型(进程创建速度慢)。在比较 Windows 和 *nix 之间的 GCC 构建时间时,这一点非常明显,特别是如果 make/configure 系统还调用一些程序只是为了获取信息。

      【讨论】:

      • 另一个因素:在许多情况下,定义在头文件类中的方法和/或模板函数在包含头文件的多个编译单元中被冗余编译;链接器会抛出一个,除了一个。
      【解决方案10】:

      大多数答案都不太清楚,因为在 C++ 中仅在编译时执行一次的执行操作的成本导致 C# 将始终运行较慢,由于运行时依赖性,此性能成本也会受到影响(要加载更多的东西能够运行),更不用说 C# 程序将始终具有更高的内存占用,所有这些都导致性能与可用硬件的能力更密切相关。对于其他解释或依赖于 VM 的语言也是如此。

      【讨论】:

        【解决方案11】:

        在大型 C++ 项目中减少编译时间的一种简单方法是创建一个 *.cpp 包含文件,其中包含项目中的所有 cpp 文件并对其进行编译。这将报头爆炸问题减少到一次。这样做的好处是编译错误仍然会引用正确的文件。

        例如,假设你有 a.cpp、b.cpp 和 c.cpp.. 创建一个文件:everything.cpp:

        #include "a.cpp"
        #include "b.cpp"
        #include "c.cpp"
        

        然后通过制作everything.cpp来编译项目

        【讨论】:

        • 我没有看到对这种方法的反对意见。假设您从脚本或 Makefile 生成包含,这不是维护问题。事实上,它确实在不混淆编译问题的情况下加快了编译速度。您可能会争论编译时的内存消耗,但这在现代机器上很少成为问题。那么这种方法的对象是什么(除了断言它是错误的)?
        • @rileyberton(因为有人赞成您的评论)让我说明一下:不,它不会加快编译速度。事实上,它通过隔离翻译单元来确保任何编译都花费最长时间。它们的优点在于,如果它们没有改变,您不需要重新编译所有 .cpp-s。 (这无视文体论点)。适当的依赖管理和precompiled headers 可能要好得多。
        • 抱歉,这可以是一种非常有效的加速编译的方法,因为您 (1) 几乎消除了链接,并且 (2) 只需要处理常用的头一次。此外,如果您愿意尝试它,它在实践中也有效。不幸的是,它使增量重建变得不可能,因此每个构建都是完全从头开始的。但是用这种方法完全重建比你得到的要快很多
        • @BartekBanachewicz 当然,但你是“它不会加快编译速度”,没有限定符。正如您所说,它使每次编译都花费最大时间(没有部分重建),但与此同时,与其他情况相比,它大大减少了最大值。我只是说它比“不要这样做”更微妙
        • 享受静态变量和函数的乐趣。如果我想要一个大的编译单元,我会创建一个大的 .cpp 文件。
        【解决方案12】:

        构建 C/C++:真正发生了什么以及为什么需要这么长时间

        软件开发的很大一部分时间不是花在编写、运行、调试甚至设计代码上,而是等待它完成编译。 为了让事情变得更快,我们首先必须了解编译 C/C++ 软件时发生了什么。步骤大致如下:

        • 配置
        • 构建工具启动
        • 依赖检查
        • 编译
        • 链接

        我们现在将更详细地研究每个步骤,重点是如何加快速度。

        配置

        这是开始构建的第一步。通常意味着运行配置脚本或 CMake、Gyp、SCons 或其他一些工具。对于非常大的基于 Autotools 的配置脚本,这可能需要一秒钟到几分钟的时间。

        此步骤相对很少发生。它只需要在更改配置或更改构建配置时运行。除了更改构建系统之外,没有太多工作可以加快这一步。

        构建工具启动

        当您运行 make 或单击 IDE 上的构建图标(通常是 make 的别名)时会发生这种情况。构建工具二进制启动并读取其配置文件以及构建配置,这通常是同一件事。

        根据构建的复杂性和大小,这可能需要几分之一秒到几秒的时间。就其本身而言,这不会那么糟糕。不幸的是,大多数基于 make 的构建系统会导致每次构建都会调用数十到数百次。通常这是由递归使用 make 引起的(这是不好的)。

        需要注意的是,Make 这么慢的原因不是实现错误。 Makefiles 的语法有一些怪癖,使得真正快速的实现几乎是不可能的。结合下一步,这个问题会更加明显。

        依赖检查

        构建工具读取其配置后,必须确定哪些文件已更改以及哪些文件需要重新编译。配置文件包含描述构建依赖关系的有向无环图。此图通常在配置步骤中构建。 构建工具启动时间和依赖扫描器在每个构建上运行。它们的组合运行时间决定了编辑-编译-调试周期的下限。对于小型项目,这个时间通常是几秒钟左右。这是可以忍受的。 Make有替代品。其中最快的是 Ninja,它是由 Google 工程师为 Chromium 构建的。 如果您使用 CMake 或 Gyp 构建,只需切换到他们的 Ninja 后端。您不必自己更改构建文件中的任何内容,只需享受速度提升。不过,大多数发行版都没有打包 Ninja,因此您可能必须自己安装它。

        编译

        此时我们终于调用了编译器。偷偷摸摸,这里是采取的大致步骤。

        • 合并包括
        • 解析代码
        • 代码生成/优化

        与流行的看法相反,编译 C++ 实际上并没有那么慢。 STL 很慢,大多数用于编译 C++ 的构建工具都很慢。但是,有更快的工具和方法可以缓解语言的慢速部分。

        使用它们需要一点麻烦,但好处是不可否认的。更快的构建时间会带来更快乐的开发人员、更高的敏捷性,并最终带来更好的代码。

        【讨论】:

          【解决方案13】:

          我认为有两个问题可能会影响 C++ 程序的编译速度。

          可能的问题 #1 - 编译标头:(这可能已经或可能尚未通过另一个答案或评论解决。)Microsoft Visual C++(AKA VC++)支持预编译的标头,我高度推荐。当您创建一个新项目并选择您正在制作的程序类型时,屏幕上应该会出现一个设置向导窗口。如果您点击底部的“下一步>”按钮,该窗口将带您进入一个包含多个功能列表的页面;确保选中“预编译头”选项旁边的框。 (注意:这是我在 C++ 中使用 Win32 控制台应用程序的经验,但对于所有类型的 C++ 程序可能并非如此。)

          可能的问题 #2 - 编译到的位置: 今年夏天,我参加了一个编程课程,我们必须将所有项目存储在 8GB 闪存驱动器上,就像实验室中的计算机一样我们每天晚上都在午夜使用 gotwiped,这会抹去我们所有的工作。如果您出于可移植性/安全性/等考虑而编译到外部存储设备,则程序编译可能需要 非常长 时间(即使使用我上面提到的预编译头文件) ,特别是如果它是一个相当大的程序。在这种情况下,我对您的建议是在您正在使用的计算机的硬盘驱动器上创建和编译程序,并且无论出于何种原因,当您想要/需要停止处理您的项目时,将它们传输到您的外部存储设备,然后单击“安全删除硬件并弹出媒体”图标,该图标应显示为一个小闪存驱动器,位于带有白色复选标记的绿色小圆圈后面,以断开连接。

          我希望这对你有帮助;让我知道是否有! :)

          【讨论】:

            【解决方案14】:

            在大型面向对象项目中,重要的原因是 C++ 难以限制依赖关系。

            私有函数需要在其各自类的公共标头中列出,这使得依赖项比它们需要的更具传递性(传染性):

            // Ugly private dependencies
            #include <map>
            #include <list>
            #include <chrono>
            #include <stdio.h>
            #include <Internal/SecretArea.h>
            #include <ThirdParty/GodObjectFactory.h>
            
            class ICantHelpButShowMyPrivatePartsSorry
            {
            public:
                int facade(int);
            
            private:
                std::map<int, int> implementation_detail_1(std::list<int>);
                std::chrono::years implementation_detail_2(FILE*);
                Intern::SecretArea implementation_detail_3(const GodObjectFactory&);
            };
            

            如果这种模式被幸福地重复到头文件的依赖树中,这往往会创建一些“上帝头文件”,间接包含项目中所有头文件的大部分。它们与 god objects 一样无所不知,只是在绘制它们的包含树之前,这一点并不明显。

            这会以两种方式增加编译时间:

            1. 他们添加到包含它们的每个编译单元(.cpp 文件)的代码量很容易比 cpp 文件本身多很多倍。从这个角度来看,catch2.hpp 是 18000 行,而大多数人(甚至 IDE)开始难以编辑大于 1000-10000 行的文件。
            2. 在编辑标题时必须重新编译的文件数不包含在依赖它的真正文件集中。

            是的,有一些缓解措施,例如前向声明、which has perceived downsidespimpl idiom,这是一个非零成本抽象。尽管 C++ 在你能做的事情上是无限的,但如果你离它的本意太远,你的同行会想知道你在抽烟。

            最糟糕的部分:如果您考虑一下,甚至不需要在其公共头文件中声明私有函数:成员函数的道德等价物可以并且通常在 C 中被模仿,它不会重新创建这个问题。

            【讨论】:

              【解决方案15】:

              简单地回答这个问题,C++ 是一种比市场上可用的其他语言复杂得多的语言。它有一个传统的包含模型,可以多次解析代码,并且它的模板库没有针对编译速度进行优化。

              语法和 ADL

              让我们通过一个非常简单的例子来看看 C++ 的语法复杂度:

              x*y;
              

              虽然您可能会说上面是一个带有乘法的表达式,但在 C++ 中不一定是这种情况。如果 x 是一个类型,那么该语句实际上就是一个指针声明。这意味着 C++ 语法是上下文相关的。

              这是另一个例子:

              foo<x> a;
              

              同样,您可能认为这是 foo 类型的变量“a”的声明,但它也可以解释为:

              (foo < x) > a;
              

              这将使它成为一个比较表达式。

              C++ 有一个名为 Argument Dependent Lookup (ADL) 的功能。 ADL 建立了管理编译器如何查找名称的规则。考虑以下示例:

              namespace A{
                struct Aa{}; 
                void foo(Aa arg);
              }
              namespace B{
                struct Bb{};
                void foo(A::Aa arg, Bb arg2);
              }
              namespace C{ 
                struct Cc{}; 
                void foo(A::Aa arg, B::Bb arg2, C::Cc arg3);
              }
              
              foo(A::Aa{}, B::Bb{}, C::Cc{});
              

              ADL 规则规定,考虑到函数调用的所有参数,我们将寻找名称“foo”。在这种情况下,所有名为“foo”的函数都将被视为重载决议。这个过程可能需要一些时间,尤其是在有很多函数重载的情况下。在模板化的上下文中,ADL 规则变得更加复杂。

              #include

              此命令可能会显着影响编译时间。根据您包含的文件类型,预处理器可能只复制几行代码,也可能复制数千行。

              此外,编译器无法优化此命令。如果头文件依赖于宏,您可以复制可以在包含之前修改的不同代码。

              这些问题有一些解决方案。您可以使用预编译的标头,它是编译器对标头中所解析内容的内部表示。然而,如果没有用户的努力,这是无法完成的,因为预编译的头文件假定头文件不依赖于宏。

              模块功能为此问题提供了语言级别的解决方案。它从 C++20 版本开始提供。

              模板

              模板的编译速度具有挑战性。每个使用模板的翻译单元都需要包含它们,并且这些模板的定义需要可用。模板的一些实例化最终会成为其他模板的实例化。在某些极端情况下,模板实例化会消耗大量资源。使用模板并且不是为编译速度而设计的库可能会变得很麻烦,正如您在此链接提供的元编程库的比较中看到的那样:http://metaben.ch/。它们的编译速度差异很大。

              如果您想了解为什么某些元编程库的编译时间比其他库更好,请查看this video about the Rule of Chiel

              结论

              C++ 是一种编译缓慢的语言,因为在最初开发该语言时,编译性能并不是最重要的。结果,C++ 最终得到了一些在运行时可能有效但在编译时不一定有效的特性。

              P.S – 我在 Incredibuild 工作,这是一家专门从事 C++ 编译加速的软件开发加速公司,欢迎您try it for free

              【讨论】:

                猜你喜欢
                • 1970-01-01
                • 1970-01-01
                • 2011-03-10
                • 1970-01-01
                • 2020-02-02
                • 1970-01-01
                • 1970-01-01
                • 1970-01-01
                • 2012-08-31
                相关资源
                最近更新 更多