一、背景

大型C++工程项目,都会面临编译耗时较长的问题。不论是开发调试迭代、准入测试,亦或是继续集成阶段,编译行为无处不在,升高编译工夫对进步研发效率来说具备十分重要意义。

美团搜寻与NLP部为公司提供根底的搜寻平台服务,出于性能的思考,底层的根底服务通过C++语言实现,其中咱们负责的深度查问了解服务(DeepQueryUnderstanding,下文简称DQU)也面临着编译耗时较长这个问题,整个服务代码在优化前编译工夫须要二十分钟左右(32核机器并行编译),曾经影响到了团队开发迭代的效率。基于这样的背景,咱们针对DQU服务的编译问题进行了专项优化。在这个过程中,咱们也积攒了一些优化的常识和教训,在这里分享给大家。

二、编译原理及剖析

2.1 编译原理介绍

为了更好地了解编译优化计划,在介绍优化计划之前,咱们先简略介绍一下编译原理,通常咱们在进行C++开发时,编译的过程次要蕴含上面四个步骤:

预处理器:宏定义替换,头文件开展,条件编译开展,删除正文。

  • gcc -E选项能够失去预处理后的后果,扩大名为.i 或 .ii。
  • C/C++预处理不做任何语法查看,不仅是因为它不具备语法查看性能,也因为预处理命令不属于C/C++语句(这也是定义宏时不要加分号的起因),语法查看是编译器要做的事件。
  • 预处理之后,失去的仅仅是真正的源代码。

编译器:生成汇编代码,失去汇编语言程序(把高级语言翻译为机器语言),该种语言程序中的每条语句都以一种规范的文本格式确切的形容了一条低级机器语言指令。

  • gcc -S选项能够失去编译后的汇编代码文件,扩大名为.s。
  • 汇编语言为不同高级语言的不同编译器提供了通用的输入语言。

汇编器:生成指标文件。

  • gcc -c选项能够失去汇编后的后果文件,扩大名为.o。
  • .o文件,是依照的二进制编码方式生成的文件。

链接器:生成可执行文件或库文件。

  • 动态库:指编译链接时,把库文件的代码全副退出到可执行文件中,因而生成的文件比拟大,但在运行时也就不再须要库文件了,其后缀名个别为“.a”。
  • 动静库:在编译链接时并没有把库文件的代码退出到可执行文件中,而是在程序执行时由运行时链接文件加载库,这样可执行文件比拟小,动静库个别后缀名为“.so”。
  • 可执行文件:将所有的二进制文件链接起来交融成一个可执行程序,不论这些文件是指标二进制文件还是库二进制文件。

2.2 C++编译特点

(1)每个源文件独立编译

C/C++的编译系统和其余高级语言存在很大的差别,其余高级语言中,编译单元是整个Module,即Module下所有源码,会在同一个编译工作中执行。而在C/C++中,编译单元是以文件为单位。每个.c/.cc/.cxx/.cpp源文件是一个独立的编译单元,导致编译优化时只能基于本文件内容进行优化,很难跨编译单元提供代码优化。

(2)每个编译单元,都须要独立解析所有蕴含的头文件

如果N个源文件援用到了同一个头文件,则这个头文件须要解析N次(对于Thrift文件或者Boost头文件这类动辄几千上万行的头文件来说,几乎就是“鬼故事”)。

如果头文件中有模板(STL/Boost),则该模板在每个cpp文件中应用时都会做一次实例化,N个源文件中的std::vector<int>会实例化N次。

(3)模板函数实例化

在C++ 98语言规范中,对于源代码中呈现的每一处模板实例化,编译器都须要去做实例化的工作;而在链接时,链接器还须要移除反复的实例化代码。显然编译器遇到一个模板定义时,每次都去进行反复的实例化工作,进行反复的编译工作。此时,如果可能让编译器防止此类反复的实例化工作,那么能够大大提高编译器的工作效率。在C++ 0x规范中一个新的语言个性 -- 内部模板的引入解决了这个问题。

在C++ 98中,曾经有一个叫做显式实例化(Explicit Instantiation)的语言个性,它的目标是批示编译器立刻进行模板实例化操作(即强制实例化)。而内部模板语法就是在显式实例化指令的语法根底上进行批改失去的,通过在显式实例化指令前增加前缀extern,从而失去内部模板的语法。

