【问题标题】:Passing as `const&` lightweight objects作为 `const&` 轻量级对象传递
【发布时间】:2019-12-12 09:57:35
【问题描述】:

给定以下宠物sn-p:

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 的构造函数,如果让它们接收副本而不是引用更好的话。

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

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

【问题讨论】:

  • 这不仅仅是你需要担心的调用,还有间接性,如果你传递一个指针,它必须通过地址进行内存查找,如果你传递一个原始值,它通常会传递在寄存器中,因此可以从 CPU 内部直接访问。 C++ 通常会通过将结构分解为各自的值并通过调用约定传递它们来优化结构的传递。如果这对中的两个值都适合一个寄存器(如果不是所有的原语应该能够做到这一点),那么按值传递可能会更快。
  • 优化器可以在内联期间省略引用。这真的取决于你。如果你能保证类型总是合适的,那么做值传递就很好了。正如我所说,按引用传递不太可能受到伤害,并且在优化期间它可能会被优化掉。
  • 由于类型是模板并且可以在通用上下文中使用,我会使用 const 引用。由于代码可能从编译器可见,它可能会在内部做最好的选择。
  • @Peregring-lk:我想你是说反了。当值 可以 放入一个或两个寄存器时,通常最好按值传递,而不是按引用传递。但同样,这仅在 内联并且您真的 希望这些函数内联时才重要。至少使用链接时优化,或者只是将它们放在标题中,因为它们非常微不足道并且不太可能需要更改。迭代器也一样:你真的希望简单的迭代器内联,而不是不透明的对象。
  • 顺便说一句,你的代码中有很多错别字。

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


【解决方案1】:

在微优化领域之外,我通常会传递一个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 比一般情况下更不愿意内联函数(例如,在没有 inline 关键字的情况下,它没有内联此示例中的 very 短函数 -O2)。

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

这是导致 T1T2 在 Linux 上的 gcc 中为 int 时的代码:

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

std::pair 的指针在 rdi 中传递,因此函数的主体是从该位置移动到 rax 的单个 8 字节。 std::pair&lt;int, int&gt; 占用 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&lt;int,int&gt;。相反,调用者将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 仍然强制将值返回到内存中,但是负载已经消失,因为 MSVC 64 位 API 允许参数最大为 64 位在寄存器中传递。

然后编译器做了一件非常愚蠢的事情:从rax 中的std::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]复制),而在按引用的情况下,对象由第一个参数指向,因此我们从@复制987654376@]1.

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


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

【讨论】:

    猜你喜欢
    • 2013-08-29
    • 1970-01-01
    • 2013-11-24
    • 1970-01-01
    • 1970-01-01
    • 2012-07-07
    • 1970-01-01
    • 1970-01-01
    • 2011-08-31
    相关资源
    最近更新 更多