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

G++ Can't Inline Polymorphic Method?

本文关键字:方法 多态 不能 G++      更新时间:2023-10-16

经过优化的g++似乎不能内联来自翻译单元静态变量的简单函数调用。代码和编译后的输出示例如下。注意函数can_inline_local通过使用DerivedType的本地实例完美地内联调用,然而cant_inline_static是一个相当长的呼叫。

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

我正在使用arm-none-eabi-g++ -vGCC version 4.9.3 20150529 (prerelease) (15:4.9.3+svn227297-1)

arm-none-eabi-g gnu + + + +化11 o3 - c - o内联。p &&arm-none-eabi-objdump内联。0 -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
...

简单地注释掉void* data;字段允许对琐碎的虚拟调用进行积极的优化。下面是objdump。如果类具有可能未初始化的数据成员,那么c++可能不信任使用静态实例方法。是否有任何方法可以指定一个类是它看起来的样子,不需要构造或初始化?如果编译器假设这些事情,所有的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,取消引用它的虚值表,查找VirtualMethod条目,然后将其与derivedtype::VirtualMethod的.text地址进行比较。如果匹配,则运行内联过程。如果它们不相同,则调用实例的vtable方法。

似乎g++希望虚拟调用最终是DerivedType::VirtualMethod,但它担心静态DerivedType变量的虚表可能指向不同的方法。如果你初始化了DerivedType的所有成员(和继承的成员)变量,那么g++就获得了完全内联'VirtualMethod'所需的信心。正如@rici解释的那样,这很可能与'derivedtype'实例被放置在.data(显式初始化)而不是.bss有关。

一个有趣的补充点:如果派生类型基类型实例都调用VirtualCaller,那么无论成员初始化如何,g++都会添加记账代码。

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

TL;DR:

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

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


免责声明:

这不是一个语言律师的问题,所以我没有写一个语言律师的答案。以下大多数都是与实现相关的,这意味着它可能不适用于与我尝试过的不同的任何特定的编译器、版本或月球阶段。它具体指的是GCC,更具体地说是ELF对象文件;它涵盖了英特尔和ARM架构,但我不主张推广它。

c++中的静态初始化充满了(有人会说"被"困扰的)繁琐的细节和角落案例。下面的演示过于简化了,因为(1)在这种情况下,大多数细节并不重要;(2)我不知道ELF加载器的所有细节,特别是在ARM平台上。但我认为它或多或少符合现实。


静态初始化与c++标准:

正如我上面所说的,这不是一个语言律师的答案,所以我不打算提供标准的长引语。你可以在标准本身中阅读§3.6.2 ([basic.start.init])。从本质上讲,如果初始化器行为良好且没有副作用,编译器可以在任何时候对全局变量进行初始化,但不会晚于严格必要的时间。为了明确后者,这里是唯一的标准引用:

如果初始化延迟到main语句的第一个语句之后的某个时间点,则初始化应该发生在与待初始化变量在同一翻译单元中定义的任何函数或变量的第一次使用之前。(§操作,帕拉。4) .

允许延迟初始化的主要原因是允许动态加载。动态(或按需)加载允许程序在所有模块实际加载并链接到可执行文件之前开始运行。这可以加快启动速度(例如,可执行程序可以立即绘制一个闪屏),方法是将它与读取程序所需的所有库所需的缓慢磁盘访问重叠,其中一些库可能根本不需要,具体取决于用户对程序的特定请求。

所以标准允许(但不要求)某种形式的"随需应变"。初始化;要实现这一点,它可能会在"禁止使用任何函数或变量"之前插入一个初始化检查。这可能是第一个这样的用途。这正是您在(内联)调用cant_inline_static之前看到的代码。

初始化和多态对象

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

所以不是"对象不需要初始化"。多态类的每个实例都需要初始化。然而,虚函数表的(符号)地址在编译时是已知的,所以这个可以作为常量初始化执行。或者不,编译器认为合适,因为变量表和vptr是实现细节,而不是c++标准强制要求的。(这是一种礼貌的虚构。我不相信存在不使用虚表和虚函数的实现。实参虚参表的精确布局和内容确实因实现而异。

初始化和加载

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

这些不同的过程——加载、链接、重定位——没有被c++标准详细定义(或者根本没有定义),c++标准将整个程序的执行——包括上面的步骤——视为程序执行的一部分。在main语句的第一个语句之前发生了一些事情实际上发生在链接和加载步骤。

在Intel/ARM平台上,gcc将翻译单元编译成ELF对象文件。还有一个链接器,它将ELF对象文件合并为单个ELF可执行文件(可能与外部库的引用一起)。一个ELF文件由许多"节"组成,每个"节"都有不同的特征。

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

  • 文本段表示只读内存。(该限制可能由操作系统强制执行,也可能不强制执行)。这包括程序本身,以及初始化为编译时常量值的静态常量对象。object文件包含这些节的实际位表示,以及在链接时插入符号地址的一些指示。[注2]

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

  • bss段(这个名字是历史遗留下来的,详见Wikipedia)表示零初始化的读写内存。这用于静态对象,这些对象的初始值将在必要时(如果)运行时计算。对象文件只包含这些对象的大小;没有提供位表示。加载器通过显式地清除分配的内存或使用虚拟内存系统将内存映射到将在第一次引用时归零的页面,将这些节的初始值设置为零。

ELF还允许编译器提供初始化段,这是在加载过程结束时执行的可执行代码,也就是说,在实际的主可执行文件开始之前。[注3]

一个初始值主要为零的读写对象,可以放在带有显式零的数据段中,也可以放在bss段中,并附带用于运行时初始化非零元素的代码。如果它在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的指针调用虚成员函数。所以从某种意义上说,如果vvpr成员从未初始化,这并不重要。但是期望编译器考虑检查这种情况是完全不合理的。如果您创建了一个多态类,那只能是因为您打算以多态方式调用成员函数。为了确定是否会发生多态调用,对该类的实例进行完整的转义分析几乎总是完全浪费时间,因此没有理由让任何人费心在编译器中包含该检查。这是我个人的观点。你有不同意的自由。: -)

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

derivedtype.DerivedType::VirtualMethod(p);

甚至在一个边缘,你可能能够逃脱调用一个多态的方法,不使用this(即,它可能是static,如果它不是多态的)使用类似的东西:

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

甚至:

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

但是在你的代码中,这将不起作用,因为你实际上调用了VirtualCaller,它显式地使用了this。(说实话,我真的不明白其中的逻辑)。然而,上面的hack——我绝不会在代码审查中接受——确实避免了使用odr的derivedtype,从而避免了初始化它的需要。在Godbolt交互式GCC编译器

上查看它指出

  1. 这是一个过度简化(见免责声明)。虚函数表实际上是一种对象描述符,而不仅仅是函数指针的向量,在虚继承的情况下,一个对象中可能有多个虚函数表。为了回答这个问题,这些都无关紧要。

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

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

我对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指令)。在静态变量的情况下,显然有一些我不理解的记账代码,但它似乎与内联无关。如果我必须猜测,我会说它正在从某个表中加载静态对象的地址,以检查它是否被实例化,而在另一种情况下,局部对象似乎被完全优化了,从而导致更短的代码。