Пропуск копий (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/
Добавить комментарий