① 显式实例化语法:template class vector<MyClass>。
② 内部模板语法:extern template class vector<MyClass>。

一旦在一个编译单元中应用了内部模板申明,那么编译器在编译该编译单元时,会跳过与该内部模板申明匹配的模板实例化。

(4)虚函数

编译器解决虚函数的办法是:给每个对象增加一个指针,寄存了指向虚函数表的地址,虚函数表存储了该类(包含继承自基类)的虚函数地址。如果派生类重写了虚函数的新定义,该虚函数表将保留新函数的地址,如果派生类没有从新定义虚函数,该虚函数表将保留函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址将被增加到虚函数表中。

调用虚函数时,程序将查看存储在对象中的虚函数表地址,转向相应的虚函数表,应用类申明中定义的第几个虚函数,程序就应用数组的第几个函数地址,并执行该函数。

应用虚函数后的变动:

① 对象将减少一个存储地址的空间(32位零碎为4字节,64位为8字节)。
② 每个类编译器都创立一个虚函数地址表。
③ 对每个函数调用都须要减少在表中查找地址的操作。

(5)编译优化

GCC提供了为了满足用户不同水平的的优化须要,提供了近百种优化选项,用来对编译工夫,指标文件长度,执行效率这个三维模型进行不同的取舍和均衡。优化的办法不一而足,总体上将有以下几类:

① 精简操作指令。
② 尽量满足CPU的流水操作。
③ 通过对程序行为地猜想,从新调整代码的执行程序。
④ 充沛应用寄存器。
⑤ 对简略的调用进行开展等等。

如果全副理解这些编译选项,对代码针对性的优化还是一项简单的工作,侥幸的是GCC提供了从O0-O3以及Os这几种不同的优化级别供大家抉择,在这些选项中,蕴含了大部分无效的编译优化选项,并且能够在这个根底上,对某些选项进行屏蔽或增加,从而大大降低了应用的难度。

  • O0:不做任何优化,这是默认的编译选项。
  • O和O1:对程序做局部编译优化,编译器会尝试减小生成代码的尺寸,以及缩短执行工夫,但并不执行须要占用大量编译工夫的优化。
  • O2:是比O1更高级的选项,进行更多的优化。GCC将执行简直所有的不蕴含工夫和空间折中的优化。当设置O2选项时,编译器并不进行循环展开以及函数内联优化。与O1比较而言,O2优化减少了编译工夫的根底上,进步了生成代码的执行效率。
  • O3:在O2的根底上进行更多的优化,例如应用伪寄存器网络,一般函数的内联,以及针对循环的更多优化。
  • Os:次要是对代码大小的优化, 通常各种优化都会打乱程序的构造,让调试工作变得无从着手。并且会打乱执行程序,依赖内存操作程序的程序须要做相干解决能力确保程序的正确性。

编译优化有可能带来的问题:

调试问题:正如下面所提到的,任何级别的优化都将带来代码构造的扭转。例如:对分支的合并和打消,对专用子表达式的打消,对循环内load/store操作的替换和更改等,都将会使指标代码的执行程序变得面目全非,导致调试信息严重不足。

内存操作程序扭转问题:在O2优化后,编译器会对影响内存操作的执行程序。例如:-fschedule-insns容许数据处理时先实现其余的指令;-fforce-mem有可能导致内存与寄存器之间的数据产生相似脏数据的不统一等。对于某些依赖内存操作程序而进行的逻辑,须要做严格的解决后能力进行优化。例如,采纳Volatile关键字限度变量的操作形式,或者利用Barrier迫使CPU严格依照指令序执行。

(6)C/C++ 跨编译单元的优化只能交给链接器

当链接器进行链接的时候,首先决定各个指标文件在最终可执行文件里的地位。而后拜访所有指标文件的地址重定义表,对其中记录的地址进行重定向(加上一个偏移量,即该编译单元在可执行文件上的起始地址)。而后遍历所有指标文件的未解决符号表,并且在所有的导出符号表里查找匹配的符号,并在未解决符号表中所记录的地位上填写实现地址,最初把所有的指标文件的内容写在各自的地位上,就生成一个可执行文件。链接的细节比较复杂,链接阶段是单过程,无奈并行减速,导致大我的项目链接极慢。

