Передача умных указателей по константной ссылке. Вскрытие

Умные указатели часто передаются в другие функции по константной ссылке. Эксперты C++, Андрей Александреску, Скотт Мейэрс и Герб Саттер, обсуждают этот вопрос на конференции C++ and Beyond 2011 (Смотреть с [04:34] On shared_ptr performance and correctness).По сути, умный указатель, который передан по константной ссылке, уже живёт в текущей области видимости где-то в вызывающем коде. Если он хранится в члене класса, то может случиться так, что этот член будет обнулён. Но это не проблема передачи по ссылке, это проблема архитектуры и политики владения.

Но этот пост не про корректность. Здесь мы рассмотрим производительность, которую мы можем получить при переходе на константные ссылки. На первый взгляд может показаться, что единственная выгода это отсутствие атомарных инкрементов/декрементов счётчика ссылок при вызове конструктора копирования и деструктора. Давайте напишем немного кода и посмотрим более внимательно, что же происходит под капотом.

Перевод статьи: blog.linderdaum.com/2014/07/03/smart-pointers-passed-by-const-reference/

Для начала, несколько вспомогательных функций:

const size_t NUM_CALLS = 10000000;

double GetSeconds () { return (double)clock () / CLOCKS_PER_SEC; }

void PrintElapsedTime (double ElapsedTime) { printf (»%f s/Mcalls\n», float (ElapsedTime / double (NUM_CALLS / 10000000))); } Интрузивный счётчик ссылок:

class iIntrusiveCounter { public: iIntrusiveCounter (): FRefCounter (0) {}; virtual ~iIntrusiveCounter () {} void IncRefCount () { FRefCounter++; } void DecRefCount () { if (--FRefCounter == 0) { delete this; } } private: std: atomic FRefCounter; }; Ad hoc умный указатель:

template class clPtr { public: clPtr (): FObject (0) {} clPtr (const clPtr& Ptr): FObject (Ptr.FObject) { FObject→IncRefCount (); } clPtr (T* const Object): FObject (Object) { FObject→IncRefCount (); } ~clPtr () { FObject→DecRefCount (); } clPtr& operator = (const clPtr& Ptr) { T* Temp = FObject; FObject = Ptr.FObject; Ptr.FObject→IncRefCount (); Temp→DecRefCount (); return *this; } inline T* operator → () const { return FObject; } private: T* FObject; }; Пока всё достаточно просто, да? Объявим простой класс, экземпляр которого мы будет передавать в функцию вначале по значению, а потом по константной ссылке:

class clTestObject: public iIntrusiveCounter { public: clTestObject (): FPayload (32167) {} // сделаем что-нибудь полезное void Do () { FPayload++; }

private: int FPayload; }; Теперь можно написать непосредственно код бенчмарка:

void ProcessByValue (clPtr O) { O→Do (); } void ProcessByConstRef (const clPtr& O) { O→Do (); }

int main () { clPtr Obj = new clTestObject; for (size_t j = 0; j!= 3; j++) { double StartTime = GetSeconds (); for (size_t i = 0; i!= NUM_CALLS; i++) { ProcessByValue (Obj); } PrintElapsedTime (GetSeconds () — StartTime); } for (size_t j = 0; j!= 3; j++) { double StartTime = GetSeconds (); for (size_t i = 0; i!= NUM_CALLS; i++) { ProcessByConstRef (Obj); } PrintElapsedTime (GetSeconds () — StartTime); } return 0; } Соберём и посмотрим, что происходит. Сначала соберём неоптимизированную версию (я использую gcc.EXE (GCC) 4.10.0 20140420 (experimental)):

gcc -O0 main.cpp -lstdc++ -std=c++11 Скорость работы 0.375 с/Мвызовов для версии «по-значению» против 0.124 с/Mвызовов для версии «по-константной-ссылке». Убедительная разница в 3x в отладочной сборке. Это хорошо. Давайте посмотрим на ассемблерный листинг. Версия «по-значению»:

