When it does not fit

One dial between two structures

A B-tree writes 226 elements of block for every key stored and a log-structured store writes two. They are presented as rival designs. They are one design at two settings of an exponent that nothing in either description mentions, and every setting between them is available.

Two structures were set beside each other in the writes nobody counted and the gap between them was enormous. A B-tree, taking sixteen thousand random keys, wrote 49.3 elements’ worth of blocks for every key it stored. A log-structured store, taking the same keys, wrote 2.0. Every counter this collection had before that measurement reported the two as identical work.

They were presented there as two designs, because that is how they are presented everywhere: an in-place tree for reading and a log-structured store for writing, and an architect chooses. That framing is wrong in a specific and useful way. They are not two designs. They are the two ends of one design, the thing that separates them is a single exponent, and every intermediate setting of it exists and can be measured.

One structure, one exponent: 226 elements written per key at ε = 1 and 45 at ε = 0.5A tree whose internal nodes hold B^ε pivots and spend the rest of the block on a buffer of pending insertions. At ε = 1 the buffer is empty, every key goes straight to its leaf, and this is the B-tree — 226 elements of block written per key, answering a query in 3 transfers. At ε = 0.5 it writes 45 and answers in 5. The rise at the left-hand end is the analysis running out: a tree that tall has more nodes than keys near its leaves, so its buffers never fill and each flush pays for a block to move a handful of messages.1100ε — the exponent the fanout is B toelements written per key33445679ε = 1 — the B-tree ·fanout 256 · 3transfers a queryε = 0.5 · fanout 16 · 5a querythe number above eachpoint is what a querycostsB = 256, M = 16,384 (M/B = 64)131,072 random keys
Fig. 1 The exponent, swept. A tree whose internal nodes reserve B^ε of their block for pivots and spend the rest on a buffer of insertions that have not yet reached a leaf. At ε = 1 the buffer is empty, every key descends immediately, and the structure is exactly the B-tree — 226 elements written per key here. At ε = 0.5 the same structure writes 45. The number above each point is what a query costs afterwards, which is the other half of the trade and the reason the curve is not simply an improvement.

What a buffer does to an insertion

A B-tree insertion is a descent and a write. It reads one node per level to find the right leaf, then dirties that leaf. The leaf is one block, the block holds B keys, and — this is the whole of the amplification — the leaf is almost certainly evicted before another key lands in it, so one key costs one whole block written. At B = 256 that is 256 elements moved to store one, and the measurement above says 226 because a few leaves do get hit twice.

The buffered version changes one thing. An arriving key is not sent to its leaf. It is written into a buffer in the root, along with every other key that has arrived recently, and it stays there until the buffer is full. When it is full, the root is read once and its contents are partitioned among the root’s children, one block written per child touched. Each key has moved down exactly one level, and the cost of moving it was shared with every other key in the buffer.

That is the entire idea, and the arithmetic falls out of it immediately. It is also the same move sorting what will not fit makes for a different problem: batch the work so that one transfer serves many elements rather than one, and accept that an individual element’s journey becomes longer in exchange for its share of the journey becoming smaller. If a buffer holds about B messages and a flush touches about f children, then f blocks were written to move B messages one level: a cost of f/B blocks per message per level. A B-tree’s cost is one block per key, full stop. So the ratio between them is f/B times the height, and both of those are controlled by the same choice.

Let f = B^ε. The height of a tree with fanout f over n keys is log_f n = (1/ε)·log_B n, and the cost per insertion is that height times B^ε/B:

insert1εlogBnB1εquery=1εlogBn\text{insert} \approx \frac{1}{\varepsilon}\cdot\frac{\log_B n}{B^{1-\varepsilon}} \qquad \text{query} = \frac{1}{\varepsilon}\cdot\log_B n

At ε = 1 the second factor of the insertion cost is B⁰/B = 1/B blocks per level… except that at ε = 1 there is no buffer at all — a node whose pivots fill its block has nothing left to buffer with — so the key descends immediately and pays the whole leaf. The formula and the structure agree at the boundary only if the boundary is read carefully, which is the kind of thing worth checking rather than asserting.

