编译器眼中的-标量对象和空对象的传参及优化

C++编译器在编译程序时,只要不违背C++语义,可以对程序进行一些变换或者优化。如果C++对象是一些特殊类型的对象,当它们是一个函数的参数(当然也包括成员函数,因为成员函数隐藏着一个this指针,在调用时把对象自己作为参数传递)时,编译器在传递参数时可以进行一些特殊的优化,以提高传递函数参数和访问数据成员的性能。

传递标量对象

先看一个类的定义,类非常简单,只有两个数据成员,它们都是两个基本类型:

class small {
    long long x = 42;
    long long y = 24;

public:
    void foo() {
        printf("%d, %d\n", x, y);
    }

    void bar() {
        printf("bar");
    }
};

void foo(small a) {
    a.foo();
}

void bar(small a) {
    a.bar();
}

int main() {
    small a{};
    foo(a);
}

为了节省汇编指令的篇幅,编译时使用优化选项-O1:最初级的优化,为了能看到完整的函数编译后的汇编代码,禁止内联优化,即:gcc -std=c++11 -O1 -fno-inline。

标量替换
先看main函数的汇编指令代码,因为组成对象的成员都是标量,在调用foo(small)函数时,并没有传入一个small对象,而是通过寄存器把函数对象的数据成员直接传递进去了:

main:
    sub     rsp, 8
    mov     edi, 42  数据成员x=42,存放在寄存器rdi中
    mov     esi, 24  数据成员y=24,存放在寄存器rsi中
    call    foo(small)
    mov     eax, 0
    add     rsp, 8
    ret

我们先确认一下是不是因为被优化导致的,把优化选项改为-O0重新编译,main函数的汇编代码如下:

main:
    push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    mov     QWORD PTR [rbp-16], 42
    mov     QWORD PTR [rbp-8], 24
    mov     rdx, QWORD PTR [rbp-16]
    mov     rax, QWORD PTR [rbp-8]
    mov     rdi, rdx    ; 数据成员x=42,存放在寄存器rdi中
    mov     rsi, rax    ; 数据成员y=24,存放在寄存器rsi中
    call    foo(small)
    mov     eax, 0
    leave
    ret

也是把数据成员x和y分开后放入两个寄存器(rdi和rsi)传递的,可见并不是因为-O1优化处理后的代码,即使不优化也会这样编译。这种没有把对象作为一个数据聚合体,而是把各个成员分离开来代替对象的方式就是标量替换

既然没有传入this指针,那么在调用对象的成员函数时,根据C++语义,在调用成员函数时,需要传入一个隐式的this参数,那么,main在调用foo(small)时没有传入this,怎么调用成员函数void small::foo()呢?看下面的汇编代码:

foo(small):
    sub     rsp, 24
    mov     QWORD PTR [rsp], rdi      ; rdi寄存器存放x
    mov     QWORD PTR [rsp+8], rsi  ; rsi寄存器存放y
    mov     rdi, rsp      ; rsp指向的16字节的空间位置存放了x和y的值,即small对象的this指针
    call    small::foo()  ; this指针传入small::foo() 成员函数
    add     rsp, 24
    ret

在foo(small)函数内部就地创建了一个small对象,然后调用成员函数,即把分散开的各个标量成员重新又组成一个聚合体结构。上面的汇编代码就是把通过寄存器传递进来的数据,作为参数创建了一个small对象,传入成员函数,相当于在foo函数本地创建了一个small对象,通过它来调用成员函数,可见编译器这么做,并没有改变C++的语义,没有任何问题。

同样,函数void bar(small)的汇编代码,也是如此:

bar(small):
    sub     rsp, 24
    mov     QWORD PTR [rsp], rdi
    mov     QWORD PTR [rsp+8], rsi
    mov     rdi, rsp ; rsp指向的16字节的空间位置存放了x和y的值,即small对象的this指针
    call    small::bar()
    add     rsp, 24
    ret

可见对于标量对象在作为参数传递时,GCC编译器做了优化(经验证clang编译器也是如此),把对象的各个数据成员拆分成单个标量数据,然后分别使用寄存器进行传递。

这样做的好处就是:

1、数据成员标量替换后使用寄存器传递,比保存在内存中通过this指针传递的性能要好,直接访问寄存器,比通过this指针间接访问内存要高效的多。

2、延迟创建对象,直到真正用到时才创建对象,如果在函数void foo(small)内部并不是必须调用small的成员函数,比如根据条件分支会执行不同的路径,如果有的路径不调用,也就不用创建了。

IPA-SRA优化
从前面的例子可以看出,编译器对对象进行了标量替换,在对象作为参数传递的地方没有传递this指针,而是改成传递标量化的数据成员。但是在调用对象的成员函数就时,还得要进行对象创建,即把标量化的数据成员又还原成一个聚合结构的数据。

那么,能不能在调用成员函数时不还原为对象,用到哪个数据成员就传递哪个,继续使用标量成员呢?GCC编译器是可以的。

在GCC编译器中,如果使用O2/O3优化选项,或者O1同时加上-fipa-sra编译选项进行编译,也会把成员函数的this指针去掉,改成传递标量替换后的数据。下面看一下加上-fipa-sra编译选项后产生的汇编代码:

