繁体   English   中英

作为const&`轻量级对象传递

[英]Passing as `const&` lightweight objects

给出以下宠物片段:

template<class T1, class T2>
struct my_pair { /* constructors and such */ };

auto f(std::pair<T1, T2> const& p) // (1)
{ return my_pair<T1, T2>(p.first, p.second); }

auto f(std::pair<T1, T2> p) // (2)
{ return my_pair<T1, T2>(p.first, p.second); }

如果我知道T1T2都是轻量级对象,其复制时间可以忽略(例如,每个指针有两个),那么将std::pair作为副本传递而不是作为引用传递? 因为我知道有时候让编译器忽略副本比强迫它处理引用(例如,优化复制链)更好。

同样的问题也适用于my_pair的构造函数,如果让它们接收副本比引用更好的话。

调用上下文是未知的,但是对象生成器和类构造函数本身都是内联函数,因此,引用和值之间的差异并不重要,因为优化器可以看到最终目标并在路的尽头应用构造(I'我只是推测),因此对象生成器将是纯零开销的抽象,在这种情况下,我认为如果某些例外的配对对比平常大,则引用会更好。

但是,如果不是这种情况(即使对所有内容都是内联的,引用也总是或通常对副本有一定影响),那么我将寻求副本。

在微优化领域之外,我通常会传递const引用,因为您没有修改对象,并且希望避免复制。 如果有一天您确实使用了构造成本很高的T1T2 ,则副本可能会成为一个大问题:传递const引用并没有同样强大的步枪。 因此,我将按值传递作为具有非常不对称权衡的选择,并且仅当我知道数据很小时才按值选择。

至于您的特定微优化问题,它基本上取决于调用是否完全内联以及您的编译器是否正常。

全内联

如果f函数的任一变体内联到调用程序中,并且启用了优化,则对于任何一个变体,您可能都将获得相同或几乎相同的代码。 在这里使用inline_f_refinline_r_val调用进行测试。 它们都从一个未知的外部函数生成一pair ,然后调用f的按引用或变量。

对于f_valf_ref版本仅在最后更改呼叫):

template <typename T>
auto inline_f_val() {
    auto pair = get_pair<T>();
    return f_val(pair);
}

这是T1T2int时在gcc上的结果:

auto inline_f_ref<int>():
        sub     rsp, 8
        call    std::pair<int, int> get_pair<int>()
        add     rsp, 8
        ret

auto inline_f_val<int>():
        sub     rsp, 8
        call    std::pair<int, int> get_pair<int>()
        add     rsp, 8
        ret

完全相同。 编译器可以正确查看这些函数,甚至可以识别出std::pairmypair实际上具有相同的布局,因此所有f痕迹都mypair了。

这是一个版本,其中T1T2是具有两个指针的结构:

auto inline_f_ref<twop>():
        push    r12
        mov     r12, rdi
        sub     rsp, 32
        mov     rdi, rsp
        call    std::pair<twop, twop> get_pair<twop>()
        mov     rax, QWORD PTR [rsp]
        mov     QWORD PTR [r12], rax
        mov     rax, QWORD PTR [rsp+8]
        mov     QWORD PTR [r12+8], rax
        mov     rax, QWORD PTR [rsp+16]
        mov     QWORD PTR [r12+16], rax
        mov     rax, QWORD PTR [rsp+24]
        mov     QWORD PTR [r12+24], rax
        add     rsp, 32
        mov     rax, r12
        pop     r12
        ret

那是“ ref”版本,再次是“ val”版本。 在这里,编译器无法优化所有工作:创建对之后,仍然会做大量工作将std::pair内容复制到mypair对象(有4个存储区,总共存储32个字节,即4指针)。 因此,再次内联让编译器针对同一事物优化版本。

您可能会发现情况并非如此,但以我的经验来看,它们并不常见。

没有内联

没有内联,这是一个不同的故事。 您提到所有函数都是内联的,但这并不一定意味着编译器会内联它们。 特别是,gcc比平均值对内联函数更不情愿(例如,在没有inline关键字的情况下,它没有在-O2处内联非常短的函数)。

如果没有内联参数传递和返回的方式,则由ABI设置,因此编译器无法优化两个版本之间的差异。 const参考版本相当于传递一个指针,因此无论T1T2您都将传递一个指针到第一个整数寄存器中的std::pair对象。

这是在Linux上的gcc中T1T2int时的代码:

auto f_ref<int, int>(std::pair<int, int> const&):
        mov     rax, QWORD PTR [rdi]
        ret

指针std::pairrdi传递,因此函数的主体是从该位置到rax的单个8字节移动。 一个std::pair<int, int>占用8个字节,因此编译器一次完成复制整个过程。 在这种情况下,返回值在rax “按值”传递,因此我们完成了。

这取决于编译器的优化能力和ABI。 例如,这是MSVC为64位Windows目标编译的同一函数:

my_pair<int,int> f_ref<int,int>(std::pair<int,int> const &) PROC ; f_ref<int,int>, COMDAT
        mov     eax, DWORD PTR [rdx]
        mov     r8d, DWORD PTR [rdx+4]
        mov     DWORD PTR [rcx], eax
        mov     rax, rcx
        mov     DWORD PTR [rcx+4], r8d
        ret     0

这里发生了两种不同的事情。 首先,ABI是不同的。 MSVC无法在rax返回mypair<int,int> 而是,调用方在rcx传递一个指向被调用方应保存结果的位置的指针 因此,此功能除负载外还具有存储功能。 rax加载了保存数据的位置。 第二件事是,编译器太笨拙,无法将两个相邻的4字节加载合并并存储为8字节加载,因此有两个加载和两个存储。

第二部分可以由更好的编译器修复,但是第一部分是API的结果。

这是此功能按值的版本,在Linux上的gcc中:

auto f_val<int, int>(std::pair<int, int>):
        mov     rax, rdi
        ret

仍然只有一条指令,但是这次只有一次reg-reg移动,这永远不会比加载程序贵,而且通常便宜得多。

在MSVC(64位Windows)上:

my_pair<int,int> f_val<int,int>(std::pair<int,int>)
        mov     rax, rdx
        mov     DWORD PTR [rcx], edx
        shr     rax, 32                             ; 00000020H
        mov     DWORD PTR [rcx+4], eax
        mov     rax, rcx
        ret     0

您仍然有两个存储区,因为ABI仍会强制将值返回到内存中,但是由于MSVC 64位API允许将最大64位大小的参数传递到寄存器中,因此负载消失了。

然后编译器开始做一个非常愚蠢的事情:从raxstd::pair的64位开始,它写出低32位,将高32位移到底部,然后将其写出。 世界上最慢的仅写出64位的方式。 不过,此代码通常比引用版本要快。

在两个ABI中,按值函数都可以在寄存器中传递其自变量。 但是,这有其局限性。 这是T1T2twopf的按引用版本-一种包含两个指针的结构,Linux gcc:

auto f_ref<twop, twop>(std::pair<twop, twop> const&):
        mov     rax, rdi
        mov     r8, QWORD PTR [rsi]
        mov     rdi, QWORD PTR [rsi+8]
        mov     rcx, QWORD PTR [rsi+16]
        mov     rdx, QWORD PTR [rsi+24]
        mov     QWORD PTR [rax], r8
        mov     QWORD PTR [rax+8], rdi
        mov     QWORD PTR [rax+16], rcx
        mov     QWORD PTR [rax+24], rdx

这是按价值的版本:

auto f_val<twop, twop>(std::pair<twop, twop>):
        mov     rdx, QWORD PTR [rsp+8]
        mov     rax, rdi
        mov     QWORD PTR [rdi], rdx
        mov     rdx, QWORD PTR [rsp+16]
        mov     QWORD PTR [rdi+8], rdx
        mov     rdx, QWORD PTR [rsp+24]
        mov     QWORD PTR [rdi+16], rdx
        mov     rdx, QWORD PTR [rsp+32]
        mov     QWORD PTR [rdi+24], rdx

尽管加载和存储的顺序不同,但是两者的作用完全相同:4个加载和4个存储,从输入到输出复制32个字节。 唯一的实际区别是,在按值的情况下,对象是预期在堆栈上的(因此我们从[rsp]复制),而在按引用的情况下,对象是由第一个参数指向的,因此我们从[rdi ] 1

因此,存在一个较小的窗口,其中非内联值函数比传递引用具有优势:可以将其参数传递到寄存器中的窗口。 对于Sys V ABI,这通常适用于最大16个字节的结构,而在Windows x86-64 ABI上,最大8个字节。 还有其他限制,因此并非所有此大小的对象总是在寄存器中传递。


1您可能会说,嘿, rdi接受第一个参数,而不是rsi但是这里发生的是返回值也必须通过内存传递,因此隐藏的第一个参数-指向返回值的目标缓冲区的指针-被隐式使用,并进入rdi

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM