44

我试图使用 SO 找到答案。有许多问题列出了在 C++ 中构建仅包含标头的库的各种优缺点,但我一直无法找到一个以可量化的方式这样做的问题。

那么,在可量化的方面,使用传统分离的 c++ 头文件和实现文件与仅使用头文件有什么不同?

为简单起见,我假设不使用模板(因为它们只需要标题)。

为了详细说明,我列出了我从文章中看到的优点和缺点。显然,有些是不容易量化的(比如易用性),因此对于量化比较是没有用的。我将用(可量化的)标记那些我期望可量化的指标。

仅标题的优点

  1. 它更容易包含,因为您不需要在构建系统中指定链接器选项。
  2. 您始终使用与其余代码相同的编译器(选项)编译所有库代码,因为库的函数已内联在您的代码中。
  3. 它可能会快很多。(可量化)
  4. 可能给编译器/链接器更好的优化机会(解释/量化,如果可能的话)
  5. 如果您仍然使用模板,则需要。

仅标头的缺点

  1. 它使代码膨胀。(可量化的)(这如何影响执行时间和内存占用)
  2. 更长的编译时间。(可量化)
  3. 失去接口和实现的分离。
  4. 有时会导致难以解决的循环依赖。
  5. 防止共享库/DLL 的二进制兼容性。
  6. 它可能会激怒喜欢使用 C++ 的传统方式的同事。

您可以从更大的开源项目(比较类似大小的代码库)中使用的任何示例将不胜感激。或者,如果您知道可以在仅标题版本和单独版本之间切换的项目(使用包含两者的第三个文件),那将是理想的。轶事数字也很有用,因为它们给了我一个大致的了解,我可以从中获得一些洞察力。

优点和缺点的来源:

提前致谢...

更新:

对于以后可能会阅读本文并有兴趣获得一些有关链接和编译的背景信息的人,我发现这些资源很有用:

更新:(回应以下评论)

仅仅因为答案可能会有所不同,并不意味着测量是无用的。您必须从某个点开始测量。你的测量值越多,图片就越清晰。我在这个问题中要求的不是整个故事,而是图片的一瞥。当然,如果任何人想不道德地宣扬他们的偏见,他们都可以使用数字来歪曲一个论点。但是,如果有人对两个选项之间的差异感到好奇并发布了这些结果,我认为这些信息很有用。

没有人对这个话题感到好奇,足以衡量它吗?

我喜欢枪战项目。我们可以从删除大部分变量开始。仅在一种版本的 linux 上使用一种版本的 gcc。仅对所有基准测试使用相同的硬件。不要用多线程编译。

然后,我们可以测量:

  • 可执行文件大小
  • 运行
  • 内存占用
  • 编译时间(对于整个项目和通过更改一个文件)
  • 链接时间
4

3 回答 3

36

总结(重点):

  • 两个包进行了基准测试(一个有 78 个编译单元,一个有 301 个编译单元)
  • 传统编译(多单元编译)导致应用程序速度提高了 7%(在 78 个单元包中);301 单元包中的应用程序运行时没有变化。
  • 传统编译和仅标头基准在运行时使用相同数量的内存(在两个包中)。
  • 仅标头编译(单单元编译)导致可执行文件大小在 301 单元包中小 10%(在 78 单元包中仅小 1%)。
  • 传统编译使用大约三分之一的内存来构建这两个包。
  • 传统编译的编译时间是第一次编译的三倍,而重新编译只需要 4% 的时间(因为只有头文件必须重新编译所有源代码)。
  • 传统编译需要更长的时间来链接第一次编译和后续编译。

Box2D 基准测试,数据:

box2d_data_gcc.csv

植物学基准,数据:

botan_data_gcc.csv

Box2D 总结(78 个单元)

在此处输入图像描述

植物学摘要(301个单位)

在此处输入图像描述

漂亮的图表:

Box2D 可执行文件大小:

Box2D 可执行文件大小

Box2D 编译/链接/构建/运行时间:

Box2D 编译/链接/构建/运行时间

Box2D 编译/链接/构建/运行最大内存使用量:

