company_banner

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

Автор оригинала: Scott Wolchok
  • Перевод


Пропуск копий (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) — оптимизация именованного возвращаемого значения.

RUVDS.com
VDS/VPS-хостинг. Скидка 10% по коду HABR

Похожие публикации

Комментарии 20

    +7
    Чисто профессиональное замечание, перевод:
    вы написали для класса конструктор копирования, который может делать все, и ожидаете
    не равнозначен оригиналу:
    you wrote a copy constructor for your class that could do anything
    и более, того из-за этого теряется смысл всей статьи. Суть в некорректном переводе anything.
    В англоязычном оригинале подразумевалось, что в конструкторе копирования можно делать всё что захочется, и что программист ожидает это поведение, то есть, что конструктор копирования будет вызван и какая-то часть кода, которая присутствует только там, будет вызвана соответственно, но из-за того, что компилятор удалил копию, это не произойдёт и часть кода не только не будет вызвана, а будет удалена.
    В оригинале это как раз указано:
    If compilers were to unpredictably remove copies, and thus remove pairs of copy/move constructor & destructor calls, they might break your code.
    а вот перевод гораздо менее точный
    Если компиляторы будут непредсказуемым образом удалять копии, тем самым также удаляя пары конструкторов & деструкторов копирования/перемещения, то это может привести к нарушению кода.
    хотя формально он правильный, только более обтекаемый, обобщённый, что ли. Но именно это и ломает весь смысл фразы и статьи в итоге.
      +2
      Спасибо за пояснение, поправим
      +1

      И какие практические правила для эффективной передачи объектов?


      • Возврат "тяжелого" объекта из функции.
        • Например, я хочу вернуть вектор, строку итп. Как лучше всего поступать? Насколько мне известно, основная рекомендация: возвращать по значению и положиться на RVO/NVRO. Может ли тут быть ситуация, когда это не сработает? Как можно подсказать компилятору?
      • Перемещение (передача владения) в функцию
        • Привести к rvalue с помощью std::move?

      А еще где-то неподалеку perfect forwarding...

        +2

        В "return expr;" expr всегда является rvalue, то есть будет вызван конструктор перемещения, если он есть. Поэтому вектор можно спокойно возвращать по значению (без std::move)

          +1

          Более того, если возвращать return std::move(expr); то rvo не будет, а будет конструктор перемещения всегда

            0
            Для собственного развития: а есть практические примеры в которых имеет смысл так делать?
              0

              Честно говоря не знаю. Может если компилятор не поддерживает rvo

                0
                Пишут, что лучше возвращать без move
                +1
                так имеет смысл делать, если хочется переместить member структуры или класса

                struct A { std::string s; };

                std::string foo()
                {
                A a;
                //...
                return std::move(a).s; //если просто сделать a.s, то будет копирование
                }
              0
              Уточню, что «всегда» относилось к контексту темы NRVO. В общем случае это не так, конечно.
              +1
              Если не хочется задумываться о нюансах и потенциальных проблемах, можно возвращать через аргумент функции по ссылке/указателю, а не через return.
              Но с учётом того, что возвращаемый объект модифицируется в процессе работы фукнции и, если функция раньше времени упадёт, то может остаться неоделанный объект, если исключение неправильн обработано будет.
                +1

                Для начала стоит пользоваться C++20, у которого больше свободы в использовании неявного перемещения.
                До него не каждый return перемещал.


                https://oleksandrkvl.github.io/2021/04/02/cpp-20-overview.html#more-impl-moves

                  0
                  Насколько мне известно, основная рекомендация: возвращать по значению и положиться на RVO/NVRO. Может ли тут быть ситуация, когда это не сработает?
                  Скажем, в таком случае RVO сработает:
                  std::string foo = ...;
                  return foo;
                  а в таком нет:
                  std::string foo = ...;
                  std::string bar = ...;
                  return cond ? foo : bar;
                  поэтому во втором случае лучше добавлять std::move. Подробнее можно почитать у Майерса, он хорошо эти случаи разбирает.
                  –1

                  А зачем вам ассемблерный вывод, заставили бы copy ctor и move ctor писать что-нибудь в cout, и всё, не надо дизассемблировать.


                  Upd: формально, не нашёл вашей ситуации в стандарте. Так что я так понимаю стандарт здесь не даёт никаких гарантий по copy elision.

                    +6
                    какая-то не подробная статья. Рассмотрен лишь один пример, да и тот мягко говоря тривиален
                    И, конечно же, вы ожидаете, что, согласно правилам С++, этот конструктор будет вызван всякий раз когда объект вашего класса копируется. Но если компиляторы будут непредсказуемо удалять копирование, тем самым удаляя пары вызовов копирующего/перемещающего конструктора и деструктора они могут разрушить всю логику вашего кода.
                    RVO была допустима еще до с++11, и в каждом следующем стандарте расширяется набор кейсов, в которых компилятор вправе удалить лишние копирования/мувы. Так что если ваш код полагается на сайд эффекты копирований/мувов, то вы сам себе злобный буратино.
                      +1
                      Автор оригинальной статьи забыл или игнорирует «правило пяти». Собственно пример и построен на том, что реализован конструктор копирования, а конструктор перемещения создан компилятором — классический пример применения этого правила.
                      0

                      Нельзя не заметить, что определённую оптимизацию, позволяющую избежать копирований временного объекта Widget, сделать всё-таки можно. Для этого мы вместо копирования продляем жизнь временному объекту (подробности), которая вернула функция doSomeVeryComplicatedThingWithSeveralArguments, а затем перемещаем его.


                      void someFunctionV3() {
                          Widget&& complicatedThingResult =
                              doSomeVeryComplicatedThingWithSeveralArguments(123, "hello");
                          consume(std::move(complicatedThingResult));
                      }
                        0
                        нет особой разницы между этими вариантами:
                        Widget&& foo = someFunc();
                        Widget foo = someFunc();
                        Кроме крайне шаблонных вариантов, а-ля
                        std::forward
                        или универсальной ссылки вида auto&& foo = Foo(...);, где Foo() может возвращать результат либо по значению, либо по lvalue. По крайней мере в вашем примере код изменился только в том, что стал сложнее для прочтения начинающими плюсовиками.
                          0

                          Разницы нет в случае RVO/NRVO, но как вы же сами выше и продемонстрировали, есть ситуации, когда эти оптимизации не применяются. В случае же с rvalue ссылкой мы гарантируем отсутствие копирований временного объекта.

                            0
                            нет, не гарантируете. Код
                            Widget&& complicatedThingResult =
                                doSomeVeryComplicatedThingWithSeveralArguments(...);
                            consume(complicatedThingResult); // без std::move
                            точно так же скомпилируется и в нем точно так же будет копирование, как и в варианте с не-ссылочной переменной.

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое