Repository navigation
Releases: ilgrad/lexindex
Releases · ilgrad/lexindex
Release list
lexindex v4.5.3
Added
- CPython 3.15 and 3.15t. One
cp315-abi3.abi3twheel (PEP 803) for free-threaded 3.15 and
later, which a GIL 3.15 also takes; thecp311-abi3wheel still serves 3.11–3.14 andcp314t
stays. CI runs the Python tests on 3.15, and on 3.15t with the GIL off. PyO3 0.29 → 0.30.
lexindex v4.5.2
Fixed
- A crafted
DictIndexblob whose restart keys run out of order could make a routed lookup
panic. Since 4.4.0route_microblockssends a lookup to its microblock by words it reads off
each block's restart keys, and the route counts the bytes the probe shares with the word below
it. That count stays inside the probe only while the words ascend, and nothing checked that they
did: in a blob libFuzzer's dailyparse_dictrun found, the word taken as below was above and
agreed with the probe's zero padding, so the scan began past the probe's end. A debug build
panicked on the subtraction; a release build compared the stored suffix against more padding,
and a dictionary in a packed code slices the probe from there, which panics in any build. Every
read is bounds-checked, so none left the probe. Words out of order now leave their block to its
restart run. Only a dictionaryroute_microblockswas called on was exposed, a
HashedDictIndex's included; an index this crate built always has its words in order, so none
changes. The blob is kept with a test that fails without the check
(tests/data/panicking-4.5.0-dict-route.bdx).
lexindex v4.5.1
Fixed
- A
DictIndexlookup at ten million keys and block 32 is back at 4.4.0's time; 4.4.1 had made
it up to 23 % slower. 4.4.1 gave every run of two or more blocks whose samples tie a trie of
their words — a threshold measured on file paths, whose runs are 183 and 3 572 blocks long. Where
runs are short, the trie's reads (its roots,
a node, three edge arrays megabytes apart) replaced two or three head compares on adjacent lines:
over ten million DNA reads at block 32 all 312 500 blocks sit in 65 536 runs of 3–7, so every
lookup took a trie, 6.3 MB of them, for 6.4 more L2-missing fills and 2.3 more page walks a probe.
Only a run of eight blocks or more gets a trie now. In a quiet A/B, three processes a variant,
alternated (bench/results/ties-ab-2026-09-25-arz-2be75f3.txt): DNA 724 → 595 ns (4.4.0: 588),
the routed layout 705 → 605 (600), English titles 677 → 661 (646), URLs 774 → 747 (738); paths at
block 256 keep 4.4.1's gain, 700 against 4.4.0's 782; and DNA at 1024, where no samples tie, reads
472–475 in all three. A minimum of 32 read as 8 does on titles and URLs, 660 and 749. Blobs are
unchanged. - On a 32-bit target, a crafted blob of a hash index could make a load panic rather than fail.
The count of the perfect hash's remap low words was multiplied inusize, which wraps there for a
header claiming enough entries over a wide enough image; the table came out short, and only the
bounds checks on its reads kept a load inside it, by panicking. The count is now checked, and such
a blob is refused as too large — which the remap's reads, unchecked since this release (below),
depend on. A property test holds the count to the exact one worked out in 128 bits.
Changed
DictIndexbuilds 9–33 % faster, on every one of 13 corpora at blocks 32, 256 and 1024, blobs
byte for byte the same. A build already ran on every thread; the gain is in the part no thread
shared. Training draws
its samples from the runs it keeps rather than walking every key; the order check and the key
view's page faults run on every thread; the phrase trie a round builds is laid out 64 slots at a
time and reads a candidate's head in one load; and a round ranks its candidates by merging the
partitions' sorted leads. In a quiet A/B, one build a process, 11 rounds alternated
(bench/results/dict-build-ab-2026-09-25-arz-8c99766.txt): English titles at block 32 397 →
349 ms, Chinese titles 272 → 224, URLs 404 → 367, numbers 85 → 60 — the least gain 9.3 % (URLs),
the most 32.5 % (numbers at 1024). ART, the quickest builder in the string-index suite, built the
English titles in 161 ms in its 4.5.0 campaign, where lexindex's quickest took 400: the gap is
narrower, not closed.DoubleArrayIndexbuilds in under half the time, and maps its file as fast as darts-clone opens
one. Over jieba 0.42.1's 349 045 words
(bench/results/cjk-build-load-2026-09-25-arz-6623184.txt), a build takes 0.159 s where 4.5.0
took 0.354, 6.49 G instructions → 2.49 G: the trie is built from the keys in byte order, each
decoded once; a narrow row's search tests a sparse stretch of the array word by word; and the
placement tests four words an instruction where the CPU has AVX2. darts-clone still builds in
0.055 s and cedarwood in 0.099 — 2.9× and 1.6× quicker. A load's walk over every slot, which is
what lets no query check a bound, now runs on the slots' 32-bit halves, eight slots an AVX2
instruction or four with SSE2, 15.0 M → 2.7 M instructions:load_mmap1.03 → 0.62 ms in a
pairwise run against darts-clone'sopenat 0.59–0.66 in the same processes, and 0.70 against
0.81 in the table's run;load2.1 → 1.6. The same slots are refused, and blobs are byte for byte
4.5.0's.- The perfect hash's lookup: a key the first level bumps runs 22.5 % fewer instructions, and the
single lookup is ahead of PtrHash's fast set at a million keys where it was level. The seed's
shift is added to the hash before its field is cut out, one instruction fewer on every placed key.
A bumped key decodes its later level through the step table the first level ships; counts the
remap's window withpopcntwhere the CPU has it, found by std's cached detection — every other
target and Miri run the portable build, and a property test holds the two to the same answers;
and reads the remap's tables without a bounds check past the one on its entry, on lengths and
samples the loader has validated. Per key at a million keys, the harness's loop included: 284.8 →
220.7 instructions a bumped key, 24.7 → 22.8 over all keys, and 2.85 fewer in theidof
ClosedHashIndex,CompactHashIndex,HashedDictIndexandPerfectHashIndex. In a cool A/B of
bench/mphf_vs, one lookup thread, six processes a commit
(bench/results/mphf-cool-ab-2026-09-25-arz-e523ecd.txt): the single lookup 1.8 → 1.65 ns at
1 M against fast's 1.8, and 2.5 → 2.4 at 10 M against 2.9;index_all2.0 → 1.9 and 2.5 → 2.45,
against 2.4 and 2.8.
Documentation
SECURITY.mdcounts 21unsafe fns and 62 blocks, the three builds for an instruction the
x86-64 baseline lacks among them — the remap'spopcntlookup and the double array's AVX2
placement and slot check — each entered only onceis_x86_feature_detected!has found it.- The double array's build and load numbers are 4.5.1's in the README, the design notes and
the benchmarks, whose table now says where it still loses (the build) and where it no longer does
(the load); the perfect hash's benchmarks record the single lookup's lead. - The frontier harness times
DoubleArrayIndex, and reports a corpus past its limits as
refused rather than as a failure; the tables list only the structures a campaign ran.
lexindex v4.5.0
Added
DoubleArrayIndex: a character-wise double-array trie for matching a lexicon against running
text, and the fastest walk measured over Chinese. Over UD Chinese GSDSimp against jieba 0.42.1's
349 045-word lexicon it finds every word at every position in 10.6 ns a character, against
daachorse's 15.5 for the same block scan, and walks a position at a time in 13.4 against crawdad's
15.8, darts-clone's 27.8, cedar's 29.0 andStringIndex's 58.6
(bench/results/cjk-walk-2026-09-25-arz-902e4b4.txt). A label is a character — its frequency rank
in the lexicon, read from a table indexed by code point — rather than a byte, so a Chinese word of
three characters is three steps, not nine, and each eight-byte slot holds everything a step reads:
the label, whether a key ends there and whether anything continues, the child row's base and the
key's id. A step is one load, and a match costs none past it. It answerscommon_prefix,
longest_prefix,for_each_common_prefix,for_each_occurrence— every key at every position,
the text decoded once — andid, with the idsStringIndexgives the same keys; nokey(id), no
prefix enumeration. 15.62 bytes a word on that lexicon, against 17.75 for darts-clone and yada,
18.40 for crawdad, 47.95 for daachorse and 60.57 held in memory by cedarwood. It loses on the
build and the load (bench/results/cjk-build-load-2026-09-25-arz-902e4b4.txt): 0.35 s over the
lexicon against darts-clone's 0.06 and cedar's 0.10, andload_mmap, which walks every slot, 1.07
ms against darts-clone'sopen, which checks nothing, 0.78. Every row goes to the lowest base
where it fits, and floors — the lowest slot a set of labels could take never moves down — keep
that exact fit fast on a small alphabet: 0.5 s over 480 k English words, where the plain search
took 16.6. Limits, refused at build: 2²³ keys, 65 535 distinct characters, 2²³ slots. The blob is
BDA1, checked slot by slot on every load,load_mmapincluded, so a crafted blob answers wrong
ids and never reads out of bounds; a fuzz target (parse_double_array) and a golden blob pinned
byte for byte. Python:lexindex.DoubleArrayIndex, whoseoccurrencescounts characters; plugged
into jieba it cuts in 30 % less time than jieba's own dictionary, 24 % with the HMM, and 4–6 %
less thanStringIndex.occurrences(bench/results/cjk-prefix-py-2026-09-25-arz-902e4b4.txt).
inspectreports it;plan,Overlayand the C ABI do not take it.
Documentation
pathsis re-measured at 4.4.2 in the million-key frontier, and the Python jieba lane on
4.4.2's wheel.pathsalone, every structure, three rounds
(bench/results/frontier-named-2026-09-24-arz-b101076.json): with 4.4.1's tie trieDictIndex
reads 712 ns at block 256 and 758 at 1024, where the 4.4.0 campaign read 794 and 826, against
XCDAT 15's 618 — still behind, by 1.15× at block 256 and 1.11× at block 32, where it was 1.25×
and 1.20×. Every competitor reads 0.971–1.012 of the campaign and every size is byte-identical.
Through Python (bench/results/cjk-prefix-py-2026-09-24-arz-b101076.txt), jieba over
StringIndex.occurrencesbuilds the DAG in 560 ns a character against 649 over its own
dictionary and cuts in 27 % less time, 21 % with the HMM.- The common-prefix table on English words compares the same work, and
DoubleArrayIndex
answers both queries fastest there. Re-measured at 4.4.2 first
(bench/results/common-prefix-2026-09-24-arz-b101076.txt:StringIndex561 and 285 ns against
datrie's 544 and 279, where 3.0.0 read 636 and 357), the table timeddatrie's keys alone
against lexindex's(key, id)pairs. It now timesdatrie'sprefix_itemsand
longest_prefix_itemand marisa's ids, keepsdatrie's keys-only row as such, and times each
call after an untimed pass of its own (bench/results/common-prefix-2026-09-25-arz-902e4b4.txt):
DoubleArrayIndex473 and 234,StringIndex530 and 295,datrie780 and 417, its keys alone
344 and 237. Timed after another structure's pass instead, as before,DoubleArrayIndex's
common_prefixreads 691 againstStringIndex's 578: its array is 11.7 MB on these words. - The Chinese walk table is re-measured with every double array, each row timed warm. The table
it replaces ran the rows alternated, each after whatever the rows before it had left in cache,
which favoured the rows nearest the turn of a round: it had darts-clone at 41.1 and 34.8 ns and
cedar at 49.5 and 46.2; timed after two passes of its own they read 27.8 and 34.5, 29.0 and 36.2.
yada, crawdad'sTrieandMpTrieand daachorse join them, with a build and load table. - The README and the docs' front page say what
StringIndexis for and what it is not. It is
the automaton index — autocomplete, fuzzy search, ordered browse — and for an exactstring ↔ id
aloneHashedDictIndexis faster andDictIndexsmaller, and for matching a lexicon against text
DoubleArrayIndex.
lexindex v4.4.2
Changed
- A
StringIndexprefix walk —common_prefix,longest_prefix,occurrences— takes its first
character from a table: over Chinese text it runs 18–21 % faster. A walk reads the query down
the transducer a byte at a time, each node's header read afresh, and a query's first character
crosses the widest nodes, the root and the ones that spell the rest of its bytes. Over UD Chinese
GSDSimp against jieba 0.42.1's lexicon that character took 649 of a walk's 1 025 instructions. The first walk now derives the state past every first character in the
Basic Multilingual Plane — a bitmap of 64-code-point pages up to the highest such character, a
count a page, a node address and output sum a character that begins a key — and a walk starts
from there, or stops at once when no key begins with its first character; a character outside
the plane is walked as before. 581 instructions a character. Against 4.4.1 in one binary over
five processes (bench/results/cjk-prefix-ab-2026-09-24-arz-dda19a7.txt): 80.8 → 63.9 ns a
character in text order and 93.5 → 76.5 shuffled, 0.79–0.80 and 0.81–0.82 of 4.4.1's time in
every process. cedarwood 0.5.0 walks the same text in 49.5 and 46.2 ns and darts-clone 0.32 in
41.1 and 34.8, so lexindex still loses this walk, by 1.3–1.7× and 1.6–2.2× where it lost by
1.6–2.0× and 2.0–2.7×. On five more corpora a walk runs 22 % fewer instructions over a million
Chinese titles, 10 % over words and Russian titles, 8 % over English titles and 3 % over URLs
(bench/results/prefix-walk-counts-2026-09-24-arz-dda19a7.txt, counts only). The table is held
in memory only and no blob changes: 0.29 bytes a key on jieba's lexicon, 0.085 on the Chinese
titles, 0.03 on the English and Russian ones. The first walk derives it, not the load, so an
index that only answersidnever holds it; on the lexicon that first walk runs 2.9 M
instructions more.
Documentation
- The common-prefix walk over Chinese text is measured against cedar and darts-clone, and
lexindex still loses it.docs/benchmarks.mdgains a section on jieba's lexicon over 4 997
sentences: from Rust, 4.4.2'sStringIndexwalks 1.3–1.7× slower than cedarwood 0.5.0 and
1.6–2.2× slower than darts-clone 0.32, holding a seventh of cedar's bytes and half darts-clone's;
from Python, jieba over 4.4.0'sStringIndex.occurrencescuts in 25 % less time than over its own
dictionary, 18 % with the HMM, the fastest backend measured
(bench/results/cjk-prefix-2026-09-24-arz-3a73ed5.txt).
lexindex v4.4.1
Changed
- A
DictIndexlookup that lands among blocks whose samples tie is placed by a trie of their
words: on a million file pathsidtakes 8 % less time, at the default block and at 1024. A
lookup opens with a binary search over one eight-byte sample a block, taken past the bytes every
head shares; where a run of blocks shares the probe's sample, their heads were compared one at a
time. On paths that run is the index — 3 899 of 3 907 samples tie at the default block, paths
sharing long prefixes — and a lookup made 11.4 head compares, a quarter of its instructions. A
load now splits each such run by its heads' eight-byte words past what they share, and a sub-run
that ties again the same way past its own prefix: on paths 1 136 nodes and 5 035 edges, nine
levels at the deepest. A lookup descends it reading only the probe, checks once against the head
it arrives at, and starts the block's scan from that compare: 16.3 % fewer instructions a member
idat 256 and 10.8 % at 1024, and within 0.6 % — all of it fewer — on words, English titles,
URLs and UUIDs. Every answer is the one 4.4.0 gives, and no blob changes, byte for byte. The trie
is held in memory only: 0.10 bytes a key on paths at 256, 0.02 at 1024, under 0.005 on titles and
URLs, nothing where no samples tie. A load of paths runs 11 % more instructions at 256 and 3 % at
1024, and at most 1 % more elsewhere. Against 4.4.0 in one process, each build loaded twice, over
three processes (bench/results/id-ab-2026-09-24-arz-b362f43.txt):idon paths 768 → 704 ns at
256 and 812 → 750 at 1024, every allocation of the new build under every one of the old; on
English titles, URLs, UUIDs and words, and everykey_into, within ±1.5 %. What it costs is at
load, where the trie is built: a mapped load of paths takes 238 µs against 130 at 256 and 118
against 96 at 1024, andfrom_bytes, which reads and checks the whole blob, 4.2 and 0.2 % longer.
Elsewherefrom_bytesmoves by at most 1.7 % andload_mmapby at most 5 %, 12 µs. - A load of a
DictIndexin a character code holds half the tables it did. Every load built the
code's lookup tables beside the blob — a mapped one too — and they held the alphabet twice, took
four bytes a codeword where sixteen bits carry it, and covered all of Unicode at their first level
whatever the keys used. On jieba's lexicon afrom_bytesindex held 3.687 bytes a key, 21 % over
its 3.040-byte blob and abovemarisa-trie's 3.578, whose load is its file to within 0.1 %; it
holds 3.392 now, 5.2 % under marisa's. A million Chinese titles go from 6.474 to 6.243 bytes a key
and a million Russian ones from 7.254 to 7.138, the tables halving on all three. Blobs are
unchanged, byte for byte (bench/results/resident-2026-09-24-arz-b362f43.txt, which has the
marisa rows). What it costs is in spelling the query, where a codeword is widened from sixteen
bits:idon the lexicon runs 1 % more instructions and takes 1–2 % more time, 260 → 266 ns in
the A/B above, and on the titles 0.6 and 0.7 %;key_intois level on all three.
Documentation
- What a mapped
DictIndexbuilds is stated as it is. The README, the docs' front page and the
Python module said aload_mmapbuilds only the per-block samples; it also decodes the symbol
tables and, where they apply, builds a code's tables and the trie above — 0.07–0.39 bytes a key in
all over the corpora measured, of whichdocs/design.mdgives the parts.
lexindex v4.4.0
Added
DictIndex::route_microblocks, in Rust and in Python: a fasterDictIndexsearch for eight
bytes of memory a microblock. Once the samples and the head have chosen a block, a lookup walks
the block's restart run — the first keys of its microblocks, coded against one another — to find
the microblock it scans.route_microblocks()derives, for every block, the bytes its restarts
share with its head and each restart's eight bytes past them; a lookup then counts the words below
its own instead of walking, and walks only where a word ties it. No answer changes, and no blob,
byte for byte: the words are held in memory only,8 / microbytes a key — 0.5 at the default
block of 256, 0.25 at 1024 — and the method returns the bytes they take. It is a method rather
than the default because it is paid for twice. Routed, the default block would give up its size
lead over the smallest trie on four corpora of the thirteen at a million keys —words,
domains,identsandpypi, where it leads by 0.34–0.39 B/key — and deriving the words reads
every block's restart run, which a lazyload_mmapleaves unread until a lookup needs it. It
takes&self, so a dictionary shared through anArc— the Python object,
HashedDictIndex::dict— routes in place, and a lookup that races it walks the run. Against the
same index unrouted, in one process and in two builds of the harness
(bench/results/id-ab-2026-09-24-arz-0216b39.txt): at the default blockidtakes 19–31 % less
time on twelve corpora at a million keys and 13–22 % on six at ten million, andids_of16–24 %
and 7–17 %; at 1024 keys a blockidtakes 23–36 % and 14–28 % less. With the change below,
against 4.3.3, a routedidat the default block takes 23–41 % less time at a million keys and
19–31 % at ten million.paths, whose restarts tie in the eight bytes past what they share, moves
between −2.4 and +1.8 %. At 32 keys a block, two microblocks, the words save at most 15 %, and
costnumericat ten million 3–10 %.
Changed
- A
DictIndexlookup is faster with nothing asked for:idby 4–16 % andids_ofby 4–13 % on
twelve corpora of thirteen at a million keys. Most of a lookup's instructions go to two
front-coded scans, and the scan matched on the shard's codec for every entry it compared. It now
picks the codec and the kind of header code once a run and walks the run in a loop of its own for
that pair; reads the probe's last eight bytes once a walk, where every compare that reached the
end of the key had folded them a byte at a time; and tests the symbol table's own codes before its
escape and phrase prefixes. Blobs are unchanged, byte for byte. Against 4.3.3 in the run above,
unrouted:idtakes 4.9–15.5 % less time at the default block on twelve corpora at a million keys
and 2.3–11.7 % on six at ten million, 6.3–16.4 % and 2.8–15.7 % at 1024 keys a block, and
3.7–15.2 % and 0.6–14.0 % at 32;ids_ofat the default block 3.7–12.7 % and 2.2–9.6 %. On
pathsevery lookup is 1.1–2.6 % faster.
lexindex v4.3.3
Fixed
DictIndex::idon a blob in a character code is as fast as 4.2.0's again. 4.3.2'sidwas
6 % slower than 4.2.0's on a million Chinese titles. Counted per query, the search was not the
cause — in code it costs fewer cycles than 4.2's search in UTF-8 — but spelling the query: 342
instructions and some 130 cycles, run before the first step of the search could issue. The code
keeps order, so the blocks a query falls between are the same whether the per-block samples are
taken from the heads as coded or as decoded, and a coded blob's loader now takes both. Where the
decoded samples tie no more blocks than the coded ones — 51 of 3 907 against 70 on the Chinese
titles, none either way on jieba's lexicon —idsearches them with the query's own UTF-8 and
spells it while that search waits on its loads. The Russian titles tie 659 decoded against 161
coded, eight bytes of UTF-8 being four letters, and keep the coded route. The decoded samples are
derived at load like the coded ones and take as much memory, eight bytes a block; blobs are
unchanged, byte for byte. With the change below, against 4.2.0 and 4.3.2 in one process, in two
builds of the harness (bench/results/id-ab-2026-09-24-arz-af19cb7.txt): on the Chinese titles
idis level with 4.2.0 (−0.5 and +0.3 %) and 5.7–6.5 % faster than 4.3.2, on the lexicon
6.7–8.0 % faster than 4.2.0, and on the Russian titles 2.7–3.0 % faster than 4.3.2 and 0.8–2.7 %
slower than 4.2.0.DictIndex::ids_ofon a blob in a character code is 6–13 % faster. It checked every&str
key as UTF-8 a second time, and grew, zero-filled and cut back an arena the size of the whole
batch a key at a time. It now takes astras it is, and spells 32 keys at a time into one arena
reused across the batch, so it no longer holds a spelled copy of every key. Against 4.3.2 in the
same run: 8.1–8.3 % faster on the Chinese titles, 12.6–12.9 % on the Russian ones and 6.1–6.7 % on
the lexicon. That still leaves it 2.8–4.5 % and 1.2–2.4 % slower than 4.2.0's on the two sets of
titles, and 5.0–5.3 % faster on the lexicon: on all three corpora a batch is slower than a loop of
idin every version measured, so the spelling has no wait to run beside.
Changed
- A
DictIndexlookup compares a head only where the samples leave it open, andidis faster
on every corpus but paths. A block whose sample is below the probe's has its head below the
probe, and one whose sample is above has its head above, so only a run of blocks sharing the
probe's sample leaves heads to compare. Every lookup had also compared the head of the block
before that run: one more dependent load and amemcmp, for a block the samples had already
chosen. Between 0.4 and 12.4 % of the probes land in a run on thirteen of the fourteen corpora
measured; on paths all of them do, since every path shares one byte and past it the samples tie
3 899 blocks of 3 907. On the eleven corpora that take no code, against 4.3.2 in the same run,
idis 2.7–5.5 % faster on eight of them, 0.9–4.3 % on identifiers and UUIDs and level on paths;
ids_ofis 2.8–5.2 % faster on nine and level on paths, and on identifiers 4.0 % faster in one
harness build and 1.5 % slower in the other, whose whole row, the control's included, the machine
slowed by 9–25 %.
lexindex v4.3.2
Fixed
DictIndexbuilds faster than 4.2.0 again; 4.3.0 had made every in-memory build 11–81 %
slower. Before building, 4.3.0 counted the keys' bytes outside ASCII to learn whether a
character code could pay, and where one could it counted their characters and spelled every key:
three passes on one thread, in key order, over the caller's keys. Those lie wherever the caller
allocated them, so such a pass waits on memory at every key, and the byte count compiled to four
instructions a byte, which keeps few of those loads in flight. The count now takes eight bytes a
step; the three passes run a range of keys a thread, over one view of the keys that the
encoder's threads now share rather than taking their own; and every pass prefetches the key 32
places ahead. The order check and the deduplication in front of the build — a sort's check for
a run and adedup_by, two more passes of the same kind — are now one pass that reads ahead.
Blobs are unchanged, byte for byte. Over the thirteen corpora of the million-key frontier, at
blocks 1024 and 256, alternated with 4.2.0 and 4.3.1 in one run
(bench/results/build-ab-2026-09-23-arz-152a00f.txt), a build takes 11–53 % less time than
4.3.1's on every corpus; against 4.2.0's it is 29–35 % faster on Russian titles, 11–14 % on
Chinese ones, 3–25 % on eight Latin corpora, and within 2 % on paths, English titles and URLs.
The loss was found by re-measuring the frontier at 4.3.1
(bench/results/frontier-1m-2026-09-23-arz-8dc8b73.json), whereDictIndexno longer built
first on three corpora of the thirteen, and the corpus sweep at 4.3.1 shows it too
(bench/results/sweep-2026-09-23-arz-8dc8b73.json).
lexindex v4.3.1
Fixed
key_intoon a blob in a character code is back at 4.2.0's speed, andidmuch closer to
it. 4.3.0 decoded a key a character at a time through the tables the code is stored in — the
codeword's lead found among the leads, a branch on the mode and on the lead's width,
char::encode_utf8— and then checked the finished key as UTF-8 a second time; it spelled a
query through a two-level index, behind a branch on the mode. A codeword now decodes in two reads
and one four-byte store, out of a table of the 256 values its first byte can take and one of
every character's UTF-8. What the decoder writes is whole characters out of a table built from
chars, so it becomes aStringwithout the second pass; a debug build still checks it, and so
do the fuzz targets. Code points below U+0800 — ASCII, Cyrillic and every other script UTF-8
spells in two bytes — are one flat level of the index, and under the eight-bit mode ASCII is in
the index too, so spelling a query no longer asks which mode it is in. Blobs are unchanged, byte
for byte. Against 4.2.0 in one process, in two builds of the harness
(bench/results/charcode-ab-2026-09-23-arz-668a1d3.txt):key_intois 2.5–3.3 % faster on a
million Russian titles, where 4.3.0 was 19–23 % slower, within 2 % on a million Chinese ones
(4.3.0: 11–12 % slower) and level on jieba's lexicon (4.3.0: 4–5 % slower);idis 1–2 %
slower on the Russian titles (4.3.0: 6–7 %), 6–8 % on the Chinese ones (4.3.0: 12–13 %) and 3–4 %
faster on the lexicon, as in 4.3.0. English titles, whose blob is 4.2's, read within 2 % of
4.2.0, the band two builds of the harness move by. Most of whatidstill pays is spelling the
query: all of it on the Russian titles, where spelling it twice adds 610 instructions andid
pays 605 over 4.2.0, and two thirds of the 530 instructions it pays on the Chinese ones.
Documentation
- The million-key size table and the README's margin over
marisa-trieare re-measured at
4.3.0 (bench/results/sweep-2026-09-23-arz-4f2a0e9.json), so they show the character code. Of
the 264 cells the run shares with the 4.0.0 one, 252 are the same to the byte; the twelve that
moved areDictIndexon the Russian and Chinese titles, 6.7–14.7 % smaller, and their margins
over marisa's best setting go from 2.2 and 1.6 % to 14 and 9 %, so the README's range now starts
at English titles' 3.9 %. At ten million keys
(bench/results/sweep10m-2026-09-23-arz-4f2a0e9.json) all 66 cells are the same to the byte: the
code takes none of those six corpora. The run's timings are not published, since the machine was
busy.