Умные указатели часто передаются в другие функции по константной ссылке. Эксперты 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/Mcallsn", 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<int> FRefCounter;
};
Ad hoc умный указатель:
template <class T> 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<clTestObject> O ) { O->Do(); }
void ProcessByConstRef( const clPtr<clTestObject>& O ) { O->Do(); }
int main()
{
clPtr<clTestObject> 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. У Герба Саттера есть весьма подробное эссе на эту тему, которое в мельчайших подробностях затрагивает языковую сторону передачи умных указателей по ссылке в С++.
Автор: