mpengine 0.3.1__tar.gz → 0.5.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 (33) hide show
  1. {mpengine-0.3.1/src/mpengine.egg-info → mpengine-0.5.0}/PKG-INFO +113 -3
  2. {mpengine-0.3.1 → mpengine-0.5.0}/README.md +291 -186
  3. {mpengine-0.3.1 → mpengine-0.5.0}/pyproject.toml +85 -61
  4. mpengine-0.5.0/src/mpengine/__init__.py +69 -0
  5. mpengine-0.5.0/src/mpengine/banner.py +22 -0
  6. mpengine-0.5.0/src/mpengine/engine.py +850 -0
  7. mpengine-0.5.0/src/mpengine/orchestrator.py +1048 -0
  8. mpengine-0.5.0/src/mpengine/partition.py +93 -0
  9. mpengine-0.5.0/src/mpengine/py.typed +3 -0
  10. {mpengine-0.3.1 → mpengine-0.5.0/src/mpengine.egg-info}/PKG-INFO +113 -3
  11. {mpengine-0.3.1 → mpengine-0.5.0}/src/mpengine.egg-info/SOURCES.txt +11 -1
  12. mpengine-0.5.0/src/mpengine.egg-info/requires.txt +8 -0
  13. mpengine-0.5.0/tests/test_broadcast.py +118 -0
  14. mpengine-0.5.0/tests/test_engine_batching.py +168 -0
  15. mpengine-0.5.0/tests/test_engine_core.py +138 -0
  16. mpengine-0.5.0/tests/test_engine_perf.py +233 -0
  17. mpengine-0.5.0/tests/test_logging.py +214 -0
  18. mpengine-0.5.0/tests/test_orchestrator.py +308 -0
  19. mpengine-0.5.0/tests/test_partition.py +155 -0
  20. mpengine-0.5.0/tests/test_reliability.py +233 -0
  21. mpengine-0.5.0/tests/test_shared.py +153 -0
  22. mpengine-0.3.1/src/mpengine/__init__.py +0 -86
  23. mpengine-0.3.1/src/mpengine/banner.py +0 -34
  24. mpengine-0.3.1/src/mpengine/engine.py +0 -276
  25. mpengine-0.3.1/src/mpengine/orchestrator.py +0 -788
  26. mpengine-0.3.1/src/mpengine/partition.py +0 -122
  27. mpengine-0.3.1/src/mpengine.egg-info/requires.txt +0 -3
  28. {mpengine-0.3.1 → mpengine-0.5.0}/LICENSE +0 -0
  29. {mpengine-0.3.1 → mpengine-0.5.0}/setup.cfg +0 -0
  30. {mpengine-0.3.1 → mpengine-0.5.0}/src/mpengine/spider.txt +0 -0
  31. {mpengine-0.3.1 → mpengine-0.5.0}/src/mpengine/worker_names.json +0 -0
  32. {mpengine-0.3.1 → mpengine-0.5.0}/src/mpengine.egg-info/dependency_links.txt +0 -0
  33. {mpengine-0.3.1 → mpengine-0.5.0}/src/mpengine.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mpengine
3
- Version: 0.3.1
3
+ Version: 0.5.0
4
4
  Summary: A small, general-purpose multiprocessing engine: dispatch any callable across processes with run manifests, per-worker logs, on-disk outputs and failure isolation
5
5
  Author: Amandeep Singh
6
6
  License-Expression: MIT
@@ -19,16 +19,23 @@ Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
21
  Classifier: Topic :: System :: Distributed Computing
22
+ Classifier: Typing :: Typed
22
23
  Requires-Python: >=3.10
23
24
  Description-Content-Type: text/markdown
24
25
  License-File: LICENSE
25
26
  Requires-Dist: numpy>=1.26
26
27
  Requires-Dist: cloudpickle>=3.0
27
28
  Requires-Dist: tqdm>=4.60
29
+ Requires-Dist: threadpoolctl>=3.0
30
+ Provides-Extra: test
31
+ Requires-Dist: pytest>=7.0; extra == "test"
32
+ Requires-Dist: joblib>=1.3; extra == "test"
28
33
  Dynamic: license-file
29
34
 
30
35
  # mpengine
31
36
 
