diff --git a/benchmarks/ring_buffer_bench.cpp b/benchmarks/ring_buffer_bench.cpp index 8283fbe..a2cc234 100644 --- a/benchmarks/ring_buffer_bench.cpp +++ b/benchmarks/ring_buffer_bench.cpp @@ -53,6 +53,88 @@ static void BM_SPSC_Throughput(benchmark::State& state) { state.SetItemsProcessed(state.iterations() * total_operations * 2); } +template +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 start{false}; + + std::vector input(batch_size); + std::vector 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) ->Arg(64) ->Arg(1024) @@ -61,4 +143,13 @@ BENCHMARK_TEMPLATE(BM_SPSC_Throughput, shovy::RingBuffer) ->Repetitions(5) ->ReportAggregatesOnly(true); +BENCHMARK_TEMPLATE(BM_SPSC_BatchThroughput, shovy::RingBuffer) + ->Args({4096, 8}) + ->Args({4096, 32}) + ->Args({4096, 64}) + ->Args({4096, 256}) + ->UseRealTime() + ->Repetitions(5) + ->ReportAggregatesOnly(true); + BENCHMARK_MAIN(); \ No newline at end of file diff --git a/docs/benchmark_report.md b/docs/benchmark_report.md index 193d4d7..5e401fa 100644 --- a/docs/benchmark_report.md +++ b/docs/benchmark_report.md @@ -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. --- diff --git a/include/spsc/ring_buffer.hpp b/include/spsc/ring_buffer.hpp index e8f155b..f2aadeb 100644 --- a/include/spsc/ring_buffer.hpp +++ b/include/spsc/ring_buffer.hpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include namespace shovy{ @@ -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); } diff --git a/tests/ring_buffer_test.cpp b/tests/ring_buffer_test.cpp index 825ad53..acb9b27 100644 --- a/tests/ring_buffer_test.cpp +++ b/tests/ring_buffer_test.cpp @@ -85,3 +85,67 @@ TEST(RingBufferTest, MultiThreadDataRaceDemonstration){ } } + +TEST(RingBufferTest, BasicBatchOperations){ + shovy::RingBuffer 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 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 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 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()); +}