fastconstmap 0.9.0__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. {fastconstmap-0.9.0/fastconstmap.egg-info → fastconstmap-0.11.0}/PKG-INFO +145 -24
  2. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/README.md +143 -23
  3. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/benchmarks/bench_batch.c +46 -2
  4. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/benchmarks/benchmark.py +11 -1
  5. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/fastconstmap/__init__.py +15 -6
  6. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/fastconstmap/_fastconstmap.pyi +46 -4
  7. {fastconstmap-0.9.0 → fastconstmap-0.11.0/fastconstmap.egg-info}/PKG-INFO +145 -24
  8. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/fastconstmap.egg-info/SOURCES.txt +3 -1
  9. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/pyproject.toml +4 -1
  10. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/src/constmap.c +557 -170
  11. fastconstmap-0.11.0/src/constmap.h +210 -0
  12. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/src/fastconstmap_module.c +556 -18
  13. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/tests/test_constmap.py +85 -30
  14. fastconstmap-0.11.0/tests/test_interop.py +114 -0
  15. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/tests/test_shared_memory.py +63 -8
  16. fastconstmap-0.11.0/tests/test_threading.py +149 -0
  17. fastconstmap-0.9.0/src/constmap.h +0 -131
  18. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/LICENSE +0 -0
  19. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/MANIFEST.in +0 -0
  20. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/examples/example.py +0 -0
  21. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/examples/shared_memory_example.py +0 -0
  22. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/fastconstmap/py.typed +0 -0
  23. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/fastconstmap.egg-info/dependency_links.txt +0 -0
  24. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/fastconstmap.egg-info/top_level.txt +0 -0
  25. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/setup.cfg +0 -0
  26. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/setup.py +0 -0
  27. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/src/third_party/xxhash/LICENSE +0 -0
  28. {fastconstmap-0.9.0 → fastconstmap-0.11.0}/src/third_party/xxhash/xxhash.h +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastconstmap
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: Fast, immutable, compact map from strings to uint64 (binary fuse filter).
5
5
  Author-email: Daniel Lemire <daniel@lemire.me>
6
6
  License: Apache-2.0
@@ -8,6 +8,7 @@ Project-URL: Homepage, https://github.com/lemire/fastconstmap
8
8
  Project-URL: Source, https://github.com/lemire/fastconstmap
9
9
  Classifier: License :: OSI Approved :: Apache Software License
10
10
  Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
11
12
  Classifier: Programming Language :: C
12
13
  Classifier: Operating System :: OS Independent
13
14
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
@@ -29,7 +30,8 @@ Given a `dict[str, int]` at build time, you get back a lookup object that:
29
30
 
30
31
  - uses **~9 bytes per key** (or ~18 with missing-key detection),
31
32
  - answers a lookup in **one xxhash call plus three array reads**,
32
- - is **immutable** and **serializable** to bytes / a file,
33
+ - is **immutable** and **serializable** to bytes / a file, in a format the
34
+ Go and Rust implementations read too,
33
35
  - exposes both a single-key API (`m[key]`) and a **batched** API
34
36
  (`m.get_many([...])`, `m.get_many_into([...], buf)`) that amortises Python-C
35
37
  call overhead and overlaps the memory accesses of a whole block of keys.
@@ -50,7 +52,7 @@ dependencies.
50
52
  ## Usage
51
53
 
52
54
  ```python
53
- from fastconstmap import ConstMap, VerifiedConstMap
55
+ from fastconstmap import ConstMap, VerifiedConstMap, PairedVerifiedConstMap
54
56
 
55
57
  d = {"apple": 100, "banana": 200, "cherry": 300}
56
58
 
@@ -69,13 +71,19 @@ vm.get("grape", -1) # -> -1
69
71
  vm["grape"] # raises KeyError
70
72
  vm.get_many(["banana", "grape"], default=-1) # -> [200, -1]
71
73
 
74
+ # Variant 3: same as VerifiedConstMap, laid out for fewer cache misses when
75
+ # the keys you look up are usually present.
76
+ pm = PairedVerifiedConstMap(d)
77
+ pm["apple"] # -> 100
78
+ pm.get("grape") # -> None
79
+
72
80
  # Batches can also be written straight into a buffer you own — no list of
73
81
  # Python ints is built.
74
82
  import array
75
83
  out = array.array("Q", [0]) * 3
76
84
  m.get_many_into(["apple", "banana", "cherry"], out) # -> 3; out == [100, 200, 300]
77
85
 
78
- # Either kind can be saved and loaded.
86
+ # Any kind can be saved and loaded.
79
87
  m.save("mymap.cmap")
80
88
  m2 = ConstMap.load("mymap.cmap")
81
89
 
@@ -84,18 +92,48 @@ blob = m.to_bytes()
84
92
  m3 = ConstMap.from_bytes(blob)
85
93
  ```
86
94
 
87
- ### Choosing between `ConstMap` and `VerifiedConstMap`
95
+ ### Choosing between `ConstMap`, `VerifiedConstMap` and `PairedVerifiedConstMap`
88
96
 