L25: leal -60(%ebp), %eax leal -64(%ebp), %edx movl %edx, (%esp) movl %eax, %ecx call __ZN5clPtrI12clTestObjectEC1ERKS1_ // вызываем конструктор копирования subl $4, %esp leal -60(%ebp), %eax movl %eax, (%esp) call __Z14ProcessByValue5clPtrI12clTestObjectE leal -60(%ebp), %eax movl %eax, %ecx call __ZN5clPtrI12clTestObjectED1Ev // вызываем деструктор addl $1, -32(%ebp) L24: cmpl $10000000, -32(%ebp) jne L25 Версия «по-константной-ссылке». Обратите внимание на сколько всё стало чище даже в отладочном билде:

L29: leal -64(%ebp), %eax movl %eax, (%esp) call __Z17ProcessByConstRefRK5clPtrI12clTestObjectE // просто один вызов addl $1, -40(%ebp) L28: cmpl $10000000, -40(%ebp) jne L29 Все вызовы на своих местах и всё что удалось сэкономить это две довольно-таки дорогие атомарные операции. Но отладочные сборки это не то, что нам нужно, так ведь? Давайте всё оптимизируем и посмотрим, что произойдёт:

gcc -O3 main.cpp -lstdc++ -std=c++11 Версия «по-значению» теперь выполняется за 0.168 секунды на 1 млн. вызовов. Время выполняния версии «по-константной-ссылке» обустилось в буквальном ссылке до нуля. Это не ошибка. Не важно сколько итераций мы сделаем, время выполнения этого простого теста будет нулевым. Давайте посмотрим на ассемблер, чтобы убедиться, не ошиблись ли мы где-нибудь. Вот оптимизированная версия передачи по значению:

L25: call _clock movl %eax, 36(%esp) fildl 36(%esp) movl $10000000, 36(%esp) fdivs LC0 fstpl 24(%esp) .p2align 4,,10 L24: movl 32(%esp), %eax lock addl $1, (%eax) // заинлайненный IncRefCount ()… movl 40(%esp), %ecx addl $1, 8(%ecx) // ProcessByValue () и Do () скомпилированы в 2 строки lock subl $1, (%eax) //, а это DecRefCount (). Впечатляет. jne L23 movl (%ecx), %eax call *4(%eax) L23: subl $1, 36(%esp) jne L24 call _clock Хорошо, но что ещё можно сделать при передаче по ссылке, что она станет работать на столько быстро, что мы не можем это измерить? Вот она:

call _clock movl %eax, 36(%esp) movl 40(%esp), %eax addl $10000000, 8(%eax) // предвычесленный окончательный результат, никаких циклов, ничего call _clock movl %eax, 32(%esp) movl $20, 4(%esp) fildl 32(%esp) movl $LC2, (%esp) movl $1, 48(%esp) flds LC0 fdivr %st, %st (1) fildl 36(%esp) fdivp %st, %st (1) fsubrp %st, %st (1) fstpl 8(%esp) call _printf Вот это да! В этот листинг поместился весь бенчмарк. Отсутствие атомарных операций позволило оптимизатору залезть в этот код и развернуть цикл в одно предвычисленное значение. Конечно, этот пример тривиален. Однако, он позволяет чётко говорить о 2-х выгодах передачи умных указетелей по константной ссылке, которые делают её не преждевременной оптимизацией, а серьёзным средством улучшения производительность:

1) удаление атомарных операций даёт большую выгоду само по себе2) удаление атомарных операций позволяет оптимизатору причесать код

Полный исходник здесь.

На вашем компиляторе результат может отличаться :)

P.S. У Герба Саттера есть весьма подробное эссе на эту тему, которое в мельчайших подробностях затрагивает языковую сторону передачи умных указателей по ссылке в С++.

© Habrahabr.ru