Печальная правда о пропуске копий в C++

Kate

Administrator
Команда форума
Пропуск копий (copy elision) – это оптимизация компилятора, которая, как и следует из имени, устраняет лишние операции копирования и перемещения. Она аналогична классической оптимизации размножения копий, но выполняется конкретно для объектов C++, которые могут иметь нестандартные конструкторы копирования и перемещения. В этой статьей я продемонстрирую пример, в котором очевидная ожидаемая от компилятора оптимизация на практике не происходит.

Ввод дополнительной переменной для разрыва строки​


Предположим, что у нас есть длинный вызов функции, возвращающий объект, который нужно мгновенно передать другой функции так:

#include <string>
#include <string_view>

// Тип данных, который дорого копировать, непросто удалить и невозможно переместить
struct Widget {
std::string s;
};

void consume(Widget w);

Widget doSomeVeryComplicatedThingWithSeveralArguments(
int arg1, std::string_view arg2);

void someFunction() {
consume(doSomeVeryComplicatedThingWithSeveralArguments(123, "hello"));
}

Как видно из сгенерированного кода ассемблера, здесь все отлично:

someFunction(): # @someFunction()
pushq %rbx
subq $32, %rsp
movq %rsp, %rbx
movl $5, %edx
movl $.L.str, %ecx
movq %rbx, %rdi
movl $123, %esi
callq doSomeVeryComplicatedThingWithSeveralArguments(int, std::basic_string_view<char, std::char_traits<char> >)
movq %rbx, %rdi
callq consume(Widget)
movq (%rsp), %rdi
leaq 16(%rsp), %rax
cmpq %rax, %rdi
je .LBB0_2
callq operator delete(void*)
.LBB0_2:
addq $32, %rsp
popq %rbx
retq
.L.str:
.asciz "hello"

Временный Widget, возвращаемый из doSomeVeryComplicatedThingWithSeveralArguments, создается в области стека, которую под него выделила someFunction. Затем, как объяснялось в статье о правилах передачи параметров (англ.), указатель на эту область стека передается напрямую для использования.

Теперь представьте, что строка функции someFuncton показалась вам слишком длинной, или что вы хотите дать результату doSomeVeryComplicatedThingWithSeveralArguments описательное имя, для чего меняете код:

void someFunctionV2() {
auto complicatedThingResult =
doSomeVeryComplicatedThingWithSeveralArguments(123, "hello");
consume(complicatedThingResult);
}

Естественно, все съезжает:

someFunctionV2(): # @someFunctionV2()
pushq %r15
pushq %r14
pushq %r12
pushq %rbx
subq $72, %rsp
leaq 40(%rsp), %rdi
movl $5, %edx
movl $.L.str, %ecx
movl $123, %esi
callq doSomeVeryComplicatedThingWithSeveralArguments(int, std::basic_string_view<char, std::char_traits<char> >)
leaq 24(%rsp), %r12
movq %r12, 8(%rsp)
movq 40(%rsp), %r14
movq 48(%rsp), %rbx
movq %r12, %r15
cmpq $16, %rbx
jb .LBB1_4
testq %rbx, %rbx
js .LBB1_13
movq %rbx, %rdi
incq %rdi
js .LBB1_14
callq operator new(unsigned long)
movq %rax, %r15
movq %rax, 8(%rsp)
movq %rbx, 24(%rsp)
.LBB1_4:
testq %rbx, %rbx
je .LBB1_8
cmpq $1, %rbx
jne .LBB1_7
movb (%r14), %al
movb %al, (%r15)
jmp .LBB1_8
.LBB1_7:
movq %r15, %rdi
movq %r14, %rsi
movq %rbx, %rdx
callq memcpy
.LBB1_8:
movq %rbx, 16(%rsp)
movb $0, (%r15,%rbx)
leaq 8(%rsp), %rdi
callq consume(Widget)
movq 8(%rsp), %rdi
cmpq %r12, %rdi
je .LBB1_10
callq operator delete(void*)
.LBB1_10:
movq 40(%rsp), %rdi
leaq 56(%rsp), %rax
cmpq %rax, %rdi
je .LBB1_12
callq operator delete(void*)
.LBB1_12:
addq $72, %rsp
popq %rbx
popq %r12
popq %r14
popq %r15
retq
.LBB1_13:
movl $.L.str.2, %edi
callq std::__throw_length_error(char const*)
.LBB1_14:
callq std::__throw_bad_alloc()
.L.str:
.asciz "hello"

