#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, а forHandWritten
— нет. То же самое, когда я использовал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);
}
// ...
};
Если вы выполняете побитовую копию этого класса, перемещенный экземпляр не регистрируется в реестре.
При обнулении ссылка повреждается, и деструктор исходного экземпляра, скорее всего, завершится сбоем при отмене регистрации в реестре.