三、服务问题剖析

DQU是美团搜寻应用的查问了解平台,外部蕴含了大量的模型、词表、在代码构造上,蕴含20多个Thrift文件 ,应用大量Boost处理函数 ,同时引入了SF框架,公司第三方组件SDK以及分词三个Submodule,各个模块采纳动静库编译加载的形式,模块之间通过音讯总线做数据的传输,音讯总线是一个大的Event类,这样这个类就蕴含了各个模块须要的数据类型的定义,所以各个模块都会引入Event头文件,不合理的依赖关系造成这个文件被改变,简直所有的模块都会从新编译。

每个服务所面临的编译问题都有各自的特点,然而遇到问题的实质起因是相似的,联合编译的过程和原理,咱们从预编译开展、头文件依赖以及编译过程耗时3个方面对DQU服务编译问题进行了剖析。

3.1 编译开展剖析

编译开展剖析就是通过C++的预编译阶段保留的.ii文件,查看通过开展后的编译文件大小,具体能够通过在cmake中指定编译选型 “-save-temps” 保留编译两头文件。

set(CMAKE_CXX_FLAGS "-std=c++11 ${CMAKE_CXX_FLAGS} -ggdb -Og -fPIC -w -Wl,--export-dynamic -Wno-deprecated -fpermissive -save-temps")

编译耗时的最间接起因就是编译文件开展之后比拟大,通过编译开展后的文件大小和内容,通过预编译开展剖析能看到文件开展后的文件有40多万行,发现有大量的Boost库援用及头文件援用造成的开展文件比拟大,影响到编译的耗时。通过这个形式可能找到各个文件编译耗时的共性,下图是编译开展后文件大小截图。

3.2 头文件依赖剖析

头文件依赖剖析是从援用头文件数量的角度来看代码是否正当的一种剖析形式,咱们实现了一个脚本,用来统计头文件的依赖关系,并且剖析输入头文件依赖援用计数,用来辅助判断头文件依赖关系是否正当。

(1) 头文件援用总数后果统计

通过工具统计出编译源文件间接和间接依赖的头文件的总个数,用来从头文件引入数量上剖析问题。

(2) 单个头文件依赖关系统计

通过工具剖析头文件依赖关系,生成依赖关系拓扑图,可能直观的看到依赖不合理的中央。

图中蕴含援用档次关系,以及援用头文件个数。

3.3 编译耗时后果分段统计

编译耗时分段统计是从后果上看各个文件的编译耗时以及各个编译阶段的耗时状况,这个是直观的一个后果,失常状况下,是和文件开展大小以及头文件援用个数是正相干的,cmake通过指定环境变量能打印出编译和链接阶段的耗时状况,通过这个数据能直观的剖析出耗时状况。

set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CMAKE_COMMAND} -E time")set_property(GLOBAL PROPERTY RULE_LAUNCH_LINK "${CMAKE_COMMAND} -E time")

编译耗时后果输入:

3.4 剖析工具建设

通过下面的工具剖析能拿到几个编译数据:

① 头文件依赖关系及个数。
② 预编译开展大小及内容。
③ 各个文件编译耗时。
④ 整体链接耗时。
⑤ 能够计算出编译并行度。

通过这几个数据的输出咱们思考能够做个自动化剖析工具,找出优化点以及界面化展现。基于这个目标,咱们建设了全流程自动化剖析工具,可能主动剖析耗时共性问题以及TopN耗时文件。剖析工具解决流程如下图所示:

(1) 整体统计分析成果

具体字段阐明:

① cost_time 编译耗时,单位是秒。
② file_compile_size,编译两头文件大小,单位是M。
③ file_name,文件名称。
④ include_h_nums,引入头文件个数,单位是个。
⑤ top_h_files_info, 引入最多的TopN头文件。

(2)Top10 编译耗时文件统计

用来展现统计编译耗时最久的TopN文件,N能够自定义指定。

(3)Top10编译两头文件大小统计

