G++ 不能内联多态方法?

G++ Can't Inline Polymorphic Method?

经过优化的 G++ 似乎无法内联来自翻译单元静态变量的微不足道的函数调用。下面的代码和编译输出示例。请注意,函数 can_inline_local 通过使用 DerivedType 的本地实例完美地内联调用,但是 cant_inline_static 是一个相当长的调用。

在你因为过早的优化而报警之前,我想为自己辩护,多态继承会非常清楚地描述我的内核级串行驱动程序中断服务例程。如果 G++ 只能为我内联虚拟调用(使用我认为它在编译时应该知道的内容),那么我将拥有编译为 C 性能的清晰+可测试代码。

我正在使用 arm-none-eabi-g++ -v gcc 版本 4.9.3 20150529(预发布)(15:4.9.3+svn227297-1)

arm-none-eabi-g++ -std=gnu++11 -O3 -c -o inline.o inline.cpp && arm-none-eabi-objdump inline.o -S > inline.dump

inline.cpp:

extern "C"{
    int * const MEMORY_MAPPED_IO_A = (int*)0x40001000;
    int * const MEMORY_MAPPED_IO_B = (int*)0x40002000;
}

namespace{
    /** Anon namespace should make these
        typedefs static to this translation unit */
    struct BaseType{
        void* data;
        virtual void VirtualMethod(int parameter){
            *MEMORY_MAPPED_IO_A = parameter;
        }

        void VirtualCaller(int parameter){
            this->VirtualMethod(parameter);
        }
    };

    struct DerivedType : BaseType{
        void VirtualMethod(int parameter) final {
            *MEMORY_MAPPED_IO_B = parameter;
        }
    };

    /** static keyword here may be superfluous */
    static BaseType basetype;
    static DerivedType derivedtype;

    extern "C"{
        void cant_inline_static(int parameter){
            derivedtype.VirtualCaller(1);
        }

        void can_inline_local(int parameter){
            DerivedType localobj;
            localobj.VirtualCaller(1);
        }
    }
}

inline.dump

inline.o:     file format elf32-littlearm


Disassembly of section .text:

