Skip to content
91 changes: 91 additions & 0 deletions benchmarks/ring_buffer_bench.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,88 @@ static void BM_SPSC_Throughput(benchmark::State& state) {
state.SetItemsProcessed(state.iterations() * total_operations * 2);
}

template<typename Queue>
static void BM_SPSC_BatchThroughput(benchmark::State& state) {
const size_t capacity = state.range(0);
constexpr size_t total_operations = 10'000'000;
const size_t batch_size = state.range(1);

for (auto _ : state) {
state.PauseTiming();

Queue buffer{capacity};
std::atomic<bool> start{false};

std::vector<size_t> input(batch_size);
std::vector<size_t> output(batch_size);
for (size_t i = 0; i < batch_size; i++){
input[i] = i;
}

std::thread producer([&]() {
while (!start.load(std::memory_order_acquire)) {

}

size_t produced = 0;

while (produced < total_operations) {
size_t pushed = buffer.push_batch(
input.data(),
batch_size
);

if (pushed > 0){
produced += pushed;
}
else{
std::this_thread::yield();
}
}
});


std::thread consumer([&]() {
while (!start.load(std::memory_order_acquire)) {

}

size_t consumed = 0;

while (consumed < total_operations) {

size_t popped = buffer.pop_batch(
output.data(),
batch_size
);

if (popped > 0) {
consumed += popped;
benchmark::DoNotOptimize(output);
}
else {
std::this_thread::yield();
}
}
});

state.ResumeTiming();

start.store(true, std::memory_order_release);

producer.join();
consumer.join();

state.PauseTiming();
}

state.SetItemsProcessed(
state.iterations() *
total_operations *
2
);
}

BENCHMARK_TEMPLATE(BM_SPSC_Throughput, shovy::RingBuffer<size_t>)
->Arg(64)
->Arg(1024)
Expand All @@ -61,4 +143,13 @@ BENCHMARK_TEMPLATE(BM_SPSC_Throughput, shovy::RingBuffer<size_t>)
->Repetitions(5)
->ReportAggregatesOnly(true);

BENCHMARK_TEMPLATE(BM_SPSC_BatchThroughput, shovy::RingBuffer<size_t>)
->Args({4096, 8})
->Args({4096, 32})
->Args({4096, 64})
->Args({4096, 256})
->UseRealTime()
->Repetitions(5)
->ReportAggregatesOnly(true);

BENCHMARK_MAIN();
37 changes: 37 additions & 0 deletions docs/benchmark_report.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,44 @@ Regression at small capacity (64): Throughput drops from 89.98 M/s to 81.03 M/s

Engineering trade-off: This optimization requires capacity to be a power of two (otherwise & (capacity - 1) produces incorrect results). This is a classic space-for-time strategy—ideal for performance-critical scenarios where buffer sizes can be pre-aligned (e.g., network packet pools, memory pools). Applications requiring arbitrary prime capacities must retain the modulo operator.

---

## v0.3 Industrial-Ready

### 📈 SPSC RingBuffer Batch Operations and `std::memcpy` (Issue #6)

