Atomic operations are very useful for ensuring correct behavior in concurrent programs, while sacrificing much less performance compared to traditional locking mechanisms. However, for high performance C, C++, and CUDA concurrent programs, experienced developers are aware that even atomic operations can become a performance bottleneck, and they will try to minimize their usage whenever possible. However, some atomic operations are hidden behind seemingly innocuous constructs, such as copying a shared pointer, which can lead to unexpected performance degradation.
In this blog post, we will explore a common performance bottleneck caused by hidden atomic operations, specifically focusing on the case of copying shared pointers in multi-threaded C++ programs. We will demonstrate how seemingly harmless operations can introduce contention and degrade performance, and discuss strategies to mitigate these issues.
Performance Bottleneck from Hidden Atomic Operations
In the following code, we will set up a benchmark to measure the throughput scaling with the number of threads. In one implementation, each thread will repeatedly copy the same shared pointer followed by some extremely lightweight work on the data it points to. In the other implementation, each thread will use a reference to an immutable object followed by the same extremely lightweight work on the referenced object. These two implementations look similar and interchangable at first glance. In practice, seeing the first implementation is also common. If there is no performance difference between using shared pointers and using references to immutable objects, the throughput should scale with the number of threads and both implementations should exhibit similar performance scaling.
// Build and run in a C++ development container: // g++ -O3 -DNDEBUG -std=c++17 -pthread hidden_multi_threaded_atomics.cpp // -o hidden_multi_threaded_atomics // ./hidden_multi_threaded_atomics --max-threads 16 --iterations 20000000 // // The shared_ptr benchmark deliberately copies the *same* shared_ptr once per // iteration. Each copy increments and decrements its shared control block's // atomic reference count, so threads contend on one cache line. The borrowed // benchmark keeps the owner alive outside the timed region and uses a reference // to the same immutable object instead.
namespace {
structPayload { // The immutable data object managed by owner. Its volatile read prevents // the compiler from removing the per-iteration work; it is not the // contended atomic state. volatile std::uint64_t value{0x9e3779b97f4a7c15ULL}; };
intmain(int argc, char** argv) { Options const options{parse_options(argc, argv)}; // The sole shared owner. Its lifetime covers both benchmark calls and all // worker joins, so the borrowed-reference benchmark is safe. autoconst owner{std::make_shared<Payload const>()};
std::cout << "Each thread performs " << options.iterations << " iterations. Rates are aggregate million iterations/second." << std::endl << " shared_ptr: copies one shared owner every iteration" << std::endl << " borrowed: uses a reference while the owner stays alive" << std::endl << std::endl << std::setw(7) << "threads" << std::setw(16) << "shared_ptr" << std::setw(16) << "borrowed ref" << std::setw(12) << "speedup" << std::endl;
std::size_t threads{1}; while (true) { Measurement const shared{ run_shared_ptr_copies(owner, threads, options.iterations)}; Measurement const borrowed{ run_borrowed_reference(owner, threads, options.iterations)}; if (shared.checksum != borrowed.checksum) { std::cerr << "Benchmark variants produced different results" << std::endl; return EXIT_FAILURE; } print_result(threads, shared, borrowed, options.iterations);
To build and run the benchmark, please run the following commands. I built and ran the benchmark on my local desktop that has an 8-core 16-thread Intel Core i9-9900K CPU.
1 2 3 4 5 6 7 8 9 10 11 12
$ g++ -O3 -DNDEBUG -std=c++17 -pthread hidden_multi_threaded_atomics.cpp -o hidden_multi_threaded_atomics $ ./hidden_multi_threaded_atomics --max-threads 16 --iterations 20000000 Each thread performs 20000000 iterations. Rates are aggregate million iterations/second. shared_ptr: copies one shared owner every iteration borrowed: uses a reference while the owner stays alive
It turns out that the throughput of using shared pointers does not scale well with the number of threads, while using references to immutable objects scales much better. In fact, the throughput of using shared pointers remains almost consstant regardless of the number of threads, suggesting that there is a performance bottleneck associated with shared pointer management in a multi-threaded context.
The reason for this behavior is that shared pointers internally use atomic reference counting to manage the lifetime of the object they own. There are costs associated with these atomic operations. In the single-threaded case where work is heavy, the overhead of atomic reference counting is almost negligible, but in the multi-threaded case, contention on the atomic operations becomes a bottleneck, limiting scalability. In our case, every time a shared pointer is copied or destroyed, the reference count is updated atomically, which introduces contention and limits scalability in a multi-threaded environment. On the other hand, using references to immutable objects avoids this overhead, allowing for much better performance and scalability.
It should also be noted that when using shared pointers, the througput with two threads is significantly lower than the throughput with a single thread. To understand this, we have to understand how atomic operation works with cache coherence in multi-core systems. When multiple threads on different cores try to update the same atomic variable, such as the reference count of a shared pointer, the cache lines containing that variable must be synchronized across cores. This synchronization introduces additional latency and reduces throughput, even with just two threads. Consequently, the performance degradation with two threads compared to a single thread is a result of cache coherence overhead and contention on the atomic reference count.