Box2D 编译/链接/构建/运行最大内存使用量

Botan 可执行文件大小:

Botan 可执行文件大小

Botan 编译/链接/构建/运行时间:

Botan 编译/链接/构建/运行时

Botan 编译/链接/构建/运行最大内存使用量:

Botan 编译/链接/构建/运行最大内存使用量


基准详情

TL;博士


测试的项目Box2DBotan被选中,因为它们的计算成本可能很高,包含大量单元,并且实际上作为单个单元编译时几乎没有错误或没有错误。尝试了许多其他项目,但花费了太多时间来“修复”为一个单元进行编译。内存占用量是通过定期轮询内存占用量并使用最大值来测量的,因此可能并不完全准确。

此外,此基准测试不会自动生成标头依赖项(以检测标头更改)。在使用不同构建系统的项目中,这可能会增加所有基准测试的时间。

基准测试中有 3 个编译器,每个编译器有 5 个配置。

编译器:

  • 海合会
  • 国际商会

编译器配置:

  • 默认 - 默认编译器选项
  • 优化原生 --O3 -march=native
  • 尺寸优化 --Os
  • LTO/IPO 原生 --O3 -flto -march=native使用 clang 和 gcc,-O3 -ipo -march=native使用 icpc/icc
  • 零优化 --Os

我认为这些都可能对单单元和多单元构建之间的比较有不同的影响。我将 LTO/IPO 包括在内,因此我们可能会看到实现单单元有效性的“正确”方法如何进行比较。

csv字段说明:

  • Test Name- 基准的名称。例子:Botan, Box2D
  • 测试配置 - 命名此测试的特定配置(特殊 cxx 标志等)。通常与Test Name.
  • Compiler- 使用的编译器的名称。例子:gcc,icc,clang
  • Compiler Configuration- 使用的编译器选项配置的名称。例子:gcc opt native
  • Compiler Version String- 编译器本身的编译器版本输出的第一行。示例:在我的系统上g++ --version生成。g++ (GCC) 4.6.1
  • Header only-True如果这个测试用例是作为一个单元构建的,False如果它是作为一个多单元项目构建的。
  • Units- 测试用例中的单元数,即使它是作为单个单元构建的。
  • Compile Time,Link Time,Build Time,Run Time- 听起来。
  • Re-compile Time AVG,Re-compile Time MAX,Re-link Time AVG,Re-link Time MAX,Re-build Time AVG,Re-build Time MAX- 触摸单个文件后重建项目的时间。每个单元都被触及,每个单元都被重建。最大次数和平均次数记录在这些字段中。
  • Compile Memory,Link Memory,Build Memory,Run Memory,Executable Size- 正如他们的声音。

要重现基准:

  • Bullwork 是run.py
  • 需要psutil(用于内存占用测量)。
  • 需要 GNUMake。
  • 实际上,路径中需要 gcc、clang、icc/icpc。当然可以修改以删除其中任何一个。
  • 每个基准都应该有一个数据文件,列出该基准的单位。run.py将创建两个测试用例,一个是每个单元单独编译,一个是每个单元一起编译。示例:box2d.data。文件格式定义为 json 字符串,包含具有以下键的字典
    • "units"- 构成c/cpp/cc该项目单元的文件列表
    • "executable"- 要编译的可执行文件的名称。
    • "link_libs"- 要链接到的已安装库的空格分隔列表。
    • "include_directores"- 要包含在项目中的目录列表。
    • "command"- 可选的。执行基准测试的特殊命令。例如,"command": "botan_test --benchmark"
  • 并非所有 C++ 项目都可以轻松完成;单个单元中不得有冲突/歧义。
  • 要将项目添加到测试用例,请使用项目信息(包括数据文件名)修改run.pytest_base_cases中的列表。
  • 如果一切运行良好,输出文件data.csv应该包含基准测试结果。

要生成条形图:

  • 您应该从基准生成的 data.csv 文件开始。
  • 获取chart.py。需要matplotlib
  • 调整fields列表以决定生成哪些图表。
  • 运行python chart.py data.csv
  • 一个文件,test.png现在应该包含结果。