* **Memory Order**: `acquire`/`release` (same as #3)
* **Alignment**: `alignas(std::hardware_destructive_interference_size)` (same as #4)
* **Capacity**: Power‑of‑two (enables bitwise & for index wrapping)
* **New APIs**: `push_batch(const T*, size_t)` and `pop_batch(T*, size_t)` using `std::memcpy` for `trivially copyable` types

#### Single‑Element Throughput (for reference)
| Capacity | Operation Latency (avg) | Throughput (ops/s) | 5-run Std. Dev. (CV) |
|:---------|:------------------------|:-------------------|:---------------------|
| **64** | ~11.67 ns | **85.04 M/s** | ±1.41 M/s (1.66%) |
| **1024** | ~6.99 ns | **189.61 M/s** | ±3.92 M/s (2.07%) |
| **4096** | ~6.73 ns | **180.54 M/s** | ±5.62 M/s (3.11%) |
> These numbers are taken from the same binary that also runs the batch benchmarks. They show that adding the batch APIs did not degrade single‑element performance.

#### Batch Throughput (fixed capacity = 4096)
| Capacity | Operation Latency (avg) | Throughput (ops/s) | 5-run Std. Dev. (CV) |
|:---------|:------------------------|:-------------------|:---------------------|
| **8** | ~12.39 ns | **394.28 M/s** | ±4.50 M/s (1.14%) |
| **64** | ~6.13 ns | **1.0197 G/s** | ±10.77 M/s (1.06%) |
| **1024** | ~6.21 ns | **1.2588 G/s** | ±10.75 M/s (0.85%) |
| **4096** | ~5.98 ns | **1.3059 G/s** | ±18.78 M/s (1.44%) |

### 👀 Observation

**Batch operations dramatically improve throughput** – even with a small batch of 8, we already see more than 2× the throughput of the single‑element path (394 M/s vs 180 M/s at capacity 4096). With batch size 256, throughput reaches 1.3 G/s, an improvement of ~7×.

**Diminishing returns** – the gain from batch size 64 to 256 is only ~4%, suggesting that the overhead of atomic operations and memory copying has saturated the memory bus. The sweet spot for this hardware is around 64 elements per batch.

**Stability** – the coefficient of variation (CV) remains below 1.5% for all batch sizes, indicating that batch operations exhibit very consistent performance, even under system load.

**Latency per element drops** – from ~12.4 ns (batch=8) down to ~6 ns (batch=32–256), confirming that amortising atomic updates and using memcpy effectively reduces per‑element overhead.

**No regression in single‑element path** – the single‑element throughput (180 M/s) is slightly higher than the previous power‑of‑two version (170 M/s), likely due to the more efficient internal implementation that also benefits the single‑element calls.

---

Expand Down
42 changes: 42 additions & 0 deletions include/spsc/ring_buffer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#include <stdexcept>
#include <atomic>
#include <new>
#include <cstring>
#include <algorithm>

namespace shovy{

Expand Down Expand Up @@ -40,6 +42,46 @@ class RingBuffer{
return true;
}

size_t push_batch(const T *src, size_t count) {
const size_t current_w = write_idx.load(std::memory_order_relaxed);
const size_t current_r = read_idx.load(std::memory_order_acquire);
size_t available = capacity_ - (current_w - current_r);
size_t writeNum = std::min(count, available);

if (writeNum == 0) return 0;
const size_t startW = current_w & (capacity_ - 1);
const size_t length1 = std::min(capacity_ - startW, writeNum);
std::memcpy(buffer_ + startW, src, length1 * sizeof(T));

if (writeNum > length1) {
const size_t length2 = writeNum - length1;
std::memcpy(buffer_, src + length1, length2 * sizeof(T));
}

write_idx.store(current_w + writeNum, std::memory_order_release);
return writeNum;
}

size_t pop_batch(T* dest, size_t count) {
const size_t current_r = read_idx.load(std::memory_order_relaxed);
const size_t current_w = write_idx.load(std::memory_order_acquire);
size_t available = current_w - current_r;
size_t readNum = std::min(available, count);

if (readNum == 0) return 0;
const size_t startR = current_r & (capacity_ - 1);
const size_t length1 = std::min(capacity_ - startR, readNum);
std::memcpy(dest, buffer_ + startR, length1 * sizeof(T));

if (readNum > length1) {
const size_t length2 = readNum - length1;
std::memcpy(dest + length1, buffer_, length2 * sizeof(T));
}

read_idx.store(current_r + readNum, std::memory_order_release);
return readNum;
}

bool empty() const {
return write_idx.load(std::memory_order_relaxed) == read_idx.load(std::memory_order_relaxed);
}
Expand Down
64 changes: 64 additions & 0 deletions tests/ring_buffer_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,67 @@ TEST(RingBufferTest, MultiThreadDataRaceDemonstration){
}

}

TEST(RingBufferTest, BasicBatchOperations){
shovy::RingBuffer<int> buffer(16);
EXPECT_EQ(buffer.capacity(), 16);
EXPECT_TRUE(buffer.empty());

int data[8] = {0,1,2,3,4,5,6,7};
int out[8] = {0};

EXPECT_EQ(buffer.push_batch(data, 8), 8);
EXPECT_FALSE(buffer.full());
EXPECT_FALSE(buffer.empty());

EXPECT_EQ(buffer.pop_batch(out, 8), 8);
EXPECT_TRUE(buffer.empty());

for(int i = 0; i < 8; i++){
EXPECT_EQ(out[i], data[i]);
}

}

TEST(RingBufferTest, BatchWrapAround) {
shovy::RingBuffer<int> buffer(16);

int dummy[14] = {};
int out[14] = {};
buffer.push_batch(dummy, 14);
buffer.pop_batch(out, 14);

int input[8] = {10,20,30,40,50,60,70,80};
EXPECT_EQ(buffer.push_batch(input, 8), 8);

int output[8] = {};
EXPECT_EQ(buffer.pop_batch(output, 8), 8);

for(int i = 0; i < 8; i++) {
EXPECT_EQ(output[i], input[i]);
}
}


TEST(RingBufferTest, BatchPushPartialWhenFull){
shovy::RingBuffer<int> buffer(8);

int input[16] = {};

EXPECT_EQ(buffer.push_batch(input, 16), 8);
EXPECT_TRUE(buffer.full());

EXPECT_EQ(buffer.push_batch(input, 4), 0);
}

TEST(RingBufferTest, BatchPopPartialWhenEmpty){
shovy::RingBuffer<int> buffer(8);

int input[4] = {1,2,3,4};
int output[8] = {};

buffer.push_batch(input, 4);

EXPECT_EQ(buffer.pop_batch(output, 8), 4);
EXPECT_TRUE(buffer.empty());
}
Loading