C++服务编译耗时优化原理及实践( 二 )


② 外部模板语法:extern template class vector 。
一旦在一个编译单元中使用了外部模板声明,那么编译器在编译该编译单元时,会跳过与该外部模板声明匹配的模板实例化 。
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头文件,不合理的依赖关系造成这个文件被改动,几乎所有的模块都会重新编译 。


推荐阅读