fastconstmap 0.1.0__tar.gz → 0.7.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.
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/MANIFEST.in +3 -0
- {fastconstmap-0.1.0/fastconstmap.egg-info → fastconstmap-0.7.0}/PKG-INFO +74 -1
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/README.md +73 -0
- fastconstmap-0.7.0/benchmarks/benchmark.py +155 -0
- fastconstmap-0.7.0/examples/example.py +25 -0
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/fastconstmap/__init__.py +4 -2
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/fastconstmap/_fastconstmap.pyi +13 -4
- {fastconstmap-0.1.0 → fastconstmap-0.7.0/fastconstmap.egg-info}/PKG-INFO +74 -1
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/fastconstmap.egg-info/SOURCES.txt +2 -0
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/pyproject.toml +5 -2
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/src/constmap.c +82 -3
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/src/constmap.h +19 -0
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/src/fastconstmap_module.c +148 -20
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/LICENSE +0 -0
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/fastconstmap/py.typed +0 -0
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/fastconstmap.egg-info/dependency_links.txt +0 -0
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/fastconstmap.egg-info/top_level.txt +0 -0
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/setup.cfg +0 -0
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/setup.py +0 -0
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/src/third_party/xxhash/LICENSE +0 -0
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/src/third_party/xxhash/xxhash.h +0 -0
- {fastconstmap-0.1.0 → fastconstmap-0.7.0}/tests/test_constmap.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastconstmap
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -19,6 +19,7 @@ Dynamic: license-file
|
|
|
19
19
|
# fastconstmap
|
|
20
20
|
|
|
21
21
|
[](LICENSE)
|
|
22
|
+
[](https://github.com/lemire/fastconstmap/actions/workflows/test.yml)
|
|
22
23
|
|
|
23
24
|
Fast, immutable, compact map from strings to 64-bit integers — for Python.
|
|
24
25
|
|
|
@@ -101,6 +102,78 @@ Keys must be unique (Python dict semantics already guarantee this).
|
|
|
101
102
|
Construction raises `ValueError` in the extremely unlikely event of an
|
|
102
103
|
xxhash collision (~2⁻⁶⁴ per key pair).
|
|
103
104
|
|
|
105
|
+
## Sharing a map across processes (zero-copy)
|
|
106
|
+
|
|
107
|
+
A map's serialized form *is* its in-memory lookup array (plus a small
|
|
108
|
+
header). That means it can live in a
|
|
109
|
+
[`multiprocessing.shared_memory`](https://docs.python.org/3/library/multiprocessing.shared_memory.html)
|
|
110
|
+
block and be opened by any number of processes **without copying** — every
|
|
111
|
+
process reads the same physical pages.
|
|
112
|
+
|
|
113
|
+
Three methods make this work:
|
|
114
|
+
|
|
115
|
+
| Method | Purpose |
|
|
116
|
+
|--|--|
|
|
117
|
+
| `m.serialized_size()` | bytes needed to hold the serialized map |
|
|
118
|
+
| `m.write_into(buffer)` | serialize straight into a writable buffer (no intermediate `bytes`) |
|
|
119
|
+
| `ConstMap.from_buffer(buffer)` | open a **zero-copy** map that reads directly from `buffer` |
|
|
120
|
+
|
|
121
|
+
Producer — build once, publish into a **named** shared-memory block:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from multiprocessing.shared_memory import SharedMemory
|
|
125
|
+
from fastconstmap import ConstMap
|
|
126
|
+
|
|
127
|
+
SHM_NAME = "fastconstmap_demo"
|
|
128
|
+
|
|
129
|
+
cm = ConstMap({f"key-{i}": i for i in range(1_000_000)})
|
|
130
|
+
|
|
131
|
+
shm = SharedMemory(create=True, size=cm.serialized_size(), name=SHM_NAME)
|
|
132
|
+
cm.write_into(shm.buf)
|
|
133
|
+
# keep `shm` alive (do not close/unlink) while consumers are running
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Consumer — attach to the same name with no copy:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from multiprocessing.shared_memory import SharedMemory
|
|
140
|
+
from fastconstmap import ConstMap
|
|
141
|
+
|
|
142
|
+
SHM_NAME = "fastconstmap_demo"
|
|
143
|
+
|
|
144
|
+
shm = SharedMemory(name=SHM_NAME) # attach by name, no `create=`
|
|
145
|
+
cm = ConstMap.from_buffer(shm.buf) # zero-copy: no per-process copy
|
|
146
|
+
cm["key-42"] # reads straight from shared memory
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Choosing the name yourself (rather than letting `SharedMemory` generate
|
|
150
|
+
one) means consumers can hard-code it or read it from config — no need to
|
|
151
|
+
pass the auto-generated name around. Pick a unique name; creating a block
|
|
152
|
+
whose name already exists raises `FileExistsError`.
|
|
153
|
+
|
|
154
|
+
Notes and constraints:
|
|
155
|
+
|
|
156
|
+
- **`from_buffer` does not copy.** The returned map holds a reference to the
|
|
157
|
+
buffer; the buffer (and, for shared memory, the `SharedMemory` object)
|
|
158
|
+
must stay alive and **must not be closed or mutated** while the map is in
|
|
159
|
+
use. Drop the map (`del cm`) before calling `shm.close()`.
|
|
160
|
+
- The map is **immutable** — the intended pattern is *write once in the
|
|
161
|
+
producer, then only read in every process*. Concurrent readers need no
|
|
162
|
+
locking.
|
|
163
|
+
- `from_buffer` verifies the magic bytes and the FNV-1a checksum, so a
|
|
164
|
+
truncated or corrupt block raises `ValueError` rather than returning
|
|
165
|
+
garbage.
|
|
166
|
+
- Requirements: a **little-endian** host (x86-64, ARM64, …) and an
|
|
167
|
+
**8-byte-aligned** buffer. `SharedMemory.buf`, `bytes`, and `bytearray`
|
|
168
|
+
all satisfy the alignment requirement; an offset slice of a buffer may
|
|
169
|
+
not, in which case `from_buffer` raises `ValueError` — use
|
|
170
|
+
`from_bytes()` (which copies) instead.
|
|
171
|
+
- `VerifiedConstMap` supports the same three methods.
|
|
172
|
+
|
|
173
|
+
`from_bytes()` remains available when you *want* an owned copy (or need to
|
|
174
|
+
load on a big-endian host): it copies the data and the resulting map owns
|
|
175
|
+
its memory independently of the source buffer.
|
|
176
|
+
|
|
104
177
|
## Benchmark
|
|
105
178
|
|
|
106
179
|
On an Apple M-series CPU, with 1,000,000 string keys
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# fastconstmap
|
|
2
2
|
|
|
3
3
|
[](LICENSE)
|
|
4
|
+
[](https://github.com/lemire/fastconstmap/actions/workflows/test.yml)
|
|
4
5
|
|
|
5
6
|
Fast, immutable, compact map from strings to 64-bit integers — for Python.
|
|
6
7
|
|
|
@@ -83,6 +84,78 @@ Keys must be unique (Python dict semantics already guarantee this).
|
|
|
83
84
|
Construction raises `ValueError` in the extremely unlikely event of an
|
|
84
85
|
xxhash collision (~2⁻⁶⁴ per key pair).
|
|
85
86
|
|
|
87
|
+
## Sharing a map across processes (zero-copy)
|
|
88
|
+
|
|
89
|
+
A map's serialized form *is* its in-memory lookup array (plus a small
|
|
90
|
+
header). That means it can live in a
|
|
91
|
+
[`multiprocessing.shared_memory`](https://docs.python.org/3/library/multiprocessing.shared_memory.html)
|
|
92
|
+
block and be opened by any number of processes **without copying** — every
|
|
93
|
+
process reads the same physical pages.
|
|
94
|
+
|
|
95
|
+
Three methods make this work:
|
|
96
|
+
|
|
97
|
+
| Method | Purpose |
|
|
98
|
+
|--|--|
|
|
99
|
+
| `m.serialized_size()` | bytes needed to hold the serialized map |
|
|
100
|
+
| `m.write_into(buffer)` | serialize straight into a writable buffer (no intermediate `bytes`) |
|
|
101
|
+
| `ConstMap.from_buffer(buffer)` | open a **zero-copy** map that reads directly from `buffer` |
|
|
102
|
+
|
|
103
|
+
Producer — build once, publish into a **named** shared-memory block:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from multiprocessing.shared_memory import SharedMemory
|
|
107
|
+
from fastconstmap import ConstMap
|
|
108
|
+
|
|
109
|
+
SHM_NAME = "fastconstmap_demo"
|
|
110
|
+
|
|
111
|
+
cm = ConstMap({f"key-{i}": i for i in range(1_000_000)})
|
|
112
|
+
|
|
113
|
+
shm = SharedMemory(create=True, size=cm.serialized_size(), name=SHM_NAME)
|
|
114
|
+
cm.write_into(shm.buf)
|
|
115
|
+
# keep `shm` alive (do not close/unlink) while consumers are running
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Consumer — attach to the same name with no copy:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
from multiprocessing.shared_memory import SharedMemory
|
|
122
|
+
from fastconstmap import ConstMap
|
|
123
|
+
|
|
124
|
+
SHM_NAME = "fastconstmap_demo"
|
|
125
|
+
|
|
126
|
+
shm = SharedMemory(name=SHM_NAME) # attach by name, no `create=`
|
|
127
|
+
cm = ConstMap.from_buffer(shm.buf) # zero-copy: no per-process copy
|
|
128
|
+
cm["key-42"] # reads straight from shared memory
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Choosing the name yourself (rather than letting `SharedMemory` generate
|
|
132
|
+
one) means consumers can hard-code it or read it from config — no need to
|
|
133
|
+
pass the auto-generated name around. Pick a unique name; creating a block
|
|
134
|
+
whose name already exists raises `FileExistsError`.
|
|
135
|
+
|
|
136
|
+
Notes and constraints:
|
|
137
|
+
|
|
138
|
+
- **`from_buffer` does not copy.** The returned map holds a reference to the
|
|
139
|
+
buffer; the buffer (and, for shared memory, the `SharedMemory` object)
|
|
140
|
+
must stay alive and **must not be closed or mutated** while the map is in
|
|
141
|
+
use. Drop the map (`del cm`) before calling `shm.close()`.
|
|
142
|
+
- The map is **immutable** — the intended pattern is *write once in the
|
|
143
|
+
producer, then only read in every process*. Concurrent readers need no
|
|
144
|
+
locking.
|
|
145
|
+
- `from_buffer` verifies the magic bytes and the FNV-1a checksum, so a
|
|
146
|
+
truncated or corrupt block raises `ValueError` rather than returning
|
|
147
|
+
garbage.
|
|
148
|
+
- Requirements: a **little-endian** host (x86-64, ARM64, …) and an
|
|
149
|
+
**8-byte-aligned** buffer. `SharedMemory.buf`, `bytes`, and `bytearray`
|
|
150
|
+
all satisfy the alignment requirement; an offset slice of a buffer may
|
|
151
|
+
not, in which case `from_buffer` raises `ValueError` — use
|
|
152
|
+
`from_bytes()` (which copies) instead.
|
|
153
|
+
- `VerifiedConstMap` supports the same three methods.
|
|
154
|
+
|
|
155
|
+
`from_bytes()` remains available when you *want* an owned copy (or need to
|
|
156
|
+
load on a big-endian host): it copies the data and the resulting map owns
|
|
157
|
+
its memory independently of the source buffer.
|
|
158
|
+
|
|
86
159
|
## Benchmark
|
|
87
160
|
|
|
88
161
|
On an Apple M-series CPU, with 1,000,000 string keys
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Benchmark fastconstmap against the built-in dict.
|
|
2
|
+
|
|
3
|
+
Run as:
|
|
4
|
+
python benchmarks/benchmark.py [N]
|
|
5
|
+
|
|
6
|
+
Defaults to N = 1,000,000 keys.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import argparse
|
|
10
|
+
import random
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from typing import Callable
|
|
14
|
+
|
|
15
|
+
from fastconstmap import ConstMap, VerifiedConstMap
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_data(n: int, seed: int = 0xC0FFEE) -> tuple[list[str], dict[str, int]]:
|
|
19
|
+
rng = random.Random(seed)
|
|
20
|
+
keys = [f"key-{i}-{rng.randint(0, 1 << 30):x}" for i in range(n)]
|
|
21
|
+
return keys, {k: i for i, k in enumerate(keys)}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def time_loop(label: str, iterations: int, fn: Callable[[int], None]) -> float:
|
|
25
|
+
# Warm-up so the JIT-equivalent (branch predictor, page faults) settles.
|
|
26
|
+
for i in range(min(iterations, 1000)):
|
|
27
|
+
fn(i)
|
|
28
|
+
t0 = time.perf_counter()
|
|
29
|
+
for i in range(iterations):
|
|
30
|
+
fn(i)
|
|
31
|
+
elapsed = time.perf_counter() - t0
|
|
32
|
+
per_op_ns = elapsed * 1e9 / iterations
|
|
33
|
+
print(f" {label:<28} {per_op_ns:8.1f} ns/op ({elapsed:.3f} s total)")
|
|
34
|
+
return per_op_ns
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def time_batch(label: str, batches: int, batch_size: int, fn: Callable[[], None]) -> float:
|
|
38
|
+
for _ in range(min(batches, 5)):
|
|
39
|
+
fn()
|
|
40
|
+
t0 = time.perf_counter()
|
|
41
|
+
for _ in range(batches):
|
|
42
|
+
fn()
|
|
43
|
+
elapsed = time.perf_counter() - t0
|
|
44
|
+
per_op_ns = elapsed * 1e9 / (batches * batch_size)
|
|
45
|
+
print(f" {label:<28} {per_op_ns:8.1f} ns/op "
|
|
46
|
+
f"({elapsed*1000/batches:.2f} ms/batch of {batch_size})")
|
|
47
|
+
return per_op_ns
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def report_size(label: str, value: int, n: int) -> None:
|
|
51
|
+
print(f" {label:<28} {value:12,} bytes ({value/n:.2f} bytes/key)")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def deep_sizeof(obj, seen: set[int] | None = None) -> int:
|
|
55
|
+
"""Recursive sizeof that walks dict / list / tuple / set, deduping by id.
|
|
56
|
+
|
|
57
|
+
Returns the total bytes attributable to ``obj`` and everything it
|
|
58
|
+
transitively references. Small interned ints (-5..256) and interned
|
|
59
|
+
strings are counted once because of the id() dedup, which matches
|
|
60
|
+
reality — they're shared with the rest of the process.
|
|
61
|
+
"""
|
|
62
|
+
if seen is None:
|
|
63
|
+
seen = set()
|
|
64
|
+
obj_id = id(obj)
|
|
65
|
+
if obj_id in seen:
|
|
66
|
+
return 0
|
|
67
|
+
seen.add(obj_id)
|
|
68
|
+
size = sys.getsizeof(obj)
|
|
69
|
+
if isinstance(obj, dict):
|
|
70
|
+
for k, v in obj.items():
|
|
71
|
+
size += deep_sizeof(k, seen)
|
|
72
|
+
size += deep_sizeof(v, seen)
|
|
73
|
+
elif isinstance(obj, (list, tuple, set, frozenset)):
|
|
74
|
+
for item in obj:
|
|
75
|
+
size += deep_sizeof(item, seen)
|
|
76
|
+
return size
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def main() -> None:
|
|
80
|
+
parser = argparse.ArgumentParser()
|
|
81
|
+
parser.add_argument("n", type=int, nargs="?", default=1_000_000)
|
|
82
|
+
parser.add_argument("--lookups", type=int, default=2_000_000,
|
|
83
|
+
help="number of individual lookups to time")
|
|
84
|
+
parser.add_argument("--batch-size", type=int, default=1024)
|
|
85
|
+
parser.add_argument("--batches", type=int, default=2000)
|
|
86
|
+
args = parser.parse_args()
|
|
87
|
+
n = args.n
|
|
88
|
+
|
|
89
|
+
print(f"=== fastconstmap benchmark — n = {n:,} keys, "
|
|
90
|
+
f"python {sys.version.split()[0]} ===\n")
|
|
91
|
+
|
|
92
|
+
keys, d = make_data(n)
|
|
93
|
+
# Lookup index pattern: shuffled to avoid trivial cache wins.
|
|
94
|
+
rng = random.Random(1234)
|
|
95
|
+
lookup_idx = [rng.randrange(n) for _ in range(args.lookups)]
|
|
96
|
+
|
|
97
|
+
# ---- Construction time ----
|
|
98
|
+
print("Construction:")
|
|
99
|
+
t0 = time.perf_counter()
|
|
100
|
+
cm = ConstMap(d)
|
|
101
|
+
print(f" ConstMap.__init__ {time.perf_counter()-t0:7.3f} s")
|
|
102
|
+
|
|
103
|
+
t0 = time.perf_counter()
|
|
104
|
+
vm = VerifiedConstMap(d)
|
|
105
|
+
print(f" VerifiedConstMap.__init__ {time.perf_counter()-t0:7.3f} s")
|
|
106
|
+
|
|
107
|
+
t0 = time.perf_counter()
|
|
108
|
+
pyd = dict(d)
|
|
109
|
+
print(f" dict(d) {time.perf_counter()-t0:7.3f} s")
|
|
110
|
+
|
|
111
|
+
# ---- Memory ----
|
|
112
|
+
# The ConstMap retains no string objects: cm.nbytes() is its entire
|
|
113
|
+
# footprint. To compare honestly we walk the dict and add up the
|
|
114
|
+
# dict-table size *plus* every str key and int value it references
|
|
115
|
+
# (deduped by id, so interned objects count once).
|
|
116
|
+
print("\nMemory:")
|
|
117
|
+
report_size("ConstMap.nbytes", cm.nbytes(), n)
|
|
118
|
+
report_size("VerifiedConstMap.nbytes", vm.nbytes(), n)
|
|
119
|
+
dict_total = deep_sizeof(pyd)
|
|
120
|
+
report_size("dict (table+keys+values)", dict_total, n)
|
|
121
|
+
print(f" ratio dict / ConstMap {dict_total / cm.nbytes():.1f}x")
|
|
122
|
+
|
|
123
|
+
# ---- Single lookup ----
|
|
124
|
+
print(f"\nSingle lookup, {args.lookups:,} ops:")
|
|
125
|
+
|
|
126
|
+
time_loop("dict[k]", args.lookups, lambda i: pyd[keys[lookup_idx[i]]])
|
|
127
|
+
time_loop("ConstMap[k]", args.lookups, lambda i: cm[keys[lookup_idx[i]]])
|
|
128
|
+
time_loop("VerifiedConstMap[k]", args.lookups, lambda i: vm[keys[lookup_idx[i]]])
|
|
129
|
+
|
|
130
|
+
# ---- Batched lookup ----
|
|
131
|
+
batch_keys = [keys[rng.randrange(n)] for _ in range(args.batch_size)]
|
|
132
|
+
print(f"\nBatched lookup, {args.batches} × {args.batch_size}:")
|
|
133
|
+
|
|
134
|
+
# dict batch: pure Python list comprehension is the natural comparison.
|
|
135
|
+
time_batch("dict comprehension", args.batches, args.batch_size,
|
|
136
|
+
lambda: [pyd[k] for k in batch_keys])
|
|
137
|
+
time_batch("ConstMap.get_many", args.batches, args.batch_size,
|
|
138
|
+
lambda: cm.get_many(batch_keys))
|
|
139
|
+
time_batch("VerifiedConstMap.get_many", args.batches, args.batch_size,
|
|
140
|
+
lambda: vm.get_many(batch_keys))
|
|
141
|
+
|
|
142
|
+
# ---- Serialization ----
|
|
143
|
+
print("\nSerialization:")
|
|
144
|
+
t0 = time.perf_counter()
|
|
145
|
+
blob = cm.to_bytes()
|
|
146
|
+
print(f" ConstMap.to_bytes {time.perf_counter()-t0:7.3f} s ({len(blob):,} bytes)")
|
|
147
|
+
t0 = time.perf_counter()
|
|
148
|
+
cm2 = ConstMap.from_bytes(blob)
|
|
149
|
+
print(f" ConstMap.from_bytes {time.perf_counter()-t0:7.3f} s")
|
|
150
|
+
# Verify round-trip.
|
|
151
|
+
assert cm2[keys[0]] == d[keys[0]]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
main()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Tiny end-to-end example: build, look up, save, reload."""
|
|
2
|
+
from fastconstmap import ConstMap, VerifiedConstMap
|
|
3
|
+
|
|
4
|
+
d = {"apple": 100, "banana": 200, "cherry": 300}
|
|
5
|
+
|
|
6
|
+
# Minimal, no missing-key detection.
|
|
7
|
+
m = ConstMap(d)
|
|
8
|
+
print("apple ->", m["apple"])
|
|
9
|
+
print("batched ->", m.get_many(["banana", "cherry"]))
|
|
10
|
+
|
|
11
|
+
# Dict-like, detects missing keys.
|
|
12
|
+
vm = VerifiedConstMap(d)
|
|
13
|
+
print("vm grape?", vm.get("grape"))
|
|
14
|
+
print("vm has banana?", "banana" in vm)
|
|
15
|
+
|
|
16
|
+
# Round-trip through bytes.
|
|
17
|
+
blob = m.to_bytes()
|
|
18
|
+
m2 = ConstMap.from_bytes(blob)
|
|
19
|
+
assert m2["banana"] == 200
|
|
20
|
+
|
|
21
|
+
# Round-trip through a file.
|
|
22
|
+
m.save("example.cmap")
|
|
23
|
+
m3 = ConstMap.load("example.cmap")
|
|
24
|
+
assert m3["cherry"] == 300
|
|
25
|
+
print("save/load OK")
|
|
@@ -14,9 +14,11 @@ Two classes are exposed:
|
|
|
14
14
|
works.
|
|
15
15
|
|
|
16
16
|
Both types are immutable after construction and can be serialised to bytes
|
|
17
|
-
or to a file path.
|
|
17
|
+
or to a file path. They can also be placed in a ``multiprocessing``
|
|
18
|
+
``SharedMemory`` block and opened by many processes with no per-process
|
|
19
|
+
copy — see ``serialized_size``, ``write_into`` and ``from_buffer``.
|
|
18
20
|
"""
|
|
19
21
|
from ._fastconstmap import ConstMap, VerifiedConstMap
|
|
20
22
|
|
|
21
23
|
__all__ = ["ConstMap", "VerifiedConstMap"]
|
|
22
|
-
__version__ = "0.
|
|
24
|
+
__version__ = "0.7.0"
|
|
@@ -3,8 +3,9 @@ from __future__ import annotations
|
|
|
3
3
|
from os import PathLike
|
|
4
4
|
from typing import Iterable, Mapping, Optional, Union
|
|
5
5
|
|
|
6
|
-
KeyT
|
|
7
|
-
PathT
|
|
6
|
+
KeyT = Union[str, bytes]
|
|
7
|
+
PathT = Union[str, bytes, PathLike[str], PathLike[bytes]]
|
|
8
|
+
BufferT = Union[bytes, bytearray, memoryview]
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class ConstMap:
|
|
@@ -21,7 +22,11 @@ class ConstMap:
|
|
|
21
22
|
def get_many(self, keys: Iterable[KeyT]) -> list[int]: ...
|
|
22
23
|
def to_bytes(self) -> bytes: ...
|
|
23
24
|
@classmethod
|
|
24
|
-
def from_bytes(cls, data:
|
|
25
|
+
def from_bytes(cls, data: BufferT) -> "ConstMap": ...
|
|
26
|
+
def serialized_size(self) -> int: ...
|
|
27
|
+
def write_into(self, buffer: BufferT) -> int: ...
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_buffer(cls, buffer: BufferT) -> "ConstMap": ...
|
|
25
30
|
def save(self, path: PathT) -> None: ...
|
|
26
31
|
@classmethod
|
|
27
32
|
def load(cls, path: PathT) -> "ConstMap": ...
|
|
@@ -46,7 +51,11 @@ class VerifiedConstMap:
|
|
|
46
51
|
def get_many(self, keys: Iterable[KeyT], default: Optional[int] = None) -> list[Optional[int]]: ...
|
|
47
52
|
def to_bytes(self) -> bytes: ...
|
|
48
53
|
@classmethod
|
|
49
|
-
def from_bytes(cls, data:
|
|
54
|
+
def from_bytes(cls, data: BufferT) -> "VerifiedConstMap": ...
|
|
55
|
+
def serialized_size(self) -> int: ...
|
|
56
|
+
def write_into(self, buffer: BufferT) -> int: ...
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_buffer(cls, buffer: BufferT) -> "VerifiedConstMap": ...
|
|
50
59
|
def save(self, path: PathT) -> None: ...
|
|
51
60
|
@classmethod
|
|
52
61
|
def load(cls, path: PathT) -> "VerifiedConstMap": ...
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastconstmap
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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
|
|
@@ -19,6 +19,7 @@ Dynamic: license-file
|
|
|
19
19
|
# fastconstmap
|
|
20
20
|
|
|
21
21
|
[](LICENSE)
|
|
22
|
+
[](https://github.com/lemire/fastconstmap/actions/workflows/test.yml)
|
|
22
23
|
|
|
23
24
|
Fast, immutable, compact map from strings to 64-bit integers — for Python.
|
|
24
25
|
|
|
@@ -101,6 +102,78 @@ Keys must be unique (Python dict semantics already guarantee this).
|
|
|
101
102
|
Construction raises `ValueError` in the extremely unlikely event of an
|
|
102
103
|
xxhash collision (~2⁻⁶⁴ per key pair).
|
|
103
104
|
|
|
105
|
+
## Sharing a map across processes (zero-copy)
|
|
106
|
+
|
|
107
|
+
A map's serialized form *is* its in-memory lookup array (plus a small
|
|
108
|
+
header). That means it can live in a
|
|
109
|
+
[`multiprocessing.shared_memory`](https://docs.python.org/3/library/multiprocessing.shared_memory.html)
|
|
110
|
+
block and be opened by any number of processes **without copying** — every
|
|
111
|
+
process reads the same physical pages.
|
|
112
|
+
|
|
113
|
+
Three methods make this work:
|
|
114
|
+
|
|
115
|
+
| Method | Purpose |
|
|
116
|
+
|--|--|
|
|
117
|
+
| `m.serialized_size()` | bytes needed to hold the serialized map |
|
|
118
|
+
| `m.write_into(buffer)` | serialize straight into a writable buffer (no intermediate `bytes`) |
|
|
119
|
+
| `ConstMap.from_buffer(buffer)` | open a **zero-copy** map that reads directly from `buffer` |
|
|
120
|
+
|
|
121
|
+
Producer — build once, publish into a **named** shared-memory block:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from multiprocessing.shared_memory import SharedMemory
|
|
125
|
+
from fastconstmap import ConstMap
|
|
126
|
+
|
|
127
|
+
SHM_NAME = "fastconstmap_demo"
|
|
128
|
+
|
|
129
|
+
cm = ConstMap({f"key-{i}": i for i in range(1_000_000)})
|
|
130
|
+
|
|
131
|
+
shm = SharedMemory(create=True, size=cm.serialized_size(), name=SHM_NAME)
|
|
132
|
+
cm.write_into(shm.buf)
|
|
133
|
+
# keep `shm` alive (do not close/unlink) while consumers are running
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Consumer — attach to the same name with no copy:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from multiprocessing.shared_memory import SharedMemory
|
|
140
|
+
from fastconstmap import ConstMap
|
|
141
|
+
|
|
142
|
+
SHM_NAME = "fastconstmap_demo"
|
|
143
|
+
|
|
144
|
+
shm = SharedMemory(name=SHM_NAME) # attach by name, no `create=`
|
|
145
|
+
cm = ConstMap.from_buffer(shm.buf) # zero-copy: no per-process copy
|
|
146
|
+
cm["key-42"] # reads straight from shared memory
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Choosing the name yourself (rather than letting `SharedMemory` generate
|
|
150
|
+
one) means consumers can hard-code it or read it from config — no need to
|
|
151
|
+
pass the auto-generated name around. Pick a unique name; creating a block
|
|
152
|
+
whose name already exists raises `FileExistsError`.
|
|
153
|
+
|
|
154
|
+
Notes and constraints:
|
|
155
|
+
|
|
156
|
+
- **`from_buffer` does not copy.** The returned map holds a reference to the
|
|
157
|
+
buffer; the buffer (and, for shared memory, the `SharedMemory` object)
|
|
158
|
+
must stay alive and **must not be closed or mutated** while the map is in
|
|
159
|
+
use. Drop the map (`del cm`) before calling `shm.close()`.
|
|
160
|
+
- The map is **immutable** — the intended pattern is *write once in the
|
|
161
|
+
producer, then only read in every process*. Concurrent readers need no
|
|
162
|
+
locking.
|
|
163
|
+
- `from_buffer` verifies the magic bytes and the FNV-1a checksum, so a
|
|
164
|
+
truncated or corrupt block raises `ValueError` rather than returning
|
|
165
|
+
garbage.
|
|
166
|
+
- Requirements: a **little-endian** host (x86-64, ARM64, …) and an
|
|
167
|
+
**8-byte-aligned** buffer. `SharedMemory.buf`, `bytes`, and `bytearray`
|
|
168
|
+
all satisfy the alignment requirement; an offset slice of a buffer may
|
|
169
|
+
not, in which case `from_buffer` raises `ValueError` — use
|
|
170
|
+
`from_bytes()` (which copies) instead.
|
|
171
|
+
- `VerifiedConstMap` supports the same three methods.
|
|
172
|
+
|
|
173
|
+
`from_bytes()` remains available when you *want* an owned copy (or need to
|
|
174
|
+
load on a big-endian host): it copies the data and the resulting map owns
|
|
175
|
+
its memory independently of the source buffer.
|
|
176
|
+
|
|
104
177
|
## Benchmark
|
|
105
178
|
|
|
106
179
|
On an Apple M-series CPU, with 1,000,000 string keys
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fastconstmap"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.7.0"
|
|
8
8
|
description = "Fast, immutable, compact map from strings to uint64 (binary fuse filter)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "Apache-2.0" }
|
|
@@ -38,7 +38,10 @@ test-command = "pytest {project}/tests"
|
|
|
38
38
|
archs = ["x86_64", "universal2", "arm64"]
|
|
39
39
|
|
|
40
40
|
[tool.cibuildwheel.linux]
|
|
41
|
-
|
|
41
|
+
# "auto64" = the runner's native 64-bit arch (no 32-bit i686). The wheels
|
|
42
|
+
# workflow runs this on both an x86_64 runner and an aarch64 runner, so
|
|
43
|
+
# this produces x86_64 and aarch64 manylinux wheels — each built natively.
|
|
44
|
+
archs = ["auto64"]
|
|
42
45
|
|
|
43
46
|
[tool.cibuildwheel.windows]
|
|
44
47
|
archs = ["AMD64"]
|
|
@@ -563,15 +563,17 @@ uint64_t fcm_verified_constmap_lookup(const fcm_verified_constmap_t *vm,
|
|
|
563
563
|
/* [4] segment_length */
|
|
564
564
|
/* [4] segment_count */
|
|
565
565
|
/* [4] data_len */
|
|
566
|
+
/* [4] reserved (zero) — pads the header to 32 bytes so the data array */
|
|
567
|
+
/* starts 8-byte aligned, enabling zero-copy uint64 views */
|
|
566
568
|
/* [8 * data_len] data */
|
|
567
569
|
/* (verified only) [8 * data_len] checks */
|
|
568
570
|
/* [8] FNV-1a-64 checksum of all preceding bytes */
|
|
569
571
|
/* ------------------------------------------------------------------------- */
|
|
570
572
|
|
|
571
|
-
static const uint8_t fcm_magic_constmap[8] = {'C','M','A','P','0','0','0','
|
|
572
|
-
static const uint8_t fcm_magic_verified_constmap[8] = {'V','C','M','P','0','0','0','
|
|
573
|
+
static const uint8_t fcm_magic_constmap[8] = {'C','M','A','P','0','0','0','2'};
|
|
574
|
+
static const uint8_t fcm_magic_verified_constmap[8] = {'V','C','M','P','0','0','0','2'};
|
|
573
575
|
|
|
574
|
-
#define FCM_HEADER_SIZE
|
|
576
|
+
#define FCM_HEADER_SIZE 32u /* 8 magic + 8 seed + 4 seglen + 4 segcount + 4 datalen + 4 pad */
|
|
575
577
|
#define FCM_TRAILER_SIZE 8u /* checksum */
|
|
576
578
|
|
|
577
579
|
static inline void fcm_write_u32(uint8_t *p, uint32_t v) {
|
|
@@ -629,6 +631,7 @@ int fcm_constmap_write(const fcm_constmap_t *cm, void *buf) {
|
|
|
629
631
|
fcm_write_u32(p, cm->segment_length); p += 4;
|
|
630
632
|
fcm_write_u32(p, cm->segment_count); p += 4;
|
|
631
633
|
fcm_write_u32(p, cm->data_len); p += 4;
|
|
634
|
+
fcm_write_u32(p, 0); p += 4; /* reserved padding */
|
|
632
635
|
for (uint32_t i = 0; i < cm->data_len; i++) {
|
|
633
636
|
fcm_write_u64(p, cm->data[i]); p += 8;
|
|
634
637
|
}
|
|
@@ -688,6 +691,7 @@ int fcm_verified_constmap_write(const fcm_verified_constmap_t *vm, void *buf) {
|
|
|
688
691
|
fcm_write_u32(p, vm->segment_length); p += 4;
|
|
689
692
|
fcm_write_u32(p, vm->segment_count); p += 4;
|
|
690
693
|
fcm_write_u32(p, vm->data_len); p += 4;
|
|
694
|
+
fcm_write_u32(p, 0); p += 4; /* reserved padding */
|
|
691
695
|
for (uint32_t i = 0; i < vm->data_len; i++) {
|
|
692
696
|
fcm_write_u64(p, vm->data[i]); p += 8;
|
|
693
697
|
}
|
|
@@ -744,3 +748,78 @@ int fcm_verified_constmap_read(fcm_verified_constmap_t *out, const void *buf, si
|
|
|
744
748
|
out->checks = checks;
|
|
745
749
|
return FCM_OK;
|
|
746
750
|
}
|
|
751
|
+
|
|
752
|
+
/* ------------------------------------------------------------------------- */
|
|
753
|
+
/* Zero-copy views */
|
|
754
|
+
/* ------------------------------------------------------------------------- */
|
|
755
|
+
|
|
756
|
+
static inline int fcm_host_is_little_endian(void) {
|
|
757
|
+
const uint16_t x = 1;
|
|
758
|
+
return *(const uint8_t *)&x == 1;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
int fcm_constmap_view(fcm_constmap_t *out, const void *buf, size_t buf_len) {
|
|
762
|
+
if (!out) return FCM_E_INVALID_FORMAT;
|
|
763
|
+
memset(out, 0, sizeof(*out));
|
|
764
|
+
if (!fcm_host_is_little_endian()) return FCM_E_INVALID_FORMAT;
|
|
765
|
+
if (buf_len < FCM_HEADER_SIZE + FCM_TRAILER_SIZE) return FCM_E_SHORT_BUFFER;
|
|
766
|
+
|
|
767
|
+
const uint8_t *p = (const uint8_t *)buf;
|
|
768
|
+
if (memcmp(p, fcm_magic_constmap, 8) != 0) return FCM_E_INVALID_FORMAT;
|
|
769
|
+
|
|
770
|
+
uint32_t data_len = fcm_read_u32(p + 24);
|
|
771
|
+
size_t expected = FCM_HEADER_SIZE + (size_t)data_len * 8u + FCM_TRAILER_SIZE;
|
|
772
|
+
if (buf_len < expected) return FCM_E_SHORT_BUFFER;
|
|
773
|
+
|
|
774
|
+
uint64_t got_sum = fcm_read_u64(p + expected - 8);
|
|
775
|
+
uint64_t expected_sum = fcm_fnv1a64(p, expected - 8);
|
|
776
|
+
if (got_sum != expected_sum) return FCM_E_CHECKSUM;
|
|
777
|
+
|
|
778
|
+
const uint8_t *dp = p + FCM_HEADER_SIZE;
|
|
779
|
+
if (((uintptr_t)dp & 7u) != 0) return FCM_E_UNALIGNED;
|
|
780
|
+
|
|
781
|
+
uint32_t segment_length = fcm_read_u32(p + 16);
|
|
782
|
+
out->seed = fcm_read_u64(p + 8);
|
|
783
|
+
out->segment_length = segment_length;
|
|
784
|
+
out->segment_length_mask = segment_length ? segment_length - 1 : 0;
|
|
785
|
+
out->segment_count = fcm_read_u32(p + 20);
|
|
786
|
+
out->segment_count_length = out->segment_count * segment_length;
|
|
787
|
+
out->data_len = data_len;
|
|
788
|
+
/* Borrowed pointer into `buf`; the integer round-trip launders away the
|
|
789
|
+
* source const-ness. Lookups only read this array. */
|
|
790
|
+
out->data = (uint64_t *)(uintptr_t)dp;
|
|
791
|
+
return FCM_OK;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
int fcm_verified_constmap_view(fcm_verified_constmap_t *out, const void *buf, size_t buf_len) {
|
|
795
|
+
if (!out) return FCM_E_INVALID_FORMAT;
|
|
796
|
+
memset(out, 0, sizeof(*out));
|
|
797
|
+
if (!fcm_host_is_little_endian()) return FCM_E_INVALID_FORMAT;
|
|
798
|
+
if (buf_len < FCM_HEADER_SIZE + FCM_TRAILER_SIZE) return FCM_E_SHORT_BUFFER;
|
|
799
|
+
|
|
800
|
+
const uint8_t *p = (const uint8_t *)buf;
|
|
801
|
+
if (memcmp(p, fcm_magic_verified_constmap, 8) != 0) return FCM_E_INVALID_FORMAT;
|
|
802
|
+
|
|
803
|
+
uint32_t data_len = fcm_read_u32(p + 24);
|
|
804
|
+
size_t expected = FCM_HEADER_SIZE + (size_t)data_len * 16u + FCM_TRAILER_SIZE;
|
|
805
|
+
if (buf_len < expected) return FCM_E_SHORT_BUFFER;
|
|
806
|
+
|
|
807
|
+
uint64_t got_sum = fcm_read_u64(p + expected - 8);
|
|
808
|
+
uint64_t expected_sum = fcm_fnv1a64(p, expected - 8);
|
|
809
|
+
if (got_sum != expected_sum) return FCM_E_CHECKSUM;
|
|
810
|
+
|
|
811
|
+
const uint8_t *dp = p + FCM_HEADER_SIZE;
|
|
812
|
+
const uint8_t *cp = dp + (size_t)data_len * 8u;
|
|
813
|
+
if (((uintptr_t)dp & 7u) != 0) return FCM_E_UNALIGNED;
|
|
814
|
+
|
|
815
|
+
uint32_t segment_length = fcm_read_u32(p + 16);
|
|
816
|
+
out->seed = fcm_read_u64(p + 8);
|
|
817
|
+
out->segment_length = segment_length;
|
|
818
|
+
out->segment_length_mask = segment_length ? segment_length - 1 : 0;
|
|
819
|
+
out->segment_count = fcm_read_u32(p + 20);
|
|
820
|
+
out->segment_count_length = out->segment_count * segment_length;
|
|
821
|
+
out->data_len = data_len;
|
|
822
|
+
out->data = (uint64_t *)(uintptr_t)dp;
|
|
823
|
+
out->checks = (uint64_t *)(uintptr_t)cp;
|
|
824
|
+
return FCM_OK;
|
|
825
|
+
}
|
|
@@ -25,6 +25,7 @@ extern "C" {
|
|
|
25
25
|
#define FCM_E_INVALID_FORMAT -5
|
|
26
26
|
#define FCM_E_CHECKSUM -6
|
|
27
27
|
#define FCM_E_SHORT_BUFFER -7
|
|
28
|
+
#define FCM_E_UNALIGNED -8
|
|
28
29
|
|
|
29
30
|
#define FCM_NOT_FOUND ((uint64_t)0xFFFFFFFFFFFFFFFFULL)
|
|
30
31
|
|
|
@@ -85,6 +86,24 @@ size_t fcm_verified_constmap_serialized_size(const fcm_verified_constmap_t *vm);
|
|
|
85
86
|
int fcm_verified_constmap_write(const fcm_verified_constmap_t *vm, void *buf);
|
|
86
87
|
int fcm_verified_constmap_read (fcm_verified_constmap_t *out, const void *buf, size_t buf_len);
|
|
87
88
|
|
|
89
|
+
/* Zero-copy views over a serialized buffer.
|
|
90
|
+
*
|
|
91
|
+
* Unlike `*_read`, these do NOT copy: `out->data` (and `out->checks`) point
|
|
92
|
+
* directly into `buf`. This makes a map openable from shared memory with no
|
|
93
|
+
* per-process copy.
|
|
94
|
+
*
|
|
95
|
+
* Requirements: a little-endian host and a buffer whose start is 8-byte
|
|
96
|
+
* aligned (so the embedded uint64 arrays are aligned). The serialized
|
|
97
|
+
* format pads its header to a multiple of 8 to guarantee this.
|
|
98
|
+
*
|
|
99
|
+
* Returns FCM_OK, or FCM_E_UNALIGNED / FCM_E_INVALID_FORMAT (e.g. on a
|
|
100
|
+
* big-endian host) / FCM_E_CHECKSUM / FCM_E_SHORT_BUFFER.
|
|
101
|
+
*
|
|
102
|
+
* The caller MUST keep `buf` alive for the lifetime of `out`, and MUST NOT
|
|
103
|
+
* call `fcm_*_free` on a view (the data is borrowed, not owned). */
|
|
104
|
+
int fcm_constmap_view(fcm_constmap_t *out, const void *buf, size_t buf_len);
|
|
105
|
+
int fcm_verified_constmap_view(fcm_verified_constmap_t *out, const void *buf, size_t buf_len);
|
|
106
|
+
|
|
88
107
|
#ifdef __cplusplus
|
|
89
108
|
}
|
|
90
109
|
#endif
|
|
@@ -153,8 +153,22 @@ fail:
|
|
|
153
153
|
typedef struct {
|
|
154
154
|
PyObject_HEAD
|
|
155
155
|
fcm_constmap_t cm;
|
|
156
|
+
/* When `view.obj` is non-NULL the map is a zero-copy view: `cm.data`
|
|
157
|
+
* points into `view`'s buffer and must NOT be freed. */
|
|
158
|
+
Py_buffer view;
|
|
156
159
|
} PyConstMap;
|
|
157
160
|
|
|
161
|
+
/* Release whatever storage the map currently holds (owned heap data or a
|
|
162
|
+
* borrowed buffer). Safe to call on a zero-initialised struct. */
|
|
163
|
+
static void pyconstmap_release(PyConstMap *self) {
|
|
164
|
+
if (self->view.obj != NULL) {
|
|
165
|
+
/* Borrowed: data lives inside the buffer; just drop our reference. */
|
|
166
|
+
PyBuffer_Release(&self->view); /* sets view.obj = NULL */
|
|
167
|
+
self->cm.data = NULL;
|
|
168
|
+
}
|
|
169
|
+
fcm_constmap_free(&self->cm); /* frees owned data; no-op if NULL */
|
|
170
|
+
}
|
|
171
|
+
|
|
158
172
|
static int PyConstMap_init(PyConstMap *self, PyObject *args, PyObject *kwargs) {
|
|
159
173
|
PyObject *dict;
|
|
160
174
|
static char *kwlist[] = {"mapping", NULL};
|
|
@@ -166,7 +180,7 @@ static int PyConstMap_init(PyConstMap *self, PyObject *args, PyObject *kwargs) {
|
|
|
166
180
|
PyObject *keep = NULL;
|
|
167
181
|
if (fcm_dict_to_arrays(dict, &n, &keys, &vals, &keep) < 0) return -1;
|
|
168
182
|
|
|
169
|
-
|
|
183
|
+
pyconstmap_release(self);
|
|
170
184
|
|
|
171
185
|
int rc;
|
|
172
186
|
Py_BEGIN_ALLOW_THREADS
|
|
@@ -182,7 +196,7 @@ static int PyConstMap_init(PyConstMap *self, PyObject *args, PyObject *kwargs) {
|
|
|
182
196
|
}
|
|
183
197
|
|
|
184
198
|
static void PyConstMap_dealloc(PyConstMap *self) {
|
|
185
|
-
|
|
199
|
+
pyconstmap_release(self);
|
|
186
200
|
Py_TYPE(self)->tp_free((PyObject *)self);
|
|
187
201
|
}
|
|
188
202
|
|
|
@@ -363,20 +377,74 @@ static PyObject *PyConstMap_data_bytes(PyConstMap *self, PyObject *Py_UNUSED(ign
|
|
|
363
377
|
return PyLong_FromUnsignedLongLong((unsigned long long)self->cm.data_len * 8ULL);
|
|
364
378
|
}
|
|
365
379
|
|
|
380
|
+
static PyObject *PyConstMap_serialized_size(PyConstMap *self, PyObject *Py_UNUSED(ignored)) {
|
|
381
|
+
return PyLong_FromSize_t(fcm_constmap_serialized_size(&self->cm));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* Serialize directly into a caller-provided writable buffer (e.g. a
|
|
385
|
+
* SharedMemory block), avoiding the intermediate bytes object. */
|
|
386
|
+
static PyObject *PyConstMap_write_into(PyConstMap *self, PyObject *arg) {
|
|
387
|
+
Py_buffer view;
|
|
388
|
+
if (PyObject_GetBuffer(arg, &view, PyBUF_WRITABLE) < 0) return NULL;
|
|
389
|
+
size_t need = fcm_constmap_serialized_size(&self->cm);
|
|
390
|
+
if (view.len < 0 || (size_t)view.len < need) {
|
|
391
|
+
PyErr_Format(PyExc_ValueError,
|
|
392
|
+
"buffer too small: need %zu bytes, got %zd",
|
|
393
|
+
need, view.len);
|
|
394
|
+
PyBuffer_Release(&view);
|
|
395
|
+
return NULL;
|
|
396
|
+
}
|
|
397
|
+
fcm_constmap_write(&self->cm, view.buf);
|
|
398
|
+
PyBuffer_Release(&view);
|
|
399
|
+
return PyLong_FromSize_t(need);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/* Zero-copy: build a map that reads directly out of `buffer`. The buffer
|
|
403
|
+
* is kept alive by the returned object; lookups touch it directly. */
|
|
404
|
+
static PyObject *PyConstMap_from_buffer(PyTypeObject *type, PyObject *arg) {
|
|
405
|
+
Py_buffer view;
|
|
406
|
+
if (PyObject_GetBuffer(arg, &view, PyBUF_SIMPLE) < 0) return NULL;
|
|
407
|
+
PyConstMap *self = (PyConstMap *)type->tp_alloc(type, 0);
|
|
408
|
+
if (!self) { PyBuffer_Release(&view); return NULL; }
|
|
409
|
+
/* tp_alloc zeroed the struct, so self->view.obj == NULL and cm is clear. */
|
|
410
|
+
int rc = fcm_constmap_view(&self->cm, view.buf, (size_t)view.len);
|
|
411
|
+
if (rc != FCM_OK) {
|
|
412
|
+
PyBuffer_Release(&view);
|
|
413
|
+
Py_DECREF(self);
|
|
414
|
+
if (rc == FCM_E_UNALIGNED) {
|
|
415
|
+
PyErr_SetString(PyExc_ValueError,
|
|
416
|
+
"buffer is not 8-byte aligned; cannot create a zero-copy view "
|
|
417
|
+
"(use from_bytes() to copy instead)");
|
|
418
|
+
} else {
|
|
419
|
+
fcm_set_python_error(rc);
|
|
420
|
+
}
|
|
421
|
+
return NULL;
|
|
422
|
+
}
|
|
423
|
+
self->view = view; /* keep the buffer alive for the map's lifetime */
|
|
424
|
+
return (PyObject *)self;
|
|
425
|
+
}
|
|
426
|
+
|
|
366
427
|
static PyMethodDef PyConstMap_methods[] = {
|
|
367
|
-
{"get",
|
|
428
|
+
{"get", (PyCFunction)PyConstMap_get, METH_O,
|
|
368
429
|
"Return the value for `key`. If `key` was not in the original mapping, the return value is undefined."},
|
|
369
|
-
{"get_many",
|
|
430
|
+
{"get_many", (PyCFunction)PyConstMap_get_many, METH_O,
|
|
370
431
|
"Look up an iterable of keys. Returns a list of ints in the same order."},
|
|
371
|
-
{"to_bytes",
|
|
432
|
+
{"to_bytes", (PyCFunction)PyConstMap_serialize, METH_NOARGS,
|
|
372
433
|
"Serialize the map to bytes."},
|
|
373
|
-
{"from_bytes",
|
|
374
|
-
"Deserialize a map from a bytes-like object."},
|
|
375
|
-
{"
|
|
434
|
+
{"from_bytes", (PyCFunction)PyConstMap_deserialize, METH_O | METH_CLASS,
|
|
435
|
+
"Deserialize a map from a bytes-like object (copies the data)."},
|
|
436
|
+
{"serialized_size", (PyCFunction)PyConstMap_serialized_size, METH_NOARGS,
|
|
437
|
+
"Number of bytes that to_bytes()/write_into() will produce."},
|
|
438
|
+
{"write_into", (PyCFunction)PyConstMap_write_into, METH_O,
|
|
439
|
+
"Serialize directly into a writable buffer (e.g. SharedMemory.buf). Returns bytes written."},
|
|
440
|
+
{"from_buffer", (PyCFunction)PyConstMap_from_buffer, METH_O | METH_CLASS,
|
|
441
|
+
"Create a zero-copy map that reads directly from a buffer (e.g. shared memory). "
|
|
442
|
+
"The buffer is kept alive by the returned map and must not be modified while in use."},
|
|
443
|
+
{"save", (PyCFunction)PyConstMap_save, METH_O,
|
|
376
444
|
"Save the map to a file path."},
|
|
377
|
-
{"load",
|
|
445
|
+
{"load", (PyCFunction)PyConstMap_load, METH_O | METH_CLASS,
|
|
378
446
|
"Load a map from a file path."},
|
|
379
|
-
{"nbytes",
|
|
447
|
+
{"nbytes", (PyCFunction)PyConstMap_data_bytes, METH_NOARGS,
|
|
380
448
|
"Heap size of the lookup array in bytes."},
|
|
381
449
|
{NULL, NULL, 0, NULL}
|
|
382
450
|
};
|
|
@@ -407,8 +475,19 @@ static PyTypeObject PyConstMapType = {
|
|
|
407
475
|
typedef struct {
|
|
408
476
|
PyObject_HEAD
|
|
409
477
|
fcm_verified_constmap_t vm;
|
|
478
|
+
/* Non-NULL view.obj => zero-copy view: data/checks point into the buffer. */
|
|
479
|
+
Py_buffer view;
|
|
410
480
|
} PyVerifiedConstMap;
|
|
411
481
|
|
|
482
|
+
static void pyverified_release(PyVerifiedConstMap *self) {
|
|
483
|
+
if (self->view.obj != NULL) {
|
|
484
|
+
PyBuffer_Release(&self->view);
|
|
485
|
+
self->vm.data = NULL;
|
|
486
|
+
self->vm.checks = NULL;
|
|
487
|
+
}
|
|
488
|
+
fcm_verified_constmap_free(&self->vm);
|
|
489
|
+
}
|
|
490
|
+
|
|
412
491
|
static int PyVerifiedConstMap_init(PyVerifiedConstMap *self, PyObject *args, PyObject *kwargs) {
|
|
413
492
|
PyObject *dict;
|
|
414
493
|
static char *kwlist[] = {"mapping", NULL};
|
|
@@ -420,7 +499,7 @@ static int PyVerifiedConstMap_init(PyVerifiedConstMap *self, PyObject *args, PyO
|
|
|
420
499
|
PyObject *keep = NULL;
|
|
421
500
|
if (fcm_dict_to_arrays(dict, &n, &keys, &vals, &keep) < 0) return -1;
|
|
422
501
|
|
|
423
|
-
|
|
502
|
+
pyverified_release(self);
|
|
424
503
|
|
|
425
504
|
int rc;
|
|
426
505
|
Py_BEGIN_ALLOW_THREADS
|
|
@@ -436,7 +515,7 @@ static int PyVerifiedConstMap_init(PyVerifiedConstMap *self, PyObject *args, PyO
|
|
|
436
515
|
}
|
|
437
516
|
|
|
438
517
|
static void PyVerifiedConstMap_dealloc(PyVerifiedConstMap *self) {
|
|
439
|
-
|
|
518
|
+
pyverified_release(self);
|
|
440
519
|
Py_TYPE(self)->tp_free((PyObject *)self);
|
|
441
520
|
}
|
|
442
521
|
|
|
@@ -655,20 +734,69 @@ static PyObject *PyVerifiedConstMap_nbytes(PyVerifiedConstMap *self, PyObject *P
|
|
|
655
734
|
return PyLong_FromUnsignedLongLong((unsigned long long)self->vm.data_len * 16ULL);
|
|
656
735
|
}
|
|
657
736
|
|
|
737
|
+
static PyObject *PyVerifiedConstMap_serialized_size(PyVerifiedConstMap *self, PyObject *Py_UNUSED(ignored)) {
|
|
738
|
+
return PyLong_FromSize_t(fcm_verified_constmap_serialized_size(&self->vm));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
static PyObject *PyVerifiedConstMap_write_into(PyVerifiedConstMap *self, PyObject *arg) {
|
|
742
|
+
Py_buffer view;
|
|
743
|
+
if (PyObject_GetBuffer(arg, &view, PyBUF_WRITABLE) < 0) return NULL;
|
|
744
|
+
size_t need = fcm_verified_constmap_serialized_size(&self->vm);
|
|
745
|
+
if (view.len < 0 || (size_t)view.len < need) {
|
|
746
|
+
PyErr_Format(PyExc_ValueError,
|
|
747
|
+
"buffer too small: need %zu bytes, got %zd",
|
|
748
|
+
need, view.len);
|
|
749
|
+
PyBuffer_Release(&view);
|
|
750
|
+
return NULL;
|
|
751
|
+
}
|
|
752
|
+
fcm_verified_constmap_write(&self->vm, view.buf);
|
|
753
|
+
PyBuffer_Release(&view);
|
|
754
|
+
return PyLong_FromSize_t(need);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
static PyObject *PyVerifiedConstMap_from_buffer(PyTypeObject *type, PyObject *arg) {
|
|
758
|
+
Py_buffer view;
|
|
759
|
+
if (PyObject_GetBuffer(arg, &view, PyBUF_SIMPLE) < 0) return NULL;
|
|
760
|
+
PyVerifiedConstMap *self = (PyVerifiedConstMap *)type->tp_alloc(type, 0);
|
|
761
|
+
if (!self) { PyBuffer_Release(&view); return NULL; }
|
|
762
|
+
int rc = fcm_verified_constmap_view(&self->vm, view.buf, (size_t)view.len);
|
|
763
|
+
if (rc != FCM_OK) {
|
|
764
|
+
PyBuffer_Release(&view);
|
|
765
|
+
Py_DECREF(self);
|
|
766
|
+
if (rc == FCM_E_UNALIGNED) {
|
|
767
|
+
PyErr_SetString(PyExc_ValueError,
|
|
768
|
+
"buffer is not 8-byte aligned; cannot create a zero-copy view "
|
|
769
|
+
"(use from_bytes() to copy instead)");
|
|
770
|
+
} else {
|
|
771
|
+
fcm_set_python_error(rc);
|
|
772
|
+
}
|
|
773
|
+
return NULL;
|
|
774
|
+
}
|
|
775
|
+
self->view = view;
|
|
776
|
+
return (PyObject *)self;
|
|
777
|
+
}
|
|
778
|
+
|
|
658
779
|
static PyMethodDef PyVerifiedConstMap_methods[] = {
|
|
659
|
-
{"get",
|
|
780
|
+
{"get", (PyCFunction)PyVerifiedConstMap_get, METH_VARARGS,
|
|
660
781
|
"Return value for `key`, or `default` (default None) if not present."},
|
|
661
|
-
{"get_many",
|
|
782
|
+
{"get_many", (PyCFunction)(void *)PyVerifiedConstMap_get_many, METH_VARARGS | METH_KEYWORDS,
|
|
662
783
|
"Look up an iterable of keys; missing keys yield `default` (default None)."},
|
|
663
|
-
{"to_bytes",
|
|
784
|
+
{"to_bytes", (PyCFunction)PyVerifiedConstMap_serialize, METH_NOARGS,
|
|
664
785
|
"Serialize the map to bytes."},
|
|
665
|
-
{"from_bytes",
|
|
666
|
-
"Deserialize a map from a bytes-like object."},
|
|
667
|
-
{"
|
|
786
|
+
{"from_bytes", (PyCFunction)PyVerifiedConstMap_deserialize, METH_O | METH_CLASS,
|
|
787
|
+
"Deserialize a map from a bytes-like object (copies the data)."},
|
|
788
|
+
{"serialized_size", (PyCFunction)PyVerifiedConstMap_serialized_size, METH_NOARGS,
|
|
789
|
+
"Number of bytes that to_bytes()/write_into() will produce."},
|
|
790
|
+
{"write_into", (PyCFunction)PyVerifiedConstMap_write_into, METH_O,
|
|
791
|
+
"Serialize directly into a writable buffer (e.g. SharedMemory.buf). Returns bytes written."},
|
|
792
|
+
{"from_buffer", (PyCFunction)PyVerifiedConstMap_from_buffer, METH_O | METH_CLASS,
|
|
793
|
+
"Create a zero-copy map that reads directly from a buffer (e.g. shared memory). "
|
|
794
|
+
"The buffer is kept alive by the returned map and must not be modified while in use."},
|
|
795
|
+
{"save", (PyCFunction)PyVerifiedConstMap_save, METH_O,
|
|
668
796
|
"Save the map to a file path."},
|
|
669
|
-
{"load",
|
|
797
|
+
{"load", (PyCFunction)PyVerifiedConstMap_load, METH_O | METH_CLASS,
|
|
670
798
|
"Load a map from a file path."},
|
|
671
|
-
{"nbytes",
|
|
799
|
+
{"nbytes", (PyCFunction)PyVerifiedConstMap_nbytes, METH_NOARGS,
|
|
672
800
|
"Heap size of the data + checks arrays in bytes."},
|
|
673
801
|
{NULL, NULL, 0, NULL}
|
|
674
802
|
};
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|