为什么移动 ctor 比复制 ctor 慢?

Why is move ctor slower than copy ctor?

本文关键字:ctor 复制 移动 为什么      更新时间:2023-10-16

我有以下代码来测试复制 ctor 并移动 ctor 的std::string类,结果让我感到惊讶,移动 ctor 比复制 ctor 慢~1.4倍。

据我了解,移动构造不需要分配内存,对于std::string情况,移动构造对象中可能有一个内部指针直接设置为移动对象的指针,它应该比为缓冲区分配内存然后在复制构造时从对象复制内容更快。

这是代码:

#include <string>
#include <iostream>
void CopyContruct(const std::string &s) {
auto copy = std::string(s);
}
void MoveContruct(std::string &&s) {
auto copy = std::move(s);
//auto copy = std::string(std::move(s));
}
int main(int argc, const char *argv[]) {
for (int i = 0; i < 50000000; ++i) {
CopyContruct("hello world");
//MoveContruct("hello world");
}
return 0;
}

编辑:

从这两个函数的组装中,我可以看到MoveConstruct有一个实例化std::remove_reference类模板,我认为这应该是罪魁祸首,但我不熟悉汇编,任何人都可以详细说明吗?

以下代码在 x86-64 gcc7.2 的 https://godbolt.org/上反编译:

CopyContruct(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&):
push rbp
mov rbp, rsp
sub rsp, 48
mov QWORD PTR [rbp-40], rdi
mov rdx, QWORD PTR [rbp-40]
lea rax, [rbp-32]
mov rsi, rdx
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)
lea rax, [rbp-32]
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::~basic_string()
nop
leave
ret
MoveContruct(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&&):
push rbp
mov rbp, rsp
sub rsp, 48
mov QWORD PTR [rbp-40], rdi
mov rax, QWORD PTR [rbp-40]
mov rdi, rax
call std::remove_reference<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&>::type&& std::move<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&>(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)
mov rdx, rax
lea rax, [rbp-32]
mov rsi, rdx
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&&)
lea rax, [rbp-32]
mov rdi, rax
call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::~basic_string()
nop
leave
ret

编辑2:

事情越来越有趣了,我std::string改为std::vector,正如评论中@FantasticMrFox提到的,结果恰恰相反,MoveConstructCopyConstruct~1.9倍,似乎std::remove_reference不是罪魁祸首,但这两个类的优化可能是。

编辑3:

以下代码在 MacOS 上使用 Apple LLVM 版本 8.0.0 (clang-800.0.42.1) 编译,优化标志为 -O3。

.section    __TEXT,__text,regular,pure_instructions
.macosx_version_min 10, 11
.globl  __Z12CopyContructRKNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE
.align  4, 0x90
__Z12CopyContructRKNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE: ## @_Z12CopyContructRKNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE
.cfi_startproc
## BB#0:
pushq   %rbp
Ltmp0:
.cfi_def_cfa_offset 16
Ltmp1:
.cfi_offset %rbp, -16
movq    %rsp, %rbp
Ltmp2:
.cfi_def_cfa_register %rbp
pushq   %rbx
subq    $24, %rsp
Ltmp3:
.cfi_offset %rbx, -24
movq    %rdi, %rax
leaq    -32(%rbp), %rbx
movq    %rbx, %rdi
movq    %rax, %rsi
callq   __ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEC1ERKS5_
movq    %rbx, %rdi
callq   __ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEED1Ev
addq    $24, %rsp
popq    %rbx
popq    %rbp
retq
.cfi_endproc
.globl  __Z12MoveContructONSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE
.align  4, 0x90
__Z12MoveContructONSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE: ## @_Z12MoveContructONSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEEE
.cfi_startproc
## BB#0:
pushq   %rbp
Ltmp4:
.cfi_def_cfa_offset 16
Ltmp5:
.cfi_offset %rbp, -16
movq    %rsp, %rbp
Ltmp6:
.cfi_def_cfa_register %rbp
subq    $32, %rsp
movq    16(%rdi), %rax
movq    %rax, -8(%rbp)
movq    (%rdi), %rax
movq    8(%rdi), %rcx
movq    %rcx, -16(%rbp)
movq    %rax, -24(%rbp)
movq    $0, 16(%rdi)
movq    $0, 8(%rdi)
movq    $0, (%rdi)
leaq    -24(%rbp), %rdi
callq   __ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEED1Ev
addq    $32, %rsp
popq    %rbp
retq
.cfi_endproc

这种微基准测试通常具有误导性,因为它不会测试您认为它测试的东西。

但是,在您的情况下,我可以解释您看到的测量的最可能原因。

std::string,在所有现代实现中,使用称为"小缓冲区优化"或 SBO 的东西。(@FantasticMrFox在评论中关于使用蝇量级的断言是错误的。我认为除了空字符串之外,任何流行的实现都没有使用过蝇量级。他的意思是写时复制,GNU的标准库过去使用过,但GNU因为兼容的C++11字符串不能使用COW)而放弃了。

在此优化中,在字符串对象内部保留了一些空间来存储短字符串并避免为它们分配堆。

这意味着字符串的复制和移动构造函数大致实现如下:

copy(source) {
if source length > internal buffer capacity
allocate space
copy source buffer to my buffer
}
move(source) {
if source uses internal buffer {
copy source buffer to my buffer
set source length to zero
set first byte of source buffer to zero
} else {
steal source buffer
}
}

如您所见,移动构造函数稍微复杂一些。它也比某些实现中的优化程度更高,但一般逻辑保持不变。

因此,对于小缓冲区字符串(我怀疑您正在测试的字符串适合您的特定实现),复制工作更少,因为不需要重置源字符串。

但是,当您打开完全优化时,编译器可能会识别一些死存储并删除它们。(当然,编译器可能只是删除整个基准测试,因为它实际上不做任何事情。

当我用 clang-O3将你的代码输入到 clang 或 gcc 时,我从 clang 得到

main: # @main
mov eax, 50000000
.LBB0_1: # =>This Inner Loop Header: Depth=1
add eax, -25
jne .LBB0_1
xor eax, eax
ret

和海湾合作委员会:

main:
xor eax, eax
ret

我确实将函数放在匿名命名空间中,以摆脱必须导出函数本身的噪音。 但主要的是完全优化。

微基准测试通常具有误导性。