Box2D

  • Box2D 原样从svn 中使用,修订版 251。
  • 该基准取自此处,在此处进行了修改,可能不能代表一个好的 Box2D 基准,并且它可能没有使用足够的 Box2D 来执行此编译器基准测试。
  • box2d.data 文件是通过查找所有 .cpp 单元手动编写的。

牡丹

  • 使用Botan-1.10.3
  • 数据文件:botan_bench.data
  • 首先运行./configure.py --disable-asm --with-openssl --enable-modules=asn1,benchmark,block,cms,engine,entropy,filters,hash,kdf,mac,bigint,ec_gfp,mp_generic,numbertheory,mutex,rng,ssl,stream,cvc,这会生成头文件和 Makefile。
  • 我禁用了程序集,因为程序集可能会干扰函数边界不阻止优化时可能发生的优化。然而,这是推测,可能是完全错误的。
  • 然后运行类似grep -o "\./src.*cpp" Makefile和的命令grep -o "\./checks.*" Makefile来获取 .cpp 单元并将它们放入botan_bench.data文件中。
  • 修改/checks/checks.cpp为不调用 x509 单元测试,并删除 x509 检查,因为 Botan typedef 和 openssl 之间的冲突。
  • 使用了包含在 Botan 源中的基准。

系统规格:

  • OpenSuse 11.4,32 位
  • 4GB 内存
  • Intel(R) Core(TM) i7 CPU Q 720 @ 1.60GHz
于 2012-11-27T21:05:57.050 回答
28

更新

这是 Real Slaw 的原始答案。他上面的答案(被接受的)是他的第二次尝试。我觉得他的第二次尝试完全回答了这个问题。- 荷马6

好吧,为了比较,您可以查找“统一构建”的想法(与图形引擎无关)。基本上,“统一构建”是将所有 cpp 文件包含到一个文件中,并将它们全部编译为一个编译单元。我认为这应该提供一个很好的比较,作为 AFAICT,这相当于让你的项目只有标题。你会惊讶于你列出的第二个“骗局”;“统一构建”的重点是减少编译时间。据说统一构建编译速度更快,因为它们:

.. 是一种减少构建开销的方法(特别是打开和关闭文件,并通过减少生成的目标文件的数量来减少链接时间),因此用于显着加快构建时间。

—— altdevblogaday

编译时间比较(从这里):

在此处输入图像描述

“unity build”的三大参考:

我假设您想要列出利弊的原因。

仅标题的优点

[...]

3)它可能会快很多。(可量化)代码可能会优化得更好。原因是,当单元分开时,函数只是一个函数调用,因此必须保持不变。没有关于此调用的信息,例如:

  • 这个函数会修改内存(因此我们反映这些变量/内存的寄存器在它返回时会过时)?
  • 此函数是否查看全局内存(因此我们无法重新排序调用函数的位置)
  • 等等

此外,如果函数内部代码已知,那么内联它可能是值得的(即将其代码直接转储到调用函数中)。内联避免了函数调用开销。内联还允许发生大量其他优化(例如,常量传播;例如我们factorial(10)调用factorial()的代码factorial(),我们实际上可以对函数中的变量进行变量化并将其替换为 10,如果幸运的话,我们甚至可以在编译时得到答案,而在运行时根本不需要运行任何东西)。内联后的其他优化包括死代码消除和(可能)更好的分支预测。

4)可能给编译器/链接器更好的优化机会(解释/量化,如果可能的话)

我认为这是从(3)得出的。

仅标头的缺点

1)它使代码膨胀。(可量化)(这如何影响执行时间和内存占用)据我所知,仅标头可以以几种方式使代码膨胀。

首先是模板膨胀;其中编译器实例化了从未使用过的类型的不必要模板。这不是仅针对标头的,而是针对模板的,现代编译器对此进行了改进,以使其成为最小的关注点。

