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

от автора

Пропуск копий (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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *