Ring Buffer Indexing: Power-of-Two Masking & Full vs. Empty Disambiguation
Why do production ring buffers (Rust's std::collections::VecDeque<T>, Java's ArrayDeque<E>, Linux kernel kfifo, and LMAX Disruptor) enforce that capacity $C$ is always a power of two ($C = 2^p$)? Because wrapping an index modulo $C$ normally requires an integer division instruction (div on x86-64 takes 14–26 clock cycles and cannot be pipelined), whereas when $C = 2^p$, modulo reduces to a 1-cycle bitwise AND with $M = C - 1$:
$$\text{physical\_index}(i) = (\text{head} + i) \bmod 2^p = (\text{head} + i) \mathrel{\&} (C - 1)$$
How do we distinguish a completely full ring buffer from a completely empty ring buffer when both states have $\text{head} \equiv \text{tail} \pmod C$? There are two canonical engineering designs:
- Explicit
head_+size_(RustVecDequestyle): Store the physical indexhead_$\in [0, C)$ and element countsize_$\in [0, C]$. The tail index is computed on the fly as(head_ + size_) & (cap_ - 1),Empty issize_ == 0, and Full issize_ == cap_. - Unmasked Monotonic
read_idx_&write_idx_(Linuxkfifostyle): Store monotonically increasing 64-bit unsigned countersread_idx_andwrite_idx_that are only masked with& (cap_ - 1)at the moment of array indexing! Thensize() == write_idx_ - read_idx_, Empty iswrite_idx_ == read_idx_, and Full iswrite_idx_ - read_idx_ == cap_(critical for lock-free Single-Producer Single-Consumer queues).
When a circular deque is full (size_ == cap_) and grows to $2C$, we cannot simply memcpy the underlying array of length $C$ directly into the new buffer if the logical sequence wraps around the physical boundary! Specifically, if $\text{head} > 0$, the logical elements $[0 \dots C-1]$ are physically split into two contiguous spans:
- First Span (Physical Suffix):
data_[head_ .. C - 1]of length $L_1 = C - \text{head}_-$ moves tonew_data[0 .. L_1 - 1]. - Second Span (Physical Prefix):
data_[0 .. head_ - 1]of length $L_2 = \text{head}_-$ moves tonew_data[L_1 .. C - 1].
After copying both spans, reset head_ = 0 in the new buffer so the resized deque starts completely linearized!
Architectural Comparison of Deque Implementations
| Implementation | push_front / pop_front |
Random Access d[i] |
Cache Misses per Scan | Pointer Stability on Push? |
|---|---|---|---|---|
FlatCircularDeque (Rust VecDeque / Java ArrayDeque) |
$O(1)$ amortized | $O(1)$ via (head + i) & mask |
Minimum ($1$ contiguous buffer) | No (invalidated on $2\times$ resize) |
Segmented Map Deque (C++ std::deque) |
$O(1)$ amortized | $O(1)$ (double dereference: map $ o$ chunk) | Higher (512B–4KB chunks scattered in heap) | Yes (for push_front / push_back) |
Doubly Linked List (std::list) |
$O(1)$ worst-case | $O(N)$ pointer chase | Catastrophic (1 miss per element) | Yes (all iterators stable) |
Visualizing Wrapped Physical Spans & Unwrapping Upon $2\times$ Growth
Circular Deque Operations & Two-Span Reallocation Flow
Move Span 1 [head..cap)
Move Span 2 [0..head)
Reset head_ = 0"] B -- "No" --> D["push_front: head_ = (head_ - 1) & mask
push_back: idx = (head_ + size_) & mask"] C --> D
VecDeque Beats C++ libstdc++ std::deque in Cache SpeedBecause the C++ standard mandates that std::deque::push_back and push_front must never invalidate references to existing elements, std::deque cannot use a single resizable ring buffer. Instead, it uses a pointer-array directory ("map") pointing to fixed-size heap chunks (512 bytes in libstdc++, or absurdly just 16 bytes / 1 element in MSVC!). Every random access dq[i] executes a double pointer dereference, and iterating across chunk boundaries stalls hardware stride prefetchers. A single-buffer power-of-two ring deque (like Rust's VecDeque or the CircularDeque below) keeps all elements in one contiguous allocation, running $2\times$ to $5\times$ faster for sliding-window and BFS workloads.
Step through push(x) operations as capacity doubles (1 → 2 → 4 → 8 → 16). Watch Tarjan’s potential function Φ = 2n − m bank 2 credits on cheap inserts to pay for O(n) reallocation copies.
Invariant & State
m = 1 | Size n = 1 | Load Factor α = 100%
Production Implementation: Power-of-Two Dynamic Circular Deque
#include <cassert>
#include <cstddef>
#include <memory>
#include <new>
#include <utility>
template <typename T>
class CircularDeque {
T* data_ = nullptr;
std::size_t head_ = 0;
std::size_t size_ = 0;
std::size_t cap_ = 0; // Always a power of 2 when allocated (0 initially)
std::size_t mask() const noexcept { return cap_ - 1; }
void grow() {
const std::size_t new_cap = (cap_ == 0) ? 8 : (cap_ * 2);
T* new_data = static_cast<T*>(::operator new(new_cap * sizeof(T), std::align_val_t{alignof(T)}));
if (size_ > 0) {
// Unwrap the two physical spans [head_ .. cap_) and [0 .. head_) into [0 .. size_)
const std::size_t span1 = cap_ - head_;
if (span1 >= size_) {
std::uninitialized_move_n(data_ + head_, size_, new_data);
} else {
std::uninitialized_move_n(data_ + head_, span1, new_data);
std::uninitialized_move_n(data_, size_ - span1, new_data + span1);
}
for (std::size_t i = 0; i < size_; ++i) {
std::destroy_at(data_ + ((head_ + i) & mask()));
}
}
if (data_) {
::operator delete(data_, std::align_val_t{alignof(T)});
}
data_ = new_data;
head_ = 0;
cap_ = new_cap;
}
public:
CircularDeque() = default;
~CircularDeque() {
for (std::size_t i = 0; i < size_; ++i) {
std::destroy_at(data_ + ((head_ + i) & mask()));
}
if (data_) ::operator delete(data_, std::align_val_t{alignof(T)});
}
void push_back(T value) {
if (size_ == cap_) grow();
const std::size_t tail = (head_ + size_) & mask();
std::construct_at(data_ + tail, std::move(value));
++size_;
}
void push_front(T value) {
if (size_ == cap_) grow();
// Unsigned wrap-around: (0 - 1) & (cap_ - 1) == cap_ - 1!
head_ = (head_ - 1) & mask();
std::construct_at(data_ + head_, std::move(value));
++size_;
}
void pop_front() noexcept {
assert(size_ > 0);
std::destroy_at(data_ + head_);
head_ = (head_ + 1) & mask();
--size_;
}
void pop_back() noexcept {
assert(size_ > 0);
--size_;
const std::size_t tail = (head_ + size_) & mask();
std::destroy_at(data_ + tail);
}
T& operator[](std::size_t i) noexcept {
return data_[(head_ + i) & mask()];
}
const T& operator[](std::size_t i) const noexcept {
return data_[(head_ + i) & mask()];
}
std::size_t size() const noexcept { return size_; }
std::size_t capacity() const noexcept { return cap_; }
};
Two catastrophic bugs plague custom circular buffers:
- Using
& (cap - 1)whencapis not a power of two: Bitwise ANDx & (cap - 1)equalsx % capif and only ifcapis an exact power of 2 ($2^p$). If you grow capacity by $1.5\times$ (e.g., $C = 12, C - 1 = 11 = ext{0b1011}$), bit 2 is $0$, so(head + i) & 11silently skips indices $4, 5, 6, 7$ and overwrites existing elements! - Copying
data_[0 .. size_]without unwrapping on resize: Whenhead_ > 0and the buffer wraps around, doubling capacity changes the modulus from $C$ to $2C$. If you leave elements at their old physical indices without unwrapping Span 1 and Span 2 into contiguous order,(head_ + i) & (2C - 1)will read uninitialized garbage slots in $[C, C + \text{head}_-)$.