89
- | | `ConstMap` | `VerifiedConstMap` |
90
- |--|--|--|
91
- | Bytes per key | ~9 | ~18 |
92
- | Lookup of present key | value | value |
93
- | Lookup of missing key | **undefined garbage** | `KeyError` / `default` / `None` |
94
- | Best if | you always look up known keys | you need dict-like semantics |
97
+ | | `ConstMap` | `VerifiedConstMap` | `PairedVerifiedConstMap` |
98
+ |--|--|--|--|
99
+ | Bytes per key | ~9 | ~18 | ~18 |
100
+ | Lookup of present key | value | value | value |
101
+ | Lookup of missing key | **undefined garbage** | `KeyError` / `default` / `None` | same |
102
+ | Cache lines touched per lookup | 3 | 6 (3 on a miss) | 3 |
103
+ | Best if | you always look up known keys | you need dict-like semantics and most lookups miss | you need dict-like semantics and most lookups hit |
95
104
 
96
- The false-positive rate of `VerifiedConstMap` (a missing key wrongly
105
+ The false-positive rate of the verified maps (a missing key wrongly
97
106
  reported as present) is roughly 2⁻⁶⁴, which is negligible in practice.
98
107
 
108
+ `VerifiedConstMap` keeps its values and its 64-bit check words in two
109
+ separate arrays. A lookup reads three check words, and if they match, three
110
+ value words: six cache lines, in two different arrays. `PairedVerifiedConstMap`
111
+ is the same map with each value stored next to its check word, so the three
112
+ reads bring in both at once (three cache lines), and on x86-64 and AArch64
113
+ each slot is loaded and XORed as one 128-bit word (SSE2 / NEON).
114
+
115
+ The trade-off is what happens on a **miss**. With the split layout, a
116
+ one-at-a-time lookup of an absent key reads only the check array, which is
117
+ half the size of the whole map and so far more likely to be sitting in cache.
118
+ The paired layout always pulls in the value alongside the check, so its
119
+ working set for misses is twice as large. In batched lookups
120
+ (`get_many`, `get_many_into`) the split layout gathers both arrays
121
+ unconditionally, so there the paired layout wins whether keys hit or miss.
122
+
123
+ Measured at the C level (`fcm_*_lookup`, one key at a time, random order
124
+ over the whole key set, best of five), ns per lookup, *hit* = key present,
125
+ *miss* = key absent:
126
+
127
+ | | keys | `VerifiedConstMap` hit | `PairedVerifiedConstMap` hit | `VerifiedConstMap` miss | `PairedVerifiedConstMap` miss |
128
+ |--|--|--|--|--|--|
129
+ | Apple M4 Max | 1M | 9.9 | 9.5 | **6.4** | 8.8 |
130
+ | | 10M | 20.9 | **17.1** | **15.5** | 17.1 |
131
+ | Xeon Gold 6548N | 1M | 17.3 | **12.8** | 13.8 | 13.0 |
132
+ | | 10M | 44.4 | **37.3** | **31.8** | 37.4 |
133
+
134
+ The two serialised formats are the same size but not interchangeable:
135
+ loading one class's bytes with the other raises `ValueError`.
136
+
99
137
  ## Batched lookups
100
138
 
101
139
  `get_many` takes an iterable of keys and returns a list of values, in the same
@@ -140,14 +178,25 @@ dominates instead.
140
178
 
141
179
  | | | loop over lookup | `lookup_many` | |
142
180
  |---|---|---|---|---|
143
- | **Apple M4 Max** | `ConstMap` cold | 5.5 | **3.8** | 30% |
144
- | | `ConstMap` hot | 4.2 | **3.1** | 27% |
145
- | | `VerifiedConstMap` cold | 9.6 | **7.8** | 19% |
146
- | | `VerifiedConstMap` hot | 5.4 | **5.2** | 4% |
147
- | **Xeon Gold 6548N** | `ConstMap` cold | 12.6 | **10.4** | 18% |
148
- | | `ConstMap` hot | 6.3 | **5.0** | 21% |
149
- | | `VerifiedConstMap` cold | 15.9 | 16.6 | −5% |
150
- | | `VerifiedConstMap` hot | 7.9 | **6.7** | 15% |
181
+ | **Apple M4 Max** | `ConstMap` cold | 7.1 | **5.5** | 23% |
182
+ | | `ConstMap` hot | 6.1 | **4.6** | 24% |
183
+ | | `VerifiedConstMap` cold | 11.9 | **8.0** | 33% |
184
+ | | `VerifiedConstMap` hot | 7.4 | **5.8** | 21% |
185
+ | | `PairedVerifiedConstMap` cold | 10.8 | **7.3** | 32% |
186
+ | | `PairedVerifiedConstMap` hot | 6.6 | **4.8** | 27% |
187
+ | **Xeon Gold 6548N** | `ConstMap` cold | 19.1 | **13.6** | 29% |
188
+ | | `ConstMap` hot | 10.7 | **8.5** | 21% |
189
+ | | `VerifiedConstMap` cold | 21.8 | 21.9 | ~0% |
190
+ | | `VerifiedConstMap` hot | 12.6 | **10.5** | 17% |
191
+ | | `PairedVerifiedConstMap` cold | 21.0 | **15.0** | 29% |
192
+ | | `PairedVerifiedConstMap` hot | 12.5 | **9.9** | 21% |
193
+
194
+ These are with the default key hash, XXH64, which is what makes the files
195
+ interchangeable with the Go and Rust implementations (see
196
+ [Interoperability](#interoperability-with-the-go-and-rust-implementations)).
197
+ XXH3, the hash of 0.9 and earlier, is about 1.3 ns faster per 20-byte key on
198
+ the M4 Max and 2 ns on the Xeon; `ConstMap(d, hash="xxh3")` keeps it, at the
199
+ price of files only fastconstmap can read.
151
200
 
152
201
  The one case where batching does not pay is `VerifiedConstMap` in the cold
153
202
  regime on the Xeon. That map probes two arrays per lookup, so a fresh batch is
@@ -155,7 +204,9 @@ bound by a memory latency the overlapping cannot hide. The Go original reports
155
204
  the same result on the same processor.
156
205
 
157
206
  Through the Python API, ns/key on the same batches — 0.8.0 is the previous
158
- per-key loop, 0.9.0 the batched path:
207
+ per-key loop, 0.9.0 the batched path (both measured with XXH3; with the
208
+ default XXH64 of 0.10, add roughly 1-4 ns/key to the batched columns, and
209
+ 1-5 ns to a single `m[k]` that costs 50-120 ns):
159
210
 
160
211
  | | | 0.8.0 | 0.9.0 | `get_many_into` |
161
212
  |---|---|---|---|---|
@@ -247,7 +298,7 @@ Notes and constraints:
247
298
  use. Drop the map (`del cm`) before calling `shm.close()`.
248
299
  - The map is **immutable** — the intended pattern is *write once in the
249
300
  producer, then only read in every process*. Concurrent readers need no
250
- locking.
301
+ locking (see [Threads](#threads-and-free-threaded-python)).
251
302
  - `from_buffer` verifies the magic bytes and the FNV-1a checksum, so a
252
303
  truncated or corrupt block raises `ValueError` rather than returning
253
304
  garbage.
@@ -256,12 +307,82 @@ Notes and constraints:
256
307
  all satisfy the alignment requirement; an offset slice of a buffer may
257
308
  not, in which case `from_buffer` raises `ValueError` — use
258
309
  `from_bytes()` (which copies) instead.
259
- - `VerifiedConstMap` supports the same three methods.
310
+ - `VerifiedConstMap` and `PairedVerifiedConstMap` support the same three
311
+ methods.
260
312
 
261
313
  `from_bytes()` remains available when you *want* an owned copy (or need to
262
314
  load on a big-endian host): it copies the data and the resulting map owns
263
315
  its memory independently of the source buffer.
264
316
 
317
+ ## Threads and free-threaded Python
318
+
319
+ The maps are immutable, so any number of threads can look up keys in the
320
+ same map at once, without locking. The extension supports free-threaded
321
+ Python (3.13t, 3.14t): it does not re-enable the GIL on import, and lookups
322
+ from several threads then run in parallel.
323
+
324
+ ```python
325
+ from concurrent.futures import ThreadPoolExecutor
326
+
327
+ m = ConstMap(d)
328
+ with ThreadPoolExecutor(8) as pool:
329
+ results = list(pool.map(m.get_many, batches))
330
+ ```
331
+
332
+ Building a map from a dict that another thread is modifying is safe (on a
333
+ free-threaded build the map is built from a snapshot of the dict), and so is
334
+ a batched lookup over a list that another thread is modifying. `__init__` can only be called once on a
335
+ map; calling it again raises `TypeError` rather than replacing the data
336
+ under other readers.
337
+
338
+ ## Interoperability with the Go and Rust implementations
339
+
340
+ A map saved by any of [`constmap`][constmap] (Go), [`rsconstmap`][rsconstmap]
341
+ (Rust) and fastconstmap loads in the other two, on a little-endian host (which
342
+ is every mainstream one: x86-64, ARM64, RISC-V, Apple silicon). The three share
343
+ the key hash (XXH64), the mixing, the seed sequence and the layout, so for the
344
+ same input Go and Rust write byte-identical files, and fastconstmap writes the
345
+ same bytes apart from one header word:
346
+
347
+ | class | magic | notes |
348
+ |---|---|---|
349
+ | `ConstMap` | `CMAP0003` | Go and Rust write `CMAP0001`, the same format minus a 4-byte key count; all three read both |
350
+ | `VerifiedConstMap` | `VMAP0001` | identical; the header word Go and Rust leave zero holds the key count here |
351
+ | `PairedVerifiedConstMap` | `PMAP0001` | likewise |
352
+
353
+ ```python
354
+ m = ConstMap.load("built-by-go.cmap") # from constmap.SaveToFile / save_to_file
355
+ vm = VerifiedConstMap.load("built-by-rust.vmap")
356
+ ```
357
+
358
+ Two things to know about a map that came from Go or Rust:
359
+
360
+ - **`len(m)` is 0**, because those writers do not record the key count. Every
361
+ lookup is unaffected.
362
+ - **A Go or Rust `ConstMap` file (`CMAP0001`) cannot be opened with
363
+ `from_buffer`**: its 28-byte header leaves the array 4 bytes off an 8-byte
364
+ boundary in any aligned buffer, so `from_buffer` raises `ValueError`. `load`
365
+ and `from_bytes`, which copy, work as usual. The other two formats have a
366
+ 32-byte header and view fine.
367
+
368
+ **Files from fastconstmap 0.9 and earlier** (`CMAP0002` / `VCMP0002`) still
369
+ load and answer correctly: they were built with XXH3, and a map keeps the hash
370
+ its file was built with (`m.hash` tells you which). Re-saving such a map keeps
371
+ its legacy magic, since its table only answers to XXH3; Go and Rust name these
372
+ files in their error message. To get a file the other implementations read,
373
+ rebuild the map from its keys.
374
+
375
+ **Keeping XXH3.** XXH3 is faster than XXH64 on short keys, by about 1-2 ns per
376
+ lookup at the C level. If you do not need the shared format, build with
377
+ `ConstMap(d, hash="xxh3")` (likewise for the other two classes); such a map
378
+ serializes with the legacy magic, readable by fastconstmap only.
379
+
380
+ The interoperability is tested: `tests/interop/` holds files written by the Go
381
+ package from a fixed input, which the test suite loads and checks, and the Go
382
+ and Rust repositories hold files written by this package.
383
+
384
+ [rsconstmap]: https://github.com/lemire/rsconstmap
385
+
265
386
  ## Benchmark
266
387
 
267
388
  On an Apple M4 Max, with 1,000,000 string keys (`key-{i}-{hex}` shaped
@@ -315,7 +436,7 @@ python benchmarks/benchmark.py 1000000
315
436
 
316
437
  Given *n* (key, value) pairs the algorithm:
317
438
 
318
- 1. Hashes each key with xxhash3 to a 64-bit value.
439
+ 1. Hashes each key with XXH64 (or XXH3 if asked) to a 64-bit value.
319
440
  2. Maps each hashed key to three positions `h0, h1, h2` in an array of
320
441
  size ~1.125·*n*, using overlapping segments.
321
442
  3. Finds, via [peeling][bff], an ordering in which each key has an
@@ -11,7 +11,8 @@ Given a `dict[str, int]` at build time, you get back a lookup object that:
11
11
 
12
12
  - uses **~9 bytes per key** (or ~18 with missing-key detection),
13
13
  - answers a lookup in **one xxhash call plus three array reads**,
14
- - is **immutable** and **serializable** to bytes / a file,
14
+ - is **immutable** and **serializable** to bytes / a file, in a format the
15
+ Go and Rust implementations read too,
15
16
  - exposes both a single-key API (`m[key]`) and a **batched** API
16
17
  (`m.get_many([...])`, `m.get_many_into([...], buf)`) that amortises Python-C
17
18
  call overhead and overlaps the memory accesses of a whole block of keys.
@@ -32,7 +33,7 @@ dependencies.
32
33
  ## Usage
33
34
 
34
35
  ```python
35
- from fastconstmap import ConstMap, VerifiedConstMap
36
+ from fastconstmap import ConstMap, VerifiedConstMap, PairedVerifiedConstMap
36
37
 
37
38
  d = {"apple": 100, "banana": 200, "cherry": 300}
38
39
 
@@ -51,13 +52,19 @@ vm.get("grape", -1) # -> -1
51
52
  vm["grape"] # raises KeyError
52
53
  vm.get_many(["banana", "grape"], default=-1) # -> [200, -1]
53
54
 
55
+ # Variant 3: same as VerifiedConstMap, laid out for fewer cache misses when
56
+ # the keys you look up are usually present.
57
+ pm = PairedVerifiedConstMap(d)
58
+ pm["apple"] # -> 100
59
+ pm.get("grape") # -> None
60
+
54
61
  # Batches can also be written straight into a buffer you own — no list of
55
62
  # Python ints is built.
56
63
  import array
57
64
  out = array.array("Q", [0]) * 3
58
65
  m.get_many_into(["apple", "banana", "cherry"], out) # -> 3; out == [100, 200, 300]
59
66
 
60
- # Either kind can be saved and loaded.
67
+ # Any kind can be saved and loaded.
61
68
  m.save("mymap.cmap")
62
69
  m2 = ConstMap.load("mymap.cmap")
63
70
 
@@ -66,18 +73,48 @@ blob = m.to_bytes()
66
73
  m3 = ConstMap.from_bytes(blob)
67
74
  ```
68
75
 
69
- ### Choosing between `ConstMap` and `VerifiedConstMap`
76
+ ### Choosing between `ConstMap`, `VerifiedConstMap` and `PairedVerifiedConstMap`
70
77
 
71
- | | `ConstMap` | `VerifiedConstMap` |
72
- |--|--|--|
73
- | Bytes per key | ~9 | ~18 |
74
- | Lookup of present key | value | value |
75
- | Lookup of missing key | **undefined garbage** | `KeyError` / `default` / `None` |
76
- | Best if | you always look up known keys | you need dict-like semantics |
78
+ | | `ConstMap` | `VerifiedConstMap` | `PairedVerifiedConstMap` |
79
+ |--|--|--|--|
80
+ | Bytes per key | ~9 | ~18 | ~18 |
81
+ | Lookup of present key | value | value | value |
82
+ | Lookup of missing key | **undefined garbage** | `KeyError` / `default` / `None` | same |
83
+ | Cache lines touched per lookup | 3 | 6 (3 on a miss) | 3 |
84
+ | Best if | you always look up known keys | you need dict-like semantics and most lookups miss | you need dict-like semantics and most lookups hit |
77
85
 
78
- The false-positive rate of `VerifiedConstMap` (a missing key wrongly
86
+ The false-positive rate of the verified maps (a missing key wrongly
79
87
  reported as present) is roughly 2⁻⁶⁴, which is negligible in practice.
80
88
 
89
+ `VerifiedConstMap` keeps its values and its 64-bit check words in two
90
+ separate arrays. A lookup reads three check words, and if they match, three
91
+ value words: six cache lines, in two different arrays. `PairedVerifiedConstMap`
92
+ is the same map with each value stored next to its check word, so the three
93
+ reads bring in both at once (three cache lines), and on x86-64 and AArch64
94
+ each slot is loaded and XORed as one 128-bit word (SSE2 / NEON).
95
+
96
+ The trade-off is what happens on a **miss**. With the split layout, a
97
+ one-at-a-time lookup of an absent key reads only the check array, which is
98
+ half the size of the whole map and so far more likely to be sitting in cache.
99
+ The paired layout always pulls in the value alongside the check, so its
100
+ working set for misses is twice as large. In batched lookups
101
+ (`get_many`, `get_many_into`) the split layout gathers both arrays
102
+ unconditionally, so there the paired layout wins whether keys hit or miss.
103
+
104
+ Measured at the C level (`fcm_*_lookup`, one key at a time, random order
105
+ over the whole key set, best of five), ns per lookup, *hit* = key present,
106
+ *miss* = key absent:
107
+
108
+ | | keys | `VerifiedConstMap` hit | `PairedVerifiedConstMap` hit | `VerifiedConstMap` miss | `PairedVerifiedConstMap` miss |
109
+ |--|--|--|--|--|--|
110
+ | Apple M4 Max | 1M | 9.9 | 9.5 | **6.4** | 8.8 |
111
+ | | 10M | 20.9 | **17.1** | **15.5** | 17.1 |
112
+ | Xeon Gold 6548N | 1M | 17.3 | **12.8** | 13.8 | 13.0 |
113
+ | | 10M | 44.4 | **37.3** | **31.8** | 37.4 |
114
+
115
+ The two serialised formats are the same size but not interchangeable:
116
+ loading one class's bytes with the other raises `ValueError`.
117
+
81
118
  ## Batched lookups
82
119
 
83
120
  `get_many` takes an iterable of keys and returns a list of values, in the same
@@ -122,14 +159,25 @@ dominates instead.
122
159
 
123
160
  | | | loop over lookup | `lookup_many` | |
124
161
  |---|---|---|---|---|
125
- | **Apple M4 Max** | `ConstMap` cold | 5.5 | **3.8** | 30% |
126
- | | `ConstMap` hot | 4.2 | **3.1** | 27% |
127
- | | `VerifiedConstMap` cold | 9.6 | **7.8** | 19% |
128
- | | `VerifiedConstMap` hot | 5.4 | **5.2** | 4% |
129
- | **Xeon Gold 6548N** | `ConstMap` cold | 12.6 | **10.4** | 18% |
130
- | | `ConstMap` hot | 6.3 | **5.0** | 21% |
131
- | | `VerifiedConstMap` cold | 15.9 | 16.6 | −5% |
132
- | | `VerifiedConstMap` hot | 7.9 | **6.7** | 15% |
162
+ | **Apple M4 Max** | `ConstMap` cold | 7.1 | **5.5** | 23% |
163
+ | | `ConstMap` hot | 6.1 | **4.6** | 24% |
164
+ | | `VerifiedConstMap` cold | 11.9 | **8.0** | 33% |
165
+ | | `VerifiedConstMap` hot | 7.4 | **5.8** | 21% |
166
+ | | `PairedVerifiedConstMap` cold | 10.8 | **7.3** | 32% |
167
+ | | `PairedVerifiedConstMap` hot | 6.6 | **4.8** | 27% |
168
+ | **Xeon Gold 6548N** | `ConstMap` cold | 19.1 | **13.6** | 29% |
169
+ | | `ConstMap` hot | 10.7 | **8.5** | 21% |
170
+ | | `VerifiedConstMap` cold | 21.8 | 21.9 | ~0% |
171
+ | | `VerifiedConstMap` hot | 12.6 | **10.5** | 17% |
172
+ | | `PairedVerifiedConstMap` cold | 21.0 | **15.0** | 29% |
173
+ | | `PairedVerifiedConstMap` hot | 12.5 | **9.9** | 21% |
174
+
175
+ These are with the default key hash, XXH64, which is what makes the files
176
+ interchangeable with the Go and Rust implementations (see
177
+ [Interoperability](#interoperability-with-the-go-and-rust-implementations)).
178
+ XXH3, the hash of 0.9 and earlier, is about 1.3 ns faster per 20-byte key on
179
+ the M4 Max and 2 ns on the Xeon; `ConstMap(d, hash="xxh3")` keeps it, at the
180
+ price of files only fastconstmap can read.
133
181
 
134
182
  The one case where batching does not pay is `VerifiedConstMap` in the cold
135
183
  regime on the Xeon. That map probes two arrays per lookup, so a fresh batch is
@@ -137,7 +185,9 @@ bound by a memory latency the overlapping cannot hide. The Go original reports
137
185
  the same result on the same processor.
138
186
 
139
187
  Through the Python API, ns/key on the same batches — 0.8.0 is the previous
140
- per-key loop, 0.9.0 the batched path:
188
+ per-key loop, 0.9.0 the batched path (both measured with XXH3; with the
189
+ default XXH64 of 0.10, add roughly 1-4 ns/key to the batched columns, and
190
+ 1-5 ns to a single `m[k]` that costs 50-120 ns):
141
191
 
142
192
  | | | 0.8.0 | 0.9.0 | `get_many_into` |
143
193
  |---|---|---|---|---|
@@ -229,7 +279,7 @@ Notes and constraints:
229
279
  use. Drop the map (`del cm`) before calling `shm.close()`.
230
280
  - The map is **immutable** — the intended pattern is *write once in the
231
281
  producer, then only read in every process*. Concurrent readers need no
232
- locking.
282
+ locking (see [Threads](#threads-and-free-threaded-python)).
233
283
  - `from_buffer` verifies the magic bytes and the FNV-1a checksum, so a
234
284
  truncated or corrupt block raises `ValueError` rather than returning
235
285
  garbage.
@@ -238,12 +288,82 @@ Notes and constraints:
238
288
  all satisfy the alignment requirement; an offset slice of a buffer may
239
289
  not, in which case `from_buffer` raises `ValueError` — use
240
290
  `from_bytes()` (which copies) instead.
241
- - `VerifiedConstMap` supports the same three methods.
291
+ - `VerifiedConstMap` and `PairedVerifiedConstMap` support the same three
292
+ methods.
242
293
 
243
294
  `from_bytes()` remains available when you *want* an owned copy (or need to
244
295
  load on a big-endian host): it copies the data and the resulting map owns
245
296
  its memory independently of the source buffer.
246
297
 
298
+ ## Threads and free-threaded Python
299
+
300
+ The maps are immutable, so any number of threads can look up keys in the
301
+ same map at once, without locking. The extension supports free-threaded
302
+ Python (3.13t, 3.14t): it does not re-enable the GIL on import, and lookups
303
+ from several threads then run in parallel.
304
+
305
+ ```python
306
+ from concurrent.futures import ThreadPoolExecutor
307
+
308
+ m = ConstMap(d)
309
+ with ThreadPoolExecutor(8) as pool:
310
+ results = list(pool.map(m.get_many, batches))
311
+ ```
312
+
313
+ Building a map from a dict that another thread is modifying is safe (on a
314
+ free-threaded build the map is built from a snapshot of the dict), and so is
315
+ a batched lookup over a list that another thread is modifying. `__init__` can only be called once on a
316
+ map; calling it again raises `TypeError` rather than replacing the data
317
+ under other readers.
318
+
319
+ ## Interoperability with the Go and Rust implementations
320
+
321
+ A map saved by any of [`constmap`][constmap] (Go), [`rsconstmap`][rsconstmap]
322
+ (Rust) and fastconstmap loads in the other two, on a little-endian host (which
323
+ is every mainstream one: x86-64, ARM64, RISC-V, Apple silicon). The three share
324
+ the key hash (XXH64), the mixing, the seed sequence and the layout, so for the
325
+ same input Go and Rust write byte-identical files, and fastconstmap writes the
326
+ same bytes apart from one header word:
327
+
328
+ | class | magic | notes |
329
+ |---|---|---|
330
+ | `ConstMap` | `CMAP0003` | Go and Rust write `CMAP0001`, the same format minus a 4-byte key count; all three read both |
331
+ | `VerifiedConstMap` | `VMAP0001` | identical; the header word Go and Rust leave zero holds the key count here |
332
+ | `PairedVerifiedConstMap` | `PMAP0001` | likewise |
333
+
334
+ ```python
335
+ m = ConstMap.load("built-by-go.cmap") # from constmap.SaveToFile / save_to_file
336
+ vm = VerifiedConstMap.load("built-by-rust.vmap")
337
+ ```
338
+
339
+ Two things to know about a map that came from Go or Rust:
340
+
341
+ - **`len(m)` is 0**, because those writers do not record the key count. Every
342
+ lookup is unaffected.
343
+ - **A Go or Rust `ConstMap` file (`CMAP0001`) cannot be opened with
344
+ `from_buffer`**: its 28-byte header leaves the array 4 bytes off an 8-byte
345
+ boundary in any aligned buffer, so `from_buffer` raises `ValueError`. `load`
346
+ and `from_bytes`, which copy, work as usual. The other two formats have a
347
+ 32-byte header and view fine.
348
+
349
+ **Files from fastconstmap 0.9 and earlier** (`CMAP0002` / `VCMP0002`) still
350
+ load and answer correctly: they were built with XXH3, and a map keeps the hash
351
+ its file was built with (`m.hash` tells you which). Re-saving such a map keeps
352
+ its legacy magic, since its table only answers to XXH3; Go and Rust name these
353
+ files in their error message. To get a file the other implementations read,
354
+ rebuild the map from its keys.
355
+
356
+ **Keeping XXH3.** XXH3 is faster than XXH64 on short keys, by about 1-2 ns per
357
+ lookup at the C level. If you do not need the shared format, build with
358
+ `ConstMap(d, hash="xxh3")` (likewise for the other two classes); such a map
359
+ serializes with the legacy magic, readable by fastconstmap only.
360
+
361
+ The interoperability is tested: `tests/interop/` holds files written by the Go
362
+ package from a fixed input, which the test suite loads and checks, and the Go
363
+ and Rust repositories hold files written by this package.
364
+
365
+ [rsconstmap]: https://github.com/lemire/rsconstmap
366
+
247
367
  ## Benchmark
248
368
 
249
369
  On an Apple M4 Max, with 1,000,000 string keys (`key-{i}-{hex}` shaped
@@ -297,7 +417,7 @@ python benchmarks/benchmark.py 1000000
297
417
 
298
418
  Given *n* (key, value) pairs the algorithm:
299
419
 
300
- 1. Hashes each key with xxhash3 to a 64-bit value.
420
+ 1. Hashes each key with XXH64 (or XXH3 if asked) to a 64-bit value.
301
421
  2. Maps each hashed key to three positions `h0, h1, h2` in an array of
302
422
  size ~1.125·*n*, using overlapping segments.
303
423
  3. Finds, via [peeling][bff], an ordering in which each key has an
@@ -113,14 +113,18 @@ int main(int argc, char **argv) {
113
113
  if (!values) { fprintf(stderr, "oom\n"); return 1; }
114
114
  for (size_t i = 0; i < map_n; i++) values[i] = i;
115
115
 
116
- fcm_constmap_t cm;
117
- fcm_verified_constmap_t vm;
116
+ fcm_constmap_t cm;
117
+ fcm_verified_constmap_t vm;
118
+ fcm_paired_verified_constmap_t pm;
118
119
  if (fcm_constmap_new(&cm, all.keys, values, map_n) != FCM_OK) {
119
120
  fprintf(stderr, "construction failed\n"); return 1;
120
121
  }
121
122
  if (fcm_verified_constmap_new(&vm, all.keys, values, map_n) != FCM_OK) {
122
123
  fprintf(stderr, "verified construction failed\n"); return 1;
123
124
  }
125
+ if (fcm_paired_verified_constmap_new(&pm, all.keys, values, map_n) != FCM_OK) {
126
+ fprintf(stderr, "paired construction failed\n"); return 1;
127
+ }
124
128
 
125
129
  keyset_t pools[NUM_POOLS];
126
130
  for (int p = 0; p < NUM_POOLS; p++) {
@@ -215,11 +219,51 @@ int main(int argc, char **argv) {
215
219
  report("lookup_many (hot)", now_sec() - t0, total,
216
220
  loop_ns * 1e9 / (double)total);
217
221
 
222
+ /* ---- PairedVerifiedConstMap, cold ---- */
223
+ printf("\nPairedVerifiedConstMap:\n");
224
+ t0 = now_sec();
225
+ for (int i = 0; i < REPEATS; i++) {
226
+ const keyset_t *q = &pools[i % NUM_POOLS];
227
+ for (size_t j = 0; j < q->n; j++)
228
+ out[j] = fcm_paired_verified_constmap_lookup(&pm, q->keys[j].bytes, q->keys[j].len);
229
+ sink = out[0];
230
+ }
231
+ loop_ns = now_sec() - t0;
232
+ report("loop over lookup (cold)", loop_ns, total, 0.0);
233
+
234
+ t0 = now_sec();
235
+ for (int i = 0; i < REPEATS; i++) {
236
+ const keyset_t *q = &pools[i % NUM_POOLS];
237
+ fcm_paired_verified_constmap_lookup_many(&pm, q->keys, q->n, out);
238
+ sink = out[0];
239
+ }
240
+ report("lookup_many (cold)", now_sec() - t0, total,
241
+ loop_ns * 1e9 / (double)total);
242
+
243
+ /* ---- PairedVerifiedConstMap, hot ---- */
244
+ t0 = now_sec();
245
+ for (int i = 0; i < REPEATS; i++) {
246
+ for (size_t j = 0; j < hot->n; j++)
247
+ out[j] = fcm_paired_verified_constmap_lookup(&pm, hot->keys[j].bytes, hot->keys[j].len);
248
+ sink = out[0];
249
+ }
250
+ loop_ns = now_sec() - t0;
251
+ report("loop over lookup (hot)", loop_ns, total, 0.0);
252
+
253
+ t0 = now_sec();
254
+ for (int i = 0; i < REPEATS; i++) {
255
+ fcm_paired_verified_constmap_lookup_many(&pm, hot->keys, hot->n, out);
256
+ sink = out[0];
257
+ }
258
+ report("lookup_many (hot)", now_sec() - t0, total,
259
+ loop_ns * 1e9 / (double)total);
260
+
218
261
  for (int p = 0; p < NUM_POOLS; p++) keyset_free(&pools[p]);
219
262
  keyset_free(&all);
220
263
  free(values);
221
264
  free(out);
222
265
  fcm_constmap_free(&cm);
223
266
  fcm_verified_constmap_free(&vm);
267
+ fcm_paired_verified_constmap_free(&pm);
224
268
  return 0;
225
269
  }
@@ -22,7 +22,7 @@ import sys
22
22
  import time
23
23
  from typing import Callable
24
24
 
25
- from fastconstmap import ConstMap, VerifiedConstMap
25
+ from fastconstmap import ConstMap, PairedVerifiedConstMap, VerifiedConstMap
26
26
 
27
27
 
28
28
  def make_data(n: int, seed: int = 0xC0FFEE) -> tuple[list[str], dict[str, int]]:
@@ -118,6 +118,10 @@ def main() -> None:
118
118
  vm = VerifiedConstMap(d)
119
119
  print(f" VerifiedConstMap.__init__ {time.perf_counter()-t0:7.3f} s")
120
120
 
121
+ t0 = time.perf_counter()
122
+ pm = PairedVerifiedConstMap(d)
123
+ print(f" PairedVerifiedConstMap.__init__ {time.perf_counter()-t0:7.3f} s")
124
+
121
125
  t0 = time.perf_counter()
122
126
  pyd = dict(d)
123
127
  print(f" dict(d) {time.perf_counter()-t0:7.3f} s")
@@ -130,6 +134,7 @@ def main() -> None:
130
134
  print("\nMemory:")
131
135
  report_size("ConstMap.nbytes", cm.nbytes(), n)
132
136
  report_size("VerifiedConstMap.nbytes", vm.nbytes(), n)
137
+ report_size("PairedVerifiedConstMap.nbytes", pm.nbytes(), n)
133
138
  dict_total = deep_sizeof(pyd)
134
139
  report_size("dict (table+keys+values)", dict_total, n)
135
140
  print(f" {'ratio dict / ConstMap':<38} {dict_total / cm.nbytes():.1f}x")
@@ -140,6 +145,7 @@ def main() -> None:
140
145
  time_loop("dict[k]", n, lambda i: pyd[query[i]])
141
146
  time_loop("ConstMap[k]", n, lambda i: cm[query[i]])
142
147
  time_loop("VerifiedConstMap[k]", n, lambda i: vm[query[i]])
148
+ time_loop("PairedVerifiedConstMap[k]", n, lambda i: pm[query[i]])
143
149
 
144
150
  # ---- Batched lookup ----
145
151
  # Cold rotates through many distinct random batches, so the cache lines a
@@ -175,6 +181,10 @@ def main() -> None:
175
181
  lambda: vm.get_many(cold()))
176
182
  time_batch("VerifiedConstMap.get_many_into (cold)", args.batches, args.batch_size,
177
183
  lambda: vm.get_many_into(cold(), out))
184
+ time_batch("PairedVerifiedConstMap.get_many (cold)", args.batches, args.batch_size,
185
+ lambda: pm.get_many(cold()))
186
+ time_batch("PairedVerifiedConstMap.get_many_into (cold)", args.batches, args.batch_size,
187
+ lambda: pm.get_many_into(cold(), out))
178
188
 
179
189
  # ---- Serialization ----
180
190
  print("\nSerialization:")
@@ -3,7 +3,7 @@
3
3
  Build a compact lookup structure from any ``dict[str, int]`` (or
4
4
  ``dict[bytes, int]``). Lookup costs one xxhash and three array reads.
5
5
 
6
- Two classes are exposed:
6
+ Three classes are exposed:
7
7
 
8
8
  * :class:`ConstMap` — minimal, ~9 bytes/key. Returns undefined values for
9
9
  keys that were not in the original mapping. Use when you know upstream
@@ -12,9 +12,18 @@ Two classes are exposed:
12
12
  probability ~1 − 2⁻⁶⁴. Behaves like a normal ``dict``: ``m[k]`` raises
13
13
  :class:`KeyError`, ``m.get(k, default)`` returns ``default``, ``k in m``
14
14
  works.
15
+ * :class:`PairedVerifiedConstMap` — same size and behaviour as
16
+ :class:`VerifiedConstMap`, but stores each value next to its check word so
17
+ a lookup of a present key touches three cache lines instead of six (and
18
+ reads each as one 128-bit word on x86-64 and AArch64). Faster when most
19
+ lookups hit; slower when most miss. Its serialised form is not
20
+ interchangeable with :class:`VerifiedConstMap`'s.
15
21
 
16
- Both types are immutable after construction and can be serialised to bytes
17
- or to a file path. Both also take batches: ``get_many`` returns a list, and
22
+ All three are immutable after construction and can be serialised to bytes
23
+ or to a file path, in a format shared with the Go (``constmap``) and Rust
24
+ (``rsconstmap``) implementations: a file saved by any of the three loads in
25
+ the other two, on a little-endian host. Pass ``hash="xxh3"`` to a constructor
26
+ for a faster key hash whose files only fastconstmap reads. All also take batches: ``get_many`` returns a list, and
18
27
  ``get_many_into`` writes 64-bit words straight into a buffer you own (an
19
28
  ``array("Q")``, a numpy ``uint64`` array, a ``SharedMemory`` block), which
20
29
  allocates nothing per key. A batch is faster than a loop because the map
@@ -23,7 +32,7 @@ whole block overlap instead of being paid one after another. They can also be pl
23
32
  ``SharedMemory`` block and opened by many processes with no per-process
24
33
  copy — see ``serialized_size``, ``write_into`` and ``from_buffer``.
25
34
  """
26
- from ._fastconstmap import ConstMap, VerifiedConstMap, NOT_FOUND
35
+ from ._fastconstmap import ConstMap, VerifiedConstMap, PairedVerifiedConstMap, NOT_FOUND
27
36
 
28
- __all__ = ["ConstMap", "VerifiedConstMap", "NOT_FOUND"]
29
- __version__ = "0.9.0"
37
+ __all__ = ["ConstMap", "VerifiedConstMap", "PairedVerifiedConstMap", "NOT_FOUND"]
38
+ __version__ = "0.10.0"