.L.str.2:
.asciz "basic_string::_M_create"

Теперь берем наш идеальный Widget, complicatedThingResult, и копируем его в новый временный Widget, который будет передаваться в качестве первого аргумента. По завершении всех действий нужно будет удалить два Widget: complicatedThingResult и безымянный временный Widget, который мы передавали для использования. Вы можете ожидать, что компилятор оптимизирует someFunctionV2(), сделав ее подобной someFunction, но этого не произойдет.

Проблема, конечно же, в том, что мы забыли выполнить std::move complicatedThingResult:

void someFunctionV3() {
auto complicatedThingResult =
doSomeVeryComplicatedThingWithSeveralArguments(123, "hello");
consume(std::move(complicatedThingResult));
}

И теперь сгенерированный код ассемблера выглядит в точности, как наш исходный пример. Постойте-ка…что?

someFunctionV3(): # @someFunctionV3()
pushq %r14
pushq %rbx
subq $72, %rsp
leaq 8(%rsp), %rdi
movl $5, %edx
movl $.L.str, %ecx
movl $123, %esi
callq doSomeVeryComplicatedThingWithSeveralArguments(int, std::basic_string_view<char, std::char_traits<char> >)
leaq 56(%rsp), %r14
movq %r14, 40(%rsp)
movq 8(%rsp), %rax
leaq 24(%rsp), %rbx
cmpq %rbx, %rax
je .LBB1_1
movq %rax, 40(%rsp)
movq 24(%rsp), %rax
movq %rax, 56(%rsp)
jmp .LBB1_3
.LBB1_1:
movups (%rax), %xmm0
movups %xmm0, (%r14)
.LBB1_3:
movq 16(%rsp), %rax
movq %rax, 48(%rsp)
movq %rbx, 8(%rsp)
movq $0, 16(%rsp)
movb $0, 24(%rsp)
leaq 40(%rsp), %rdi
callq consume(Widget)
movq 40(%rsp), %rdi
cmpq %r14, %rdi
je .LBB1_5
callq operator delete(void*)
.LBB1_5:
movq 8(%rsp), %rdi
cmpq %rbx, %rdi
je .LBB1_7
callq operator delete(void*)
.LBB1_7:
addq $72, %rsp
popq %rbx
popq %r14
retq
.L.str:
.asciz "hello"

У нас по-прежнему есть два Widget, только временный передаваемый аргумент теперь перемещен конструктором. Первая версия someFunction все еще оказывается меньше и быстрее!

Что же здесь происходит?​


Суть проблемы пропуска копий в том, что он допускается только в определенном списке случаев. (Говоря коротко, при RVO1 и инициализации из prvalue это происходит обязательно, при NRVO2 и в ряде случаев с исключениями и сопрограммами пропуск считается допустимым. Все.). На то есть философская причина: вы написали специфичный конструктор копирования для вашего класса, в котором могли реализовать всё что угодно. И, конечно же, вы ожидаете, что, согласно правилам С++, этот конструктор будет вызван всякий раз когда объект вашего класса копируется. Но если компиляторы будут непредсказуемо удалять копирование, тем самым удаляя пары вызовов копирующего/перемещающего конструктора и деструктора они могут разрушить всю логику вашего кода.

Говоря конкретно, в приведенном списке допускающих пропуск копий ситуаций нет таких, которые бы соответствовали рассмотренным нами примерам. В этот список не включены такие случаи, как «последнее использование переменной перед ее выходом из области» или «передача переменной в функцию по значению, когда других действий с ней не предпринималось, то есть очевидно, что данная операция безопасна». Возможно, в будущем такие ситуации будут учтены, но в C++20 и более ранних версиях этого точно нет.

1. RVO (return value optimization) — оптимизация возвращаемого значения.
2. NRVO (named return value optimization) — оптимизация именованного возвращаемого значения.


Источник статьи: https://habr.com/ru/company/ruvds/blog/551782/
 
Сверху