When it does not fit

The read a filter has no key for

A Bloom filter on every run of a log-structured store turns a lookup for a missing key from a read of every level into a fraction of one — 0.72 transfers across eight levels at five bits a key, and 0.00027 at twenty. A range query over the same store reads nine transfers at five bits and nine at twenty, because a filter answers whether one named key is in a run and a range has no key to name.

The filter each run carries measured what a Bloom filter on every run of a log-structured store buys. Without filters, a lookup for a key the store does not hold reads a block at every level — three levels, three transfers, and nothing found. With five bits a key spread evenly, it wastes 0.279 reads; spread so that small levels get more bits and the large one fewer, 0.201. The level count, which is the whole read cost of a log-structured store, is bought back almost entirely.

A range query is a different question and gets no such answer. A filter tells a store whether a named key might be in a run. “Every key between aa and bb” names no key, so there is nothing to ask, and every run whose interval overlaps the range has to be opened and read. The level count is paid in full, on every range query, however many bits the filters hold.

This page measures what that costs.

The two costs, against the size ratio

A log-structured store’s level count falls as its size ratio rises: with runs of 4,096 keys and a million keys held, a ratio of two gives eight levels and a ratio of sixteen gives two. That is the dial one dial between two structures swept, and it is paid for in writes.

The level count is a fraction of a transfer to a point lookup and the whole cost of a range query: from 8 levels to 2, the range falls from 16 transfers to 5 and the absent lookup from 0.72 to 0.18Block transfers against the size ratio, on logarithmic axes, on a store of 1,048,576 keys with 5 bits of filter a key: a range query for 100 keys, a point lookup for a key the store does not hold, and the number of levels. Size ratio 2: 8 levels, the range reads 16, the absent lookup 0.7241, write amplification 8. Size ratio 3: 5 levels, the range reads 10, the absent lookup 0.4526, write amplification 10. Size ratio 4: 4 levels, the range reads 9, the absent lookup 0.3621, write amplification 12. Size ratio 6: 3 levels, the range reads 7, the absent lookup 0.2715, write amplification 15. Size ratio 8: 3 levels, the range reads 7, the absent lookup 0.2715, write amplification 21. Size ratio 10: 3 levels, the range reads 6, the absent lookup 0.2715, write amplification 27. Size ratio 16: 2 levels, the range reads 5, the absent lookup 0.1810, write amplification 30.234681016110size ratioblock transfers · levelsa range of 100 keyslevelsan absent point lookup1,048,576 keys, 5 bits a keyfilters answer one of these two
Fig. 1 Block transfers against the size ratio, on logarithmic axes, on a store of 1,048,576 keys with five bits of filter a key: a range query for 100 keys, a point lookup for a key the store does not hold, and the level count. From a ratio of two to sixteen the levels fall from eight to two, the range from sixteen transfers to five, and the absent lookup from 0.72 to 0.18.

Two lines fall together and one of them is a fraction of a transfer.

At a size ratio of two the absent point lookup costs 0.72 transfers and the range query costs sixteen. Both numbers are the same eight levels, seen through different questions: the point lookup pays L×false-positive rateL \times \text{false-positive rate} and the range pays LL seeks plus the rows. A store tuned to make its point lookups cheap has almost nothing left to gain there — 0.72 transfers to 0.18 is a saving of half a transfer — and the same change saves a range query eleven.

So the dial’s value depends entirely on which question is being asked, and the earlier page’s framing hid that by asking only one.

What the filter bits do, and do not

