Defeating the Pointer Tax: Unrolled Linked Lists
Consider storing 4-byte int32_t integers in a 64-bit doubly linked list (std::list<int32_t>). Each node contains 8B prev + 8B next + 4B val + 4B alignment padding + 8B–16B malloc chunk header = 32 to 40 bytes per 4-byte integer ($800\% - 1000\%$ memory bloat!), and traversing $N$ elements incurs $N$ dependent pointer-chasing cache misses.
An Unrolled Linked List replaces the single element in each node with a small inline array T elements[B] and an occupancy counter uint16_t count. When we size the node to match a single 64-byte CPU cache line (e.g., 8B prev + 8B next + 4B count + eleven 4-byte integers = 64 bytes):
- Cache Miss Reduction: Fetching a single node into L1d brings in up to $B$ consecutive elements simultaneously, reducing cache misses during sequential traversal from $N$ to $\approx N / B$.
- B-Tree Split/Merge Occupancy Invariant: Every interior node maintains occupancy $count \in [\lceil B / 2 \rceil, B]$. Inserting into a full node ($count == B$) allocates a new sibling node and moves the upper $B / 2$ elements into it in a single cache-line register write ($O(B) = O(1)$ time), guaranteeing at least $50\%$ space utilization across all nodes!
Why does GNU Emacs use a Gap Buffer while Zed and Xi-Editor use a Rope (or B-Tree Piece Table in VS Code)?
- Gap Buffer: Stores the document as a single contiguous array with an empty "gap"
[gap_start .. gap_end)located right at the user's cursor:[Prefix Text: 0 .. gap_start) [ FREE GAP ] [Suffix Text: gap_end .. cap). Typing or backspacing at the cursor simply increments or decrementsgap_startin $O(1)$ worst-case time with 100% contiguous array locality! Moving the cursor by $k$ characters shifts $k$ bytes across the gap viastd::memmovein $O(k)$ time. - Rope (Weight-Balanced Tree of String Leaves): Represents a large document as a balanced tree where each leaf holds a short flat string chunk (e.g., 512–1024 bytes) and each internal node caches the total byte/UTF-8/line length of its left subtree. Splitting, concatenating, or inserting anywhere in a 1 GB file takes $O(\log N)$ time without ever moving megabytes of memory!
Sequence Data Structure Trade-Off Matrix
| Data Structure | Index Lookup $A[i]$ | Insert at Cursor | Move Cursor by $\Delta$ | Cache Misses (Scan $N$) | Production Adopters |
|---|---|---|---|---|---|
| Doubly Linked List | $O(N)$ | $O(1)$ | $O(\Delta)$ chases | $\Theta(N)$ | Intrusive kernel queues only |
| Unrolled Linked List (Block $B$) | $O(N / B)$ | $O(B)$ | $O(1 + \Delta / B)$ | $\Theta(N / B)$ | SkipList/B+Tree leaves, Slab queues |
| Gap Buffer | $O(1)$ (1 branch: $i < \text{gap\_start}$) | $O(1)$ amortized | $O(\Delta)$ (memmove) |
$\Theta(N / L_{\text{line}})$ (Optimal) | GNU Emacs, Scintilla (Notepad++) |
| Rope / B-Tree Piece Table | $O(\log N)$ | $O(\log N)$ | $O(\log N)$ | $\Theta(N / B_{\text{leaf}})$ | VS Code (Piece Tree), Zed (SumTree Rope) |
Visualizing Cache-Line-Sized Unrolled Nodes & The Gap Buffer Split
Unrolled Node Overflow & Half-Split Lifecycle
at local index idx"] --> B{"u.count == B?
(Node Full)"} B -- "No (< B)" --> C["memmove u.elems[idx..count]
Write val, u.count++"] B -- "Yes (== B)" --> D["Allocate sibling Node v
Move upper B/2 elems to v
Link u <-> v"] D --> E["Insert val into u or v
Both nodes >= B/2 full!"]
Beginners often worry that inserting into the middle of an Unrolled Linked List node (or shifting a local Gap Buffer) requires shifting up to $B$ elements. On modern CPUs, an entire 64-byte cache line fits inside a single 512-bit ZMM register (or two 256-bit YMM registers). Shifting 32–44 bytes within an already-fetched L1 cache line executes in 1 to 2 clock cycles (~0.5 nanoseconds), whereas chasing a single pointer to a cold heap node in DRAM costs ~200 clock cycles (~70 nanoseconds). Trading intra-cache-line shifts for fewer pointer hops is the fundamental design law of modern in-memory data structures (B+Trees, SwissTable, Abseil inlined_vector, Unrolled Lists).
Visualize cache-line-aligned Unrolled Linked List node splits (B elements per block) and Rope binary tree weight-guided index(i), split(k), and concat operations.
Invariant & State
Production Implementation: Generic Gap Buffer Engine
#include <algorithm>
#include <cassert>
#include <cstddef>
#include <cstring>
#include <vector>
class GapBuffer {
std::vector<char> buf_;
std::size_t gap_start_ = 0;
std::size_t gap_end_;
std::size_t gap_size() const noexcept { return gap_end_ - gap_start_; }
void expand_gap(std::size_t min_gap) {
const std::size_t old_size = size();
const std::size_t new_cap = std::max(buf_.size() * 2, old_size + min_gap + 64);
std::vector<char> next_buf(new_cap);
// Copy prefix [0 .. gap_start_) to start of next_buf
std::memcpy(next_buf.data(), buf_.data(), gap_start_);
// Copy suffix [gap_end_ .. buf_.size()) to end of next_buf
const std::size_t suffix_len = buf_.size() - gap_end_;
const std::size_t new_gap_end = new_cap - suffix_len;
std::memcpy(next_buf.data() + new_gap_end, buf_.data() + gap_end_, suffix_len);
buf_ = std::move(next_buf);
gap_end_ = new_gap_end;
}
public:
explicit GapBuffer(std::size_t initial_cap = 64)
: buf_(initial_cap), gap_start_(0), gap_end_(initial_cap) {}
std::size_t size() const noexcept { return buf_.size() - gap_size(); }
// Move cursor (gap_start_) to logical position pos via overlapping-safe memmove
void move_cursor(std::size_t pos) noexcept {
assert(pos <= size());
if (pos < gap_start_) {
const std::size_t delta = gap_start_ - pos;
std::memmove(buf_.data() + gap_end_ - delta, buf_.data() + pos, delta);
gap_start_ -= delta;
gap_end_ -= delta;
} else if (pos > gap_start_) {
const std::size_t delta = pos - gap_start_;
std::memmove(buf_.data() + gap_start_, buf_.data() + gap_end_, delta);
gap_start_ += delta;
gap_end_ += delta;
}
}
// O(1) amortized insertion at cursor
void insert_char(char c) {
if (gap_start_ == gap_end_) expand_gap(1);
buf_[gap_start_++] = c;
}
// O(1) backspace deletion before cursor
void backspace() noexcept {
if (gap_start_ > 0) --gap_start_;
}
// O(1) branchless random access to logical index i
char operator[](std::size_t i) const noexcept {
const std::size_t offset = (i >= gap_start_) ? gap_size() : 0;
return buf_[i + offset];
}
};
memcpy Instead of memmove When Sliding the GapWhen moving the cursor in a Gap Buffer by a small distance $\Delta < \text{gap\_size()}$, the source range and destination range do not overlap; however, when $\Delta > \text{gap\_size()}$, the source range [pos .. gap_start_) and destination range [gap_end_ - delta .. gap_end_) physically overlap in memory! Calling std::memcpy on overlapping memory regions is Undefined Behavior in C/C++ and corrupts text when vectorized AVX backwards/forwards copies overwrite bytes before reading them. Always use std::memmove when sliding a gap buffer or shifting elements inside an unrolled list node.