mpengine 0.3.0__tar.gz → 0.4.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 (27) hide show
  1. {mpengine-0.3.0/src/mpengine.egg-info → mpengine-0.4.1}/PKG-INFO +65 -1
  2. {mpengine-0.3.0 → mpengine-0.4.1}/README.md +245 -186
  3. {mpengine-0.3.0 → mpengine-0.4.1}/pyproject.toml +81 -61
  4. {mpengine-0.3.0 → mpengine-0.4.1}/src/mpengine/__init__.py +96 -86
  5. {mpengine-0.3.0 → mpengine-0.4.1}/src/mpengine/banner.py +2 -9
  6. mpengine-0.4.1/src/mpengine/engine.py +698 -0
  7. {mpengine-0.3.0 → mpengine-0.4.1}/src/mpengine/orchestrator.py +933 -665
  8. mpengine-0.4.1/src/mpengine/py.typed +3 -0
  9. {mpengine-0.3.0 → mpengine-0.4.1}/src/mpengine/spider.txt +2 -2
  10. {mpengine-0.3.0 → mpengine-0.4.1}/src/mpengine/worker_names.json +1 -1
  11. {mpengine-0.3.0 → mpengine-0.4.1/src/mpengine.egg-info}/PKG-INFO +65 -1
  12. {mpengine-0.3.0 → mpengine-0.4.1}/src/mpengine.egg-info/SOURCES.txt +9 -1
  13. mpengine-0.4.1/src/mpengine.egg-info/requires.txt +8 -0
  14. mpengine-0.4.1/tests/test_broadcast.py +118 -0
  15. mpengine-0.4.1/tests/test_engine_batching.py +168 -0
  16. mpengine-0.4.1/tests/test_engine_core.py +138 -0
  17. mpengine-0.4.1/tests/test_engine_perf.py +255 -0
  18. mpengine-0.4.1/tests/test_logging.py +214 -0
  19. mpengine-0.4.1/tests/test_orchestrator.py +308 -0
  20. mpengine-0.4.1/tests/test_partition.py +155 -0
  21. mpengine-0.3.0/src/mpengine/engine.py +0 -258
  22. mpengine-0.3.0/src/mpengine.egg-info/requires.txt +0 -3
  23. {mpengine-0.3.0 → mpengine-0.4.1}/LICENSE +0 -0
  24. {mpengine-0.3.0 → mpengine-0.4.1}/setup.cfg +0 -0
  25. {mpengine-0.3.0 → mpengine-0.4.1}/src/mpengine/partition.py +0 -0
  26. {mpengine-0.3.0 → mpengine-0.4.1}/src/mpengine.egg-info/dependency_links.txt +0 -0
  27. {mpengine-0.3.0 → mpengine-0.4.1}/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.0
3
+ Version: 0.4.1
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
@@ -131,6 +138,44 @@ Develop with `debug=True`, then flip it off. Chasing a bug through a process
131
138
  pool means reading a traceback re-raised from a worker that has already exited,
132
139
  and it never tells you which job dict was at fault.
133
140
 