Ten times the filter bits take an absent point lookup from 1.53 transfers to 2.7e-4 and leave the range query at 9Block transfers against bits of filter per key, on logarithmic axes, on a store of 1,048,576 keys with 4 levels: a point lookup for a key the store does not hold, and a range query for 100 keys. 2 bits a key: an absent point lookup reads 1.5302, a range reads 9. 4 bits a key: an absent point lookup reads 0.5854, a range reads 9. 5 bits a key: an absent point lookup reads 0.3621, a range reads 9. 8 bits a key: an absent point lookup reads 0.0857, a range reads 9. 12 bits a key: an absent point lookup reads 0.0125, a range reads 9. 20 bits a key: an absent point lookup reads 0.0003, a range reads 9. A Bloom filter answers whether one named key is in a run; a range query has no key to name, so every level is read whatever the filters cost.245812200.0010.010.1110bits of filter a keyblock transfersa range of 100 keysan absent point lookup1,048,576 keys, 4 levelsthe filter has no key to be asked about
Fig. 2 Block transfers against bits of filter a key, on logarithmic axes, on a four-level store: an absent point lookup and a range query for 100 keys. From two bits a key to twenty, the point lookup falls from 1.53 transfers to 0.00027 — a factor of five and a half thousand — and the range query stays at nine.

The flat line is the page’s title. Ten times the memory spent on filters changes a range query’s cost by nothing at all, because the filter is being asked a question it cannot be asked.

That is worth separating from the ordinary observation that a Bloom filter has a false-positive rate. A filter with a rate of zero would still leave the range query at nine transfers. The limit is not the filter’s accuracy; it is its interface. A membership test over a set has no extension to an interval unless the structure indexes the interval, which a Bloom filter by construction does not.

It also means the two costs are separately tunable and are usually tuned together. A store’s memory budget buys filter bits, and filter bits buy point lookups; its size ratio buys levels, and levels buy both point lookups and ranges. A system with a memory budget and a range-heavy workload is spending its memory on the wrong axis, and would do better to spend the same bytes on a block cache, which helps both.

How long a range has to be before the levels stop mattering

A range of 1 key costs 4.0 times what one run would, and a range of 100,000 costs 1.00 times — the levels are a fixed charge and the rows are notBlock transfers a range query reads against how many keys it asks for, on logarithmic axes, on a store of 1,048,576 keys with a size ratio of 4 and 4 levels; the lower line is what the same range would cost in a single sorted run. 1 keys: 8 transfers against 2 in one run, a ratio of 4.00. 10 keys: 8 transfers against 2 in one run, a ratio of 4.00. 100 keys: 9 transfers against 3 in one run, a ratio of 3.00. 1,000 keys: 21 transfers against 17 in one run, a ratio of 1.24. 10,000 keys: 162 transfers against 158 in one run, a ratio of 1.03. 100,000 keys: 1568 transfers against 1564 in one run, a ratio of 1.00.1101001,00010,000100,0001010010³keys the range asks forblock transfers4 levelsone sorted run1,048,576 keys, size ratio 4one seek a level, then the rows
Fig. 3 Block transfers a range query reads against how many keys it asks for, on logarithmic axes, on a four-level store; the lower line is the same range in a single sorted run. A range of one key costs eight transfers against two — four times — a range of a hundred costs nine against three, a range of a thousand 21 against 17, and a range of 100,000 costs 1.00 times what one run would.

The level count is a fixed charge and the rows are not, so the overhead is a ratio that falls as the range grows. A range of one key — which is a point lookup asked the other way — costs four times what a single sorted run would; a range that reads a thousand keys costs 1.24 times; a range that reads a hundred thousand costs what any structure would, because by then the answer is the cost.

The crossing is at about a thousand keys, and that number is the one a system designer needs. It is not a property of the store’s size: it is the point at which the range’s own rows fill more blocks than the store has levels, so it depends on the level count and the block size and on nothing else. With four levels and sixty-four keys a block, a range of 256 keys reads as many blocks of data as it pays in seeks.

Below that crossing a log-structured store is answering range queries at a multiple of what a single sorted run would, and the multiple is the level count. The index that is not worth reading made the same kind of point about a different structure — that where a method turns is decided by the block size, a number the query does not contain and the schema does not mention — and the level count here is that number’s counterpart. Above it, the structure barely matters. A workload of short range scans — “the last ten events for this user”, which is the shape most key-value stores actually serve — sits entirely below the crossing.

The two questions a store is asked

It is worth writing the two costs out, because they have the same LL in them and almost nothing else in common.

