Рукописный ход

#c #c 11 #move-semantics #memcpy

#c #c 11 #перемещение-семантика #memcpy

Вопрос:

Я написал векторный класс, чтобы изучить семантику перемещения. Я использую конструктор перемещения для перемещения T (закомментированная строка).

Мой вопрос в том, почему бы просто не скопировать все байты временного объекта и не установить все байты временного объекта равными нулю, как в C?

Я знаю, что деструктор будет вызван для временного объекта, и для его правильного уничтожения может потребоваться несколько инициализированных членов. Это может быть причиной, по которой я не должен изменять внутреннее представление объекта.

Но в случае, если я на 100% уверен, что у моего ~ T() нет таких требований, хорошая ли это оптимизация?

  20     void push_back(Tamp;amp; val)
 21     {
 22         check_cap();
 23         //new (m_data   m_size) T(std::move(val));
 24         for(int i = 0; i < sizeof(T);   i)
 25         {
 26             reinterpret_cast<char*> (m_data   m_size)[i] = reinterpret_cast<char*> (amp;val)[i];
 27             reinterpret_cast<char*> (amp;val)[i] = 0;
 28         }
 29         m_size  ;
 30     }
 

(пожалуйста, ничего не говорите о приведениях и их безопасности, если это не связано с актуальным вопросом)

(Я знаю, что это не очень хороший подход, и лучше не использовать его в реальном проекте. Но меня интересует только то, насколько это хорошо с точки зрения эффективности.)

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

1. Здесь вы найдете множество вопросов по stackoverflow.com от людей, удивляющихся, почему после того, как они memcpy создали свои объекты C , отсюда и туда, весь ад вырвался на свободу. Именно это в конечном итоге и происходит здесь. Это закончится только слезами. Вы не можете ожидать, что просто скопируете объект C , байт за байтом, и все будут жить долго и счастливо. C так не работает.

2. Вы можете копировать представление памяти только для тривиально копируемых типов. Как только у типа появляется пользовательский конструктор перемещения, он больше не может быть тривиально скопирован.

3. Вы пытались измерить обе версии, чтобы увидеть, в чем разница?

4. Конечно. Вот простой пример. Типичным std::string является указатель и размер строки. Вы просто байт за байтом скопировали его. Отлично! И теперь у вас есть два std::string s, указывающих на один и тот же внутренний буфер, и оба их деструктора (в конечном итоге) попытаются delete[] использовать один и тот же точный указатель с сигналом. Возникает холмистость. Теперь у вас таинственный сбой и повреждение памяти в совершенно не связанной части вашего кода, что заставляет вас написать еще один пост в Stackoverflow, задаваясь вопросом, что с этим? Итак, насколько более «эффективным», чем правильная семантика перемещения, оказалось, в конце концов?

5. Этот вопрос подразумевает непонимание того, что такое движение в C .

Ответ №1:

почему бы просто не скопировать все байты временного объекта и не установить все байты временного объекта равными нулю, как в C?

это хорошая оптимизация?

Я думаю, что нет. Вот краткое сравнение между вашей рукописной версией и обычной версией с использованием clang :

введите описание изображения здесь

При использовании g результат равен нулю, поэтому выигрыша тоже нет.

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

1. Для придирки — элемент должен быть вставлен через размещение new new(m_data m_size) T(std::move(val)); , а не operator= . Но это не меняет бенчмарк.

2. Странно то, что in Normal компилятор выдает инструкции SSE, а for HandWritten — нет. То же самое, когда я использовал memcpy / memset вместо ручного цикла. В моем вышеупомянутом эксперименте ( godbolt.org/z/cTs8z4 ), GCC вел себя так же, пока я -mavx явно не указал флаг.

3. Возможно, @Quimby. Я использовал new T[...]; so, чтобы элементы уже были там, готовые к перемещению.

4. @TedLyngmo Clang привел к той же сборке на основе SSE, только с -O2 : godbolt.org/z/TxTo94 . Не знаю, почему это не произошло внутри quick-bench.

5. @TedLyngmo Ах, не заметил этого, извините.

Ответ №2:

Ваш план плохой.

Компиляторы могут, используя правило as-if, часто вычислять, что memcpy и ноль — или даже просто пропуск деструктора — являются законными.

Если такой компилятор сталкивается с вашим неопределенным поведением, созданным вручную, он либо запутывается в неопределенном поведении, либо становится неспособным к дальнейшей оптимизации из-за этого.


Иногда есть веские причины прибегнуть к неопределенному поведению. Но они начинаются с доказательства того, что ваше решение улучшает ситуацию, и исчерпывает стандартные совместимые решения. И они заканчиваются откровенным обсуждением реальных краткосрочных, среднесрочных и долгосрочных рисков в обмен только на краткосрочную гарантированную выгоду.

Ваш случай не делает ничего из этого.

Оптимизация move-destroy для memcpy — это то, что компиляторы уже делают с типами, которые легко понять в простом потоке кода. Выполнение этого вручную в 99/100 раз бессмысленно и в 90/100 раз вредно.

Если ваши типы не просты, а поток кода прост для понимания, то, вероятно, вашу оптимизацию также трудно доказать безопасной. И если вы упростите свои типы и поток кода, к тому времени, когда вы сможете надежно доказать, что ваш memcpy zero является оптимальным, ваш компилятор, вероятно, тоже сможет.

Сядьте с godbolt и поиграйте с оптимизированным выводом компилятора. Это познавательно.

Ответ №3:

Ваша оптимизация может быть действительной для нескольких случаев. Это может вызвать проблемы, когда ваш конструктор перемещения выполняет что-то конкретное.

  class MyCustomClass : public IObserver
 {
     Registry amp;registry;
     // ...
 public:
     MyCustomClass(MyCustomClass amp;amp;rhs)
     : registry{rhs.registry}
     {
          registry.register(*this);
     }
     // ...
 };
 

Если вы выполняете побитовую копию этого класса, перемещенный экземпляр не регистрируется в реестре.

При обнулении ссылка повреждается, и деструктор исходного экземпляра, скорее всего, завершится сбоем при отмене регистрации в реестре.