编译器或者链接器会优化掉虚表吗

垃圾民科内容,勿读,请搜索devirtualization、LTO等等内容

引子

前一段时间和实验室的同学讨论虚表和RTTI相关的问题,由于我是编译器论调的拥泵,所以我信誓旦旦的说如果编译器发现虚表或者虚函数无用的话,会自动优化掉这些虚表信息。但是查过资料才发现,其实编译器并不会这么做!


ODR原则

在C++中有一个很重要的概念就是ODR原则,总的来说,ODR分为3个方面:

  1. 一个模板,类型,函数或者"对象"可以在多个编译单元中各存在一份拷贝。其中的一些可以有任意数量的声明。一个定义提供一个实例

  2. 但是在整个程序中,一个"对象"或者non-inline函数只能有一份儿定义。你可以声明一个"对象"或者函数,但是永远不使用它,即并不需要提供定义。但绝对不能有超过一份儿的定义。

  3. 其他的例如类型,模板或者extern line函数,可以在不止一份编译单元中定义。对于一个给定的实体,每一份儿定义必须相同。

国外曾经有人剖析过clang的启动时间,使用callgrind发现大部分时间都耗费在"_dl_look_up_symbol_x"上。使用gdb查看调用栈发现,动态链接器在动态重定位上耗费了太多的时间。

注:_dl_look_up_symbol_x是glibc C运行时库中的一个内部函数,在加载动态库的时候用于寻找符号名(例如:函数),在程序第一次启动的时候会被调用很多次

使用"objdump -R clang"(查看动态重定位信息)这个人发现超过42%的符号含有"clang",进一步研究发现这些符号大部分都是含有虚表的Clang内部类定义。

基本上运行时的重定位,42%的时间用于查找Clang内部类相关的符号。到底是什么原因导致的呢?

其实导致这个问题是由于编译器和链接器没有很好的交互导致的,这就不得不提到小标题ODR原则。对于普通函数来说,ODR原则比较容易理解,整个程序中只能有一份定义,否则链接的时候会报出重定义的错误。但是编译器隐式产生的一些信息,例如虚表,会在用到虚表的编译单元中生成虚表和虚函数等相关定义。由于虚表可能被同时定义在多个编译单元中,标识虚表的符号应该作为weak,weak symbols彼此之间不冲突,最终只保留一份儿定义并丢弃掉其他的备份。当然这些操作都是链接器来实现的。

但不幸的是,编译器并不知道当前的这个编译单元是否有可能被作为动态库的一部分或者作为main执行体的一部分。所以会将虚表存储在编译单元中,但为了应用ODR原则,需要删除多出来的无用的虚表信息,这些操作因此只能延迟到动态链接的时候。

但是在链接的时候,链接器就有机会去消除掉动态重定位,因为链接器知道目标文件是否作为main可执行体,如果是可执行体那么执行体中的定义就不能被其他的定义所覆盖。但是传统的ld链接器并不支持这种优化。但是新的gold链接器是支持这种优化操作的。


编译器或者链接器会不会优化掉虚表

前面我们已经探讨过(见 C++中的out-of-line虚函数),如果class中的没有out-of-line的虚函数,那么虚表定义会被生成到所有使用到它的编译单元中,然后在链接的时候来选择去掉冗余。

编译器能否优化掉虚表信息的关键点是 “虚表是否有用”或者“是否是死代码”,如果编译器能够知道当前的目标文件作为main可执行体或者作为动态库,那么编译器就可以有选择地优化掉虚表信息。但是关键是编译器并不知道当前的文件是否是main可执行体,所以编译器会做保守选择,不去优化虚表信息

从以下几个方面来看,编译器不太可能作虚表相关的优化:

1.通常情况下,编译器只能看到一个编译单元。编译器不能确定是否只有一个子类,或许几个月后心血来潮,你又添加了一个子类,编译然后和以前的目标文件链接在一起。

2.另外考虑到动态加载,在编译该文件很长时间之后,你仍然能够添加更多的子类。如果我是个编译器作者,我不会去冒险提供这样的优化。

3.另外完全没有必要。如果你担心虚函数的效率问题,你完全可以使用CRTP(curiously recurring template pattern)来实现静态多态。

虽然不可能优化掉虚表或者虚函数信息,但是编译器做到了另一项优化,就是在虚函数调用做到静态决议,如果当前虚调用能够在编译期确定,就无需获取对象地址赋给eax,然后再获取虚表地址或者调整对象首地址获得虚表地址,再在上面加上下标来jmp到正确的地址上去(其实,下标在编译时就是已经确定好的)。例如下面的代码:

#include <iostream>

class A
{
  public:
    virtual void f()
    {
        std::cout << "A::f()" << std::endl;
    }
};

class B : public A
{
  public:
    void f()
    {
        std::cout << "B::f()" << std::endl;
    }
};

int main()
{
    B b;
    A* a = &b;
    // 此处编译器完全可以做到静态决议
    a->f();

    return 0;
}

Clang可以简单的做到这个优化,甚至会inline这个函数调用。生成的汇编代码如下:

