A sorted byte-string set backed by a threaded trie: a faster drop-in than sortedcontainers.SortedSet for build + prefix-scan + serialized-flush workloads.
Project description
trieset
A sorted byte-string set backed by a threaded trie. A faster drop-in than
sortedcontainers.SortedSet for
one specific, common workload: you build a set of byte strings and then do
prefix scans / counts and/or a serialized sorted flush.
It is not a "faster sorted set" in general. The wins and the costs are both
stated honestly below, measured per-element against SortedSet (the fair drop-in
test), not in a bulk microbenchmark.
Status: public alpha (0.1.0a1). First release is an sdist --
pip install triesetcompiles the C-extension from source (needs a C++17 compiler; standard on Linux dev machines). Prebuilt binary (manylinux) wheels are a planned follow-up. The benchmark numbers below are measured on a quiesced 16-core Linux box; re-measure on your hardware before relying on a specific ratio.
When to use it (and when not)
SortedSet is the de-facto answer for an ordered, mutable set with fast
membership + ordered iteration in pure Python. Use trieset instead only when
your keys are byte strings and your workload is dominated by ANY of:
- autocomplete --
startsWith/ prefix scans:startsWithexistence is ~9-15x faster thanSortedSetat every prefix width; NARROW prefix collect (a prefix matching few keys) is a few x faster too. (WIDE prefix collect -- where you materialize a large fraction of the set -- favorsSortedSet; see the cost section.) - prefix counting --
prefix_count(p)isO(prefix-length)(per-node subtree counts), so it beatsSortedSet's bisect at EVERY width (~16-25x measured). - building the set (lots of inserts) -- the fastest builder of any PREFIX-CAPABLE structure here (a plain hash set builds faster, but answers no prefix query),
- serialized sorted flush (persist / send on the wire / feed a C consumer).
Do NOT reach for it when your hot path is point membership at scale, or exploding all keys back into a Python list, or you are memory-constrained -- see the costs below.
Measured drop-in verdict
Per-element, quiesced box, best-of-5, ratio = trie / SortedSet (<1 = trie
faster). These core ops are unaffected by the prefix internals:
| operation | N=1k | N=10k | N=100k | verdict |
|---|---|---|---|---|
add (per-element build) |
0.13x | 0.12x | 0.15x | trie WINS ~7x, every N |
flush() serialized |
0.04x | 0.13x | 0.25x | trie WINS 4-25x, every N |
x in t membership HIT |
0.67x | 0.99x | 1.53x | wins small, wash-to-~1.5x at scale |
x in t membership MISS |
0.45x | 0.59x | 1.08x | wins to ~10k, ~wash at 100k |
tolist() (C loop) |
1.87x | 2.43x | 3.04x | SS wins (storage floor) |
list(t) (per-elem iter) |
2.20x | 3.46x | 4.39x | SS wins (use tolist()) |
Prefix operations, by prefix width
Prefix performance depends on how WIDE the prefix is (how many keys it matches),
so a single ratio hides the story. Measured per query, N=100k, quiesced box,
clustered keys (us/query; lower is faster; bold = winner of trie vs SortedSet):
| prefix width | ~matches/query | prefix_count: trie / SS |
startsWith: trie / SS |
prefix collect: trie / SS |
|---|---|---|---|---|
| wide (1 char) | ~3850 | 0.06 / 1.51 (trie ~25x) | 0.10 / 1.48 (trie ~15x) | 175 / 53 (SS ~3.3x) |
| medium (3) | ~144 | 0.08 / 1.85 (trie ~23x) | 0.13 / 1.60 (trie ~12x) | 7.2 / 5.1 (SS ~1.4x) |
| narrow (6) | ~1 | 0.11 / 1.79 (trie ~16x) | 0.17 / 1.58 (trie ~9x) | 0.26 / 1.32 (trie ~5x) |
Reading it:
prefix_countwins at every width (~16-25x). It isO(prefix-length)-- a descend-and-read of a per-node subtree count, not a walk over matches -- so it beatsSortedSet'sO(log n)bisect-difference even on wide prefixes. (Earlier releases walked the matches and LOST on wide prefixes; that is fixed.)startsWith/ prefix existence wins at every width (~9-15x). AnO(prefix-length)descent vs a bisect +startswithcompare, flat in the match count. This is the most robust prefix win.prefixcollect (materializing the matching keys) is split: the trie wins NARROW (~5x -- few keys to build), butSortedSetwins WIDE (a contiguous slice ofPyBytesit already holds, vs the trie allocating each key). If your prefixes routinely return a large fraction of the set,SortedSetcollect is faster.
What WINS (the drop-in claim)
- build /
add: ~5x faster thanSortedSetat every N (and the fastest builder of every PREFIX-CAPABLE structure benchmarked; a plain hash set builds ~5x faster thantriesetbut answers no prefix query) -- per-element insert beatsSortedSet's bisect + list-insert, with no relocation and no declared alphabet. startsWith/ prefix existence: ~9-15x faster thanSortedSet, every width.prefix_count: ~16-25x faster thanSortedSet, every width (O(prefix-len)).- NARROW prefix collect (autocomplete -- a prefix matching few keys): a few x
faster than
SortedSet, and 5-60x faster than every other Python trie. - serialized sorted flush (
flush): 3-14x faster -- one contiguous C pass vs per-element Python serialization. - prefix-walk (ancestors):
O(word-length).shortest_prefix(word)/longest_prefix(word)/prefixes(word)return the stored keys that are PREFIXES OFword(the dual ofprefix()) in a single descent -- the natural "is any stored key a prefix of X" / "shortest dictionary root of X" query. (Honest caveat: on the whole replace-words style problem a plainsetstill wins end-to-end because build cost dominates -- this primitive closes the gap versus other tries, not a new headline versus a hash set.)
What does NOT win (stated up front)
- Point membership (
x in t) is a wash at small N and a wash-to-slight-loss at scale (measured ~1.0-1.5x SortedSet at N=100k, depending on the query mix).SortedSet's bisect isO(log n)over contiguous, cache-friendly arrays; the trie doesO(k)pointer-chasing across arena-scattered nodes (a cache miss per level), so it does not WIN membership and a hash set beats both. Do not picktriesetfor a membership-heavy workload -- but it is the fastest trie here. - WIDE prefix collect (materializing a large fraction of the set) is slower
than
SortedSet's contiguous slice -- see the by-width table. (Only the COUNT is width-independent; collecting the keys is not.) - Listing all keys is slower -- the storage-model floor:
SortedSetalready holds thePyBytes, while the trie mustmalloc+memcpyabytesper key.tolist()(one C loop) is ~2-3x slower; the per-elementlist(t)is ~2-4x slower. Usetolist()to materialize andfor k in tto stream / early-exit; do not uselist(t). - Memory: modestly higher (~1.1-1.5x, measurement-sensitive). TrieSet's
structure is ~73 B/key (exact arena);
SortedSetmeasures ~50-70 B/key by RSS (it stores thePyBytesplus index pointers), so a like-for-like build-peak RSS comparison is ~1.1x and an RSS-delta comparison ~1.4x (an older optimistic SortedSet estimate gave ~1.8x). Either way it uses more thanSortedSet, and far more thanmarisa-trie's succinct ~18 B/key loaded. (Was ~3-7x before the leaf-tagging + inline-key shrink; +4% back from the per-node subtree counts that makeprefix_countO(prefix-len).)
In one sentence: the fastest structure here to BUILD a mutable byte-string
dictionary and do AUTOCOMPLETE on it (startsWith + prefix_count + narrow
prefix suggestions) and SERIALIZED FLUSH; WIDE prefix collect and point membership
favor SortedSet; ~2-4x slower to list all keys; modestly more memory (~1.1-1.5x).
Versus compiled tries (datrie, marisa-trie)
SortedSet is pure Python; the honest next question is how trieset sits among
compiled trie libraries. Measured (lowercase-ascii keys, quiesced; peers:
datrie, marisa-trie, pygtrie, pyahocorasick):
- Build:
triesetis the fastest -- ~2.9x faster thanpyahocorasick, ~3.4x faster thanmarisa-trie's bulk build, ~18x faster thanpygtrie, and orders of magnitude faster thandatrie(~80x even pre-sorted, hundreds of x in random order -- its double-array relocates on incremental insert). - Prefix COUNT + COLLECT:
triesetbeats every compiled trie at every width -- it counts inO(prefix-len)without building Python objects (the peers must materialize and count: ~600-8000x slower on wide prefixes) and collects ~3-60x faster. - Prefix EXISTENCE (
startsWith):triesetdoes not lead here.datrieandpyahocorasick(a compiled Aho-Corasick automaton) answerstartsWith~1.5-2.5x faster thantriesetat every width (their compiled lookup beats the trie's Seek-first-element).triesetstill beatsmarisa/pygtrieon existence, and beatsSortedSet~9-15x. - Membership:
triesetis the fastest trie (beatsdatrie,marisa,pyahocorasick), though still a wash-to-slight-loss versusSortedSet's bisect. - Memory:
marisa-trieis far smaller (succinct: ~4.4 B/key image, ~18 B/key loaded vs ~73) and serializes/loads a real queryable index almost for free -- but it is read-only (noadd/delete).pyahocorasickis heavier thantrieset;datrieneeds a fixed alphabet declared up front and builds pathologically slowly. (hat-triedoes not build on Python 3.11+ and was not tested.)
So the niche is a mutable, arbitrary-byte, no-declared-alphabet set you
build incrementally and prefix count / collect / autocomplete -- where
trieset is the fastest of the Python tries. If you only need startsWith
existence on a near-static set, datrie or pyahocorasick answer that one op
faster; for a static set where memory is paramount, prefer marisa-trie.
Install
pip install trieset
The first release is a source distribution, so pip compiles the CPython
C-extension (trieset.tt_trieset) from the binding plus the bundled trie core.
You need a C++17 compiler (g++/clang++; standard on Linux dev machines).
Portability: the build uses -O3 and not -march=native. The core's SIMD
child-lookup is __SSE2__-guarded and SSE2 is the x86-64 baseline, so the build is
correct on every x86-64 CPU (and falls back to a scalar path elsewhere). Prebuilt
binary wheels (no compiler needed) are a planned follow-up.
Quickstart
from trieset import TrieSet
t = TrieSet([b"apple", b"apricot", b"banana"])
t.add(b"avocado")
t.update([b"cherry", b"apple"]) # set semantics: duplicate is a no-op
b"banana" in t # True (membership: O(len key))
len(t) # 5
list(t.prefix(b"ap")) # [b'apple', b'apricot'] (sorted)
t.prefix_count(b"a") # 3 (O(prefix-len), counted in C)
t.shortest_prefix(b"applesauce") # b'apple' (shortest stored key that prefixes the word)
t.longest_prefix(b"applesauce") # b'apple'
list(t.prefixes(b"applesauce")) # [b'apple'] (all stored keys that prefix the word)
t.tolist() # all keys, sorted (one C loop)
for k in t: # stream sorted keys, O(1) peak memory
...
t.first(), t.last() # (b'apple', b'cherry')
blob = t.flush() # all keys, sorted, length-framed bytes:
# repeated [u32-le len][key bytes]
API and honest limitations
- Keys are byte strings only. Any buffer-protocol object
(
bytes/bytearray/memoryview) is accepted on input; every returned key isbytes.strraisesTypeError-- by design (this is a byte-string set). - No random
remove/discard. The core supports only "drain the smallest", not delete-by-key, so it is not exposed (avoids anO(n)emulation). This is the build-then-query / sorted-flush niche; a delete-heavy set is not the target. - No multiplicity. It is a set: a key is present 0 or 1 times.
- Not thread-safe for concurrent writes (single-writer core). The GIL is held throughout, so concurrent Python threads are serialized correctly.
Type hints ship with the package (py.typed + trieset/__init__.pyi).
Prior art (not novel, not first)
The structure is not novel: prior art includes marisa-trie, datrie, the Cuckoo
Trie, and the Patricia-trie memtables in ToplingDB / TerarkDB. The contribution
here is the clean from-scratch derivation plus the packaged
build + prefix + sorted-flush combination with honest, measured numbers --
something SortedSet and the existing trie packages do not jointly offer. The
shared trie core also backs a RocksDB MemTableRep plugin (a separate project).
License
MIT. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
File details
Details for the file trieset-0.1.0a1.tar.gz.
File metadata
- Download URL: trieset-0.1.0a1.tar.gz
- Upload date:
- Size: 46.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f01e1fdebd873100c9cc86f2b61fb336979375fb4c18103ac2a43e0ba40376a9
|
|
| MD5 |
02af482c45ffe06fe087d59542475a02
|
|
| BLAKE2b-256 |
5fea4a7d94aff72fe80d1c5c76da28511d7afc81f905c0b48d0bc6618aec8308
|