00000000 <_ZN12_GLOBAL__N_18BaseType13VirtualMethodEi>:
   0:   e59f3004    ldr r3, [pc, #4]    ; c <_ZN12_GLOBAL__N_18BaseType13VirtualMethodEi+0xc>
   4:   e5831000    str r1, [r3]
   8:   e12fff1e    bx  lr
   c:   40001000    .word   0x40001000

00000010 <_ZN12_GLOBAL__N_111DerivedType13VirtualMethodEi>:
  10:   e59f3004    ldr r3, [pc, #4]    ; 1c <_ZN12_GLOBAL__N_111DerivedType13VirtualMethodEi+0xc>
  14:   e5831000    str r1, [r3]
  18:   e12fff1e    bx  lr
  1c:   40002000    .word   0x40002000

00000020 <cant_inline_static>:
  20:   e59f0028    ldr r0, [pc, #40]   ; 50 <cant_inline_static+0x30>
  24:   e5903000    ldr r3, [r0]
  28:   e59f2024    ldr r2, [pc, #36]   ; 54 <cant_inline_static+0x34>
  2c:   e5933000    ldr r3, [r3]
  30:   e1530002    cmp r3, r2
  34:   1a000003    bne 48 <cant_inline_static+0x28>
  38:   e3a02001    mov r2, #1
  3c:   e59f3014    ldr r3, [pc, #20]   ; 58 <cant_inline_static+0x38>
  40:   e5832000    str r2, [r3]
  44:   e12fff1e    bx  lr
  48:   e3a01001    mov r1, #1
  4c:   e12fff13    bx  r3
    ...
  58:   40002000    .word   0x40002000

0000005c <can_inline_local>:
  5c:   e3a02001    mov r2, #1
  60:   e59f3004    ldr r3, [pc, #4]    ; 6c <can_inline_local+0x10>
  64:   e5832000    str r2, [r3]
  68:   e12fff1e    bx  lr
  6c:   40002000    .word   0x40002000

Disassembly of section .text.startup:

00000000 <_GLOBAL__sub_I_cant_inline_static>:
   0:   e59f3014    ldr r3, [pc, #20]   ; 1c <_GLOBAL__sub_I_cant_inline_static+0x1c>
   4:   e59f2014    ldr r2, [pc, #20]   ; 20 <_GLOBAL__sub_I_cant_inline_static+0x20>
   8:   e2831008    add r1, r3, #8
   c:   e2833018    add r3, r3, #24
  10:   e5821008    str r1, [r2, #8]
  14:   e5823000    str r3, [r2]
  18:   e12fff1e    bx  lr
    ...

更新

简单地注释掉 BaseType 中的 void* 数据; 字段允许对琐碎的虚拟调用进行积极优化。下面是 objdump。如果 class 具有可能未初始化的数据成员,则 G++ 似乎不信任使用静态实例方法。有什么方法可以指定 class 是它看起来的样子并且不需要构造或初始化吗?如果编译器假设这样的事情会因为某些我不知道的 over-designed/esoteric 特性而导致所有 C++ 都失效吗?我觉得我正在抓住救命稻草,但值得再问一问。

inline.o:     file format elf32-littlearm


Disassembly of section .text.cant_inline_static:

00000000 <cant_inline_static>:
  0:    2201        movs    r2, #1
  2:    4b01        ldr r3, [pc, #4]    ; (8 <cant_inline_static+0x8>)
  4:    601a        str r2, [r3, #0]
  6:    4770        bx  lr
  8:    40002000    .word   0x40002000

Disassembly of section .text.can_inline_local:

00000000 <can_inline_local>:
  0:    2201        movs    r2, #1
  2:    4b01        ldr r3, [pc, #4]    ; (8 <cant_inline_static+0x8>)
  4:    601a        str r2, [r3, #0]
  6:    4770        bx  lr
  8:    40002000    .word   0x40002000

最终更新

我计算出了cant_inline_static开头的记账代码。它只是获取静态实例 derivedtype,取消引用其 vtable,查找 VirtualMethod 条目,然后将其与 .text 地址进行比较DerivedType::VirtualMethod。如果它们匹配:内联过程是 运行。如果它们不同:调用实例的 vtable 方法。

看起来 G++ 期望虚拟调用最终是 DerivedType::VirtualMethod 但它担心 static DerivedType derivedtype 变量的 vtable 可能指向不同的方法。如果您初始化 DerivedType 的所有成员(和继承的成员)变量,那么 G++ 就会获得完全内联 'VirtualMethod' 所需的信心。正如@rici 解释的那样,它很可能与 'derivedtype' 实例被放置 .data (显式初始化)而不是 .bss 有关。

要添加的有趣的一点:如果 derivedtypebasetype 实例都调用 VirtualCaller 那么无论成员初始化如何,G++ 都会添加簿记代码。

在这一点上,我正在扮演考古学家的角色,发现一些人是如何编写 G++ 优化器的这一部分的。这是一次有趣的旅程。我在这里得到了一些非常好的帮助。并且在这个过程中学到了很多关于虚方法性能的知识。

我对ARM汇编编程几乎一无所知,所以我很可能会把自己搞得一团糟:)但看起来确实是内联的。在这两个函数中你可以找到:

e3a02001    mov r2, #1         ; put 1 to register r2
e59f3014    ldr r3, [pc, #20]  ; put address 0x40002000 to r3
e5832000    str r2, [r3]       ; store value of r1 to adress in r3

在这两种情况下都没有调用方法(我希望有 bl 指令)。 在静态变量的情况下,显然有一些我不理解的簿记代码,但它似乎与内联无关。如果非要我猜的话,我会说它正在从一些 table 加载静态对象的地址来检查它是否被实例化,而在另一种情况下,本地对象似乎被完全优化掉了,从而导致更短代码。

TL;DR:

void* data; 替换为 void* data = 0;。 (如果有更多数据成员,则必须将它们中的每一个初始化为某个编译时常量值。)

一旦你这样做,g++ 将在目标文件中预初始化 derivedtype,而不是在 运行 时这样做。


免责声明:

这不是语言律师问题,所以我没有写语言律师答案。以下大部分是 实现相关的 ,这意味着它可能不适用于与我尝试过的不同的任何特定编译器、版本或月相。它特指GCC,更特指ELF目标文件;涵盖了 Intel 和 ARM 体系结构,但我没有对它进行概括。

C++ 中的静态初始化充满了(有些人会说“受其困扰”)魔鬼占据的细节和极端情况。下面的介绍过于简单,因为 (1) 在这种情况下,大部分细节都无关紧要; (2) 我不知道 ELF 加载器的所有细节,尤其是在 ARM 平台上。不过我觉得还是比较符合实际的。


静态初始化和 C++ 标准:

正如我上面所说,这不是语言律师的回答,所以我不会提供标准的长引号。您可以阅读标准本身的 §3.6.2 ([basic.start.init])。本质上,如果初始化器行为良好且没有副作用,编译器可以安排在任何它想要的时间初始化全局变量,但不得晚于绝对必要的时间。要清楚后者,这里是唯一的标准引用:

If the initialization is deferred to some point in time after the first statement of main, it shall occur before the first odr-use of any function or variable defined in the same translation unit as the variable to be initialized. (§3.6.2, para. 4).

允许延迟初始化的主要原因是允许动态加载。动态(或按需)加载允许程序在所有模块实际加载并 link 进入可执行文件之前启动 运行ning。这可以加快启动速度(例如,这样可执行文件可以立即绘制启动画面),方法是将其与读取程序所需的所有库所需的慢速磁盘访问重叠,其中一些库可能不需要全部,取决于具体用户对程序的要求。

所以标准允许(但不要求)一种“按需”初始化的形式;为了实现这一点,它可能会在“任何函数或变量的 ODR 使用”之前插入初始化检查,这可能是第一次使用。这正是您在 cant_inline_static.

的(内联)调用之前看到的代码

初始化和多态对象

重要的是 derivedtype 是多态 class 的一个实例。多态 class 的每个实例都有一个额外的隐藏数据成员,其中包括指向函数指针向量(和其他信息)的指针(“vptr”),通常称为“vtable”。虚函数调用是这样实现的:在运行时,虚函数调用是通过对象的vtable间接调用的。 [注 1] 关于这个还有很多可以说的,但这里的重点是多态的每个实例 class 都有一个 vptr,其值需要被初始化。

所以并不是说“对象不需要初始化”。多态 class 的每个实例都需要初始化。然而,vtable 的(符号)地址在编译时是已知的,所以这个 可以 作为常量初始化执行。或者不是,正如编译器认为合适的那样,因为 vtables 和 vptr 是实现细节,而不是 C++ 标准强制要求的。 (这是礼貌的虚构。我不相信存在不使用 vtable 和 vptr 的实现。vtable 的精确布局和内容确实因实现而异。)

初始化和加载程序

在程序(翻译单元的集合)的编译(“翻译”)和 main() 的执行开始之间,需要将各种翻译的翻译单元(目标文件)读入内存并组合成程序图像。在这样做的过程中,在一个翻译单元中定义并在另一个翻译单元中使用的名称需要分配地址,并且需要在使用它们的地方插入地址。即使在单个翻译单元中,通常也需要修改对名称的引用以考虑分配给名称的实际地址。

这些不同的过程——加载、linking、重定位——没有被 C++ 标准详细定义(或根本没有定义),它对待 整个 程序的执行——包括上述步骤——作为程序执行的一部分。因此,一些被描述为“在 main 的第一个语句之前”发生的事情实际上发生在 linking 和加载步骤中。

在 Intel/ARM 平台上,gcc 将翻译单元编译成 ELF 目标文件。还有一个 linker 将 ELF 目标文件组合成单个 ELF 可执行文件(可能引用外部库)。一个 ELF 文件由许多“部分”组成,每个部分具有不同的特征。

ELF 定义了大量的节类型和选项,但实际上有三个主要的 class 节,它们通常被混淆地描述为文本、数据和 bss。

  • 文本部分代表只读内存。 (该限制可能会或可能不会由 OS 强制执行)。这包括程序本身,以及初始化为编译时常量值的静态常量对象。目标文件包含这些部分的实际位表示,以及在 link 时间插入符号地址的一些指示。 [注2]

  • 数据段代表初始化的读写内存。这包括静态对象,其值可以由编译器计算,但可以在 运行 时修改。同样,目标文件包含初始值的实际位表示。

  • bss sections(这个名字是历史的好奇,详见Wikipedia)代表零初始化的读写内存。这用于静态对象,其初始值将在 运行 时间(如果)必要时计算。对象文件只包含这些对象的大小;没有提供位表示。加载程序将这些部分的初始值安排为零,方法是显式清除分配的内存或使用虚拟内存系统将内存映射到将在第一次引用时清零的页面。

ELF 还允许编译器提供初始化部分,这是要在加载过程结束时执行的可执行代码,即在实际的主要可执行文件启动之前。 [注3]

初始值大部分为零的读写对象可以放置在数据部分,明确为零,或者 bss 部分连同代码以 运行-time 初始化非- 零元素。如果它在 bss 部分中,则初始化代码可能在初始化部分中,或者它可能在延迟执行的构造函数中。 Gcc 将根据自己的启发式和优化标志选择上述策略之一。

我不知道 gcc 使用的所有启发式方法,但我相信它通常更喜欢 bss 部分,这是合乎逻辑的,因为在循环中零初始化内存通常比复制一堆更快从磁盘文件中提取零,以及将字节保存在磁盘文件本身中。但是,如果您明确地对数据进行零初始化,则 gcc 将使用一个数据部分,除非整个对象都进行了零初始化(即使如此,如果您指定了 -fno-zero-initialized-in-bss)。所以你可以观察到两者之间的区别:

struct S {
  int one = 1;
  int zeros[1000000] = {0};
};
S s;

struct S {
  int one = 1;
  int zeros[1000000];
};
S s;

在我的系统上,生成的目标文件的大小是 4,000,962 对 2,184 字节。

返回OP

因此,在问题的代码中,我们有一个静态对象 derivedtype,它带有一个(继承的)默认初始化数据成员。由于它是一个多态对象的实例,它还有一个内部的vptr数据成员,需要对其进行初始化。所以它看起来像一个混合数据对象,因此 gcc 将它放在 bss 部分并插入代码以在需要时(懒惰地)初始化它。

显式初始化数据成员(甚至为 0)会导致 gcc 将对象放入数据部分,使其静态初始化;这避免了惰性初始化代码。

但对象实际上不需要初始化

碰巧,在这种特殊情况下,不可能通过指向 derivedtype 的指针调用虚拟成员函数。所以从某种意义上说,如果 vptr 成员从未被初始化,那真的无关紧要。但是期望编译器甚至考虑检查该场景是完全不合理的。如果你创建一个多态的class,那只能是因为你打算多态地调用成员函数。对 class 的实例进行完全逃逸分析以确定是否会发生多态调用几乎总是完全浪费时间,因此没有理由让任何人费心将检查包括在内一个编译器。 (这是个人意见。你可以自由地不同意。:-))

如果您真的想告诉编译器某个特定的成员函数调用不是多态的,您可以使用显式调用自由地这样做:

derivedtype.DerivedType::VirtualMethod(p);

更进一步,您可以通过调用不使用 this 的多态方法(即如果不使用 static多态)使用类似的东西:

((DerivedType)nullptr)->DerivedType::VirtualMethod(p);

甚至:

((decltype(derivedtype)*)(nullptr)->decltype(derivedtype)::VirtualMethod(p);

但是在您的代码中,这将不起作用,因为您实际上调用了 VirtualCaller,它明确地使用了 this。 (老实说,我不太明白那里的逻辑)。然而,上面的 hack —— 我 永远不会 在代码审查中接受 —— 确实避免了 odr-using derivedtype,从而避免了初始化它的需要。 See it here on the Godbolt Interactive GCC compiler


备注

  1. 这是过于简单化了(参见免责声明)。 vtable实际上是一种对象描述符,而不仅仅是函数指针的向量,在虚继承的情况下,一个对象中可能有多个vptr。就此答案而言,none 是相关的。

  2. 只读数据部分通常称为 .rodata,但它们仍通常被描述为“文本”部分。这是我警告过的过度简化之一。

  3. 对于动态加载的库,初始化代码将在动态加载程序将模块加载到内存后执行,然后返回执行程序。这通常会在 main() 开始后很久。但同样,这与这里无关。