py-sharedmemory 4.2.0__tar.gz → 4.2.1__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 (37) hide show
  1. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/CHANGELOG.md +10 -0
  2. {py_sharedmemory-4.2.0/py_sharedmemory.egg-info → py_sharedmemory-4.2.1}/PKG-INFO +8 -6
  3. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/README.md +7 -5
  4. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/py_sharedmemory/__init__.py +1 -1
  5. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/py_sharedmemory/convert.py +23 -10
  6. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/py_sharedmemory/sender.py +3 -2
  7. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1/py_sharedmemory.egg-info}/PKG-INFO +8 -6
  8. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/tests/test_convert.py +89 -8
  9. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/LICENSE +0 -0
  10. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/MANIFEST.in +0 -0
  11. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/benchmarks/__init__.py +0 -0
  12. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/benchmarks/benchmark.py +0 -0
  13. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/benchmarks/plot.py +0 -0
  14. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/benchmarks/results/windows-numpy.json +0 -0
  15. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/benchmarks/results/windows-numpy.png +0 -0
  16. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/examples/basic.py +0 -0
  17. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/examples/numpy_batches.py +0 -0
  18. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/examples/worker_pool.py +0 -0
  19. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/py_sharedmemory/factory.py +0 -0
  20. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/py_sharedmemory/py.typed +0 -0
  21. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/py_sharedmemory/receiver.py +0 -0
  22. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/py_sharedmemory.egg-info/SOURCES.txt +0 -0
  23. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/py_sharedmemory.egg-info/dependency_links.txt +0 -0
  24. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/py_sharedmemory.egg-info/requires.txt +0 -0
  25. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/py_sharedmemory.egg-info/top_level.txt +0 -0
  26. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/pyproject.toml +0 -0
  27. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/setup.cfg +0 -0
  28. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/tests/__init__.py +0 -0
  29. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/tests/conftest.py +0 -0
  30. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/tests/helpers.py +0 -0
  31. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/tests/test_benchmark.py +0 -0
  32. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/tests/test_objects.py +0 -0
  33. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/tests/test_package.py +0 -0
  34. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/tests/test_processes.py +0 -0
  35. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/tests/test_queue.py +0 -0
  36. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/tests/test_recycling.py +0 -0
  37. {py_sharedmemory-4.2.0 → py_sharedmemory-4.2.1}/tests/test_threads.py +0 -0
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.2.1
4
+
5
+ - `put` copies large `bytes` and `bytearray` objects (64 KiB and up) straight into shared
6
+ memory, wherever they appear in a message. It used to build the complete pickle in
7
+ memory first: an extra copy of the payload that briefly needed up to 1.5 times its size
8
+ (48 MiB for a 32 MiB `bytearray`). Strided NumPy arrays, which NumPy pickles as `bytes`,
9
+ skip that copy too. The message format is unchanged.
10
+ - Resizing a `bytearray` while its message is being pickled, from a `__reduce__` method,
11
+ now raises `BufferError` instead of risking a corrupted message.
12
+
3
13
  ## 4.2.0
4
14
 
5
15
  - Reuse received shared-memory blocks instead of freeing them. Writing into fresh pages
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-sharedmemory
3
- Version: 4.2.0
3
+ Version: 4.2.1
4
4
  Summary: Fast multiprocessing.Queue alternative that moves large Python objects and NumPy arrays through shared memory
5
5
  Author: Tobias Würth
6
6
  License-Expression: MIT
@@ -68,8 +68,9 @@ images, and simulation states arrive up to 2× faster, and transfers too large f
68
68
 
69
69
  - **Familiar API:** `put` / `get` with `block`, `timeout`, `put_nowait` / `get_nowait`,
70
70
  `queue.Full`, and `queue.Empty`.
71
- - **Any picklable object:** NumPy arrays and other buffers use pickle protocol 5
72
- out-of-band buffers, so they are copied straight into shared memory.
71
+ - **Any picklable object:** NumPy arrays, other protocol-5 buffers, and large `bytes` and
72
+ `bytearray` objects are copied straight into shared memory, without an intermediate
73
+ pickle copy.
73
74
  - **Bounded memory:** a capacity limit applies backpressure, and received blocks are
74
75
  reused for later messages, so shared memory stays within `capacity × message size`.
75
76
  - **One producer, many consumers:** consumer processes and threads share one receiver.
@@ -161,9 +162,10 @@ pages on every `put`.
161
162
 
162
163
  ## How it works
163
164
 