A point lookup for a key the store does not hold reads a block at every level whose filter says maybe. With LL levels and a false-positive rate pp that is LpLp transfers in expectation — eight levels at five bits a key is 8×0.0905=0.728 \times 0.0905 = 0.72. Every term is small and the product is a fraction of one transfer.

A range query reads one seek into each level plus the blocks holding that level’s share of the rows: L+irsi/BL + \sum_i \lceil r s_i / B \rceil, where rr is the rows asked for and sis_i is level ii’s share of the keys. With eight levels and a hundred rows over sixty-four-key blocks the second term is eight and the first is eight, so the levels are half the cost and the rows are the other half.

Set the two expressions beside each other and the difference is one factor. The point lookup multiplies LL by pp; the range query does not multiply LL by anything. Everything in this page follows from that, including the flat line on the filter-bits plate, which is pp not appearing.

There is a second consequence that the plates show and the expressions make obvious. Spending memory on filters lowers pp and therefore lowers LpLp; raising the size ratio lowers LL and therefore lowers both. The size ratio is the only parameter that helps both questions, which is why it is the one a mixed workload has to be tuned on, and it is the parameter whose cost is paid in writes rather than in memory.

What it costs to fix by turning the dial

Read cost against write amplification: raising the size ratio from 2 to 16 costs 3.8× the writes and saves a range query 3.2× where it saves an absent point lookup 4.0× of a fraction of one transferBlock transfers a query reads against the write amplification of the store that answers it, on logarithmic axes, over size ratios from 2 to 16 on 1,048,576 keys at 5 bits of filter a key. Size ratio 2: 8 levels, write amplification 8, a range of 100 keys reads 16 and an absent point lookup 0.724. Size ratio 3: 5 levels, write amplification 10, a range of 100 keys reads 10 and an absent point lookup 0.453. Size ratio 4: 4 levels, write amplification 12, a range of 100 keys reads 9 and an absent point lookup 0.362. Size ratio 6: 3 levels, write amplification 15, a range of 100 keys reads 7 and an absent point lookup 0.272. Size ratio 8: 3 levels, write amplification 21, a range of 100 keys reads 7 and an absent point lookup 0.272. Size ratio 10: 3 levels, write amplification 27, a range of 100 keys reads 6 and an absent point lookup 0.272. Size ratio 16: 2 levels, write amplification 30, a range of 100 keys reads 5 and an absent point lookup 0.181.8101215212730110write amplificationblock transfers a queryT = 2T = 4T = 8T = 16a range of 100 keysan absent point lookup1,048,576 keys, 5 bits a keyeach point is one size ratio
Fig. 4 Block transfers a query reads against the write amplification of the store answering it, on logarithmic axes, over size ratios from two to sixteen. Raising the ratio from two to sixteen costs 3.8 times the writes; it saves a range query 3.2 times and an absent point lookup 4.0 times of a fraction of one transfer.

This is the frontier the store is actually on, and reading it against the earlier page’s changes the answer.

Judged on point lookups, raising the size ratio is nearly pointless. It takes 0.72 transfers to 0.18, a saving of half a transfer a query, and costs 3.8 times the write amplification. Almost no workload justifies that, which is why write-optimised stores are configured with small size ratios and many levels.

Judged on short range queries it is the main lever available. Sixteen transfers to five is a saving of eleven, and 3.8 times the writes for it is a trade a read-heavy workload can want. The writes nobody counted measured a B-tree writing 49.3 elements of block per key stored against a log-structured store’s 2.0; a store at a ratio of sixteen writes 30 units against a ratio of two’s 8, which is still far below the B-tree and is a large change within the family.

So the tuning question is not “how much memory for filters” and not “how write-optimised should this be”. It is what share of the queries name a key, and that share moves the best size ratio by a factor of eight in this sweep.

