作为“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); }
如果我知道 T1
和 T2
都是轻量级对象,其复制时间可以忽略不计(例如,每个指针都有几个指针),那么传递 std::pair
是否更好?作为副本而不是作为参考?因为我知道有时候让编译器忽略副本比强制它处理引用更好(例如,优化复制链)。
同样的问题也适用于 my_pair
的构造函数,如果让它们接收副本比引用更好。
调用上下文未知,但对象生成器和 class 构造函数本身都是内联函数,因此引用和值之间的差异可能并不重要,因为优化器可以看到最终目标并应用构造在这条路的尽头(我只是在推测),所以对象生成器将是纯粹的零开销抽象,在这种情况下,我认为引用会更好,以防某些异常对比平常大。
但如果情况并非如此(引用总是或通常对副本有一些影响,即使所有内容都是内联的),那么我会选择副本。
在微优化领域之外,我通常会传递一个 const
引用,因为您没有修改对象并且希望避免复制。如果有一天你 do 使用造价昂贵的 T1
或 T2
,副本可能是一个大问题:没有同等大的步兵枪常量引用。因此,我将按值传递视为具有非常不对称权衡的选择,并且仅当我知道数据很小时才按值进行选择。
至于您具体的微优化问题,它基本上取决于调用是否完全内联以及您的编译器是否不错。
完全内联
如果您的 f
函数的任一变体被内联到调用程序中,并且启用了优化,您可能会为这两个变体获得相同或几乎相同的代码。我用 inline_f_ref
和 inline_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);
}
以下是 T1
和 T2
为 int
时 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::pair
和 mypair
实际上具有相同的布局,因此 f
的所有痕迹都消失了。
这是一个版本,其中 T1
和 T2
是一个具有两个指针的结构,而不是:
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
参考版本相当于传递一个指针,因此无论 T1
和 T2
你将传递一个指向第一个整数寄存器中的 std::pair
对象的指针。
这是导致 T1
和 T2
为 int
的代码,在 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 位大小 将在寄存器中传递。
然后编译器做了一件非常愚蠢的事情:从 rax
中 std::pair
的 64 位开始,它写出底部的 32 位,将顶部的 32 位移到底部然后写出来。简单地写出 64 位的世界上最慢的方法。不过,此代码通常会比引用版本更快。
在两个 ABI 中,按值函数都能够在寄存器中传递其参数。然而,这有其局限性。这是 f
的引用版本,当 T1
和 T2
是 twop
- 一个包含两个指针的结构,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
.
给定以下宠物片段:
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); }
如果我知道 T1
和 T2
都是轻量级对象,其复制时间可以忽略不计(例如,每个指针都有几个指针),那么传递 std::pair
是否更好?作为副本而不是作为参考?因为我知道有时候让编译器忽略副本比强制它处理引用更好(例如,优化复制链)。
同样的问题也适用于 my_pair
的构造函数,如果让它们接收副本比引用更好。
调用上下文未知,但对象生成器和 class 构造函数本身都是内联函数,因此引用和值之间的差异可能并不重要,因为优化器可以看到最终目标并应用构造在这条路的尽头(我只是在推测),所以对象生成器将是纯粹的零开销抽象,在这种情况下,我认为引用会更好,以防某些异常对比平常大。
但如果情况并非如此(引用总是或通常对副本有一些影响,即使所有内容都是内联的),那么我会选择副本。
在微优化领域之外,我通常会传递一个 const
引用,因为您没有修改对象并且希望避免复制。如果有一天你 do 使用造价昂贵的 T1
或 T2
,副本可能是一个大问题:没有同等大的步兵枪常量引用。因此,我将按值传递视为具有非常不对称权衡的选择,并且仅当我知道数据很小时才按值进行选择。
至于您具体的微优化问题,它基本上取决于调用是否完全内联以及您的编译器是否不错。
完全内联
如果您的 f
函数的任一变体被内联到调用程序中,并且启用了优化,您可能会为这两个变体获得相同或几乎相同的代码。我用 inline_f_ref
和 inline_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);
}
以下是 T1
和 T2
为 int
时 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::pair
和 mypair
实际上具有相同的布局,因此 f
的所有痕迹都消失了。
这是一个版本,其中 T1
和 T2
是一个具有两个指针的结构,而不是:
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
参考版本相当于传递一个指针,因此无论 T1
和 T2
你将传递一个指向第一个整数寄存器中的 std::pair
对象的指针。
这是导致 T1
和 T2
为 int
的代码,在 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 位大小 将在寄存器中传递。
然后编译器做了一件非常愚蠢的事情:从 rax
中 std::pair
的 64 位开始,它写出底部的 32 位,将顶部的 32 位移到底部然后写出来。简单地写出 64 位的世界上最慢的方法。不过,此代码通常会比引用版本更快。
在两个 ABI 中,按值函数都能够在寄存器中传递其参数。然而,这有其局限性。这是 f
的引用版本,当 T1
和 T2
是 twop
- 一个包含两个指针的结构,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
.