[英]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
的构造函数,如果让它们接收副本比引用更好的话。
调用上下文是未知的,但是对象生成器和类构造函数本身都是内联函数,因此,引用和值之间的差异并不重要,因为优化器可以看到最终目标并在路的尽头应用构造(I'我只是推测),因此对象生成器将是纯零开销的抽象,在这种情况下,我认为如果某些例外的配对对比平常大,则引用会更好。
但是,如果不是这种情况(即使对所有内容都是内联的,引用也总是或通常对副本有一定影响),那么我将寻求副本。
在微优化领域之外,我通常会传递const
引用,因为您没有修改对象,并且希望避免复制。 如果有一天您确实使用了构造成本很高的T1
或T2
,则副本可能会成为一个大问题:传递const引用并没有同样强大的步枪。 因此,我将按值传递作为具有非常不对称权衡的选择,并且仅当我知道数据很小时才按值选择。
至于您的特定微优化问题,它基本上取决于调用是否完全内联以及您的编译器是否正常。
如果f
函数的任一变体内联到调用程序中,并且启用了优化,则对于任何一个变体,您可能都将获得相同或几乎相同的代码。 我在这里使用inline_f_ref
和inline_r_val
调用进行测试。 它们都从一个未知的外部函数生成一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
痕迹都mypair
了。
这是一个版本,其中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比平均值对内联函数更不情愿(例如,在没有inline
关键字的情况下,它没有在-O2
处内联非常短的函数)。
如果没有内联参数传递和返回的方式,则由ABI设置,因此编译器无法优化两个版本之间的差异。 const
参考版本相当于传递一个指针,因此无论T1
和T2
您都将传递一个指针到第一个整数寄存器中的std::pair
对象。
这是在Linux上的gcc中T1
和T2
为int
时的代码:
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个字节,因此编译器一次完成复制整个过程。 在这种情况下,返回值在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位大小的参数传递到寄存器中,因此负载消失了。
然后编译器开始做一个非常愚蠢的事情:从rax
的std::pair
的64位开始,它写出低32位,将高32位移到底部,然后将其写出。 世界上最慢的仅写出64位的方式。 不过,此代码通常比引用版本要快。
在两个ABI中,按值函数都可以在寄存器中传递其自变量。 但是,这有其局限性。 这是T1
和T2
为twop
时f
的按引用版本-一种包含两个指针的结构,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.