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

时间:2019-08-04 18:03:59

标签: c++ pass-by-reference pass-by-value micro-optimization

给出以下宠物片段:

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的构造函数接收副本比引用的副本更好,那么该问题同样适用于UITableview

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

但是,如果不是这种情况(即使对所有内容都是内联引用,引用总是或通常会对副本产生一些影响),然后我会去寻找副本。

1 个答案:

答案 0 :(得分:3)

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

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

全内联

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

f_val一样(f_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的痕迹都消失了。

这里是一个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比平均值更不愿意内联函数(例如,在本示例中,没有-O2关键字的情况下,它没有内联非常短函数)。 / p>

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

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

int

auto f_ref<int, int>(std::pair<int, int> const&): mov rax, QWORD PTR [rdi] ret 的指针在std::pair中传递,因此该函数的主体是从该位置到rdi的单个8字节移动。 rax占用8个字节,因此编译器一次完成复制整个过程。在这种情况下,返回值在std::pair<int, int>中“按值”传递,因此就可以完成了。

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

rax

这里发生了两种不同的事情。首先,ABI是不同的。 MSVC无法在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 中返回mypair<int,int>。相反,呼叫者将{em> pointer 传递给rax到被呼叫者应保存结果的位置。因此,此功能除负载外还具有存储功能。 rcx加载了已保存数据的位置。第二件事是,编译器太笨拙,无法将两个相邻的4字节加载组合在一起并存储为8字节加载,因此有两个加载和两个存储。

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

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

rax

仅保留一条指令,但这次仅执行一次reg-reg移动,这永远不会比加载程序贵,并且通常便宜得多。

在MSVC(64位Windows)上:

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

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

然后编译器开始做一个非常愚蠢的事情:从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 中的std::pair的64位开始,它写出最低的32位,将最高的32位移至最低,然后把那些写出来。世界上最慢的仅写出64位的方式。仍然,此代码通常比按引用版本要快。

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

twop

以下是按值显示的版本:

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

尽管加载和存储的顺序不同,但两者的作用完全相同:4个加载和4个存储,从输入到输出复制32个字节。唯一真正的区别是,在按值排序的情况下,对象应该在堆栈上(因此我们从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 复制),而在按引用方式的情况下,对象由第一个参数指向,因此我们复制来自[rsp]] 1

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


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