Algorithm Search⌘K

Cache-oblivious B-tree in Python

Dynamic search tree that matches B-tree I/O bounds without knowing B, built from a van Emde Boas layout and a packed memory array.

File
cache_oblivious_b_tree.py, 258 lines
Level
I0 not checked yet
Toolchain
Python 3.14, pytest 9
Test framework
pytest
Run the tests
python3 -m pytest
cache_oblivious_b_tree.pyRawGitHub
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/cache-oblivious-b-tree/. The authors are listed in prov/KRJ5HP.jsonl.

from typing import Any, Generic, Iterator, Optional, Protocol, TypeVar


class Ordered(Protocol):
    def __lt__(self, other: Any, /) -> bool: ...


K = TypeVar("K", bound=Ordered)

# density thresholds of the packed memory array: upper from TAU_LEAF at the
# segments to TAU_ROOT at the whole array, lower from RHO_LEAF to RHO_ROOT
TAU_LEAF, TAU_ROOT = 1.0, 0.5
RHO_LEAF, RHO_ROOT = 1 / 16, 1 / 8
MIN_CAPACITY = 8


def veb_pos(h: int, d: int, p: int) -> int:
    """Position of the node (d, p) in the van Emde Boas layout of a complete
    binary tree with h levels; p holds the d turns from the root, 0 for left."""
    if h == 1:
        return 0
    ht = h // 2
    hb = h - ht
    if d < ht:
        return veb_pos(ht, d, p)
    j = p >> (d - ht)
    rest = p & ((1 << (d - ht)) - 1)
    return (1 << ht) - 1 + j * ((1 << hb) - 1) + veb_pos(hb, d - ht, rest)