第二种更明显的方式是函数的(过度)内联。如果一个大函数在任何使用它的地方都被内联,那些调用函数的大小将会增长。几年前,这可能是对可执行文件大小和可执行映像内存大小的担忧,但 HDD 空间和内存已经增长到几乎没有意义。更重要的问题是增加的函数大小会破坏指令缓存(因此现在更大的函数不适合缓存,现在必须在 CPU 通过函数执行时重新填充缓存)。内联后寄存器压力会增加(寄存器数量有限制,CPU 可以直接处理的 on-CPU 内存)。这意味着编译器将不得不在现在更大的函数中间处理寄存器,因为变量太多。

2) 更长的编译时间。(可量化)

好吧,由于许多原因,仅标头编译在逻辑上会导致更长的编译时间(尽管“统一构建”的性能;逻辑不一定是真实的,其中涉及其他因素)。一个原因可能是,如果整个项目只有标头,那么我们会丢失增量构建。这意味着项目任何部分的任何更改都意味着必须重新构建整个项目,而对于单独的编译单元,一个 cpp 中的更改仅意味着必须重新构建目标文件,并且重新链接项目。

根据我的(轶事)经验,这是一个很大的打击。在某些特殊情况下,仅标头会大大提高性能,但在生产力方面,通常不值得。当您开始获得更大的代码库时,每次从头开始编译的时间可能超过 10 分钟。重新编译一个微小的变化开始变得令人厌烦。你不知道我忘记了多少次“;” 并且不得不等待 5 分钟才能听到它,只是回去修复它,然后再等 5 分钟找到我刚刚通过修复“;”介绍的其他内容。

性能很棒,生产力要好得多;它会浪费你的大量时间,并让你失去动力/分散你的编程目标。

编辑:我应该提到,过程间优化(另请参见链接时优化整个程序优化)试图实现“统一构建”的优化优势。在大多数编译器 AFAIK 中,这种实现仍然有点不稳定,但最终这可能会克服性能优势。

于 2012-09-12T02:14:30.837 回答
4

我希望这与 Realz 所说的不太相似。

可执行文件 (/object) 大小:(可执行文件 0% / 对象仅在标题上最大 50%)

我假设头文件中定义的函数将被复制到每个对象中。在生成可执行文件时,我想说删除重复函数应该相当容易(不知道哪个链接器会/不这样做,我假设大多数都这样做),所以(可能)没有真正的区别可执行文件大小,但对象大小很好。差异在很大程度上取决于标题中实际包含多少代码与项目的其余部分。并不是说对象大小这些天真的很重要,除了链接时间。

运行时间:(1%)

我会说基本相同(函数地址是函数地址),除了内联函数。我希望内联函数在您的平均程序中产生的差异小于 1%,因为函数调用确实有一些开销,但这与实际使用程序执行任何操作的开销相比,这算不了什么。

内存占用:(0%)

可执行文件中的相同内容 = 相同的内存占用(在运行时),假设链接器删除了重复的函数。如果不删除重复的功能,它会产生很大的不同。

编译时间(对于整个项目和通过更改一个文件):(对于任何一个,整个项目的速度最高可提高 50%,对于非标头,单个项目的速度最高可提高 99%)

巨大的差异。更改头文件中的某些内容会导致包含它的所有内容重新编译,而 cpp 文件中的更改只需要重新创建该对象并重新链接。对于仅标头库的完整编译,速度会慢 50%。但是,对于预编译头文件或统一构建,使用仅头文件库的完整编译可能会更快,但是需要重新编译大量文件的更改是一个巨大的缺点,我会说这使得它不值得. 不需要经常完全重新编译。此外,您可以在 cpp 文件中包含某些内容,但不能在其头文件中包含(这种情况经常发生),因此,在适当设计的程序(树状依赖结构/模块化)中,当更改函数声明或某些内容时(总是需要对头文件的更改),

链接时间:(仅标头最多快 50%)

对象可能更大,因此处理它们需要更长的时间。可能与文件的大小成线性比例。根据我在大型项目中的有限经验(编译+链接时间足够长,实际上很重要),与编译时间相比,链接时间几乎可以忽略不计(除非您不断进行小的更改和构建,那么我希望您会感觉到它,我想这可能经常发生)。

于 2012-10-02T15:31:42.693 回答