C++ noexcept and move constructors effect on performance in STL Containers

A little known feature of some of the C++ standard library containers is that in the latest C++ Standard they will use the C++11 move operation to more cheaply resize the container when possible.

A move operation is cheaper than a straight copy because it performs fewer operations at the cost of trashing the source object. This is because the memory is not copied and then reassigned, simply reassigned. (full details here if you are unfamiliar)

The actual mechanism which tells you if the container is using a move constructor or copy constructor is quite opaque to the user and the actual condition for it being switched not well documented.

class MyClass
{
    ...

    //This is a correctly formed copy constructor
    MyClass ( MyClass&& _input) noexcept
    {
     ... do something ...
    }

    ...

};

An STL container can only use the move constructor in it's resizing operation if that constructor does not break its strong exception safety guarantee. In more plain language, it wont use the move constructor of an object if that can throw an exception. This is because if an exception is thrown in the move then the data that was being processed could be lost, where as in a copy constructor the original will not be changed.

So, how do we tell the STL library that our move constructor wont throw any exceptions? Simple, we use the "noexcept" function specifier!

Tagging our move constructor with "noexcept" tells the compiler that it will not throw any exceptions. This condition is checked in C++ using the type trait function: "std::is_no_throw_move_constructible". This function will tell you whether the specifier is correctly set on your move constructor. If you haven't wrote a move constructor yourself the compiler generated one should pass this test. This is really only a concern if you have implemented your own. If your type passes the test then when the STL container calls "std::move_if_noexcept" it will correctly move the object instead of falling back to a copy.

template<typename Y>
MoveProperties GetProps()
{
	MoveProperties mp;
	mp.moveassignable               = std::is_move_assignable<Y>::value;
	mp.moveconstructible            = std::is_move_constructible<Y>::value;
	mp.moveTriviallyAssignable      = std::is_trivially_move_assignable<Y>::value;
	mp.moveTriviallyConstructible   = std::is_trivially_move_constructible<Y>::value;
	mp.moveNoThrowAssignable        = std::is_nothrow_move_assignable<Y>::value;
	mp.moveNoThrowConstructible     = std::is_nothrow_move_constructible<Y>::value;
	return mp;
}

We can use these tests to build two classes, one which has a noexcept copy contructor and one without. Then place them into a large STL container and time them to see what a difference this can make to the performance of the system.

So firstly, two classes:

#define ArraySize 100

unsigned int staticCounter = 0;
unsigned int copyCount = 0;
unsigned int moveCount = 0;

class NoThrowMoveConstructible
{
public:
	NoThrowMoveConstructible() 
	{ 
		m_memberVariable = new std::array<int, ArraySize>();
		m_memberVariable->fill(staticCounter++); 
	}
	~NoThrowMoveConstructible()
	{
		if (m_memberVariable != nullptr)
		{
			delete m_memberVariable;
		}
	}
	NoThrowMoveConstructible(NoThrowMoveConstructible&&	_in) noexcept 
	{
		moveCount++;
		this->m_memberVariable = _in.m_memberVariable;
		_in.m_memberVariable = nullptr;
	}
	NoThrowMoveConstructible(const NoThrowMoveConstructible& _in) noexcept
	{ 
		copyCount++;
		this->m_memberVariable = new std::array<int, ArraySize>(*(_in.m_memberVariable));
	}

	std::array<int, ArraySize>* m_memberVariable;
};

class CantBeNoThrowMoveConstructible
{
public:
	CantBeNoThrowMoveConstructible() 
	{ 
		m_memberVariable = new std::array<int, ArraySize>(); 
		m_memberVariable->fill(staticCounter++);
	}

	~CantBeNoThrowMoveConstructible()
	{
		if (m_memberVariable != nullptr)
		{
			delete m_memberVariable;
		}
	}

	CantBeNoThrowMoveConstructible(CantBeNoThrowMoveConstructible&& _in) 
	{ 
		moveCount++;
		this->m_memberVariable = _in.m_memberVariable;
		_in.m_memberVariable = nullptr;
	}

