作为“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 的构造函数,如果让它们接收副本比引用更好。

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

但如果情况并非如此(引用总是或通常对副本有一些影响,即使所有内容都是内联的),那么我会选择副本。

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

至于您具体的微优化问题,它基本上取决于调用是否完全内联以及您的编译器是否不错。

完全内联

如果您的 f 函数的任一变体被内联到调用程序中,并且启用了优化,您可能会为这两个变体获得相同或几乎相同的代码。我用 inline_f_refinline_r_val 调用测试 here。它们都从未知的外部函数生成 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 的所有痕迹都消失了。

这是一个版本,其中 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 比一般人更不愿意内联函数(例如,它没有内联 very 这个例子中的短函数 -O2 没有 inline 关键字).

没有内联参数的传递方式,returned 由 ABI 设置,因此编译器无法优化消除两个版本之间的差异。 const 参考版本相当于传递一个指针,因此无论 T1T2 你将传递一个指向第一个整数寄存器中的 std::pair 对象的指针。

这是导致 T1T2int 的代码,在 Linux 上的 gcc 中:

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

std::pair 的指针在 rdi 中传递,因此函数的主体是从该位置到 rax 的单个 8 字节移动。 std::pair<int, int> 占用 8 个字节,因此编译器一次性复制了整个内容。在这种情况下,return 值在 rax 中传递 "by value",因此我们完成了。

这取决于编译器的优化能力和 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 中 return mypair<int,int>。相反,调用者将 rcx 一个 指针 传递给被调用者应保存结果的位置。所以这个函数除了加载之外还有存储。 rax 载入保存数据的位置。第二件事是编译器太笨了,无法将两个相邻的 4 字节加载和存储组合成 8 字节的,所以有两个加载和两个存储。

第二部分可以通过更好的编译器修复,但第一部分是 API.

的结果

这是此函数的 by value 版本,在 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 仍然强制在内存中 returned 值,但是负载消失了,因为 MSVC 64 位 API 允许参数 最多 64 位大小 将在寄存器中传递。

然后编译器做了一件非常愚蠢的事情:从 raxstd::pair 的 64 位开始,它写出底部的 32 位,将顶部的 32 位移到底部然后写出来。简单地写出 64 位的世界上最慢的方法。不过,此代码通常会比引用版本更快。

在两个 ABI 中,按值函数都能够在寄存器中传递其参数。然而,这有其局限性。这是 f 的引用版本,当 T1T2twop - 一个包含两个指针的结构,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.

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


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