main()函数没有变化,仍然是使用标量替换,没有创建small对象。

main:
    sub     rsp, 8
    mov     edi, 42
    mov     esi, 24
    call    foo(small)
    mov     eax, 0
    add     rsp, 8
    ret

而foo(small)函数继续优化,不再临时创建一个small对象了,而是直接调用了一个名称被修饰过的成员函数:small::foo() (.isra.0)

foo(small):
    sub     rsp, 8
    call    small::foo() (.isra.0)  传进来的寄存器rdi和rsi转发到small::foo() (.isra.0)
    add     rsp, 8
    ret

small::foo() (.isra.0)的汇编代码如下:

small::foo() (.isra.0):
    sub     rsp, 8
    mov     rdx, rsi  ; 数据成员x
    mov     rsi, rdi   ; 数据成员y
    mov     edi, OFFSET FLAT:.LC1
    mov     eax, 0
    call    printf
    add     rsp, 8
    ret

由此可见,在main->foo(small)->small::foo()整个调用链路中没有了this指针,全部使用寄存器作为传参的中介,不再把对象创建在内存中,调用空对象的成员函数就像是在调用一个普通函数。显然,这种方式提高了程序的函数调用及数据访问的性能。

以“.isra.NNN”结尾的函数名称,其中 NNN 是一些数字,如small::foo() (.isra.0),是GCC执行选项-fipa-sra编译器优化时,添加到函数名称的后缀。这种优化方式叫:Interprocedural optimization (IPA),该选项执行聚合体结构的过程间标量替换,删除未使用的参数,不过,该优化选项目前仅GCC编译器支持。

这里测试用的对象size是16字节,经过测试,即使对象的size多于16字节,并且数据成员都是基本类型的话,在传递对象作为参数时,仍然会进行标量替换,但是传递时不是通过寄存器,而是通过栈来传递的。比如:

foo(small):
    sub     rsp, 8
    mov     rsi, QWORD PTR [rsp+24] ;从栈中获取对象的标量数据
    mov     rdi, QWORD PTR [rsp+16] ;从栈中获取对象的标量数据
    call    small::foo() (.isra.0)
    add     rsp, 8
    ret

传递空对象

空对象中一个数据成员也没有,它在作为参数传递时就更简单了,既然是空对象,意味着不可能会通过this指针去访问数据成员,那就什么也不用传递了。如果需要this指针时,也无需创建对象,直接随意指定一个地址作为this指针(哪怕是野指针、空指针)就可以了。当然,在使用IPA-SRA优化时,既然不需要this指针,成员函数中隐含的this参数也被优化掉了。实际上,空类提供的成员函数,因为不访问数据成员,等同于静态成员函数,编译器的IPA-SRA优化就是想办法把成员函数的this指针参数给优化掉。

下面看一下代码示例:

// 空类,没有任何数据成员
class small {
public:
    void foo(int x, int y) {
        printf("%d\n", x+y);
    }
};

void foo(small a) {
    a.foo(42, 24);
}

int main() {
    small a{};
    foo(a);
}

下面是编译后的汇编代码:

.LC1:
        .string "%d\n"
small::foo(int, int) (.isra.0):
        sub     rsp, 8
        add     esi, edi
        mov     edi, OFFSET FLAT:.LC1
        mov     eax, 0
        call    printf
        add     rsp, 8
        ret
foo(small):
        sub     rsp, 8
        mov     esi, 24 ; x参数
        mov     edi, 42 ; y参数
        call    small::foo(int, int) (.isra.0)
        add     rsp, 8
        ret
main:
        sub     rsp, 8
        call    foo(small) ; 什么也没有传递
        mov     eax, 0
        add     rsp, 8
        ret

如果把foo和bar函数改成传递引用类型的参数:

void foo(small& a) {
    a.foo(42, 24);
}

除了main()函数之外,其它函数没有任何变化:

main:
    sub     rsp, 24
    lea     rdi, [rsp+15] ; 传入一个地址当作this指针,里面的数据没有初始化
    call    foo(small&) ; 即使传入this,也被foo(small&)忽视
    mov     eax, 0
    add     rsp, 24
    ret

在调用foo(small&)时,没有创建任何对象,只是把rsp+15内存地址作为small参数对象的this指针,来调用foo(small&)函数,注意这个地址中的数据是什么无所谓,所以没有初始化它。

有趣的是,如果把main()测试函数改为通过野指针或者空指针来调用small的成员函数,程序也不会发生错误,因为编译器经过IPA-SRA的优化后, 把成员函数的this指针参数去掉了,当然即使没有ipa-sra的优化,也不会发生错误,因为this指针根本用不着,给它传什么值都可以。如下程序运行时没有任何问题:

int main() {
    small *ptr = reinterpret_cast<small *>(0x12345678); // 野指针
    ptr->foo(1,2);
    ptr = nullptr; // 空指针
    ptr->foo(1,2);
}

在C++标准库中的std::string_vew(C++17)和std::span(C++20),它们创建的对象都是标量对象,对象中只包含了一个指针和一个数据长度的值,它们用作函数参数传递的时候,最好定义为值类型的形式,这样传参时编译器会对它们进行标量替换,使用寄存器分别传递它们的指针及长度数据成员,以优化性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值