通过统计和展现编译文件大小,用来判断这块是否合乎预期,这个是和编译耗时一一对应的。

(4)Top10引入最多头文件的头文件统计

(5)Top10头文件反复次数统计

目前,这个工具反对一键化生成编译耗时剖析后果,其中几个小工具,比方依赖文件个数工具曾经集成到公司的上线集成测试流程中,通过自动化工具查看代码改变对编译耗时的影响,工具的建设还在一直迭代优化中,后续会集成到公司的MCD平台中,能够主动剖析来定位编译耗时长的问题,解决其它部门编译耗时问题。

四、优化计划与实际

通过使用上述相干工具,咱们可能发现Top10编译耗时文件的共性,比方都依赖音讯总线文件platform_query_analysis_enent.h,这个文件又间接间接引入2000多个头文件,咱们重点优化了这类文件,通过工具的编译开展,找出了Boost应用、模板类开展、Thrift头文件开展等共性问题,并针对这些问题做专门的优化。此外,咱们也应用了一些业内通用的编译优化计划,并获得了不错的成果。上面具体介绍咱们采纳的各种优化计划。

4.1 通用编译减速计划

业内有不少通用编译减速工具(计划),无需侵入代码就能进步编译速度,十分值得尝试。

(1)并行编译

在Linux平台上个别应用GNU的Make工具进行编译,在执行make命令时能够加上-j参数减少编译并行度,如make -j 4将开启4个工作。在实践中咱们并不将该参数写死,而是通过$(nproc)办法动静获取编译机的CPU核数作为编译并发度,从而最大限度利用多核的性能劣势。

(2)分布式编译

应用分布式编译技术,比方利用Distcc和Dmucs构建大规模、分布式C++编译环境,Linux平台利用网络集群进行分布式编译,须要思考网络时延与网络稳定性。分布式编译适宜规模较大的我的项目,比方单机编译须要数小时甚至数天。DQU服务从代码规模以及单机编译时长来说,临时还不须要应用分布式的形式来减速,具体细节能够参考Distcc官网文档阐明。

(3)预编译头文件

PCH(Precompiled Header),该办法事后将罕用头文件的编译后果保存起来,这样编译器在解决对应的头文件引入时能够间接应用事后编译好的后果,从而放慢整个编译流程。PCH是业内非常罕用的减速编译的办法,且大家反馈成果十分不错。在咱们的我的项目中,因为波及到很多Shared Library的编译生成,而Shared Library相互之间无奈共享PCH,因而没有获得料想成果。

(4)CCache