A worked instance makes the sharing concrete. Take a block of two hundred and fifty-six elements and a fanout of sixteen. The root’s buffer holds two hundred and forty pending keys; when it fills, the root is read once and sixteen children are written, so 240 keys moved one level for seventeen transfers — about fourteen keys per transfer. A B-tree at the same block size moves one key per transfer, all the way down. The buffered tree then has to repeat that at every level, five of them here rather than three, and the product of those two effects is the whole result.

One structure, one exponent: 57 elements written per key at ε = 1 and 28 at ε = 0.5A tree whose internal nodes hold B^ε pivots and spend the rest of the block on a buffer of pending insertions. At ε = 1 the buffer is empty, every key goes straight to its leaf, and this is the B-tree — 57 elements of block written per key, answering a query in 3 transfers. At ε = 0.5 it writes 28 and answers in 5. The rise at the left-hand end is the analysis running out: a tree that tall has more nodes than keys near its leaves, so its buffers never fill and each flush pays for a block to move a handful of messages.1ε — the exponent the fanout is B toelements written per key334557810ε = 1 — the B-tree ·fanout 64 · 3 transfersa queryε = 0.5 · fanout 8 · 5a querythe number above eachpoint is what a querycostsB = 64, M = 4,096 (M/B = 64)32,768 random keys
Fig. 2 The same sweep through a block a quarter the size. Every number falls, because a smaller block is less to write — 57 elements per key at the top rather than 226 — and the shape does not: the curve descends from the tree, bottoms out, and turns back up. What the block size changes is how much the dial is worth, which is the next section’s subject.

The endpoint is the B-tree, to the transfer

The claim that these are one structure is easy to make and easy to make loosely. The version that can be checked is stronger: the general implementation, run at ε = 1, must produce the same transfer counts as the special one, not similar ones.

It does, and it did not on the first attempt. The first version charged a write for every child a flush touched and then charged again when that child flushed onward — so at ε = 1, where a buffer holds one message and nothing ever rests anywhere, it reported a write per level per key and an amplification of 100 on a structure whose defining characteristic is that it writes one block per key. The repair is to charge a block only when messages stay in it. With that, the two implementations agree to the transfer, and the gate holds them to it.

That is the same discipline the writes nobody counted needed when its first amplification measurement came out at zero, and it is the recurring lesson of this field: the code constructing the case is as likely to be wrong as the code being measured, and it is far less likely to be checked.

There is a second reason to insist on the endpoint rather than on a family resemblance, and it is about what a measurement is for. A plate showing a buffered tree beating a B-tree proves very little on its own, because two implementations by two authors differ in a hundred ways and any of them could be the cause. A plate showing one implementation, at two settings of one named parameter, is a controlled comparison: everything except that parameter is byte-for-byte the same code. Fitting a class to measurements makes the same demand of a growth curve, and the discipline is identical — an experiment that varies two things at once has measured neither.

Elements written per key inserted, 16,384 random keysThe same 16,384 insertions into an in-place B-tree and into a log-structured store at four size ratios. The B-tree dirties one leaf per key and that leaf is evicted before it is touched again, so it writes a whole block — 49 elements — for each key. The log-structured store writes each key once per level it passes through and writes in whole blocks, so it moves 2.0 elements per key at T = 4. Neither number appears in any operation count, and the second structure exists entirely because of the first.elements of block written per key insertedB-tree, in place49.3Log-structured, T = 23.0 · 16× less than the treeLog-structured, T = 42.0 · 25× less than the treeLog-structured, T = 81.0 · 49× less than the treeLog-structured, T = 161.0 · 49× less than the treeB = 64, M = 4,096 (M/B = 64)25× between the two structures at T = 4
Fig. 3 The two structures as the earlier measurement presented them: an in-place tree against a log-structured store at four size ratios, on the same sixteen thousand insertions. Nothing here is wrong. What it does not show is that the space between the two bars is occupied — that there is a continuum, and that a system can sit anywhere on it by choosing one number.

What the exponent costs, stated as a rate