class CacheObliviousBTree(Generic[K]):
    """An ordered set of distinct keys: a packed memory array (PMA) of keys
    with gaps, and above it a static binary index tree in the van Emde Boas
    layout with one leaf per segment of the PMA.

    Every index node stores the smallest key in the segments below it, or
    None if they are empty. A search walks down the index to a segment and
    scans it. An update changes one window of the PMA and then fixes the
    index nodes above the segments of that window; when the PMA is resized,
    the index is built again.
    """

    def __init__(self) -> None:
        self.n = 0
        self.moves = 0
        self._layout(MIN_CAPACITY)
        self.slots: list[Optional[K]] = [None] * self.cap
        self._build_index()

    # layout of the PMA and of the index -----------------------------------

    def _layout(self, cap: int) -> None:
        self.cap = cap
        lg = cap.bit_length() - 1
        s = 1
        while 2 * s <= lg:
            s *= 2
        self.S = max(2, s)  # segment size, a power of two at most lg(cap)
        self.H = (cap // self.S).bit_length() - 1  # the index has H + 1 levels

    def tau(self, i: int) -> float:
        if self.H == 0:
            return TAU_ROOT
        return TAU_LEAF - (TAU_LEAF - TAU_ROOT) * i / self.H

    def rho(self, i: int) -> float:
        if self.H == 0:
            return RHO_ROOT
        return RHO_LEAF + (RHO_ROOT - RHO_LEAF) * i / self.H

    def _build_index(self) -> None:
        h = self.H + 1
        self.pos = [[veb_pos(h, d, p) for p in range(1 << d)] for d in range(h)]
        self.index: list[Optional[K]] = [None] * ((1 << h) - 1)
        self._update(0, (1 << self.H) - 1)  # @line L14

    def _seg_min(self, s: int) -> Optional[K]:
        for x in self.slots[s * self.S : (s + 1) * self.S]:
            if x is not None:
                return x
        return None

    def _update(self, s0: int, s1: int) -> None:
        """Recompute the index nodes above the segments s0..s1 (inclusive)."""
        H = self.H
        for s in range(s0, s1 + 1):  # @line L11
            self.index[self.pos[H][s]] = self._seg_min(s)
        for d in range(H - 1, -1, -1):  # @line L12
            for p in range(s0 >> (H - d), (s1 >> (H - d)) + 1):  # @line L13
                left = self.index[self.pos[d + 1][2 * p]]
                right = self.index[self.pos[d + 1][2 * p + 1]]
                self.index[self.pos[d][p]] = left if left is not None else right

    # searching ------------------------------------------------------------

    def _search(self, k: K, touched: Optional[list[int]] = None) -> tuple[int, int]:
        """Return (s, p): the segment s reached in the index, and the position
        p of the largest key <= k, or -1 if there is none. If `touched` is a
        list, the cells read are added to it: an index position q as -1 - q,
        and a PMA position as itself."""
        d, p = 0, 0  # @line L1
        while d < self.H:  # @line L2
            right = self.index[self.pos[d + 1][2 * p + 1]]
            if touched is not None:
                touched.append(-1 - self.pos[d + 1][2 * p + 1])
            if right is not None and not (k < right):  # @line L3
                p = 2 * p + 1
            else:
                p = 2 * p
            d += 1
        best = -1
        a = p * self.S
        for j in range(a, a + self.S):  # @line L4
            if touched is not None:
                touched.append(j)
            x = self.slots[j]
            if x is None:
                continue
            if k < x:
                break
            best = j
        return p, best

    def predecessor(self, k: K) -> Optional[K]:
        """The largest key <= k, or None."""
        _, p = self._search(k)
        return self.slots[p] if p >= 0 else None

    def __contains__(self, k: K) -> bool:
        x = self.predecessor(k)
        return x is not None and not (x < k)

    def range(self, lo: K, hi: K) -> list[K]:
        """The keys with lo <= key <= hi: one search, then one scan."""
        _, p = self._search(lo)
        start = p if p >= 0 and not (self.slots[p] < lo) else p + 1  # type: ignore[operator]
        out: list[K] = []
        for x in self.slots[start:]:
            if x is None:
                continue
            if hi < x:
                break
            out.append(x)
        return out

    def blocks_touched(self, k: K, block: int) -> int:
        """Blocks of `block` cells read by a search for k, with the index and
        the PMA stored as two arrays that each start at a block boundary."""
        touched: list[int] = []
        self._search(k, touched)
        return len({(t < 0, (-1 - t if t < 0 else t) // block) for t in touched})

    # updates --------------------------------------------------------------

    def _window(self, pos: int, i: int) -> tuple[int, int]:
        size = self.S << i
        start = (pos // size) * size
        return start, start + size

    def _count(self, a: int, b: int) -> int:
        return sum(1 for x in self.slots[a:b] if x is not None)

    def insert(self, k: K) -> None:
        """Insert k; inserting a present key changes nothing."""
        s, p = self._search(k)  # @line L5
        if p >= 0 and not (self.slots[p] < k):  # type: ignore[operator]
            return
        here = max(p, 0)
        self.n += 1
        for i in range(self.H + 1):  # @line L6
            a, b = self._window(here, i)
            if (self._count(a, b) + 1) / (b - a) <= self.tau(i):
                self._rebalance(a, b, k)
                self._update(a // self.S, (b - 1) // self.S)  # @line L8
                return
        self._resize(k)  # @line L7

    def delete(self, k: K) -> None:
        """Delete k; deleting an absent key changes nothing."""
        _, p = self._search(k)  # @line L9
        if p < 0 or self.slots[p] < k:  # type: ignore[operator]
            return
        self.slots[p] = None
        self.n -= 1
        for i in range(self.H + 1):
            a, b = self._window(p, i)
            if self._count(a, b) / (b - a) >= self.rho(i) or self.cap == MIN_CAPACITY:
                if i > 0:
                    self._rebalance(a, b, None)
                self._update(a // self.S, (b - 1) // self.S)  # @line L10
                return
        self._resize(None)

    def _rebalance(self, a: int, b: int, extra: Optional[K]) -> None:
        items = [x for x in self.slots[a:b] if x is not None]
        if extra is not None:
            j = 0
            while j < len(items) and items[j] < extra:  # type: ignore[operator]
                j += 1
            items.insert(j, extra)
        self._spread(a, b, items)

    def _spread(self, a: int, b: int, items: list[K]) -> None:
        w, m = b - a, len(items)
        new: list[Optional[K]] = [None] * w
        for t, x in enumerate(items):
            new[t * w // m] = x
        old = self.slots[a:b]
        for t in range(w):
            if new[t] is not None and new[t] is not old[t]:
                self.moves += 1
        self.slots[a:b] = new

    def _resize(self, extra: Optional[K]) -> None:
        items = [x for x in self.slots if x is not None]
        if extra is not None:
            j = 0
            while j < len(items) and items[j] < extra:  # type: ignore[operator]
                j += 1
            items.insert(j, extra)
        cap = MIN_CAPACITY
        while cap < 2 * 2**0.5 * len(items):  # density in (0.177, 0.354]
            cap *= 2
        self._layout(cap)
        self.slots = [None] * cap
        if items:
            self._spread(0, cap, items)
        self._build_index()

    # inspection -----------------------------------------------------------

    def __len__(self) -> int:
        return self.n

    def __iter__(self) -> Iterator[K]:
        return iter([x for x in self.slots if x is not None])

    def check_invariant(self) -> None:
        """Raise AssertionError if a property of the PMA or the index fails."""
        keys = list(self)
        assert len(keys) == self.n
        assert all(a < b for a, b in zip(keys, keys[1:]))
        assert self.cap & (self.cap - 1) == 0 and self.cap % self.S == 0
        assert self.cap // self.S == 1 << self.H
        H = self.H
        positions = sorted(q for row in self.pos for q in row)
        assert positions == list(range((1 << (H + 1)) - 1))  # vEB is a bijection
        for s in range(1 << H):
            assert self.index[self.pos[H][s]] == self._seg_min(s)
        for d in range(H):
            for p in range(1 << d):
                left = self.index[self.pos[d + 1][2 * p]]
                right = self.index[self.pos[d + 1][2 * p + 1]]
                want = left if left is not None else right
                assert self.index[self.pos[d][p]] == want
test_cache_oblivious_b_tree.py 84 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/cache-oblivious-b-tree/. The authors are listed in prov/KRJ5HP.jsonl.

import math
import random

from cache_oblivious_b_tree import CacheObliviousBTree, veb_pos


def show(t: CacheObliviousBTree[int]) -> str:
    return " ".join("_" if x is None else str(x) for x in t.slots)


def test_example() -> None:
    t: CacheObliviousBTree[int] = CacheObliviousBTree()
    for k in [50, 20, 80, 10, 60, 30, 70, 40]:
        t.insert(k)
        t.check_invariant()
    assert (t.cap, t.S, t.H) == (32, 4, 3)
    assert show(t).startswith("10 _ _ _ 20 _ _ _ 30 _ 40 _ _ 50 _ _ _ _ 60")
    # the index in van Emde Boas order: root, its two children, then the
    # four bottom trees of three nodes each
    assert t.index == [10, 10, 60, 10, 10, 20, 30, 30, 50, 60, 60, 70, 80, 80, None]
    assert t.pos[3] == [4, 5, 7, 8, 10, 11, 13, 14]
    touched: list[int] = []
    s, p = t._search(45, touched)
    assert [-1 - q for q in touched if q < 0] == [2, 6, 8]  # mins 60, 30, 50
    assert s == 2 and t.slots[p] == 40
    assert t.predecessor(45) == 40 and t.predecessor(5) is None
    assert t.range(15, 60) == [20, 30, 40, 50, 60]
    t.delete(30)
    t.check_invariant()
    assert 30 not in t and t.index[t.pos[3][2]] == 40


def test_edges() -> None:
    t: CacheObliviousBTree[int] = CacheObliviousBTree()
    assert t.predecessor(3) is None and 3 not in t and t.range(0, 9) == []
    t.delete(3)
    t.insert(3)
    t.insert(3)
    assert list(t) == [3] and t.predecessor(100) == 3
    t.delete(3)
    assert list(t) == [] and len(t) == 0
    t.check_invariant()
    assert veb_pos(4, 3, 5) == 11


def test_against_set() -> None:
    rng = random.Random(41)
    for _ in range(500):
        t: CacheObliviousBTree[int] = CacheObliviousBTree()
        ref: set[int] = set()
        for i in range(rng.randrange(1, 250)):
            k = rng.randrange(150)
            r = rng.random()
            if r < 0.35:
                t.delete(k)
                ref.discard(k)
            elif r < 0.85:
                t.insert(k)
                ref.add(k)
            else:
                lo, hi = sorted((k, rng.randrange(150)))
                assert t.range(lo, hi) == sorted(x for x in ref if lo <= x <= hi)
            below = [x for x in ref if x <= k]
            assert t.predecessor(k) == (max(below) if below else None)
            if i % 23 == 0:
                t.check_invariant()
        t.check_invariant()
        assert list(t) == sorted(ref)


def test_search_blocks() -> None:
    N = 4096
    t: CacheObliviousBTree[int] = CacheObliviousBTree()
    for k in random.Random(5).sample(range(10 * N), N):
        t.insert(k)
    t.check_invariant()
    queries = random.Random(6).sample(range(10 * N), 200)
    for B in (4, 16, 64, 256):
        worst = max(t.blocks_touched(q, B) for q in queries)
        assert worst <= 4 * math.log(t.cap, B) + 4
    assert t.moves <= N * math.log2(N) ** 2 / 2

What the next level needs

  • the tests did not run in this build

Read the full page about Cache-oblivious B-tree