Pull to refresh

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

Reading time 4 min
Views 19K
Умные указатели часто передаются в другие функции по константной ссылке. Эксперты 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<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. У Герба Саттера есть весьма подробное эссе на эту тему, которое в мельчайших подробностях затрагивает языковую сторону передачи умных указателей по ссылке в С++.
Tags:
Hubs:
+23
Comments 13
Comments Comments 13

Articles