fastconstmap 0.9.0__tar.gz → 0.10.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 (27) hide show
  1. {fastconstmap-0.9.0/fastconstmap.egg-info → fastconstmap-0.10.0}/PKG-INFO +122 -23
  2. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/README.md +121 -22
  3. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/benchmarks/bench_batch.c +46 -2
  4. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/benchmarks/benchmark.py +11 -1
  5. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/fastconstmap/__init__.py +15 -6
  6. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/fastconstmap/_fastconstmap.pyi +46 -4
  7. {fastconstmap-0.9.0 → fastconstmap-0.10.0/fastconstmap.egg-info}/PKG-INFO +122 -23
  8. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/fastconstmap.egg-info/SOURCES.txt +1 -0
  9. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/pyproject.toml +1 -1
  10. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/src/constmap.c +557 -170
  11. fastconstmap-0.10.0/src/constmap.h +210 -0
  12. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/src/fastconstmap_module.c +441 -7
  13. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/tests/test_constmap.py +85 -30
  14. fastconstmap-0.10.0/tests/test_interop.py +114 -0
  15. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/tests/test_shared_memory.py +63 -8
  16. fastconstmap-0.9.0/src/constmap.h +0 -131
  17. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/LICENSE +0 -0
  18. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/MANIFEST.in +0 -0
  19. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/examples/example.py +0 -0
  20. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/examples/shared_memory_example.py +0 -0
  21. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/fastconstmap/py.typed +0 -0
  22. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/fastconstmap.egg-info/dependency_links.txt +0 -0
  23. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/fastconstmap.egg-info/top_level.txt +0 -0
  24. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/setup.cfg +0 -0
  25. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/setup.py +0 -0
  26. {fastconstmap-0.9.0 → fastconstmap-0.10.0}/src/third_party/xxhash/LICENSE +0 -0
  27. {fastconstmap-0.9.0 → fastconstmap-0.10.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.10.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
@@ -29,7 +29,8 @@ Given a `dict[str, int]` at build time, you get back a lookup object that:
29
29
 
30
30
  - uses **~9 bytes per key** (or ~18 with missing-key detection),
31
31
  - answers a lookup in **one xxhash call plus three array reads**,
32
- - is **immutable** and **serializable** to bytes / a file,
32
+ - is **immutable** and **serializable** to bytes / a file, in a format the
33
+ Go and Rust implementations read too,
33
34
  - exposes both a single-key API (`m[key]`) and a **batched** API
34
35
  (`m.get_many([...])`, `m.get_many_into([...], buf)`) that amortises Python-C
35
36
  call overhead and overlaps the memory accesses of a whole block of keys.
@@ -50,7 +51,7 @@ dependencies.
50
51
  ## Usage
51
52
 
52
53
  ```python
53
- from fastconstmap import ConstMap, VerifiedConstMap
54
+ from fastconstmap import ConstMap, VerifiedConstMap, PairedVerifiedConstMap
54
55
 
55
56
  d = {"apple": 100, "banana": 200, "cherry": 300}
56
57
 
@@ -69,13 +70,19 @@ vm.get("grape", -1) # -> -1
69
70
  vm["grape"] # raises KeyError
70
71
  vm.get_many(["banana", "grape"], default=-1) # -> [200, -1]
71
72
 
73
+ # Variant 3: same as VerifiedConstMap, laid out for fewer cache misses when
74
+ # the keys you look up are usually present.
75
+ pm = PairedVerifiedConstMap(d)
76
+ pm["apple"] # -> 100
77
+ pm.get("grape") # -> None
78
+
72
79
  # Batches can also be written straight into a buffer you own — no list of
73
80
  # Python ints is built.
74
81
  import array
75
82
  out = array.array("Q", [0]) * 3
76
83
  m.get_many_into(["apple", "banana", "cherry"], out) # -> 3; out == [100, 200, 300]
77
84
 
78
- # Either kind can be saved and loaded.
85
+ # Any kind can be saved and loaded.
79
86
  m.save("mymap.cmap")
80
87
  m2 = ConstMap.load("mymap.cmap")
81
88
 
@@ -84,18 +91,48 @@ blob = m.to_bytes()
84
91
  m3 = ConstMap.from_bytes(blob)
85
92
  ```
86
93
 
87
- ### Choosing between `ConstMap` and `VerifiedConstMap`
94
+ ### Choosing between `ConstMap`, `VerifiedConstMap` and `PairedVerifiedConstMap`
88
95
 
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 |
96
+ | | `ConstMap` | `VerifiedConstMap` | `PairedVerifiedConstMap` |
97
+ |--|--|--|--|
98
+ | Bytes per key | ~9 | ~18 | ~18 |
99
+ | Lookup of present key | value | value | value |
100
+ | Lookup of missing key | **undefined garbage** | `KeyError` / `default` / `None` | same |
101
+ | Cache lines touched per lookup | 3 | 6 (3 on a miss) | 3 |
102
+ | 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
103
 
96
- The false-positive rate of `VerifiedConstMap` (a missing key wrongly
104
+ The false-positive rate of the verified maps (a missing key wrongly
97
105
  reported as present) is roughly 2⁻⁶⁴, which is negligible in practice.
98
106
 
107
+ `VerifiedConstMap` keeps its values and its 64-bit check words in two
108
+ separate arrays. A lookup reads three check words, and if they match, three
109
+ value words: six cache lines, in two different arrays. `PairedVerifiedConstMap`
110
+ is the same map with each value stored next to its check word, so the three
111
+ reads bring in both at once (three cache lines), and on x86-64 and AArch64
112
+ each slot is loaded and XORed as one 128-bit word (SSE2 / NEON).
113
+
114
+ The trade-off is what happens on a **miss**. With the split layout, a
115
+ one-at-a-time lookup of an absent key reads only the check array, which is
116
+ half the size of the whole map and so far more likely to be sitting in cache.
117
+ The paired layout always pulls in the value alongside the check, so its
118
+ working set for misses is twice as large. In batched lookups
119
+ (`get_many`, `get_many_into`) the split layout gathers both arrays
120
+ unconditionally, so there the paired layout wins whether keys hit or miss.
121
+
122
+ Measured at the C level (`fcm_*_lookup`, one key at a time, random order
123
+ over the whole key set, best of five), ns per lookup, *hit* = key present,
124
+ *miss* = key absent:
125
+
126
+ | | keys | `VerifiedConstMap` hit | `PairedVerifiedConstMap` hit | `VerifiedConstMap` miss | `PairedVerifiedConstMap` miss |
127
+ |--|--|--|--|--|--|
128
+ | Apple M4 Max | 1M | 9.9 | 9.5 | **6.4** | 8.8 |
129
+ | | 10M | 20.9 | **17.1** | **15.5** | 17.1 |
130
+ | Xeon Gold 6548N | 1M | 17.3 | **12.8** | 13.8 | 13.0 |
131
+ | | 10M | 44.4 | **37.3** | **31.8** | 37.4 |
132
+
133
+ The two serialised formats are the same size but not interchangeable:
134
+ loading one class's bytes with the other raises `ValueError`.
135
+
99
136
  ## Batched lookups
100
137
 
101
138
  `get_many` takes an iterable of keys and returns a list of values, in the same
@@ -140,14 +177,25 @@ dominates instead.
140
177
 
141
178
  | | | loop over lookup | `lookup_many` | |
142
179
  |---|---|---|---|---|
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% |
180
+ | **Apple M4 Max** | `ConstMap` cold | 7.1 | **5.5** | 23% |
181
+ | | `ConstMap` hot | 6.1 | **4.6** | 24% |
182
+ | | `VerifiedConstMap` cold | 11.9 | **8.0** | 33% |
183
+ | | `VerifiedConstMap` hot | 7.4 | **5.8** | 21% |
184
+ | | `PairedVerifiedConstMap` cold | 10.8 | **7.3** | 32% |
185
+ | | `PairedVerifiedConstMap` hot | 6.6 | **4.8** | 27% |
186
+ | **Xeon Gold 6548N** | `ConstMap` cold | 19.1 | **13.6** | 29% |
187
+ | | `ConstMap` hot | 10.7 | **8.5** | 21% |
188
+ | | `VerifiedConstMap` cold | 21.8 | 21.9 | ~0% |
189
+ | | `VerifiedConstMap` hot | 12.6 | **10.5** | 17% |
190
+ | | `PairedVerifiedConstMap` cold | 21.0 | **15.0** | 29% |
191
+ | | `PairedVerifiedConstMap` hot | 12.5 | **9.9** | 21% |
192
+
193
+ These are with the default key hash, XXH64, which is what makes the files
194
+ interchangeable with the Go and Rust implementations (see
195
+ [Interoperability](#interoperability-with-the-go-and-rust-implementations)).
196
+ XXH3, the hash of 0.9 and earlier, is about 1.3 ns faster per 20-byte key on
197
+ the M4 Max and 2 ns on the Xeon; `ConstMap(d, hash="xxh3")` keeps it, at the
198
+ price of files only fastconstmap can read.
151
199
 
152
200
  The one case where batching does not pay is `VerifiedConstMap` in the cold
153
201
  regime on the Xeon. That map probes two arrays per lookup, so a fresh batch is
@@ -155,7 +203,9 @@ bound by a memory latency the overlapping cannot hide. The Go original reports
155
203
  the same result on the same processor.
156
204
 
157
205
  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:
206
+ per-key loop, 0.9.0 the batched path (both measured with XXH3; with the
207
+ default XXH64 of 0.10, add roughly 1-4 ns/key to the batched columns, and
208
+ 1-5 ns to a single `m[k]` that costs 50-120 ns):
159
209
 
160
210
  | | | 0.8.0 | 0.9.0 | `get_many_into` |
161
211
  |---|---|---|---|---|
@@ -256,12 +306,61 @@ Notes and constraints:
256
306
  all satisfy the alignment requirement; an offset slice of a buffer may
257
307
  not, in which case `from_buffer` raises `ValueError` — use
258
308
  `from_bytes()` (which copies) instead.
259
- - `VerifiedConstMap` supports the same three methods.
309
+ - `VerifiedConstMap` and `PairedVerifiedConstMap` support the same three
310
+ methods.
260
311
 
261
312
  `from_bytes()` remains available when you *want* an owned copy (or need to
262
313
  load on a big-endian host): it copies the data and the resulting map owns
263
314
  its memory independently of the source buffer.
264
315
 
316
+ ## Interoperability with the Go and Rust implementations
317
+
318
+ A map saved by any of [`constmap`][constmap] (Go), [`rsconstmap`][rsconstmap]
319
+ (Rust) and fastconstmap loads in the other two, on a little-endian host (which
320
+ is every mainstream one: x86-64, ARM64, RISC-V, Apple silicon). The three share
321
+ the key hash (XXH64), the mixing, the seed sequence and the layout, so for the
322
+ same input Go and Rust write byte-identical files, and fastconstmap writes the
323
+ same bytes apart from one header word:
324
+
325
+ | class | magic | notes |
326
+ |---|---|---|
327
+ | `ConstMap` | `CMAP0003` | Go and Rust write `CMAP0001`, the same format minus a 4-byte key count; all three read both |
328
+ | `VerifiedConstMap` | `VMAP0001` | identical; the header word Go and Rust leave zero holds the key count here |
329
+ | `PairedVerifiedConstMap` | `PMAP0001` | likewise |
330
+
331
+ ```python
332
+ m = ConstMap.load("built-by-go.cmap") # from constmap.SaveToFile / save_to_file
333
+ vm = VerifiedConstMap.load("built-by-rust.vmap")
334
+ ```
335
+
336
+ Two things to know about a map that came from Go or Rust:
337
+
338
+ - **`len(m)` is 0**, because those writers do not record the key count. Every
339
+ lookup is unaffected.
340
+ - **A Go or Rust `ConstMap` file (`CMAP0001`) cannot be opened with
341
+ `from_buffer`**: its 28-byte header leaves the array 4 bytes off an 8-byte
342
+ boundary in any aligned buffer, so `from_buffer` raises `ValueError`. `load`
343
+ and `from_bytes`, which copy, work as usual. The other two formats have a
344
+ 32-byte header and view fine.
345
+
346
+ **Files from fastconstmap 0.9 and earlier** (`CMAP0002` / `VCMP0002`) still
347
+ load and answer correctly: they were built with XXH3, and a map keeps the hash
348
+ its file was built with (`m.hash` tells you which). Re-saving such a map keeps
349
+ its legacy magic, since its table only answers to XXH3; Go and Rust name these
350
+ files in their error message. To get a file the other implementations read,
351
+ rebuild the map from its keys.
352
+
353
+ **Keeping XXH3.** XXH3 is faster than XXH64 on short keys, by about 1-2 ns per
354
+ lookup at the C level. If you do not need the shared format, build with
355
+ `ConstMap(d, hash="xxh3")` (likewise for the other two classes); such a map
356
+ serializes with the legacy magic, readable by fastconstmap only.
357
+
358
+ The interoperability is tested: `tests/interop/` holds files written by the Go
359
+ package from a fixed input, which the test suite loads and checks, and the Go
360
+ and Rust repositories hold files written by this package.
361
+
362
+ [rsconstmap]: https://github.com/lemire/rsconstmap
363
+
265
364
  ## Benchmark
266
365
 
267
366
  On an Apple M4 Max, with 1,000,000 string keys (`key-{i}-{hex}` shaped
@@ -315,7 +414,7 @@ python benchmarks/benchmark.py 1000000
315
414
 
316
415
  Given *n* (key, value) pairs the algorithm:
317
416
 
318
- 1. Hashes each key with xxhash3 to a 64-bit value.
417
+ 1. Hashes each key with XXH64 (or XXH3 if asked) to a 64-bit value.
319
418
  2. Maps each hashed key to three positions `h0, h1, h2` in an array of
320
419
  size ~1.125·*n*, using overlapping segments.
321
420
  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
  |---|---|---|---|---|
@@ -238,12 +288,61 @@ 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
+ ## Interoperability with the Go and Rust implementations
299
+
300
+ A map saved by any of [`constmap`][constmap] (Go), [`rsconstmap`][rsconstmap]
301
+ (Rust) and fastconstmap loads in the other two, on a little-endian host (which
302
+ is every mainstream one: x86-64, ARM64, RISC-V, Apple silicon). The three share
303
+ the key hash (XXH64), the mixing, the seed sequence and the layout, so for the
304
+ same input Go and Rust write byte-identical files, and fastconstmap writes the
305
+ same bytes apart from one header word:
306
+
307
+ | class | magic | notes |
308
+ |---|---|---|
309
+ | `ConstMap` | `CMAP0003` | Go and Rust write `CMAP0001`, the same format minus a 4-byte key count; all three read both |
310
+ | `VerifiedConstMap` | `VMAP0001` | identical; the header word Go and Rust leave zero holds the key count here |
311
+ | `PairedVerifiedConstMap` | `PMAP0001` | likewise |
312
+
313
+ ```python
314
+ m = ConstMap.load("built-by-go.cmap") # from constmap.SaveToFile / save_to_file
315
+ vm = VerifiedConstMap.load("built-by-rust.vmap")
316
+ ```
317
+
318
+ Two things to know about a map that came from Go or Rust:
319
+
320
+ - **`len(m)` is 0**, because those writers do not record the key count. Every
321
+ lookup is unaffected.
322
+ - **A Go or Rust `ConstMap` file (`CMAP0001`) cannot be opened with
323
+ `from_buffer`**: its 28-byte header leaves the array 4 bytes off an 8-byte
324
+ boundary in any aligned buffer, so `from_buffer` raises `ValueError`. `load`
325
+ and `from_bytes`, which copy, work as usual. The other two formats have a
326
+ 32-byte header and view fine.
327
+
328
+ **Files from fastconstmap 0.9 and earlier** (`CMAP0002` / `VCMP0002`) still
329
+ load and answer correctly: they were built with XXH3, and a map keeps the hash
330
+ its file was built with (`m.hash` tells you which). Re-saving such a map keeps
331
+ its legacy magic, since its table only answers to XXH3; Go and Rust name these
332
+ files in their error message. To get a file the other implementations read,
333
+ rebuild the map from its keys.
334
+
335
+ **Keeping XXH3.** XXH3 is faster than XXH64 on short keys, by about 1-2 ns per
336
+ lookup at the C level. If you do not need the shared format, build with
337
+ `ConstMap(d, hash="xxh3")` (likewise for the other two classes); such a map
338
+ serializes with the legacy magic, readable by fastconstmap only.
339
+
340
+ The interoperability is tested: `tests/interop/` holds files written by the Go
341
+ package from a fixed input, which the test suite loads and checks, and the Go
342
+ and Rust repositories hold files written by this package.
343
+
344
+ [rsconstmap]: https://github.com/lemire/rsconstmap
345
+
247
346
  ## Benchmark
248
347
 
249
348
  On an Apple M4 Max, with 1,000,000 string keys (`key-{i}-{hex}` shaped
@@ -297,7 +396,7 @@ python benchmarks/benchmark.py 1000000
297
396
 
298
397
  Given *n* (key, value) pairs the algorithm:
299
398
 
300
- 1. Hashes each key with xxhash3 to a 64-bit value.
399
+ 1. Hashes each key with XXH64 (or XXH3 if asked) to a 64-bit value.
301
400
  2. Maps each hashed key to three positions `h0, h1, h2` in an array of
302
401
  size ~1.125·*n*, using overlapping segments.
303
402
  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"