A dial is only interesting if both directions are priced, and the query side is the one that is easy to forget because it does not appear in a write-amplification measurement at all.

A point query descends the tree, and a buffered tree is taller than a B-tree over the same keys, because its fanout is smaller. Worse, a query cannot stop at the leaf: a key may still be sitting in a buffer somewhere on the path, so every node on the descent must be examined for it. That costs nothing extra in transfers — the node was read anyway — but it means the descent cannot be shortened.

So the trade is height against writes, and at B = 256 over 131,072 keys it runs:

ε fanout height elements written per key
1 256 3 225.6
0.85 111 3 148.9
0.7 49 4 76.9
0.5 16 5 44.7
0.34 7 7 59.6
0.25 4 9 90.9

Those are transfers rather than durations, which is the standing caveat of this field and is stated once here rather than after every number: the count is not the time, and the four orders of magnitude between the cheapest and dearest kinds of transfer are why nothing on this page is converted into one.

The row worth staring at is the second. At ε = 0.85 the tree is the same height as the B-tree — three transfers to a query, identical — and it writes a third less. That is not a trade at all; it is a strictly better structure at these parameters, obtained by giving up a hundred and forty-five pivots per node that the fanout did not need.

One structure, one exponent: 518 elements written per key at ε = 1 and 72 at ε = 0.5A tree whose internal nodes hold B^ε pivots and spend the rest of the block on a buffer of pending insertions. At ε = 1 the buffer is empty, every key goes straight to its leaf, and this is the B-tree — 518 elements of block written per key, answering a query in 2 transfers. At ε = 0.5 it writes 72 and answers in 4. The rise at the left-hand end is the analysis running out: a tree that tall has more nodes than keys near its leaves, so its buffers never fill and each flush pays for a block to move a handful of messages.1100ε — the exponent the fanout is B toelements written per key223334ε = 1 — the B-tree ·fanout 1024 · 2transfers a queryε = 0.5 · fanout 32 · 4a querythe number above eachpoint is what a querycostsB = 1024, M = 65,536 (M/B = 64)131,072 random keys
Fig. 4 The same sweep at a block of a thousand and twenty-four elements, which is the scale a page-based store actually runs at. The top of the curve is 518 elements written per key and the bottom is 72 — a factor of seven — and the query cost goes from two transfers to four. The larger the block, the more the dial is worth, because the quantity being divided is the block itself.

The direction of that dependence is the useful part. The amplification a B-tree suffers is proportional to B, and the amplification a buffered tree suffers is proportional to B^ε. So a hardware trend towards larger transfer units — which is what every generation of storage device has produced — makes the in-place tree worse and the buffered tree better, at the same time, on the same device. A structure chosen for one block size is a structure chosen for one era.

Where the analysis stops describing the measurement

The curve turns back up. At ε = 0.34 the amplification is 59.6 and at ε = 0.25 it is 90.9, both worse than the 44.7 at ε = 0.5, and the formula above says they should be better. This is drawn rather than clipped, because it is a real property of the structure at a real size and not an artefact.

The amortised argument assumes a buffer fills. A tree of fanout four over 131,072 keys has nine levels and its bottom internal level holds tens of thousands of nodes — more nodes near the leaves than there are keys to distribute among them. Those buffers never fill: each one receives a handful of messages over the whole run and then has to be drained anyway, at the price of a whole block, to answer the first query. The per-message cost of a flush that moves eight messages instead of two hundred and fifty is thirty times higher, and no amount of asymptotic reasoning about large n changes what happens at this one.

The general statement is the one this collection keeps arriving at from different directions. An amortised bound is a promise about a long enough sequence, and whether a given sequence is long enough is a question about the parameters rather than about the bound. Choosing a growth factor makes the same point about an array that doubles; here the sequence that has to be long enough is not the whole run but the run through each individual node, and there are exponentially many nodes competing for it.

So the honest reading of the plate is: the dial works over roughly ε ∈ [0.5, 1] at these sizes, its useful range widens as n grows, and the minimum is a measured location rather than a limit.