37
+ [![CI](https://github.com/singhamandeep-kgp/multiprocessor/actions/workflows/ci.yml/badge.svg)](https://github.com/singhamandeep-kgp/multiprocessor/actions/workflows/ci.yml)
38
+
32
39
  *(This repo is named `multiprocessor`; the package it ships is `mpengine` — see below.)*
33
40
 
34
41
  A small, general-purpose multiprocessing engine. Give it any callable and a list
@@ -39,7 +46,7 @@ Every run produces three things:
39
46
 
40
47
  | what | where |
41
48
  |---|---|
42
- | a manifest of exactly what was launched, and how it ended | `<base>/manifests/<run_id>.txt` |
49
+ | human-readable and versioned JSON manifests | `<base>/manifests/<run_id>.txt` and `.json` |
43
50
  | each job's result, saved to disk | `<base>/outputs/<run_id>/<label>` |
44
51
  | one log file per **worker process** | `<base>/logs/<run_id>/worker_<pid>.log` |
45
52
  | a log of the run as a whole | `<base>/logs/<run_id>/run.log` |
@@ -126,11 +133,92 @@ run with a custom `save_fn`, pass the matching reader as
126
133
  | `n_workers` | worker *process* count (not threads — see below); defaults to `os.cpu_count()`, clamped down to the number of jobs if there are fewer jobs than that |
127
134
  | `debug` | run sequentially in-process — real tracebacks you can attach a debugger to, no pool |
128
135
  | `show_progress` | live terminal display: one overall bar for the whole run, plus a live rate number per worker process. Ignored when `debug=True` |
136
+ | `retries`, `retry_delay` | retry transient callback failures, optionally pausing between attempts |
137
+ | `failure_policy` | `continue` (default), `raise` after all jobs, or `fail_fast` |
138
+ | `timeout` | wall-clock limit for the complete parallel dispatch |
139
+ | `cancel_token` | a thread-safe `CancellationToken` another thread can use to abort the run |
129
140
 
130
141
  Develop with `debug=True`, then flip it off. Chasing a bug through a process
131
142
  pool means reading a traceback re-raised from a worker that has already exited,
132
143
  and it never tells you which job dict was at fault.
133
144
 
145
+ ### Performance
146
+
147
+ The defaults are already tuned; these are the knobs for when they aren't
148
+ enough. Measured on 8 cores with OpenBLAS against joblib 1.5.3 — reproduce
149
+ with `python benchmarks/bench.py`, which keeps each "before" path runnable
150
+ rather than quoting a remembered number.
151
+
152
+ | argument | meaning | measured |
153
+ |---|---|---|
154
+ | `blas_threads` | threads each worker's native BLAS may use for one numpy call. `'auto'` = `cpu_count // workers`; an int to set it; `None` to disable | 24 SVD jobs **8.26s → 1.20s** |
155
+ | `chunksize` | jobs per submission. `'auto'` batches large runs and collapses to 1 on small ones | 20,000 tiny jobs **5.55s → 1.01s** |
156
+ | `broadcast` | a `dict` of values shipped once per *worker* instead of once per job, delivered to your function as keyword arguments | 200 jobs + 80 MB panel **7.97s → 0.55s** |
157
+ | `reuse_pool` | keep the pool (and its broadcast payload) alive between calls instead of rebuilding it. Off by default | 10 dispatches **9.86s → 0.02s** |
158
+ | `initializer`, `initargs` | run once per worker before its first job — and unlike the stdlib's, may be a closure | — |
159
+ | `max_tasks_per_child` | recycle a worker every N user jobs, so a slow leak can't end the run; disables batching to keep the count exact (Python 3.11+) | — |
160
+
161
+ **BLAS threads** is the one that catches people out. numpy hands matrix work to
162
+ a native library that is itself multi-threaded, and each worker process loads
163
+ its own copy believing it owns the machine — so eight workers each spawn eight
164
+ BLAS threads and 64 threads fight over 8 cores. Capping them means parallelism
165
+ comes from mpengine, one job per core, instead. This is on by default.
166
+
167
+ **Broadcast** is for the panel or fitted model every job needs:
168
+
169
+ ```python
170
+ summary = run(score, param_sets, base_dir="runs",
171
+ broadcast={"panel": panel}, reuse_pool=True)
172
+ ```
173
+
174
+ `score` is then called as `score(**params, panel=panel)`. Worth being precise
175
+ about when it pays: batching alone already reduces a closure-captured payload
176
+ to one copy per batch, so on a single cold call with a payload under ~10 MB,
177
+ broadcast costs slightly more than it saves. Above that, or paired with
178
+ `reuse_pool=True` where the payload is delivered once for the life of the pool,
179
+ it is a different order of magnitude — 0.55s on the 80 MB case, level with
180
+ joblib's memmapped 0.58s. Call `shutdown_pools()` when you're done with a
181
+ reused pool, or let `atexit` do it.
182
+
183
+ For a large NumPy array, wrap it in `Shared` to copy it once into OS shared
184
+ memory and let every worker read the same physical bytes:
185
+
186
+ ```python
187
+ from mpengine import Shared
188
+
189
+ summary = run(score, param_sets, base_dir="runs",
190
+ broadcast={"panel": Shared(panel)})
191
+ ```
192
+
193
+ Shared arrays are read-only in workers and must be C-contiguous with a
194
+ non-object dtype. Keep the original array alive while a reused pool needs it.
195
+
196
+ ### Reliability controls
197
+
198
+ ```python
199
+ from mpengine import CancellationToken, RunFailedError, run
200
+
201
+ token = CancellationToken()
202
+ try:
203
+ summary = run(
204
+ score,
205
+ param_sets,
206
+ base_dir="runs",
207
+ retries=2,
208
+ retry_delay=0.25,
209
+ failure_policy="raise",
210
+ timeout=300,
211
+ cancel_token=token,
212
+ )
213
+ except RunFailedError as exc:
214
+ summary = exc.summary # outputs and both manifests are already durable
215
+ ```
216
+
217
+ `failure_policy="continue"` preserves the original behavior. `"raise"` runs
218
+ everything and then raises if any job failed. `"fail_fast"` terminates the
219
+ pool after the first failed result. Timeout and cancellation also terminate
220
+ the pool and raise exceptions carrying a partial `summary`.
221
+
134
222
  ### Logging
135
223
 
136
224
  The library logs its whole lifecycle through the standard `logging` module and
@@ -182,10 +270,13 @@ That's a cost trade-off to be aware of, not a limitation on what's allowed.
182
270
  you want to drive the pool yourself:
183
271
 
184
272
  ```python
185
- from mpengine import expand_call, process_jobs, process_jobs_
273
+ from mpengine import expand_call, iter_jobs, process_jobs, process_jobs_
186
274
 
187
275
  jobs = [{"func": my_task, "x": 1, "y": 2}, {"func": other_task, "n": 5}]
188
276
  results = process_jobs(jobs) # n_workers defaults to os.cpu_count(); or process_jobs_ to stay sequential
277
+
278
+ for result in iter_jobs(jobs): # consume each result as soon as it completes
279
+ store(result)
189
280
  ```
190
281
 
191
282
  A job is just a dict carrying its own callback plus that callback's kwargs, so a
@@ -203,6 +294,25 @@ for triangular workloads — where item `i` costs `O(i)`, such as an
203
294
  expanding-window computation — which keeps workers from idling while one
204
295
  overloaded worker finishes.
205
296
 
297
+ ## Tests
298
+
299
+ ```bash
300
+ pip install -e ".[test]"
301
+ pytest # everything, ~30s
302
+ pytest -m "not slow" # skip the process-pool tests, under a second
303
+ ```
304
+
305
+ 576 tests. The ones that spawn real pools are marked `slow` and dominate the
306
+ runtime, so the marker exists to keep a fast inner loop available - but they
307
+ run by default, because the behaviour they cover (a dead worker surfacing
308
+ instead of hanging, per-job failure attribution inside a batch, BLAS budgets
309
+ read back from inside a worker) is exactly the behaviour worth guarding.
310
+
311
+ CI runs the suite on Linux, Windows and macOS across Python 3.10, 3.12 and
312
+ 3.14. The platform spread matters here: Windows and macOS start workers with
313
+ `spawn`, Linux does not, and mpengine caps BLAS threads by a different
314
+ mechanism in each case.
315
+
206
316
  ## Changelog
207
317
 
208
318
  See [CHANGELOG.md](CHANGELOG.md). Versions below 1.0 may carry breaking changes