CCache(Compiler Cache是一个编译缓存工具,其原理是将cpp的编译后果保留在文件缓存中,当前编译时若对应文件无变动可间接从缓存中获取编译后果。须要留神的是,Make自身也有肯定缓存性能,当指标文件已编译(且依赖无变动)时,若源文件工夫戳无变动也不会再次编译;但CCache是按文件内容做的缓存,且同一机器的多个我的项目能够共享缓存,因而实用面更大。

(5)Module编译

如果你的我的项目是用C++ 20进行开发的,那么祝贺你,Module编译也是一个优化编译速度的计划,C++20之前的版本会把每一个cpp当做一个编译单元解决,会存在引入的头文件被屡次解析编译的问题。而Module的呈现就是解决这一问题,Module不再须要头文件(只须要一个模块文件,不须要申明和实现两个文件),它会将你的(.ixx 或者 .cppm)模块实体间接编译,并主动生成一个二进制接口文件。import和include预处理不同,编译好的模块下次import的时候不会反复编译,能够大幅度提高编译器的效率。

(6)主动依赖剖析

Google也推出了开源的Include-What-You-Use工具(简称IWYU),基于Clang的C/C++工程冗余头文件查看工具。IWYU依赖Clang编译套件,应用该工具能够扫描出文件依赖问题,同时该工具还提供脚本解决头文件依赖问题,咱们尝试搭建了这套剖析工具,这个工具也提供自动化头文件解决方案,然而因为咱们的代码依赖比较复杂,有动静库、动态库、子仓库等,这个工具提供的优化性能不能间接应用,其它团队如果代码构造比较简单的话,能够思考应用这个工具剖析优化,会生成如下后果文件,领导哪些头文件须要删除。

>>> Fixing #includes in '/opt/meituan/zhoulei/query_analysis/src/common/qa/record/brand_record.h'@@ -1,9 +1,10 @@ #ifndef _MTINTENTION_DATA_BRAND_RECORD_H_ #define _MTINTENTION_DATA_BRAND_RECORD_H_-#include "qa/data/record.h"-#include "qa/data/template_map.hpp"-#include "qa/data/template_vector.hpp"-#include <boost/serialization/version.hpp>+#include <boost/serialization/version.hpp>  // for BOOST_CLASS_VERSION+#include <string>                       // for string+#include <vector>                       // for vector++#include "qa/data/file_buffer.h"        // for REG_TEMPLATE_FILE_HANDLER

4.2 代码优化计划与实际

(1)前置类型申明

通过剖析头文件援用统计,咱们发现我的项目中被援用最多的是总线类型Event,而该类型中又搁置了各种业务须要的成员,示例如下:

#include “a.h”#include "b.h"class Event {// 业务A, B, C ...  A1 a1;  A2 a2;     // ...  B1 b1;  B2 b2;  // ...};

这导致Event中蕴含了数量宏大的头文件,在头文件开展后,文件大小达到15M;而各种业务都会须要应用Event,天然会重大连累编译性能。

咱们通过前置类型申明来解决这个问题,即不引入对应类型的头文件,只做前置申明,在Event中只应用对应类型的指针,如下所示:

class A2;// ...class Event {// 业务A, B, C ...  shared_ptr<A1> a1;  shared_ptr<A2> a2;  // ...  shared_ptr<B1> b1;  shared_ptr<B2> b2;  // ...};

只有在真正应用对应成员变量时,才须要引入对应头文件;这样真正做到了按需引入头文件。

(2)内部模板

因为模板被应用时才会实例化这一个性,雷同的实例能够呈现在多个文件对象中。编译器要对每一处模板进行实例化,链接器还要移除反复的实例化代码。当在宽泛应用模板的我的项目中,编译器会产生大量的冗余代码,这会极大地减少编译工夫和链接工夫。C++ 11新规范中能够通过内部模板来防止。

// util.htemplate <typename T> void max(T) { ... }
// A.cppextern template void max<int>(int);#include "util.h"template void max<int>(int); // 显式地实例化 void test1(){     max(1);}


在编译A.cpp的时候,实例化出一个 max<int>(int)版本的函数。

// B.cpp#include "util.h"extern template void max<int>(int); // 内部模板的申明void test2(){    max(2);}

在编译B.cpp的时候,就不再生成 max<int>(int)实例化代码,这样就节俭了后面提到的实例化,编译以及链接的耗时了。

(3)多态替换模板应用

咱们的我的项目重度应用词典相干操作,如加载词典、解析词典、匹配词典(各种花式匹配),这些操作都是通过Template模板扩大反对各种不同类型的词典。据统计,词典的类型超过150个,这也造成模板开展的代码量收缩。

template <class R>class Dict {public:  // 匹配key和condition,赋值给record  bool match(const string &key, const string &condition, R &record);  // 对每种类型的Record都会开展一次private:  map<string, R> dict;};


侥幸的是,咱们词典的绝大部分操作都能够形象出几类接口,因而能够只实现针对基类的操作:

class Record {  // 基类public:  virtual bool match(const string &condition);  // 派生类需实现};class Dict {public:  shared_ptr<Record> match(const string &key, const string &condition);  // 应用方传入派生类的指针即可private:  map<string, shared_ptr<Record>> dict;};


通过继承和多态,咱们无效防止了大量的模板开展。须要留神的是,应用指针作为Map的Value会减少内存调配的压力,举荐应用Tcmalloc或Jemalloc替换默认的Ptmalloc优化内存调配。

(4)替换Boost库

Boost是一个宽泛应用的根底库,涵盖了大量罕用函数,非常不便、好用,然而也存在一些不足之处。一个显著毛病是其实现采纳了hpp的模式,即申明和实现均放在头文件中,这会造成预编译开展后非常微小。

// 字符串操作是罕用性能,仅仅引入该头文件开展大小就超过4M#include <boost/algorithm/string.hpp>// 与此绝对的,引入多个STL的头文件,开展后仅仅只有1M#include <vector>#include <map>// ...

在咱们我的项目中次要应用的Boost函数不超过二十个,局部能够在STL中找到代替,局部咱们手动做了实现,使得我的项目从重度依赖Boost转变成绝大部分达到Boost-Free,大大降低了编译的累赘。

(5)预编译

代码中有一些平时改变比拟少,然而对编译耗时产生肯定的影响,比方Thrift生成的文件,模型库文件以及Common目录下的通用文件,咱们采取提起预编译成动静库,缩小后续文件的编译耗时,也解决了局部编译依赖。

(6)解决编译依赖,进步编译并行度

在咱们我的项目中有大量模块级别的动静库文件须要编译,cmake文件指定的编译依赖关系在肯定水平上限度了编译并行度的执行。

比方上面这个场景,通过正当设置库文件依赖关系,能够进步编译并行度。

4.3 优化成果

咱们通过32C、64G内存机器做了编译耗时优化前后的成果比照,统计后果如下:

4.4 守住优化成绩

编译优化是一件“逆水行舟”的事件,开发人员总是偏向于一直减少新的性能、新的库乃至新的框架,而要删除旧代码、旧库、下线旧框架总是困难重重(置信一线开发人员肯定深有体会)。因而,如何守住之前获得的优化成绩也是至关重要的。咱们在实践中有以下几点领会:

  • 代码审核是艰难的(引起编译耗时减少的改变,往往无奈通过审核代码直观地发现)。
  • 工具、流程才值得依赖。
  • 关键在于管制增量。

咱们发现,cpp文件的编译耗时,和其预编译开展文件(.ii)大小呈正相干(绝大部分状况下);对每一个上线版本,将其所有cpp文件的预编译开展大小记录下来,就造成了其编译指纹(CF,Compile Fingerprint)。通过比拟相邻两个版本的CF,就能较精确的晓得新版带来的编译耗时次要由哪些改变引入,并能够进一步剖析耗时上涨是否正当,是否有优化空间。

咱们将该种形式制作成脚本工具并引入上线流程,从而可能很分明的理解每次代码发版带来的编译性能影响,并无效地帮忙咱们守住后期的优化成绩。

五、总结

DQU我的项目是美团搜寻业务环节中重要的一环,该零碎须要对接20+RPC、数十个模型、加载超过300个词典,应用内存数十G,日均响应申请超过20亿的大型C++服务。在业务高速迭代的状况,简短的编译工夫为开发同学带来较大的困扰,肯定水平上制约了开发效率。最终咱们通过编译优化剖析工具建设,联合采纳了通用编译优化减速计划和代码层面的优化,将DQU的编译工夫缩短了70%,并通过引CCache等伎俩,使得本地开发的编译,可能在100s内实现,给开发团队节俭了大量的工夫。

在获得阶段性成绩之后,咱们总结整个问题解决的过程,并积淀出一些分析方法、工具以及流程标准。这些工具在后续的开发迭代过程中,可能疾速无效地检测新的代码变更带来的编译工夫变动,并成为了咱们的上线流程查看中的一环检测规范。这一点与咱们以往一次性的或者针对性的编译优化,产生了很大的区别。毕竟代码的保护是一个长久的过程,系统化的解决这一问题,不只是须要无效的办法和便捷的工具,更须要一个标准化的,规范化的上线流程来放弃成绩。心愿本文对大家能有所帮忙。

参考文献

  • [1]《编译原理透视·图解编译原理》
  • [2] CCache
  • [3] 分布式编译
  • [4] 头文件预编译
  • [5] 头文件预编译
  • [6] C++ Templates
  • [7] Include-what-you-use

作者简介

本文作者周磊、识瀚、朱超、王鑫、刘亮、昌术、李超、云森、永超等,均来自美团AI平台搜寻与NLP部。

| 想浏览更多技术文章,请关注美团技术团队(meituantech)官网微信公众号。

| 在公众号菜单栏回复【2019年货】、【2018年货】、【2017年货】、【算法】等关键词,可查看美团技术团队历年技术文章合集。