164
- 1. `put()` pickles the object with protocol 5. Large buffers, such as NumPy array data,
165
- stay out-of-band, and everything is copied into one shared-memory block: a block from
166
- an earlier, received message when one fits, otherwise a new one.
165
+ 1. `put()` pickles the object with protocol 5. Large buffers, such as NumPy array data and
166
+ `bytes` or `bytearray` objects of 64 KiB or more, never enter the pickle; everything is copied once,
167
+ from the original objects into one shared-memory block: a block from an earlier,
168
+ received message when one fits, otherwise a new one.
167
169
  2. Only the block's name, buffer sizes, and a sequence number travel through a
168
170
  `multiprocessing.Queue`.
169
171
  3. `get()` attaches to the block, copies the data into local memory, unpickles it, and
@@ -18,8 +18,9 @@ images, and simulation states arrive up to 2× faster, and transfers too large f
18
18
 
19
19
  - **Familiar API:** `put` / `get` with `block`, `timeout`, `put_nowait` / `get_nowait`,
20
20
  `queue.Full`, and `queue.Empty`.
21
- - **Any picklable object:** NumPy arrays and other buffers use pickle protocol 5
22
- out-of-band buffers, so they are copied straight into shared memory.
21
+ - **Any picklable object:** NumPy arrays, other protocol-5 buffers, and large `bytes` and
22
+ `bytearray` objects are copied straight into shared memory, without an intermediate
23
+ pickle copy.
23
24
  - **Bounded memory:** a capacity limit applies backpressure, and received blocks are
24
25
  reused for later messages, so shared memory stays within `capacity × message size`.
25
26
  - **One producer, many consumers:** consumer processes and threads share one receiver.
@@ -111,9 +112,10 @@ pages on every `put`.
111
112
 
112
113
  ## How it works
113
114
 
114
- 1. `put()` pickles the object with protocol 5. Large buffers, such as NumPy array data,
115
- stay out-of-band, and everything is copied into one shared-memory block: a block from
116
- an earlier, received message when one fits, otherwise a new one.
115
+ 1. `put()` pickles the object with protocol 5. Large buffers, such as NumPy array data and
116
+ `bytes` or `bytearray` objects of 64 KiB or more, never enter the pickle; everything is copied once,
117
+ from the original objects into one shared-memory block: a block from an earlier,
118
+ received message when one fits, otherwise a new one.
117
119
  2. Only the block's name, buffer sizes, and a sequence number travel through a
118
120
  `multiprocessing.Queue`.
119
121
  3. `get()` attaches to the block, copies the data into local memory, unpickles it, and
@@ -25,5 +25,5 @@ from .factory import create_shared_memory_pair
25
25
  from .receiver import SharedMemoryReceiver
26
26
  from .sender import SharedMemorySender
27
27
 
28
- __version__ = "4.2.0"
28
+ __version__ = "4.2.1"
29
29
  __all__ = ["SharedMemoryReceiver", "SharedMemorySender", "__version__", "create_shared_memory_pair"]
@@ -37,6 +37,16 @@ def release_shared_memory(allocation: SharedMemory) -> None:
37
37
  allocation.unlink()
38
38
 
39
39
 
