Hello! Welcome back to our course on building a high-performance telemetry engine.
In our last lesson, we tackled the subtle but critical performance issue of false sharing. By aligning our queue's head and tail indices to the CPU's cache line size, we ensured that our producer and consumer threads can operate on their respective data without causing expensive, unnecessary cache invalidations. Our SPSC queue is now correct, thread-safe, and hardware-optimized.
However, there's one more significant algorithmic optimization we can apply. So far, we've pushed and popped one element at a time. Each of these operations requires atomic instructions and memory fences to guarantee safety, and this overhead can add up, especially when logging thousands of events per second.
In this lesson, we will focus on our final SPSC queue optimization: implementing batch operations for pushing and popping to reduce atomic operation overhead. This technique is fundamental to achieving the high throughput required for a low-latency telemetry system.
The Cost of One-at-a-Time
Let's quickly review the cost of a single push operation in our current queue:
- Load the cached
head_index. - Check if the queue is full. If the cached check fails, perform an expensive atomic
loadof the realhead_index withacquiresemantics. - Copy the element into the buffer slot.
- Perform an atomic
storeto thetail_index withreleasesemantics to make the write visible to the consumer.
The atomic operations, especially the release store, are the performance bottleneck. They act as a barrier, preventing the compiler and CPU from reordering instructions and ensuring that memory changes are propagated to other cores. While necessary for correctness, paying this price for every single element is inefficient.
The Solution: Amortization through Batching
The solution is to amortize this cost. Instead of pushing one element, what if we could push N elements while only paying the synchronization cost once? This is the core idea of batching.
By modifying our API to handle chunks of data, we can perform the atomic checks and updates once for an entire batch, dramatically reducing the per-element overhead.
Correct and Efficient Bounded FIFO Queues
To formalize this idea, let's turn to the paper 'Correct and Efficient Bounded FIFO Queues'. It outlines several optimizations for lock-free queues, including the one we're interested in.
Please read subsection 'c) Batching' on page 3. The authors explain the twofold benefits of this approach.
As the paper highlights, the benefits are:
- Amortization: The cost of synchronization (atomic operations and memory fences) is spread across all elements in the batch.
- Platform Optimization: It enables the use of highly optimized functions like
memcpyto copy the entire batch at once, which is much faster than a per-element copy loop.
This concept is so powerful that it's a key optimization strategy in ultra-low latency systems.
When Nanoseconds Matter: Ultrafast Trading Systems in C++ - David Gross - CppCon 2024
To hear this from an industry expert, let's watch a short clip from the CppCon talk 'When Nanoseconds Matter' by David Gross. He discusses the single most important optimization for his high-frequency trading queue.
Watch from 0:55:52 to 0:57:15. The speaker explains that the biggest optimization is to avoid updating the atomic write counter on every single message. By reserving a large chunk and updating the atomic only once per chunk, he can effectively reduce the atomic operations by a factor of a thousand.
A Simple Batching Implementation
Let's look at how we might implement a basic batch push operation. The logic follows the same principles as our single-element push, but adapted for multiple items.
// A conceptual batch push operation
bool push_batch(const T* elements, size_t count) {
// 1. Load current positions
const auto current_tail = tail_.value.load(std::memory_order_relaxed);
const auto current_head = head_cached_.value; // Use cached value first
// 2. Check for space
const size_t free_space = capacity() - (current_tail - current_head);
if (free_space < count) {
// Cached value was not enough, do a real atomic load
head_cached_.value = head_.value.load(std::memory_order_acquire);
if (capacity() - (current_tail - head_cached_.value) < count) {
return false; // Not enough space even after update
}
}
// 3. Copy the data (simplified, ignores wrap-around for now)
for (size_t i = 0; i < count; ++i) {
buffer_[(current_tail + i) & mask_] = elements[i];
}
// 4. Publish the write with a single atomic store
tail_.value.store(current_tail + count, std::memory_order_release);
return true;
}
Notice that the expensive release store to tail_ happens only once, after all count elements have been copied. We've successfully amortized the cost.
Test your understanding!
Suppose a single atomic release store takes 20 nanoseconds and a single element copy takes 2 ns. Ignoring the initial checks, what is the approximate total time to push 100 elements using:
- A single-element
pushfunction called 100 times in a loop. - A single batch
push_batchfunction call.
What is the per-element cost in each case?
Show answer
-
Single-element
push(100 times):- Total time = 100 * (atomic store + copy) = 100 * (20 ns + 2 ns) = 2200 ns.
- Per-element cost = 22 ns.
-
Batch
push_batch(1 time):- Total time = (1 * atomic store) + (100 * copy) = 20 ns + (100 * 2 ns) = 220 ns.
- Per-element cost = 2.2 ns.
The batch operation is 10 times faster in this scenario. The per-element cost approaches the raw copy time as the batch size increases, making the synchronization overhead negligible.
Handling the "Wrap-Around"
The simple for loop in our conceptual code works, but it's not ideal. The modulo (& mask_) inside the loop can be slower than a straight memory copy. To use a faster memcpy, we must provide a contiguous block of memory.
However, in a circular buffer, a batch might not fit in a single contiguous block. It might need to "wrap around" the end of the buffer.
This is a classic problem in circular buffer implementations. A well-designed batching API must handle this gracefully. The GitHub repository for 'LockFreeSpscQueue' has an excellent explanation and visualization of this problem and its solution.
Please read the section 'Core Concept: The Circular Buffer'. Focus on the 'Wrap-Around' Problem. The key insight is that a write or read operation that crosses the buffer boundary can be split into two contiguous chunks.
The solution is to provide an API that can return two blocks of memory.
- Block 1: The contiguous space from the current write position to the end of the buffer.
- Block 2: The contiguous space from the beginning of the buffer.
The caller can then perform two memcpy operations, one for each block. This is far more efficient than an element-by-element copy loop for any non-trivial batch size.
Advanced API: Direct Buffer Access with Proxies
We can take this one step further. So far, we've assumed the caller has a buffer of data ready to be copied into our queue. This implies one copy: from the user's buffer to the queue's buffer.
For maximum performance, we want to eliminate this copy. What if we could serialize our data directly into the queue's internal buffer? This is a key technique in data-oriented design and is crucial for your telemetry profiler, where you'll be constructing event data on the fly.
This can be achieved with a smart-pointer-like proxy object that provides temporary, direct access to the queue's memory.
Single Producer Single Consumer Lock-free FIFO From the Ground Up - Charles Frasch - CppCon 2023
Charles Frasch, in his CppCon talk, introduces exactly this pattern. He calls his proxy objects 'Pusher' and 'Popper'. Let's see how they work.
Please watch from 34:05 to 43:30. Pay close attention to the motivation: reducing memcpy operations. The 'Pusher' object is created by a factory function on the queue. It gives the caller a pointer to write into, and when the 'Pusher' is destroyed (goes out of scope), it automatically advances the atomic cursor. This is a brilliant use of the RAII (Resource Acquisition Is Initialization) pattern.
Let's break down this powerful pattern:
- Reservation: The producer thread calls a function like
prepare_write(N)on the queue. This function checks if there's a contiguous block ofNslots available. - Proxy Object: If space is available, the function returns a
WriteProxyobject. This object contains astd::spanor a pointer/size pair pointing directly into the queue's internal buffer. - Direct Write: The producer can now write, move, or construct its
Nobjects directly into the memory provided by theWriteProxy. This completely eliminates an intermediate copy. - Commit: When the
WriteProxyobject goes out of scope, its destructor is called. The destructor is responsible for performing the single atomicstorewithreleasesemantics to thetail_pointer, officially "committing" the write and making the data visible to the consumer. If the write is aborted (e.g., due to an exception), the destructor does nothing, and the write is never committed.
This proxy pattern is the pinnacle of performance for this type of queue, as it minimizes both synchronization overhead and memory copies.
Conclusion
In this lesson, we've transformed our SPSC queue from a good implementation into a high-throughput one. By moving from single-element operations to batch operations, we've drastically reduced the per-element cost of thread synchronization.
Key Takeaways:
- Single-element operations are inefficient because they pay the high cost of atomic synchronization for every small item.
- Batching amortizes this synchronization cost over many elements, making the overhead negligible for large batches.
- A robust batching implementation must handle the wrap-around case in a circular buffer, typically by splitting the operation into two contiguous memory blocks.
- The most performant API uses a proxy object (RAII) to provide direct write access to the queue's internal buffer, eliminating an intermediate
memcpyand enabling "zero-copy" (in-place construction) semantics.
What's Next?
This lesson concludes our deep dive into the implementation of a single-producer, single-consumer lock-free queue. You now have a complete, highly optimized data structure that is the fundamental building block of our telemetry system.
In our next lesson, we will zoom out from the implementation details of a single queue and start designing the overall system architecture. We'll address the question: "How do we collect data from many producer threads at once?" The answer lies in our next topic: "Design a per-thread buffer architecture to eliminate write-side contention". We will see how to combine multiple SPSC queues into a system that can scale to any number of instrumented threads.