Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 38

Batch Operations for Optimized Data Transfer

Recommended resources

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:

  1. Load the cached head_ index.
  2. Check if the queue is full. If the cached check fails, perform an expensive atomic load of the real head_ index with acquire semantics.
  3. Copy the element into the buffer slot.
  4. Perform an atomic store to the tail_ index with release semantics 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:

  1. Amortization: The cost of synchronization (atomic operations and memory fences) is spread across all elements in the batch.
  2. Platform Optimization: It enables the use of highly optimized functions like memcpy to 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:

  1. A single-element push function called 100 times in a loop.
  2. A single batch push_batch function call.

What is the per-element cost in each case?

Show answer
  1. Single-element push (100 times):

    • Total time = 100 * (atomic store + copy) = 100 * (20 ns + 2 ns) = 2200 ns.
    • Per-element cost = 22 ns.
  2. 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.

joz-k/LockFreeSpscQueue

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:

  1. Reservation: The producer thread calls a function like prepare_write(N) on the queue. This function checks if there's a contiguous block of N slots available.
  2. Proxy Object: If space is available, the function returns a WriteProxy object. This object contains a std::span or a pointer/size pair pointing directly into the queue's internal buffer.
  3. Direct Write: The producer can now write, move, or construct its N objects directly into the memory provided by the WriteProxy. This completely eliminates an intermediate copy.
  4. Commit: When the WriteProxy object goes out of scope, its destructor is called. The destructor is responsible for performing the single atomic store with release semantics to the tail_ 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 memcpy and 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.