Elements written per key inserted, 32,768 random keysThe same 32,768 insertions into an in-place B-tree and into a log-structured store at four size ratios. The B-tree dirties one leaf per key and that leaf is evicted before it is touched again, so it writes a whole block — 130 elements — for each key. The log-structured store writes each key once per level it passes through and writes in whole blocks, so it moves 1.0 elements per key at T = 4. Neither number appears in any operation count, and the second structure exists entirely because of the first.elements of block written per key insertedB-tree, in place129.6Log-structured, T = 22.0 · 65× less than the treeLog-structured, T = 41.0 · 130× less than the treeLog-structured, T = 81.0 · 130× less than the treeLog-structured, T = 161.0 · 130× less than the treeLog-structured, T = 321.0 · 130× less than the treeB = 256, M = 16,384 (M/B = 64)130× between the two structures at T = 4
Fig. 5 The far end of the same continuum, reached by a different construction. A log-structured store writes each key once per level of a merge hierarchy and writes in whole blocks throughout, so its amplification is a small multiple rather than a fraction of a block — 130 elements per key for the in-place tree here against between one and four for the log-structured one. The size ratio is that structure’s own version of the exponent, and it moves the same trade in the same direction.

Which end of the dial a system is on is a statement about its workload

Nothing above says what ε should be, and that is not an omission. The exponent is a function of the ratio of writes to reads in the workload, and no property of the data structure can supply it.

The arithmetic is one line. If a workload performs w insertions for every r queries, the total transfers are w·insert(ε) + r·query(ε), and minimising that is a one-dimensional search over a curve this site can draw for any B, M and n. A write-heavy log takes ε small. A read-heavy index takes ε at one. A store that must serve both takes something in between, and the something is the number a real system’s tuning guide contains without explaining.

This is the same shape as the threshold somebody chose — a number in a source file that decides more about the behaviour than the complexity analysis does — with one difference worth naming. A sort’s cutoff is a constant chosen once by whoever shipped it. This one is a parameter of the workload, so it cannot be chosen once, and a structure that fixes it has made a decision on the operator’s behalf and hidden it inside a name.

There is one more asymmetry in the trade and it belongs on the query side. A buffered tree’s insertions are sequential in their effect: a flush writes a small set of sibling blocks, which is a short burst of nearby addresses. A B-tree’s insertions are scattered by construction, since a random key lands in a random leaf. Nothing in a transfer count distinguishes those — one access, eight kilobytes established that a transfer is a transfer whatever its address — and every real device charges less for the first than for the second. The count understates the buffered end again.

Search cost against fanout, n = 4,194,304A B-tree over 4,194,304 keys at 5 block sizes. The outline is the tree's height — the number of nodes on a root-to-leaf path — and the filled bar is the transfers a search actually paid, averaged over 200 random queries. The two differ because the top levels of the tree are few enough blocks to stay in memory: at B = 1024 the height is 3 and the measured cost is 1.01. Binary search over the same keys costs 20 transfers, and its cost does not depend on B at all.levels on the path (outline) · transfers actually paid (filled)B = 411 levels · 6.99 transfers · 16,384 blocks residentB = 166 levels · 3.23 transfers · 4,096 blocks residentB = 644 levels · 1.99 transfers · 1,024 blocks residentB = 2563 levels · 1.30 transfers · 256 blocks residentB = 10243 levels · 1.01 transfers · 64 blocks residentbinary search over the same 4,194,304 keys: 20 transfersB = 1024, M = 65,536 (M/B = 64)20× between binary search and the widest tree
Fig. 6 Why the query side of the trade is smaller than it looks. A tree’s height is the number of nodes on a root-to-leaf path, and the transfers a search actually pays are fewer, because the top levels are few enough blocks to stay resident. So an extra level of height costs less than one extra transfer on average — which tilts the whole trade towards the buffered end and is invisible in any statement of the height.

What the counters can and cannot say about this

Three qualifications, in the order they would bite.

The query cost here is a count of transfers and not of anything else. A descent is a dependent chain: every node’s address comes from the node above it, so none of the reads can be issued in parallel. The block that is not a block argues this at length, and it applies asymmetrically here — the extra levels a buffered tree adds are all on the dependent chain, so two extra transfers on a query cost rather more than two transfers’ worth of time. The dial’s query side is understated by this model.

