Masstree
Paper: Mao, Kohler, and Morris, Cache Craftiness for Fast Multicore Key-Value Storage (EuroSys 2012).
Around 2010: the database fits in memory
- Cheaper, larger RAM lets more databases fit in memory. Systems such as H-Store/VoltDB, Redis, and memcached explore this opportunity
- Without disk reads, CPU work, cache misses, and synchronization become the bottlenecks
- Masstree (2012): an ordered index in shared memory, accessed by many cores. Last lecture’s B-link tree assumed atomic, private page reads; that assumption is gone
In memory does not mean equally fast
- The CPU still has a hierarchy between its execution units and DRAM (illustrative modern x86 sizes and latencies; hardware-dependent)
- L1 data: ~48 KB per core, close to the core — ~1 ns
- L2: ~1–2 MB per core, usually private — ~5 ns
- L3: ~32 MB shared among cores on a chip or chiplet — ~15 ns
- DRAM: much larger, off chip — ~100 ns, ~300 CPU cycles
- Fetching from memory to cache: cache line, 64 bytes.
- Cache misses means CPU is empty waiting for that 300 cycles.
The job
- One server, many cores; all keys and values fit in DRAM
get(k),put(k, v),remove(k),iterate(k, n)- arbitrary-length binary keys and values
- many keys with long common prefixes
- e.g., reverse-domain URLs:
edu.harvard.seas.www/... - db table names, like “user_tbl/alice”
- e.g., reverse-domain URLs:
- Two problems to solve:
- a single lookup spends too long waiting for memory
- many lookups interfere with each other, even when they only want to read
Strawman 1: a hash table
- Good point lookups. Compute a hash, go almost directly to the bucket
- But a hash destroys key order. Finding the next 100 keys after k now needs another index or a scan
- We asked for range queries. Keep an ordered index
- Remember this choice at evaluation time: a faster hash table does not implement the same interface
Strawman 2: the B-tree we already have, in RAM
- It works, with suitable synchronization. But the cost model changed
- On disk: minimize page I/Os. A large node amortizes seek time over many keys
- In memory: minimize dependent cache misses
- CPU asks for a cache line, waits for DRAM, then learns the next address
- a pointer chase cannot fetch the next node until it has examined this one
- hot upper nodes may be cached; lower levels of a large tree usually are not
read root → choose child → read child → choose child → read leaf
next address unknown until the previous read finishes
- A useful cost model:
- lookup time ≈ memory latency * tree height * work within node
- A disk-sized B-tree node goes too far the other way: too many cache lines within a node
- multicore throughput also depends on memory bandwidth and cache-line ownership transfers
- Let us reduce the node size to make fewer trips to memory per node?
- A balanced binary tree on roughly 1 million keys has about 20 levels
- What should an in-memory B-tree node look like?
Fewer trips, more work per trip
- Put several separators and child pointers together
- wider fanout → fewer levels
- comparisons within a fetched node are much cheaper than another DRAM round trip
- Put the bytes needed for routing in the node
- an array of pointers to separately allocated strings adds another pointer chase per comparison
- start by storing a short piece of each key directly
- A node can span several cache lines. Fetch those lines together
- as soon as we know the node’s address, issue software prefetches for its lines
- the addresses within one node are known, so their fetches can overlap
- the next child address is still unknown. Prefetching does not remove the dependency between levels
- Why not make the node enormous?
- more bandwidth, more cache pollution, more comparisons, and limited outstanding memory requests
- a wider node only helps if the time saved by fewer levels exceeds these costs
- The paper’s tuning point: width 15, with a target of about four 64-byte cache lines of node data
- 15 key slots; an interior node can have 16 children
- metadata and key suffix storage also consume space
- an empirical compromise on their hardware, not a universal best node size
- So far: a small, wide B+ tree, with inline key pieces and prefetching
Long keys spoil the plan
- Suppose keys share a 64-byte prefix and differ near the end
- Ordinary string comparisons keep checking that same prefix
- at the root, at the next level, several times inside each node
- storing only the first few bytes inline does not help distinguish these keys
- following pointers to the rest of the string brings back the memory stalls
- A byte-at-a-time trie avoids repeated prefix comparisons
- consume one byte, follow an edge, consume the next byte
- but a long prefix becomes a long chain of nodes
- an array of 256 child pointers per byte can waste a lot of memory
- We want the trie’s habit of consuming a prefix once, with the B-tree’s compact, balanced search among alternatives
Consume eight bytes at a time
- Slice the key into 8-byte pieces
- At each trie position, use a B+ tree to index the next slice
- the B+ tree stores only slices that actually occur; no enormous array of all possible 64-bit values
- if keys agree on this slice, descend to another B+ tree for the next slice
- That is Masstree: a trie whose nodes are B+ trees
- layer: which 8-byte slice we are examining
- level: how far down one of those B+ trees we are
- a layer can contain many B+ trees, one per prefix. A single B+ tree can have several levels
- The leaves are called border nodes: their entries can lead to values or to the next layer
Build one by hand
- Insert
user0001/alice- layer 0 indexes
user0001; store the remaining/aliceas a suffix alongside its value - no reason to allocate another tree for one key
- layer 0 indexes
- Insert
user0001/bob- same first slice, different suffix. Now we need a second layer
- replace the suffix/value entry by a pointer to a new B+ tree holding both suffixes
- Insert
user0002/alice: different first slice; another entry in layer 0
layer 0: B+ tree on bytes 0..7
... → border node
user0001 ─────────────────────────────┐
user0002 → suffix /alice, value C │
│
v
layer 1, prefix user0001: B+ tree on bytes 8..15
/alice → value A
/bob → value B
get(user0001/bob):- search the layer-0 tree for
user0001 - entry says next layer; consume eight bytes
- search that tree for
/bob, find B
- search the layer-0 tree for
- If the suffixes share another full slice, repeat. Create layers only when needed
- If only one long key occupies a slice, keep its unmatched suffix and compare it at the end
- avoids a chain of mostly empty trees just to represent one key
- Keys in a layer-h tree all share the same first
8hbytes - For keys sharing a long prefix of length P, followed by short distinguishing suffixes:
- ordinary full-string B-tree comparisons can cost O(P log N)
- Masstree consumes the prefix once along the path: O(P + log N) comparison work
- it still traverses the prefix layers. A 64-byte shared prefix does not disappear for free
- general worst case for maximum key length L remains O(L log N); the shape depends on the keys
Why eight bytes?
- A slice fits in a machine word: one integer comparison instead of a string-comparison loop
- Need integer order to match byte-string order
bytes of "ABCDEFGH": 41 42 43 44 45 46 47 48
little-endian integer load: 0x4847464544434241
comparison representation: 0x4142434445464748
- Byte-swap on little-endian machines; compare as an unsigned integer
Multi-thread safety: the assumption from last time breaks
- Node read/write is no longer atomic.
- Writer inserting 20 into a sorted array might move 30 and 40 before installing 20
- Reader can observe keys from one moment, child pointers or values from another
- a right link repairs a stale position, not an inconsistent node
- First repair: give each node a version and a dirty marker
writer, holding node lock: reader:
mark node dirty v1 ← stable version of node
change keys / pointers read the needed fields
increment version v2 ← node version
clear dirty accept only if unchanged and clean
- Dirty marker detects an update in progress; version detects one that finished between the two reads
- The reader may have observed nonsense. It must validate before accepting the result
- including a “not found” result
- Conservative scheme: record versions for the whole path, validate all of them before returning, restart on any change
- correct with the required ordering and memory-lifetime rules, but lots of unnecessary retries
- Can we make common writes safe to observe, so they do not force a retry at all?
Update a value: publish one pointer
- Suppose a put changes an existing value
- If it fits in an atomically writable slot: replace that slot in one aligned write
- For a larger value, especially multiple columns:
- lock the border node against other writers
- allocate and initialize a replacement value object, copying unchanged columns
- atomically publish its pointer in the existing slot
- unlock
- A reader sees either the old object or the new one, never half the column updates
- The node’s key layout did not change. No node-version increment is needed for this update
- But do not immediately free the old object
- a reader may already hold its pointer
- an atomic pointer replacement solves publication, not reclamation
Insert a key: stop moving the old keys
- A conventional leaf keeps physical slots in sorted order
- Inserting in the middle means shifting keys and values. Too many writes to publish atomically
- What actually needs to be sorted? The order in which we search, not the physical slots
- Leave entries where they are. Store a separate permutation describing their sorted order
physical slot: 0 1 2 3
key: 30 10 40 --
logical order: [1, 0, 2] count = 3
insert 20:
fill slot 3: 30 10 40 20
publish order: [1, 3, 0, 2] count = 4
- Reader loads the permutation once, then uses that saved order to examine the slots
- old permutation: does not inspect slot 3
- new permutation: sees the initialized key/value in slot 3
- no intermediate order is visible
- Fifteen slots make this fit in one 64-bit word:
- 4 bits for the count, 15 × 4 bits for slot indexes
- unused indexes identify available slots
- Writer locks the node, fills an unused slot, then publishes the new permutation in one aligned write
- initialization must become visible before the permutation; fences enforce this order
- no key movement, no dirty interval, no version increment for this ordinary insert
- The lock bit and the dirty bits are deliberately different
- lock: another writer must wait
- dirty: a reader cannot trust the layout
- a locked node can still be perfectly safe to read
- This is the permuter. A concurrency optimization comes from changing the data layout
- Two exceptions are still waiting for us: a full node must split, and a previously removed slot cannot always be reused this way
Delete: the slot-reuse trap
- To remove an entry, publish a permutation that omits its slot
- Leave the old slot contents intact: a reader may still be using an old permutation
- Why not immediately treat that slot as an ordinary free slot?
reader get(10) writer
old permutation says 10 is slot 1
remove 10 from permutation
reuse slot 1 for key 99, value V99
read value from slot 1
return V99 for key 10 wrong
- Returning the old value for 10 could be legal: the get overlapped the remove
- Returning 99’s value for 10 is never legal
- Therefore reusing a removed slot must use the dirty/version protocol and change
vinsert- the old reader detects the change and retries
- “inserts never cause retries” was only true for the simple case using an unused slot
- No merging, but empty nodes can be unlinked; empty ancestors can be removed too
- deletion marks
- Unlinked does not mean safe to free
Reclamation: who might still hold a pointer?
- This applies to replaced values, removed nodes, and old suffix storage
- A writer cannot inspect another core’s registers to see whether it still has a pointer
- Epoch-based reclamation:
- threads announce progress through read-side epochs
- removed objects are retired first
- free them only after every reader that could have seen them has passed the relevant epoch
- A reader can finish using an old value while later readers see the replacement
- This is the RCU-like part of the design. Masstree does not copy the whole tree or every node on each write
- values may be replaced by copying; tree nodes are often changed in place
- atomic publication, version validation, and delayed freeing solve different problems
- Readers take no tree locks, but may spin on a dirty node or retry
- no read locks is not a guarantee of bounded completion time
What does a scan promise?
- Search for the first key ≥ k, enumerate entries in permutation order, and walk border-node links
- At a next-layer entry, traverse that subtree in order, then resume the enclosing layer
- every suffix below one prefix sorts together
- layer traversal makes this more involved than walking one flat leaf list
- Individual observations need the same validation and memory-lifetime rules as point lookups
- The paper explicitly does not make
getrangeatomic with respect to concurrent inserts and updates- a scan can combine observations from different times
- a transactionally consistent snapshot needs another mechanism
- Ordered access, atomic updates to one key, and transactions across multiple keys are separate promises
Did the design choices matter?
- Read the experiments as tests of the mechanisms, not a current database ranking
- 2012 hardware/software; up to 16 cores, small keys/values, large memory-resident datasets
- Factor analysis (Figure 8, Section 6.2): start with a concurrent binary tree, add changes cumulatively
- allocator and huge pages also matter: tree traversal is not the only source of memory overhead
- integer comparison, wider nodes, prefetching, then the permuter and trie layers
- generated inside the server: no network or logging in this figure
- Does the tree still matter in a complete server? Section 6.3 turns network and logging on
- 140-million-key workload, 16 cores: about 8.03 M gets/s and 5.78 M puts/s
- 1.90× get and 1.53× put throughput of the optimized binary-tree baseline
- do not mix these absolute numbers with Figure 8’s in-process experiment
- Sharing versus partitioning (Figure 11): the tradeoff we predicted
- uniform reads: static per-core trees are 1.5× faster, largely from local DRAM access
- one partition gets 40% of requests: the shared tree is 3.5× faster
- evidence about this static-partition experiment, not proof that sharing always wins
- Cost of order (Section 6.4): a favorable hash-table baseline gets 2.5× the throughput on its tested workload
- it cannot answer range queries. The extra interface has a real cost
A split really changes the search path
- Full border node: allocate a right sibling and move the higher keys into it
- Now old entries disappear from one node and appear in another. One permutation store cannot publish the whole change
- Use the version protocol for structural changes
locked: writer exclusioninserting,splitting: dirty markersvinsert,vsplit: counters for completed changes of the two kinds- also root, border, and deleted flags
- Why two counters?
- an interior insertion adds a separator/child; a reader can search that same node again
- a split may move responsibility for the key to a different node; rereading the old node may never find it
- Interior inserts still rearrange entries. The permuter above is for border nodes
Writers go up
- Descend without locks, like a reader. Lock the target border node, then recheck that it is still the right node
- To split n:
- allocate n’; mark n and n’ splitting before moving entries
- distribute entries, with the higher keys in n’; link border siblings
- lock n’s parent; check that it is still the parent (it may itself have split)
- mark the parent inserting and add n’
- finish version changes and unlock
- If the parent is full: mark it splitting and propagate upward
- transfer the protection upward before releasing the lower nodes
- create a new root if necessary
- Splits use at most three node locks at once: the splitting node, its new sibling, and its parent
- As in Lehman-Yao, structural writers acquire existing-node locks upward; they do not lock the root on every insert
- The split markings matter as much as the locks. Locks coordinate writers; markings explain the change to readers
Readers need an overlap between parent and child
- Tempting algorithm: read parent, validate parent, then read child’s version and contents
- Still wrong. Each node can look consistent while the path is stale
reader wants a key under X writer splits interior node B
read A; choose child B
validate A: unchanged
move child X from B to new B'
add B' to A; finish split
read B's new version and contents
validate B: unchanged
search below B wrong subtree: X is under B'
- Fix: snapshot the child’s stable version before validating the parent
vA ← stableversion(A)
B ← choose_child(A, key)
vB ← stableversion(B) // take this before releasing A logically
validate A against vA // ignore a change in the lock bit alone
if valid:
continue in B, carrying vB // do not replace it with a fresh snapshot
- Hand-over-hand validation, in place of hand-over-hand locking
- Why the split cannot fall through the gap:
- writer marks B splitting before changing A, and carries the split protection upward
- if reader captured B before the split, its saved vB exposes the split when it next validates B
- if reader waits through B’s split to obtain a stable vB, the change to A is already detectable
- if reader follows the new pointer to B’, the moved children were installed before that pointer was published
- Version changed during an interior-node search?
- only insertion changed: retry at that node
- split counter changed: restart at the root of this B+ tree layer
- This replaces remembering and revalidating the entire path
At the border, recover to the right
- Border nodes have sibling links. Interior nodes in Masstree do not
- Each border node owns a half-open slice range
[lowkey, highkey)- its low boundary stays fixed for its lifetime
- splitting moves higher keys right; the next node’s low boundary tells us whether to move there
- If border-node validation fails: obtain a stable version, check the next sibling’s low boundary, move right as needed, and try the lookup again
- This is last lecture’s repair idea, with version checking added because nodes are no longer atomic pages
- A deleted node forces a restart
- A saved layer-root pointer can also become stale after a root split
- the root flag and parent pointer let traversal find the new root
- “restart at root” means the current root, not blindly trusting an old pointer