The size ratio a store should be tuned to moves from 6 to 16 as the share of queries that are ranges rises from none to all — and it has moved by the time one query in 10 is a rangeThe size ratio minimising the cost of one query plus 1 insertion, against the share of queries that are ranges of 100 keys, on logarithmic axes. Reads are block transfers as counted elsewhere on this page; an insertion is charged its write amplification divided by the 64 keys in a block. no ranges: size ratio 6, 3 levels, write amplification 15, 0.506 transfers a query. 0.1% ranges: size ratio 6, 3 levels, write amplification 15, 0.513 transfers a query. 0.3% ranges: size ratio 6, 3 levels, write amplification 15, 0.526 transfers a query. 1.0% ranges: size ratio 6, 3 levels, write amplification 15, 0.573 transfers a query. 3.0% ranges: size ratio 6, 3 levels, write amplification 15, 0.708 transfers a query. 10.0% ranges: size ratio 16, 2 levels, write amplification 30, 1.132 transfers a query. 30.0% ranges: size ratio 16, 2 levels, write amplification 30, 2.095 transfers a query. 100.0% ranges: size ratio 16, 2 levels, write amplification 30, 5.469 transfers a query.00.1%0.3%1%3%10%30%100%10share of queries that are rangessize ratio to tune tothe best size ratio1,048,576 keys, ranges of 100one insertion a query at 64 keys a block
Fig. 5 The size ratio minimising the cost of one query plus one insertion, against the share of queries that are ranges of 100 keys, on logarithmic axes. An insertion is charged its write amplification divided by the sixty-four keys in a block. With no ranges the best ratio is six — three levels, write amplification fifteen, 0.506 transfers a query. From one query in ten being a range it is sixteen, at two levels and write amplification thirty.

One query in ten. That is the whole of the practical finding, and it is worth stating as a warning rather than as a recommendation: a store configured on a pure point-lookup benchmark is configured for a workload that almost nothing has, and the configuration changes as soon as a tenth of the traffic scans anything.

The charge on the insertion is the one number on that plate that was chosen rather than counted, and the choice is conservative. A write-amplification unit is charged as one key’s worth of block, so an insertion at a ratio of sixteen costs 30/64 of a transfer. A system that charged writes more dearly — a device whose writes cost several times its reads, which is the read ratio what insurance against an estimate costs sweeps — would keep the small ratio longer, and the crossing would move right rather than disappear.

The same memory sized to the levels: 0.201 wasted reads a lookup against 0.279, at 5 bits a keyA log-structured store of 65,536 keys in 3 levels, looked up 6,000 times, with every run's Bloom filter given a budget of bits a key and that budget divided two ways. For a key that is not there, every level whose filter says yes costs a wasted read; with no filters that is all 3 of them. At 5 bits a key the even split reads 0.279 and the sized one 0.201; at 10 bits, 0.027 and 0.018. The memory is identical at every point.0.020.050.10.20.5121235810filter bits a keywasted reads per absent lookupno filters: all 3 levelssame rate every levelrates sized to levelsn = 65,536, size ratio 4, first run 1,024 keysthe same memory at every point
Fig. 6 The earlier page’s measurement, for the cost this page is contrasting with: the same filter memory spread evenly across the levels and sized to them, on absent point lookups. Sized filters waste 0.201 reads a lookup against an even spread’s 0.279, at five bits a key. Every number on that plate is a fraction of one transfer, and none of it reaches a range query.

Setting that plate beside this page’s is the clearest statement of the problem. The earlier page’s whole finding — a real improvement, correctly measured, worth having — moves a cost from 0.279 transfers to 0.201. The level count it is optimising around costs a short range query eleven transfers. The two pages are optimising the same structural quantity at scales two orders of magnitude apart, and only one of them is where a range-serving store’s time goes.

Why this is the shape of a whole family of mistakes

The general form is worth naming, because a reader who has this one will spot the others.

A structure has several costs; an optimisation removes one of them; the removed cost is the one the structure was most often measured on; and the structure’s design parameters are then tuned as though the other costs did not exist. Here: filters remove the level count from point lookups, point lookups are how key-value stores are benchmarked, and the size ratio gets tuned for write amplification and point lookups alone.

The filter that feeds the table is the same device in a different structure, and it has the same blind spot — it can decline a probe for a named key and cannot decline a scan. A filter has a selectivity is the standing statement that a filter’s value is a ratio between two costs; what this page adds is that the denominator can be a cost the filter cannot touch, in which case the ratio is one however good the filter is.

And the honest summary of what a filter is follows from it. A Bloom filter is not a way of making a store faster; it is a way of removing one specific read — a read for a key that is not there — and everything it is worth is the share of a workload made of those reads.

That share is larger than it sounds, which is why the device is everywhere. A key-value store behind a cache sees mostly misses, because the hits were served by the cache; a store sharded across machines sees a lookup for every shard and finds the key on one; a store used as a set sees membership tests by definition. In all three the absent point lookup is most of the traffic and the filter is the whole design. The mistake is not building it. The mistake is letting the parameter it optimised — the level count seen through pp — stand in for the parameter it did not.

And the two can be separated by a measurement any running store can make, which is the practical form of this page. A store that counts its queries by kind knows the range share; the level count is in its own configuration; the block size and the mean range length are in its logs. Those four numbers are the whole of the plate above, and none of them requires an experiment.

What is not measured here

One seek a level, charged as one transfer. A range query is modelled as one transfer to find the range’s start in each level’s index, then the blocks holding that level’s share of the rows. A real store’s index is itself blocks and a seek into a large level costs more than one; the plate therefore understates the range query’s cost, and the understatement grows with the level count — which is the direction that would make this page’s case stronger.

Levelled, not tiered. Each level is one sorted run. A tiered store keeps several runs a level and a range query reads all of them, so the level count in every number here becomes a run count, several times larger. That is the arrangement most write-optimised stores actually use.

Ranges that overlap every level. Every run’s key interval is assumed to overlap the range, which is exact for a store whose levels each hold a uniform sample of the key space and pessimistic for one whose recent levels hold a narrow range of recent keys. A store with time-ordered keys has levels that mostly do not overlap a given range, and a per-run minimum and maximum key — which every real store keeps — prunes them for free.

Closed forms, not a simulation. The point-lookup costs here are the closed forms rather than the built-and-measured filters of the earlier page, because this page’s sweeps are over stores too large to build once for each point. The earlier page’s simulation agreed with these forms at the sizes it could reach, which is the only reason to trust them here.

No comparison with a B-tree. The obvious rival for a range-heavy workload is the structure a log-structured store was built to avoid, and the writes nobody counted priced the two on insertions. A B-tree answers a range in one seek plus the rows, which is this page’s “one sorted run” line, and pays 49.3 elements of block per key stored to do it. The crossing between the two families on a mixed workload is a plate this page does not draw, and it is the plate a system designer actually wants. One dial between two structures is the argument that they are one design at two settings, so the crossing is a point on a curve rather than a choice between two things.

No compaction cost per query. Write amplification is reported as the classical (T1)L(T-1)L rather than measured, and the reads compaction itself performs are not charged to any query.

One block size and one key size. Sixty-four keys to a block throughout, which sets where the crossing on the length plate falls. A store of wide rows fits fewer to a block, so the range’s own data costs more blocks and the level count is a smaller share of it — the crossing moves left and the whole finding weakens. A store of narrow keys moves it right.

Still open: the filter a range query could use

A Bloom filter cannot be asked about an interval, and there is a structure that can. A prefix filter — a Bloom filter over the prefixes of keys rather than over keys — can be asked “does this run hold any key beginning with pp”, which is a range query when the range is a prefix. Real stores ship them, and their value is bounded by how well the query’s ranges line up with the prefixes the filter was built on.

The measurement that follows builds a store whose runs each carry a filter over bb-bit key prefixes, sweeps bb from one to the key’s full width, and asks how many levels a range query can skip at each prefix length: short prefixes match everything and skip nothing, long prefixes match almost nothing but cover only ranges narrower than a prefix. The prediction from this page is that the useful band is narrow and that its position is set by the range length rather than by the store — so a store serving ranges of mixed lengths would need several such filters, and the memory that buys them is memory not spent on the point-lookup filters that are already there.

Named alongside this one

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

The objects this essay names

Each one links to every other essay that touches it.

Block sizeBloom filterCompactionDesign parameterLsm treeRange queryRead amplificationSorted runSpace time tradeWorkloadWrite amplification