mpengine 0.1.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.
- mpengine-0.1.0/LICENSE +21 -0
- mpengine-0.1.0/PKG-INFO +170 -0
- mpengine-0.1.0/README.md +141 -0
- mpengine-0.1.0/pyproject.toml +61 -0
- mpengine-0.1.0/setup.cfg +4 -0
- mpengine-0.1.0/src/mpengine/__init__.py +76 -0
- mpengine-0.1.0/src/mpengine/engine.py +140 -0
- mpengine-0.1.0/src/mpengine/orchestrator.py +442 -0
- mpengine-0.1.0/src/mpengine/partition.py +73 -0
- mpengine-0.1.0/src/mpengine/worker_names.json +10 -0
- mpengine-0.1.0/src/mpengine.egg-info/PKG-INFO +170 -0
- mpengine-0.1.0/src/mpengine.egg-info/SOURCES.txt +13 -0
- mpengine-0.1.0/src/mpengine.egg-info/dependency_links.txt +1 -0
- mpengine-0.1.0/src/mpengine.egg-info/requires.txt +3 -0
- mpengine-0.1.0/src/mpengine.egg-info/top_level.txt +1 -0
mpengine-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amandeep Singh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
mpengine-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mpengine
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Author: Amandeep Singh
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/singhamandeep-kgp/multiprocessor
|
|
8
|
+
Project-URL: Repository, https://github.com/singhamandeep-kgp/multiprocessor
|
|
9
|
+
Project-URL: Issues, https://github.com/singhamandeep-kgp/multiprocessor/issues
|
|
10
|
+
Keywords: multiprocessing,parallel,concurrency,process-pool,vectorization
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: numpy>=1.26
|
|
26
|
+
Requires-Dist: cloudpickle>=3.0
|
|
27
|
+
Requires-Dist: tqdm>=4.60
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# mpengine
|
|
31
|
+
|
|
32
|
+
*(This repo is named `multiprocessor`; the package it ships is `mpengine` — see below.)*
|
|
33
|
+
|
|
34
|
+
A small, general-purpose multiprocessing engine. Give it any callable and a list
|
|
35
|
+
of parameter sets; it runs them across processes and leaves behind a record of
|
|
36
|
+
what happened.
|
|
37
|
+
|
|
38
|
+
Every run produces three things:
|
|
39
|
+
|
|
40
|
+
| what | where |
|
|
41
|
+
|---|---|
|
|
42
|
+
| a manifest of exactly what was launched, and how it ended | `<base>/manifests/<run_id>.txt` |
|
|
43
|
+
| each job's result, saved to disk | `<base>/outputs/<run_id>/<label>` |
|
|
44
|
+
| one log file per **worker process** | `<base>/logs/<run_id>/worker_<pid>.log` |
|
|
45
|
+
|
|
46
|
+
A single job failing does **not** abort the run — it is recorded as a failed
|
|
47
|
+
job and the rest continue.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install -e path/to/mpengine # editable, for local development
|
|
53
|
+
pip install path/to/mpengine # or a plain install
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`numpy`, `cloudpickle`, and `tqdm` are required.
|
|
57
|
+
|
|
58
|
+
## Use
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from mpengine import run
|
|
62
|
+
|
|
63
|
+
def my_task(x, y):
|
|
64
|
+
return x * y
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__": # required - see below
|
|
67
|
+
summary = run(
|
|
68
|
+
my_task,
|
|
69
|
+
[{"x": 2, "y": 3}, {"x": 4, "y": 5}],
|
|
70
|
+
base_dir="runs",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
print(summary.n_ok, summary.n_failed)
|
|
74
|
+
for r in summary.results:
|
|
75
|
+
print(r.label, r.status, r.output_path or r.error)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### The `if __name__ == "__main__":` guard
|
|
79
|
+
|
|
80
|
+
On Windows and macOS, Python starts worker processes with `spawn`, which
|
|
81
|
+
re-imports your module in every worker. Without the guard, that re-import runs
|
|
82
|
+
your `run(...)` call again in each worker, which spawns more workers, and so on
|
|
83
|
+
until the process dies. Put anything that *calls* `run()` inside the guard;
|
|
84
|
+
your task functions themselves stay at module level, as normal.
|
|
85
|
+
|
|
86
|
+
### Placing the outputs
|
|
87
|
+
|
|
88
|
+
`base_dir` derives all three locations. To place them independently, pass any of
|
|
89
|
+
`output_dir`, `log_dir`, `manifest_dir` — an explicit path always wins, so you
|
|
90
|
+
can give a `base_dir` and still redirect just the logs.
|
|
91
|
+
|
|
92
|
+
### Reading the results back
|
|
93
|
+
|
|
94
|
+
Results are written to disk, one file per job (pickled by default). To load a
|
|
95
|
+
finished run back into memory as `{label: result}`:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from mpengine import load_run_outputs
|
|
99
|
+
|
|
100
|
+
outputs = load_run_outputs(summary.output_dir) # or the path run() printed
|
|
101
|
+
print(outputs["job_0000"])
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Only successful jobs appear — a failed job never wrote a file, so its label is
|
|
105
|
+
absent; check `summary.results` to see which failed and why. If you wrote the
|
|
106
|
+
run with a custom `save_fn`, pass the matching reader as
|
|
107
|
+
`load_run_outputs(..., load_fn=your_loader)`.
|
|
108
|
+
|
|
109
|
+
### Other options
|
|
110
|
+
|
|
111
|
+
| argument | meaning |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `save_fn` | how each result is written. Default `save_pickle`; supply your own (e.g. parquet) |
|
|
114
|
+
| `labels` | names for each job; defaults to `job_0000`, `job_0001`, … |
|
|
115
|
+
| `task` | names the run; defaults to `func.__name__` |
|
|
116
|
+
| `n_threads` | worker count; defaults to `min(cpu_count, 8)` |
|
|
117
|
+
| `debug` | run sequentially in-process — real tracebacks you can attach a debugger to, no pool |
|
|
118
|
+
| `show_progress` | live terminal display: one overall bar for the whole run, plus a live rate number per worker process. Ignored when `debug=True` |
|
|
119
|
+
|
|
120
|
+
Develop with `debug=True`, then flip it off. Chasing a bug through a process
|
|
121
|
+
pool means reading a `RemoteTraceback` from a worker that has already exited,
|
|
122
|
+
and it never tells you which job dict was at fault.
|
|
123
|
+
|
|
124
|
+
### Closures and lambdas
|
|
125
|
+
|
|
126
|
+
`func`, and any custom `save_fn`, can be a closure or a lambda — not just a
|
|
127
|
+
module-level function. Jobs are serialized with `cloudpickle` before crossing
|
|
128
|
+
the process boundary, which (unlike stdlib `pickle`) can serialize a function
|
|
129
|
+
by *value* (its bytecode plus whatever it captured), not just by reference.
|
|
130
|
+
|
|
131
|
+
The one caveat: whatever a closure captures travels with **every job** that
|
|
132
|
+
uses it — a closure capturing a large array re-serializes that array per job.
|
|
133
|
+
That's a cost trade-off to be aware of, not a limitation on what's allowed.
|
|
134
|
+
|
|
135
|
+
## Going lower-level
|
|
136
|
+
|
|
137
|
+
`run()` is the convenient layer. The primitives underneath are exported too, if
|
|
138
|
+
you want to drive the pool yourself:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from mpengine import expand_call, process_jobs, process_jobs_
|
|
142
|
+
|
|
143
|
+
jobs = [{"func": my_task, "x": 1, "y": 2}, {"func": other_task, "n": 5}]
|
|
144
|
+
results = process_jobs(jobs, n_threads=8) # or process_jobs_ to stay sequential
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
A job is just a dict carrying its own callback plus that callback's kwargs, so a
|
|
148
|
+
single call can dispatch entirely different functions with different signatures
|
|
149
|
+
and return types.
|
|
150
|
+
|
|
151
|
+
Partitioning helpers are available for splitting work into chunks:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from mpengine import lin_parts, nested_parts, parts_to_molecules
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`lin_parts` gives equal-count chunks. `nested_parts` gives equal-*work* chunks
|
|
158
|
+
for triangular workloads — where item `i` costs `O(i)`, such as an
|
|
159
|
+
expanding-window computation — which keeps workers from idling while one
|
|
160
|
+
overloaded worker finishes.
|
|
161
|
+
|
|
162
|
+
## Provenance
|
|
163
|
+
|
|
164
|
+
The dispatch core follows López de Prado, *Advances in Financial Machine
|
|
165
|
+
Learning*, Chapter 20; docstrings name the specific snippets. Nothing in
|
|
166
|
+
`mpengine` is finance-specific.
|
|
167
|
+
|
|
168
|
+
The `src/learning/` package in this repo holds the exercises the engine grew out
|
|
169
|
+
of. It depends on a separate private project and is deliberately **not**
|
|
170
|
+
packaged — installing `mpengine` elsewhere never pulls it in.
|
mpengine-0.1.0/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
|
|
17
|
+
A single job failing does **not** abort the run — it is recorded as a failed
|
|
18
|
+
job and the rest continue.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install -e path/to/mpengine # editable, for local development
|
|
24
|
+
pip install path/to/mpengine # or a plain install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`numpy`, `cloudpickle`, and `tqdm` are required.
|
|
28
|
+
|
|
29
|
+
## Use
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from mpengine import run
|
|
33
|
+
|
|
34
|
+
def my_task(x, y):
|
|
35
|
+
return x * y
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__": # required - see below
|
|
38
|
+
summary = run(
|
|
39
|
+
my_task,
|
|
40
|
+
[{"x": 2, "y": 3}, {"x": 4, "y": 5}],
|
|
41
|
+
base_dir="runs",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
print(summary.n_ok, summary.n_failed)
|
|
45
|
+
for r in summary.results:
|
|
46
|
+
print(r.label, r.status, r.output_path or r.error)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### The `if __name__ == "__main__":` guard
|
|
50
|
+
|
|
51
|
+
On Windows and macOS, Python starts worker processes with `spawn`, which
|
|
52
|
+
re-imports your module in every worker. Without the guard, that re-import runs
|
|
53
|
+
your `run(...)` call again in each worker, which spawns more workers, and so on
|
|
54
|
+
until the process dies. Put anything that *calls* `run()` inside the guard;
|
|
55
|
+
your task functions themselves stay at module level, as normal.
|
|
56
|
+
|
|
57
|
+
### Placing the outputs
|
|
58
|
+
|
|
59
|
+
`base_dir` derives all three locations. To place them independently, pass any of
|
|
60
|
+
`output_dir`, `log_dir`, `manifest_dir` — an explicit path always wins, so you
|
|
61
|
+
can give a `base_dir` and still redirect just the logs.
|
|
62
|
+
|
|
63
|
+
### Reading the results back
|
|
64
|
+
|
|
65
|
+
Results are written to disk, one file per job (pickled by default). To load a
|
|
66
|
+
finished run back into memory as `{label: result}`:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from mpengine import load_run_outputs
|
|
70
|
+
|
|
71
|
+
outputs = load_run_outputs(summary.output_dir) # or the path run() printed
|
|
72
|
+
print(outputs["job_0000"])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Only successful jobs appear — a failed job never wrote a file, so its label is
|
|
76
|
+
absent; check `summary.results` to see which failed and why. If you wrote the
|
|
77
|
+
run with a custom `save_fn`, pass the matching reader as
|
|
78
|
+
`load_run_outputs(..., load_fn=your_loader)`.
|
|
79
|
+
|
|
80
|
+
### Other options
|
|
81
|
+
|
|
82
|
+
| argument | meaning |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `save_fn` | how each result is written. Default `save_pickle`; supply your own (e.g. parquet) |
|
|
85
|
+
| `labels` | names for each job; defaults to `job_0000`, `job_0001`, … |
|
|
86
|
+
| `task` | names the run; defaults to `func.__name__` |
|
|
87
|
+
| `n_threads` | worker count; defaults to `min(cpu_count, 8)` |
|
|
88
|
+
| `debug` | run sequentially in-process — real tracebacks you can attach a debugger to, no pool |
|
|
89
|
+
| `show_progress` | live terminal display: one overall bar for the whole run, plus a live rate number per worker process. Ignored when `debug=True` |
|
|
90
|
+
|
|
91
|
+
Develop with `debug=True`, then flip it off. Chasing a bug through a process
|
|
92
|
+
pool means reading a `RemoteTraceback` from a worker that has already exited,
|
|
93
|
+
and it never tells you which job dict was at fault.
|
|
94
|
+
|
|
95
|
+
### Closures and lambdas
|
|
96
|
+
|
|
97
|
+
`func`, and any custom `save_fn`, can be a closure or a lambda — not just a
|
|
98
|
+
module-level function. Jobs are serialized with `cloudpickle` before crossing
|
|
99
|
+
the process boundary, which (unlike stdlib `pickle`) can serialize a function
|
|
100
|
+
by *value* (its bytecode plus whatever it captured), not just by reference.
|
|
101
|
+
|
|
102
|
+
The one caveat: whatever a closure captures travels with **every job** that
|
|
103
|
+
uses it — a closure capturing a large array re-serializes that array per job.
|
|
104
|
+
That's a cost trade-off to be aware of, not a limitation on what's allowed.
|
|
105
|
+
|
|
106
|
+
## Going lower-level
|
|
107
|
+
|
|
108
|
+
`run()` is the convenient layer. The primitives underneath are exported too, if
|
|
109
|
+
you want to drive the pool yourself:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from mpengine import expand_call, process_jobs, process_jobs_
|
|
113
|
+
|
|
114
|
+
jobs = [{"func": my_task, "x": 1, "y": 2}, {"func": other_task, "n": 5}]
|
|
115
|
+
results = process_jobs(jobs, n_threads=8) # or process_jobs_ to stay sequential
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
A job is just a dict carrying its own callback plus that callback's kwargs, so a
|
|
119
|
+
single call can dispatch entirely different functions with different signatures
|
|
120
|
+
and return types.
|
|
121
|
+
|
|
122
|
+
Partitioning helpers are available for splitting work into chunks:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from mpengine import lin_parts, nested_parts, parts_to_molecules
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`lin_parts` gives equal-count chunks. `nested_parts` gives equal-*work* chunks
|
|
129
|
+
for triangular workloads — where item `i` costs `O(i)`, such as an
|
|
130
|
+
expanding-window computation — which keeps workers from idling while one
|
|
131
|
+
overloaded worker finishes.
|
|
132
|
+
|
|
133
|
+
## Provenance
|
|
134
|
+
|
|
135
|
+
The dispatch core follows López de Prado, *Advances in Financial Machine
|
|
136
|
+
Learning*, Chapter 20; docstrings name the specific snippets. Nothing in
|
|
137
|
+
`mpengine` is finance-specific.
|
|
138
|
+
|
|
139
|
+
The `src/learning/` package in this repo holds the exercises the engine grew out
|
|
140
|
+
of. It depends on a separate private project and is deliberately **not**
|
|
141
|
+
packaged — installing `mpengine` elsewhere never pulls it in.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mpengine"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A small, general-purpose multiprocessing engine: dispatch any callable across processes with run manifests, per-worker logs, on-disk outputs and failure isolation"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Amandeep Singh" }]
|
|
13
|
+
keywords = ["multiprocessing", "parallel", "concurrency", "process-pool", "vectorization"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
25
|
+
"Topic :: System :: Distributed Computing",
|
|
26
|
+
]
|
|
27
|
+
# Floor is 3.10, not 3.13: `vermin` puts the code itself at 3.9, and the only
|
|
28
|
+
# dependency that would push it higher is numpy - pinned loosely below so pip
|
|
29
|
+
# resolves whatever numpy suits the installing interpreter. A tighter floor
|
|
30
|
+
# just locks out users for no reason.
|
|
31
|
+
requires-python = ">=3.10"
|
|
32
|
+
dependencies = [
|
|
33
|
+
"numpy>=1.26",
|
|
34
|
+
"cloudpickle>=3.0",
|
|
35
|
+
"tqdm>=4.60",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/singhamandeep-kgp/multiprocessor"
|
|
40
|
+
Repository = "https://github.com/singhamandeep-kgp/multiprocessor"
|
|
41
|
+
Issues = "https://github.com/singhamandeep-kgp/multiprocessor/issues"
|
|
42
|
+
|
|
43
|
+
# Only `mpengine` is packaged. The `learning` package (src/learning) holds the
|
|
44
|
+
# AFML Ch.20 exercises this engine grew out of - it depends on `statarb` and is
|
|
45
|
+
# deliberately NOT shipped, so installing mpengine into another project never
|
|
46
|
+
# drags in the exercises or their data-warehouse coupling. It stays importable
|
|
47
|
+
# locally because the editable install puts all of src/ on sys.path.
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["src"]
|
|
50
|
+
include = ["mpengine*"]
|
|
51
|
+
|
|
52
|
+
# worker_names.json is data, not code - setuptools' package discovery above
|
|
53
|
+
# only picks up .py files by default, so it needs to be listed explicitly to
|
|
54
|
+
# actually ship inside a built wheel.
|
|
55
|
+
[tool.setuptools.package-data]
|
|
56
|
+
mpengine = ["worker_names.json"]
|
|
57
|
+
|
|
58
|
+
# `statarb` (needed only by src/learning) is installed separately as an editable
|
|
59
|
+
# path install (pip install -e ../Equity_StatArb) rather than declared here,
|
|
60
|
+
# since a relative file:// dependency URL is not portably supported across pip
|
|
61
|
+
# versions/platforms.
|
mpengine-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""mpengine - a small, general-purpose multiprocessing engine.
|
|
2
|
+
|
|
3
|
+
Two layers, which you can use at whichever level suits you:
|
|
4
|
+
|
|
5
|
+
- ``engine`` the primitives. A job is just a dict carrying its own
|
|
6
|
+
callback plus that callback's kwargs; ``expand_call`` turns
|
|
7
|
+
such a dict into a call, and ``process_jobs`` /
|
|
8
|
+
``process_jobs_`` dispatch them in parallel or sequentially.
|
|
9
|
+
- ``orchestrator`` the production layer. ``run()`` adds a manifest of exactly
|
|
10
|
+
what was launched, one log file per worker process, results
|
|
11
|
+
saved to disk, and failure isolation so one bad job cannot
|
|
12
|
+
lose the rest of the run.
|
|
13
|
+
|
|
14
|
+
Most callers only ever need ``run``::
|
|
15
|
+
|
|
16
|
+
from mpengine import run
|
|
17
|
+
|
|
18
|
+
def my_task(x, y):
|
|
19
|
+
return x * y
|
|
20
|
+
|
|
21
|
+
summary = run(
|
|
22
|
+
my_task,
|
|
23
|
+
[{"x": 2, "y": 3}, {"x": 4, "y": 5}],
|
|
24
|
+
base_dir="runs",
|
|
25
|
+
)
|
|
26
|
+
print(summary.n_ok, summary.n_failed)
|
|
27
|
+
|
|
28
|
+
That writes ``runs/manifests/<run_id>.txt``, ``runs/outputs/<run_id>/`` and
|
|
29
|
+
``runs/logs/<run_id>/``.
|
|
30
|
+
|
|
31
|
+
Worker targets - ``func`` itself, and any custom ``save_fn`` - can be closures
|
|
32
|
+
or lambdas, not just module-level functions. Jobs are serialized with
|
|
33
|
+
``cloudpickle`` before crossing the process boundary, which can pickle a
|
|
34
|
+
function by value (not just by reference). The one caveat: whatever a closure
|
|
35
|
+
captures travels with every job that uses it.
|
|
36
|
+
|
|
37
|
+
The dispatch core follows Lopez de Prado, *Advances in Financial Machine
|
|
38
|
+
Learning*, Ch.20 - the docstrings name the specific snippets - but nothing here
|
|
39
|
+
is finance-specific; it parallelizes any callable.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from mpengine.engine import expand_call, process_jobs, process_jobs_, report_progress
|
|
43
|
+
from mpengine.orchestrator import (
|
|
44
|
+
JobResult,
|
|
45
|
+
RunSummary,
|
|
46
|
+
WorkerStats,
|
|
47
|
+
load_pickle,
|
|
48
|
+
load_run_outputs,
|
|
49
|
+
run,
|
|
50
|
+
save_pickle,
|
|
51
|
+
)
|
|
52
|
+
from mpengine.partition import equal_chunks, lin_parts, nested_parts, parts_to_molecules
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
# the usual entry point
|
|
56
|
+
"run",
|
|
57
|
+
"RunSummary",
|
|
58
|
+
"JobResult",
|
|
59
|
+
"WorkerStats",
|
|
60
|
+
"save_pickle",
|
|
61
|
+
# reading a finished run's outputs back
|
|
62
|
+
"load_run_outputs",
|
|
63
|
+
"load_pickle",
|
|
64
|
+
# lower-level dispatch, if you want to drive the pool yourself
|
|
65
|
+
"expand_call",
|
|
66
|
+
"process_jobs",
|
|
67
|
+
"process_jobs_",
|
|
68
|
+
"report_progress",
|
|
69
|
+
# atom -> molecule partitioning
|
|
70
|
+
"equal_chunks",
|
|
71
|
+
"lin_parts",
|
|
72
|
+
"nested_parts",
|
|
73
|
+
"parts_to_molecules",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Ch.20 SS20.5 - the multiprocessing engine (exercises 6+7).
|
|
2
|
+
|
|
3
|
+
expand_call / process_jobs_ / process_jobs / report_progress - a generic
|
|
4
|
+
job-dict-to-function-call pipeline, reusable by any exercise rather than
|
|
5
|
+
tied to one atom/molecule shape. This is the book's own point: stop writing
|
|
6
|
+
a bespoke parallelization wrapper per function, and build one library that
|
|
7
|
+
can parallelize unknown functions regardless of their arguments or output.
|
|
8
|
+
|
|
9
|
+
A "job" here is just a dict: one 'func' entry (the callback) plus whatever
|
|
10
|
+
kwargs that callback needs. `expand_call` turns such a dict into a call,
|
|
11
|
+
which is the hinge the whole engine turns on.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import datetime as dt
|
|
17
|
+
import multiprocessing as mp
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any, Callable
|
|
22
|
+
|
|
23
|
+
import cloudpickle
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def expand_call(kargs: dict[str, Any]) -> Any:
|
|
27
|
+
"""Ch.20 Snippet 20.10 (`expandCall`) - turn a job dict into a call.
|
|
28
|
+
|
|
29
|
+
Deviation from the book: `kargs` is copied before popping. The book's
|
|
30
|
+
literal `kargs.pop('func')` mutates the *caller's* dict in place, so the
|
|
31
|
+
same job list can only ever be run once - a second pass raises
|
|
32
|
+
KeyError('func'). ex04 deliberately runs one job list through both
|
|
33
|
+
`process_jobs_` and `process_jobs` to compare them, so the copy matters.
|
|
34
|
+
"""
|
|
35
|
+
kargs = dict(kargs)
|
|
36
|
+
func = kargs.pop("func")
|
|
37
|
+
return func(**kargs)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def process_jobs_(jobs: list[dict[str, Any]]) -> list[Any]:
|
|
41
|
+
"""Ch.20 Snippet 20.8 (`processJobs_`) - sequential fallback, for debugging.
|
|
42
|
+
|
|
43
|
+
Runs every job in-process, one at a time, in submission order. No pool,
|
|
44
|
+
no pickling, no spawn - so a failing job raises immediately, at its exact
|
|
45
|
+
position in the list, with an ordinary live traceback you can attach a
|
|
46
|
+
debugger to. That clarity is the entire reason this mode exists.
|
|
47
|
+
"""
|
|
48
|
+
return [expand_call(job) for job in jobs]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def report_progress(job_num: int, num_jobs: int, time0: float, task: str) -> None:
|
|
52
|
+
"""Ch.20 Snippet 20.9's `reportProgress`, modernized.
|
|
53
|
+
|
|
54
|
+
Overwrites its own line via '\\r' until the final job, then emits '\\n'.
|
|
55
|
+
Units stay the book's minutes even though a fast demo run always shows
|
|
56
|
+
"0.00 minutes" - the snippet is sized for real multi-minute workloads.
|
|
57
|
+
"""
|
|
58
|
+
frac = job_num / num_jobs
|
|
59
|
+
minutes_elapsed = (time.time() - time0) / 60.0
|
|
60
|
+
minutes_remaining = minutes_elapsed * (1 / frac - 1) if frac > 0 else 0.0
|
|
61
|
+
timestamp = dt.datetime.now().isoformat(sep=" ", timespec="seconds")
|
|
62
|
+
msg = (
|
|
63
|
+
f"{timestamp} {frac * 100:5.1f}% {task} done after {minutes_elapsed:.2f} "
|
|
64
|
+
f"minutes. Remaining {minutes_remaining:.2f} minutes."
|
|
65
|
+
)
|
|
66
|
+
print(msg, end="\n" if job_num >= num_jobs else "\r", file=sys.stderr, flush=True)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_from_blob(blob: bytes) -> tuple[int, float, Any]:
|
|
70
|
+
"""Pool target: undo the cloudpickle wrapping `process_jobs` applies before
|
|
71
|
+
submission, then dispatch exactly as `expand_call` always has.
|
|
72
|
+
|
|
73
|
+
Returns `(pid, atom_seconds, result)`. The pid attributes the completion to
|
|
74
|
+
a specific worker process; `atom_seconds` is measured around the call
|
|
75
|
+
itself, *inside* the worker, so it is pure compute time - it cannot include
|
|
76
|
+
queueing, spawn cost, or the idle gap between this atom finishing and the
|
|
77
|
+
rest of the run finishing. That distinction is the whole point: timing the
|
|
78
|
+
same thing from the parent would measure when the result *arrived*, not how
|
|
79
|
+
long the work actually took.
|
|
80
|
+
"""
|
|
81
|
+
job = cloudpickle.loads(blob)
|
|
82
|
+
t0 = time.perf_counter()
|
|
83
|
+
result = expand_call(job)
|
|
84
|
+
return os.getpid(), time.perf_counter() - t0, result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def process_jobs(
|
|
88
|
+
jobs: list[dict[str, Any]],
|
|
89
|
+
task: str | None = None,
|
|
90
|
+
n_threads: int = 24,
|
|
91
|
+
on_progress: Callable[[int, float, Any], None] | None = None,
|
|
92
|
+
) -> list[Any]:
|
|
93
|
+
"""Ch.20 Snippet 20.9 (`processJobs`) - real `mp.Pool` + `imap_unordered`.
|
|
94
|
+
|
|
95
|
+
Results arrive in *completion* order, not submission order, which is what
|
|
96
|
+
lets `report_progress` report honestly as each job lands - with uneven job
|
|
97
|
+
costs (see ex03), an early-submitted heavy job can finish long after
|
|
98
|
+
several later-submitted light ones.
|
|
99
|
+
|
|
100
|
+
Each job is serialized with `cloudpickle` before submission (rather than
|
|
101
|
+
relying on `Pool`'s own stdlib-`pickle` handling of `jobs`), so a job's
|
|
102
|
+
'func' - or a nested callable, like `orchestrator.run`'s `save_fn` - can be
|
|
103
|
+
a closure or a lambda, not just a module-level function. `_run_from_blob`
|
|
104
|
+
is itself a plain module-level function, so it still pickles by reference
|
|
105
|
+
with no dependence on `__main__` spawn fixup.
|
|
106
|
+
|
|
107
|
+
`on_progress`, if given, is called as `on_progress(pid, atom_seconds,
|
|
108
|
+
result)` for every completed job - `pid` identifies which worker process
|
|
109
|
+
produced it and `atom_seconds` is that job's pure compute time as measured
|
|
110
|
+
inside the worker, which is what lets a caller (see `orchestrator.run`'s
|
|
111
|
+
`show_progress`) drive a per-worker live display with real timings. The
|
|
112
|
+
return value here is unaffected either way - still the plain `list[Any]`
|
|
113
|
+
of results, never the `(pid, atom_seconds, result)` triples.
|
|
114
|
+
Supplying `on_progress` means the caller is rendering its own progress
|
|
115
|
+
display, so the book's own text-based `report_progress` is skipped for
|
|
116
|
+
that call - the two would otherwise both write to stderr and garble each
|
|
117
|
+
other's redraws.
|
|
118
|
+
|
|
119
|
+
Uses explicit close()+join() rather than `with mp.Pool(...) as pool:`,
|
|
120
|
+
whose __exit__ calls terminate() - an abrupt kill, not the book's graceful
|
|
121
|
+
shutdown. The try/finally still lets a job's exception propagate while
|
|
122
|
+
guaranteeing the workers are torn down.
|
|
123
|
+
"""
|
|
124
|
+
if task is None:
|
|
125
|
+
task = jobs[0]["func"].__name__
|
|
126
|
+
blobs = [cloudpickle.dumps(job) for job in jobs]
|
|
127
|
+
pool = mp.Pool(processes=n_threads)
|
|
128
|
+
out: list[Any] = []
|
|
129
|
+
time0 = time.time()
|
|
130
|
+
try:
|
|
131
|
+
for i, (pid, atom_s, out_) in enumerate(pool.imap_unordered(_run_from_blob, blobs), 1):
|
|
132
|
+
out.append(out_)
|
|
133
|
+
if on_progress is not None:
|
|
134
|
+
on_progress(pid, atom_s, out_)
|
|
135
|
+
else:
|
|
136
|
+
report_progress(i, len(jobs), time0, task)
|
|
137
|
+
finally:
|
|
138
|
+
pool.close()
|
|
139
|
+
pool.join()
|
|
140
|
+
return out
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""A production orchestration layer on top of `mpengine.engine`.
|
|
2
|
+
|
|
3
|
+
`engine.py` stays the pure, book-faithful engine (`expand_call`/
|
|
4
|
+
`process_jobs_`/`process_jobs`) and is unchanged by this module. `run()` is
|
|
5
|
+
the one entry point on top of it: give it a function and a list of parameter
|
|
6
|
+
sets, and it handles everything a real multiprocessing job needs beyond the
|
|
7
|
+
book's scope -
|
|
8
|
+
|
|
9
|
+
1. a manifest .txt recording exactly what was launched (and, once done,
|
|
10
|
+
what happened)
|
|
11
|
+
2. every job's result saved to disk, in whatever format the caller chooses
|
|
12
|
+
3. one log file per *worker process* (not per job), so a failure is
|
|
13
|
+
attributable to a specific process, not just a stack trace
|
|
14
|
+
|
|
15
|
+
Unlike `engine.py`'s `process_jobs`, a single job failing here does not abort
|
|
16
|
+
the run - it's recorded as a failed `JobResult` and the rest continue. That is
|
|
17
|
+
the actual point of "know which one broke and which didn't": you want the N-1
|
|
18
|
+
good results even when job N blew up.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import datetime as dt
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import pickle
|
|
28
|
+
import random
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
import traceback
|
|
32
|
+
from contextlib import contextmanager
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from importlib import resources
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Callable, Iterator
|
|
37
|
+
|
|
38
|
+
from tqdm import tqdm
|
|
39
|
+
|
|
40
|
+
from mpengine.engine import expand_call, process_jobs, process_jobs_
|
|
41
|
+
|
|
42
|
+
N_WORKERS = min(os.cpu_count() or 4, 8)
|
|
43
|
+
|
|
44
|
+
# one FileHandler-backed logger per worker *process*, lazily created on that
|
|
45
|
+
# process's first job and reused for every job after - keyed by pid, which is
|
|
46
|
+
# always fresh in a newly spawned worker, so no cross-run coordination needed
|
|
47
|
+
_worker_logger: logging.Logger | None = None
|
|
48
|
+
_worker_log_dir: Path | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class JobResult:
|
|
53
|
+
label: str
|
|
54
|
+
status: str # "ok" | "error"
|
|
55
|
+
output_path: str | None = None
|
|
56
|
+
error: str | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class RunSummary:
|
|
61
|
+
run_id: str
|
|
62
|
+
manifest_path: str
|
|
63
|
+
output_dir: str
|
|
64
|
+
log_dir: str
|
|
65
|
+
n_jobs: int
|
|
66
|
+
n_ok: int
|
|
67
|
+
n_failed: int
|
|
68
|
+
elapsed_s: float
|
|
69
|
+
results: list[JobResult] = field(default_factory=list)
|
|
70
|
+
# keyed by worker pid; only populated when show_progress=True, since that
|
|
71
|
+
# is what installs the per-atom timing hook
|
|
72
|
+
worker_stats: dict[int, "WorkerStats"] = field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def save_pickle(obj: Any, path: Path) -> None:
|
|
76
|
+
"""Default save_fn. A caller-supplied save_fn can be a lambda or a closure
|
|
77
|
+
too - like any other job field, it crosses the process boundary via
|
|
78
|
+
`cloudpickle`, not stdlib `pickle`."""
|
|
79
|
+
with open(path, "wb") as f:
|
|
80
|
+
pickle.dump(obj, f)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_pickle(path: Path) -> Any:
|
|
84
|
+
"""Counterpart to `save_pickle`, and the default `load_fn` for
|
|
85
|
+
`load_run_outputs`. Runs in the calling process, not a worker, so the
|
|
86
|
+
ordinary `pickle` caveat applies: whatever types were saved must be
|
|
87
|
+
importable here to be reconstructed."""
|
|
88
|
+
with open(path, "rb") as f:
|
|
89
|
+
return pickle.load(f)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load_run_outputs(
|
|
93
|
+
run_dir: str | Path,
|
|
94
|
+
load_fn: Callable[[Path], Any] = load_pickle,
|
|
95
|
+
) -> dict[str, Any]:
|
|
96
|
+
"""Read a finished run's outputs back as `{label: result}`.
|
|
97
|
+
|
|
98
|
+
`run_dir` is that run's output directory - pass `RunSummary.output_dir`,
|
|
99
|
+
or the path `run()` printed as "Output stored here". Keys are the labels
|
|
100
|
+
`run()` assigned (`job_0000`, ...), so a result can be matched straight
|
|
101
|
+
back to the `JobResult` that produced it.
|
|
102
|
+
|
|
103
|
+
Only successful jobs appear. A failed job never wrote a file, so its label
|
|
104
|
+
is simply missing here - check `RunSummary.results` to see which failed and
|
|
105
|
+
why, rather than inferring it from a gap in these keys.
|
|
106
|
+
|
|
107
|
+
`load_fn` must match whatever `save_fn` wrote the run; `save_pickle` and
|
|
108
|
+
`load_pickle` are the matched default pair.
|
|
109
|
+
"""
|
|
110
|
+
path = Path(run_dir)
|
|
111
|
+
if not path.is_dir():
|
|
112
|
+
raise NotADirectoryError(f"not a run output directory: {path}")
|
|
113
|
+
return {p.name: load_fn(p) for p in sorted(path.iterdir()) if p.is_file()}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _get_worker_logger(log_dir: Path) -> logging.Logger:
|
|
117
|
+
global _worker_logger, _worker_log_dir
|
|
118
|
+
if _worker_logger is not None and _worker_log_dir == log_dir:
|
|
119
|
+
return _worker_logger
|
|
120
|
+
|
|
121
|
+
pid = os.getpid()
|
|
122
|
+
logger = logging.getLogger(f"mpengine.orchestrator.worker.{pid}")
|
|
123
|
+
logger.setLevel(logging.INFO)
|
|
124
|
+
logger.propagate = False
|
|
125
|
+
if not logger.handlers:
|
|
126
|
+
handler = logging.FileHandler(log_dir / f"worker_{pid}.log")
|
|
127
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
|
|
128
|
+
logger.addHandler(handler)
|
|
129
|
+
|
|
130
|
+
_worker_logger, _worker_log_dir = logger, log_dir
|
|
131
|
+
return logger
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _run_and_save_job(job: dict) -> JobResult:
|
|
135
|
+
"""The actual Pool/sequential target. Unwraps its own bookkeeping fields,
|
|
136
|
+
calls the caller's function via `expand_call` (reusing engine.py rather
|
|
137
|
+
than reimplementing the dict-to-call trick), saves the result, and always
|
|
138
|
+
returns a JobResult - exceptions are caught here, never re-raised, so one
|
|
139
|
+
bad job cannot abort the run.
|
|
140
|
+
"""
|
|
141
|
+
label = job["label"]
|
|
142
|
+
output_dir = job["output_dir"]
|
|
143
|
+
log_dir = job["log_dir"]
|
|
144
|
+
save_fn = job["save_fn"]
|
|
145
|
+
inner_job = job["inner_job"]
|
|
146
|
+
|
|
147
|
+
logger = _get_worker_logger(log_dir)
|
|
148
|
+
logger.info("starting %s", label)
|
|
149
|
+
try:
|
|
150
|
+
result = expand_call(inner_job)
|
|
151
|
+
except Exception as exc:
|
|
152
|
+
logger.error("failed %s: %s\n%s", label, exc, traceback.format_exc())
|
|
153
|
+
return JobResult(label=label, status="error", error=str(exc))
|
|
154
|
+
|
|
155
|
+
output_path = output_dir / label
|
|
156
|
+
try:
|
|
157
|
+
save_fn(result, output_path)
|
|
158
|
+
except Exception as exc:
|
|
159
|
+
logger.error("save failed %s: %s\n%s", label, exc, traceback.format_exc())
|
|
160
|
+
return JobResult(label=label, status="error", error=f"save failed: {exc}")
|
|
161
|
+
|
|
162
|
+
logger.info("completed %s -> %s", label, output_path)
|
|
163
|
+
return JobResult(label=label, status="ok", output_path=str(output_path))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _load_worker_names() -> list[str]:
|
|
167
|
+
"""Cool, memorable codenames for the progress display - purely cosmetic,
|
|
168
|
+
so a worker reads as "Worker Jarvis (PID 12345)" rather than a bare pid.
|
|
169
|
+
Lives in `worker_names.json` (not inline here) so the name pool can be
|
|
170
|
+
edited without touching this module. Sized generously past any realistic
|
|
171
|
+
core count; `_next_worker_name` falls back to a numbered name if a run
|
|
172
|
+
somehow has more workers than names.
|
|
173
|
+
"""
|
|
174
|
+
data = resources.files("mpengine").joinpath("worker_names.json").read_text(encoding="utf-8")
|
|
175
|
+
return json.loads(data)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
_WORKER_NAMES = _load_worker_names()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass
|
|
182
|
+
class WorkerStats:
|
|
183
|
+
"""Per-worker-process tallies, accumulated live as atoms complete."""
|
|
184
|
+
|
|
185
|
+
name: str
|
|
186
|
+
pid: int
|
|
187
|
+
n_atoms: int = 0
|
|
188
|
+
busy_s: float = 0.0
|
|
189
|
+
last_atom_s: float = 0.0
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def avg_atom_s(self) -> float:
|
|
193
|
+
return self.busy_s / self.n_atoms if self.n_atoms else 0.0
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def atoms_per_s(self) -> float:
|
|
197
|
+
return self.n_atoms / self.busy_s if self.busy_s else 0.0
|
|
198
|
+
|
|
199
|
+
def line(self) -> str:
|
|
200
|
+
plural = "atom " if self.n_atoms == 1 else "atoms"
|
|
201
|
+
return (
|
|
202
|
+
f"Worker {self.name} (PID {self.pid}): {self.n_atoms} {plural} | "
|
|
203
|
+
f"avg {self.avg_atom_s:.2f}s/atom | last {self.last_atom_s:.2f}s"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@contextmanager
|
|
208
|
+
def _progress_renderer(
|
|
209
|
+
n_jobs: int, task: str, stats_out: dict[int, WorkerStats]
|
|
210
|
+
) -> Iterator[Callable[[int, float, Any], None]]:
|
|
211
|
+
"""tqdm-based live display for `run(show_progress=True)`: one overall bar
|
|
212
|
+
(total=n_jobs), plus a per-worker line created lazily the first time a pid
|
|
213
|
+
is seen. Workers get no fixed total - `Pool` assigns jobs to them
|
|
214
|
+
dynamically as they free up, so there's no way to know one in advance.
|
|
215
|
+
|
|
216
|
+
The per-worker numbers are computed here from `atom_seconds` measured
|
|
217
|
+
inside the worker, *not* from tqdm's own rate. tqdm's rate would divide by
|
|
218
|
+
the bar's lifetime - creation to close - and since every bar is closed
|
|
219
|
+
together at the end of the run, that span is mostly idle waiting, which
|
|
220
|
+
makes an early-finishing worker look slow and the last-finishing worker
|
|
221
|
+
look fast. Dividing real atom counts by real compute time avoids that
|
|
222
|
+
inversion entirely.
|
|
223
|
+
|
|
224
|
+
Every line is recomputed and redrawn the instant its worker finishes an
|
|
225
|
+
atom (`mininterval=0` so tqdm cannot throttle the redraw). A worker's line
|
|
226
|
+
holds steady while it is mid-atom - there is no partial-progress signal
|
|
227
|
+
from inside a running atom, only completions.
|
|
228
|
+
|
|
229
|
+
Each worker gets a random, unique codename for the run (shuffled once per
|
|
230
|
+
call, so it varies run to run); the pid is shown alongside it, since the
|
|
231
|
+
name itself is only decorative. `stats_out` is populated in place so the
|
|
232
|
+
caller keeps the tallies after the display is torn down.
|
|
233
|
+
"""
|
|
234
|
+
overall = tqdm(total=n_jobs, desc=task, position=0, file=sys.stderr, mininterval=0)
|
|
235
|
+
worker_bars: dict[int, tqdm] = {}
|
|
236
|
+
available_names = _WORKER_NAMES.copy()
|
|
237
|
+
random.shuffle(available_names)
|
|
238
|
+
|
|
239
|
+
def _next_worker_name() -> str:
|
|
240
|
+
if available_names:
|
|
241
|
+
return available_names.pop()
|
|
242
|
+
return f"Worker-{len(worker_bars) + 1}"
|
|
243
|
+
|
|
244
|
+
def on_progress(pid: int, atom_s: float, _result: Any) -> None:
|
|
245
|
+
if pid not in worker_bars:
|
|
246
|
+
stats_out[pid] = WorkerStats(name=_next_worker_name(), pid=pid)
|
|
247
|
+
worker_bars[pid] = tqdm(
|
|
248
|
+
total=None,
|
|
249
|
+
position=len(worker_bars) + 1,
|
|
250
|
+
bar_format="{desc}",
|
|
251
|
+
file=sys.stderr,
|
|
252
|
+
mininterval=0,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
stats = stats_out[pid]
|
|
256
|
+
stats.n_atoms += 1
|
|
257
|
+
stats.busy_s += atom_s
|
|
258
|
+
stats.last_atom_s = atom_s
|
|
259
|
+
|
|
260
|
+
bar = worker_bars[pid]
|
|
261
|
+
bar.set_description_str(stats.line(), refresh=True)
|
|
262
|
+
overall.update(1)
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
yield on_progress
|
|
266
|
+
finally:
|
|
267
|
+
overall.close()
|
|
268
|
+
for bar in worker_bars.values():
|
|
269
|
+
bar.close()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _print_worker_ranking(stats: dict[int, WorkerStats]) -> None:
|
|
273
|
+
"""Rank workers fastest-to-slowest by seconds per atom.
|
|
274
|
+
|
|
275
|
+
Note the caveat printed alongside: with uneven atom sizes, a low
|
|
276
|
+
s/atom can mean small atoms rather than a genuinely faster worker.
|
|
277
|
+
"""
|
|
278
|
+
if not stats:
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
ranked = sorted(stats.values(), key=lambda s: s.avg_atom_s)
|
|
282
|
+
print("\nworkers, fastest to slowest (by avg seconds per atom):")
|
|
283
|
+
for i, s in enumerate(ranked):
|
|
284
|
+
tag = ""
|
|
285
|
+
if len(ranked) > 1:
|
|
286
|
+
tag = " <- fastest" if i == 0 else (" <- slowest" if i == len(ranked) - 1 else "")
|
|
287
|
+
print(
|
|
288
|
+
f" {s.name:<12} (PID {s.pid:>6}) {s.n_atoms:>3} atoms "
|
|
289
|
+
f"avg {s.avg_atom_s:6.2f}s/atom {s.atoms_per_s:6.2f} atoms/s{tag}"
|
|
290
|
+
)
|
|
291
|
+
print(" (uneven atom sizes: a low s/atom can mean small atoms, not a faster worker)")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def run(
|
|
295
|
+
func: Callable[..., Any],
|
|
296
|
+
param_sets: list[dict[str, Any]],
|
|
297
|
+
*,
|
|
298
|
+
base_dir: str | Path | None = None,
|
|
299
|
+
output_dir: str | Path | None = None,
|
|
300
|
+
log_dir: str | Path | None = None,
|
|
301
|
+
manifest_dir: str | Path | None = None,
|
|
302
|
+
save_fn: Callable[[Any, Path], None] = save_pickle,
|
|
303
|
+
labels: list[str] | None = None,
|
|
304
|
+
task: str | None = None,
|
|
305
|
+
n_threads: int = N_WORKERS,
|
|
306
|
+
debug: bool = False,
|
|
307
|
+
show_progress: bool = False,
|
|
308
|
+
) -> RunSummary:
|
|
309
|
+
"""Run `func(**params)` for every dict in `param_sets`, organized.
|
|
310
|
+
|
|
311
|
+
Destinations: pass `base_dir` and the three output locations are derived
|
|
312
|
+
as `<base_dir>/outputs`, `<base_dir>/logs` and `<base_dir>/manifests`; or
|
|
313
|
+
pass `output_dir`/`log_dir`/`manifest_dir` explicitly to place them
|
|
314
|
+
independently. An explicit path always wins over the derived one, so you
|
|
315
|
+
can give a `base_dir` and still redirect just the logs elsewhere.
|
|
316
|
+
|
|
317
|
+
`func` can be a closure or a lambda, not just a module-level function -
|
|
318
|
+
`engine.process_jobs` serializes jobs with `cloudpickle`, which can pickle
|
|
319
|
+
a function by value. The only cost: whatever a closure captures travels
|
|
320
|
+
with every job that uses it.
|
|
321
|
+
|
|
322
|
+
Every destination directory gets a `<run_id>` subfolder (`task` + a
|
|
323
|
+
timestamp), so successive runs never collide and a worker's log file
|
|
324
|
+
(named by its pid) can never be confused with a previous run's.
|
|
325
|
+
|
|
326
|
+
`show_progress=True` (only meaningful when `debug=False` - ignored
|
|
327
|
+
otherwise, since debug mode is one sequential in-process run with no
|
|
328
|
+
"workers" to distinguish) renders a live terminal display: one overall bar
|
|
329
|
+
for the whole run, plus a per-worker line showing atoms done, average
|
|
330
|
+
seconds per atom and the last atom's time, each recomputed and redrawn the
|
|
331
|
+
moment that worker finishes an atom. It also prints a fastest-to-slowest
|
|
332
|
+
ranking at the end, and fills `RunSummary.worker_stats` (keyed by pid) so
|
|
333
|
+
the same numbers are available programmatically.
|
|
334
|
+
"""
|
|
335
|
+
if base_dir is not None:
|
|
336
|
+
base = Path(base_dir)
|
|
337
|
+
output_dir = output_dir if output_dir is not None else base / "outputs"
|
|
338
|
+
log_dir = log_dir if log_dir is not None else base / "logs"
|
|
339
|
+
manifest_dir = manifest_dir if manifest_dir is not None else base / "manifests"
|
|
340
|
+
|
|
341
|
+
missing = [
|
|
342
|
+
name
|
|
343
|
+
for name, value in (
|
|
344
|
+
("output_dir", output_dir),
|
|
345
|
+
("log_dir", log_dir),
|
|
346
|
+
("manifest_dir", manifest_dir),
|
|
347
|
+
)
|
|
348
|
+
if value is None
|
|
349
|
+
]
|
|
350
|
+
if missing:
|
|
351
|
+
raise ValueError(
|
|
352
|
+
f"pass base_dir, or all of output_dir/log_dir/manifest_dir (missing: {', '.join(missing)})"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
task = task or func.__name__
|
|
356
|
+
run_id = f"{task}_{dt.datetime.now():%Y%m%d_%H%M%S}"
|
|
357
|
+
|
|
358
|
+
output_path = Path(output_dir) / run_id
|
|
359
|
+
log_path = Path(log_dir) / run_id
|
|
360
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
361
|
+
log_path.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
Path(manifest_dir).mkdir(parents=True, exist_ok=True)
|
|
363
|
+
manifest_path = Path(manifest_dir) / f"{run_id}.txt"
|
|
364
|
+
|
|
365
|
+
labels = labels or [f"job_{i:04d}" for i in range(len(param_sets))]
|
|
366
|
+
if len(labels) != len(param_sets):
|
|
367
|
+
raise ValueError(f"got {len(labels)} labels for {len(param_sets)} param sets")
|
|
368
|
+
|
|
369
|
+
jobs = [
|
|
370
|
+
{
|
|
371
|
+
"func": _run_and_save_job,
|
|
372
|
+
"job": {
|
|
373
|
+
"label": label,
|
|
374
|
+
"output_dir": output_path,
|
|
375
|
+
"log_dir": log_path,
|
|
376
|
+
"save_fn": save_fn,
|
|
377
|
+
"inner_job": {"func": func, **params},
|
|
378
|
+
},
|
|
379
|
+
}
|
|
380
|
+
for label, params in zip(labels, param_sets)
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
with open(manifest_path, "w") as f:
|
|
384
|
+
f.write(f"run_id: {run_id}\n")
|
|
385
|
+
f.write(f"launched: {dt.datetime.now().isoformat(sep=' ', timespec='seconds')}\n")
|
|
386
|
+
f.write(f"func: {func.__name__}\n")
|
|
387
|
+
f.write(f"task: {task}\n")
|
|
388
|
+
f.write(f"n_threads: {n_threads}\n")
|
|
389
|
+
f.write(f"debug: {debug}\n")
|
|
390
|
+
f.write(f"n_jobs: {len(jobs)}\n")
|
|
391
|
+
f.write(f"output_dir: {output_path}\n")
|
|
392
|
+
f.write(f"log_dir: {log_path}\n")
|
|
393
|
+
f.write("\nparameters:\n")
|
|
394
|
+
for label, params in zip(labels, param_sets):
|
|
395
|
+
f.write(f" {label}: {params}\n")
|
|
396
|
+
f.write("\n")
|
|
397
|
+
|
|
398
|
+
worker_stats: dict[int, WorkerStats] = {}
|
|
399
|
+
|
|
400
|
+
t0 = time.perf_counter()
|
|
401
|
+
if debug:
|
|
402
|
+
raw_results = process_jobs_(jobs)
|
|
403
|
+
elif show_progress:
|
|
404
|
+
with _progress_renderer(len(jobs), task, worker_stats) as on_progress:
|
|
405
|
+
raw_results = process_jobs(jobs, task=task, n_threads=n_threads, on_progress=on_progress)
|
|
406
|
+
else:
|
|
407
|
+
raw_results = process_jobs(jobs, task=task, n_threads=n_threads)
|
|
408
|
+
elapsed_s = time.perf_counter() - t0
|
|
409
|
+
|
|
410
|
+
if show_progress and not debug:
|
|
411
|
+
_print_worker_ranking(worker_stats)
|
|
412
|
+
|
|
413
|
+
n_ok = sum(1 for r in raw_results if r.status == "ok")
|
|
414
|
+
n_failed = len(raw_results) - n_ok
|
|
415
|
+
|
|
416
|
+
with open(manifest_path, "a") as f:
|
|
417
|
+
f.write("results:\n")
|
|
418
|
+
for r in raw_results:
|
|
419
|
+
if r.status == "ok":
|
|
420
|
+
f.write(f" {r.label}: ok -> {r.output_path}\n")
|
|
421
|
+
else:
|
|
422
|
+
f.write(f" {r.label}: ERROR - {r.error}\n")
|
|
423
|
+
f.write(f"\nn_ok: {n_ok}\n")
|
|
424
|
+
f.write(f"n_failed: {n_failed}\n")
|
|
425
|
+
f.write(f"elapsed_s: {elapsed_s:.3f}\n")
|
|
426
|
+
|
|
427
|
+
print(f"Logs stored here - {log_path}")
|
|
428
|
+
print(f"Output stored here - {output_path}")
|
|
429
|
+
print(f"Manifest stored here - {manifest_path}")
|
|
430
|
+
|
|
431
|
+
return RunSummary(
|
|
432
|
+
run_id=run_id,
|
|
433
|
+
manifest_path=str(manifest_path),
|
|
434
|
+
output_dir=str(output_path),
|
|
435
|
+
log_dir=str(log_path),
|
|
436
|
+
n_jobs=len(jobs),
|
|
437
|
+
n_ok=n_ok,
|
|
438
|
+
n_failed=n_failed,
|
|
439
|
+
elapsed_s=elapsed_s,
|
|
440
|
+
results=raw_results,
|
|
441
|
+
worker_stats=worker_stats,
|
|
442
|
+
)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Atom -> molecule partitioning helpers, used by every exercise."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Sequence, TypeVar
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def equal_chunks(seq: Sequence[T], n: int) -> list[list[T]]:
|
|
13
|
+
"""Split `seq` into up to `n` contiguous, roughly equal-size chunks.
|
|
14
|
+
|
|
15
|
+
Appropriate when atoms have uniform cost (linParts territory) - not the
|
|
16
|
+
book's actual triangular-cost `nestedParts`, which gets its own exercise.
|
|
17
|
+
"""
|
|
18
|
+
n = max(1, min(n, len(seq)))
|
|
19
|
+
q, r = divmod(len(seq), n)
|
|
20
|
+
chunks = []
|
|
21
|
+
start = 0
|
|
22
|
+
for i in range(n):
|
|
23
|
+
size = q + (1 if i < r else 0)
|
|
24
|
+
if size == 0:
|
|
25
|
+
break
|
|
26
|
+
chunks.append(list(seq[start:start + size]))
|
|
27
|
+
start += size
|
|
28
|
+
return chunks
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def lin_parts(num_atoms: int, num_threads: int) -> np.ndarray:
|
|
32
|
+
"""Ch.20 Snippet 20.5 (`linParts`) - boundary indices for up to
|
|
33
|
+
`num_threads` equal-*count* partitions of `num_atoms` atoms of uniform
|
|
34
|
+
cost. Molecule m (1-indexed) is `atoms[parts[m-1]:parts[m]]` - see
|
|
35
|
+
`parts_to_molecules`.
|
|
36
|
+
"""
|
|
37
|
+
num_threads_ = min(num_threads, num_atoms)
|
|
38
|
+
parts = np.linspace(0, num_atoms, num_threads_ + 1)
|
|
39
|
+
return np.ceil(parts).astype(int)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def nested_parts(num_atoms: int, num_threads: int, upper_triang: bool = False) -> np.ndarray:
|
|
43
|
+
"""Ch.20 Snippet 20.6 (`nestedParts`) - boundary indices for up to
|
|
44
|
+
`num_threads` equal-*work* partitions of a triangular-cost workload where
|
|
45
|
+
atom i costs O(i) (e.g. an expanding-window computation over i atoms).
|
|
46
|
+
|
|
47
|
+
Each boundary r_m is the positive root of
|
|
48
|
+
(1/2)(r_m + r_{m-1} + 1)(r_m - r_{m-1}) = N(N+1) / (2M)
|
|
49
|
+
solved iteratively from r_0 = 0, keeping intermediate boundaries as
|
|
50
|
+
floats and rounding once at the end (rounding progressively would
|
|
51
|
+
compound error).
|
|
52
|
+
|
|
53
|
+
`upper_triang=True` reverses which end carries the heavy rows - use it
|
|
54
|
+
when atom cost *decreases* with atom index instead of increasing.
|
|
55
|
+
"""
|
|
56
|
+
num_threads_ = min(num_threads, num_atoms)
|
|
57
|
+
parts = [0.0]
|
|
58
|
+
for _ in range(num_threads_):
|
|
59
|
+
prev = parts[-1]
|
|
60
|
+
part = 1 + 4 * (prev**2 + prev + num_atoms * (num_atoms + 1) / num_threads_)
|
|
61
|
+
part = (-1 + part**0.5) / 2
|
|
62
|
+
parts.append(part)
|
|
63
|
+
parts_arr = np.round(parts).astype(int)
|
|
64
|
+
if upper_triang:
|
|
65
|
+
parts_arr = np.concatenate(([0], np.cumsum(np.diff(parts_arr)[::-1])))
|
|
66
|
+
return parts_arr
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parts_to_molecules(atoms: Sequence[T], parts: np.ndarray) -> list[list[T]]:
|
|
70
|
+
"""Slice a flat atom list into molecules given boundary indices `parts`
|
|
71
|
+
(as returned by `lin_parts`/`nested_parts`): molecule m = atoms[parts[m-1]:parts[m]].
|
|
72
|
+
"""
|
|
73
|
+
return [list(atoms[parts[i - 1]:parts[i]]) for i in range(1, len(parts))]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[
|
|
2
|
+
"Jarvis", "Enigma", "Falcon", "Nimbus", "Phoenix", "Vortex", "Titan", "Orion",
|
|
3
|
+
"Nova", "Cipher", "Raven", "Atlas", "Zephyr", "Comet", "Blaze", "Echo",
|
|
4
|
+
"Griffin", "Hydra", "Kraken", "Mirage", "Nebula", "Onyx", "Pulsar", "Quasar",
|
|
5
|
+
"Rogue", "Specter", "Talon", "Umbra", "Viper", "Wraith", "Zenith", "Aurora",
|
|
6
|
+
"Basilisk", "Cyclone", "Draco", "Ember", "Fenrir", "Gargoyle", "Helix", "Icarus",
|
|
7
|
+
"Juggernaut", "Karma", "Labyrinth", "Maverick", "Nexus", "Oracle", "Paradox", "Quantum",
|
|
8
|
+
"Ronin", "Sentinel", "Talisman", "Ultra", "Valkyrie", "Warden", "Xenon", "Yeti",
|
|
9
|
+
"Zodiac", "Anomaly", "Banshee", "Catalyst", "Dynamo", "Eclipse", "Frost", "Glitch"
|
|
10
|
+
]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mpengine
|
|
3
|
+
Version: 0.1.0
|
|
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
|
+
Author: Amandeep Singh
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/singhamandeep-kgp/multiprocessor
|
|
8
|
+
Project-URL: Repository, https://github.com/singhamandeep-kgp/multiprocessor
|
|
9
|
+
Project-URL: Issues, https://github.com/singhamandeep-kgp/multiprocessor/issues
|
|
10
|
+
Keywords: multiprocessing,parallel,concurrency,process-pool,vectorization
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: numpy>=1.26
|
|
26
|
+
Requires-Dist: cloudpickle>=3.0
|
|
27
|
+
Requires-Dist: tqdm>=4.60
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# mpengine
|
|
31
|
+
|
|
32
|
+
*(This repo is named `multiprocessor`; the package it ships is `mpengine` — see below.)*
|
|
33
|
+
|
|
34
|
+
A small, general-purpose multiprocessing engine. Give it any callable and a list
|
|
35
|
+
of parameter sets; it runs them across processes and leaves behind a record of
|
|
36
|
+
what happened.
|
|
37
|
+
|
|
38
|
+
Every run produces three things:
|
|
39
|
+
|
|
40
|
+
| what | where |
|
|
41
|
+
|---|---|
|
|
42
|
+
| a manifest of exactly what was launched, and how it ended | `<base>/manifests/<run_id>.txt` |
|
|
43
|
+
| each job's result, saved to disk | `<base>/outputs/<run_id>/<label>` |
|
|
44
|
+
| one log file per **worker process** | `<base>/logs/<run_id>/worker_<pid>.log` |
|
|
45
|
+
|
|
46
|
+
A single job failing does **not** abort the run — it is recorded as a failed
|
|
47
|
+
job and the rest continue.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install -e path/to/mpengine # editable, for local development
|
|
53
|
+
pip install path/to/mpengine # or a plain install
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`numpy`, `cloudpickle`, and `tqdm` are required.
|
|
57
|
+
|
|
58
|
+
## Use
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from mpengine import run
|
|
62
|
+
|
|
63
|
+
def my_task(x, y):
|
|
64
|
+
return x * y
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__": # required - see below
|
|
67
|
+
summary = run(
|
|
68
|
+
my_task,
|
|
69
|
+
[{"x": 2, "y": 3}, {"x": 4, "y": 5}],
|
|
70
|
+
base_dir="runs",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
print(summary.n_ok, summary.n_failed)
|
|
74
|
+
for r in summary.results:
|
|
75
|
+
print(r.label, r.status, r.output_path or r.error)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### The `if __name__ == "__main__":` guard
|
|
79
|
+
|
|
80
|
+
On Windows and macOS, Python starts worker processes with `spawn`, which
|
|
81
|
+
re-imports your module in every worker. Without the guard, that re-import runs
|
|
82
|
+
your `run(...)` call again in each worker, which spawns more workers, and so on
|
|
83
|
+
until the process dies. Put anything that *calls* `run()` inside the guard;
|
|
84
|
+
your task functions themselves stay at module level, as normal.
|
|
85
|
+
|
|
86
|
+
### Placing the outputs
|
|
87
|
+
|
|
88
|
+
`base_dir` derives all three locations. To place them independently, pass any of
|
|
89
|
+
`output_dir`, `log_dir`, `manifest_dir` — an explicit path always wins, so you
|
|
90
|
+
can give a `base_dir` and still redirect just the logs.
|
|
91
|
+
|
|
92
|
+
### Reading the results back
|
|
93
|
+
|
|
94
|
+
Results are written to disk, one file per job (pickled by default). To load a
|
|
95
|
+
finished run back into memory as `{label: result}`:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from mpengine import load_run_outputs
|
|
99
|
+
|
|
100
|
+
outputs = load_run_outputs(summary.output_dir) # or the path run() printed
|
|
101
|
+
print(outputs["job_0000"])
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Only successful jobs appear — a failed job never wrote a file, so its label is
|
|
105
|
+
absent; check `summary.results` to see which failed and why. If you wrote the
|
|
106
|
+
run with a custom `save_fn`, pass the matching reader as
|
|
107
|
+
`load_run_outputs(..., load_fn=your_loader)`.
|
|
108
|
+
|
|
109
|
+
### Other options
|
|
110
|
+
|
|
111
|
+
| argument | meaning |
|
|
112
|
+
|---|---|
|
|
113
|
+
| `save_fn` | how each result is written. Default `save_pickle`; supply your own (e.g. parquet) |
|
|
114
|
+
| `labels` | names for each job; defaults to `job_0000`, `job_0001`, … |
|
|
115
|
+
| `task` | names the run; defaults to `func.__name__` |
|
|
116
|
+
| `n_threads` | worker count; defaults to `min(cpu_count, 8)` |
|
|
117
|
+
| `debug` | run sequentially in-process — real tracebacks you can attach a debugger to, no pool |
|
|
118
|
+
| `show_progress` | live terminal display: one overall bar for the whole run, plus a live rate number per worker process. Ignored when `debug=True` |
|
|
119
|
+
|
|
120
|
+
Develop with `debug=True`, then flip it off. Chasing a bug through a process
|
|
121
|
+
pool means reading a `RemoteTraceback` from a worker that has already exited,
|
|
122
|
+
and it never tells you which job dict was at fault.
|
|
123
|
+
|
|
124
|
+
### Closures and lambdas
|
|
125
|
+
|
|
126
|
+
`func`, and any custom `save_fn`, can be a closure or a lambda — not just a
|
|
127
|
+
module-level function. Jobs are serialized with `cloudpickle` before crossing
|
|
128
|
+
the process boundary, which (unlike stdlib `pickle`) can serialize a function
|
|
129
|
+
by *value* (its bytecode plus whatever it captured), not just by reference.
|
|
130
|
+
|
|
131
|
+
The one caveat: whatever a closure captures travels with **every job** that
|
|
132
|
+
uses it — a closure capturing a large array re-serializes that array per job.
|
|
133
|
+
That's a cost trade-off to be aware of, not a limitation on what's allowed.
|
|
134
|
+
|
|
135
|
+
## Going lower-level
|
|
136
|
+
|
|
137
|
+
`run()` is the convenient layer. The primitives underneath are exported too, if
|
|
138
|
+
you want to drive the pool yourself:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from mpengine import expand_call, process_jobs, process_jobs_
|
|
142
|
+
|
|
143
|
+
jobs = [{"func": my_task, "x": 1, "y": 2}, {"func": other_task, "n": 5}]
|
|
144
|
+
results = process_jobs(jobs, n_threads=8) # or process_jobs_ to stay sequential
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
A job is just a dict carrying its own callback plus that callback's kwargs, so a
|
|
148
|
+
single call can dispatch entirely different functions with different signatures
|
|
149
|
+
and return types.
|
|
150
|
+
|
|
151
|
+
Partitioning helpers are available for splitting work into chunks:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from mpengine import lin_parts, nested_parts, parts_to_molecules
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`lin_parts` gives equal-count chunks. `nested_parts` gives equal-*work* chunks
|
|
158
|
+
for triangular workloads — where item `i` costs `O(i)`, such as an
|
|
159
|
+
expanding-window computation — which keeps workers from idling while one
|
|
160
|
+
overloaded worker finishes.
|
|
161
|
+
|
|
162
|
+
## Provenance
|
|
163
|
+
|
|
164
|
+
The dispatch core follows López de Prado, *Advances in Financial Machine
|
|
165
|
+
Learning*, Chapter 20; docstrings name the specific snippets. Nothing in
|
|
166
|
+
`mpengine` is finance-specific.
|
|
167
|
+
|
|
168
|
+
The `src/learning/` package in this repo holds the exercises the engine grew out
|
|
169
|
+
of. It depends on a separate private project and is deliberately **not**
|
|
170
|
+
packaged — installing `mpengine` elsewhere never pulls it in.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/mpengine/__init__.py
|
|
5
|
+
src/mpengine/engine.py
|
|
6
|
+
src/mpengine/orchestrator.py
|
|
7
|
+
src/mpengine/partition.py
|
|
8
|
+
src/mpengine/worker_names.json
|
|
9
|
+
src/mpengine.egg-info/PKG-INFO
|
|
10
|
+
src/mpengine.egg-info/SOURCES.txt
|
|
11
|
+
src/mpengine.egg-info/dependency_links.txt
|
|
12
|
+
src/mpengine.egg-info/requires.txt
|
|
13
|
+
src/mpengine.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mpengine
|