40
+ class _PickleChunks(list[memoryview]):
41
+ """Pickle output kept as views of what pickle wrote rather than one joined copy."""
42
+
43
+ def write(self, data: Any) -> None:
44
+ # Pickle passes bytes and bytearrays of 64 KiB or more as the objects themselves.
45
+ # A view also stops a bytearray from being resized before it is copied, which would
46
+ # leave its length header in the stream wrong.
47
+ self.append(memoryview(data))
48
+
49
+
40
50
  def serialize(
41
51
  data: Any,
42
52
  sequence: int = 0,
@@ -44,8 +54,11 @@ def serialize(
44
54
  ) -> tuple[SharedMemory, SharedMemoryMessage]:
45
55
  """Copy a pickle and its out-of-band buffers into an owned block of at least that size.
46
56
 
47
- `allocate` returns a new or reused block; this function frees it if copying fails.
57
+ Large bytes, bytearrays, and contiguous arrays are copied once, straight from the
58
+ original objects into shared memory. `allocate` returns a new or reused block; this
59
+ function frees it if copying fails.
48
60
  """
61
+ chunks = _PickleChunks()
49
62
  buffers: list[memoryview] = []
50
63
  allocation = None
51
64
 
@@ -53,23 +66,23 @@ def serialize(
53
66
  buffers.append(buffer.raw())
54
67
 
55
68
  try:
56
- pickle_data = pickle.dumps(data, protocol=5, buffer_callback=append_buffer)
57
- buffer_sizes = (len(pickle_data), *(buffer.nbytes for buffer in buffers))
69
+ pickle.Pickler(chunks, protocol=5, buffer_callback=append_buffer).dump(data)
70
+ pickle_size = sum(chunk.nbytes for chunk in chunks)
71
+ buffer_sizes = (pickle_size, *(buffer.nbytes for buffer in buffers))
58
72
  allocation = allocate(sum(buffer_sizes))
59
73
  shared = cast(memoryview, allocation.buf)
60
- shared[: len(pickle_data)] = pickle_data
61
- offset = len(pickle_data)
62
- for buffer in buffers:
63
- shared[offset : offset + buffer.nbytes] = buffer
64
- offset += buffer.nbytes
74
+ offset = 0
75
+ for view in (*chunks, *buffers):
76
+ shared[offset : offset + view.nbytes] = view
77
+ offset += view.nbytes
65
78
  return allocation, SharedMemoryMessage(allocation.name, buffer_sizes, sequence)
66
79
  except BaseException:
67
80
  if allocation is not None:
68
81
  release_shared_memory(allocation)
69
82
  raise
70
83
  finally:
71
- for buffer in buffers:
72
- buffer.release()
84
+ for view in (*chunks, *buffers):
85
+ view.release()
73
86
 
74
87
 
75
88
  def deserialize(message: SharedMemoryMessage) -> Any:
@@ -104,8 +104,9 @@ class SharedMemorySender:
104
104
  """Send a picklable object through shared memory.
105
105
 
106
106
  Args:
107
- data: Any object that pickle protocol 5 can serialize. NumPy arrays and
108
- other buffers are copied out-of-band without an extra pickle copy.
107
+ data: Any object that pickle protocol 5 can serialize. NumPy arrays, other
108
+ buffers, and large bytes-like objects are copied without an extra
109
+ pickle copy.
109
110
  block: Wait for capacity when `capacity` messages are outstanding.
110
111
  timeout: Maximum seconds to wait for capacity; `None` waits forever.
111
112
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-sharedmemory
3
- Version: 4.2.0
3
+ Version: 4.2.1
4
4
  Summary: Fast multiprocessing.Queue alternative that moves large Python objects and NumPy arrays through shared memory
5
5
  Author: Tobias Würth
6
6
  License-Expression: MIT
@@ -68,8 +68,9 @@ images, and simulation states arrive up to 2× faster, and transfers too large f
68
68
 
69
69
  - **Familiar API:** `put` / `get` with `block`, `timeout`, `put_nowait` / `get_nowait`,
70
70
  `queue.Full`, and `queue.Empty`.
71
- - **Any picklable object:** NumPy arrays and other buffers use pickle protocol 5
72
- out-of-band buffers, so they are copied straight into shared memory.
71
+ - **Any picklable object:** NumPy arrays, other protocol-5 buffers, and large `bytes` and
72
+ `bytearray` objects are copied straight into shared memory, without an intermediate
73
+ pickle copy.
73
74
  - **Bounded memory:** a capacity limit applies backpressure, and received blocks are
74
75
  reused for later messages, so shared memory stays within `capacity × message size`.
75
76
  - **One producer, many consumers:** consumer processes and threads share one receiver.
@@ -161,9 +162,10 @@ pages on every `put`.
161
162
 
162
163
  ## How it works
163
164
 
164
- 1. `put()` pickles the object with protocol 5. Large buffers, such as NumPy array data,
165
- stay out-of-band, and everything is copied into one shared-memory block: a block from
166
- an earlier, received message when one fits, otherwise a new one.
165
+ 1. `put()` pickles the object with protocol 5. Large buffers, such as NumPy array data and
166
+ `bytes` or `bytearray` objects of 64 KiB or more, never enter the pickle; everything is copied once,
167
+ from the original objects into one shared-memory block: a block from an earlier,
168
+ received message when one fits, otherwise a new one.
167
169
  2. Only the block's name, buffer sizes, and a sequence number travel through a
168
170
  `multiprocessing.Queue`.
169
171
  3. `get()` attaches to the block, copies the data into local memory, unpickles it, and
@@ -1,5 +1,6 @@
1
1
  import pickle
2
2
  import sys
3
+ import tracemalloc
3
4
  from multiprocessing.shared_memory import SharedMemory
4
5
  from unittest.mock import Mock
5
6
 
@@ -155,26 +156,106 @@ def test_protocol_5_buffer_mutability_and_empty_buffers(payload):
155
156
  assert payload[0] != 0
156
157
 
157
158
 
158
- def test_pickle_failure_releases_exported_views():
159
- payload = bytearray(b"data")
160
- with pytest.raises(ValueError, match="Cannot serialize"):
161
- serialize(FailsAfterBuffer(payload))
162
- # Resizing would raise BufferError if the raw pickle buffer were retained.
159
+ MiB = 2**20
160
+ # Pickle hands bytes-like objects from 64 KiB up to its output file by reference.
161
+ LARGE = 2**17
162
+
163
+
164
+ @pytest.mark.parametrize(
165
+ "payload",
166
+ [
167
+ pytest.param(lambda: bytes(32 * MiB), id="bytes"),
168
+ pytest.param(lambda: bytearray(32 * MiB), id="bytearray"),
169
+ pytest.param(
170
+ lambda: {"step": 7, "frames": bytearray(32 * MiB), "tags": ["train"] * 100},
171
+ id="nested",
172
+ ),
173
+ ],
174
+ )
175
+ def test_large_bytes_reach_shared_memory_without_an_intermediate_copy(payload):
176
+ original = payload()
177
+ tracemalloc.start()
178
+ try:
179
+ handle, info = serialize(original)
180
+ peak = tracemalloc.get_traced_memory()[1]
181
+ finally:
182
+ tracemalloc.stop()
183
+ try:
184
+ # pickle.dumps would allocate the whole payload again, and more while growing.
185
+ assert peak < MiB
186
+ assert deserialize(info) == original
187
+ finally:
188
+ release_shared_memory(handle)
189
+
190
+
191
+ class ResizesEarlierPayload:
192
+ def __init__(self, payload):
193
+ self.payload = payload
194
+
195
+ def __reduce__(self):
196
+ self.payload.extend(b"more")
197
+ return int, ()
198
+
199
+
200
+ def test_bytearray_cannot_be_resized_before_it_is_copied():
201
+ # A resize after pickle wrote the length header would corrupt the stream.
202
+ payload = bytearray(LARGE)
203
+ with pytest.raises(BufferError):
204
+ serialize([payload, ResizesEarlierPayload(payload)])
163
205
  payload.extend(b"more")
206
+ assert len(payload) == LARGE + 4
164
207
 
165
208
 
166
- def test_allocation_failure_releases_exported_views(monkeypatch):
167
- payload = bytearray(b"data")
209
+ @pytest.mark.parametrize(
210
+ ("payload", "wrap"),
211
+ [
212
+ pytest.param(bytearray(b"data"), FailsAfterBuffer, id="out-of-band"),
213
+ pytest.param(bytearray(LARGE), lambda payload: [payload, Unpicklable()], id="in-band"),
214
+ ],
215
+ )
216
+ def test_pickle_failure_releases_exported_views(payload, wrap):
217
+ with pytest.raises(ValueError, match="Cannot serialize"):
218
+ serialize(wrap(payload))
219
+ # Resizing would raise BufferError if a view of the payload were retained.
220
+ payload.extend(b"more")
221
+
168
222
 
223
+ @pytest.mark.parametrize(
224
+ ("payload", "wrap"),
225
+ [
226
+ pytest.param(bytearray(b"data"), Buffered, id="out-of-band"),
227
+ pytest.param(bytearray(LARGE), list, id="in-band"),
228
+ ],
229
+ )
230
+ def test_allocation_failure_releases_exported_views(monkeypatch, payload, wrap):
169
231
  def fail_allocation(**kwargs):
170
232
  raise MemoryError("No shared memory")
171
233
 
172
234
  monkeypatch.setattr(convert, "SharedMemory", fail_allocation)
173
235
  with pytest.raises(MemoryError):
174
- serialize(Buffered(payload))
236
+ serialize(wrap(payload))
175
237
  payload.extend(b"more")
176
238
 
177
239
 
240
+ def test_success_releases_exported_views():
241
+ payload = bytearray(LARGE)
242
+ handle, info = serialize({"payload": payload})
243
+ try:
244
+ payload.extend(b"more")
245
+ assert deserialize(info) == {"payload": bytearray(LARGE)}
246
+ finally:
247
+ release_shared_memory(handle)
248
+
249
+
250
+ def test_payload_spanning_many_pickle_frames_round_trips():
251
+ original = {"numbers": list(range(200_000)), "blob": b"x" * LARGE, "text": "y" * LARGE}
252
+ handle, info = serialize(original)
253
+ try:
254
+ assert deserialize(info) == original
255
+ finally:
256
+ release_shared_memory(handle)
257
+
258
+
178
259
  def test_copy_failure_unlinks_new_allocation(monkeypatch):
179
260
  created = []
180
261
 
File without changes