Dump of assembler code for function main():
   0x0000000000400500 <+0>: push   %rbp
   0x0000000000400501 <+1>: mov    %rsp,%rbp
   0x0000000000400504 <+4>: mov    $0x40060c,%edi
   0x0000000000400509 <+9>: xor    %al,%al
   0x000000000040050b <+11>:  callq  0x4003f0 <printf@plt>
   0x0000000000400510 <+16>:  xor    %eax,%eax
   0x0000000000400512 <+18>:  pop    %rbp
   0x0000000000400513 <+19>:  retq   

GCC4.6同样能够推断出是否能够做到静态决议,不需要做虚表查找操作,但是它没有进行函数inline操作,生成的汇编代码如下所示:

Dump of assembler code for function main():
   0x0000000000400560 <+0>: sub    $0x18,%rsp
   0x0000000000400564 <+4>: mov    %rsp,%rdi
   0x0000000000400567 <+7>: movq   $0x4007c0,(%rsp)
   0x000000000040056f <+15>:  callq  0x400680 <B::f()>
   0x0000000000400574 <+20>:  xor    %eax,%eax
   0x0000000000400576 <+22>:  add    $0x18,%rsp
   0x000000000040057a <+26>:  retq   

虽然编译器做不到虚表的删除,但是链接器不同,链接器能够得到整个程序的图景,链接器相比编译器来说有更多的信息可以参考,所以链接器完全有可能删除掉无用的虚函数信息,gcc提供了相关优化选项。


Elimination of unused virtual functions

前面我们提到GCC提供了相关选项来优化掉虚表信息,根据现在查到的资料,ARMKEIL会根据用户的需要删除无用的虚函数,该技术称作VFE( Virtual Function Elimination )。可见优化掉无用的虚函数虽然不是通用的做法,但是起码编译器都提供有相关的选项。关于优化掉虚表技术,我们先介绍一个ARM linker的优化技术"Elimination of unused sections"。

当目标文件中的某个区块的代码不可达或者符号信息没有"strong-reference"的时候,Elimination of unused sections技术会删除这个区块。注意linker只会在整个区块都无用的时候才会删除该区块,这个要求未免太过强了,所以ARM compiler提供了一个"–split_sections"选项,为源码中的每个函数产生一个单独的"区块"sections(ELF格式的),然后再将无用函数所在的区块(当然该区块只包含该无用函数)删除。另外ARM compiler相对应的还提供了很多选项,例如"attribute((used))"来保证函数不被优化掉。

其实类似这样的技术在Visual C++和GCC中都存在,例如Visual C++提供了一个Function-level Linking的选项,该选项会将每个函数保存到单独的section里面,当链接器需要用到某个函数时,就把它合并到输出文件中,对于没有用到的函数则将它们抛弃。GCC提供了**-ffunction-sections-fdata-sections**两个选项,用于将函数和数据保持到独立的段中。这种做法可以减小可执行文件的长度,节约空间,但是这个技术会增加编译和链接时间,链接器需要计算各个函数之间的依赖关系,并且所有的函数都保持到独立的段中,目标函数的数量大大增加,重定位过程也会因为段的数目的增加而变得复杂。

为什么静态运行库里面一个目标文件只包含一个函数?
答:.a静态库文件就是一组.o文件的集合,如果一个.o文件包含多个函数,那么链接器在进行链接的时候有可能会将不需要的函数一并包含进来(链接是以.o文件为基本单位实现的),所以将每个函数单独放在一个.o文件中,可以减少空间的浪费。其实上述的两个删除无用区块的方式就是借鉴这种思想,将函数放到单独的区块中,然后选择性的链入。基本思想就是将粒度划分的很细,然后有比较大的灵活性。

elimination of unused virtual functionselimination of unused sections的精炼版。删除无用区块技术可以高效的删除C代码中的无用函数,但是在C++应用中,virtual functions 和 RunTime Type Information( RTTI )对象是通过一种所谓的指针表格来引用的,也就是虚表(vtables)。即使这个虚函数没有被用到,但是虚函数的符号还是会被虚表所引用,所以传统的基于删除无用区块的技术对删除无用虚函数来说没有用。如果没有额外的信息,linker不可能知道哪项虚表条目会在运行时被用到。所以elimination of unused sections对C++应用来说不太使用,而**VFE( Virtual Function Elimination )**技术能够解决这个问题。

VFE技术是ARM编译器和链接器合作来完成的,ARM编译器提供关于无用函数的额外信息,然后链接器借助这个信息来删除无用虚函数和RTTI对象。ARM编译器会在一个以**.arm_vfe**开头的"区块"(sections)中存放这些额外信息。当然标准elf格式文件中没有这个区块,ARM linker是自己实现的,所以区块格式是什么样子的也就无所谓了。

这些信息来源于ARMKEIL,由于我没有看到关于这个更详细的论文,所以实现机制不太明确。

2008年有一篇专利是关于关于优化虚表技术的Optimized code generation through elimination of unused virtual functions有时间在详细分析这个技术。

综上所述,通用的编译器是不太可能优化掉虚表的,顶多是静态决议虚调用,但是链接器可以在编译器的帮助下优化掉虚表信息。如果代码运行环境对代码size要求较高,可以有选择地删除一些虚表信息。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值