DSA DSA In Depth
0%
lesson unrolled-listsgap-buffertext-editors

Unrolled Linked Lists & Gap Buffers: Defeating Pointer Chasing

7 min read Module 1.3: Linked Structures, Monotonic Stacks/Queues & Cache-Conscious Lists Updated Oct 23, 2026
📄 Cheat Sheet
Why Linked Lists vs Contiguous Arrays Isn't a Real Choice (CPU Cache Locality)
SimonDev Why Linked Lists vs Contiguous Arrays Isn't a Real Choice (CPU Cache Locality) Watch on YouTube ↗

Deep-dive lecture covering Unrolled Linked Lists & Gap Buffers: Defeating Pointer Chasing.

0:00 / --:--

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!
Architectural Invariant: Gap Buffers vs. Ropes / Piece Tables in Text Editors

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 decrements gap_start in $O(1)$ worst-case time with 100% contiguous array locality! Moving the cursor by $k$ characters shifts $k$ bytes across the gap via std::memmove in $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 Linked List Node: Exact 64-Byte CPU Cache Line (B = 11 x int32_t + 20B Metadata) prev (8B) next (8B) cnt (4B) elements[0 .. 10]: Eleven Contiguous int32_t (44 Bytes) --> 1 Cache Miss Loads 11 Elements; Pointer Overhead Drops from 400% to 36%! Gap Buffer Layout: O(1) Cursor Insertions & O(1) Branch-Offset Random Access Prefix [0 .. gap_start) FREE GAP [gap_start .. gap_end) ^ Cursor Inserts Here in O(1) Suffix [gap_end .. cap) Logical Index i Lookup: buf[i + (i >= gap_start ? (gap_end - gap_start) : 0)] --> O(1) time!

Unrolled Node Overflow & Half-Split Lifecycle

flowchart LR A["Insert val into Node u
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!"]
Hardware Reality: Why Moving 32 Bytes Inside a Cache Line Is Virtually Free

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).

Unrolled Linked List Cache-Block Split & Rope Weight-Balanced Tree
O(N/B) Scan · O(log N) Rope Split

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.

Step 1 / 7
Custom Input:
Timeline

Invariant & State

Rope Weight Navigation: Each internal node stores weight = length(leftSubtree). For index i = 8 ≥ weight(6), go right and subtract i ← i - 6 = 2.
Root (leftWeight = 6) Leaf: "Hello_" (len=6) Leaf: "World!" (i'=8-6=2 → 'r')
Query Index
i = 8 ("Hello_World!"[8])
Root Comparison
8 ≥ leftWeight(6) ⇒ Go Right (i=2)
Concat / Split Cost
O(1) / O(log N)
[Step 1] Navigated Rope tree: i=8 ≥ 6 branched right with adjusted offset i=2, landing on "World!"[2] = 'r'.

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];
  }
};
Production Pitfall: Using memcpy Instead of memmove When Sliding the Gap

When 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.

Check yourself Question 1 of 4
Concept Check Unrolled Linked List Block Capacity Sizing recall

You are designing an unrolled linked list on x86-64 (64-byte cache lines) to store `uint64_t` elements. Each node has an 8-byte `next` pointer, a 4-byte `count`, and a 4-byte flags field (16 bytes of metadata total). How many `uint64_t` elements fit in a single 64-byte cache-line-sized node?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

What primary architectural advantage does a B-tree Piece Table hold over a classic node-per-substring Rope?

Key Takeaways

Unrolled linked lists pack B elements into each 64-byte cache-line node, cutting pointer overhead by B times and reducing sequential scan cache misses from Theta(N) to Theta(N / B).
Splitting full unrolled nodes into two half-full nodes (B/2 elements each) guarantees at least 50% memory utilization and O(1) intra-cache-line insertion.
A Gap Buffer places spare array capacity directly at the active cursor [gap_start .. gap_end), enabling O(1) cursor insertions/deletions and O(1) random access via buf[i + (i >= gap_start ? gap_len : 0)].
For large multi-megabyte documents with non-local edits or multi-cursor editing, Ropes and B-Tree Piece Tables provide O(log N) splits and inserts at arbitrary offsets.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue