Repository navigation
lexindex v4.0.0
Changed
-
DictIndexcame down on every cell of the size sweep, and the format isBDX3. Thirteen
corpora at a hundred thousand keys, eleven at a million and six at ten million, each at blocks of
128, 256 and 1024: ninety cells against 3.0.0, ninety of them smaller. The median is 13 % at a
hundred thousand, 25 % at a million and 38 % at ten million; the ends are 0.9 % onpypiat the
smallest scale and 56 % onnumeric. Seven changes, each priced on its own:The entry headers are one stream a shard wide.
BDX2spent a byte on every front-coded entry's
(lcp, len)pair, four bits each, and two varints on every pair that did not fit. The pair
distribution inside one shard is far narrower than that byte: a frame of reference over a run
(min lcp,min len, two widths, all-ones escaping to the varints at the head of that entry's
suffix) or a learned table of the2^w - 1costliest pairs at one to ten bits each carries almost
all of them, and which of the two wins is a property of the shard, so one byte a group chooses it
from every run at once. That is why a shard is now collected before any of it is written — the
code cannot be chosen from a block — and both builds go through the same collection, so a streamed
blob is still byte-identical to one held in memory. At block 1024: paths 14.34 → 12.46 bytes a
key, urls 11.25 → 9.28, dna 7.70 → 7.27.A shard whose alphabet is narrow skips the symbols. A symbol table spends eight bits on a code
and earns them back by naming runs of bytes; on a shard drawn from four characters, or sixteen, or
sixty-four, that is the wrong trade twice over — two bits already meet the order-0 bound, and no
run of bases is frequent enough to pay for a symbol. The two are priced against each other on the
shard's own bytes, and each shard takes the winner, so a blob holding both shapes gets both. The
codes of a run are continuous — an entry's suffix starts where the one before it ended, mid-byte —
because padding each to a byte costs 0.32 bytes a key on DNA, the whole margin. At block 256: dna
7.36 → 4.34 bytes a key, opaque 13.24 → 10.36, numeric 1.39 → 0.98.uuidkeeps its tables on
every shard; seventeen characters do not fit a nibble.The block sample is taken past the prefix every head shares. A million URLs all begin
https://example.com/, so the eight bytes a block's sample held were the same word for 3 899 of
3 907 blocks and the binary search that opens every lookup answered nothing. The sample is taken
atg, the bytes every head in the blob shares — two bytes of header, none per block. Measured on
a million URLs: 165 samples duplicate their neighbour against 3 899, a probe's run of candidate
blocks is 2.02 against 3 271, and anidcompares 1.13 heads against 11.54. Sorting is unchanged,
since every head shares those bytes, and a probe that does not share them is answered by the
boundary rather than a search.What the whole blob repeats is named once. Front coding takes the head a key shares with the key
before it; what is left still repeats across blocks —/index.html,.example.com/,
- Wikipedia— and no symbol reaches past eight bytes. A dictionary of such spans is mined over
the blob's own suffixes and stored once; a shard that buys it gives up symbols for phrase codes,
a split atssymbols leaving255 - sbyte codes that name 256 phrases in one further byte or
65 536 in two, with 255 still the escape. A suffix is parsed by dynamic programming over the
cheapest coding in bits, and the miner is three rounds of that parse and a count of windows of up
to three adjacent tokens covering three to thirty-two bytes, keeping only candidates whose gain
clears six times what they cost to store. Mining stops after the first round unless one of three
sampled shards would take a split by 2 % on its own bytes — what the miner ranks is what coding a
span once would save, and what decides the format is whether a shard would rather spend those byte
codes on symbols; on a million opaque keys 40 000 spans clear the first bar and no shard takes
one. Each shard then decides for itself and has its table retrained on what its phrases did not
cover, and a blob no shard bought stores no dictionary. The vocabulary is a function of the keys
alone — the miner's candidate pools are a constant rather than the thread count, so the same keys
give byte-identical blobs on machines with one core and sixteen. At block 1024 over a million
keys: urls 9.26 → 7.27 bytes a key, English titles 9.08 → 7.21, paths 12.48 → 9.32.The per-block samples are derived, not stored. The binary search that opens a lookup runs over
eight bytes of every block's head, andBDX2carried those bytes a second time in a section of
their own. They are built from the heads at load instead: the same array, in the same place, for
eight bytes a block off every blob — 2.666 bytes a key to 2.635 on the word list at the default
block, 1.099 to 1.037 on a million numeric keys at 128 — and neither the load nor a lookup moved
(five corpora, load within 0.9 % andidandkeywithin 3 %, both directions).load_mmap
borrows every section there now is.A microblock's start is an offset inside its own block.
BDX2measured every microblock start
from the start of the blob, which needed au64base every 64 of them and a delta wide enough to
cross four blocks — thirteen bits an entry on the word list at the default block, and the third
largest section of a dictionary of decimal ids. A block's own start is a word the lookup reads
before it needs any of them, so the array is stored relative to it: no bases at all and eleven
bits an entry, 41 243 bytes against 52 493. It is smaller on every corpus and block measured
(twenty-four cells, eight corpora at 32, 256 and 1024): −0.04 % to −3.7 %, the small blocks
gaining most because they hold the most microblocks — numeric ids −3.7 % at 32 and −2.3 % at the
default, the word list −1.6 % and −0.6 %. It is also one load fewer on the search path, worth
−0.7 % to −1.3 % of the instructions anidretires and the same offkey_into.The vocabulary is the miner's, and nothing prunes it after. A candidate is kept when what it
saves over the whole blob clears its storage — the miner's bar carries ascale, because the
gains are counted on a sample of the suffixes and paid for by every key. A pass then re-asked the
same question with the scale taken off: it parsed those samples and dropped every phrase no
sampled piece picked. A phrase that earns its keep at one key in ten thousand appears zero times
in such a sample, so the pass could only remove what the blob-scaled test had already approved —
3 265 phrases to 1 461 on a million domains, 19 089 to 10 392 on a million paths. Measured over
ten corpora at four blocks, dropping the pass is smaller on 36 of 44 cells and larger on 8, every
loss under 0.17 %: domains −4.3 to −4.9 %, paths −2.0 to −4.6 %, identifiers −0.8 to −3.6 %, urls
and English titles at a hundred thousand −2.3 to −3.0 %, Russian titles −1.2 to −1.6 %. A variant
that kept the pass but only re-ranked, never dropping, lands within 0.08 % of no pass at all, so
the ordering it substituted is worth nothing. It costs the lookup, because a shard that reaches
more phrases gives up symbols for them:id+0.4 to +1.4 % andkey_into+0.7 to +4.8 %.End to end,
BDX2againstBDX3at the default block over a million keys each (lexindex build --index dict, sizes being a function of the keys): urls 11.25 → 7.51 bytes a key, paths 14.67
→ 9.87, article titles 9.37 → 7.37 in English, 11.36 → 7.67 in Russian and 8.28 →
6.27 in Chinese, DNA 7.70 → 4.37, opaque ids 13.80 → 10.40, numeric 2.13 → 1.01,
identifiers 6.66 → 5.23, domains 5.07 → 4.50, UUIDs 20.45 → 18.04; the dictionary's
479 823 words 2.84 → 2.64 and 889 864 PyPI names 4.91 → 4.17. That is 7.0 % off the word
list at one end and 52.5 % off decimal ids at the other. Againstmarisa-trieat its own best
num_trieson each corpus, that makesDictIndexthe smallest key-storing structure on ten of
the eleven sweep corpora at a million keys and on all six at ten million, where 3.0.0 gave
English titles and urls back to the trie — the two it takes back are won by 0.11 % and 0.33 %,
which is a tie and a lead rather than a comfortable win on either.
plan()prices the dictionary and the vocabulary's growth, so its estimate follows.What it costs to build. The price is the phrase machinery and nothing else, so it is paid only
by the corpora that buy phrases.lexindex build --index dictend to end, the two binaries
alternated X-2-2-X-X-2-2-X and the minimum of four taken: the word list 0.12 → 0.13 s at 1.29× the
peak, DNA 0.52 → 0.54 s at 1.51×, urls 0.53 → 0.86 s at 1.82×, paths 0.70 → 1.13 s at 1.69×. Most
of that wall clock is reading the file and sorting it, which the format does not touch; the build
call on its own is 1.04× on DNA, 1.31× on decimal ids, 1.42× on the word list, 2.30× on domains
and 3.20–3.75× on titles, urls and paths at a million keys. The miner's own cost is fixed — it
reads a bounded sample of the suffixes and holds a bounded pool of candidates — so it amortises
where a blob that size is actually built: at ten million keys the CLI is 1.20× on urls (6.77 →
8.15 s, peak unchanged at 977 MB) and 1.21× on English article titles (6.14 → 7.42 s, 937 →
977 MB), and the build call 2.42× and 2.09×. Turning the miner off outright is what names its
share: −70 % of the build's retired instructions on urls, −63 % on domains, and −3.1 % on the
word list, where the scout refuses after one round over a sixteenth of the pieces and the blob
comes out byte-identical either way — the 3.1 % is the scout's own price for a decision that saves
the other two thirds. That is what buys the wide end of the size range above; a corpus the scout
turns away keeps only what the other four changes give it.Those multipliers were measured before the parse was tuned, and the parse is a third of a build's
instructions. Six changes to it — the symbol table's eight bytes carried backwards rather than
re-read, the double array's step reading one record instead of two and needing no bounds check,
the parse token packed from twelve bytes into one word, one word a byte shared across the memo's
rows, and one induction variable in the walk — take a build's retired instructions, net of reading
and sorting the keys, down 9.9 % on urls, 9.6 % on English titles, 9.2 % on identifiers and
8.8 % on paths, and the build call itself down 2.4–3.6 % in wall clock; the blobs are
byte-identical. Dropping the pruning pass takes a further 5.2 % to 8.6 % off the same
measure. Corpora that buy no phrase, decimal ids and the word list among them, are flat by
construction.What it costs to read. Against
BDX2over a million keys at block 256, the two binaries
alternated X-2-2-X-X-2-2-X so that code layout is not mistaken for the format:idruns 0.80×
to 1.24× andkey_into0.77× to 1.46×.key_intois the lane that came out ahead — nine
corpora of twelve, by 23 % on UUIDs, 17 % on urls, 16 % on paths and 14 % on Russian titles — and
idis the one that did not: three of twelve, urls 0.80×, opaque ids 0.95×, UUIDs 0.95×. Behind
on both are decimal ids (1.24× / 1.46×), where the blob is 0.49× and aBDX2lookup is the
fastest of any corpus, and the word list (1.20× / 1.00×) and domains (1.13× / 1.02×), whose short
keys make the coded header the whole cost. A header that is coded rather than a byte is read with
a shift and a mask, and that is the price of the bytes it saves.BDX1andBDX2are refused by name, as one reader and not three, so a dictionary blob written
before 4.0 has to be rebuilt from its keys, whichBDX2stores: 3.x answerskey(id)over the
whole blob and 4.0 builds from the list.docs/migration-4.mdis the worked path, index by index,
with what happens to the ids.golden-2.2.0-dict.bdxjoins the refused fixtures andgolden-4.0.0-dict.bdxpins
the new bytes. -
plan()pricesBDX3, and theDictIndexestimate lands within 1.0 % of the built blob at the
median. Three changes, each scored on the same 69 cells — 23 corpora at each of the three priced
blocks.The old model read a blob as one byte an entry plus a compressed suffix, which is what
BDX2
wrote;BDX3codes the headers and can spend well under a byte on one, so the plan was charging
for a byte that is not there and discounting the suffix to match. The two are now measured apart —
the sample blob is walked rather than added up by section — and the phrase dictionary is priced as
its own term, per key, since it grows with the corpus and not with the shards the tables follow.The sample is built with the corpus's economics, not its own. The miner admits a phrase on what
it saves over the whole blob against what storing it costs once, so a hundred thousand keys afford
a tenth of the dictionary a million do: built as an index of its own, the sample reads the suffix
ratio 0.71 where the million-key blob spends 0.53 on article titles. No slope fitted below the
sample reaches the corpus either — the vocabulary is flat below a hundred thousand keys and falls
convexly above them — so the sample is mined at the corpus's key count instead, over every suffix
it holds, and the rate is carried flat. A million urls goes from 14 % high to 0.9 % low, three
million article titles from 24 % to 1.4 %. That vocabulary is bought once a plan, not once a
block: the three priced blocks read the ratio within 0.4 % of each other, and mining at each of
them was both two thirds of what a plan spends and less accurate — the smallest block's own draw
reads the ratio 21 % high on decimal ids.And what a stored entry costs comes off a second draw. A
(lcp, len)pair is coded against its
shard's own distribution, which is a property of the neighbours a key is coded against; a uniform
draw puts thosen / 100 000positions apart and read a million decimal ids 0.51 bytes a header
where that blob spends 0.34, a third of it. So the plan draws twice — uniformly for what a suffix
compresses to and what an fst spends on a node, and in runs of 4 096 consecutive keys for what an
entry and a block cost — both decided by hash, soplan_filemakes the same two draws over a
merged stream thatplanmakes over the sorted keys, and the two still print the same ladder to
the byte on a 7 343 721-line path list.1.0 % median, 4.2 % at the 90th percentile, 7.2 % at worst, against 9.0 / 14.2 / 23.9 for the
same model reading one flat-rate sample and 8.8 / 23.8 / 33.3 for the modelBDX3inherited.
Corpora whose suffixes repeat nothing — DNA, UUIDs, opaque ids — land inside 0.5 %; the spread is
an identifier list at 4.3 % low and a 7.3-million-line path list at 7.2 % high. The second draw and
the corpus-economy mine cost a fixed 0.35 s and 44 MB a plan, so the ratio is worst on the
cheapest plan and vanishes on the largest: 1.30× the wall time and 1.39× the peak on a million
urls, 1.22× and 1.62× on a million Chinese titles, 1.06× and 1.12× on ten million English ones,
and no change either way on the streamed 7 343 721-line path list, where the external sort's
run budget is already higher than the miner ever reaches.docs/usage.mdcarries the numbers and
says which way the estimate errs. -
The miner's gain maps are a third smaller, and the build spends 6–9 % fewer cycles for the same
instructions. A candidate span was keyed by au128— two 64-bit rolling hashes carrying its
length — and au128aligns a map entry to sixteen, so(key, (gain, span))was padded from 40
bytes to 48. Folding both lanes into one word takes it to 32. Nothing else moved: over fifteen
corpus-block cells the blob is byte-identical, and the instruction count falls 0.6–0.9 % while the
cycles fall 6.0–9.3 % — these maps were never the build's work, they were its misses.
reserve_rehashover them is 8.5 % of the build's cycles against under 1.5 % of its
instructions, and 39 % of its page faults, which is what the earlier profile could not explain.
A million urls, alternated A-B-A-B: 18.7 → 17.0 G cycles, 0.904 → 0.863 s, 115 k → 96 k faults,
peak 293 → 252 MB; a million paths 21.5 → 20.2 G cycles and 121 k → 108 k faults;plan, which
mines densely, 187 → 158 MB on the same urls. A sixty-four-bit key can collide, which credits one
span with another's gain and drops the loser from the dictionary — bytes, not correctness, at
about one run in forty million. -
The latency model is re-fitted to
BDX3, and the fit is worse where it should be. The
constants insrc/estimate.rswere fitted toBDX2lanes, so onBDX3they under-priced every
DictIndexlookup by about a third —plan(.., Objective::Latency)was ranking the dictionary
with a number the format no longer produces. Re-measured over the same 240 cells (four blocks ×
three sizes × the corpus set, one process,bench/latency_model.py measure) and re-fitted from
that artifact. Mean absolute error onid(key)is 6–13 %; held out a corpus at a time the model
still names the fastestid(key)in 30 of 30 cells, the fastestkey(id)in 30 and the fastest
under acommon_prefix-heavy workload in 30. Where it got worse is the one place the format
moved: picking betweenDictIndexandStringIndexheld out falls to 16 of 30 with a worst
case of 1.75×, from 24 and 1.24×, becauseBDX3raised the dictionary'sidlane by about a
sixth while the transducer's stayed where it was and the two now cross inside the corpus set
rather than outside it.prefix_countis fitted at 26–46 % mean error and is quoted as such: its
cost is dominated by how many keys the prefix matches, and the model has no term for that. -
The remap's samples are two levels, and cost half of what they did. Every 64 values of the
remap carried the position of their block's first one in au32, half a bit a value. The
position is what a lookup adds to nothing else, so what it actually needs is the offset from a
coarser mark: au32every 2 048 values and au16every 64 above it is 0.27 bits a value
against 0.5, and the two loads are independent — both tables are small enough to stay in L1 and
only their sum feeds the window's address, so the lookup gains an add and no dependent read. The
width is safe by construction rather than by luck: the low bits are chosen to leave the high
stream about as dense as it is sparse, so it holds about two positions a value and a super-block
spans about 4 096; measured on real tables of 1 M, 10 M and 50 M keys the widest block offset was
4 414, 4 710 and 4 789, a fourteenth of what au16holds, and a sequence that would defy that
is refused by name rather than wrapped. The remap's samples fall from 0.0071 / 0.0067 / 0.0065
bits a key to 0.0038 / 0.0035 / 0.0035 at those three sizes — 0.0033 / 0.0032 / 0.0030 off the
whole function, which is whereMPH3's last third of a hundredth of a bit was hiding. -
The perfect hash is 1 % smaller again and bumps a sixth fewer keys: a seed is chosen by the
product of its keys' positions, not their sum. Of the seeds that place a bucket,MPH3took
the one whose keys' in-slice positions summed lowest, since low positions are what the buckets
still to come cannot use. A sum is indifferent to how those positions are spread; the product
of the positions plus a constant — PHast'sShiftOnlyProdWrappedobjective, due to Piotr
Beling — wants the lowest of them lowest, which packs the front of every slice tighter where
the later buckets are placed. It is scored from a 16 KB table oflog₂in fixed point, one
entry a key a candidate; the search itself, its candidates and its windows, is unchanged.
Measured on 10 M real word-bigram hashes at the shippedλ4.5: 1.935 bits/key against
1.952 and 1.30 % of keys bumped against 1.57 %; at 100 M, 1.932 against 1.948 and 1.29 %
against 1.56 %. The constant is flat between 50 and 150 and 95 is shipped. The build runs
about 57 more instructions a key for the scoring, 2–3 ns of 63 on a hot machine, at the same
cycle count within noise. The seeds change, so thegolden-4.0.0-*fixtures are re-pinned; the
format does not. -
The checked lookups run on fewer instructions:
CompactHashIndex::id37 % fewer,
PerfectHashIndex::id53 % fewer.CompactHashIndex::idcalled its slot lookup and the
pair hash out of line and read every fingerprint through the bit-packed path with its overflow
and tail checks, and once inlined still paid four bounds checks and anOptionfor a table
whose length the loader had already checked. Now the lookup is inlined into the caller as one
unit and the default-width fingerprint is one byte load with no check, on the loader's
invariant: a blob's perfect hash is validated to map every hash into[0, m)when it is
parsed, and the fingerprint table to hold exactlymentries. 1 M URLs, keys read from
memory: 198 → 125 instructions a lookup (perf stat, exact counts; 16-bit fingerprints
200 → 178).PerfectHashIndex::idvalidated the stored key as UTF-8 before comparing it with
the probe — a pass over every byte that equality with a&stralready implies — and called
its slot lookup out of line: 420 → 198 instructions and 920 → 540 cycles a lookup on a hot
machine. And every index's byte source now resolves its pointer once, when it is built or
loaded, instead of on every access — a tag match, anArcdereference and a range check a
lookup: compactid125 → 115 instructions, perfectid198 → 180,DictIndex::id
5 490 → 4 950. In this regime the instruction count bounds how many keys' cache misses the
core overlaps, so it is the lever: the fingerprint derived from the hash's own state (below)
took the compact lookup from 117 to 65 ns on a hot machine where the in-cache hash alone
gained 5. -
The batch lookups prefetch a key's last line as well as its first.
ids_ofon the three
hash indexes pulled in the first byte of a key 32 keys ahead and left the rest of the key to a
demand miss; the hash reads a key to its end, and a key of a few dozen bytes lies across two
lines as often as not. Naming the last byte too: on 1 M URLsClosedHashIndex::ids_of32 → 17
ns a key andCompactHashIndex::ids_of47 → 22, on 1 M titles 20 → 12 and 29 → 18, on 1 M
paths 54 → 48 and 91 → 79 (hot machine, ratios). Cool, at the head of this release and with the
lookup work that followed, the same six read 12.8 / 14.6, 9.7 / 13.2 and 41.2 / 47.5 ns a key
(bench/results/mphf-strings-2026-09-17-*). A singleidcannot make that prefetch — it has no
next key to look at — and does not change. -
The key hash is new — branch-free over the key's length, half the time on real words — and
every hash blob moves toBMP8,BCH8andBCL2. The hash 2.0–3.x shipped read a key eight
bytes at a time in a loop and finished with a tail switch; on real keys 6 of its 9.5 ns were
branch mispredictions, and withMPH3at 3.8 ns a lookup the hash had become the cost of a
string lookup. The new hash reads a key of 4–16 bytes as four overlapping 4-byte loads, 17–32
as its first and last sixteen, longer keys through two multiply lanes over 32-byte blocks that
end on the last 32, and 1–3 bytes as one word; the words go through two 64×64→128 multiplies
folded to 64 bits, side by side, and one more that merges them with the length — no loop and
no branch on the length inside a class, and no bounds check: every load's index is in bounds
by construction, and the six checks LLVM could not prove cost 6–16 % of the hash on real keys.
The constants are consecutive words of π. The fingerprint is no longer a second pass: it
folds the hash's own two lane products under its own constants with the length rotated — one
multiply over the hash instead of a second merge and a second lane pair per block, so the pair
everyCompactHashIndexlookup computes costs 1.06–1.23× the hash alone where it cost
1.6–1.95× (URLs 13.2 → 7.8 ns, UUIDs 12.2 → 7.1, 125-byte paths 21.7 → 12.6), with the same
distribution battery result andhash_keyitself unchanged. Measured
A-B-A-B in one process on 2026-09-16, the hash alone over keys read in order: dictionary words
3.9 ns against 7.3, English titles 7.4 against 11.2, URLs 6.5 against 13.2; 8-byte keys are the
one loss, 3.8 against 2.9. The distribution battery (bench/results/hash-quality-*) reads the
same. End to end,ClosedHashIndex::idover 1 M URLs / UUIDs / titles / paths reads 43 / 39 /
28 / 65 ns against 54.5 / 46 / 32 / 92 in the same protocol over the old hash, a path that
includes the key's own cache miss (bench/results/mphf-strings-2026-09-16-*); PtrHash's fast
set over xxh3 of the same strings reads 55 / 50 / 38 / 100. Every blob keyed on the old hash would answer wrong ids under the new one, soBMP7,
BCH7andBCL1join the refused lists with a message naminglexindex < 4.0and the
rebuild;BIX4andOVL2are untouched, and the standaloneMPH1–MPH3tables
still load — a table is keyed on nothing but the hashes it was handed.hash_key_bytes
(featurebench-mphf) returns the new values. Rebuilding from the keys is the migration; the
golden-4.0.0-*fixtures pin the new bytes and the 2.0.0 ones become refused fixtures. -
The perfect hash is 6 % smaller and bumps half as many keys: its seeds gain modes, and the
blob format moves toMPH3. AnMPH2seed was one shift: a bucket's keys sit at fixed
offsets in their slices and the seed slides them along together, 255 positions at stride 2,
and a bucket two of whose keys share an offset can never be placed — that was half of the 3 %
of keys bumped to the next level. AnMPH3seed is two mode bits and six shift bits: each mode
reads a bucket's offsets from a different field of the hash, so the four modes are four
independent constellations of the bucket, each slid 64 positions at a stride of a sixty-fourth
of the slice, and the seed taken is the lowest-sum placement over all four. Measured on 10 M
real word-bigram hashes: 1.954 bits/key against 2.088, 1.58 % of keys bumped against
3.03 %, for 1.4× the single-thread build time (fewer than half of PtrHash's or PHast+'s). The
search runs on one occupancy word a key per mode — a key's 64 shifts are 64 consecutive bits of
one plane of the map, its run to the wrap and the rest before it — so the feasible shifts of a
bucket are the zero bits of an OR, and the lowest sum is at the first free shift at or after
some wrap: atrailing_zerosa wrap. Every hash blob written from here on carries anMPH3
table, which 3.0 cannot read; theMPH2tables 1.1 to 3.0 wrote still load standalone and
keep their seed geometry when written back. Thegolden-4.0.0-*fixtures pin the new bytes. -
A bumped key's remap is a sampled Elias–Fano stream, read in two steps.
MPH2sent a
bumped key through four dependent lines after its first-level seed: the next level's seed, a
rank word over the lower levels' values, the hole list's select sample and low word, its high
words.MPH3stores the remap as a non-decreasing sequence over every value of the lower
levels — a value no key takes repeats its predecessor's hole — so it needs no rank: the
values' high parts in one unary stream, au32sample a block of 64 values holding the
position of the block's first one, and a packed array of their low bits. A lookup reads its
block's sample — four bytes a block, under a megabyte for a billion keys, so in cache — then
counts through the four words from there with four popcounts and one select, beside its low
word; a block whose ones run past those four words, under one lookup in ten thousand on the
tables measured, goes on word by word. A bumped key is now its next level's seed, then its
four words and its low word side by side, andindex_allhas one prefetch stage fewer.
Measured on 10 M real word-bigram hashes: 1.917 bits/key, the remap 0.116 of them at
6 low bits, whereMPH2's rank vector and hole list would take 0.125 for the same sequence;
at 100 M, 1.915 and 0.114. Blocks of 128 values would save 0.003 bits a key and need eight
words a lookup: 5–20 % slower on a bumped key and 1.5–6 % onindex_all, hot.
AnMPH2blob's rank vector and hole list are decoded into the stream when it loads, so it is
written back asMPH3under its own seed geometry — not byte for byte, asOVL1becameOVL2
in 1.x. -
The perfect hash's tables ask for transparent huge pages. A table of a huge page or more —
the first level's seeds from about 9 M keys up — is allocated on a 2 MiB boundary and advised
MADV_HUGEPAGEbefore its first write, so a Linux kernel with transparent huge pages at
madvise(the common default) oralwaysbacks it with 2 MiB pages from the first touch, and
a random lookup stops missing the TLB on nearly every seed. Measured against the same process
with huge pages turned off: single lookups 10 % faster at 10 M keys and 3–4 % at 100 M,
batches 12 % and 2–3 %; bits and build unchanged. Nothing changes below 2 MiB, on other
systems, or where the advice is refused; the file-backed tables ofload_mmapcannot get
them. No dependency: onemadvisedeclaration. -
index_allfinds each key's bucket once and reads its geometry as immediates. The batch
loop pulled a key's seed in 64 keys ahead and then found the key's bucket a second time when
its turn came, multiplied the shift by a stride it read from the level, and kept the level's
geometry and its own bookkeeping on the stack. It now keeps the buckets it found in a ring of
64, shifts instead of multiplying, and runs each block through a function of its own with
the geometry every table of this version has — four modes of 64 shifts, slices of 1024 —
folded in as constants: 54 → 47 instructions a key. Measured A-B-A-B against the loop before
it: 7–10 % faster at 10 M keys, 3–5 % at 100 M; below 2^18 first-level seeds the batch is the
single lookup in a loop, as before, and unchanged. -
index_allpulls a seed in three times on a first level past L3, and a seed's prefetch
checks no range. The batch pulled each key's seed in once, 64 keys ahead. From about 75 M
keys the first level outgrows a client core's 16 MB L3 and its lines come from DRAM, and with
one issued every key that far ahead more are outstanding than the core has fill buffers for:
at a billion keys a fifth of the lines were loaded on demand all the same, a stall each, against
one in fifty at 16 keys ahead — a lead that was 20 % slower than 64 at 100 M. A first level of
16 MB or more now pulls a seed in 56, 28 and 14 keys ahead, so that a dropped prefetch is issued
again while there is still time, at the cost of a hit where the line has come. And no seed
prefetch checks its range any more — a bucket is below the table's length by construction, and
a prefetch cannot fault — a compare and a branch a key. Measured in one process against the loop
before, A-B-A-B on a hot machine: 3–7 % faster from 3 M to 60 M keys, 22 % at 100 M, 19 % at
300 M and 17 % at a billion (10.3 → 8.5 ns/key), 3–7 % on eight threads from 100 M up. -
A lookup reads its seed's mode from a table. A seed's top two bits pick which field of the
hash is a key's offset, and the lookup decoded them into a shift on every placed key: a copy of
the seed, a shift and a mask. A 256-byte table indexed by the seed holds the shift instead, one
load from lines that stay in L1. Measured against the lookup before, processes alternated A-B-B-A
on a hot machine, fat LTO: a single lookup 12 % faster at 10 M keys, 7 % at 100 M and 9 % at a
billion (18.6 → 16.9 ns), a loop collecting ids 8 %, andindex_all1–5 %. -
An index's own arrays ask for huge pages too, and
from_bytescopies a blob once. The
perfect hash's tables were on 2 MiB pages, but the arrays around them sat on 4 KiB pages, and a
PerfectHashIndexarena of a few hundred megabytes read at random missed the TLB twice a
lookup, for the key's offset and for its bytes. Every byte array an index owns — the arena, a
CompactHashIndex's fingerprints, aDictIndex's blocks, aStringIndex's transducer — is
now allocated as those tables are, on a 2 MiB boundary from a huge page up and advised before
its first write. The fingerprint table is built in place, andfrom_bytescopies the blob
once, where it copied it into aVecand then into anArc. Measured at 10 M URLs, UUIDs and
Wikipedia titles, processes alternated A-B-B-A against the build before on a hot machine, fat
LTO:PerfectHashIndex::id8–11 % faster,ids_of4–14 % andkey10–16 %, built or
loaded; its build 7–12 % faster, andfrom_bytes4.6–4.8× (0.86 → 0.19 s on 10 M URLs —
2.1× of that is the copy saved, measured with huge pages off).
DictIndex::id2–5 % andkey_into2–6 % faster, its batch within 1 % with huge pages on and
off in one build;CompactHashIndex::id1–2 %, its fingerprint table being one line a lookup.
The arena gains from 480 k words and 1 M URLs up (id5–10 %,key5–12 %); nothing
below 2 MiB moves —CompactHashIndexat 1 M keys,ClosedHashIndexat any size — and build
peaks are unchanged. A mapped index reads its file's pages, as before. -
Mphf::buildover hashes in any order: the serial sort is gone — 2.3–2.7× faster on eight
threads, and a third of the peak at 100 M. Hashes handed over out of order were copied and the
copy sorted on one thread before the first level could cut them into chunks: 22–25 ns a key on
one thread or eight, and a construction peak the size of the keys. Now each thread marks its
share of the keys with their group of chunks — two bits a key, four groups over equal shares of
the buckets — and counts them by chunk; a group's keys are copied out by chunk when its first
chunk is claimed, and each chunk is put in bucket order by one counting sort on the thread that
places it, whose cursors end as the chunk's bucket offsets. Grouping every chunk up front by its
buckets' two low bytes cost three times that sort, and copying all the keys at once is within
2 % of four copies of a quarter. Measured on splitmix64 hashes in generation order against the
build before, processes alternated A-B-B-A on a hot machine, fat LTO: 10 M 84.1 → 72.2
ns/key on one thread and 32.8 → 14.3 on eight, 100 M 87.1 → 71.8 and 35.3 → 12.9; the
construction peak above the keys 82 → 30 MB and 84 → 48 MB at 10 M, 818 → 261 MB and
820 → 280 MB at 100 M. The same hashes in ascending order build as before (61.8 / 10.6 ns at
10 M, 61.9 / 10.2 at 100 M), into the same table whatever the order and the thread count. Every
index here hands its perfect hash sorted hashes, so the gain isMphf's callers' — and the
comparison's with PtrHash and PHast, which bucket their keys inside the build in any order. -
Mphf::buildsearches a bucket's seed over fixed lanes: 1.8–1.9× faster on one thread, 1.3–1.4×
on eight, and ahead of PHast+ on both. The seed search spent its time on loops whose trip counts
the branch predictor could not know — one a key a mode, a candidate loop a wrap, and a collision
check that struck candidates out one at a time — and its placement order was a queue a size class
under a binary heap, whose updates were most of the placement's instructions. Now a bucket of 1 to
16 keys runs through a function of its own width, every loop's count known to the compiler. Two
keys can meet under a mode's shifts only if their values are equal modulo the slice, so a table of
the mode each residue was last seen in sends a mode to the exact check only when two of its keys
agree, and the shifts a meeting rules out are a span of the mode's word; the first free shift at
or after every wrap comes out of one addition, and a seed's score and the seed are one key, so the
lowest needs no order. The buckets wait in a bitmap of priority slots — a bucket's index less its
size class's term, in whole buckets, and a place in the slot the class fixes — taken from the
lowest set bit behind a cursor. The seeds, and so the tables, are byte-identical, and a test holds
the lanes to the search they replace over 24 000 buckets. Measured withbench/mphf_vson a cool
machine, splitmix64 keys in generation order, against the run before: 75.2 / 70.6 / 70.3 →
42.8 / 37.5 / 37.5 ns/key on one thread at 1 M / 10 M / 100 M, 16.5 / 13.6 / 12.3 → 12.3 /
10.6 / 8.7 on eight, and 12.8 → 7.7 s for 10⁹ keys on eight; PHast+ builds the same keys in
55.4 / 60.2 / 67.9 ns on one thread, ahead of the old build at every size, and 17.9 / 15.4 / 16.5
on eight. -
Measured against PtrHash and PHast on a cool machine, with three faults in the comparison
harness corrected.bench/mphf_vsat 1 M / 10 M / 100 M splitmix64 keys, one process, A-B-A-B:
MPH31.929 / 1.918 / 1.914 bits/key, the smallest function in every table, builds 42.8 /
37.5 / 37.5 ns/key on one thread and 12.3 / 10.6 / 8.7 on eight, the fastest in every table,
single lookups 1.9 / 2.6 / 10.0 ns, batches 2.2 / 2.5 / 3.5, a construction peak of 6 /
30 / 262 MB above the keys — againstptr_hash2.1.1's compact set at 2.143 bits, 148–165 ns
builds, 4.0 / 5.5 / 16.8 lookups, 4.2 / 4.7 / 6.2 batches and 16 / 156 / 906 MB; its fast set at
2.990 bits, level on the single lookup at 1 M, 1.9; and thephcrate's PHast+ at 2.146–2.160
bits, whose builds read 55.4 / 60.2 / 67.9 ns on one thread and 17.9 / 15.4 / 16.5 on eight. The
harness sorted the keys for its dedup, and every build took them sorted:Mphfskips its own sort
on sorted hashes, while PtrHash and PHast bucket their keys whatever the order, so every earlier
table readMPH3's build at 55–58 ns on one thread with a 60 MB peak at 100 M; the keys are built
on in generation order now. Thephcrate's PHast+ hashes with wyhash in the harness: its default
is std's SipHash-1-3, which every earlier table timed, so its rows read 2–4× slower than the crate
intends (PHast+ 8.8 → 3.5 ns at 1 M with the right hasher, its build 69.6 → 58.3). And the
harness's lookup loops were inlined into one function beside each row's build, which cost lexindex
0.7–3.6 ns a lookup and the other rows nothing measurable; each row's loop now stands alone. The
tables and the corrections are indocs/benchmarks.md; the string-to-id path end to end, on real
key corpora, isbench/mphf_vs'sstringsbinary andbench/results/mphf-strings-*. -
Mphf::index_allanswers the keys the first level bumps in staged prefetches, and the single
lookup ranks and selects off derived counts. The batch used to pull in only the first level's
seed, sixteen keys ahead, so each of the ~3 % of keys that level bumps made four more dependent
misses in turn — its next level's seed, the rank words, the select sample, the high words — and
at 100 M keys the batch stopped at 13.5 ns a key against 6.9 for PtrHash'sindex_stream. The
keys now go through the first level in blocks of 1024 with each seed pulled in 64 keys ahead,
and a block's bumped keys are answered after it in five stages that run eight keys apart, each
stage pulling in what the next one reads; below 2^18 first-level seeds (about 1.2 M keys) the
level sits in L2 and the batch is the single lookup in a loop. The single lookup gains too: the
remap's rank reads a 16-bit count per word instead of counting its block's words in a loop, and
the Elias–Fano select compares a window of counts at once and finds the bit inside its word
without a loop, where the old scan's mispredicted exits were most of a bumped key's cost. Both
count directories are derived on build and on load and never written — the blob format is
unchanged — and cost 0.023 bits a key in memory. Inbench/mphf_vsat 1 M / 10 M / 100 M keys:
the batch 4.3 / 5.3 / 13.5 → 3.2 / 4.2 / 5.8 ns, ahead of PtrHash compact and balanced
(4.6 / 4.9 / 6.3) at every size and never above the single lookup; the single lookup 3.7 / 5.8 /
20.1 → 3.2 / 4.9 / 15.8 ns (compact 4.2 / 6.0 / 17.6). PtrHash's fast set, at 2.99 bits,
still leads both lookup columns outright.docs/benchmarks.mdcarries the re-measured tables. -
planprices eachDictIndexblock as a candidate of its own. The block is a knob worth
2.51 against 2.85 bytes a key on an English word list — a spread wider than the gap between some
whole indexes — and the ranking hid it by pricing the default, 256, and nothing else.Fast
(32),Balanced(256) andCompact(1024) are three rows on the ladder now, each with its own
size and modelled latency,Plan::bestmay name any of them, andlexindex build --index auto
builds the block on the line it marked rather than the default one. On an English word list the
three are modelled at 2.83, 2.61 and 2.48 bytes a key against built blobs of 2.85, 2.64 and 2.51
— every one of them within 1.3 %. Two consequences inside. The
sample is fitted per block — the compression ratio, the packed per-block arrays and the symbol
table all move with the block, and carrying the default block's constants across read 9.5 % high
at 32. AndPlan::closecompares the two cheapest candidates of different kinds: two blocks of
one index are always within a per cent or two of each other, and warning about that would be
warning about every plan.--blockis still refused alongside--index auto, now because
naming one would ask the planner to rank the three and then overrule its answer. -
The planner draws its sample by hash instead of by position.
step_by(n / 100_000)was
neither a sample nor 100 000 keys: integer division gives a step of one at 150 001 keys, so "the
sample" was the whole corpus and every constant was read off a build the size of the real index —
and above that, a stride over sorted keys takes one key from every prefix group whatever the
group's size. A key is kept now while its hash is under a cutoff that falls as the draw fills:
a uniform sample of the distinct keys, the same one on every run over the same keys, needing
neither the corpus's size in advance nor the corpus in memory — which is what letsplan_file
answer exactly asplandoes. It is also the more accurate draw. Measured over 23 corpora at all
three blocks, the modelledDictIndexsize is within 1.3 % of the build at the median and
5.1 % at worst outside the two corpora the plan flags as thin (from 1.4 % and 5.4 % for a
stride), and theStringIndexestimate from 3.4 / 9.8 % median and 90th percentile to 3.0 / 7.6.
Against thestep_bydraw this replaces, on the corpora it read worst:idents-full12.3 % →
2.5 %,titles-zhat a million keys 15.5 % → 3.0 %,titles-zhwhole 26.3 % → 7.1 %. -
lexindex planandlexindex build --index autonow assume an open vocabulary, which means
they imply--exactand so rank only the three indexes that can tell a stranger from a member.
Left to itself the ranking was won byClosedHashIndexat 0.24 bytes a key, which answers a key
it has never seen with some member's id — the right index for a fixed vocabulary, and a trap for
a reader who has not decided yet. The two probabilistic indexes are put back by
--closed-vocabulary, and the ladder says on stderr that they were left out and how to ask
for them; that line is printed only when the default is what excluded them, since any other need
rules them out anyway. Naming one with--index closedstill builds it. The library is
unchanged:Needs::default()asks only forid(key)in Rust and Python, becauseplan's
caller there has already decided and a command line's has not. -
A new blob format now ships beside a reader for the old one. 1.0 and 3.0 each refused what
the previous major wrote; from 3.0 on a writer change lands in a minor release whose loader
still reads the previous format, and dropping a reader is reserved for a major withlexindex dumpas the published way back.BDX2is frozen under that rule.docs/design.md § Versioning
carries it. -
SECURITY.mdnames the supported version. Its table still said 2.1.x, and promised that 2.1
blobs load — 3.0 refuses theBDX1dictionary. Now: 4.0.x supported, and every row below it says
which of its blobs this version refuses and which two still load — the new key hash retires
BMP7,BCH7andBCL1alongsideBDX2, so a 3.x install has four of five indexes to rebuild.
Itsunsafeinventory was recounted with it: thirteenunsafe fns and thirty-eight blocks in
the library, not the nine and eleven it still claimed. Four of the functions and twenty-seven of
the blocks are this release's own — the key hash's unchecked loads, the huge-page allocator, and
writing into aVec's spare capacity before extending it — and the document had described
unsafeas confined to memory mapping and one prefetch, which stopped being true in 4.0. -
Stale figures in the docs and the source comments, corrected to what the code does. The
DictIndexexample in the README quoted 3.2 B/key (the block-32 figure) anddocs/usage.md
2.9, where the default block measures 2.64;src/offsets.rsdescribed two packed arrays where
there are three, and priced them at the old block;docs/design.mdgave the per-block
directory as 0.147 B/key and 5 % of the blob, themicro = 16figure, against 0.100 and 3.5 %
measured at 32; the microblock was documented as one per sixteen keys and the symbol table as
one per index rather than one per shard;BDX2was attributed to 2.2. The README's capability
table now carries whatload_mmapdoes not borrow — the per-block samples, eight bytes a block. -
The research frontier is cited, not just named.
docs/benchmarks.mdtables PDT, SuRF/FST,
XCDAT, CoCo-trie, C² and the front-coding line this index descends from (libCSD, IBiS) with
their papers, repositories and licences, and the README andbench/compare.pypoint at it. -
bench/sweep.pyprices marisa at eight and sixteen tries as well. Four tries is its floor on
an English word list and was taken for its floor everywhere; on keys with long shared prefixes,
and on random identifiers, the deeper recursion is smaller. -
bench/mphf_vsmeasures the function, not the caller's key array. Its lookup pass read
each probe key throughkeys[order[i]]— a random 8-byte fetch from an 80 MB array per query,
one DRAM miss charged to every row alike — and the perfect hash's lookup was published as 29 ns
at 10 M; with the keys copied into an array in probe order it is 5.8 (PtrHash compact 6.6,
PHast+ 14.2), the builds unchanged. The harness now prints every minimum's spread and a batch
column (index_allagainstindex_stream), anddocs/benchmarks.mdcarries the re-measured
comparison at 1 M, 10 M and 100 M keys — where the 100 M batch is the one column PtrHash leads,
6.9 against 13.5 ns — with ConsensusRecSplit on the authors' harness in its own process:
1.46–1.58 bits a key at 7–49× the build and 15–27× the lookup. The 2026-09-10 results file stays
as measured. -
The fuzzers run daily, on four processes for thirty minutes a target. They had ten minutes a
week on one core insidesanitize.yml;fuzz.ymlgives each target two CPU-hours a day,
starting from the corpus the last run left and minimising it withcargo fuzz cminbefore it is
kept, so what the cache carries is the smallest set that reaches the same coverage rather than
every file a month of runs ever wrote.
Added
-
A wheel for free-threaded CPython 3.14. The module has told CPython it does not need the GIL
since 0.12, and CI has run the suite on3.14tsince then — but the published wheels areabi3,
and free-threading has no stable ABI before 3.15 (PEP 803 putsabi3tthere), sopip install
on a3.14tinterpreter fell through to the sdist and compiled the extension itself. It now finds
lexindex-<version>-cp314-cp314t-<platform>.whlfor the same six platforms the abi3 wheel covers,
minus the cross-built macOS x86_64 one — a version-specific build needs the interpreter it is
built against, which a cross build does not have. The wheel is the same source with the same
guarantees, and its smoke test assertssys._is_gil_enabled()is false after the import, so a
release cannot quietly ship a module that switches the GIL back on. -
Kind::answersis
public: whether an index of that kind answers everything aNeedsasks for, which is whatplan
filters its five candidates by. A caller that presents the ranking can now say why a kind is
missing from it without restating the capability table — which is exactly what the command line
above needed. -
lexindex inspect <blob> --sectionsand
DictIndex::sections,
which return aDictSections: where a dictionary blob's bytes actually go. Thirteen byte fields
that sum to the blob — the symbol tables, the block heads, the samples, the three packed offset
arrays, and the front-coded data split the way it is written, into one header byte an entry, the
(lcp, len)varints of the entries too wide for that byte, and the symbol-coded suffixes — plus
the counts of restarts, entries and wide headers. The sum is exact for any blob, including one
this crate did not write: the codes are what is left after the headers rather than what a walk
managed to read, so a damaged stream leaves the split approximate and the total right. On an
English word list at the default block it reports 56 % suffix codes, 34 % header bytes and
3.3 % directory, which is the measurement a change to the format has to start from. -
A C ABI, under the
capifeature: one opaqueLexindexIndexhandle over the five indexes
and fourteenlexindex_*functions — open, from bytes, build, save, kind, len,id,ids,
contains,key, free, and the version, ABI-version and last-error accessors — declared in
include/lexindex.h, whichcbindgengenerates fromsrc/capi.rsand CI regenerates to check.
Every fallible call returns aLexindexStatus; the message behind a failure is thread-local and
read withlexindex_last_error. A C caller opens a blob it may not have written, so the kind is
a run-time fact and one handle type serves all five: what a kind cannot answer —keyon the
two that store no keys,containson the bare perfect hash — isLEXINDEX_STATUS_UNSUPPORTED
rather than a missing symbol.cargo build --release --features capiexports it from the
cdylib;cargo rustc --release --features capi --crate-type staticlibgives the archive.
examples/capi.cwalks the whole surface and runs in CI against the built library. The ABI has
a number of its own,LEXINDEX_ABI_VERSION, append-only within a crate major. Not in it, each
additive when asked:Overlay, the mmap loaders, the automata queries, the fingerprint and
block knobs ofbuild. -
plan_file(path, needs, objective):
the ranking of a keys file, without ever holding it. The file is sorted externally — runs in
memory, spilled beside it, merged back — and the merge is where the key count, the mean length
and both shared-prefix distances are counted, exactly rather than from a sample; only the
three constants no statistic gives still come from one, and that sample is drawn by hash, which
does not need the corpus in hand. So this is not an approximation ofplan: on a 925 MB,
7 343 721-line path list the two print the same ladder to the byte, at 0.27 GB of
resident memory against 1.30 and in less wall time.lexindex plan <file>andlexindex build --index autouse it, so a ranked build now streams like a named one — where--index autoused
to read the corpus in and say on stderr that pricing it meant holding it. Standard input still
cannot be planned this way: it cannot be read twice. -
plan_for(keys, needs, objective)
andObjective: the ranking
can be for something other than the blob size.Memoryis whatplanhas always answered and
stays the default —planis exactlyplan_for(…, Objective::Memory), and its signature has not
moved.Latencyranks by the modelled cost of oneid(key);Balancedpicks whichever
candidate gives up least on the axis it does worse on. The candidates and their sizes do not
change with the objective, only the order and soPlan::best. On an English word list asked for
a reverse lookup,MemoryanswersDictIndexat 2.48 bytes a key and block 1024,Latency
answersPerfectHashIndex, four times the size and a third of the wait, andBalancedanswers
the same dictionary at block 32 — 17 % faster for 14 % more space.lexindex plan|build --objective memory|latency|balancedand Python'splan(..., objective=...)reach it, and every
estimate now carriesnanos. -
Objective::Workload: rank
by what the index will actually be asked. AWorkloadweighshits,misses,reverse,
prefix,common_prefixandlongest_prefixagainst each other — relative weights, so counts
read off a log serve as they are — andbatchsets the keys anids_ofcall holds. Every
candidate is priced at the workload's mean operation from constants fitted per operation, and one
that cannot answer an operation prices itself out. An operation is a need as well:reverserules
out the two indexes that store no keys and a prefix query asks for an ordered one, and
Plan::needssays what
a ranking was made under. The ordered pair is where it decides something: on an English word list
common_prefixnine to one against hits answersStringIndexat 420 ns against 1 502 for the
fastest dictionary,prefixin the same proportion answersDictIndexat block 32, 298 ns
against 824, and batches of 1 024 leavePerfectHashIndexfirst at 74 ns a key. A workload that
asks nothing isLatency.lexindex plan|build --objective hits=9,misses=1,batch=64and Python's
plan(..., objective={"hits": 9, "misses": 1, "batch": 64})reach it. -
Plan::nanos, and a
nanosecond column on the ladder.a + b·s + c·len + d·s·lenper structure, operation and
DictIndexblock, wherelenis the mean key length andsislog2of the candidate's blob
over 64 KiB — what a lookup waits on is how far its bytes are from the CPU, which the key count
alone does not say. Fitted on relative error to 240 cells: eight structures over thirteen corpora
at 100 000 keys, eleven at 1 000 000 and six at 10 000 000, timed in one process with every
operation over fresh probes of its own and every structure timed at every place in its round.
bench/latency_model.pyis the harness; itsfitre-derives the tables insrc/estimate.rs
from the artifact inbench/results/, so the constants are checkable rather than asserted. Mean
absolute error 5–13 % onid(key), 23–28 % at worst on prefix counts. It is an ordering, not a
prediction. Fitted with a corpus left out, it names the fastestid(key)on that corpus in 30
of 30 corpus-size cells and the fastest ordered index in 24, never picking one more than 1.24×
slower; against the published sweep, which it was not fitted to, it picks betweenDictIndexand
StringIndexthe way the measurement does in 24 of 30 cells, never one more than 1.20× slower.
And because the cells were timed through the Python binding it reads 311 and 318 ns for
StringIndexandDictIndexon the word list where the Rust harness indocs/benchmarks.md
measures 234 and 266; that overhead falls on every candidate alike, so it moves the numbers and
not the ranking. The ladder says so on its last line, every time it prints one. A corpus under
100 000 keys is priced as its 100 000-key equivalent, and the blob and key length are held at the
smallest the fit saw rather than extrapolated past the evidence. -
lexindex build --stream: the command line builds without holding the corpus. With a named
--indexand a keys file,buildfeeds the file into the streaming builders instead of reading
it into aVec<String>first —auto(the default) past a quarter of the memory
/proc/meminforeports available,always/nevereither way, or a size (1000,512M,
4G). Measured on a 925 MB, 7 343 721-line path list, the peak resident set falls from 1.34 to
0.28 GB forstring, 1.42 to 0.26 fordict, 1.29 to 0.15 forcompactand forclosed, and
2.96 to 0.97 forperfect— whose blob is itself 935 MB, so it is the one index that cannot
finish below the file it was built from. All five blobs are byte for byte the ones the
in-memory build writes:--streamis a memory setting, never a format. Standard input is never
streamed, because the dictionary builder reads its source three times and a pipe cannot be
rewound.--index autostreams as well, sinceplan_fileprices a file without holding it
either: the ladder comes off the streamed plan and its winner is built the same way. -
ClosedHashIndex::build_to_file,
the fifth and last index to get one, so every kind the command line can build now has a
streaming path. One pass hashes each key to its 16-byte pair and drops the string; runs are
spilled and merged beside the output and the perfect hash is built from the merged file. There
is no second pass — this index stores nothing per key, so what is left in memory is the perfect
hash itself, which is the blob. It keeps the checkbuildmakes, one bit per slot, that the
construction was minimal and perfect: an index that stores nothing per key has no second chance
to notice that two keys were handed one id. -
lexindex dump <blob>, the fourth subcommand: the keys a blob holds, one per line, in id
order — sorted order forStringIndexandDictIndex, construction order for
PerfectHashIndex, live keys only for anOverlay.lexindex dump old.blob | lexindex build - new.blobis a migration off any format a later version stops reading, and it round-trips:
rebuilding from a dump reproduces the blob byte for byte. The two keyless indexes say so rather
than writing an empty file, and a key holding a line break is an error naming its id rather than
a line a builder would read back as two keys. -
The research frontier, measured.
bench/frontier/builds the C² paper's benchmark — C²-FST,
C²-CoCo and C²-MARISA beside FST, CoCo-trie, MARISA, PDT, ART and C-ART, every dependency at a
pinned commit — and XCDAT, and runsDictIndexandStringIndexunder the same protocol: one
build, then one cold pass over every key in a fixed shuffle, three rounds a structure, each
process waiting for a quiet machine.docs/benchmarks.mdtables it at a million and ten million
keys. lexindex is on the size–latency front on all thirteen corpora and all six;DictIndexis
the smallest structure on twelve of the thirteen at a million keys and five of the six at ten
million, the fastest ondnaat ten million, and builds before every compressed trie on every
corpus. The one corpus that keeps a trie smaller isnumeric, whereStringIndexholds the whole
corpus in a few hundred bytes; C²-MARISA reads faster thanDictIndexon ten of the thirteen
and is larger on all of them. Nothing of the competitors is
vendored — CoCo-trie is GPLv3, PDT non-commercial — andbench/tables.pyrenders the published
frontier tables from their artifacts and checks them like the rest.
Fixed
- A streaming build no longer opens one file per spilled run. The external sort merged every
run it had written in one heap, a descriptor and a 1 MiB read buffer each — so the resident cost
of the merge followed the corpus, in a builder whose whole point is that nothing does. At 256 MiB
of keys a run, a thousand runs is a corpus of ~256 GiB: a gigabyte of read buffers, and a
descriptor count that outruns a 1 024 soft limit wherever one is still in force. The runs are now
collapsed in groups of 128 first, so a merge opens at most 128 readers whatever the corpus was —
one extra pass over the spilled bytes per factor of 128, and the common case, a build that spills
fewer runs than that, does no extra work at all. - The hash battery reported 99 999 collisions in 100 000 keys on a machine without a
dictionary — its first scheduled run, on a CI runner. The hash was not at fault: thepaths
family was spelled from dictionary words outside the check that there is a dictionary, and the
stand-in word made it one key repeated a hundred thousand times. The families that need words
are built only where the words exist now, and every family is checked for a repeated key before
its collisions are counted. Every number the battery prints is unchanged. StringIndex::from_untrusted_bytesrefuses a hostile transducer structurally, so it no longer
depends on unwinding. The loader caughtfst's decoder panic and returned it as an
IndexError, which is not a defence underpanic = "abort"— an embedded profile, a size-tuned
wasm build — where the same blob aborted the process. The cause was upstream and specific:
fst::raw::Fst::newreads the root address out of the footer and then checks it only when it is
zero, its(root == EMPTY && len != empty_total) && root + addr_offset != lenshort-circuiting
on the first conjunct, so a blob naming a root past its own end is accepted andFst::root
indexes the blob with it — address 881 into 107 bytes for the committed
tests/data/panicking-1.0.0-string.bix, raised insidefstbefore this crate sees a node. Every
address the walk hands tofstis now measured against the blob first, from the fields that
decide how many bytes a node occupies — the state byte, the pack sizes, the transition count and
the packed deltas — and a node claiming more than sit below it, or a transition whose delta would
be subtracted past the blob's start, is refused before the decoder is called. It is a bounds
check and not a second decoder: it reads no input byte, no output and no transition index,
because none of those can move a read. Thecatch_unwindstays as a backstop for a decode this
crate has not modelled.tests/data/unfitting-4.0.0-string.bixpins the second shape, 40 bytes
whose pack sizes underflow the node's end address, found in the first seconds of a fuzz run
against an aborting build; both specimens now come back asFormat, andSECURITY.mdno longer
carries the exception. What the claim rests on: the same target rebuilt withpanic = "abort",
where a panic is a crash and not a caught error, ran 3 832 487 116 executions over 24 CPU-hours
without producing one.- The weekly 32-bit Miri job stopped at the first overlay test that streams a perfect hash into
a file. The arena is written through a file mapping, which Miri cannot interpret, so that test
and every overlay test after it went unchecked on a 32-bit target. The mapping half of the two
tests that reach it is compiled out under Miri; the rest of both still runs there. - An
MPH2blob whose remap ran backwards panicked the loader. The v2 upgrade path decodes the
older remap and re-encodes it as the sampled Elias–Fano stream, and it checked that every decoded
value lands inside the image but not that the sequence is non-decreasing. The high parts arrive
in order because the bitmap is walked in order; the low part under a repeated high part is
whatever the blob says, so two entries sharing a high part could come back out of order and reach
Remap::encode, which takes a non-decreasing sequence by contract. Now refused as aFormat
error at the boundary, where the range check already was. Found by the nightly fuzz job's
parse_mphftarget, which had been red since 2026-09-16; the specimen is
tests/data/panicking-3.0.0-mphf.bin. - An
MPH3blob whose level claimed a one-value slice panicked the loader. A level's stride is
its slice shifted down by the geometry's mode bits, so a slice narrower than one shift leaves a
stride of zero and a shift of 64 — an address the level is then read at with whatever the
arithmetic wraps to. The loader checked that the slice is a power of two and fits the level, but
not that it is wide enough for the stride it implies;slice_fornever returns under 256, so no
build writes one. Now refused as aFormaterror beside the other three shape checks. Found by
the sameparse_mphftarget on the run that followed the fix above; the specimen is
tests/data/panicking-3.0.0-mphf-slice.bin.