A range query is a different question and the model here does not ask it. Both structures answer point queries in the counts above. A scan over a key range is where the in-place tree is at its best and the log-structured end at its worst, because the log-structured end has the range spread across every level of its hierarchy. Nothing in a write-amplification figure sees that, and a system chosen on these numbers alone would be chosen on half the evidence.

And the buffers are memory. A tree whose internal nodes carry buffers has a larger working set than one whose internal nodes carry only pivots, so at a fixed M fewer of its top levels stay resident. The measurements above charge that honestly — the resident set is simulated, not assumed — but the effect is worth naming, because it is the mechanism by which the trade could stop being a trade at a small enough memory. It is the same edge the cliff where the data stops fitting measures for a working set that outgrows a cache, one level of the hierarchy down, and it arrives suddenly for the same reason.

None of the three changes the direction of the result. All three change how much to trust the second decimal place, which is the standing caution of this whole field and the reason the count is not the time exists.

What was actually gained, stated narrowly

It would be easy to read the plate as “buffered trees are better”, and that is not what was measured.

What was measured is that a family of structures exists between two that are usually presented as alternatives, that it is parameterised by one number, and that the number is not in either structure’s description. The B-tree and the log-structured store are the endpoints, and the reason they are the ones with names is historical rather than technical: one was designed for disks in 1970 and the other for disks in 1996, and nobody was obliged to notice that the second is the first with the fanout turned down and the leftover space put to work.

The strongest form of the claim is the one the gate holds: at ε = 1 the general implementation reproduces the special one transfer for transfer. Whatever else is true, these are not two things.

That matters beyond this pair, because the same shape recurs whenever a field has two named designs and a space between them. What O notation does not say is about a class discarding a constant; this is a class discarding a parameter, which is worse, because a constant at least leaves a number behind to be measured and a parameter that nobody names leaves nothing. Both structures here are O(logBn)O(\log_B n) for a query and both are described that way, and the description is true of every point on the curve — including the points where the amplification differs by a factor of seven.

The counters that make the difference visible are the ones this field added rather than the ones it inherited. Comparisons cannot see it, since neither structure compares anything the other does not. Auxiliary space cannot see it, since measuring what an algorithm keeps counts what is held rather than what is moved. Only a count that charges for a block, and charges separately for a dirty one on the way out, distinguishes 226 from 45.

Where this ladder goes from here: the sizes nothing here chose

Three questions this rung opened and did not answer, in the order they are worth taking.

The buffer’s size is a second dial and it has been held fixed. Every measurement above spends all of a node’s non-pivot space on the buffer. It need not: a node could reserve some of its block for a Bloom filter over the keys below it, which would let a query skip subtrees it cannot be in and would take some of the space the buffer is using. That is a three-way division of one block — pivots, buffer, filter — and only one of its axes has been swept here.

The flush policy is a choice this made without saying so. The implementation flushes a full buffer to every child at once. The published analyses flush to whichever single child has the most pending messages, which pays one read and one write to move a larger batch. The two have the same asymptotic cost and different constants, and the constants are what this field is about — so the difference is measurable and unmeasured.

And the turn at the bottom of the curve is a finding without an explanation with a number in it. The account above — that deep buffers never fill because there are more nodes than keys to fill them — is the right shape and is not yet a formula. The quantity that would settle it is the expected number of messages a node at level ℓ receives over a run of n insertions, which is computable in closed form and would say exactly where the minimum sits as a function of n, B and ε. That is a small piece of arithmetic and it would turn the most interesting part of this plate from an observation into a prediction the next rung could check.

What this makes readable

Essays that name this one as a prerequisite.

Named alongside this one

Essays reaching for the same objects. Nobody chose these; they are what the concept index makes visible.

What links here

Every essay whose body links to this one.

The objects this essay names

Each one links to every other essay that touches it.

Amortised analysisB-treeBlock transferBuffered treeExternal-memory modelFanoutLsm treeParameter choiceRegimeTrade offTree heightTuning constantWrite amplification