	CantBeNoThrowMoveConstructible(const CantBeNoThrowMoveConstructible& _in) 
	{ 
		copyCount++;
		this->m_memberVariable = new std::array<int, ArraySize>(*(_in.m_memberVariable));
	}

	std::array<int, ArraySize>* m_memberVariable;
};

These two classes are identical except for the "noexcept" being applied to its copy and move constructors. They are each simply a wrapper around a dynamically assigned array of integers. We are using an "std::array" here so that if we were to do thorough testing it would be trivial to test copy/move on different sized objects.

Using our "Props" function we can see that the classes have the correct properties. No throw move construction is false on our "CantBeNoThrowMoveConstructible" class and true on our "NoThrowMoveConstructible" class. They are otherwise identical when it comes to move properties.

    NoThrowMoveConstructible:
	moveassignable              false
	moveconstructible           true
	moveTriviallyAssignable     false
	moveTriviallyConstructible  false
	moveNoThrowAssignable	    false
	moveNoThrowConstructible    true

    CantBeNoThrowMoveConstructible:
	moveassignable	            false	
	moveconstructible           true	
	moveTriviallyAssignable     false	
	moveTriviallyConstructible  false	
	moveNoThrowAssignable       false	
	moveNoThrowConstructible    false	

If we were to run this analysis on any basic class or class where we havent overriden any of the move operator functions or constructors we would see all these properties return true. This is because the default implementations of these functions which are automatically added to your class if you don't replace them (much like the default destructors) are created with the correct noexcept properties to allow moving the objects.

Now that we know the classes are behaving as we would expect we can run a simple timing function to judge the performance and using the counters verify that behavior at run-time.

template< typename Y >
std::vector<Y> OutputTimeForFillingVector(unsigned int vectorSize)
{
	copyCount = 0;
	moveCount = 0;
	std::vector<Y> vectorToFill;

	Timer<std::chrono::milliseconds> t;
	t.Start();

	for (int i = 0; i < vectorSize; i++)
	{
		Y val;
		vectorToFill.emplace_back(val);
	}

	std::vector<Y> copyVector = vectorToFill;

	std::cout << "Move Operations: " << moveCount << ". Copy Operations: " << copyCount << "\n";
	std::cout << "Time taken for " << typeid(Y).name() << ": " << t.GetElapsedTime().count() << "ms.\n";

	return copyVector;
}

int main()
{
	MoveProperties NoThrowMoveable		= GetProps<NoThrowMoveConstructible>();
	MoveProperties NoneNoThrowMoveable = GetProps<CantBeNoThrowMoveConstructible>();
	
	unsigned int vecSize = 1000000;
	
	std::cout << "Can do move operations: \n";
	OutputTimeForFillingVector<NoThrowMoveConstructible>(vecSize);
	std::cout << "Can't do move operations: \n";
	OutputTimeForFillingVector<CantBeNoThrowMoveConstructible>(vecSize);

	system("pause");
	return 0;
}

This simple code simply creates a vector of size "vecSize" by repeatedly adding a single element to the vector. The time it takes for this to happen and the number of copies and moves that were performed on the underlying object is then output to the console for us to analyse.

Can do move operations:
Move Operations: 2099753. Copy Operations: 2000000
Time taken for class NoThrowMoveConstructible: 469ms.
Can't do move operations:
Move Operations: 0. Copy Operations: 4099753
Time taken for class CantBeNoThrowMoveConstructible: 926ms.

This leads to the class which cannot be correctly moved taking nearly twice as long as the one that can, to be placed into a simple vector. Importantly you can see the effect of this in the Visual Studio Diagnostics panel's process memory watcher.

stdmove.png

We can see that the "std::move" enabled class has a smooth increase in memory as the memory is allocated and moved to the copy of the object in the vector leading to a steady increase. Where as the copy constructed class has to frequently make copies of its memory and deallocate the the memory in the dead objects leading to a  bouncing rise in memory over a longer time due to the extra work that is having to be done.

The lesson in all of this is that if you touch the copy-constructors or operators make sure to assign the correct exception handling status to the functions to be able to enable the optimal behaviour. If you never touch these at all, there is a good chance they will default to the correct behaviour, but it is always good to check!