141
+ ### Performance
142
+
143
+ The defaults are already tuned; these are the knobs for when they aren't
144
+ enough. Measured on 8 cores with OpenBLAS against joblib 1.5.3 — reproduce
145
+ with `python benchmarks/bench.py`, which keeps each "before" path runnable
146
+ rather than quoting a remembered number.
147
+
148
+ | argument | meaning | measured |
149
+ |---|---|---|
150
+ | `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** |
151
+ | `chunksize` | jobs per submission. `'auto'` batches large runs and collapses to 1 on small ones | 20,000 tiny jobs **5.55s → 1.01s** |
152
+ | `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** |
153
+ | `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** |
154
+ | `initializer`, `initargs` | run once per worker before its first job — and unlike the stdlib's, may be a closure | — |
155
+ | `max_tasks_per_child` | recycle a worker every N jobs, so a slow leak can't end the run (Python 3.11+) | — |
156
+
157
+ **BLAS threads** is the one that catches people out. numpy hands matrix work to
158
+ a native library that is itself multi-threaded, and each worker process loads
159
+ its own copy believing it owns the machine — so eight workers each spawn eight
160
+ BLAS threads and 64 threads fight over 8 cores. Capping them means parallelism
161
+ comes from mpengine, one job per core, instead. This is on by default.
162
+
163
+ **Broadcast** is for the panel or fitted model every job needs:
164
+
165
+ ```python
166
+ summary = run(score, param_sets, base_dir="runs",
167
+ broadcast={"panel": panel}, reuse_pool=True)
168
+ ```
169
+
170
+ `score` is then called as `score(**params, panel=panel)`. Worth being precise
171
+ about when it pays: batching alone already reduces a closure-captured payload
172
+ to one copy per batch, so on a single cold call with a payload under ~10 MB,
173
+ broadcast costs slightly more than it saves. Above that, or paired with
174
+ `reuse_pool=True` where the payload is delivered once for the life of the pool,
175
+ it is a different order of magnitude — 0.55s on the 80 MB case, level with
176
+ joblib's memmapped 0.58s. Call `shutdown_pools()` when you're done with a
177
+ reused pool, or let `atexit` do it.
178
+
134
179
  ### Logging
135
180
 
136
181
  The library logs its whole lifecycle through the standard `logging` module and
@@ -203,6 +248,25 @@ for triangular workloads — where item `i` costs `O(i)`, such as an
203
248
  expanding-window computation — which keeps workers from idling while one
204
249
  overloaded worker finishes.
205
250
 
251
+ ## Tests
252
+
253
+ ```bash
254
+ pip install -e ".[test]"
255
+ pytest # everything, ~30s
256
+ pytest -m "not slow" # skip the process-pool tests, under a second
257
+ ```
258
+
259
+ 576 tests. The ones that spawn real pools are marked `slow` and dominate the
260
+ runtime, so the marker exists to keep a fast inner loop available - but they
261
+ run by default, because the behaviour they cover (a dead worker surfacing
262
+ instead of hanging, per-job failure attribution inside a batch, BLAS budgets
263
+ read back from inside a worker) is exactly the behaviour worth guarding.
264
+
265
+ CI runs the suite on Linux, Windows and macOS across Python 3.10, 3.12 and
266
+ 3.14. The platform spread matters here: Windows and macOS start workers with
267
+ `spawn`, Linux does not, and mpengine caps BLAS threads by a different
268
+ mechanism in each case.
269
+
206
270
  ## Changelog
207
271
 
208
272
  See [CHANGELOG.md](CHANGELOG.md). Versions below 1.0 may carry breaking changes
@@ -1,186 +1,245 @@
1
- # mpengine
2
-
3
- *(This repo is named `multiprocessor`; the package it ships is `mpengine` — see below.)*
4
-
5
- A small, general-purpose multiprocessing engine. Give it any callable and a list
6
- of parameter sets; it runs them across processes and leaves behind a record of
7
- what happened.
8
-
9
- Every run produces three things:
10
-
11
- | what | where |
12
- |---|---|
13
- | a manifest of exactly what was launched, and how it ended | `<base>/manifests/<run_id>.txt` |
14
- | each job's result, saved to disk | `<base>/outputs/<run_id>/<label>` |
15
- | one log file per **worker process** | `<base>/logs/<run_id>/worker_<pid>.log` |
16
- | a log of the run as a whole | `<base>/logs/<run_id>/run.log` |
17
-
18
- A single job failing does **not** abort the run — it is recorded as a failed
19
- job and the rest continue.
20
-
21
- ## Install
22
-
23
- ```bash
24
- pip install mpengine
25
- ```
26
-
27
- Requires Python 3.10+. `numpy`, `cloudpickle` and `tqdm` come with it —
28
- pip installs them for you.
29
-
30
- <details>
31
- <summary>Installing from source instead</summary>
32
-
33
- ```bash
34
- pip install git+https://github.com/singhamandeep-kgp/multiprocessor.git # latest from GitHub
35
- pip install -e path/to/multiprocessor # editable, for developing mpengine itself
36
- ```
37
- </details>
38
-
39
- ## Use
40
-
41
- ```python
42
- from mpengine import run
43
-
44
- def my_task(x, y):
45
- return x * y
46
-
47
- if __name__ == "__main__": # required - see below
48
- summary = run(
49
- my_task,
50
- [{"x": 2, "y": 3}, {"x": 4, "y": 5}],
51
- base_dir="runs",
52
- )
53
-
54
- print(summary.n_ok, summary.n_failed)
55
- for r in summary.results:
56
- print(r.label, r.status, r.output_path or r.error)
57
- ```
58
-
59
- ### The `if __name__ == "__main__":` guard
60
-
61
- On Windows and macOS, Python starts worker processes with `spawn`, which
62
- re-imports your module in every worker. Without the guard, that re-import runs
63
- your `run(...)` call again in each worker, which spawns more workers, and so on
64
- until the process dies. Put anything that *calls* `run()` inside the guard;
65
- your task functions themselves stay at module level, as normal.
66
-
67
- ### Placing the outputs
68
-
69
- `base_dir` derives all three locations. To place them independently, pass any of
70
- `output_dir`, `log_dir`, `manifest_dir` — an explicit path always wins, so you
71
- can give a `base_dir` and still redirect just the logs.
72
-
73
- ### Reading the results back
74
-
75
- Results are written to disk, one file per job (pickled by default). To load a
76
- finished run back into memory as `{label: result}`:
77
-
78
- ```python
79
- from mpengine import load_run_outputs
80
-
81
- outputs = load_run_outputs(summary.output_dir) # or the path run() printed
82
- print(outputs["job_0000"])
83
- ```
84
-
85
- Only successful jobs appear — a failed job never wrote a file, so its label is
86
- absent; check `summary.results` to see which failed and why. If you wrote the
87
- run with a custom `save_fn`, pass the matching reader as
88
- `load_run_outputs(..., load_fn=your_loader)`.
89
-
90
- ### Other options
91
-
92
- | argument | meaning |
93
- |---|---|
94
- | `save_fn` | how each result is written. Default `save_pickle`; supply your own (e.g. parquet) |
95
- | `labels` | names for each job; defaults to `job_0000`, `job_0001`, … |
96
- | `task` | names the run; defaults to `func.__name__` |
97
- | `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 |
98
- | `debug` | run sequentially in-process — real tracebacks you can attach a debugger to, no pool |
99
- | `show_progress` | live terminal display: one overall bar for the whole run, plus a live rate number per worker process. Ignored when `debug=True` |
100
-
101
- Develop with `debug=True`, then flip it off. Chasing a bug through a process
102
- pool means reading a traceback re-raised from a worker that has already exited,
103
- and it never tells you which job dict was at fault.
104
-
105
- ### Logging
106
-
107
- The library logs its whole lifecycle through the standard `logging` module and
108
- writes nothing to stdout except the banner, so embedding it never pollutes a
109
- host application's output. It installs only a `NullHandler` — to see anything,
110
- configure logging yourself:
111
-
112
- ```python
113
- import logging
114
- logging.basicConfig(level=logging.INFO)
115
- ```
116
-
117
- Two loggers, so either half can be tuned or silenced independently:
118
-
119
- | logger | emits |
120
- |---|---|
121
- | `mpengine.orchestrator` | run start/done, resolved dirs, per-job outcomes, worker ranking |
122
- | `mpengine.engine` | dispatch start/done, worker-count clamping, progress milestones, worker death |
123
-
124
- Levels: **INFO** for run lifecycle, **DEBUG** for per-job success (a 10,000-job
125
- sweep would otherwise be 10,000 INFO lines), **WARNING** for a failed job or a
126
- clamped worker count, **ERROR** for a dead worker.
127
-
128
- Every run also writes `<log_dir>/<run_id>/run.log` containing the parent's
129
- whole view of that run — dispatch, milestones, every job outcome, the summary —
130
- alongside the existing per-worker log files. That happens regardless of how you
131
- configure logging, so a run is always a self-contained durable record.
132
-
133
- **Progress display is terminal-aware.** Interactively you get the tqdm bars and
134
- per-worker lines as before. When stderr is not a terminal — piped, redirected to
135
- a log file, running under CI or a scheduler — those are suppressed (they render
136
- as unreadable escape-sequence noise in a file) and periodic completion
137
- milestones are logged at INFO instead.
138
-
139
- ### Closures and lambdas
140
-
141
- `func`, and any custom `save_fn`, can be a closure or a lambda — not just a
142
- module-level function. Jobs are serialized with `cloudpickle` before crossing
143
- the process boundary, which (unlike stdlib `pickle`) can serialize a function
144
- by *value* (its bytecode plus whatever it captured), not just by reference.
145
-
146
- The one caveat: whatever a closure captures travels with **every job** that
147
- uses it — a closure capturing a large array re-serializes that array per job.
148
- That's a cost trade-off to be aware of, not a limitation on what's allowed.
149
-
150
- ## Going lower-level
151
-
152
- `run()` is the convenient layer. The primitives underneath are exported too, if
153
- you want to drive the pool yourself:
154
-
155
- ```python
156
- from mpengine import expand_call, process_jobs, process_jobs_
157
-
158
- jobs = [{"func": my_task, "x": 1, "y": 2}, {"func": other_task, "n": 5}]
159
- results = process_jobs(jobs) # n_workers defaults to os.cpu_count(); or process_jobs_ to stay sequential
160
- ```
161
-
162
- A job is just a dict carrying its own callback plus that callback's kwargs, so a
163
- single call can dispatch entirely different functions with different signatures
164
- and return types.
165
-
166
- Partitioning helpers are available for splitting work into chunks:
167
-
168
- ```python
169
- from mpengine import lin_parts, nested_parts, parts_to_molecules
170
- ```
171
-
172
- `lin_parts` gives equal-count chunks. `nested_parts` gives equal-*work* chunks
173
- for triangular workloads — where item `i` costs `O(i)`, such as an
174
- expanding-window computation — which keeps workers from idling while one
175
- overloaded worker finishes.
176
-
177
- ## Changelog
178
-
179
- See [CHANGELOG.md](CHANGELOG.md). Versions below 1.0 may carry breaking changes
180
- in a minor release; each is listed there with the migration needed.
181
-
182
- ## What else is in the repo
183
-
184
- The `src/learning/` package holds demo and exercise scripts used while
185
- developing the engine. It depends on a separate private project and is
186
- deliberately **not** packaged — installing `mpengine` never pulls it in.
1
+ # mpengine
2
+
3
+ [![CI](https://github.com/singhamandeep-kgp/multiprocessor/actions/workflows/ci.yml/badge.svg)](https://github.com/singhamandeep-kgp/multiprocessor/actions/workflows/ci.yml)
4
+
5
+ *(This repo is named `multiprocessor`; the package it ships is `mpengine` — see below.)*
6
+
7
+ A small, general-purpose multiprocessing engine. Give it any callable and a list
8
+ of parameter sets; it runs them across processes and leaves behind a record of
9
+ what happened.
10
+
11
+ Every run produces three things:
12
+
13
+ | what | where |
14
+ |---|---|
15
+ | a manifest of exactly what was launched, and how it ended | `<base>/manifests/<run_id>.txt` |
16
+ | each job's result, saved to disk | `<base>/outputs/<run_id>/<label>` |
17
+ | one log file per **worker process** | `<base>/logs/<run_id>/worker_<pid>.log` |
18
+ | a log of the run as a whole | `<base>/logs/<run_id>/run.log` |
19
+
20
+ A single job failing does **not** abort the run — it is recorded as a failed
21
+ job and the rest continue.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ pip install mpengine
27
+ ```
28
+
29
+ Requires Python 3.10+. `numpy`, `cloudpickle` and `tqdm` come with it —
30
+ pip installs them for you.
31
+
32
+ <details>
33
+ <summary>Installing from source instead</summary>
34
+
35
+ ```bash
36
+ pip install git+https://github.com/singhamandeep-kgp/multiprocessor.git # latest from GitHub
37
+ pip install -e path/to/multiprocessor # editable, for developing mpengine itself
38
+ ```
39
+ </details>
40
+
41
+ ## Use
42
+
43
+ ```python
44
+ from mpengine import run
45
+
46
+ def my_task(x, y):
47
+ return x * y
48
+
49
+ if __name__ == "__main__": # required - see below
50
+ summary = run(
51
+ my_task,
52
+ [{"x": 2, "y": 3}, {"x": 4, "y": 5}],
53
+ base_dir="runs",
54
+ )
55
+
56
+ print(summary.n_ok, summary.n_failed)
57
+ for r in summary.results:
58
+ print(r.label, r.status, r.output_path or r.error)
59
+ ```
60
+
61
+ ### The `if __name__ == "__main__":` guard
62
+
63
+ On Windows and macOS, Python starts worker processes with `spawn`, which
64
+ re-imports your module in every worker. Without the guard, that re-import runs
65
+ your `run(...)` call again in each worker, which spawns more workers, and so on
66
+ until the process dies. Put anything that *calls* `run()` inside the guard;
67
+ your task functions themselves stay at module level, as normal.
68
+
69
+ ### Placing the outputs
70
+
71
+ `base_dir` derives all three locations. To place them independently, pass any of
72
+ `output_dir`, `log_dir`, `manifest_dir` — an explicit path always wins, so you
73
+ can give a `base_dir` and still redirect just the logs.
74
+
75
+ ### Reading the results back
76
+
77
+ Results are written to disk, one file per job (pickled by default). To load a
78
+ finished run back into memory as `{label: result}`:
79
+
80
+ ```python
81
+ from mpengine import load_run_outputs
82
+
83
+ outputs = load_run_outputs(summary.output_dir) # or the path run() printed
84
+ print(outputs["job_0000"])
85
+ ```
86
+
87
+ Only successful jobs appear — a failed job never wrote a file, so its label is
88
+ absent; check `summary.results` to see which failed and why. If you wrote the
89
+ run with a custom `save_fn`, pass the matching reader as
90
+ `load_run_outputs(..., load_fn=your_loader)`.
91
+
92
+ ### Other options
93
+
94
+ | argument | meaning |
95
+ |---|---|
96
+ | `save_fn` | how each result is written. Default `save_pickle`; supply your own (e.g. parquet) |
97
+ | `labels` | names for each job; defaults to `job_0000`, `job_0001`, … |
98
+ | `task` | names the run; defaults to `func.__name__` |
99
+ | `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 |
100
+ | `debug` | run sequentially in-process — real tracebacks you can attach a debugger to, no pool |
101
+ | `show_progress` | live terminal display: one overall bar for the whole run, plus a live rate number per worker process. Ignored when `debug=True` |
102
+
103
+ Develop with `debug=True`, then flip it off. Chasing a bug through a process
104
+ pool means reading a traceback re-raised from a worker that has already exited,
105
+ and it never tells you which job dict was at fault.
106
+
107
+ ### Performance
108
+
109
+ The defaults are already tuned; these are the knobs for when they aren't
110
+ enough. Measured on 8 cores with OpenBLAS against joblib 1.5.3 — reproduce
111
+ with `python benchmarks/bench.py`, which keeps each "before" path runnable
112
+ rather than quoting a remembered number.
113
+
114
+ | argument | meaning | measured |
115
+ |---|---|---|
116
+ | `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** |
117
+ | `chunksize` | jobs per submission. `'auto'` batches large runs and collapses to 1 on small ones | 20,000 tiny jobs **5.55s → 1.01s** |
118
+ | `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** |
119
+ | `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** |
120
+ | `initializer`, `initargs` | run once per worker before its first job — and unlike the stdlib's, may be a closure | — |
121
+ | `max_tasks_per_child` | recycle a worker every N jobs, so a slow leak can't end the run (Python 3.11+) | — |
122
+
123
+ **BLAS threads** is the one that catches people out. numpy hands matrix work to
124
+ a native library that is itself multi-threaded, and each worker process loads
125
+ its own copy believing it owns the machine — so eight workers each spawn eight
126
+ BLAS threads and 64 threads fight over 8 cores. Capping them means parallelism
127
+ comes from mpengine, one job per core, instead. This is on by default.
128
+
129
+ **Broadcast** is for the panel or fitted model every job needs:
130
+
131
+ ```python
132
+ summary = run(score, param_sets, base_dir="runs",
133
+ broadcast={"panel": panel}, reuse_pool=True)
134
+ ```
135
+
136
+ `score` is then called as `score(**params, panel=panel)`. Worth being precise
137
+ about when it pays: batching alone already reduces a closure-captured payload
138
+ to one copy per batch, so on a single cold call with a payload under ~10 MB,
139
+ broadcast costs slightly more than it saves. Above that, or paired with
140
+ `reuse_pool=True` where the payload is delivered once for the life of the pool,
141
+ it is a different order of magnitude — 0.55s on the 80 MB case, level with
142
+ joblib's memmapped 0.58s. Call `shutdown_pools()` when you're done with a
143
+ reused pool, or let `atexit` do it.
144
+
145
+ ### Logging
146
+
147
+ The library logs its whole lifecycle through the standard `logging` module and
148
+ writes nothing to stdout except the banner, so embedding it never pollutes a
149
+ host application's output. It installs only a `NullHandler` — to see anything,
150
+ configure logging yourself:
151
+
152
+ ```python
153
+ import logging
154
+ logging.basicConfig(level=logging.INFO)
155
+ ```
156
+
157
+ Two loggers, so either half can be tuned or silenced independently:
158
+
159
+ | logger | emits |
160
+ |---|---|
161
+ | `mpengine.orchestrator` | run start/done, resolved dirs, per-job outcomes, worker ranking |
162
+ | `mpengine.engine` | dispatch start/done, worker-count clamping, progress milestones, worker death |
163
+
164
+ Levels: **INFO** for run lifecycle, **DEBUG** for per-job success (a 10,000-job
165
+ sweep would otherwise be 10,000 INFO lines), **WARNING** for a failed job or a
166
+ clamped worker count, **ERROR** for a dead worker.
167
+
168
+ Every run also writes `<log_dir>/<run_id>/run.log` containing the parent's
169
+ whole view of that run — dispatch, milestones, every job outcome, the summary —
170
+ alongside the existing per-worker log files. That happens regardless of how you
171
+ configure logging, so a run is always a self-contained durable record.
172
+
173
+ **Progress display is terminal-aware.** Interactively you get the tqdm bars and
174
+ per-worker lines as before. When stderr is not a terminal — piped, redirected to
175
+ a log file, running under CI or a scheduler — those are suppressed (they render
176
+ as unreadable escape-sequence noise in a file) and periodic completion
177
+ milestones are logged at INFO instead.
178
+
179
+ ### Closures and lambdas
180
+
181
+ `func`, and any custom `save_fn`, can be a closure or a lambda — not just a
182
+ module-level function. Jobs are serialized with `cloudpickle` before crossing
183
+ the process boundary, which (unlike stdlib `pickle`) can serialize a function
184
+ by *value* (its bytecode plus whatever it captured), not just by reference.
185
+
186
+ The one caveat: whatever a closure captures travels with **every job** that
187
+ uses it — a closure capturing a large array re-serializes that array per job.
188
+ That's a cost trade-off to be aware of, not a limitation on what's allowed.
189
+
190
+ ## Going lower-level
191
+
192
+ `run()` is the convenient layer. The primitives underneath are exported too, if
193
+ you want to drive the pool yourself:
194
+
195
+ ```python
196
+ from mpengine import expand_call, process_jobs, process_jobs_
197
+
198
+ jobs = [{"func": my_task, "x": 1, "y": 2}, {"func": other_task, "n": 5}]
199
+ results = process_jobs(jobs) # n_workers defaults to os.cpu_count(); or process_jobs_ to stay sequential
200
+ ```
201
+
202
+ A job is just a dict carrying its own callback plus that callback's kwargs, so a
203
+ single call can dispatch entirely different functions with different signatures
204
+ and return types.
205
+
206
+ Partitioning helpers are available for splitting work into chunks:
207
+
208
+ ```python
209
+ from mpengine import lin_parts, nested_parts, parts_to_molecules
210
+ ```
211
+
212
+ `lin_parts` gives equal-count chunks. `nested_parts` gives equal-*work* chunks
213
+ for triangular workloads — where item `i` costs `O(i)`, such as an
214
+ expanding-window computation — which keeps workers from idling while one
215
+ overloaded worker finishes.
216
+
217
+ ## Tests
218
+
219
+ ```bash
220
+ pip install -e ".[test]"
221
+ pytest # everything, ~30s
222
+ pytest -m "not slow" # skip the process-pool tests, under a second
223
+ ```
224
+
225
+ 576 tests. The ones that spawn real pools are marked `slow` and dominate the
226
+ runtime, so the marker exists to keep a fast inner loop available - but they
227
+ run by default, because the behaviour they cover (a dead worker surfacing
228
+ instead of hanging, per-job failure attribution inside a batch, BLAS budgets
229
+ read back from inside a worker) is exactly the behaviour worth guarding.
230
+
231
+ CI runs the suite on Linux, Windows and macOS across Python 3.10, 3.12 and
232
+ 3.14. The platform spread matters here: Windows and macOS start workers with
233
+ `spawn`, Linux does not, and mpengine caps BLAS threads by a different
234
+ mechanism in each case.
235
+
236
+ ## Changelog
237
+
238
+ See [CHANGELOG.md](CHANGELOG.md). Versions below 1.0 may carry breaking changes
239
+ in a minor release; each is listed there with the migration needed.
240
+
241
+ ## What else is in the repo
242
+
243
+ The `src/learning/` package holds demo and exercise scripts used while
244
+ developing the engine. It depends on a separate private project and is
245
+ deliberately **not** packaged — installing `mpengine` never pulls it in.